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
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)."""