SQLAlchemy 2.1.1__py3-none-any.whl → 2.1.2__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.
sqlalchemy/__init__.py CHANGED
@@ -285,7 +285,7 @@ from .types import Uuid as Uuid
285
285
  from .types import VARBINARY as VARBINARY
286
286
  from .types import VARCHAR as VARCHAR
287
287
 
288
- __version__ = "2.1.1"
288
+ __version__ = "2.1.2"
289
289
 
290
290
 
291
291
  def __go(lcls: Any) -> None:
@@ -3315,6 +3315,27 @@ class MSDialect(default._BackendsMultiReflection, default.DefaultDialect):
3315
3315
  finally:
3316
3316
  cursor.close()
3317
3317
 
3318
+ def min_year_for_type(self, typeobj):
3319
+ typeobj = sqltypes.to_instance(typeobj)
3320
+
3321
+ if isinstance(typeobj, SMALLDATETIME):
3322
+ return 1900
3323
+ elif isinstance(typeobj, (DATETIME2, DATETIMEOFFSET)):
3324
+ return datetime.MINYEAR
3325
+ elif isinstance(typeobj, sqltypes.DateTime):
3326
+ # DateTime renders DATETIMEOFFSET with timezone=True,
3327
+ # else DATETIME
3328
+ return datetime.MINYEAR if typeobj.timezone else 1753
3329
+ elif (
3330
+ isinstance(typeobj, sqltypes.Date)
3331
+ and not isinstance(typeobj, sqltypes.DATE)
3332
+ and self.server_version_info < MS_2008_VERSION
3333
+ ):
3334
+ # Date renders DATETIME before SQL Server 2008
3335
+ return 1753
3336
+ else:
3337
+ return datetime.MINYEAR
3338
+
3318
3339
  def initialize(self, connection):
3319
3340
  super().initialize(connection)
3320
3341
  self._setup_version_attributes()
@@ -489,15 +489,12 @@ class _ODBCDateTimeBindProcessor:
489
489
  else:
490
490
  # for DATETIMEOFFSET or DateTime(timezone=True)
491
491
  #
492
- # Convert to string format required by T-SQL
493
- dto_string = value.strftime("%Y-%m-%d %H:%M:%S.%f %z")
494
- # offset needs a colon, e.g., -0700 -> -07:00
495
- # "UTC offset in the form (+-)HHMM[SS[.ffffff]]"
496
- # backend currently rejects seconds / fractional seconds
497
- dto_string = re.sub(
498
- r"([\+\-]\d{2})([\d\.]+)$", r"\1:\2", dto_string
499
- )
500
- return dto_string
492
+ # Convert to string format required by T-SQL. isoformat()
493
+ # zero-pads the year, which strftime("%Y") does not do on
494
+ # all platforms, and renders the offset with a colon,
495
+ # e.g. -07:00. backend currently rejects seconds /
496
+ # fractional seconds in the offset
497
+ return value.isoformat(sep=" ", timespec="microseconds")
501
498
 
502
499
  return process
503
500
 
@@ -4846,8 +4846,12 @@ class PGDialect(default._BackendsMultiReflection, default.DefaultDialect):
4846
4846
  )
4847
4847
  .outerjoin(
4848
4848
  pg_catalog.pg_description,
4849
- pg_catalog.pg_description.c.objoid
4850
- == pg_catalog.pg_constraint.c.oid,
4849
+ sql.and_(
4850
+ pg_catalog.pg_description.c.objoid
4851
+ == pg_catalog.pg_constraint.c.oid,
4852
+ pg_catalog.pg_description.c.classoid
4853
+ == sql.func.cast("pg_catalog.pg_constraint", REGCLASS),
4854
+ ),
4851
4855
  )
4852
4856
  .where(
4853
4857
  pg_catalog.pg_constraint.c.contype == bindparam("contype"),
@@ -5039,8 +5043,12 @@ class PGDialect(default._BackendsMultiReflection, default.DefaultDialect):
5039
5043
  )
