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.
- devbench/__init__.py +121 -0
- devbench/__main__.py +24 -0
- devbench/_core.py +252 -0
- devbench/_integration.py +107 -0
- devbench/asgi.py +82 -0
- devbench/celery.py +152 -0
- devbench/config.py +189 -0
- devbench/context.py +94 -0
- devbench/direct.py +639 -0
- devbench/django/__init__.py +12 -0
- devbench/django/apps.py +68 -0
- devbench/django/management/__init__.py +0 -0
- devbench/django/management/commands/__init__.py +0 -0
- devbench/django/management/commands/devbench_test.py +18 -0
- devbench/django/middleware.py +87 -0
- devbench/egress_policy.py +101 -0
- devbench/fastapi.py +14 -0
- devbench/fingerprint.py +199 -0
- devbench/flask.py +54 -0
- devbench/identity.py +50 -0
- devbench/logs.py +282 -0
- devbench/py.typed +0 -0
- devbench/reporter.py +233 -0
- devbench/scrub.py +247 -0
- devbench/trace.py +82 -0
- devbench/version.py +12 -0
- devbench/wsgi.py +67 -0
- devbench-0.1.0.dist-info/METADATA +259 -0
- devbench-0.1.0.dist-info/RECORD +32 -0
- devbench-0.1.0.dist-info/WHEEL +5 -0
- devbench-0.1.0.dist-info/licenses/LICENSE +21 -0
- devbench-0.1.0.dist-info/top_level.txt +1 -0
devbench/celery.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Celery: failed tasks, and the trace from the request that enqueued them::
|
|
2
|
+
|
|
3
|
+
# where your Celery app is created (tasks.py, celery.py, ...)
|
|
4
|
+
import devbench.celery
|
|
5
|
+
devbench.celery.install()
|
|
6
|
+
|
|
7
|
+
Under Django this is automatic (``"devbench.django"`` installs it when
|
|
8
|
+
Celery is loaded). Signals only: nothing wraps your tasks.
|
|
9
|
+
|
|
10
|
+
* ``before_task_publish``: the active trace travels in the task message's
|
|
11
|
+
headers, so a task's failure and its log lines join the request that
|
|
12
|
+
enqueued it.
|
|
13
|
+
* ``task_prerun`` / ``task_postrun``: the task runs in its own scope (its
|
|
14
|
+
trace; identity set inside it ends with it).
|
|
15
|
+
* ``task_failure``: reported as a ``job`` exception, symbol = the task name.
|
|
16
|
+
A retry (``self.retry``) is not a failure; the last attempt's failure is.
|
|
17
|
+
* ``after_setup_logger`` / ``after_setup_task_logger``: log capture is put
|
|
18
|
+
back after the worker replaces the root logger's handlers.
|
|
19
|
+
* ``worker_process_shutdown``: a prefork child flushes before it exits (the
|
|
20
|
+
pool ends children without running atexit).
|
|
21
|
+
|
|
22
|
+
A worker process reports as "<service>-celery" unless DEVBENCH_SERVICE is
|
|
23
|
+
set, so an app's web and worker processes are told apart.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import logging
|
|
29
|
+
import threading
|
|
30
|
+
from typing import Any, Dict, Optional
|
|
31
|
+
|
|
32
|
+
from celery import signals
|
|
33
|
+
|
|
34
|
+
from . import _core, _integration, context, logs, reporter
|
|
35
|
+
from .trace import Trace
|
|
36
|
+
|
|
37
|
+
# The task-message header the trace travels in.
|
|
38
|
+
HEADER = "adt_trace"
|
|
39
|
+
WORKER_SUFFIX = "celery"
|
|
40
|
+
|
|
41
|
+
_installed = False
|
|
42
|
+
_lock = threading.Lock()
|
|
43
|
+
_tokens: Dict[str, Any] = {}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def install() -> bool:
|
|
47
|
+
"""Connects the hooks. Safe to call more than once. Returns True when
|
|
48
|
+
installed."""
|
|
49
|
+
global _installed
|
|
50
|
+
with _lock:
|
|
51
|
+
if _installed:
|
|
52
|
+
return True
|
|
53
|
+
try:
|
|
54
|
+
signals.before_task_publish.connect(_before_publish, weak=False, dispatch_uid="devbench.publish")
|
|
55
|
+
signals.task_prerun.connect(_prerun, weak=False, dispatch_uid="devbench.prerun")
|
|
56
|
+
signals.task_postrun.connect(_postrun, weak=False, dispatch_uid="devbench.postrun")
|
|
57
|
+
signals.task_failure.connect(_failure, weak=False, dispatch_uid="devbench.failure")
|
|
58
|
+
signals.worker_init.connect(_worker_init, weak=False, dispatch_uid="devbench.worker_init")
|
|
59
|
+
signals.worker_process_shutdown.connect(_worker_shutdown, weak=False, dispatch_uid="devbench.shutdown")
|
|
60
|
+
signals.after_setup_logger.connect(_after_logging, weak=False, dispatch_uid="devbench.logger")
|
|
61
|
+
signals.after_setup_task_logger.connect(_after_logging, weak=False, dispatch_uid="devbench.task_logger")
|
|
62
|
+
_installed = True
|
|
63
|
+
except Exception:
|
|
64
|
+
return False
|
|
65
|
+
if _in_worker():
|
|
66
|
+
_mark_worker()
|
|
67
|
+
_integration.setup()
|
|
68
|
+
return True
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _in_worker() -> bool:
|
|
72
|
+
return _integration.celery_worker_command()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _mark_worker() -> None:
|
|
76
|
+
try:
|
|
77
|
+
cfg = _core.config()
|
|
78
|
+
if not cfg.worker:
|
|
79
|
+
cfg.worker = WORKER_SUFFIX
|
|
80
|
+
except Exception:
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _worker_init(**_: Any) -> None:
|
|
85
|
+
_mark_worker()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _after_logging(logger: Any = None, **_: Any) -> None:
|
|
89
|
+
"""A worker replaces the root logger's handlers when it sets up logging
|
|
90
|
+
(worker_hijack_root_logger), and its task logger does not propagate:
|
|
91
|
+
capture goes back on both."""
|
|
92
|
+
try:
|
|
93
|
+
if _core.capture_logs() and isinstance(logger, logging.Logger):
|
|
94
|
+
logs.install(logger)
|
|
95
|
+
except Exception:
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _before_publish(headers: Optional[dict] = None, **_: Any) -> None:
|
|
100
|
+
try:
|
|
101
|
+
trace = context.current_trace()
|
|
102
|
+
if trace is not None and isinstance(headers, dict) and HEADER not in headers:
|
|
103
|
+
headers[HEADER] = str(trace)
|
|
104
|
+
except Exception:
|
|
105
|
+
pass
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _prerun(task_id: Optional[str] = None, task: Any = None, **_: Any) -> None:
|
|
109
|
+
try:
|
|
110
|
+
trace = Trace.parse(_header(task))
|
|
111
|
+
_scope, token = context.enter(trace)
|
|
112
|
+
if task_id:
|
|
113
|
+
_tokens[task_id] = token
|
|
114
|
+
except Exception:
|
|
115
|
+
pass
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _postrun(task_id: Optional[str] = None, **_: Any) -> None:
|
|
119
|
+
try:
|
|
120
|
+
token = _tokens.pop(task_id, None) if task_id else None
|
|
121
|
+
if token is not None:
|
|
122
|
+
context.leave(token)
|
|
123
|
+
except Exception:
|
|
124
|
+
pass
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _failure(sender: Any = None, exception: Optional[BaseException] = None, **_: Any) -> None:
|
|
128
|
+
try:
|
|
129
|
+
reporter.capture(exception, "job", False, _task_name(sender))
|
|
130
|
+
except Exception:
|
|
131
|
+
pass
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _worker_shutdown(**_: Any) -> None:
|
|
135
|
+
_core.flush(timeout=2.0)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _header(task: Any) -> Optional[str]:
|
|
139
|
+
request = getattr(task, "request", None)
|
|
140
|
+
if request is None:
|
|
141
|
+
return None
|
|
142
|
+
value = getattr(request, HEADER, None)
|
|
143
|
+
if value is None:
|
|
144
|
+
headers = getattr(request, "headers", None)
|
|
145
|
+
if isinstance(headers, dict):
|
|
146
|
+
value = headers.get(HEADER)
|
|
147
|
+
return value if isinstance(value, str) else None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _task_name(task: Any) -> Optional[str]:
|
|
151
|
+
name = getattr(task, "name", None)
|
|
152
|
+
return name if isinstance(name, str) and name else None
|
devbench/config.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Where reports go, parsed from DEVBENCH_DSN, and everything else read at
|
|
2
|
+
start (docs/SERVER_SDK_SPEC.md, "Dev Bench naming and configuration";
|
|
3
|
+
DECISIONS #161)."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
from typing import Mapping, Optional
|
|
10
|
+
from urllib.parse import quote, unquote, urlsplit
|
|
11
|
+
|
|
12
|
+
SHAPE = "https://<public>:<secret>@<host>"
|
|
13
|
+
_LOOPBACK = re.compile(r"\A(?:localhost|127(?:\.[0-9]{1,3}){3}|::1|\[::1\])\Z|\.localhost\Z", re.IGNORECASE)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class InvalidDSN(ValueError):
|
|
17
|
+
"""A DSN that does not parse. The message says what to fix and never
|
|
18
|
+
contains either key."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DSN:
|
|
22
|
+
"""``https://<public>:<secret>@<host>[:port]`` from ``adt dsn create``, or
|
|
23
|
+
a single-key ``https://<key>@<host>``.
|
|
24
|
+
|
|
25
|
+
``scheme://host[:port]`` is the ingest base. The secret (a server key) is
|
|
26
|
+
what this process authenticates with; the public part (a browser key) is
|
|
27
|
+
only ever handed to a page, via ``browser_dsn``. A path or query, if
|
|
28
|
+
present, is ignored: the keys alone identify tenant, environment and
|
|
29
|
+
source.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
__slots__ = ("base", "key", "public_key")
|
|
33
|
+
|
|
34
|
+
def __init__(self, base: str, key: str, public_key: Optional[str] = None) -> None:
|
|
35
|
+
self.base = base
|
|
36
|
+
self.key = key
|
|
37
|
+
self.public_key = public_key
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def flush_url(self) -> str:
|
|
41
|
+
return self.base + "/v1/flush"
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def check_url(self) -> str:
|
|
45
|
+
"""The self-test endpoint: validates the key, stores nothing."""
|
|
46
|
+
return self.base + "/v1/check"
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def logs_url(self) -> str:
|
|
50
|
+
return self.base + "/v1/logs"
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def browser_dsn(self) -> Optional[str]:
|
|
54
|
+
"""The DSN a browser may hold: https://<public>@host, or None when
|
|
55
|
+
there is no public part."""
|
|
56
|
+
if self.public_key is None:
|
|
57
|
+
return None
|
|
58
|
+
scheme, rest = self.base.split("://", 1)
|
|
59
|
+
return "%s://%s@%s" % (scheme, quote(self.public_key, safe=""), rest)
|
|
60
|
+
|
|
61
|
+
def __str__(self) -> str:
|
|
62
|
+
# The DSN without its keys. The secret must never be printed.
|
|
63
|
+
return self.base
|
|
64
|
+
|
|
65
|
+
__repr__ = __str__
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def parse(cls, raw: object) -> "DSN":
|
|
69
|
+
text = ("" if raw is None else str(raw)).strip()
|
|
70
|
+
if not text:
|
|
71
|
+
raise InvalidDSN("is empty")
|
|
72
|
+
try:
|
|
73
|
+
parts = urlsplit(text)
|
|
74
|
+
port = parts.port
|
|
75
|
+
except ValueError:
|
|
76
|
+
raise InvalidDSN("is not a URL (expected %s)" % SHAPE) from None
|
|
77
|
+
|
|
78
|
+
scheme = (parts.scheme or "").lower()
|
|
79
|
+
if scheme not in ("http", "https"):
|
|
80
|
+
raise InvalidDSN("must start with https:// (expected %s)" % SHAPE)
|
|
81
|
+
host = parts.hostname or ""
|
|
82
|
+
if not host:
|
|
83
|
+
raise InvalidDSN("has no host (expected %s)" % SHAPE)
|
|
84
|
+
|
|
85
|
+
user = unquote(parts.username or "").strip()
|
|
86
|
+
secret = unquote(parts.password or "").strip()
|
|
87
|
+
if not user and not secret:
|
|
88
|
+
raise InvalidDSN("has no key (expected %s)" % SHAPE)
|
|
89
|
+
|
|
90
|
+
# The secret is a real secret on a server. Over plain http it would
|
|
91
|
+
# cross the network readable by anything on the path, so http is
|
|
92
|
+
# accepted only for a loopback ingest (local development and tests).
|
|
93
|
+
if scheme == "http" and not _LOOPBACK.search(host):
|
|
94
|
+
raise InvalidDSN("uses http:// for %s: the key would cross the network in plaintext; use https://" % host)
|
|
95
|
+
|
|
96
|
+
default_port = 443 if scheme == "https" else 80
|
|
97
|
+
port_text = ":%d" % port if port is not None and port != default_port else ""
|
|
98
|
+
if ":" in host and not host.startswith("["):
|
|
99
|
+
host = "[%s]" % host
|
|
100
|
+
base = "%s://%s%s" % (scheme, host, port_text)
|
|
101
|
+
if not secret:
|
|
102
|
+
return cls(base, user)
|
|
103
|
+
return cls(base, secret, user or None)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# Tried in order after the REVISION file when no release is configured. Common
|
|
107
|
+
# deploy tools and platforms only; never an app's own variable. The same order
|
|
108
|
+
# as the Ruby and Go SDKs (#23).
|
|
109
|
+
RELEASE_FALLBACKS = ("HEROKU_SLUG_COMMIT", "KAMAL_VERSION", "GITHUB_SHA", "GIT_SHA", "SOURCE_VERSION", "RENDER_GIT_COMMIT")
|
|
110
|
+
# Capistrano-style deploys write the deployed commit here, in the app root.
|
|
111
|
+
REVISION_FILE = "REVISION"
|
|
112
|
+
# A commit or tag, not a document: read no more than this.
|
|
113
|
+
REVISION_MAX_BYTES = 255
|
|
114
|
+
_OFF = ("false", "0", "no", "off")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _presence(value: object) -> Optional[str]:
|
|
118
|
+
if value is None:
|
|
119
|
+
return None
|
|
120
|
+
text = str(value).strip()
|
|
121
|
+
return text or None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class Configuration:
|
|
125
|
+
"""Everything Dev Bench reads at start. From the environment by default;
|
|
126
|
+
``devbench.configure(...)`` sets the same fields from code.
|
|
127
|
+
|
|
128
|
+
* ``DEVBENCH_DSN``: where to send (fallback ``ADT_DSN``). Unset: nothing
|
|
129
|
+
is sent (this SDK has no sidecar mode).
|
|
130
|
+
* ``DEVBENCH_SERVICE``: this app's name. Default: what the framework
|
|
131
|
+
knows (the Django project, the Flask app's import name), else "app";
|
|
132
|
+
with "-celery" appended in a Celery worker.
|
|
133
|
+
* ``DEVBENCH_RELEASE``: the deployed version. Default, first found: a
|
|
134
|
+
REVISION file in the app root, then RELEASE_FALLBACKS, else "".
|
|
135
|
+
* ``DEVBENCH_ENABLED``: "false" turns everything off.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
def __init__(self, env: Optional[Mapping[str, str]] = None) -> None:
|
|
139
|
+
env = os.environ if env is None else env
|
|
140
|
+
self.dsn: Optional[str] = _presence(env.get("DEVBENCH_DSN")) or _presence(env.get("ADT_DSN"))
|
|
141
|
+
self.service: Optional[str] = _presence(env.get("DEVBENCH_SERVICE"))
|
|
142
|
+
self.release: Optional[str] = _presence(env.get("DEVBENCH_RELEASE"))
|
|
143
|
+
self.enabled: bool = (env.get("DEVBENCH_ENABLED") or "").strip().lower() not in _OFF
|
|
144
|
+
# Seconds between background flushes (spec: 60).
|
|
145
|
+
self.flush_interval: float = 60.0
|
|
146
|
+
self._release_env = next((v for v in (_presence(env.get(n)) for n in RELEASE_FALLBACKS) if v), None)
|
|
147
|
+
# Set by the integrations, never by the app: the framework's own name
|
|
148
|
+
# for the app, the app root (REVISION, relative frame paths), and
|
|
149
|
+
# whether this process is a Celery worker.
|
|
150
|
+
self.framework_service: Optional[str] = None
|
|
151
|
+
self.app_root: Optional[str] = None
|
|
152
|
+
self.worker: Optional[str] = None
|
|
153
|
+
self._detected_release: Optional[str] = None
|
|
154
|
+
|
|
155
|
+
def resolved_service(self) -> str:
|
|
156
|
+
"""The service name sent with every flush and folded into every
|
|
157
|
+
fingerprint. An explicit service always wins; a worker process
|
|
158
|
+
defaults to "<app>-<worker>", so an app's web and job processes are
|
|
159
|
+
told apart with no DEVBENCH_SERVICE at all."""
|
|
160
|
+
explicit = _presence(self.service)
|
|
161
|
+
if explicit:
|
|
162
|
+
return explicit
|
|
163
|
+
base = _presence(self.framework_service) or "app"
|
|
164
|
+
return "%s-%s" % (base, self.worker) if self.worker else base
|
|
165
|
+
|
|
166
|
+
def resolved_root(self) -> str:
|
|
167
|
+
return _presence(self.app_root) or os.getcwd()
|
|
168
|
+
|
|
169
|
+
def resolved_release(self) -> str:
|
|
170
|
+
"""Explicit (configure or DEVBENCH_RELEASE) wins; else the REVISION
|
|
171
|
+
file in the app root, read once; else RELEASE_FALLBACKS; else ""."""
|
|
172
|
+
explicit = _presence(self.release)
|
|
173
|
+
if explicit:
|
|
174
|
+
return explicit
|
|
175
|
+
if self._detected_release is None:
|
|
176
|
+
self._detected_release = self._revision_file() or self._release_env or ""
|
|
177
|
+
return self._detected_release
|
|
178
|
+
|
|
179
|
+
def _revision_file(self) -> Optional[str]:
|
|
180
|
+
try:
|
|
181
|
+
path = os.path.join(self.resolved_root(), REVISION_FILE)
|
|
182
|
+
if not os.path.isfile(path):
|
|
183
|
+
return None
|
|
184
|
+
with open(path, "rb") as f:
|
|
185
|
+
head = f.read(REVISION_MAX_BYTES)
|
|
186
|
+
lines = head.decode("utf-8", "replace").splitlines()
|
|
187
|
+
return _presence(lines[0]) if lines else None
|
|
188
|
+
except Exception:
|
|
189
|
+
return None
|
devbench/context.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Per-request (and per-job) state: the active trace, who the request is for,
|
|
2
|
+
and how many failures were handled during it.
|
|
3
|
+
|
|
4
|
+
One mutable ``Scope`` object per request, held in a ContextVar. Mutable on
|
|
5
|
+
purpose: frameworks run parts of a request in a *copy* of the context (a
|
|
6
|
+
FastAPI/Starlette sync endpoint runs in a thread pool; asgiref's
|
|
7
|
+
sync_to_async copies too), and a ``ContextVar.set`` made in a copy never
|
|
8
|
+
reaches the middleware that reports the request's failure. Setting a field on
|
|
9
|
+
the shared Scope does.
|
|
10
|
+
|
|
11
|
+
Scopes nest and restore rather than clear: servers reuse threads, and
|
|
12
|
+
clearing would leak one request's absence into the next one's presence;
|
|
13
|
+
inheriting would attribute one user's failure to whoever this thread served
|
|
14
|
+
before.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import contextlib
|
|
20
|
+
import contextvars
|
|
21
|
+
from typing import Iterator, Optional
|
|
22
|
+
|
|
23
|
+
from .trace import Trace
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Scope:
|
|
27
|
+
__slots__ = ("trace", "user", "handled", "__weakref__")
|
|
28
|
+
|
|
29
|
+
def __init__(self, trace: Optional[Trace] = None) -> None:
|
|
30
|
+
self.trace = trace
|
|
31
|
+
# A frozen dict {"email": ..., "account": ...} (empty fields dropped)
|
|
32
|
+
# or None. Normalised before it gets here (identity.normalize).
|
|
33
|
+
self.user: Optional[dict] = None
|
|
34
|
+
# Failures handled during this request: the middleware turns a
|
|
35
|
+
# non-zero count into the x-adt-handled response header.
|
|
36
|
+
self.handled = 0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
_SCOPE: "contextvars.ContextVar[Optional[Scope]]" = contextvars.ContextVar("devbench_scope", default=None)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def current() -> Optional[Scope]:
|
|
43
|
+
return _SCOPE.get()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def current_trace() -> Optional[Trace]:
|
|
47
|
+
scope = _SCOPE.get()
|
|
48
|
+
return scope.trace if scope is not None else None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def current_user() -> Optional[dict]:
|
|
52
|
+
scope = _SCOPE.get()
|
|
53
|
+
return scope.user if scope is not None else None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def ensure() -> Scope:
|
|
57
|
+
"""The current scope, creating one (outside any request: a script, a
|
|
58
|
+
shell) when there is none."""
|
|
59
|
+
scope = _SCOPE.get()
|
|
60
|
+
if scope is None:
|
|
61
|
+
scope = Scope()
|
|
62
|
+
_SCOPE.set(scope)
|
|
63
|
+
return scope
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@contextlib.contextmanager
|
|
67
|
+
def scoped(trace: Optional[Trace]) -> Iterator[Scope]:
|
|
68
|
+
"""Runs the block with a fresh scope for ``trace`` (which may be None),
|
|
69
|
+
restoring whatever was there before."""
|
|
70
|
+
scope = Scope(trace)
|
|
71
|
+
token = _SCOPE.set(scope)
|
|
72
|
+
try:
|
|
73
|
+
yield scope
|
|
74
|
+
finally:
|
|
75
|
+
try:
|
|
76
|
+
_SCOPE.reset(token)
|
|
77
|
+
except ValueError:
|
|
78
|
+
# Reset from another context (a generator finalised elsewhere):
|
|
79
|
+
# fall back to clearing, never raising into the app.
|
|
80
|
+
_SCOPE.set(None)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def enter(trace: Optional[Trace]) -> "tuple[Scope, contextvars.Token]":
|
|
84
|
+
"""For hooks whose start and end are separate calls (Celery's prerun and
|
|
85
|
+
postrun): returns the new scope and the token ``leave`` needs."""
|
|
86
|
+
scope = Scope(trace)
|
|
87
|
+
return scope, _SCOPE.set(scope)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def leave(token: "contextvars.Token") -> None:
|
|
91
|
+
try:
|
|
92
|
+
_SCOPE.reset(token)
|
|
93
|
+
except (ValueError, RuntimeError):
|
|
94
|
+
_SCOPE.set(None)
|