pyoq-sql 1.0.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.
Files changed (264) hide show
  1. pyoq/__init__.py +7 -0
  2. pyoq/__main__.py +5 -0
  3. pyoq/_native.pyi +5 -0
  4. pyoq/cli/__init__.py +5 -0
  5. pyoq/cli/commands.py +270 -0
  6. pyoq/cli/defaults.py +98 -0
  7. pyoq/cli/services.py +97 -0
  8. pyoq/config/__init__.py +19 -0
  9. pyoq/config/loader.py +204 -0
  10. pyoq/config/models.py +243 -0
  11. pyoq/descriptors.py +165 -0
  12. pyoq/diagnostics/__init__.py +68 -0
  13. pyoq/diagnostics/budget.py +136 -0
  14. pyoq/diagnostics/events.py +137 -0
  15. pyoq/diagnostics/fingerprint.py +267 -0
  16. pyoq/diagnostics/instrumented.py +237 -0
  17. pyoq/diagnostics/metrics.py +61 -0
  18. pyoq/diagnostics/observation.py +227 -0
  19. pyoq/diagnostics/scoped.py +103 -0
  20. pyoq/django/__init__.py +15 -0
  21. pyoq/django/apps.py +17 -0
  22. pyoq/django/execution.py +317 -0
  23. pyoq/django/generation.py +59 -0
  24. pyoq/django/management/__init__.py +0 -0
  25. pyoq/django/management/commands/__init__.py +0 -0
  26. pyoq/django/management/commands/makemigrations.py +53 -0
  27. pyoq/django/management/commands/pyoq_codegen.py +75 -0
  28. pyoq/django/parameters.py +101 -0
  29. pyoq/django/schema.py +379 -0
  30. pyoq/django/settings.py +87 -0
  31. pyoq/django/timeouts.py +105 -0
  32. pyoq/dsl/__init__.py +64 -0
  33. pyoq/dsl/aio/__init__.py +31 -0
  34. pyoq/dsl/aio/context.py +295 -0
  35. pyoq/dsl/aio/queries.py +335 -0
  36. pyoq/dsl/aio/writes.py +368 -0
  37. pyoq/dsl/context.py +326 -0
  38. pyoq/dsl/entry.py +37 -0
  39. pyoq/dsl/labels.py +36 -0
  40. pyoq/dsl/queries.py +339 -0
  41. pyoq/dsl/result.py +164 -0
  42. pyoq/dsl/writes.py +360 -0
  43. pyoq/errors.py +317 -0
  44. pyoq/fastapi/__init__.py +32 -0
  45. pyoq/fastapi/dependencies.py +167 -0
  46. pyoq/fastapi/lifespan.py +119 -0
  47. pyoq/fetching/__init__.py +55 -0
  48. pyoq/fetching/collections.py +136 -0
  49. pyoq/fetching/execution.py +587 -0
  50. pyoq/fetching/joined.py +79 -0
  51. pyoq/fetching/nesting.py +183 -0
  52. pyoq/fetching/plans.py +541 -0
  53. pyoq/fetching/select_in.py +149 -0
  54. pyoq/fetching/tables.py +110 -0
  55. pyoq/generation/__init__.py +54 -0
  56. pyoq/generation/cleanup.py +44 -0
  57. pyoq/generation/contracts.py +248 -0
  58. pyoq/generation/drift.py +169 -0
  59. pyoq/generation/lock.py +33 -0
  60. pyoq/generation/manifest.py +114 -0
  61. pyoq/generation/model.py +1001 -0
  62. pyoq/generation/pipeline.py +119 -0
  63. pyoq/generation/rendering/__init__.py +5 -0
  64. pyoq/generation/rendering/domains.py +51 -0
  65. pyoq/generation/rendering/enums.py +29 -0
  66. pyoq/generation/rendering/exports.py +70 -0
  67. pyoq/generation/rendering/imports.py +63 -0
  68. pyoq/generation/rendering/package.py +56 -0
  69. pyoq/generation/rendering/relations.py +133 -0
  70. pyoq/generation/rendering/routines.py +396 -0
  71. pyoq/generation/rendering/rows.py +79 -0
  72. pyoq/generation/rendering/source.py +121 -0
  73. pyoq/generation/rendering/tables.py +300 -0
  74. pyoq/generation/rendering/writes.py +514 -0
  75. pyoq/generation/validation.py +27 -0
  76. pyoq/generation/writer.py +184 -0
  77. pyoq/hydration/__init__.py +24 -0
  78. pyoq/hydration/engine.py +155 -0
  79. pyoq/hydration/identity.py +194 -0
  80. pyoq/hydration/plan.py +116 -0
  81. pyoq/migrations/__init__.py +9 -0
  82. pyoq/migrations/alembic.py +106 -0
  83. pyoq/migrations/hooks.py +75 -0
  84. pyoq/naming.py +261 -0
  85. pyoq/policies/__init__.py +47 -0
  86. pyoq/policies/bypass.py +122 -0
  87. pyoq/policies/governed.py +430 -0
  88. pyoq/policies/model.py +242 -0
  89. pyoq/policies/rewriting.py +263 -0
  90. pyoq/py.typed +1 -0
  91. pyoq/query/__init__.py +312 -0
  92. pyoq/query/aggregates.py +172 -0
  93. pyoq/query/arrays.py +65 -0
  94. pyoq/query/binding.py +52 -0
  95. pyoq/query/capabilities.py +317 -0
  96. pyoq/query/casts.py +73 -0
  97. pyoq/query/choices.py +185 -0
  98. pyoq/query/decoding.py +360 -0
  99. pyoq/query/documents.py +56 -0
  100. pyoq/query/execution/__init__.py +63 -0
  101. pyoq/query/execution/aio/__init__.py +31 -0
  102. pyoq/query/execution/aio/operations.py +228 -0
  103. pyoq/query/execution/aio/pooling.py +233 -0
  104. pyoq/query/execution/aio/streaming.py +161 -0
  105. pyoq/query/execution/aio/transactions.py +105 -0
  106. pyoq/query/execution/batch.py +96 -0
  107. pyoq/query/execution/binding_style.py +30 -0
  108. pyoq/query/execution/compilation.py +48 -0
  109. pyoq/query/execution/context.py +61 -0
  110. pyoq/query/execution/control.py +50 -0
  111. pyoq/query/execution/operations.py +224 -0
  112. pyoq/query/execution/planning.py +107 -0
  113. pyoq/query/execution/pooling.py +279 -0
  114. pyoq/query/execution/results.py +36 -0
  115. pyoq/query/execution/streaming.py +178 -0
  116. pyoq/query/execution/transactions.py +95 -0
  117. pyoq/query/expressions.py +1200 -0
  118. pyoq/query/fields.py +60 -0
  119. pyoq/query/mysql/__init__.py +59 -0
  120. pyoq/query/mysql/aio/__init__.py +38 -0
  121. pyoq/query/mysql/aio/commands.py +389 -0
  122. pyoq/query/mysql/aio/driver.py +196 -0
  123. pyoq/query/mysql/aio/executor.py +123 -0
  124. pyoq/query/mysql/aio/factory.py +26 -0
  125. pyoq/query/mysql/aio/operations.py +38 -0
  126. pyoq/query/mysql/aio/pool.py +53 -0
  127. pyoq/query/mysql/aio/transactions.py +313 -0
  128. pyoq/query/mysql/commands.py +354 -0
  129. pyoq/query/mysql/compiler.py +134 -0
  130. pyoq/query/mysql/context.py +20 -0
  131. pyoq/query/mysql/executor.py +126 -0
  132. pyoq/query/mysql/expressions.py +244 -0
  133. pyoq/query/mysql/factory.py +46 -0
  134. pyoq/query/mysql/health.py +66 -0
  135. pyoq/query/mysql/identifiers.py +9 -0
  136. pyoq/query/mysql/model.py +79 -0
  137. pyoq/query/mysql/operations.py +43 -0
  138. pyoq/query/mysql/parameters.py +69 -0
  139. pyoq/query/mysql/planning.py +20 -0
  140. pyoq/query/mysql/pool.py +67 -0
  141. pyoq/query/mysql/transactions.py +331 -0
  142. pyoq/query/mysql/writes.py +73 -0
  143. pyoq/query/nodes.py +750 -0
  144. pyoq/query/postgres/__init__.py +48 -0
  145. pyoq/query/postgres/aio/__init__.py +25 -0
  146. pyoq/query/postgres/aio/bulk.py +56 -0
  147. pyoq/query/postgres/aio/commands.py +264 -0
  148. pyoq/query/postgres/aio/executor.py +152 -0
  149. pyoq/query/postgres/aio/factory.py +26 -0
  150. pyoq/query/postgres/aio/operations.py +26 -0
  151. pyoq/query/postgres/aio/pool.py +40 -0
  152. pyoq/query/postgres/aio/transactions.py +295 -0
  153. pyoq/query/postgres/bulk.py +62 -0
  154. pyoq/query/postgres/commands.py +238 -0
  155. pyoq/query/postgres/compiler.py +114 -0
  156. pyoq/query/postgres/context.py +20 -0
  157. pyoq/query/postgres/executor.py +147 -0
  158. pyoq/query/postgres/expressions.py +311 -0
  159. pyoq/query/postgres/factory.py +24 -0
  160. pyoq/query/postgres/health.py +24 -0
  161. pyoq/query/postgres/identifiers.py +9 -0
  162. pyoq/query/postgres/model.py +81 -0
  163. pyoq/query/postgres/operations.py +25 -0
  164. pyoq/query/postgres/parameters.py +71 -0
  165. pyoq/query/postgres/planning.py +20 -0
  166. pyoq/query/postgres/pool.py +52 -0
  167. pyoq/query/postgres/transactions.py +295 -0
  168. pyoq/query/postgres/writes.py +37 -0
  169. pyoq/query/projections.py +105 -0
  170. pyoq/query/raw.py +90 -0
  171. pyoq/query/recursion.py +265 -0
  172. pyoq/query/rendering/__init__.py +1 -0
  173. pyoq/query/rendering/expressions.py +913 -0
  174. pyoq/query/rendering/identifiers.py +40 -0
  175. pyoq/query/rendering/projections.py +63 -0
  176. pyoq/query/rendering/queries.py +334 -0
  177. pyoq/query/rendering/sources.py +66 -0
  178. pyoq/query/rendering/writes.py +176 -0
  179. pyoq/query/results.py +459 -0
  180. pyoq/query/routines.py +196 -0
  181. pyoq/query/rows.py +156 -0
  182. pyoq/query/select.py +793 -0
  183. pyoq/query/select_nodes.py +277 -0
  184. pyoq/query/sources.py +236 -0
  185. pyoq/query/sqlite/__init__.py +43 -0
  186. pyoq/query/sqlite/commands.py +201 -0
  187. pyoq/query/sqlite/compiler.py +139 -0
  188. pyoq/query/sqlite/context.py +20 -0
  189. pyoq/query/sqlite/executor.py +119 -0
  190. pyoq/query/sqlite/expressions.py +224 -0
  191. pyoq/query/sqlite/factory.py +32 -0
  192. pyoq/query/sqlite/health.py +28 -0
  193. pyoq/query/sqlite/identifiers.py +9 -0
  194. pyoq/query/sqlite/model.py +73 -0
  195. pyoq/query/sqlite/operations.py +36 -0
  196. pyoq/query/sqlite/parameters.py +50 -0
  197. pyoq/query/sqlite/planning.py +20 -0
  198. pyoq/query/sqlite/pool.py +50 -0
  199. pyoq/query/sqlite/streaming.py +13 -0
  200. pyoq/query/sqlite/transactions.py +274 -0
  201. pyoq/query/sqlite/writes.py +35 -0
  202. pyoq/query/statements.py +27 -0
  203. pyoq/query/values.py +23 -0
  204. pyoq/query/vendor.py +162 -0
  205. pyoq/query/windows.py +424 -0
  206. pyoq/query/write_nodes.py +174 -0
  207. pyoq/query/writes.py +628 -0
  208. pyoq/relations/__init__.py +66 -0
  209. pyoq/relations/batching.py +219 -0
  210. pyoq/relations/derivation.py +111 -0
  211. pyoq/relations/fetching.py +355 -0
  212. pyoq/relations/graph.py +245 -0
  213. pyoq/relations/loading.py +74 -0
  214. pyoq/relations/model.py +75 -0
  215. pyoq/relations/planning.py +206 -0
  216. pyoq/runtime/__init__.py +9 -0
  217. pyoq/runtime/kernels.py +25 -0
  218. pyoq/runtime/python.py +43 -0
  219. pyoq/runtime/selection.py +73 -0
  220. pyoq/sanic/__init__.py +32 -0
  221. pyoq/sanic/scope.py +197 -0
  222. pyoq/sanic/workers.py +129 -0
  223. pyoq/schema/__init__.py +108 -0
  224. pyoq/schema/codec.py +711 -0
  225. pyoq/schema/models.py +604 -0
  226. pyoq/schema/mysql/__init__.py +16 -0
  227. pyoq/schema/mysql/connection.py +72 -0
  228. pyoq/schema/mysql/dsn.py +72 -0
  229. pyoq/schema/mysql/records.py +354 -0
  230. pyoq/schema/mysql/reflection.py +309 -0
  231. pyoq/schema/mysql/source.py +30 -0
  232. pyoq/schema/mysql/sql.py +128 -0
  233. pyoq/schema/mysql/types.py +105 -0
  234. pyoq/schema/postgres/__init__.py +13 -0
  235. pyoq/schema/postgres/connection.py +62 -0
  236. pyoq/schema/postgres/records.py +384 -0
  237. pyoq/schema/postgres/reflection.py +466 -0
  238. pyoq/schema/postgres/source.py +30 -0
  239. pyoq/schema/postgres/sql.py +246 -0
  240. pyoq/schema/postgres/types.py +98 -0
  241. pyoq/schema/registry.py +45 -0
  242. pyoq/schema/source.py +15 -0
  243. pyoq/schema/sqlite/__init__.py +6 -0
  244. pyoq/schema/sqlite/connection.py +54 -0
  245. pyoq/schema/sqlite/records.py +167 -0
  246. pyoq/schema/sqlite/reflection.py +393 -0
  247. pyoq/schema/sqlite/source.py +30 -0
  248. pyoq/schema/sqlite/sql.py +254 -0
  249. pyoq/schema/sqlite/types.py +74 -0
  250. pyoq/serving/__init__.py +19 -0
  251. pyoq/serving/databases.py +107 -0
  252. pyoq/snapshots/__init__.py +20 -0
  253. pyoq/snapshots/drift.py +312 -0
  254. pyoq/snapshots/files.py +96 -0
  255. pyoq/snapshots/routing.py +40 -0
  256. pyoq/snapshots/source.py +33 -0
  257. pyoq/tracing/__init__.py +5 -0
  258. pyoq/tracing/spans.py +89 -0
  259. pyoq/unset.py +14 -0
  260. pyoq_sql-1.0.0.dist-info/METADATA +3034 -0
  261. pyoq_sql-1.0.0.dist-info/RECORD +264 -0
  262. pyoq_sql-1.0.0.dist-info/WHEEL +4 -0
  263. pyoq_sql-1.0.0.dist-info/entry_points.txt +3 -0
  264. pyoq_sql-1.0.0.dist-info/licenses/LICENSE +373 -0
