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
|
@@ -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")
|
pyoq/migrations/hooks.py
ADDED
|
@@ -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
|
+
)
|
pyoq/policies/bypass.py
ADDED
|
@@ -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
|
+
)
|