5040
5044
  .outerjoin(
5041
5045
  pg_catalog.pg_description,
5042
- pg_catalog.pg_description.c.objoid
5043
- == pg_catalog.pg_constraint.c.oid,
5046
+ sql.and_(
5047
+ pg_catalog.pg_description.c.objoid
5048
+ == pg_catalog.pg_constraint.c.oid,
5049
+ pg_catalog.pg_description.c.classoid
5050
+ == sql.func.cast("pg_catalog.pg_constraint", REGCLASS),
5051
+ ),
5044
5052
  )
5045
5053
  .order_by(
5046
5054
  pg_catalog.pg_class.c.relname,
@@ -5632,8 +5640,12 @@ class PGDialect(default._BackendsMultiReflection, default.DefaultDialect):
5632
5640
  )
5633
5641
  .outerjoin(
5634
5642
  pg_catalog.pg_description,
5635
- pg_catalog.pg_description.c.objoid
5636
- == pg_catalog.pg_constraint.c.oid,
5643
+ sql.and_(
5644
+ pg_catalog.pg_description.c.objoid
5645
+ == pg_catalog.pg_constraint.c.oid,
5646
+ pg_catalog.pg_description.c.classoid
5647
+ == sql.func.cast("pg_catalog.pg_constraint", REGCLASS),
5648
+ ),
5637
5649
  )
5638
5650
  .order_by(
5639
5651
  pg_catalog.pg_class.c.relname,
@@ -69,7 +69,7 @@ class BaseRow:
69
69
  (
70
70
  _apply_processors(processors, data)
71
71
  if processors is not None
72
- else data if isinstance(data, tuple) else tuple(data)
72
+ else data if type(data) is tuple else tuple(data)
73
73
  ),
74
74
  )
75
75
 
@@ -16,6 +16,7 @@ as the base class for their own corresponding classes.
16
16
 
17
17
  from __future__ import annotations
18
18
 
19
+ import datetime
19
20
  import functools
20
21
  import operator
21
22
  import random
@@ -829,6 +830,17 @@ class DefaultDialect(Dialect):
829
830
  """
830
831
  return type_api.adapt_type(typeobj, self.colspecs)
831
832
 
833
+ def min_year_for_type(self, typeobj: TypeEngine[Any]) -> int:
834
+ """Return the earliest year the database accepts for the given
835
+ date or datetime type.
836
+
837
+ The default implementation returns ``datetime.MINYEAR``.
838
+
839
+ .. versionadded:: 2.0.55
840
+
841
+ """
842
+ return datetime.MINYEAR
843
+
832
844
  def has_index(self, connection, table_name, index_name, schema=None, **kw):
833
845
  if not self.has_table(connection, table_name, schema=schema, **kw):
834
846
  return False
@@ -1465,6 +1465,23 @@ class Dialect(EventTarget):
1465
1465
 
1466
1466
  raise NotImplementedError()
1467
1467
 
1468
+ def min_year_for_type(self, typeobj: TypeEngine[Any]) -> int:
1469
+ """Return the earliest year the database accepts for the given
1470
+ date or datetime type.
1471
+
1472
+ The type may be a generic type such as :class:`.DateTime`, in
1473
+ which case the limit is that of the database type the dialect
1474
+ renders for it. Dialects whose date or datetime types don't
1475
+ accept all years from ``datetime.MINYEAR`` forward override this
1476
+ method; e.g. SQL Server's ``DATETIME`` type, which the dialect
1477
+ renders for :class:`.DateTime`, accepts years from 1753 forward.
1478
+
1479
+ .. versionadded:: 2.0.55
1480
+
1481
+ """
1482
+
1483
+ raise NotImplementedError()
1484
+
1468
1485
  def initialize(self, connection: Connection) -> None:
1469
1486
  """Called during strategized creation of the dialect with a
1470
1487
  connection.
@@ -3437,7 +3454,8 @@ class ExceptionContext:
3437
3454
 
3438
3455
  """
3439
3456
 
3440
- __slots__ = ()
3457
+ if not TYPE_CHECKING:
3458
+ __slots__ = ()
3441
3459
 
3442
3460
  dialect: Dialect
3443
3461
  """The :class:`_engine.Dialect` in use.
@@ -8,6 +8,7 @@
8
8
  from __future__ import annotations
9
9
 
10
10
  import abc
11
+ import asyncio
11
12
  import functools
12
13
  from typing import Any
13
14
  from typing import AsyncGenerator
