Skip to content

Commit

Permalink
feat: with_cwd, fix handling of env-vars passed to plumbum Commands (#…
Browse files Browse the repository at this point in the history
…513)

* tests: shorten test_timeout to 3s instead of 10s

* command: fix handling of env-vars passed to plumbum Commands; support new with_cwd

- don't use the non-threadsafe and session-dependent .env() context manager
- sync popen support for 'env' param in all machine impls: local/ssh/paramiko
- add .with_cwd() to complement .with_env()
  • Loading branch information
koreno authored Jan 26, 2021
1 parent f817e62 commit e1bc3f1
Show file tree
Hide file tree
Showing 9 changed files with 90 additions and 25 deletions.
17 changes: 17 additions & 0 deletions docs/_cheatsheet.rst
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,15 @@ also :ref:`import commands <import-hack>`:
>>> grep
LocalCommand(<LocalPath /bin/grep>)

Or, use the ``local.cmd`` syntactic-sugar:

.. code-block:: python
>>> local.cmd.ls
LocalCommand(<LocalPath /bin/ls>)
>>> local.cmd.ls()
u'build.py\ndist\ndocs\nLICENSE\nplumbum\nREADME.rst\nsetup.py\ntests\ntodo.txt\n'
See :ref:`guide-local-commands`.

Piping
Expand Down Expand Up @@ -62,6 +71,14 @@ Working-directory manipulation
...
u'15\n'
A more explicit, and thread-safe way of running a command in a differet directory is using the ``.with_cwd()`` method:

.. code-block:: python
>>> ls_in_docs = local.cmd.ls.with_cwd("docs")
>>> ls_in_docs()
'api\nchangelog.rst\n_cheatsheet.rst\ncli.rst\ncolorlib.rst\n_color_list.html\ncolors.rst\nconf.py\nindex.rst\nlocal_commands.rst\nlocal_machine.rst\nmake.bat\nMakefile\n_news.rst\npaths.rst\nquickref.rst\nremote.rst\n_static\n_templates\ntyped_env.rst\nutils.rst\n'
See :ref:`guide-paths` and :ref:`guide-local-machine`.

Foreground and background execution
Expand Down
10 changes: 8 additions & 2 deletions docs/local_machine.rst
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,7 @@ The ``local.cwd`` attribute represents the current working directory. You can ch
>>> local.cwd
<Workdir d:\workspace\plumbum\docs>

But a much more useful pattern is to use it as a *context manager*, so it behaves like
``pushd``/``popd``::
You can also use it as a *context manager*, so it behaves like ``pushd``/``popd``::

>>> with local.cwd("c:\\windows"):
... print "%s:%s" % (local.cwd, (ls | wc["-l"])())
Expand All @@ -46,6 +45,13 @@ But a much more useful pattern is to use it as a *context manager*, so it behave
>>> print "%s:%s" % (local.cwd, (ls | wc["-l"])())
d:\workspace\plumbum: 9

Finally, A more explicit and thread-safe way of running a command in a differet directory is using the ``.with_cwd()`` method:

>>> ls_in_docs = local.cmd.ls.with_cwd("docs")
>>> ls_in_docs()
'api\nchangelog.rst\n_cheatsheet.rst\ncli.rst\ncolorlib.rst\n_color_list.html\ncolors.rst\nconf.py\nindex.rst\nlocal_commands.rst\nlocal_machine.rst\nmake.bat\nMakefile\n_news.rst\npaths.rst\nquickref.rst\nremote.rst\n_static\n_templates\ntyped_env.rst\nutils.rst\n'


Environment
-----------
Much like ``cwd``, ``local.env`` represents the *local environment*. It is a dictionary-like
Expand Down
33 changes: 23 additions & 10 deletions plumbum/commands/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,11 +98,20 @@ def __call__(self, *args, **kwargs):
def _get_encoding(self):
raise NotImplementedError()

def with_env(self, **envvars):
def with_env(self, **env):
"""Returns a BoundEnvCommand with the given environment variables"""
if not envvars:
if not env:
return self
return BoundEnvCommand(self, envvars)
return BoundEnvCommand(self, env=env)

def with_cwd(self, path):
"""
Returns a BoundEnvCommand with the specified working directory.
This overrides a cwd specified in a wrapping `machine.cwd()` context manager.
"""
if not path:
return self
return BoundEnvCommand(self, cwd=path)

setenv = with_env

Expand Down Expand Up @@ -313,14 +322,15 @@ def popen(self, args=(), **kwargs):


class BoundEnvCommand(BaseCommand):
__slots__ = ("cmd", "envvars")
__slots__ = ("cmd", "env", "cwd")

def __init__(self, cmd, envvars):
def __init__(self, cmd, env={}, cwd=None):
self.cmd = cmd
self.envvars = envvars
self.env = env
self.cwd = cwd

def __repr__(self):
return "BoundEnvCommand(%r, %r)" % (self.cmd, self.envvars)
return "BoundEnvCommand(%r, %r)" % (self.cmd, self.env)

def _get_encoding(self):
return self.cmd._get_encoding()
Expand All @@ -332,9 +342,12 @@ def formulate(self, level=0, args=()):
def machine(self):
return self.cmd.machine

