deployangel 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,176 @@
1
+ """DeployAngel agent for Python web apps and job workers.
2
+
3
+ Framework integrations call start(); the rest of this module is the API they
4
+ record through, plus checkpoint() and notify() for application code. Every
5
+ function here is safe to call anywhere: it never raises and never touches the
6
+ network."""
7
+
8
+ from __future__ import annotations
9
+
10
+ import atexit
11
+ import os
12
+ import re
13
+ import sys
14
+ import threading
15
+ from typing import Optional
16
+
17
+ from deployangel.agent import Agent
18
+ from deployangel.config import Configuration
19
+ from deployangel.version import VERSION
20
+
21
+ __all__ = ["VERSION", "checkpoint", "configuration", "configure", "notify", "recording", "shutdown", "start"]
22
+
23
+ # Web servers and job workers report from boot, so idle processes still send
24
+ # heartbeats; other processes (manage.py commands, shells) only report after
25
+ # recording something.
26
+ EAGER_PROGRAMS = re.compile(r"\b(gunicorn|uvicorn|hypercorn|daphne|granian|waitress|uwsgi|celery|rqworker|rq)\b")
27
+ CHECKPOINT_NAME = re.compile(r"\A[a-z0-9][a-z0-9_.:-]{0,99}\Z", re.IGNORECASE)
28
+
29
+ _configuration: Optional[Configuration] = None
30
+ _agent: Optional[Agent] = None
31
+ _lock = threading.Lock()
32
+ _capabilities = ["http", "exceptions"]
33
+ _metadata_sources: list = []
34
+ _hooks_installed = False
35
+ _warned_checkpoint = False
36
+
37
+
38
+ def configuration() -> Configuration:
39
+ global _configuration
40
+ if _configuration is None:
41
+ _configuration = Configuration()
42
+ return _configuration
43
+
44
+
45
+ def configure(**options) -> Configuration:
46
+ """deployangel.configure(environments=["production", "staging"])"""
47
+ config = configuration()
48
+ config.update(**options)
49
+ return config
50
+
51
+
52
+ def agent() -> Optional[Agent]:
53
+ return _agent
54
+
55
+
56
+ def start(environment: Optional[str] = None, root: Optional[str] = None, framework: Optional[str] = None,
57
+ framework_version: Optional[str] = None, eager: Optional[bool] = None, debug: bool = False) -> Optional[Agent]:
58
+ """Starts this process's agent, once; later calls return the same agent.
59
+ Framework integrations call it at boot. debug is the framework's debug
60
+ flag, which names the environment "development" unless it's configured."""
61
+ global _agent
62
+ try:
63
+ with _lock:
64
+ if _agent is not None:
65
+ return _agent
66
+ config = configuration()
67
+ environment = config.environment or environment or ("development" if debug else "production")
68
+ _agent = Agent(config=config, environment=environment, root=config.root or root or os.getcwd(),
69
+ framework=framework, framework_version=framework_version,
70
+ eager=server_process() if eager is None else eager, capabilities=_capabilities)
71
+ from deployangel.metadata import Metadata
72
+
73
+ _agent.metadata = Metadata(config=config, root=_agent.root, environment=environment, sources=_metadata_sources)
74
+ _install_process_hooks()
75
+ return _agent
76
+ except Exception as error:
77
+ try:
78
+ configuration().logger.warning("DeployAngel failed to start: %s: %s", type(error).__name__, error)
79
+ except Exception:
80
+ pass
81
+ return None
82
+
83
+
84
+ def recording() -> bool:
85
+ return _agent is not None and _agent.active
86
+
87
+
88
+ def checkpoint(name: str, count: int = 1) -> None:
89
+ """Counts a business event, such as deployangel.checkpoint("order.fulfilled").
90
+ DeployAngel learns each checkpoint's normal rate and fails a release after
91
+ which it drops sharply or stops."""
92
+ global _warned_checkpoint
93
+ try:
94
+ name = str(name)
95
+ if not CHECKPOINT_NAME.match(name) or not isinstance(count, int) or isinstance(count, bool) or count <= 0:
96
+ if not _warned_checkpoint:
97
+ configuration().logger.warning("DeployAngel ignored checkpoint %r: use letters, numbers, and . _ : - (max 100)", name)
98
+ _warned_checkpoint = True
99
+ return
100
+ add_capability("checkpoints")
101
+ if _agent is not None:
102
+ _agent.record_checkpoint(name, count)
103
+ except Exception:
104
+ pass
105
+
106
+
107
+ def notify(exception: BaseException) -> None:
108
+ """Reports an exception the app caught and handled itself. It's shown for
109
+ context; only unhandled exceptions count toward verdicts."""
110
+ if _agent is not None:
111
+ _agent.record_exception(exception, handled=True)
112
+
113
+
114
+ def record_request(route_key: str, status: int, duration_ms: float, unhandled: bool = False, in_totals: bool = True) -> None:
115
+ if _agent is not None:
116
+ _agent.record_request(route_key, status, duration_ms, unhandled=unhandled, in_totals=in_totals)
117
+
118
+
119
+ def record_job(job_class: str, duration_ms: float, failed: bool = False, discarded: bool = False,
120
+ queue_latency_ms: Optional[float] = None) -> None:
121
+ if _agent is not None:
122
+ _agent.record_job(job_class, duration_ms, failed=failed, discarded=discarded, queue_latency_ms=queue_latency_ms)
123
+
124
+
125
+ def record_exception(exception: BaseException, source: Optional[str] = None, handled: bool = False) -> None:
126
+ if _agent is not None:
127
+ _agent.record_exception(exception, source=source, handled=handled)
128
+
129
+
130
+ def add_capability(name: str) -> None:
131
+ """Signals this process can observe, announced in every payload so the
132
+ cloud never claims to verify what the agent cannot see."""
133
+ if name not in _capabilities:
134
+ _capabilities.append(name)
135
+
136
+
137
+ def add_metadata_source(source) -> None:
138
+ """An integration that knows part of the app: an object with any of
139
+ routes(), job_classes(), job_class_files(), and schedules()."""
140
+ if source not in _metadata_sources:
141
+ _metadata_sources.append(source)
142
+
143
+
144
+ def after_fork() -> None:
145
+ if _agent is not None:
146
+ _agent.after_fork()
147
+
148
+
149
+ def shutdown() -> None:
150
+ if _agent is not None:
151
+ _agent.shutdown()
152
+
153
+
154
+ def server_process() -> bool:
155
+ argv = getattr(sys, "orig_argv", None) or sys.argv
156
+ return bool(EAGER_PROGRAMS.search(" ".join(os.path.basename(str(arg)) if i == 0 else str(arg)
157
+ for i, arg in enumerate(argv[:4]))))
158
+
159
+
160
+ def _install_process_hooks() -> None:
161
+ global _hooks_installed
162
+ if _hooks_installed:
163
+ return
164
+ _hooks_installed = True
165
+ atexit.register(shutdown)
166
+ if hasattr(os, "register_at_fork"):
167
+ os.register_at_fork(after_in_child=after_fork)
168
+
169
+
170
+ def _reset_for_tests() -> None:
171
+ global _agent, _configuration, _warned_checkpoint
172
+ _agent = None
173
+ _configuration = None
174
+ _warned_checkpoint = False
175
+ del _capabilities[2:]
176
+ _metadata_sources.clear()
deployangel/agent.py ADDED
@@ -0,0 +1,270 @@
1
+ """The per-process runtime: records requests and jobs into the aggregator and
2
+ sends completed minutes from a background thread. Every public method fails
3
+ open; nothing here may raise into, or block, the customer's app."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import os
8
+ import random
9
+ import threading
10
+ import time
11
+ from typing import Callable, Mapping, Optional
12
+
13
+ from deployangel.core import fingerprint, protocol, redaction
14
+ from deployangel.core import release as release_module
15
+ from deployangel.core.aggregator import Aggregator
16
+ from deployangel.core.buffer import Buffer
17
+ from deployangel.core.instance import Instance
18
+ from deployangel.core.transport import Transport, encode
19
+
20
+ TELEMETRY_PATH = "/api/v1/telemetry"
21
+ METADATA_PATH = "/api/v1/application_metadata"
22
+ SHUTDOWN_TIMEOUT = 2.0
23
+ DEFAULT_PAUSE = 60
24
+ # Seconds past each minute a process waits before sending, chosen once per
25
+ # process so the cloud gets a steady stream instead of a burst at the top of
26
+ # the minute. The cloud reads a minute 2 minutes after it ends, so a send that
27
+ # times out and goes again a minute later still lands in time.
28
+ FLUSH_JITTER = (1.0, 50.0)
29
+ # Set on an exception object once it's recorded, whichever instrumentation
30
+ # sees it first.
31
+ SEEN = "__deployangel_recorded__"
32
+
33
+
34
+ class Agent:
35
+ def __init__(self, config, environment: str, root: Optional[str] = None, framework: Optional[str] = None,
36
+ framework_version: Optional[str] = None, env: Mapping[str, str] = os.environ, transport=None,
37
+ clock: Callable[[], float] = time.time, eager: bool = False, capabilities: Optional[list] = None):
38
+ self.config = config
39
+ self.active = config.is_active(environment)
40
+ self.environment = environment
41
+ self.root = root
42
+ self.release = release_module.resolve(config, env=env, root=root) if self.active else release_module.Release(None, None, "unknown")
43
+ self.runtime = protocol.runtime(framework, framework_version)
44
+ self.metadata = None
45
+ self.capabilities = capabilities if capabilities is not None else ["http", "exceptions"]
46
+ self._transport = transport or Transport(config)
47
+ self._env = env
48
+ self._clock = clock
49
+ self._eager = eager
50
+ self._warned: set = set()
51
+ self._reset_process_state()
52
+ if self.active and self.release.unknown:
53
+ self._warn_once("unknown_release", "DeployAngel could not determine the release; set DEPLOYANGEL_REVISION "
54
+ "or enable Heroku dyno metadata. Telemetry will not be attributed to deployments.")
55
+ if self.active and eager:
56
+ self.start_reporter()
57
+
58
+ def record_request(self, route_key: str, status: int, duration_ms: float, unhandled: bool = False, in_totals: bool = True) -> None:
59
+ if not self.active:
60
+ return
61
+ try:
62
+ self._check_fork()
63
+ self.start_reporter()
64
+ self._aggregator.record(route_key, status, duration_ms, unhandled=unhandled, in_totals=in_totals)
65
+ except Exception as error:
66
+ self._warn_once("record", f"DeployAngel failed to record a request: {type(error).__name__}: {error}")
67
+
68
+ def record_job(self, job_class: str, duration_ms: float, failed: bool = False, discarded: bool = False,
69
+ queue_latency_ms: Optional[float] = None) -> None:
70
+ if not self.active:
71
+ return
72
+ try:
73
+ self._check_fork()
74
+ self.start_reporter()
75
+ self._aggregator.record_job(job_class, duration_ms, failed=failed, discarded=discarded, queue_latency_ms=queue_latency_ms)
76
+ except Exception as error:
77
+ self._warn_once("record_job", f"DeployAngel failed to record a job: {type(error).__name__}: {error}")
78
+
79
+ def record_discard(self, job_class: str) -> None:
80
+ if not self.active:
81
+ return
82
+ try:
83
+ self._check_fork()
84
+ self._aggregator.record_discard(job_class)
85
+ except Exception as error:
86
+ self._warn_once("record_discard", f"DeployAngel failed to record a discarded job: {type(error).__name__}: {error}")
87
+
88
+ def record_checkpoint(self, name: str, count: int = 1) -> None:
89
+ if not self.active:
90
+ return
91
+ try:
92
+ self._check_fork()
93
+ self.start_reporter()
94
+ self._aggregator.record_checkpoint(name, count)
95
+ except Exception as error:
96
+ self._warn_once("record_checkpoint", f"DeployAngel failed to record a checkpoint: {type(error).__name__}: {error}")
97
+
98
+ def record_exception(self, exception: BaseException, source: Optional[str] = None, handled: bool = False) -> None:
99
+ """Records each exception object once, whichever instrumentation sees it
100
+ first."""
101
+ if not self.active or not isinstance(exception, BaseException):
102
+ return
103
+ try:
104
+ if getattr(exception, SEEN, False):
105
+ return
106
+ try:
107
+ setattr(exception, SEEN, True)
108
+ except Exception:
109
+ pass
110
+ messages = self.config.exception_messages
111
+ details = fingerprint.for_exception(exception, self.root, message=messages,
112
+ redactions=redaction.current() if messages else ())
113
+ self.record_exception_details(details, fingerprint.backtrace(exception, self.root), source=source, handled=handled)
114
+ except Exception as error:
115
+ self._warn_once("record_exception", f"DeployAngel failed to record an exception: {type(error).__name__}: {error}")
116
+
117
+ def record_exception_details(self, details: dict, backtrace: Optional[list], source: Optional[str] = None,
118
+ handled: bool = False) -> None:
119
+ """An exception already fingerprinted, such as one parsed from a
120
+ formatted traceback."""
121
+ if not self.active:
122
+ return
123
+ try:
124
+ self._check_fork()
125
+ self.start_reporter()
126
+ self._aggregator.record_exception(details, source=source, handled=handled, backtrace=backtrace)
127
+ except Exception as error:
128
+ self._warn_once("record_exception", f"DeployAngel failed to record an exception: {type(error).__name__}: {error}")
129
+
130
+ def flush(self, include_current: bool = False, deadline: Optional[float] = None) -> int:
131
+ """Drains completed minutes into the buffer, encoded so unsent minutes
132
+ stay small while DeployAngel is unreachable, and sends what it can."""
133
+ if not self.active:
134
+ return 0
135
+ try:
136
+ for period in self._aggregator.drain(include_current=include_current, max_periods=self.config.max_queued_payloads):
137
+ self._buffer.push(encode(protocol.telemetry(period, self.instance, self.release, self.runtime, self.capabilities)))
138
+ return self._send_buffered(deadline)
139
+ except Exception as error:
140
+ self._warn_once("flush", f"DeployAngel failed to flush telemetry: {type(error).__name__}: {error}")
141
+ return 0
142
+
143
+ def start_reporter(self) -> None:
144
+ if not self.active or (self._thread is not None and self._thread.is_alive()):
145
+ return
146
+ with self._thread_lock:
147
+ if self._thread is not None and self._thread.is_alive():
148
+ return
149
+ self._stopping.clear()
150
+ thread = threading.Thread(target=self._run_reporter, name="deployangel-reporter", daemon=True)
151
+ thread.start()
152
+ self._thread = thread
153
+
154
+ def shutdown(self, timeout: float = SHUTDOWN_TIMEOUT) -> None:
155
+ """Sends the in-progress minute too, bounded by a short timeout, because
156
+ deployments restart processes. Runs on the calling thread: new threads
157
+ can't start while the interpreter shuts down."""
158
+ if not self.active:
159
+ return
160
+ try:
161
+ if os.getpid() != self._pid:
162
+ return
163
+ self._stopping.set()
164
+ self.flush(include_current=True, deadline=time.monotonic() + timeout)
165
+ except Exception:
166
+ pass
167
+
168
+ def after_fork(self) -> None:
169
+ """Threads do not survive fork, and the parent's identity and counts
170
+ must not be reused by the child."""
171
+ self._reset_process_state()
172
+ if self.active and self._eager:
173
+ self.start_reporter()
174
+
175
+ def send_metadata(self) -> None:
176
+ """Sent once per process; retried on the next minute if it fails. File
177
+ digests are only uploaded when the cloud has not seen the manifest."""
178
+ if self._metadata_sent or self.metadata is None or self._paused():
179
+ return
180
+ try:
181
+ base = {"protocol_version": protocol.VERSION, "instance": self.instance.to_protocol(),
182
+ "release": self.release.to_protocol(), "runtime": self.runtime}
183
+ base.update(self.metadata.to_protocol())
184
+ result = self._transport.post(METADATA_PATH, base)
185
+ if not result.ok:
186
+ return
187
+ if isinstance(result.body, dict) and result.body.get("manifest_needed"):
188
+ if not self._transport.post(METADATA_PATH, dict(base, files=self.metadata.files())).ok:
189
+ return
190
+ self._metadata_sent = True
191
+ except Exception as error:
192
+ self._warn_once("metadata", f"DeployAngel failed to send application metadata: {type(error).__name__}: {error}")
193
+
194
+ def _check_fork(self) -> None:
195
+ # A fork the interpreter didn't see, such as uWSGI's, which forks
196
+ # workers from C.
197
+ if os.getpid() != self._pid:
198
+ self.after_fork()
199
+
200
+ def _reset_process_state(self) -> None:
201
+ self._pid = os.getpid()
202
+ self.instance = Instance(env=self._env, now=self._clock())
203
+ self._aggregator = Aggregator(max_routes=self.config.max_routes, clock=self._clock)
204
+ self._buffer = Buffer(self.config.max_queued_payloads)
205
+ self._paused_until: Optional[float] = None
206
+ self._metadata_sent = False
207
+ self._thread: Optional[threading.Thread] = None
208
+ self._thread_lock = threading.Lock()
209
+ self._stopping = threading.Event()
210
+ self._jitter = random.uniform(*FLUSH_JITTER)
211
+
212
+ def _run_reporter(self) -> None:
213
+ try:
214
+ while not self._stopping.is_set():
215
+ if self._stopping.wait(self._seconds_until_next_flush()):
216
+ break
217
+ self.send_metadata()
218
+ self.flush()
219
+ except Exception as error:
220
+ self._warn_once("reporter", f"DeployAngel reporter stopped: {type(error).__name__}: {error}")
221
+
222
+ def _seconds_until_next_flush(self) -> float:
223
+ interval = self.config.flush_interval
224
+ return (interval - (self._clock() % interval)) + self._jitter
225
+
226
+ def _send_buffered(self, deadline: Optional[float] = None) -> int:
227
+ sent = 0
228
+ while True:
229
+ payload = self._buffer.shift()
230
+ if payload is None:
231
+ break
232
+ remaining = None
233
+ if deadline is not None:
234
+ remaining = deadline - time.monotonic()
235
+ if remaining <= 0:
236
+ self._buffer.unshift(payload)
237
+ break
238
+ if self._paused():
239
+ self._buffer.unshift(payload)
240
+ break
241
+ result = self._transport.post(TELEMETRY_PATH, payload, timeout=min(remaining, self.config.timeout) if remaining else None)
242
+ if result.outcome == "ok":
243
+ sent += 1
244
+ elif result.outcome == "retry":
245
+ self._buffer.unshift(payload)
246
+ break
247
+ else:
248
+ self._handle_rejection(result)
249
+ return sent
250
+
251
+ def _handle_rejection(self, result) -> None:
252
+ if result.status == 429:
253
+ self._paused_until = self._clock() + (result.retry_after if result.retry_after and result.retry_after > 0 else DEFAULT_PAUSE)
254
+ elif result.status in (401, 403):
255
+ self._warn_once("auth", f"DeployAngel rejected the token (HTTP {result.status}); check DEPLOYANGEL_TOKEN "
256
+ "and that it has the telemetry scope.")
257
+ else:
258
+ self._warn_once(f"rejected_{result.status}", f"DeployAngel rejected a telemetry payload (HTTP {result.status}).")
259
+
260
+ def _paused(self) -> bool:
261
+ return self._paused_until is not None and self._clock() < self._paused_until
262
+
263
+ def _warn_once(self, key: str, message: str) -> None:
264
+ if key in self._warned:
265
+ return
266
+ self._warned.add(key)
267
+ try:
268
+ self.config.logger.warning(message)
269
+ except Exception:
270
+ pass
deployangel/asgi.py ADDED
@@ -0,0 +1,216 @@
1
+ """ASGI integration for Starlette and FastAPI: a middleware that records each
2
+ request against the route the router matched ("GET /items/{item_id}"), and
3
+ the route table for the metadata.
4
+
5
+ app = FastAPI()
6
+ deployangel.fastapi.init(app)
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import time
12
+ from typing import Optional
13
+
14
+ import deployangel
15
+ from deployangel import http
16
+ from deployangel.core import redaction
17
+ from deployangel.metadata import module_file
18
+
19
+ IGNORED_METHODS = ("HEAD", "OPTIONS")
20
+ # The documentation pages FastAPI serves itself.
21
+ FRAMEWORK_MODULES = ("fastapi.", "starlette.")
22
+
23
+
24
+ def init(app):
25
+ """Adds the middleware, lists the app's routes, and starts the agent. Call
26
+ it where the app is created, before it serves requests."""
27
+ try:
28
+ app.add_middleware(DeployAngelMiddleware)
29
+ deployangel.add_metadata_source(StarletteRoutes(app))
30
+ framework, version = _framework(app)
31
+ return deployangel.start(framework=framework, framework_version=version, debug=bool(getattr(app, "debug", False)))
32
+ except Exception as error:
33
+ deployangel.configuration().logger.warning("DeployAngel failed to start: %s: %s", type(error).__name__, error)
34
+ return None
35
+
36
+
37
+ class DeployAngelMiddleware:
38
+ def __init__(self, app):
39
+ self.app = app
40
+
41
+ async def __call__(self, scope, receive, send):
42
+ if scope.get("type") != "http" or not deployangel.recording():
43
+ await self.app(scope, receive, send)
44
+ return
45
+ started = time.perf_counter()
46
+ # The router changes the scope as it goes (a mount moves root_path,
47
+ # and a mounted app replaces "app"), so note what it was.
48
+ original = {"type": "http", "path": scope.get("path", ""), "root_path": scope.get("root_path", ""),
49
+ "method": scope.get("method", "GET"), "headers": scope.get("headers", [])}
50
+ outer_app = scope.get("app")
51
+ redaction.set_request_host(_header(scope, b"host"))
52
+ status = []
53
+
54
+ async def send_with_status(message):
55
+ if message.get("type") == "http.response.start":
56
+ status.append(message.get("status", 200))
57
+ await send(message)
58
+
59
+ try:
60
+ await self.app(scope, receive, send_with_status)
61
+ except Exception as exception:
62
+ _record(scope, original, outer_app, 500, started, exception)
63
+ raise
64
+ _record(scope, original, outer_app, status[0] if status else 500, started, None)
65
+
66
+
67
+ def _record(scope, original, outer_app, status, started, exception) -> None:
68
+ try:
69
+ http.record(original["method"], route_pattern(scope, original, outer_app), status, started,
70
+ unhandled=exception is not None, exception=exception)
71
+ except Exception:
72
+ pass
73
+
74
+
75
+ def route_pattern(scope, original, outer_app) -> Optional[str]:
76
+ """The matched route's path format, with the paths of any mounts it sits
77
+ under; None when nothing matched or a mounted non-router app (such as
78
+ StaticFiles) answered."""
79
+ from starlette.routing import Mount
80
+
81
+ route = scope.get("route")
82
+ if route is None:
83
+ if scope.get("endpoint") is None:
84
+ return None
85
+ route = _match(getattr(outer_app, "routes", []), original)
86
+ return route
87
+ if isinstance(route, Mount):
88
+ return None
89
+ pattern = _effective_path(scope) or getattr(route, "path_format", None) or getattr(route, "path", None)
90
+ if not pattern:
91
+ return None
92
+ if scope.get("root_path", "") != original["root_path"]:
93
+ pattern = _mount_prefix(getattr(outer_app, "routes", []), _route_path(original)) + pattern
94
+ return pattern
95
+
96
+
97
+ def _effective_path(scope) -> Optional[str]:
98
+ """FastAPI 0.14x includes routers lazily: the route in the scope is the
99
+ router's own, without the prefix include_router() added, and the path the
100
+ request matched is in FastAPI's part of the scope."""
101
+ fastapi_scope = scope.get("fastapi")
102
+ context = fastapi_scope.get("effective_route_context") if isinstance(fastapi_scope, dict) else None
103
+ if context is None:
104
+ return None
105
+ return getattr(getattr(context, "starlette_route", None), "path_format", None) or getattr(context, "path_format", None)
106
+
107
+
108
+ def _route_path(scope) -> str:
109
+ path, root = scope["path"], scope["root_path"]
110
+ return path[len(root):] if root and path.startswith(root) else path
111
+
112
+
113
+ def _mount_prefix(routes, path: str) -> str:
114
+ from starlette.routing import Mount
115
+
116
+ for route in routes:
117
+ if isinstance(route, Mount):
118
+ match = route.path_regex.match(path)
119
+ if match:
120
+ return route.path + _mount_prefix(getattr(route, "routes", []) or [], "/" + match.groupdict().get("path", ""))
121
+ return ""
122
+
123
+
124
+ def _match(routes, scope, prefix: str = "") -> Optional[str]:
125
+ """For a router that doesn't name the matched route in the scope: matches
126
+ the request again against the route table."""
127
+ from starlette.routing import Match, Mount
128
+
129
+ for route in routes:
130
+ try:
131
+ if isinstance(route, Mount):
132
+ match = route.path_regex.match(_route_path(scope))
133
+ if match and getattr(route, "routes", None):
134
+ child = dict(scope, path="/" + match.groupdict().get("path", ""), root_path="")
135
+ found = _match(route.routes, child, prefix + route.path)
136
+ if found:
137
+ return found
138
+ continue
139
+ result, _ = route.matches(scope)
140
+ if result == Match.FULL:
141
+ return prefix + (getattr(route, "path_format", None) or route.path)
142
+ except Exception:
143
+ continue
144
+ return None
145
+
146
+
147
+ def _header(scope, name: bytes) -> Optional[str]:
148
+ for key, value in scope.get("headers") or []:
149
+ if key == name:
150
+ return value.decode("latin-1")
151
+ return None
152
+
153
+
154
+ def _framework(app) -> tuple:
155
+ try:
156
+ import fastapi
157
+
158
+ if isinstance(app, fastapi.FastAPI):
159
+ return "fastapi", fastapi.__version__
160
+ except ImportError:
161
+ pass
162
+ import starlette
163
+
164
+ return "starlette", getattr(starlette, "__version__", None)
165
+
166
+
167
+ class StarletteRoutes:
168
+ def __init__(self, app):
169
+ self.app = app
170
+
171
+ def routes(self) -> list:
172
+ agent = deployangel.agent()
173
+ root = agent.root if agent else None
174
+ return list(_walk(self.app.routes, "", root))
175
+
176
+
177
+ def _walk(routes, prefix: str, root: Optional[str]):
178
+ from starlette.routing import Mount
179
+
180
+ for route in _flatten(routes):
181
+ if isinstance(route, Mount):
182
+ if getattr(route, "routes", None):
183
+ yield from _walk(route.routes, prefix + route.path, root)
184
+ continue
185
+ if not getattr(route, "methods", None) or not getattr(route, "endpoint", None):
186
+ continue
187
+ endpoint = getattr(route, "endpoint", None)
188
+ module = getattr(endpoint, "__module__", None) or ""
189
+ if module.startswith(FRAMEWORK_MODULES):
190
+ continue
191
+ path = prefix + (getattr(route, "path_format", None) or route.path)
192
+ entry = {"controller": module, "action": getattr(endpoint, "__qualname__", None) or getattr(endpoint, "__name__", "")}
193
+ source = module_file(module, root)
194
+ if source:
195
+ entry["files"] = [source]
196
+ for method in sorted(getattr(route, "methods", None) or ["GET"]):
197
+ if method not in IGNORED_METHODS:
198
+ yield dict(entry, key=f"{method} {path}")
199
+
200
+
201
+ def _flatten(routes):
202
+ """The routes as matched, with FastAPI 0.14x's lazily included routers
203
+ expanded (each with its include_router() prefix)."""
204
+ try:
205
+ from fastapi.routing import iter_route_contexts
206
+ except ImportError:
207
+ return list(routes)
208
+ from starlette.routing import Mount
209
+
210
+ flat = []
211
+ for route in routes:
212
+ if isinstance(route, Mount):
213
+ flat.append(route)
214
+ else:
215
+ flat.extend(iter_route_contexts([route]))
216
+ return flat