├── .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 | [](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 |
--------------------------------------------------------------------------------