SQLAlchemy 2.0.51__py3-none-any.whl → 2.0.52__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 (39) hide show
  1. sqlalchemy/__init__.py +1 -1
  2. sqlalchemy/connectors/pyodbc.py +13 -4
  3. sqlalchemy/dialects/mssql/base.py +1 -1
  4. sqlalchemy/dialects/postgresql/base.py +72 -12
  5. sqlalchemy/dialects/postgresql/json.py +3 -0
  6. sqlalchemy/dialects/sqlite/aiosqlite.py +30 -0
  7. sqlalchemy/dialects/sqlite/base.py +10 -28
  8. sqlalchemy/dialects/sqlite/pysqlite.py +89 -12
  9. sqlalchemy/orm/bulk_persistence.py +16 -0
  10. sqlalchemy/orm/decl_base.py +14 -0
  11. sqlalchemy/orm/path_registry.py +55 -13
  12. sqlalchemy/orm/properties.py +2 -2
  13. sqlalchemy/orm/relationships.py +2 -2
  14. sqlalchemy/orm/session.py +25 -23
  15. sqlalchemy/orm/strategy_options.py +11 -6
  16. sqlalchemy/orm/util.py +4 -1
  17. sqlalchemy/sql/_elements_constructors.py +12 -0
  18. sqlalchemy/sql/compiler.py +10 -0
  19. sqlalchemy/sql/elements.py +2 -2
  20. sqlalchemy/sql/schema.py +16 -17
  21. sqlalchemy/sql/sqltypes.py +9 -5
  22. sqlalchemy/testing/__init__.py +2 -0
  23. sqlalchemy/testing/config.py +7 -0
  24. sqlalchemy/testing/fixtures/mypy.py +5 -3
  25. sqlalchemy/testing/fixtures/orm.py +2 -2
  26. sqlalchemy/testing/fixtures/sql.py +2 -2
  27. sqlalchemy/testing/plugin/plugin_base.py +4 -0
  28. sqlalchemy/testing/plugin/pytestplugin.py +30 -0
  29. sqlalchemy/testing/suite/test_reflection.py +88 -0
  30. sqlalchemy/testing/util.py +41 -0
  31. sqlalchemy/util/__init__.py +2 -0
  32. sqlalchemy/util/compat.py +1 -1
  33. sqlalchemy/util/langhelpers.py +93 -0
  34. sqlalchemy/util/typing.py +2 -0
  35. {sqlalchemy-2.0.51.dist-info → sqlalchemy-2.0.52.dist-info}/METADATA +4 -2
  36. {sqlalchemy-2.0.51.dist-info → sqlalchemy-2.0.52.dist-info}/RECORD +39 -39
  37. {sqlalchemy-2.0.51.dist-info → sqlalchemy-2.0.52.dist-info}/WHEEL +1 -1
  38. {sqlalchemy-2.0.51.dist-info → sqlalchemy-2.0.52.dist-info}/licenses/LICENSE +0 -0
  39. {sqlalchemy-2.0.51.dist-info → sqlalchemy-2.0.52.dist-info}/top_level.txt +0 -0
sqlalchemy/__init__.py CHANGED
@@ -269,7 +269,7 @@ from .types import Uuid as Uuid
269
269
  from .types import VARBINARY as VARBINARY
270
270
  from .types import VARCHAR as VARCHAR
271
271
 
272
- __version__ = "2.0.51"
272
+ __version__ = "2.0.52"
273
273
 
274
274
 
275
275
  def __go(lcls: Any) -> None:
@@ -77,10 +77,16 @@ class PyODBCConnector(Connector):
77
77
  else:
78
78
 
79
79
  def check_quote(token: str) -> str:
80
- if ";" in str(token) or str(token).startswith("{"):
80
+ if (
81
+ ";" in str(token)
82
+ or "}" in str(token)
83
+ or str(token).startswith("{")
84
+ ):
81
85
  token = "{%s}" % token.replace("}", "}}")
82
86
  return token
83
87
 
88
+ driver = keys.pop("driver", self.pyodbc_driver_name)
89
+
84
90
  keys = {k: check_quote(v) for k, v in keys.items()}
