[Date Prev][Date Next] [Thread Prev][Thread Next] [Date Index] [Thread Index]

Bug#1111486: bookworm-pu: package python-mitogen/0.3.3-9+deb12u1



Package: release.debian.org
Severity: normal
Tags: bookworm
X-Debbugs-Cc: python-mitogen@packages.debian.org
Control: affects -1 + src:python-mitogen
User: release.debian.org@packages.debian.org
Usertags: pu

[ Reason ]
bookworm systems were unable to use mitogen to operate on trixie systems 
that have Python 3.13. The payload code expected the "imp" module to 
exist.

[ Impact ]
Users are unable to use mitogen (e.g. with ansible) to operate on trixie 
systems.

[ Tests ]
There is an automated test suite. We run as much of it as we can.

danjean manually tested the proposed package and confirmed that it 
works.

[ Risks ]
Changes are from a single upstream commit, with minimal backporting 
required. They are relatively self-contained. I don't recall any issues 
with them, when this change first landed upstream.

mitogen is virtually a leaf package.

[ Checklist ]
  [x] *all* changes are documented in the d/changelog
  [x] I reviewed all changes and I approve them
  [x] attach debdiff against the package in oldstable
  [x] the issue is verified as fixed in unstable

[ Changes ]

 python-mitogen (0.3.3-9+deb12u1) bookworm; urgency=medium
 .
   * Patch: Support targets with Python >= 3.12. (Closes: #1111363)
   * Patch test module paths in testlib.py in the ansible-tests autopkgtest.

The patch adds new mechanisms (using importlib) to find Python modules 
to import. It keeps the old imp-based methods for older Python versions.
diff -Nru python-mitogen-0.3.3/debian/changelog python-mitogen-0.3.3/debian/changelog
--- python-mitogen-0.3.3/debian/changelog	2023-05-13 15:45:14.000000000 +0200
+++ python-mitogen-0.3.3/debian/changelog	2025-08-18 16:33:11.000000000 +0200
@@ -1,3 +1,10 @@
+python-mitogen (0.3.3-9+deb12u1) bookworm; urgency=medium
+
+  * Patch: Support targets with Python >= 3.12. (Closes: #1111363)
+  * Patch test module paths in testlib.py in the ansible-tests autopkgtest.
+
+ -- Stefano Rivera <stefanor@debian.org>  Mon, 18 Aug 2025 16:33:11 +0200
+
 python-mitogen (0.3.3-9) unstable; urgency=medium
 
   * Patch: Use poll() in the broker to handle more file descriptors.
diff -Nru python-mitogen-0.3.3/debian/patches/ansible-6 python-mitogen-0.3.3/debian/patches/ansible-6
--- python-mitogen-0.3.3/debian/patches/ansible-6	2023-05-13 15:45:14.000000000 +0200
+++ python-mitogen-0.3.3/debian/patches/ansible-6	2025-08-18 16:33:11.000000000 +0200
@@ -8,16 +8,18 @@
 Bug-Debian: https://bugs.debian.org/1019501
 Origin: upstream, https://github.com/mitogen-hq/mitogen/pull/933
 ---
- .ci/azure-pipelines.yml    |   60 ++-------------------------------------------
- ansible_mitogen/loaders.py |    2 -
- docs/ansible_detailed.rst  |    2 -
- mitogen/master.py          |    2 -
- tox.ini                    |   18 ++++++-------
+ .ci/azure-pipelines.yml    | 60 +++-------------------------------------------
+ ansible_mitogen/loaders.py |  2 +-
+ docs/ansible_detailed.rst  |  2 +-
+ mitogen/master.py          |  2 +-
+ tox.ini                    | 18 +++++++-------
  5 files changed, 14 insertions(+), 70 deletions(-)
 
---- a/.ci/azure-pipelines.yml	2022-09-17 01:48:54.444253545 -0400
-+++ b/.ci/azure-pipelines.yml	2022-09-17 01:48:54.436253501 -0400
-@@ -33,9 +33,6 @@
+diff --git a/.ci/azure-pipelines.yml b/.ci/azure-pipelines.yml
+index 6f45397..98f4805 100644
+--- a/.ci/azure-pipelines.yml
++++ b/.ci/azure-pipelines.yml
+@@ -33,9 +33,6 @@ jobs:
        Loc_27_210:
          python.version: '2.7'
          tox.env: py27-mode_localhost-ansible2.10
@@ -119,16 +121,18 @@
        Ans_36_4:
          python.version: '3.6'
          tox.env: py36-mode_ansible-ansible4
-@@ -259,3 +202,6 @@
+@@ -259,3 +202,6 @@ jobs:
        Ans_310_5:
          python.version: '3.10'
          tox.env: py310-mode_ansible-ansible5
 +      Ans_310_6:
 +        python.version: '3.10'
 +        tox.env: py310-mode_ansible-ansible6
---- a/ansible_mitogen/loaders.py	2022-09-17 01:48:54.444253545 -0400
-+++ b/ansible_mitogen/loaders.py	2022-09-17 01:48:54.436253501 -0400
-@@ -48,7 +48,7 @@
+diff --git a/ansible_mitogen/loaders.py b/ansible_mitogen/loaders.py
+index cd05fea..20aa499 100644
+--- a/ansible_mitogen/loaders.py
++++ b/ansible_mitogen/loaders.py
+@@ -48,7 +48,7 @@ __all__ = [
  
  
  ANSIBLE_VERSION_MIN = (2, 10)
@@ -137,9 +141,11 @@
  
  NEW_VERSION_MSG = (
      "Your Ansible version (%s) is too recent. The most recent version\n"
---- a/docs/ansible_detailed.rst	2022-09-17 01:48:54.444253545 -0400
-+++ b/docs/ansible_detailed.rst	2022-09-17 01:48:54.436253501 -0400
-@@ -148,7 +148,7 @@
+diff --git a/docs/ansible_detailed.rst b/docs/ansible_detailed.rst
+index d329807..dd569a7 100644
+--- a/docs/ansible_detailed.rst
++++ b/docs/ansible_detailed.rst
+@@ -148,7 +148,7 @@ Noteworthy Differences
  * Mitogen 0.2.x supports Ansible 2.3-2.9; with Python 2.6, 2.7, or 3.6.
    Mitogen 0.3.1+ supports
      - Ansible 2.10, 3, and 4; with Python 2.7, or 3.6-3.10
@@ -161,8 +167,10 @@
              # - get_filename() may throw ImportError if pkgutil.find_loader()
              #   picks a "parent" package's loader for some crap that's been
              #   stuffed in sys.modules, for example in the case of urllib3:
---- a/tox.ini	2022-09-17 01:48:54.444253545 -0400
-+++ b/tox.ini	2022-09-17 01:48:54.436253501 -0400
+diff --git a/tox.ini b/tox.ini
+index 6b2addc..5b01516 100644
+--- a/tox.ini
++++ b/tox.ini
 @@ -26,6 +26,7 @@
  # ansible == 3.*     ansible-base ~= 2.10.0
  # ansible == 4.*     ansible-core ~= 2.11.0
diff -Nru python-mitogen-0.3.3/debian/patches/python3.12-targets python-mitogen-0.3.3/debian/patches/python3.12-targets
--- python-mitogen-0.3.3/debian/patches/python3.12-targets	1970-01-01 01:00:00.000000000 +0100
+++ python-mitogen-0.3.3/debian/patches/python3.12-targets	2025-08-18 16:33:11.000000000 +0200
@@ -0,0 +1,1121 @@
+From: Alex Willmer <alex@moreati.org.uk>
+Date: Sun, 17 Mar 2024 14:55:15 +0000
+Subject: mitogen: Support PEP 451 ModuleSpec API, required for Python 3.12
+
+importlib.machinery.ModuleSpec and find_spec() were introduced in Python 3.4
+under PEP 451. They replace the find_module() API of PEP 302, which was
+deprecated from Python 3.4. They were removed in Python 3.12 along with the
+imp module.
+
+This change adds support for the PEP 451 APIs. Mitogen should no longer import
+imp on Python versions that support ModuleSpec. Tests have been added to cover
+the new APIs.
+
+CI jobs have been added to cover Python 3.x on macOS.
+
+Refs #1033
+Co-authored-by: Witold Baryluk <witold.baryluk@gmail.com>
+Bug-Debain: https://bugs.debian.org/1111363
+---
+ ansible_mitogen/module_finder.py                   | 122 +++++++++++++++-
+ ansible_mitogen/runner.py                          |  91 +++++++++++-
+ docs/internals.rst                                 |   2 +-
+ mitogen/core.py                                    | 153 +++++++++++++++++++--
+ mitogen/master.py                                  | 122 +++++++++++++++-
+ .../connection_delegation/delegate_to_template.yml |   4 +-
+ .../interpreter_discovery/ansible_2_8_tests.yml    |   7 -
+ tests/ansible/lib/modules/module_finder_test.py    |  12 ++
+ tests/ansible/tests/module_finder_test.py          |  80 +++++++++++
+ tests/importer_test.py                             | 126 +++++++++++++++--
+ tests/module_finder_test.py                        |  14 +-
+ tests/testlib.py                                   |   9 +-
+ 12 files changed, 697 insertions(+), 45 deletions(-)
+ create mode 100644 tests/ansible/lib/modules/module_finder_test.py
+ create mode 100644 tests/ansible/tests/module_finder_test.py
+
+diff --git a/ansible_mitogen/module_finder.py b/ansible_mitogen/module_finder.py
+index cec465c..f227c7b 100644
+--- a/ansible_mitogen/module_finder.py
++++ b/ansible_mitogen/module_finder.py
+@@ -31,12 +31,23 @@ from __future__ import unicode_literals
+ __metaclass__ = type
+ 
+ import collections
+-import imp
++import logging
+ import os
++import re
++import sys
++
++try:
++    # Python >= 3.4, PEP 451 ModuleSpec API
++    import importlib.machinery
++    import importlib.util
++except ImportError:
++    # Python < 3.4, PEP 302 Import Hooks
++    import imp
+ 
+ import mitogen.master
+ 
+ 
++LOG = logging.getLogger(__name__)
+ PREFIX = 'ansible.module_utils.'
+ 
+ 
+@@ -119,14 +130,121 @@ def find_relative(parent, name, path=()):
+ 
+ 
+ def scan_fromlist(code):
++    """Return an iterator of (level, name) for explicit imports in a code
++    object.
++
++    Not all names identify a module. `from os import name, path` generates
++    `(0, 'os.name'), (0, 'os.path')`, but `os.name` is usually a string.
++
++    >>> src = 'import a; import b.c; from d.e import f; from g import h, i\\n'
++    >>> code = compile(src, '<str>', 'exec')
++    >>> list(scan_fromlist(code))
++    [(0, 'a'), (0, 'b.c'), (0, 'd.e.f'), (0, 'g.h'), (0, 'g.i')]
++    """
+     for level, modname_s, fromlist in mitogen.master.scan_code_imports(code):
+         for name in fromlist:
+-            yield level, '%s.%s' % (modname_s, name)
++            yield level, str('%s.%s' % (modname_s, name))
+         if not fromlist:
+             yield level, modname_s
+ 
+ 
++def walk_imports(code, prefix=None):
++    """Return an iterator of names for implicit parent imports & explicit
++    imports in a code object.
++
++    If a prefix is provided, then only children of that prefix are included.
++    Not all names identify a module. `from os import name, path` generates
++    `'os', 'os.name', 'os.path'`, but `os.name` is usually a string.
++
++    >>> source = 'import a; import b; import b.c; from b.d import e, f\\n'
++    >>> code = compile(source, '<str>', 'exec')
++    >>> list(walk_imports(code))
++    ['a', 'b', 'b', 'b.c', 'b', 'b.d', 'b.d.e', 'b.d.f']
++    >>> list(walk_imports(code, prefix='b'))
++    ['b.c', 'b.d', 'b.d.e', 'b.d.f']
++    """
++    if prefix is None:
++        prefix = ''
++    pattern = re.compile(r'(^|\.)(\w+)')
++    start = len(prefix)
++    for _, name, fromlist in mitogen.master.scan_code_imports(code):
++        if not name.startswith(prefix):
++            continue
++        for match in pattern.finditer(name, start):
++            yield name[:match.end()]
++        for leaf in fromlist:
++            yield str('%s.%s' % (name, leaf))
++
++
+ def scan(module_name, module_path, search_path):
++    # type: (str, str, list[str]) -> list[(str, str, bool)]
++    """Return a list of (name, path, is_package) for ansible.module_utils
++    imports used by an Ansible module.
++    """
++    log = LOG.getChild('scan')
++    log.debug('%r, %r, %r', module_name, module_path, search_path)
++
++    if sys.version_info >= (3, 4):
++        result = _scan_importlib_find_spec(
++            module_name, module_path, search_path,
++        )
++        log.debug('_scan_importlib_find_spec %r', result)
++    else:
++        result = _scan_imp_find_module(module_name, module_path, search_path)
++        log.debug('_scan_imp_find_module %r', result)
++    return result
++
++
++def _scan_importlib_find_spec(module_name, module_path, search_path):
++    # type: (str, str, list[str]) -> list[(str, str, bool)]
++    module = importlib.machinery.ModuleSpec(
++        module_name, loader=None, origin=module_path,
++    )
++    prefix = importlib.machinery.ModuleSpec(
++        PREFIX.rstrip('.'), loader=None,
++    )
++    prefix.submodule_search_locations = search_path
++    queue = collections.deque([module])
++    specs = {prefix.name: prefix}
++    while queue:
++        spec = queue.popleft()
++        if spec.origin is None:
++            continue
++        try:
++            with open(spec.origin, 'rb') as f:
++                code = compile(f.read(), spec.name, 'exec')
++        except Exception as exc:
++            raise ValueError((exc, module, spec, specs))
++
++        for name in walk_imports(code, prefix.name):
++            if name in specs:
++                continue
++
++            parent_name = name.rpartition('.')[0]
++            parent = specs[parent_name]
++            if parent is None or not parent.submodule_search_locations:
++                specs[name] = None
++                continue
++
++            child = importlib.util._find_spec(
++                name, parent.submodule_search_locations,
++            )
++            if child is None or child.origin is None:
++                specs[name] = None
++                continue
++
++            specs[name] = child
++            queue.append(child)
++
++    del specs[prefix.name]
++    return sorted(
++        (spec.name, spec.origin, spec.submodule_search_locations is not None)
++        for spec in specs.values() if spec is not None
++    )
++
++
++def _scan_imp_find_module(module_name, module_path, search_path):
++    # type: (str, str, list[str]) -> list[(str, str, bool)]
+     module = Module(module_name, module_path, imp.PY_SOURCE, None)
+     stack = [module]
+     seen = set()
+diff --git a/ansible_mitogen/runner.py b/ansible_mitogen/runner.py
+index 31ccf1c..4306b0b 100644
+--- a/ansible_mitogen/runner.py
++++ b/ansible_mitogen/runner.py
+@@ -40,7 +40,6 @@ from __future__ import absolute_import, division, print_function
+ __metaclass__ = type
+ 
+ import atexit
+-import imp
+ import os
+ import re
+ import shlex
+@@ -63,6 +62,14 @@ except ImportError:
+     # Python 2.4
+     ctypes = None
+ 
++try:
++    # Python >= 3.4, PEP 451 ModuleSpec API
++    import importlib.machinery
++    import importlib.util
++except ImportError:
++    # Python < 3.4, PEP 302 Import Hooks
++    import imp
++
+ try:
+     import json
+ except ImportError:
+@@ -519,10 +526,71 @@ class ModuleUtilsImporter(object):
+             sys.modules.pop(fullname, None)
+ 
+     def find_module(self, fullname, path=None):
++        """
++        Return a loader for the module with fullname, if we will load it.
++
++        Implements importlib.abc.MetaPathFinder.find_module().
++        Deprecrated in Python 3.4+, replaced by find_spec().
++        Raises ImportWarning in Python 3.10+. Removed in Python 3.12.
++        """
+         if fullname in self._by_fullname:
+             return self
+ 
++    def find_spec(self, fullname, path, target=None):
++        """
++        Return a `ModuleSpec` for module with `fullname` if we will load it.
++        Otherwise return `None`.
++
++        Implements importlib.abc.MetaPathFinder.find_spec(). Python 3.4+.
++        """
++        if fullname.endswith('.'):
++            return None
++
++        try:
++            module_path, is_package = self._by_fullname[fullname]
++        except KeyError:
++            LOG.debug('Skipping %s: not present', fullname)
++            return None
++
++        LOG.debug('Handling %s', fullname)
++        origin = 'master:%s' % (module_path,)
++        return importlib.machinery.ModuleSpec(
++            fullname, loader=self, origin=origin, is_package=is_package,
++        )
++
++    def create_module(self, spec):
++        """
++        Return a module object for the given ModuleSpec.
++
++        Implements PEP-451 importlib.abc.Loader API introduced in Python 3.4.
++        Unlike Loader.load_module() this shouldn't populate sys.modules or
++        set module attributes. Both are done by Python.
++        """
++        module = types.ModuleType(spec.name)
++        # FIXME create_module() shouldn't initialise module attributes
++        module.__file__ = spec.origin
++        return module
++
++    def exec_module(self, module):
++        """
++        Execute the module to initialise it. Don't return anything.
++
++        Implements PEP-451 importlib.abc.Loader API, introduced in Python 3.4.
++        """
++        spec = module.__spec__
++        path, _ = self._by_fullname[spec.name]
++        source = ansible_mitogen.target.get_small_file(self._context, path)
++        code = compile(source, path, 'exec', 0, 1)
++        exec(code, module.__dict__)
++        self._loaded.add(spec.name)
++
+     def load_module(self, fullname):
++        """
++        Return the loaded module specified by fullname.
++
++        Implements PEP 302 importlib.abc.Loader.load_module().
++        Deprecated in Python 3.4+, replaced by create_module() & exec_module().
++        """
+         path, is_pkg = self._by_fullname[fullname]
+         source = ansible_mitogen.target.get_small_file(self._context, path)
+         code = compile(source, path, 'exec', 0, 1)
+@@ -823,12 +891,17 @@ class NewStyleRunner(ScriptRunner):
+         synchronization mechanism by importing everything the module will need
+         prior to detaching.
+         """
++        # I think "custom" means "found in custom module_utils search path",
++        # e.g. playbook relative dir, ~/.ansible/..., Ansible collection.
+         for fullname, _, _ in self.module_map['custom']:
+             mitogen.core.import_module(fullname)
++
++        # I think "builtin" means "part of ansible/ansible-base/ansible-core",
++        # as opposed to Python builtin modules such as sys.
+         for fullname in self.module_map['builtin']:
+             try:
+                 mitogen.core.import_module(fullname)
+-            except ImportError:
++            except ImportError as exc:
+                 # #590: Ansible 2.8 module_utils.distro is a package that
+                 # replaces itself in sys.modules with a non-package during
+                 # import. Prior to replacement, it is a real package containing
+@@ -839,8 +912,18 @@ class NewStyleRunner(ScriptRunner):
+                 # loop progresses to the next entry and attempts to preload
+                 # 'distro._distro', the import mechanism will fail. So here we
+                 # silently ignore any failure for it.
+-                if fullname != 'ansible.module_utils.distro._distro':
+-                    raise
++                if fullname == 'ansible.module_utils.distro._distro':
++                    continue
++
++                # ansible.module_utils.compat.selinux raises ImportError if it
++                # can't load libselinux.so. The importer would usually catch
++                # this & skip selinux operations. We don't care about selinux,
++                # we're using import to get a copy of the module.
++                if (fullname == 'ansible.module_utils.compat.selinux'
++                    and exc.msg == 'unable to load libselinux.so'):
++                    continue
++
++                raise
+ 
+     def _setup_excepthook(self):
+         """
+diff --git a/docs/internals.rst b/docs/internals.rst
+index 7f44d7b..434a74f 100644
+--- a/docs/internals.rst
++++ b/docs/internals.rst
+@@ -174,7 +174,7 @@ Module Finders
+    :members:
+ 
+ .. currentmodule:: mitogen.master
+-.. autoclass:: ParentEnumerationMethod
++.. autoclass:: ParentImpEnumerationMethod
+    :members:
+ 
+ 
+diff --git a/mitogen/core.py b/mitogen/core.py
+index bee722e..253fb07 100644
+--- a/mitogen/core.py
++++ b/mitogen/core.py
+@@ -54,13 +54,18 @@ import syslog
+ import threading
+ import time
+ import traceback
++import types
+ import warnings
+ import weakref
+ import zlib
+ 
+-# Python >3.7 deprecated the imp module.
+-warnings.filterwarnings('ignore', message='the imp module is deprecated')
+-import imp
++try:
++    # Python >= 3.4, PEP 451 ModuleSpec API
++    import importlib.machinery
++    import importlib.util
++except ImportError:
++    # Python < 3.4, PEP 302 Import Hooks
++    import imp
+ 
+ # Absolute imports for <2.5.
+ select = __import__('select')
+@@ -1353,6 +1358,19 @@ class Importer(object):
+     def __repr__(self):
+         return 'Importer'
+ 
++    @staticmethod
++    def _loader_from_module(module, default=None):
++        """Return the loader for a module object."""
++        try:
++            return module.__spec__.loader
++        except AttributeError:
++            pass
++        try:
++            return module.__loader__
++        except AttributeError:
++            pass
++        return default
++
+     def builtin_find_module(self, fullname):
+         # imp.find_module() will always succeed for __main__, because it is a
+         # built-in module. That means it exists on a special linked list deep
+@@ -1388,14 +1406,13 @@ class Importer(object):
+         try:
+             #_v and self._log.debug('Python requested %r', fullname)
+             fullname = to_text(fullname)
+-            pkgname, dot, _ = str_rpartition(fullname, '.')
++            pkgname, _, suffix = str_rpartition(fullname, '.')
+             pkg = sys.modules.get(pkgname)
+             if pkgname and getattr(pkg, '__loader__', None) is not self:
+                 self._log.debug('%s is submodule of a locally loaded package',
+                                 fullname)
+                 return None
+ 
+-            suffix = fullname[len(pkgname+dot):]
+             if pkgname and suffix not in self._present.get(pkgname, ()):
+                 self._log.debug('%s has no submodule %s', pkgname, suffix)
+                 return None
+@@ -1415,6 +1432,66 @@ class Importer(object):
+         finally:
+             del _tls.running
+ 
++    def find_spec(self, fullname, path, target=None):
++        """
++        Return a `ModuleSpec` for module with `fullname` if we will load it.
++        Otherwise return `None`, allowing other finders to try.
++
++        fullname    Fully qualified name of the module (e.g. foo.bar.baz)
++        path        Path entries to search. None for a top-level module.
++        target      Existing module to be reloaded (if any).
++
++        Implements importlib.abc.MetaPathFinder.find_spec()
++        Python 3.4+.
++        """
++        # Presence of _tls.running indicates we've re-invoked importlib.
++        # Abort early to prevent infinite recursion. See below.
++        if hasattr(_tls, 'running'):
++            return None
++
++        log = self._log.getChild('find_spec')
++
++        if fullname.endswith('.'):
++            return None
++
++        pkgname, _, modname = fullname.rpartition('.')
++        if pkgname and modname not in self._present.get(pkgname, ()):
++            log.debug('Skipping %s. Parent %s has no submodule %s',
++                      fullname, pkgname, modname)
++            return None
++
++        pkg = sys.modules.get(pkgname)
++        pkg_loader = self._loader_from_module(pkg)
++        if pkgname and pkg_loader is not self:
++            log.debug('Skipping %s. Parent %s was loaded by %r',
++                      fullname, pkgname, pkg_loader)
++            return None
++
++        # #114: whitelisted prefixes override any system-installed package.
++        if self.whitelist != ['']:
++            if any(s and fullname.startswith(s) for s in self.whitelist):
++                log.debug('Handling %s. It is whitelisted', fullname)
++                return importlib.machinery.ModuleSpec(fullname, loader=self)
++
++        if fullname == '__main__':
++            log.debug('Handling %s. A special case', fullname)
++            return importlib.machinery.ModuleSpec(fullname, loader=self)
++
++        # Re-invoke the import machinery to allow other finders to try.
++        # Set a guard, so we don't infinitely recurse. See top of this method.
++        _tls.running = True
++        try:
++            spec = importlib.util._find_spec(fullname, path, target)
++        finally:
++            del _tls.running
++
++        if spec:
++            log.debug('Skipping %s. Available as %r', fullname, spec)
++            return spec
++
++        log.debug('Handling %s. Unavailable locally', fullname)
++        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 '
+@@ -1501,6 +1578,64 @@ class Importer(object):
+         if present:
+             callback()
+ 
++    def create_module(self, spec):
++        """
++        Return a module object for the given ModuleSpec.
++
++        Implements PEP-451 importlib.abc.Loader API introduced in Python 3.4.
++        Unlike Loader.load_module() this shouldn't populate sys.modules or
++        set module attributes. Both are done by Python.
++        """
++        self._log.debug('Creating module for %r', spec)
++
++        # FIXME Should this be done in find_spec()? Can it?
++        self._refuse_imports(spec.name)
++
++        # FIXME "create_module() should properly handle the case where it is
++        #       called more than once for the same spec/module." -- PEP-451
++        event = threading.Event()
++        self._request_module(spec.name, callback=event.set)
++        event.wait()
++
++        # 0:fullname 1:pkg_present 2:path 3:compressed 4:related
++        _, pkg_present, path, _, _ = self._cache[spec.name]
++
++        if path is None:
++            raise ImportError(self.absent_msg % (spec.name))
++
++        spec.origin = self.get_filename(spec.name)
++        if pkg_present is not None:
++            # TODO Namespace packages
++            spec.submodule_search_locations = []
++            self._present[spec.name] = pkg_present
++
++        module = types.ModuleType(spec.name)
++        # FIXME create_module() shouldn't initialise module attributes
++        module.__file__ = spec.origin
++        return module
++
++    def exec_module(self, module):
++        """
++        Execute the module to initialise it. Don't return anything.
++
++        Implements PEP-451 importlib.abc.Loader API, introduced in Python 3.4.
++        """
++        name = module.__spec__.name
++        origin = module.__spec__.origin
++        self._log.debug('Executing %s from %s', name, origin)
++        source = self.get_source(name)
++        try:
++            # Compile the source into a code object. Don't add any __future__
++            # flags and don't inherit any from this module.
++            # FIXME Should probably be exposed as get_code()
++            code = compile(source, origin, 'exec', flags=0, dont_inherit=True)
++        except SyntaxError:
++            # FIXME Why is this LOG, rather than self._log?
++            LOG.exception('while importing %r', name)
++            raise
++
++        exec(code, module.__dict__)
++
+     def load_module(self, fullname):
+         """
+         Return the loaded module specified by fullname.
+@@ -1516,11 +1651,11 @@ class Importer(object):
+         self._request_module(fullname, event.set)
+         event.wait()
+ 
+-        ret = self._cache[fullname]
+-        if ret[2] is None:
++        # 0:fullname 1:pkg_present 2:path 3:compressed 4:related
++        _, pkg_present, path, _, _ = self._cache[fullname]
++        if path is None:
+             raise ModuleNotFoundError(self.absent_msg % (fullname,))
+ 
+-        pkg_present = ret[1]
+         mod = sys.modules.setdefault(fullname, imp.new_module(fullname))
+         mod.__file__ = self.get_filename(fullname)
+         mod.__loader__ = self
+@@ -3921,7 +4056,7 @@ class ExternalContext(object):
+ 
+     def _setup_package(self):
+         global mitogen
+-        mitogen = imp.new_module('mitogen')
++        mitogen = types.ModuleType('mitogen')
+         mitogen.__package__ = 'mitogen'
+         mitogen.__path__ = []
+         mitogen.__loader__ = self.importer
+diff --git a/mitogen/master.py b/mitogen/master.py
+index 4fb535f..9b5c2ff 100644
+--- a/mitogen/master.py
++++ b/mitogen/master.py
+@@ -37,7 +37,6 @@ contexts.
+ 
+ import dis
+ import errno
+-import imp
+ import inspect
+ import itertools
+ import logging
+@@ -50,6 +49,16 @@ import threading
+ import types
+ import zlib
+ 
++try:
++    # Python >= 3.4, PEP 451 ModuleSpec API
++    import importlib.machinery
++    import importlib.util
++    from _imp import is_builtin as _is_builtin
++except ImportError:
++    # Python < 3.4, PEP 302 Import Hooks
++    import imp
++    from imp import is_builtin as _is_builtin
++
+ try:
+     import sysconfig
+ except ImportError:
+@@ -122,14 +131,14 @@ def is_stdlib_name(modname):
+     """
+     Return :data:`True` if `modname` appears to come from the standard library.
+     """
+-    # `imp.is_builtin()` isn't a documented as part of Python's stdlib API.
++    # `(_imp|imp).is_builtin()` isn't a documented part of Python's stdlib.
+     #
+     # """
+     # Main is a little special - imp.is_builtin("__main__") will return False,
+     # but BuiltinImporter is still the most appropriate initial setting for
+     # its __loader__ attribute.
+     # """ -- comment in CPython pylifecycle.c:add_main_module()
+-    if imp.is_builtin(modname) != 0:
++    if _is_builtin(modname) != 0:
+         return True
+ 
+     module = sys.modules.get(modname)
+@@ -460,6 +469,9 @@ class FinderMethod(object):
+     name according to the running Python interpreter. You'd think this was a
+     simple task, right? Naive young fellow, welcome to the real world.
+     """
++    def __init__(self):
++        self.log = LOG.getChild(self.__class__.__name__)
++
+     def __repr__(self):
+         return '%s()' % (type(self).__name__,)
+ 
+@@ -641,7 +653,7 @@ class SysModulesMethod(FinderMethod):
+         return path, source, is_pkg
+ 
+ 
+-class ParentEnumerationMethod(FinderMethod):
++class ParentImpEnumerationMethod(FinderMethod):
+     """
+     Attempt to fetch source code by examining the module's (hopefully less
+     insane) parent package, and if no insane parents exist, simply use
+@@ -668,9 +680,9 @@ class ParentEnumerationMethod(FinderMethod):
+     @staticmethod
+     def _iter_parents(fullname):
+         """
+-        >>> list(ParentEnumerationMethod._iter_parents('a'))
++        >>> list(ParentImpEnumerationMethod._iter_parents('a'))
+         [('', 'a')]
+-        >>> list(ParentEnumerationMethod._iter_parents('a.b.c'))
++        >>> list(ParentImpEnumerationMethod._iter_parents('a.b.c'))
+         [('a.b', 'c'), ('a', 'b'), ('', 'a')]
+         """
+         while fullname:
+@@ -770,6 +782,9 @@ class ParentEnumerationMethod(FinderMethod):
+         """
+         See implementation for a description of how this works.
+         """
++        if sys.version_info >= (3, 4):
++            return None
++
+         #if fullname not in sys.modules:
+             # Don't attempt this unless a module really exists in sys.modules,
+             # else we could return junk.
+@@ -798,6 +813,98 @@ class ParentEnumerationMethod(FinderMethod):
+                 return self._found_module(fullname, path, fp)
+ 
+ 
++class ParentSpecEnumerationMethod(ParentImpEnumerationMethod):
++    def _find_parent_spec(self, fullname):
++        #history = []
++        debug = self.log.debug
++        children = []
++        for parent_name, child_name in self._iter_parents(fullname):
++            children.insert(0, child_name)
++            if not parent_name:
++                debug('abandoning %r, reached top-level', fullname)
++                return None, children
++
++            try:
++                parent = sys.modules[parent_name]
++            except KeyError:
++                debug('skipping %r, not in sys.modules', parent_name)
++                continue
++
++            try:
++                spec = parent.__spec__
++            except AttributeError:
++                debug('skipping %r: %r.__spec__ is absent',
++                      parent_name, parent)
++                continue
++
++            if not spec:
++                debug('skipping %r: %r.__spec__=%r',
++                      parent_name, parent, spec)
++                continue
++
++            if spec.name != parent_name:
++                debug('skipping %r: %r.__spec__.name=%r does not match',
++                      parent_name, parent, spec.name)
++                continue
++
++            if not spec.submodule_search_locations:
++                debug('skipping %r: %r.__spec__.submodule_search_locations=%r',
++                      parent_name, parent, spec.submodule_search_locations)
++                continue
++
++            return spec, children
++
++        raise ValueError('%s._find_parent_spec(%r) unexpectedly reached bottom'
++                         % (self.__class__.__name__, fullname))
++
++    def find(self, fullname):
++        # Returns absolute path, ParentImpEnumerationMethod returns relative
++        # >>> spec_pem.find('six_brokenpkg._six')[::2]
++        # ('/Users/alex/src/mitogen/tests/data/importer/six_brokenpkg/_six.py', False)
++
++        if sys.version_info < (3, 4):
++            return None
++
++        fullname = to_text(fullname)
++        spec, children = self._find_parent_spec(fullname)
++        for child_name in children:
++            if spec:
++                name = '%s.%s' % (spec.name, child_name)
++                submodule_search_locations = spec.submodule_search_locations
++            else:
++                name = child_name
++                submodule_search_locations = None
++            spec = importlib.util._find_spec(name, submodule_search_locations)
++            if spec is None:
++                self.log.debug('%r spec unavailable from %s', fullname, spec)
++                return None
++
++            is_package = spec.submodule_search_locations is not None
++            if name != fullname:
++                if not is_package:
++                    self.log.debug('%r appears to be child of non-package %r',
++                                   fullname, spec)
++                    return None
++                continue
++
++            if not spec.has_location:
++                self.log.debug('%r.origin cannot be read as a file', spec)
++                return None
++
++            if os.path.splitext(spec.origin)[1] != '.py':
++                self.log.debug('%r.origin does not contain Python source code',
++                               spec)
++                return None
++
++            # FIXME This should use loader.get_source()
++            with open(spec.origin, 'rb') as f:
++                source = f.read()
++
++            return spec.origin, source, is_package
++
++        raise ValueError('%s.find(%r) unexpectedly reached bottom'
++                         % (self.__class__.__name__, fullname))
++
+ class ModuleFinder(object):
+     """
+     Given the name of a loaded module, make a best-effort attempt at finding
+@@ -838,7 +945,8 @@ class ModuleFinder(object):
+         DefectivePython3xMainMethod(),
+         PkgutilMethod(),
+         SysModulesMethod(),
+-        ParentEnumerationMethod(),
++        ParentSpecEnumerationMethod(),
++        ParentImpEnumerationMethod(),
+     ]
+ 
+     def get_module_source(self, fullname):
+diff --git a/tests/ansible/integration/connection_delegation/delegate_to_template.yml b/tests/ansible/integration/connection_delegation/delegate_to_template.yml
+index be083ff..357c485 100644
+--- a/tests/ansible/integration/connection_delegation/delegate_to_template.yml
++++ b/tests/ansible/integration/connection_delegation/delegate_to_template.yml
+@@ -42,7 +42,7 @@
+               'keepalive_count': 10,
+               'password': null,
+               'port': null,
+-              'python_path': ["/usr/bin/python"],
++              'python_path': ["{{ ansible_facts.discovered_interpreter_python | default('/usr/bin/python') }}"],
+               'remote_name': null,
+               'ssh_args': [
+                 '-o',
+@@ -72,7 +72,7 @@
+               'keepalive_count': 10,
+               'password': null,
+               'port': null,
+-              'python_path': ["/usr/bin/python"],
++              'python_path': ["{{ ansible_facts.discovered_interpreter_python | default('/usr/bin/python') }}"],
+               'remote_name': null,
+               'ssh_args': [
+                 '-o',
+diff --git a/tests/ansible/integration/interpreter_discovery/ansible_2_8_tests.yml b/tests/ansible/integration/interpreter_discovery/ansible_2_8_tests.yml
+index 201ef8b..fa9baba 100644
+--- a/tests/ansible/integration/interpreter_discovery/ansible_2_8_tests.yml
++++ b/tests/ansible/integration/interpreter_discovery/ansible_2_8_tests.yml
+@@ -190,13 +190,6 @@
+               - distro == 'ubuntu'
+               - distro_version is version('16.04', '>=', strict=True)
+ 
+-          - name: mac assertions
+-            assert:
+-              that:
+-              - auto_out.ansible_facts.discovered_interpreter_python == '/usr/bin/python'
+-              fail_msg: auto_out={{auto_out}}
+-            when: os_family == 'Darwin'
+-
+           always:
+           - meta: clear_facts
+       when:
+diff --git a/tests/ansible/lib/modules/module_finder_test.py b/tests/ansible/lib/modules/module_finder_test.py
+new file mode 100644
+index 0000000..41cf1c1
+--- /dev/null
++++ b/tests/ansible/lib/modules/module_finder_test.py
+@@ -0,0 +1,12 @@
++from __future__ import absolute_import, division, print_function
++__metaclass__ = type
++
++import os
++import sys
++
++import ansible.module_utils.external1
++
++from ansible.module_utils.externalpkg.extmod import path as epem_path
++
++def main():
++    pass
+diff --git a/tests/ansible/tests/module_finder_test.py b/tests/ansible/tests/module_finder_test.py
+new file mode 100644
+index 0000000..79e8fdb
+--- /dev/null
++++ b/tests/ansible/tests/module_finder_test.py
+@@ -0,0 +1,80 @@
++import os.path
++import sys
++import textwrap
++import unittest
++
++import ansible_mitogen.module_finder
++
++import testlib
++
++
++class ScanFromListTest(testlib.TestCase):
++    def test_absolute_imports(self):
++        source = textwrap.dedent('''\
++            from __future__ import absolute_import
++            import a; import b.c; from d.e import f; from g import h, i
++        ''')
++        code = compile(source, '<str>', 'exec')
++        self.assertEqual(
++            list(ansible_mitogen.module_finder.scan_fromlist(code)),
++            [(0, '__future__.absolute_import'), (0, 'a'), (0, 'b.c'), (0, 'd.e.f'), (0, 'g.h'), (0, 'g.i')],
++        )
++
++
++class WalkImportsTest(testlib.TestCase):
++    def test_absolute_imports(self):
++        source = textwrap.dedent('''\
++            from __future__ import absolute_import
++            import a; import b; import b.c; from b.d import e, f
++        ''')
++        code = compile(source, '<str>', 'exec')
++
++        self.assertEqual(
++            list(ansible_mitogen.module_finder.walk_imports(code)),
++            ['__future__', '__future__.absolute_import', 'a', 'b', 'b', 'b.c', 'b', 'b.d', 'b.d.e', 'b.d.f'],
++        )
++        self.assertEqual(
++            list(ansible_mitogen.module_finder.walk_imports(code, prefix='b')),
++            ['b.c', 'b.d', 'b.d.e', 'b.d.f'],
++        )
++
++
++class ScanTest(testlib.TestCase):
++    module_name = 'ansible_module_module_finder_test__this_should_not_matter'
++    module_path = os.path.join(testlib.ANSIBLE_MODULES_DIR, 'module_finder_test.py')
++    search_path = (
++        'does_not_exist/module_utils',
++        testlib.ANSIBLE_MODULE_UTILS_DIR,
++    )
++
++    @staticmethod
++    def relpath(path):
++        return os.path.relpath(path, testlib.ANSIBLE_MODULE_UTILS_DIR)
++
++    @unittest.skipIf(sys.version_info < (3, 4), 'find spec() unavailable')
++    def test_importlib_find_spec(self):
++        scan = ansible_mitogen.module_finder._scan_importlib_find_spec
++        actual = scan(self.module_name, self.module_path, self.search_path)
++        self.assertEqual(
++            [(name, self.relpath(path), is_pkg) for name, path, is_pkg in actual],
++            [
++                ('ansible.module_utils.external1', 'external1.py', False),
++                ('ansible.module_utils.external2', 'external2.py', False),
++                ('ansible.module_utils.externalpkg', 'externalpkg/__init__.py', True),
++                ('ansible.module_utils.externalpkg.extmod', 'externalpkg/extmod.py',False),
++            ],
++        )
++
++    @unittest.skipIf(sys.version_info >= (3, 4), 'find spec() preferred')
++    def test_imp_find_module(self):
++        scan = ansible_mitogen.module_finder._scan_imp_find_module
++        actual = scan(self.module_name, self.module_path, self.search_path)
++        self.assertEqual(
++            [(name, self.relpath(path), is_pkg) for name, path, is_pkg in actual],
++            [
++                ('ansible.module_utils.external1', 'external1.py', False),
++                ('ansible.module_utils.external2', 'external2.py', False),
++                ('ansible.module_utils.externalpkg', 'externalpkg/__init__.py', True),
++                ('ansible.module_utils.externalpkg.extmod', 'externalpkg/extmod.py',False),
++            ],
++        )
+diff --git a/tests/importer_test.py b/tests/importer_test.py
+index e48c02a..e86af8a 100644
+--- a/tests/importer_test.py
++++ b/tests/importer_test.py
+@@ -2,6 +2,7 @@ import sys
+ import threading
+ import types
+ import zlib
++import unittest
+ 
+ import mock
+ 
+@@ -42,6 +43,49 @@ class ImporterMixin(testlib.RouterMixin):
+         super(ImporterMixin, self).tearDown()
+ 
+ 
++class InvalidNameTest(ImporterMixin, testlib.TestCase):
++    modname = 'trailingdot.'
++    # 0:fullname 1:pkg_present 2:path 3:compressed 4:related
++    response = (modname, None, None, None, None)
++
++    @unittest.skipIf(sys.version_info < (3, 4), 'Requires ModuleSpec, Python 3.4+')
++    def test_find_spec_invalid(self):
++        self.set_get_module_response(self.response)
++        self.assertEqual(self.importer.find_spec(self.modname, path=None), None)
++
++
++class MissingModuleTest(ImporterMixin, testlib.TestCase):
++    modname = 'missing'
++    # 0:fullname 1:pkg_present 2:path 3:compressed 4:related
++    response = (modname, None, None, None, None)
++
++    @unittest.skipIf(sys.version_info >= (3, 4), 'Superceded in Python 3.4+')
++    def test_load_module_missing(self):
++        self.set_get_module_response(self.response)
++        self.assertRaises(ImportError, self.importer.load_module, self.modname)
++
++    @unittest.skipIf(sys.version_info < (3, 4), 'Requires ModuleSpec, Python 3.4+')
++    def test_find_spec_missing(self):
++        """
++        Importer should optimistically offer itself as a module loader
++        when there are no disqualifying criteria.
++        """
++        import importlib.machinery
++        self.set_get_module_response(self.response)
++        spec = self.importer.find_spec(self.modname, path=None)
++        self.assertIsInstance(spec, importlib.machinery.ModuleSpec)
++        self.assertEqual(spec.name, self.modname)
++        self.assertEqual(spec.loader, self.importer)
++
++    @unittest.skipIf(sys.version_info < (3, 4), 'Requires ModuleSpec, Python 3.4+')
++    def test_create_module_missing(self):
++        import importlib.machinery
++        self.set_get_module_response(self.response)
++        spec = importlib.machinery.ModuleSpec(self.modname, self.importer)
++        self.assertRaises(ImportError, self.importer.create_module, spec)
++
++
++@unittest.skipIf(sys.version_info >= (3, 4), 'Superceded in Python 3.4+')
+ class LoadModuleTest(ImporterMixin, testlib.TestCase):
+     data = zlib.compress(b("data = 1\n\n"))
+     path = 'fake_module.py'
+@@ -50,14 +94,6 @@ class LoadModuleTest(ImporterMixin, testlib.TestCase):
+     # 0:fullname 1:pkg_present 2:path 3:compressed 4:related
+     response = (modname, None, path, data, [])
+ 
+-    def test_no_such_module(self):
+-        self.set_get_module_response(
+-            # 0:fullname 1:pkg_present 2:path 3:compressed 4:related
+-            (self.modname, None, None, None, None)
+-        )
+-        self.assertRaises(ImportError,
+-            lambda: self.importer.load_module(self.modname))
+-
+     def test_module_added_to_sys_modules(self):
+         self.set_get_module_response(self.response)
+         mod = self.importer.load_module(self.modname)
+@@ -80,6 +116,26 @@ class LoadModuleTest(ImporterMixin, testlib.TestCase):
+         self.assertIsNone(mod.__package__)
+ 
+ 
++@unittest.skipIf(sys.version_info < (3, 4), 'Requires ModuleSpec, Python 3.4+')
++class ModuleSpecTest(ImporterMixin, testlib.TestCase):
++    data = zlib.compress(b("data = 1\n\n"))
++    path = 'fake_module.py'
++    modname = 'fake_module'
++
++    # 0:fullname 1:pkg_present 2:path 3:compressed 4:related
++    response = (modname, None, path, data, [])
++
++    def test_module_attributes(self):
++        import importlib.machinery
++        self.set_get_module_response(self.response)
++        spec = importlib.machinery.ModuleSpec(self.modname, self.importer)
++        mod = self.importer.create_module(spec)
++        self.assertIsInstance(mod, types.ModuleType)
++        self.assertEqual(mod.__name__, 'fake_module')
++        #self.assertFalse(hasattr(mod, '__file__'))
++
++
++@unittest.skipIf(sys.version_info >= (3, 4), 'Superceded in Python 3.4+')
+ class LoadSubmoduleTest(ImporterMixin, testlib.TestCase):
+     data = zlib.compress(b("data = 1\n\n"))
+     path = 'fake_module.py'
+@@ -93,6 +149,25 @@ class LoadSubmoduleTest(ImporterMixin, testlib.TestCase):
+         self.assertEqual(mod.__package__, 'mypkg')
+ 
+ 
++@unittest.skipIf(sys.version_info < (3, 4), 'Requires ModuleSpec, Python 3.4+')
++class SubmoduleSpecTest(ImporterMixin, testlib.TestCase):
++    data = zlib.compress(b("data = 1\n\n"))
++    path = 'fake_module.py'
++    modname = 'mypkg.fake_module'
++    # 0:fullname 1:pkg_present 2:path 3:compressed 4:related
++    response = (modname, None, path, data, [])
++
++    def test_module_attributes(self):
++        import importlib.machinery
++        self.set_get_module_response(self.response)
++        spec = importlib.machinery.ModuleSpec(self.modname, self.importer)
++        mod = self.importer.create_module(spec)
++        self.assertIsInstance(mod, types.ModuleType)
++        self.assertEqual(mod.__name__, 'mypkg.fake_module')
++        #self.assertFalse(hasattr(mod, '__file__'))
++
++
++@unittest.skipIf(sys.version_info >= (3, 4), 'Superceded in Python 3.4+')
+ class LoadModulePackageTest(ImporterMixin, testlib.TestCase):
+     data = zlib.compress(b("func = lambda: 1\n\n"))
+     path = 'fake_pkg/__init__.py'
+@@ -140,6 +215,41 @@ class LoadModulePackageTest(ImporterMixin, testlib.TestCase):
+         self.assertEqual(mod.func.__module__, self.modname)
+ 
+ 
++@unittest.skipIf(sys.version_info < (3, 4), 'Requires ModuleSpec, Python 3.4+')
++class PackageSpecTest(ImporterMixin, testlib.TestCase):
++    data = zlib.compress(b("func = lambda: 1\n\n"))
++    path = 'fake_pkg/__init__.py'
++    modname = 'fake_pkg'
++    # 0:fullname 1:pkg_present 2:path 3:compressed 4:related
++    response = (modname, [], path, data, [])
++
++    def test_module_attributes(self):
++        import importlib.machinery
++        self.set_get_module_response(self.response)
++        spec = importlib.machinery.ModuleSpec(self.modname, self.importer)
++        mod = self.importer.create_module(spec)
++        self.assertIsInstance(mod, types.ModuleType)
++        self.assertEqual(mod.__name__, 'fake_pkg')
++        #self.assertFalse(hasattr(mod, '__file__'))
++
++    def test_get_filename(self):
++        import importlib.machinery
++        self.set_get_module_response(self.response)
++        spec = importlib.machinery.ModuleSpec(self.modname, self.importer)
++        _ = self.importer.create_module(spec)
++        filename = self.importer.get_filename(self.modname)
++        self.assertEqual('master:fake_pkg/__init__.py', filename)
++
++    def test_get_source(self):
++        import importlib.machinery
++        self.set_get_module_response(self.response)
++        spec = importlib.machinery.ModuleSpec(self.modname, self.importer)
++        _ = self.importer.create_module(spec)
++        source = self.importer.get_source(self.modname)
++        self.assertEqual(source,
++            mitogen.core.to_text(zlib.decompress(self.data)))
++
++
+ class EmailParseAddrSysTest(testlib.RouterMixin, testlib.TestCase):
+     def initdir(self, caplog):
+         self.caplog = caplog
+diff --git a/tests/module_finder_test.py b/tests/module_finder_test.py
+index 401a607..8ccbd88 100644
+--- a/tests/module_finder_test.py
++++ b/tests/module_finder_test.py
+@@ -139,9 +139,7 @@ class SysModulesMethodTest(testlib.TestCase):
+         self.assertIsNone(tup)
+ 
+ 
+-class GetModuleViaParentEnumerationTest(testlib.TestCase):
+-    klass = mitogen.master.ParentEnumerationMethod
+-
++class ParentEnumerationMixin(object):
+     def call(self, fullname):
+         return self.klass().find(fullname)
+ 
+@@ -231,6 +229,16 @@ class GetModuleViaParentEnumerationTest(testlib.TestCase):
+         self.assertEqual(is_pkg, False)
+ 
+ 
++@unittest.skipIf(sys.version_info >= (3, 4), 'Superceded in Python >= 3.4')
++class ParentImpEnumerationMethodTest(ParentEnumerationMixin, testlib.TestCase):
++    klass = mitogen.master.ParentImpEnumerationMethod
++
++
++@unittest.skipIf(sys.version_info < (3, 4), 'Requires ModuleSpec, Python 3.4+')
++class ParentSpecEnumerationMethodTest(ParentEnumerationMixin, testlib.TestCase):
++    klass = mitogen.master.ParentSpecEnumerationMethod
++
++
+ class ResolveRelPathTest(testlib.TestCase):
+     klass = mitogen.master.ModuleFinder
+ 
+diff --git a/tests/testlib.py b/tests/testlib.py
+index 8ab895c..1146a92 100644
+--- a/tests/testlib.py
++++ b/tests/testlib.py
+@@ -41,8 +41,13 @@ except NameError:
+ 
+ 
+ LOG = logging.getLogger(__name__)
+-DATA_DIR = os.path.join(os.path.dirname(__file__), 'data')
+-MODS_DIR = os.path.join(DATA_DIR, 'importer')
++
++TESTS_DIR =                     os.path.join(os.path.dirname(__file__))
++ANSIBLE_LIB_DIR =               os.path.join(TESTS_DIR, 'ansible', 'lib')
++ANSIBLE_MODULE_UTILS_DIR =      os.path.join(TESTS_DIR, 'ansible', 'lib', 'module_utils')
++ANSIBLE_MODULES_DIR =           os.path.join(TESTS_DIR, 'ansible', 'lib', 'modules')
++DATA_DIR =                      os.path.join(TESTS_DIR, 'data')
++MODS_DIR =                      os.path.join(TESTS_DIR, 'data', 'importer')
+ 
+ sys.path.append(DATA_DIR)
+ sys.path.append(MODS_DIR)
diff -Nru python-mitogen-0.3.3/debian/patches/series python-mitogen-0.3.3/debian/patches/series
--- python-mitogen-0.3.3/debian/patches/series	2023-05-13 15:45:14.000000000 +0200
+++ python-mitogen-0.3.3/debian/patches/series	2025-08-18 16:33:11.000000000 +0200
@@ -7,3 +7,4 @@
 ansible-6
 hack-remove-cleanup
 poll-poller
+python3.12-targets
diff -Nru python-mitogen-0.3.3/debian/tests/ansible-tests python-mitogen-0.3.3/debian/tests/ansible-tests
--- python-mitogen-0.3.3/debian/tests/ansible-tests	2023-05-13 15:45:14.000000000 +0200
+++ python-mitogen-0.3.3/debian/tests/ansible-tests	2025-08-18 16:33:11.000000000 +0200
@@ -2,7 +2,8 @@
 set -eufx
 
 cp -a tests/ansible "$AUTOPKGTEST_TMP/ansible-tests"
-cp tests/testlib.py "$AUTOPKGTEST_TMP/ansible-tests/"
+sed -e "s/TESTS_DIR, 'ansible', /TESTS_DIR, /" tests/testlib.py \
+    > "$AUTOPKGTEST_TMP/ansible-tests/testlib.py"
 cd "$AUTOPKGTEST_TMP"
 
 python3 -m unittest discover -v \

Reply to: