snake-orm 0.1.0b1__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.
Files changed (198) hide show
  1. snake_orm-0.1.0b1.dist-info/METADATA +366 -0
  2. snake_orm-0.1.0b1.dist-info/RECORD +198 -0
  3. snake_orm-0.1.0b1.dist-info/WHEEL +4 -0
  4. snake_orm-0.1.0b1.dist-info/entry_points.txt +2 -0
  5. snake_orm-0.1.0b1.dist-info/licenses/LICENSE +21 -0
  6. snakeorm/__init__.py +214 -0
  7. snakeorm/advisor.py +109 -0
  8. snakeorm/cli/__init__.py +3 -0
  9. snakeorm/cli/app.py +992 -0
  10. snakeorm/cli/discovery.py +179 -0
  11. snakeorm/cli/hooks.py +86 -0
  12. snakeorm/compiler/__init__.py +3 -0
  13. snakeorm/compiler/compiler.py +330 -0
  14. snakeorm/connection.py +228 -0
  15. snakeorm/contrib/__init__.py +17 -0
  16. snakeorm/contrib/asgi.py +192 -0
  17. snakeorm/contrib/config.py +264 -0
  18. snakeorm/contrib/deliver.py +256 -0
  19. snakeorm/contrib/django.py +245 -0
  20. snakeorm/contrib/sidecar.py +44 -0
  21. snakeorm/contrib/wsgi.py +176 -0
  22. snakeorm/core/__init__.py +1 -0
  23. snakeorm/core/config.py +173 -0
  24. snakeorm/core/converters.py +172 -0
  25. snakeorm/core/exceptions.py +175 -0
  26. snakeorm/core/placement.py +15 -0
  27. snakeorm/core/sentinels.py +37 -0
  28. snakeorm/core/signals.py +114 -0
  29. snakeorm/debug/__init__.py +42 -0
  30. snakeorm/debug/assets/css/panel.css +616 -0
  31. snakeorm/debug/assets/css/snake_orm_app.css +293 -0
  32. snakeorm/debug/assets/img/icon.svg +5 -0
  33. snakeorm/debug/assets/js/history.js +425 -0
  34. snakeorm/debug/assets/js/language.js +283 -0
  35. snakeorm/debug/assets/js/panel.js +241 -0
  36. snakeorm/debug/assets/js/snake_orm_app.js +82 -0
  37. snakeorm/debug/assets/pages/cli.html +28 -0
  38. snakeorm/debug/assets/pages/config.html +34 -0
  39. snakeorm/debug/assets/pages/database_first.html +29 -0
  40. snakeorm/debug/assets/pages/help.html +21 -0
  41. snakeorm/debug/assets/pages/history.html +27 -0
  42. snakeorm/debug/capture.py +234 -0
  43. snakeorm/debug/channel.py +131 -0
  44. snakeorm/debug/collector.py +144 -0
  45. snakeorm/debug/config.py +149 -0
  46. snakeorm/debug/html.py +578 -0
  47. snakeorm/debug/matrix.py +60 -0
  48. snakeorm/debug/origin.py +42 -0
  49. snakeorm/debug/otel/__init__.py +68 -0
  50. snakeorm/debug/otel/context.py +43 -0
  51. snakeorm/debug/otel/exporter.py +277 -0
  52. snakeorm/debug/otel/payload.py +97 -0
  53. snakeorm/debug/otel/report.py +29 -0
  54. snakeorm/debug/otel/spans.py +393 -0
  55. snakeorm/debug/otel/summary.py +59 -0
  56. snakeorm/debug/record.py +54 -0
  57. snakeorm/debug/report.py +311 -0
  58. snakeorm/debug/testing.py +23 -0
  59. snakeorm/decorators/__init__.py +13 -0
  60. snakeorm/decorators/abstract.py +26 -0
  61. snakeorm/decorators/db_first.py +104 -0
  62. snakeorm/decorators/function.py +28 -0
  63. snakeorm/decorators/model.py +268 -0
  64. snakeorm/decorators/polymorphic.py +140 -0
  65. snakeorm/decorators/result.py +167 -0
  66. snakeorm/decorators/row.py +90 -0
  67. snakeorm/decorators/trigger.py +47 -0
  68. snakeorm/decorators/view.py +149 -0
  69. snakeorm/dialects/__init__.py +6 -0
  70. snakeorm/dialects/base.py +373 -0
  71. snakeorm/dialects/capabilities.py +571 -0
  72. snakeorm/dialects/literals.py +46 -0
  73. snakeorm/dialects/matrix.py +384 -0
  74. snakeorm/dialects/mysql.py +519 -0
  75. snakeorm/dialects/postgres.py +504 -0
  76. snakeorm/dialects/sqlite.py +417 -0
  77. snakeorm/drivers/__init__.py +18 -0
  78. snakeorm/drivers/asyncbase.py +73 -0
  79. snakeorm/drivers/asyncdecorators.py +244 -0
  80. snakeorm/drivers/asyncpool.py +228 -0
  81. snakeorm/drivers/asyncpsycopg.py +124 -0
  82. snakeorm/drivers/asyncpymysql.py +50 -0
  83. snakeorm/drivers/asyncsqlite.py +30 -0
  84. snakeorm/drivers/base.py +76 -0
  85. snakeorm/drivers/failures.py +181 -0
  86. snakeorm/drivers/logging.py +167 -0
  87. snakeorm/drivers/pool.py +352 -0
  88. snakeorm/drivers/psycopg.py +188 -0
  89. snakeorm/drivers/pymysql.py +176 -0
  90. snakeorm/drivers/savepoints.py +46 -0
  91. snakeorm/drivers/sqlite.py +170 -0
  92. snakeorm/drivers/threaded.py +143 -0
  93. snakeorm/drivers/timeout.py +87 -0
  94. snakeorm/dto/__init__.py +55 -0
  95. snakeorm/dto/read.py +231 -0
  96. snakeorm/dto/region.py +326 -0
  97. snakeorm/dto/resolve.py +354 -0
  98. snakeorm/dto/spec.py +201 -0
  99. snakeorm/expressions/__init__.py +88 -0
  100. snakeorm/expressions/conditional.py +178 -0
  101. snakeorm/expressions/expression.py +810 -0
  102. snakeorm/expressions/functions.py +71 -0
  103. snakeorm/expressions/keys.py +299 -0
  104. snakeorm/expressions/paths.py +43 -0
  105. snakeorm/expressions/scalar.py +293 -0
  106. snakeorm/expressions/window.py +248 -0
  107. snakeorm/fields/__init__.py +68 -0
  108. snakeorm/fields/check.py +79 -0
  109. snakeorm/fields/column.py +316 -0
  110. snakeorm/fields/enum.py +64 -0
  111. snakeorm/fields/index.py +46 -0
  112. snakeorm/fields/relationship.py +738 -0
  113. snakeorm/fields/typed.py +736 -0
  114. snakeorm/helpers/__init__.py +1 -0
  115. snakeorm/helpers/annotations.py +21 -0
  116. snakeorm/helpers/inheritance.py +41 -0
  117. snakeorm/helpers/pyliteral.py +52 -0
  118. snakeorm/helpers/pytype.py +50 -0
  119. snakeorm/introspection/__init__.py +13 -0
  120. snakeorm/introspection/base.py +30 -0
  121. snakeorm/introspection/drift.py +78 -0
  122. snakeorm/introspection/models.py +633 -0
  123. snakeorm/introspection/mysql.py +367 -0
  124. snakeorm/introspection/postgres.py +397 -0
  125. snakeorm/introspection/sqlite.py +217 -0
  126. snakeorm/introspection/unsupported.py +94 -0
  127. snakeorm/linker/__init__.py +3 -0
  128. snakeorm/linker/linker.py +521 -0
  129. snakeorm/metadata/__init__.py +41 -0
  130. snakeorm/metadata/check.py +37 -0
  131. snakeorm/metadata/column.py +201 -0
  132. snakeorm/metadata/enum_storage.py +33 -0
  133. snakeorm/metadata/fk_action.py +19 -0
  134. snakeorm/metadata/foreign_key.py +26 -0
  135. snakeorm/metadata/index.py +43 -0
  136. snakeorm/metadata/index_method.py +19 -0
  137. snakeorm/metadata/int_size.py +20 -0
  138. snakeorm/metadata/json_storage.py +21 -0
  139. snakeorm/metadata/polymorphic.py +30 -0
  140. snakeorm/metadata/primary_key.py +23 -0
  141. snakeorm/metadata/relationship.py +60 -0
  142. snakeorm/metadata/relationship_kind.py +43 -0
  143. snakeorm/metadata/routine.py +26 -0
  144. snakeorm/metadata/server_default.py +20 -0
  145. snakeorm/metadata/table.py +97 -0
  146. snakeorm/metadata/table_kind.py +23 -0
  147. snakeorm/metadata/trigger.py +62 -0
  148. snakeorm/metadata/type_params.py +348 -0
  149. snakeorm/migration/__init__.py +88 -0
  150. snakeorm/migration/asyncrunner.py +168 -0
  151. snakeorm/migration/autodetect.py +96 -0
  152. snakeorm/migration/ddl.py +1172 -0
  153. snakeorm/migration/diff.py +701 -0
  154. snakeorm/migration/loader.py +77 -0
  155. snakeorm/migration/operations.py +952 -0
  156. snakeorm/migration/planner.py +78 -0
  157. snakeorm/migration/realize.py +420 -0
  158. snakeorm/migration/renames.py +153 -0
  159. snakeorm/migration/render.py +1095 -0
  160. snakeorm/migration/runner.py +314 -0
  161. snakeorm/migration/squash.py +60 -0
  162. snakeorm/migration/state.py +107 -0
  163. snakeorm/model.py +162 -0
  164. snakeorm/py.typed +0 -0
  165. snakeorm/query/__init__.py +9 -0
  166. snakeorm/query/compound.py +288 -0
  167. snakeorm/query/join_kind.py +19 -0
  168. snakeorm/query/joined.py +143 -0
  169. snakeorm/query/query.py +1001 -0
  170. snakeorm/query/recursive.py +244 -0
  171. snakeorm/registry/__init__.py +5 -0
  172. snakeorm/registry/by_module.py +163 -0
  173. snakeorm/registry/registry.py +166 -0
  174. snakeorm/session/__init__.py +8 -0
  175. snakeorm/session/asyncsession.py +638 -0
  176. snakeorm/session/coercion.py +306 -0
  177. snakeorm/session/factory.py +29 -0
  178. snakeorm/session/guards.py +269 -0
  179. snakeorm/session/isolation.py +23 -0
  180. snakeorm/session/mapper.py +213 -0
  181. snakeorm/session/planning.py +815 -0
  182. snakeorm/session/retry.py +96 -0
  183. snakeorm/session/session.py +871 -0
  184. snakeorm/session/shared.py +355 -0
  185. snakeorm/sql/__init__.py +18 -0
  186. snakeorm/sql/adapt.py +104 -0
  187. snakeorm/sql/aggregate.py +232 -0
  188. snakeorm/sql/condition.py +359 -0
  189. snakeorm/sql/delete.py +46 -0
  190. snakeorm/sql/insert.py +149 -0
  191. snakeorm/sql/joins.py +158 -0
  192. snakeorm/sql/pk_subquery.py +54 -0
  193. snakeorm/sql/refs.py +19 -0
  194. snakeorm/sql/resolver.py +28 -0
  195. snakeorm/sql/select.py +175 -0
  196. snakeorm/sql/update.py +82 -0
  197. snakeorm/sql/value.py +552 -0
  198. snakeorm/times.py +276 -0
