Skip to content

Commit 329fee4

Browse files
authored
Merge pull request #186 from getappmap/flask2_20221017
Add Flask 2 support
2 parents f104587 + f15e591 commit 329fee4

File tree

14 files changed

+143
-92
lines changed

14 files changed

+143
-92
lines changed

appmap/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@
55
from ._implementation.labels import labels # noqa: F401
66
from ._implementation.recording import Recording # noqa: F401
77

8+
try:
9+
from . import django # noqa: F401
10+
except ImportError:
11+
# not using django
12+
pass
13+
814
try:
915
from . import flask # noqa: F401
1016
except ImportError:

appmap/_implementation/flask.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
""" remote_recording is a Flask app that can be mounted to expose the remote-recording endpoint. """
2+
import json
3+
4+
from flask import Flask
5+
6+
from . import generation
7+
from .recorder import Recorder
8+
from .web_framework import AppmapMiddleware
9+
10+
remote_recording = Flask(__name__)
11+
12+
13+
@remote_recording.route("/record", methods=["GET"])
14+
def status():
15+
if not AppmapMiddleware.should_record():
16+
return "Appmap is disabled.", 404
17+
18+
return {"enabled": Recorder.get_current().get_enabled()}
19+
20+
21+
@remote_recording.route("/record", methods=["POST"])
22+
def start():
23+
r = Recorder.get_current()
24+
if r.get_enabled():
25+
return "Recording is already in progress", 409
26+
27+
r.start_recording()
28+
return "", 200
29+
30+
31+
@remote_recording.route("/record", methods=["DELETE"])
32+
def stop():
33+
r = Recorder.get_current()
34+
if not r.get_enabled():
35+
return "No recording is in progress", 404
36+
37+
r.stop_recording()
38+
39+
return json.loads(generation.dump(r))

appmap/_implementation/web_framework.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,8 @@ class AppmapMiddleware(ABC):
134134
def __init__(self):
135135
self.record_url = "/_appmap/record"
136136

137-
def should_record(self):
137+
@staticmethod
138+
def should_record():
138139
return DetectEnabled.should_enable("remote") or DetectEnabled.should_enable(
139140
"requests"
140141
)

appmap/flask.py

Lines changed: 47 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,19 @@
1-
import datetime
2-
import json
3-
import os.path
1+
import re
42
import time
5-
from functools import wraps
63

74
import flask
85
import flask.cli
96
import jinja2
107
from flask import _app_ctx_stack, request
8+
from flask.cli import ScriptInfo
119
from werkzeug.exceptions import BadRequest
12-
from werkzeug.routing import parse_rule
10+
from werkzeug.middleware.dispatcher import DispatcherMiddleware
1311

14-
from appmap._implementation import generation, web_framework
12+
import appmap.wrapt as wrapt
1513
from appmap._implementation.detect_enabled import DetectEnabled
1614
from appmap._implementation.env import Env
1715
from appmap._implementation.event import HttpServerRequestEvent, HttpServerResponseEvent
16+
from appmap._implementation.flask import remote_recording
1817
from appmap._implementation.recorder import Recorder
1918
from appmap._implementation.web_framework import AppmapMiddleware
2019
from appmap._implementation.web_framework import TemplateHandler as BaseTemplateHandler
@@ -45,62 +44,40 @@ def request_params(req):
4544
return values_dict(params.lists())
4645

4746

47+
NP_PARAMS = re.compile(r"<Rule '(.*?)'")
48+
NP_PARAM_DELIMS = str.maketrans("<>", "{}")
49+
50+
4851
class AppmapFlask(AppmapMiddleware):
49-
def __init__(self, app=None):
52+
"""
53+
A Flask extension to add remote recording to an application.
54+
Should be loaded by default, but can also be added manually.
55+
56+
For example:
57+
58+
```
59+
from appmap.flask import AppmapFlask
60+
61+
app = new Flask(__Name__)
62+
AppmapFlask().init_app(app)
63+
```
64+
"""
65+
66+
def __init__(self):
5067
super().__init__()
51-
self.app = app
52-
if app is not None:
53-
self.init_app(app)
5468

