pyoq-sql 1.0.2__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 (267) hide show
  1. pyoq/__init__.py +10 -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 +31 -0
  9. pyoq/config/connection.py +161 -0
  10. pyoq/config/loader.py +289 -0
  11. pyoq/config/models.py +245 -0
  12. pyoq/config/values.py +142 -0
  13. pyoq/descriptors.py +165 -0
  14. pyoq/diagnostics/__init__.py +68 -0
  15. pyoq/diagnostics/budget.py +136 -0
  16. pyoq/diagnostics/events.py +137 -0
  17. pyoq/diagnostics/fingerprint.py +267 -0
  18. pyoq/diagnostics/instrumented.py +237 -0
  19. pyoq/diagnostics/metrics.py +61 -0
  20. pyoq/diagnostics/observation.py +227 -0
  21. pyoq/diagnostics/scoped.py +103 -0
  22. pyoq/django/__init__.py +15 -0
  23. pyoq/django/apps.py +17 -0
  24. pyoq/django/execution.py +317 -0
  25. pyoq/django/generation.py +59 -0
  26. pyoq/django/management/__init__.py +0 -0
  27. pyoq/django/management/commands/__init__.py +0 -0
  28. pyoq/django/management/commands/makemigrations.py +53 -0
  29. pyoq/django/management/commands/pyoq_codegen.py +75 -0
  30. pyoq/django/parameters.py +101 -0
  31. pyoq/django/schema.py +379 -0
  32. pyoq/django/settings.py +87 -0
  33. pyoq/django/timeouts.py +105 -0
  34. pyoq/dsl/__init__.py +64 -0
  35. pyoq/dsl/aio/__init__.py +31 -0
  36. pyoq/dsl/aio/context.py +295 -0
  37. pyoq/dsl/aio/queries.py +335 -0
  38. pyoq/dsl/aio/writes.py +368 -0
  39. pyoq/dsl/context.py +326 -0
  40. pyoq/dsl/entry.py +37 -0
  41. pyoq/dsl/labels.py +36 -0
  42. pyoq/dsl/queries.py +339 -0
  43. pyoq/dsl/result.py +164 -0
  44. pyoq/dsl/writes.py +360 -0
  45. pyoq/errors.py +317 -0
  46. pyoq/fastapi/__init__.py +32 -0
  47. pyoq/fastapi/dependencies.py +167 -0
  48. pyoq/fastapi/lifespan.py +119 -0
  49. pyoq/fetching/__init__.py +55 -0
  50. pyoq/fetching/collections.py +136 -0
  51. pyoq/fetching/execution.py +587 -0
  52. pyoq/fetching/joined.py +79 -0
  53. pyoq/fetching/nesting.py +183 -0
  54. pyoq/fetching/plans.py +541 -0
  55. pyoq/fetching/select_in.py +149 -0
  56. pyoq/fetching/tables.py +110 -0
  57. pyoq/generation/__init__.py +54 -0
  58. pyoq/generation/cleanup.py +44 -0
  59. pyoq/generation/contracts.py +248 -0
  60. pyoq/generation/drift.py +169 -0
  61. pyoq/generation/lock.py +33 -0
  62. pyoq/generation/manifest.py +114 -0
  63. pyoq/generation/model.py +1001 -0
  64. pyoq/generation/pipeline.py +119 -0
  65. pyoq/generation/rendering/__init__.py +5 -0
  66. pyoq/generation/rendering/domains.py +51 -0
  67. pyoq/generation/rendering/enums.py +29 -0
  68. pyoq/generation/rendering/exports.py +70 -0
  69. pyoq/generation/rendering/imports.py +63 -0
  70. pyoq/generation/rendering/package.py +56 -0
  71. pyoq/generation/rendering/relations.py +133 -0
  72. pyoq/generation/rendering/routines.py +396 -0
  73. pyoq/generation/rendering/rows.py +79 -0
  74. pyoq/generation/rendering/source.py +121 -0
  75. pyoq/generation/rendering/tables.py +300 -0
  76. pyoq/generation/rendering/writes.py +514 -0
  77. pyoq/generation/validation.py +27 -0
  78. pyoq/generation/writer.py +184 -0
  79. pyoq/hydration/__init__.py +24 -0
  80. pyoq/hydration/engine.py +155 -0
  81. pyoq/hydration/identity.py +194 -0
  82. pyoq/hydration/plan.py +116 -0
  83. pyoq/migrations/__init__.py +9 -0
  84. pyoq/migrations/alembic.py +106 -0
  85. pyoq/migrations/hooks.py +75 -0
  86. pyoq/naming.py +261 -0
  87. pyoq/policies/__init__.py +47 -0
  88. pyoq/policies/bypass.py +122 -0
  89. pyoq/policies/governed.py +430 -0
  90. pyoq/policies/model.py +242 -0
  91. pyoq/policies/rewriting.py +263 -0
  92. pyoq/py.typed +1 -0
  93. pyoq/query/__init__.py +312 -0
  94. pyoq/query/aggregates.py +172 -0
  95. pyoq/query/arrays.py +65 -0
  96. pyoq/query/binding.py +52 -0
  97. pyoq/query/capabilities.py +317 -0
  98. pyoq/query/casts.py +73 -0
  99. pyoq/query/choices.py +185 -0
  100. pyoq/query/decoding.py +360 -0
  101. pyoq/query/documents.py +56 -0
  102. pyoq/query/execution/__init__.py +63 -0
  103. pyoq/query/execution/aio/__init__.py +31 -0
  104. pyoq/query/execution/aio/operations.py +228 -0
  105. pyoq/query/execution/aio/pooling.py +233 -0
  106. pyoq/query/execution/aio/streaming.py +161 -0
  107. pyoq/query/execution/aio/transactions.py +105 -0
  108. pyoq/query/execution/batch.py +96 -0
  109. pyoq/query/execution/binding_style.py +30 -0
  110. pyoq/query/execution/compilation.py +48 -0
  111. pyoq/query/execution/context.py +61 -0
  112. pyoq/query/execution/control.py +50 -0
  113. pyoq/query/execution/operations.py +224 -0
  114. pyoq/query/execution/planning.py +107 -0
  115. pyoq/query/execution/pooling.py +279 -0
  116. pyoq/query/execution/results.py +36 -0
  117. pyoq/query/execution/streaming.py +178 -0
  118. pyoq/query/execution/transactions.py +95 -0
  119. pyoq/query/expressions.py +1200 -0
  120. pyoq/query/fields.py +60 -0
  121. pyoq/query/mysql/__init__.py +59 -0
  122. pyoq/query/mysql/aio/__init__.py +38 -0
  123. pyoq/query/mysql/aio/commands.py +389 -0
  124. pyoq/query/mysql/aio/driver.py +196 -0
  125. pyoq/query/mysql/aio/executor.py +123 -0
  126. pyoq/query/mysql/aio/factory.py +26 -0
  127. pyoq/query/mysql/aio/operations.py +38 -0
  128. pyoq/query/mysql/aio/pool.py +53 -0
  129. pyoq/query/mysql/aio/transactions.py +313 -0
  130. pyoq/query/mysql/commands.py +354 -0
  131. pyoq/query/mysql/compiler.py +134 -0
  132. pyoq/query/mysql/context.py +20 -0
  133. pyoq/query/mysql/executor.py +126 -0
  134. pyoq/query/mysql/expressions.py +244 -0
  135. pyoq/query/mysql/factory.py +46 -0
  136. pyoq/query/mysql/health.py +66 -0
  137. pyoq/query/mysql/identifiers.py +9 -0
  138. pyoq/query/mysql/model.py +79 -0
  139. pyoq/query/mysql/operations.py +43 -0
  140. pyoq/query/mysql/parameters.py +69 -0
  141. pyoq/query/mysql/planning.py +20 -0
  142. pyoq/query/mysql/pool.py +67 -0
  143. pyoq/query/mysql/transactions.py +331 -0
  144. pyoq/query/mysql/writes.py +73 -0
  145. pyoq/query/nodes.py +750 -0
  146. pyoq/query/postgres/__init__.py +48 -0
  147. pyoq/query/postgres/aio/__init__.py +25 -0
  148. pyoq/query/postgres/aio/bulk.py +56 -0
  149. pyoq/query/postgres/aio/commands.py +264 -0
  150. pyoq/query/postgres/aio/executor.py +152 -0
  151. pyoq/query/postgres/aio/factory.py +26 -0
  152. pyoq/query/postgres/aio/operations.py +26 -0
  153. pyoq/query/postgres/aio/pool.py +40 -0
  154. pyoq/query/postgres/aio/transactions.py +295 -0
  155. pyoq/query/postgres/bulk.py +62 -0
  156. pyoq/query/postgres/commands.py +238 -0
  157. pyoq/query/postgres/compiler.py +114 -0
  158. pyoq/query/postgres/context.py +20 -0
  159. pyoq/query/postgres/executor.py +147 -0
  160. pyoq/query/postgres/expressions.py +311 -0
  161. pyoq/query/postgres/factory.py +24 -0
  162. pyoq/query/postgres/health.py +24 -0
  163. pyoq/query/postgres/identifiers.py +9 -0
  164. pyoq/query/postgres/model.py +81 -0
  165. pyoq/query/postgres/operations.py +25 -0
  166. pyoq/query/postgres/parameters.py +71 -0
  167. pyoq/query/postgres/planning.py +20 -0
  168. pyoq/query/postgres/pool.py +52 -0
  169. pyoq/query/postgres/transactions.py +295 -0
  170. pyoq/query/postgres/writes.py +37 -0
  171. pyoq/query/projections.py +105 -0
  172. pyoq/query/raw.py +90 -0
  173. pyoq/query/recursion.py +265 -0
  174. pyoq/query/rendering/__init__.py +1 -0
  175. pyoq/query/rendering/expressions.py +913 -0
  176. pyoq/query/rendering/identifiers.py +40 -0
  177. pyoq/query/rendering/projections.py +63 -0
  178. pyoq/query/rendering/queries.py +334 -0
  179. pyoq/query/rendering/sources.py +66 -0
  180. pyoq/query/rendering/writes.py +176 -0
  181. pyoq/query/results.py +459 -0
  182. pyoq/query/routines.py +196 -0
  183. pyoq/query/rows.py +156 -0
  184. pyoq/query/select.py +793 -0
  185. pyoq/query/select_nodes.py +277 -0
  186. pyoq/query/sources.py +236 -0
  187. pyoq/query/sqlite/__init__.py +43 -0
  188. pyoq/query/sqlite/commands.py +201 -0
  189. pyoq/query/sqlite/compiler.py +139 -0
  190. pyoq/query/sqlite/context.py +20 -0
  191. pyoq/query/sqlite/executor.py +119 -0
  192. pyoq/query/sqlite/expressions.py +224 -0
  193. pyoq/query/sqlite/factory.py +32 -0
  194. pyoq/query/sqlite/health.py +28 -0
  195. pyoq/query/sqlite/identifiers.py +9 -0
  196. pyoq/query/sqlite/model.py +73 -0
  197. pyoq/query/sqlite/operations.py +36 -0
  198. pyoq/query/sqlite/parameters.py +50 -0
  199. pyoq/query/sqlite/planning.py +20 -0
  200. pyoq/query/sqlite/pool.py +50 -0
  201. pyoq/query/sqlite/streaming.py +13 -0
  202. pyoq/query/sqlite/transactions.py +274 -0
  203. pyoq/query/sqlite/writes.py +35 -0
  204. pyoq/query/statements.py +27 -0
  205. pyoq/query/values.py +23 -0
  206. pyoq/query/vendor.py +162 -0
  207. pyoq/query/windows.py +424 -0
  208. pyoq/query/write_nodes.py +174 -0
  209. pyoq/query/writes.py +628 -0
  210. pyoq/relations/__init__.py +66 -0
  211. pyoq/relations/batching.py +219 -0
  212. pyoq/relations/derivation.py +111 -0
  213. pyoq/relations/fetching.py +355 -0
  214. pyoq/relations/graph.py +245 -0
  215. pyoq/relations/loading.py +74 -0
  216. pyoq/relations/model.py +75 -0
  217. pyoq/relations/planning.py +206 -0
  218. pyoq/runtime/__init__.py +9 -0
  219. pyoq/runtime/kernels.py +25 -0
  220. pyoq/runtime/python.py +43 -0
  221. pyoq/runtime/selection.py +73 -0
  222. pyoq/sanic/__init__.py +32 -0
  223. pyoq/sanic/scope.py +197 -0
  224. pyoq/sanic/workers.py +129 -0
  225. pyoq/schema/__init__.py +108 -0
  226. pyoq/schema/codec.py +711 -0
  227. pyoq/schema/models.py +604 -0
  228. pyoq/schema/mysql/__init__.py +16 -0
  229. pyoq/schema/mysql/connection.py +73 -0
  230. pyoq/schema/mysql/dsn.py +72 -0
  231. pyoq/schema/mysql/records.py +354 -0
  232. pyoq/schema/mysql/reflection.py +309 -0
  233. pyoq/schema/mysql/source.py +30 -0
  234. pyoq/schema/mysql/sql.py +128 -0
  235. pyoq/schema/mysql/types.py +105 -0
  236. pyoq/schema/postgres/__init__.py +13 -0
  237. pyoq/schema/postgres/connection.py +63 -0
  238. pyoq/schema/postgres/records.py +384 -0
  239. pyoq/schema/postgres/reflection.py +466 -0
  240. pyoq/schema/postgres/source.py +30 -0
  241. pyoq/schema/postgres/sql.py +246 -0
  242. pyoq/schema/postgres/types.py +98 -0
  243. pyoq/schema/registry.py +45 -0
  244. pyoq/schema/source.py +15 -0
  245. pyoq/schema/sqlite/__init__.py +6 -0
  246. pyoq/schema/sqlite/connection.py +54 -0
  247. pyoq/schema/sqlite/records.py +167 -0
  248. pyoq/schema/sqlite/reflection.py +393 -0
  249. pyoq/schema/sqlite/source.py +30 -0
  250. pyoq/schema/sqlite/sql.py +254 -0
  251. pyoq/schema/sqlite/types.py +74 -0
  252. pyoq/serving/__init__.py +23 -0
  253. pyoq/serving/databases.py +107 -0
  254. pyoq/serving/opening.py +331 -0
  255. pyoq/snapshots/__init__.py +20 -0
  256. pyoq/snapshots/drift.py +312 -0
  257. pyoq/snapshots/files.py +96 -0
  258. pyoq/snapshots/routing.py +40 -0
  259. pyoq/snapshots/source.py +33 -0
  260. pyoq/tracing/__init__.py +5 -0
  261. pyoq/tracing/spans.py +89 -0
  262. pyoq/unset.py +14 -0
  263. pyoq_sql-1.0.2.dist-info/METADATA +3050 -0
  264. pyoq_sql-1.0.2.dist-info/RECORD +267 -0
  265. pyoq_sql-1.0.2.dist-info/WHEEL +4 -0
  266. pyoq_sql-1.0.2.dist-info/entry_points.txt +3 -0
  267. pyoq_sql-1.0.2.dist-info/licenses/LICENSE +373 -0
