New Upstream Release - python-spur

Ready changes

Summary

Merged new upstream version: 0.3.23 (was: 0.3.22).

Resulting package

Built on 2023-03-23T21:57 (took 3m49s)

The resulting binary packages can be installed (if you have the apt repository enabled) by running one of:

apt install -t fresh-releases python3-spur

Lintian Result

Diff

diff --git a/CHANGES b/CHANGES
index c50e91e..2d18f59 100644
--- a/CHANGES
+++ b/CHANGES
@@ -1,5 +1,15 @@
 # CHANGES
 
+## 0.3.23
+
+* Raise minimum Python version to 3.6.
+
+* Handle FileNotFoundError when using subprocess on Python >= 3.8.
+
+## 0.3.22
+
+* Fix: default connect timeout was not being set correctly.
+
 ## 0.3.21
 
 * Add close() method to shells, behaving the same as __exit__().
diff --git a/README.rst b/README.rst
index d9efa94..a66d89c 100644
--- a/README.rst
+++ b/README.rst
@@ -36,7 +36,7 @@ LocalShell
 
 Takes no arguments:
 
-.. code-block:: sh
+.. code-block:: python
 
     spur.LocalShell()
 
@@ -76,7 +76,7 @@ Optional arguments:
 * ``missing_host_key`` -- by default, an error is raised when a host
   key is missing. One of the following values can be used to change the
   behaviour when a host key is missing:
-   
+
   - ``spur.ssh.MissingHostKey.raise_error`` -- raise an error
   - ``spur.ssh.MissingHostKey.warn`` -- accept the host key and log a
     warning
@@ -88,14 +88,14 @@ Optional arguments:
   often found on embedded systems, try changing ``shell_type`` to a more
   appropriate value, such as ``spur.ssh.ShellTypes.minimal``. The following
   shell types are currently supported:
-  
+
   - ``spur.ssh.ShellTypes.sh`` -- the Bourne shell. Supports all features.
-  
+
   - ``spur.ssh.ShellTypes.minimal`` -- a minimal shell. Several features
     are unsupported:
-    
+
     - Non-existent commands will not raise ``spur.NoSuchCommandError``.
-    
+
     - The following arguments to ``spawn`` and ``run`` are unsupported unless
       set to their default values:
       ``cwd``, ``update_env``, and ``store_pid``.
@@ -103,7 +103,7 @@ Optional arguments:
 * ``look_for_private_keys`` -- by default, Spur will search for discoverable
   private key files in ``~/.ssh/``.
   Set to ``False`` to disable this behaviour.
-  
+
 * ``load_system_host_keys`` -- by default, Spur will attempt to read host keys
   from the user's known hosts file, as used by OpenSSH, and no exception will
   be raised if the file can't be read.
@@ -213,6 +213,12 @@ assuming you already have an instance of ``SshShell``:
         with open("/path/to/local", "wb") as local_file:
             shutil.copyfileobj(remote_file, local_file)
 
+close()
+~~~~~~~
+
+Closes and the shell and releases any associated resources.
+``close()`` is called automatically when the shell is used as a context manager.
+
 Process interface
 -----------------
 
diff --git a/debian/changelog b/debian/changelog
index 3766063..a66f9a6 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -1,3 +1,10 @@
+python-spur (0.3.23-1) UNRELEASED; urgency=low
+
+  * New upstream release.
+  * New upstream release.
+
+ -- Debian Janitor <janitor@jelmer.uk>  Thu, 23 Mar 2023 21:53:37 -0000
+
 python-spur (0.3.21-3) unstable; urgency=medium
 
   [ Debian Janitor ]
diff --git a/makefile b/makefile
index 75a5b9a..ee0f56a 100644
--- a/makefile
+++ b/makefile
@@ -1,29 +1,39 @@
-.PHONY: test upload clean bootstrap
+.PHONY: test
 
 test:
-	sh -c '. _virtualenv/bin/activate; nosetests -m'\''^$$'\'' `find tests -name '\''*.py'\''`'
-	
-upload:
+	sh -c '. _virtualenv/bin/activate; py.test tests'
+
+.PHONY: upload
+
+upload: build-dist
 	tox