@@ -0,0 +1,68 @@
1
+ """The `otel` channel: the debug report as OpenTelemetry spans, over OTLP/HTTP.
2
+
3
+ It is the one delivery meant for PRODUCTION, and the only one whose consumer is a tool rather than a
4
+ person. Its audience is `OPERATOR`: it goes out sideways to infrastructure the operator already
5
+ runs, instead of riding on the response. That is why it is NOT in `RISKY_CHANNELS`: putting it there
6
+ would drop it exactly where it justifies existing.
7
+
8
+ Riding on the response is NOT what makes a channel risky — `timing` rides it and is harmless. What
9
+ decides it is whether the requester ends up holding the SQL, which is what `ChannelAudience` asks.
10
+
11
+ What travels is the parametrised SQL and never the values. The convention collects `db.query.text`
12
+ by default "because parametrising is a strong signal from the user that anything sensitive is in
13
+ the values", and SnakeORM never interpolates. The values are opt-in, key by key, from code.
14
+
15
+ Four pieces:
16
+
17
+ - `context` — the only thing `opentelemetry-api` is for: is a span already open, and which.
18
+ - `summary` — `db.query.summary`, which NAMES the span (`SELECT orders`, not the whole SQL).
19
+ - `spans` — the pure mapping from a `DebugReport` to the hybrid shape.
20
+ - `payload` / `exporter` — OTLP's JSON and the background queue that posts it.
21
+ """
22
+
23
+ from snakeorm.debug.otel.context import TraceContext as TraceContext
24
+ from snakeorm.debug.otel.context import active_context as active_context
25
+ from snakeorm.debug.otel.exporter import DEFAULT_ENDPOINT as DEFAULT_ENDPOINT
26
+ from snakeorm.debug.otel.exporter import DEFAULT_SERVICE_NAME as DEFAULT_SERVICE_NAME
27
+ from snakeorm.debug.otel.exporter import ENDPOINT_ENV_KEY as ENDPOINT_ENV_KEY
28
+ from snakeorm.debug.otel.exporter import OtelExporter as OtelExporter
29
+ from snakeorm.debug.otel.exporter import SERVICE_NAME_ENV_KEY as SERVICE_NAME_ENV_KEY
30
+ from snakeorm.debug.otel.exporter import (
31
+ TRACES_ENDPOINT_ENV_KEY as TRACES_ENDPOINT_ENV_KEY,
32
+ )
33
+ from snakeorm.debug.otel.exporter import Transport as Transport
34
+ from snakeorm.debug.otel.exporter import current_exporter as current_exporter
35
+ from snakeorm.debug.otel.exporter import endpoint_from_env as endpoint_from_env
36
+ from snakeorm.debug.otel.exporter import post_json as post_json
37
+ from snakeorm.debug.otel.exporter import service_name_from_env as service_name_from_env
38
+ from snakeorm.debug.otel.exporter import set_exporter as set_exporter
39
+ from snakeorm.debug.otel.payload import SCOPE_NAME as SCOPE_NAME
40
+ from snakeorm.debug.otel.payload import encode_payload as encode_payload
41
+ from snakeorm.debug.otel.payload import otlp_payload as otlp_payload
42
+ from snakeorm.debug.otel.report import export_report as export_report
43
+ from snakeorm.debug.otel.spans import CODE_FILE_PATH as CODE_FILE_PATH
44
+ from snakeorm.debug.otel.spans import CODE_FUNCTION_NAME as CODE_FUNCTION_NAME
45
+ from snakeorm.debug.otel.spans import CODE_LINE_NUMBER as CODE_LINE_NUMBER
46
+ from snakeorm.debug.otel.spans import DB_COLLECTION_NAME as DB_COLLECTION_NAME
47
+ from snakeorm.debug.otel.spans import DB_NAMESPACE as DB_NAMESPACE
48
+ from snakeorm.debug.otel.spans import DB_OPERATION_NAME as DB_OPERATION_NAME
49
+ from snakeorm.debug.otel.spans import (
50
+ DB_QUERY_PARAMETER_PREFIX as DB_QUERY_PARAMETER_PREFIX,
51
+ )
52
+ from snakeorm.debug.otel.spans import DB_QUERY_SUMMARY as DB_QUERY_SUMMARY
53
+ from snakeorm.debug.otel.spans import DB_QUERY_TEXT as DB_QUERY_TEXT
54
+ from snakeorm.debug.otel.spans import (
55
+ DB_RESPONSE_RETURNED_ROWS as DB_RESPONSE_RETURNED_ROWS,
56
+ )
57
+ from snakeorm.debug.otel.spans import DB_SYSTEM_NAME as DB_SYSTEM_NAME
58
+ from snakeorm.debug.otel.spans import SNAKEORM_HAS_N_PLUS_ONE as SNAKEORM_HAS_N_PLUS_ONE
59
+ from snakeorm.debug.otel.spans import AttributeValue as AttributeValue
60
+ from snakeorm.debug.otel.spans import IdSource as IdSource
61
+ from snakeorm.debug.otel.spans import RandomIds as RandomIds
62
+ from snakeorm.debug.otel.spans import SnakeSpan as SnakeSpan
63
+ from snakeorm.debug.otel.spans import SpanEvent as SpanEvent
64
+ from snakeorm.debug.otel.spans import SpanKind as SpanKind
65
+ from snakeorm.debug.otel.spans import monotonic_epoch_ns as monotonic_epoch_ns
66
+ from snakeorm.debug.otel.spans import spans_from_report as spans_from_report
67
+ from snakeorm.debug.otel.summary import QuerySummary as QuerySummary
68
+ from snakeorm.debug.otel.summary import summarise as summarise
@@ -0,0 +1,43 @@
1
+ """Reading the trace context the APPLICATION already has open, if it has one.
2
+
3
+ This is the ONLY thing `opentelemetry-api` is needed for, and it is the only thing that cannot be
4
+ reimplemented: a process-wide context variable owned by a library we do not control. Everything else
5
+ in this channel —the payload, the HTTP POST, the queue— is stdlib.
6
+
7
+ It degrades three ways and none of them raises: no library installed, no provider active, or a
8
+ provider that misbehaves. All three give `None`, which turns our span into a loose root instead of a
9
+ child.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class TraceContext:
19
+ """The active span, in the hex form OTLP/JSON wants: 32 characters of trace, 16 of span."""
20
+
21
+ trace_id: str
22
+ span_id: str
23
+
24
+
25
+ def active_context() -> TraceContext | None:
26
+ """The application's active span, or `None` if there is none to hang off.
27
+
28
+ `ImportError` is the expected path for an application that never installed the extra, so it is
29
+ caught rather than guarded with a flag. The broad catch underneath is deliberate too: a
30
+ telemetry read has no business turning into a 500.
31
+ """
32
+ try:
33
+ from opentelemetry import trace
34
+
35
+ span_context = trace.get_current_span().get_span_context()
36
+ if not span_context.is_valid:
37
+ return None
38
+ return TraceContext(
39
+ trace_id=format(span_context.trace_id, "032x"),
40
+ span_id=format(span_context.span_id, "016x"),
41
+ )
42
+ except Exception:
43
+ return None
@@ -0,0 +1,277 @@
1
+ """Sending the spans: OTLP over HTTP, from a BACKGROUND QUEUE, with `urllib` and nothing else.
2
+
3
+ The queue is not an optimisation and it is not deferred to a later version. Measured against
4
+ localhost, exporting in line adds ~210 ms to a request of 503 queries; on the asynchronous path that
5
+ does not slow one request down, it blocks the WHOLE event loop. So `submit` reads the active trace
6
+ context —which has to happen on the calling thread, because that is where the context lives— hands
7
+ the report to a worker and returns.
8
+
9
+ Two failure modes, and both are declared rather than swallowed. A full queue DROPS (telemetry must
10
+ never make an application wait) and counts the drop. A transport that fails counts the failure and
11
+ warns ONCE, naming the endpoint: a silence would be the exact fault this ORM exists not to commit,
12
+ and a warning per request would be a log nobody reads.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import atexit
18
+ import os
19
+ import queue
20
+ import threading
21
+ import warnings
22
+ from collections.abc import Callable, Mapping
23
+ from typing import TYPE_CHECKING
24
+
25
+ from snakeorm.core.exceptions import SnakeWarning
26
+ from snakeorm.debug.otel.context import TraceContext, active_context
27
+ from snakeorm.debug.otel.payload import encode_payload, otlp_payload
28
+ from snakeorm.debug.otel.spans import spans_from_report
29
+
30
+ if TYPE_CHECKING:
31
+ from snakeorm.debug.report import DebugReport
32
+
33
+ Transport = Callable[[str, bytes], None]
34
+ """How the body actually leaves: `(endpoint, body) -> None`. Injected, so it is testable offline."""
35
+
36
+ DEFAULT_ENDPOINT = "http://localhost:4318/v1/traces"
37
+ """The local OTLP/HTTP port, which is the one `docker compose --profile tracing` publishes."""
38
+
39
+ DEFAULT_SERVICE_NAME = "snakeorm"
40
+ """What Jaeger's Service dropdown says when the application did not name itself."""
41
+
42
+ TRACES_ENDPOINT_ENV_KEY = "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT"
43
+ ENDPOINT_ENV_KEY = "OTEL_EXPORTER_OTLP_ENDPOINT"
44
+ SERVICE_NAME_ENV_KEY = "OTEL_SERVICE_NAME"
45
+ # The STANDARD variable names, not ours. Anyone who has configured any other exporter has already
46
+ # set them, and a private spelling would be one more thing to discover.
47
+
48
+ _TRACES_PATH = "/v1/traces"
49
+ _HTTP_TIMEOUT_SECONDS = 5.0
50
+ _DEFAULT_QUEUE_SIZE = 256
51
+
52
+
53
+ def post_json(endpoint: str, body: bytes) -> None:
54
+ """POST the payload as `application/json`. The response body is read and dropped: only the send matters.
55
+
56
+ `urllib.request` is imported HERE and not at the top, which is the one lazy import in this
57
+ package and the one that earns it. `snakeorm/__init__` re-exports `snakeorm.debug`, which
58
+ re-exports `export_report`, which lives in this module — so a module-level import would put an
59
+ HTTP client (plus `http.client`, `email.*` and `ssl`, ~17 ms measured) into the startup of every
60
+ application that uses the ORM, including the ones that never turn this channel on. The channel
61
+ promises that switching it off costs nothing; an import is part of that promise.
62
+ """
63
+ import urllib.request
64
+
65
+ request = urllib.request.Request(
66
+ endpoint,
67
+ data=body,
68
+ headers={"Content-Type": "application/json"},
69
+ method="POST",
70
+ )
71
+ with urllib.request.urlopen(request, timeout=_HTTP_TIMEOUT_SECONDS) as response:
72
+ response.read()
73
+
74
+
75
+ def endpoint_from_env(environ: Mapping[str, str] | None = None) -> str:
76
+ """The traces endpoint: the specific variable, then the generic one plus `/v1/traces`, then local."""
77
+ env = _environ(environ)
78
+ specific = env.get(TRACES_ENDPOINT_ENV_KEY, "").strip()
79
+ if specific:
80
+ return specific
81
+ generic = env.get(ENDPOINT_ENV_KEY, "").strip()
82
+ if generic:
83
+ return f"{generic.rstrip('/')}{_TRACES_PATH}"
84
+ return DEFAULT_ENDPOINT
85
+
86
+
87
+ def service_name_from_env(environ: Mapping[str, str] | None = None) -> str:
88
+ """The service name (`OTEL_SERVICE_NAME`), or ours when the application did not declare one."""
89
+ return (
90
+ _environ(environ).get(SERVICE_NAME_ENV_KEY, "").strip() or DEFAULT_SERVICE_NAME
91
+ )
92
+
93
+
94
+ def _environ(environ: Mapping[str, str] | None) -> Mapping[str, str]:
95
+ """The given mapping, or the real environment. Injected in tests, read for real everywhere else."""
96
+ return os.environ if environ is None else environ
97
+
98
+
99
+ class OtelExporter:
100
+ """Turns reports into OTLP spans and posts them, from a worker thread of its own.
101
+
102
+ One per process (see `current_exporter`): one thread and one queue, not one per request.
103
+ """
104
+
105
+ __slots__ = (
106
+ "_dropped",
107
+ "_endpoint",
108
+ "_failures",
109
+ "_lock",
110
+ "_parameter_keys",
111
+ "_pending",
112
+ "_queue",
113
+ "_service_name",
114
+ "_transport",
115
+ "_warned",
116
+ "_worker",
117
+ )
118
+
119
+ def __init__(
120
+ self,
121
+ *,
122
+ endpoint: str,
123
+ service_name: str,
124
+ transport: Transport = post_json,
125
+ queue_size: int = _DEFAULT_QUEUE_SIZE,
126
+ parameter_keys: frozenset[str] = frozenset(),
127
+ ) -> None:
128
+ """`parameter_keys` names the query parameters to send, and there is NO environment variable for it.
129
+
130
+ That omission is the decision: an environment variable is precisely the switch somebody
131
+ flips in production by accident, and this one would send user data to a tracing backend. It
132
+ takes an explicit line of code, key by key.
133
+ """
134
+ self._endpoint = endpoint
135
+ self._service_name = service_name
136
+ self._transport = transport
137
+ self._parameter_keys = parameter_keys
138
+ self._queue: queue.Queue[tuple[DebugReport, TraceContext | None] | None] = (
139
+ queue.Queue(maxsize=queue_size)
140
+ )
141
+ self._lock = threading.Condition()
142
+ self._pending = 0
143
+ self._dropped = 0
144
+ self._failures = 0
145
+ self._warned = False
146
+ self._worker: threading.Thread | None = None
147
+
148
+ @property
149
+ def endpoint(self) -> str:
150
+ """Where the spans are posted."""
151
+ return self._endpoint
152
+
153
+ @property
154
+ def service_name(self) -> str:
155
+ """What the resource calls this service."""
156
+ return self._service_name
157
+
158
+ @property
159
+ def dropped(self) -> int:
160
+ """How many reports were discarded because the queue was full."""
161
+ return self._dropped
162
+
163
+ @property
164
+ def failures(self) -> int:
165
+ """How many sends the transport refused."""
166
+ return self._failures
167
+
168
+ def submit(self, report: DebugReport) -> None:
169
+ """Queue a report and RETURN. Nothing is serialised or sent on the calling thread.
170
+
171
+ The trace context is read HERE and not in the worker: it lives in a `ContextVar` belonging
172
+ to the request's task, and a worker thread cannot see it.
173
+ """
174
+ item = (report, active_context())
175
+ with self._lock:
176
+ self._start_worker()
177
+ try:
178
+ self._queue.put_nowait(item)
179
+ except queue.Full:
180
+ self._dropped += 1
181
+ return
182
+ self._pending += 1
183
+
184
+ def send_now(self, report: DebugReport, *, parent: TraceContext | None) -> None:
185
+ """Build the payload and post it, synchronously. The worker's body, and the test's seam."""
186
+ try:
187
+ spans = spans_from_report(
188
+ report, parent=parent, parameter_keys=self._parameter_keys
189
+ )
190
+ body = encode_payload(otlp_payload(spans, service_name=self._service_name))
191
+ self._transport(self._endpoint, body)
192
+ except (
193
+ Exception
194
+ ) as error: # a collector that is down is not an application error
195
+ self._record_failure(error)
196
+
197
+ def flush(self, timeout: float = 5.0) -> bool:
198
+ """Wait until the queue has drained. `True` if it did, `False` if the wait ran out."""
199
+ with self._lock:
200
+ return self._lock.wait_for(lambda: self._pending == 0, timeout=timeout)
201
+
202
+ def shutdown(self, timeout: float = 5.0) -> None:
203
+ """Drain what is queued and stop the worker. Registered at exit for the process exporter."""
204
+ self.flush(timeout)
205
+ with self._lock:
206
+ worker, self._worker = self._worker, None
207
+ if worker is None:
208
+ return
209
+ self._queue.put(None)
210
+ worker.join(timeout)
211
+
212
+ def _start_worker(self) -> None:
213
+ """Start the worker on first use. Daemon: a stuck send never keeps the process alive."""
214
+ if self._worker is not None:
215
+ return
216
+ self._worker = threading.Thread(
217
+ target=self._drain, name="snakeorm-otel", daemon=True
218
+ )
219
+ self._worker.start()
220
+
221
+ def _drain(self) -> None:
222
+ """Take reports off the queue and send them, one at a time, until the sentinel arrives."""
223
+ while True:
224
+ item = self._queue.get()
225
+ if item is None:
226
+ return
227
+ report, parent = item
228
+ try:
229
+ self.send_now(report, parent=parent)
230
+ finally:
231
+ with self._lock:
232
+ self._pending -= 1
233
+ self._lock.notify_all()
234
+
235
+ def _record_failure(self, error: Exception) -> None:
236
+ """Count the failure and warn about the FIRST one, naming the endpoint and the reason."""
237
+ with self._lock:
238
+ self._failures += 1
239
+ already_warned, self._warned = self._warned, True
240
+ if already_warned:
241
+ return
242
+ warnings.warn(
243
+ f"The debug channel 'otel' could not reach {self._endpoint}: {error}. "
244
+ f"The spans of this request are lost; the following failures stay quiet.",
245
+ SnakeWarning,
246
+ stacklevel=2,
247
+ )
248
+
249
+
250
+ _process_exporter: OtelExporter | None = None
251
+ _process_lock = threading.Lock()
252
+
253
+
254
+ def current_exporter() -> OtelExporter:
255
+ """The process's exporter, built from the standard environment variables on first use."""
256
+ global _process_exporter
257
+ with _process_lock:
258
+ if _process_exporter is None:
259
+ _process_exporter = OtelExporter(
260
+ endpoint=endpoint_from_env(), service_name=service_name_from_env()
261
+ )
262
+ atexit.register(_shutdown_process_exporter)
263
+ return _process_exporter
264
+
265
+
266
+ def set_exporter(exporter: OtelExporter | None) -> None:
267
+ """Install (or clear) the process exporter. `None` makes the next call build a fresh one."""
268
+ global _process_exporter
269
+ with _process_lock:
270
+ _process_exporter = exporter
271
+
272
+
273
+ def _shutdown_process_exporter() -> None:
274
+ """Drain whatever is queued before the interpreter goes away, so the last request is not lost."""
275
+ exporter = _process_exporter
276
+ if exporter is not None:
277
+ exporter.shutdown()
@@ -0,0 +1,97 @@
1
+ """The OTLP/HTTP JSON body: the ~60 lines of stdlib that stand in for the whole SDK.
2
+
3
+ The shape is protobuf's JSON mapping, which is picky in ways plain JSON is not, and each pickiness
4
+ is a 400 from the collector rather than a wrong picture:
5
+
6
+ - A 64-bit field travels as a STRING (`startTimeUnixNano`, `intValue`). A JSON number loses
7
+ precision above 2^53, and a Unix nanosecond is well past that.
8
+ - Every attribute is a `{key, value}` pair with the value TAGGED by type.
9
+ - An absent parent is an ABSENT field, not an empty one.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+
16
+ from snakeorm.debug.otel.spans import AttributeValue, SnakeSpan, SpanEvent
17
+
18
+ SCOPE_NAME = "snakeorm"
19
+ """Who produced these spans. It is how a reader tells ours from the application's own."""
20
+
21
+ SERVICE_NAME_KEY = "service.name"
22
+ """The resource attribute Jaeger lists in its Service dropdown."""
23
+
24
+
25
+ def otlp_payload(
26
+ spans: tuple[SnakeSpan, ...], *, service_name: str
27
+ ) -> dict[str, object]:
28
+ """The complete `resourceSpans` document for one export, ready to be serialised."""
29
+ return {
30
+ "resourceSpans": [
31
+ {
32
+ "resource": {
33
+ "attributes": [_attribute(SERVICE_NAME_KEY, service_name)]
34
+ },
35
+ "scopeSpans": [
36
+ {
37
+ "scope": {"name": SCOPE_NAME},
38
+ "spans": [_span(span) for span in spans],
39
+ }
40
+ ],
41
+ }
42
+ ]
43
+ }
44
+
45
+
46
+ def encode_payload(payload: dict[str, object]) -> bytes:
47
+ """The bytes the POST sends. Compact and UTF-8: nothing here is read by a person."""
48
+ return json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode()
49
+
50
+
51
+ def _span(span: SnakeSpan) -> dict[str, object]:
52
+ """One span in OTLP's JSON shape, omitting the parent when there is none."""
53
+ encoded: dict[str, object] = {
54
+ "traceId": span.trace_id,
55
+ "spanId": span.span_id,
56
+ "name": span.name,
57
+ "kind": int(span.kind),
58
+ "startTimeUnixNano": str(span.start_unix_nano),
59
+ "endTimeUnixNano": str(span.end_unix_nano),
60
+ "attributes": [_attribute(key, value) for key, value in span.attributes],
61
+ }
62
+ if span.parent_span_id:
63
+ encoded["parentSpanId"] = span.parent_span_id
64
+ if span.events:
65
+ encoded["events"] = [_event(event) for event in span.events]
66
+ return encoded
67
+
68
+
69
+ def _event(event: SpanEvent) -> dict[str, object]:
70
+ """One span event: its instant, its name and its own attributes."""
71
+ return {
72
+ "name": event.name,
73
+ "timeUnixNano": str(event.time_unix_nano),
74
+ "attributes": [_attribute(key, value) for key, value in event.attributes],
75
+ }
76
+
77
+
78
+ def _attribute(key: str, value: AttributeValue) -> dict[str, object]:
79
+ """A `{key, value}` pair with the value tagged by its type."""
80
+ return {"key": key, "value": _value(value)}
81
+
82
+
83
+ def _value(value: AttributeValue) -> dict[str, object]:
84
+ """Tag a value with its OTLP type.
85
+
86
+ `bool` is checked FIRST on purpose: in Python it is a subclass of `int`, so the obvious order
87
+ ships every `True` as the integer `1` and the flag stops being a flag in the backend's index.
88
+ """
89
+ if isinstance(value, bool):
90
+ return {"boolValue": value}
91
+ if isinstance(value, int):
92
+ return {"intValue": str(value)}
93
+ if isinstance(value, float):
94
+ return {"doubleValue": value}
95
+ if isinstance(value, tuple):
96
+ return {"arrayValue": {"values": [{"stringValue": item} for item in value]}}
97
+ return {"stringValue": value}
@@ -0,0 +1,29 @@
1
+ """`export_report`: the ONE call the three web adapters make, and where the channel is checked.
2
+
3
+ It lives here and not in `contrib/deliver.py` on purpose. `plan_delivery` is a PURE function that
4
+ answers `(headers, envelope)` — two things that modify the response — and `Delivery` is exactly that
5
+ pair. Sending bytes over the network is neither, and widening that dataclass to hold an I/O side
6
+ effect would dissolve the reason `deliver.py` exists. So this is shared the way `index_advice`
7
+ already is: one function, called from all three adapters right after `report.with_request(...)`.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import TYPE_CHECKING
13
+
14
+ from snakeorm.debug.channel import SnakeDebugChannel
15
+ from snakeorm.debug.otel.exporter import current_exporter
16
+
17
+ if TYPE_CHECKING:
18
+ from snakeorm.debug.report import DebugReport
19
+
20
+
21
+ def export_report(report: DebugReport, channels: frozenset[SnakeDebugChannel]) -> None:
22
+ """Queue the report as spans when the `otel` channel is on; do nothing at all when it is not.
23
+
24
+ With the channel off the whole cost is one frozenset membership test: no exporter is built, no
25
+ thread is started and `opentelemetry` is never imported.
26
+ """
27
+ if SnakeDebugChannel.OTEL not in channels:
28
+ return
29
+ current_exporter().submit(report)