@@ -0,0 +1,106 @@
1
+ """Bringing PyOQ back in step around an Alembic migration.
2
+
3
+ Alembic changes a database in `alembic upgrade`. It does not change one in
4
+ `alembic revision`, and a post write hook runs there: Alembic hands each hook
5
+ the revision file it has just written, while the database is still whatever it
6
+ was. Recording the schema at that moment records the schema the migration is
7
+ about to replace, and generates from it, which is why this is not a post write
8
+ hook.
9
+
10
+ The step belongs after the upgrade. Alembic offers two places for one.
11
+
12
+ In `env.py`, which the project owns, once the migrations have run:
13
+
14
+ from pyoq.migrations import after_migration_at
15
+
16
+ with connectable.connect() as connection:
17
+ context.configure(connection=connection, target_metadata=target_metadata)
18
+ with context.begin_transaction():
19
+ context.run_migrations()
20
+ after_migration_at(PROJECT_ROOT)
21
+
22
+ Or as a step of its own, after the upgrade has finished:
23
+
24
+ alembic upgrade head && pyoq-after-migration
25
+
26
+ The command takes the project directory, or nothing and uses the working
27
+ directory. Being handed a file is how a post write hook calls a command, so
28
+ that is refused and says why rather than recording the schema a migration has
29
+ not yet reached.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import sys
35
+ from pathlib import Path
36
+ from typing import IO, NoReturn
37
+
38
+ from pyoq.errors import ConfigurationFileError, PyOQError
39
+ from pyoq.migrations.hooks import after_migration_at
40
+
41
+ _PYPROJECT = "pyproject.toml"
42
+
43
+
44
+ def main(
45
+ arguments: list[str] | None = None,
46
+ *,
47
+ output: IO[str] | None = None,
48
+ error: IO[str] | None = None,
49
+ ) -> int:
50
+ """Bring the snapshot and the generated package back in step."""
51
+ written = sys.stdout if output is None else output
52
+ failed = sys.stderr if error is None else error
53
+ given = sys.argv[1:] if arguments is None else arguments
54
+ try:
55
+ root = _project_root(given)
56
+ outcome = after_migration_at(root)
57
+ except PyOQError as problem:
58
+ failed.write(f"pyoq: {problem}\n")
59
+ return 1
60
+ written.write(f"{outcome.describe()}\n")
61
+ return 0
62
+
63
+
64
+ def run() -> NoReturn:
65
+ raise SystemExit(main())
66
+
67
+
68
+ def _project_root(arguments: list[str]) -> Path:
69
+ """The project to bring back in step, named by a directory inside it."""
70
+ if not arguments:
71
+ return _declaring(Path.cwd())
72
+ given = Path(arguments[0]).resolve()
73
+ _require_directory(given)
74
+ return _declaring(given)
75
+
76
+
77
+ def _require_directory(given: Path) -> None:
78
+ """A file is what a post write hook passes, and that is the wrong moment.
79
+
80
+ Alembic runs a post write hook inside `alembic revision`, handing it the
81
+ revision it has just written. The database has not been upgraded yet, so
82
+ recording it there records the schema this migration replaces.
83
+ """
84
+ if given.is_dir():
85
+ return
86
+ message = (
87
+ f"{given} is not a directory. This command takes the project to bring "
88
+ "back in step, and runs after a migration has been applied. Alembic "
89
+ "passes a written revision to a post write hook, which runs inside "
90
+ "`alembic revision` while the database is still what it was, so this "
91
+ "is not one. Run it after `alembic upgrade`, or call "
92
+ "`after_migration_at` from `env.py` once the migrations have run."
93
+ )
94
+ raise ConfigurationFileError(message)
95
+
96
+
97
+ def _declaring(directory: Path) -> Path:
98
+ """The first directory at or above this one that a project declares itself in."""
99
+ for candidate in (directory, *directory.parents):
100
+ if (candidate / _PYPROJECT).is_file():
101
+ return candidate
102
+ message = f"no {_PYPROJECT} at or above {directory}"
103
+ raise ConfigurationFileError(message)
104
+
105
+
106
+ __all__ = ("main", "run")
@@ -0,0 +1,75 @@
1
+ """Bringing generated code back in step after a migration runs.
2
+
3
+ A migration changes the schema, which makes the generated package stale and
4
+ the recorded snapshot wrong. Every migration tool has somewhere to run a step
5
+ after it writes or applies, and each of them needs the same two things done,
6
+ so the work lives here once and the tools call it.
7
+
8
+ Nothing here imports a migration tool. A hook is an ordinary callable a
9
+ project wires up, so Alembic, Django, a shell script, or a CI job all reach
10
+ the same code and none of them is a dependency.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+ from typing import TYPE_CHECKING
18
+
19
+ from pyoq.cli.services import CommandServices
20
+ from pyoq.config import load_configuration
21
+ from pyoq.generation import GenerationMode
22
+
23
+ if TYPE_CHECKING:
24
+ from pyoq.config import Configuration
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class MigrationOutcome:
29
+ """What the hook did, in the words each step reported."""
30
+
31
+ recorded: str
32
+ generated: str
33
+
34
+ def describe(self) -> str:
35
+ return f"{self.recorded}; {self.generated}"
36
+
37
+
38
+ def after_migration(
39
+ configuration: Configuration,
40
+ /,
41
+ *,
42
+ services: CommandServices | None = None,
43
+ ) -> MigrationOutcome:
44
+ """Record the schema again and bring the generated package back in step.
45
+
46
+ Both, and in that order: the snapshot is what generation reads offline, so
47
+ a package regenerated before the snapshot is written would be generated
48
+ from the schema that has just been replaced.
49
+ """
50
+ running = services or CommandServices.defaults()
51
+ recorded = running.snapshot.execute(configuration)
52
+ generated = running.generation.execute(configuration, GenerationMode.WRITE)
53
+ return MigrationOutcome(recorded, generated)
54
+
55
+
56
+ def after_migration_at(
57
+ project_root: Path | str,
58
+ /,
59
+ *,
60
+ selected_profile: str | None = None,
61
+ services: CommandServices | None = None,
62
+ ) -> MigrationOutcome:
63
+ """The same, for a caller that has a project directory and no configuration.
64
+
65
+ Migration tools know where the project is and nothing about PyOQ's
66
+ configuration, so this loads it the way the command line does.
67
+ """
68
+ configuration = load_configuration(
69
+ Path(project_root),
70
+ selected_profile=selected_profile,
71
+ )
72
+ return after_migration(configuration, services=services)
73
+
74
+
75
+ __all__ = ("MigrationOutcome", "after_migration", "after_migration_at")
pyoq/naming.py ADDED
@@ -0,0 +1,261 @@
1
+ """Deterministic database-to-Python naming policy."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import keyword
6
+ import unicodedata
7
+ from collections.abc import Iterable
8
+ from dataclasses import dataclass
9
+ from enum import StrEnum
10
+
11
+ from pyoq.errors import NamingCollisionError, NamingValidationError
12
+ from pyoq.schema import Identifier
13
+
14
+
15
+ class PythonNameStyle(StrEnum):
16
+ SNAKE_CASE = "snake-case"
17
+ PASCAL_CASE = "pascal-case"
18
+ UPPER_SNAKE_CASE = "upper-snake-case"
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class NameRequest:
23
+ key: str
24
+ source: Identifier
25
+
26
+ def __post_init__(self) -> None:
27
+ _require_text(self.key, "name request key")
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class NamingScope:
32
+ name: str
33
+ style: PythonNameStyle
34
+ requests: tuple[NameRequest, ...]
35
+ reserved_names: tuple[str, ...] = ()
36
+
37
+ def __post_init__(self) -> None:
38
+ _require_text(self.name, "naming scope")
39
+ _require_unique_request_keys(self.requests, self.name)
40
+ canonical_reserved_names = _canonical_reserved_names(self.reserved_names)
41
+ object.__setattr__(self, "reserved_names", canonical_reserved_names)
42
+
43
+
44
+ @dataclass(frozen=True, slots=True, order=True)
45
+ class ResolvedName:
46
+ scope: str
47
+ key: str
48
+ source: Identifier
49
+ python_name: str
50
+
51
+
52
+ @dataclass(frozen=True, slots=True, order=True)
53
+ class NameCollision:
54
+ scope: str
55
+ python_name: str
56
+ requests: tuple[NameRequest, ...]
57
+
58
+
59
+ @dataclass(frozen=True, slots=True)
60
+ class NamingResult:
61
+ names: tuple[ResolvedName, ...]
62
+
63
+ def get(self, scope: str, key: str) -> str:
64
+ for resolved_name in self.names:
65
+ if resolved_name.scope == scope and resolved_name.key == key:
66
+ return resolved_name.python_name
67
+ message = f"no generated name exists for scope {scope!r} and key {key!r}"
68
+ raise KeyError(message)
69
+
70
+
71
+ @dataclass(frozen=True, slots=True)
72
+ class NamingPolicy:
73
+ """Resolve complete naming scopes without retaining mutable state."""
74
+
75
+ def resolve(self, scopes: tuple[NamingScope, ...]) -> NamingResult:
76
+ _require_unique_scope_names(scopes)
77
+ resolved_names: list[ResolvedName] = []
78
+ collisions: list[NameCollision] = []
79
+ for scope in sorted(scopes, key=lambda candidate: candidate.name):
80
+ scope_names, scope_collisions = _resolve_scope(scope)
81
+ resolved_names.extend(scope_names)
82
+ collisions.extend(scope_collisions)
83
+ if collisions:
84
+ ordered_collisions = tuple(sorted(collisions))
85
+ raise NamingCollisionError(
86
+ _collision_message(ordered_collisions),
87
+ ordered_collisions,
88
+ )
89
+ return NamingResult(tuple(sorted(resolved_names)))
90
+
91
+
92
+ def _resolve_scope(
93
+ scope: NamingScope,
94
+ ) -> tuple[list[ResolvedName], list[NameCollision]]:
95
+ grouped_requests: dict[str, list[NameRequest]] = {}
96
+ reserved_names = frozenset(scope.reserved_names)
97
+ for request in scope.requests:
98
+ python_name = _resolve_python_name(
99
+ request.source.value,
100
+ scope.style,
101
+ reserved_names,
102
+ )
103
+ grouped_requests.setdefault(python_name, []).append(request)
104
+ return _partition_scope_names(scope.name, grouped_requests)
105
+
106
+
107
+ def _partition_scope_names(
108
+ scope_name: str,
109
+ grouped_requests: dict[str, list[NameRequest]],
110
+ ) -> tuple[list[ResolvedName], list[NameCollision]]:
111
+ resolved_names: list[ResolvedName] = []
112
+ collisions: list[NameCollision] = []
113
+ for python_name, requests in sorted(grouped_requests.items()):
114
+ ordered_requests = tuple(sorted(requests, key=lambda request: request.key))
115
+ if len(ordered_requests) > 1:
116
+ collisions.append(NameCollision(scope_name, python_name, ordered_requests))
117
+ continue
118
+ request = next(iter(ordered_requests))
119
+ resolved_names.append(
120
+ ResolvedName(scope_name, request.key, request.source, python_name)
121
+ )
122
+ return resolved_names, collisions
123
+
124
+
125
+ def _resolve_python_name(
126
+ source: str,
127
+ style: PythonNameStyle,
128
+ reserved_names: frozenset[str],
129
+ ) -> str:
130
+ words = _identifier_words(source)
131
+ styled_name = _apply_style(words, style)
132
+ python_name = _prefix_leading_digit(styled_name)
133
+ while _is_reserved(python_name, reserved_names):
134
+ python_name += "_"
135
+ return python_name
136
+
137
+
138
+ def _identifier_words(source: str) -> tuple[str, ...]:
139
+ normalized_source = unicodedata.normalize("NFKC", source)
140
+ words: list[str] = []
141
+ current_word: list[str] = []
142
+ for index, character in enumerate(normalized_source):
143
+ if not _is_word_character(character, bool(current_word)):
144
+ _finish_word(words, current_word)
145
+ continue
146
+ if current_word and _starts_word(normalized_source, index):
147
+ _finish_word(words, current_word)
148
+ current_word.append(character)
149
+ _finish_word(words, current_word)
150
+ return tuple(words) or ("unnamed",)
151
+
152
+
153
+ def _is_word_character(character: str, word_started: bool) -> bool:
154
+ if character == "_":
155
+ return False
156
+ if word_started:
157
+ return f"a{character}".isidentifier()
158
+ return character.isidentifier() or character.isdecimal()
159
+
160
+
161
+ def _starts_word(source: str, index: int) -> bool:
162
+ previous = source[index - 1]
163
+ current = source[index]
164
+ following = source[index + 1] if index + 1 < len(source) else ""
165
+ if previous.isdigit() != current.isdigit():
166
+ return True
167
+ if previous.islower() and current.isupper():
168
+ return True
169
+ return previous.isupper() and current.isupper() and following.islower()
170
+
171
+
172
+ def _finish_word(words: list[str], current_word: list[str]) -> None:
173
+ if current_word:
174
+ words.append("".join(current_word))
175
+ current_word.clear()
176
+
177
+
178
+ def _apply_style(words: tuple[str, ...], style: PythonNameStyle) -> str:
179
+ if style is PythonNameStyle.SNAKE_CASE:
180
+ return "_".join(word.lower() for word in words)
181
+ if style is PythonNameStyle.UPPER_SNAKE_CASE:
182
+ return "_".join(word.upper() for word in words)
183
+ return "".join(_capitalize_word(word) for word in words)
184
+
185
+
186
+ def _capitalize_word(word: str) -> str:
187
+ return word[:1].upper() + word[1:].lower()
188
+
189
+
190
+ def _prefix_leading_digit(name: str) -> str:
191
+ return f"_{name}" if name[0].isdigit() else name
192
+
193
+
194
+ def _is_reserved(name: str, reserved_names: frozenset[str]) -> bool:
195
+ return (
196
+ keyword.iskeyword(name) or keyword.issoftkeyword(name) or name in reserved_names
197
+ )
198
+
199
+
200
+ def _canonical_reserved_names(names: tuple[object, ...]) -> tuple[str, ...]:
201
+ valid_names: list[str] = []
202
+ for name in names:
203
+ if not isinstance(name, str) or not name.isidentifier():
204
+ message = f"reserved Python name must be a valid identifier: {name!r}"
205
+ raise NamingValidationError(message)
206
+ valid_names.append(name)
207
+ return tuple(sorted(set(valid_names)))
208
+
209
+
210
+ def _require_unique_request_keys(requests: tuple[NameRequest, ...], scope: str) -> None:
211
+ duplicate_keys = _duplicate_values(request.key for request in requests)
212
+ if duplicate_keys:
213
+ message = (
214
+ f"naming scope {scope!r} contains duplicate request keys: "
215
+ f"{duplicate_keys!r}"
216
+ )
217
+ raise NamingValidationError(message)
218
+
219
+
220
+ def _require_unique_scope_names(scopes: tuple[NamingScope, ...]) -> None:
221
+ duplicate_names = _duplicate_values(scope.name for scope in scopes)
222
+ if duplicate_names:
223
+ message = f"naming batch contains duplicate scopes: {duplicate_names!r}"
224
+ raise NamingValidationError(message)
225
+
226
+
227
+ def _duplicate_values(values: Iterable[str]) -> list[str]:
228
+ seen: set[str] = set()
229
+ duplicates: set[str] = set()
230
+ for value in values:
231
+ if value in seen:
232
+ duplicates.add(value)
233
+ seen.add(value)
234
+ return sorted(duplicates)
235
+
236
+
237
+ def _collision_message(collisions: tuple[NameCollision, ...]) -> str:
238
+ descriptions: list[str] = []
239
+ for collision in collisions:
240
+ request_keys = ", ".join(request.key for request in collision.requests)
241
+ descriptions.append(
242
+ f"{collision.scope!r}.{collision.python_name} from [{request_keys}]"
243
+ )
244
+ return "generated name collisions: " + "; ".join(descriptions)
245
+
246
+
247
+ def _require_text(value: object, label: str) -> None:
248
+ if not isinstance(value, str) or not value:
249
+ message = f"{label} must be a non-empty string"
250
+ raise NamingValidationError(message)
251
+
252
+
253
+ __all__ = (
254
+ "NameCollision",
255
+ "NameRequest",
256
+ "NamingPolicy",
257
+ "NamingResult",
258
+ "NamingScope",
259
+ "PythonNameStyle",
260
+ "ResolvedName",
261
+ )
@@ -0,0 +1,47 @@
1
+ """Holding every statement to the rules a project set for it."""
2
+
3
+ from pyoq.policies.bypass import (
4
+ AuditSink,
5
+ Bypass,
6
+ CollectingAudit,
7
+ administrative_bypass,
8
+ async_administrative_bypass,
9
+ )
10
+ from pyoq.policies.governed import (
11
+ AsyncGovernedOperations,
12
+ GovernedCompiler,
13
+ GovernedOperations,
14
+ async_governed,
15
+ governed,
16
+ )
17
+ from pyoq.policies.model import (
18
+ AllowedTables,
19
+ Policies,
20
+ Policy,
21
+ RowConstraint,
22
+ SoftDelete,
23
+ TableReference,
24
+ TenantScope,
25
+ )
26
+ from pyoq.policies.rewriting import govern
27
+
28
+ __all__ = (
29
+ "AllowedTables",
30
+ "AsyncGovernedOperations",
31
+ "AuditSink",
32
+ "Bypass",
33
+ "CollectingAudit",
34
+ "GovernedCompiler",
35
+ "GovernedOperations",
36
+ "Policies",
37
+ "Policy",
38
+ "RowConstraint",
39
+ "SoftDelete",
40
+ "TableReference",
41
+ "TenantScope",
42
+ "administrative_bypass",
43
+ "async_administrative_bypass",
44
+ "async_governed",
45
+ "govern",
46
+ "governed",
47
+ )
@@ -0,0 +1,122 @@
1
+ """Going around a policy on purpose, and saying so.
2
+
3
+ A policy that can be set aside quietly is a policy nobody can rely on. Going
4
+ around one asks for a reason and somewhere to record it, and hands back the
5
+ ungoverned database only for as long as the block runs, so it cannot be kept.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from contextlib import contextmanager
11
+ from dataclasses import dataclass
12
+ from typing import TYPE_CHECKING, Protocol
13
+
14
+ from pyoq.errors import PolicyViolationError
15
+ from pyoq.policies.governed import AsyncGovernedOperations, GovernedOperations
16
+
17
+ if TYPE_CHECKING:
18
+ from collections.abc import Generator
19
+
20
+ from pyoq.query.execution import QueryOperations
21
+ from pyoq.query.execution.aio import AsyncQueryOperations
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class Bypass:
26
+ """One deliberate step around the policies a database was given."""
27
+
28
+ reason: str
29
+ policies: tuple[str, ...]
30
+ entered: bool = True
31
+
32
+
33
+ class AuditSink(Protocol):
34
+ """Somewhere a bypass is written down."""
35
+
36
+ def record(self, bypass: Bypass, /) -> None: ...
37
+
38
+
39
+ class CollectingAudit:
40
+ """Keeps what it is told, for a caller that wants to look afterwards."""
41
+
42
+ __slots__ = ("entries",)
43
+ entries: list[Bypass]
44
+
45
+ def __init__(self) -> None:
46
+ self.entries = []
47
+
48
+ def record(self, bypass: Bypass, /) -> None:
49
+ self.entries.append(bypass)
50
+
51
+
52
+ @contextmanager
53
+ def administrative_bypass(
54
+ database: GovernedOperations,
55
+ reason: str,
56
+ audit: AuditSink,
57
+ /,
58
+ ) -> Generator[QueryOperations]:
59
+ """The database without its policies, for as long as this block runs.
60
+
61
+ The ungoverned database is yielded rather than returned, so it belongs to
62
+ the block and cannot be kept past it.
63
+ """
64
+ _require_reason(reason)
65
+ audit.record(_entering(database, reason))
66
+ try:
67
+ yield database.released
68
+ finally:
69
+ audit.record(_leaving(database, reason))
70
+
71
+
72
+ @contextmanager
73
+ def async_administrative_bypass(
74
+ database: AsyncGovernedOperations,
75
+ reason: str,
76
+ audit: AuditSink,
77
+ /,
78
+ ) -> Generator[AsyncQueryOperations]:
79
+ """The same, for a database that is awaited."""
80
+ _require_reason(reason)
81
+ audit.record(_entering(database, reason))
82
+ try:
83
+ yield database.released
84
+ finally:
85
+ audit.record(_leaving(database, reason))
86
+
87
+
88
+ def _entering(
89
+ database: GovernedOperations | AsyncGovernedOperations,
90
+ reason: str,
91
+ ) -> Bypass:
92
+ return Bypass(reason, _named(database), entered=True)
93
+
94
+
95
+ def _leaving(
96
+ database: GovernedOperations | AsyncGovernedOperations,
97
+ reason: str,
98
+ ) -> Bypass:
99
+ return Bypass(reason, _named(database), entered=False)
100
+
101
+
102
+ def _named(
103
+ database: GovernedOperations | AsyncGovernedOperations,
104
+ ) -> tuple[str, ...]:
105
+ return tuple(type(policy).__name__ for policy in database.policies.members)
106
+
107
+
108
+ def _require_reason(reason: str) -> None:
109
+ """A bypass with nothing written against it is one nobody can review."""
110
+ if reason.strip():
111
+ return
112
+ message = "going around a policy needs a reason that says why"
113
+ raise PolicyViolationError(message)
114
+
115
+
116
+ __all__ = (
117
+ "AuditSink",
118
+ "Bypass",
119
+ "CollectingAudit",
120
+ "administrative_bypass",
121
+ "async_administrative_bypass",
122
+ )