Skip to content

Commit c501db5

Browse files
committed
feat: support Flask 2
Fix up Flask support so it's compatible with v2. Also fixes initial configuration, so a Flask app will be recorded by default.
1 parent d60799f commit c501db5

File tree

7 files changed

+101
-68
lines changed

7 files changed

+101
-68
lines changed

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: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,8 +134,11 @@ class AppmapMiddleware(ABC):
134134
def __init__(self):
135135
self.record_url = "/_appmap/record"
136136

137-
def should_record(self):
138-
return DetectEnabled.should_enable("remote") or DetectEnabled.should_enable("requests")
137+
@staticmethod
138+
def should_record():
139+
return DetectEnabled.should_enable("remote") or DetectEnabled.should_enable(
140+
"requests"
141+
)
139142

140143
def before_request_hook(self, request, request_path, recording_is_running):
141144
if request_path == self.record_url:

appmap/flask.py

Lines changed: 32 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,19 @@
1-
import datetime
2-
import json
3-
import os.path
41
import re
52
import time
6-
from functools import wraps
73

84
import flask
95
import flask.cli
106
import jinja2
117
from flask import _app_ctx_stack, request
8+
from flask.cli import ScriptInfo
129
from werkzeug.exceptions import BadRequest
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
@@ -50,61 +49,35 @@ def request_params(req):
5049

5150

5251
class AppmapFlask(AppmapMiddleware):
53-
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):
5467
super().__init__()
55-
self.app = app
56-
if app is not None:
57-
self.init_app(app)
5868

5969
def init_app(self, app):
6070
if self.should_record():
61-
# it may record requests but not remote (APPMAP=false)
6271
self.recorder = Recorder.get_current()
6372

6473
if DetectEnabled.should_enable("remote"):
65-
app.add_url_rule(
66-
self.record_url,
67-
"appmap_record_get",
68-
view_func=self.record_get,
69-
methods=["GET"],
70-
)
71-
app.add_url_rule(
72-
self.record_url,
73-
"appmap_record_post",
74-
view_func=self.record_post,
75-
methods=["POST"],
76-
)
77-
app.add_url_rule(
78-
self.record_url,
79-
"appmap_record_delete",
80-
view_func=self.record_delete,
81-
methods=["DELETE"],
74+
app.wsgi_app = DispatcherMiddleware(
75+
app.wsgi_app, {"/_appmap": remote_recording}
8276
)
8377

8478
app.before_request(self.before_request)
8579
app.after_request(self.after_request)
8680

87-
def record_get(self):
88-
if not self.should_record():
89-
return "Appmap is disabled.", 404
90-
91-
return {"enabled": self.recorder.get_enabled()}
92-
93-
def record_post(self):
94-
if self.recorder.get_enabled():
95-
return "Recording is already in progress", 409
96-
97-
self.recorder.start_recording()
98-
return "", 200
99-
100-
def record_delete(self):
101-
if not self.recorder.get_enabled():
102-
return "No recording is in progress", 404
103-
104-
self.recorder.stop_recording()
105-
106-
return json.loads(generation.dump(self.recorder))
107-
10881
def before_request(self):
10982
if not self.should_record():
11083
return
@@ -182,18 +155,18 @@ class TemplateHandler(BaseTemplateHandler):
182155
pass
183156

184157

185-
def wrap_cli_fn(fn):
186-
@wraps(fn)
187-
def install_middleware(*args, **kwargs):
188-
app = fn(*args, **kwargs)
189-
if app:
190-
appmap_flask = AppmapFlask()
191-
appmap_flask.init_app(app)
192-
return app
158+
def install_extension(wrapped, _, args, kwargs):
159+
app = wrapped(*args, **kwargs)
160+
if app:
161+
AppmapFlask().init_app(app)
193162

194-
return install_middleware
163+
return app
195164

196165

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

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_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 """

requirements-dev.txt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
#requirements-dev.txt
2+
pip
3+
poetry
4+
tox
5+
django
6+
flask
7+
pytest-django

0 commit comments

Comments
 (0)