Repository: amoffat/sh
Pull request #611 (2022-07-18 06:04:40)

1.14.3


Patch:
diff --git a/.coveragerc b/.coveragerc
index 6d889f5..3ed6dd4 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -2,6 +2,7 @@
 branch = True
 source =
     sh.py
+relative_files = True
 
 [report]
 exclude_lines =
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
index 6ad7089..d9f74c8 100644
--- a/.github/FUNDING.yml
+++ b/.github/FUNDING.yml
@@ -1,6 +1,6 @@
 # These are supported funding model platforms
 
-github: [amoffat]
+github: [ecederstrand, amoffat]
 patreon: # Replace with a single Patreon username
 open_collective: # Replace with a single Open Collective username
 ko_fi: # Replace with a single Ko-fi username
diff --git a/.gitignore b/.gitignore
index 1deb479..3156648 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,6 @@ __pycache__/
 *.py[co]
 .tox
 .coverage
+/.cache/
+/.venv/
+/build
\ No newline at end of file
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 57628af..0000000
--- a/.travis.yml
+++ /dev/null
@@ -1,27 +0,0 @@
-os:
-    - linux
-
-language: python
-
-python:
-    # - 2.6 No longer supported on Travis
-    - 2.7
-    # - 3.3 No longer supported on Travis
-    - 3.4
-    - 3.5
-    - 3.6
-    - 3.7
-    - 3.8
-    - pypy
-    - pypy3.5
-
-before_script:
-    - pip install -r requirements-dev.txt
-
-script:
-    - python sh.py travis
-    - python -m flake8 sh.py test.py
-    - if python -c 'import sys; sys.exit(int(not sys.version_info >= (3, 5)))' ; then python setup.py check --restructuredtext --metadata --strict ; fi
-
-after_success:
-    - coveralls
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ab69be2..7ef51bd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,4 +1,7 @@
 # Changelog
