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/__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/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 4493057c..b0ee891c 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 @@ -314,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): @@ -362,6 +360,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) 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