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
snakeorm/times.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""Date tooling: building and converting to UTC without anyone guessing a zone.
|
|
2
|
+
|
|
3
|
+
A zone-aware `datetime` column accepts UTC ONLY, and the ORM rejects anything else. This is the
|
|
4
|
+
other half of that rule: the way through, which a guard that forbids has to offer.
|
|
5
|
+
|
|
6
|
+
Why UTC is demanded instead of converting internally: `TIMESTAMPTZ` **does not store the offset**, it
|
|
7
|
+
stores the instant. If the ORM accepted `14:30+02:00`, Postgres would return `12:30+00:00` and SQLite
|
|
8
|
+
`14:30+02:00` — the same instant written differently, so `.hour` would be 12 or 14 depending on the
|
|
9
|
+
engine. By demanding UTC at the door, what you write is exactly what you read, on both.
|
|
10
|
+
|
|
11
|
+
Where dates come from in practice, which is what decides which tool is needed:
|
|
12
|
+
|
|
13
|
+
JS `date.toISOString()` -> "2026-06-01T12:30:00.000Z" already UTC -> parse_utc()
|
|
14
|
+
HTML `<input datetime-local>` -> "2026-06-01T14:30" NO zone -> utc_from_zone()
|
|
15
|
+
|
|
16
|
+
The second is the one that matters: that text does not say where the time is from. Only someone who
|
|
17
|
+
knows the user can, so the caller supplies the zone and never the ORM.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from datetime import UTC, datetime, tzinfo
|
|
23
|
+
from typing import Any
|
|
24
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
25
|
+
|
|
26
|
+
from snakeorm.core.exceptions import SnakeValueError
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def utc_now() -> datetime:
|
|
30
|
+
"""The current instant, WITH a zone and in UTC.
|
|
31
|
+
|
|
32
|
+
It exists so nobody has to remember: a bare `datetime.now()` returns a naive one, which is the
|
|
33
|
+
value the ORM is going to reject. A `utc_now()` that reads at a glance saves the round trip.
|
|
34
|
+
"""
|
|
35
|
+
return datetime.now(UTC)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def to_utc(value: datetime) -> datetime:
|
|
39
|
+
"""Re-expresses in UTC an instant that ALREADY has a zone. It does not move it: only rewrites it.
|
|
40
|
+
|
|
41
|
+
It rejects a naive one on purpose. A `datetime` with no zone identifies no instant — the same
|
|
42
|
+
14:30 are different moments in Madrid and in Bogota — so converting it would mean GUESSING where
|
|
43
|
+
it is from. `utc_from_zone()` is there for that, where the zone comes from whoever knows it.
|
|
44
|
+
"""
|
|
45
|
+
if value.tzinfo is None or value.utcoffset() is None:
|
|
46
|
+
raise SnakeValueError(
|
|
47
|
+
f"{value!r} has no zone, so it identifies no instant: the same 14:30 are different "
|
|
48
|
+
f"moments in Madrid and in Bogota. If you know which zone that time is from, use "
|
|
49
|
+
f"utc_from_zone(value, 'Europe/Madrid'); if it comes from a client, have it send you "
|
|
50
|
+
f"the offset."
|
|
51
|
+
)
|
|
52
|
+
return value.astimezone(UTC)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def utc_from_zone(value: datetime, zone: str) -> datetime:
|
|
56
|
+
"""Places a LOCAL time in its zone and returns the instant in UTC.
|
|
57
|
+
|
|
58
|
+
utc_from_zone(datetime(2026, 6, 1, 14, 30), "Europe/Madrid") # -> 12:30 UTC
|
|
59
|
+
|
|
60
|
+
It is the form's tool: `<input type="datetime-local">` sends "2026-06-01T14:30" with no zone. The
|
|
61
|
+
zone is applied with its DAYLIGHT SAVING (`ZoneInfo`, not a fixed offset): the same 14:30 in
|
|
62
|
+
Madrid are 12:30 UTC in June and 13:30 in January, and a fixed offset would be right half the
|
|
63
|
+
year.
|
|
64
|
+
|
|
65
|
+
It rejects a `datetime` that already has a zone: it would be ambiguous between reinterpreting and
|
|
66
|
+
converting, two reasonable readings with different results. `to_utc()` is there to convert.
|
|
67
|
+
"""
|
|
68
|
+
if value.tzinfo is not None:
|
|
69
|
+
raise SnakeValueError(
|
|
70
|
+
f"{value!r} already has a zone ({value.tzinfo}), so placing it in {zone!r} is "
|
|
71
|
+
f"ambiguous: there is no telling whether you want to reinterpret the time or convert "
|
|
72
|
+
f"the instant. To convert, to_utc(value)."
|
|
73
|
+
)
|
|
74
|
+
try:
|
|
75
|
+
info = ZoneInfo(zone)
|
|
76
|
+
except (ZoneInfoNotFoundError, ValueError) as error:
|
|
77
|
+
raise SnakeValueError(
|
|
78
|
+
f"{zone!r} is not a known time zone. IANA database names are expected, such as "
|
|
79
|
+
f"'Europe/Madrid' or 'America/Bogota'."
|
|
80
|
+
) from error
|
|
81
|
+
return value.replace(tzinfo=info).astimezone(UTC)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def parse_utc(text: str) -> datetime:
|
|
85
|
+
"""Reads an ISO-8601 WITH a zone and returns the instant in UTC.
|
|
86
|
+
|
|
87
|
+
parse_utc("2026-06-01T12:30:00.000Z") # what JS sends with toISOString()
|
|
88
|
+
parse_utc("2026-06-01T14:30:00+02:00") # another offset: re-expressed in UTC
|
|
89
|
+
|
|
90
|
+
It rejects text with no zone — what `<input type="datetime-local">` sends — because that text does
|
|
91
|
+
not say where the time is from, and the ORM is not going to assume it.
|
|
92
|
+
"""
|
|
93
|
+
# `fromisoformat` accepts the `Z` from 3.11 onwards, which is the project's floor.
|
|
94
|
+
try:
|
|
95
|
+
value = datetime.fromisoformat(text)
|
|
96
|
+
except ValueError as error:
|
|
97
|
+
raise SnakeValueError(
|
|
98
|
+
f"{text!r} is not an ISO-8601 date. Something like "
|
|
99
|
+
f"'2026-06-01T12:30:00Z' or '2026-06-01T14:30:00+02:00' is expected."
|
|
100
|
+
) from error
|
|
101
|
+
if value.tzinfo is None:
|
|
102
|
+
raise SnakeValueError(
|
|
103
|
+
f"{text!r} carries no zone, so it identifies no instant. It is what an "
|
|
104
|
+
f"<input type='datetime-local'> sends: if you know which zone that time is from, use "
|
|
105
|
+
f"utc_from_zone(datetime.fromisoformat({text!r}), 'Europe/Madrid')."
|
|
106
|
+
)
|
|
107
|
+
return value.astimezone(UTC)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class SnakeUtc(datetime):
|
|
111
|
+
"""An instant in UTC. There is no way to build one that is not.
|
|
112
|
+
|
|
113
|
+
created: SnakeColumn[SnakeUtc] = snake_datetimetz()
|
|
114
|
+
|
|
115
|
+
It is a SUBCLASS of `datetime`, and the whole design decision sits right there:
|
|
116
|
+
|
|
117
|
+
- Facing inwards, the checker rejects `SnakeUtc = datetime.now(UTC)`, because a `datetime` is not
|
|
118
|
+
a `SnakeUtc`. The error shows up in the editor, not when saving.
|
|
119
|
+
- Facing outwards, a `SnakeUtc` IS a `datetime`: `isinstance`, `isoformat()`, `astimezone()`, the
|
|
120
|
+
DRF or Pydantic serialisers and the templates all keep working without noticing.
|
|
121
|
+
|
|
122
|
+
A wrapper keeping the `datetime` inside would give the first and lose the second: every library
|
|
123
|
+
in the stack would have to be taught the type.
|
|
124
|
+
|
|
125
|
+
Two shapes arrive from outside, and only one carries the instant:
|
|
126
|
+
|
|
127
|
+
JS `date.toISOString()` "2026-06-01T12:30:00.000Z" -> parse() direct
|
|
128
|
+
HTML `<input datetime-local>` "2026-06-01T14:30" -> from_zone() must be placed
|
|
129
|
+
|
|
130
|
+
The second cannot be resolved on its own: that string does not say where the time is from. Only
|
|
131
|
+
someone who knows the user does, so the caller supplies the zone.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
__slots__ = ()
|
|
135
|
+
|
|
136
|
+
def __new__(
|
|
137
|
+
cls,
|
|
138
|
+
year: int,
|
|
139
|
+
month: int = 1,
|
|
140
|
+
day: int = 1,
|
|
141
|
+
hour: int = 0,
|
|
142
|
+
minute: int = 0,
|
|
143
|
+
second: int = 0,
|
|
144
|
+
microsecond: int = 0,
|
|
145
|
+
tzinfo: tzinfo | None = None,
|
|
146
|
+
*,
|
|
147
|
+
fold: int = 0,
|
|
148
|
+
) -> SnakeUtc:
|
|
149
|
+
"""Builds the instant: with no zone it places it in UTC, and with another zone it RAISES.
|
|
150
|
+
|
|
151
|
+
Converting here would be silent: you would ask for 14:30 in Madrid and the object would say
|
|
152
|
+
12:30 without you having asked. `of()` is there to convert, and it is called on purpose.
|
|
153
|
+
|
|
154
|
+
The signature is written out in full (rather than `*args`) so the checker types construction
|
|
155
|
+
exactly as it types `datetime`'s: with `*args` any nonsense compiled.
|
|
156
|
+
"""
|
|
157
|
+
if (
|
|
158
|
+
tzinfo is not None
|
|
159
|
+
and datetime(
|
|
160
|
+
year, month, day, hour, minute, second, microsecond, tzinfo, fold=fold
|
|
161
|
+
).utcoffset()
|
|
162
|
+
):
|
|
163
|
+
raise SnakeValueError(
|
|
164
|
+
f"SnakeUtc is an instant in UTC and it was asked for with zone {tzinfo}. If you "
|
|
165
|
+
f"wanted that moment expressed in UTC, SnakeUtc.of(datetime(..., tzinfo={tzinfo})): "
|
|
166
|
+
f"converting in here would be changing your time without you having asked."
|
|
167
|
+
)
|
|
168
|
+
return super().__new__(
|
|
169
|
+
cls, year, month, day, hour, minute, second, microsecond, UTC, fold=fold
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
def astimezone(self, tz: tzinfo | None = None) -> datetime: # type: ignore[override]
|
|
173
|
+
"""The same instant in another zone, as a plain `datetime` — it is NO LONGER a `SnakeUtc`.
|
|
174
|
+
|
|
175
|
+
This is the piece that keeps the type from being viral. `datetime.astimezone` rebuilds the
|
|
176
|
+
SAME class, so without this a `SnakeUtc` in Madrid would try to exist and the constructor
|
|
177
|
+
would reject it: any template or serialiser painting a date in local time would blow up. And
|
|
178
|
+
the type changing is the CORRECT outcome: converted to Madrid it is no longer a UTC instant.
|
|
179
|
+
"""
|
|
180
|
+
return self._plain().astimezone(tz)
|
|
181
|
+
|
|
182
|
+
def replace(self, *args: Any, **kwargs: Any) -> datetime: # type: ignore[override]
|
|
183
|
+
"""Like `datetime.replace`, but returning a plain `datetime` if the zone is changed.
|
|
184
|
+
|
|
185
|
+
Same criterion as `astimezone`: relabelling the zone stops it being an instant in UTC, so it
|
|
186
|
+
stops being a `SnakeUtc`. Leave the zone alone and it still is one.
|
|
187
|
+
"""
|
|
188
|
+
requested_zone = kwargs.get("tzinfo", _SENTINEL)
|
|
189
|
+
if requested_zone is _SENTINEL or requested_zone is UTC:
|
|
190
|
+
return super().replace(*args, **kwargs)
|
|
191
|
+
return self._plain().replace(*args, **kwargs)
|
|
192
|
+
|
|
193
|
+
def _plain(self) -> datetime:
|
|
194
|
+
"""The same instant as a stdlib `datetime`, without this class's restrictions."""
|
|
195
|
+
return datetime(
|
|
196
|
+
self.year,
|
|
197
|
+
self.month,
|
|
198
|
+
self.day,
|
|
199
|
+
self.hour,
|
|
200
|
+
self.minute,
|
|
201
|
+
self.second,
|
|
202
|
+
self.microsecond,
|
|
203
|
+
tzinfo=UTC,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
@classmethod
|
|
207
|
+
def now(cls, tz: object = None) -> SnakeUtc:
|
|
208
|
+
"""The current instant. The signature accepts `tz` for `datetime` compatibility and ignores
|
|
209
|
+
it: a `SnakeUtc` is always UTC."""
|
|
210
|
+
del tz
|
|
211
|
+
return cls.of(datetime.now(UTC))
|
|
212
|
+
|
|
213
|
+
@classmethod
|
|
214
|
+
def of(cls, value: datetime) -> SnakeUtc:
|
|
215
|
+
"""Re-expresses in UTC a `datetime` that ALREADY has a zone. It does not move the instant.
|
|
216
|
+
|
|
217
|
+
It rejects a naive one: with no zone there is no instant to re-express, it would have to be
|
|
218
|
+
guessed where it is from.
|
|
219
|
+
"""
|
|
220
|
+
if value.tzinfo is None or value.utcoffset() is None:
|
|
221
|
+
raise SnakeValueError(
|
|
222
|
+
f"{value!r} has no zone, so it identifies no instant: the same 14:30 are "
|
|
223
|
+
f"different moments in Madrid and in Bogota. If you know which zone it is from, "
|
|
224
|
+
f"SnakeUtc.from_zone(value, 'Europe/Madrid')."
|
|
225
|
+
)
|
|
226
|
+
in_utc = value.astimezone(UTC)
|
|
227
|
+
return cls(
|
|
228
|
+
in_utc.year,
|
|
229
|
+
in_utc.month,
|
|
230
|
+
in_utc.day,
|
|
231
|
+
in_utc.hour,
|
|
232
|
+
in_utc.minute,
|
|
233
|
+
in_utc.second,
|
|
234
|
+
in_utc.microsecond,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
@classmethod
|
|
238
|
+
def from_zone(cls, value: datetime, zone: str) -> SnakeUtc:
|
|
239
|
+
"""Places a LOCAL time in its zone and returns the instant in UTC.
|
|
240
|
+
|
|
241
|
+
SnakeUtc.from_zone(datetime.fromisoformat(form["when"]), user.zone)
|
|
242
|
+
|
|
243
|
+
It is the form's path. The zone is applied with its DAYLIGHT SAVING (`ZoneInfo`, not a fixed
|
|
244
|
+
offset): the same 14:30 in Madrid are 12:30 UTC in June and 13:30 in January, and a fixed
|
|
245
|
+
offset would be right half the year — the worst kind of bug, the one that only shows up for
|
|
246
|
+
one season.
|
|
247
|
+
"""
|
|
248
|
+
return cls.of(utc_from_zone(value, zone))
|
|
249
|
+
|
|
250
|
+
@classmethod
|
|
251
|
+
def parse(cls, text: str) -> SnakeUtc:
|
|
252
|
+
"""Reads an ISO-8601 WITH a zone. It is the direct path from JS.
|
|
253
|
+
|
|
254
|
+
Text with no zone is REJECTED: it is what `<input type="datetime-local">` sends and it does
|
|
255
|
+
not say where the time is from. Converting an offset here IS correct — you called `parse` to
|
|
256
|
+
get the UTC — but inventing a zone that never came is not.
|
|
257
|
+
"""
|
|
258
|
+
return cls.of(parse_utc(text))
|
|
259
|
+
|
|
260
|
+
def to_zone(self, zone: str) -> datetime:
|
|
261
|
+
"""The same instant, expressed in another zone. For PAINTING it, not for storing it.
|
|
262
|
+
|
|
263
|
+
Storing in UTC and showing in the reader's zone is the full cycle; this is its outbound half.
|
|
264
|
+
"""
|
|
265
|
+
try:
|
|
266
|
+
info = ZoneInfo(zone)
|
|
267
|
+
except (ZoneInfoNotFoundError, ValueError) as error:
|
|
268
|
+
raise SnakeValueError(
|
|
269
|
+
f"{zone!r} is not a known time zone. IANA database names are expected, such as "
|
|
270
|
+
f"'Europe/Madrid' or 'America/Bogota'."
|
|
271
|
+
) from error
|
|
272
|
+
return self.astimezone(info)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
_SENTINEL = object()
|
|
276
|
+
"""Marks "no `tzinfo` was passed" in `replace`, which `None` cannot tell apart (None IS valid)."""
|