From 699f3f8d81e5fc299f3e9384d9475298375a7376 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Rzepecki?= Date: Mon, 9 Nov 2026 20:44:48 +0100 Subject: [PATCH 1/2] fix: build the import filter chain before hooking imports The filter chain used to be built lazily, on the first import that went through our finder hooks. Building it creates a DistMatcher for each `dist:` entry in appmap.yml, which calls importlib.metadata.files(). On Python 3.13+ that imports csv lazily; the import went through our hooks back into do_import, which started building the chain again, and the nested files() call then found csv partially initialized. With process recording enabled this happened while processing appmap.pth, aborting interpreter startup. Build the chain eagerly instead, once the configuration has registered its filters, and only then hook the finders, so nothing imported while building it can reach the hooks. This also avoids two latent problems with the lazy build: an import between hooking and filter registration would have cached an empty chain, and Importer.instrument_function (used by the FastAPI integration) assumed the chain already existed. Assisted-by: Claude:claude-opus-5-5 --- _appmap/__init__.py | 1 + _appmap/importer.py | 24 +++++++++++++++++++++--- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/_appmap/__init__.py b/_appmap/__init__.py index 94e6c59f..1ade6ecb 100644 --- a/_appmap/__init__.py +++ b/_appmap/__init__.py @@ -12,6 +12,7 @@ def initialize(**kwargs): importer.initialize() recorder.initialize() configuration.initialize() # needs to be initialized after recorder + importer.install() # needs the filters registered by configuration metadata.initialize() web_framework.initialize() recording.initialize() diff --git a/_appmap/importer.py b/_appmap/importer.py index 4493057c..016eab6f 100644 --- a/_appmap/importer.py +++ b/_appmap/importer.py @@ -166,7 +166,7 @@ def get_filter_stack(self): @classmethod def initialize(cls): cls.filter_stack = [] - cls.filter_chain = [] + cls.filter_chain = None cls._skip_instrumenting = ("appmap", "_appmap") cls.instrument_properties = ( Env.current.get("APPMAP_INSTRUMENT_PROPERTIES", "true").lower() == "true" @@ -176,6 +176,10 @@ def initialize(cls): def use_filter(cls, filter_class): cls.filter_stack.append(filter_class) + @classmethod + def build_filter_chain(cls): + cls.filter_chain = reduce(lambda acc, e: e(acc), cls.filter_stack, NullFilter(None)) + @classmethod def instrument_function(cls, fn_name, filterableFn: FilterableFn, selected_functions=None): # Only instrument the function if it was specifically called out for the package @@ -194,8 +198,10 @@ def do_import(cls, *args, **kwargs): return logger.trace("do_import, mod %s args %s kwargs %s", mod, args, kwargs) - if not cls.filter_chain: - cls.filter_chain = reduce(lambda acc, e: e(acc), cls.filter_stack, NullFilter(None)) + if cls.filter_chain is None: + # Only possible while _appmap is being (re)initialized, before install() has run. + logger.trace("no filter chain yet, not instrumenting %s", mod.__name__) + return def instrument_functions(filterable, selected_functions=None): # pylint: disable=too-many-locals @@ -362,6 +368,18 @@ def copy(self): def initialize(): Importer.initialize() + + +def install(): + """ + Build the filter chain and hook the finders. Must run after the filters have been registered + (i.e. after configuration.initialize()). + + The chain is built before the finders are hooked because building it can import modules (e.g. + since 3.13, importlib.metadata.files imports csv lazily). If the hooks were already in place, + those imports would need the chain that's still being built. + """ + Importer.build_filter_chain() # If we're not enabled, there's no reason to hook the finders. if Env.current.enabled: logger.trace("sys.metapath: %s", sys.meta_path) From 69cb0a5ee88195e30887b6a57d4d4d519839b2d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Rzepecki?= Date: Mon, 9 Nov 2026 20:44:48 +0100 Subject: [PATCH 2/2] feat!: require Python 3.11+, test on 3.11-3.15 Python 3.8-3.10 have reached end of life, and ubuntu-latest no longer provides 3.9.14 at all. Raise requires-python to >=3.11 and test on 3.11 through 3.15. - Drop compatibility code for older versions: the BooleanOptionalAction fallback, the pre-3.11 find_spec wrapping, the relative_to workaround for missing is_relative_to, and version-gated tests and deps. - Fix test_python_version's regex, which couldn't match a two-digit minor version. - tox: run the web env against Django 5.2 LTS (Django 4.2 breaks on 3.14), add a django6 env for 3.12+, and limit django3/django4 to 3.11-3.12, the newest Pythons they support. - Add Python and Django version classifiers. BREAKING CHANGE: Python 3.8, 3.9 and 3.10 are no longer supported. Assisted-by: Claude:claude-opus-5-5 --- .github/workflows/lint-and-test.yml | 6 +++--- README.md | 2 +- _appmap/configuration.py | 9 +++------ _appmap/importer.py | 28 ++++++++++------------------ _appmap/py_version_check.py | 2 +- _appmap/test/test_command.py | 6 +++--- _appmap/test/test_params.py | 4 ---- _appmap/test/test_properties.py | 3 +-- appmap/command/runner.py | 29 ++++++----------------------- pylintrc | 2 +- pyproject.toml | 15 ++++++++++++--- tox.ini | 21 +++++++++++++-------- 12 files changed, 54 insertions(+), 73 deletions(-) diff --git a/.github/workflows/lint-and-test.yml b/.github/workflows/lint-and-test.yml index f0ad33d8..c5056cad 100644 --- a/.github/workflows/lint-and-test.yml +++ b/.github/workflows/lint-and-test.yml @@ -26,11 +26,11 @@ jobs: matrix: os: [ ubuntu-latest ] python: + - "3.15" + - "3.14" + - "3.13" - "3.12" - "3.11" - - "3.10" - - "3.9.14" - - "3.8" runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v5 diff --git a/README.md b/README.md index ad9f8e88..03321c34 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ cd appmap-python APPMAP_DISPLAY_PARAMS=true uv run appmap-python pytest # Run tests with a specific Python version -APPMAP_DISPLAY_PARAMS=true uv run --python 3.9 appmap-python pytest +APPMAP_DISPLAY_PARAMS=true uv run --python 3.11 appmap-python pytest # Run tests in parallel APPMAP_DISPLAY_PARAMS=true uv run appmap-python pytest -n auto diff --git a/_appmap/configuration.py b/_appmap/configuration.py index ab370436..8522e785 100644 --- a/_appmap/configuration.py +++ b/_appmap/configuration.py @@ -185,13 +185,10 @@ def default(self): env = Env.current output_dir = env.output_dir root_dir = env.root_dir - try: + # If output_dir isn't relative to root_dir, APPMAP_OUTPUT_DIR is set, and we've already + # logged a warning. + if output_dir.is_relative_to(root_dir): ret["appmap_dir"] = str(output_dir.relative_to(root_dir)) - except ValueError: - # The only way we can get here is if APPMAP_OUTPUT_DIR is set, and we've already logged - # a warning. (Note that PurePath.is_relative_to wasn't added till 3.9, so it can't be - # used here.) - pass return ret diff --git a/_appmap/importer.py b/_appmap/importer.py index 016eab6f..b0ee891c 100644 --- a/_appmap/importer.py +++ b/_appmap/importer.py @@ -320,26 +320,18 @@ def wrapped_find_spec(find_spec, _, args, kwargs): def wrap_finder_find_spec(finder): - # Prior to 3.11, it worked fine to just grab find_spec from the finder and wrap it. The - # implementation of builtin finders must have changed with 3.11, because we now need the same - # kind of workaround we use above for exec_module. - if sys.version_info[1] < 11: - find_spec = getattr(finder, "find_spec", None) - if find_spec is None: - logger.trace("no find_spec for finder %r", finder) - return - + # Builtin finders define find_spec as a classmethod or staticmethod, which wrapt has trouble + # identifying (see the comment in wrapped_find_spec above), so wrap the descriptor found in + # __dict__ instead. + find_spec = inspect.getattr_static(finder, "find_spec", None) + if find_spec is None: + logger.trace("no find_spec for finder %r", finder) + return + + if isinstance(find_spec, (classmethod, staticmethod)): finder.find_spec = wrap_finder_function(find_spec, wrapped_find_spec) else: - find_spec = inspect.getattr_static(finder, "find_spec", None) - if find_spec is None: - logger.trace("no find_spec for finder %r", finder) - return - - if isinstance(find_spec, (classmethod, staticmethod)): - finder.find_spec = wrap_finder_function(find_spec, wrapped_find_spec) - else: - finder.find_spec = wrap_finder_function(finder.find_spec, wrapped_find_spec) + finder.find_spec = wrap_finder_function(finder.find_spec, wrapped_find_spec) class MetapathObserver(MutableSequence): diff --git a/_appmap/py_version_check.py b/_appmap/py_version_check.py index c89156b9..33b7d53e 100644 --- a/_appmap/py_version_check.py +++ b/_appmap/py_version_check.py @@ -17,7 +17,7 @@ def _get_platform_version(): def check_py_version(): - req = (3, 8) + req = (3, 11) actual = _get_platform_version() if _get_py_version() < req: raise AppMapPyVerException( diff --git a/_appmap/test/test_command.py b/_appmap/test/test_command.py index 2f666982..b2f49282 100644 --- a/_appmap/test/test_command.py +++ b/_appmap/test/test_command.py @@ -115,14 +115,14 @@ def test_no_errors(self, capsys): def test_python_version(self, capsys, mocker): mocker.patch( "_appmap.py_version_check._get_py_version", - return_value=(3, 5), + return_value=(3, 10), ) mocker.patch( "_appmap.py_version_check._get_platform_version", - return_value="3.5", + return_value="3.10", ) - self.check_errors(capsys, 1, 1, r"Minimum Python version supported is \d\.\d, found") + self.check_errors(capsys, 1, 1, r"Minimum Python version supported is \d+\.\d+, found") def test_django_version(self, capsys, mocker): mocker.patch( diff --git a/_appmap/test/test_params.py b/_appmap/test/test_params.py index bf836dcd..ed6deae1 100644 --- a/_appmap/test/test_params.py +++ b/_appmap/test/test_params.py @@ -266,9 +266,6 @@ def test_optional_2_keyword(self, params): }, ) - @pytest.mark.skipif( - sys.version_info < (3, 8), reason="positional-only added in 3.8" - ) @pytest.mark.parametrize("params", ["positional_only"], indirect=True) def test_positional_only(self, params): evt = params.C().positional_only(1, 2) @@ -282,7 +279,6 @@ def test_positional_only(self, params): evt, 1, {"class": "builtins.int", "kind": "req", "name": "p2", "value": "2"} ) - @pytest.mark.skipif(sys.version_info < (3, 8), reason="keyword-only added in 3.8") @pytest.mark.parametrize("params", ["keyword_only"], indirect=True) def test_keyword_only(self, params): evt = params.C().keyword_only(p2=2, p1=1) diff --git a/_appmap/test/test_properties.py b/_appmap/test/test_properties.py index 9377358b..ebb3e79c 100644 --- a/_appmap/test/test_properties.py +++ b/_appmap/test/test_properties.py @@ -118,8 +118,7 @@ def test_operator_itemgetter(events): assert len(events) == 2 assert events[0].to_dict() == DictIncluding({ "event": "call", - # operator.itemgetter.__module__ isn't available before 3.10 - # "defined_class": "operator", + "defined_class": "operator", "method_id": "itemgetter (get)", }) diff --git a/appmap/command/runner.py b/appmap/command/runner.py index a5c0bbe8..e111f966 100644 --- a/appmap/command/runner.py +++ b/appmap/command/runner.py @@ -53,29 +53,12 @@ def recording_types(v: str): default=argparse.SUPPRESS, ) -if sys.version_info >= (3, 9): - _parser.add_argument( - "--enable-log", - help="create a log file", - action=argparse.BooleanOptionalAction, - default=False, - ) -else: - # You can see why BooleanOptionalAction was added. This is close, though not - # really as good.... - _enable_log_group = _parser.add_mutually_exclusive_group() - _enable_log_group.add_argument( - "--enable-log", - help="create a log file", - dest="enable_log", - action="store_true", - ) - _enable_log_group.add_argument( - "--no-enable-log", - help="don't create a log file", - dest="enable_log", - action="store_false", - ) +_parser.add_argument( + "--enable-log", + help="create a log file", + action=argparse.BooleanOptionalAction, + default=False, +) _parser.add_argument( "command", diff --git a/pylintrc b/pylintrc index 853e0308..cb8c9069 100644 --- a/pylintrc +++ b/pylintrc @@ -88,7 +88,7 @@ persistent=yes # Minimum Python version to use for version dependent checks. Will default to # the version used to run pylint. -py-version=3.10 +py-version=3.11 # Discover python modules and packages in the file system subtree. recursive=no diff --git a/pyproject.toml b/pyproject.toml index 55e8cddd..8f5c4450 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ name = "appmap" version = "3.0.1" description = "Create AppMap files by recording a Python application." readme = "README.md" -requires-python = ">=3.8" +requires-python = ">=3.11" license = { text = "MIT" } authors = [ { name = "Alan Potter", email = "alan@app.land" }, @@ -14,10 +14,20 @@ classifiers = [ "Development Status :: 4 - Beta", "Framework :: Django", "Framework :: Django :: 3.2", + "Framework :: Django :: 4.2", + "Framework :: Django :: 5.2", + "Framework :: Django :: 6.0", "Framework :: Flask", "Framework :: Pytest", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.15", "Topic :: Software Development", "Topic :: Software Development :: Debuggers", "Topic :: Software Development :: Documentation", @@ -60,8 +70,7 @@ test = [ "Flask", "sqlalchemy", "pytest-django>=4.7,<5.0", - "numpy>=1.24.4,<2.0; python_version < '3.9'", - "numpy>=2.0; python_version >= '3.9'", + "numpy>=2.0", ] dev = [ "appmap[test]", diff --git a/tox.ini b/tox.ini index 852b3390..6989c2c7 100644 --- a/tox.ini +++ b/tox.ini @@ -9,19 +9,23 @@ isolated_build = true # older version of the web frameworks, just run the tests that are specific to them. # Default envlist is only for matrix testing. Linter and vendoring should be called explicitly -envlist = py3{10,11,12}-{django5}, py3{8,9,10,11,12}-{web,django3,django4,flask2,sqlalchemy1} +envlist = + py3{11,12,13,14,15}-{web,django5,flask2,sqlalchemy1} + py3{12,13,14,15}-django6 + # Django 3.2 and 4.2 don't support Python 3.13+. + py3{11,12}-{django3,django4} [gh-actions] python = - 3.8: py38 - 3.9: py39 - 3.10: py310 3.11: py311 3.12: py312 + 3.13: py313 + 3.14: py314 + 3.15: py315 [web-deps] deps= - Django >=4.0, <5.0 + Django >=5.2, <6.0 Flask >=3.0 sqlalchemy >=2.0, <3.0 @@ -34,13 +38,13 @@ setenv = APPMAP_DISPLAY_PARAMS=true deps= web: {[web-deps]deps} - web,django3,django4,django5: pytest-django >=4.7, <5.0 - py38: numpy==1.24.4 - py3{9,10,11,12}: numpy >=2 + web,django3,django4,django5,django6: pytest-django >=4.7, <5.0 + numpy >=2 flask2: Flask >= 2.0, <3.0 django3: Django >=3.2, <4.0 django4: Django >=4.0, <5.0 django5: Django >=5.0, <6.0 + django6: Django >=6.0, <7.0 sqlalchemy1: sqlalchemy >=1.4.11, <2.0 commands = @@ -48,6 +52,7 @@ commands = django3: appmap-python pytest -n logical _appmap/test/test_django.py django4: appmap-python pytest -n logical _appmap/test/test_django.py django5: appmap-python pytest -n logical _appmap/test/test_django.py + django6: appmap-python pytest -n logical _appmap/test/test_django.py flask2: appmap-python pytest -n logical _appmap/test/test_flask.py sqlalchemy1: appmap-python pytest -n logical _appmap/test/test_sqlalchemy.py