Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/lint-and-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,11 @@ jobs:
matrix:
os: [ ubuntu-latest ]
python:
- "3.15"
Comment thread
dividedmind marked this conversation as resolved.
Resolved
- "3.14"
- "3.13"
- "3.12"
- "3.11"
- "3.10"
- "3.9.14"
- "3.8"
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v5
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions _appmap/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
9 changes: 3 additions & 6 deletions _appmap/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
52 changes: 31 additions & 21 deletions _appmap/importer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion _appmap/py_version_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
6 changes: 3 additions & 3 deletions _appmap/test/test_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
4 changes: 0 additions & 4 deletions _appmap/test/test_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
3 changes: 1 addition & 2 deletions _appmap/test/test_properties.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
})

Expand Down
29 changes: 6 additions & 23 deletions appmap/command/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion pylintrc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 12 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand All @@ -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",
Expand Down Expand Up @@ -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]",
Expand Down
21 changes: 13 additions & 8 deletions tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -34,20 +38,21 @@ 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 =
web: appmap-python {posargs:pytest -n logical}
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

Expand Down
Loading