def popen(self, args=(), **kwargs):
with self.machine.env(**self.envvars):
return self.cmd.popen(args, **kwargs)
def popen(self, args=(), cwd=None, env={}, **kwargs):
return self.cmd.popen(
args,
cwd=self.cwd if cwd is None else cwd,
env=dict(self.env, **env),
**kwargs)


class Pipeline(BaseCommand):
Expand Down
13 changes: 9 additions & 4 deletions plumbum/machines/local.py
Original file line number Diff line number Diff line change
Expand Up @@ -291,10 +291,15 @@ def preexec_fn(prev_fn=kwargs.get("preexec_fn", lambda: None)):

if cwd is None:
cwd = self.cwd
if env is None:
env = self.env
if isinstance(env, BaseEnv):
env = env.getdict()

envs = [self.env, env]
env = {}
for _env in envs:
if not _env:
continue
if isinstance(_env, BaseEnv):
_env = _env.getdict()
env.update(_env)

if self._as_user_stack:
argv, executable = self._as_user_stack[-1](argv)
Expand Down
3 changes: 3 additions & 0 deletions plumbum/machines/paramiko_machine.py
Original file line number Diff line number Diff line change
Expand Up @@ -313,10 +313,13 @@ def popen(self,
stdout=None,
stderr=None,
new_session=False,
env=None,
cwd=None):
# new_session is ignored for ParamikoMachine
argv = []
envdelta = self.env.getdelta()
if env:
envdelta.update(env)
argv.extend(["cd", str(cwd or self.cwd), "&&"])
if envdelta:
argv.append("env")
Expand Down
15 changes: 11 additions & 4 deletions plumbum/machines/ssh_machine.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,13 +127,20 @@ def __str__(self):
return "ssh://%s" % (self._fqhost, )

@_setdoc(BaseRemoteMachine)
def popen(self, args, ssh_opts=(), **kwargs):
def popen(self, args, ssh_opts=(), env=None, cwd=None, **kwargs):
cmdline = []
cmdline.extend(ssh_opts)
cmdline.append(self._fqhost)
if args and hasattr(self, "env"):
envdelta = self.env.getdelta()
cmdline.extend(["cd", str(self.cwd), "&&"])
if args:
envdelta = {}
if hasattr(self, "env"):
envdelta.update(self.env.getdelta())
if env:
envdelta.update(env)
if cwd is None:
cwd = getattr(self, "cwd", None)
if cwd:
cmdline.extend(["cd", str(cwd), "&&"])
if envdelta:
cmdline.append("env")
cmdline.extend("%s=%s" % (k, shquote(v)) for k, v in envdelta.items())
Expand Down
2 changes: 1 addition & 1 deletion setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ plumbum.cli = i18n/*/LC_MESSAGES/*.mo

[options.extras_require]
ssh =
paramiko; python_version >='2.7'
paramiko
dev =
pytest
pytest-cov
Expand Down
19 changes: 15 additions & 4 deletions tests/test_local.py
Original file line number Diff line number Diff line change
Expand Up @@ -353,10 +353,18 @@ def test_repr_command(self):
def test_cwd(self):
from plumbum.cmd import ls
assert local.cwd == os.getcwd()
assert "__init__.py" not in ls().splitlines()
assert "machines" not in ls().splitlines()

with local.cwd("../plumbum"):
assert "__init__.py" in ls().splitlines()
assert "__init__.py" not in ls().splitlines()
assert "machines" in ls().splitlines()
assert "machines" not in ls().splitlines()

assert "machines" in ls.with_cwd("../plumbum")().splitlines()
path = local.cmd.pwd.with_cwd("../plumbum")().strip()
with local.cwd("/"):
assert "machines" not in ls().splitlines()
assert "machines" in ls.with_cwd(path)().splitlines()

with pytest.raises(OSError):
local.cwd.chdir("../non_exist1N9")

Expand Down Expand Up @@ -487,7 +495,7 @@ def test_run(self):
def test_timeout(self):
from plumbum.cmd import sleep
with pytest.raises(ProcessTimedOut):
sleep(10, timeout = 5)
sleep(3, timeout=1)

@skip_on_windows
def test_pipe_stderr(self, capfd):
Expand Down Expand Up @@ -870,6 +878,9 @@ def test_bound_env(self):
assert printenv.with_env(FOO = "sea", BAR = "world")("FOO") == "sea\n"
assert printenv("FOO") == "hello\n"

assert local.cmd.pwd.with_cwd("/")() == "/\n"
assert local.cmd.pwd['-L'].with_env(A='X').with_cwd("/")() == "/\n"

def test_nesting_lists_as_argv(self):
from plumbum.cmd import ls
c = ls["-l", ["-a", "*.py"]]
Expand Down
3 changes: 3 additions & 0 deletions tests/test_remote.py
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,9 @@ def test_bound_env(self):
assert printenv.with_env(FOO = "sea", BAR = "world")("FOO") == "sea\n"
assert printenv.with_env(FOO = "sea", BAR = "world")("BAR") == "world\n"

assert rem.cmd.pwd.with_cwd("/")() == "/\n"
assert rem.cmd.pwd['-L'].with_env(A='X').with_cwd("/")() == "/\n"

@pytest.mark.skipif('useradd' not in local,
reason = "System does not have useradd (Mac?)")
def test_sshpass(self):
Expand Down

0 comments on commit e1bc3f1

Please sign in to comment.