diff --git a/packages/python/changelog.d/2026-09-05-resolve-config-extensions-split.md b/packages/python/changelog.d/2026-09-05-resolve-config-extensions-split.md new file mode 100644 index 000000000..137824305 --- /dev/null +++ b/packages/python/changelog.d/2026-09-05-resolve-config-extensions-split.md @@ -0,0 +1,3 @@ +**Changed** + +- **The config-extension resolver split into three modules.** `_resolve_entries` moved to `dirsql/resolve_entries.py` and `resolve_configs_extension_specs` to `dirsql/resolve_configs_extension_specs.py`; `dirsql/resolve_config_extensions.py` keeps the TOML loading and the single-config `resolve_config_extension_specs`. Every function keeps its name and behavior — internal reorganisation with no change to the published API or CLI behavior. (#1056) diff --git a/packages/python/dirsql/_async.py b/packages/python/dirsql/_async.py index 1fdfb8078..3323509f5 100644 --- a/packages/python/dirsql/_async.py +++ b/packages/python/dirsql/_async.py @@ -4,7 +4,7 @@ import os from dirsql._dirsql import DirSQL as _RustDirSQL -from dirsql.resolve_config_extensions import resolve_configs_extension_specs +from dirsql.resolve_configs_extension_specs import resolve_configs_extension_specs from dirsql.resolve_extension import resolve_extension_path diff --git a/packages/python/dirsql/_async_test.py b/packages/python/dirsql/_async_test.py index a582d546e..be64a4ac8 100644 --- a/packages/python/dirsql/_async_test.py +++ b/packages/python/dirsql/_async_test.py @@ -159,6 +159,12 @@ async def it_passes_no_extensions_through_unresolved(): resolver.assert_not_called() def describe_config_file_extensions(): + def it_imports_the_config_resolver_from_the_plural_module(): + assert ( + async_mod.resolve_configs_extension_specs.__module__ + == "dirsql.resolve_configs_extension_specs" + ) + @pytest.mark.asyncio async def it_appends_resolved_config_extensions_and_suppresses_the_core(): with ( diff --git a/packages/python/dirsql/cli/resolve_config_extensions.py b/packages/python/dirsql/cli/resolve_config_extensions.py index 64c5f74ce..72b6b4936 100644 --- a/packages/python/dirsql/cli/resolve_config_extensions.py +++ b/packages/python/dirsql/cli/resolve_config_extensions.py @@ -3,10 +3,10 @@ The compiled ``dirsql`` binary loads a config's extensions literally -- it has no ``importlib``, so it cannot resolve a bare **package name**. When any TOML config in argv names an extension by package name, the shared SDK -resolver (:mod:`dirsql.resolve_config_extensions`) resolves every config's -extensions and this launcher passes the resolved literal paths to the binary -via repeatable ``--extension`` flags; the binary then loads those and ignores -the configs' own extension entries. +resolver (:mod:`dirsql.resolve_configs_extension_specs`) resolves every +config's extensions and this launcher passes the resolved literal paths to +the binary via repeatable ``--extension`` flags; the binary then loads those +and ignores the configs' own extension entries. Native-language configs (``.py`` / ``.js`` / ``.mjs`` / ``.cjs``) are untouched: the binary dispatches those to ``dirsql interpret``, whose handshake already @@ -15,7 +15,7 @@ from __future__ import annotations -from ..resolve_config_extensions import resolve_configs_extension_specs +from ..resolve_configs_extension_specs import resolve_configs_extension_specs from .config_paths_from_argv import config_paths_from_argv # Config extensions the binary dispatches to `dirsql interpret`; never diff --git a/packages/python/dirsql/cli/resolve_config_extensions_test.py b/packages/python/dirsql/cli/resolve_config_extensions_test.py index 1b5076591..5738f2c56 100644 --- a/packages/python/dirsql/cli/resolve_config_extensions_test.py +++ b/packages/python/dirsql/cli/resolve_config_extensions_test.py @@ -26,6 +26,12 @@ def _patch(specs, paths=("/x/.dirsql.toml",)): def describe_with_resolved_extensions(): + def it_imports_the_shared_resolver_from_the_plural_module(): + assert ( + rce.resolve_configs_extension_specs.__module__ + == "dirsql.resolve_configs_extension_specs" + ) + def it_passes_init_through_untouched(): argv = ["init", "--root", "."] with _patch([{"path": "R:pkg", "entrypoint": None}]) as (scan, resolver): diff --git a/packages/python/dirsql/resolve_config_extensions.py b/packages/python/dirsql/resolve_config_extensions.py index a11441afe..4470ac52c 100644 --- a/packages/python/dirsql/resolve_config_extensions.py +++ b/packages/python/dirsql/resolve_config_extensions.py @@ -8,7 +8,8 @@ the config's entries are not loaded a second time. Shared by the ``DirSQL`` constructor (``config=`` path) and the CLI launcher -(which converts the resolved specs into ``--extension`` flags). +(which converts the resolved specs into ``--extension`` flags), both of which +reach it through :mod:`dirsql.resolve_configs_extension_specs`. """ from __future__ import annotations @@ -17,7 +18,8 @@ import sys from importlib import import_module -from .resolve_extension import is_bare_name, resolve_extension_path +from .resolve_entries import _resolve_entries +from .resolve_extension import is_bare_name def _load_toml_module(): @@ -67,21 +69,6 @@ def _has_bare_name(entries): ) -def _resolve_entries(entries, base): - specs = [] - for e in entries: - entrypoint = e.get("entrypoint") - specs.append( - { - "path": resolve_extension_path( - e["path"], base=base, resolve_relative=True - ), - "entrypoint": entrypoint if isinstance(entrypoint, str) else None, - } - ) - return specs - - def resolve_config_extension_specs(config_path): """Resolve a TOML config's ``[[dirsql.extension]]`` entries to literal paths. @@ -100,27 +87,3 @@ def resolve_config_extension_specs(config_path): if not _has_bare_name(entries): return None return _resolve_entries(entries, base) - - -def resolve_configs_extension_specs(config_paths): - """Resolve the ``[[dirsql.extension]]`` entries of several configs, in order. - - The SDK intervenes for the whole set only when **some** config names an - extension by bare package name (the core can resolve neither package names - nor -- once globally suppressed -- the literal entries of the other - configs). When it intervenes it resolves **every** config's entries, each - against that config's own parent directory, concatenated in ``config_paths`` - order; the caller suppresses the core's config-extension loading and passes - the resolved list. Returns ``None`` when no config uses a package name, - leaving every config's loading to the core. - """ - loaded = [_load_extension_entries(p) for p in config_paths] - if not any(item is not None and _has_bare_name(item[0]) for item in loaded): - return None - specs = [] - for item in loaded: - if item is None: - continue - entries, base = item - specs.extend(_resolve_entries(entries, base)) - return specs diff --git a/packages/python/dirsql/resolve_config_extensions_test.py b/packages/python/dirsql/resolve_config_extensions_test.py index 382d8d399..70be44c87 100644 --- a/packages/python/dirsql/resolve_config_extensions_test.py +++ b/packages/python/dirsql/resolve_config_extensions_test.py @@ -1,8 +1,8 @@ """Unit tests for `resolve_config_extension_specs`. -Real `.dirsql.toml` files are parsed; the only mocked collaborator is -`resolve_extension_path` (the effectful file-vs-package resolver). The pure -`is_bare_name` stays real. +Real `.dirsql.toml` files are parsed; `_resolve_entries` (which owns the +effectful per-entry resolution) is mocked, returning a `base:path` sentinel +per entry. The pure `is_bare_name` stays real. """ import os @@ -29,8 +29,8 @@ def _write(path, body): def _patch(): return mock.patch.object( rce, - "resolve_extension_path", - side_effect=lambda path, base, resolve_relative: f"R:{path}", + "_resolve_entries", + side_effect=lambda entries, base: [f"{base}:{e['path']}" for e in entries], ) @@ -93,9 +93,9 @@ def it_returns_none_when_extension_entries_are_not_tables(cfg_dir): def it_returns_none_when_all_paths_are_literal(cfg_dir): path = os.path.join(cfg_dir, ".dirsql.toml") _write(path, '[[dirsql.extension]]\npath = "ext/a.so"\n') - with _patch() as resolver: + with _patch() as resolve_entries: assert rce.resolve_config_extension_specs(path) is None - resolver.assert_not_called() + resolve_entries.assert_not_called() def it_resolves_every_entry_when_a_path_is_a_bare_package_name(cfg_dir): path = os.path.join(cfg_dir, ".dirsql.toml") @@ -107,24 +107,16 @@ def it_resolves_every_entry_when_a_path_is_a_bare_package_name(cfg_dir): "[[dirsql.extension]]\n" 'path = "ext/local.so"\n', ) - with _patch() as resolver: + with _patch() as resolve_entries: specs = rce.resolve_config_extension_specs(path) - assert specs == [ - {"path": "R:sqlite_vec", "entrypoint": "sqlite3_vec_init"}, - {"path": "R:ext/local.so", "entrypoint": None}, - ] - resolver.assert_any_call("sqlite_vec", base=cfg_dir, resolve_relative=True) - resolver.assert_any_call("ext/local.so", base=cfg_dir, resolve_relative=True) - - def it_normalizes_a_non_string_entrypoint_to_none(cfg_dir): - path = os.path.join(cfg_dir, ".dirsql.toml") - _write( - path, - '[[dirsql.extension]]\npath = "sqlite_vec"\nentrypoint = 42\n', + assert specs == [f"{cfg_dir}:sqlite_vec", f"{cfg_dir}:ext/local.so"] + resolve_entries.assert_called_once_with( + [ + {"path": "sqlite_vec", "entrypoint": "sqlite3_vec_init"}, + {"path": "ext/local.so"}, + ], + cfg_dir, ) - with _patch(): - specs = rce.resolve_config_extension_specs(path) - assert specs == [{"path": "R:sqlite_vec", "entrypoint": None}] def it_skips_a_non_string_path_in_the_package_name_probe(cfg_dir): # A dict entry whose `path` isn't a string is not treated as a package @@ -137,63 +129,4 @@ def it_skips_a_non_string_path_in_the_package_name_probe(cfg_dir): ) with _patch(): specs = rce.resolve_config_extension_specs(path) - assert specs == [ - {"path": "R:42", "entrypoint": None}, - {"path": "R:sqlite_vec", "entrypoint": None}, - ] - - -def describe_resolve_configs_extension_specs(): - def it_returns_none_when_no_config_uses_a_bare_name(cfg_dir): - a = os.path.join(cfg_dir, "a.toml") - b = os.path.join(cfg_dir, "b.toml") - _write(a, '[[dirsql.extension]]\npath = "ext/a.so"\n') - _write( - b, '[[table]]\nname = "t"\nddl = "CREATE TABLE t (x TEXT)"\nglob = "*"\n' - ) - with _patch() as resolver: - assert rce.resolve_configs_extension_specs([a, b]) is None - resolver.assert_not_called() - - def it_returns_none_for_an_empty_list(): - assert rce.resolve_configs_extension_specs([]) is None - - def it_resolves_every_config_in_order_when_one_uses_a_bare_name(cfg_dir): - a = os.path.join(cfg_dir, "a.toml") - b = os.path.join(cfg_dir, "b.toml") - # a: only a literal; b: a bare package name -> intervene for both. - _write(a, '[[dirsql.extension]]\npath = "ext/a.so"\n') - _write(b, '[[dirsql.extension]]\npath = "sqlite_vec"\n') - with _patch(): - specs = rce.resolve_configs_extension_specs([a, b]) - assert specs == [ - {"path": "R:ext/a.so", "entrypoint": None}, - {"path": "R:sqlite_vec", "entrypoint": None}, - ] - - def it_skips_a_missing_config_but_resolves_the_rest(cfg_dir): - missing = os.path.join(cfg_dir, "nope.toml") - b = os.path.join(cfg_dir, "b.toml") - _write(b, '[[dirsql.extension]]\npath = "sqlite_vec"\n') - with _patch(): - specs = rce.resolve_configs_extension_specs([missing, b]) - assert specs == [{"path": "R:sqlite_vec", "entrypoint": None}] - - def it_resolves_each_config_against_its_own_directory(cfg_dir): - import tempfile - - with tempfile.TemporaryDirectory() as other: - a = os.path.join(cfg_dir, "a.toml") - b = os.path.join(other, "b.toml") - _write(a, '[[dirsql.extension]]\npath = "sqlite_vec"\n') - _write(b, '[[dirsql.extension]]\npath = "ext/b.so"\n') - with mock.patch.object( - rce, - "resolve_extension_path", - side_effect=lambda path, base, resolve_relative: base, - ): - specs = rce.resolve_configs_extension_specs([a, b]) - assert specs == [ - {"path": cfg_dir, "entrypoint": None}, - {"path": other, "entrypoint": None}, - ] + assert specs == [f"{cfg_dir}:42", f"{cfg_dir}:sqlite_vec"] diff --git a/packages/python/dirsql/resolve_configs_extension_specs.py b/packages/python/dirsql/resolve_configs_extension_specs.py new file mode 100644 index 000000000..ed07dbccb --- /dev/null +++ b/packages/python/dirsql/resolve_configs_extension_specs.py @@ -0,0 +1,35 @@ +"""Resolution of several TOML configs' ``[[dirsql.extension]]`` entries, in order. + +The plural counterpart to :func:`dirsql.resolve_config_extensions +.resolve_config_extension_specs`, used by the SDK constructor and the CLI +launcher when argv carries more than one ``--config``. +""" + +from __future__ import annotations + +from .resolve_config_extensions import _has_bare_name, _load_extension_entries +from .resolve_entries import _resolve_entries + + +def resolve_configs_extension_specs(config_paths): + """Resolve the ``[[dirsql.extension]]`` entries of several configs, in order. + + The SDK intervenes for the whole set only when **some** config names an + extension by bare package name (the core can resolve neither package names + nor -- once globally suppressed -- the literal entries of the other + configs). When it intervenes it resolves **every** config's entries, each + against that config's own parent directory, concatenated in ``config_paths`` + order; the caller suppresses the core's config-extension loading and passes + the resolved list. Returns ``None`` when no config uses a package name, + leaving every config's loading to the core. + """ + loaded = [_load_extension_entries(p) for p in config_paths] + if not any(item is not None and _has_bare_name(item[0]) for item in loaded): + return None + specs = [] + for item in loaded: + if item is None: + continue + entries, base = item + specs.extend(_resolve_entries(entries, base)) + return specs diff --git a/packages/python/dirsql/resolve_configs_extension_specs_test.py b/packages/python/dirsql/resolve_configs_extension_specs_test.py new file mode 100644 index 000000000..c5d64085e --- /dev/null +++ b/packages/python/dirsql/resolve_configs_extension_specs_test.py @@ -0,0 +1,77 @@ +"""Unit tests for `resolve_configs_extension_specs`. + +Real `.dirsql.toml` files are parsed; `_resolve_entries` (which owns the +effectful per-entry resolution) is mocked, returning a `base:path` sentinel +per entry so ordering and the per-config base directory are both observable. +""" + +import os +import tempfile +from unittest import mock + +import pytest + +import dirsql.resolve_configs_extension_specs as mod + + +@pytest.fixture +def cfg_dir(): + with tempfile.TemporaryDirectory() as d: + yield d + + +def _write(path, body): + with open(path, "w") as f: + f.write(body) + + +def _patch(): + return mock.patch.object( + mod, + "_resolve_entries", + side_effect=lambda entries, base: [f"{base}:{e['path']}" for e in entries], + ) + + +def describe_resolve_configs_extension_specs(): + def it_returns_none_when_no_config_uses_a_bare_name(cfg_dir): + a = os.path.join(cfg_dir, "a.toml") + b = os.path.join(cfg_dir, "b.toml") + _write(a, '[[dirsql.extension]]\npath = "ext/a.so"\n') + _write( + b, '[[table]]\nname = "t"\nddl = "CREATE TABLE t (x TEXT)"\nglob = "*"\n' + ) + with _patch() as resolve_entries: + assert mod.resolve_configs_extension_specs([a, b]) is None + resolve_entries.assert_not_called() + + def it_returns_none_for_an_empty_list(): + assert mod.resolve_configs_extension_specs([]) is None + + def it_resolves_every_config_in_order_when_one_uses_a_bare_name(cfg_dir): + a = os.path.join(cfg_dir, "a.toml") + b = os.path.join(cfg_dir, "b.toml") + # a: only a literal; b: a bare package name -> intervene for both. + _write(a, '[[dirsql.extension]]\npath = "ext/a.so"\n') + _write(b, '[[dirsql.extension]]\npath = "sqlite_vec"\n') + with _patch(): + specs = mod.resolve_configs_extension_specs([a, b]) + assert specs == [f"{cfg_dir}:ext/a.so", f"{cfg_dir}:sqlite_vec"] + + def it_skips_a_missing_config_but_resolves_the_rest(cfg_dir): + missing = os.path.join(cfg_dir, "nope.toml") + b = os.path.join(cfg_dir, "b.toml") + _write(b, '[[dirsql.extension]]\npath = "sqlite_vec"\n') + with _patch(): + specs = mod.resolve_configs_extension_specs([missing, b]) + assert specs == [f"{cfg_dir}:sqlite_vec"] + + def it_resolves_each_config_against_its_own_directory(cfg_dir): + with tempfile.TemporaryDirectory() as other: + a = os.path.join(cfg_dir, "a.toml") + b = os.path.join(other, "b.toml") + _write(a, '[[dirsql.extension]]\npath = "sqlite_vec"\n') + _write(b, '[[dirsql.extension]]\npath = "ext/b.so"\n') + with _patch(): + specs = mod.resolve_configs_extension_specs([a, b]) + assert specs == [f"{cfg_dir}:sqlite_vec", f"{other}:ext/b.so"] diff --git a/packages/python/dirsql/resolve_entries.py b/packages/python/dirsql/resolve_entries.py new file mode 100644 index 000000000..8bad0b02b --- /dev/null +++ b/packages/python/dirsql/resolve_entries.py @@ -0,0 +1,20 @@ +"""Resolution of one config's ``[[dirsql.extension]]`` entries to literal paths.""" + +from __future__ import annotations + +from .resolve_extension import resolve_extension_path + + +def _resolve_entries(entries, base): + specs = [] + for e in entries: + entrypoint = e.get("entrypoint") + specs.append( + { + "path": resolve_extension_path( + e["path"], base=base, resolve_relative=True + ), + "entrypoint": entrypoint if isinstance(entrypoint, str) else None, + } + ) + return specs diff --git a/packages/python/dirsql/resolve_entries_test.py b/packages/python/dirsql/resolve_entries_test.py new file mode 100644 index 000000000..3a5557772 --- /dev/null +++ b/packages/python/dirsql/resolve_entries_test.py @@ -0,0 +1,46 @@ +"""Unit tests for `_resolve_entries`. + +The one collaborator, the effectful `resolve_extension_path`, is mocked. +""" + +from unittest import mock + +import dirsql.resolve_entries as mod + + +def _patch(): + return mock.patch.object( + mod, + "resolve_extension_path", + side_effect=lambda path, base, resolve_relative: f"R:{path}", + ) + + +def describe_resolve_entries(): + def it_resolves_nothing_for_an_empty_entry_list(): + with _patch() as resolver: + assert mod._resolve_entries([], "/cfg") == [] + resolver.assert_not_called() + + def it_resolves_every_entry_against_the_base_directory(): + with _patch() as resolver: + specs = mod._resolve_entries( + [ + {"path": "sqlite_vec", "entrypoint": "sqlite3_vec_init"}, + {"path": "ext/local.so"}, + ], + "/cfg", + ) + assert specs == [ + {"path": "R:sqlite_vec", "entrypoint": "sqlite3_vec_init"}, + {"path": "R:ext/local.so", "entrypoint": None}, + ] + resolver.assert_any_call("sqlite_vec", base="/cfg", resolve_relative=True) + resolver.assert_any_call("ext/local.so", base="/cfg", resolve_relative=True) + + def it_normalizes_a_non_string_entrypoint_to_none(): + with _patch(): + specs = mod._resolve_entries( + [{"path": "sqlite_vec", "entrypoint": 42}], "/cfg" + ) + assert specs == [{"path": "R:sqlite_vec", "entrypoint": None}] diff --git a/packages/python/e2e-attestations/claude-1056-py-ofpf9.json b/packages/python/e2e-attestations/claude-1056-py-ofpf9.json new file mode 100644 index 000000000..f74f59847 --- /dev/null +++ b/packages/python/e2e-attestations/claude-1056-py-ofpf9.json @@ -0,0 +1,7 @@ +{ + "command": "just test-e2e", + "ran_at": 1788571386, + "exit_code": 0, + "commit": "2b1dbcacb1960ec0e13c5fa06e58dfef012dbb45", + "branch": "claude/1056-py-ofpf9" +}