Coverage for src/asgi_profiler/rules.py: 98%
33 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-22 16:59 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-22 16:59 +0000
1"""Deciding which requests get recorded.
3Four mechanisms, in one place so the precedence between them is written down
4once rather than inferred from the order of `if` statements in the middleware:
61. The viewer's own mount is never recorded. Not overridable -- a profiler that
7 records itself fills its history with pages you opened to read the history.
82. `@profiler_include` / `@profiler_exclude` on the endpoint. Decisive. A
9 decorator on the function is the most specific statement of intent
10 available, so it beats every pattern.
113. `exclude_paths` or `exclude_regex` matching -> not recorded.
124. `include_regex` set and not matching -> not recorded.
135. Otherwise recorded.
15Steps 3 and 4 only need the path, which the middleware has before routing, so
16an excluded route can be skipped without allocating a profile or capturing a
17single query. Step 2 needs `scope["endpoint"]`, which Starlette only sets
18*during* routing -- so a decorator can only be honoured after the fact.
20That tension is what `any_includes()` is for. If nothing in the process is
21decorated with `@profiler_include`, no late decision can ever flip a `False`
22back to `True`, and the cheap pre-routing skip is safe. The moment one exists,
23the middleware stops skipping early and decides afterwards instead. You pay
24for the feature only if you use it.
25"""
27from __future__ import annotations
29import re
30from collections.abc import Callable
31from typing import Any, TypeVar
33__all__ = ["profiler_exclude", "profiler_include"]
35F = TypeVar("F", bound=Callable[..., Any])
37#: Set on the endpoint function by the decorators. `True` forces recording,
38#: `False` forbids it, absent means "no opinion, fall through to the patterns".
39MARKER = "_asgi_profiler_record"
41#: How many endpoints in this process carry `@profiler_include`.
42_include_count = 0
45def profiler_include(func: F) -> F:
46 """Always record this endpoint, whatever the patterns say.
48 For carving a route back in when `include_regex` or `exclude_regex` would
49 otherwise drop it:
51 @app.get("/health/deep")
52 @profiler_include
53 def deep_health():
54 ...
56 Works in either decorator order -- above or below the route decorator --
57 because the route registers the same function object either way.
58 """
59 global _include_count
60 if getattr(func, MARKER, None) is not True: 60 ↛ 62line 60 didn't jump to line 62 because the condition on line 60 was always true
61 _include_count += 1
62 setattr(func, MARKER, True)
63 return func
66def profiler_exclude(func: F) -> F:
67 """Never record this endpoint, whatever the patterns say.
69 @app.get("/health")
70 @profiler_exclude
71 def health():
72 ...
74 With the default `include_regex` (everything), decorating a handful of
75 routes with this is the whole configuration most applications need.
76 """
77 setattr(func, MARKER, False)
78 return func
81def any_includes() -> bool:
82 """Has anything in this process been marked `@profiler_include`?
84 Lets the middleware keep skipping excluded requests before routing in the
85 common case where nothing can override that decision later.
86 """
87 return _include_count > 0
90def endpoint_decision(scope: Any) -> bool | None:
91 """The decorator's verdict for the endpoint that handled this request.
93 `None` means the endpoint had no decorator, or routing never reached one
94 (a 404, or a mounted sub-application that does not set `endpoint`).
95 """
96 endpoint = scope.get("endpoint")
97 if endpoint is None:
98 return None
99 decision = getattr(endpoint, MARKER, None)
100 return decision if isinstance(decision, bool) else None
103def compile_pattern(pattern: str | None, field: str) -> re.Pattern[str] | None:
104 """Compile a user-supplied pattern, or explain why it will not compile.
106 `re.error: nothing to repeat at position 0` is what Python says about a
107 bare `"*"`, and it tells someone who was thinking in glob patterns nothing
108 useful. This says the thing they need to hear instead.
109 """
110 if pattern is None:
111 return None
112 try:
113 return re.compile(pattern)
114 except re.error as exc:
115 hint = ""
116 if pattern in {"*", "**", "*.*"}:
117 hint = (
118 ' It looks like a glob. These are regular expressions, so "*" '
119 'on its own is a syntax error -- use ".*" to match everything, '
120 f"or leave {field} as None, which already means everything."
121 )
122 raise ValueError(
123 f"{field}={pattern!r} is not a valid regular expression ({exc}).{hint}"
124 ) from exc
127def path_allows(
128 path: str,
129 *,
130 excludes: tuple[str, ...],
131 include_re: re.Pattern[str] | None,
132 exclude_re: re.Pattern[str] | None,
133) -> bool:
134 """Steps 3 and 4: everything decidable from the path alone.
136 Patterns are matched with `search`, not `fullmatch`, so `"/health"` catches
137 `/health/db` the way people expect a filter to behave. Anchor with `^` and
138 `$` when you want the strict reading.
139 """
140 if _prefix_match(path, excludes):
141 return False
142 if exclude_re is not None and exclude_re.search(path):
143 return False
144 return not (include_re is not None and not include_re.search(path))
147def _prefix_match(path: str, prefixes: tuple[str, ...]) -> bool:
148 """Match on segment boundaries.
150 A bare `startswith` would make an excluded `/profiler` swallow the
151 application's own `/profiler-admin`, silently and with no error -- which
152 reads to the user as "the profiler is broken".
153 """
154 for prefix in prefixes:
155 trimmed = prefix.rstrip("/")
156 if not trimmed:
157 return True # excluded at the root
158 if path == trimmed or path.startswith(trimmed + "/"):
159 return True
160 return False