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.
- pyoq/__init__.py +10 -0
- pyoq/__main__.py +5 -0
- pyoq/_native.pyi +5 -0
- pyoq/cli/__init__.py +5 -0
- pyoq/cli/commands.py +270 -0
- pyoq/cli/defaults.py +98 -0
- pyoq/cli/services.py +97 -0
- pyoq/config/__init__.py +31 -0
- pyoq/config/connection.py +161 -0
- pyoq/config/loader.py +289 -0
- pyoq/config/models.py +245 -0
- pyoq/config/values.py +142 -0
- pyoq/descriptors.py +165 -0
- pyoq/diagnostics/__init__.py +68 -0
- pyoq/diagnostics/budget.py +136 -0
- pyoq/diagnostics/events.py +137 -0
- pyoq/diagnostics/fingerprint.py +267 -0
- pyoq/diagnostics/instrumented.py +237 -0
- pyoq/diagnostics/metrics.py +61 -0
- pyoq/diagnostics/observation.py +227 -0
- pyoq/diagnostics/scoped.py +103 -0
- pyoq/django/__init__.py +15 -0
- pyoq/django/apps.py +17 -0
- pyoq/django/execution.py +317 -0
- pyoq/django/generation.py +59 -0
- pyoq/django/management/__init__.py +0 -0
- pyoq/django/management/commands/__init__.py +0 -0
- pyoq/django/management/commands/makemigrations.py +53 -0
- pyoq/django/management/commands/pyoq_codegen.py +75 -0
- pyoq/django/parameters.py +101 -0
- pyoq/django/schema.py +379 -0
- pyoq/django/settings.py +87 -0
- pyoq/django/timeouts.py +105 -0
- pyoq/dsl/__init__.py +64 -0
- pyoq/dsl/aio/__init__.py +31 -0
- pyoq/dsl/aio/context.py +295 -0
- pyoq/dsl/aio/queries.py +335 -0
- pyoq/dsl/aio/writes.py +368 -0
- pyoq/dsl/context.py +326 -0
- pyoq/dsl/entry.py +37 -0
- pyoq/dsl/labels.py +36 -0
- pyoq/dsl/queries.py +339 -0
- pyoq/dsl/result.py +164 -0
- pyoq/dsl/writes.py +360 -0
- pyoq/errors.py +317 -0
- pyoq/fastapi/__init__.py +32 -0
- pyoq/fastapi/dependencies.py +167 -0
- pyoq/fastapi/lifespan.py +119 -0
- pyoq/fetching/__init__.py +55 -0
- pyoq/fetching/collections.py +136 -0
- pyoq/fetching/execution.py +587 -0
- pyoq/fetching/joined.py +79 -0
- pyoq/fetching/nesting.py +183 -0
- pyoq/fetching/plans.py +541 -0
- pyoq/fetching/select_in.py +149 -0
- pyoq/fetching/tables.py +110 -0
- pyoq/generation/__init__.py +54 -0
- pyoq/generation/cleanup.py +44 -0
- pyoq/generation/contracts.py +248 -0
- pyoq/generation/drift.py +169 -0
- pyoq/generation/lock.py +33 -0
- pyoq/generation/manifest.py +114 -0
- pyoq/generation/model.py +1001 -0
- pyoq/generation/pipeline.py +119 -0
- pyoq/generation/rendering/__init__.py +5 -0
- pyoq/generation/rendering/domains.py +51 -0
- pyoq/generation/rendering/enums.py +29 -0
- pyoq/generation/rendering/exports.py +70 -0
- pyoq/generation/rendering/imports.py +63 -0
- pyoq/generation/rendering/package.py +56 -0
- pyoq/generation/rendering/relations.py +133 -0
- pyoq/generation/rendering/routines.py +396 -0
- pyoq/generation/rendering/rows.py +79 -0
- pyoq/generation/rendering/source.py +121 -0
- pyoq/generation/rendering/tables.py +300 -0
- pyoq/generation/rendering/writes.py +514 -0
- pyoq/generation/validation.py +27 -0
- pyoq/generation/writer.py +184 -0
- pyoq/hydration/__init__.py +24 -0
- pyoq/hydration/engine.py +155 -0
- pyoq/hydration/identity.py +194 -0
- pyoq/hydration/plan.py +116 -0
- pyoq/migrations/__init__.py +9 -0
- pyoq/migrations/alembic.py +106 -0
- pyoq/migrations/hooks.py +75 -0
- pyoq/naming.py +261 -0
- pyoq/policies/__init__.py +47 -0
- pyoq/policies/bypass.py +122 -0
- pyoq/policies/governed.py +430 -0
- pyoq/policies/model.py +242 -0
- pyoq/policies/rewriting.py +263 -0
- pyoq/py.typed +1 -0
- pyoq/query/__init__.py +312 -0
- pyoq/query/aggregates.py +172 -0
- pyoq/query/arrays.py +65 -0
- pyoq/query/binding.py +52 -0
- pyoq/query/capabilities.py +317 -0
- pyoq/query/casts.py +73 -0
- pyoq/query/choices.py +185 -0
- pyoq/query/decoding.py +360 -0
- pyoq/query/documents.py +56 -0
- pyoq/query/execution/__init__.py +63 -0
- pyoq/query/execution/aio/__init__.py +31 -0
- pyoq/query/execution/aio/operations.py +228 -0
- pyoq/query/execution/aio/pooling.py +233 -0
- pyoq/query/execution/aio/streaming.py +161 -0
- pyoq/query/execution/aio/transactions.py +105 -0
- pyoq/query/execution/batch.py +96 -0
- pyoq/query/execution/binding_style.py +30 -0
- pyoq/query/execution/compilation.py +48 -0
- pyoq/query/execution/context.py +61 -0
- pyoq/query/execution/control.py +50 -0
- pyoq/query/execution/operations.py +224 -0
- pyoq/query/execution/planning.py +107 -0
- pyoq/query/execution/pooling.py +279 -0
- pyoq/query/execution/results.py +36 -0
- pyoq/query/execution/streaming.py +178 -0
- pyoq/query/execution/transactions.py +95 -0
- pyoq/query/expressions.py +1200 -0
- pyoq/query/fields.py +60 -0
- pyoq/query/mysql/__init__.py +59 -0
- pyoq/query/mysql/aio/__init__.py +38 -0
- pyoq/query/mysql/aio/commands.py +389 -0
- pyoq/query/mysql/aio/driver.py +196 -0
- pyoq/query/mysql/aio/executor.py +123 -0
- pyoq/query/mysql/aio/factory.py +26 -0
- pyoq/query/mysql/aio/operations.py +38 -0
- pyoq/query/mysql/aio/pool.py +53 -0
- pyoq/query/mysql/aio/transactions.py +313 -0
- pyoq/query/mysql/commands.py +354 -0
- pyoq/query/mysql/compiler.py +134 -0
- pyoq/query/mysql/context.py +20 -0
- pyoq/query/mysql/executor.py +126 -0
- pyoq/query/mysql/expressions.py +244 -0
- pyoq/query/mysql/factory.py +46 -0
- pyoq/query/mysql/health.py +66 -0
- pyoq/query/mysql/identifiers.py +9 -0
- pyoq/query/mysql/model.py +79 -0
- pyoq/query/mysql/operations.py +43 -0
- pyoq/query/mysql/parameters.py +69 -0
- pyoq/query/mysql/planning.py +20 -0
- pyoq/query/mysql/pool.py +67 -0
- pyoq/query/mysql/transactions.py +331 -0
- pyoq/query/mysql/writes.py +73 -0
- pyoq/query/nodes.py +750 -0
- pyoq/query/postgres/__init__.py +48 -0
- pyoq/query/postgres/aio/__init__.py +25 -0
- pyoq/query/postgres/aio/bulk.py +56 -0
- pyoq/query/postgres/aio/commands.py +264 -0
- pyoq/query/postgres/aio/executor.py +152 -0
- pyoq/query/postgres/aio/factory.py +26 -0
- pyoq/query/postgres/aio/operations.py +26 -0
- pyoq/query/postgres/aio/pool.py +40 -0
- pyoq/query/postgres/aio/transactions.py +295 -0
- pyoq/query/postgres/bulk.py +62 -0
- pyoq/query/postgres/commands.py +238 -0
- pyoq/query/postgres/compiler.py +114 -0
- pyoq/query/postgres/context.py +20 -0
- pyoq/query/postgres/executor.py +147 -0
- pyoq/query/postgres/expressions.py +311 -0
- pyoq/query/postgres/factory.py +24 -0
- pyoq/query/postgres/health.py +24 -0
- pyoq/query/postgres/identifiers.py +9 -0
- pyoq/query/postgres/model.py +81 -0
- pyoq/query/postgres/operations.py +25 -0
- pyoq/query/postgres/parameters.py +71 -0
- pyoq/query/postgres/planning.py +20 -0
- pyoq/query/postgres/pool.py +52 -0
- pyoq/query/postgres/transactions.py +295 -0
- pyoq/query/postgres/writes.py +37 -0
- pyoq/query/projections.py +105 -0
- pyoq/query/raw.py +90 -0
- pyoq/query/recursion.py +265 -0
- pyoq/query/rendering/__init__.py +1 -0
- pyoq/query/rendering/expressions.py +913 -0
- pyoq/query/rendering/identifiers.py +40 -0
- pyoq/query/rendering/projections.py +63 -0
- pyoq/query/rendering/queries.py +334 -0
- pyoq/query/rendering/sources.py +66 -0
- pyoq/query/rendering/writes.py +176 -0
- pyoq/query/results.py +459 -0
- pyoq/query/routines.py +196 -0
- pyoq/query/rows.py +156 -0
- pyoq/query/select.py +793 -0
- pyoq/query/select_nodes.py +277 -0
- pyoq/query/sources.py +236 -0
- pyoq/query/sqlite/__init__.py +43 -0
- pyoq/query/sqlite/commands.py +201 -0
- pyoq/query/sqlite/compiler.py +139 -0
- pyoq/query/sqlite/context.py +20 -0
- pyoq/query/sqlite/executor.py +119 -0
- pyoq/query/sqlite/expressions.py +224 -0
- pyoq/query/sqlite/factory.py +32 -0
- pyoq/query/sqlite/health.py +28 -0
- pyoq/query/sqlite/identifiers.py +9 -0
- pyoq/query/sqlite/model.py +73 -0
- pyoq/query/sqlite/operations.py +36 -0
- pyoq/query/sqlite/parameters.py +50 -0
- pyoq/query/sqlite/planning.py +20 -0
- pyoq/query/sqlite/pool.py +50 -0
- pyoq/query/sqlite/streaming.py +13 -0
- pyoq/query/sqlite/transactions.py +274 -0
- pyoq/query/sqlite/writes.py +35 -0
- pyoq/query/statements.py +27 -0
- pyoq/query/values.py +23 -0
- pyoq/query/vendor.py +162 -0
- pyoq/query/windows.py +424 -0
- pyoq/query/write_nodes.py +174 -0
- pyoq/query/writes.py +628 -0
- pyoq/relations/__init__.py +66 -0
- pyoq/relations/batching.py +219 -0
- pyoq/relations/derivation.py +111 -0
- pyoq/relations/fetching.py +355 -0
- pyoq/relations/graph.py +245 -0
- pyoq/relations/loading.py +74 -0
- pyoq/relations/model.py +75 -0
- pyoq/relations/planning.py +206 -0
- pyoq/runtime/__init__.py +9 -0
- pyoq/runtime/kernels.py +25 -0
- pyoq/runtime/python.py +43 -0
- pyoq/runtime/selection.py +73 -0
- pyoq/sanic/__init__.py +32 -0
- pyoq/sanic/scope.py +197 -0
- pyoq/sanic/workers.py +129 -0
- pyoq/schema/__init__.py +108 -0
- pyoq/schema/codec.py +711 -0
- pyoq/schema/models.py +604 -0
- pyoq/schema/mysql/__init__.py +16 -0
- pyoq/schema/mysql/connection.py +73 -0
- pyoq/schema/mysql/dsn.py +72 -0
- pyoq/schema/mysql/records.py +354 -0
- pyoq/schema/mysql/reflection.py +309 -0
- pyoq/schema/mysql/source.py +30 -0
- pyoq/schema/mysql/sql.py +128 -0
- pyoq/schema/mysql/types.py +105 -0
- pyoq/schema/postgres/__init__.py +13 -0
- pyoq/schema/postgres/connection.py +63 -0
- pyoq/schema/postgres/records.py +384 -0
- pyoq/schema/postgres/reflection.py +466 -0
- pyoq/schema/postgres/source.py +30 -0
- pyoq/schema/postgres/sql.py +246 -0
- pyoq/schema/postgres/types.py +98 -0
- pyoq/schema/registry.py +45 -0
- pyoq/schema/source.py +15 -0
- pyoq/schema/sqlite/__init__.py +6 -0
- pyoq/schema/sqlite/connection.py +54 -0
- pyoq/schema/sqlite/records.py +167 -0
- pyoq/schema/sqlite/reflection.py +393 -0
- pyoq/schema/sqlite/source.py +30 -0
- pyoq/schema/sqlite/sql.py +254 -0
- pyoq/schema/sqlite/types.py +74 -0
- pyoq/serving/__init__.py +23 -0
- pyoq/serving/databases.py +107 -0
- pyoq/serving/opening.py +331 -0
- pyoq/snapshots/__init__.py +20 -0
- pyoq/snapshots/drift.py +312 -0
- pyoq/snapshots/files.py +96 -0
- pyoq/snapshots/routing.py +40 -0
- pyoq/snapshots/source.py +33 -0
- pyoq/tracing/__init__.py +5 -0
- pyoq/tracing/spans.py +89 -0
- pyoq/unset.py +14 -0
- pyoq_sql-1.0.2.dist-info/METADATA +3050 -0
- pyoq_sql-1.0.2.dist-info/RECORD +267 -0
- pyoq_sql-1.0.2.dist-info/WHEEL +4 -0
- pyoq_sql-1.0.2.dist-info/entry_points.txt +3 -0
- pyoq_sql-1.0.2.dist-info/licenses/LICENSE +373 -0
pyoq/fetching/tables.py
ADDED
|
@@ -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)
|
pyoq/generation/drift.py
ADDED
|
@@ -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
|
pyoq/generation/lock.py
ADDED
|
@@ -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
|