@@ -15,6 +16,7 @@ from typing import AsyncIterator
15
16
  from typing import Awaitable
16
17
  from typing import Callable
17
18
  from typing import ClassVar
19
+ from typing import Coroutine
18
20
  from typing import Dict
19
21
  from typing import Generator
20
22
  from typing import Generic
@@ -37,6 +39,35 @@ _T_co = TypeVar("_T_co", bound=Any, covariant=True)
37
39
  _PT = TypeVar("_PT", bound=Any)
38
40
 
39
41
 
42
+ async def _run_to_completion(coro: Coroutine[Any, Any, _T]) -> _T:
43
+ """Run ``coro`` in a task of its own and wait for it to complete, even
44
+ if the calling task is cancelled meanwhile.
45
+
46
+ Used for cleanup, such as the ``close()`` performed by an async context
47
+ manager on exit, that must not be abandoned partway through. A
48
+ cancellation received while waiting is re-raised once ``coro`` has
49
+ completed, so that the caller does not return before its cleanup has
50
+ finished; were it to return early, the cleanup would carry on in the
51
+ background, where for example it could return a connection into a pool
52
+ that the application has meanwhile disposed of.
53
+
54
+ """
55
+ task = asyncio.create_task(coro)
56
+ cancelled = None
57
+ while True:
58
+ try:
59
+ result = await asyncio.shield(task)
60
+ except asyncio.CancelledError as err:
61
+ if task.done():
62
+ raise
63
+ cancelled = err
64
+ else:
65
+ break
66
+ if cancelled is not None:
67
+ raise cancelled
68
+ return result
69
+
70
+
40
71
  class ReversibleProxy(Generic[_PT]):