85
91
 
86
92
  dsn_connection = "dsn" in keys or (
@@ -96,7 +102,6 @@ class PyODBCConnector(Connector):
96
102
  port = ",%d" % int(keys.pop("port"))
97
103
 
98
104
  connectors = []
99
- driver = keys.pop("driver", self.pyodbc_driver_name)
100
105
  if driver is None and keys:
101
106
  # note if keys is empty, this is a totally blank URL
102
107
  util.warn(
@@ -105,7 +110,9 @@ class PyODBCConnector(Connector):
105
110
  "DSN-less connections"
106
111
  )
107
112
  else:
108
- connectors.append("DRIVER={%s}" % driver)
113
+ connectors.append(
114
+ "DRIVER={%s}" % str(driver).replace("}", "}}")
115
+ )
109
116
 
110
117
  connectors.extend(
111
118
  [
@@ -136,7 +143,9 @@ class PyODBCConnector(Connector):
136
143
  "AutoTranslate=%s" % keys.pop("odbc_autotranslate")
137
144
  )
138
145
 
139
- connectors.extend(["%s=%s" % (k, v) for k, v in keys.items()])
146
+ connectors.extend(
147
+ ["%s=%s" % (check_quote(k), v) for k, v in keys.items()]
148
+ )
140
149
 
141
150
  return ((";".join(connectors),), connect_args)
142
151
 
@@ -1585,7 +1585,7 @@ class MSUUid(sqltypes.Uuid):
1585
1585
  if self.native_uuid:
1586
1586
 
1587
1587
  def process(value):
1588
- return f"""'{str(value).replace("''", "'")}'"""
1588
+ return f"""'{str(value).replace("'", "''")}'"""
1589
1589
 
1590
1590
  return process
1591
1591
  else:
@@ -1213,6 +1213,33 @@ identifier::
1213
1213
  ),
1214
1214
  )
1215
1215
 
1216
+ SQLAlchemy does not automatically copy the columns from the inherited tables
1217
+ mentioned in the ``postgresql_inherits`` argument into the new
1218
+ :class:`_schema.Table`. To populate the new table columns reflection may be
1219
+ used, or a function similar to the following one::
1220
+
1221
+ def get_parent_columns(tbl: sa.Table) -> list[sa.Column]:
1222
+ return [
1223
+ sa.Column(
1224
+ c.name,
1225
+ c.type,
1226
+ key=c.key,
1227
+ nullable=c.nullable,
1228
+ # Set system=true to omit from the CREATE TABLE statement
1229
+ system=True,
1230
+ )
1231
+ for c in tbl.columns
1232
+ ]
1233
+
1234
+
1235
+ documents = Table(
1236
+ "some_table",
1237
+ metadata,
1238
+ *get_parent_columns(some_supertable),
1239
+ # ... # add other columns here normally if needed
1240
+ postgresql_inherits="some_supertable",
1241
+ )
1242
+
1216
1243
  ``ON COMMIT``
1217
1244
  ^^^^^^^^^^^^^
1218
1245
 
@@ -2135,7 +2162,9 @@ class PGCompiler(compiler.SQLCompiler):
2135
2162
  return "string_agg%s" % self.function_argspec(fn)
2136
2163
 
2137
2164
  def visit_sequence(self, seq, **kw):
2138
- return "nextval('%s')" % self.preparer.format_sequence(seq)
2165
+ return "nextval(%s)" % self.preparer._format_sequence_string_literal(
2166
+ seq
2167
+ )
2139
2168
 
2140
2169
  def limit_clause(self, select, **kw):
2141
2170
  text = ""
@@ -2968,9 +2997,43 @@ class PGTypeCompiler(compiler.GenericTypeCompiler):
2968
2997
  return "JSONPATH"
2969
2998
 
2970
2999
 
