diff --git a/ansible_mitogen/target.py b/ansible_mitogen/target.py index 6e128af4..199f3a14 100644 --- a/ansible_mitogen/target.py +++ b/ansible_mitogen/target.py @@ -40,7 +40,6 @@ import errno import grp import json import logging -import operator import os import pty import pwd @@ -66,8 +65,6 @@ if not sys.modules.get(str('__main__')): import ansible.module_utils.json_utils -from ansible.module_utils.six.moves import reduce - import ansible_mitogen.runner @@ -718,7 +715,9 @@ def apply_mode_spec(spec, mode): mask = CHMOD_MASKS[ch] bits = CHMOD_BITS[ch] cur_perm_bits = mode & mask - new_perm_bits = reduce(operator.or_, (bits[p] for p in perms), 0) + new_perm_bits = 0 + for perm in perms: + new_perm_bits |= bits[perm] mode &= ~mask if op == '=': mode |= new_perm_bits diff --git a/docs/ansible_detailed.rst b/docs/ansible_detailed.rst index e692c43a..ff5f9dcb 100644 --- a/docs/ansible_detailed.rst +++ b/docs/ansible_detailed.rst @@ -145,6 +145,8 @@ Noteworthy Differences +-----------------+ 3.11 - 3.14 | | 12 | | +-----------------+-----------------+ + | 13 | 3.12 - 3.14 | + +-----------------+-----------------+ Verify your installation is running one of these versions by checking ``ansible --version`` output. diff --git a/docs/changelog.rst b/docs/changelog.rst index 8eaaff7e..26523031 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -18,6 +18,18 @@ To avail of fixes in an unreleased version, please download a ZIP file `directly from GitHub `_. +v0.3.36 (2025-12-01) +-------------------- + +* :gh:issue:`1237` :mod:`mitogen`: Re-declare Python 2.4 compatibility +* :gh:issue:`1385` :mod:`ansible_mitogen`: Remove a use of + ``ansible.module_utils.six`` +* :gh:issue:`1354` docs: Document Ansible 13 (ansible-core 2.20) support +* :gh:issue:`1354` :mod:`mitogen`: Clarify error message when a module + request would be refused by allow or deny listing +* :gh:issue:`1348` :mod:`mitogen`: Fix hanging process with 100% CPU usage + + v0.3.35 (2025-12-01) -------------------- diff --git a/docs/index.rst b/docs/index.rst index 32083db0..220a7fff 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -332,12 +332,16 @@ a large fleet of machines, or to alert the parent of unexpected state changes. Compatibility ############# -Mitogen is compatible with **Python 2.4** released November 2004, making it +``mitogen.*`` is compatible with Python 2.4 - 2.7 and 3.6 onward; making it suitable for managing a fleet of potentially ancient corporate hardware, such as Red Hat Enterprise Linux 5, released in 2007. -Every combination of Python 3.x/2.x parent and child should be possible, -however at present only Python 2.4, 2.6, 2.7 and 3.6 are tested automatically. +Every combination of Python 3.x/2.x parent and child should be possible. +Automated testing cannot cover every combination, automated testing tries to +cover the extemities (e.g. Python 3.14 parent -> Python 2.4 child). + +``ansible_mitogen.*`` is compatible with Python 2.7 and 3.6 onward; making it +suitable for Ansible 2.10 onward. Zero Dependencies diff --git a/mitogen/__init__.py b/mitogen/__init__.py index 92297521..03694786 100644 --- a/mitogen/__init__.py +++ b/mitogen/__init__.py @@ -35,7 +35,7 @@ be expected. On the slave, it is built dynamically during startup. #: Library version as a tuple. -__version__ = (0, 3, 35) +__version__ = (0, 3, 36) #: This is :data:`False` in slave contexts. Previously it was used to prevent diff --git a/mitogen/core.py b/mitogen/core.py index 441743d4..90499fac 100644 --- a/mitogen/core.py +++ b/mitogen/core.py @@ -541,6 +541,7 @@ def is_blacklisted_import(importer, fullname): any packages have been whitelisted and `fullname` is not part of one. NB: + - The default whitelist is `['']` which matches any module name. - If a package is on both lists, then it is treated as blacklisted. - If any package is whitelisted, then all non-whitelisted packages are treated as blacklisted. @@ -1536,9 +1537,8 @@ class Importer(object): return importlib.machinery.ModuleSpec(fullname, loader=self) blacklisted_msg = ( - '%r is present in the Mitogen importer blacklist, therefore this ' - 'context will not attempt to request it from the master, as the ' - 'request will always be refused.' + 'A %r request would be refused by the Mitogen master. The module is ' + 'on the deny list (blacklist) or not on the allow list (whitelist).' ) pkg_resources_msg = ( 'pkg_resources is prohibited from importing __main__, as it causes ' diff --git a/mitogen/parent.py b/mitogen/parent.py index 6e30b1c6..97681653 100644 --- a/mitogen/parent.py +++ b/mitogen/parent.py @@ -1415,9 +1415,8 @@ class Connection(object): # W: write side of interpreter stdin. # r: read side of core_src FD. # w: write side of core_src FD. - # C: the decompressed core source. - # Final os.close(STDOUT_FILENO) to avoid --py-debug build corrupting stream with + # Final os.close(STDERR_FILENO) to avoid --py-debug build corrupting stream with # "[1234 refs]" during exit. @staticmethod def _first_stage(): @@ -1437,8 +1436,17 @@ class Connection(object): os.environ['ARGV0']=sys.executable os.execl(sys.executable,sys.executable+'(mitogen:%s)'%sys.argv[2]) os.write(1,'MITO000\n'.encode()) + # Size of the compressed core source to be read + n=int(sys.argv[3]) + # Read `len(compressed preamble)` bytes sent by our Mitogen parent. + # `select()` handles non-blocking stdin (e.g. sudo + log_output). + # `C` accumulates compressed bytes. C=''.encode() - while int(sys.argv[3])-len(C)and select.select([0],[],[]):C+=os.read(0,int(sys.argv[3])-len(C)) + # data chunk + V='V' + # Stop looping if no more data is needed or EOF is detected (empty bytes). + while n-len(C) and V:select.select([0],[],[]);V=os.read(0,n-len(C));C+=V + # Raises `zlib.error` if compressed preamble is truncated or invalid C=zlib.decompress(C) f=os.fdopen(W,'wb',0) f.write(C) @@ -1463,8 +1471,9 @@ class Connection(object): return [self.options.python_path] def get_boot_command(self): - source = inspect.getsource(self._first_stage) - source = textwrap.dedent('\n'.join(source.strip().split('\n')[2:])) + lines = inspect.getsourcelines(self._first_stage)[0][2:] + # Remove line comments, leading indentation, trailing newline + source = textwrap.dedent(''.join(s for s in lines if '#' not in s))[:-1] source = source.replace(' ', ' ') compressor = zlib.compressobj( zlib.Z_BEST_COMPRESSION, zlib.DEFLATED, -zlib.MAX_WBITS, diff --git a/setup.py b/setup.py index ad60847e..047b7e86 100644 --- a/setup.py +++ b/setup.py @@ -82,7 +82,7 @@ setup( license = 'BSD-3-Clause', url = 'https://github.com/mitogen-hq/mitogen/', packages = find_packages(exclude=['tests', 'examples']), - python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*', + python_requires='>=2.4, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*', zip_safe = False, classifiers = [ 'Environment :: Console', @@ -91,6 +91,9 @@ setup( 'Operating System :: MacOS :: MacOS X', 'Operating System :: POSIX', 'Programming Language :: Python', + 'Programming Language :: Python :: 2.4', + 'Programming Language :: Python :: 2.5', + 'Programming Language :: Python :: 2.6', 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.6', diff --git a/tests/first_stage_test.py b/tests/first_stage_test.py index 2576ec14..354f7479 100644 --- a/tests/first_stage_test.py +++ b/tests/first_stage_test.py @@ -1,4 +1,6 @@ -import subprocess +import errno +import operator +import os import mitogen.core import mitogen.parent @@ -7,6 +9,269 @@ from mitogen.core import b import testlib +def create_child_using_pipes(args, blocking, preexec_fn=None): + """ + Create a child process whose stdin/stdout/stderr is connected to a pipe. + + :param list args: + Program argument vector. + :param bool blocking: + If :data:`True`, the sockets use blocking IO, otherwise non-blocking. + :param function preexec_fn: + If not :data:`None`, a function to run within the post-fork child + before executing the target program. + :returns: + :class:`PopenProcess` instance. + """ + + parent_rfp, child_wfp = mitogen.core.pipe(blocking) + child_rfp, parent_wfp = mitogen.core.pipe(blocking) + stderr_r, stderr = mitogen.core.pipe(blocking=blocking) + mitogen.core.set_cloexec(stderr_r.fileno()) + try: + proc = testlib.subprocess.Popen( + args=args, + stdin=child_rfp, + stdout=child_wfp, + stderr=stderr, + close_fds=True, + preexec_fn=preexec_fn, + ) + except Exception: + parent_rfp.close() + parent_wfp.close() + stderr_r.close() + raise + finally: + child_rfp.close() + child_wfp.close() + stderr.close() + + return mitogen.parent.PopenProcess( + proc=proc, + stdin=parent_wfp, + stdout=parent_rfp, + stderr=stderr_r, + ) + + +def create_child_using_sockets(args, blocking, size=None, preexec_fn=None): + """ + Create a child process whose stdin/stdout is connected to a socket and stderr to a pipe. + + :param list args: + Program argument vector. + :param bool blocking: + If :data:`True`, the sockets use blocking IO, otherwise non-blocking. + :param int size: + If not :data:`None`, use the value as the socket buffer size. + :param function preexec_fn: + If not :data:`None`, a function to run within the post-fork child + before executing the target program. + :returns: + :class:`PopenProcess` instance. + """ + + parent_rw_fp, child_rw_fp = mitogen.parent.create_socketpair(size=size, blocking=blocking) + stderr_r, stderr = mitogen.core.pipe(blocking=blocking) + mitogen.core.set_cloexec(stderr_r.fileno()) + try: + proc = testlib.subprocess.Popen( + args=args, + stdin=child_rw_fp, + stdout=child_rw_fp, + stderr=stderr, + close_fds=True, + preexec_fn=preexec_fn, + ) + except Exception: + parent_rw_fp.close() + stderr_r.close() + raise + finally: + child_rw_fp.close() + stderr.close() + + return mitogen.parent.PopenProcess( + proc=proc, + stdin=parent_rw_fp, + stdout=parent_rw_fp, + stderr=stderr_r, + ) + + +class DummyConnectionBlocking(mitogen.parent.Connection): + """Dummy blocking IO connection""" + + create_child = staticmethod(create_child_using_sockets) + name_prefix = "dummy_blocking" + + #: Dictionary of extra kwargs passed to :attr:`create_child`. + #: Use a size smaller than the conn.get_preamble() size so multiple + #: read-calls are needed in the first stage. + create_child_args = {"blocking": True, "size": 4096} + + +class DummyConnectionNonBlocking(mitogen.parent.Connection): + """Dummy non-blocking IO connection""" + + create_child = staticmethod(create_child_using_sockets) + name_prefix = "dummy_non_blocking" + + #: Dictionary of extra kwargs passed to :attr:`create_child`. + #: Use a size smaller than the conn.get_preamble() size so multiple + #: read-calls are needed in the first stage. + create_child_args = {"blocking": False, "size": 4096} + + +class DummyConnectionEOFRead(mitogen.parent.Connection): + """Dummy connection that triggers an EOF-read(STDIN) in the first_stage""" + + name_prefix = "dummy_eof_read" + + #: Dictionary of extra kwargs passed to :attr:`create_child`. + create_child_args = {"blocking": True} + + @staticmethod + def create_child(*a, **kw): + proc = create_child_using_pipes(*a, **kw) + # Close the pipe -> results in an EOF-read(STDIN) in the first_stage + proc.stdin.close() + # Whatever the parent writes to the child, drop it. + proc.stdin = open("/dev/null", "wb") + return proc + + +class DummyConnectionEndlessBlockingRead(mitogen.parent.Connection): + """Dummy connection that triggers a non-returning read(STDIN) call in the + first_stage. + + """ + + name_prefix = "dummy_endless_blocking_read" + + #: Dictionary of extra kwargs passed to :attr:`create_child`. + create_child_args = {"blocking": True} + + @staticmethod + def create_child(*a, **kw): + proc = create_child_using_pipes(*a, **kw) + # Keep the pipe open by having a reference to it, otherwise it would be + # automatically closed by the garbage collector. + proc._mitogen_test_orig_stdin = proc.stdin + # Whatever the parent writes to the child, drop it -> read from STDOUT + # blocks forever in the fork child as no data could be read. + proc.stdin = open("/dev/null", "wb") + return proc + + +class ConnectionTest(testlib.RouterMixin, testlib.TestCase): + def test_non_blocking_stdin(self): + """Test that first stage works with non-blocking STDIN + + The boot command should read the preamble from STDIN, write all ECO + markers to STDOUT, and then execute the preamble. + + This test writes the complete preamble to non-blocking STDIN. + + 1. Fork child reads from non-blocking STDIN + 2. Fork child writes all data as expected by the protocol. + 3. A context call works as expected. + + """ + with testlib.LogCapturer() as _: + ctx = self.router._connect(DummyConnectionNonBlocking, connect_timeout=0.5) + self.assertEqual(3, ctx.call(operator.add, 1, 2)) + + def test_blocking_stdin(self): + """Test that first stage works with blocking STDIN + + The boot command should read the preamble from STDIN, write all ECO + markers to STDOUT, and then execute the preamble. + + This test writes the complete preamble to blocking STDIN. + + 1. Fork child reads from blocking STDIN + 2. Fork child writes all data as expected by the protocol. + 3. A context call works as expected. + + """ + with testlib.LogCapturer() as _: + ctx = self.router._connect(DummyConnectionBlocking, connect_timeout=0.5) + self.assertEqual(3, ctx.call(operator.add, 1, 2)) + + def test_broker_connect_eof_error(self): + """Test that broker takes care about EOF errors in the first stage + + The boot command should write an ECO marker to stdout, try to read the + preamble from STDIN. This read returns with an EOF and the process exits. + + This test writes closes the pipe for STDIN of the fork child to enforce an EOF read call. + 1. Fork child reads from STDIN and reads an EOF and breaks the read-loop + 2. Decompressing the received data results in an error + 3. The child process exits + 4. The streams get disconnected -> mitogen.parent.EofError is raised + + """ + + with testlib.LogCapturer() as _: + e = self.assertRaises(mitogen.parent.EofError, + self.router._connect, DummyConnectionEOFRead, connect_timeout=0.5) + self.assertIn("Error -5 while decompressing data", str(e)) + + # Test that a TimeoutError is raised by the broker and all resources + # are cleaned up. + options = mitogen.parent.Options( + old_router=self.router, + max_message_size=self.router.max_message_size, + connect_timeout=0.5, + ) + conn = DummyConnectionEOFRead(options, router=self.router) + e = self.assertRaises(mitogen.parent.EofError, + conn.connect, context=mitogen.core.Context(None, 1234)) + self.assertIn("Error -5 while decompressing data", str(e)) + # Ensure the child process is reaped if the connection times out. + testlib.wait_for_child(conn.proc.pid) + e = self.assertRaises(OSError, + os.kill, conn.proc.pid, 0) + self.assertEqual(e.args[0], errno.ESRCH) + + def test_broker_connect_timeout_because_endless_blocking_read(self): + """Test that broker takes care about connection timeouts + + The boot command should write an ECO marker to stdout, try to read the + preamble from STDIN. This read blocks forever as the parent does write + all the data to /dev/null instead of the pipe. The broker should then + raise a TimeoutError as the child needs too much time. + + This test writes no data to STDIN of the fork child to enforce a blocking read call. + 1. Fork child tries to read from STDIN, but blocks forever. + 2. Parent connection timeout timer pops up and the parent cleans up + everything from the child (e.g. kills the child process). + 3. TimeoutError is raised in the connect call + + """ + with testlib.LogCapturer() as _: + # Ensure the child process is reaped if the connection times out. + options = mitogen.parent.Options( + old_router=self.router, + max_message_size=self.router.max_message_size, + connect_timeout=0.5, + ) + + conn = DummyConnectionEndlessBlockingRead(options, router=self.router) + try: + self.assertRaises(mitogen.core.TimeoutError, + lambda: conn.connect(context=mitogen.core.Context(None, 1234)) + ) + testlib.wait_for_child(conn.proc.pid) + e = self.assertRaises(OSError, + os.kill, conn.proc.pid, 0) + self.assertEqual(e.args[0], errno.ESRCH) + finally: + conn.proc._mitogen_test_orig_stdin.close() + + class CommandLineTest(testlib.RouterMixin, testlib.TestCase): # Ensure this version of Python produces a command line that is sufficient # to bootstrap this version of Python. @@ -17,28 +282,32 @@ class CommandLineTest(testlib.RouterMixin, testlib.TestCase): # * 3.x starting 2.7 def test_valid_syntax(self): - options = mitogen.parent.Options(max_message_size=123) - conn = mitogen.parent.Connection(options, self.router) - conn.context = mitogen.core.Context(None, 123) - args = conn.get_boot_command() + """Test valid syntax - # The boot command should write an ECO marker to stdout, read the - # preamble from stdin, then execute it. + The boot command should write an ECO marker to stdout, read the + preamble from stdin, then execute it. - # This test attaches /dev/zero to stdin to create a specific failure - # 1. Fork child reads bytes of NUL (`b'\0'`) - # 2. Fork child crashes (trying to decompress the junk data) - # 3. Fork child's file descriptors (write pipes) are closed by the OS - # 4. Fork parent does `dup(, )` and `exec()` - # 5. Python reads `b''` (i.e. EOF) from stdin (a closed pipe) - # 6. Python runs `''` (a valid script) and exits with success + This test attaches /dev/zero to stdin to create a specific failure - fp = open("/dev/zero", "r") + 1. Fork child reads bytes of NUL (`b'\0'`) + 2. Fork child crashes (trying to decompress the junk data) + 3. Fork child's file descriptors (write pipes) are closed by the OS + 4. Fork parent does `dup(, )` and `exec()` + 5. Python reads `b''` (i.e. EOF) from stdin (a closed pipe) + 6. Python runs `''` (a valid script) and exits with success + + """ + + options = mitogen.parent.Options(max_message_size=123) + conn = mitogen.parent.Connection(options, self.router) + conn.context = mitogen.core.Context(None, 123) + fp = open("/dev/zero", "rb") try: - proc = subprocess.Popen(args, + proc = testlib.subprocess.Popen( + args=conn.get_boot_command(), stdin=fp, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, + stdout=testlib.subprocess.PIPE, + stderr=testlib.subprocess.PIPE, ) stdout, stderr = proc.communicate() self.assertEqual(0, proc.returncode) @@ -50,3 +319,49 @@ class CommandLineTest(testlib.RouterMixin, testlib.TestCase): ) finally: fp.close() + + def test_premature_eof(self): + """The boot command should write an ECO marker to stdout, read the + preamble from stdin, then execute it. + + This test writes some data to STDIN and closes it then to create an + EOF situation. + 1. Fork child tries to read from STDIN, but stops as EOF is received. + 2. Fork child crashes (trying to decompress the junk data) + 3. Fork child's file descriptors (write pipes) are closed by the OS + 4. Fork parent does `dup(, )` and `exec()` + 5. Python reads `b''` (i.e. EOF) from stdin (a closed pipe) + 6. Python runs `''` (a valid script) and exits with success""" + + options = mitogen.parent.Options(max_message_size=123) + conn = mitogen.parent.Connection(options, self.router) + conn.context = mitogen.core.Context(None, 123) + proc = testlib.subprocess.Popen( + args=conn.get_boot_command(), + stdout=testlib.subprocess.PIPE, + stderr=testlib.subprocess.PIPE, + stdin=testlib.subprocess.PIPE, + ) + + # Do not send all of the data from the preamble + proc.stdin.write(conn.get_preamble()[:-128]) + proc.stdin.close() + try: + returncode = proc.wait(timeout=1) + except testlib.subprocess.TimeoutExpired: + proc.kill() + proc.wait(timeout=3) + self.fail("First stage did not handle EOF on STDIN") + try: + self.assertEqual(0, returncode) + self.assertEqual( + proc.stdout.read(), + mitogen.parent.BootstrapProtocol.EC0_MARKER + b("\n"), + ) + self.assertIn( + b("Error -5 while decompressing data"), + proc.stderr.read(), + ) + finally: + proc.stdout.close() + proc.stderr.close() diff --git a/tests/image_prep/hosts.ini b/tests/image_prep/hosts.ini index 254643a7..0c57e914 100644 --- a/tests/image_prep/hosts.ini +++ b/tests/image_prep/hosts.ini @@ -43,6 +43,7 @@ centos7 centos8 debian9 debian10 +ubuntu1604 ubuntu1804 [ansible_11] diff --git a/tests/parent_test.py b/tests/parent_test.py index 558a89b6..dfacffb1 100644 --- a/tests/parent_test.py +++ b/tests/parent_test.py @@ -2,7 +2,6 @@ import errno import fcntl import os import signal -import sys import time import unittest @@ -21,23 +20,6 @@ except NameError: from io import FileIO as file -def wait_for_child(pid, timeout=1.0): - deadline = mitogen.core.now() + timeout - while timeout < mitogen.core.now(): - try: - target_pid, status = os.waitpid(pid, os.WNOHANG) - if target_pid == pid: - return - except OSError: - e = sys.exc_info()[1] - if e.args[0] == errno.ECHILD: - return - - time.sleep(0.05) - - assert False, "wait_for_child() timed out" - - @mitogen.core.takes_econtext def call_func_in_sibling(ctx, econtext, sync_sender): recv = ctx.call_async(time.sleep, 99999) @@ -106,7 +88,7 @@ class ReapChildTest(testlib.RouterMixin, testlib.TestCase): self.assertRaises(mitogen.core.TimeoutError, lambda: conn.connect(context=mitogen.core.Context(None, 1234)) ) - wait_for_child(conn.proc.pid) + testlib.wait_for_child(conn.proc.pid) e = self.assertRaises(OSError, lambda: os.kill(conn.proc.pid, 0) ) @@ -165,7 +147,7 @@ class ContextTest(testlib.RouterMixin, testlib.TestCase): local = self.router.local() pid = local.call(os.getpid) local.shutdown(wait=True) - wait_for_child(pid) + testlib.wait_for_child(pid) self.assertRaises(OSError, lambda: os.kill(pid, 0)) diff --git a/tests/responder_test.py b/tests/responder_test.py index d1e65816..1e376078 100644 --- a/tests/responder_test.py +++ b/tests/responder_test.py @@ -198,21 +198,3 @@ class ForwardTest(testlib.RouterMixin, testlib.TestCase): self.assertEqual(2+os_fork, self.router.responder.good_load_module_count) self.assertLess(10000, self.router.responder.good_load_module_size) self.assertGreater(40000, self.router.responder.good_load_module_size) - - -class BlacklistTest(testlib.TestCase): - @unittest.skip('implement me') - def test_whitelist_no_blacklist(self): - assert 0 - - @unittest.skip('implement me') - def test_whitelist_has_blacklist(self): - assert 0 - - @unittest.skip('implement me') - def test_blacklist_no_whitelist(self): - assert 0 - - @unittest.skip('implement me') - def test_blacklist_has_whitelist(self): - assert 0 diff --git a/tests/testlib.py b/tests/testlib.py index 15016964..868aaa84 100644 --- a/tests/testlib.py +++ b/tests/testlib.py @@ -86,6 +86,23 @@ if faulthandler is not None: mitogen.core.LOG.propagate = True +def wait_for_child(pid, timeout=1.0): + deadline = mitogen.core.now() + timeout + while timeout < mitogen.core.now(): + try: + target_pid, status = os.waitpid(pid, os.WNOHANG) + if target_pid == pid: + return + except OSError: + e = sys.exc_info()[1] + if e.args[0] == errno.ECHILD: + return + + time.sleep(0.05) + + assert False, "wait_for_child() timed out" + + def base_executable(executable=None): '''Return the path of the Python executable used to create the virtualenv. ''' @@ -172,11 +189,11 @@ def _have_cmd(args): def have_python2(): - return _have_cmd(['python2']) + return _have_cmd(['python2', '--version']) def have_python3(): - return _have_cmd(['python3']) + return _have_cmd(['python3', '--version']) def have_sudo_nopassword(): diff --git a/tox.ini b/tox.ini index 28c97496..3ef977ac 100644 --- a/tox.ini +++ b/tox.ini @@ -63,7 +63,7 @@ envlist = py{27,36}-m_ans-ans{2.10,3,4} py{311}-m_ans-ans{2.10,3-5} py{313}-m_ans-ans{6-9} - py{314}-m_ans-ans{10-12} + py{314}-m_ans-ans{10-13} py{27,36,314}-m_mtg report,