41
72
  _proxy_objects: ClassVar[
42
73
  Dict[weakref.ref[Any], weakref.ref[ReversibleProxy[Any]]]
@@ -6,7 +6,6 @@
6
6
  # the MIT License: https://www.opensource.org/licenses/mit-license.php
7
7
  from __future__ import annotations
8
8
 
9
- import asyncio
10
9
  import contextlib
11
10
  from typing import Any
12
11
  from typing import AsyncIterator
@@ -24,6 +23,7 @@ from typing import TypeVar
24
23
  from typing import Union
25
24
 
26
25
  from . import exc as async_exc
26
+ from .base import _run_to_completion
27
27
  from .base import asyncstartablecontext
28
28
  from .base import GeneratorStartableContext
29
29
  from .base import ProxyComparable
@@ -606,8 +606,7 @@ class AsyncConnection( # type: ignore[misc]
606
606
  except GeneratorExit:
607
607
  pass
608
608
  else:
609
- task = asyncio.create_task(ar.close())
610
- await asyncio.shield(task)
609
+ await _run_to_completion(ar.close())
611
610
 
612
611
  @overload
613
612
  async def execute(
@@ -913,8 +912,7 @@ class AsyncConnection( # type: ignore[misc]
913
912
  return self.start().__await__()
914
913
 
915
914
  async def __aexit__(self, type_: Any, value: Any, traceback: Any) -> None:
916
- task = asyncio.create_task(self.close())
917
- await asyncio.shield(task)
915
+ await _run_to_completion(self.close())
918
916
 
919
917
  # START PROXY METHODS AsyncConnection
920
918
 
@@ -6,7 +6,6 @@
6
6
  # the MIT License: https://www.opensource.org/licenses/mit-license.php
7
7
  from __future__ import annotations
8
8
 
9
- import asyncio
10
9
  from typing import Any
11
10
  from typing import Awaitable
12
11
  from typing import Callable
@@ -30,6 +29,7 @@ from typing import Union
30
29
 
31
30
  from . import engine
32
31
  from . import exc as async_exc
32
+ from .base import _run_to_completion
33
33
  from .base import ReversibleProxy
34
34
  from .base import StartableContext
35
35
  from .result import _ensure_sync_result
@@ -1375,8 +1375,7 @@ class AsyncSession(ReversibleProxy[Session]):
1375
1375
  return self
1376
1376
 
1377
1377
  async def __aexit__(self, type_: Any, value: Any, traceback: Any) -> None:
1378
- task = asyncio.create_task(self.close())
1379
- await asyncio.shield(task)
1378
+ await _run_to_completion(self.close())
1380
1379
 
1381
1380
  def _maker_context_manager(self: _AS) -> _AsyncSessionContextManager[_AS]:
1382
1381
  return _AsyncSessionContextManager(self)
@@ -2121,8 +2120,7 @@ class _AsyncSessionContextManager(Generic[_AS]):
2121
2120
  await self.trans.__aexit__(type_, value, traceback)
2122
2121
  await self.async_session.__aexit__(type_, value, traceback)
2123
2122
 
2124
- task = asyncio.create_task(go())
2125
- await asyncio.shield(task)
2123
+ await _run_to_completion(go())
2126
2124
 
2127
2125
 
2128
2126
  class AsyncSessionTransaction(
sqlalchemy/ext/automap.py CHANGED
@@ -1661,11 +1661,11 @@ def _m2m_relationship(
1661
1661
  overlaps=overlaps,
1662
1662
  secondary=table,
1663
1663
  primaryjoin=and_(
1664
- fk.column == fk.parent for fk in m2m_const[0].elements
1665
- ), # type: ignore [arg-type]
1664
+ *(fk.column == fk.parent for fk in m2m_const[0].elements)
1665
+ ),
1666
1666
  secondaryjoin=and_(
1667
- fk.column == fk.parent for fk in m2m_const[1].elements
1668
- ), # type: ignore [arg-type]
1667
+ *(fk.column == fk.parent for fk in m2m_const[1].elements)
1668
+ ),
1669
1669
  backref=backref_obj,
1670
1670
  collection_class=collection_class,
1671
1671
  )
@@ -1687,11 +1687,11 @@ def _m2m_relationship(
1687
1687
  overlaps=overlaps,
1688
1688
  secondary=table,
1689
1689
  primaryjoin=and_(
1690
- fk.column == fk.parent for fk in m2m_const[1].elements
1691
- ), # type: ignore [arg-type]
1690
+ *(fk.column == fk.parent for fk in m2m_const[1].elements)
1691
+ ),
1692
1692
  secondaryjoin=and_(
1693
- fk.column == fk.parent for fk in m2m_const[0].elements
1694
- ), # type: ignore [arg-type]
1693
+ *(fk.column == fk.parent for fk in m2m_const[0].elements)
1694
+ ),
1695
1695
  back_populates=relationship_name,
1696
1696
  collection_class=collection_class,
1697
1697
  )
@@ -2620,24 +2620,41 @@ class _JoinCondition:
2620
2620
  return self._has_annotation(self.primaryjoin, "remote")
2621
2621
 
2622
2622
  @util.memoized_property
2623
- def secondary_covers_parent_primary_key(self) -> bool:
2624
- """Return True if the "secondary" selectable's join to the parent
2625
- table contains columns that encompass the complete primary key
2626
- value of the parent.
2623
+ def secondary_can_be_used_for_related_join(self) -> bool:
2624
+ """Return True if the "secondary" selectable may be joined directly
2625
+ to the related entity in order to load related rows for a set of
2626
+ parent primary key values, omitting the parent table.
2627
2627
 
2628
- Used in optimizing the selectinload loader strategy to indicate
2629
- the parent table need not be included in the query, as a complete
2630
- primary key can be derived from the secondary table.
2628
+ This is the case when the primaryjoin consists only of equality
2629
+ comparisons between the complete primary key of the parent and
2630
+ columns of the secondary. If the primaryjoin contains any other
2631
+ criteria, it would be lost when the parent table is omitted.
2632
+
2633
+ Used by the selectinload loader strategy for its ``omit_join``
2634
+ optimization.
2631
2635
 
2632
2636
  """
2633
2637
  if self.secondary is None:
2634
2638
  return False
2635
2639
 
2636
- secondary_synced_parent_cols = util.column_set(
2637
- l for (l, _) in self.synchronize_pairs
2638
- )
2639
- return self.prop.parent._local_pk_cols.issubset(
2640
- secondary_synced_parent_cols
2640
+ parent_pk_cols = self.prop.parent._local_pk_cols
2641
+ if not parent_pk_cols.issubset(l for (l, _) in self.synchronize_pairs):
2642
+ return False
2643
+
2644
+ # compare against the parent primary key comparisons only; any
2645
+ # other criteria in the primaryjoin, including comparisons of
2646
+ # non-primary key parent columns to the secondary, make the
2647
+ # comparison fail. compare() treats AND as unordered and "==" as
2648
+ # commutative, so the primaryjoin may state these comparisons in
2649
+ # any order or orientation
2650
+ return self.primaryjoin.compare(
2651
+ sql.and_(
2652
+ *[
2653
+ l == r
2654
+ for (l, r) in self.synchronize_pairs
2655
+ if l in parent_pk_cols
2656
+ ]
2657
+ )
2641
2658
  )
2642
2659
 
2643
2660
  def _annotate_fks(self) -> None:
@@ -3029,7 +3029,9 @@ class _SelectInLoader(_PostLoader, util.MemoizedSlots):
3029
3029
  self.omit_join = lazyloader.use_get
3030
3030
  elif is_m2m and not self.parent_property._is_self_referential:
3031
3031
  join_cond = self.parent_property._join_condition
3032
- self.omit_join = join_cond.secondary_covers_parent_primary_key
3032
+ self.omit_join = (
3033
+ join_cond.secondary_can_be_used_for_related_join
3034
+ )
3033
3035
  else:
3034
3036
  self.omit_join = self.parent._get_clause[0].compare(
3035
3037
  lazyloader._rev_lazywhere,
sqlalchemy/pool/base.py CHANGED
@@ -733,10 +733,21 @@ class _ConnectionRecord(ConnectionPoolEntry):
733
733
  def _checkin_failed(
734
734
  self, err: BaseException, _fairy_was_created: bool = True
735
735
  ) -> None:
736
- self.invalidate(e=err)
737
- self.checkin(
738
- _fairy_was_created=_fairy_was_created,
739
- )
736
+ try:
737
+ self.invalidate(e=err)
738
+ finally:
739
+ if self.dbapi_connection is not None:
740
+ # invalidate() was itself interrupted, such as by a second
741
+ # cancellation, before it could close the connection. keep
742
+ # the connection so that its close can be retried, but
743
+ # have the next checkout recycle it rather than use it
744
+ self._soft_invalidate_time = time.time()
745
+
746
+ # the record must be returned in any case; nothing else will
747
+ # ever return it, as no _ConnectionFairy refers to it
748
+ self.checkin(
749
+ _fairy_was_created=_fairy_was_created,
750
+ )
740
751
 
741
752
  def checkin(self, _fairy_was_created: bool = True) -> None:
742
753
  if self.fairy_ref is None and _fairy_was_created:
@@ -1017,10 +1028,15 @@ def _finalize_fairy(
1017
1028
  pool.logger.error(
1018
1029
  "Exception during reset or similar", exc_info=True
1019
1030
  )
1020
- if connection_record:
1021
- connection_record.invalidate(e=e)
1022
1031
  if not isinstance(e, Exception):
1032
+ # a cancellation or similar propagates from here, so the
1033
+ # checkin() below is not reached; return the record now,
1034
+ # rather than leaving it to garbage collection of the fairy
1035
+ if connection_record:
1036
+ connection_record._checkin_failed(e)
1023
1037
  raise
1038
+ if connection_record:
1039
+ connection_record.invalidate(e=e)
1024
1040
  finally:
1025
1041
  if detach and is_gc_cleanup and dont_restore_gced:
1026
1042
  message = (
@@ -1817,6 +1817,12 @@ def text(text: str) -> TextClause:
1817
1817
 
1818
1818
  t = text(r"SELECT * FROM users WHERE name='\:username'")
1819
1819
 
1820
+ .. note:: A name that is immediately followed by another colon,
1821
+ such as ``:this:that`` or ``:value::int``, is currently not
1822
+ interpreted as a bound parameter and is rendered as literal text;
1823
+ whitespace may be used to separate the name from the colon, e.g.
1824
+ ``:value ::int``. This behavior may change in a future release.
1825
+
1820
1826
  The :class:`_expression.TextClause`
1821
1827
  construct includes methods which can
1822
1828
  provide information about the bound parameters as well as the column
@@ -1910,6 +1916,26 @@ def tstring(template: Template) -> TString:
1910
1916
  some_json = {"foo": "bar"}
1911
1917
  stmt = tstring(t"SELECT {literal(some_json, JSON)}")
1912
1918
 
1919
+ **Literal SQL Text**:
1920
+
1921
+ The plain string portions of the template are rendered exactly as
1922
+ given. Unlike :func:`_expression.text`, they are not scanned for
1923
+ bound parameter names in the ``:name`` format, and there are no escaping
1924
+ rules such as ``\:`` for colons; a colon in the template, such as within
1925
+ a quoted SQL string, is rendered unchanged. A named bound parameter
1926
+ whose value is passed at execution time may be embedded explicitly
1927
+ using :func:`_expression.bindparam`::
1928
+
1929
+ from sqlalchemy import tstring, bindparam, Integer
1930
+
1931
+ user_id = bindparam("user_id", type_=Integer)
1932
+ stmt = tstring(t"SELECT * FROM users WHERE id = {user_id}")
1933
+ result = connection.execute(stmt, {"user_id": 42})
1934
+
1935
+ .. versionchanged:: 2.1.2 The plain string portions of a
1936
+ :func:`_expression.tstring` are no longer scanned for bound parameter
1937
+ names or ``\:`` escapes, as was the case in 2.1.0 and 2.1.1.
1938
+
1913
1939
  **Column Specification**:
1914
1940
 
1915
1941
  Like :func:`_expression.text`, the :func:`_expression.tstring` construct
@@ -266,8 +266,8 @@ FK_INITIALLY = re.compile(r"^(?:DEFERRED|IMMEDIATE)$", re.I)
266
266
  _WINDOW_EXCLUDE_RE = re.compile(
267
267
  r"^(?:CURRENT ROW|GROUP|TIES|NO OTHERS)$", re.I
268
268
  )
269
- BIND_PARAMS = re.compile(r"(?<![:\w\$\x5c]):([\w\$]+)(?![:\w\$])", re.UNICODE)
270
- BIND_PARAMS_ESC = re.compile(r"\x5c(:[\w\$]*)(?![:\w\$])", re.UNICODE)
269
+ BIND_PARAMS = elements.TextClause._bind_params_regex
270
+ BIND_PARAMS_ESC = re.compile(r"\x5c(:\w*)(?![:\w])", re.UNICODE)
271
271
 
272
272
  _pyformat_template = "%%(%(name)s)s"
273
273
  BIND_TEMPLATES = {
@@ -1422,6 +1422,17 @@ class SQLCompiler(Compiled):
1422
1422
  _positional_pattern = re.compile(
1423
1423
  f"{_pyformat_pattern.pattern}|{_post_compile_pattern.pattern}"
1424
1424
  )
1425
+
1426
+ # variants of the above for dialects that double percent signs in
1427
+ # literal text; a "%%" pair is matched first so that an escaped
1428
+ # percent sign is never taken as the start of a bound parameter
1429
+ _pct_escaped_pyformat_pattern = re.compile(
1430
+ f"%%|{_pyformat_pattern.pattern}"
1431
+ )
1432
+ _pct_escaped_positional_pattern = re.compile(
1433
+ f"%%|{_positional_pattern.pattern}"
1434
+ )
1435
+
1425
1436
  _collect_params: Final[bool]
1426
1437
  _collected_params: util.immutabledict[str, Any]
1427
1438
 
@@ -1733,24 +1744,38 @@ class SQLCompiler(Compiled):
1733
1744
  placeholder = "?"
1734
1745
 
1735
1746
  positions = []
1747
+ binds = self.binds
1748
+
1749
+ if self.escaped_bind_names:
1750
+ reverse_escape = {v: k for k, v in self.escaped_bind_names.items()}
1751
+ assert len(self.escaped_bind_names) == len(reverse_escape)
1752
+ else:
1753
+ reverse_escape = {}
1754
+
1755
+ if self.preparer._double_percents:
1756
+ positional_pattern = self._pct_escaped_positional_pattern
1757
+ else:
1758
+ positional_pattern = self._positional_pattern
1736
1759
 
1737
1760
  def find_position(m: re.Match[str]) -> str:
1738
1761
  normal_bind = m.group(1)
1739
1762
  if normal_bind:
1763
+ if reverse_escape.get(normal_bind, normal_bind) not in binds:
1764
+ # literal text that looks like a bound parameter
1765
+ return m.group(0)
1740
1766
  positions.append(normal_bind)
1741
1767
  return placeholder
1742
- else:
1768
+ elif m.group(2):
1743
1769
  # this a post-compile bind
1744
1770
  positions.append(m.group(2))
1745
1771
  return m.group(0)
1772
+ else:
1773
+ # an escaped percent sign
1774
+ return m.group(0)
1746
1775
 
1747
- self.string = re.sub(
1748
- self._positional_pattern, find_position, self.string
1749
- )
1776
+ self.string = re.sub(positional_pattern, find_position, self.string)
1750
1777
 
1751
- if self.escaped_bind_names:
1752
- reverse_escape = {v: k for k, v in self.escaped_bind_names.items()}
1753
- assert len(self.escaped_bind_names) == len(reverse_escape)
1778
+ if reverse_escape:
1754
1779
  self.positiontup = [
1755
1780
  reverse_escape.get(name, name) for name in positions
1756
1781
  ]
@@ -1761,7 +1786,7 @@ class SQLCompiler(Compiled):
1761
1786
  positions = []
1762
1787
 
1763
1788
  single_values_expr = re.sub(
1764
- self._positional_pattern,
1789
+ positional_pattern,
1765
1790
  find_position,
1766
1791
  self._insertmanyvalues.single_values_expr,
1767
1792
  )
@@ -1769,7 +1794,7 @@ class SQLCompiler(Compiled):
1769
1794
  (
1770
1795
  v[0],
1771
1796
  v[1],
1772
- re.sub(self._positional_pattern, find_position, v[2]),
1797
+ re.sub(positional_pattern, find_position, v[2]),
1773
1798
  v[3],
1774
1799
  )
1775
1800
  for v in self._insertmanyvalues.insert_crud_params
@@ -1829,9 +1854,15 @@ class SQLCompiler(Compiled):
1829
1854
  }
1830
1855
  assert len(param_pos) == len_before
1831
1856
 
1832
- # Can't use format here since % chars are not escaped.
1833
- self.string = self._pyformat_pattern.sub(
1834
- lambda m: param_pos[m.group(1)], self.string
1857
+ if self.preparer._double_percents:
1858
+ pyformat_pattern = self._pct_escaped_pyformat_pattern
1859
+ else:
1860
+ pyformat_pattern = self._pyformat_pattern
1861
+
1862
+ # any match not in param_pos is an escaped percent sign or literal
1863
+ # text that looks like a bound parameter
1864
+ self.string = pyformat_pattern.sub(
1865
+ lambda m: param_pos.get(m.group(1), m.group(0)), self.string
1835
1866
  )
1836
1867
 
1837
1868
  if self._insertmanyvalues:
@@ -2277,9 +2308,16 @@ class SQLCompiler(Compiled):
2277
2308
  numeric_positiontup, self.next_numeric_pos
2278
2309
  )
2279
2310
  }
2280
- # Can't use format here since % chars are not escaped.
2281
- statement = self._pyformat_pattern.sub(
2282
- lambda m: param_pos[m.group(1)], statement
2311
+ if self.preparer._double_percents:
2312
+ pyformat_pattern = self._pct_escaped_pyformat_pattern
2313
+ else:
2314
+ pyformat_pattern = self._pyformat_pattern
2315
+
2316
+ # only the expanded parameters are replaced; any other
2317
+ # match is an escaped percent sign or literal text that looks
2318
+ # like a bound parameter
2319
+ statement = pyformat_pattern.sub(
2320
+ lambda m: param_pos.get(m.group(1), m.group(0)), statement
2283
2321
  )
2284
2322
  new_positiontup.extend(numeric_positiontup)
2285
2323
 
@@ -2820,6 +2858,9 @@ class SQLCompiler(Compiled):
2820
2858
  ),
2821
2859
  )
2822
2860
 
2861
+ def visit_tstring_text(self, element, **kw):
2862
+ return self.post_process_text(element.text)
2863
+
2823
2864
  def visit_tstring(self, tstring, add_to_result_map=None, **kw):
2824
2865
  if self._collect_params:
2825
2866
  self._add_to_params(tstring)