extend_sqlglot() (sqlmesh/core/dialect.py) patches sqlglot's global Tokenizer/Parser/Generator classes via _override:
def _override(klass: t.Type[Tokenizer | Parser], func: t.Callable) -> None:
name = func.__name__
setattr(klass, f"_{name}", getattr(klass, name))
setattr(klass, name, func)
_override saves whatever is currently installed under _<name> — so applying it twice saves the wrapper itself as "the original". For _parse_types (saved as __parse_types, which the module-level wrapper reads back un-mangled at dialect.py:487), the second application makes the wrapper call itself: the next type/CAST parse anywhere in the process raises RecursionError, and sqlglot's original method is lost (overwritten), so the process cannot recover without re-importing sqlglot.
Minimal repro
import sqlglot
import sqlmesh # 1st extend_sqlglot() — sqlmesh/__init__.py:19
from sqlmesh.core.dialect import extend_sqlglot
extend_sqlglot() # 2nd application
sqlglot.parse_one("SELECT CAST(1 AS INT)") # RecursionError: maximum recursion depth exceeded
How this happens without anyone calling it twice on purpose
extend_sqlglot() is the first statement of sqlmesh/__init__.py (line 19), before the rest of the import cascade (analytics, context, model kinds, croniter, ...). If anything later in that first import sqlmesh raises, CPython evicts the half-initialised sqlmesh package from sys.modules — but the fully-imported sqlmesh.core.dialect (and the patched global Parser) survive. Any retry of import sqlmesh then re-runs extend_sqlglot() on the already-patched classes and arms the recursion.
We hit this in a real test suite two ways: a sandboxed/monkeypatched environment made croniter's import-time platform.architecture() subprocess probe raise mid-import; and a deep ambient call stack made a class-body type-parse overflow during the model-kind imports. Both left the parser patched, the package evicted, and the next import poisoned the whole process (RecursionError at dialect.py:487 on every subsequent parse).
Suggested fix
Make the patch idempotent — e.g. in _override:
def _override(klass: t.Type[Tokenizer | Parser], func: t.Callable) -> None:
name = func.__name__
if getattr(klass, name, None) is func: # already installed — don't re-save
return
setattr(klass, f"_{name}", getattr(klass, name))
setattr(klass, name, func)
or a module-level ran-once flag at the top of extend_sqlglot(). Note the function already has partial re-entry guards (the generator TRANSFORMS/WITH_SEPARATED_COMMENTS membership checks) — but not on the _override path, and generator.UNWRAPPED_INTERVAL_VALUES also grows on every call.
Environment
- sqlmesh 0.236.0 (also reproduced by inspection on main @
97d60cb), sqlglot 30.8.0, Python 3.13, macOS/Linux.
References
_override:
|
def _override(klass: t.Type[Tokenizer | Parser], func: t.Callable) -> None: |
|
name = func.__name__ |
|
setattr(klass, f"_{name}", getattr(klass, name)) |
|
setattr(klass, name, func) |
- wrapper / recursion site:
|
def _parse_types( |
|
self: Parser, |
|
check_func: bool = False, |
|
schema: bool = False, |
|
allow_identifiers: bool = True, |
|
with_collation: bool = False, |
|
) -> t.Optional[exp.Expr]: |
|
start = self._curr |
|
parsed_type = self.__parse_types( # type: ignore |
|
check_func=check_func, |
|
schema=schema, |
|
allow_identifiers=allow_identifiers, |
|
with_collation=with_collation, |
|
) |
|
|
|
if schema and parsed_type: |
|
parsed_type.meta["sql"] = self._find_sql(start, self._prev) |
|
|
|
return parsed_type |
- call site:
|
from sqlmesh.core.dialect import extend_sqlglot |
|
|
|
extend_sqlglot() |
- croniter import-time subprocess (one realistic mid-import failure): https://github.com/pallets-eco/croniter/blob/28ec077e57dd41d96cd9ae664cce31cdc58d07fe/src/croniter/croniter.py#L52-L58
extend_sqlglot()(sqlmesh/core/dialect.py) patches sqlglot's globalTokenizer/Parser/Generatorclasses via_override:_overridesaves whatever is currently installed under_<name>— so applying it twice saves the wrapper itself as "the original". For_parse_types(saved as__parse_types, which the module-level wrapper reads back un-mangled atdialect.py:487), the second application makes the wrapper call itself: the next type/CAST parse anywhere in the process raisesRecursionError, and sqlglot's original method is lost (overwritten), so the process cannot recover without re-importing sqlglot.Minimal repro
How this happens without anyone calling it twice on purpose
extend_sqlglot()is the first statement ofsqlmesh/__init__.py(line 19), before the rest of the import cascade (analytics, context, model kinds, croniter, ...). If anything later in that firstimport sqlmeshraises, CPython evicts the half-initialisedsqlmeshpackage fromsys.modules— but the fully-importedsqlmesh.core.dialect(and the patched globalParser) survive. Any retry ofimport sqlmeshthen re-runsextend_sqlglot()on the already-patched classes and arms the recursion.We hit this in a real test suite two ways: a sandboxed/monkeypatched environment made croniter's import-time
platform.architecture()subprocess probe raise mid-import; and a deep ambient call stack made a class-body type-parse overflow during the model-kind imports. Both left the parser patched, the package evicted, and the next import poisoned the whole process (RecursionErroratdialect.py:487on every subsequent parse).Suggested fix
Make the patch idempotent — e.g. in
_override:or a module-level ran-once flag at the top of
extend_sqlglot(). Note the function already has partial re-entry guards (the generatorTRANSFORMS/WITH_SEPARATED_COMMENTSmembership checks) — but not on the_overridepath, andgenerator.UNWRAPPED_INTERVAL_VALUESalso grows on every call.Environment
97d60cb), sqlglot 30.8.0, Python 3.13, macOS/Linux.References
_override:sqlmesh/sqlmesh/core/dialect.py
Lines 783 to 786 in 97d60cb
sqlmesh/sqlmesh/core/dialect.py
Lines 479 to 497 in 97d60cb
sqlmesh/sqlmesh/__init__.py
Lines 17 to 19 in 97d60cb