+## 1.14.3 - 7/17/22
+*   bugfix where `Command` was not aware of default call args when wrapping the module [#559](https://github.com/amoffat/sh/pull/573)
+
 ## 1.14.1 - 10/24/20
 *   bugfix where setting `_ok_code` to not include 0, but 0 was the exit code [#545](https://github.com/amoffat/sh/pull/545)
 
diff --git a/README.rst b/README.rst
index f807900..088b3dd 100644
--- a/README.rst
+++ b/README.rst
@@ -22,7 +22,7 @@
 
 |
 
-sh is a full-fledged subprocess replacement for Python 2.6 - 3.8, PyPy and PyPy3
+sh is a full-fledged subprocess replacement for Python 2, Python 3, PyPy and PyPy3
 that allows you to call *any* program as if it were a function:
 
 .. code:: python
@@ -32,6 +32,9 @@ that allows you to call *any* program as if it were a function:
 
 sh is *not* a collection of system commands implemented in Python.
 
+sh relies on various Unix system calls and only works on Unix-like operating
+systems - Linux, macOS, BSDs etc. Specifically, Windows is not supported.
+
 `Complete documentation here <https://amoffat.github.io/sh>`_
 
 Installation
@@ -40,7 +43,7 @@ Installation
 ::
 
     $> pip install sh
-    
+
 Support
 =======
 * `Andrew Moffat <https://github.com/amoffat>`_ - author/maintainer
@@ -63,7 +66,7 @@ run::
 
     $> ./build.sh
 
-This will install ubuntu 18.04 LTS and all python versions from 2.6-3.8.  Once it's done, stay in that directory and
+This will install ubuntu 18.04 LTS and all supported python versions.  Once it's done, stay in that directory and
 run::
 
     $> ./run.sh
@@ -82,7 +85,7 @@ Coverage
 
 First run all of the tests::
 
-    $> python sh.py test
+    $> SH_TESTS_RUNNING=1 coverage run --source=sh -m unittest
 
 This will aggregate a ``.coverage``.  You may then visualize the report with::
 
diff --git a/requirements-dev.txt b/requirements-dev.txt
index f82c409..a5cd747 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -1,6 +1,5 @@
 Pygments==2.1.3
-coverage==4.2
-coveralls==1.1
+coverage==5.5
 docopt==0.6.2
 docutils==0.12
 flake8==3.7.9
diff --git a/setup.py b/setup.py
index 97cba90..08ec1a2 100644
--- a/setup.py
+++ b/setup.py
@@ -50,6 +50,7 @@ setup(
         "Programming Language :: Python :: 3.6",
         "Programming Language :: Python :: 3.7",
         "Programming Language :: Python :: 3.8",
+        "Programming Language :: Python :: 3.9",
         "Programming Language :: Python :: Implementation :: CPython",
         "Programming Language :: Python :: Implementation :: PyPy",
         "Topic :: Software Development :: Build Tools",
diff --git a/sh.py b/sh.py
index 2135d87..0353e82 100644
--- a/sh.py
+++ b/sh.py
@@ -2,7 +2,7 @@
 http://amoffat.github.io/sh/
 """
 # ===============================================================================
-# Copyright (C) 2011-2020 by Andrew Moffat
+# Copyright (C) 2011-2022 by Andrew Moffat
 #
 # Permission is hereby granted, free of charge, to any person obtaining a copy
 # of this software and associated documentation files (the "Software"), to deal
@@ -22,19 +22,16 @@ http://amoffat.github.io/sh/
 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 # THE SOFTWARE.
 # ===============================================================================
-__version__ = "1.14.2"
+__version__ = "1.14.3"
 __project_url__ = "https://github.com/amoffat/sh"
 
 from collections import deque
+
 try:
     from collections.abc import Mapping
 except ImportError:
     from collections import Mapping
-from contextlib import contextmanager
-from functools import partial
-from io import UnsupportedOperation, open as fdopen
-from locale import getpreferredencoding
-from types import ModuleType, GeneratorType
+
 import ast
 import errno
 import fcntl
@@ -60,6 +57,12 @@ import traceback
 import tty
 import warnings
 import weakref
+from contextlib import contextmanager
+from functools import partial
+from io import UnsupportedOperation
+from io import open as fdopen
+from locale import getpreferredencoding
+from types import GeneratorType, ModuleType
 
 IS_PY3 = sys.version_info[0] == 3
 MINOR_VER = sys.version_info[1]
@@ -71,18 +74,19 @@ if IS_PY3:
     from io import BytesIO as cStringIO
 
     iocStringIO = cStringIO
-    from queue import Queue, Empty
+    from queue import Empty, Queue
 
     # for some reason, python 3.1 removed the builtin "callable", wtf
     if not hasattr(__builtins__, "callable"):
         def callable(ob):
             return hasattr(ob, "__call__")
 else:
-    from StringIO import StringIO
-    from cStringIO import OutputType as cStringIO
-    from io import StringIO as ioStringIO
     from io import BytesIO as iocStringIO
-    from Queue import Queue, Empty
+    from io import StringIO as ioStringIO
+
+    from cStringIO import OutputType as cStringIO
+    from Queue import Empty, Queue
+    from StringIO import StringIO
 
 try:
     from shlex import quote as shlex_quote  # here from 3.3 onward
@@ -534,7 +538,7 @@ def canonicalize(path):
     return os.path.abspath(os.path.expanduser(path))
 
 
-def which(program, paths=None):
+def _which(program, paths=None):
     """ takes a program name or full path, plus an optional collection of search
     paths, and returns the full path of the requested executable.  if paths is
     specified, it is the entire list of search paths, and the PATH env is not
@@ -576,14 +580,14 @@ def which(program, paths=None):
 
 
 def resolve_command_path(program):
-    path = which(program)
+    path = _which(program)
     if not path:
         # our actual command might have a dash in it, but we can't call
         # that from python (we have to use underscores), so we'll check
         # if a dash version of our underscore command exists and use that
         # if it does
         if "_" in program:
-            path = which(program.replace("_", "-"))
+            path = _which(program.replace("_", "-"))
         if not path:
             return None
     return path
@@ -990,7 +994,7 @@ class RunningCommand(object):
 
 
 def output_redirect_is_filename(out):
-    return isinstance(out, basestring)
+    return isinstance(out, basestring) or hasattr(out, '__fspath__')
 
 
 def get_prepend_stack():
@@ -1290,7 +1294,7 @@ class Command(object):
     )
 
     def __init__(self, path, search_paths=None):
-        found = which(path, search_paths)
+        found = _which(path, search_paths)
 
         self._path = encode_to_py3bytes_or_py2str("")
 
@@ -2412,6 +2416,7 @@ class OProc(object):
                 return False, self.exit_code
             return True, self.exit_code
 
+        witnessed_end = False
         try:
             # WNOHANG is just that...we're calling waitpid without hanging...
             # essentially polling the process.  the return result is (0, 0) if
@@ -2420,7 +2425,7 @@ class OProc(object):
             pid, exit_code = no_interrupt(os.waitpid, self.pid, os.WNOHANG)
             if pid == self.pid:
                 self.exit_code = handle_process_exit_code(exit_code)
-                self._process_just_ended()
+                witnessed_end = True
 
                 return False, self.exit_code
 
@@ -2431,6 +2436,8 @@ class OProc(object):
             return True, self.exit_code
         finally:
             self._wait_lock.release()
+            if witnessed_end:
+                self._process_just_ended()
 
     def _process_just_ended(self):
         if self._timeout_timer:
@@ -2466,31 +2473,32 @@ class OProc(object):
 
             else:
                 self.log.debug("exit code already set (%d), no need to wait", self.exit_code)
+        self._process_exit_cleanup(witnessed_end=witnessed_end)
+        return self.exit_code
 
-            self._quit_threads.set()
+    def _process_exit_cleanup(self, witnessed_end):
+        self._quit_threads.set()
 
-            # we may not have a thread for stdin, if the pipe has been connected
-            # via _piped="direct"
-            if self._input_thread:
-                self._input_thread.join()
+        # we may not have a thread for stdin, if the pipe has been connected
+        # via _piped="direct"
+        if self._input_thread:
+            self._input_thread.join()
 
-            # wait, then signal to our output thread that the child process is
-            # done, and we should have finished reading all the stdout/stderr
-            # data that we can by now
-            timer = threading.Timer(2.0, self._stop_output_event.set)
-            timer.start()
+        # wait, then signal to our output thread that the child process is
+        # done, and we should have finished reading all the stdout/stderr
+        # data that we can by now
+        timer = threading.Timer(2.0, self._stop_output_event.set)
+        timer.start()
 
-            # wait for our stdout and stderr streamreaders to finish reading and
-            # aggregating the process output
-            self._output_thread.join()
-            timer.cancel()
+        # wait for our stdout and stderr streamreaders to finish reading and
+        # aggregating the process output
+        self._output_thread.join()
+        timer.cancel()
 
-            self._background_thread.join()
+        self._background_thread.join()
 
-            if witnessed_end:
-                self._process_just_ended()
-
-            return self.exit_code
+        if witnessed_end:
+            self._process_just_ended()
 
 
 def input_thread(log, stdin, is_alive, quit_thread, close_before_term):
@@ -3292,16 +3300,22 @@ class Environment(dict):
         if k.startswith("__") and k.endswith("__"):
             raise AttributeError
 
-        # is it a custom builtin?
-        builtin = getattr(self, "b_" + k, None)
-        if builtin:
-            return builtin
+        if k == 'cd':
+            # Don't resolve the system binary. It's useful in scripts to be
+            # able to switch directories in the current process. Can also be
+            # used as a context manager.
+            return Cd
 
         # is it a command?
         cmd = resolve_command(k, self.baked_args)
         if cmd:
             return cmd
 
+        # is it a custom builtin?
+        builtin = getattr(self, "b_" + k, None)
+        if builtin:
+            return builtin
+
         # how about an environment variable?
         # this check must come after testing if its a command, because on some
         # systems, there are an environment variables that can conflict with
@@ -3315,20 +3329,25 @@ class Environment(dict):
         # nothing found, raise an exception
         raise CommandNotFound(k)
 
-    # methods that begin with "b_" are custom builtins and will override any
-    # program that exists in our path.  this is useful for things like
-    # common shell builtins that people are used to, but which aren't actually
-    # full-fledged system binaries
-    @staticmethod
-    def b_cd(path=None):
-        if path:
-            os.chdir(path)
-        else:
-            os.chdir(os.path.expanduser('~'))
-
+    # Methods that begin with "b_" are implementations of shell built-ins that
+    # people are used to, but which may not have an executable equivalent.
     @staticmethod
     def b_which(program, paths=None):
-        return which(program, paths)
+        return _which(program, paths)
+
+
+class Cd(object):
+    def __new__(cls, path=None):
+        res = super(Cd, cls).__new__(cls)
+        res.old_path = os.getcwd()
+        os.chdir(path or os.path.expanduser('~'))
+        return res
+
+    def __enter__(self):
+        pass
+
+    def __exit__(self, exc_type, exc_val, exc_tb):
+        os.chdir(self.old_path)
 
 
 class Contrib(ModuleType):  # pragma: no cover
@@ -3512,7 +3531,18 @@ class SelfWrapper(ModuleType):
         # if we set this to None.  and 3.3 needs a value for __path__
         self.__path__ = []
         self.__self_module = self_module
-        self.__env = Environment(globals(), baked_args=baked_args)
+
+        # Copy the Command class and add any baked call kwargs to it
+        cls_attrs = Command.__dict__.copy()
+        if baked_args:
+            call_args, _ = Command._extract_call_args(baked_args)
+            cls_attrs['_call_args'] = cls_attrs['_call_args'].copy()
+            cls_attrs['_call_args'].update(call_args)
+        command_cls = type(Command.__name__, Command.__bases__, cls_attrs)
+        globs = globals().copy()
+        globs[Command.__name__] = command_cls
+
+        self.__env = Environment(globs, baked_args=baked_args)
 
     def __getattr__(self, name):
         return self.__env[name]
@@ -3676,114 +3706,10 @@ class ModuleImporterFromVariables(object):
         return module
 
 
-def run_tests(env, locale, a, version, force_select, **extra_env):  # pragma: no cover
-    py_version = "python"
-    py_version += str(version)
-
-    py_bin = which(py_version)
-    return_code = None
-
-    poller = "poll"
-    if force_select:
-        poller = "select"
-
-    if py_bin:
-        print("Testing %s, locale %r, poller: %s" % (py_version.capitalize(), locale, poller))
-
-        env["SH_TESTS_USE_SELECT"] = str(int(force_select))
-        env["LANG"] = locale
-
-        for k, v in extra_env.items():
-            env[k] = str(v)
-
-        cmd = [py_bin, "-W", "ignore", os.path.join(THIS_DIR, "test.py")] + a[1:]
-        print("Running %r" % cmd)
-        return_code = os.spawnve(os.P_WAIT, cmd[0], cmd, env)
-
-    return return_code
-
-
-def main():  # pragma: no cover
-    from optparse import OptionParser
-
-    parser = OptionParser()
-    parser.add_option("-e", "--envs", dest="envs", default=None, action="append")
-    parser.add_option("-l", "--locales", dest="constrain_locales", default=None, action="append")
-    options, parsed_args = parser.parse_args()
-
-    # these are essentially restrictions on what envs/constrain_locales to restrict to for
-    # the tests.  if they're empty lists, it means use all available
-    action = None
-    if parsed_args:
-        action = parsed_args[0]
-
-    if action in ("test", "travis", "tox"):
-        import test
-        coverage = None
-        if test.HAS_UNICODE_LITERAL:
-            try:
-                import coverage
-            except ImportError:
-                pass
-
-        env = os.environ.copy()
-        env["SH_TESTS_RUNNING"] = "1"
-        if coverage:
-            test.append_module_path(env, coverage)
-
-        # if we're testing locally, run all versions of python on the system
-        if action == "test":
-            all_versions = ("2.6", "2.7", "3.1", "3.2", "3.3", "3.4", "3.5", "3.6", "3.7", "3.8")
-
-        # if we're testing on travis or tox, just use the system's default python, since travis will spawn a vm per
-        # python version in our .travis.yml file, and tox will run its matrix via tox.ini
-        else:
-            v = sys.version_info
-            sys_ver = "%d.%d" % (v[0], v[1])
-            all_versions = (sys_ver,)
-
-        all_force_select = [True]
-        if HAS_POLL:
-            all_force_select.append(False)
-
-        all_locales = ("en_US.UTF-8", "C")
-        i = 0
-        ran_versions = set()
-        for locale in all_locales:
-            # make sure this locale is allowed
-            if options.constrain_locales and locale not in options.constrain_locales:
-                continue
-
-            for version in all_versions:
-                # make sure this version is allowed
-                if options.envs and version not in options.envs:
-                    continue
-
-                for force_select in all_force_select:
-                    env_copy = env.copy()
-
-                    ran_versions.add(version)
-                    exit_code = run_tests(env_copy, locale, parsed_args, version, force_select, SH_TEST_RUN_IDX=i)
-
-                    if exit_code is None:
-                        print("Couldn't find %s, skipping" % version)
-
-                    elif exit_code != 0:
-                        print("Failed for %s, %s" % (version, locale))
-                        exit(1)
-
-                    i += 1
-
-        print("Tested Python versions: %s" % ",".join(sorted(list(ran_versions))))
-
-    else:
-        env = Environment(globals())
-        run_repl(env)
-
-
 if __name__ == "__main__":  # pragma: no cover
     # we're being run as a stand-alone script
-    main()
+    env = Environment(globals())
+    run_repl(env)
 else:
     # we're being imported from somewhere
     sys.modules[__name__] = SelfWrapper(sys.modules[__name__])
diff --git a/tox.ini b/tox.ini
index 1f986c7..5cd09ac 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,6 +1,6 @@
 [tox]
 # virtualenv for py26 is broken, so don't put it here
-envlist = py{27,31,32,33,34,35,36,37,38},docs
+envlist = py{27,31,32,33,34,35,36,37,38,39},docs
 
 [testenv]
 deps = -r requirements-dev.txt


Test patch:
diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml
new file mode 100644
index 0000000..735dac9
--- /dev/null
+++ b/.github/workflows/run-tests.yml
@@ -0,0 +1,53 @@
+# This workflow will install Python dependencies, run tests and converage with a variety of Python versions
+# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions
+
+name: Run tests
+
+on: [push, pull_request]
+
+jobs:
+  test:
+    runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        python-version: [2.7, 3.5, 3.6, 3.7, 3.8, 3.9, pypy2, pypy3]
+
+    steps:
+    - uses: actions/checkout@v2
+
+    - name: Set up Python ${{ matrix.python-version }}
+      uses: actions/setup-python@v2
+      with:
+        python-version: ${{ matrix.python-version }}
+
+    - name: Upgrade pip
+      run: |
+        python -m pip install --upgrade pip
+
+    - name: Install dependencies
+      run: |
+        python -m pip install -r requirements-dev.txt
+
+    - name: 'Python2: Run tests'
+      if: matrix.python-version == '2.7' || matrix.python-version == 'pypy2'
+      run: |
+        python -m pip install .
+        SH_TESTS_RUNNING=1 SH_TESTS_USE_SELECT=0 LANG=C python -m unittest test
+        SH_TESTS_RUNNING=1 SH_TESTS_USE_SELECT=1 LANG=C python -m unittest test
+        SH_TESTS_RUNNING=1 SH_TESTS_USE_SELECT=0 LANG=en_US.UTF-8 python -m unittest test
+        SH_TESTS_RUNNING=1 SH_TESTS_USE_SELECT=1 LANG=en_US.UTF-8 python -m unittest test
+
+    - name: 'Python3: Run tests and report to Coveralls'
+      env:
+       GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+      if: matrix.python-version != '2.7' && matrix.python-version != 'pypy2'
+      run: |
+        python -m pip install coveralls==3.1.0
+        python -m pip install .
+        python -m flake8 sh.py test.py
+        python setup.py check --restructuredtext --metadata --strict
+        SH_TESTS_RUNNING=1 SH_TESTS_USE_SELECT=0 LANG=C coverage run -a --source=sh -m unittest
+        SH_TESTS_RUNNING=1 SH_TESTS_USE_SELECT=1 LANG=C coverage run -a --source=sh -m unittest
+        SH_TESTS_RUNNING=1 SH_TESTS_USE_SELECT=0 LANG=en_US.UTF-8 coverage run -a --source=sh -m unittest
+        SH_TESTS_RUNNING=1 SH_TESTS_USE_SELECT=1 LANG=en_US.UTF-8 coverage run -a --source=sh -m unittest
+        coveralls --service=github
diff --git a/docker_test_suite/Dockerfile b/docker_test_suite/Dockerfile
index e491af1..839285f 100644
--- a/docker_test_suite/Dockerfile
+++ b/docker_test_suite/Dockerfile
@@ -28,7 +28,8 @@ RUN apt-get -y install\
     python3.5\
     python3.6\
     python3.7\
-    python3.8
+    python3.8\
+    python3.9
 
 RUN apt-get -y install python3-distutils\
     && curl https://bootstrap.pypa.io/get-pip.py | python -
diff --git a/test.py b/test.py
index d9cc1da..072551e 100644
--- a/test.py
+++ b/test.py
@@ -23,33 +23,6 @@ IS_PY3 = sys.version_info[0] == 3
 IS_PY2 = not IS_PY3
 MINOR_VER = sys.version_info[1]
 
-# coverage doesn't work in python 3.1, 3.2 due to it just being a shit
-# python
-HAS_UNICODE_LITERAL = not (IS_PY3 and MINOR_VER in (1, 2))
-
-cov = None
-if HAS_UNICODE_LITERAL:
-    run_idx = int(os.environ.pop("SH_TEST_RUN_IDX", "0"))
-    first_run = run_idx == 0
-
-    try:
-        import coverage
-    except ImportError:
-        pass
-    else:
-        # for some reason, we can't run auto_data on the first run, or the coverage
-        # numbers get really screwed up
-        auto_data = True
-        if first_run:
-            auto_data = False
-
-        cov = coverage.Coverage(auto_data=auto_data)
-
-        if first_run:
-            cov.erase()
-
-        cov.start()
-
 try:
     import unittest.mock
 except ImportError:
@@ -163,6 +136,7 @@ requires_utf8 = skipUnless(sh.DEFAULT_ENCODING == "UTF-8", "System encoding must
 not_macos = skipUnless(not IS_MACOS, "Doesn't work on MacOS")
 requires_py3 = skipUnless(IS_PY3, "Test only works on Python 3")
 requires_py35 = skipUnless(IS_PY3 and MINOR_VER >= 5, "Test only works on Python 3.5 or higher")
+requires_py36 = skipUnless(IS_PY3 and MINOR_VER >= 6, "Test only works on Python 3.6 or higher")
 
 
 def requires_poller(poller):
@@ -270,7 +244,7 @@ class FunctionalTests(BaseTests):
 
     def test_print_command(self):
         from sh import ls, which
-        actual_location = which("ls")
+        actual_location = str(which("ls")).strip()
         out = str(ls)
         self.assertEqual(out, actual_location)
 
@@ -605,12 +579,15 @@ print(dict(HERP=sh.HERP))
         self.assertEqual(out, "{'HERP': 'DERP'}")
 
     def test_which(self):
-        from sh import which, ls
+        # Test 'which' as built-in function
+        from sh import ls
+        which = sh._SelfWrapper__env.b_which
         self.assertEqual(which("fjoawjefojawe"), None)
         self.assertEqual(which("ls"), str(ls))
 
     def test_which_paths(self):
-        from sh import which
+        # Test 'which' as built-in function
+        which = sh._SelfWrapper__env.b_which
         py = create_tmp_test("""
 print("hi")
 """)
@@ -781,7 +758,7 @@ exit(2)
     def test_command_wrapper_equivalence(self):
         from sh import Command, ls, which
 
-        self.assertEqual(Command(which("ls")), ls)
+        self.assertEqual(Command(str(which("ls")).strip()), ls)
 
     def test_doesnt_execute_directories(self):
         save_path = os.environ['PATH']
@@ -986,8 +963,8 @@ print(sys.argv[1])
     def test_command_wrapper(self):
         from sh import Command, which
 
-        ls = Command(which("ls"))
-        wc = Command(which("wc"))
+        ls = Command(str(which("ls")).strip())
+        wc = Command(str(which("wc")).strip())
 
         c1 = int(wc(ls("-A1"), l=True))  # noqa: E741
         c2 = len(os.listdir("."))
@@ -1520,14 +1497,16 @@ import os
 import time
 import signal
 
+i = 0
 def sig_handler(sig, frame):
-    print(10)
-    exit(0)
+    global i
+    i = 42
 
 signal.signal(signal.SIGINT, sig_handler)
 
-for i in range(5):
+for _ in range(6):
     print(i)
+    i += 1
     sys.stdout.flush()
     time.sleep(0.5)
 """)
@@ -1545,7 +1524,7 @@ for i in range(5):
         p.wait()
 
         self.assertEqual(p.process.exit_code, 0)
-        self.assertEqual(p, "0\n1\n2\n3\n10\n")
+        self.assertEqual(p, "0\n1\n2\n3\n42\n43\n")
 
     def test_iter_generator(self):
         py = create_tmp_test("""
@@ -1797,6 +1776,15 @@ exit(code)
         outfile.seek(0)
         self.assertEqual(b"output\n", outfile.read())
 
+    @requires_py36
+    def test_out_pathlike(self):
+        from pathlib import Path
+        outfile = tempfile.NamedTemporaryFile()
+        py = create_tmp_test("print('output')")
+        python(py.name, _out=Path(outfile.name))
+        outfile.seek(0)
+        self.assertEqual(b"output\n", outfile.read())
+
     def test_bg_exit_code(self):
         py = create_tmp_test("""
 import time
@@ -2261,13 +2249,15 @@ p.wait()
 
     def test_pushd(self):
         """ test basic pushd functionality """
+        child = realpath(tempfile.mkdtemp())
+
         old_wd1 = sh.pwd().strip()
         old_wd2 = os.getcwd()
 
         self.assertEqual(old_wd1, old_wd2)
-        self.assertNotEqual(old_wd1, tempdir)
+        self.assertNotEqual(old_wd1, child)
 
-        with sh.pushd(tempdir):
+        with sh.pushd(child):
             new_wd1 = sh.pwd().strip()
             new_wd2 = os.getcwd()
 
@@ -2276,13 +2266,11 @@ p.wait()
         self.assertEqual(old_wd3, old_wd4)
         self.assertEqual(old_wd1, old_wd3)
 
-        self.assertEqual(new_wd1, tempdir)
-        self.assertEqual(new_wd2, tempdir)
+        self.assertEqual(new_wd1, child)
+        self.assertEqual(new_wd2, child)
 
     def test_pushd_cd(self):
         """ test that pushd works like pushd/popd with built-in cd correctly """
-        import sh
-
         child = realpath(tempfile.mkdtemp())
         try:
             old_wd = os.getcwd()
@@ -2303,6 +2291,12 @@ p.wait()
         self.assertNotEqual(orig, os.getcwd())
         self.assertEqual(my_dir, os.getcwd())
 
+    def test_cd_context_manager(self):
+        orig = os.getcwd()
+        with sh.cd(tempdir):
+            self.assertEqual(tempdir, os.getcwd())
+        self.assertEqual(orig, os.getcwd())
+
     def test_non_existant_cwd(self):
         from sh import ls
 
@@ -2353,6 +2347,29 @@ print(time())
         self.assertEqual(callback.exit_code, 0)
         self.assertTrue(callback.success)
 
+    # https://github.com/amoffat/sh/issues/564
+    def test_done_callback_no_deadlock(self):
+        import time
+
+        py = create_tmp_test("""
+from sh import sleep
+
+def done(cmd, success, exit_code):
+    print(cmd, success, exit_code)
+
+sleep('1', _done=done)
+""")
+
+        p = python(py.name, _bg=True, _timeout=2)
+
+        # do a little setup to prove that a command with a _done callback is run
+        # in the background
+        wait_start = time.time()
+        p.wait()
+        wait_elapsed = time.time() - wait_start
+
+        self.assertLess(abs(wait_elapsed - 1.0), 1.0)
+
     def test_fork_exc(self):
         from sh import ForkException
 
@@ -3138,6 +3155,13 @@ class ExecutionContextTests(unittest.TestCase):
         _sh.echo("-n", "TEST")
         self.assertEqual("TEST", out.getvalue())
 
+    def test_command_with_baked_call_args(self):
+        # Test that sh.Command() knows about baked call args
+        import sh
+        _sh = sh(_ok_code=1)
+        self.assertEqual(sh.Command._call_args['ok_code'], 0)
+        self.assertEqual(_sh.Command._call_args['ok_code'], 1)
+
     def test_importer_detects_module_name(self):
         import sh
         _sh = sh()
@@ -3205,6 +3229,4 @@ if __name__ == "__main__":
                 exit(1)
 
     finally:
-        if cov:
-            cov.stop()
-            cov.save()
+        pass