5569
def init_app(self, app):
5670
if self.should_record():
57-
# it may record requests but not remote (APPMAP=false)
5871
self.recorder = Recorder.get_current()
5972

6073
if DetectEnabled.should_enable("remote"):
61-
app.add_url_rule(
62-
self.record_url,
63-
"appmap_record_get",
64-
view_func=self.record_get,
65-
methods=["GET"],
66-
)
67-
app.add_url_rule(
68-
self.record_url,
69-
"appmap_record_post",
70-
view_func=self.record_post,
71-
methods=["POST"],
72-
)
73-
app.add_url_rule(
74-
self.record_url,
75-
"appmap_record_delete",
76-
view_func=self.record_delete,
77-
methods=["DELETE"],
74+
app.wsgi_app = DispatcherMiddleware(
75+
app.wsgi_app, {"/_appmap": remote_recording}
7876
)
7977

8078
app.before_request(self.before_request)
8179
app.after_request(self.after_request)
8280

83-
def record_get(self):
84-
if not self.should_record():
85-
return "Appmap is disabled.", 404
86-
87-
return {"enabled": self.recorder.get_enabled()}
88-
89-
def record_post(self):
90-
if self.recorder.get_enabled():
91-
return "Recording is already in progress", 409
92-
93-
self.recorder.start_recording()
94-
return "", 200
95-
96-
def record_delete(self):
97-
if not self.recorder.get_enabled():
98-
return "No recording is in progress", 404
99-
100-
self.recorder.stop_recording()
101-
102-
return json.loads(generation.dump(self.recorder))
103-
10481
def before_request(self):
10582
if not self.should_record():
10683
return
@@ -112,16 +89,17 @@ def before_request(self):
11289
def before_request_main(self, rec, request):
11390
Metadata.add_framework("flask", flask.__version__)
11491
np = None
115-
# See
116-
# https://github.com/pallets/werkzeug/blob/2.0.0/src/werkzeug/routing.py#L213
117-
# for a description of parse_rule.
11892
if request.url_rule:
119-
np = "".join(
120-
[
121-
f"{{{p}}}" if c else p
122-
for c, _, p in parse_rule(request.url_rule.rule)
123-
]
124-
)
93+
# Transform request.url to the expected normalized-path form. For example,
94+
# "/post/<username>/<post_id>/summary" becomes "/post/{username}/{post_id}/summary".
95+
# Notes:
96+
# * the value of `repr` of this rule begins with "<Rule '/post/<username>/<post_id>/summary'"
97+
# * the variable names in a rule can only contain alphanumerics:
98+
# * flask 1: https://github.com/pallets/werkzeug/blob/1dde4b1790f9c46b7122bb8225e6b48a5b22a615/src/werkzeug/routing.py#L143
99+
# * flask 2: https://github.com/pallets/werkzeug/blob/99f328cf2721e913bd8a3128a9cdd95ca97c334c/src/werkzeug/routing/rules.py#L56
100+
r = repr(request.url_rule)
101+
np = NP_PARAMS.findall(r)[0].translate(NP_PARAM_DELIMS)
102+
125103
call_event = HttpServerRequestEvent(
126104
request_method=request.method,
127105
path_info=request.path,
@@ -174,18 +152,18 @@ class TemplateHandler(BaseTemplateHandler):
174152
pass
175153

176154

177-
def wrap_cli_fn(fn):
178-
@wraps(fn)
179-
def install_middleware(*args, **kwargs):
180-
app = fn(*args, **kwargs)
181-
if app:
182-
appmap_flask = AppmapFlask()
183-
appmap_flask.init_app(app)
184-
return app
155+
def install_extension(wrapped, _, args, kwargs):
156+
app = wrapped(*args, **kwargs)
157+
if app:
158+
AppmapFlask().init_app(app)
185159

186-
return install_middleware
160+
return app
187161

188162

189163
if Env.current.enabled:
190-
flask.cli.call_factory = wrap_cli_fn(flask.cli.call_factory)
191-
flask.cli.locate_app = wrap_cli_fn(flask.cli.locate_app)
164+
# ScriptInfo.load_app is the function that's used by the Flask cli to load an app, no matter how
165+
# the app's module is specified (e.g. with the FLASK_APP env var, the `--app` flag, etc). Hook
166+
# it so it installs our extension on the app.
167+
ScriptInfo.load_app = wrapt.wrap_function_wrapper(
168+
"flask.cli", "ScriptInfo.load_app", install_extension
169+
)

appmap/test/data/django/app/settings.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,3 @@
1010
# Must set ROOT_URLCONF else we get
1111
# AttributeError: 'Settings' object has no attribute 'ROOT_URLCONF'
1212
ROOT_URLCONF = "app.urls"
13-
14-
MIDDLEWARE = ["appmap.django.Middleware"]
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
import appmap

appmap/test/data/flask/app.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
1-
"""Rudimentary Flask application for testing."""
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+
"""
27
# pylint: disable=missing-function-docstring
38

49
from flask import Flask, make_response
510
from markupsafe import escape
611

7-
from appmap.flask import AppmapFlask
8-
912
app = Flask(__name__)
1013

11-
appmap_flask = AppmapFlask(app)
12-
1314

1415
@app.route("/")
1516
def hello_world():
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
import appmap

appmap/test/test_django.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -208,12 +208,15 @@ def test_middleware_reset(pytester, monkeypatch):
208208
class TestRecordRequestsDjango(TestRecordRequests):
209209
@staticmethod
210210
def server_start_thread(env_vars_str):
211+
# Use appmap from our working copy, not the module installed by virtualenv. Add the init
212+
# directory so the sitecustomize.py file it contains will be loaded on startup. This
213+
# simulates a real installation.
211214
exec_cmd(
212215
"""
213-
# use appmap from our working copy, not the module installed by virtualenv
214-
export PYTHONPATH=`pwd`
216+
export PYTHONPATH="$PWD"
215217
216218
cd appmap/test/data/django/
219+
PYTHONPATH="$PYTHONPATH:$PWD/init"
217220
"""
218221
+ env_vars_str
219222
+ """ APPMAP_OUTPUT_DIR=/tmp python manage.py runserver 127.0.0.1:"""

appmap/test/test_flask.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import pytest
99

1010
from appmap._implementation.env import Env
11+
from appmap.flask import AppmapFlask
1112
from appmap.test.helpers import DictIncluding
1213

1314
from .._implementation.metadata import Metadata
@@ -40,6 +41,11 @@ def flask_app(data_dir, monkeypatch):
4041
import app # pylint: disable=import-error
4142

4243
importlib.reload(app)
44+
45+
# Add the AppmapFlask extension to the app. This now happens automatically when a Flask app is
46+
# started from the command line, but must be done manually otherwise.
47+
AppmapFlask().init_app(app.app)
48+
4349
return app.app
4450

4551

@@ -70,12 +76,15 @@ def test_template(app, events):
7076
class TestRecordRequestsFlask(TestRecordRequests):
7177
@staticmethod
7278
def server_start_thread(env_vars_str):
79+
# Use appmap from our working copy, not the module installed by virtualenv. Add the init
80+
# directory so the sitecustomize.py file it contains will be loaded on startup. This
81+
# simulates a real installation.
7382
exec_cmd(
7483
"""
75-
# use appmap from our working copy, not the module installed by virtualenv
76-
export PYTHONPATH=`pwd`
84+
export PYTHONPATH="$PWD"
7785
7886
cd appmap/test/data/flask/
87+
PYTHONPATH="$PYTHONPATH:$PWD/init"
7988
"""
8089
+ env_vars_str
8190
+ """ APPMAP_OUTPUT_DIR=/tmp FLASK_DEBUG=1 FLASK_APP=app.py flask run -p """

0 commit comments

Comments
 (0)