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.
Files changed (198) hide show
  1. snake_orm-0.1.0b1.dist-info/METADATA +366 -0
  2. snake_orm-0.1.0b1.dist-info/RECORD +198 -0
  3. snake_orm-0.1.0b1.dist-info/WHEEL +4 -0
  4. snake_orm-0.1.0b1.dist-info/entry_points.txt +2 -0
  5. snake_orm-0.1.0b1.dist-info/licenses/LICENSE +21 -0
  6. snakeorm/__init__.py +214 -0
  7. snakeorm/advisor.py +109 -0
  8. snakeorm/cli/__init__.py +3 -0
  9. snakeorm/cli/app.py +992 -0
  10. snakeorm/cli/discovery.py +179 -0
  11. snakeorm/cli/hooks.py +86 -0
  12. snakeorm/compiler/__init__.py +3 -0
  13. snakeorm/compiler/compiler.py +330 -0
  14. snakeorm/connection.py +228 -0
  15. snakeorm/contrib/__init__.py +17 -0
  16. snakeorm/contrib/asgi.py +192 -0
  17. snakeorm/contrib/config.py +264 -0
  18. snakeorm/contrib/deliver.py +256 -0
  19. snakeorm/contrib/django.py +245 -0
  20. snakeorm/contrib/sidecar.py +44 -0
  21. snakeorm/contrib/wsgi.py +176 -0
  22. snakeorm/core/__init__.py +1 -0
  23. snakeorm/core/config.py +173 -0
  24. snakeorm/core/converters.py +172 -0
  25. snakeorm/core/exceptions.py +175 -0
  26. snakeorm/core/placement.py +15 -0
  27. snakeorm/core/sentinels.py +37 -0
  28. snakeorm/core/signals.py +114 -0
  29. snakeorm/debug/__init__.py +42 -0
  30. snakeorm/debug/assets/css/panel.css +616 -0
  31. snakeorm/debug/assets/css/snake_orm_app.css +293 -0
  32. snakeorm/debug/assets/img/icon.svg +5 -0
  33. snakeorm/debug/assets/js/history.js +425 -0
  34. snakeorm/debug/assets/js/language.js +283 -0
  35. snakeorm/debug/assets/js/panel.js +241 -0
  36. snakeorm/debug/assets/js/snake_orm_app.js +82 -0
  37. snakeorm/debug/assets/pages/cli.html +28 -0
  38. snakeorm/debug/assets/pages/config.html +34 -0
  39. snakeorm/debug/assets/pages/database_first.html +29 -0
  40. snakeorm/debug/assets/pages/help.html +21 -0
  41. snakeorm/debug/assets/pages/history.html +27 -0
  42. snakeorm/debug/capture.py +234 -0
  43. snakeorm/debug/channel.py +131 -0
  44. snakeorm/debug/collector.py +144 -0
  45. snakeorm/debug/config.py +149 -0
  46. snakeorm/debug/html.py +578 -0
  47. snakeorm/debug/matrix.py +60 -0
  48. snakeorm/debug/origin.py +42 -0
  49. snakeorm/debug/otel/__init__.py +68 -0
  50. snakeorm/debug/otel/context.py +43 -0
  51. snakeorm/debug/otel/exporter.py +277 -0
  52. snakeorm/debug/otel/payload.py +97 -0
  53. snakeorm/debug/otel/report.py +29 -0
  54. snakeorm/debug/otel/spans.py +393 -0
  55. snakeorm/debug/otel/summary.py +59 -0
  56. snakeorm/debug/record.py +54 -0
  57. snakeorm/debug/report.py +311 -0
  58. snakeorm/debug/testing.py +23 -0
  59. snakeorm/decorators/__init__.py +13 -0
  60. snakeorm/decorators/abstract.py +26 -0
  61. snakeorm/decorators/db_first.py +104 -0
  62. snakeorm/decorators/function.py +28 -0
  63. snakeorm/decorators/model.py +268 -0
  64. snakeorm/decorators/polymorphic.py +140 -0
  65. snakeorm/decorators/result.py +167 -0
  66. snakeorm/decorators/row.py +90 -0
  67. snakeorm/decorators/trigger.py +47 -0
  68. snakeorm/decorators/view.py +149 -0
  69. snakeorm/dialects/__init__.py +6 -0
  70. snakeorm/dialects/base.py +373 -0
  71. snakeorm/dialects/capabilities.py +571 -0
  72. snakeorm/dialects/literals.py +46 -0
  73. snakeorm/dialects/matrix.py +384 -0
  74. snakeorm/dialects/mysql.py +519 -0
  75. snakeorm/dialects/postgres.py +504 -0
  76. snakeorm/dialects/sqlite.py +417 -0
  77. snakeorm/drivers/__init__.py +18 -0
  78. snakeorm/drivers/asyncbase.py +73 -0
  79. snakeorm/drivers/asyncdecorators.py +244 -0
  80. snakeorm/drivers/asyncpool.py +228 -0
  81. snakeorm/drivers/asyncpsycopg.py +124 -0
  82. snakeorm/drivers/asyncpymysql.py +50 -0
  83. snakeorm/drivers/asyncsqlite.py +30 -0
  84. snakeorm/drivers/base.py +76 -0
  85. snakeorm/drivers/failures.py +181 -0
  86. snakeorm/drivers/logging.py +167 -0
  87. snakeorm/drivers/pool.py +352 -0
  88. snakeorm/drivers/psycopg.py +188 -0
  89. snakeorm/drivers/pymysql.py +176 -0
  90. snakeorm/drivers/savepoints.py +46 -0
  91. snakeorm/drivers/sqlite.py +170 -0
  92. snakeorm/drivers/threaded.py +143 -0
  93. snakeorm/drivers/timeout.py +87 -0
  94. snakeorm/dto/__init__.py +55 -0
  95. snakeorm/dto/read.py +231 -0
  96. snakeorm/dto/region.py +326 -0
  97. snakeorm/dto/resolve.py +354 -0
  98. snakeorm/dto/spec.py +201 -0
  99. snakeorm/expressions/__init__.py +88 -0
  100. snakeorm/expressions/conditional.py +178 -0
  101. snakeorm/expressions/expression.py +810 -0
  102. snakeorm/expressions/functions.py +71 -0
  103. snakeorm/expressions/keys.py +299 -0
  104. snakeorm/expressions/paths.py +43 -0
  105. snakeorm/expressions/scalar.py +293 -0
  106. snakeorm/expressions/window.py +248 -0
  107. snakeorm/fields/__init__.py +68 -0
  108. snakeorm/fields/check.py +79 -0
  109. snakeorm/fields/column.py +316 -0
  110. snakeorm/fields/enum.py +64 -0
  111. snakeorm/fields/index.py +46 -0
  112. snakeorm/fields/relationship.py +738 -0
  113. snakeorm/fields/typed.py +736 -0
  114. snakeorm/helpers/__init__.py +1 -0
  115. snakeorm/helpers/annotations.py +21 -0
  116. snakeorm/helpers/inheritance.py +41 -0
  117. snakeorm/helpers/pyliteral.py +52 -0
  118. snakeorm/helpers/pytype.py +50 -0
  119. snakeorm/introspection/__init__.py +13 -0
  120. snakeorm/introspection/base.py +30 -0
  121. snakeorm/introspection/drift.py +78 -0
  122. snakeorm/introspection/models.py +633 -0
  123. snakeorm/introspection/mysql.py +367 -0
  124. snakeorm/introspection/postgres.py +397 -0
  125. snakeorm/introspection/sqlite.py +217 -0
  126. snakeorm/introspection/unsupported.py +94 -0
  127. snakeorm/linker/__init__.py +3 -0
  128. snakeorm/linker/linker.py +521 -0
  129. snakeorm/metadata/__init__.py +41 -0
  130. snakeorm/metadata/check.py +37 -0
  131. snakeorm/metadata/column.py +201 -0
  132. snakeorm/metadata/enum_storage.py +33 -0
  133. snakeorm/metadata/fk_action.py +19 -0
  134. snakeorm/metadata/foreign_key.py +26 -0
  135. snakeorm/metadata/index.py +43 -0
  136. snakeorm/metadata/index_method.py +19 -0
  137. snakeorm/metadata/int_size.py +20 -0
  138. snakeorm/metadata/json_storage.py +21 -0
  139. snakeorm/metadata/polymorphic.py +30 -0
  140. snakeorm/metadata/primary_key.py +23 -0
  141. snakeorm/metadata/relationship.py +60 -0
  142. snakeorm/metadata/relationship_kind.py +43 -0
  143. snakeorm/metadata/routine.py +26 -0
  144. snakeorm/metadata/server_default.py +20 -0
  145. snakeorm/metadata/table.py +97 -0
  146. snakeorm/metadata/table_kind.py +23 -0
  147. snakeorm/metadata/trigger.py +62 -0
  148. snakeorm/metadata/type_params.py +348 -0
  149. snakeorm/migration/__init__.py +88 -0
  150. snakeorm/migration/asyncrunner.py +168 -0
  151. snakeorm/migration/autodetect.py +96 -0
  152. snakeorm/migration/ddl.py +1172 -0
  153. snakeorm/migration/diff.py +701 -0
  154. snakeorm/migration/loader.py +77 -0
  155. snakeorm/migration/operations.py +952 -0
  156. snakeorm/migration/planner.py +78 -0
  157. snakeorm/migration/realize.py +420 -0
  158. snakeorm/migration/renames.py +153 -0
  159. snakeorm/migration/render.py +1095 -0
  160. snakeorm/migration/runner.py +314 -0
  161. snakeorm/migration/squash.py +60 -0
  162. snakeorm/migration/state.py +107 -0
  163. snakeorm/model.py +162 -0
  164. snakeorm/py.typed +0 -0
  165. snakeorm/query/__init__.py +9 -0
  166. snakeorm/query/compound.py +288 -0
  167. snakeorm/query/join_kind.py +19 -0
  168. snakeorm/query/joined.py +143 -0
  169. snakeorm/query/query.py +1001 -0
  170. snakeorm/query/recursive.py +244 -0
  171. snakeorm/registry/__init__.py +5 -0
  172. snakeorm/registry/by_module.py +163 -0
  173. snakeorm/registry/registry.py +166 -0
  174. snakeorm/session/__init__.py +8 -0
  175. snakeorm/session/asyncsession.py +638 -0
  176. snakeorm/session/coercion.py +306 -0
  177. snakeorm/session/factory.py +29 -0
  178. snakeorm/session/guards.py +269 -0
  179. snakeorm/session/isolation.py +23 -0
  180. snakeorm/session/mapper.py +213 -0
  181. snakeorm/session/planning.py +815 -0
  182. snakeorm/session/retry.py +96 -0
  183. snakeorm/session/session.py +871 -0
  184. snakeorm/session/shared.py +355 -0
  185. snakeorm/sql/__init__.py +18 -0
  186. snakeorm/sql/adapt.py +104 -0
  187. snakeorm/sql/aggregate.py +232 -0
  188. snakeorm/sql/condition.py +359 -0
  189. snakeorm/sql/delete.py +46 -0
  190. snakeorm/sql/insert.py +149 -0
  191. snakeorm/sql/joins.py +158 -0
  192. snakeorm/sql/pk_subquery.py +54 -0
  193. snakeorm/sql/refs.py +19 -0
  194. snakeorm/sql/resolver.py +28 -0
  195. snakeorm/sql/select.py +175 -0
  196. snakeorm/sql/update.py +82 -0
  197. snakeorm/sql/value.py +552 -0
  198. 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,3 @@
1
+ """SnakeORM's Model Compiler: class → immutable metadata graph."""
2
+
3
+ from snakeorm.compiler.compiler import compile_model as compile_model
@@ -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
+ )