3000
+ class _CompilerSequence:
3001
+ """Minimal stand-in for :class:`.Sequence`.
3002
+
3003
+ Used for the implicit sequence behind a SERIAL column, where no
3004
+ :class:`.Sequence` object exists but a name still has to be rendered
3005
+ through :meth:`.IdentifierPreparer.format_sequence`.
3006
+
3007
+ """
3008
+
3009
+ __slots__ = ("name", "schema")
3010
+
3011
+ # the schema handed to us has already been resolved against the
3012
+ # schema translate map, so don't let format_sequence() translate it
3013
+ # a second time
3014
+ _use_schema_map = False
3015
+
3016
+ def __init__(self, name, schema=None):
3017
+ self.name = name
3018
+ self.schema = schema
3019
+
3020
+
2971
3021
  class PGIdentifierPreparer(compiler.IdentifierPreparer):
2972
3022
  reserved_words = RESERVED_WORDS
2973
3023
 
3024
+ def _format_sequence_string_literal(self, sequence):
3025
+ """Render a sequence name as a SQL string literal.
3026
+
3027
+ ``nextval()`` and friends take the sequence as a string rather than
3028
+ as an identifier, so the quoted identifier produced by
3029
+ :meth:`.format_sequence` has to be escaped a second time for the
3030
+ enclosing literal.
3031
+
3032
+ """
3033
+ return "'%s'" % self.format_sequence(
3034
+ sequence, use_schema=True
3035
+ ).replace("'", "''")
3036
+
2974
3037
  def _unquote_identifier(self, value):
2975
3038
  if value[0] == self.initial_quote:
