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/config/loader.py
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""Strict project configuration loading."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import tomllib
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import cast
|
|
8
|
+
|
|
9
|
+
from pyoq.config import (
|
|
10
|
+
Configuration,
|
|
11
|
+
ConnectionConfiguration,
|
|
12
|
+
DatabaseDialect,
|
|
13
|
+
DatabaseProfile,
|
|
14
|
+
DataSourceName,
|
|
15
|
+
EnvironmentReference,
|
|
16
|
+
PoolConfiguration,
|
|
17
|
+
SettingValue,
|
|
18
|
+
)
|
|
19
|
+
from pyoq.errors import (
|
|
20
|
+
ConfigurationFileError,
|
|
21
|
+
ConfigurationValidationError,
|
|
22
|
+
PathContainmentError,
|
|
23
|
+
UnknownConfigurationSettingError,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
_DEFAULT_CODEGEN_DIRECTORY = "pyoq_generated"
|
|
27
|
+
_DEFAULT_CODEGEN_PACKAGE = "pyoq_generated"
|
|
28
|
+
_DEFAULT_PROFILE = "default"
|
|
29
|
+
_ROOT_SETTINGS = frozenset(
|
|
30
|
+
{
|
|
31
|
+
"codegen-directory",
|
|
32
|
+
"codegen-package",
|
|
33
|
+
"profiles",
|
|
34
|
+
"schema-snapshot",
|
|
35
|
+
"selected-profile",
|
|
36
|
+
}
|
|
37
|
+
)
|
|
38
|
+
_CONNECTION_SETTINGS = frozenset({"database", "host", "password", "port", "username"})
|
|
39
|
+
_POOL_SETTINGS = frozenset(
|
|
40
|
+
{"maximum-pool-size", "minimum-pool-size", "pool-checkout-timeout"}
|
|
41
|
+
)
|
|
42
|
+
_PROFILE_SETTINGS = (
|
|
43
|
+
frozenset({"database-path", "dialect", "dsn", "dsn-environment"})
|
|
44
|
+
| _CONNECTION_SETTINGS
|
|
45
|
+
| _POOL_SETTINGS
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
RawTable = dict[str, object]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def load_configuration(
|
|
52
|
+
project_root: Path,
|
|
53
|
+
*,
|
|
54
|
+
explicit: Configuration | None = None,
|
|
55
|
+
pyproject_path: Path | None = None,
|
|
56
|
+
selected_profile: str | None = None,
|
|
57
|
+
) -> Configuration:
|
|
58
|
+
if explicit is not None:
|
|
59
|
+
return (
|
|
60
|
+
explicit
|
|
61
|
+
if selected_profile is None
|
|
62
|
+
else explicit.select_profile(selected_profile)
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
root = project_root.resolve()
|
|
66
|
+
source_path = _resolve_config_path(root, pyproject_path)
|
|
67
|
+
document = _read_toml(source_path)
|
|
68
|
+
table = _pyoq_table(document)
|
|
69
|
+
configuration = _parse_configuration(root, table)
|
|
70
|
+
return (
|
|
71
|
+
configuration
|
|
72
|
+
if selected_profile is None
|
|
73
|
+
else configuration.select_profile(selected_profile)
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _read_toml(path: Path) -> RawTable:
|
|
78
|
+
try:
|
|
79
|
+
with path.open("rb") as stream:
|
|
80
|
+
document = tomllib.load(stream)
|
|
81
|
+
except (OSError, tomllib.TOMLDecodeError) as error:
|
|
82
|
+
message = f"unable to read configuration: {path}"
|
|
83
|
+
raise ConfigurationFileError(message) from error
|
|
84
|
+
return cast(RawTable, document)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _pyoq_table(document: RawTable) -> RawTable:
|
|
88
|
+
tool = _require_table(document.get("tool"), "tool")
|
|
89
|
+
return _require_table(tool.get("pyoq"), "tool.pyoq")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _parse_configuration(root: Path, table: RawTable) -> Configuration:
|
|
93
|
+
_reject_unknown(table, _ROOT_SETTINGS, "tool.pyoq")
|
|
94
|
+
profiles = _parse_profiles(table.get("profiles"))
|
|
95
|
+
return Configuration(
|
|
96
|
+
project_root=root,
|
|
97
|
+
codegen_directory=Path(
|
|
98
|
+
_string_setting(
|
|
99
|
+
table,
|
|
100
|
+
"codegen-directory",
|
|
101
|
+
_DEFAULT_CODEGEN_DIRECTORY,
|
|
102
|
+
"tool.pyoq",
|
|
103
|
+
)
|
|
104
|
+
),
|
|
105
|
+
codegen_package=_string_setting(
|
|
106
|
+
table,
|
|
107
|
+
"codegen-package",
|
|
108
|
+
_DEFAULT_CODEGEN_PACKAGE,
|
|
109
|
+
"tool.pyoq",
|
|
110
|
+
),
|
|
111
|
+
selected_profile=_string_setting(
|
|
112
|
+
table,
|
|
113
|
+
"selected-profile",
|
|
114
|
+
_DEFAULT_PROFILE,
|
|
115
|
+
"tool.pyoq",
|
|
116
|
+
),
|
|
117
|
+
profiles=profiles,
|
|
118
|
+
schema_snapshot=_optional_path_setting(table, "schema-snapshot"),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _optional_path_setting(table: RawTable, key: str) -> Path | None:
|
|
123
|
+
"""A path a project may leave unset, which most projects do."""
|
|
124
|
+
written = table.get(key)
|
|
125
|
+
if written is None:
|
|
126
|
+
return None
|
|
127
|
+
if not isinstance(written, str) or not written:
|
|
128
|
+
message = f"tool.pyoq.{key} must be a non-empty string"
|
|
129
|
+
raise ConfigurationValidationError(message)
|
|
130
|
+
return Path(written)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _parse_profiles(value: object) -> tuple[DatabaseProfile, ...]:
|
|
134
|
+
table = _require_table(value, "tool.pyoq.profiles")
|
|
135
|
+
if not table:
|
|
136
|
+
message = "tool.pyoq.profiles must contain at least one profile"
|
|
137
|
+
raise ConfigurationValidationError(message)
|
|
138
|
+
return tuple(_parse_profile(name, settings) for name, settings in table.items())
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _parse_profile(name: str, value: object) -> DatabaseProfile:
|
|
142
|
+
location = f"tool.pyoq.profiles.{name}"
|
|
143
|
+
table = _require_table(value, location)
|
|
144
|
+
_reject_unknown(table, _PROFILE_SETTINGS, location)
|
|
145
|
+
dialect_name = _required_string(table, "dialect", location)
|
|
146
|
+
try:
|
|
147
|
+
dialect = DatabaseDialect(dialect_name)
|
|
148
|
+
except ValueError as error:
|
|
149
|
+
supported = ", ".join(dialect.value for dialect in DatabaseDialect)
|
|
150
|
+
message = f"{location}.dialect must be one of: {supported}"
|
|
151
|
+
raise ConfigurationValidationError(message) from error
|
|
152
|
+
dsn_name = _optional_string(table, "dsn-environment", location)
|
|
153
|
+
dsn_environment = None if dsn_name is None else EnvironmentReference(dsn_name)
|
|
154
|
+
dsn_value = _optional_string(table, "dsn", location)
|
|
155
|
+
database_name = _optional_string(table, "database-path", location)
|
|
156
|
+
return DatabaseProfile(
|
|
157
|
+
name=name,
|
|
158
|
+
dialect=dialect,
|
|
159
|
+
dsn_environment=dsn_environment,
|
|
160
|
+
database_path=None if database_name is None else Path(database_name),
|
|
161
|
+
dsn=None if dsn_value is None else DataSourceName(dsn_value),
|
|
162
|
+
connection=_connection_configuration(table, location),
|
|
163
|
+
pool=_pool_configuration(table, location),
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _connection_configuration(
|
|
168
|
+
table: RawTable,
|
|
169
|
+
location: str,
|
|
170
|
+
) -> ConnectionConfiguration | None:
|
|
171
|
+
if not any(key in table for key in _CONNECTION_SETTINGS):
|
|
172
|
+
return None
|
|
173
|
+
return ConnectionConfiguration(
|
|
174
|
+
database=_required_setting_value(table, "database", location),
|
|
175
|
+
username=_required_setting_value(table, "username", location),
|
|
176
|
+
host=_optional_setting_value(table, "host", location),
|
|
177
|
+
port=_optional_setting_value(table, "port", location, allow_number=True),
|
|
178
|
+
password=_optional_setting_value(table, "password", location),
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _pool_configuration(table: RawTable, location: str) -> PoolConfiguration:
|
|
183
|
+
return PoolConfiguration(
|
|
184
|
+
minimum_size=_optional_setting_value(
|
|
185
|
+
table,
|
|
186
|
+
"minimum-pool-size",
|
|
187
|
+
location,
|
|
188
|
+
allow_number=True,
|
|
189
|
+
),
|
|
190
|
+
maximum_size=_optional_setting_value(
|
|
191
|
+
table,
|
|
192
|
+
"maximum-pool-size",
|
|
193
|
+
location,
|
|
194
|
+
allow_number=True,
|
|
195
|
+
),
|
|
196
|
+
checkout_timeout=_optional_setting_value(
|
|
197
|
+
table,
|
|
198
|
+
"pool-checkout-timeout",
|
|
199
|
+
location,
|
|
200
|
+
allow_number=True,
|
|
201
|
+
),
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _resolve_config_path(root: Path, configured: Path | None) -> Path:
|
|
206
|
+
path = root / "pyproject.toml" if configured is None else configured
|
|
207
|
+
candidate = path if path.is_absolute() else root / path
|
|
208
|
+
resolved = candidate.resolve()
|
|
209
|
+
if not resolved.is_relative_to(root):
|
|
210
|
+
message = f"configuration path escapes project root: {path}"
|
|
211
|
+
raise PathContainmentError(message)
|
|
212
|
+
return resolved
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _require_table(value: object, location: str) -> RawTable:
|
|
216
|
+
if not isinstance(value, dict):
|
|
217
|
+
message = f"{location} must be a table"
|
|
218
|
+
raise ConfigurationValidationError(message)
|
|
219
|
+
return cast(RawTable, value)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _reject_unknown(table: RawTable, allowed: frozenset[str], location: str) -> None:
|
|
223
|
+
unknown = sorted(table.keys() - allowed)
|
|
224
|
+
if unknown:
|
|
225
|
+
names = ", ".join(unknown)
|
|
226
|
+
message = f"unknown settings in {location}: {names}"
|
|
227
|
+
raise UnknownConfigurationSettingError(message)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _required_string(table: RawTable, key: str, location: str) -> str:
|
|
231
|
+
value = table.get(key)
|
|
232
|
+
if not isinstance(value, str) or not value:
|
|
233
|
+
message = f"{location}.{key} must be a non-empty string"
|
|
234
|
+
raise ConfigurationValidationError(message)
|
|
235
|
+
return value
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _string_setting(
|
|
239
|
+
table: RawTable,
|
|
240
|
+
key: str,
|
|
241
|
+
default: str,
|
|
242
|
+
location: str,
|
|
243
|
+
) -> str:
|
|
244
|
+
value = table.get(key, default)
|
|
245
|
+
if not isinstance(value, str) or not value:
|
|
246
|
+
message = f"{location}.{key} must be a non-empty string"
|
|
247
|
+
raise ConfigurationValidationError(message)
|
|
248
|
+
return value
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _optional_string(table: RawTable, key: str, location: str) -> str | None:
|
|
252
|
+
value = table.get(key)
|
|
253
|
+
if value is None:
|
|
254
|
+
return None
|
|
255
|
+
if not isinstance(value, str) or not value:
|
|
256
|
+
message = f"{location}.{key} must be a non-empty string"
|
|
257
|
+
raise ConfigurationValidationError(message)
|
|
258
|
+
return value
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _required_setting_value(
|
|
262
|
+
table: RawTable,
|
|
263
|
+
key: str,
|
|
264
|
+
location: str,
|
|
265
|
+
) -> SettingValue:
|
|
266
|
+
value = _optional_setting_value(table, key, location)
|
|
267
|
+
if value is None:
|
|
268
|
+
message = f"{location}.{key} is required with structured connection fields"
|
|
269
|
+
raise ConfigurationValidationError(message)
|
|
270
|
+
return value
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _optional_setting_value(
|
|
274
|
+
table: RawTable,
|
|
275
|
+
key: str,
|
|
276
|
+
location: str,
|
|
277
|
+
*,
|
|
278
|
+
allow_number: bool = False,
|
|
279
|
+
) -> SettingValue | None:
|
|
280
|
+
value = table.get(key)
|
|
281
|
+
if value is None:
|
|
282
|
+
return None
|
|
283
|
+
if isinstance(value, str) and value:
|
|
284
|
+
return SettingValue(value)
|
|
285
|
+
if allow_number and not isinstance(value, bool) and isinstance(value, int | float):
|
|
286
|
+
return SettingValue(str(value))
|
|
287
|
+
expected = "a value or environment reference" if allow_number else "a string"
|
|
288
|
+
message = f"{location}.{key} must be {expected}"
|
|
289
|
+
raise ConfigurationValidationError(message)
|
pyoq/config/models.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Immutable configuration contracts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import keyword
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from dataclasses import dataclass, field, replace
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from pyoq.config.connection import (
|
|
12
|
+
ConnectionConfiguration,
|
|
13
|
+
PoolConfiguration,
|
|
14
|
+
ResolvedPoolConfiguration,
|
|
15
|
+
)
|
|
16
|
+
from pyoq.config.values import (
|
|
17
|
+
DatabaseDialect,
|
|
18
|
+
DataSourceName,
|
|
19
|
+
Dialect,
|
|
20
|
+
EnvironmentReference,
|
|
21
|
+
SecretValue,
|
|
22
|
+
SettingValue,
|
|
23
|
+
)
|
|
24
|
+
from pyoq.errors import (
|
|
25
|
+
ConfigurationValidationError,
|
|
26
|
+
PathContainmentError,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
_PROFILE_NAME = re.compile(r"[A-Za-z][A-Za-z0-9_-]*")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class DatabaseProfile:
|
|
34
|
+
name: str
|
|
35
|
+
dialect: DatabaseDialect
|
|
36
|
+
dsn_environment: EnvironmentReference | None = None
|
|
37
|
+
database_path: Path | None = None
|
|
38
|
+
dsn: DataSourceName | None = None
|
|
39
|
+
connection: ConnectionConfiguration | None = None
|
|
40
|
+
pool: PoolConfiguration = field(default_factory=PoolConfiguration)
|
|
41
|
+
|
|
42
|
+
def __post_init__(self) -> None:
|
|
43
|
+
if not _PROFILE_NAME.fullmatch(self.name):
|
|
44
|
+
message = f"invalid profile name: {self.name!r}"
|
|
45
|
+
raise ConfigurationValidationError(message)
|
|
46
|
+
if (
|
|
47
|
+
self.database_path is not None
|
|
48
|
+
and self.dialect is not DatabaseDialect.SQLITE
|
|
49
|
+
):
|
|
50
|
+
message = "database-path is only valid for SQLite profiles"
|
|
51
|
+
raise ConfigurationValidationError(message)
|
|
52
|
+
if self.dsn is not None and self.dsn_environment is not None:
|
|
53
|
+
message = "dsn and dsn-environment are mutually exclusive"
|
|
54
|
+
raise ConfigurationValidationError(message)
|
|
55
|
+
if self.database_path is not None and self._has_dsn():
|
|
56
|
+
message = "database-path and dsn settings are mutually exclusive"
|
|
57
|
+
raise ConfigurationValidationError(message)
|
|
58
|
+
if self.connection is not None and self._has_connection_target():
|
|
59
|
+
message = "structured connection fields cannot be combined with a dsn"
|
|
60
|
+
raise ConfigurationValidationError(message)
|
|
61
|
+
if self.connection is not None and self.dialect is DatabaseDialect.SQLITE:
|
|
62
|
+
message = "structured connection fields are not valid for SQLite profiles"
|
|
63
|
+
raise ConfigurationValidationError(message)
|
|
64
|
+
|
|
65
|
+
def resolve_dsn(
|
|
66
|
+
self,
|
|
67
|
+
environment: Mapping[str, str] | None = None,
|
|
68
|
+
) -> SecretValue | None:
|
|
69
|
+
if self.dsn is not None:
|
|
70
|
+
return self.dsn.resolve(environment)
|
|
71
|
+
if self.dsn_environment is not None:
|
|
72
|
+
return self.dsn_environment.resolve(environment)
|
|
73
|
+
if self.connection is not None:
|
|
74
|
+
return self.connection.resolve_dsn(self.dialect, environment)
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
def _has_dsn(self) -> bool:
|
|
78
|
+
return self.dsn is not None or self.dsn_environment is not None
|
|
79
|
+
|
|
80
|
+
def _has_connection_target(self) -> bool:
|
|
81
|
+
return self.database_path is not None or self._has_dsn()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True, slots=True)
|
|
85
|
+
class Configuration:
|
|
86
|
+
project_root: Path
|
|
87
|
+
codegen_directory: Path
|
|
88
|
+
codegen_package: str
|
|
89
|
+
selected_profile: str
|
|
90
|
+
profiles: tuple[DatabaseProfile, ...]
|
|
91
|
+
schema_snapshot: Path | None = None
|
|
92
|
+
"""Where the recorded schema is kept, relative to the project root.
|
|
93
|
+
|
|
94
|
+
Generation reads it instead of a database when it is set, which is what
|
|
95
|
+
lets a check run where there are no credentials to connect with.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def __post_init__(self) -> None:
|
|
99
|
+
root = self.project_root.resolve()
|
|
100
|
+
object.__setattr__(self, "project_root", root)
|
|
101
|
+
codegen_directory = _canonical_codegen_directory(
|
|
102
|
+
root,
|
|
103
|
+
self.codegen_directory,
|
|
104
|
+
)
|
|
105
|
+
object.__setattr__(self, "codegen_directory", codegen_directory)
|
|
106
|
+
object.__setattr__(
|
|
107
|
+
self,
|
|
108
|
+
"schema_snapshot",
|
|
109
|
+
_canonical_snapshot(root, self.schema_snapshot),
|
|
110
|
+
)
|
|
111
|
+
_validate_package_name(self.codegen_package)
|
|
112
|
+
_validate_profiles(self.profiles, self.selected_profile)
|
|
113
|
+
_validate_database_paths(root, self.profiles)
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def generated_path(self) -> Path:
|
|
117
|
+
return (self.project_root / self.codegen_directory).resolve()
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def schema_snapshot_path(self) -> Path | None:
|
|
121
|
+
"""The recorded schema, as an absolute path, where one is configured."""
|
|
122
|
+
if self.schema_snapshot is None:
|
|
123
|
+
return None
|
|
124
|
+
return (self.project_root / self.schema_snapshot).resolve()
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def active_profile(self) -> DatabaseProfile:
|
|
128
|
+
return _find_profile(self.profiles, self.selected_profile)
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def active_database_path(self) -> Path | None:
|
|
132
|
+
configured = self.active_profile.database_path
|
|
133
|
+
if configured is None:
|
|
134
|
+
return None
|
|
135
|
+
return (self.project_root / configured).resolve()
|
|
136
|
+
|
|
137
|
+
def select_profile(self, name: str) -> Configuration:
|
|
138
|
+
_find_profile(self.profiles, name)
|
|
139
|
+
return replace(self, selected_profile=name)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def load_configuration(
|
|
143
|
+
project_root: Path,
|
|
144
|
+
*,
|
|
145
|
+
explicit: Configuration | None = None,
|
|
146
|
+
pyproject_path: Path | None = None,
|
|
147
|
+
selected_profile: str | None = None,
|
|
148
|
+
) -> Configuration:
|
|
149
|
+
from pyoq.config.loader import load_configuration as load_from_toml
|
|
150
|
+
|
|
151
|
+
return load_from_toml(
|
|
152
|
+
project_root,
|
|
153
|
+
explicit=explicit,
|
|
154
|
+
pyproject_path=pyproject_path,
|
|
155
|
+
selected_profile=selected_profile,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _contained(root: Path, configured: Path, label: str) -> Path:
|
|
160
|
+
"""Where a configured path lands, refused if that is outside the project.
|
|
161
|
+
|
|
162
|
+
One policy for every path a project configures. It is written once because
|
|
163
|
+
three of them need it, and the one that had its own copy is the one that
|
|
164
|
+
escaped. Resolving first is what catches a symbolic link that points out
|
|
165
|
+
of the project as surely as a `..` does.
|
|
166
|
+
"""
|
|
167
|
+
if configured.is_absolute():
|
|
168
|
+
message = f"{label} must be relative to the project root"
|
|
169
|
+
raise PathContainmentError(message)
|
|
170
|
+
resolved = (root / configured).resolve()
|
|
171
|
+
if resolved == root or not resolved.is_relative_to(root):
|
|
172
|
+
message = f"{label} escapes project root: {configured}"
|
|
173
|
+
raise PathContainmentError(message)
|
|
174
|
+
return resolved
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _canonical_codegen_directory(root: Path, configured: Path) -> Path:
|
|
178
|
+
return _contained(root, configured, "codegen-directory").relative_to(root)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _canonical_snapshot(root: Path, configured: Path | None) -> Path | None:
|
|
182
|
+
"""The recorded schema is written and replaced, so it stays in the project."""
|
|
183
|
+
if configured is None:
|
|
184
|
+
return None
|
|
185
|
+
return _contained(root, configured, "schema-snapshot").relative_to(root)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _validate_package_name(name: str) -> None:
|
|
189
|
+
parts = name.split(".")
|
|
190
|
+
invalid = any(not part.isidentifier() or keyword.iskeyword(part) for part in parts)
|
|
191
|
+
if invalid:
|
|
192
|
+
message = f"codegen-package is not a valid dotted package name: {name!r}"
|
|
193
|
+
raise ConfigurationValidationError(message)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _validate_profiles(
|
|
197
|
+
profiles: tuple[DatabaseProfile, ...],
|
|
198
|
+
selected_profile: str,
|
|
199
|
+
) -> None:
|
|
200
|
+
if not profiles:
|
|
201
|
+
message = "configuration must contain at least one profile"
|
|
202
|
+
raise ConfigurationValidationError(message)
|
|
203
|
+
names = tuple(profile.name for profile in profiles)
|
|
204
|
+
if len(set(names)) != len(names):
|
|
205
|
+
message = "configuration profile names must be unique"
|
|
206
|
+
raise ConfigurationValidationError(message)
|
|
207
|
+
_find_profile(profiles, selected_profile)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _validate_database_paths(
|
|
211
|
+
root: Path,
|
|
212
|
+
profiles: tuple[DatabaseProfile, ...],
|
|
213
|
+
) -> None:
|
|
214
|
+
for profile in profiles:
|
|
215
|
+
configured = profile.database_path
|
|
216
|
+
if configured is None:
|
|
217
|
+
continue
|
|
218
|
+
_contained(root, configured, "database-path")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _find_profile(
|
|
222
|
+
profiles: tuple[DatabaseProfile, ...],
|
|
223
|
+
name: str,
|
|
224
|
+
) -> DatabaseProfile:
|
|
225
|
+
for profile in profiles:
|
|
226
|
+
if profile.name == name:
|
|
227
|
+
return profile
|
|
228
|
+
message = f"selected profile does not exist: {name!r}"
|
|
229
|
+
raise ConfigurationValidationError(message)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
__all__ = (
|
|
233
|
+
"Configuration",
|
|
234
|
+
"ConnectionConfiguration",
|
|
235
|
+
"DataSourceName",
|
|
236
|
+
"DatabaseDialect",
|
|
237
|
+
"DatabaseProfile",
|
|
238
|
+
"Dialect",
|
|
239
|
+
"EnvironmentReference",
|
|
240
|
+
"PoolConfiguration",
|
|
241
|
+
"ResolvedPoolConfiguration",
|
|
242
|
+
"SecretValue",
|
|
243
|
+
"SettingValue",
|
|
244
|
+
"load_configuration",
|
|
245
|
+
)
|
pyoq/config/values.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Typed scalar values used by project configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from enum import StrEnum
|
|
10
|
+
|
|
11
|
+
from pyoq.errors import ConfigurationValidationError, SecretResolutionError
|
|
12
|
+
|
|
13
|
+
_ENVIRONMENT_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
|
|
14
|
+
_ENVIRONMENT_REFERENCE = re.compile(r"\$\{(?P<name>[A-Za-z_][A-Za-z0-9_]*)\}")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DatabaseDialect(StrEnum):
|
|
18
|
+
POSTGRES = "postgres"
|
|
19
|
+
MYSQL = "mysql"
|
|
20
|
+
SQLITE = "sqlite"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
Dialect = DatabaseDialect
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, slots=True, repr=False)
|
|
27
|
+
class SecretValue:
|
|
28
|
+
_value: str
|
|
29
|
+
|
|
30
|
+
def __post_init__(self) -> None:
|
|
31
|
+
if not self._value:
|
|
32
|
+
message = "secret values cannot be empty"
|
|
33
|
+
raise ConfigurationValidationError(message)
|
|
34
|
+
|
|
35
|
+
def reveal(self) -> str:
|
|
36
|
+
return self._value
|
|
37
|
+
|
|
38
|
+
def __repr__(self) -> str:
|
|
39
|
+
return "SecretValue('[REDACTED]')"
|
|
40
|
+
|
|
41
|
+
def __str__(self) -> str:
|
|
42
|
+
return "[REDACTED]"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True, slots=True)
|
|
46
|
+
class EnvironmentReference:
|
|
47
|
+
name: str
|
|
48
|
+
|
|
49
|
+
def __post_init__(self) -> None:
|
|
50
|
+
if not _ENVIRONMENT_NAME.fullmatch(self.name):
|
|
51
|
+
# The value is not repeated back. Whatever was written here is
|
|
52
|
+
# frequently a connection string, and a connection string carries
|
|
53
|
+
# a password: saying it aloud would put the password in a
|
|
54
|
+
# traceback, a log, and whatever collects them.
|
|
55
|
+
message = (
|
|
56
|
+
"dsn-environment names an environment variable, such as "
|
|
57
|
+
"DATABASE_URL, and what was given is not a variable name. "
|
|
58
|
+
"A connection string itself belongs in dsn."
|
|
59
|
+
)
|
|
60
|
+
raise ConfigurationValidationError(message)
|
|
61
|
+
|
|
62
|
+
def resolve(self, environment: Mapping[str, str] | None = None) -> SecretValue:
|
|
63
|
+
values = os.environ if environment is None else environment
|
|
64
|
+
value = values.get(self.name)
|
|
65
|
+
if not value:
|
|
66
|
+
message = f"required environment variable is unavailable: {self.name}"
|
|
67
|
+
raise SecretResolutionError(message)
|
|
68
|
+
return SecretValue(value)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True, slots=True, repr=False)
|
|
72
|
+
class DataSourceName:
|
|
73
|
+
_value: str
|
|
74
|
+
|
|
75
|
+
def __post_init__(self) -> None:
|
|
76
|
+
_validate_value(self._value, "dsn")
|
|
77
|
+
|
|
78
|
+
def resolve(self, environment: Mapping[str, str] | None = None) -> SecretValue:
|
|
79
|
+
return _resolve_value(self._value, environment)
|
|
80
|
+
|
|
81
|
+
def __repr__(self) -> str:
|
|
82
|
+
return "DataSourceName('[REDACTED]')"
|
|
83
|
+
|
|
84
|
+
def __str__(self) -> str:
|
|
85
|
+
return "[REDACTED]"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True, slots=True, repr=False)
|
|
89
|
+
class SettingValue:
|
|
90
|
+
"""One profile scalar, either literal or a complete environment reference."""
|
|
91
|
+
|
|
92
|
+
_value: str
|
|
93
|
+
|
|
94
|
+
def __post_init__(self) -> None:
|
|
95
|
+
_validate_value(self._value, "profile setting")
|
|
96
|
+
|
|
97
|
+
def resolve(self, environment: Mapping[str, str] | None = None) -> SecretValue:
|
|
98
|
+
return _resolve_value(self._value, environment)
|
|
99
|
+
|
|
100
|
+
def __repr__(self) -> str:
|
|
101
|
+
return "SettingValue('[REDACTED]')"
|
|
102
|
+
|
|
103
|
+
def __str__(self) -> str:
|
|
104
|
+
return "[REDACTED]"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _validate_value(value: str, label: str) -> None:
|
|
108
|
+
if not value:
|
|
109
|
+
message = f"{label} values cannot be empty"
|
|
110
|
+
raise ConfigurationValidationError(message)
|
|
111
|
+
if "${" in value and not _ENVIRONMENT_REFERENCE.fullmatch(value):
|
|
112
|
+
# Anywhere in the string, not only at the start. A value is a whole
|
|
113
|
+
# reference or none of one, and something half written is a mistake
|
|
114
|
+
# rather than a literal: read as a literal it would be used as typed,
|
|
115
|
+
# and the first anyone knew of it would be the server refusing it.
|
|
116
|
+
# The value is not repeated back here either, for the same reason.
|
|
117
|
+
message = (
|
|
118
|
+
f"{label} is a literal or a whole ${{VARIABLE_NAME}} reference, "
|
|
119
|
+
f"and what was given is neither. A value that has to contain "
|
|
120
|
+
f"'${{' belongs in the environment, referenced whole."
|
|
121
|
+
)
|
|
122
|
+
raise ConfigurationValidationError(message)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _resolve_value(
|
|
126
|
+
value: str,
|
|
127
|
+
environment: Mapping[str, str] | None,
|
|
128
|
+
) -> SecretValue:
|
|
129
|
+
match = _ENVIRONMENT_REFERENCE.fullmatch(value)
|
|
130
|
+
if match is None:
|
|
131
|
+
return SecretValue(value)
|
|
132
|
+
return EnvironmentReference(match.group("name")).resolve(environment)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
__all__ = (
|
|
136
|
+
"DataSourceName",
|
|
137
|
+
"DatabaseDialect",
|
|
138
|
+
"Dialect",
|
|
139
|
+
"EnvironmentReference",
|
|
140
|
+
"SecretValue",
|
|
141
|
+
"SettingValue",
|
|
142
|
+
)
|