-	_virtualenv/bin/python setup.py sdist bdist_wheel upload
+	_virtualenv/bin/twine upload dist/*
 	make clean
-	
-register:
-	python setup.py register
+
+.PHONY: build-dist
+
+build-dist:
+	rm -rf dist
+	_virtualenv/bin/pyproject-build
+
+.PHONY: clean
 
 clean:
 	rm -f MANIFEST
 	rm -rf dist build
-	
+
+.PHONY: bootstrap
+
 bootstrap: _virtualenv
 	_virtualenv/bin/pip install -e .
-ifneq ($(wildcard test-requirements.txt),) 
+ifneq ($(wildcard test-requirements.txt),)
 	_virtualenv/bin/pip install -r test-requirements.txt
 endif
 	make clean
 
-_virtualenv: 
+_virtualenv:
 	python3 -m venv _virtualenv
 	_virtualenv/bin/pip install --upgrade pip
 	_virtualenv/bin/pip install --upgrade setuptools
 	_virtualenv/bin/pip install --upgrade wheel
+	_virtualenv/bin/pip install --upgrade build twine
diff --git a/setup.py b/setup.py
index 9f17300..f48dadc 100644
--- a/setup.py
+++ b/setup.py
@@ -8,27 +8,29 @@ def read(fname):
 
 setup(
     name='spur',
-    version='0.3.21',
+    version='0.3.23',
     description='Run commands and manipulate files locally or over SSH using the same interface',
     long_description=read("README.rst"),
     author='Michael Williamson',
     author_email='mike@zwobble.org',
-    url='http://github.com/mwilliamson/spur.py',
+    url='https://github.com/mwilliamson/spur.py',
     keywords="ssh shell subprocess process",
     packages=['spur'],
-    install_requires=["paramiko>=1.13.1,<3"],
+    install_requires=["paramiko>=1.13.1,<4"],
+    python_requires='>=3.6',
+    license="BSD-2-Clause",
     classifiers=[
         'Development Status :: 4 - Beta',
         'Intended Audience :: Developers',
         'License :: OSI Approved :: BSD License',
         'Programming Language :: Python',
-        'Programming Language :: Python :: 2',
-        'Programming Language :: Python :: 2.7',
         'Programming Language :: Python :: 3',
-        'Programming Language :: Python :: 3.4',
-        'Programming Language :: Python :: 3.5',
         'Programming Language :: Python :: 3.6',
         'Programming Language :: Python :: 3.7',
+        'Programming Language :: Python :: 3.8',
+        'Programming Language :: Python :: 3.9',
+        'Programming Language :: Python :: 3.10',
+        'Programming Language :: Python :: 3.11',
         'Topic :: Internet',
     ],
 )
diff --git a/spur/local.py b/spur/local.py
index 0996ed3..502f547 100644
--- a/spur/local.py
+++ b/spur/local.py
@@ -71,6 +71,13 @@ class LocalShell(object):
                 bufsize=0,
                 **self._subprocess_args(command, *args, **kwargs)
             )
+        except FileNotFoundError as error:
+            if cwd is not None and error.filename == cwd:
+                raise CouldNotChangeDirectoryError(cwd, error)
+            elif error.filename == command[0]:
+                raise NoSuchCommandError(command[0])
+            else:
+                raise
         except OSError as error:
             if cwd is not None and self._is_cannot_change_directory_oserror(error, cwd):
                 raise CouldNotChangeDirectoryError(cwd, error)
diff --git a/spur/ssh.py b/spur/ssh.py
index ac1f309..6ee0c24 100644
--- a/spur/ssh.py
+++ b/spur/ssh.py
@@ -129,6 +129,9 @@ class SshShell(object):
             load_system_host_keys=True,
             sock=None):
 
+        if connect_timeout is None:
+            connect_timeout = _ONE_MINUTE
+
         if port is None:
             port = 22
 
@@ -141,7 +144,7 @@ class SshShell(object):
         self._password = password
         self._private_key_file = private_key_file
         self._client = None
-        self._connect_timeout = connect_timeout if not None else _ONE_MINUTE
+        self._connect_timeout = connect_timeout
         self._look_for_private_keys = look_for_private_keys
         self._load_system_host_keys = load_system_host_keys
         self._closed = False
diff --git a/test-requirements.txt b/test-requirements.txt
index 2270513..e079f8a 100644
--- a/test-requirements.txt
+++ b/test-requirements.txt
@@ -1 +1 @@
-nose>=1.2.1,<2
+pytest
diff --git a/tests/assertions.py b/tests/assertions.py
new file mode 100644
index 0000000..b80cc2b
--- /dev/null
+++ b/tests/assertions.py
@@ -0,0 +1,13 @@
+import pytest
+
+
+def assert_equal(expected, actual):
+    assert expected == actual
+
+
+def assert_not_equal(expected, actual):
+    assert expected != actual
+
+
+def assert_raises(exception_type, func):
+    pytest.raises(exception_type, func)
diff --git a/tests/local_tests.py b/tests/local_tests.py
index 850a737..919b82b 100644
--- a/tests/local_tests.py
+++ b/tests/local_tests.py
@@ -1,7 +1,5 @@
 import spur
 
-from nose.tools import istest
-
 from .process_test_set import ProcessTestSet
 from .open_test_set import OpenTestSet
 
@@ -9,13 +7,11 @@ from .open_test_set import OpenTestSet
 class LocalTestMixin(object):
     def create_shell(self):
         return spur.LocalShell()
-    
 
-@istest
+
 class LocalOpenTests(OpenTestSet, LocalTestMixin):
     pass
 
 
-@istest
 class LocalProcessTests(ProcessTestSet, LocalTestMixin):
     pass
diff --git a/tests/open_test_set.py b/tests/open_test_set.py
index ae3805a..81665e8 100644
--- a/tests/open_test_set.py
+++ b/tests/open_test_set.py
@@ -3,25 +3,24 @@ from __future__ import unicode_literals
 import uuid
 import functools
 
-from nose.tools import assert_equal, istest, nottest
+from .assertions import assert_equal
+
 
 __all__ = ["OpenTestSet"]
 
 
-@nottest
-def test(test_func):
+def with_shell(test_func):
     @functools.wraps(test_func)
-    @istest
     def run_test(self, *args, **kwargs):
         with self.create_shell() as shell:
             test_func(shell)
-    
+
     return run_test
 
 
 class OpenTestSet(object):
-    @test
-    def can_write_to_files_opened_by_open(shell):
+    @with_shell
+    def test_can_write_to_files_opened_by_open(shell):
         path = "/tmp/{0}".format(uuid.uuid4())
         f = shell.open(path, "w")
         try:
@@ -31,9 +30,9 @@ class OpenTestSet(object):
         finally:
             f.close()
             shell.run(["rm", path])
-            
-    @test
-    def can_read_files_opened_by_open(shell):
+
+    @with_shell
+    def test_can_read_files_opened_by_open(shell):
         path = "/tmp/{0}".format(uuid.uuid4())
         shell.run(["sh", "-c", "echo hello > '{0}'".format(path)])
         f = shell.open(path)
@@ -42,16 +41,16 @@ class OpenTestSet(object):
         finally:
             f.close()
             shell.run(["rm", path])
-            
-    @test
-    def open_can_be_used_as_context_manager(shell):
+
+    @with_shell
+    def test_open_can_be_used_as_context_manager(shell):
         path = "/tmp/{0}".format(uuid.uuid4())
         shell.run(["sh", "-c", "echo hello > '{0}'".format(path)])
         with shell.open(path) as f:
             assert_equal("hello\n", f.read())
-            
-    @test
-    def files_can_be_opened_in_binary_mode(shell):
+
+    @with_shell
+    def test_files_can_be_opened_in_binary_mode(shell):
         path = "/tmp/{0}".format(uuid.uuid4())
         shell.run(["sh", "-c", "echo hello > '{0}'".format(path)])
         with shell.open(path, "rb") as f:
diff --git a/tests/process_test_set.py b/tests/process_test_set.py
index 5381100..2bfc9c8 100644
--- a/tests/process_test_set.py
+++ b/tests/process_test_set.py
@@ -7,18 +7,15 @@ import signal
 import functools
 import posixpath
 
-from nose.tools import istest, nottest, assert_equal, assert_not_equal, assert_raises, assert_true
-
 import spur
+from .assertions import assert_equal, assert_not_equal, assert_raises
 
 
 __all__ = ["ProcessTestSet"]
 
 
-@nottest
-def test(test_func):
+def with_shell(test_func):
     @functools.wraps(test_func)
-    @istest
     def run_test(self, *args, **kwargs):
         with self.create_shell() as shell:
             test_func(shell)
@@ -27,63 +24,63 @@ def test(test_func):
 
 
 class ProcessTestSet(object):
-    @test
-    def output_of_run_is_stored(shell):
+    @with_shell
+    def test_output_of_run_is_stored(shell):
         result = shell.run(["echo", "hello"])
         assert_equal(b"hello\n", result.output)
 
-    @test
-    def output_is_not_truncated_when_not_ending_in_a_newline(shell):
+    @with_shell
+    def test_output_is_not_truncated_when_not_ending_in_a_newline(shell):
         result = shell.run(["echo", "-n", "hello"])
         assert_equal(b"hello", result.output)
 
-    @test
-    def trailing_newlines_are_not_stripped_from_run_output(shell):
+    @with_shell
+    def test_trailing_newlines_are_not_stripped_from_run_output(shell):
         result = shell.run(["echo", "\n\n"])
         assert_equal(b"\n\n\n", result.output)
 
-    @test
-    def stderr_output_of_run_is_stored(shell):
+    @with_shell
+    def test_stderr_output_of_run_is_stored(shell):
         result = shell.run(["sh", "-c", "echo hello 1>&2"])
         assert_equal(b"hello\n", result.stderr_output)
 
-    @test
-    def output_bytes_are_decoded_if_encoding_is_set(shell):
+    @with_shell
+    def test_output_bytes_are_decoded_if_encoding_is_set(shell):
         result = shell.run(["bash", "-c", r'echo -e "\u2603"'], encoding="utf8")
         assert_equal(_u("☃\n"), result.output)
 
-    @test
-    def cwd_of_run_can_be_set(shell):
+    @with_shell
+    def test_cwd_of_run_can_be_set(shell):
         result = shell.run(["pwd"], cwd="/")
         assert_equal(b"/\n", result.output)
 
-    @test
-    def environment_variables_can_be_added_for_run(shell):
+    @with_shell
+    def test_environment_variables_can_be_added_for_run(shell):
         result = shell.run(["sh", "-c", "echo $NAME"], update_env={"NAME": "Bob"})
         assert_equal(b"Bob\n", result.output)
 
-    @test
-    def exception_is_raised_if_return_code_is_not_zero(shell):
+    @with_shell
+    def test_exception_is_raised_if_return_code_is_not_zero(shell):
         assert_raises(spur.RunProcessError, lambda: shell.run(["false"]))
 
-    @test
-    def exception_has_output_from_command(shell):
+    @with_shell
+    def test_exception_has_output_from_command(shell):
         try:
             shell.run(["sh", "-c", "echo Hello world!; false"])
             assert_true(False)
         except spur.RunProcessError as error:
             assert_equal(b"Hello world!\n", error.output)
 
-    @test
-    def exception_has_stderr_output_from_command(shell):
+    @with_shell
+    def test_exception_has_stderr_output_from_command(shell):
         try:
             shell.run(["sh", "-c", "echo Hello world! 1>&2; false"])
             assert_true(False)
         except spur.RunProcessError as error:
             assert_equal(b"Hello world!\n", error.stderr_output)
 
-    @test
-    def exception_message_contains_return_code_and_all_output(shell):
+    @with_shell
+    def test_exception_message_contains_return_code_and_all_output(shell):
         try:
             shell.run(["sh", "-c", "echo starting; echo failed! 1>&2; exit 1"])
             assert_true(False)
@@ -93,8 +90,8 @@ class ProcessTestSet(object):
                 error.args[0]
             )
 
-    @test
-    def exception_message_contains_output_as_string_if_encoding_is_set(shell):
+    @with_shell
+    def test_exception_message_contains_output_as_string_if_encoding_is_set(shell):
         try:
             shell.run(["sh", "-c", "echo starting; echo failed! 1>&2; exit 1"], encoding="ascii")
             assert_true(False)
@@ -104,8 +101,8 @@ class ProcessTestSet(object):
                 error.args[0]
             )
 
-    @test
-    def exception_message_shows_unicode_bytes(shell):
+    @with_shell
+    def test_exception_message_shows_unicode_bytes(shell):
         try:
             shell.run(["sh", "-c", _u("echo ‽; exit 1")])
             assert_true(False)
@@ -115,45 +112,45 @@ class ProcessTestSet(object):
                 error.args[0]
             )
 
-    @test
-    def return_code_stored_if_errors_allowed(shell):
+    @with_shell
+    def test_return_code_stored_if_errors_allowed(shell):
         result = shell.run(["sh", "-c", "exit 14"], allow_error=True)
         assert_equal(14, result.return_code)
 
-    @test
-    def can_get_result_of_spawned_process(shell):
+    @with_shell
+    def test_can_get_result_of_spawned_process(shell):
         process = shell.spawn(["echo", "hello"])
         result = process.wait_for_result()
         assert_equal(b"hello\n", result.output)
 
-    @test
-    def calling_wait_for_result_is_idempotent(shell):
+    @with_shell
+    def test_calling_wait_for_result_is_idempotent(shell):
         process = shell.spawn(["echo", "hello"])
         process.wait_for_result()
         result = process.wait_for_result()
         assert_equal(b"hello\n", result.output)
 
-    @test
-    def wait_for_result_raises_error_if_return_code_is_not_zero(shell):
+    @with_shell
+    def test_wait_for_result_raises_error_if_return_code_is_not_zero(shell):
         process = shell.spawn(["false"])
         assert_raises(spur.RunProcessError, process.wait_for_result)
 
-    @test
-    def can_write_to_stdin_of_spawned_processes(shell):
+    @with_shell
+    def test_can_write_to_stdin_of_spawned_processes(shell):
         process = shell.spawn(["sh", "-c", "read value; echo $value"])
         process.stdin_write(b"hello\n")
         result = process.wait_for_result()
         assert_equal(b"hello\n", result.output)
 
-    @test
-    def can_tell_if_spawned_process_is_running(shell):
+    @with_shell
+    def test_can_tell_if_spawned_process_is_running(shell):
         process = shell.spawn(["sh", "-c", "echo after; read dont_care; echo after"])
         assert_equal(True, process.is_running())
         process.stdin_write(b"\n")
         _wait_for_assertion(lambda: assert_equal(False, process.is_running()))
 
-    @test
-    def can_write_stdout_to_file_object_while_process_is_executing(shell):
+    @with_shell
+    def test_can_write_stdout_to_file_object_while_process_is_executing(shell):
         output_file = io.BytesIO()
         process = shell.spawn(
             ["sh", "-c", "echo hello; read dont_care;"],
@@ -164,8 +161,8 @@ class ProcessTestSet(object):
         process.stdin_write(b"\n")
         assert_equal(b"hello\n", process.wait_for_result().output)
 
-    @test
-    def can_write_stderr_to_file_object_while_process_is_executing(shell):
+    @with_shell
+    def test_can_write_stderr_to_file_object_while_process_is_executing(shell):
         output_file = io.BytesIO()
         process = shell.spawn(
             ["sh", "-c", "echo hello 1>&2; read dont_care;"],
@@ -176,8 +173,8 @@ class ProcessTestSet(object):
         process.stdin_write(b"\n")
         assert_equal(b"hello\n", process.wait_for_result().stderr_output)
 
-    @test
-    def when_encoding_is_set_then_stdout_is_decoded_before_writing_to_stdout_argument(shell):
+    @with_shell
+    def test_when_encoding_is_set_then_stdout_is_decoded_before_writing_to_stdout_argument(shell):
         output_file = io.StringIO()
         process = shell.spawn(
             ["bash", "-c", r'echo -e "\u2603"hello; read dont_care'],
@@ -189,27 +186,27 @@ class ProcessTestSet(object):
         process.stdin_write(b"\n")
         assert_equal(_u("☃hello\n"), process.wait_for_result().output)
 
-    @test
-    def can_get_process_id_of_process_if_store_pid_is_true(shell):
+    @with_shell
+    def test_can_get_process_id_of_process_if_store_pid_is_true(shell):
         process = shell.spawn(["sh", "-c", "echo $$"], store_pid=True)
         result = process.wait_for_result()
         assert_equal(int(result.output.strip()), process.pid)
 
-    @test
-    def process_id_is_not_available_if_store_pid_is_not_set(shell):
+    @with_shell
+    def test_process_id_is_not_available_if_store_pid_is_not_set(shell):
         process = shell.spawn(["sh", "-c", "echo $$"])
         assert not hasattr(process, "pid")
 
-    @test
-    def can_send_signal_to_process_if_store_pid_is_set(shell):
+    @with_shell
+    def test_can_send_signal_to_process_if_store_pid_is_set(shell):
         process = shell.spawn(["cat"], store_pid=True)
         assert process.is_running()
         process.send_signal(signal.SIGTERM)
         _wait_for_assertion(lambda: assert_equal(False, process.is_running()))
 
 
-    @test
-    def spawning_non_existent_command_raises_specific_no_such_command_exception(shell):
+    @with_shell
+    def test_spawning_non_existent_command_raises_specific_no_such_command_exception(shell):
         try:
             shell.spawn(["bin/i-am-not-a-command"])
             # Expected exception
@@ -219,8 +216,8 @@ class ProcessTestSet(object):
             assert_equal("bin/i-am-not-a-command", error.command)
 
 
-    @test
-    def spawning_command_that_uses_path_env_variable_asks_if_command_is_installed(shell):
+    @with_shell
+    def test_spawning_command_that_uses_path_env_variable_asks_if_command_is_installed(shell):
         try:
             shell.spawn(["i-am-not-a-command"])
             # Expected exception
@@ -234,8 +231,8 @@ class ProcessTestSet(object):
             assert_equal("i-am-not-a-command", error.command)
 
 
-    @test
-    def using_non_existent_cwd_does_not_raise_no_such_command_error(shell):
+    @with_shell
+    def test_using_non_existent_cwd_does_not_raise_no_such_command_error(shell):
         cwd = "/some/path/that/hopefully/doesnt/exists/ljaslkfjaslkfjas"
         try:
             shell.spawn(["echo", "1"], cwd=cwd)
@@ -245,41 +242,41 @@ class ProcessTestSet(object):
             assert not isinstance(error, spur.NoSuchCommandError)
 
 
-    @test
-    def commands_are_run_without_pseudo_terminal_by_default(shell):
+    @with_shell
+    def test_commands_are_run_without_pseudo_terminal_by_default(shell):
         result = shell.run(["bash", "-c", "[ -t 0 ]"], allow_error=True)
         assert_not_equal(0, result.return_code)
 
 
-    @test
-    def command_can_be_explicitly_run_with_pseudo_terminal(shell):
+    @with_shell
+    def test_command_can_be_explicitly_run_with_pseudo_terminal(shell):
         result = shell.run(["bash", "-c", "[ -t 0 ]"], allow_error=True, use_pty=True)
         assert_equal(0, result.return_code)
 
 
-    @test
-    def output_is_captured_when_using_pty(shell):
+    @with_shell
+    def test_output_is_captured_when_using_pty(shell):
         result = shell.run(["echo", "-n", "hello"], use_pty=True)
         assert_equal(b"hello", result.output)
 
 
-    @test
-    def stderr_is_redirected_stdout_when_using_pty(shell):
+    @with_shell
+    def test_stderr_is_redirected_stdout_when_using_pty(shell):
         result = shell.run(["sh", "-c", "echo -n hello 1>&2"], use_pty=True)
         assert_equal(b"hello", result.output)
         assert_equal(b"", result.stderr_output)
 
 
-    @test
-    def can_write_to_stdin_of_spawned_process_when_using_pty(shell):
+    @with_shell
+    def test_can_write_to_stdin_of_spawned_process_when_using_pty(shell):
         process = shell.spawn(["sh", "-c", "read value; echo $value"], use_pty=True)
         process.stdin_write(b"hello\n")
         result = process.wait_for_result()
         # Get the output twice since the pty echoes input
         assert_equal(b"hello\r\nhello\r\n", result.output)
 
-    @test
-    def using_non_existent_cwd_raises_could_not_change_directory_error(shell):
+    @with_shell
+    def test_using_non_existent_cwd_raises_could_not_change_directory_error(shell):
         cwd = "/some/silly/path"
         try:
             shell.spawn(["echo", "1"], cwd=cwd)
@@ -289,8 +286,8 @@ class ProcessTestSet(object):
             assert_equal("Could not change directory to: {0}".format(cwd), error.args[0].split("\n")[0])
             assert_equal(cwd, error.directory)
 
-    @test
-    def attempting_to_change_directory_without_permissions_raises_cannot_change_directory_error(shell):
+    @with_shell
+    def test_attempting_to_change_directory_without_permissions_raises_cannot_change_directory_error(shell):
         with shell.temporary_dir() as temp_dir:
             dir_without_execute_permissions = posixpath.join(temp_dir, "a")
             shell.run(["mkdir", dir_without_execute_permissions])
@@ -302,8 +299,8 @@ class ProcessTestSet(object):
             except spur.CouldNotChangeDirectoryError as error:
                 assert_equal(dir_without_execute_permissions, error.directory)
 
-    @test
-    def using_non_existent_cwd_and_command_raises_could_not_change_directory_error(shell):
+    @with_shell
+    def test_using_non_existent_cwd_and_command_raises_could_not_change_directory_error(shell):
         try:
             shell.spawn(["bin/i-am-not-a-command"], cwd="/some/silly/path")
             # Expected exception
@@ -311,8 +308,8 @@ class ProcessTestSet(object):
         except spur.CouldNotChangeDirectoryError as error:
             assert_equal("/some/silly/path", error.directory)
 
-    @test
-    def using_non_existent_command_and_correct_cwd_raises_no_such_command_exception(shell):
+    @with_shell
+    def test_using_non_existent_command_and_correct_cwd_raises_no_such_command_exception(shell):
         try:
             shell.spawn(["bin/i-am-not-a-command"], cwd="/bin")
             # Expected exception
@@ -320,8 +317,8 @@ class ProcessTestSet(object):
         except spur.NoSuchCommandError as error:
             assert_equal("bin/i-am-not-a-command", error.command)
 
-    @test
-    def can_find_command_in_cwd(shell):
+    @with_shell
+    def test_can_find_command_in_cwd(shell):
         # TODO: the behaviour in subprocess seems to be inconsistent between
         # both Python versions and platforms (Windows vs Unix)
         # See:
@@ -330,8 +327,7 @@ class ProcessTestSet(object):
         result = shell.run(["./ls"], cwd="/bin")
         assert_equal(result.return_code, 0)
 
-    @istest
-    def shell_can_be_closed_using_close_method(self):
+    def test_shell_can_be_closed_using_close_method(self):
         shell = self.create_shell()
         try:
             result = shell.run(["echo", "hello"])
diff --git a/tests/ssh_tests.py b/tests/ssh_tests.py
index a239434..105a486 100644
--- a/tests/ssh_tests.py
+++ b/tests/ssh_tests.py
@@ -3,11 +3,9 @@ from __future__ import unicode_literals
 import io
 import socket
 
-from nose.tools import istest, assert_raises, assert_equal
-from paramiko.util import retry_on_signal
-
 import spur
 import spur.ssh
+from .assertions import assert_equal, assert_raises
 from .testing import create_ssh_shell, HOSTNAME, PORT, PASSWORD, USERNAME
 from .process_test_set import ProcessTestSet
 from .open_test_set import OpenTestSet
@@ -16,29 +14,25 @@ from .open_test_set import OpenTestSet
 class SshTestMixin(object):
     def create_shell(self):
         return create_ssh_shell()
-    
 
-@istest
+
 class SshOpenTests(OpenTestSet, SshTestMixin):
     pass
 
 
-@istest
 class SshProcessTests(ProcessTestSet, SshTestMixin):
     pass
 
 
-@istest
-def attempting_to_connect_to_wrong_port_raises_connection_error():
+def test_attempting_to_connect_to_wrong_port_raises_connection_error():
     def try_connection():
         shell = _create_shell_with_wrong_port()
         shell.run(["echo", "hello"])
-        
+
     assert_raises(spur.ssh.ConnectionError, try_connection)
 
 
-@istest
-def connection_error_contains_original_error():
+def test_connection_error_contains_original_error():
     try:
         shell = _create_shell_with_wrong_port()
         shell.run(["true"])
@@ -48,8 +42,7 @@ def connection_error_contains_original_error():
         assert isinstance(error.original_error, IOError)
 
 
-@istest
-def connection_error_contains_traceback_for_original_error():
+def test_connection_error_contains_traceback_for_original_error():
     try:
         shell = _create_shell_with_wrong_port()
         shell.run(["true"])
@@ -59,36 +52,31 @@ def connection_error_contains_traceback_for_original_error():
         assert "Traceback (most recent call last):" in error.original_traceback
 
 
-@istest
-def missing_host_key_set_to_accept_allows_connection_with_missing_host_key():
+def test_missing_host_key_set_to_accept_allows_connection_with_missing_host_key():
     with create_ssh_shell(missing_host_key=spur.ssh.MissingHostKey.accept) as shell:
         shell.run(["true"])
 
 
-@istest
-def missing_host_key_set_to_warn_allows_connection_with_missing_host_key():
+def test_missing_host_key_set_to_warn_allows_connection_with_missing_host_key():
     with create_ssh_shell(missing_host_key=spur.ssh.MissingHostKey.warn) as shell:
         shell.run(["true"])
 
 
-@istest
-def missing_host_key_set_to_raise_error_raises_error_when_missing_host_key():
+def test_missing_host_key_set_to_raise_error_raises_error_when_missing_host_key():
     with create_ssh_shell(missing_host_key=spur.ssh.MissingHostKey.raise_error) as shell:
         assert_raises(spur.ssh.ConnectionError, lambda: shell.run(["true"]))
-        
 
-@istest
-def trying_to_use_ssh_shell_after_exit_results_in_error():
+
+def test_trying_to_use_ssh_shell_after_exit_results_in_error():
     with create_ssh_shell() as shell:
         pass
-        
+
     assert_raises(Exception, lambda: shell.run(["true"]))
 
 
-@istest
-def an_open_socket_can_be_used_for_ssh_connection_with_sock_argument():
+def test_an_open_socket_can_be_used_for_ssh_connection_with_sock_argument():
     sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
-    retry_on_signal(lambda: sock.connect((HOSTNAME, PORT)))
+    sock.connect((HOSTNAME, PORT))
 
     with _create_shell_with_wrong_port(sock=sock) as shell:
         result = shell.run(["echo", "hello"])
@@ -109,63 +97,56 @@ def _create_shell_with_wrong_port(**kwargs):
 class MinimalSshTestMixin(object):
     def create_shell(self):
         return create_ssh_shell(shell_type=spur.ssh.ShellTypes.minimal)
-    
 
-@istest
+
 class MinimalSshOpenTests(OpenTestSet, MinimalSshTestMixin):
     pass
 
 
-@istest
 class MinimalSshProcessTests(ProcessTestSet, MinimalSshTestMixin):
-    spawning_command_that_uses_path_env_variable_asks_if_command_is_installed = None
-    spawning_non_existent_command_raises_specific_no_such_command_exception = None
-    
-    can_get_process_id_of_process_if_store_pid_is_true = None
-    can_send_signal_to_process_if_store_pid_is_set = None
+    test_spawning_command_that_uses_path_env_variable_asks_if_command_is_installed = None
+    test_spawning_non_existent_command_raises_specific_no_such_command_exception = None
+
+    test_can_get_process_id_of_process_if_store_pid_is_true = None
+    test_can_send_signal_to_process_if_store_pid_is_set = None
 
     # cwd is not supported when using a minimal shell
-    using_non_existent_cwd_raises_could_not_change_directory_error = None
-    attempting_to_change_directory_without_permissions_raises_cannot_change_directory_error = None
-    using_non_existent_cwd_and_command_raises_could_not_change_directory_error = None
-    using_non_existent_command_and_correct_cwd_raises_no_such_command_exception = None
-    can_find_command_in_cwd = None
-    
-    @istest
-    def cannot_store_pid(self):
+    test_using_non_existent_cwd_raises_could_not_change_directory_error = None
+    test_attempting_to_change_directory_without_permissions_raises_cannot_change_directory_error = None
+    test_using_non_existent_cwd_and_command_raises_could_not_change_directory_error = None
+    test_using_non_existent_command_and_correct_cwd_raises_no_such_command_exception = None
+    test_can_find_command_in_cwd = None
+
+    def test_cannot_store_pid(self):
         self._assert_unsupported_feature(store_pid=True)
-    
-    cwd_of_run_can_be_set = None
-    
-    @istest
-    def cannot_set_cwd(self):
+
+    test_cwd_of_run_can_be_set = None
+
+    def test_cannot_set_cwd(self):
         self._assert_unsupported_feature(cwd="/")
-    
-    environment_variables_can_be_added_for_run = None
-    
-    @istest
-    def update_env_can_be_empty(self):
+
+    test_environment_variables_can_be_added_for_run = None
+
+    def test_update_env_can_be_empty(self):
         self._assert_supported_feature(update_env={})
-        
-    @istest
-    def cannot_update_env(self):
+
+    def test_cannot_update_env(self):
         self._assert_unsupported_feature(update_env={"x": "one"})
-        
-    @istest
-    def cannot_set_new_process_group(self):
+
+    def test_cannot_set_new_process_group(self):
         self._assert_unsupported_feature(new_process_group=True)
-    
-    
+
+
     def _assert_supported_feature(self, **kwargs):
         with self.create_shell() as shell:
             result = shell.run(["echo", "hello"], **kwargs)
-        
+
         assert_equal(b"hello\n", result.output)
-    
-    
+
+
     def _assert_unsupported_feature(self, **kwargs):
         name, = kwargs.keys()
-        
+
         try:
             with self.create_shell() as shell:
                 shell.run(["echo", "hello"], **kwargs)
@@ -175,18 +156,14 @@ class MinimalSshProcessTests(ProcessTestSet, MinimalSshTestMixin):
 
 
 
-@istest
 class ReadInitializationLineTests(object):
-    @istest
-    def reading_initialization_line_returns_int_from_line_of_file(self):
+    def test_reading_initialization_line_returns_int_from_line_of_file(self):
         assert_equal(42, spur.ssh._read_int_initialization_line(io.StringIO("42\n")))
-        
-    @istest
-    def blank_lines_are_skipped(self):
+
+    def test_blank_lines_are_skipped(self):
         assert_equal(42, spur.ssh._read_int_initialization_line(io.StringIO("\n \n\t\t\n42\n")))
-        
-    @istest
-    def error_if_non_blank_line_is_not_integer(self):
+
+    def test_error_if_non_blank_line_is_not_integer(self):
         try:
             spur.ssh._read_int_initialization_line(io.StringIO("x\n"))
             assert False, "Expected error"
diff --git a/tox.ini b/tox.ini
index 993feaf..ac1a169 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,12 +1,11 @@
 [tox]
-envlist = py27,py34,py35,py36,py37,pypy
+envlist = py36,py37,py38,py39,py310,py311
 [testenv]
 changedir = {envtmpdir}
 deps=-r{toxinidir}/test-requirements.txt
 commands=
-    nosetests {toxinidir}/tests
+    py.test {toxinidir}/tests
 passenv=TEST_SSH_*
-[testenv:py32]
-deps=
-    {[testenv]deps}
-    paramiko<2
+[pytest]
+python_classes = *Tests
+python_files = *_tests.py

Debdiff

[The following lists of changes regard files as different if they have different names, permissions or owners.]

Files in second set of .debs but not in first

-rw-r--r--  root/root   /usr/lib/python3/dist-packages/spur-0.3.23.egg-info/PKG-INFO
-rw-r--r--  root/root   /usr/lib/python3/dist-packages/spur-0.3.23.egg-info/dependency_links.txt
-rw-r--r--  root/root   /usr/lib/python3/dist-packages/spur-0.3.23.egg-info/requires.txt
-rw-r--r--  root/root   /usr/lib/python3/dist-packages/spur-0.3.23.egg-info/top_level.txt

Files in first set of .debs but not in second

-rw-r--r--  root/root   /usr/lib/python3/dist-packages/spur-0.3.21.egg-info/PKG-INFO
-rw-r--r--  root/root   /usr/lib/python3/dist-packages/spur-0.3.21.egg-info/dependency_links.txt
-rw-r--r--  root/root   /usr/lib/python3/dist-packages/spur-0.3.21.egg-info/requires.txt
-rw-r--r--  root/root   /usr/lib/python3/dist-packages/spur-0.3.21.egg-info/top_level.txt

No differences were encountered in the control files

More details

Full run details