devbench 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,68 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import sys
5
+ from typing import Any, Optional
6
+
7
+ from django.apps import AppConfig
8
+
9
+ MIDDLEWARE_PATH = "devbench.django.middleware.DevbenchMiddleware"
10
+
11
+
12
+ class DevbenchConfig(AppConfig):
13
+ name = "devbench.django"
14
+ label = "devbench"
15
+ verbose_name = "Dev Bench"
16
+ default = True
17
+
18
+ def ready(self) -> None:
19
+ # A diagnostics package that can fail a boot is worse than none.
20
+ try:
21
+ from .. import _core, _integration
22
+
23
+ if not _core.enabled():
24
+ return
25
+ from django.conf import settings
26
+
27
+ _integration.setup(service=_project_name(settings), root=_base_dir(settings))
28
+ insert_middleware(settings)
29
+
30
+ from django.core.signals import got_request_exception
31
+
32
+ from .middleware import on_request_exception
33
+
34
+ got_request_exception.connect(on_request_exception, dispatch_uid="devbench.request_exception")
35
+
36
+ # The standard Celery layout imports the Celery app from the
37
+ # project package, so it is loaded by now, in the web process and
38
+ # the worker alike.
39
+ if "celery" in sys.modules:
40
+ from .. import celery as devbench_celery
41
+
42
+ devbench_celery.install()
43
+ except Exception:
44
+ pass
45
+
46
+
47
+ def insert_middleware(settings: Any) -> bool:
48
+ """Puts DevbenchMiddleware first, as sentry-sdk instruments the handler,
49
+ so ``"devbench.django"`` in INSTALLED_APPS is the whole install. Not when
50
+ the app already lists it. Runs in ready(), before Django's handler reads
51
+ MIDDLEWARE. Returns True when it was added."""
52
+ current = list(getattr(settings, "MIDDLEWARE", None) or [])
53
+ if MIDDLEWARE_PATH in current:
54
+ return False
55
+ settings.MIDDLEWARE = [MIDDLEWARE_PATH] + current
56
+ return True
57
+
58
+
59
+ def _project_name(settings: Any) -> Optional[str]:
60
+ # DJANGO_SETTINGS_MODULE "acme_shop.settings" -> "acme_shop".
61
+ module = getattr(settings, "SETTINGS_MODULE", None) or os.environ.get("DJANGO_SETTINGS_MODULE") or ""
62
+ head = module.split(".")[0]
63
+ return head if "." in module else None
64
+
65
+
66
+ def _base_dir(settings: Any) -> Optional[str]:
67
+ base = getattr(settings, "BASE_DIR", None)
68
+ return str(base) if base else None
File without changes
File without changes
@@ -0,0 +1,18 @@
1
+ """``python manage.py devbench_test``: ``python -m devbench test`` with the
2
+ project's settings loaded, so the service name is the project's."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import Any
7
+
8
+ from django.core.management.base import BaseCommand, CommandError
9
+
10
+
11
+ class Command(BaseCommand):
12
+ help = "Check DEVBENCH_DSN against Dev Bench and print the HTTP result"
13
+
14
+ def handle(self, *args: Any, **options: Any) -> None:
15
+ from devbench import _core
16
+
17
+ if not _core.self_test(self.stdout):
18
+ raise CommandError("Dev Bench test failed (see above)")
@@ -0,0 +1,87 @@
1
+ """DevbenchMiddleware for Django (sync and async). Added first in MIDDLEWARE
2
+ by the app config.
3
+
4
+ Django turns every exception inside the stack into a response before it
5
+ reaches this middleware, so failures are reported from
6
+ ``got_request_exception``, which Django sends for exactly the exceptions it
7
+ answers with a 500 (an Http404, PermissionDenied, BadRequest or
8
+ SuspiciousOperation is a response, not a failure, and never reaches it). The
9
+ signal runs inside this middleware's scope, so the report carries the
10
+ request's trace and user.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import sys
16
+ from typing import Any, Callable, Optional
17
+
18
+ from asgiref.sync import iscoroutinefunction, markcoroutinefunction
19
+
20
+ from .. import _core, _integration, context
21
+ from ..trace import WSGI_HEADER, Trace
22
+
23
+
24
+ class DevbenchMiddleware:
25
+ sync_capable = True
26
+ async_capable = True
27
+
28
+ def __init__(self, get_response: Callable[..., Any]) -> None:
29
+ self.get_response = get_response
30
+ self._async = iscoroutinefunction(get_response)
31
+ if self._async:
32
+ markcoroutinefunction(self)
33
+
34
+ def __call__(self, request: Any) -> Any:
35
+ if self._async:
36
+ return self.__acall__(request)
37
+ if not _core.enabled():
38
+ return self.get_response(request)
39
+ with context.scoped(_parse(request)):
40
+ response = self.get_response(request)
41
+ _mark_handled(response)
42
+ return response
43
+
44
+ async def __acall__(self, request: Any) -> Any:
45
+ if not _core.enabled():
46
+ return await self.get_response(request)
47
+ with context.scoped(_parse(request)):
48
+ response = await self.get_response(request)
49
+ _mark_handled(response)
50
+ return response
51
+
52
+
53
+ def on_request_exception(sender: Any = None, request: Any = None, **_: Any) -> None:
54
+ """got_request_exception: the exception being handled is sys.exc_info()."""
55
+ try:
56
+ _integration.report_request_error(sys.exc_info()[1], _symbol(request))
57
+ except Exception:
58
+ pass
59
+
60
+
61
+ def _parse(request: Any) -> Optional[Trace]:
62
+ try:
63
+ return Trace.parse(request.META.get(WSGI_HEADER))
64
+ except Exception:
65
+ return None
66
+
67
+
68
+ def _symbol(request: Any) -> Optional[str]:
69
+ # "shop.views.checkout": the resolved view, when routing got that far.
70
+ try:
71
+ match = getattr(request, "resolver_match", None)
72
+ if match is None:
73
+ return None
74
+ return getattr(match, "_func_path", None) or _integration.callable_path(match.func)
75
+ except Exception:
76
+ return None
77
+
78
+
79
+ def _mark_handled(response: Any) -> None:
80
+ try:
81
+ count = _integration.handled_count()
82
+ if count <= 0 or response is None:
83
+ return
84
+ response[_integration.HANDLED_HEADER] = str(count)
85
+ response[_integration.EXPOSE_HEADER] = _integration.exposed(response.get(_integration.EXPOSE_HEADER))
86
+ except Exception:
87
+ pass
@@ -0,0 +1,101 @@
1
+ """Learned redaction rules, as ingest sends them on a flush response
2
+ (``redaction``), compiled and applied exactly as the sidecar does
3
+ (internal/sidecar/rules.go and Sidecar.redactBody)::
4
+
5
+ {"fp": <shape fp>, "egress": "masked"|"none"|"full"} shape rule
6
+ {"kind": "field", "target": "license"} mask that field's value
7
+ {"kind": "term", "target": "Whitfield"} mask that literal
8
+
9
+ Immutable once built; the transport swaps a whole new one in when a response
10
+ carries a rule set, and keeps the current one when a response carries none
11
+ (absent is not empty: ingest omits the field when it could not look the
12
+ rules up).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from typing import Any, Dict, List, Optional
19
+
20
+ from .fingerprint import GO_WS as WS
21
+
22
+ FIELD = "field"
23
+ TERM = "term"
24
+
25
+ # store.NormalizeRule's field-name shape; anything else is ignored, so a
26
+ # malformed rule never becomes an odd pattern here.
27
+ FIELD_NAME = re.compile(r"[a-z0-9_.\-]{1,64}\Z")
28
+ # A shorter term would mask fragments of ordinary words everywhere.
29
+ MIN_TERM_BYTES = 3
30
+
31
+
32
+ class EgressPolicy:
33
+ __slots__ = ("_shapes", "_terms", "_fields")
34
+
35
+ def __init__(self, shapes: Dict[str, str], fields: List[str], terms: List[str]) -> None:
36
+ self._shapes = dict(shapes)
37
+ self._terms = tuple(terms)
38
+ self._fields = _field_pattern(fields) if fields else None
39
+
40
+ @classmethod
41
+ def compile(cls, rules: Any) -> "EgressPolicy":
42
+ """rules: the parsed JSON array. Entries that are not objects, or not
43
+ understood, are skipped (a newer ingest may send kinds this SDK cannot
44
+ enforce; the local passes still run)."""
45
+ shapes: Dict[str, str] = {}
46
+ fields: List[str] = []
47
+ terms: List[str] = []
48
+ for rule in rules if isinstance(rules, list) else []:
49
+ if not isinstance(rule, dict):
50
+ continue
51
+ kind = rule.get("kind")
52
+ kind = kind if isinstance(kind, str) else ""
53
+ target = rule.get("target")
54
+ target = target if isinstance(target, str) else ""
55
+ if kind == "":
56
+ fp = rule.get("fp")
57
+ if isinstance(fp, str) and fp:
58
+ egress = rule.get("egress")
59
+ shapes[fp] = egress if isinstance(egress, str) else ""
60
+ elif kind == FIELD:
61
+ name = target.strip().lower()
62
+ if FIELD_NAME.match(name) and name not in fields:
63
+ fields.append(name)
64
+ elif kind == TERM:
65
+ if len(target.encode("utf-8", "replace")) >= MIN_TERM_BYTES and target not in terms:
66
+ terms.append(target)
67
+ return cls(shapes, fields, terms)
68
+
69
+ def rule_for(self, fp: Optional[str]) -> Optional[str]:
70
+ """The shape rule for a fingerprint, or None."""
71
+ return self._shapes.get(fp) if fp else None
72
+
73
+ def has_shapes(self) -> bool:
74
+ return bool(self._shapes)
75
+
76
+ @property
77
+ def terms(self) -> tuple:
78
+ return self._terms
79
+
80
+ def mask(self, value: str) -> str:
81
+ """Field and term rules. Additive only: it removes text, never
82
+ restores any."""
83
+ if self._fields is not None:
84
+ value = self._fields.sub(r"\g<1>\g<2>\g<3>\g<4><redacted:field>", value)
85
+ for term in self._terms:
86
+ value = value.replace(term, "<redacted:term>")
87
+ return value
88
+
89
+
90
+ def _field_pattern(fields: List[str]) -> "re.Pattern[str]":
91
+ """The field name, exactly ("license" must not catch "licensed" or
92
+ "driver_license"), optionally quoted or a Ruby symbol, then a separator,
93
+ then one value: a quoted string or a bare token. Go's pattern, with Go's
94
+ \\s and with \\A for Go's unanchored-mode ^."""
95
+ names = "|".join(re.escape(f) for f in fields)
96
+ return re.compile(
97
+ r"(\A|[^a-z0-9_.\-])([\"':]?)(" + names + ")"
98
+ r"([\"']?" + WS + "*(?:=>|=|:)" + WS + "*)"
99
+ r"(\"(?:[^\"\\]|\\.)*\"|'(?:[^'\\]|\\.)*'|[^\t\n\f\r ,;&)}\]]+)",
100
+ re.IGNORECASE,
101
+ )
devbench/fastapi.py ADDED
@@ -0,0 +1,14 @@
1
+ """FastAPI (and Starlette)::
2
+
3
+ from fastapi import FastAPI
4
+ from devbench.fastapi import DevbenchMiddleware
5
+
6
+ app = FastAPI()
7
+ app.add_middleware(DevbenchMiddleware)
8
+
9
+ The same middleware as devbench.asgi.
10
+ """
11
+
12
+ from .asgi import DevbenchMiddleware
13
+
14
+ __all__ = ["DevbenchMiddleware"]
@@ -0,0 +1,199 @@
1
+ """A port of internal/fingerprint (Go): the same signal gives the same
2
+ fingerprint, byte for byte, as the sidecar, the browser, Ruby and Go compute.
3
+
4
+ If two implementations disagree, the same problem is counted as two: triage
5
+ cost doubles and one issue is split in half. testdata/fingerprint_vectors.json
6
+ holds all of them to one answer (tests/test_vectors.py).
7
+
8
+ Go's regexp and Python's differ in ways that matter here, so the patterns are
9
+ spelled out rather than copied:
10
+
11
+ * Go's ``\\b`` is an ASCII word boundary; Python's (str patterns) treats "é"
12
+ as a word character. ``GO_B`` rebuilds Go's.
13
+ * Go's ``\\d`` is ASCII; Python's matches every Unicode digit. Spelled
14
+ ``[0-9]``.
15
+ * Go's ``\\s`` is ``[\\t\\n\\f\\r ]``; Python's adds ``\\v`` and Unicode
16
+ spaces. ``GO_WS`` is Go's.
17
+ * Go's strings.TrimSpace trims Unicode spaces (unicode.IsSpace), a set that
18
+ differs from str.strip()'s. ``GO_SPACE`` is Go's.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import hashlib
24
+ import re
25
+ from typing import Any, Iterable, Mapping
26
+
27
+ GO_W = "[0-9A-Za-z_]"
28
+ GO_B = "(?:(?<=%s)(?!%s)|(?<!%s)(?=%s))" % (GO_W, GO_W, GO_W, GO_W)
29
+ GO_WS = r"[\t\n\f\r ]"
30
+ GO_NOT_WS = r"[^\t\n\f\r ]"
31
+ # unicode.IsSpace, which strings.TrimSpace and strings.Fields use.
32
+ GO_SPACE = "\t\n\v\f\r \u0085  " + "".join(chr(c) for c in range(0x2000, 0x200B)) + "

   "
33
+ GO_SPACE_CLASS = "\t\n\v\f\r \u0085   - 

   "
34
+
35
+
36
+ def go_re(source: str, flags: int = 0) -> "re.Pattern[str]":
37
+ """Compiles a Go pattern: ``\\d`` (outside character classes) as ASCII
38
+ digits and ``\\b`` as Go's ASCII word boundary."""
39
+ return re.compile(source.replace(r"\d", "[0-9]").replace(r"\b", GO_B), flags)
40
+
41
+
42
+ # Ordered as in Go: more specific patterns first, or a UUID becomes a string
43
+ # of <num> fragments.
44
+ TEMPLATE_RULES = [
45
+ (go_re(r"\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b", re.IGNORECASE), "<uuid>"),
46
+ (go_re(r"\b\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?"), "<ts>"),
47
+ (go_re(r"\b\d{4}-\d{2}-\d{2}\b"), "<date>"),
48
+ (go_re(r"\b[^\t\n\f\r <>@]+@[^\t\n\f\r <>@]+\.[A-Za-z]{2,}\b"), "<email>"),
49
+ (go_re(r"\b[a-z][a-z0-9+.-]*://" + GO_NOT_WS + "+"), "<url>"),
50
+ (go_re(r"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b"), "<ip>"),
51
+ (go_re(r"\b(?:0x)?[0-9a-fA-F]{8,}\b"), "<hex>"),
52
+ (re.compile(r'"[^"]*"'), "<str>"),
53
+ (re.compile(r"'[^']*'"), "<str>"),
54
+ (go_re(r"\b\d+(?:\.\d+)?" + GO_WS + r"?(?:ms|s|us|ns|µs)\b"), "<dur>"),
55
+ (go_re(r"\b\d+(?:\.\d+)?\b"), "<num>"),
56
+ ]
57
+
58
+ WHITESPACE = re.compile(GO_WS + "+")
59
+ MAX_FRAMES = 5
60
+ KEEP_SEGMENTS = 3
61
+
62
+ DEPENDENCY_FRAGMENTS = (
63
+ "/node_modules/", "/vendor/", "/gems/", "/ruby/gems/", "/usr/local/go/src/",
64
+ "/go/pkg/mod/", "/.bundle/", "<anonymous>",
65
+ )
66
+
67
+ ASSET_DIGEST = re.compile(r"(.+)[-.]([A-Za-z0-9_]{8,})((?:\.chunk|\.bundle)?\.(?:js|mjs|cjs|css)(?:\.map)?)\Z")
68
+ _SURROGATE = re.compile("[\ud800-\udfff]")
69
+ _LOWER_HEX = re.compile(r"[0-9a-f]*\Z")
70
+ _DIGIT = re.compile("[0-9]")
71
+ _UPPER = re.compile("[A-Z]")
72
+ _QUERY = re.compile(r"[?#]")
73
+
74
+
75
+ def utf8(text: Any) -> str:
76
+ """Go treats invalid UTF-8 as U+FFFD. A Python str cannot hold invalid
77
+ UTF-8, but it can hold lone surrogates (surrogateescape'd bytes from a
78
+ path or an environment variable), which cannot be encoded: each becomes
79
+ U+FFFD, as Go would have read the byte."""
80
+ if isinstance(text, bytes):
81
+ return text.decode("utf-8", "replace")
82
+ if not isinstance(text, str):
83
+ text = "" if text is None else str(text)
84
+ if _SURROGATE.search(text):
85
+ return _SURROGATE.sub("�", text)
86
+ return text
87
+
88
+
89
+ def trim(text: str) -> str:
90
+ return text.strip(GO_SPACE)
91
+
92
+
93
+ def compute(kind: str, source: str, service: str = "", type: str = "", message: str = "",
94
+ frames: Iterable[Mapping[str, Any]] | None = None) -> str | None:
95
+ """The fingerprint (64 hex chars) of a signal, or None when the signal has
96
+ no kind/source, or no type, message or frames (Go returns an error)."""
97
+ frames = list(frames or [])
98
+ if not kind or not source:
99
+ return None
100
+ if not type and not message and not frames:
101
+ return None
102
+ norm = normalize(kind, source, service, type, message, frames)
103
+ return hashlib.sha256(canonical(norm).encode("utf-8")).hexdigest()
104
+
105
+
106
+ def normalize(kind: str, source: str, service: str, type: str, message: str,
107
+ frames: Iterable[Mapping[str, Any]] | None) -> dict:
108
+ """The canonical form, field by field, as Go's Normalized."""
109
+ return {
110
+ "kind": utf8(kind),
111
+ "source": utf8(source),
112
+ "service": utf8(service),
113
+ "type": trim(utf8(type)),
114
+ "template": template(message),
115
+ "frames": _normalize_frames(frames or []),
116
+ }
117
+
118
+
119
+ def template(message: Any) -> str:
120
+ """Reduces a message to its shape, replacing values that vary between
121
+ occurrences with holes."""
122
+ out = trim(utf8(message))
123
+ if not out:
124
+ return ""
125
+ for pattern, hole in TEMPLATE_RULES:
126
+ out = pattern.sub(hole, out)
127
+ return trim(WHITESPACE.sub(" ", out))
128
+
129
+
130
+ def normalize_path(path: Any) -> str:
131
+ p = trim(utf8(path))
132
+ if not p:
133
+ return ""
134
+ i = p.find("://")
135
+ if i >= 0:
136
+ rest = p[i + 3:]
137
+ j = rest.find("/")
138
+ p = rest[j:] if j >= 0 else rest
139
+ m = _QUERY.search(p)
140
+ if m:
141
+ p = p[: m.start()]
142
+ p = p.replace("\\", "/")
143
+ segments = p.strip("/").split("/")
144
+ segments = segments[-KEEP_SEGMENTS:]
145
+ segments[-1] = _strip_asset_digest(segments[-1])
146
+ return "/".join(segments)
147
+
148
+
149
+ def canonical(norm: Mapping[str, Any]) -> str:
150
+ return "\x1e".join([
151
+ "v1", norm["kind"], norm["source"], norm["service"], norm["type"], norm["template"],
152
+ "\x1f".join(norm["frames"]),
153
+ ])
154
+
155
+
156
+ def _normalize_frames(frames: Iterable[Mapping[str, Any]]) -> list:
157
+ out = []
158
+ for f in frames:
159
+ function = _field(f, "function")
160
+ file = _field(f, "file")
161
+ if _dependency(file, function):
162
+ continue
163
+ fn = trim(function)
164
+ path = normalize_path(file)
165
+ if not fn and not path:
166
+ continue
167
+ # Deliberately no line number.
168
+ out.append(fn + "@" + path)
169
+ if len(out) == MAX_FRAMES:
170
+ break
171
+ return out
172
+
173
+
174
+ def _field(frame: Any, name: str) -> str:
175
+ if not isinstance(frame, Mapping):
176
+ return ""
177
+ value = frame.get(name)
178
+ return "" if value is None else utf8(value)
179
+
180
+
181
+ def _dependency(file: str, function: str) -> bool:
182
+ hay = file + " " + function
183
+ return any(frag in hay for frag in DEPENDENCY_FRAGMENTS)
184
+
185
+
186
+ def _strip_asset_digest(name: str) -> str:
187
+ m = ASSET_DIGEST.match(name)
188
+ if m is None or not _digest(m.group(2)):
189
+ return name
190
+ return m.group(1) + m.group(3)
191
+
192
+
193
+ def _digest(token: str) -> bool:
194
+ digit = _DIGIT.search(token) is not None
195
+ upper = _UPPER.search(token) is not None
196
+ hex_only = _LOWER_HEX.match(token) is not None
197
+ if hex_only and digit and len(token) >= 8:
198
+ return True
199
+ return len(token) == 8 and digit and upper
devbench/flask.py ADDED
@@ -0,0 +1,54 @@
1
+ """Flask::
2
+
3
+ from flask import Flask
4
+ from devbench.flask import Devbench
5
+
6
+ app = Flask(__name__)
7
+ Devbench(app) # or devbench_ext = Devbench(); devbench_ext.init_app(app)
8
+
9
+ Wraps ``app.wsgi_app`` in the WSGI middleware (trace, identity scope,
10
+ ``x-adt-handled``) and subscribes to ``got_request_exception``, which Flask
11
+ sends for an unhandled exception before it answers 500 (an ``abort(404)`` or
12
+ any other HTTPException is a response, not a failure, and never reaches it).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Any, Optional
18
+
19
+ from flask import got_request_exception, request
20
+
21
+ from . import _integration
22
+ from .wsgi import DevbenchMiddleware
23
+
24
+
25
+ class Devbench:
26
+ def __init__(self, app: Any = None) -> None:
27
+ if app is not None:
28
+ self.init_app(app)
29
+
30
+ def init_app(self, app: Any) -> None:
31
+ extensions = getattr(app, "extensions", None)
32
+ if isinstance(extensions, dict):
33
+ if "devbench" in extensions:
34
+ return
35
+ extensions["devbench"] = self
36
+ _integration.setup(service=getattr(app, "import_name", None))
37
+ app.wsgi_app = DevbenchMiddleware(app.wsgi_app)
38
+ got_request_exception.connect(_on_exception, app, weak=False)
39
+
40
+
41
+ def _on_exception(sender: Any, exception: Optional[BaseException] = None, **_: Any) -> None:
42
+ try:
43
+ _integration.report_request_error(exception, _symbol(sender))
44
+ except Exception:
45
+ pass
46
+
47
+
48
+ def _symbol(app: Any) -> Optional[str]:
49
+ try:
50
+ endpoint = request.endpoint
51
+ view = app.view_functions.get(endpoint) if endpoint else None
52
+ return _integration.callable_path(view) if view is not None else endpoint
53
+ except Exception:
54
+ return None
devbench/identity.py ADDED
@@ -0,0 +1,50 @@
1
+ """Who was affected (docs/SERVER_SDK_SPEC.md, Capability 5).
2
+
3
+ Identity travels in its own field and nowhere else: never in a message, a
4
+ symbol or a log line, so it can never move a fingerprint.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any, Optional
10
+
11
+ from . import context
12
+ from .fingerprint import utf8
13
+
14
+ MAX = 255
15
+
16
+
17
+ def normalize(email: Any, account: Any) -> Optional[dict]:
18
+ """``{"email", "account"}`` with empty fields dropped (email trimmed and
19
+ lowercased, each at most 255 characters), or None when neither is
20
+ present."""
21
+ user = {}
22
+ e = _text(email).strip().lower()
23
+ if e:
24
+ user["email"] = e[:MAX]
25
+ a = _text(account).strip()
26
+ if a:
27
+ user["account"] = a[:MAX]
28
+ return user or None
29
+
30
+
31
+ def set_user(email: Any = None, account: Any = None) -> None:
32
+ """Records who the current request (or job) is for. Call once per
33
+ request, e.g. from middleware after authentication; the Dev Bench
34
+ middleware ends it with the request. Each field is optional; calling
35
+ again replaces the whole identity, and calling with neither clears it.
36
+ Never raises."""
37
+ try:
38
+ context.ensure().user = normalize(email, account)
39
+ except Exception:
40
+ pass
41
+
42
+
43
+ def _text(value: Any) -> str:
44
+ # An identity that cannot be read is dropped rather than raised.
45
+ if value is None:
46
+ return ""
47
+ try:
48
+ return utf8(value)
49
+ except Exception:
50
+ return ""