snake-orm 0.1.0b1__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.
- snake_orm-0.1.0b1.dist-info/METADATA +366 -0
- snake_orm-0.1.0b1.dist-info/RECORD +198 -0
- snake_orm-0.1.0b1.dist-info/WHEEL +4 -0
- snake_orm-0.1.0b1.dist-info/entry_points.txt +2 -0
- snake_orm-0.1.0b1.dist-info/licenses/LICENSE +21 -0
- snakeorm/__init__.py +214 -0
- snakeorm/advisor.py +109 -0
- snakeorm/cli/__init__.py +3 -0
- snakeorm/cli/app.py +992 -0
- snakeorm/cli/discovery.py +179 -0
- snakeorm/cli/hooks.py +86 -0
- snakeorm/compiler/__init__.py +3 -0
- snakeorm/compiler/compiler.py +330 -0
- snakeorm/connection.py +228 -0
- snakeorm/contrib/__init__.py +17 -0
- snakeorm/contrib/asgi.py +192 -0
- snakeorm/contrib/config.py +264 -0
- snakeorm/contrib/deliver.py +256 -0
- snakeorm/contrib/django.py +245 -0
- snakeorm/contrib/sidecar.py +44 -0
- snakeorm/contrib/wsgi.py +176 -0
- snakeorm/core/__init__.py +1 -0
- snakeorm/core/config.py +173 -0
- snakeorm/core/converters.py +172 -0
- snakeorm/core/exceptions.py +175 -0
- snakeorm/core/placement.py +15 -0
- snakeorm/core/sentinels.py +37 -0
- snakeorm/core/signals.py +114 -0
- snakeorm/debug/__init__.py +42 -0
- snakeorm/debug/assets/css/panel.css +616 -0
- snakeorm/debug/assets/css/snake_orm_app.css +293 -0
- snakeorm/debug/assets/img/icon.svg +5 -0
- snakeorm/debug/assets/js/history.js +425 -0
- snakeorm/debug/assets/js/language.js +283 -0
- snakeorm/debug/assets/js/panel.js +241 -0
- snakeorm/debug/assets/js/snake_orm_app.js +82 -0
- snakeorm/debug/assets/pages/cli.html +28 -0
- snakeorm/debug/assets/pages/config.html +34 -0
- snakeorm/debug/assets/pages/database_first.html +29 -0
- snakeorm/debug/assets/pages/help.html +21 -0
- snakeorm/debug/assets/pages/history.html +27 -0
- snakeorm/debug/capture.py +234 -0
- snakeorm/debug/channel.py +131 -0
- snakeorm/debug/collector.py +144 -0
- snakeorm/debug/config.py +149 -0
- snakeorm/debug/html.py +578 -0
- snakeorm/debug/matrix.py +60 -0
- snakeorm/debug/origin.py +42 -0
- snakeorm/debug/otel/__init__.py +68 -0
- snakeorm/debug/otel/context.py +43 -0
- snakeorm/debug/otel/exporter.py +277 -0
- snakeorm/debug/otel/payload.py +97 -0
- snakeorm/debug/otel/report.py +29 -0
- snakeorm/debug/otel/spans.py +393 -0
- snakeorm/debug/otel/summary.py +59 -0
- snakeorm/debug/record.py +54 -0
- snakeorm/debug/report.py +311 -0
- snakeorm/debug/testing.py +23 -0
- snakeorm/decorators/__init__.py +13 -0
- snakeorm/decorators/abstract.py +26 -0
- snakeorm/decorators/db_first.py +104 -0
- snakeorm/decorators/function.py +28 -0
- snakeorm/decorators/model.py +268 -0
- snakeorm/decorators/polymorphic.py +140 -0
- snakeorm/decorators/result.py +167 -0
- snakeorm/decorators/row.py +90 -0
- snakeorm/decorators/trigger.py +47 -0
- snakeorm/decorators/view.py +149 -0
- snakeorm/dialects/__init__.py +6 -0
- snakeorm/dialects/base.py +373 -0
- snakeorm/dialects/capabilities.py +571 -0
- snakeorm/dialects/literals.py +46 -0
- snakeorm/dialects/matrix.py +384 -0
- snakeorm/dialects/mysql.py +519 -0
- snakeorm/dialects/postgres.py +504 -0
- snakeorm/dialects/sqlite.py +417 -0
- snakeorm/drivers/__init__.py +18 -0
- snakeorm/drivers/asyncbase.py +73 -0
- snakeorm/drivers/asyncdecorators.py +244 -0
- snakeorm/drivers/asyncpool.py +228 -0
- snakeorm/drivers/asyncpsycopg.py +124 -0
- snakeorm/drivers/asyncpymysql.py +50 -0
- snakeorm/drivers/asyncsqlite.py +30 -0
- snakeorm/drivers/base.py +76 -0
- snakeorm/drivers/failures.py +181 -0
- snakeorm/drivers/logging.py +167 -0
- snakeorm/drivers/pool.py +352 -0
- snakeorm/drivers/psycopg.py +188 -0
- snakeorm/drivers/pymysql.py +176 -0
- snakeorm/drivers/savepoints.py +46 -0
- snakeorm/drivers/sqlite.py +170 -0
- snakeorm/drivers/threaded.py +143 -0
- snakeorm/drivers/timeout.py +87 -0
- snakeorm/dto/__init__.py +55 -0
- snakeorm/dto/read.py +231 -0
- snakeorm/dto/region.py +326 -0
- snakeorm/dto/resolve.py +354 -0
- snakeorm/dto/spec.py +201 -0
- snakeorm/expressions/__init__.py +88 -0
- snakeorm/expressions/conditional.py +178 -0
- snakeorm/expressions/expression.py +810 -0
- snakeorm/expressions/functions.py +71 -0
- snakeorm/expressions/keys.py +299 -0
- snakeorm/expressions/paths.py +43 -0
- snakeorm/expressions/scalar.py +293 -0
- snakeorm/expressions/window.py +248 -0
- snakeorm/fields/__init__.py +68 -0
- snakeorm/fields/check.py +79 -0
- snakeorm/fields/column.py +316 -0
- snakeorm/fields/enum.py +64 -0
- snakeorm/fields/index.py +46 -0
- snakeorm/fields/relationship.py +738 -0
- snakeorm/fields/typed.py +736 -0
- snakeorm/helpers/__init__.py +1 -0
- snakeorm/helpers/annotations.py +21 -0
- snakeorm/helpers/inheritance.py +41 -0
- snakeorm/helpers/pyliteral.py +52 -0
- snakeorm/helpers/pytype.py +50 -0
- snakeorm/introspection/__init__.py +13 -0
- snakeorm/introspection/base.py +30 -0
- snakeorm/introspection/drift.py +78 -0
- snakeorm/introspection/models.py +633 -0
- snakeorm/introspection/mysql.py +367 -0
- snakeorm/introspection/postgres.py +397 -0
- snakeorm/introspection/sqlite.py +217 -0
- snakeorm/introspection/unsupported.py +94 -0
- snakeorm/linker/__init__.py +3 -0
- snakeorm/linker/linker.py +521 -0
- snakeorm/metadata/__init__.py +41 -0
- snakeorm/metadata/check.py +37 -0
- snakeorm/metadata/column.py +201 -0
- snakeorm/metadata/enum_storage.py +33 -0
- snakeorm/metadata/fk_action.py +19 -0
- snakeorm/metadata/foreign_key.py +26 -0
- snakeorm/metadata/index.py +43 -0
- snakeorm/metadata/index_method.py +19 -0
- snakeorm/metadata/int_size.py +20 -0
- snakeorm/metadata/json_storage.py +21 -0
- snakeorm/metadata/polymorphic.py +30 -0
- snakeorm/metadata/primary_key.py +23 -0
- snakeorm/metadata/relationship.py +60 -0
- snakeorm/metadata/relationship_kind.py +43 -0
- snakeorm/metadata/routine.py +26 -0
- snakeorm/metadata/server_default.py +20 -0
- snakeorm/metadata/table.py +97 -0
- snakeorm/metadata/table_kind.py +23 -0
- snakeorm/metadata/trigger.py +62 -0
- snakeorm/metadata/type_params.py +348 -0
- snakeorm/migration/__init__.py +88 -0
- snakeorm/migration/asyncrunner.py +168 -0
- snakeorm/migration/autodetect.py +96 -0
- snakeorm/migration/ddl.py +1172 -0
- snakeorm/migration/diff.py +701 -0
- snakeorm/migration/loader.py +77 -0
- snakeorm/migration/operations.py +952 -0
- snakeorm/migration/planner.py +78 -0
- snakeorm/migration/realize.py +420 -0
- snakeorm/migration/renames.py +153 -0
- snakeorm/migration/render.py +1095 -0
- snakeorm/migration/runner.py +314 -0
- snakeorm/migration/squash.py +60 -0
- snakeorm/migration/state.py +107 -0
- snakeorm/model.py +162 -0
- snakeorm/py.typed +0 -0
- snakeorm/query/__init__.py +9 -0
- snakeorm/query/compound.py +288 -0
- snakeorm/query/join_kind.py +19 -0
- snakeorm/query/joined.py +143 -0
- snakeorm/query/query.py +1001 -0
- snakeorm/query/recursive.py +244 -0
- snakeorm/registry/__init__.py +5 -0
- snakeorm/registry/by_module.py +163 -0
- snakeorm/registry/registry.py +166 -0
- snakeorm/session/__init__.py +8 -0
- snakeorm/session/asyncsession.py +638 -0
- snakeorm/session/coercion.py +306 -0
- snakeorm/session/factory.py +29 -0
- snakeorm/session/guards.py +269 -0
- snakeorm/session/isolation.py +23 -0
- snakeorm/session/mapper.py +213 -0
- snakeorm/session/planning.py +815 -0
- snakeorm/session/retry.py +96 -0
- snakeorm/session/session.py +871 -0
- snakeorm/session/shared.py +355 -0
- snakeorm/sql/__init__.py +18 -0
- snakeorm/sql/adapt.py +104 -0
- snakeorm/sql/aggregate.py +232 -0
- snakeorm/sql/condition.py +359 -0
- snakeorm/sql/delete.py +46 -0
- snakeorm/sql/insert.py +149 -0
- snakeorm/sql/joins.py +158 -0
- snakeorm/sql/pk_subquery.py +54 -0
- snakeorm/sql/refs.py +19 -0
- snakeorm/sql/resolver.py +28 -0
- snakeorm/sql/select.py +175 -0
- snakeorm/sql/update.py +82 -0
- snakeorm/sql/value.py +552 -0
- snakeorm/times.py +276 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Finding the user's `SnakeOrmConfig` from wherever the CLI was invoked.
|
|
2
|
+
|
|
3
|
+
Nothing new is declared: the application's own entry point is imported and the config taken out of
|
|
4
|
+
it, because `--models` and `--dsn` would be a second source for a fact the config already holds.
|
|
5
|
+
|
|
6
|
+
- importing the entry module RUNS the `@snake_model` decorators, so the registry fills as a side
|
|
7
|
+
effect of the import rather than as a separate step.
|
|
8
|
+
- the config is found by TYPE, not by name: it is the one `SnakeOrmConfig` instance in that module.
|
|
9
|
+
|
|
10
|
+
Each framework is recognised by ITS OWN convention:
|
|
11
|
+
|
|
12
|
+
| marker | framework | how the config is reached |
|
|
13
|
+
|-------------|-----------|----------------------------------------------------|
|
|
14
|
+
| `manage.py` | Django | `DJANGO_SETTINGS_MODULE` -> `config_from_django()` |
|
|
15
|
+
| `main.py` | FastAPI | import; the instance is in the module |
|
|
16
|
+
| `app.py` | Flask | import; the instance is in the module |
|
|
17
|
+
| `wsgi.py` / `asgi.py` | either | import; the instance is in the module |
|
|
18
|
+
|
|
19
|
+
THE ONE CONDITION: the entry module has to be importable without doing work. Django settings are
|
|
20
|
+
constants and FastAPI puts its side effects in `lifespan`, so both are safe by construction; a Flask
|
|
21
|
+
app calling `create_app()` at import time is not. `SNAKEORM_CLI` is set before importing so an app
|
|
22
|
+
that must do work can tell who is asking.
|
|
23
|
+
|
|
24
|
+
`--config`, `--models` and `--dsn` win over anything found here, and finding nothing raises naming
|
|
25
|
+
every route it tried.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import importlib
|
|
31
|
+
import os
|
|
32
|
+
import re
|
|
33
|
+
import sys
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from types import ModuleType
|
|
36
|
+
|
|
37
|
+
from snakeorm.contrib.config import SnakeOrmConfig
|
|
38
|
+
from snakeorm.core.exceptions import SnakeConfigError
|
|
39
|
+
|
|
40
|
+
_DJANGO_MARKER = "manage.py"
|
|
41
|
+
|
|
42
|
+
_ENTRY_MARKERS: tuple[str, ...] = ("main.py", "app.py", "wsgi.py", "asgi.py")
|
|
43
|
+
"""Entry-point file names, in the order they are tried.
|
|
44
|
+
|
|
45
|
+
Each one is the framework's OWN convention and not our invention: `main.py` is what every FastAPI
|
|
46
|
+
tutorial and every `uvicorn main:app` uses, `app.py` is what Flask's CLI looks for on its own, and
|
|
47
|
+
`wsgi.py`/`asgi.py` are the deployment entry points both of them document.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
_MARKER_ENV = "SNAKEORM_CLI"
|
|
51
|
+
"""Set to `1` before importing the entry module, so an app can tell a tool from a real boot.
|
|
52
|
+
|
|
53
|
+
The escape hatch, not the mechanism. An application that has to do work at import time can guard it
|
|
54
|
+
with this instead of having its database rebuilt because somebody listed the tables.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
_SETTINGS_PATTERN = re.compile(r"""DJANGO_SETTINGS_MODULE["']\s*,\s*["']([\w.]+)["']""")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def find_config(explicit: str | None = None) -> SnakeOrmConfig:
|
|
61
|
+
"""The user's `SnakeOrmConfig`, by declared priority. It never guesses in silence.
|
|
62
|
+
|
|
63
|
+
1. `--config module:NAME` (or just `module`), which always wins.
|
|
64
|
+
2. `SNAKEORM_CONFIG` in the environment, same shape.
|
|
65
|
+
3. The application's entry point, found by walking up from the working directory.
|
|
66
|
+
|
|
67
|
+
With nothing to go on it raises, naming the three routes and what it looked for.
|
|
68
|
+
"""
|
|
69
|
+
pointer = explicit or os.environ.get("SNAKEORM_CONFIG")
|
|
70
|
+
if pointer:
|
|
71
|
+
return _from_pointer(pointer)
|
|
72
|
+
|
|
73
|
+
root, marker = _entry_point()
|
|
74
|
+
if root is None or marker is None:
|
|
75
|
+
raise SnakeConfigError(
|
|
76
|
+
f"No SnakeORM configuration found. Three routes, in order: (1) --config "
|
|
77
|
+
f"module:NAME, (2) the SNAKEORM_CONFIG environment variable, (3) an application "
|
|
78
|
+
f"entry point in this directory or above it ({_DJANGO_MARKER} or one of "
|
|
79
|
+
f"{', '.join(_ENTRY_MARKERS)}). None of them answered."
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
if root not in sys.path:
|
|
83
|
+
# The project root goes on the path so its own imports resolve, which is what the caller
|
|
84
|
+
# was doing by hand with PYTHONPATH.
|
|
85
|
+
sys.path.insert(0, root)
|
|
86
|
+
os.environ[_MARKER_ENV] = "1"
|
|
87
|
+
|
|
88
|
+
if marker == _DJANGO_MARKER:
|
|
89
|
+
return _from_django(Path(root) / marker)
|
|
90
|
+
return _config_in(_import(Path(marker).stem, root, marker))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _entry_point() -> tuple[str | None, str | None]:
|
|
94
|
+
"""`(project root, marker)` of the nearest application, walking up from the cwd.
|
|
95
|
+
|
|
96
|
+
Django goes first at each level: a project can hold `manage.py` beside a `wsgi.py`, and the
|
|
97
|
+
settings are the richer answer of the two.
|
|
98
|
+
"""
|
|
99
|
+
for directory in (Path.cwd(), *Path.cwd().parents):
|
|
100
|
+
if (directory / _DJANGO_MARKER).is_file():
|
|
101
|
+
return str(directory), _DJANGO_MARKER
|
|
102
|
+
for marker in _ENTRY_MARKERS:
|
|
103
|
+
if (directory / marker).is_file():
|
|
104
|
+
return str(directory), marker
|
|
105
|
+
return None, None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _from_django(manage: Path) -> SnakeOrmConfig:
|
|
109
|
+
"""Django's config, through the path Django itself documents.
|
|
110
|
+
|
|
111
|
+
`manage.py` is where the settings module is named, so it is read from there rather than
|
|
112
|
+
guessed. `django.setup()` touches no database: it loads settings and populates the app registry.
|
|
113
|
+
"""
|
|
114
|
+
match = _SETTINGS_PATTERN.search(manage.read_text(encoding="utf-8"))
|
|
115
|
+
if match is None:
|
|
116
|
+
raise SnakeConfigError(
|
|
117
|
+
f"{manage} does not name a DJANGO_SETTINGS_MODULE, so the settings that hold the "
|
|
118
|
+
f"SnakeORM configuration cannot be located. Point at it with --config or set "
|
|
119
|
+
f"DJANGO_SETTINGS_MODULE yourself."
|
|
120
|
+
)
|
|
121
|
+
os.environ.setdefault("DJANGO_SETTINGS_MODULE", match.group(1))
|
|
122
|
+
|
|
123
|
+
import django # pyright: ignore[reportMissingImports]
|
|
124
|
+
|
|
125
|
+
from snakeorm.contrib.django import config_from_django
|
|
126
|
+
|
|
127
|
+
django.setup()
|
|
128
|
+
return config_from_django()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _from_pointer(pointer: str) -> SnakeOrmConfig:
|
|
132
|
+
"""`module:NAME` or `module`. With a name it is taken; without one it is found by type."""
|
|
133
|
+
module_name, _, attribute = pointer.partition(":")
|
|
134
|
+
module = importlib.import_module(module_name)
|
|
135
|
+
if not attribute:
|
|
136
|
+
return _config_in(module)
|
|
137
|
+
found = getattr(module, attribute, None)
|
|
138
|
+
if not isinstance(found, SnakeOrmConfig):
|
|
139
|
+
raise SnakeConfigError(
|
|
140
|
+
f"'{pointer}' is not a SnakeOrmConfig: {module_name}.{attribute} is "
|
|
141
|
+
f"{type(found).__name__}."
|
|
142
|
+
)
|
|
143
|
+
return found
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _import(name: str, root: str, marker: str) -> ModuleType:
|
|
147
|
+
"""Import the entry module, turning an import-time failure into an explanation."""
|
|
148
|
+
try:
|
|
149
|
+
return importlib.import_module(name)
|
|
150
|
+
except Exception as error: # the app's own import, and its failures are the app's
|
|
151
|
+
raise SnakeConfigError(
|
|
152
|
+
f"Importing {marker} (in {root}) failed: {type(error).__name__}: {error}. The CLI "
|
|
153
|
+
f"imports it because that import is what registers the models. A module that does "
|
|
154
|
+
f"work when imported —opening connections, migrating, seeding— will fail here and "
|
|
155
|
+
f"misbehave under a reloader too; move it behind an app factory or a lifespan hook, "
|
|
156
|
+
f"or guard it with the {_MARKER_ENV} variable."
|
|
157
|
+
) from error
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _config_in(module: ModuleType) -> SnakeOrmConfig:
|
|
161
|
+
"""The single `SnakeOrmConfig` in the module, found by TYPE. Zero or several: it says so."""
|
|
162
|
+
found = [
|
|
163
|
+
(name, value)
|
|
164
|
+
for name, value in vars(module).items()
|
|
165
|
+
if isinstance(value, SnakeOrmConfig)
|
|
166
|
+
]
|
|
167
|
+
if len(found) == 1:
|
|
168
|
+
return found[0][1]
|
|
169
|
+
if not found:
|
|
170
|
+
raise SnakeConfigError(
|
|
171
|
+
f"'{module.__name__}' holds no SnakeOrmConfig. That object is what carries the "
|
|
172
|
+
f"connections and the migrations directory; build one there, or point at another "
|
|
173
|
+
f"module with --config."
|
|
174
|
+
)
|
|
175
|
+
raise SnakeConfigError(
|
|
176
|
+
f"'{module.__name__}' holds {len(found)} SnakeOrmConfig objects "
|
|
177
|
+
f"({', '.join(name for name, _ in found)}). Name the one you mean with "
|
|
178
|
+
f"--config {module.__name__}:<NAME>."
|
|
179
|
+
)
|
snakeorm/cli/hooks.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Framework-NATIVE ways of invoking the same CLI. Adapters, with no logic of their own.
|
|
2
|
+
|
|
3
|
+
The `snakeorm` executable works everywhere and is the agnostic answer. This module is the other
|
|
4
|
+
half: it lets somebody type the command the way their framework taught them —`manage.py snakeorm`,
|
|
5
|
+
`flask snakeorm`— and reach the SAME `main()`. One core, one adapter per framework.
|
|
6
|
+
|
|
7
|
+
The discipline that makes that shape work: **an adapter parses nothing, decides nothing and
|
|
8
|
+
defaults nothing.** It hands the arguments over and returns the exit code. An adapter that starts
|
|
9
|
+
interpreting its own arguments drifts from the core it was adapting.
|
|
10
|
+
|
|
11
|
+
Which is also why FastAPI is absent, and that is not an oversight. Django has `manage.py` and Flask
|
|
12
|
+
has `app.cli`; FastAPI has no command line of its own — `uvicorn main:app` is an argument to another
|
|
13
|
+
program, not a hook. So FastAPI uses the executable, which already needs nothing configured.
|
|
14
|
+
|
|
15
|
+
It lives in `cli/` and not in `contrib/`, and the acyclicity net is what said so: `cli` already
|
|
16
|
+
imports `contrib` to reach `SnakeOrmConfig`, so an adapter sitting in `contrib` and importing the
|
|
17
|
+
CLI closed a circle between the two packages. The dependency runs one way.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from collections.abc import Sequence
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def run(argv: Sequence[str]) -> int:
|
|
27
|
+
"""The single door every adapter goes through. Imported lazily so a hook costs nothing."""
|
|
28
|
+
from snakeorm.cli import main
|
|
29
|
+
|
|
30
|
+
return main(list(argv))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SnakeOrmCommand:
|
|
34
|
+
"""Django management command. Put ONE line in your project and every subcommand is there:
|
|
35
|
+
|
|
36
|
+
# myapp/management/commands/snakeorm.py
|
|
37
|
+
from snakeorm.contrib.cli_hooks import SnakeOrmCommand as Command
|
|
38
|
+
|
|
39
|
+
Django demands the file live at that path and the class be called `Command`, so the line cannot
|
|
40
|
+
be avoided — but it holds no logic, which is the part that matters. `run_from_argv` is
|
|
41
|
+
overridden rather than `handle` on purpose: Django would otherwise parse the arguments with its
|
|
42
|
+
own `argparse` and reject the ones it does not know, so the subcommand's flags would have to be
|
|
43
|
+
declared a second time here. Taking the raw `argv` keeps ONE parser, in the core, and means a
|
|
44
|
+
flag added there works from `manage.py` on the same day.
|
|
45
|
+
|
|
46
|
+
It does not subclass `BaseCommand`: Django accepts any object with `run_from_argv`, and not
|
|
47
|
+
importing Django keeps this module importable without it.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
help = "Run the SnakeORM CLI (makemigrations, migrate, tables, ...)."
|
|
51
|
+
|
|
52
|
+
def run_from_argv(self, argv: Sequence[str]) -> None:
|
|
53
|
+
"""`['manage.py', 'snakeorm', 'tables', '--detail']` -> the core gets `['tables', '--detail']`."""
|
|
54
|
+
raise SystemExit(run(argv[2:]))
|
|
55
|
+
|
|
56
|
+
def print_help(self, prog_name: str, subcommand: str) -> None:
|
|
57
|
+
"""`manage.py help snakeorm` shows the CORE's help, not a second one written here."""
|
|
58
|
+
run(["--help"])
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def flask_command() -> Any:
|
|
62
|
+
"""A `click` command that carries the CLI into `flask`. Register it on your app:
|
|
63
|
+
|
|
64
|
+
from snakeorm.contrib.cli_hooks import flask_command
|
|
65
|
+
|
|
66
|
+
app.cli.add_command(flask_command())
|
|
67
|
+
|
|
68
|
+
Then `flask snakeorm tables` works beside `flask run`. `ignore_unknown_options` plus
|
|
69
|
+
`UNPROCESSED` is what stops click from reading the arguments: they travel untouched to the one
|
|
70
|
+
parser that knows them.
|
|
71
|
+
|
|
72
|
+
`click` is imported inside, because it arrives with Flask and this module has to import without
|
|
73
|
+
either of them.
|
|
74
|
+
"""
|
|
75
|
+
import click
|
|
76
|
+
|
|
77
|
+
@click.command(
|
|
78
|
+
"snakeorm",
|
|
79
|
+
context_settings={"ignore_unknown_options": True, "help_option_names": []},
|
|
80
|
+
help=SnakeOrmCommand.help,
|
|
81
|
+
)
|
|
82
|
+
@click.argument("args", nargs=-1, type=click.UNPROCESSED)
|
|
83
|
+
def snakeorm(args: tuple[str, ...]) -> None:
|
|
84
|
+
raise SystemExit(run(args))
|
|
85
|
+
|
|
86
|
+
return snakeorm
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"""The Model Compiler: Python class -> immutable SnakeTableInfo.
|
|
2
|
+
|
|
3
|
+
It walks the descriptors ONCE. The type comes from the annotation; `nullable` is inferred
|
|
4
|
+
from `| None`; the primary key (primary_key=True columns) is mandatory.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import inspect
|
|
10
|
+
import sys
|
|
11
|
+
from dataclasses import MISSING as _NO_DEFAULT
|
|
12
|
+
from dataclasses import Field, fields
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from enum import Enum
|
|
15
|
+
from typing import Any, get_args, get_type_hints
|
|
16
|
+
|
|
17
|
+
from snakeorm.helpers.annotations import unwrap_optional
|
|
18
|
+
from snakeorm.core.placement import DEFAULT_SCHEMA
|
|
19
|
+
from snakeorm.core.exceptions import SnakeModelDefinitionError
|
|
20
|
+
from snakeorm.expressions import SnakeExpr
|
|
21
|
+
from snakeorm.fields import MISSING, SnakeColumn
|
|
22
|
+
from snakeorm.helpers.inheritance import collect_inherited
|
|
23
|
+
from snakeorm.times import SnakeUtc
|
|
24
|
+
from snakeorm.metadata import (
|
|
25
|
+
SnakeCheckInfo,
|
|
26
|
+
SnakeColumnInfo,
|
|
27
|
+
SnakeDateTimeParams,
|
|
28
|
+
SnakeEnumStorage,
|
|
29
|
+
SnakeIndexInfo,
|
|
30
|
+
SnakePolymorphicInfo,
|
|
31
|
+
SnakePrimaryKeyInfo,
|
|
32
|
+
SnakeTableInfo,
|
|
33
|
+
SnakeTableKind,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _column_hints(cls: type) -> dict[str, Any]:
|
|
38
|
+
"""Resolves annotations, tolerating forward refs of relations to models not yet defined.
|
|
39
|
+
|
|
40
|
+
`get_type_hints` is all-or-nothing: a `SnakeToMany[Child]` pointing at a model further down blows
|
|
41
|
+
it up. Since the compiler only cares about COLUMNS, on failure it resolves those alone (the linker
|
|
42
|
+
resolves the relations afterwards).
|
|
43
|
+
"""
|
|
44
|
+
try:
|
|
45
|
+
return get_type_hints(cls)
|
|
46
|
+
except NameError:
|
|
47
|
+
# Fallback walking the MRO: each class resolves ITS OWN column annotations with its
|
|
48
|
+
# module's globals (so an abstract base from another module contributes its columns too).
|
|
49
|
+
resolved: dict[str, Any] = {}
|
|
50
|
+
for klass in reversed(cls.__mro__):
|
|
51
|
+
globalns = getattr(sys.modules.get(klass.__module__), "__dict__", {})
|
|
52
|
+
# `inspect.get_annotations` and NOT `vars(klass)["__annotations__"]`: since 3.14 (PEP
|
|
53
|
+
# 649) annotations are lazy and no longer sit in the class `__dict__`, so the raw read
|
|
54
|
+
# answered an EMPTY dict there and every column fell through to `object`.
|
|
55
|
+
for attr, raw in inspect.get_annotations(klass).items():
|
|
56
|
+
if not isinstance(vars(klass).get(attr), SnakeColumn):
|
|
57
|
+
continue
|
|
58
|
+
resolved[attr] = eval(raw, globalns) if isinstance(raw, str) else raw # noqa: S307
|
|
59
|
+
return resolved
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _compile_indexes(
|
|
63
|
+
cls: type, columns: list[SnakeColumnInfo]
|
|
64
|
+
) -> tuple[SnakeIndexInfo, ...]:
|
|
65
|
+
"""The table's indexes: those declared in `SnakeIndexes` plus those from the `index=True` flag.
|
|
66
|
+
|
|
67
|
+
The flag produces a NON-unique index. The explicit declaration wins: if `SnakeIndexes` already
|
|
68
|
+
covers those columns, the flag does not duplicate. It goes by the SQL name, not the attribute's.
|
|
69
|
+
"""
|
|
70
|
+
declared = [
|
|
71
|
+
SnakeIndexInfo(
|
|
72
|
+
columns=index.column_names(),
|
|
73
|
+
unique=index.unique,
|
|
74
|
+
name=index.name,
|
|
75
|
+
where=index.where,
|
|
76
|
+
method=index.method,
|
|
77
|
+
)
|
|
78
|
+
for index in getattr(cls, "SnakeIndexes", [])
|
|
79
|
+
]
|
|
80
|
+
covered = {index.columns for index in declared}
|
|
81
|
+
from_flag = [
|
|
82
|
+
SnakeIndexInfo(columns=(column.name,))
|
|
83
|
+
for column in columns
|
|
84
|
+
if column.index and (column.name,) not in covered
|
|
85
|
+
]
|
|
86
|
+
return (*declared, *from_flag)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _guard_enum_declaration(
|
|
90
|
+
cls: type, attr: str, python_type: type, descriptor: SnakeColumn[Any]
|
|
91
|
+
) -> None:
|
|
92
|
+
"""Demands `snake_enum` when the annotation is an Enum. One single path, and an explicit one.
|
|
93
|
+
|
|
94
|
+
Without the guard the failure arrived late: the dialect blew up while generating the DDL, and the
|
|
95
|
+
return journey would hand back the raw `str` instead of the member (breaking the declared type).
|
|
96
|
+
"""
|
|
97
|
+
if not (isinstance(python_type, type) and issubclass(python_type, Enum)):
|
|
98
|
+
return
|
|
99
|
+
if descriptor.enum_type is None:
|
|
100
|
+
raise SnakeModelDefinitionError(
|
|
101
|
+
f"{cls.__name__}.{attr} is annotated as {python_type.__name__}, which is an "
|
|
102
|
+
f"enum, but it was declared with snake_column(). Use "
|
|
103
|
+
f"snake_enum({python_type.__name__}) to decide how it gets stored."
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _guard_type_params(
|
|
108
|
+
cls: type, attr: str, python_type: type, descriptor: SnakeColumn[Any]
|
|
109
|
+
) -> None:
|
|
110
|
+
"""Rejects one family's parameters on a column of ANOTHER family.
|
|
111
|
+
|
|
112
|
+
ONE guard for all four (and for the fifth that gets added): the family knows which Python type it
|
|
113
|
+
belongs to (`params.python_type`), so comparing that against the annotation is enough. There used
|
|
114
|
+
to be a hand-written guard per knob — and that is why `precision`/`scale` went without one for the
|
|
115
|
+
project's entire life: nobody remembered to write the fourth.
|
|
116
|
+
|
|
117
|
+
It fails AT COMPILE TIME, in our own words. Without this, `precision` on a `str` did not blow up
|
|
118
|
+
until the `migrate`, with a raw syntax error from the engine.
|
|
119
|
+
"""
|
|
120
|
+
params = descriptor.type_params
|
|
121
|
+
if params is None or params.accepts(python_type):
|
|
122
|
+
return
|
|
123
|
+
expected = params.python_type.__name__
|
|
124
|
+
# What the USER wrote, falling back to the family's own name. The message used to take the
|
|
125
|
+
# declarator from the FAMILY always, so `snake_auto()` was reported as `snake_int()` and
|
|
126
|
+
# `snake_datetimetz()` as `snake_datetime()` — naming a call that does not appear in the file
|
|
127
|
+
# being complained about, in the best-designed guard of the compiler.
|
|
128
|
+
declarator = descriptor.declared_by or params.declarator
|
|
129
|
+
# And only the parameters that DIFFER from their default. Listing everything that was not `None`
|
|
130
|
+
# included the ones sitting untouched, so `snake_auto()` — which takes no size — was reported as
|
|
131
|
+
# "declares size=BIGINT" and sent the reader hunting for a `size=` they never typed.
|
|
132
|
+
explicit = ", ".join(
|
|
133
|
+
f"{field.name}={getattr(params, field.name)}"
|
|
134
|
+
for field in fields(params)
|
|
135
|
+
if getattr(params, field.name) is not None
|
|
136
|
+
and getattr(params, field.name) != _default_of(field)
|
|
137
|
+
)
|
|
138
|
+
# With nothing set explicitly there is no parameter to quote, and "declares type parameters" is
|
|
139
|
+
# not a sentence anybody wants to read: the specifier itself is the whole complaint.
|
|
140
|
+
said = (
|
|
141
|
+
f"declares {explicit} with {declarator}()"
|
|
142
|
+
if explicit
|
|
143
|
+
else f"is declared with {declarator}()"
|
|
144
|
+
)
|
|
145
|
+
raise SnakeModelDefinitionError(
|
|
146
|
+
f"{cls.__name__}.{attr} {said}, which only "
|
|
147
|
+
f"applies to a {expected} column, but its type is "
|
|
148
|
+
f"{getattr(python_type, '__name__', python_type)!r}. The ANNOTATION is what rules the "
|
|
149
|
+
f"type: either change it to {expected}, or declare the column with the specifier of its "
|
|
150
|
+
f"family."
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _default_of(field: Field[object]) -> object:
|
|
155
|
+
"""The default a dataclass field carries, or a sentinel that equals nothing.
|
|
156
|
+
|
|
157
|
+
`MISSING` here is `dataclasses.MISSING`, and it is imported under another name on purpose: this
|
|
158
|
+
module already imports a `MISSING` from `snakeorm.fields`, and the two are different sentinels.
|
|
159
|
+
"""
|
|
160
|
+
if field.default is not _NO_DEFAULT:
|
|
161
|
+
return field.default
|
|
162
|
+
if field.default_factory is not _NO_DEFAULT:
|
|
163
|
+
return field.default_factory()
|
|
164
|
+
return _NO_DEFAULT
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _guard_datetime_declaration(
|
|
168
|
+
cls: type, attr: str, python_type: type, descriptor: SnakeColumn[Any]
|
|
169
|
+
) -> None:
|
|
170
|
+
"""Demands that the date specifier and the annotation say the SAME thing about the zone.
|
|
171
|
+
|
|
172
|
+
`TIMESTAMP` and `TIMESTAMPTZ` are different database types, so the model has to say which one it
|
|
173
|
+
creates — just as `snake_int(size=SMALLINT)` says SMALLINT — and the specifier is where that is
|
|
174
|
+
read. The Python type says it too, but covering something else: `SnakeUtc` is what makes building
|
|
175
|
+
a non-UTC value impossible, and the checker is what enforces that. Each covers what the other
|
|
176
|
+
does not.
|
|
177
|
+
|
|
178
|
+
Redundancy is only healthy if it cannot lie, and that is what this guard is about. Same treatment
|
|
179
|
+
as `snake_enum(Status)` over a `SnakeColumn[Status]`: it is repeated, and it is checked.
|
|
180
|
+
|
|
181
|
+
A date declared with `snake_column()` is AMBIGUOUS and is rejected too: choosing on the user's
|
|
182
|
+
behalf is exactly what made EVERYTHING `TIMESTAMPTZ` without anyone having decided it.
|
|
183
|
+
"""
|
|
184
|
+
if not (isinstance(python_type, type) and issubclass(python_type, datetime)):
|
|
185
|
+
return
|
|
186
|
+
params = descriptor.type_params
|
|
187
|
+
if not isinstance(params, SnakeDateTimeParams):
|
|
188
|
+
raise SnakeModelDefinitionError(
|
|
189
|
+
f"{cls.__name__}.{attr} is a date declared with snake_column(), and that does not say "
|
|
190
|
+
f"which column to create. Use snake_datetimetz() with SnakeColumn[SnakeUtc] if it "
|
|
191
|
+
f"stores an INSTANT (TIMESTAMPTZ), or snake_datetime() with SnakeColumn[datetime] if "
|
|
192
|
+
f"it stores a WALL-CLOCK TIME (TIMESTAMP), which does not identify any instant."
|
|
193
|
+
)
|
|
194
|
+
with_zone = python_type is SnakeUtc
|
|
195
|
+
if params.tz == with_zone:
|
|
196
|
+
return
|
|
197
|
+
if params.tz:
|
|
198
|
+
raise SnakeModelDefinitionError(
|
|
199
|
+
f"{cls.__name__}.{attr} uses snake_datetimetz() (a TIMESTAMPTZ column, an instant) "
|
|
200
|
+
f"but is annotated {python_type.__name__}, which is a wall-clock time and does not "
|
|
201
|
+
f"identify one. Annotate it SnakeColumn[SnakeUtc], or declare it with snake_datetime()."
|
|
202
|
+
)
|
|
203
|
+
raise SnakeModelDefinitionError(
|
|
204
|
+
f"{cls.__name__}.{attr} is annotated SnakeUtc (an instant) but uses snake_datetime(), "
|
|
205
|
+
f"which creates a TIMESTAMP without a zone: storing it there would lose the tzinfo and with "
|
|
206
|
+
f"it the instant. Declare it with snake_datetimetz(), or annotate it SnakeColumn[datetime] "
|
|
207
|
+
f"if it is a wall-clock time."
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _enum_checks(columns: list[SnakeColumnInfo]) -> tuple[SnakeCheckInfo, ...]:
|
|
212
|
+
"""CHECKs derived from the enum columns with `storage=CHECK`.
|
|
213
|
+
|
|
214
|
+
The rule is DERIVED from the enum's members (adding a value changes the CHECK and the diff spots
|
|
215
|
+
it). The values go in the enum's order, so the SQL is stable between runs.
|
|
216
|
+
"""
|
|
217
|
+
checks: list[SnakeCheckInfo] = []
|
|
218
|
+
for column in columns:
|
|
219
|
+
if (
|
|
220
|
+
column.enum_type is None
|
|
221
|
+
or column.enum_storage is not SnakeEnumStorage.CHECK
|
|
222
|
+
):
|
|
223
|
+
continue
|
|
224
|
+
allowed = tuple(member.value for member in column.enum_type)
|
|
225
|
+
expression: SnakeExpr[Any] = SnakeExpr(
|
|
226
|
+
path=(column.name,), python_type=column.python_type
|
|
227
|
+
)
|
|
228
|
+
checks.append(SnakeCheckInfo(condition=expression.in_(allowed)))
|
|
229
|
+
return tuple(checks)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def compile_model(
|
|
233
|
+
cls: type,
|
|
234
|
+
*,
|
|
235
|
+
table: str | None = None,
|
|
236
|
+
prefix: str | None = None,
|
|
237
|
+
schema: str = DEFAULT_SCHEMA,
|
|
238
|
+
database: str = "default",
|
|
239
|
+
kind: SnakeTableKind = SnakeTableKind.TABLE,
|
|
240
|
+
polymorphic: SnakePolymorphicInfo | None = None,
|
|
241
|
+
) -> SnakeTableInfo:
|
|
242
|
+
"""Compiles a class with SnakeColumn descriptors into an immutable SnakeTableInfo.
|
|
243
|
+
|
|
244
|
+
Table name `{prefix}_{table}`; `table` changes only the table, `prefix` the namespace. With
|
|
245
|
+
`kind=VIEW` (`@snake_view`) it shares the pipeline but does NOT demand a PK; the decorator adds
|
|
246
|
+
the SELECT afterwards (`view_definition`).
|
|
247
|
+
"""
|
|
248
|
+
hints = _column_hints(cls)
|
|
249
|
+
columns: list[SnakeColumnInfo] = []
|
|
250
|
+
pk_columns: list[SnakeColumnInfo] = []
|
|
251
|
+
|
|
252
|
+
# Walks the MRO (base->child): it also collects the columns inherited from an abstract base.
|
|
253
|
+
for attr, descriptor in collect_inherited(cls, SnakeColumn).items():
|
|
254
|
+
annotation = hints.get(attr)
|
|
255
|
+
type_args = get_args(annotation) if annotation is not None else ()
|
|
256
|
+
inner = type_args[0] if type_args else object
|
|
257
|
+
python_type, inferred_nullable = unwrap_optional(inner)
|
|
258
|
+
# The descriptor keeps the compiled type: class access hands it to the expression, and the
|
|
259
|
+
# SQL layer has no other way to know it (the generic `T` does not survive to runtime).
|
|
260
|
+
descriptor.python_type = python_type
|
|
261
|
+
_guard_enum_declaration(cls, attr, python_type, descriptor)
|
|
262
|
+
_guard_type_params(cls, attr, python_type, descriptor)
|
|
263
|
+
_guard_datetime_declaration(cls, attr, python_type, descriptor)
|
|
264
|
+
info = SnakeColumnInfo(
|
|
265
|
+
name=descriptor.column_name,
|
|
266
|
+
python_type=python_type,
|
|
267
|
+
nullable=inferred_nullable, # the annotation is the ONLY source: `SnakeColumn[str | None]`
|
|
268
|
+
unique=descriptor.unique,
|
|
269
|
+
default=None if descriptor.default is MISSING else descriptor.default,
|
|
270
|
+
index=descriptor.index,
|
|
271
|
+
db_comment=descriptor.db_comment,
|
|
272
|
+
type_params=descriptor.type_params,
|
|
273
|
+
attr_name=descriptor.attr_name,
|
|
274
|
+
has_default=descriptor.has_default,
|
|
275
|
+
autoincrement=descriptor.autoincrement,
|
|
276
|
+
default_factory=descriptor.default_factory,
|
|
277
|
+
server_default=descriptor.server_default,
|
|
278
|
+
server_default_sql=descriptor.server_default_sql,
|
|
279
|
+
enum_type=descriptor.enum_type,
|
|
280
|
+
enum_storage=descriptor.enum_storage,
|
|
281
|
+
)
|
|
282
|
+
columns.append(info)
|
|
283
|
+
if descriptor.primary_key:
|
|
284
|
+
pk_columns.append(info)
|
|
285
|
+
|
|
286
|
+
# The PK is only demanded of what WE GOVERN (kind TABLE). A view may not have one, and neither
|
|
287
|
+
# may a `@snake_db_first` mirror: its legacy schema belongs to the sysadmin and often has none.
|
|
288
|
+
if not pk_columns and kind is SnakeTableKind.TABLE:
|
|
289
|
+
raise SnakeModelDefinitionError(
|
|
290
|
+
f"Model {cls.__name__} has to declare at least one PK (primary_key=True)."
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
indexes = _compile_indexes(cls, columns)
|
|
294
|
+
checks = (*_enum_checks(columns), *getattr(cls, "SnakeChecks", ()))
|
|
295
|
+
|
|
296
|
+
base_name = table if table is not None else f"{cls.__name__.lower()}s"
|
|
297
|
+
if polymorphic is not None:
|
|
298
|
+
_guard_discriminator(cls, columns, polymorphic)
|
|
299
|
+
return SnakeTableInfo(
|
|
300
|
+
name=f"{prefix}_{base_name}" if prefix else base_name,
|
|
301
|
+
columns=tuple(columns),
|
|
302
|
+
primary_key=SnakePrimaryKeyInfo(columns=tuple(pk_columns)),
|
|
303
|
+
schema=schema,
|
|
304
|
+
database=database,
|
|
305
|
+
db_comment=getattr(cls, "SnakeComment", None),
|
|
306
|
+
indexes=indexes,
|
|
307
|
+
checks=checks,
|
|
308
|
+
kind=kind,
|
|
309
|
+
polymorphic=polymorphic,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _guard_discriminator(
|
|
314
|
+
cls: type, columns: list[SnakeColumnInfo], polymorphic: SnakePolymorphicInfo
|
|
315
|
+
) -> None:
|
|
316
|
+
"""The discriminator column has to EXIST and be a `str`, or the hierarchy does not hold up.
|
|
317
|
+
|
|
318
|
+
At compile time and not at query time: otherwise the failure shows up as `WHERE
|
|
319
|
+
nonexistent_column = 'dog'` in production. A discriminator's value is a string; an `int` that
|
|
320
|
+
accepts it lies.
|
|
321
|
+
"""
|
|
322
|
+
found = next((c for c in columns if c.name == polymorphic.column), None)
|
|
323
|
+
if found is None: # pragma: no cover - impossible: the column IS the declaration
|
|
324
|
+
return
|
|
325
|
+
if found.python_type is not str:
|
|
326
|
+
raise SnakeModelDefinitionError(
|
|
327
|
+
f"The discriminator '{polymorphic.column}' of {cls.__name__} is "
|
|
328
|
+
f"{found.python_type.__name__}, and it has to be `str`: its value is the name of "
|
|
329
|
+
f"the subclass, and storing it in another type would force a conversion on every query."
|
|
330
|
+
)
|