pyoq/config/loader.py ADDED
@@ -0,0 +1,204 @@
1
+ """Strict project configuration loading."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import tomllib
6
+ from pathlib import Path
7
+ from typing import cast
8
+
9
+ from pyoq.config import (
10
+ Configuration,
11
+ DatabaseDialect,
12
+ DatabaseProfile,
13
+ EnvironmentReference,
14
+ )
15
+ from pyoq.errors import (
16
+ ConfigurationFileError,
17
+ ConfigurationValidationError,
18
+ PathContainmentError,
19
+ UnknownConfigurationSettingError,
20
+ )
21
+
22
+ _DEFAULT_CODEGEN_DIRECTORY = "pyoq_generated"
23
+ _DEFAULT_CODEGEN_PACKAGE = "pyoq_generated"
24
+ _DEFAULT_PROFILE = "default"
25
+ _ROOT_SETTINGS = frozenset(
26
+ {
27
+ "codegen-directory",
28
+ "codegen-package",
29
+ "profiles",
30
+ "schema-snapshot",
31
+ "selected-profile",
32
+ }
33
+ )
34
+ _PROFILE_SETTINGS = frozenset({"database-path", "dialect", "dsn-environment"})
35
+
36
+ RawTable = dict[str, object]
37
+
38
+
39
+ def load_configuration(
40
+ project_root: Path,
41
+ *,
42
+ explicit: Configuration | None = None,
43
+ pyproject_path: Path | None = None,
44
+ selected_profile: str | None = None,
45
+ ) -> Configuration:
46
+ if explicit is not None:
47
+ return (
48
+ explicit
49
+ if selected_profile is None
50
+ else explicit.select_profile(selected_profile)
51
+ )
52
+
53
+ root = project_root.resolve()
54
+ source_path = _resolve_config_path(root, pyproject_path)
55
+ document = _read_toml(source_path)
56
+ table = _pyoq_table(document)
57
+ configuration = _parse_configuration(root, table)
58
+ return (
59
+ configuration
60
+ if selected_profile is None
61
+ else configuration.select_profile(selected_profile)
62
+ )
63
+
64
+
65
+ def _read_toml(path: Path) -> RawTable:
66
+ try:
67
+ with path.open("rb") as stream:
68
+ document = tomllib.load(stream)
69
+ except (OSError, tomllib.TOMLDecodeError) as error:
70
+ message = f"unable to read configuration: {path}"
71
+ raise ConfigurationFileError(message) from error
72
+ return cast(RawTable, document)
73
+
74
+
75
+ def _pyoq_table(document: RawTable) -> RawTable:
76
+ tool = _require_table(document.get("tool"), "tool")
77
+ return _require_table(tool.get("pyoq"), "tool.pyoq")
78
+
79
+
80
+ def _parse_configuration(root: Path, table: RawTable) -> Configuration:
81
+ _reject_unknown(table, _ROOT_SETTINGS, "tool.pyoq")
82
+ profiles = _parse_profiles(table.get("profiles"))
83
+ return Configuration(
84
+ project_root=root,
85
+ codegen_directory=Path(
86
+ _string_setting(
87
+ table,
88
+ "codegen-directory",
89
+ _DEFAULT_CODEGEN_DIRECTORY,
90
+ "tool.pyoq",
91
+ )
92
+ ),
93
+ codegen_package=_string_setting(
94
+ table,
95
+ "codegen-package",
96
+ _DEFAULT_CODEGEN_PACKAGE,
97
+ "tool.pyoq",
98
+ ),
99
+ selected_profile=_string_setting(
100
+ table,
101
+ "selected-profile",
102
+ _DEFAULT_PROFILE,
103
+ "tool.pyoq",
104
+ ),
105
+ profiles=profiles,
106
+ schema_snapshot=_optional_path_setting(table, "schema-snapshot"),
107
+ )
108
+
109
+
110
+ def _optional_path_setting(table: RawTable, key: str) -> Path | None:
111
+ """A path a project may leave unset, which most projects do."""
112
+ written = table.get(key)
113
+ if written is None:
114
+ return None
115
+ if not isinstance(written, str) or not written:
116
+ message = f"tool.pyoq.{key} must be a non-empty string"
117
+ raise ConfigurationValidationError(message)
118
+ return Path(written)
119
+
120
+
121
+ def _parse_profiles(value: object) -> tuple[DatabaseProfile, ...]:
122
+ table = _require_table(value, "tool.pyoq.profiles")
123
+ if not table:
124
+ message = "tool.pyoq.profiles must contain at least one profile"
125
+ raise ConfigurationValidationError(message)
126
+ return tuple(_parse_profile(name, settings) for name, settings in table.items())
127
+
128
+
129
+ def _parse_profile(name: str, value: object) -> DatabaseProfile:
130
+ location = f"tool.pyoq.profiles.{name}"
131
+ table = _require_table(value, location)
132
+ _reject_unknown(table, _PROFILE_SETTINGS, location)
133
+ dialect_name = _required_string(table, "dialect", location)
134
+ try:
135
+ dialect = DatabaseDialect(dialect_name)
136
+ except ValueError as error:
137
+ supported = ", ".join(dialect.value for dialect in DatabaseDialect)
138
+ message = f"{location}.dialect must be one of: {supported}"
139
+ raise ConfigurationValidationError(message) from error
140
+ dsn_name = _optional_string(table, "dsn-environment", location)
141
+ dsn_environment = None if dsn_name is None else EnvironmentReference(dsn_name)
142
+ database_name = _optional_string(table, "database-path", location)
143
+ return DatabaseProfile(
144
+ name=name,
145
+ dialect=dialect,
146
+ dsn_environment=dsn_environment,
147
+ database_path=None if database_name is None else Path(database_name),
148
+ )
149
+
150
+
151
+ def _resolve_config_path(root: Path, configured: Path | None) -> Path:
152
+ path = root / "pyproject.toml" if configured is None else configured
153
+ candidate = path if path.is_absolute() else root / path
154
+ resolved = candidate.resolve()
155
+ if not resolved.is_relative_to(root):
156
+ message = f"configuration path escapes project root: {path}"
157
+ raise PathContainmentError(message)
158
+ return resolved
159
+
160
+
161
+ def _require_table(value: object, location: str) -> RawTable:
162
+ if not isinstance(value, dict):
163
+ message = f"{location} must be a table"
164
+ raise ConfigurationValidationError(message)
165
+ return cast(RawTable, value)
166
+
167
+
168
+ def _reject_unknown(table: RawTable, allowed: frozenset[str], location: str) -> None:
169
+ unknown = sorted(table.keys() - allowed)
170
+ if unknown:
171
+ names = ", ".join(unknown)
172
+ message = f"unknown settings in {location}: {names}"
173
+ raise UnknownConfigurationSettingError(message)
174
+
175
+
176
+ def _required_string(table: RawTable, key: str, location: str) -> str:
177
+ value = table.get(key)
178
+ if not isinstance(value, str) or not value:
179
+ message = f"{location}.{key} must be a non-empty string"
180
+ raise ConfigurationValidationError(message)
181
+ return value
182
+
183
+
184
+ def _string_setting(
185
+ table: RawTable,
186
+ key: str,
187
+ default: str,
188
+ location: str,
189
+ ) -> str:
190
+ value = table.get(key, default)
191
+ if not isinstance(value, str) or not value:
192
+ message = f"{location}.{key} must be a non-empty string"
193
+ raise ConfigurationValidationError(message)
194
+ return value
195
+
196
+
197
+ def _optional_string(table: RawTable, key: str, location: str) -> str | None:
198
+ value = table.get(key)
199
+ if value is None:
200
+ return None
201
+ if not isinstance(value, str) or not value:
202
+ message = f"{location}.{key} must be a non-empty string"
203
+ raise ConfigurationValidationError(message)
204
+ return value
pyoq/config/models.py ADDED
@@ -0,0 +1,243 @@
1
+ """Immutable configuration contracts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import keyword
6
+ import os
7
+ import re
8
+ from collections.abc import Mapping
9
+ from dataclasses import dataclass, replace
10
+ from enum import StrEnum
11
+ from pathlib import Path
12
+
13
+ from pyoq.errors import (
14
+ ConfigurationValidationError,
15
+ PathContainmentError,
16
+ SecretResolutionError,
17
+ )
18
+
19
+ _ENVIRONMENT_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
20
+ _PROFILE_NAME = re.compile(r"[A-Za-z][A-Za-z0-9_-]*")
21
+
22
+
23
+ class DatabaseDialect(StrEnum):
24
+ POSTGRES = "postgres"
25
+ MYSQL = "mysql"
26
+ SQLITE = "sqlite"
27
+
28
+
29
+ @dataclass(frozen=True, slots=True, repr=False)
30
+ class SecretValue:
31
+ _value: str
32
+
33
+ def __post_init__(self) -> None:
34
+ if not self._value:
35
+ message = "secret values cannot be empty"
36
+ raise ConfigurationValidationError(message)
37
+
38
+ def reveal(self) -> str:
39
+ return self._value
40
+
41
+ def __repr__(self) -> str:
42
+ return "SecretValue('[REDACTED]')"
43
+
44
+ def __str__(self) -> str:
45
+ return "[REDACTED]"
46
+
47
+
48
+ @dataclass(frozen=True, slots=True)
49
+ class EnvironmentReference:
50
+ name: str
51
+
52
+ def __post_init__(self) -> None:
53
+ if not _ENVIRONMENT_NAME.fullmatch(self.name):
54
+ message = f"invalid environment variable name: {self.name!r}"
55
+ raise ConfigurationValidationError(message)
56
+
57
+ def resolve(self, environment: Mapping[str, str] | None = None) -> SecretValue:
58
+ values = os.environ if environment is None else environment
59
+ value = values.get(self.name)
60
+ if not value:
61
+ message = f"required environment variable is unavailable: {self.name}"
62
+ raise SecretResolutionError(message)
63
+ return SecretValue(value)
64
+
65
+
66
+ @dataclass(frozen=True, slots=True)
67
+ class DatabaseProfile:
68
+ name: str
69
+ dialect: DatabaseDialect
70
+ dsn_environment: EnvironmentReference | None = None
71
+ database_path: Path | None = None
72
+
73
+ def __post_init__(self) -> None:
74
+ if not _PROFILE_NAME.fullmatch(self.name):
75
+ message = f"invalid profile name: {self.name!r}"
76
+ raise ConfigurationValidationError(message)
77
+ if (
78
+ self.database_path is not None
79
+ and self.dialect is not DatabaseDialect.SQLITE
80
+ ):
81
+ message = "database-path is only valid for SQLite profiles"
82
+ raise ConfigurationValidationError(message)
83
+ if self.database_path is not None and self.dsn_environment is not None:
84
+ message = "database-path and dsn-environment are mutually exclusive"
85
+ raise ConfigurationValidationError(message)
86
+
87
+
88
+ @dataclass(frozen=True, slots=True)
89
+ class Configuration:
90
+ project_root: Path
91
+ codegen_directory: Path
92
+ codegen_package: str
93
+ selected_profile: str
94
+ profiles: tuple[DatabaseProfile, ...]
95
+ schema_snapshot: Path | None = None
96
+ """Where the recorded schema is kept, relative to the project root.
97
+
98
+ Generation reads it instead of a database when it is set, which is what
99
+ lets a check run where there are no credentials to connect with.
100
+ """
101
+
102
+ def __post_init__(self) -> None:
103
+ root = self.project_root.resolve()
104
+ object.__setattr__(self, "project_root", root)
105
+ codegen_directory = _canonical_codegen_directory(
106
+ root,
107
+ self.codegen_directory,
108
+ )
109
+ object.__setattr__(self, "codegen_directory", codegen_directory)
110
+ object.__setattr__(
111
+ self,
112
+ "schema_snapshot",
113
+ _canonical_snapshot(root, self.schema_snapshot),
114
+ )
115
+ _validate_package_name(self.codegen_package)
116
+ _validate_profiles(self.profiles, self.selected_profile)
117
+ _validate_database_paths(root, self.profiles)
118
+
119
+ @property
120
+ def generated_path(self) -> Path:
121
+ return (self.project_root / self.codegen_directory).resolve()
122
+
123
+ @property
124
+ def schema_snapshot_path(self) -> Path | None:
125
+ """The recorded schema, as an absolute path, where one is configured."""
126
+ if self.schema_snapshot is None:
127
+ return None
128
+ return (self.project_root / self.schema_snapshot).resolve()
129
+
130
+ @property
131
+ def active_profile(self) -> DatabaseProfile:
132
+ return _find_profile(self.profiles, self.selected_profile)
133
+
134
+ @property
135
+ def active_database_path(self) -> Path | None:
136
+ configured = self.active_profile.database_path
137
+ if configured is None:
138
+ return None
139
+ return (self.project_root / configured).resolve()
140
+
141
+ def select_profile(self, name: str) -> Configuration:
142
+ _find_profile(self.profiles, name)
143
+ return replace(self, selected_profile=name)
144
+
145
+
146
+ def load_configuration(
147
+ project_root: Path,
148
+ *,
149
+ explicit: Configuration | None = None,
150
+ pyproject_path: Path | None = None,
151
+ selected_profile: str | None = None,
152
+ ) -> Configuration:
153
+ from pyoq.config.loader import load_configuration as load_from_toml
154
+
155
+ return load_from_toml(
156
+ project_root,
157
+ explicit=explicit,
158
+ pyproject_path=pyproject_path,
159
+ selected_profile=selected_profile,
160
+ )
161
+
162
+
163
+ def _contained(root: Path, configured: Path, label: str) -> Path:
164
+ """Where a configured path lands, refused if that is outside the project.
165
+
166
+ One policy for every path a project configures. It is written once because
167
+ three of them need it, and the one that had its own copy is the one that
168
+ escaped. Resolving first is what catches a symbolic link that points out
169
+ of the project as surely as a `..` does.
170
+ """
171
+ if configured.is_absolute():
172
+ message = f"{label} must be relative to the project root"
173
+ raise PathContainmentError(message)
174
+ resolved = (root / configured).resolve()
175
+ if resolved == root or not resolved.is_relative_to(root):
176
+ message = f"{label} escapes project root: {configured}"
177
+ raise PathContainmentError(message)
178
+ return resolved
179
+
180
+
181
+ def _canonical_codegen_directory(root: Path, configured: Path) -> Path:
182
+ return _contained(root, configured, "codegen-directory").relative_to(root)
183
+
184
+
185
+ def _canonical_snapshot(root: Path, configured: Path | None) -> Path | None:
186
+ """The recorded schema is written and replaced, so it stays in the project."""
187
+ if configured is None:
188
+ return None
189
+ return _contained(root, configured, "schema-snapshot").relative_to(root)
190
+
191
+
192
+ def _validate_package_name(name: str) -> None:
193
+ parts = name.split(".")
194
+ invalid = any(not part.isidentifier() or keyword.iskeyword(part) for part in parts)
195
+ if invalid:
196
+ message = f"codegen-package is not a valid dotted package name: {name!r}"
197
+ raise ConfigurationValidationError(message)
198
+
199
+
200
+ def _validate_profiles(
201
+ profiles: tuple[DatabaseProfile, ...],
202
+ selected_profile: str,
203
+ ) -> None:
204
+ if not profiles:
205
+ message = "configuration must contain at least one profile"
206
+ raise ConfigurationValidationError(message)
207
+ names = tuple(profile.name for profile in profiles)
208
+ if len(set(names)) != len(names):
209
+ message = "configuration profile names must be unique"
210
+ raise ConfigurationValidationError(message)
211
+ _find_profile(profiles, selected_profile)
212
+
213
+
214
+ def _validate_database_paths(
215
+ root: Path,
216
+ profiles: tuple[DatabaseProfile, ...],
217
+ ) -> None:
218
+ for profile in profiles:
219
+ configured = profile.database_path
220
+ if configured is None:
221
+ continue
222
+ _contained(root, configured, "database-path")
223
+
224
+
225
+ def _find_profile(
226
+ profiles: tuple[DatabaseProfile, ...],
227
+ name: str,
228
+ ) -> DatabaseProfile:
229
+ for profile in profiles:
230
+ if profile.name == name:
231
+ return profile
232
+ message = f"selected profile does not exist: {name!r}"
233
+ raise ConfigurationValidationError(message)
234
+
235
+
236
+ __all__ = (
237
+ "Configuration",
238
+ "DatabaseDialect",
239
+ "DatabaseProfile",
240
+ "EnvironmentReference",
241
+ "SecretValue",
242
+ "load_configuration",
243
+ )
pyoq/descriptors.py ADDED
@@ -0,0 +1,165 @@
1
+ """Runtime contracts used by generated database types."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import ClassVar, Generic, TypeAlias, TypeVar
7
+
8
+ from pyoq.query import Expression, FieldNode, ScalarFamily
9
+ from pyoq.relations import RelationCardinality, RelationDirection
10
+ from pyoq.schema import JsonScalar as SchemaJsonScalar
11
+ from pyoq.schema import JsonValue as SchemaJsonValue
12
+ from pyoq.schema import KeyKind, ReferentialAction
13
+ from pyoq.unset import UNSET, UnsetType
14
+
15
+ Value = TypeVar("Value")
16
+ Row = TypeVar("Row", covariant=True)
17
+ Insert = TypeVar("Insert", covariant=True)
18
+ Update = TypeVar("Update", covariant=True)
19
+ SourceRow = TypeVar("SourceRow", covariant=True)
20
+ TargetRow = TypeVar("TargetRow", covariant=True)
21
+ JsonScalar: TypeAlias = SchemaJsonScalar
22
+ JsonValue: TypeAlias = SchemaJsonValue
23
+
24
+
25
+ class Missing:
26
+ __slots__ = ()
27
+
28
+
29
+ class Present:
30
+ __slots__ = ()
31
+
32
+
33
+ @dataclass(frozen=True, slots=True)
34
+ class ColumnDescriptor(Expression[Value], Generic[Value]):
35
+ database_name: str
36
+ nullable: bool
37
+ writable: bool
38
+ has_default: bool
39
+ generated: bool
40
+ scalar_family: ScalarFamily = ScalarFamily.OTHER
41
+ table_name: str | None = None
42
+ schema_name: str | None = None
43
+ catalog_name: str | None = None
44
+ field_name: str | None = None
45
+ value_type: type[object] | None = None
46
+
47
+ def __post_init__(self) -> None:
48
+ _require_name(self.database_name, "column")
49
+ self._initialize_expression(
50
+ FieldNode(
51
+ self.database_name,
52
+ self.table_name,
53
+ self.schema_name,
54
+ self.catalog_name,
55
+ self.scalar_family,
56
+ self.value_type,
57
+ self.nullable,
58
+ ),
59
+ self.scalar_family,
60
+ )
61
+
62
+
63
+ @dataclass(frozen=True, slots=True)
64
+ class TableDescriptor(Generic[Row, Insert, Update]):
65
+ COLUMNS: ClassVar[tuple[object, ...]] = ()
66
+ """Every column this table has, in the order the table declares them.
67
+
68
+ Generation fills this in. A table written by hand names no columns, and
69
+ so cannot be selected from without saying which.
70
+ """
71
+
72
+ database_name: str
73
+ schema_name: str | None = None
74
+ catalog_name: str | None = None
75
+ row_type: type[Row] | None = None
76
+ """The class one row of this table reads back as.
77
+
78
+ Generation fills this in, so a table constant carries the type of what it
79
+ holds as well as its name. A table written by hand names no row class, and
80
+ so a row of it can only be read as the values it came back with.
81
+ """
82
+
83
+ def __post_init__(self) -> None:
84
+ _require_name(self.database_name, "table")
85
+
86
+
87
+ @dataclass(frozen=True, slots=True)
88
+ class KeyDescriptor(Generic[Value]):
89
+ kind: KeyKind
90
+ column_names: tuple[str, ...]
91
+ database_name: str | None = None
92
+
93
+ def __post_init__(self) -> None:
94
+ if not self.column_names:
95
+ message = "key descriptor requires at least one column"
96
+ raise ValueError(message)
97
+
98
+
99
+ @dataclass(frozen=True, slots=True)
100
+ class RelationshipDescriptor(Generic[SourceRow, TargetRow]):
101
+ source_table: str
102
+ source_columns: tuple[str, ...]
103
+ target_table: str
104
+ target_columns: tuple[str, ...]
105
+ source_schema: str | None = None
106
+ source_catalog: str | None = None
107
+ target_schema: str | None = None
108
+ target_catalog: str | None = None
109
+ """Where each side lives, because a table name alone names more than one.
110
+
111
+ Two schemas may each hold a `parent` and a `child` joined on the same
112
+ columns. Without these, a constant generated for one of them describes
113
+ both, and reading through it answers with whichever was found first.
114
+ """
115
+
116
+ database_name: str | None = None
117
+ on_update: ReferentialAction = ReferentialAction.NO_ACTION
118
+ on_delete: ReferentialAction = ReferentialAction.NO_ACTION
119
+ direction: RelationDirection = RelationDirection.FORWARD
120
+ cardinality: RelationCardinality = RelationCardinality.TO_ONE
121
+ optional: bool = False
122
+ target_row_type: type[TargetRow] | None = None
123
+ """The class one row of the table this reaches reads back as.
124
+
125
+ The same fact as a table constant's own, carried here so that following a
126
+ relation says what it arrives at without the caller naming it again.
127
+ """
128
+
129
+ def __post_init__(self) -> None:
130
+ _require_name(self.source_table, "relationship source table")
131
+ _require_name(self.target_table, "relationship target table")
132
+ if not self.source_columns or len(self.source_columns) != len(
133
+ self.target_columns
134
+ ):
135
+ message = "relationship descriptor columns must be non-empty and aligned"
136
+ raise ValueError(message)
137
+ if self.cardinality is RelationCardinality.TO_MANY and self.optional:
138
+ message = "a to-many relationship cannot be optional"
139
+ raise ValueError(message)
140
+
141
+ @property
142
+ def to_one(self) -> bool:
143
+ return self.cardinality is RelationCardinality.TO_ONE
144
+
145
+
146
+ def _require_name(value: str, label: str) -> None:
147
+ if not value:
148
+ message = f"{label} name cannot be empty"
149
+ raise ValueError(message)
150
+
151
+
152
+ __all__ = (
153
+ "UNSET",
154
+ "ColumnDescriptor",
155
+ "JsonScalar",
156
+ "JsonValue",
157
+ "KeyDescriptor",
158
+ "Missing",
159
+ "Present",
160
+ "RelationCardinality",
161
+ "RelationDirection",
162
+ "RelationshipDescriptor",
163
+ "TableDescriptor",
164
+ "UnsetType",
165
+ )
@@ -0,0 +1,68 @@
1
+ """Seeing what a scope asked a database to do."""
2
+
3
+ from pyoq.diagnostics.budget import QueryBudget, QueryScope
4
+ from pyoq.diagnostics.events import (
5
+ CollectingSink,
6
+ EventPolicy,
7
+ EventSink,
8
+ StatementEvent,
9
+ StatementFailed,
10
+ StatementFinished,
11
+ StatementStarted,
12
+ reportable_values,
13
+ )
14
+ from pyoq.diagnostics.fingerprint import (
15
+ CacheMetrics,
16
+ QueryShape,
17
+ forget_shapes,
18
+ normalize_sql,
19
+ query_shape,
20
+ shape_cache_metrics,
21
+ )
22
+ from pyoq.diagnostics.instrumented import (
23
+ AsyncInstrumentedOperations,
24
+ InstrumentedOperations,
25
+ )
26
+ from pyoq.diagnostics.metrics import DatabaseMetrics, metrics_of
27
+ from pyoq.diagnostics.observation import (
28
+ DEFAULT_REPEAT_THRESHOLD,
29
+ DEFAULT_SHAPE_LIMIT,
30
+ DEFAULT_SITE_LIMIT,
31
+ CallSite,
32
+ QueryObserver,
33
+ RepeatedQuery,
34
+ calling_site,
35
+ )
36
+ from pyoq.diagnostics.scoped import AsyncScopedOperations, ScopedOperations
37
+
38
+ __all__ = (
39
+ "DEFAULT_REPEAT_THRESHOLD",
40
+ "DEFAULT_SHAPE_LIMIT",
41
+ "DEFAULT_SITE_LIMIT",
42
+ "AsyncInstrumentedOperations",
43
+ "AsyncScopedOperations",
44
+ "CacheMetrics",
45
+ "CallSite",
46
+ "CollectingSink",
47
+ "DatabaseMetrics",
48
+ "EventPolicy",
49
+ "EventSink",
50
+ "InstrumentedOperations",
51
+ "QueryBudget",
52
+ "QueryObserver",
53
+ "QueryScope",
54
+ "QueryShape",
55
+ "RepeatedQuery",
56
+ "ScopedOperations",
57
+ "StatementEvent",
58
+ "StatementFailed",
59
+ "StatementFinished",
60
+ "StatementStarted",
61
+ "calling_site",
62
+ "forget_shapes",
63
+ "metrics_of",
64
+ "normalize_sql",
65
+ "query_shape",
66
+ "reportable_values",
67
+ "shape_cache_metrics",
68
+ )