├── .gitmodules ├── appmap.pth ├── appmap ├── command │ ├── __init__.py │ ├── appmap_agent_init.py │ ├── appmap_agent_validate.py │ ├── appmap_agent_status.py │ └── runner.py ├── labeling │ ├── http.yml │ ├── jwt.yml │ ├── logger.yml │ ├── jobs.yml │ ├── random.yml │ ├── flask.yml │ ├── __init__.py │ ├── formats.yml │ ├── django.yml │ └── crypto.yml ├── unittest.py ├── uvicorn.py ├── sqlalchemy.py ├── __init__.py ├── http.py └── pytest.py ├── envrc.example ├── _appmap ├── test │ ├── data │ │ ├── config │ │ │ ├── test │ │ │ │ ├── foo.py │ │ │ │ └── __init__.py │ │ │ └── src │ │ │ │ └── package │ │ │ │ └── __init__.py │ │ ├── package1 │ │ │ ├── __init__.py │ │ │ └── package2 │ │ │ │ ├── __init__.py │ │ │ │ ├── mod1.py │ │ │ │ └── __main__.py │ │ ├── django │ │ │ ├── test │ │ │ │ ├── __init__.py │ │ │ │ ├── test_request.py │ │ │ │ ├── test_unittest_setup.py │ │ │ │ └── test_app.py │ │ │ ├── djangoapp │ │ │ │ ├── djangoapp.py │ │ │ │ ├── hello_world.html │ │ │ │ ├── middleware.py │ │ │ │ ├── __init__.py │ │ │ │ ├── settings_dev.py │ │ │ │ ├── settings.py │ │ │ │ └── urls.py │ │ │ ├── init │ │ │ │ └── sitecustomize.py │ │ │ ├── appmap.yml │ │ │ ├── pytest.ini │ │ │ └── manage.py │ │ ├── flask │ │ │ ├── templates │ │ │ │ └── test.html │ │ │ ├── init │ │ │ │ └── sitecustomize.py │ │ │ ├── appmap.yml │ │ │ ├── test_app.py │ │ │ └── flaskapp.py │ │ ├── pytest │ │ │ ├── tests │ │ │ │ ├── __init__.py │ │ │ │ ├── test_noappmap.py │ │ │ │ └── test_simple.py │ │ │ ├── expected │ │ │ │ ├── status_xsucceeded.metadata.json │ │ │ │ ├── status_errored.metadata.json │ │ │ │ ├── status_failed.metadata.json │ │ │ │ └── status_xfailed.metadata.json │ │ │ ├── appmap-no-test-cases.yml │ │ │ ├── appmap.yml │ │ │ └── simple.py │ │ ├── trial │ │ │ ├── test │ │ │ │ ├── __init__.py │ │ │ │ └── test_deferred.py │ │ │ ├── .gitignore │ │ │ ├── init │ │ │ │ └── sitecustomize.py │ │ │ ├── appmap.yml │ │ │ ├── appmap-no-test-cases.yml │ │ │ └── expected │ │ │ │ ├── pytest-no-test-cases.appmap.json │ │ │ │ └── pytest.appmap.json │ │ ├── appmap_testing │ │ │ ├── __init__.py │ │ │ └── django_simplelazyobject.py │ │ ├── fastapi │ │ │ ├── fastapiapp │ │ │ │ ├── __init__.py │ │ │ │ └── main.py │ │ │ ├── init │ │ │ │ └── sitecustomize.py │ │ │ ├── appmap.yml │ │ │ └── test_app.py │ │ ├── config-exclude │ │ │ ├── test │ │ │ │ └── __init__.py │ │ │ ├── src │ │ │ │ └── package │ │ │ │ │ └── __init__.py │ │ │ ├── venv │ │ │ │ └── venv_mod │ │ │ │ │ └── __init__.py │ │ │ ├── .hide │ │ │ │ └── hidden_mod │ │ │ │ │ └── __init__.py │ │ │ └── node_modules │ │ │ │ └── node_mod │ │ │ │ └── __init__.py │ │ ├── config-up │ │ │ ├── project │ │ │ │ ├── p1 │ │ │ │ │ └── __init__.py │ │ │ │ └── p2 │ │ │ │ │ └── sub1 │ │ │ │ │ └── __init__.py │ │ │ └── appmap.yml │ │ ├── unittest │ │ │ ├── init │ │ │ │ └── sitecustomize.py │ │ │ ├── expected │ │ │ │ ├── status_xsucceeded.metadata.json │ │ │ │ ├── status_errored.metadata.json │ │ │ │ ├── status_failed.metadata.json │ │ │ │ ├── status_xfailed.metadata.json │ │ │ │ ├── unittest-no-test-cases.appmap.json │ │ │ │ ├── pytest.appmap.json │ │ │ │ └── unittest.appmap.json │ │ │ ├── appmap.yml │ │ │ ├── appmap-no-test-cases.yml │ │ │ └── simple │ │ │ │ ├── test_noappmap.py │ │ │ │ ├── __init__.py │ │ │ │ └── test_simple.py │ │ ├── flask-instrumented │ │ │ ├── init │ │ │ │ └── sitecustomize.py │ │ │ ├── appmap.yml │ │ │ ├── flaskapp.py │ │ │ └── test_app.py │ │ ├── pytest-instrumented │ │ │ ├── init │ │ │ │ └── sitecustomize.py │ │ │ ├── appmap.yml │ │ │ └── test_instrumented.py │ │ ├── appmap-broken.yml │ │ ├── appmap-empty-path.yml │ │ ├── appmap-class.yml │ │ ├── appmap-func.yml │ │ ├── appmap-malformed-path.yml │ │ ├── appmap-exclude-fn.yml │ │ ├── appmap-all-paths-malformed.yml │ │ ├── appmap-no-pyyaml.yml │ │ ├── appmap.yml │ │ ├── params.py │ │ ├── remote.appmap.json │ │ ├── properties_class.py │ │ └── example_class.py │ ├── bin │ │ └── server_runner │ ├── __init__.py │ ├── test_env.py │ ├── test_util.py │ ├── test_django_simplelazyobject.py │ ├── test_metadata.py │ ├── test_importer.py │ ├── test_http.py │ ├── helpers.py │ ├── test_runner.py │ ├── test_generation.py │ ├── test_labels.py │ ├── test_fastapi.py │ ├── test_sqlalchemy.py │ ├── test_describe_value.py │ ├── appmap_test_base.py │ ├── test_properties.py │ ├── normalize.py │ ├── test_events.py │ └── test_command.py ├── wrapt ├── noappmap.py ├── singleton.py ├── trace_logger.py ├── __init__.py ├── py_version_check.py ├── fastapi.py ├── flask.py ├── remote_recording.py ├── django.py ├── unittest.py ├── labels.py ├── metadata.py ├── recording.py ├── generation.py └── instrument.py ├── .coveragerc ├── ci ├── tests │ ├── data │ │ └── readonly-mount-appmap.log │ ├── test_pipenv.sh │ ├── test_poetry.sh │ └── smoketest.sh └── scripts │ ├── build_with_poetry.sh │ ├── run_tests.sh │ └── patch_artifacts_if_distribution_name_is_altered.sh ├── tool-versions.example ├── vendor ├── vendor.txt └── _appmap │ └── wrapt │ ├── __init__.py │ ├── LICENSE │ └── arguments.py ├── requirements-test.txt ├── conftest.py ├── ruff.toml.example ├── appmap.yml ├── requirements-dev.txt ├── .gitignore ├── .github ├── actions │ ├── dockerhub-login │ │ └── action.yml │ ├── refetch-artifacts │ │ └── action.yml │ ├── setup-semantic-release │ │ └── action.yml │ └── setup │ │ └── action.yml └── workflows │ ├── add-issues-to-board.yml │ ├── plan.yml │ └── lint-and-test.yml ├── pytest.ini ├── CONTRIBUTING.md ├── LICENSE ├── .releaserc.yml ├── tox.ini ├── docs └── recording-env-vars.md ├── pyproject.toml └── README.md /.gitmodules: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /appmap.pth: -------------------------------------------------------------------------------- 1 | import appmap 2 | -------------------------------------------------------------------------------- /appmap/command/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /envrc.example: -------------------------------------------------------------------------------- 1 | layout python 2 | -------------------------------------------------------------------------------- /_appmap/test/data/config/test/foo.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /_appmap/test/data/package1/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /_appmap/wrapt: -------------------------------------------------------------------------------- 1 | ../vendor/_appmap/wrapt -------------------------------------------------------------------------------- /_appmap/test/data/config/test/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /_appmap/test/data/django/test/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /_appmap/test/data/flask/templates/test.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /_appmap/test/data/pytest/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /_appmap/test/data/trial/test/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /_appmap/test/data/appmap_testing/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /_appmap/test/data/config/src/package/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /_appmap/test/data/django/djangoapp/djangoapp.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /_appmap/test/data/fastapi/fastapiapp/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /_appmap/test/data/package1/package2/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /_appmap/test/data/config-exclude/test/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /_appmap/test/data/config-up/project/p1/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /_appmap/test/data/trial/.gitignore: -------------------------------------------------------------------------------- 1 | _trial_temp/ 2 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = 3 | $PWD/appmap 4 | -------------------------------------------------------------------------------- /_appmap/test/data/config-exclude/src/package/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /_appmap/test/data/config-up/appmap.yml: -------------------------------------------------------------------------------- 1 | name: config-up-name -------------------------------------------------------------------------------- /_appmap/test/data/config-up/project/p2/sub1/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /_appmap/test/data/flask/init/sitecustomize.py: -------------------------------------------------------------------------------- 1 | import appmap 2 | -------------------------------------------------------------------------------- /_appmap/test/data/trial/init/sitecustomize.py: -------------------------------------------------------------------------------- 1 | import appmap 2 | -------------------------------------------------------------------------------- /_appmap/test/data/django/init/sitecustomize.py: -------------------------------------------------------------------------------- 1 | import appmap 2 | -------------------------------------------------------------------------------- /_appmap/test/data/fastapi/init/sitecustomize.py: -------------------------------------------------------------------------------- 1 | import appmap 2 | -------------------------------------------------------------------------------- /_appmap/test/data/unittest/init/sitecustomize.py: -------------------------------------------------------------------------------- 1 | import appmap 2 | -------------------------------------------------------------------------------- /ci/tests/data/readonly-mount-appmap.log: -------------------------------------------------------------------------------- 1 | # For a test in smoketest -------------------------------------------------------------------------------- /tool-versions.example: -------------------------------------------------------------------------------- 1 | python 3.8.18 3.9.18 3.10.13 3.11.7 3.12.1 2 | -------------------------------------------------------------------------------- /_appmap/test/data/config-exclude/venv/venv_mod/__init__.py: -------------------------------------------------------------------------------- 1 | # venv module 2 | -------------------------------------------------------------------------------- /_appmap/test/data/flask-instrumented/init/sitecustomize.py: -------------------------------------------------------------------------------- 1 | import appmap 2 | -------------------------------------------------------------------------------- /_appmap/test/data/pytest-instrumented/init/sitecustomize.py: -------------------------------------------------------------------------------- 1 | import appmap 2 | -------------------------------------------------------------------------------- /appmap/labeling/http.yml: -------------------------------------------------------------------------------- 1 | protocol.http: http.client.HTTPConnection.request 2 | -------------------------------------------------------------------------------- /_appmap/test/data/config-exclude/.hide/hidden_mod/__init__.py: -------------------------------------------------------------------------------- 1 | # Hidden module 2 | -------------------------------------------------------------------------------- /vendor/vendor.txt: -------------------------------------------------------------------------------- 1 | wrapt==1.15.0 ; python_version >= "3.8" and python_version < "4.0" -------------------------------------------------------------------------------- /_appmap/test/data/flask/appmap.yml: -------------------------------------------------------------------------------- 1 | name: FlaskTest 2 | packages: 3 | - path: app 4 | -------------------------------------------------------------------------------- /requirements-test.txt: -------------------------------------------------------------------------------- 1 | django ~= 3.2 2 | pytest-django < 4.8 3 | sqlalchemy < 2.0 4 | -------------------------------------------------------------------------------- /_appmap/test/data/appmap-broken.yml: -------------------------------------------------------------------------------- 1 | name: busted 2 | # open bracket, no close 3 | packages: [ 4 | -------------------------------------------------------------------------------- /_appmap/test/data/config-exclude/node_modules/node_mod/__init__.py: -------------------------------------------------------------------------------- 1 | # Module in node_modules 2 | -------------------------------------------------------------------------------- /_appmap/test/data/django/appmap.yml: -------------------------------------------------------------------------------- 1 | name: test_app 2 | packages: 3 | - path: djangoapp 4 | 5 | -------------------------------------------------------------------------------- /_appmap/test/data/django/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | DJANGO_SETTINGS_MODULE = djangoapp.settings 3 | -------------------------------------------------------------------------------- /_appmap/test/data/fastapi/appmap.yml: -------------------------------------------------------------------------------- 1 | name: FastAPITest 2 | packages: 3 | - path: fastapiapp 4 | -------------------------------------------------------------------------------- /appmap/labeling/jwt.yml: -------------------------------------------------------------------------------- 1 | jwt.encode: 2 | - jwt.encode 3 | 4 | jwt.decode: 5 | - jwt.decode 6 | -------------------------------------------------------------------------------- /_appmap/test/data/django/djangoapp/hello_world.html: -------------------------------------------------------------------------------- 1 | 2 |

Hello World!

