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

1"""Deciding which requests get recorded. 

2 

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: 

5 

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. 

14 

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. 

19 

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

26 

27from __future__ import annotations 

28 

29import re 

30from collections.abc import Callable 

31from typing import Any, TypeVar 

32 

33__all__ = ["profiler_exclude", "profiler_include"] 

34 

35F = TypeVar("F", bound=Callable[..., Any]) 

36 

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" 

40 

41#: How many endpoints in this process carry `@profiler_include`. 

42_include_count = 0 

43 

44 

45def profiler_include(func: F) -> F: 

46 """Always record this endpoint, whatever the patterns say. 

47 

48 For carving a route back in when `include_regex` or `exclude_regex` would 

49 otherwise drop it: 

50 

51 @app.get("/health/deep") 

52 @profiler_include 

53 def deep_health(): 

54 ... 

55 

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 

64 

65 

66def profiler_exclude(func: F) -> F: 

67 """Never record this endpoint, whatever the patterns say. 

68 

69 @app.get("/health") 

70 @profiler_exclude 

71 def health(): 

72 ... 

73 

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 

79 

80 

81def any_includes() -> bool: 

82 """Has anything in this process been marked `@profiler_include`? 

83 

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 

88 

89 

90def endpoint_decision(scope: Any) -> bool | None: 

91 """The decorator's verdict for the endpoint that handled this request. 

92 

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 

101 

102 

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. 

105 

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 

125 

126 

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. 

135 

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)) 

145 

146 

147def _prefix_match(path: str, prefixes: tuple[str, ...]) -> bool: 

148 """Match on segment boundaries. 

149 

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