2976
3039
  value = value[1:-1].replace(
@@ -3156,8 +3219,8 @@ class PGExecutionContext(default.DefaultExecutionContext):
3156
3219
  def fire_sequence(self, seq, type_):
3157
3220
  return self._execute_scalar(
3158
3221
  (
3159
- "select nextval('%s')"
3160
- % self.identifier_preparer.format_sequence(seq)
3222
+ "select nextval(%s)"
3223
+ % self.identifier_preparer._format_sequence_string_literal(seq)
3161
3224
  ),
3162
3225
  type_,
3163
3226
  )
@@ -3194,13 +3257,12 @@ class PGExecutionContext(default.DefaultExecutionContext):
3194
3257
  else:
3195
3258
  effective_schema = None
3196
3259
 
3197
- if effective_schema is not None:
3198
- exc = 'select nextval(\'"%s"."%s"\')' % (
3199
- effective_schema,
3200
- seq_name,
3260
+ exc = (
3261
+ "select nextval(%s)"
3262
+ % self.identifier_preparer._format_sequence_string_literal(
3263
+ _CompilerSequence(seq_name, effective_schema)
3201
3264
  )
3202
- else:
3203
- exc = "select nextval('\"%s\"')" % (seq_name,)
3265
+ )
3204
3266
 
3205
3267
  return self._execute_scalar(exc, column.type)
3206
3268
 
@@ -5229,9 +5291,7 @@ class PGDialect(default.DefaultDialect):
5229
5291
  util.warn("Could not parse CHECK constraint text: %r" % src)
5230
5292
  sqltext = ""
5231
5293
  else:
5232
- sqltext = re.compile(
5233
- r"^[\s\n]*\((.+)\)[\s\n]*$", flags=re.DOTALL
5234
- ).sub(r"\1", m.group(1))
5294
+ sqltext = util.strip_outer_parens(m.group(1))
5235
5295
  entry = {
5236
5296
  "name": check_name,
5237
5297
  "sqltext": sqltext,
@@ -49,6 +49,9 @@ class JSONPathType(sqltypes.JSON.JSONPathType):
49
49
  if isinstance(value, str):
50
50
  # If it's already a string assume that it's in json path
51
51
  # format. This allows using cast with json paths literals
52
+ # Still need to process through super_proc for proper escaping
53
+ if super_proc:
54
+ value = super_proc(value)
52
55
  return value
53
56
  elif value:
54
57
  # If it's already a string assume that it's in json path
@@ -76,6 +76,36 @@ based on the kind of SQLite database that's requested:
76
76
  may be used by specifying it via the
77
77
  :paramref:`_sa.create_engine.poolclass` parameter.
78
78
 
79
+ .. _aiosqlite_memory:
80
+
81
+ Using a Memory Database with Multiple Coroutines
82
+ -------------------------------------------------
83
+
84
+ The default :class:`.StaticPool` used for ``:memory:`` databases forces all
85
+ coroutines to share a single DBAPI connection. Because SQLite maintains only
86
+ one transaction state per connection, concurrent coroutines can interfere
87
+ with each other — a ``ROLLBACK`` in one coroutine will also discard
88
+ uncommitted work from any other coroutine using the same engine.
89
+
90
+ For async workloads where multiple :class:`.AsyncSession` or
91
+ :class:`.AsyncConnection` objects may be active simultaneously, use SQLite's
92
+ shared-cache URI mode instead. This gives each checkout its own DBAPI
93
+ connection with independent transaction state while still sharing one
94
+ in-memory database::
95
+
96
+ engine = create_async_engine(
97
+ "sqlite+aiosqlite:///file::memory:?cache=shared&uri=true"
98
+ )
99
+
100
+ Because this URL form is treated as a file-based database by the dialect,
101
+ :class:`.AsyncAdaptedQueuePool` is used automatically and no additional
102
+ configuration is needed.
103
+
104
+ See the pysqlite documentation at
105
+ :ref:`pysqlite_uri_shared_cache` for full details on shared-cache memory
106
+ databases, including how to use named databases to maintain multiple
107
+ independent in-memory databases within the same process.
108
+
79
109
  """ # noqa
80
110
 
81
111
  from __future__ import annotations
@@ -1128,7 +1128,7 @@ class DATETIME(_DateTimeMixin, sqltypes.DateTime):
1128
1128
  storage_format=(
1129
1129
  "%(year)04d/%(month)02d/%(day)02d %(hour)02d:%(minute)02d:%(second)02d"
1130
1130
  ),
1131
- regexp=r"(\d+)/(\d+)/(\d+) (\d+)-(\d+)-(\d+)",
1131
+ regexp=r"(\d+)/(\d+)/(\d+) (\d+):(\d+):(\d+)",
1132
1132
  )
1133
1133
 
1134
1134
  :param truncate_microseconds: when ``True`` microseconds will be truncated
@@ -2774,7 +2774,7 @@ class SQLiteDialect(default.DefaultDialect):
2774
2774
  )
2775
2775
  INLINE_UNIQUE_PATTERN = (
2776
2776
  r'(?:(".+?")|(?:[\[`])?([a-z0-9_]+)(?:[\]`])?)[\t ]'
2777
- r"+[a-z0-9_ ]+?[\t ]+UNIQUE"
2777
+ r"+[a-z0-9_]+(?:[\t ]+[a-z0-9_]+)*?[\t ]+UNIQUE"
2778
2778
  )
2779
2779
 
2780
2780
  for match in re.finditer(UNIQUE_PATTERN, table_data, re.I):
@@ -2865,32 +2865,14 @@ class SQLiteDialect(default.DefaultDialect):
2865
2865
  flags=re.DOTALL,
2866
2866
  )
2867
2867
 
2868
- # Find the matching closing parenthesis by counting balanced parens
2869
- # Must track string context to ignore parens inside string literals
2870
- start = match.end() # Position after 'CHECK ('
2871
- paren_count = 1
2872
- in_single_quote = False
2873
- in_double_quote = False
2874
-
2875
- for pos, char in enumerate(table_data[start:], start):
2876
- # Track string literal context
2877
- if char == "'" and not in_double_quote:
2878
- in_single_quote = not in_single_quote
2879
- elif char == '"' and not in_single_quote:
2880
- in_double_quote = not in_double_quote
2881
- # Only count parens when not inside a string literal
2882
- elif not in_single_quote and not in_double_quote:
2883
- if char == "(":
2884
- paren_count += 1
2885
- elif char == ")":
2886
- paren_count -= 1
2887
- if paren_count == 0:
2888
- # Successfully found matching closing parenthesis
2889
- sqltext = table_data[start:pos].strip()
2890
- cks.append(
2891
- {"sqltext": sqltext, "name": constraint_name}
2892
- )
2893
- break
2868
+ # Find the matching closing parenthesis with quote-aware paren
2869
+ # counting. ``match.end() - 1`` is the position of the ``(``
2870
+ # that opened the CHECK clause; ``match.end()`` is the first
2871
+ # character of the constraint body.
2872
+ close = util.find_matching_paren(table_data, match.end() - 1)
2873
+ if close is not None:
2874
+ sqltext = table_data[match.end() : close].strip()
2875
+ cks.append({"sqltext": sqltext, "name": constraint_name})
2894
2876
 
2895
2877
  cks.sort(key=lambda d: d["name"] or "~") # sort None as last
2896
2878
  if cks:
@@ -209,8 +209,8 @@ processing.
209
209
 
210
210
  .. _pysqlite_threading_pooling:
211
211
 
212
- Threading/Pooling Behavior
213
- ---------------------------
212
+ Concurrency/Threading/Pooling Behavior
213
+ --------------------------------------
214
214
 
215
215
  The ``sqlite3`` DBAPI by default prohibits the use of a particular connection
216
216
  in a thread which is not the one in which it was created. As SQLite has
@@ -261,15 +261,82 @@ connection reuse implemented by :class:`.QueuePool`. However, it still
261
261
  may be beneficial to use this class if the application is experiencing
262
262
  issues with files being locked.
263
263
 
264
- Using a Memory Database in Multiple Threads
265
- ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
264
+ Using a Memory Database in Multiple Threads or Coroutines
265
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
266
+
267
+ A ``:memory:`` SQLite database exists only within the scope of a single
268
+ DBAPI connection. It is not possible for two separate ``sqlite3``
269
+ connection objects to access the same ``:memory:`` database unless SQLite's
270
+ shared-cache feature is enabled. Without shared cache, each connection
271
+ creates its own independent in-memory database.
272
+
273
+ This means a ``:memory:`` database is **not suitable** for use with
274
+ multiple concurrent threads or coroutines unless either:
275
+
276
+ * All workers are fully serialized (mutexed) against each other
277
+ such that only one is using the database at a time, or
278
+ * SQLite's shared-cache URI feature is used to allow multiple
279
+ independent connections to access the same in-memory database.
280
+
281
+ The same considerations apply when using the :ref:`aiosqlite <aiosqlite>`
282
+ dialect, which wraps ``pysqlite`` connections in an async interface —
283
+ without shared cache, each connection still creates its own independent
284
+ in-memory database.
285
+
286
+ The recommended approach for multithreaded or async in-memory
287
+ use is the shared-cache URI feature, described below.
288
+
289
+ .. _pysqlite_uri_shared_cache:
290
+
291
+ Using a Shared-Cache Memory Database
292
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
293
+
294
+ SQLite's
295
+ `URI shared-cache <https://www.sqlite.org/sharedcache.html>`_
296
+ mode allows multiple independent DBAPI connections, each with
297
+ their own transaction state, to access the same in-memory
298
+ database. This is enabled by using a ``file:`` URI with
299
+ ``cache=shared`` and passing ``uri=true`` in the query
300
+ string::
301
+
302
+ engine = create_engine("sqlite:///file::memory:?cache=shared&uri=true")
303
+
304
+ For async use with aiosqlite, use
305
+ :func:`_asyncio.create_async_engine`::
306
+
307
+ engine = create_async_engine(
308
+ "sqlite+aiosqlite:///file::memory:?cache=shared&uri=true"
309
+ )
310
+
311
+ Because this URL form is treated as a file-based database by the
312
+ dialect, :class:`.QueuePool` is used automatically and
313
+ ``check_same_thread`` defaults to ``False``, so no additional pool
314
+ or connect_args configuration is needed. Each checkout from the
315
+ pool is a distinct DBAPI connection with its own transaction state,
316
+ and the in-memory database persists as long as at least one
317
+ connection remains open.
318
+
319
+ The shared-cache database is scoped by the filename component of
320
+ the URI. ``file::memory:`` (empty name) is process-global — all
321
+ engines in the process that use this URI share the same database.
322
+ To maintain multiple independent in-memory databases within the
323
+ same process, supply a distinct name for each::
324
+
325
+ engine_a = create_engine(
326
+ "sqlite:///file:db_a?mode=memory&cache=shared&uri=true"
327
+ )
328
+ engine_b = create_engine(
329
+ "sqlite:///file:db_b?mode=memory&cache=shared&uri=true"
330
+ )
331
+
332
+ Using StaticPool for Single-Connection Memory Databases
333
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
266
334
 
267
- To use a ``:memory:`` database in a multithreaded scenario, the same
268
- connection object must be shared among threads, since the database exists
269
- only within the scope of that connection. The
270
- :class:`.StaticPool` implementation will maintain a single connection
271
- globally, and the ``check_same_thread`` flag can be passed to Pysqlite
272
- as ``False``::
335
+ An older approach for sharing a ``:memory:`` database among threads
336
+ is to force all checkouts to return the same DBAPI connection using
337
+ :class:`.StaticPool`. **This approach does not support any form of
338
+ concurrency** and is only useful when all access to the engine is
339
+ fully serialized, such as in single-threaded test suites::
273
340
 
274
341
  from sqlalchemy.pool import StaticPool
275
342
 
@@ -279,8 +346,18 @@ as ``False``::
279
346
  poolclass=StaticPool,
280
347
  )
281
348
 
282
- Note that using a ``:memory:`` database in multiple threads requires a recent
283
- version of SQLite.
349
+ .. warning::
350
+
351
+ Because :class:`.StaticPool` maintains a single DBAPI connection,
352
+ all :class:`.Session` or :class:`.Connection` objects that use
353
+ this engine share that one underlying connection and its single
354
+ SQLite transaction state. A ``ROLLBACK`` issued by one session
355
+ (e.g. during error handling) will also roll back uncommitted work
356
+ from any other session, and concurrent ``COMMIT`` / ``ROLLBACK``
357
+ calls can interfere with each other unpredictably. This approach
358
+ is only appropriate when access to the engine is fully serialized,
359
+ such as in single-threaded test suites. For concurrent workloads,
360
+ use the shared-cache URI approach described above.
284
361
 
285
362
  Using Temporary Tables with SQLite
286
363
  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
@@ -785,6 +785,22 @@ class BulkUDCompileState(ORMDMLState):
785
785
  }
786
786
  )
787
787
 
788
+ # disable result-level adapt_to_context. ORM UPDATE/DELETE with
789
+ # RETURNING uses a "two level" statement: the invoked ORM statement
790
+ # holds the user's returning() columns while the cached Core
791
+ # statement returns primary key + supplemental columns, so the two
792
+ # are not positionally aligned. adapt_to_context() would remap
793
+ # result keys positionally between them and mislabel columns on a
794
+ # cache hit; the ORM instead interprets rows via its own
795
+ # from_statement context, so the Core adaptation would interfere
796
+ # here.
797
+ if not execution_options:
798
+ execution_options = context._orm_load_exec_options
799
+ else:
800
+ execution_options = execution_options.union(
801
+ context._orm_load_exec_options
802
+ )
803
+
788
804
  return (
789
805
  statement,
790
806
  util.immutabledict(execution_options).union(
@@ -1240,6 +1240,20 @@ class _ClassScanMapperConfig(_MapperConfig):
1240
1240
  "documentation for additional information.",
1241
1241
  code="dcte",
1242
1242
  ) from ex
1243
+ else:
1244
+ # as of Python 3.15, dataclasses no longer renders the
1245
+ # auto-generated class docstring immediately; it instead installs
1246
+ # a descriptor that renders the ``__init__`` signature the first
1247
+ # time ``__doc__`` is accessed (see
1248
+ # ``dataclasses._AutoDocstring``). As the ``finally:`` block
1249
+ # below puts the class' original annotations back, that deferred
1250
+ # render would no longer see the dataclass-oriented annotations
1251
+ # applied above, and would omit them from the docstring entirely.
1252
+ # Read the attribute now, while those annotations are still in
1253
+ # place, so the docstring we generate is the same on every Python
1254
+ # version.
1255
+ klass.__doc__
1256
+
1243
1257
  finally:
1244
1258
  # restore original annotations outside of the dataclasses
1245
1259
  # process; for mixins and __abstract__ superclasses, SQLAlchemy
@@ -17,6 +17,7 @@ from typing import cast
17
17
  from typing import Dict
18
18
  from typing import Iterator
19
19
  from typing import List
20
+ from typing import Mapping
20
21
  from typing import Optional
21
22
  from typing import overload
22
23
  from typing import Sequence
@@ -30,6 +31,8 @@ from .. import exc
30
31
  from .. import util
31
32
  from ..sql import visitors
32
33
  from ..sql.cache_key import HasCacheKey
34
+ from ..util.typing import Final
35
+ from ..util.typing import Literal
33
36
 
34
37
  if TYPE_CHECKING:
35
38
  from ._typing import _InternalEntityType
@@ -82,6 +85,40 @@ def _unreduce_path(path: _SerializedPath) -> PathRegistry:
82
85
  _WILDCARD_TOKEN: _LiteralStar = "*"
83
86
  _DEFAULT_TOKEN = "_sa_default"
84
87
 
88
+ _RELATIONSHIP_TOKEN: Final[Literal["relationship"]] = "relationship"
89
+ _COLUMN_TOKEN: Final[Literal["column"]] = "column"
90
+
91
+ _UNPREFIXED_TOKENS = frozenset([_WILDCARD_TOKEN, _DEFAULT_TOKEN])
92
+ """the wildcard strings accepted from the user in a loader option.
93
+
94
+ these are prefixed with the target property's ``strategy_wildcard_key`` to
95
+ form the tokens in :data:`._PATH_TOKENS`, and are not themselves valid as an
96
+ element of a path.
97
+
98
+ """
99
+
100
+ _PATH_TOKENS = frozenset(
101
+ f"{wildcard_key}:{suffix}"
102
+ for wildcard_key in (_RELATIONSHIP_TOKEN, _COLUMN_TOKEN)
103
+ for suffix in (_WILDCARD_TOKEN, _DEFAULT_TOKEN)
104
+ )
105
+ """the complete set of tokens which may appear within a path.
106
+
107
+ :attr:`.PathToken._intern` is populated from this collection at module import
108
+ time, so that a token is present in every process, including one which has
109
+ not yet run any query.
110
+
111
+ """
112
+
113
+ _ACCEPTED_TOKENS = _UNPREFIXED_TOKENS | _PATH_TOKENS
114
+ """every string a loader option may accept in place of an attribute name.
115
+
116
+ this is the union of the bare wildcards the user writes and the prefixed
117
+ tokens which the loader option internals hand back to themselves; only the
118
+ latter may appear in a path.
119
+
120
+ """
121
+
85
122
 
86
123
  class PathRegistry(HasCacheKey):
87
124
  """Represent query load paths and registry functions.
@@ -356,12 +393,12 @@ class CreatesToken(PathRegistry):
356
393
  is_root: bool
357
394
 
358
395
  def token(self, token: _StrPathToken) -> TokenRegistry:
359
- if token.endswith(f":{_WILDCARD_TOKEN}"):
396
+ if token not in PathToken._intern:
397
+ raise exc.ArgumentError(f"invalid token: {token}")
398
+ elif token.endswith(f":{_WILDCARD_TOKEN}"):
360
399
  return TokenRegistry(self, token)
361
- elif token.endswith(f":{_DEFAULT_TOKEN}"):
362
- return TokenRegistry(self.root, token)
363
400
  else:
364
- raise exc.ArgumentError(f"invalid token: {token}")
401
+ return TokenRegistry(self.root, token)
365
402
 
366
403
 
367
404
  class RootRegistry(CreatesToken):
@@ -408,7 +445,17 @@ PathRegistry.root = RootRegistry()
408
445
  class PathToken(orm_base.InspectionAttr, HasCacheKey, str):
409
446
  """cacheable string token"""
410
447
 
411
- _intern: Dict[str, PathToken] = {}
448
+ _intern: Mapping[str, PathToken]
449
+ """the :class:`.PathToken` for each of :data:`._PATH_TOKENS`.
450
+
451
+ this collection is fully populated below at module import time and is
452
+ never added to afterwards; it's typed as :class:`.Mapping` so that a
453
+ mutation is flagged by type checkers.
454
+ :meth:`.PathRegistry._deserialize_path` relies on it being complete,
455
+ distinguishing a token from a mapped attribute key by testing
456
+ membership here.
457
+
458
+ """
412
459
 
413
460
  def _gen_cache_key(
414
461
  self, anon_map: anon_map, bindparams: List[BindParameter[Any]]
@@ -419,13 +466,8 @@ class PathToken(orm_base.InspectionAttr, HasCacheKey, str):
419
466
  def _path_for_compare(self) -> Optional[_PathRepresentation]:
420
467
  return None
421
468
 
422
- @classmethod
423
- def intern(cls, strvalue: str) -> PathToken:
424
- if strvalue in cls._intern:
425
- return cls._intern[strvalue]
426
- else:
427
- cls._intern[strvalue] = result = PathToken(strvalue)
428
- return result
469
+
470
+ PathToken._intern = {token: PathToken(token) for token in _PATH_TOKENS}
429
471
 
430
472
 
431
473
  class TokenRegistry(PathRegistry):
@@ -437,7 +479,7 @@ class TokenRegistry(PathRegistry):
437
479
  parent: CreatesToken
438
480
 
439
481
  def __init__(self, parent: CreatesToken, token: _StrPathToken):
440
- token = PathToken.intern(token)
482
+ token = PathToken._intern[token]
441
483
 
442
484
  self.token = token
443
485
  self.parent = parent
@@ -29,7 +29,6 @@ from typing import Union
29
29
 
30
30
  from . import attributes
31
31
  from . import exc as orm_exc
32
- from . import strategy_options
33
32
  from .base import _DeclarativeMapped
34
33
  from .base import class_mapper
35
34
  from .descriptor_props import CompositeProperty
@@ -42,6 +41,7 @@ from .interfaces import _MapsColumns
42
41
  from .interfaces import MapperProperty
43
42
  from .interfaces import PropComparator
44
43
  from .interfaces import StrategizedProperty
44
+ from .path_registry import _COLUMN_TOKEN
45
45
  from .relationships import RelationshipProperty
46
46
  from .util import de_stringify_annotation
47
47
  from .. import exc as sa_exc
@@ -108,7 +108,7 @@ class ColumnProperty(
108
108
 
109
109
  """
110
110
 
111
- strategy_wildcard_key = strategy_options._COLUMN_TOKEN
111
+ strategy_wildcard_key = _COLUMN_TOKEN
112
112
  inherit_cache = True
113
113
  """:meta private:"""
114
114
 
@@ -45,7 +45,6 @@ from typing import Union
45
45
  import weakref
46
46
 
47
47
  from . import attributes
48
- from . import strategy_options
49
48
  from ._typing import insp_is_aliased_class
50
49
  from ._typing import is_has_collection_adapter
51
50
  from .base import _DeclarativeMapped
@@ -64,6 +63,7 @@ from .interfaces import ONETOMANY
64
63
  from .interfaces import PropComparator
65
64
  from .interfaces import RelationshipDirection
66
65
  from .interfaces import StrategizedProperty
66
+ from .path_registry import _RELATIONSHIP_TOKEN
67
67
  from .util import _orm_annotate
68
68
  from .util import _orm_deannotate
69
69
  from .util import CascadeOptions
@@ -322,7 +322,7 @@ class RelationshipProperty(
322
322
 
323
323
  """
324
324
 
325
- strategy_wildcard_key = strategy_options._RELATIONSHIP_TOKEN
325
+ strategy_wildcard_key = _RELATIONSHIP_TOKEN
326
326
  inherit_cache = True
327
327
  """:meta private:"""
328
328