3 | 4 | -------------------------------------------------------------------------------- /_appmap/test/data/unittest/expected/status_xsucceeded.metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "test_status": "succeeded" 3 | } 4 | -------------------------------------------------------------------------------- /_appmap/test/data/appmap-empty-path.yml: -------------------------------------------------------------------------------- 1 | name: TestApp 2 | packages: 3 | - path: example_class 4 | - path: 5 | 6 | -------------------------------------------------------------------------------- /_appmap/test/data/trial/appmap.yml: -------------------------------------------------------------------------------- 1 | name: deferred 2 | record_test_cases: "true" 3 | packages: 4 | - path: test 5 | -------------------------------------------------------------------------------- /_appmap/test/data/unittest/appmap.yml: -------------------------------------------------------------------------------- 1 | name: Simple 2 | record_test_cases: true 3 | packages: 4 | - path: simple 5 | -------------------------------------------------------------------------------- /_appmap/test/data/flask-instrumented/appmap.yml: -------------------------------------------------------------------------------- 1 | name: FlaskTest 2 | packages: 3 | - path: flaskapp 4 | - path: flask 5 | -------------------------------------------------------------------------------- /_appmap/test/data/pytest/expected/status_xsucceeded.metadata.json: -------------------------------------------------------------------------------- 1 | ../../unittest/expected/status_xsucceeded.metadata.json -------------------------------------------------------------------------------- /_appmap/test/data/package1/package2/mod1.py: -------------------------------------------------------------------------------- 1 | class Mod1Class: 2 | def func(self): 3 | return "Mod1Class.func" 4 | -------------------------------------------------------------------------------- /_appmap/test/data/pytest/appmap-no-test-cases.yml: -------------------------------------------------------------------------------- 1 | name: Simple 2 | record_test_cases: false 3 | packages: 4 | - path: simple 5 | -------------------------------------------------------------------------------- /_appmap/test/data/pytest/appmap.yml: -------------------------------------------------------------------------------- 1 | name: Simple 2 | record_test_cases: true 3 | packages: 4 | - path: simple 5 | - path: tests -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | collect_ignore = [os.path.join("_appmap", "test", "data")] 4 | pytest_plugins = ["pytester"] 5 | -------------------------------------------------------------------------------- /_appmap/test/data/appmap-class.yml: -------------------------------------------------------------------------------- 1 | name: TestApp 2 | packages: 3 | - path: example_class 4 | - path: package1.package2.Mod1Class 5 | -------------------------------------------------------------------------------- /_appmap/test/data/trial/appmap-no-test-cases.yml: -------------------------------------------------------------------------------- 1 | name: deferred 2 | record_test_cases: "false" 3 | packages: 4 | - path: test 5 | -------------------------------------------------------------------------------- /_appmap/test/data/unittest/appmap-no-test-cases.yml: -------------------------------------------------------------------------------- 1 | name: Simple 2 | record_test_cases: false 3 | packages: 4 | - path: simple 5 | -------------------------------------------------------------------------------- /_appmap/test/data/appmap-func.yml: -------------------------------------------------------------------------------- 1 | name: TestApp 2 | packages: 3 | - path: example_class 4 | - path: package1.package2.Mod1Class.func 5 | -------------------------------------------------------------------------------- /_appmap/test/data/appmap-malformed-path.yml: -------------------------------------------------------------------------------- 1 | name: TestApp 2 | packages: 3 | - path: example_class 4 | - path: package1/package2/Mod1Class 5 | -------------------------------------------------------------------------------- /ruff.toml.example: -------------------------------------------------------------------------------- 1 | line-length = 100 2 | extend-exclude = ["sitecustomize.py"] 3 | 4 | [lint.isort] 5 | known-first-party = ['appmap', '_appmap'] -------------------------------------------------------------------------------- /appmap.yml: -------------------------------------------------------------------------------- 1 | appmap_dir: tmp/appmap 2 | language: python 3 | name: appmap-python 4 | packages: 5 | - path: wrapt 6 | - path: _appmap 7 | - path: appmap 8 | - path: test 9 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | #requirements-dev.txt 2 | tox 3 | django 4 | flask >=2, <= 3 5 | pytest-django<4.8 6 | fastapi 7 | httpx 8 | sqlalchemy 9 | debugpy 10 | numpy -------------------------------------------------------------------------------- /ci/tests/test_pipenv.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | pip -q install pipenv 5 | 6 | mkdir /pipenv || true 7 | cd /pipenv 8 | 9 | pipenv run /ci/smoketest.sh 10 | -------------------------------------------------------------------------------- /_appmap/test/data/appmap-exclude-fn.yml: -------------------------------------------------------------------------------- 1 | 2 | name: TestApp 3 | packages: 4 | - path: example_class 5 | exclude: 6 | - ExampleClass.another_method 7 | - path: package1.package2.Mod1Class 8 | -------------------------------------------------------------------------------- /_appmap/test/data/package1/package2/__main__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from .mod1 import Mod1Class 4 | 5 | def main(): 6 | print(Mod1Class().func()) 7 | return 0 8 | 9 | sys.exit(main()) -------------------------------------------------------------------------------- /appmap/labeling/logger.yml: -------------------------------------------------------------------------------- 1 | log: 2 | - logging.debug 3 | - logging.info 4 | - logging.warning 5 | - logging.error 6 | - logging.critical 7 | - logging.exception 8 | - logging.log 9 | -------------------------------------------------------------------------------- /_appmap/test/data/appmap-all-paths-malformed.yml: -------------------------------------------------------------------------------- 1 | name: TestApp 2 | packages: 3 | - path: abc/xyz 4 | - path: abc\xyz 5 | - path: \abc 6 | - path: xyz/ 7 | - path: 42 8 | - path: . 9 | - path: 10 | -------------------------------------------------------------------------------- /_appmap/test/bin/server_runner: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -x 4 | 5 | cd "$1"; shift 6 | 7 | set -a 8 | PYTHONUNBUFFERED=1 9 | APPMAP_OUTPUT_DIR=/tmp 10 | PYTHONPATH=./init 11 | 12 | exec $@ 13 | -------------------------------------------------------------------------------- /_appmap/test/__init__.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | # Make sure assertions in web_framework get rewritten (e.g. to show 4 | # diffs in generated appmaps) 5 | pytest.register_assert_rewrite("_appmap.test.web_framework") 6 | -------------------------------------------------------------------------------- /_appmap/test/data/django/djangoapp/middleware.py: -------------------------------------------------------------------------------- 1 | def hello_world(get_response): 2 | def _hello_world(request): 3 | print("Hello world!") 4 | return get_response(request) 5 | 6 | return _hello_world 7 | -------------------------------------------------------------------------------- /appmap/labeling/jobs.yml: -------------------------------------------------------------------------------- 1 | job.create: 2 | - rq.queue.Queue.create_job 3 | - celery.app.task.Task.apply_async 4 | - huey.api.Huey.enqueue 5 | job.cancel: 6 | - rq.job.Job.cancel 7 | - celery.result.AsyncResult.revoke 8 | - huey.api.TaskWrapper.revoke 9 | -------------------------------------------------------------------------------- /_appmap/test/data/django/test/test_request.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from django.test import Client 3 | 4 | 5 | class TestRequest(TestCase): 6 | def test_request_test(self): 7 | resp = self.client.get("/test") 8 | assert resp.status_code == 200 9 | -------------------------------------------------------------------------------- /_appmap/test/data/unittest/simple/test_noappmap.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import simple 4 | 5 | import appmap 6 | 7 | 8 | @appmap.noappmap 9 | class TestNoAppMap(unittest.TestCase): 10 | def test_unrecorded(self): 11 | print(simple.Simple().hello_world("!")) 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .envrc 2 | .tool-versions 3 | 4 | poetry.lock 5 | 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | *.so 10 | 11 | .python-version 12 | 13 | /dist/ 14 | .venv/ 15 | 16 | htmlcov/ 17 | 18 | .vscode/ 19 | 20 | /.tox 21 | /node_modules 22 | /ruff.toml 23 | 24 | appmap.log 25 | -------------------------------------------------------------------------------- /ci/tests/test_poetry.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | pip -q install poetry 5 | 6 | mkdir /poetry || true 7 | cd /poetry 8 | 9 | poetry init -q 10 | 11 | # Yes, we need to set RUNNER, and we need to "poetry run" the script. 12 | RUNNER="poetry run" poetry run /ci/smoketest.sh 13 | -------------------------------------------------------------------------------- /appmap/labeling/random.yml: -------------------------------------------------------------------------------- 1 | pseudorandom: 2 | - random.random 3 | 4 | random.insecure: 5 | - random.random 6 | 7 | random.secure: 8 | - os.urandom 9 | - secrets.randbelow 10 | - secrets.randbits 11 | - secrets.token_bytes 12 | - secrets.token_hex 13 | - secrets.token_urlsafe 14 | -------------------------------------------------------------------------------- /_appmap/test/data/django/test/test_unittest_setup.py: -------------------------------------------------------------------------------- 1 | 2 | from unittest import TestCase 3 | 4 | from django.test import Client 5 | 6 | class DisabledRequestsRecordingTest(TestCase): 7 | def setUp(self) -> None: 8 | Client().get("/") 9 | 10 | def test_request_in_setup(self): 11 | pass 12 | -------------------------------------------------------------------------------- /_appmap/test/data/pytest-instrumented/appmap.yml: -------------------------------------------------------------------------------- 1 | name: Simple 2 | packages: 3 | - path: simple 4 | - path: _pytest 5 | exclude: 6 | # - _py.path 7 | - compat.safe_getattr 8 | - config.PytestPluginManager 9 | - fixtures.getfixturemarker 10 | - config.argparsing 11 | - config.Config.rootpath 12 | - path: pytest -------------------------------------------------------------------------------- /_appmap/test/data/pytest/expected/status_errored.metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "test_status": "failed", 3 | "test_failure": { 4 | "message": "RuntimeError: test error", 5 | "location": "tests/test_simple.py:30" 6 | }, 7 | "exception": { 8 | "class": "RuntimeError", 9 | "message": "test error" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /_appmap/test/data/appmap_testing/django_simplelazyobject.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=missing-module-docstring,missing-function-docstring 2 | 3 | from django.utils.functional import SimpleLazyObject 4 | 5 | def evaluate(): 6 | raise Exception("lazy object evaluated") 7 | 8 | def lazy(): 9 | return SimpleLazyObject(evaluate) 10 | -------------------------------------------------------------------------------- /_appmap/test/data/unittest/expected/status_errored.metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "test_status": "failed", 3 | "test_failure": { 4 | "message": "RuntimeError: test error", 5 | "location": "simple/test_simple.py:35" 6 | }, 7 | "exception": { 8 | "class": "RuntimeError", 9 | "message": "test error" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /_appmap/noappmap.py: -------------------------------------------------------------------------------- 1 | _appmap_noappmap = "_appmap_noappmap" 2 | 3 | 4 | def decorator(obj): 5 | setattr(obj, _appmap_noappmap, True) 6 | return obj 7 | 8 | 9 | def disables(test_fn, cls=None): 10 | return hasattr(test_fn, _appmap_noappmap) or ( 11 | cls is not None and hasattr(cls, _appmap_noappmap) 12 | ) 13 | -------------------------------------------------------------------------------- /_appmap/test/data/fastapi/test_app.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi.testclient import TestClient 3 | from fastapiapp import app 4 | 5 | 6 | @pytest.fixture 7 | def client(): 8 | yield TestClient(app) 9 | 10 | 11 | def test_request(client): 12 | response = client.get("/") 13 | 14 | assert response.status_code == 200 15 | -------------------------------------------------------------------------------- /_appmap/test/data/pytest/expected/status_failed.metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "test_status": "failed", 3 | "test_failure": { 4 | "message": "AssertionError: assert False", 5 | "location": "tests/test_simple.py:16" 6 | }, 7 | "exception": { 8 | "class": "AssertionError", 9 | "message": "assert False" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /_appmap/test/data/pytest/expected/status_xfailed.metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "test_status": "failed", 3 | "test_failure": { 4 | "message": "AssertionError: assert False", 5 | "location": "tests/test_simple.py:21" 6 | }, 7 | "exception": { 8 | "class": "AssertionError", 9 | "message": "assert False" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /appmap/unittest.py: -------------------------------------------------------------------------------- 1 | from _appmap.env import Env 2 | 3 | logger = Env.current.getLogger(__name__) 4 | 5 | if not Env.current.is_appmap_repo and Env.current.enables("tests"): 6 | logger.debug("Test recording is enabled (unittest)") 7 | # pylint: disable=unused-import 8 | import _appmap.unittest # pyright: ignore # noqa: F401 9 | -------------------------------------------------------------------------------- /_appmap/test/data/unittest/expected/status_failed.metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "test_status": "failed", 3 | "test_failure": { 4 | "message": "AssertionError: False is not true", 5 | "location": "simple/test_simple.py:23" 6 | }, 7 | "exception": { 8 | "class": "AssertionError", 9 | "message": "False is not true" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /_appmap/test/data/unittest/expected/status_xfailed.metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "test_status": "failed", 3 | "test_failure": { 4 | "message": "AssertionError: False is not true", 5 | "location": "simple/test_simple.py:27" 6 | }, 7 | "exception": { 8 | "class": "AssertionError", 9 | "message": "False is not true" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /.github/actions/dockerhub-login/action.yml: -------------------------------------------------------------------------------- 1 | name: login to Dockerhub (to prevent image pull trottling) 2 | runs: 3 | using: composite 4 | steps: 5 | - name: docker login 6 | run: | 7 | docker login -u "$DOCKERHUB_USERNAME" --password-stdin <<< "$DOCKERHUB_PASSWORD" || echo "::warning::docker-login failed, ignoring" 8 | shell: bash 9 | -------------------------------------------------------------------------------- /_appmap/test/data/unittest/simple/__init__.py: -------------------------------------------------------------------------------- 1 | class Simple: 2 | def hello(self): 3 | return "Hello" 4 | 5 | def world(self): 6 | return "world" 7 | 8 | def hello_world(self, bang): 9 | return "%s %s%s" % (self.hello(), self.world(), bang) 10 | 11 | def getReady(self): 12 | pass 13 | 14 | def finishUp(self): 15 | pass 16 | -------------------------------------------------------------------------------- /_appmap/test/data/appmap-no-pyyaml.yml: -------------------------------------------------------------------------------- 1 | name: TestApp 2 | packages: 3 | # note: this rule needs to go first, before the more general rule 4 | - path: example_class.Super 5 | shallow: true 6 | - path: example_class 7 | - path: appmap_testing 8 | - path: package1 9 | labels: 10 | serialization: yaml.dump 11 | example-label: 12 | - example_class.ExampleClass.test_exception 13 | - yaml.dump 14 | -------------------------------------------------------------------------------- /_appmap/test/test_env.py: -------------------------------------------------------------------------------- 1 | from _appmap.env import Env 2 | 3 | 4 | def test_disable_temporarily(): 5 | env = Env({"_APPMAP": "true"}) 6 | assert env.enables("requests") 7 | try: 8 | with env.disabled("requests"): 9 | assert not env.enables("requests") 10 | raise RuntimeError("hell") 11 | except RuntimeError: 12 | ... 13 | assert env.enables("requests") 14 | -------------------------------------------------------------------------------- /_appmap/singleton.py: -------------------------------------------------------------------------------- 1 | class SingletonMeta(type): 2 | def __init__(cls, *args, **kwargs): 3 | type.__init__(cls, *args, **kwargs) 4 | cls._instance = None 5 | 6 | @property 7 | def current(cls): 8 | if not cls._instance: 9 | cls._instance = cls() 10 | 11 | return cls._instance 12 | 13 | def reset(cls, **kwargs): 14 | cls._instance = cls(**kwargs) 15 | -------------------------------------------------------------------------------- /_appmap/test/data/pytest/tests/test_noappmap.py: -------------------------------------------------------------------------------- 1 | from simple import Simple 2 | 3 | import appmap 4 | 5 | 6 | def test_recorded(): 7 | print(Simple().hello_world()) 8 | 9 | 10 | @appmap.noappmap 11 | def test_unrecorded_fn(): 12 | print(Simple().hello_world()) 13 | 14 | 15 | @appmap.noappmap 16 | class TestNotRecorded: 17 | def test_unrecorded_method(self): 18 | print(Simple().hello_world()) 19 | -------------------------------------------------------------------------------- /_appmap/test/data/appmap.yml: -------------------------------------------------------------------------------- 1 | name: TestApp 2 | packages: 3 | # note: this rule needs to go first, before the more general rule 4 | - path: example_class.Super 5 | shallow: true 6 | - path: example_class 7 | - path: properties_class 8 | - path: appmap_testing 9 | - path: package1 10 | - dist: PyYAML 11 | labels: 12 | serialization: yaml.dump 13 | example-label: 14 | - example_class.ExampleClass.test_exception 15 | - yaml.dump 16 | -------------------------------------------------------------------------------- /_appmap/trace_logger.py: -------------------------------------------------------------------------------- 1 | # trace_logger.py 2 | import logging 3 | 4 | TRACE = logging.DEBUG - 5 5 | # pyright: reportAttributeAccessIssue=false 6 | class TraceLogger(logging.Logger): 7 | def trace(self, msg, /, *args, **kwargs): 8 | if self.isEnabledFor(TRACE): 9 | self._log(TRACE, msg, args, **kwargs) 10 | 11 | 12 | def install(): 13 | logging.setLoggerClass(TraceLogger) 14 | logging.addLevelName(TRACE, "TRACE") 15 | -------------------------------------------------------------------------------- /.github/workflows/add-issues-to-board.yml: -------------------------------------------------------------------------------- 1 | name: Add new issues to project tracker 2 | 3 | on: 4 | issues: 5 | types: 6 | - opened 7 | 8 | jobs: 9 | add-to-project: 10 | name: Add issue to project 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/add-to-project@main 14 | with: 15 | project-url: https://github.com/orgs/getappmap/projects/15 16 | github-token: ${{ secrets.ADD_TO_PROJECT_BOARD_PAT }} 17 | -------------------------------------------------------------------------------- /.github/actions/refetch-artifacts/action.yml: -------------------------------------------------------------------------------- 1 | name: Refetch artifacts 2 | runs: 3 | using: "composite" 4 | steps: 5 | - name: download wheel.zip 6 | uses: actions/download-artifact@v4 7 | with: 8 | name: wheel 9 | path: ./dist 10 | - name: download sdist.zip 11 | uses: actions/download-artifact@v4 12 | with: 13 | name: sdist 14 | path: ./dist 15 | - name: inspect 16 | shell: bash 17 | run: ls dist/ 18 | -------------------------------------------------------------------------------- /_appmap/test/data/pytest/simple.py: -------------------------------------------------------------------------------- 1 | class Simple: 2 | def hello(self): 3 | return "Hello" 4 | 5 | def world(self): 6 | return "world!" 7 | 8 | def hello_world(self): 9 | return "%s %s" % (self.hello(), self.world()) 10 | 11 | def show_numpy_dict(self): 12 | from numpy import int64 13 | 14 | d = self.get_numpy_dict({int64(0): "zero", int64(1): "one"}) 15 | print(d) 16 | return d 17 | 18 | def get_numpy_dict(self, d): 19 | return d -------------------------------------------------------------------------------- /_appmap/test/data/trial/test/test_deferred.py: -------------------------------------------------------------------------------- 1 | import time # noqa: F401 2 | 3 | from twisted.internet import defer, reactor 4 | from twisted.trial import unittest 5 | 6 | 7 | class TestDeferred(unittest.TestCase): 8 | def test_hello_world(self): 9 | d = defer.Deferred() 10 | 11 | def cb(_): 12 | self.assertTrue(False) 13 | 14 | d.addCallback(cb) 15 | 16 | reactor.callLater(1, d.callback, None) 17 | 18 | return d 19 | 20 | test_hello_world.todo = "don't fix me" 21 | -------------------------------------------------------------------------------- /appmap/command/appmap_agent_init.py: -------------------------------------------------------------------------------- 1 | import json 2 | import sys 3 | 4 | import yaml 5 | 6 | from _appmap.configuration import Config 7 | 8 | 9 | def _run(): 10 | print( 11 | json.dumps( 12 | { 13 | "configuration": { 14 | "filename": "appmap.yml", 15 | "contents": yaml.dump(Config.current.default), 16 | } 17 | } 18 | ) 19 | ) 20 | 21 | return 0 22 | 23 | 24 | def run(): 25 | sys.exit(_run()) 26 | 27 | 28 | if __name__ == "__main__": 29 | run() 30 | -------------------------------------------------------------------------------- /_appmap/test/data/django/djangoapp/__init__.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import django 4 | import django.conf 5 | 6 | django.conf.settings.configure( 7 | DATABASES={"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}}, 8 | MIDDLEWARE=["django.middleware.http.ConditionalGetMiddleware"], 9 | ROOT_URLCONF="djangoapp.urls", 10 | TEMPLATES=[ 11 | { 12 | "BACKEND": "django.template.backends.django.DjangoTemplates", 13 | "DIRS": [Path(__file__).parent], 14 | } 15 | ], 16 | SECRET_KEY="not-a-secret", 17 | ) 18 | 19 | django.setup() 20 | -------------------------------------------------------------------------------- /_appmap/test/data/pytest/tests/test_simple.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | 5 | 6 | def test_hello_world(): 7 | from simple import Simple 8 | 9 | os.chdir("/tmp") 10 | assert Simple().hello_world() == "Hello world!" 11 | 12 | assert len(Simple().show_numpy_dict()) > 0 13 | 14 | 15 | def test_status_failed(): 16 | assert False 17 | 18 | 19 | @pytest.mark.xfail 20 | def test_status_xfailed(): 21 | assert False 22 | 23 | 24 | @pytest.mark.xfail 25 | def test_status_xsucceeded(): 26 | assert True 27 | 28 | 29 | def test_status_errored(): 30 | raise RuntimeError("test error") 31 | -------------------------------------------------------------------------------- /_appmap/__init__.py: -------------------------------------------------------------------------------- 1 | """PYTEST_DONT_REWRITE""" 2 | 3 | from . import configuration, event, importer, metadata, recorder, recording, web_framework 4 | from . import env as appmapenv 5 | from .py_version_check import check_py_version 6 | 7 | 8 | def initialize(**kwargs): 9 | check_py_version() 10 | appmapenv.initialize(**kwargs) 11 | event.initialize() 12 | importer.initialize() 13 | recorder.initialize() 14 | configuration.initialize() # needs to be initialized after recorder 15 | metadata.initialize() 16 | web_framework.initialize() 17 | recording.initialize() 18 | 19 | 20 | initialize() 21 | -------------------------------------------------------------------------------- /ci/scripts/build_with_poetry.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | set -o pipefail 5 | 6 | if [ -z "$DISTRIBUTION_NAME" ] || [ "$DISTRIBUTION_NAME" = "appmap" ] ; then 7 | exec poetry build $* 8 | fi 9 | 10 | echo "Altering distribution name to $DISTRIBUTION_NAME" 11 | 12 | cp -v pyproject.toml /tmp/pyproject.bak 13 | sed -i -e "s/^name = \".*\"/name = \"${DISTRIBUTION_NAME}\"/" pyproject.toml 14 | grep -n 'name = "' pyproject.toml 15 | 16 | poetry build $* 17 | 18 | echo "Not patching artifacts with Provides-Dist, they won't work anyway (this flow is solely for publishing test)" 19 | cp -v /tmp/pyproject.bak pyproject.toml 20 | -------------------------------------------------------------------------------- /_appmap/test/data/pytest-instrumented/test_instrumented.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | # Copied from pytest-dev/pytest. When recorded, this test case will raise an OutcomeException 4 | # (specifically _pytest.outcomes.Skipped). 5 | def test_skipped(pytester): 6 | pytester.makeconftest( 7 | """ 8 | import pytest 9 | def pytest_ignore_collect(): 10 | pytest.skip("intentional") 11 | """ 12 | ) 13 | pytester.makepyfile("def test_hello(): pass") 14 | result = pytester.runpytest_inprocess() 15 | assert result.ret == pytest.ExitCode.NO_TESTS_COLLECTED 16 | result.stdout.fnmatch_lines(["*1 skipped*"]) 17 | -------------------------------------------------------------------------------- /ci/scripts/run_tests.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | SMOKETEST_DOCKER_IMAGE=${SMOKETEST_DOCKER_IMAGE:-"python:3.11"} 4 | DISTRIBUTION_NAME=${DISTRIBUTION_NAME:-appmap} 5 | 6 | set -x 7 | t=$([ -t 0 ] && echo 't') 8 | docker run -q -i${t} --rm \ 9 | -v $PWD/dist:/dist \ 10 | -v $PWD/_appmap/test/data/unittest:/_appmap/test/data/unittest\ 11 | -v $PWD/ci/tests:/ci/tests\ 12 | -v $PWD/.git:/tmp/.git:ro\ 13 | -v $PWD/ci/tests/data/readonly-mount-appmap.log:/tmp/appmap.log:ro\ 14 | -w /tmp\ 15 | -e DISTRIBUTION_NAME \ 16 | $SMOKETEST_DOCKER_IMAGE bash -ce "${@:-/ci/tests/smoketest.sh; /ci/tests/test_pipenv.sh; /ci/tests/test_poetry.sh}" 17 | -------------------------------------------------------------------------------- /_appmap/py_version_check.py: -------------------------------------------------------------------------------- 1 | import platform 2 | import sys 3 | 4 | 5 | class AppMapPyVerException(Exception): 6 | pass 7 | 8 | 9 | # Library code uses these, so provide intermediate 10 | # functions that can be stubbed when testing. 11 | def _get_py_version(): 12 | return sys.version_info 13 | 14 | 15 | def _get_platform_version(): 16 | return platform.python_version() 17 | 18 | 19 | def check_py_version(): 20 | req = (3, 8) 21 | actual = _get_platform_version() 22 | if _get_py_version() < req: 23 | raise AppMapPyVerException( 24 | f"Minimum Python version supported is {req[0]}.{req[1]}, found {actual}" 25 | ) 26 | -------------------------------------------------------------------------------- /_appmap/test/data/django/djangoapp/settings_dev.py: -------------------------------------------------------------------------------- 1 | # If the SECRET_KEY isn't defined we get the misleading error message 2 | # CommandError: You must set settings.ALLOWED_HOSTS if DEBUG is False. 3 | SECRET_KEY = "3*+d^_kjnr2gz)4q2m(&&^%$p4fj5dk3%lz4pl3g4m-%6!gf&)" 4 | 5 | # Must set DEBUG=True else we get the error 6 | # $ python manage.py runserver 0.0.0.0:8000 7 | # CommandError: You must set settings.ALLOWED_HOSTS if DEBUG is False. 8 | DEBUG = True 9 | 10 | # Must set ROOT_URLCONF else we get 11 | # AttributeError: 'Settings' object has no attribute 'ROOT_URLCONF' 12 | ROOT_URLCONF = "djangoapp.urls" 13 | 14 | # Turn off deprecation warning 15 | USE_TZ = True 16 | -------------------------------------------------------------------------------- /_appmap/test/data/flask/test_app.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from flaskapp import app 3 | 4 | 5 | @pytest.fixture(name="client") 6 | def test_client(): 7 | with app.test_client() as c: # pylint: disable=no-member 8 | yield c 9 | 10 | 11 | def test_request(client): 12 | response = client.get("/") 13 | 14 | assert response.status_code == 200 15 | 16 | def test_not_found(client): 17 | response = client.get("/not_found") 18 | 19 | assert response.status_code == 404 20 | 21 | 22 | def test_errorhandler(client): 23 | response = client.post("/do_post", content_type="application/json") 24 | 25 | assert response.status_code == 400 26 | assert response.text == "That's a bad request!" 27 | -------------------------------------------------------------------------------- /appmap/labeling/flask.yml: -------------------------------------------------------------------------------- 1 | security.authentication: 2 | - werkzeug.security.check_password_hash 3 | 4 | security.authorization: 5 | - flask_login.UserMixin.is_authenticated 6 | - flask_login.UserMixin.is_active 7 | - flask_login.UserMixin.is_anonymous 8 | - flask_login.AnonymousUserMixin.is_authenticated 9 | - flask_login.AnonymousUserMixin.is_active 10 | - flask_login.AnonymousUserMixin.is_anonymous 11 | - flask_authorize.has_permission 12 | - flask_authorize.user_has_role 13 | - flask_authorize.user_in_group 14 | - flask_authorize.authorize.has_role 15 | - flask_authorize.authorize.in_group 16 | - flask_authorize.authorize.read 17 | - flask_authorize.authorize.update 18 | - flask_authorize.authorize.delete 19 | -------------------------------------------------------------------------------- /.github/actions/setup-semantic-release/action.yml: -------------------------------------------------------------------------------- 1 | name: setup semantic-release with plugins 2 | runs: 3 | using: composite 4 | steps: 5 | - uses: actions/setup-node@v5 6 | id: setup-node 7 | - uses: actions/cache@v4 8 | with: 9 | path: ~/.npm 10 | key: ${{ runner.os }}-npm-${{ steps.setup-node.node-version }} 11 | - shell: bash 12 | run: | 13 | npm i -g \ 14 | semantic-release \ 15 | @semantic-release/exec \ 16 | @semantic-release/git \ 17 | @semantic-release/github \ 18 | @semantic-release/changelog \ 19 | @google/semantic-release-replace-plugin 20 | -------------------------------------------------------------------------------- /.github/workflows/plan.yml: -------------------------------------------------------------------------------- 1 | name: Plan issue with Navie 2 | 3 | on: 4 | issues: 5 | types: [opened, edited, reopened, labeled, unlabeled] 6 | 7 | permissions: 8 | contents: read 9 | issues: write 10 | 11 | jobs: 12 | plan: 13 | if: contains(github.event.issue.labels.*.name, 'navie-plan') 14 | runs-on: ubuntu-latest 15 | env: 16 | OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v4 20 | with: 21 | submodules: recursive 22 | 23 | - name: Plan with Navie 24 | uses: getappmap/navie-editor/plan@main 25 | with: 26 | issue_id: ${{ github.event.issue.number }} 27 | github_token: ${{ secrets.GITHUB_TOKEN }} 28 | -------------------------------------------------------------------------------- /_appmap/test/data/flask-instrumented/flaskapp.py: -------------------------------------------------------------------------------- 1 | """ 2 | Rudimentary Flask application for testing. 3 | """ 4 | # pylint: disable=missing-function-docstring 5 | 6 | import werkzeug 7 | from appmap.flask import AppmapFlask 8 | from flask import Flask, request 9 | 10 | app = Flask(__name__) 11 | AppmapFlask(app).init_app() 12 | 13 | 14 | @app.route("/") 15 | def hello_world(): 16 | return "Hello, World!" 17 | 18 | @app.route("/exception") 19 | def raise_exception(): 20 | raise Exception("An exception") 21 | 22 | @app.post("/do_post") 23 | def do_post(): 24 | _ = request.get_json() 25 | return "Got post request" 26 | 27 | 28 | @app.errorhandler(werkzeug.exceptions.BadRequest) 29 | def handle_bad_request(e): 30 | return "That's a bad request!", 400 -------------------------------------------------------------------------------- /_appmap/test/data/django/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "djangoapp.settings") 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == "__main__": 22 | main() 23 | -------------------------------------------------------------------------------- /appmap/labeling/__init__.py: -------------------------------------------------------------------------------- 1 | """ This module provides predefined labels for some common library functions. 2 | For consistency, the labels are defined in YAML data files in this module, 3 | structured exactly like the labels section in appmap.yml. 4 | """ 5 | 6 | from functools import lru_cache 7 | 8 | import yaml 9 | from importlib_resources import files 10 | 11 | from _appmap.labels import LabelSet # noqa: F401 12 | 13 | 14 | @lru_cache(maxsize=None) 15 | def presets(): 16 | """Load the LabelSet of the presets included with appmap-python.""" 17 | labels = [] 18 | for resource in files(__name__).iterdir(): 19 | if resource.suffix == ".yml": 20 | loaded = yaml.safe_load(resource.read_text()) 21 | labels.append(loaded) 22 | return labels 23 | -------------------------------------------------------------------------------- /_appmap/test/data/trial/expected/pytest-no-test-cases.appmap.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.9", 3 | "metadata": { 4 | "language": { 5 | "name": "python" 6 | }, 7 | "client": { 8 | "name": "appmap", 9 | "url": "https://github.com/applandinc/appmap-python" 10 | }, 11 | "feature_group": "Deferred", 12 | "recording": { 13 | "defined_class": "test.test_deferred.TestDeferred", 14 | "method_id": "test_hello_world" 15 | }, 16 | "source_location": "test/test_deferred.py:7", 17 | "name": "Deferred hello world", 18 | "feature": "Hello world", 19 | "app": "deferred", 20 | "recorder": { 21 | "name": "pytest", 22 | "type": "tests" 23 | }, 24 | "test_status": "succeeded" 25 | }, 26 | "events": [], 27 | "classMap": [] 28 | } -------------------------------------------------------------------------------- /_appmap/test/data/params.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | 4 | class C: 5 | @staticmethod 6 | def static(p): 7 | return p 8 | 9 | @classmethod 10 | def cls(cls, p): 11 | return p 12 | 13 | def zero(self): 14 | return "Hello world!" 15 | 16 | def one(self, p): 17 | return p 18 | 19 | def args_kwargs(self, *args, **kwargs): 20 | return "%s %s" % (args, kwargs) 21 | 22 | def with_defaults(self, p1=1, p2=2): 23 | return "%s %s" % (p1, p2) 24 | 25 | 26 | if sys.version_info >= (3, 8): 27 | exec( 28 | """ 29 | def positional_only(self, p1, p2, /): 30 | return '%s %s' % (p1, p2) 31 | C.positional_only = positional_only 32 | 33 | def keyword_only(self, *, p1, p2): 34 | return '%s %s' % (p1, p2) 35 | C.keyword_only = keyword_only 36 | """ 37 | ) 38 | -------------------------------------------------------------------------------- /_appmap/fastapi.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file contains a FastAPI app that is mounted on /_appmap to expose the remote-recording endpoint 3 | in a user's app. 4 | 5 | It should only be imported if other code has already verified that FastAPI is available. 6 | """ 7 | 8 | from fastapi import FastAPI, Response 9 | 10 | from . import remote_recording 11 | 12 | app = FastAPI() 13 | 14 | 15 | def _rr_response(fn): 16 | body, rrstatus = fn() 17 | return Response(content=body, status_code=rrstatus, media_type="application/json") 18 | 19 | 20 | @app.get("/record") 21 | def status(): 22 | return _rr_response(remote_recording.status) 23 | 24 | 25 | @app.post("/record") 26 | def start(): 27 | return _rr_response(remote_recording.start) 28 | 29 | 30 | @app.delete("/record") 31 | def stop(): 32 | return _rr_response(remote_recording.stop) 33 | -------------------------------------------------------------------------------- /_appmap/test/data/django/djangoapp/settings.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | 4 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 5 | BASE_DIR = Path(__file__).resolve().parent.parent 6 | 7 | # If the SECRET_KEY isn't defined we get the misleading error message 8 | # CommandError: You must set settings.ALLOWED_HOSTS if DEBUG is False. 9 | SECRET_KEY = "3*+d^_kjnr2gz)4q2m(&&^%$p4fj5dk3%lz4pl3g4m-%6!gf&)" 10 | 11 | DEBUG = False 12 | ALLOWED_HOSTS = ["*"] 13 | # Must set ROOT_URLCONF else we get 14 | # AttributeError: 'Settings' object has no attribute 'ROOT_URLCONF' 15 | ROOT_URLCONF = "djangoapp.urls" 16 | 17 | # Turn off deprecation warning 18 | USE_TZ = True 19 | 20 | DATABASES = { 21 | "default": { 22 | "ENGINE": "django.db.backends.sqlite3", 23 | "NAME": BASE_DIR / "db.sqlite3", 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /_appmap/test/data/flask-instrumented/test_app.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from flaskapp import app 3 | 4 | 5 | @pytest.fixture(name="client") 6 | def test_client(): 7 | with app.test_client() as c: # pylint: disable=no-member 8 | yield c 9 | 10 | 11 | def test_request(client): 12 | response = client.get("/") 13 | 14 | assert response.status_code == 200 15 | 16 | def test_exception(client): 17 | response = client.get("/exception") 18 | 19 | assert response.status_code == 500 20 | 21 | def test_not_found(client): 22 | response = client.get("/not_found") 23 | 24 | assert response.status_code == 404 25 | 26 | 27 | def test_errorhandler(client): 28 | response = client.post("/do_post", content_type="application/json") 29 | 30 | assert response.status_code == 400 31 | assert response.text == "That's a bad request!" 32 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | markers = 3 | app 4 | datafiles: load datafiles 5 | appmap_enabled 6 | appmap_record_requests 7 | example_dir 8 | server: arguments for server fixture 9 | 10 | testpaths = _appmap/test 11 | pytester_example_dir = _appmap/test/data 12 | 13 | # running in a subprocess ensures that environment variables are set correctly and no classes are 14 | # loaded. Also, the remote-recording tests can't be run in parallel, so they're marked to run in the 15 | # same load group and distribution is done by loadgroup. 16 | addopts = --runpytest subprocess --ignore vendor --tb=short --dist loadgroup 17 | 18 | # We're stuck at pytest ~6.1.2. This warning got removed in a later 19 | # version. 20 | filterwarnings = ignore:testdir.copy_example is an experimental api that may change over time 21 | 22 | env = 23 | APPMAP_DISABLE_LOG_FILE = true -------------------------------------------------------------------------------- /appmap/uvicorn.py: -------------------------------------------------------------------------------- 1 | # uvicorn integration 2 | from uvicorn.config import Config 3 | 4 | from _appmap import wrapt 5 | from _appmap.env import Env 6 | 7 | 8 | def install_extension(wrapped, config, args, kwargs): 9 | wrapped(*args, **kwargs) 10 | try: 11 | # pylint: disable=import-outside-toplevel 12 | from .fastapi import FastAPIInserter 13 | 14 | # pylint: enable=import-outside-toplevel 15 | 16 | app = config.loaded_app 17 | if app: 18 | # uvicorn doc recommends running with `--reload` in development, so use 19 | # that to decide whether to enable remote recording 20 | config.loaded_app = FastAPIInserter(config.loaded_app, config.reload).run() 21 | except ImportError: 22 | # Not FastAPI 23 | pass 24 | 25 | 26 | if Env.current.enabled: 27 | Config.load = wrapt.wrap_function_wrapper("uvicorn.config", "Config.load", install_extension) 28 | -------------------------------------------------------------------------------- /_appmap/flask.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file contains a Flask app that is mounted on /_appmap to expose the remote-recording endpoint 3 | in a user's app. 4 | 5 | It should only be imported if other code has already verified that Flask is available. 6 | """ 7 | 8 | from flask import Flask, Response 9 | 10 | from . import remote_recording 11 | 12 | app = Flask(__name__) 13 | 14 | 15 | @app.route("/record", methods=["GET"]) 16 | def status(): 17 | body, rrstatus = remote_recording.status() 18 | return Response(body, status=rrstatus, mimetype="application/json") 19 | 20 | 21 | @app.route("/record", methods=["POST"]) 22 | def start(): 23 | body, rrstatus = remote_recording.start() 24 | return Response(body, status=rrstatus, mimetype="application/json") 25 | 26 | 27 | @app.route("/record", methods=["DELETE"]) 28 | def stop(): 29 | body, rrstatus = remote_recording.stop() 30 | return Response(body, status=rrstatus, mimetype="application/json") 31 | -------------------------------------------------------------------------------- /_appmap/test/test_util.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test util functionality 3 | """ 4 | 5 | import uuid 6 | from pathlib import Path 7 | 8 | from _appmap.utils import locate_file_up, scenario_filename 9 | 10 | 11 | def test_scenario_filename__short(): 12 | """leaves short names alone""" 13 | assert scenario_filename("foobar") == "foobar" 14 | 15 | 16 | def test_scenario_filename__special_character(): 17 | """has a customizable suffix""" 18 | assert scenario_filename("foobar?=65") == "foobar_65" 19 | 20 | def test_locate_file_up(data_dir): 21 | result = locate_file_up("appmap.yml", Path(data_dir) / "package1" / "package2") 22 | assert result.parts[-3:] == ("_appmap", "test", "data") 23 | 24 | result = locate_file_up("test_util.py", Path(data_dir) / "package1" / "package2") 25 | assert result.parts[-2:] == ("_appmap", "test") 26 | 27 | impossible_file_name = str(uuid.uuid4()) + ".yml" 28 | result = locate_file_up(impossible_file_name, data_dir) 29 | assert result is None 30 | -------------------------------------------------------------------------------- /appmap/labeling/formats.yml: -------------------------------------------------------------------------------- 1 | format.yaml.generate: 2 | - yaml.dump 3 | - yaml.dump_all 4 | - yaml.serialize_all 5 | format.yaml.parse: 6 | - yaml.scan 7 | - yaml.parse 8 | - yaml.compose 9 | - yaml.load 10 | - yaml.load_all 11 | format.json.generate: 12 | - json.dumps 13 | - json.dump 14 | format.json.parse: 15 | - json.loads 16 | - json.load 17 | format.pickle.generate: 18 | - pickle.dumps 19 | - pickle.dump 20 | format.pickle.parse: 21 | - pickle.loads 22 | - pickle.load 23 | format.marshal.generate: 24 | - marshal.dumps 25 | - marshal.dump 26 | format.marshal.parse: 27 | - marshal.loads 28 | - marshal.load 29 | deserialize.unsafe: 30 | - yaml.load 31 | - yaml.load_all 32 | - pickle.loads 33 | - pickle.load 34 | - marshal.loads 35 | - marshal.load 36 | deserialize.safe: 37 | - json.loads 38 | - json.load 39 | serialize: 40 | - yaml.dump 41 | - yaml.dump_all 42 | - yaml.serialize_all 43 | - json.dumps 44 | - json.dump 45 | - pickle.dumps 46 | - pickle.dump 47 | - marshal.dumps 48 | - marshal.dump 49 | -------------------------------------------------------------------------------- /_appmap/test/test_django_simplelazyobject.py: -------------------------------------------------------------------------------- 1 | """Test django.utils.functional.SimpleLazyObject handling""" 2 | 3 | import pytest 4 | import appmap 5 | 6 | @pytest.mark.appmap_enabled 7 | @pytest.mark.usefixtures("with_data_dir") 8 | def test_recording_simplelazyobject_does_not_evaluate(): 9 | """Test if recording a django.utils.functional.SimpleLazyObject does not force evaluation. 10 | 11 | SimpleLazyObject is a simple delayed instantiation utility. 12 | It only lazily instantiates an object when needed by evaluating a function. 13 | Since it can be used not only for performance, but also to delay 14 | instantiating objects until after some setup has been completed, we 15 | need to make sure simply recording a function which returns it 16 | doesn't cause incorrect premature evaluation. 17 | """ 18 | with appmap.Recording(): 19 | import appmap_testing.django_simplelazyobject as ecds # pylint: disable=import-outside-toplevel, import-error 20 | 21 | ecds.lazy() 22 | 23 | # if we're here and the exception wasn't thrown, we're good 24 | -------------------------------------------------------------------------------- /_appmap/test/data/django/test/test_app.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.core.handlers.base import BaseHandler 3 | 4 | 5 | @pytest.mark.parametrize( 6 | "mware", 7 | [["djangoapp.middleware.hello_world"], ("djangoapp.middleware.hello_world",)], 8 | ) 9 | def test_middleware_iterable_with_reset(client, settings, mware): 10 | """ 11 | Test that we can update the middleware, whatever type of iterable it may be (Django doesn't 12 | care). 13 | """ 14 | settings.DEBUG = True 15 | settings.MIDDLEWARE = mware 16 | orig_type = type(settings.MIDDLEWARE) 17 | response = client.get("/_appmap/record") 18 | handler = BaseHandler() 19 | handler.load_middleware() 20 | assert type(settings.MIDDLEWARE) == orig_type 21 | assert response.status_code == 200 22 | 23 | 24 | def test_request(client): 25 | response = client.get("/") 26 | 27 | assert response.status_code == 200 28 | 29 | 30 | def test_remote_disabled_in_prod(client, settings): 31 | settings.DEBUG = False 32 | response = client.get("/_appmap/record") 33 | assert response.status_code == 404 34 | -------------------------------------------------------------------------------- /_appmap/remote_recording.py: -------------------------------------------------------------------------------- 1 | """ remote_recording contains the functions neccessary to implement a remote-recording endpoint. """ 2 | 3 | import json 4 | from threading import Lock 5 | 6 | from . import generation 7 | from .recorder import Recorder 8 | 9 | # pylint: disable=global-statement 10 | _enabled_lock = Lock() 11 | _enabled = False 12 | 13 | 14 | def status(): 15 | with _enabled_lock: 16 | return json.dumps({"enabled": _enabled}), 200 17 | 18 | 19 | def start(): 20 | global _enabled 21 | with _enabled_lock: 22 | if _enabled: 23 | return "Recording is already in progress", 409 24 | 25 | Recorder.new_global().start_recording() 26 | _enabled = True 27 | return "", 200 28 | 29 | 30 | def stop(): 31 | global _enabled 32 | with _enabled_lock: 33 | if not _enabled: 34 | return "No recording is in progress", 404 35 | r = Recorder.get_global() 36 | r.stop_recording() 37 | _enabled = False 38 | return generation.dump(r), 200 39 | 40 | 41 | def initialize(): 42 | global _enabled 43 | _enabled = False 44 | -------------------------------------------------------------------------------- /_appmap/django.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file contains Django middleware that can be inserted into an app's stack to expose the remote 3 | recording endpoint. 4 | 5 | It expects the importer to have verified that Django is available. 6 | """ 7 | 8 | from http import HTTPStatus 9 | 10 | from django.http import HttpRequest, HttpResponse 11 | 12 | from . import remote_recording 13 | 14 | 15 | class RemoteRecording: # pylint: disable=missing-class-docstring,too-few-public-methods 16 | def __init__(self, get_response): 17 | super().__init__() 18 | self.get_response = get_response 19 | 20 | def __call__(self, request: HttpRequest): 21 | if request.path != "/_appmap/record": 22 | return self.get_response(request) 23 | 24 | handlers = { 25 | "GET": remote_recording.status, 26 | "POST": remote_recording.start, 27 | "DELETE": remote_recording.stop, 28 | } 29 | 30 | def not_allowed(): 31 | return "", HTTPStatus.METHOD_NOT_ALLOWED 32 | 33 | assert request.method is not None 34 | body, status = handlers.get(request.method, not_allowed)() 35 | 36 | return HttpResponse(body, status=status, content_type="application/json") 37 | -------------------------------------------------------------------------------- /ci/scripts/patch_artifacts_if_distribution_name_is_altered.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | set -o pipefail 4 | 5 | artifacts=$* 6 | injection_string="Provides-Dist: appmap" 7 | if [ -n "$artifacts" ] && [ -n "$DISTRIBUTION_NAME" ] && [ "$DISTRIBUTION_NAME" != "appmap" ]; then 8 | echo "Altered distribution name detected, injecting '$injection_string' into artifacts: $artifacts" 9 | for artifact in $artifacts ; do 10 | TMP=$(mktemp -d) 11 | ARTIFACT_PATH="$(realpath ${artifact})" 12 | if [[ $artifact == *.whl ]]; then 13 | unzip -q "$ARTIFACT_PATH" -d "$TMP" 14 | DISTINFO=$(find "$TMP" -type d -name "*.dist-info") 15 | echo "$injection_string" >> "$DISTINFO/METADATA" 16 | (cd "$TMP" && zip -qr "$ARTIFACT_PATH" .) 17 | else 18 | tar -xzf "$ARTIFACT_PATH" -C "$TMP" 19 | PKG_INFO_FILE=$(find "$TMP" -type f -name "PKG-INFO") 20 | echo "$injection_string" >> "$PKG_INFO_FILE" 21 | 22 | # Get the top-level directory to repack correctly 23 | PKGDIR=$(find "$TMP" -mindepth 1 -maxdepth 1 -type d) 24 | (cd "$TMP" && tar -czf "$ARTIFACT_PATH" "$(basename "$PKGDIR")") 25 | fi 26 | echo "($injection_string): patched $ARTIFACT_PATH" 27 | rm -rf "$TMP" 28 | done 29 | fi 30 | -------------------------------------------------------------------------------- /vendor/_appmap/wrapt/__init__.py: -------------------------------------------------------------------------------- 1 | __version_info__ = ('1', '15', '0') 2 | __version__ = '.'.join(__version_info__) 3 | 4 | from .wrappers import (ObjectProxy, CallableObjectProxy, FunctionWrapper, 5 | BoundFunctionWrapper, WeakFunctionProxy, PartialCallableObjectProxy, 6 | resolve_path, apply_patch, wrap_object, wrap_object_attribute, 7 | function_wrapper, wrap_function_wrapper, patch_function_wrapper, 8 | transient_function_wrapper) 9 | 10 | from .decorators import (adapter_factory, AdapterFactory, decorator, 11 | synchronized) 12 | 13 | from .importer import (register_post_import_hook, when_imported, 14 | notify_module_loaded, discover_post_import_hooks) 15 | 16 | # Import of inspect.getcallargs() included for backward compatibility. An 17 | # implementation of this was previously bundled and made available here for 18 | # Python <2.7. Avoid using this in future. 19 | 20 | from inspect import getcallargs 21 | 22 | # Variant of inspect.formatargspec() included here for forward compatibility. 23 | # This is being done because Python 3.11 dropped inspect.formatargspec() but 24 | # code for handling signature changing decorators relied on it. Exposing the 25 | # bundled implementation here in case any user of wrapt was also needing it. 26 | 27 | from .arguments import formatargspec 28 | -------------------------------------------------------------------------------- /vendor/_appmap/wrapt/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013-2023, Graham Dumpleton 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 15 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 16 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 17 | ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE 18 | LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 19 | CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 20 | SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 21 | INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 22 | CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 23 | ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 24 | POSSIBILITY OF SUCH DAMAGE. 25 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to appmap-python 2 | 3 | We are incredibly thankful for the contributions we receive from the community. Before contributing, please take a moment to read our [Contributor License Agreement](https://github.com/applandorg/community/blob/master/docs/CLA%20Instructions.pdf) and our [Code of Conduct](https://github.com/applandorg/community/blob/master/docs/Code%20of%20Conduct%20for%20Contributors.pdf). 4 | 5 | ## Contributor License Agreement 6 | We require our external contributors to sign a Contributor License Agreement ("CLA") in order to ensure that 7 | our projects remain licensed under Free and Open Source licenses such as while allowing 8 | AppLand to build a sustainable business. 9 | 10 | AppLand is committed to having a true Free and Open Source Software license for our 11 | non-commercial software. A CLA enables AppLand to safely commercialize our products while 12 | keeping a standard FOSS license with all the rights that license grants to users. 13 | 14 | * [Contributor License Agreement](https://github.com/applandorg/community/blob/master/docs/CLA%20Instructions.pdf) 15 | 16 | 17 | ## Code of Conduct 18 | 19 | We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and 20 | healthy community. 21 | 22 | * [Code of Conduct](https://github.com/applandorg/community/blob/master/docs/Code%20of%20Conduct%20for%20Contributors.pdf) 23 | -------------------------------------------------------------------------------- /_appmap/test/data/remote.appmap.json: -------------------------------------------------------------------------------- 1 | { 2 | "classMap": [], 3 | "events": [ 4 | { 5 | "event": "call", 6 | "http_server_request": { 7 | "path_info": "/", 8 | "normalized_path_info": "/", 9 | "protocol": "HTTP/1.1", 10 | "request_method": "GET" 11 | }, 12 | "id": 1 13 | }, 14 | { 15 | "event": "return", 16 | "http_server_response": { 17 | "status_code": 200 18 | }, 19 | "id": 2, 20 | "parent_id": 1 21 | }, 22 | { 23 | "event": "call", 24 | "http_server_request": { 25 | "path_info": "/user/test_user", 26 | "normalized_path_info": "/user/{username}", 27 | "protocol": "HTTP/1.1", 28 | "request_method": "GET" 29 | }, 30 | "id": 3 31 | }, 32 | { 33 | "event": "return", 34 | "http_server_response": { 35 | "status_code": 200 36 | }, 37 | "id": 4, 38 | "parent_id": 3 39 | } 40 | ], 41 | "metadata": { 42 | "client": { 43 | "name": "appmap", 44 | "url": "https://github.com/applandinc/appmap-python" 45 | }, 46 | "language": { 47 | "name": "python" 48 | } 49 | }, 50 | "version": "1.9" 51 | } 52 | -------------------------------------------------------------------------------- /_appmap/test/data/unittest/simple/test_simple.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import patch 3 | 4 | import simple # isort: skip 5 | 6 | # Importing from decouple will cause a failure if we're not hooking 7 | # finders correctly. 8 | from decouple import config # noqa: F401 9 | 10 | import appmap 11 | 12 | 13 | class UnitTestTest(unittest.TestCase): 14 | def test_hello_world(self): 15 | self.assertEqual(simple.Simple().hello_world("!"), "Hello world!") 16 | 17 | @patch("simple.Simple.hello_world") 18 | def test_patch(self, patched_hw): 19 | simple.Simple().hello_world("!") 20 | patched_hw.assert_called_once_with("!") 21 | 22 | def test_status_failed(self): 23 | self.assertTrue(False) 24 | 25 | @unittest.expectedFailure 26 | def test_status_xfailed(self): 27 | self.assertTrue(False) 28 | 29 | @unittest.expectedFailure 30 | def test_status_xsucceeded(self): 31 | self.assertTrue(True) 32 | 33 | @staticmethod 34 | def test_status_errored(): 35 | raise RuntimeError("test error") 36 | 37 | def setUp(self): 38 | simple.Simple().getReady() 39 | 40 | def tearDown(self): 41 | simple.Simple().finishUp() 42 | 43 | def test_with_subtest(self): 44 | with self.subTest("subtest"): 45 | self.assertEqual(simple.Simple().hello_world("!"), "Hello world!") 46 | 47 | @appmap.noappmap 48 | def test_unrecorded(self): 49 | print(simple.Simple().hello_world("!")) 50 | -------------------------------------------------------------------------------- /.github/actions/setup/action.yml: -------------------------------------------------------------------------------- 1 | name: Setup base (python, pip cache, tox) 2 | inputs: 3 | python: 4 | description: "Python version to use" 5 | required: true 6 | type: string 7 | default: 3.12 8 | outputs: 9 | 'python-version': 10 | value: ${{ steps.python.outputs.python-version }} 11 | runs: 12 | using: "composite" 13 | steps: 14 | - name: pip cache 15 | uses: actions/cache@v4 16 | with: 17 | path: | 18 | ~/.cache/pip 19 | key: ${{ runner.os }}-pip-${{ inputs.python }} 20 | 21 | - name: Cargo cache 22 | uses: actions/cache/@v4 23 | with: 24 | path: "~/.cargo" 25 | key: ${{ runner.os }}-cargo 26 | 27 | - name: Poetry cache 28 | uses: actions/cache/@v4 29 | with: 30 | path: "~/.cache/pypoetry" 31 | key: ${{ runner.os }}-poetry-${{ inputs.python }} 32 | restore-keys: | 33 | ${{ runner.os }}-poetry- 34 | 35 | - uses: actions/setup-python@v6 36 | id: python 37 | with: 38 | python-version: ${{ inputs.python }} 39 | 40 | - name: upgrade pip and install tox 41 | shell: bash 42 | run: | 43 | python -m pip -q install --upgrade pip "setuptools==65.6.2" 44 | pip -q install "tox<4" tox-gh-actions 45 | 46 | - name: install Rust and Poetry 47 | shell: bash 48 | run : | 49 | curl https://sh.rustup.rs -sSf | sh -s -- -y --default-toolchain stable --profile minimal 50 | source "$HOME/.cargo/env" 51 | pip -q install poetry>=1.2.0 52 | -------------------------------------------------------------------------------- /.github/workflows/lint-and-test.yml: -------------------------------------------------------------------------------- 1 | name: Lint and test 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - master 7 | - 'ci/**' # ci testing, pre-releases 8 | #- 'feature/**' 9 | 10 | jobs: 11 | lint: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v5 15 | - uses: ./.github/actions/setup 16 | - name: Lint 17 | id: lint 18 | run: tox -e lint 19 | continue-on-error: true 20 | - name: Emit warning if lint failed 21 | if: ${{ steps.lint.outcome != 'success' }} 22 | run: echo "::warning::Linter failure suppressed (continue-on-error=true)" 23 | test: 24 | strategy: 25 | fail-fast: false 26 | matrix: 27 | os: [ ubuntu-latest ] 28 | python: 29 | - "3.12" 30 | - "3.11" 31 | - "3.10" 32 | - "3.9.14" 33 | - "3.8" 34 | runs-on: ${{ matrix.os }} 35 | steps: 36 | - uses: actions/checkout@v5 37 | - uses: ./.github/actions/setup 38 | with: 39 | python: ${{ matrix.python }} 40 | - name: Test 41 | run: tox 42 | validation: 43 | name: Validation 44 | runs-on: ubuntu-latest 45 | needs: [test] 46 | if: always() 47 | steps: 48 | - name: Validate matrix test success 49 | run: | 50 | # Check the status of the 'test' job (which includes all matrix variations) 51 | if [ "${{ needs.test.result }}" != "success" ]; then 52 | echo "One or more matrix test jobs failed." 53 | exit 1 54 | fi 55 | echo "All matrix test jobs passed." 56 | -------------------------------------------------------------------------------- /_appmap/test/data/flask/flaskapp.py: -------------------------------------------------------------------------------- 1 | """ 2 | Rudimentary Flask application for testing. 3 | 4 | NB: This should not explicitly reference the `appmap` module in any way. Doing so invalidates 5 | testing of record-by-default. 6 | """ 7 | # pylint: disable=missing-function-docstring 8 | 9 | from flask import Flask, make_response, request 10 | from markupsafe import escape 11 | import werkzeug 12 | 13 | app = Flask(__name__) 14 | 15 | 16 | @app.route("/") 17 | def hello_world(): 18 | return "Hello, World!" 19 | 20 | 21 | @app.route("/test") 22 | def the_test(): 23 | response = make_response("testing") 24 | response.add_etag() 25 | return response 26 | 27 | 28 | @app.route("/user/") 29 | def show_user_profile(username): 30 | # show the user profile for that user 31 | return "User %s" % escape(username) 32 | 33 | 34 | @app.route("/post/") 35 | def show_post(post_id): 36 | # show the post with the given id, the id is an integer 37 | return "Post %d" % post_id 38 | 39 | 40 | @app.route("/post///summary") 41 | def show_user_post(username, post_id): 42 | # Show the summary of a user's post 43 | return f"User {escape(username)} post {post_id}" 44 | 45 | 46 | @app.route("//posts/") 47 | def show_org_user_posts(org, username): 48 | return f"org {org} username {username}" 49 | 50 | 51 | @app.route("/exception") 52 | def raise_exception(): 53 | raise Exception("An exception") 54 | 55 | @app.post("/do_post") 56 | def do_post(): 57 | _ = request.get_json() 58 | return "Got post request" 59 | 60 | 61 | @app.errorhandler(werkzeug.exceptions.BadRequest) 62 | def handle_bad_request(e): 63 | return "That's a bad request!", 400 -------------------------------------------------------------------------------- /_appmap/test/test_metadata.py: -------------------------------------------------------------------------------- 1 | """Test Metadata""" 2 | 3 | # pylint: disable=protected-access, missing-function-docstring 4 | 5 | from _appmap.metadata import Metadata 6 | 7 | from ..test.helpers import DictIncluding 8 | 9 | 10 | def test_missing_git(git, monkeypatch): 11 | monkeypatch.setenv("PATH", "") 12 | try: 13 | metadata = Metadata(root_dir=git.cwd) 14 | assert "git" not in metadata 15 | except FileNotFoundError: 16 | assert False, "_git_available didn't handle missing git" 17 | 18 | 19 | def test_git_metadata(git): 20 | metadata = Metadata(root_dir=git.cwd) 21 | assert "git" in metadata 22 | git_md = metadata["git"] 23 | assert git_md == DictIncluding( 24 | { 25 | "repository": "https://www.example.test/repo.git", 26 | "branch": "main", 27 | } 28 | ) 29 | 30 | 31 | def test_tags(git): 32 | git("add new_file") 33 | git('commit -m "added new file"') 34 | git("rm README.metadata") 35 | git('commit -m "Removed readme"') 36 | 37 | metadata = Metadata(root_dir=git.cwd) 38 | git_md = metadata["git"] 39 | 40 | assert git_md == DictIncluding( 41 | { 42 | "repository": "https://www.example.test/repo.git", 43 | "branch": "main", 44 | } 45 | ) 46 | 47 | 48 | def test_add_framework(): 49 | Metadata.add_framework("foo", "3.4") 50 | Metadata.add_framework("foo", "3.4") 51 | assert Metadata()["frameworks"] == [{"name": "foo", "version": "3.4"}] 52 | 53 | Metadata.add_framework("bar") 54 | Metadata.add_framework("baz", "4.2") 55 | assert Metadata()["frameworks"] == [ 56 | {"name": "bar"}, 57 | {"name": "baz", "version": "4.2"}, 58 | ] 59 | -------------------------------------------------------------------------------- /_appmap/test/test_importer.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=missing-function-docstring 2 | 3 | import sys 4 | 5 | import pytest 6 | 7 | from _appmap.importer import Importer, wrap_exec_module 8 | from _appmap.wrapt.wrappers import BoundFunctionWrapper 9 | 10 | 11 | def test_exec_module_protection(monkeypatch): 12 | """ 13 | Test that recording.wrap_exec_module properly protects against 14 | rewrapping a wrapped exec_module function. Repeatedly wrap 15 | the function, up to the recursion limit, then call the wrapped 16 | function. If wrapping protection is working properly, there 17 | won't be a problem. If wrapping protection is broken, this 18 | test will fail with a RecursionError. 19 | """ 20 | 21 | def exec_module(): 22 | pass 23 | 24 | def do_import(*args, **kwargs): # pylint: disable=unused-argument 25 | pass 26 | 27 | monkeypatch.setattr(Importer, "do_import", do_import) 28 | f = exec_module 29 | for _ in range(sys.getrecursionlimit()): 30 | f = wrap_exec_module(f) 31 | 32 | f() 33 | assert True 34 | 35 | 36 | @pytest.mark.appmap_enabled(config="appmap-exclude-fn.yml") 37 | @pytest.mark.usefixtures("with_data_dir") 38 | def test_excluded(verify_example_appmap): 39 | def check_imports(*_): 40 | # pylint: disable=import-outside-toplevel, import-error 41 | from example_class import ExampleClass # pyright: ignore[reportMissingImports] 42 | 43 | # pylint: enable=import-outside-toplevel, import-error 44 | 45 | assert isinstance(ExampleClass.instance_method, BoundFunctionWrapper) 46 | assert not isinstance(ExampleClass.another_method, BoundFunctionWrapper) 47 | 48 | verify_example_appmap(check_imports, "instance_method") 49 | -------------------------------------------------------------------------------- /_appmap/test/test_http.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=missing-function-docstring,missing-function-docstring,missing-module-docstring 2 | # pylint: disable=unused-argument,unused-import 3 | import httpretty 4 | import pytest 5 | import requests 6 | 7 | import appmap.http # noqa: F401 8 | 9 | from ..test.helpers import DictIncluding 10 | 11 | 12 | def test_http_client_capture(mock_requests, events): 13 | requests.get("https://example.test/foo/bar?q=one&q=two&q2=%F0%9F%A6%A0", timeout=1) 14 | 15 | assert events[0].to_dict() == DictIncluding( 16 | { 17 | "event": "call", 18 | "defined_class": "http.client.HTTPConnection", 19 | "method_id": "request", 20 | } 21 | ) 22 | assert events[1].to_dict() == DictIncluding({"event": "return"}) 23 | 24 | request = events[2] 25 | assert request.http_client_request == { 26 | "request_method": "GET", 27 | "url": "https://example.test/foo/bar", 28 | "headers": {"Connection": "keep-alive"}, 29 | } 30 | message = request.message 31 | assert message[0] == DictIncluding({"name": "q", "value": "['one', 'two']"}) 32 | assert (message[1] == DictIncluding({"name": "q2", "value": "'🦠'"})) or ( 33 | message[1] == DictIncluding({"name": "q2", "value": "'\\U0001f9a0'"}) 34 | ) 35 | 36 | assert events[3].http_client_response == DictIncluding( 37 | {"status_code": 200, "mime_type": "text/plain; charset=utf-8"} 38 | ) 39 | 40 | 41 | @pytest.fixture(name="mock_requests") 42 | def mock_requests_fixture(): 43 | with httpretty.enabled(allow_net_connect=False): 44 | httpretty.register_uri( 45 | httpretty.GET, "https://example.test/foo/bar", body="hello" 46 | ) 47 | yield 48 | -------------------------------------------------------------------------------- /_appmap/unittest.py: -------------------------------------------------------------------------------- 1 | from _appmap import noappmap, testing_framework, wrapt 2 | from _appmap.env import Env 3 | from _appmap.utils import get_function_location 4 | 5 | _session = testing_framework.session("unittest", "tests") 6 | 7 | 8 | def _get_test_location(cls, method_name): 9 | fn = getattr(cls, method_name) 10 | return get_function_location(fn) 11 | 12 | # We need to disable request recording in TestCase._callSetUp. This prevents creation of a request 13 | # recording calls when requests made inside setUp method. 14 | # 15 | # This edge case can be observed in this test in django project: 16 | # $ APPMAP=TRUE ./runtests.py auth_tests.test_views.ChangelistTests.test_user_change_email 17 | # (ChangelistTests.setUp makes a request) 18 | @wrapt.patch_function_wrapper("unittest.case", "TestCase._callSetUp") 19 | def callSetUp(wrapped, _, args, kwargs): 20 | with Env.current.disabled("requests"): 21 | wrapped(*args, **kwargs) 22 | 23 | @wrapt.patch_function_wrapper("unittest.case", "TestCase._callTestMethod") 24 | def callTestMethod(wrapped, test_case, _, kwargs): 25 | already_recording = getattr(test_case, "_appmap_pytest_recording", None) 26 | 27 | test_method_name = test_case._testMethodName # pylint: disable=protected-access 28 | test_method = getattr(test_case, test_method_name) 29 | if already_recording or noappmap.disables(test_method, test_case.__class__): 30 | wrapped(test_method, **kwargs) 31 | return 32 | 33 | method_name = test_case.id().split(".")[-1] 34 | location = _get_test_location(test_case.__class__, method_name) 35 | testing_framework.disable_test_case(test_method) 36 | with _session.record(test_case.__class__, method_name, location=location) as metadata: 37 | if metadata: 38 | with testing_framework.collect_result_metadata(metadata): 39 | wrapped(test_method, **kwargs) 40 | -------------------------------------------------------------------------------- /vendor/_appmap/wrapt/arguments.py: -------------------------------------------------------------------------------- 1 | # The inspect.formatargspec() function was dropped in Python 3.11 but we need 2 | # need it for when constructing signature changing decorators based on result of 3 | # inspect.getargspec() or inspect.getfullargspec(). The code here implements 4 | # inspect.formatargspec() base on Parameter and Signature from inspect module, 5 | # which were added in Python 3.6. Thanks to Cyril Jouve for the implementation. 6 | 7 | try: 8 | from inspect import Parameter, Signature 9 | except ImportError: 10 | from inspect import formatargspec 11 | else: 12 | def formatargspec(args, varargs=None, varkw=None, defaults=None, 13 | kwonlyargs=(), kwonlydefaults={}, annotations={}): 14 | if kwonlydefaults is None: 15 | kwonlydefaults = {} 16 | ndefaults = len(defaults) if defaults else 0 17 | parameters = [ 18 | Parameter( 19 | arg, 20 | Parameter.POSITIONAL_OR_KEYWORD, 21 | default=defaults[i] if i >= 0 else Parameter.empty, 22 | annotation=annotations.get(arg, Parameter.empty), 23 | ) for i, arg in enumerate(args, ndefaults - len(args)) 24 | ] 25 | if varargs: 26 | parameters.append(Parameter(varargs, Parameter.VAR_POSITIONAL)) 27 | parameters.extend( 28 | Parameter( 29 | kwonlyarg, 30 | Parameter.KEYWORD_ONLY, 31 | default=kwonlydefaults.get(kwonlyarg, Parameter.empty), 32 | annotation=annotations.get(kwonlyarg, Parameter.empty), 33 | ) for kwonlyarg in kwonlyargs 34 | ) 35 | if varkw: 36 | parameters.append(Parameter(varkw, Parameter.VAR_KEYWORD)) 37 | return_annotation = annotations.get('return', Signature.empty) 38 | return str(Signature(parameters, return_annotation=return_annotation)) -------------------------------------------------------------------------------- /_appmap/test/helpers.py: -------------------------------------------------------------------------------- 1 | """Test helpers""" 2 | 3 | 4 | import importlib.metadata 5 | 6 | from packaging import version as pkg_version 7 | 8 | 9 | class DictIncluding(dict): 10 | """A dict that on comparison just checks whether the other dict includes 11 | all of its items. Any extra ones are ignored. 12 | 13 | >>> {'a': 5, 'b': 6} == DictIncluding({'a': 5}) 14 | True 15 | >>> {'a': 6, 'b': 6} == DictIncluding({'a': 5}) 16 | False 17 | 18 | This is especially useful for tests. 19 | """ 20 | 21 | def __eq__(self, other): 22 | return other.items() >= self.items() 23 | 24 | 25 | class HeadersIncluding(dict): 26 | """Like DictIncluding, but key comparison is case-insensitive.""" 27 | 28 | def __eq__(self, other): 29 | for k in self.keys(): 30 | v = other.get(k, other.get(k.lower(), None)) 31 | if v is None: 32 | return False 33 | return True 34 | 35 | 36 | def package_version(pkg): 37 | return pkg_version.parse(importlib.metadata.version(pkg)) 38 | 39 | 40 | def check_call_stack(events): 41 | """Ensure that the call stack in events has balanced call and return events""" 42 | stack = [] 43 | for e in events: 44 | if e.get("event") == "call": 45 | stack.append(e) 46 | elif e.get("event") == "return": 47 | assert len(stack) > 0, f"return without call, {e.get('id')}" 48 | call = stack.pop() 49 | assert call.get("id") == e.get( 50 | "parent_id" 51 | ), f"parent mismatch, {call.get('id')} != {e.get('parent_id')}" 52 | assert len(stack) == 0, f"leftover events, {len(stack)}" 53 | 54 | 55 | if __name__ == "__main__": 56 | import json 57 | from pathlib import Path 58 | import sys 59 | 60 | with Path(sys.argv[1]).open(encoding="utf-8") as f: 61 | appmap = json.load(f) 62 | check_call_stack(appmap["events"]) 63 | -------------------------------------------------------------------------------- /_appmap/test/data/fastapi/fastapiapp/main.py: -------------------------------------------------------------------------------- 1 | """ 2 | Rudimentary FastAPI application for testing. 3 | 4 | NB: This should not explicitly reference the `appmap` module in any way. Doing so invalidates 5 | testing of record-by-default. 6 | """ 7 | # pylint: disable=missing-function-docstring 8 | 9 | from typing import List 10 | 11 | from fastapi import FastAPI, Query, Request, Response 12 | 13 | app = FastAPI() 14 | 15 | 16 | @app.get("/") 17 | def hello_world(): 18 | return {"Hello": "World!"} 19 | 20 | 21 | @app.post("/echo") 22 | async def echo(request: Request): 23 | body = await request.body() 24 | return Response(content=body, media_type="application/json") 25 | 26 | 27 | @app.get("/test") 28 | async def get_test(my_params: List[str] = Query(None)): 29 | response = Response(content="testing", media_type="text/html; charset=utf-8") 30 | response.headers["ETag"] = "W/01" 31 | return response 32 | 33 | 34 | @app.post("/test") 35 | async def post_test(request: Request): 36 | await request.json() 37 | response = Response(content='{"test":true}', media_type="application/json") 38 | response.headers["ETag"] = "W/01" 39 | return response 40 | 41 | 42 | @app.get("/user/{username}") 43 | def get_user_profile(username): 44 | # show the user profile for that user 45 | return {"user": username} 46 | 47 | 48 | @app.get("/post/{post_id:int}") 49 | def get_post(post_id): 50 | # show the post with the given id, the id is an integer 51 | return {"post": post_id} 52 | 53 | 54 | @app.get("/post/{username}/{post_id:int}/summary") 55 | def get_user_post(username, post_id): 56 | # Show the summary of a user's post 57 | return {"user": username, "post": post_id} 58 | 59 | 60 | @app.get("/{org:int}/posts/{username}") 61 | def get_org_user_posts(org, username): 62 | return {"org": org, "username": username} 63 | 64 | 65 | @app.get("/exception") 66 | def raise_exception(): 67 | raise Exception("An exception") 68 | -------------------------------------------------------------------------------- /ci/tests/smoketest.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | 4 | test_recording_when_appmap_not_true() 5 | { 6 | cat < test_client.py 7 | from appmap import Recording 8 | 9 | with Recording(): 10 | print("Hello from appmap library client") 11 | EOF 12 | 13 | python test_client.py 14 | 15 | if [[ $? -eq 0 ]]; then 16 | echo 'Script executed successfully' 17 | else 18 | echo 'Script execution failed' 19 | exit 1 20 | fi 21 | } 22 | 23 | test_log_file_not_writable() 24 | { 25 | cat < test_log_file_not_writable.py 26 | import appmap 27 | EOF 28 | 29 | python test_log_file_not_writable.py 30 | 31 | if [[ $? -eq 0 ]]; then 32 | echo 'Script executed successfully' 33 | else 34 | echo 'Script execution failed' 35 | exit 1 36 | fi 37 | } 38 | 39 | set -ex 40 | 41 | # now appmap requires git 42 | apt-get update -qq \ 43 | && apt-get install -y --no-install-recommends git 44 | 45 | pip -q install -U pip pytest "flask>=2,<3" python-decouple 46 | pip -q install /dist/${DISTRIBUTION_NAME//-/_}-*-py3-none-any.whl 47 | 48 | cp -R /_appmap/test/data/unittest/simple ./. 49 | 50 | # Before we enable, run a command that tries to load the config 51 | python -m appmap.command.appmap_agent_status 52 | 53 | # Ensure that client code using appmap.Recording does not fail when not APPMAP=true 54 | test_recording_when_appmap_not_true 55 | 56 | export APPMAP=true 57 | 58 | python -m appmap.command.appmap_agent_init |\ 59 | python -c 'import json,sys; print(json.load(sys.stdin)["configuration"]["contents"])' > /tmp/appmap.yml 60 | cat /tmp/appmap.yml 61 | 62 | python -m appmap.command.appmap_agent_validate 63 | 64 | # Promote warnings to errors, so we'll fail if pytest warns it can't rewrite appmap 65 | $RUNNER pytest -Werror -k test_hello_world 66 | 67 | if [[ -f tmp/appmap/pytest/simple_test_simple_UnitTestTest_test_hello_world.appmap.json ]]; then 68 | echo 'Success' 69 | else 70 | echo 'No appmap generated?' 71 | find $PWD 72 | exit 1 73 | fi 74 | 75 | test_log_file_not_writable 76 | -------------------------------------------------------------------------------- /_appmap/labels.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | from typing import Dict, List, Optional, Union 3 | 4 | from .env import Env 5 | from .importer import Filterable 6 | 7 | logger = Env.current.getLogger(__name__) 8 | 9 | 10 | class labels: # pylint: disable=too-few-public-methods 11 | def __init__(self, *args): 12 | self._labels = args 13 | 14 | def __call__(self, fn): 15 | # For simplicity, set _appmap_labels right on the 16 | # function. We'll delete it when we instrument the function. 17 | setattr(fn, "_appmap_labels", self._labels) 18 | return fn 19 | 20 | 21 | Label = str 22 | FunctionName = str # fully qualified 23 | Config = Dict[Label, Union[FunctionName, List[FunctionName]]] 24 | 25 | 26 | class LabelSet: 27 | """A set of labels defined to be applied on specific functions.""" 28 | 29 | def __init__(self): 30 | self.labels = defaultdict(list) 31 | 32 | def append(self, config: Config): 33 | """Update this LabelSet to contain definitions from the config.""" 34 | for label, functions in config.items(): 35 | if isinstance(functions, str): 36 | functions = (functions,) 37 | for function in functions: 38 | self.labels[function].append(label) 39 | 40 | def apply(self, function: Filterable) -> Optional[List[Label]]: 41 | """Searches the labelset for the function and applies any applicable labels. 42 | Returns them.""" 43 | applicable = self.labels.get(function.fqname, None) 44 | if not applicable: 45 | return None 46 | labels(*applicable)(function.obj) 47 | return applicable 48 | 49 | def __repr__(self): 50 | if not self.labels: 51 | return "LabelSet()" 52 | inverted = defaultdict(list) 53 | for function, lbls in self.labels.items(): 54 | for label in lbls: 55 | inverted[label].append(function) 56 | return "LabelSet(%s)" % dict(inverted) 57 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The Software is provided to you by the Licensor under the License, as defined below, subject to the following condition. 2 | 3 | Without limiting other conditions in the License, the grant of rights under the License will not include, 4 | and the License does not grant to you, the right to Sell the Software. 5 | 6 | For purposes of the foregoing, “Sell” means practicing any or all of the rights granted to you under the License to provide to third parties, 7 | for a fee or other consideration (including without limitation fees for hosting or consulting/ support services related to the Software), 8 | a product or service whose value derives, entirely or substantially, from the functionality of the Software. 9 | Any license notice or attribution required by the License must also include this Commons Clause License Condition notice. 10 | 11 | Software: AppMap agent for Python 12 | 13 | License: MIT License 14 | 15 | Copyright 2022, AppLand Inc 16 | 17 | Permission is hereby granted, free of charge, to any person obtaining a copy 18 | of this software and associated documentation files (the "Software"), to deal 19 | in the Software without restriction, including without limitation the rights 20 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 21 | copies of the Software, and to permit persons to whom the Software is 22 | furnished to do so, subject to the following conditions: 23 | 24 | The above copyright notice and this permission notice shall be included in all 25 | copies or substantial portions of the Software. 26 | 27 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 28 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 29 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 30 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 31 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 32 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 33 | SOFTWARE. 34 | -------------------------------------------------------------------------------- /.releaserc.yml: -------------------------------------------------------------------------------- 1 | # Allowed number of prerelease rules: 1..3 2 | # While semantic-release allows globs, they must be combined with `prerelease: true` and suffix is derived from name than. It conflicts with PEP440 3 | # PEP440 version rules (not compatible with SemVer): [N!]N(.N)*[{a|b|rc}N][.postN][.devN] 4 | # Consequences: 5 | # - prerelease branches must be explicitly specified, no asterisks 6 | # - prerelease parameter should be one of: a,b,rc,dev,post 7 | # - translation from SemVer prerelease notation to PEP440 is tone in 'replacements' section 8 | branches: # only branches listed here will create releases 9 | - master 10 | - name: ci/trusted_publishing_test 11 | prerelease: dev 12 | #- name: develop 13 | # prerelease: dev 14 | #- name: feature/* 15 | # prerelease: true # will use branch name as suffix 16 | plugins: 17 | - '@semantic-release/commit-analyzer' 18 | - '@semantic-release/release-notes-generator' 19 | - '@semantic-release/changelog' 20 | - - '@google/semantic-release-replace-plugin' 21 | - replacements: 22 | - files: [pyproject.toml] 23 | from: ^version = ".*?" 24 | to: version = "${nextRelease.version}" 25 | countMatches: true 26 | results: 27 | - file: pyproject.toml 28 | hasChanged: true 29 | numMatches: 1 30 | numReplacements: 1 31 | - - '@google/semantic-release-replace-plugin' # optional SemVer -> PEP440 coercion 32 | - replacements: 33 | - files: [pyproject.toml] # optional: SemVer prerelease -> PEP440 ("1.2.3-dev.1" -> "1.2.3.dev1") 34 | from: '^version = "(\\d+\\.\\d+\\.\\d+)-(dev|post)\\.(\\d+)"' 35 | to: 'version = "\\1.\\2\\3"' 36 | - files: [pyproject.toml] # optional: SemVer prerelease -> PEP440 ("1.2.3-rc.10" -> "1.2.3rc10" ) 37 | from: '^version = "(\\d+\\.\\d+\\.\\d+)-(a|b|rc)\\.(\\d+)"' 38 | to: 'version = "\\1\\2\\3"' 39 | - - '@semantic-release/git' 40 | - assets: 41 | - CHANGELOG.md 42 | - pyproject.toml 43 | - - '@semantic-release/exec' 44 | - prepareCmd: | 45 | /bin/bash ./ci/scripts/build_with_poetry.sh 46 | - - '@semantic-release/github': 47 | - assets: 48 | - dist/*.whl 49 | - dist/*.tar.gz 50 | -------------------------------------------------------------------------------- /_appmap/test/data/properties_class.py: -------------------------------------------------------------------------------- 1 | from functools import cached_property, partial 2 | import operator 3 | from typing import NoReturn 4 | 5 | def free_read_only(self): 6 | return self._read_only 7 | 8 | def free_func(): 9 | return "hello world" 10 | class PropertiesClass: 11 | def __init__(self): 12 | self._read_only = "read only" 13 | self._fully_accessible = "fully accessible" 14 | self._undecorated = "undecorated" 15 | 16 | @property 17 | def read_only(self): 18 | """Read-only""" 19 | return self._read_only 20 | 21 | @property 22 | def fully_accessible(self): 23 | """Fully-accessible""" 24 | return self._fully_accessible 25 | 26 | @fully_accessible.setter 27 | def fully_accessible(self, v): 28 | self._fully_accessible = v 29 | 30 | @fully_accessible.deleter 31 | def fully_accessible(self): 32 | del self._fully_accessible 33 | 34 | def get_undecorated(self): 35 | return self._undecorated 36 | 37 | def set_undecorated(self, value): 38 | self._undecorated = value 39 | 40 | def delete_undecorated(self): 41 | del self._undecorated 42 | 43 | undecorated_property = property(get_undecorated, set_undecorated, delete_undecorated) 44 | 45 | def set_write_only(self, v): 46 | self._write_only = v 47 | 48 | def del_write_only(self): 49 | del self._write_only 50 | 51 | write_only = property(None, set_write_only, del_write_only, "Write-only") 52 | 53 | def raise_base_exception(self) -> NoReturn: 54 | raise BaseException("not derived from Exception") # pylint: disable=broad-exception-raised 55 | 56 | @cached_property 57 | def cached_read_only(self): 58 | return self._read_only 59 | 60 | operator_read_only = property(operator.attrgetter("cached_read_only")) 61 | 62 | tastes = {"bacon": "yum"} 63 | 64 | def __getitem__(self, key): 65 | return self.tastes[key] 66 | 67 | taste = property(operator.itemgetter("bacon")) 68 | 69 | free_read_only_prop = property(free_read_only) 70 | 71 | static_partial_method = staticmethod(partial(free_func)) 72 | -------------------------------------------------------------------------------- /_appmap/test/test_runner.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import pytest 4 | 5 | 6 | def test_runner_noargs(script_runner): 7 | result = script_runner.run(["appmap-python"]) 8 | assert result.returncode != 0 9 | assert result.stdout.startswith("usage") 10 | 11 | 12 | def test_runner_help(script_runner): 13 | result = script_runner.run(["appmap-python", "--help"]) 14 | assert result.returncode == 0 15 | assert result.stdout.startswith("usage") 16 | 17 | 18 | @pytest.mark.parametrize("recording_type", ["process", "remote", "requests", "tests"]) 19 | def test_runner_recording_type(script_runner, recording_type): 20 | result = script_runner.run(["appmap-python", "--record", recording_type]) 21 | assert result.returncode == 0 22 | assert ( 23 | re.search(f"(?m)^APPMAP_RECORD_{recording_type.upper()}=true$", result.stdout) is not None 24 | ) 25 | 26 | result = script_runner.run(["appmap-python", "--no-record", recording_type]) 27 | assert result.returncode == 0 28 | assert re.search(f"(?m)^APPMAP_RECORD_{recording_type.upper()}=true$", result.stdout) is None 29 | 30 | 31 | @pytest.mark.parametrize("flag,expected", [("--record", 1), ("--no-record", 0)]) 32 | def test_runner_multi_recording_type(script_runner, flag, expected): 33 | types = "process,pytest" 34 | result = script_runner.run(["appmap-python", flag, types]) 35 | assert result.returncode == 0 36 | assert len(re.findall("(?m)^APPMAP_RECORD_PROCESS=true$", result.stdout)) == expected 37 | assert len(re.findall("(?m)^APPMAP_RECORD_PYTEST=true$", result.stdout)) == expected 38 | 39 | 40 | @pytest.mark.script_launch_mode("subprocess") 41 | class TestEnv: 42 | def test_appmap_present(self, script_runner): 43 | result = script_runner.run(["appmap-python", "printenv", "APPMAP"]) 44 | assert result.returncode == 0 45 | assert re.match(r"true", result.stdout) is not None 46 | 47 | def test_recording_type_present(self, script_runner): 48 | result = script_runner.run( 49 | ["appmap-python", "--record", "process", "printenv", "APPMAP_RECORD_PROCESS"] 50 | ) 51 | assert result.returncode == 0 52 | assert re.match(r"true", result.stdout) is not None 53 | -------------------------------------------------------------------------------- /appmap/labeling/django.yml: -------------------------------------------------------------------------------- 1 | security.authentication: 2 | - django.contrib.auth.authenticate 3 | - django.contrib.auth.backends.ModelBackend.authenticate 4 | - django.contrib.auth.backends.RemoteUserBackend.authenticate 5 | 6 | security.authorization: 7 | - django.contrib.admin.options.BaseModelAdmin.has_add_permission 8 | - django.contrib.admin.options.BaseModelAdmin.has_change_permission 9 | - django.contrib.admin.options.BaseModelAdmin.has_delete_permission 10 | - django.contrib.admin.options.BaseModelAdmin.has_view_permission 11 | - django.contrib.admin.options.InlineModelAdmin.has_add_permission 12 | - django.contrib.admin.options.InlineModelAdmin.has_change_permission 13 | - django.contrib.admin.options.InlineModelAdmin.has_delete_permission 14 | - django.contrib.admin.options.InlineModelAdmin.has_view_permission 15 | # I don't see these ModelAdmin permissions in the code but the 16 | # Django docs mention them 17 | - django.contrib.admin.options.ModelAdmin.has_add_permission 18 | - django.contrib.admin.options.ModelAdmin.has_change_permission 19 | - django.contrib.admin.options.ModelAdmin.has_delete_permission 20 | - django.contrib.admin.options.ModelAdmin.has_view_permission 21 | - django.contrib.auth.backends.ModelBackend.has_perm 22 | - django.contrib.auth.backends.ModelBackend.has_module_perms 23 | - django.contrib.auth.backends.ModelBackend.user_can_authenticate 24 | - django.contrib.auth.models.User.has_perm 25 | - django.contrib.auth.models.User.has_perms 26 | - django.contrib.auth.models.AnonymousUser.has_perm 27 | - django.contrib.auth.models.AnonymousUser.has_perms 28 | - django.contrib.auth.models.AnonymousUser.is_authenticated 29 | - django.contrib.auth.models.AnonymousUser.is_anonymous 30 | - django.contrib.auth.models.AnonymousUser.has_module_perms 31 | - django.contrib.auth.models.AbstractBaseUser.is_authenticated 32 | - django.contrib.auth.models.AbstractBaseUser.is_anonymous 33 | - django.contrib.auth.models.PermissionsMixin.has_perm 34 | - django.contrib.auth.models.PermissionsMixin.has_perms 35 | - django.contrib.auth.models.PermissionsMixin.has_module_perms 36 | # Added no entries for AbstractUser because it inherits from 37 | # AbstractBaseUser, PermissionsMixin 38 | -------------------------------------------------------------------------------- /_appmap/test/data/trial/expected/pytest.appmap.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.9", 3 | "metadata": { 4 | "language": { 5 | "name": "python" 6 | }, 7 | "client": { 8 | "name": "appmap", 9 | "url": "https://github.com/applandinc/appmap-python" 10 | }, 11 | "feature_group": "Deferred", 12 | "recording": { 13 | "defined_class": "test.test_deferred.TestDeferred", 14 | "method_id": "test_hello_world" 15 | }, 16 | "source_location": "test/test_deferred.py:7", 17 | "name": "Deferred hello world", 18 | "feature": "Hello world", 19 | "app": "deferred", 20 | "recorder": { 21 | "name": "pytest", 22 | "type": "tests" 23 | }, 24 | "test_status": "succeeded" 25 | }, 26 | "events": [ 27 | { 28 | "defined_class": "test.test_deferred.TestDeferred", 29 | "method_id": "test_hello_world", 30 | "path": "test/test_deferred.py", 31 | "lineno": 8, 32 | "static": false, 33 | "receiver": { 34 | "name": "self", 35 | "kind": "req", 36 | "class": "test.test_deferred.TestDeferred", 37 | "value": "" 38 | }, 39 | "parameters": [], 40 | "id": 1, 41 | "event": "call", 42 | "thread_id": 1 43 | }, 44 | { 45 | "return_value": { 46 | "class": "twisted.internet.defer.Deferred", 47 | "value": "" 48 | }, 49 | "parent_id": 1, 50 | "id": 2, 51 | "event": "return", 52 | "thread_id": 1 53 | } 54 | ], 55 | "classMap": [ 56 | { 57 | "name": "test", 58 | "type": "package", 59 | "children": [ 60 | { 61 | "name": "test_deferred", 62 | "type": "package", 63 | "children": [ 64 | { 65 | "name": "TestDeferred", 66 | "type": "class", 67 | "children": [ 68 | { 69 | "name": "test_hello_world", 70 | "type": "function", 71 | "location": "test/test_deferred.py:8", 72 | "static": false 73 | } 74 | ] 75 | } 76 | ] 77 | } 78 | ] 79 | } 80 | ] 81 | } -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | isolated_build = true 3 | 4 | # The *-web environments test the latest versions of Django and Flask with the full test suite. For 5 | # older version of the web frameworks, just run the tests that are specific to them. 6 | 7 | # Default envlist is only for matrix testing. Linter and vendoring should be called explicitly 8 | envlist = py3{10,11,12}-{django5}, py3{8,9,10,11,12}-{web,django3,django4,flask2,sqlalchemy1} 9 | 10 | [gh-actions] 11 | python = 12 | 3.8: py38 13 | 3.9: py39 14 | 3.10: py310 15 | 3.11: py311 16 | 3.12: py312 17 | 18 | [web-deps] 19 | deps= 20 | Django >=4.0, <5.0 21 | Flask >=3.0 22 | sqlalchemy >=2.0, <3.0 23 | 24 | [testenv] 25 | passenv = 26 | PYTEST_XDIST_AUTO_NUM_WORKERS 27 | setenv = 28 | APPMAP_DISPLAY_PARAMS=true 29 | deps= 30 | poetry 31 | web: {[web-deps]deps} 32 | py38: numpy==1.24.4 33 | py3{9,10,11,12}: numpy >=2 34 | flask2: Flask >= 2.0, <3.0 35 | django3: Django >=3.2, <4.0 36 | django4: Django >=4.0, <5.0 37 | django5: Django >=5.0, <6.0 38 | sqlalchemy1: sqlalchemy >=1.4.11, <2.0 39 | 40 | commands = 41 | poetry install -v 42 | web: poetry run appmap-python {posargs:pytest -n logical} 43 | django3: poetry run appmap-python pytest -n logical _appmap/test/test_django.py 44 | django4: poetry run appmap-python pytest -n logical _appmap/test/test_django.py 45 | django5: poetry run appmap-python pytest -n logical _appmap/test/test_django.py 46 | flask2: poetry run appmap-python pytest -n logical _appmap/test/test_flask.py 47 | sqlalchemy1: poetry run appmap-python pytest -n logical _appmap/test/test_sqlalchemy.py 48 | 49 | [testenv:lint] 50 | skip_install = True 51 | deps = 52 | poetry 53 | {[web-deps]deps} 54 | numpy >=2 55 | commands = 56 | poetry install 57 | # It doesn't seem great to disable cyclic-import checking, but the imports 58 | # aren't currently causing any problems. They should probably get fixed 59 | # sometime soon. 60 | {posargs:poetry run pylint --disable=cyclic-import -j 0 appmap _appmap} 61 | 62 | [testenv:vendoring] 63 | skip_install = True 64 | deps = vendoring 65 | commands = 66 | poetry run vendoring {posargs:sync} 67 | # We don't need the .pyi files vendoring generates 68 | python -c 'from pathlib import Path; all(map(Path.unlink, Path("vendor").rglob("*.pyi")))' 69 | -------------------------------------------------------------------------------- /appmap/command/appmap_agent_validate.py: -------------------------------------------------------------------------------- 1 | import json 2 | import sys 3 | from importlib.metadata import PackageNotFoundError, version 4 | 5 | from packaging.version import parse 6 | 7 | from _appmap.py_version_check import AppMapPyVerException, check_py_version 8 | 9 | 10 | class ValidationFailure(Exception): 11 | def __init__(self, message, level="error", detailed_message=None, help_urls=None): 12 | super().__init__() 13 | self.message = message 14 | self.level = level 15 | self.detailed_message = detailed_message 16 | self.help_urls = help_urls 17 | 18 | def to_dict(self): 19 | return {k: v for k, v in vars(self).items() if v is not None} 20 | 21 | 22 | def check_python_version(): 23 | try: 24 | check_py_version() 25 | except AppMapPyVerException as e: 26 | raise ValidationFailure(str(e)) from e 27 | 28 | 29 | def _check_version(dist, v): 30 | dist_version = None 31 | try: 32 | dist_version = version(dist) 33 | 34 | required = parse(v) 35 | actual = parse(dist_version) 36 | 37 | if actual < required: 38 | raise ValidationFailure(f"{dist} must have version >= {required}, found {actual}") 39 | except PackageNotFoundError: 40 | pass 41 | 42 | return dist_version 43 | 44 | 45 | # Note that, per https://www.python.org/dev/peps/pep-0426/#name, 46 | # comparison of distribution names are case-insensitive. 47 | def check_django_version(): 48 | return _check_version("django", "3.2") 49 | 50 | 51 | def check_flask_version(): 52 | return _check_version("flask", "2.0") 53 | 54 | 55 | def check_pytest_version(): 56 | return _check_version("pytest", "6.2") 57 | 58 | 59 | def _run(): 60 | errors = [ValidationFailure("internal error")] # shouldn't ever see this 61 | try: 62 | check_python_version() 63 | django_version = check_django_version() 64 | flask_version = check_flask_version() 65 | if not (django_version or flask_version): 66 | raise ValidationFailure("No web framework found. Expected one of: Django, Flask") 67 | 68 | check_pytest_version() 69 | errors = [] 70 | except ValidationFailure as e: 71 | errors = [e] 72 | 73 | print(json.dumps([e.to_dict() for e in errors], indent=2)) 74 | 75 | return 0 if len(errors) == 0 else 1 76 | 77 | 78 | def run(): 79 | sys.exit(_run()) 80 | 81 | 82 | if __name__ == "__main__": 83 | run() 84 | -------------------------------------------------------------------------------- /_appmap/test/data/django/djangoapp/urls.py: -------------------------------------------------------------------------------- 1 | import django.http 2 | from django.urls import include, path, re_path 3 | 4 | 5 | def view(_request): 6 | return django.http.HttpResponse("testing") 7 | 8 | 9 | def user_view(_request, username): 10 | return django.http.HttpResponse(f"user {username}") 11 | 12 | 13 | def org_user_posts_view(_request, org, username): 14 | return django.http.HttpResponse(f"org {org} user {username}") 15 | 16 | 17 | def post_view(_request, post_id): 18 | return django.http.HttpResponse(f"post {post_id}") 19 | 20 | 21 | def post_unnamed_view(_request, arg): 22 | return django.http.HttpResponse(f"post with unnamed, {arg}") 23 | 24 | 25 | def user_post_view(_request, username, post_id): 26 | return django.http.HttpResponse(f"post {username} {post_id}") 27 | 28 | 29 | def echo_view(request): 30 | return django.http.HttpResponse(request.body) 31 | 32 | 33 | def exception_view(_request): 34 | raise RuntimeError("An error") 35 | 36 | 37 | def user_included_view(_request, username): 38 | return django.http.HttpResponse(f"user {username}, included") 39 | 40 | 41 | def acl_edit(_request, pk): 42 | return django.http.HttpResponse(f"acl_edit {pk}") 43 | 44 | 45 | # replicate a problematic bit of misago's routing 46 | admincp_patterns = [ 47 | re_path( 48 | "^", 49 | include( 50 | [ 51 | re_path( 52 | r"^permissions/", 53 | include( 54 | ( 55 | [re_path(r"^edit/(?P\d+)$", acl_edit, name="edit")], 56 | "permissions", 57 | ), 58 | namespace="permissions", 59 | ), 60 | ) 61 | ] 62 | ), 63 | ) 64 | ] 65 | 66 | urlpatterns = [ 67 | path("test", view), 68 | path("", view), 69 | re_path("^user/(?P[^/]+)$", user_view), 70 | path("post/", post_view), 71 | path("post///summary", user_post_view), 72 | re_path(r"^post/unnamed/(\d+)$", post_unnamed_view), 73 | path("echo", echo_view), 74 | path("exception", exception_view), 75 | re_path(r"^post/included/", include([path("", user_view)])), 76 | re_path( 77 | r"^(?P\d+)/posts/", 78 | include([path("", org_user_posts_view)]), 79 | ), 80 | re_path(r"^admincp/", include((admincp_patterns, "admin"), namespace="admin")), 81 | ] 82 | -------------------------------------------------------------------------------- /_appmap/test/test_generation.py: -------------------------------------------------------------------------------- 1 | import json 2 | import pytest 3 | 4 | import numpy as np 5 | 6 | from _appmap.generation import AppMapEncoder 7 | 8 | 9 | @pytest.mark.appmap_enabled 10 | @pytest.mark.usefixtures("with_data_dir") 11 | class TestGeneration: 12 | def test_labeled(self, verify_example_appmap): 13 | def check_labels(self, to_dict): 14 | if self.name == "labeled_method": 15 | assert list(self.labels) == ["super", "important"] 16 | ret = to_dict(self) 17 | return ret 18 | 19 | verify_example_appmap(check_labels, "labeled_method") 20 | 21 | def test_unlabeled(self, verify_example_appmap): 22 | def check_labels(self, to_dict): 23 | if self.name == "instance_method": 24 | assert not self.labels 25 | ret = to_dict(self) 26 | return ret 27 | 28 | verify_example_appmap(check_labels, "instance_method") 29 | 30 | def test_docstring(self, verify_example_appmap): 31 | def check_comment(self, to_dict): 32 | if self.name == "with_docstring": 33 | assert self.comment == "docstrings can have\nmultiple lines" 34 | ret = to_dict(self) 35 | return ret 36 | 37 | verify_example_appmap(check_comment, "with_docstring") 38 | 39 | def test_comment(self, verify_example_appmap): 40 | def check_comment(self, to_dict): 41 | if self.name == "with_comment": 42 | assert self.comment == "# comments can have\n# multiple lines\n" 43 | ret = to_dict(self) 44 | return ret 45 | 46 | verify_example_appmap(check_comment, "with_comment") 47 | 48 | def test_none(self, verify_example_appmap): 49 | def check_comment(self, to_dict): 50 | if self.name == "instance_method": 51 | assert not self.comment 52 | ret = to_dict(self) 53 | return ret 54 | 55 | verify_example_appmap(check_comment, "instance_method") 56 | 57 | class TestAppMapEncoder: 58 | def test_np_int64_type(self): 59 | data = { 60 | "value": np.int64(42), 61 | } 62 | json_str = json.dumps(data, cls=AppMapEncoder) 63 | assert '{"value": "42"}' == json_str 64 | 65 | def test_np_array_type(self): 66 | data = { 67 | "value": np.array([0, 1, 2, 3]) 68 | } 69 | json_str = json.dumps(data, cls=AppMapEncoder) 70 | assert '{"value": "[0 1 2 3]"}' == json_str 71 | -------------------------------------------------------------------------------- /appmap/sqlalchemy.py: -------------------------------------------------------------------------------- 1 | """SQL statement capture for SQLAlchemy.""" 2 | 3 | import time 4 | from importlib.metadata import version 5 | 6 | from sqlalchemy import event 7 | from sqlalchemy.engine import Engine 8 | 9 | from _appmap.event import ReturnEvent, SqlEvent 10 | from _appmap.instrument import is_instrumentation_disabled 11 | from _appmap.metadata import Metadata 12 | from _appmap.recorder import Recorder 13 | 14 | 15 | @event.listens_for(Engine, "before_cursor_execute") 16 | # pylint: disable=too-many-arguments,unused-argument 17 | def capture_sql_call(conn, cursor, statement, parameters, context, executemany): 18 | """Capture SQL query call into appmap.""" 19 | if is_instrumentation_disabled(): 20 | # We must be in the middle of fetching object representation. 21 | # Don't record this query in the appmap. 22 | pass 23 | elif Recorder.get_enabled(): 24 | Metadata.add_framework("SQLAlchemy", version("sqlalchemy")) 25 | if executemany: 26 | # Sometimes the same query is executed with different parameter sets. 27 | # Instead of substituting them all, just say how many times it was run. 28 | try: 29 | times = len(parameters) 30 | except TypeError: 31 | times = "?" 32 | sql = "-- %s times\n%s" % (times, statement) 33 | else: 34 | sql = statement 35 | dialect = conn.dialect 36 | call_event = SqlEvent( 37 | sql, vendor=dialect.name, version=dialect.server_version_info 38 | ) 39 | Recorder.add_event(call_event) 40 | setattr( 41 | context, 42 | "appmap", 43 | {"start_time": time.monotonic(), "call_event_id": call_event.id}, 44 | ) 45 | 46 | 47 | @event.listens_for(Engine, "after_cursor_execute") 48 | # pylint: disable=too-many-arguments,unused-argument 49 | def capture_sql(conn, cursor, statement, parameters, context, executemany): 50 | """Capture SQL query return into appmap.""" 51 | if is_instrumentation_disabled(): 52 | # We must be in the middle of fetching object representation. 53 | # Don't record this query in the appmap. 54 | pass 55 | elif Recorder.get_enabled(): 56 | stop = time.monotonic() 57 | duration = stop - context.appmap["start_time"] 58 | return_event = ReturnEvent( 59 | parent_id=context.appmap["call_event_id"], elapsed=duration 60 | ) 61 | del context.appmap 62 | Recorder.add_event(return_event) 63 | -------------------------------------------------------------------------------- /appmap/__init__.py: -------------------------------------------------------------------------------- 1 | """AppMap recorder for Python 2 | PYTEST_DONT_REWRITE 3 | """ 4 | import os 5 | 6 | # Note that we need to guard these imports with a conditional, rather than 7 | # putting them in a function and conditionally calling the function. If we 8 | # execute the imports in a function, the modules all get put into the funtion's 9 | # globals, rather than into appmap's globals. 10 | _enabled = os.environ.get("APPMAP", None) 11 | _recording_exported = False 12 | if _enabled is None or _enabled.upper() == "TRUE": 13 | if _enabled is not None: 14 | # Use setdefault so tests can manage settings as necessary 15 | os.environ.setdefault("_APPMAP", _enabled) 16 | _display_params = os.environ.get("APPMAP_DISPLAY_PARAMS", "false") 17 | os.environ.setdefault("_APPMAP_DISPLAY_PARAMS", _display_params) 18 | 19 | from _appmap import generation # noqa: F401 20 | from _appmap.env import Env # noqa: F401 21 | from _appmap.importer import instrument_module # noqa: F401 22 | from _appmap.labels import labels # noqa: F401 23 | from _appmap.noappmap import decorator as noappmap # noqa: F401 24 | from _appmap.recording import Recording # noqa: F401 25 | _recording_exported = True 26 | 27 | try: 28 | from . import django # noqa: F401 29 | except ImportError: 30 | pass 31 | 32 | try: 33 | from . import flask # noqa: F401 34 | except ImportError: 35 | pass 36 | 37 | try: 38 | from . import fastapi # noqa: F401 39 | except ImportError: 40 | pass 41 | 42 | try: 43 | from . import uvicorn # noqa: F401 44 | except ImportError: 45 | pass 46 | 47 | # Note: pytest integration is configured as a pytest plugin, so it doesn't 48 | # need to be imported here 49 | 50 | # unittest is part of the standard library, so it should always be 51 | # importable (and therefore doesn't need to be in a try .. except block) 52 | from . import unittest # noqa: F401 53 | 54 | def enabled(): 55 | return Env.current.enabled 56 | else: 57 | os.environ.pop("_APPMAP", None) 58 | os.environ.pop("_APPMAP_DISPLAY_PARAMS", None) 59 | else: 60 | os.environ.setdefault("_APPMAP", "false") 61 | os.environ.setdefault("_APPMAP_DISPLAY_PARAMS", "false") 62 | 63 | if not _recording_exported: 64 | # Client code that imports appmap.Recording should run correctly 65 | # even when not Env.current.enabled (not APPMAP=true). 66 | # This prevents: 67 | # ImportError: cannot import name 'Recording' from 'appmap'... 68 | from _appmap.recording import NoopRecording as Recording # noqa: F401 69 | -------------------------------------------------------------------------------- /appmap/http.py: -------------------------------------------------------------------------------- 1 | """HTTP client request and response capture. 2 | 3 | Importing this module patches http.client.HTTPConnection to record 4 | appmap events of any HTTP requests. 5 | """ 6 | 7 | import time 8 | from http.client import HTTPConnection 9 | from urllib.parse import parse_qs, urlsplit 10 | 11 | from _appmap.event import HttpClientRequestEvent, HttpClientResponseEvent 12 | from _appmap.recorder import Recorder 13 | from _appmap.utils import patch_class, values_dict 14 | 15 | 16 | def is_secure(self: HTTPConnection): 17 | """Checks whether HTTP connection is secure.""" 18 | # isinstance(self, HTTPSConnection) won't work with 19 | # eg. urllib3 HTTPConnection. Instead try duck typing. 20 | return hasattr(self, "key_file") 21 | 22 | 23 | def base_url(self: HTTPConnection): 24 | """Extract base URL from an HTTPConnection. 25 | 26 | Example result: https://appmap.example:3000 27 | """ 28 | scheme = "https" if is_secure(self) else "http" 29 | port = "" if self.port == self.default_port else f":{self.port}" 30 | return f"{scheme}://{self.host}{port}" 31 | 32 | 33 | # pylint: disable=missing-function-docstring 34 | @patch_class(HTTPConnection) 35 | class HTTPConnectionPatch: 36 | """Patch methods for HTTPConnection, building and recording appmap events 37 | as requests are issues and responses received. 38 | """ 39 | 40 | # pylint: disable=attribute-defined-outside-init 41 | def putrequest(self, orig, method, url, *args, **kwargs): 42 | split = urlsplit(url) 43 | self._appmap_request = HttpClientRequestEvent( 44 | method, 45 | base_url(self) + split.path, 46 | values_dict(parse_qs(split.query).items()), 47 | ) 48 | orig(self, method, url, *args, **kwargs) 49 | 50 | def putheader(self, orig, header, *values): 51 | request = self._appmap_request.http_client_request 52 | if not hasattr(request, "headers"): 53 | request["headers"] = {} 54 | headers = request["headers"] 55 | if header not in headers: 56 | headers[header] = [] 57 | headers[header].extend(values) 58 | orig(self, header, *values) 59 | 60 | def getresponse(self, orig): 61 | event = self._appmap_request 62 | del self._appmap_request 63 | request = event.http_client_request 64 | if "headers" in request: 65 | request["headers"] = values_dict(request["headers"].items()) 66 | 67 | enabled = Recorder.get_enabled() 68 | if enabled: 69 | Recorder.add_event(event) 70 | 71 | start = time.monotonic() 72 | response = orig(self) 73 | 74 | if enabled: 75 | Recorder.add_event( 76 | HttpClientResponseEvent( 77 | response.status, 78 | headers=response.headers, 79 | elapsed=(time.monotonic() - start), 80 | parent_id=event.id, 81 | ) 82 | ) 83 | 84 | return response 85 | -------------------------------------------------------------------------------- /_appmap/test/test_labels.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from _appmap.wrapt import BoundFunctionWrapper, FunctionWrapper 4 | 5 | 6 | @pytest.mark.appmap_enabled 7 | @pytest.mark.usefixtures("with_data_dir") 8 | class TestLabels: 9 | def test_labeled(self, verify_example_appmap): 10 | def check_labels(self, to_dict): 11 | if self.name == "labeled_method": 12 | assert list(self.labels) == ["super", "important"] 13 | ret = to_dict(self) 14 | return ret 15 | 16 | verify_example_appmap(check_labels, "labeled_method") 17 | 18 | def test_unlabeled(self, verify_example_appmap): 19 | def check_labels(self, to_dict): 20 | if self.name == "instance_method": 21 | assert not self.labels 22 | ret = to_dict(self) 23 | return ret 24 | 25 | verify_example_appmap(check_labels, "instance_method") 26 | 27 | def test_class_instrumented_by_preset(self, verify_example_appmap): 28 | def check_labels(*_): 29 | from http.client import ( # pyright: ignore[reportMissingImports] pylint: disable=import-error,import-outside-toplevel 30 | HTTPConnection, 31 | ) 32 | 33 | assert isinstance( 34 | HTTPConnection.request, BoundFunctionWrapper 35 | ), "HTTPConnection.request should be instrumented" 36 | 37 | assert not isinstance( 38 | HTTPConnection.getresponse, BoundFunctionWrapper 39 | ), "HTTPConnection.getresponse should not be instrumented" 40 | 41 | verify_example_appmap(check_labels, "instance_method") 42 | 43 | @pytest.mark.appmap_enabled(config="appmap-no-pyyaml.yml") 44 | def test_mod_instrumented_by_preset(self, verify_example_appmap): 45 | def check_labels(*_): 46 | import yaml # pylint: disable=import-outside-toplevel 47 | 48 | assert isinstance( 49 | yaml.scan, FunctionWrapper 50 | ), "yaml.scan should be instrumented" 51 | 52 | assert not isinstance( 53 | yaml.emit, FunctionWrapper 54 | ), "yaml.emit should not be instrumented" 55 | 56 | verify_example_appmap(check_labels, "instance_method") 57 | 58 | def test_function_only_in_mod(self, verify_example_appmap): 59 | def check_labels(*_): 60 | # pylint: disable=import-outside-toplevel 61 | import hmac 62 | 63 | # pylint: enable=import-outside-toplevel 64 | # 65 | # It would be good to do this test, too (even though we're testing that module functions 66 | # are labeled by presets). hmac.create_digest is a builtin-function, though, so this 67 | # won't work until https://github.com/getappmap/appmap-python/issues/216 is fixed. 68 | # 69 | # assert isinstance( 70 | # hmac.create_digest, FunctionWrapper 71 | # ), "hmac.compare_digest should be instrumented" 72 | 73 | assert not isinstance( 74 | hmac.HMAC.update, BoundFunctionWrapper 75 | ), "hmac.HMAC.update should not be instrumented" 76 | 77 | verify_example_appmap(check_labels, "instance_method") 78 | -------------------------------------------------------------------------------- /_appmap/test/test_fastapi.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | from importlib.metadata import version 3 | from types import SimpleNamespace as NS 4 | 5 | import pytest 6 | from fastapi.testclient import TestClient 7 | 8 | import appmap 9 | from _appmap.env import Env 10 | from _appmap.metadata import Metadata 11 | 12 | from .web_framework import ( 13 | _TestRecordRequests, 14 | _TestRemoteRecording, 15 | _TestRequestCapture, 16 | ) 17 | 18 | 19 | # Opt in to these tests. (I couldn't find a way to DRY this up that allowed 20 | # pytest collection to find all these tests.) 21 | class TestRecordRequests(_TestRecordRequests): 22 | pass 23 | 24 | 25 | @pytest.mark.app(remote_enabled=True) 26 | class TestRemoteRecording(_TestRemoteRecording): 27 | def setup_method(self): 28 | # Can't add __init__, pytest won't collect test classes that have one 29 | # pylint: disable=attribute-defined-outside-init 30 | self.expected_thread_id = 1 31 | self.expected_content_type = "application/json" 32 | 33 | 34 | class TestRequestCapture(_TestRequestCapture): 35 | pass 36 | 37 | 38 | @pytest.fixture(name="app") 39 | def fastapi_app(data_dir, monkeypatch, request): 40 | monkeypatch.syspath_prepend(data_dir / "fastapi") 41 | 42 | Env.current.set("APPMAP_CONFIG", data_dir / "fastapi" / "appmap.yml") 43 | 44 | from fastapiapp import main # pyright: ignore[reportMissingImports] pylint: disable=import-error,import-outside-toplevel 45 | 46 | importlib.reload(main) 47 | 48 | # FastAPI doesn't provide a way to say what environment the app is running 49 | # in. So, instead use a mark to indicate whether remote recording should be 50 | # enabled. (When we're running as part of a server integration, we infer the 51 | # environment from the server configure, e.g. "uvicorn --reload".) 52 | mark = request.node.get_closest_marker("app") 53 | remote_enabled = None 54 | if mark is not None: 55 | remote_enabled = mark.kwargs.get("remote_enabled", None) 56 | 57 | # Add the FastAPI middleware to the app. This now happens automatically when 58 | # a FastAPI app is started from uvicorn, but must be done manually 59 | # otherwise. 60 | return appmap.fastapi.Middleware(main.app, remote_enabled).init_app() 61 | 62 | 63 | @pytest.fixture(name="client") 64 | def fastapi_client(app): 65 | yield TestClient(app, headers={}) 66 | 67 | 68 | @pytest.mark.appmap_enabled(env={"APPMAP_RECORD_REQUESTS": "false"}) 69 | def test_framework_metadata(client, events): # pylint: disable=unused-argument 70 | client.get("/") 71 | assert Metadata()["frameworks"] == [{"name": "FastAPI", "version": version("fastapi")}] 72 | 73 | 74 | @pytest.fixture(name="server") 75 | def fastapi_server(xprocess, server_base): 76 | debug = server_base.debug 77 | host = server_base.host 78 | port = server_base.port 79 | reload = "--reload" if debug else "" 80 | 81 | name = "fastapi" 82 | pattern = f"Uvicorn running on http://{host}:{port}" 83 | cmd = f"-m uvicorn fastapiapp.main:app {reload} --host {host} --port {port}" 84 | xprocess.ensure(name, server_base.factory(name, cmd, pattern)) 85 | 86 | url = f"http://{server_base.host}:{port}" 87 | yield NS(debug=debug, url=url) 88 | 89 | xprocess.getinfo(name).terminate() 90 | -------------------------------------------------------------------------------- /_appmap/test/data/example_class.py: -------------------------------------------------------------------------------- 1 | """A class using all the slightly different ways a function could be defined 2 | and called. Used for testing appmap instrumentation. 3 | """ 4 | # pylint: disable=missing-function-docstring 5 | 6 | import time 7 | from functools import lru_cache, wraps 8 | from typing import NoReturn 9 | 10 | import appmap 11 | 12 | 13 | class ClassMethodMixin: 14 | @classmethod 15 | def class_method(cls): 16 | return "ClassMethodMixin#class_method, cls %s" % (cls.__name__) 17 | 18 | 19 | class Super: 20 | def instance_method(self): 21 | return self.method_not_called_directly() 22 | 23 | def method_not_called_directly(self): 24 | return "Super#instance_method" 25 | 26 | 27 | def wrap_fn(fn): 28 | @wraps(fn) 29 | def wrapped_fn(*args, **kwargs): 30 | try: 31 | print("calling %s" % (fn.__name__)) 32 | return fn(*args, **kwargs) 33 | finally: 34 | print("called %s" % (fn.__name__)) 35 | 36 | return wrapped_fn 37 | 38 | 39 | class ExampleClass(Super, ClassMethodMixin): 40 | def __repr__(self): 41 | return "ExampleClass and %s" % (self.another_method()) 42 | 43 | # Include some lines so the line numbers in the expected appmap 44 | # don't change: 45 | # 46 | 47 | def another_method(self): 48 | return "ExampleClass#another_method" 49 | 50 | def test_exception(self): 51 | raise Exception("test exception") 52 | 53 | what_time_is_it = time.gmtime 54 | 55 | @appmap.labels("super", "important") 56 | def labeled_method(self): 57 | return "super important" 58 | 59 | @staticmethod 60 | @wrap_fn 61 | def wrapped_static_method(): 62 | return "wrapped_static_method" 63 | 64 | @classmethod 65 | @wrap_fn 66 | def wrapped_class_method(cls): 67 | return "wrapped_class_method" 68 | 69 | @wrap_fn 70 | def wrapped_instance_method(self): 71 | return "wrapped_instance_method" 72 | 73 | @staticmethod 74 | @lru_cache(maxsize=1) 75 | def static_cached(value): 76 | return value + 1 77 | 78 | def instance_with_param(self, p): 79 | return p 80 | 81 | @staticmethod 82 | def static_method(): 83 | import io 84 | 85 | import yaml # Formatting is funky to minimize changes to expected appmap 86 | 87 | yaml.Dumper(io.StringIO()).open() 88 | return "ExampleClass.static_method\n...\n" 89 | 90 | @staticmethod 91 | def call_yaml(): 92 | return ExampleClass.dump_yaml("ExampleClass.call_yaml") 93 | 94 | @staticmethod 95 | def dump_yaml(data): 96 | import yaml 97 | 98 | # Call twice, to make sure both show up in the recording 99 | yaml.dump(data) 100 | yaml.dump(data) 101 | 102 | def with_docstring(self): 103 | """ 104 | docstrings can have 105 | multiple lines 106 | """ 107 | return True 108 | 109 | # comments can have 110 | # multiple lines 111 | def with_comment(self): 112 | return True 113 | 114 | def return_self(self): 115 | return self 116 | 117 | 118 | def modfunc(): 119 | return "Hello world!" 120 | -------------------------------------------------------------------------------- /docs/recording-env-vars.md: -------------------------------------------------------------------------------- 1 | The tables below describe how the variable environment variables control the various 2 | recording types. In each case, ✓ means that the corresponding recording type 3 | will be produced, ❌ means that it will not. 4 | 5 | ## Web Apps 6 | These tables describe how `APPMAP_RECORD_REQUESTS` and `APPMAP_RECORD_REMOTE` are 7 | handled when running a web app. "web app, debug on" means a Flask app run as `flask --debug`, 8 | a FastAPI app run using `uvicorn --reload` and, a Django app run with `DEBUG = True` in `settings.py`. 9 | 10 | | | `APPMAP_RECORD_REQUESTS` is unset | `APPMAP_RECORD_REQUESTS` == "true" | `APPMAP_RECORD_REQUESTS` == "false" | 11 | | -------------------- | :----------------------------: | :------------------------------: | :-------------------------------: | 12 | | "web app, debug on" | ✓ | ✓ | ❌ | 13 | | "web app, debug off" | ✓ | ✓ | ❌ | 14 | 15 | 16 | | | `APPMAP_RECORD_REMOTE` is unset | `APPMAP_RECORD_REMOTE` == "true" | `APPMAP_RECORD_REMOTE` == "false" | 17 | | -------------------- | :---------------------------: | :----------------------------: | :------------------------------: | 18 | | "web app, debug on" | ✓ | ✓ | ❌ | 19 | | "web app, debug off" | ❌ | ✓(with warning) | ❌ | 20 | 21 | 22 | ## Testing 23 | This table shows how `APPMAP_RECORD_PYTEST`, `APPMAP_RECORD_UNITTEST`, and 24 | `APPMAP_RECORD_REQUESTS` are handled when running tests in. Note that in v2, in 25 | v2, `APPMAP_RECORD_PYTEST` and `APPMAP_RECORD_UNITTEST` will be replaced with 26 | `APPMAP_RECORD_TESTS`. 27 | 28 | | | `APPMAP_RECORD_PYTEST` is unset | `APPMAP_RECORD_PYTEST` == "true" | `APPMAP_RECORD_PYTEST` == "false" | `APPMAP_RECORD_REQUESTS` is unset | `APPMAP_RECORD_REQUESTS` == "true" | `APPMAP_RECORD_REQUESTS` == "false" | 29 | | ------ | :---------------------------: | :-----------------------------: | :------------------------------: | :----------------------------: | :-----------------------------: | :------------------------------: | 30 | | pytest | ✓ | ✓ | ❌ | ❌ | ignored in v1, ✓ in v2 | ❌ | 31 | 32 | 33 | 34 | 35 | ## Process Recording 36 | `APPMAP_RECORD_PROCESS` creates recordings as described in this table. Note 37 | that, in v1, `APPMAP_RECORD_PROCESS` doesn't change the handling of any of the 38 | other variables. As a result, setting it when running a either web app or when 39 | running tests will result in an error. Whether this behavior should change in v2 40 | is TBD. 41 | 42 | | | `APPMAP_RECORD_PROCESS` is unset | `APPMAP_RECORD_PROCESS` == "true" | `APPMAP_RECORD_PROCESS` == "false" | 43 | | ----------------- | :----------------------------: | :---------------------------------: | :----------------------------------: | 44 | | process recording | ❌ | ✓ | ❌ | -------------------------------------------------------------------------------- /_appmap/test/test_sqlalchemy.py: -------------------------------------------------------------------------------- 1 | """Tests for the SQLAlchemy integration.""" 2 | 3 | from importlib.metadata import version 4 | 5 | import pytest 6 | from sqlalchemy import ( 7 | Column, 8 | ForeignKey, 9 | Integer, 10 | MetaData, 11 | String, 12 | Table, 13 | text, 14 | create_engine, 15 | ) 16 | 17 | import appmap.sqlalchemy # pylint: disable=unused-import # noqa: F401 18 | from _appmap.metadata import Metadata 19 | 20 | from ..test.helpers import DictIncluding 21 | from .appmap_test_base import AppMapTestBase 22 | 23 | 24 | class TestSQLAlchemy(AppMapTestBase): 25 | @staticmethod 26 | def test_sql_capture(connection, events): 27 | # Passing a string to execute is deprecated in 1.4 28 | # and removed in 2.0. We wrap it with text(). 29 | # https://docs.sqlalchemy.org/en/14/core/connections.html#sqlalchemy.engine.Connection.execute 30 | connection.execute(text("SELECT 1")) 31 | assert events[0].sql_query == DictIncluding( 32 | {"sql": "SELECT 1", "database_type": "sqlite"} 33 | ) 34 | assert events[0].sql_query["server_version"].startswith("3.") 35 | assert Metadata()["frameworks"] == [ 36 | {"name": "SQLAlchemy", "version": version("sqlalchemy")}, 37 | ] 38 | 39 | @staticmethod 40 | # pylint: disable=unused-argument 41 | def test_capture_ddl(events, schema): 42 | assert "CREATE TABLE addresses" in events[-2].sql_query["sql"] 43 | 44 | # pylint: disable=unused-argument 45 | def test_capture_insert(self, engine, schema, events): 46 | ins = self.users.insert().values(name="jack", fullname="Jack Jones") 47 | with engine.begin() as conn: 48 | conn.execute(ins) 49 | assert ( 50 | events[-2].sql_query["sql"] 51 | == "INSERT INTO users (name, fullname) VALUES (?, ?)" 52 | ) 53 | 54 | # pylint: disable=unused-argument 55 | def test_capture_insert_many(self, engine, schema, events): 56 | with engine.begin() as conn: 57 | conn.execute( 58 | self.addresses.insert(), 59 | [ 60 | {"user_id": 1, "email_address": "jack@yahoo.com"}, 61 | {"user_id": 1, "email_address": "jack@msn.com"}, 62 | {"user_id": 2, "email_address": "www@www.org"}, 63 | {"user_id": 2, "email_address": "wendy@aol.com"}, 64 | ], 65 | ) 66 | assert ( 67 | events[-2].sql_query["sql"] 68 | == "-- 4 times\nINSERT INTO addresses (user_id, email_address) VALUES (?, ?)" 69 | ) 70 | 71 | @staticmethod 72 | @pytest.fixture 73 | def engine(): 74 | return create_engine("sqlite:///:memory:") 75 | 76 | @staticmethod 77 | @pytest.fixture 78 | def connection(engine): 79 | return engine.connect() 80 | 81 | metadata = MetaData() 82 | users = Table( 83 | "users", 84 | metadata, 85 | Column("id", Integer, primary_key=True), 86 | Column("name", String), 87 | Column("fullname", String), 88 | ) 89 | addresses = Table( 90 | "addresses", 91 | metadata, 92 | Column("id", Integer, primary_key=True), 93 | Column("user_id", None, ForeignKey("users.id")), 94 | Column("email_address", String, nullable=False), 95 | ) 96 | 97 | @pytest.fixture 98 | def schema(self, engine): 99 | self.metadata.create_all(engine) 100 | -------------------------------------------------------------------------------- /_appmap/metadata.py: -------------------------------------------------------------------------------- 1 | """Shared metadata gathering""" 2 | 3 | import platform 4 | from functools import lru_cache 5 | 6 | from . import utils 7 | from .env import Env 8 | from .utils import compact_dict 9 | 10 | logger = Env.current.getLogger(__name__) 11 | 12 | 13 | def _lines(text): 14 | """Split text into lines, stripping and returning just the nonempty ones. 15 | 16 | Returns None if the result would be empty.""" 17 | 18 | lines = [x for x in map(lambda x: x.strip(), text.split("\n")) if len(x) > 0] 19 | if len(lines) == 0: 20 | return None 21 | return lines 22 | 23 | 24 | class Metadata(dict): 25 | """A dict that self-initializes to reflect platform and git metadata.""" 26 | 27 | def __init__(self, root_dir=None): 28 | super().__init__(self.base(root_dir or Env.current.root_dir)) 29 | if any(self.__class__.frameworks): 30 | self["frameworks"] = self.__class__.frameworks 31 | self.reset() 32 | 33 | frameworks = [] 34 | 35 | @classmethod 36 | def add_framework(cls, name, version=None): 37 | """Add a framework that will end up (only) in the next Metadata() created. 38 | 39 | Duplicate entries are ignored. 40 | """ 41 | if not any(f["name"] == name for f in cls.frameworks): 42 | cls.frameworks.append(compact_dict({"name": name, "version": version})) 43 | 44 | @classmethod 45 | def reset(cls): 46 | """Resets stored framework metadata.""" 47 | cls.frameworks = [] 48 | 49 | @staticmethod 50 | @lru_cache() 51 | def base(root_dir): 52 | """Gathers git and platform metadata given the project root directory path.""" 53 | metadata = { 54 | "language": { 55 | "name": "python", 56 | "engine": platform.python_implementation(), 57 | "version": platform.python_version(), 58 | }, 59 | "client": { 60 | "name": "appmap", 61 | "url": "https://github.com/applandinc/appmap-python", 62 | }, 63 | } 64 | 65 | if Metadata._git_available(root_dir): 66 | metadata.update({"git": Metadata._git_metadata(root_dir)}) 67 | 68 | return metadata 69 | 70 | @staticmethod 71 | @lru_cache() 72 | def _git_available(root_dir): 73 | try: 74 | ret = utils.subprocess_run(["git", "rev-parse", "HEAD"], cwd=root_dir) 75 | if not ret.returncode: 76 | return True 77 | logger.warning("Failed running 'git rev-parse HEAD', %s", ret.stderr) 78 | except FileNotFoundError as exc: 79 | msg = """ 80 | Couldn't find git executable, repository information 81 | will not be included in the AppMap. 82 | 83 | Make sure git is installed and that your PATH is set 84 | correctly. 85 | 86 | Error: %s 87 | """ 88 | logger.warning(msg, str(exc)) 89 | 90 | return False 91 | 92 | @staticmethod 93 | @lru_cache() 94 | def _git_metadata(root_dir): 95 | git = utils.git(cwd=root_dir) 96 | repository = git("config --get remote.origin.url") 97 | branch = git("rev-parse --abbrev-ref HEAD") 98 | commit = git("rev-parse HEAD") 99 | 100 | return compact_dict( 101 | { 102 | "repository": repository, 103 | "branch": branch, 104 | "commit": commit, 105 | } 106 | ) 107 | 108 | 109 | def initialize(): 110 | Metadata.reset() 111 | -------------------------------------------------------------------------------- /appmap/labeling/crypto.yml: -------------------------------------------------------------------------------- 1 | # Labels for cryptography. 2 | # 3 | # This file is by no means complete. 4 | # There are several competing crypto libraries for python, 5 | # and specific functions are often implementation details 6 | # exposed through abstract interfaces, which makes it 7 | # difficult and brittle to fully enumerate. 8 | # For this reason, the attempt was to only list the most 9 | # commonly used functions from built-in libraries, 10 | # pycrypto(dome) and cryptography. 11 | # Please open an issue or a pull request if there's any 12 | # commonly used function missing from here. 13 | crypto.pkey: 14 | - Crypto.PublicKey.pubkey.pubkey.sign 15 | - Crypto.Signature.pkcs1_15.PKCS115_SigScheme.sign 16 | - Crypto.Signature.pss.PSS_SigScheme.sign 17 | - Crypto.Signature.DSS.DssSigScheme.sign 18 | crypto.x509: 19 | - cryptography.x509.base.CertificateBuilder.sign 20 | - cryptography.x509.base.CertificateSigningRequestBuilder.sign 21 | crypto.pkcs5: 22 | - hashlib.pbkdf2_hmac 23 | - Crypto.Protocol.KDF.PBKDF2 24 | crypto.encrypt: 25 | - Crypto.Cipher.blockalgo.BlockAlgo.encrypt 26 | - cryptography.fernet.Fernet.encrypt 27 | - Crypto.Cipher.ARC4.ARC4Cipher.encrypt 28 | - Crypto.Cipher.ChaCha20.ChaCha20Cipher.encrypt 29 | - Crypto.Cipher.ChaCha20_Poly1305.ChaCha20Poly1305Cipher.encrypt 30 | - Crypto.Cipher.PKCS1_OAEP.PKCS1OAEP_Cipher.encrypt 31 | - Crypto.Cipher.PKCS1_v1_5.PKCS115_Cipher.encrypt 32 | - Crypto.Cipher.Salsa20.Salsa20Cipher.encrypt 33 | - Crypto.Cipher._mode_cbc.CbcMode.encrypt 34 | - Crypto.Cipher._mode_ccm.CcmMode.encrypt 35 | - Crypto.Cipher._mode_cfb.CfbMode.encrypt 36 | - Crypto.Cipher._mode_ctr.CtrMode.encrypt 37 | - Crypto.Cipher._mode_eax.EaxMode.encrypt 38 | - Crypto.Cipher._mode_ecb.EcbMode.encrypt 39 | - Crypto.Cipher._mode_gcm.GcmMode.encrypt 40 | - Crypto.Cipher._mode_ocb.OcbMode.encrypt 41 | - Crypto.Cipher._mode_ofb.OfbMode.encrypt 42 | - Crypto.Cipher._mode_openpgp.OpenPgpMode.encrypt 43 | - Crypto.Cipher._mode_siv.SivMode.encrypt 44 | - Crypto.Cipher.ChaCha20_Poly1305.ChaCha20Poly1305Cipher.encrypt_and_digest 45 | - Crypto.Cipher._mode_ccm.CcmMode.encrypt_and_digest 46 | - Crypto.Cipher._mode_eax.EaxMode.encrypt_and_digest 47 | - Crypto.Cipher._mode_gcm.GcmMode.encrypt_and_digest 48 | - Crypto.Cipher._mode_ocb.OcbMode.encrypt_and_digest 49 | - Crypto.Cipher._mode_siv.SivMode.encrypt_and_digest 50 | crypto.decrypt: 51 | - Crypto.Cipher.blockalgo.BlockAlgo.decrypt 52 | - cryptography.fernet.Fernet.decrypt 53 | - Crypto.Cipher.ARC4.ARC4Cipher.decrypt 54 | - Crypto.Cipher.ChaCha20.ChaCha20Cipher.decrypt 55 | - Crypto.Cipher.ChaCha20_Poly1305.ChaCha20Poly1305Cipher.decrypt 56 | - Crypto.Cipher.PKCS1_OAEP.PKCS1OAEP_Cipher.decrypt 57 | - Crypto.Cipher.PKCS1_v1_5.PKCS115_Cipher.decrypt 58 | - Crypto.Cipher.Salsa20.Salsa20Cipher.decrypt 59 | - Crypto.Cipher._mode_cbc.CbcMode.decrypt 60 | - Crypto.Cipher._mode_ccm.CcmMode.decrypt 61 | - Crypto.Cipher._mode_cfb.CfbMode.decrypt 62 | - Crypto.Cipher._mode_ctr.CtrMode.decrypt 63 | - Crypto.Cipher._mode_eax.EaxMode.decrypt 64 | - Crypto.Cipher._mode_ecb.EcbMode.decrypt 65 | - Crypto.Cipher._mode_gcm.GcmMode.decrypt 66 | - Crypto.Cipher._mode_ocb.OcbMode.decrypt 67 | - Crypto.Cipher._mode_ofb.OfbMode.decrypt 68 | - Crypto.Cipher._mode_openpgp.OpenPgpMode.decrypt 69 | - Crypto.Cipher._mode_siv.SivMode.decrypt 70 | - Crypto.Cipher.ChaCha20_Poly1305.ChaCha20Poly1305Cipher.decrypt_and_verify 71 | - Crypto.Cipher._mode_ccm.CcmMode.decrypt_and_verify 72 | - Crypto.Cipher._mode_eax.EaxMode.decrypt_and_verify 73 | - Crypto.Cipher._mode_gcm.GcmMode.decrypt_and_verify 74 | - Crypto.Cipher._mode_ocb.OcbMode.decrypt_and_verify 75 | - Crypto.Cipher._mode_siv.SivMode.decrypt_and_verify 76 | crypto.secure_compare: 77 | - hmac.compare_digest 78 | - secrets.compare_digest 79 | -------------------------------------------------------------------------------- /appmap/pytest.py: -------------------------------------------------------------------------------- 1 | from importlib.metadata import version 2 | 3 | import pytest 4 | try: 5 | from pytest_django.django_compat import is_django_unittest 6 | except ImportError: 7 | 8 | def is_django_unittest(item): 9 | return False 10 | 11 | 12 | from _appmap import noappmap, testing_framework, wrapt 13 | from _appmap.env import Env 14 | 15 | logger = Env.current.getLogger(__name__) 16 | 17 | 18 | class recorded_testcase: # pylint: disable=too-few-public-methods 19 | def __init__(self, item): 20 | self.item = item 21 | 22 | @wrapt.decorator 23 | def __call__(self, wrapped, _, args, kwargs): 24 | item = self.item 25 | with item.session.appmap.record( 26 | item.cls, item.name, method_id=item.originalname, location=item.location 27 | ) as metadata: 28 | with testing_framework.collect_result_metadata(metadata): 29 | return wrapped(*args, **kwargs) 30 | 31 | 32 | if not Env.current.is_appmap_repo and Env.current.enables("tests"): 33 | logger.debug("Test recording is enabled (Pytest)") 34 | 35 | @pytest.hookimpl 36 | def pytest_sessionstart(session): 37 | session.appmap = testing_framework.session( 38 | name="pytest", recorder_type="tests", version=version("pytest") 39 | ) 40 | 41 | @pytest.hookimpl 42 | def pytest_runtest_call(item): 43 | # The presence of a `_testcase` attribute on an item indicates 44 | # that it was created from a `unittest.TestCase`. An item 45 | # created from a TestCase has an `obj` attribute, assigned 46 | # during in setup, which holds the actual test 47 | # function. Wrapping that function will capture the recording 48 | # we want. `obj` gets unset during teardown, so there's no 49 | # chance of rewrapping it. 50 | # 51 | # However, depending on the user's configuration, `item.obj` 52 | # may have been already instrumented for recording. In this 53 | # case, it will be a `wrapt` class, rather than just a 54 | # function. This is fine: the decorator we apply here will be 55 | # called first, starting the recording. Next, the 56 | # instrumentation decorator will be called, recording the 57 | # `call` event. Finally, the original function will be called, 58 | # running the test case. (This nesting of function calls is 59 | # verified by the expected appmap in the test for a unittest 60 | # TestCase run by pytest.) 61 | if hasattr(item, "_testcase") and not is_django_unittest(item): 62 | setattr( 63 | item._testcase, # pylint: disable=protected-access 64 | "_appmap_pytest_recording", 65 | True, 66 | ) 67 | if not noappmap.disables(item.obj, item.cls): 68 | testing_framework.disable_test_case(item.obj) 69 | item.obj = recorded_testcase(item)(item.obj) 70 | 71 | @pytest.hookimpl(hookwrapper=True) 72 | def pytest_pyfunc_call(pyfuncitem): 73 | # There definitely shouldn't be a `_testcase` attribute on a 74 | # pytest test. 75 | assert not hasattr(pyfuncitem, "_testcase") 76 | 77 | if noappmap.disables(pyfuncitem.function, pyfuncitem.cls): 78 | yield 79 | return 80 | 81 | with pyfuncitem.session.appmap.record( 82 | pyfuncitem.cls, 83 | pyfuncitem.name, 84 | method_id=pyfuncitem.originalname, 85 | location=pyfuncitem.location, 86 | ) as metadata: 87 | testing_framework.disable_test_case(pyfuncitem.obj) 88 | result = yield 89 | try: 90 | with testing_framework.collect_result_metadata(metadata): 91 | result.get_result() 92 | except: # pylint: disable=bare-except # noqa: E722 93 | pass # exception got recorded in metadata 94 | -------------------------------------------------------------------------------- /_appmap/test/test_describe_value.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from _appmap.event import describe_value 4 | from _appmap.test.helpers import DictIncluding 5 | 6 | 7 | def test_describe_value_does_not_call_class(): 8 | """describe_value should not call __class__ 9 | __class__ could be overloaded in the value and 10 | could cause side effects.""" 11 | 12 | class WithOverloadedClass: 13 | # pylint: disable=missing-class-docstring,too-few-public-methods 14 | @property 15 | def __class__(self): 16 | raise RuntimeError("__class__ called") 17 | 18 | describe_value(None, WithOverloadedClass()) 19 | 20 | 21 | class TestDictValue: 22 | @pytest.fixture 23 | def value(self): 24 | return {"id": 1, "contents": "some text"} 25 | 26 | def test_one_level_schema(self, value): 27 | actual = describe_value(None, value) 28 | assert actual == DictIncluding( 29 | { 30 | "properties": [ 31 | {"name": "id", "class": "builtins.int"}, 32 | {"name": "contents", "class": "builtins.str"}, 33 | ] 34 | } 35 | ) 36 | 37 | 38 | class TestNestedDictValue: 39 | @pytest.fixture 40 | def value(self): 41 | return {"page": {"page_number": 1, "page_size": 20, "total": 2383}} 42 | 43 | def test_two_level_schema(self, value): 44 | actual = describe_value(None, value) 45 | assert actual == DictIncluding( 46 | { 47 | "properties": [ 48 | { 49 | "name": "page", 50 | "class": "builtins.dict", 51 | "properties": [ 52 | {"name": "page_number", "class": "builtins.int"}, 53 | {"name": "page_size", "class": "builtins.int"}, 54 | {"name": "total", "class": "builtins.int"}, 55 | ], 56 | } 57 | ] 58 | } 59 | ) 60 | 61 | def test_respects_max_depth(self, value): 62 | expected = {"properties": [{"name": "page", "class": "builtins.dict"}]} 63 | actual = describe_value(None, value, max_depth=1) 64 | assert actual == DictIncluding(expected) 65 | 66 | 67 | class TestListOfDicts: 68 | @pytest.fixture 69 | def value(self): 70 | return [{"id": 1, "contents": "some text"}, {"id": 2}] 71 | 72 | def test_an_array_containing_schema(self, value): 73 | actual = describe_value(None, value) 74 | assert actual["class"] == "builtins.list" 75 | assert actual["items"][0] == DictIncluding( 76 | { 77 | "class": "builtins.dict", 78 | "properties": [ 79 | {"name": "id", "class": "builtins.int"}, 80 | {"name": "contents", "class": "builtins.str"}, 81 | ], 82 | } 83 | ) 84 | assert actual["items"][1] == DictIncluding( 85 | { 86 | "class": "builtins.dict", 87 | "properties": [{"name": "id", "class": "builtins.int"}], 88 | } 89 | ) 90 | 91 | 92 | class TestNestedArrays: 93 | @pytest.fixture 94 | def value(self): 95 | return [[["one"]]] 96 | 97 | def test_arrays_ignore_max_depth(self, value): 98 | actual = describe_value(None, value, max_depth=1) 99 | expected = { 100 | "class": "builtins.list", 101 | "items": [ 102 | { 103 | "class": "builtins.list", 104 | "items": [ 105 | {"class": "builtins.list", "items": [{"class": "builtins.str"}]} 106 | ], 107 | } 108 | ], 109 | } 110 | assert actual == DictIncluding(expected) 111 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "appmap" 3 | version = "2.1.8" 4 | description = "Create AppMap files by recording a Python application." 5 | readme = "README.md" 6 | authors = [ 7 | "Alan Potter ", 8 | "Viraj Kanwade ", 9 | "Rafał Rzepecki " 10 | ] 11 | homepage = "https://github.com/applandinc/appmap-python" 12 | license = "MIT" 13 | classifiers = [ 14 | 'Development Status :: 4 - Beta', 15 | 'Framework :: Django', 16 | 'Framework :: Django :: 3.2', 17 | 'Framework :: Flask', 18 | 'Framework :: Pytest', 19 | 'Intended Audience :: Developers', 20 | 'Topic :: Software Development', 21 | 'Topic :: Software Development :: Debuggers', 22 | 'Topic :: Software Development :: Documentation' 23 | ] 24 | 25 | include = [ 26 | { path = 'appmap.pth', format = ['sdist','wheel'] }, 27 | { path = '_appmap/test/**/*', format = 'sdist' } 28 | ] 29 | 30 | exclude = ['_appmap/wrapt'] 31 | 32 | packages = [ 33 | { include = "appmap" }, 34 | { include = "_appmap/*.py" }, 35 | { include = "_appmap/wrapt/**/*", from = "vendor" } 36 | ] 37 | 38 | [tool.poetry.dependencies] 39 | # Please update the documentation if changing the supported python version 40 | # https://github.com/applandinc/applandinc.github.io/blob/master/_docs/reference/appmap-python.md#supported-versions 41 | python = "^3.8" 42 | PyYAML = ">=5.3.0" 43 | inflection = ">=0.3.0" 44 | importlib-resources = "^5.4.0" 45 | packaging = ">=19.0" 46 | # If you include "Django" as an optional dependency here, you'll be able to use poetry to install it 47 | # in your dev environment. However, doing so causes poetry v1.2.0 to remove it from the virtualenv 48 | # *created and managed by tox*, i.e. not your dev environment. 49 | # 50 | # So, if you'd like to run the tests outside of tox, run `pip install -r requirements-dev.txt` to 51 | # install it and the rest of the dev dependencies. 52 | 53 | [tool.poetry.group.dev.dependencies] 54 | Twisted = "^22.4.0" 55 | incremental = "<24.7.0" 56 | asgiref = "^3.7.2" 57 | black = "^24.2.0" 58 | coverage = "^5.3" 59 | flake8 = "^3.8.4" 60 | httpretty = "^1.0.5" 61 | isort = "^5.10.1" 62 | pprintpp = ">=0.4.0" 63 | pyfakefs = "^5.3.5" 64 | pylint = "^3.0" 65 | pytest = "^7.3.2" 66 | pytest-django = "~4.7" 67 | pytest-mock = "^3.5.1" 68 | pytest-randomly = "^3.5.0" 69 | pytest-shell-utilities = "^1.8.0" 70 | pytest-xprocess = "^0.23.0" 71 | python-decouple = "^3.5" 72 | requests = "^2.25.1" 73 | tox = "^3.22.0" 74 | # v2.30.0 of "requests" depends on urllib3 v2, which breaks the tests for http_client_requests. Pin 75 | # to v1 until this gets fixed. 76 | urllib3 = "^1" 77 | uvicorn = "^0.27.1" 78 | fastapi = "^0.110.0" 79 | httpx = "^0.27.0" 80 | pytest-env = "^1.1.3" 81 | pytest-console-scripts = "^1.4.1" 82 | pytest-xdist = "^3.6.1" 83 | psutil = "^6.0.0" 84 | ruff = "^0.5.3" 85 | 86 | [build-system] 87 | requires = ["poetry-core>=1.1.0"] 88 | build-backend = "poetry.core.masonry.api" 89 | 90 | [tool.poetry.plugins."pytest11"] 91 | appmap = "appmap.pytest" 92 | 93 | [tool.poetry.scripts] 94 | appmap-agent-init = "appmap.command.appmap_agent_init:run" 95 | appmap-agent-status = "appmap.command.appmap_agent_status:run" 96 | appmap-agent-validate = "appmap.command.appmap_agent_validate:run" 97 | appmap-python = "appmap.command.runner:run" 98 | 99 | [tool.black] 100 | line-length = 102 101 | extend-exclude = ''' 102 | /( 103 | | vendor 104 | | _appmap/wrapt 105 | )/ 106 | ''' 107 | 108 | [tool.isort] 109 | profile = "black" 110 | extend_skip = [ 111 | "vendor", 112 | "_appmap/wrapt" 113 | ] 114 | 115 | [tool.vendoring] 116 | destination = "vendor/_appmap/" 117 | requirements = "vendor/vendor.txt" 118 | namespace = "" 119 | 120 | protected-files = ["vendor.txt"] 121 | patches-dir = "vendor/patches" 122 | 123 | [tool.vendoring.transformations] 124 | drop = [ 125 | "**/*.so", 126 | ] 127 | -------------------------------------------------------------------------------- /_appmap/test/data/unittest/expected/unittest-no-test-cases.appmap.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.9", 3 | "metadata": { 4 | "language": { 5 | "name": "python" 6 | }, 7 | "client": { 8 | "name": "appmap", 9 | "url": "https://github.com/applandinc/appmap-python" 10 | }, 11 | "feature_group": "Unit test test", 12 | "recording": { 13 | "defined_class": "simple.test_simple.UnitTestTest", 14 | "method_id": "test_hello_world" 15 | }, 16 | "source_location": "simple/test_simple.py:14", 17 | "name": "Unit test test hello world", 18 | "feature": "Hello world", 19 | "app": "Simple", 20 | "recorder": { 21 | "name": "unittest", 22 | "type": "tests" 23 | }, 24 | "test_status": "succeeded" 25 | }, 26 | "events": [ 27 | { 28 | "static": false, 29 | "receiver": { 30 | "kind": "req", 31 | "value": "", 32 | "name": "self", 33 | "class": "simple.Simple" 34 | }, 35 | "parameters": [ 36 | { 37 | "kind": "req", 38 | "value": "'!'", 39 | "name": "bang", 40 | "class": "builtins.str" 41 | } 42 | ], 43 | "id": 1, 44 | "event": "call", 45 | "thread_id": 1, 46 | "defined_class": "simple.Simple", 47 | "method_id": "hello_world", 48 | "path": "simple/__init__.py", 49 | "lineno": 8 50 | }, 51 | { 52 | "static": false, 53 | "receiver": { 54 | "kind": "req", 55 | "value": "", 56 | "name": "self", 57 | "class": "simple.Simple" 58 | }, 59 | "parameters": [], 60 | "id": 2, 61 | "event": "call", 62 | "thread_id": 1, 63 | "defined_class": "simple.Simple", 64 | "method_id": "hello", 65 | "path": "simple/__init__.py", 66 | "lineno": 2 67 | }, 68 | { 69 | "return_value": { 70 | "value": "'Hello'", 71 | "class": "builtins.str" 72 | }, 73 | "parent_id": 2, 74 | "id": 3, 75 | "event": "return", 76 | "thread_id": 1 77 | }, 78 | { 79 | "static": false, 80 | "receiver": { 81 | "kind": "req", 82 | "value": "", 83 | "name": "self", 84 | "class": "simple.Simple" 85 | }, 86 | "parameters": [], 87 | "id": 4, 88 | "event": "call", 89 | "thread_id": 1, 90 | "defined_class": "simple.Simple", 91 | "method_id": "world", 92 | "path": "simple/__init__.py", 93 | "lineno": 5 94 | }, 95 | { 96 | "return_value": { 97 | "value": "'world'", 98 | "class": "builtins.str" 99 | }, 100 | "parent_id": 4, 101 | "id": 5, 102 | "event": "return", 103 | "thread_id": 1 104 | }, 105 | { 106 | "return_value": { 107 | "value": "'Hello world!'", 108 | "class": "builtins.str" 109 | }, 110 | "parent_id": 1, 111 | "id": 6, 112 | "event": "return", 113 | "thread_id": 1 114 | } 115 | ], 116 | "classMap": [ 117 | { 118 | "name": "simple", 119 | "type": "package", 120 | "children": [ 121 | { 122 | "name": "Simple", 123 | "type": "class", 124 | "children": [ 125 | { 126 | "name": "hello", 127 | "type": "function", 128 | "location": "simple/__init__.py:2", 129 | "static": false 130 | }, 131 | { 132 | "name": "hello_world", 133 | "type": "function", 134 | "location": "simple/__init__.py:8", 135 | "static": false 136 | }, 137 | { 138 | "name": "world", 139 | "type": "function", 140 | "location": "simple/__init__.py:5", 141 | "static": false 142 | } 143 | ] 144 | } 145 | ] 146 | } 147 | ] 148 | } -------------------------------------------------------------------------------- /_appmap/test/appmap_test_base.py: -------------------------------------------------------------------------------- 1 | import json 2 | import platform 3 | import re 4 | from importlib.metadata import version as dist_version 5 | from operator import itemgetter 6 | 7 | import pytest 8 | 9 | import _appmap 10 | from _appmap.recorder import Recorder 11 | 12 | 13 | def normalize_path(path): 14 | """ 15 | Normalize absolute path to a file in a package down to a package path. 16 | Not foolproof, but good enough for the tests. 17 | """ 18 | return re.sub(r"^.*site-packages/", "", path) 19 | 20 | 21 | class AppMapTestBase: 22 | def setup_method(self, _): 23 | _appmap.initialize() # pylint: disable=protected-access 24 | 25 | @staticmethod 26 | @pytest.fixture 27 | def events(): 28 | # pylint: disable=protected-access 29 | rec = Recorder.get_current() 30 | rec.clear() 31 | rec._enabled = True 32 | return rec.events 33 | 34 | @staticmethod 35 | def normalize_git(git): 36 | git.pop("repository") 37 | git.pop("branch") 38 | git.pop("commit") 39 | status = git.pop("status") 40 | assert isinstance(status, list) 41 | tag = git.pop("tag", None) 42 | if tag: 43 | assert isinstance(tag, str) 44 | commits_since_tag = git.pop("commits_since_tag", None) 45 | if commits_since_tag: 46 | assert isinstance(commits_since_tag, int) 47 | git.pop("annotated_tag", None) 48 | 49 | commits_since_annotated_tag = git.pop("commits_since_annotated_tag", None) 50 | if commits_since_annotated_tag: 51 | assert isinstance(commits_since_annotated_tag, int) 52 | 53 | @staticmethod 54 | def normalize_metadata(metadata): 55 | engine = metadata["language"].pop("engine") 56 | assert engine == platform.python_implementation() 57 | version = metadata["language"].pop("version") 58 | assert version == platform.python_version() 59 | 60 | if "frameworks" in metadata: 61 | frameworks = metadata["frameworks"] 62 | for f in frameworks: 63 | if f["name"] == "pytest": 64 | v = f.pop("version") 65 | assert v == dist_version("pytest") 66 | 67 | def normalize_appmap(self, generated_appmap): 68 | """ 69 | Normalize the data in generated_appmap, removing any 70 | environment-specific values. 71 | 72 | Note that attempts to access required keys will raise 73 | KeyError, causing the test to fail. 74 | """ 75 | 76 | def normalize(dct): 77 | if "classMap" in dct: 78 | dct["classMap"].sort(key=itemgetter("name")) 79 | if "children" in dct: 80 | dct["children"].sort(key=itemgetter("name")) 81 | if "elapsed" in dct: 82 | elapsed = dct.pop("elapsed") 83 | assert isinstance(elapsed, float) 84 | if "git" in dct: 85 | self.normalize_git(dct.pop("git")) 86 | if "location" in dct: 87 | dct["location"] = normalize_path(dct["location"]) 88 | if "path" in dct: 89 | dct["path"] = normalize_path(dct["path"]) 90 | if "metadata" in dct: 91 | self.normalize_metadata(dct["metadata"]) 92 | if "object_id" in dct: 93 | object_id = dct.pop("object_id") 94 | assert isinstance(object_id, int) 95 | if "value" in dct: 96 | # This maps all object references to the same 97 | # location. We don't actually need to verify that the 98 | # locations are correct -- if they weren't, the 99 | # instrumented code would be broken, right? 100 | v = dct["value"] 101 | dct["value"] = re.sub( 102 | r"<(.*) object at 0x.*>", r"<\1 object at 0xabcdef>", v 103 | ) 104 | return dct 105 | 106 | return json.loads(generated_appmap, object_hook=normalize) 107 | -------------------------------------------------------------------------------- /_appmap/recording.py: -------------------------------------------------------------------------------- 1 | import atexit 2 | import os 3 | from datetime import datetime, timezone 4 | from tempfile import NamedTemporaryFile 5 | 6 | from _appmap import generation 7 | from _appmap.web_framework import APPMAP_SUFFIX, HASH_LEN, NAME_MAX, name_hash 8 | 9 | from .env import Env 10 | from .recorder import Recorder 11 | 12 | logger = Env.current.getLogger(__name__) 13 | 14 | 15 | class Recording: 16 | """ 17 | Context manager to make it easy to capture a Recording. exit_hook 18 | will be called when the block exits, before any exceptions are 19 | raised. 20 | """ 21 | 22 | def __init__(self, exit_hook=None): 23 | self.events = [] 24 | self.exit_hook = exit_hook 25 | 26 | def start(self): 27 | if not Env.current.enabled: 28 | return 29 | 30 | r = Recorder.get_current() 31 | r.clear() 32 | r.start_recording() 33 | 34 | def stop(self): 35 | if not Env.current.enabled: 36 | return 37 | 38 | self.events += Recorder.stop_recording() 39 | 40 | def is_running(self): 41 | if not Env.current.enabled: 42 | return False 43 | 44 | return Recorder.get_enabled() 45 | 46 | def __enter__(self): 47 | self.start() 48 | 49 | def __exit__(self, exc_type, exc_value, tb): 50 | logger.debug("Recording.__exit__, stopping with exception %s", exc_type) 51 | self.stop() 52 | if self.exit_hook is not None: 53 | self.exit_hook(self) 54 | return False 55 | 56 | 57 | class NoopRecording: 58 | """ 59 | A noop context manager to export as "Recording" instead of class 60 | Recording when not Env.current.enabled. 61 | """ 62 | 63 | def __init__(self, exit_hook=None): 64 | self.exit_hook = exit_hook 65 | self.events = [] 66 | 67 | def start(self): 68 | pass 69 | 70 | def stop(self): 71 | pass 72 | 73 | def is_running(self): 74 | return False 75 | 76 | def __enter__(self): 77 | pass 78 | 79 | def __exit__(self, exc_type, exc_value, tb): 80 | if self.exit_hook is not None: 81 | self.exit_hook(self) 82 | return False 83 | 84 | 85 | def write_appmap( 86 | appmap, appmap_fname, recorder_type, metadata=None, basedir=Env.current.output_dir 87 | ): 88 | """Write an appmap file into basedir. 89 | 90 | Adds APPMAP_SUFFIX to basename; shortens the name if necessary. 91 | Atomically replaces existing files. Creates the basedir if required. 92 | """ 93 | 94 | if len(appmap_fname) > NAME_MAX - len(APPMAP_SUFFIX): 95 | part = NAME_MAX - len(APPMAP_SUFFIX) - 1 - HASH_LEN 96 | appmap_fname = appmap_fname[:part] + "-" + name_hash(appmap_fname[part:])[:HASH_LEN] 97 | filename = appmap_fname + APPMAP_SUFFIX 98 | 99 | basedir = basedir / recorder_type 100 | basedir.mkdir(parents=True, exist_ok=True) 101 | 102 | with NamedTemporaryFile(mode="w", dir=basedir, delete=False) as tmp: 103 | tmp.write(generation.dump(appmap, metadata)) 104 | appmap_file = basedir / filename 105 | logger.info("writing %s", appmap_file) 106 | os.replace(tmp.name, appmap_file) 107 | 108 | 109 | def initialize(): 110 | if Env.current.enables("process", Env.RECORD_PROCESS_DEFAULT): 111 | r = Recording() 112 | r.start() 113 | 114 | def save_at_exit(): 115 | nonlocal r 116 | r.stop() 117 | now = datetime.now(timezone.utc) 118 | iso_time = now.isoformat(timespec="seconds").replace("+00:00", "Z") 119 | process_id = os.getpid() 120 | appmap_name = f"{iso_time}_{process_id}" 121 | recorder_type = "process" 122 | metadata = { 123 | "name": appmap_name, 124 | "recorder": { 125 | "name": "process", 126 | "type": recorder_type, 127 | }, 128 | } 129 | write_appmap(r, appmap_name, recorder_type, metadata) 130 | 131 | atexit.register(save_at_exit) 132 | -------------------------------------------------------------------------------- /appmap/command/appmap_agent_status.py: -------------------------------------------------------------------------------- 1 | import json 2 | import sys 3 | from argparse import ArgumentParser 4 | from importlib.metadata import PackageNotFoundError, distribution, version 5 | 6 | from _appmap.configuration import Config 7 | from _appmap.env import Env 8 | 9 | logger = Env.current.getLogger(__name__) 10 | 11 | 12 | def has_dist(dist): 13 | try: 14 | distribution(dist) 15 | return True 16 | except PackageNotFoundError: 17 | pass 18 | return False 19 | 20 | 21 | class AgentFileCollector: # pylint: disable=too-few-public-methods 22 | def __init__(self): 23 | self.collected = set() 24 | 25 | def pytest_collection_modifyitems(self, items): 26 | for item in items: 27 | self.collected.add(item.fspath) 28 | items.clear() 29 | 30 | 31 | def discover_pytest_tests(): 32 | """ 33 | Use pytest to discover all test files for the current project. 34 | Disables logging from pytest, but otherwise, uses pytest's default 35 | options. This means that if the project has a pytest.ini, it will 36 | be used. 37 | """ 38 | 39 | logger.info("discovering pytest tests") 40 | # --capture=no => don't muck with stdout/stderr 41 | # --verbosity=-2 => don't do any logging during collection, and don't show warning summary 42 | # --disable-warnings => don't show warning summary 43 | # 44 | collector = AgentFileCollector() 45 | 46 | import pytest # pylint: disable=import-outside-toplevel 47 | 48 | pytest.main( 49 | [ 50 | "--collect-only", 51 | "--capture=no", 52 | "--verbosity=-2", 53 | "--disable-warnings", 54 | ], 55 | plugins=[collector], 56 | ) 57 | 58 | logger.info("found %d pytest test(s)", len(collector.collected)) 59 | return collector.collected 60 | 61 | 62 | def has_pytest_tests(): 63 | return len(discover_pytest_tests()) > 0 64 | 65 | 66 | def has_unittest_tests(): 67 | return False 68 | 69 | 70 | def _run(*, discover_tests): 71 | config = Config.current 72 | uses_pytest = has_dist("pytest") 73 | 74 | has_tests = None 75 | if discover_tests: 76 | if uses_pytest: 77 | has_tests = has_pytest_tests() 78 | else: 79 | has_tests = has_unittest_tests() 80 | 81 | if has_tests: 82 | test_command = {"args": []} 83 | 84 | if uses_pytest: 85 | test_command.update({"framework": "pytest", "command": "pytest"}) 86 | else: 87 | test_command.update( 88 | { 89 | "framework": "unittest", 90 | "command": "python", 91 | "args": ["-m", "unittest"], 92 | } 93 | ) 94 | else: 95 | test_command = None 96 | 97 | can_record = has_dist("Django") or has_dist("Flask") 98 | 99 | properties = { 100 | "properties": { 101 | "config": { 102 | "app": config.name, 103 | "present": config.file_present, 104 | "valid": config.file_valid, 105 | }, 106 | "project": { 107 | "agentVersion": version("appmap"), 108 | "language": "python", 109 | "remoteRecordingCapable": can_record, 110 | }, 111 | } 112 | } 113 | 114 | if has_tests is not None: 115 | properties["properties"]["project"]["integrationTests"] = has_tests 116 | if has_tests: 117 | properties["test_commands"] = [test_command] 118 | 119 | if test_command: 120 | properties.update({}) 121 | 122 | print(json.dumps(properties)) 123 | 124 | return 0 125 | 126 | 127 | def run(): 128 | parser = ArgumentParser(description="Report project status for AppMap agent.") 129 | parser.add_argument( 130 | "--discover-tests", action="store_true", help="Scan the project for tests" 131 | ) 132 | args = parser.parse_args() 133 | sys.exit(_run(discover_tests=args.discover_tests)) 134 | 135 | 136 | if __name__ == "__main__": 137 | run() 138 | -------------------------------------------------------------------------------- /_appmap/generation.py: -------------------------------------------------------------------------------- 1 | """Generate an AppMap""" 2 | import json 3 | 4 | from .event import Event 5 | from .metadata import Metadata 6 | 7 | 8 | # ClassMapDict needs to quack a little like a dict. If it's actually a 9 | # subclass of dict, though, json tries to process it without calling 10 | # our encoder. So, just implement the methods we need. 11 | class ClassMapDict: 12 | def __init__(self): 13 | self._dict = {} 14 | 15 | def setdefault(self, k, default): 16 | return self._dict.setdefault(k, default) 17 | 18 | def values(self): 19 | return self._dict.values() 20 | 21 | 22 | class ClassMapEntry: # pylint: disable=too-few-public-methods 23 | # pylint: disable=redefined-builtin 24 | def __init__(self, name, type): 25 | self.name = name 26 | # `type` is a builtin, but the appmap attribute is named 27 | # `type`. So, ignore this warning, unless it turns out to 28 | # actually be a problem, of course. 29 | self.type = type 30 | 31 | def to_dict(self): 32 | return {k: v for k, v in vars(self).items() if v is not None} 33 | 34 | 35 | class PackageEntry(ClassMapEntry): # pylint: disable=too-few-public-methods 36 | def __init__(self, name): 37 | super().__init__(name, "package") 38 | self.children = ClassMapDict() 39 | 40 | 41 | class ClassEntry(ClassMapEntry): # pylint: disable=too-few-public-methods 42 | def __init__(self, name): 43 | super().__init__(name, "class") 44 | self.children = ClassMapDict() 45 | 46 | 47 | class FuncEntry(ClassMapEntry): # pylint: disable=too-few-public-methods 48 | def __init__(self, e): 49 | super().__init__(e.method_id, "function") 50 | self.location = "%s:%s" % (e.path, e.lineno) 51 | self.static = e.static 52 | self.labels = e.labels 53 | self.comment = e.comment 54 | 55 | 56 | def classmap(recording): 57 | ret = ClassMapDict() 58 | for e in recording.events: 59 | try: 60 | if e.event != "call": 61 | continue 62 | 63 | packages, *classes = e.defined_class.rsplit(".", 1) 64 | # If there's only a single component in the name 65 | # (e.g. it's a module name), use it as a class. 66 | if len(classes) == 0: 67 | class_ = packages 68 | packages = [] 69 | else: 70 | class_ = classes[0] 71 | packages = packages.split(".") 72 | 73 | children = ret 74 | for p in packages: 75 | entry = children.setdefault(p, PackageEntry(p)) 76 | children = entry.children 77 | 78 | entry = children.setdefault(class_, ClassEntry(class_)) 79 | children = entry.children 80 | 81 | loc = "%s:%s" % (e.path, e.lineno) 82 | children.setdefault(loc, FuncEntry(e)) 83 | except AttributeError: 84 | # Event might not have a defined_class attribute; 85 | # SQL events for example are calls without it. 86 | # Ignore them when building the class map. 87 | continue 88 | 89 | return ret 90 | 91 | 92 | def appmap(recording, metadata): 93 | appmap_metadata = Metadata() 94 | if metadata: 95 | appmap_metadata.update(metadata) 96 | 97 | return { 98 | "version": "1.9", 99 | "metadata": appmap_metadata, 100 | "events": recording.events, 101 | "classMap": list(classmap(recording).values()), 102 | } 103 | 104 | 105 | class AppMapEncoder(json.JSONEncoder): 106 | def default(self, o): 107 | if isinstance(o, Event): 108 | return o.to_dict() 109 | if isinstance(o, ClassMapDict): 110 | return list(o.values()) 111 | if isinstance(o, ClassMapEntry): 112 | return o.to_dict() 113 | 114 | try: 115 | return json.JSONEncoder.default(self, o) 116 | except TypeError: 117 | return str(o) 118 | 119 | 120 | def dump(recording, metadata=None, indent=None): 121 | a = appmap(recording, metadata) 122 | return json.dumps(a, cls=AppMapEncoder, indent=indent) 123 | -------------------------------------------------------------------------------- /appmap/command/runner.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import getopt 3 | import os 4 | import sys 5 | import textwrap 6 | 7 | _parser = argparse.ArgumentParser( 8 | description=textwrap.dedent(""" 9 | Enable recording of the provided command, optionally specifying the 10 | type(s) of recording to enable and disable. If a recording type is 11 | specified as both enabled and disabled, it will be enabled. 12 | 13 | This command sets the environment variables described here: 14 | https://appmap.io/docs/reference/appmap-python.html#controlling-recording. 15 | For any recording type that is not explicitly specified, the 16 | corresponding environment variable will not be set. 17 | 18 | If no command is provided, the computed set of environment variables 19 | will be displayed. 20 | """), 21 | formatter_class=argparse.RawDescriptionHelpFormatter, 22 | ) 23 | 24 | _RECORDING_TYPES = set( 25 | [ 26 | "process", 27 | "remote", 28 | "requests", 29 | "tests", 30 | ] 31 | ) 32 | 33 | 34 | def recording_types(v: str): 35 | values = set(v.split(",")) 36 | if not values & _RECORDING_TYPES: 37 | raise argparse.ArgumentTypeError(v) 38 | return values 39 | 40 | 41 | _parser.add_argument( 42 | "--record", 43 | help="recording types to enable", 44 | metavar=",".join(_RECORDING_TYPES), 45 | type=recording_types, 46 | default=argparse.SUPPRESS, 47 | ) 48 | _parser.add_argument( 49 | "--no-record", 50 | help="recording types to disable", 51 | metavar=",".join(_RECORDING_TYPES), 52 | type=recording_types, 53 | default=argparse.SUPPRESS, 54 | ) 55 | 56 | if sys.version_info >= (3, 9): 57 | _parser.add_argument( 58 | "--enable-log", 59 | help="create a log file", 60 | action=argparse.BooleanOptionalAction, 61 | default=False, 62 | ) 63 | else: 64 | # You can see why BooleanOptionalAction was added. This is close, though not 65 | # really as good.... 66 | _enable_log_group = _parser.add_mutually_exclusive_group() 67 | _enable_log_group.add_argument( 68 | "--enable-log", 69 | help="create a log file", 70 | dest="enable_log", 71 | action="store_true", 72 | ) 73 | _enable_log_group.add_argument( 74 | "--no-enable-log", 75 | help="don't create a log file", 76 | dest="enable_log", 77 | action="store_false", 78 | ) 79 | 80 | _parser.add_argument( 81 | "command", 82 | nargs="*", 83 | help="the command to run (default: display the environment variables)", 84 | default=argparse.SUPPRESS, 85 | ) 86 | 87 | 88 | def run(): 89 | if len(sys.argv) == 1: 90 | _parser.print_help() 91 | sys.exit(1) 92 | 93 | # Use gnu_getopt to separate the command line into args we know about, 94 | # followed by the command to run (and its args) 95 | try: 96 | getopt_flags = ["help", "record=", "no-record=", "enable-log", "no-enable-log"] 97 | opts, cmd = getopt.gnu_getopt(sys.argv[1:], "+h", getopt_flags) 98 | except getopt.GetoptError as exc: 99 | print(exc, file=sys.stderr) 100 | _parser.print_help() 101 | sys.exit(1) 102 | 103 | # parse the args after flattening the tuples returned from gnu_getopt 104 | flags = [f for opt in opts for f in opt if len(f) > 0] 105 | parsed_args = _parser.parse_args(flags) 106 | parsed_args = vars(parsed_args) 107 | 108 | # our settings override those in the environment 109 | envvars = { 110 | "APPMAP": "true", 111 | "_APPMAP": "true", 112 | } 113 | 114 | # Set the environment variables based on the the flags. A recording type in 115 | # --record overrides one set in --no-record. The environment variable for a 116 | # type that doesn't appear in either will be unset. 117 | record = parsed_args.get("record", set()) 118 | no_record = parsed_args.get("no_record", set()) - record 119 | for enabled in record: 120 | envvars[f"APPMAP_RECORD_{enabled.upper()}"] = "true" 121 | for disabled in no_record: 122 | envvars[f"APPMAP_RECORD_{disabled.upper()}"] = "false" 123 | 124 | envvars["APPMAP_DISABLE_LOG_FILE"] = ( 125 | "true" if parsed_args.get("no_enable_log", set()) else "false" 126 | ) 127 | 128 | if len(cmd) == 0: 129 | for k, v in sorted(envvars.items()): 130 | print(f"{k}={v}") 131 | sys.exit(0) 132 | 133 | os.execvpe(cmd[0], cmd, {**os.environ, **envvars}) 134 | 135 | 136 | if __name__ == "__main__": 137 | run() 138 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | - [About](#about) 2 | - [Usage](#usage) 3 | - [Development](#development) 4 | - [Getting the code](#getting-the-code) 5 | - [Python version support](#python-version-support) 6 | - [Dependency management](#dependency-management) 7 | - [wrapt](#wrapt) 8 | - [Linting](#linting) 9 | - [Testing](#testing) 10 | - [pytest](#pytest) 11 | - [tox](#tox) 12 | - [Code Coverage](#code-coverage) 13 | 14 | # About 15 | `appmap-python` is a Python package for recording 16 | [AppMaps](https://github.com/applandinc/appmap) of your code. "AppMap" is a data format 17 | which records code structure (modules, classes, and methods), code execution events 18 | (function calls and returns), and code metadata (repo name, repo URL, commit SHA, labels, 19 | etc). It's more granular than a performance profile, but it's less granular than a full 20 | debug trace. It's designed to be optimal for understanding the design intent and structure 21 | of code and key data flows. 22 | 23 | # Usage 24 | 25 | Visit the [AppMap for Python](https://appland.com/docs/reference/appmap-python.html) reference page on AppLand.com for a complete reference guide. 26 | 27 | # Development 28 | 29 | [![Build Status](https://travis-ci.com/getappmap/appmap-python.svg?branch=master)](https://travis-ci.com/getappmap/appmap-python) 30 | 31 | ## Getting the code 32 | Clone the repo to begin development. 33 | 34 | ```shell 35 | % git clone https://github.com/applandinc/appmap-python.git 36 | Cloning into 'appmap-python'... 37 | remote: Enumerating objects: 167, done. 38 | remote: Counting objects: 100% (167/167), done. 39 | remote: Compressing objects: 100% (100/100), done. 40 | remote: Total 962 (delta 95), reused 116 (delta 61), pack-reused 795 41 | Receiving objects: 100% (962/962), 217.31 KiB | 4.62 MiB/s, done. 42 | Resolving deltas: 100% (653/653), done. 43 | ``` 44 | 45 | ## Python version support 46 | As a package intended to be installed in as many environments as possible, `appmap-python` 47 | needs to avoid using features of Python or the standard library that were added after the 48 | oldest version currently supported (see the 49 | [supported versions](https://appland.com/docs/reference/appmap-python.html#supported-versions)). 50 | 51 | ## Dependency management 52 | 53 | [poetry](https://https://python-poetry.org/) for dependency management: 54 | 55 | ``` 56 | % brew install poetry 57 | % cd appmap-python 58 | % poetry install 59 | ``` 60 | 61 | ### wrapt 62 | The one dependency that is not managed using `poetry` is `wrapt`. Because it's possible that 63 | projects that use `appmap` may also need an unmodified version of `wrapt` (e.g. `pylint` depends on 64 | `astroid`, which in turn depends on `wrapt`), we use 65 | [vendoring](https://github.com/pradyunsg/vendoring) to vendor `wrapt`. 66 | 67 | To update `wrapt`, use `tox` (described below) to run the `vendoring` environment. 68 | 69 | ## Linting 70 | [pylint](https://www.pylint.org/) for linting: 71 | 72 | ``` 73 | % cd appmap-python 74 | % poetry run pylint appmap 75 | 76 | -------------------------------------------------------------------- 77 | Your code has been rated at 10.00/10 (previous run: 10.00/10, +0.00) 78 | 79 | ``` 80 | 81 | [Note that the current configuration has a threshold set which must be met for the Travis build to 82 | pass. To make this easier to achieve, a number of checks have both been disabled. They should be 83 | reenabled as soon as possible.] 84 | 85 | 86 | ## Testing 87 | ### pytest 88 | 89 | Note that you must install the dependencies contained in 90 | [requirements-dev.txt](requirements-dev.txt) before running tests. See the explanation in 91 | [pyproject.toml](pyproject.toml) for details. 92 | 93 | Additionally, the tests currently require that you set `APPMAP=true` and 94 | `APPMAP_DISPLAY_PARAMS=true`. 95 | 96 | [pytest](https://docs.pytest.org/en/stable/) for testing: 97 | 98 | ``` 99 | % cd appmap-python 100 | % pip install -r requirements-test.txt 101 | % APPMAP=true APPMAP_DISPLAY_PARAMS=true poetry run pytest 102 | ``` 103 | 104 | ### tox 105 | Additionally, the `tox` configuration provides the ability to run the tests for all 106 | supported versions of Python and Django. 107 | 108 | `tox` requires that all the correct versions of Python to be available to create 109 | the test environments. [pyenv](https://github.com/pyenv/pyenv) is an easy way to manage 110 | multiple versions of Python, and the [xxenv-latest 111 | plugin](https://github.com/momo-lab/xxenv-latest) can help get all the latest versions. 112 | 113 | 114 | 115 | ```sh 116 | % brew install pyenv 117 | % git clone https://github.com/momo-lab/xxenv-latest.git "$(pyenv root)"/plugins/xxenv-latest 118 | % cd appmap-python 119 | % pyenv latest local 3.{9,6,7,8} 120 | % for v in 3.{9,6,7,8}; do pyenv latest install $v; done 121 | % poetry run tox 122 | ``` 123 | 124 | ## Code Coverage 125 | [coverage](https://coverage.readthedocs.io/) for coverage: 126 | 127 | ``` 128 | % cd appmap-python 129 | % poetry run coverage run -m pytest 130 | % poetry run coverage html 131 | % open htmlcov/index.html 132 | ``` 133 | -------------------------------------------------------------------------------- /_appmap/test/test_properties.py: -------------------------------------------------------------------------------- 1 | """Tests for methods decorated with @property""" 2 | 3 | # pyright: reportMissingImports=false 4 | # pylint: disable=import-error,import-outside-toplevel 5 | import pytest 6 | from _appmap.test.helpers import DictIncluding 7 | 8 | pytestmark = pytest.mark.appmap_enabled 9 | 10 | @pytest.fixture(autouse=True) 11 | def setup(with_data_dir): # pylint: disable=unused-argument 12 | # with_data_dir sets up sys.path so properties_class can be imported 13 | pass 14 | 15 | 16 | def test_getter_instrumented(events): 17 | from properties_class import PropertiesClass 18 | 19 | ec = PropertiesClass() 20 | 21 | actual = PropertiesClass.read_only.__doc__ 22 | assert actual == "Read-only" 23 | 24 | assert ec.read_only == "read only" 25 | 26 | with pytest.raises(AttributeError, match=r".*(has no setter|can't set attribute).*"): 27 | ec.read_only = "not allowed" 28 | 29 | with pytest.raises(AttributeError, match=r".*(has no deleter|can't delete attribute).*"): 30 | del ec.read_only 31 | 32 | assert len(events) == 2 33 | assert events[0].to_dict() == DictIncluding({ 34 | "event": "call", 35 | "defined_class": "properties_class.PropertiesClass", 36 | "method_id": "read_only (get)", 37 | }) 38 | 39 | 40 | def test_accessible_instrumented(events): 41 | from properties_class import PropertiesClass 42 | 43 | ec = PropertiesClass() 44 | assert PropertiesClass.fully_accessible.__doc__ == "Fully-accessible" 45 | 46 | assert ec.fully_accessible == "fully accessible" 47 | 48 | ec.fully_accessible = "updated" 49 | # Check the value of the attribute directly, to avoid extra events 50 | assert ec._fully_accessible == "updated" # pylint: disable=protected-access 51 | 52 | del ec.fully_accessible 53 | 54 | assert len(events) == 6 55 | assert events[0].to_dict() == DictIncluding({ 56 | "event": "call", 57 | "defined_class": "properties_class.PropertiesClass", 58 | "method_id": "fully_accessible (get)", 59 | }) 60 | 61 | assert events[2].to_dict() == DictIncluding({ 62 | "event": "call", 63 | "defined_class": "properties_class.PropertiesClass", 64 | "method_id": "fully_accessible (set)", 65 | }) 66 | 67 | assert events[4].to_dict() == DictIncluding({ 68 | "event": "call", 69 | "defined_class": "properties_class.PropertiesClass", 70 | "method_id": "fully_accessible (del)", 71 | }) 72 | 73 | 74 | def test_writable_instrumented(events): 75 | from properties_class import PropertiesClass 76 | 77 | ec = PropertiesClass() 78 | assert PropertiesClass.write_only.__doc__ == "Write-only" 79 | 80 | with pytest.raises(AttributeError, match=r".*(has no getter|unreadable attribute).*"): 81 | _ = ec.write_only 82 | 83 | ec.write_only = "updated example" 84 | 85 | assert len(events) == 2 86 | assert events[0].to_dict() == DictIncluding({ 87 | "event": "call", 88 | "defined_class": "properties_class.PropertiesClass", 89 | "method_id": "set_write_only (set)", 90 | }) 91 | 92 | 93 | def test_operator_attrgetter(events): 94 | from properties_class import PropertiesClass 95 | 96 | ec = PropertiesClass() 97 | 98 | assert ec.operator_read_only == "read only" 99 | 100 | with pytest.raises(AttributeError, match=r".*(has no setter|can't set attribute).*"): 101 | ec.operator_read_only = "not allowed" 102 | 103 | with pytest.raises(AttributeError, match=r".*(has no deleter|can't delete attribute).*"): 104 | del ec.operator_read_only 105 | 106 | assert len(events) == 2 107 | assert events[0].to_dict() == DictIncluding({ 108 | "event": "call", 109 | "defined_class": "properties_class.PropertiesClass", 110 | "method_id": "operator_read_only (get)", 111 | }) 112 | 113 | def test_operator_itemgetter(events): 114 | from properties_class import PropertiesClass 115 | 116 | ec = PropertiesClass() 117 | assert ec.taste == "yum" 118 | assert len(events) == 2 119 | assert events[0].to_dict() == DictIncluding({ 120 | "event": "call", 121 | # operator.itemgetter.__module__ isn't available before 3.10 122 | # "defined_class": "operator", 123 | "method_id": "itemgetter (get)", 124 | }) 125 | 126 | 127 | def test_free_function(events): 128 | from properties_class import PropertiesClass 129 | 130 | ec = PropertiesClass() 131 | assert ec.free_read_only_prop == "read only" 132 | assert len(events) == 2 133 | assert events[0].to_dict() == DictIncluding({ 134 | "event": "call", 135 | "defined_class": "properties_class", 136 | "method_id": "free_read_only (get)", 137 | }) 138 | 139 | 140 | @pytest.mark.xfail( 141 | raises=AssertionError, 142 | reason="needs fix for https://github.com/getappmap/appmap-python/issues/365", 143 | ) 144 | def test_functools_partial(events): 145 | from properties_class import PropertiesClass 146 | 147 | PropertiesClass.static_partial_method() 148 | assert len(events) > 0 149 | -------------------------------------------------------------------------------- /_appmap/test/normalize.py: -------------------------------------------------------------------------------- 1 | import json 2 | import platform 3 | import re 4 | from importlib.metadata import version as dist_version 5 | from operator import itemgetter 6 | from typing import List 7 | 8 | 9 | def normalize_path(path): 10 | """ 11 | Normalize absolute path to a file in a package down to a package path. 12 | Not foolproof, but good enough for the tests. 13 | """ 14 | return re.sub(r"^.*site-packages/", "", path) 15 | 16 | 17 | def normalize_git(git): 18 | git.pop("repository") 19 | git.pop("branch") 20 | git.pop("commit") 21 | status = git.pop("status", []) 22 | assert isinstance(status, list) 23 | tag = git.pop("tag", None) 24 | if tag: 25 | assert isinstance(tag, str) 26 | commits_since_tag = git.pop("commits_since_tag", None) 27 | if commits_since_tag: 28 | assert isinstance(commits_since_tag, int) 29 | git.pop("annotated_tag", None) 30 | 31 | commits_since_annotated_tag = git.pop("commits_since_annotated_tag", None) 32 | if commits_since_annotated_tag: 33 | assert isinstance(commits_since_annotated_tag, int) 34 | 35 | 36 | def normalize_metadata(metadata): 37 | engine = metadata["language"].pop("engine") 38 | assert engine == platform.python_implementation() 39 | version = metadata["language"].pop("version") 40 | assert version == platform.python_version() 41 | 42 | if "frameworks" in metadata: 43 | frameworks = metadata["frameworks"] 44 | for f in frameworks: 45 | if f["name"] == "pytest": 46 | v = f.pop("version") 47 | assert v == dist_version("pytest") 48 | 49 | 50 | def normalize_headers(dct): 51 | """Remove some headers which are variable between implementations. 52 | This allows sharing tests between web frameworks, for example. 53 | """ 54 | for key in list(dct.keys()): 55 | value = dct.pop(key, None) 56 | key = key.lower() 57 | if key in ["user-agent", "content-length", "content-type", "etag", "cookie", "host"]: 58 | assert value is None or isinstance(value, str) 59 | else: 60 | dct[key] = value 61 | 62 | 63 | def normalize_appmap(generated_appmap): 64 | """ 65 | Normalize the data in generated_appmap, removing any 66 | environment-specific values. 67 | """ 68 | 69 | def normalize(dct): 70 | # pylint: disable=too-many-branches 71 | if "classMap" in dct: 72 | dct["classMap"].sort(key=itemgetter("name")) 73 | if "children" in dct: 74 | dct["children"].sort(key=itemgetter("name")) 75 | if "comment" in dct: 76 | dct["comment"] = "function comment" 77 | if "elapsed" in dct: 78 | elapsed = dct.pop("elapsed") 79 | assert isinstance(elapsed, float) 80 | if "frameworks" in dct: 81 | assert all("name" in f for f in dct["frameworks"]) 82 | del dct["frameworks"] 83 | if "git" in dct: 84 | normalize_git(dct.pop("git")) 85 | if "headers" in dct: 86 | normalize_headers(dct["headers"]) 87 | if len(dct["headers"]) == 0: 88 | del dct["headers"] 89 | if "http_server_request" in dct: 90 | # the "headers" property is optional, and will be different 91 | # depending on the client sending the request. Rather than expecting 92 | # particular headers, the test code should verify that other 93 | # properties based on headers (e.g. "mime_type") are set correctly. 94 | dct["http_server_request"].pop("headers", None) 95 | normalize(dct["http_server_request"]) 96 | if "message" in dct: 97 | del dct["message"] 98 | if "location" in dct: 99 | dct["location"] = normalize_path(dct["location"]) 100 | if "path" in dct: 101 | dct["path"] = normalize_path(dct["path"]) 102 | if "metadata" in dct: 103 | normalize_metadata(dct["metadata"]) 104 | if "object_id" in dct: 105 | object_id = dct.pop("object_id") 106 | assert isinstance(object_id, int) 107 | if "value" in dct: 108 | # This maps all object references to the same 109 | # location. We don't actually need to verify that the 110 | # locations are correct -- if they weren't, the 111 | # instrumented code would be broken, right? 112 | v = dct["value"] 113 | dct["value"] = re.sub(r"<(.*)( object)* at 0x.*>", r"<\1 at 0xabcdef>", v) 114 | return dct 115 | 116 | return json.loads(generated_appmap, object_hook=normalize) 117 | 118 | 119 | def remove_line_numbers(appmap: dict): 120 | """Remove all line numbers from an appmap (in place). 121 | Makes the tests easier to modify. 122 | """ 123 | 124 | def do_classmap(classmap: List[dict]): 125 | location_re = re.compile(r":\d+$") 126 | for entry in classmap: 127 | if "location" in entry: 128 | entry["location"] = location_re.sub("", entry["location"]) 129 | do_classmap(entry.get("children", [])) 130 | 131 | do_classmap(appmap["classMap"]) 132 | for event in appmap["events"]: 133 | assert isinstance(event.pop("lineno", 42), int) 134 | 135 | return appmap 136 | -------------------------------------------------------------------------------- /_appmap/test/data/unittest/expected/pytest.appmap.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.9", 3 | "metadata": { 4 | "language": { 5 | "name": "python" 6 | }, 7 | "client": { 8 | "name": "appmap", 9 | "url": "https://github.com/applandinc/appmap-python" 10 | }, 11 | "feature_group": "Unit test test", 12 | "recording": { 13 | "defined_class": "simple.test_simple.UnitTestTest", 14 | "method_id": "test_hello_world" 15 | }, 16 | "source_location": "simple/test_simple.py:13", 17 | "name": "Unit test test hello world", 18 | "feature": "Hello world", 19 | "app": "Simple", 20 | "recorder": { 21 | "name": "pytest", 22 | "type": "tests" 23 | }, 24 | "test_status": "succeeded" 25 | }, 26 | "events": [ 27 | { 28 | "defined_class": "simple.test_simple.UnitTestTest", 29 | "method_id": "test_hello_world", 30 | "path": "simple/test_simple.py", 31 | "lineno": 14, 32 | "static": false, 33 | "receiver": { 34 | "name": "self", 35 | "kind": "req", 36 | "class": "simple.test_simple.UnitTestTest", 37 | "value": "" 38 | }, 39 | "parameters": [], 40 | "id": 1, 41 | "event": "call", 42 | "thread_id": 1 43 | }, 44 | { 45 | "defined_class": "simple.Simple", 46 | "method_id": "hello_world", 47 | "path": "simple/__init__.py", 48 | "lineno": 8, 49 | "static": false, 50 | "receiver": { 51 | "name": "self", 52 | "kind": "req", 53 | "class": "simple.Simple", 54 | "value": "" 55 | }, 56 | "parameters": [{ 57 | "class": "builtins.str", 58 | "kind": "req", 59 | "name": "bang", 60 | "value": "'!'" 61 | }], 62 | "id": 2, 63 | "event": "call", 64 | "thread_id": 1 65 | }, 66 | { 67 | "defined_class": "simple.Simple", 68 | "method_id": "hello", 69 | "path": "simple/__init__.py", 70 | "lineno": 2, 71 | "static": false, 72 | "receiver": { 73 | "name": "self", 74 | "kind": "req", 75 | "class": "simple.Simple", 76 | "value": "" 77 | }, 78 | "parameters": [], 79 | "id": 3, 80 | "event": "call", 81 | "thread_id": 1 82 | }, 83 | { 84 | "return_value": { 85 | "class": "builtins.str", 86 | "value": "'Hello'" 87 | }, 88 | "parent_id": 3, 89 | "id": 4, 90 | "event": "return", 91 | "thread_id": 1 92 | }, 93 | { 94 | "defined_class": "simple.Simple", 95 | "method_id": "world", 96 | "path": "simple/__init__.py", 97 | "lineno": 5, 98 | "static": false, 99 | "receiver": { 100 | "name": "self", 101 | "kind": "req", 102 | "class": "simple.Simple", 103 | "value": "" 104 | }, 105 | "parameters": [], 106 | "id": 5, 107 | "event": "call", 108 | "thread_id": 1 109 | }, 110 | { 111 | "return_value": { 112 | "class": "builtins.str", 113 | "value": "'world'" 114 | }, 115 | "parent_id": 5, 116 | "id": 6, 117 | "event": "return", 118 | "thread_id": 1 119 | }, 120 | { 121 | "return_value": { 122 | "class": "builtins.str", 123 | "value": "'Hello world!'" 124 | }, 125 | "parent_id": 2, 126 | "id": 7, 127 | "event": "return", 128 | "thread_id": 1 129 | }, 130 | { 131 | "return_value": { 132 | "class": "builtins.NoneType", 133 | "value": "None" 134 | }, 135 | "parent_id": 1, 136 | "id": 8, 137 | "event": "return", 138 | "thread_id": 1 139 | } 140 | ], 141 | "classMap": [ 142 | { 143 | "name": "simple", 144 | "type": "package", 145 | "children": [ 146 | { 147 | "name": "Simple", 148 | "type": "class", 149 | "children": [ 150 | { 151 | "name": "hello", 152 | "type": "function", 153 | "location": "simple/__init__.py:2", 154 | "static": false 155 | }, 156 | { 157 | "name": "hello_world", 158 | "type": "function", 159 | "location": "simple/__init__.py:8", 160 | "static": false 161 | }, 162 | { 163 | "name": "world", 164 | "type": "function", 165 | "location": "simple/__init__.py:5", 166 | "static": false 167 | } 168 | ] 169 | }, 170 | { 171 | "name": "test_simple", 172 | "type": "package", 173 | "children": [ 174 | { 175 | "name": "UnitTestTest", 176 | "type": "class", 177 | "children": [ 178 | { 179 | "name": "test_hello_world", 180 | "type": "function", 181 | "location": "simple/test_simple.py:14", 182 | "static": false 183 | } 184 | ] 185 | } 186 | ] 187 | } 188 | ] 189 | } 190 | ] 191 | } 192 | -------------------------------------------------------------------------------- /_appmap/test/data/unittest/expected/unittest.appmap.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.9", 3 | "metadata": { 4 | "language": { 5 | "name": "python" 6 | }, 7 | "client": { 8 | "name": "appmap", 9 | "url": "https://github.com/applandinc/appmap-python" 10 | }, 11 | "feature_group": "Unit test test", 12 | "recording": { 13 | "defined_class": "simple.test_simple.UnitTestTest", 14 | "method_id": "test_hello_world" 15 | }, 16 | "source_location": "simple/test_simple.py:14", 17 | "name": "Unit test test hello world", 18 | "feature": "Hello world", 19 | "app": "Simple", 20 | "recorder": { 21 | "name": "unittest", 22 | "type": "tests" 23 | }, 24 | "test_status": "succeeded" 25 | }, 26 | "events": [ 27 | { 28 | "defined_class": "simple.test_simple.UnitTestTest", 29 | "method_id": "test_hello_world", 30 | "path": "simple/test_simple.py", 31 | "lineno": 14, 32 | "static": false, 33 | "receiver": { 34 | "name": "self", 35 | "kind": "req", 36 | "class": "simple.test_simple.UnitTestTest", 37 | "value": "" 38 | }, 39 | "parameters": [], 40 | "id": 1, 41 | "event": "call", 42 | "thread_id": 1 43 | }, 44 | { 45 | "defined_class": "simple.Simple", 46 | "method_id": "hello_world", 47 | "path": "simple/__init__.py", 48 | "lineno": 8, 49 | "static": false, 50 | "receiver": { 51 | "name": "self", 52 | "kind": "req", 53 | "class": "simple.Simple", 54 | "value": "" 55 | }, 56 | "parameters": [{ 57 | "class": "builtins.str", 58 | "kind": "req", 59 | "name": "bang", 60 | "value": "'!'" 61 | }], 62 | "id": 2, 63 | "event": "call", 64 | "thread_id": 1 65 | }, 66 | { 67 | "defined_class": "simple.Simple", 68 | "method_id": "hello", 69 | "path": "simple/__init__.py", 70 | "lineno": 2, 71 | "static": false, 72 | "receiver": { 73 | "name": "self", 74 | "kind": "req", 75 | "class": "simple.Simple", 76 | "value": "" 77 | }, 78 | "parameters": [], 79 | "id": 3, 80 | "event": "call", 81 | "thread_id": 1 82 | }, 83 | { 84 | "return_value": { 85 | "class": "builtins.str", 86 | "value": "'Hello'" 87 | }, 88 | "parent_id": 3, 89 | "id": 4, 90 | "event": "return", 91 | "thread_id": 1 92 | }, 93 | { 94 | "defined_class": "simple.Simple", 95 | "method_id": "world", 96 | "path": "simple/__init__.py", 97 | "lineno": 5, 98 | "static": false, 99 | "receiver": { 100 | "name": "self", 101 | "kind": "req", 102 | "class": "simple.Simple", 103 | "value": "" 104 | }, 105 | "parameters": [], 106 | "id": 5, 107 | "event": "call", 108 | "thread_id": 1 109 | }, 110 | { 111 | "return_value": { 112 | "class": "builtins.str", 113 | "value": "'world'" 114 | }, 115 | "parent_id": 5, 116 | "id": 6, 117 | "event": "return", 118 | "thread_id": 1 119 | }, 120 | { 121 | "return_value": { 122 | "class": "builtins.str", 123 | "value": "'Hello world!'" 124 | }, 125 | "parent_id": 2, 126 | "id": 7, 127 | "event": "return", 128 | "thread_id": 1 129 | }, 130 | { 131 | "return_value": { 132 | "class": "builtins.NoneType", 133 | "value": "None" 134 | }, 135 | "parent_id": 1, 136 | "id": 8, 137 | "event": "return", 138 | "thread_id": 1 139 | } 140 | ], 141 | "classMap": [ 142 | { 143 | "name": "simple", 144 | "type": "package", 145 | "children": [ 146 | { 147 | "name": "Simple", 148 | "type": "class", 149 | "children": [ 150 | { 151 | "name": "hello", 152 | "type": "function", 153 | "location": "simple/__init__.py:2", 154 | "static": false 155 | }, 156 | { 157 | "name": "hello_world", 158 | "type": "function", 159 | "location": "simple/__init__.py:8", 160 | "static": false 161 | }, 162 | { 163 | "name": "world", 164 | "type": "function", 165 | "location": "simple/__init__.py:5", 166 | "static": false 167 | } 168 | ] 169 | }, 170 | { 171 | "name": "test_simple", 172 | "type": "package", 173 | "children": [ 174 | { 175 | "name": "UnitTestTest", 176 | "type": "class", 177 | "children": [ 178 | { 179 | "name": "test_hello_world", 180 | "type": "function", 181 | "location": "simple/test_simple.py:14", 182 | "static": false 183 | } 184 | ] 185 | } 186 | ] 187 | } 188 | ] 189 | } 190 | ] 191 | } 192 | -------------------------------------------------------------------------------- /_appmap/instrument.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import time 3 | from collections import namedtuple 4 | from contextlib import contextmanager 5 | 6 | from . import event 7 | from .env import Env 8 | from .event import CallEvent 9 | from .recorder import Recorder, AppMapLimitExceeded 10 | from .utils import appmap_tls 11 | 12 | logger = Env.current.getLogger(__name__) 13 | 14 | 15 | @contextmanager 16 | def recording_disabled(): 17 | tls = appmap_tls() 18 | original_value = tls.get("instrumentation_disabled") 19 | tls["instrumentation_disabled"] = True 20 | try: 21 | yield 22 | finally: 23 | tls["instrumentation_disabled"] = original_value 24 | 25 | 26 | def is_instrumentation_disabled(): 27 | return appmap_tls().setdefault("instrumentation_disabled", False) 28 | 29 | 30 | def track_shallow(fn): 31 | """ 32 | Check if the function should be skipped because of a shallow rule. 33 | If not, updates the last rule tracking and return False. 34 | 35 | This works by remembering last matched rule. This is rather 36 | simple and the results are not always correct. For example, 37 | consider execution flow where code matching another shallow rule 38 | repeatedly gets called from the code that's already shallow. It's 39 | difficult, if at all possible, to generally ensure correctness 40 | without tracking all execution or analyzing the call stack on each 41 | call, which is probably too inefficient. 42 | 43 | However, in the most useful case where we're interested in the 44 | interaction between client code and specific third-party libraries 45 | while ignoring their internals, it's an effective way of limiting 46 | appmap size. If you want anything more complicated and can take 47 | the performance hit, your best bet is to record without shallow 48 | and postprocess the appmap to your liking. 49 | """ 50 | tls = appmap_tls() 51 | rule = getattr(fn, "_appmap_shallow", None) 52 | logger.trace("track_shallow(%r) [%r]", fn, rule) 53 | result = rule and tls.get("last_rule", None) == rule 54 | tls["last_rule"] = rule 55 | return result 56 | 57 | 58 | @contextmanager 59 | def saved_shallow_rule(): 60 | """ 61 | A context manager to save and reset the current shallow tracking 62 | rule around the call to an instrumented function. 63 | """ 64 | tls = appmap_tls() 65 | current_rule = tls.get("last_rule", None) 66 | try: 67 | yield 68 | finally: 69 | tls["last_rule"] = current_rule 70 | 71 | 72 | _InstrumentedFn = namedtuple( 73 | "_InstrumentedFn", "fn fntype instrumented_fn make_call_event params" 74 | ) 75 | 76 | 77 | def call_instrumented(f, instance, args, kwargs): 78 | if ( 79 | (not Recorder.get_enabled()) 80 | or is_instrumentation_disabled() 81 | or track_shallow(f.instrumented_fn) 82 | ): 83 | return f.fn(*args, **kwargs) 84 | 85 | with recording_disabled(): 86 | logger.trace("%s args %s kwargs %s", f.fn, args, kwargs) 87 | params = CallEvent.set_params(f.params, instance, args, kwargs) 88 | call_event = f.make_call_event(parameters=params) 89 | Recorder.add_event(call_event) 90 | call_event_id = call_event.id 91 | start_time = time.time() 92 | try: 93 | Recorder.check_time(start_time) 94 | ret = f.fn(*args, **kwargs) 95 | elapsed_time = time.time() - start_time 96 | 97 | return_event = event.FuncReturnEvent( 98 | return_value=ret, parent_id=call_event_id, elapsed=elapsed_time 99 | ) 100 | Recorder.add_event(return_event) 101 | return ret 102 | except AppMapLimitExceeded: 103 | raise 104 | # Some applications make use of exceptions that aren't descended from Exception. For example, 105 | # pytest's OutcomeException, used to indicate the outcome of a test case, is a child of 106 | # BaseException. 107 | # 108 | # We need to catch *any* exception raised, to ensure that we add the appropriate ExceptionEvent. 109 | except BaseException: # noqa: E722 110 | elapsed_time = time.time() - start_time 111 | Recorder.add_event( 112 | event.ExceptionEvent( 113 | parent_id=call_event_id, elapsed=elapsed_time, exc_info=sys.exc_info() 114 | ) 115 | ) 116 | raise 117 | 118 | 119 | def instrument(filterable): 120 | """return an instrumented function""" 121 | logger.debug("hooking %s", filterable.fqname) 122 | 123 | make_call_event = event.CallEvent.make(filterable) 124 | params = CallEvent.make_params(filterable) 125 | 126 | # django depends on being able to find the cache_clear attribute 127 | # on functions. (You can see this by trying to map 128 | # https://github.com/chicagopython/chypi.org.) Make sure it gets 129 | # copied from the original to the wrapped function. 130 | # 131 | # Going forward, we should consider how to make this more general. 132 | def instrumented_fn(wrapped, instance, args, kwargs): 133 | with saved_shallow_rule(): 134 | f = _InstrumentedFn( 135 | wrapped, filterable.fntype, instrumented_fn, make_call_event, params 136 | ) 137 | return call_instrumented(f, instance, args, kwargs) 138 | 139 | ret = instrumented_fn 140 | setattr(ret, "_appmap_instrumented", True) 141 | return ret 142 | -------------------------------------------------------------------------------- /_appmap/test/test_events.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test event functionality 3 | """ 4 | import re 5 | from functools import partial 6 | from queue import Queue 7 | from threading import Thread 8 | 9 | import pytest 10 | 11 | import appmap 12 | from _appmap.event import _EventIds 13 | 14 | 15 | # pylint: disable=import-error 16 | def test_per_thread_id(): 17 | """thread ids should be constant for a thread""" 18 | assert _EventIds.get_thread_id() == _EventIds.get_thread_id() 19 | 20 | 21 | def test_thread_ids(): 22 | """thread ids should be different per thread""" 23 | 24 | tids = Queue() 25 | 26 | def add_thread_id(q): 27 | tid = _EventIds.get_thread_id() 28 | q.put(tid) 29 | 30 | t = partial(add_thread_id, tids) 31 | threads = [Thread(target=t) for _ in range(5)] 32 | list(map(Thread.start, threads)) 33 | list(map(Thread.join, threads)) 34 | 35 | assert not tids.empty() 36 | 37 | all_tids = [tids.get() for _ in range(tids.qsize())] 38 | assert len(set(all_tids)) == len(all_tids) # Should all be unique 39 | 40 | 41 | @pytest.mark.appmap_enabled 42 | @pytest.mark.usefixtures("with_data_dir") 43 | class TestEvents: 44 | def test_recursion_protection(self): 45 | r = appmap.Recording() 46 | with r: 47 | from example_class import ExampleClass # pylint: disable=import-outside-toplevel 48 | 49 | ExampleClass().instance_method() 50 | 51 | # If we get here, recursion protection for rendering the receiver 52 | # is working 53 | assert True 54 | 55 | def test_when_str_raises(self, mocker): 56 | r = appmap.Recording() 57 | with r: 58 | from example_class import ExampleClass # pylint: disable=import-outside-toplevel 59 | 60 | param = mocker.Mock() 61 | param.__str__ = mocker.Mock(side_effect=Exception) 62 | param.__repr__ = mocker.Mock(return_value="param.__repr__") 63 | 64 | ExampleClass().instance_with_param(param) 65 | 66 | assert len(r.events) > 0 67 | expected_value = "param.__repr__" 68 | actual_value = r.events[0].parameters[0]["value"] 69 | assert expected_value == actual_value 70 | 71 | def test_when_both_raise(self, mocker): 72 | r = appmap.Recording() 73 | with r: 74 | from example_class import ExampleClass # pylint: disable=import-outside-toplevel 75 | 76 | param = mocker.Mock() 77 | param.__str__ = mocker.Mock(side_effect=Exception) 78 | param.__repr__ = mocker.Mock(side_effect=Exception) 79 | 80 | ExampleClass().instance_with_param(param) 81 | 82 | expected_re = r"<.*? object at .*?>" 83 | actual_value = r.events[0].parameters[0]["value"] 84 | assert re.fullmatch(expected_re, actual_value) 85 | 86 | @pytest.mark.appmap_enabled(env={"APPMAP_DISPLAY_PARAMS": "false"}) 87 | def test_when_display_disabled(self, mocker): 88 | r = appmap.Recording() 89 | with r: 90 | from example_class import ExampleClass # pylint: disable=import-outside-toplevel 91 | 92 | param = mocker.MagicMock() 93 | 94 | # unittest.mock.MagicMock doesn't mock __repr__ by default 95 | param.__repr__ = mocker.Mock() 96 | 97 | ExampleClass().instance_with_param(param) 98 | 99 | param.__str__.assert_not_called() 100 | 101 | # The reason MagicMock doesn't mock __repr__ is because it 102 | # uses it. If APPMAP_DISPLAY_PARAMS is functioning 103 | # correctly, __repr__ will only be called once, by 104 | # MagicMock. (If it's broken, we may not get here at all, 105 | # because the assertion above may fail.) 106 | param.__repr__.assert_called_once_with() 107 | 108 | def test_describe_return_value_recursion_protection(self): 109 | r = appmap.Recording() 110 | with r: 111 | # pylint: disable=import-outside-toplevel 112 | from example_class import ExampleClass 113 | 114 | ExampleClass().return_self() 115 | # There should be no event for method another_method which is called by __repr__. 116 | assert [e.method_id for e in r.events if e.event == "call" and hasattr(e, "method_id")] == [ 117 | "return_self" 118 | ] 119 | 120 | # There should be an exception return event generated even when the raised exception is a 121 | # BaseException. 122 | def test_exception_event_with_base_exception(self): 123 | r = appmap.Recording() 124 | with r: 125 | # pylint: disable=import-outside-toplevel 126 | from example_class import ExampleClass 127 | 128 | try: 129 | ExampleClass().raise_base_exception() 130 | except BaseException: # pylint: disable=broad-exception-caught 131 | pass 132 | assert check_call_return_stack_order(r.events), "Unbalanced call stack" 133 | 134 | 135 | def check_call_return_stack_order(events): 136 | stack = [] 137 | for e in events: 138 | if e.event == "call": 139 | stack.append(e) 140 | elif e.event == "return": 141 | if len(stack) > 0: 142 | call = stack.pop() 143 | if call.id != e.parent_id: 144 | return False 145 | else: 146 | return False 147 | if len(stack) == 0: 148 | return True 149 | 150 | return False 151 | -------------------------------------------------------------------------------- /_appmap/test/test_command.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | from shutil import copytree 4 | from importlib.metadata import version 5 | 6 | import pytest 7 | 8 | import _appmap 9 | from appmap.command import appmap_agent_init, appmap_agent_status, appmap_agent_validate 10 | 11 | from .helpers import DictIncluding 12 | 13 | 14 | @pytest.fixture(name="_cmd_setup") 15 | def _cmd_setup(request, git, data_dir, monkeypatch): 16 | repo_root = git.cwd 17 | copytree(data_dir / request.param, str(repo_root), dirs_exist_ok=True) 18 | monkeypatch.chdir(repo_root) 19 | 20 | # pylint: disable=protected-access 21 | _appmap.initialize(cwd=repo_root) 22 | 23 | return monkeypatch 24 | 25 | 26 | @pytest.mark.parametrize("_cmd_setup", ["config"], indirect=True) 27 | def test_agent_init(_cmd_setup, capsys): 28 | rc = appmap_agent_init._run() # pylint: disable=protected-access 29 | 30 | assert rc == 0 31 | output = capsys.readouterr() 32 | config = json.loads(output.out) 33 | 34 | # Make sure the JSON has the correct form, and contains the 35 | # expected filename. The contents of the default appmap.yml are 36 | # verified by other tests 37 | assert config["configuration"]["filename"] == "appmap.yml" 38 | assert config["configuration"]["contents"] is not None 39 | 40 | 41 | class TestAgentStatus: 42 | @pytest.mark.parametrize("_cmd_setup", ["pytest"], indirect=True) 43 | @pytest.mark.parametrize("do_discovery", [True, False]) 44 | def test_test_discovery_control(self, _cmd_setup, do_discovery, mocker): 45 | mocker.patch("appmap.command.appmap_agent_status.discover_pytest_tests") 46 | rc = appmap_agent_status._run( # pylint: disable=protected-access 47 | discover_tests=do_discovery 48 | ) 49 | assert rc == 0 50 | call_count = 1 if do_discovery else 0 51 | 52 | # Well, pylint, if it didn't have call_count, assertion would fail, 53 | # wouldn't it? 54 | # pylint: disable=no-member 55 | assert appmap_agent_status.discover_pytest_tests.call_count == call_count 56 | 57 | @pytest.mark.parametrize("_cmd_setup", ["pytest"], indirect=True) 58 | def test_agent_status(self, _cmd_setup, capsys): 59 | rc = appmap_agent_status._run(discover_tests=True) # pylint: disable=protected-access 60 | 61 | assert rc == 0 62 | output = capsys.readouterr() 63 | status = json.loads(output.out) 64 | # XXX This will detect pytest as the test framework, because 65 | # appmap-python uses it. We need a better mechanism to handle 66 | # testing more broadly. 67 | props = status["properties"] 68 | config = props["config"] 69 | assert config == DictIncluding({"app": "Simple", "present": True, "valid": True}) 70 | project = props["project"] 71 | assert project == DictIncluding( 72 | { 73 | "language": "python", 74 | "remoteRecordingCapable": True, 75 | "integrationTests": True, 76 | } 77 | ) 78 | assert "agentVersion" in project 79 | test_commands = status["test_commands"] 80 | assert test_commands[0] == DictIncluding( 81 | {"args": [], "framework": "pytest", "command": "pytest"} 82 | ) 83 | 84 | @pytest.mark.parametrize("_cmd_setup", ["package1"], indirect=True) 85 | def test_agent_status_no_commands(self, _cmd_setup, capsys): 86 | rc = appmap_agent_status._run(discover_tests=True) # pylint: disable=protected-access 87 | 88 | assert rc == 0 89 | output = capsys.readouterr() 90 | status = json.loads(output.out) 91 | 92 | assert "test_commands" not in status 93 | 94 | 95 | class TestAgentValidate: 96 | def check_errors(self, capsys, status, count, msg): 97 | rc = appmap_agent_validate._run() # pylint: disable=protected-access 98 | 99 | assert rc == status 100 | 101 | output = capsys.readouterr() 102 | errors = json.loads(output.out) 103 | 104 | assert len(errors) == count 105 | if count > 0: 106 | err = errors[0] 107 | assert err["level"] == "error" 108 | assert re.match(msg, err["message"]) is not None 109 | 110 | def test_no_errors(self, capsys): 111 | # Both Django and flask are installed in a dev environment, so 112 | # validation will succeed. 113 | self.check_errors(capsys, 0, 0, None) 114 | 115 | def test_python_version(self, capsys, mocker): 116 | mocker.patch( 117 | "_appmap.py_version_check._get_py_version", 118 | return_value=(3, 5), 119 | ) 120 | mocker.patch( 121 | "_appmap.py_version_check._get_platform_version", 122 | return_value="3.5", 123 | ) 124 | 125 | self.check_errors(capsys, 1, 1, r"Minimum Python version supported is \d\.\d, found") 126 | 127 | def test_django_version(self, capsys, mocker): 128 | mocker.patch( 129 | "appmap.command.appmap_agent_validate.version", 130 | side_effect=lambda d: "3.1" if d == "django" else version(d), 131 | ) 132 | 133 | self.check_errors(capsys, 1, 1, "django must have version >= 3.2, found 3.1") 134 | 135 | def test_flask_version(self, capsys, mocker): 136 | mocker.patch( 137 | "appmap.command.appmap_agent_validate.version", 138 | side_effect=lambda d: "1.0" if d == "flask" else version(d), 139 | ) 140 | 141 | self.check_errors(capsys, 1, 1, "flask must have version >= 2.0, found 1.0") 142 | --------------------------------------------------------------------------------