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,110 @@
1
+ """One appearance of a table in a statement, and the name it answers to.
2
+
3
+ A statement can read one table more than once: two relations reaching the same
4
+ table, or a relation reaching the table it starts from. What qualifies a
5
+ column is therefore the appearance rather than the table, and an appearance
6
+ that shares its table's name with another is what makes a column ambiguous.
7
+
8
+ Every appearance but the one a statement starts from is given a name of its
9
+ own. They are numbered in the order the plan is walked, which the plan fixes,
10
+ so one plan always compiles to the same SQL.
11
+
12
+ A name is only its own if nothing else already answers to it. `pyoq_t1` is a
13
+ legal table name and a legal column name, and a database is entitled to use
14
+ both, so the names already in the schema are reserved and skipped rather than
15
+ assumed not to exist.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from dataclasses import dataclass
21
+ from typing import TYPE_CHECKING
22
+
23
+ from pyoq.query.nodes import FieldNode
24
+
25
+ if TYPE_CHECKING:
26
+ from pyoq.query.nodes import ScalarFamily
27
+ from pyoq.schema.models import Identifier, ObjectReference
28
+
29
+ _PREFIX = "pyoq_t"
30
+
31
+
32
+ @dataclass(frozen=True, slots=True)
33
+ class TableBinding:
34
+ """A table, and what a column of it is written under in this statement."""
35
+
36
+ table: ObjectReference
37
+ alias: str | None = None
38
+
39
+ @property
40
+ def qualifier(self) -> str:
41
+ """What a column of this appearance is qualified by."""
42
+ return self.table.name.value if self.alias is None else self.alias
43
+
44
+ @property
45
+ def schema(self) -> str | None:
46
+ """A name of its own stands alone, so it carries no schema."""
47
+ return None if self.alias is not None else _named(self.table.schema)
48
+
49
+ @property
50
+ def catalog(self) -> str | None:
51
+ return None if self.alias is not None else _named(self.table.catalog)
52
+
53
+ def field(self, column: Identifier, family: ScalarFamily, /) -> FieldNode:
54
+ return FieldNode(
55
+ column.value,
56
+ self.qualifier,
57
+ self.schema,
58
+ self.catalog,
59
+ family,
60
+ )
61
+
62
+
63
+ class TableNames:
64
+ """Names for the appearances one statement reads, each different.
65
+
66
+ Numbered rather than derived from the table, because two appearances of
67
+ one table would derive the same name, which is the problem being solved.
68
+
69
+ ``reserved`` is what the schema already calls things. A generated name
70
+ that matched one of those would shadow it: a collection correlated to a
71
+ table whose name it had taken would compare that table with itself and
72
+ gather nothing, which is a wrong answer rather than an error.
73
+ """
74
+
75
+ __slots__ = ("_reserved", "_taken")
76
+
77
+ def __init__(self, reserved: frozenset[str] = frozenset()) -> None:
78
+ self._reserved = reserved
79
+ self._taken = 0
80
+
81
+ def next(self, table: ObjectReference, /) -> TableBinding:
82
+ return TableBinding(table, self._unused())
83
+
84
+ def _unused(self) -> str:
85
+ """The next name nothing else answers to."""
86
+ while True:
87
+ self._taken += 1
88
+ candidate = f"{_PREFIX}{self._taken}"
89
+ if not self._claimed(candidate):
90
+ return candidate
91
+
92
+ def _claimed(self, candidate: str) -> bool:
93
+ """Whether the schema already calls something this.
94
+
95
+ A name a collection carries a joined value under is written as the
96
+ appearance and the column with a dot between them, and a column may be
97
+ named with a dot in it, so a name that any reserved one begins with is
98
+ taken as well.
99
+ """
100
+ if candidate in self._reserved:
101
+ return True
102
+ beginning = f"{candidate}."
103
+ return any(name.startswith(beginning) for name in self._reserved)
104
+
105
+
106
+ def _named(value: Identifier | None) -> str | None:
107
+ return None if value is None else value.value
108
+
109
+
110
+ __all__ = ("TableBinding", "TableNames")
@@ -0,0 +1,54 @@
1
+ """Public schema generation pipeline."""
2
+
3
+ from pyoq.generation.cleanup import ManifestOwnedCleanup
4
+ from pyoq.generation.contracts import (
5
+ MANIFEST_FORMAT_VERSION,
6
+ MANIFEST_NAME,
7
+ ConcernRenderer,
8
+ DriftChecker,
9
+ DriftReport,
10
+ GenerationMode,
11
+ GenerationPlan,
12
+ ManifestStore,
13
+ OwnedFile,
14
+ OwnershipManifest,
15
+ PlanValidator,
16
+ PlanWriter,
17
+ ProjectLock,
18
+ RenderedFile,
19
+ )
20
+ from pyoq.generation.drift import FileDriftChecker
21
+ from pyoq.generation.lock import DirectoryProjectLock
22
+ from pyoq.generation.manifest import JsonManifestStore
23
+ from pyoq.generation.pipeline import GenerationPipeline, GenerationPlanner
24
+ from pyoq.generation.rendering import GeneratedTypesRenderer
25
+ from pyoq.generation.validation import PythonSyntaxValidator
26
+ from pyoq.generation.writer import AtomicGenerationWriter
27
+ from pyoq.schema import SchemaSource
28
+
29
+ __all__ = (
30
+ "MANIFEST_FORMAT_VERSION",
31
+ "MANIFEST_NAME",
32
+ "AtomicGenerationWriter",
33
+ "ConcernRenderer",
34
+ "DirectoryProjectLock",
35
+ "DriftChecker",
36
+ "DriftReport",
37
+ "FileDriftChecker",
38
+ "GeneratedTypesRenderer",
39
+ "GenerationMode",
40
+ "GenerationPipeline",
41
+ "GenerationPlan",
42
+ "GenerationPlanner",
43
+ "JsonManifestStore",
44
+ "ManifestOwnedCleanup",
45
+ "ManifestStore",
46
+ "OwnedFile",
47
+ "OwnershipManifest",
48
+ "PlanValidator",
49
+ "PlanWriter",
50
+ "ProjectLock",
51
+ "PythonSyntaxValidator",
52
+ "RenderedFile",
53
+ "SchemaSource",
54
+ )
@@ -0,0 +1,44 @@
1
+ """Checksum-guarded cleanup of manifest-owned files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from hashlib import sha256
6
+ from pathlib import Path, PurePosixPath
7
+
8
+ from pyoq.errors import GenerationOwnershipError
9
+ from pyoq.generation.contracts import OwnershipManifest, output_path
10
+
11
+
12
+ class ManifestOwnedCleanup:
13
+ def remove_stale(
14
+ self,
15
+ staging_root: Path,
16
+ current_manifest: OwnershipManifest | None,
17
+ retained_paths: frozenset[PurePosixPath],
18
+ ) -> None:
19
+ if current_manifest is None:
20
+ return
21
+ for owned_file in current_manifest.files:
22
+ if owned_file.path in retained_paths:
23
+ continue
24
+ _require_safe_parent(staging_root, owned_file.path)
25
+ stale_path = output_path(staging_root, owned_file.path)
26
+ if not stale_path.exists() and not stale_path.is_symlink():
27
+ continue
28
+ if (
29
+ not stale_path.is_file()
30
+ or stale_path.is_symlink()
31
+ or sha256(stale_path.read_bytes()).hexdigest() != owned_file.digest
32
+ ):
33
+ message = f"refusing to remove modified owned file: {owned_file.path}"
34
+ raise GenerationOwnershipError(message)
35
+ stale_path.unlink()
36
+
37
+
38
+ def _require_safe_parent(root: Path, relative_path: PurePosixPath) -> None:
39
+ current = root
40
+ for part in relative_path.parts[:-1]:
41
+ current /= part
42
+ if current.is_symlink() or (current.exists() and not current.is_dir()):
43
+ message = f"unsafe parent for owned file: {relative_path}"
44
+ raise GenerationOwnershipError(message)
@@ -0,0 +1,248 @@
1
+ """Generation models and component protocols."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from contextlib import AbstractContextManager
6
+ from dataclasses import dataclass
7
+ from enum import StrEnum
8
+ from hashlib import sha256
9
+ from pathlib import Path, PurePosixPath
10
+ from typing import Protocol
11
+ from unicodedata import normalize
12
+
13
+ from pyoq.config import Configuration
14
+ from pyoq.errors import GenerationValidationError
15
+ from pyoq.schema import SchemaSnapshot
16
+
17
+ MANIFEST_FORMAT_VERSION = 1
18
+ MANIFEST_NAME = ".pyoq-manifest.json"
19
+ _WINDOWS_INVALID_CHARACTERS = frozenset('<>:"|?*')
20
+ _WINDOWS_RESERVED_STEMS = frozenset(
21
+ {"con", "prn", "aux", "nul"}
22
+ | {f"com{number}" for number in range(1, 10)}
23
+ | {f"lpt{number}" for number in range(1, 10)}
24
+ )
25
+
26
+
27
+ class GenerationMode(StrEnum):
28
+ WRITE = "write"
29
+ CHECK = "check"
30
+ DRY_RUN = "dry-run"
31
+
32
+
33
+ @dataclass(frozen=True, slots=True, order=True)
34
+ class RenderedFile:
35
+ path: PurePosixPath
36
+ content: str
37
+
38
+ def __post_init__(self) -> None:
39
+ _validate_output_path(self.path)
40
+ canonical_content = self.content.replace("\r\n", "\n").replace("\r", "\n")
41
+ object.__setattr__(self, "content", canonical_content)
42
+
43
+ @property
44
+ def encoded_content(self) -> bytes:
45
+ return self.content.encode("utf-8")
46
+
47
+ @property
48
+ def digest(self) -> str:
49
+ return sha256(self.encoded_content).hexdigest()
50
+
51
+
52
+ @dataclass(frozen=True, slots=True)
53
+ class GenerationPlan:
54
+ files: tuple[RenderedFile, ...]
55
+
56
+ def __post_init__(self) -> None:
57
+ ordered_files = tuple(
58
+ sorted(self.files, key=lambda generated: str(generated.path))
59
+ )
60
+ duplicate_paths = _duplicate_paths(ordered_files)
61
+ if duplicate_paths:
62
+ joined_paths = ", ".join(repr(str(path)) for path in duplicate_paths)
63
+ message = f"rendering concerns produced duplicate paths: {joined_paths}"
64
+ raise GenerationValidationError(message)
65
+ object.__setattr__(self, "files", ordered_files)
66
+
67
+
68
+ @dataclass(frozen=True, slots=True, order=True)
69
+ class OwnedFile:
70
+ path: PurePosixPath
71
+ digest: str
72
+
73
+ def __post_init__(self) -> None:
74
+ _validate_output_path(self.path)
75
+ valid_digest = (
76
+ len(self.digest) == 64
77
+ and self.digest == self.digest.lower()
78
+ and all(character in "0123456789abcdef" for character in self.digest)
79
+ )
80
+ if not valid_digest:
81
+ message = f"invalid ownership digest for {str(self.path)!r}"
82
+ raise GenerationValidationError(message)
83
+
84
+
85
+ @dataclass(frozen=True, slots=True)
86
+ class OwnershipManifest:
87
+ package: str
88
+ files: tuple[OwnedFile, ...]
89
+ format_version: int = MANIFEST_FORMAT_VERSION
90
+
91
+ def __post_init__(self) -> None:
92
+ _require_text(self.package, "generated package")
93
+ if self.format_version != MANIFEST_FORMAT_VERSION:
94
+ message = f"unsupported ownership manifest version: {self.format_version}"
95
+ raise GenerationValidationError(message)
96
+ ordered_files = tuple(sorted(self.files))
97
+ duplicate_paths = _duplicate_owned_paths(ordered_files)
98
+ if duplicate_paths:
99
+ joined_paths = ", ".join(repr(str(path)) for path in duplicate_paths)
100
+ message = f"ownership manifest contains duplicate paths: {joined_paths}"
101
+ raise GenerationValidationError(message)
102
+ object.__setattr__(self, "files", ordered_files)
103
+
104
+ @classmethod
105
+ def from_plan(cls, package: str, plan: GenerationPlan) -> OwnershipManifest:
106
+ return cls(
107
+ package=package,
108
+ files=tuple(OwnedFile(file.path, file.digest) for file in plan.files),
109
+ )
110
+
111
+
112
+ @dataclass(frozen=True, slots=True)
113
+ class DriftReport:
114
+ created: tuple[PurePosixPath, ...] = ()
115
+ updated: tuple[PurePosixPath, ...] = ()
116
+ removed: tuple[PurePosixPath, ...] = ()
117
+ conflicts: tuple[PurePosixPath, ...] = ()
118
+ manifest_changed: bool = False
119
+
120
+ @property
121
+ def is_current(self) -> bool:
122
+ return not (
123
+ self.created
124
+ or self.updated
125
+ or self.removed
126
+ or self.conflicts
127
+ or self.manifest_changed
128
+ )
129
+
130
+ @property
131
+ def change_count(self) -> int:
132
+ return len(self.created) + len(self.updated) + len(self.removed)
133
+
134
+ def summary(self) -> str:
135
+ return (
136
+ f"create {len(self.created)}, update {len(self.updated)}, "
137
+ f"remove {len(self.removed)}, conflict {len(self.conflicts)}"
138
+ )
139
+
140
+
141
+ class ConcernRenderer(Protocol):
142
+ @property
143
+ def concern(self) -> str: ...
144
+
145
+ def render(
146
+ self,
147
+ snapshot: SchemaSnapshot,
148
+ configuration: Configuration,
149
+ ) -> tuple[RenderedFile, ...]: ...
150
+
151
+
152
+ class PlanValidator(Protocol):
153
+ def validate(self, plan: GenerationPlan) -> None: ...
154
+
155
+
156
+ class ManifestStore(Protocol):
157
+ def load(self, package_root: Path) -> OwnershipManifest | None: ...
158
+
159
+ def encode(self, manifest: OwnershipManifest) -> bytes: ...
160
+
161
+
162
+ class DriftChecker(Protocol):
163
+ def compare(
164
+ self,
165
+ package_root: Path,
166
+ plan: GenerationPlan,
167
+ current_manifest: OwnershipManifest | None,
168
+ desired_manifest: OwnershipManifest,
169
+ ) -> DriftReport: ...
170
+
171
+
172
+ class PlanWriter(Protocol):
173
+ def write(
174
+ self,
175
+ package_root: Path,
176
+ plan: GenerationPlan,
177
+ current_manifest: OwnershipManifest | None,
178
+ desired_manifest: OwnershipManifest,
179
+ ) -> None: ...
180
+
181
+
182
+ class ProjectLock(Protocol):
183
+ def acquire(self, project_root: Path) -> AbstractContextManager[None]: ...
184
+
185
+
186
+ def output_path(root: Path, relative_path: PurePosixPath) -> Path:
187
+ return root.joinpath(*relative_path.parts)
188
+
189
+
190
+ def _validate_output_path(path: PurePosixPath) -> None:
191
+ invalid = (
192
+ path.is_absolute()
193
+ or not path.parts
194
+ or path == PurePosixPath(".")
195
+ or ".." in path.parts
196
+ or "\\" in str(path)
197
+ or path.name.casefold() == MANIFEST_NAME
198
+ or any(_invalid_portable_part(part) for part in path.parts)
199
+ )
200
+ if invalid:
201
+ message = f"unsafe generated path: {str(path)!r}"
202
+ raise GenerationValidationError(message)
203
+
204
+
205
+ def _duplicate_paths(files: tuple[RenderedFile, ...]) -> tuple[PurePosixPath, ...]:
206
+ grouped_paths: dict[str, list[PurePosixPath]] = {}
207
+ for generated_file in files:
208
+ key = _portable_path_key(generated_file.path)
209
+ grouped_paths.setdefault(key, []).append(generated_file.path)
210
+ return _colliding_paths(grouped_paths)
211
+
212
+
213
+ def _duplicate_owned_paths(files: tuple[OwnedFile, ...]) -> tuple[PurePosixPath, ...]:
214
+ grouped_paths: dict[str, list[PurePosixPath]] = {}
215
+ for owned_file in files:
216
+ key = _portable_path_key(owned_file.path)
217
+ grouped_paths.setdefault(key, []).append(owned_file.path)
218
+ return _colliding_paths(grouped_paths)
219
+
220
+
221
+ def _colliding_paths(
222
+ grouped_paths: dict[str, list[PurePosixPath]],
223
+ ) -> tuple[PurePosixPath, ...]:
224
+ collisions: set[PurePosixPath] = set()
225
+ for paths in grouped_paths.values():
226
+ if len(paths) > 1:
227
+ collisions.update(paths)
228
+ return tuple(sorted(collisions))
229
+
230
+
231
+ def _portable_path_key(path: PurePosixPath) -> str:
232
+ return normalize("NFKC", str(path)).casefold()
233
+
234
+
235
+ def _invalid_portable_part(part: str) -> bool:
236
+ stem = part.split(".", maxsplit=1)[0].casefold()
237
+ return (
238
+ part.endswith((" ", "."))
239
+ or stem in _WINDOWS_RESERVED_STEMS
240
+ or any(character in _WINDOWS_INVALID_CHARACTERS for character in part)
241
+ or any(ord(character) < 32 for character in part)
242
+ )
243
+
244
+
245
+ def _require_text(value: object, label: str) -> None:
246
+ if not isinstance(value, str) or not value:
247
+ message = f"{label} must be a non-empty string"
248
+ raise GenerationValidationError(message)
@@ -0,0 +1,169 @@
1
+ """Side-effect-free generated package drift detection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass
7
+ from hashlib import sha256
8
+ from pathlib import Path, PurePosixPath
9
+
10
+ from pyoq.errors import GenerationDriftError
11
+ from pyoq.generation.contracts import (
12
+ DriftReport,
13
+ GenerationPlan,
14
+ OwnedFile,
15
+ OwnershipManifest,
16
+ output_path,
17
+ )
18
+
19
+ FileDigestReader = Callable[[Path], str]
20
+
21
+
22
+ def _file_digest(path: Path) -> str:
23
+ return sha256(path.read_bytes()).hexdigest()
24
+
25
+
26
+ @dataclass(frozen=True, slots=True)
27
+ class FileDriftChecker:
28
+ digest_reader: FileDigestReader = _file_digest
29
+
30
+ def compare(
31
+ self,
32
+ package_root: Path,
33
+ plan: GenerationPlan,
34
+ current_manifest: OwnershipManifest | None,
35
+ desired_manifest: OwnershipManifest,
36
+ ) -> DriftReport:
37
+ try:
38
+ return _compare_files(
39
+ package_root,
40
+ plan,
41
+ current_manifest,
42
+ desired_manifest,
43
+ self.digest_reader,
44
+ )
45
+ except OSError as error:
46
+ message = f"unable to inspect generated package: {error}"
47
+ raise GenerationDriftError(message) from error
48
+
49
+
50
+ def _compare_files(
51
+ package_root: Path,
52
+ plan: GenerationPlan,
53
+ current_manifest: OwnershipManifest | None,
54
+ desired_manifest: OwnershipManifest,
55
+ digest_reader: FileDigestReader,
56
+ ) -> DriftReport:
57
+ current_files = _owned_files(current_manifest)
58
+ desired_files = {generated.path: generated for generated in plan.files}
59
+ created: list[PurePosixPath] = []
60
+ updated: list[PurePosixPath] = []
61
+ removed: list[PurePosixPath] = []
62
+ conflicts: list[PurePosixPath] = []
63
+ for path, generated_file in desired_files.items():
64
+ destination = output_path(package_root, path)
65
+ owned_file = current_files.get(path)
66
+ state = _planned_file_state(
67
+ package_root,
68
+ path,
69
+ destination,
70
+ generated_file.digest,
71
+ owned_file,
72
+ digest_reader,
73
+ )
74
+ _record_state(path, state, created, updated, conflicts)
75
+ for path, owned_file in current_files.items():
76
+ if path not in desired_files:
77
+ state = _stale_file_state(
78
+ package_root,
79
+ path,
80
+ output_path(package_root, path),
81
+ owned_file,
82
+ digest_reader,
83
+ )
84
+ _record_stale_state(path, state, removed, conflicts)
85
+ return DriftReport(
86
+ created=tuple(sorted(created)),
87
+ updated=tuple(sorted(updated)),
88
+ removed=tuple(sorted(removed)),
89
+ conflicts=tuple(sorted(conflicts)),
90
+ manifest_changed=current_manifest != desired_manifest,
91
+ )
92
+
93
+
94
+ def _owned_files(manifest: OwnershipManifest | None) -> dict[PurePosixPath, OwnedFile]:
95
+ if manifest is None:
96
+ return {}
97
+ return {owned_file.path: owned_file for owned_file in manifest.files}
98
+
99
+
100
+ def _planned_file_state(
101
+ package_root: Path,
102
+ relative_path: PurePosixPath,
103
+ destination: Path,
104
+ desired_digest: str,
105
+ owned_file: OwnedFile | None,
106
+ digest_reader: FileDigestReader,
107
+ ) -> str:
108
+ if _has_unsafe_parent(package_root, relative_path):
109
+ return "conflict"
110
+ if not destination.exists() and not destination.is_symlink():
111
+ return "created"
112
+ if owned_file is None or not destination.is_file() or destination.is_symlink():
113
+ return "conflict"
114
+ current_digest = digest_reader(destination)
115
+ if current_digest != owned_file.digest:
116
+ return "conflict"
117
+ return "current" if current_digest == desired_digest else "updated"
118
+
119
+
120
+ def _stale_file_state(
121
+ package_root: Path,
122
+ relative_path: PurePosixPath,
123
+ destination: Path,
124
+ owned_file: OwnedFile,
125
+ digest_reader: FileDigestReader,
126
+ ) -> str:
127
+ if _has_unsafe_parent(package_root, relative_path):
128
+ return "conflict"
129
+ if not destination.exists() and not destination.is_symlink():
130
+ return "removed"
131
+ if not destination.is_file() or destination.is_symlink():
132
+ return "conflict"
133
+ return "removed" if digest_reader(destination) == owned_file.digest else "conflict"
134
+
135
+
136
+ def _record_state(
137
+ path: PurePosixPath,
138
+ state: str,
139
+ created: list[PurePosixPath],
140
+ updated: list[PurePosixPath],
141
+ conflicts: list[PurePosixPath],
142
+ ) -> None:
143
+ if state == "created":
144
+ created.append(path)
145
+ elif state == "updated":
146
+ updated.append(path)
147
+ elif state == "conflict":
148
+ conflicts.append(path)
149
+
150
+
151
+ def _record_stale_state(
152
+ path: PurePosixPath,
153
+ state: str,
154
+ removed: list[PurePosixPath],
155
+ conflicts: list[PurePosixPath],
156
+ ) -> None:
157
+ if state == "removed":
158
+ removed.append(path)
159
+ else:
160
+ conflicts.append(path)
161
+
162
+
163
+ def _has_unsafe_parent(root: Path, relative_path: PurePosixPath) -> bool:
164
+ current = root
165
+ for part in relative_path.parts[:-1]:
166
+ current /= part
167
+ if current.is_symlink() or (current.exists() and not current.is_dir()):
168
+ return True
169
+ return False
@@ -0,0 +1,33 @@
1
+ """Fail-closed generation project lock."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Generator
6
+ from contextlib import contextmanager
7
+ from pathlib import Path
8
+
9
+ from pyoq.errors import GenerationLockError
10
+
11
+ LOCK_DIRECTORY_NAME = ".pyoq-generation-lock"
12
+
13
+
14
+ class DirectoryProjectLock:
15
+ @contextmanager
16
+ def acquire(self, project_root: Path) -> Generator[None, None, None]:
17
+ lock_path = project_root / LOCK_DIRECTORY_NAME
18
+ try:
19
+ lock_path.mkdir(mode=0o700)
20
+ except FileExistsError as error:
21
+ message = f"generation lock is already held: {lock_path}"
22
+ raise GenerationLockError(message) from error
23
+ except OSError as error:
24
+ message = f"unable to acquire generation lock: {error}"
25
+ raise GenerationLockError(message) from error
26
+ try:
27
+ yield
28
+ finally:
29
+ try:
30
+ lock_path.rmdir()
31
+ except OSError as error:
32
+ message = f"unable to release generation lock: {error}"
33
+ raise GenerationLockError(message) from error