diff --git a/packages/python/changelog.d/2026-09-04-config-paths-from-argv-module.md b/packages/python/changelog.d/2026-09-04-config-paths-from-argv-module.md new file mode 100644 index 000000000..f7317e442 --- /dev/null +++ b/packages/python/changelog.d/2026-09-04-config-paths-from-argv-module.md @@ -0,0 +1,3 @@ +**Changed** + +- **The launcher's argv config-path scan moved to its own module.** `config_paths_from_argv` now lives in `dirsql/cli/config_paths_from_argv.py`; `with_resolved_extensions` imports it. Internal reorganisation with no change to the published API or CLI behavior, mirroring the TypeScript launcher. (#1042) diff --git a/packages/python/dirsql/cli/config_paths_from_argv.py b/packages/python/dirsql/cli/config_paths_from_argv.py new file mode 100644 index 000000000..74a4c09d1 --- /dev/null +++ b/packages/python/dirsql/cli/config_paths_from_argv.py @@ -0,0 +1,28 @@ +"""Config-path extraction from the launcher's argv. + +Every config flag occurrence counts -- ``-c``/``--config`` are repeatable, +and plugin discovery injects fragments as additional ``-c`` flags -- so the +scan collects them all, in argv order. +""" + +from __future__ import annotations + + +def config_paths_from_argv(argv: list[str]) -> list[str]: + """Every config value in argv, in order (``--config X``, ``--config=X``, + ``-c X``, ``-c=X``, ``-cX``), or the default when none are given.""" + paths: list[str] = [] + i = 0 + while i < len(argv): + a = argv[i] + if a == "--config" or a == "-c": + # A bare trailing flag (no following value) yields "". + paths.append(next(iter(argv[i + 1 :]), "")) + i += 2 + continue + if a.startswith("--config="): + paths.append(a[len("--config=") :]) + elif a.startswith("-c"): + paths.append(a[len("-c") :].removeprefix("=")) + i += 1 + return paths or ["./.dirsql.toml"] diff --git a/packages/python/dirsql/cli/config_paths_from_argv_test.py b/packages/python/dirsql/cli/config_paths_from_argv_test.py new file mode 100644 index 000000000..6cc907652 --- /dev/null +++ b/packages/python/dirsql/cli/config_paths_from_argv_test.py @@ -0,0 +1,70 @@ +"""Unit tests for `config_paths_from_argv`.""" + +from dirsql.cli.config_paths_from_argv import config_paths_from_argv + + +def describe_config_paths_from_argv(): + def it_reads_the_config_form(): + assert config_paths_from_argv(["--config", "/cfg/.dirsql.toml"]) == [ + "/cfg/.dirsql.toml" + ] + + def it_reads_the_config_equals_form(): + assert config_paths_from_argv(["--config=/c/.dirsql.toml"]) == [ + "/c/.dirsql.toml" + ] + + def it_reads_the_short_c_form(): + # Discovery injects fragments as `-c `; those must be collected + # exactly like `--config `. + assert config_paths_from_argv(["-c", "/frag/dirsql.toml"]) == [ + "/frag/dirsql.toml" + ] + + def it_reads_the_short_c_equals_form(): + assert config_paths_from_argv(["-c=/frag/dirsql.toml"]) == ["/frag/dirsql.toml"] + + def it_reads_the_short_c_attached_form(): + assert config_paths_from_argv(["-c/frag/dirsql.toml"]) == ["/frag/dirsql.toml"] + + def it_collects_every_config_flag_in_argv_order(): + argv = ["-c", "a.toml", "--config", "b.toml", "--config=c.toml", "-cd.toml"] + assert config_paths_from_argv(argv) == ["a.toml", "b.toml", "c.toml", "d.toml"] + + def it_reads_the_config_value_at_any_argv_position(): + assert config_paths_from_argv(["-v", "--config", "/x/y", "tail"]) == ["/x/y"] + + def it_collects_a_discovery_injected_fragment_not_the_default(): + # The user passed no config; discovery appended `--include-default -c + # `. The fragment -- not `./.dirsql.toml` -- is the result. + argv = ["query", "SELECT 1", "--include-default", "-c", "/frag/dirsql.toml"] + assert config_paths_from_argv(argv) == ["/frag/dirsql.toml"] + + def it_consumes_a_config_value_that_looks_like_a_flag(): + # The token after `--config` / `-c` is that flag's value; it is never + # re-parsed as another config flag. + assert config_paths_from_argv(["--config", "-cx.toml"]) == ["-cx.toml"] + + def it_matches_the_config_flag_by_value_not_identity_or_ordering(): + # A runtime-built "--config" (not the interned literal) must match, + # and flags sorting on either side of "--config" must not. + assert config_paths_from_argv(["".join(["--con", "fig"]), "/x/y"]) == ["/x/y"] + assert config_paths_from_argv(["--a", "val"]) == ["./.dirsql.toml"] + + def it_does_not_mistake_other_short_flags_for_config(): + assert config_paths_from_argv(["-v", "-x", "val"]) == ["./.dirsql.toml"] + + def it_defaults_to_dot_dirsql_toml_when_no_config_given(): + assert config_paths_from_argv(["--port", "9000"]) == ["./.dirsql.toml"] + + def it_defaults_to_dot_dirsql_toml_for_an_empty_argv(): + assert config_paths_from_argv([]) == ["./.dirsql.toml"] + + def it_treats_a_bare_trailing_config_as_an_empty_path(): + assert config_paths_from_argv(["--config"]) == [""] + + def it_treats_a_bare_trailing_short_c_as_an_empty_path(): + assert config_paths_from_argv(["-c"]) == [""] + + def it_keeps_a_bare_trailing_short_c_after_an_earlier_config(): + assert config_paths_from_argv(["--config", "a.toml", "-c"]) == ["a.toml", ""] diff --git a/packages/python/dirsql/cli/resolve_config_extensions.py b/packages/python/dirsql/cli/resolve_config_extensions.py index 7312097cc..64c5f74ce 100644 --- a/packages/python/dirsql/cli/resolve_config_extensions.py +++ b/packages/python/dirsql/cli/resolve_config_extensions.py @@ -8,10 +8,6 @@ via repeatable ``--extension`` flags; the binary then loads those and ignores the configs' own extension entries. -Every config flag occurrence counts -- ``-c``/``--config`` are repeatable, -and plugin discovery injects fragments as additional ``-c`` flags -- so the -scan collects them all, in argv order. - Native-language configs (``.py`` / ``.js`` / ``.mjs`` / ``.cjs``) are untouched: the binary dispatches those to ``dirsql interpret``, whose handshake already carries resolved paths. @@ -20,32 +16,13 @@ from __future__ import annotations from ..resolve_config_extensions import resolve_configs_extension_specs +from .config_paths_from_argv import config_paths_from_argv # Config extensions the binary dispatches to `dirsql interpret`; never # pre-resolved here (that path resolves via the handshake). _NATIVE_SUFFIXES = (".py", ".js", ".mjs", ".cjs") -def _config_paths_from_argv(argv: list[str]) -> list[str]: - """Every config value in argv, in order (``--config X``, ``--config=X``, - ``-c X``, ``-c=X``, ``-cX``), or the default when none are given.""" - paths: list[str] = [] - i = 0 - while i < len(argv): - a = argv[i] - if a == "--config" or a == "-c": - # A bare trailing flag (no following value) yields "". - paths.append(next(iter(argv[i + 1 :]), "")) - i += 2 - continue - if a.startswith("--config="): - paths.append(a[len("--config=") :]) - elif a.startswith("-c"): - paths.append(a[len("-c") :].removeprefix("=")) - i += 1 - return paths or ["./.dirsql.toml"] - - def with_resolved_extensions(argv: list[str]) -> list[str]: """Return ``argv`` plus ``--extension`` flags when a TOML config names an extension by package name; otherwise return ``argv`` unchanged. Raises if a @@ -53,7 +30,7 @@ def with_resolved_extensions(argv: list[str]) -> list[str]: if argv and argv[0] == "init": return argv config_paths = [ - p for p in _config_paths_from_argv(argv) if not p.endswith(_NATIVE_SUFFIXES) + p for p in config_paths_from_argv(argv) if not p.endswith(_NATIVE_SUFFIXES) ] if not config_paths: return argv diff --git a/packages/python/dirsql/cli/resolve_config_extensions_test.py b/packages/python/dirsql/cli/resolve_config_extensions_test.py index 3fa6fb822..1b5076591 100644 --- a/packages/python/dirsql/cli/resolve_config_extensions_test.py +++ b/packages/python/dirsql/cli/resolve_config_extensions_test.py @@ -1,42 +1,72 @@ """Unit tests for `with_resolved_extensions`. -The shared SDK resolver (mocked here) owns the TOML parsing and package-name -gating; this file covers only the launcher-side argv plumbing: config-path -extraction (every `-c` / `--config` occurrence, in every spelling), the -`init` / native-config guards, and `--extension` flag construction. +Both collaborators are mocked: the argv scan (`config_paths_from_argv`) and +the shared SDK resolver, which owns the TOML parsing and package-name gating. +What is left is the launcher's own plumbing -- the `init` and native-config +guards, and `--extension` flag construction. """ +from contextlib import contextmanager from unittest import mock import dirsql.cli.resolve_config_extensions as rce -def _patch(specs): - return mock.patch.object(rce, "resolve_configs_extension_specs", return_value=specs) +@contextmanager +def _patch(specs, paths=("/x/.dirsql.toml",)): + with ( + mock.patch.object( + rce, "config_paths_from_argv", autospec=True, return_value=list(paths) + ) as scan, + mock.patch.object( + rce, "resolve_configs_extension_specs", autospec=True, return_value=specs + ) as resolver, + ): + yield scan, resolver def describe_with_resolved_extensions(): def it_passes_init_through_untouched(): argv = ["init", "--root", "."] - with _patch([{"path": "R:pkg", "entrypoint": None}]) as resolver: + with _patch([{"path": "R:pkg", "entrypoint": None}]) as (scan, resolver): assert rce.with_resolved_extensions(argv) is argv + scan.assert_not_called() resolver.assert_not_called() - def it_passes_a_native_config_through_untouched(): - argv = ["--config", "dirsql.config.py"] - with _patch([{"path": "R:pkg", "entrypoint": None}]) as resolver: + def it_matches_init_by_value_not_identity_or_ordering(): + # Only the exact first argument "init" skips resolution: a + # runtime-built "init" still skips; other subcommands (sorting above + # or below "init") do not. + with _patch(None) as (_scan, resolver): + argv = ["".join(["in", "it"])] assert rce.with_resolved_extensions(argv) is argv resolver.assert_not_called() + with _patch(None) as (_scan, resolver): + rce.with_resolved_extensions(["zzz"]) + resolver.assert_called_once_with(["/x/.dirsql.toml"]) + + def it_scans_the_whole_argv_for_config_paths(): + argv = ["query", "SELECT 1", "--include-default", "-c", "/frag/dirsql.toml"] + with _patch(None) as (scan, _resolver): + rce.with_resolved_extensions(argv) + scan.assert_called_once_with(argv) - def it_passes_a_native_short_config_through_untouched(): - argv = ["-c", "dirsql.config.mjs"] - with _patch([{"path": "R:pkg", "entrypoint": None}]) as resolver: + def it_passes_native_configs_through_untouched(): + argv = ["--config", "dirsql.config.py"] + paths = ["cfg.py", "cfg.js", "cfg.mjs", "cfg.cjs"] + with _patch([{"path": "R:pkg", "entrypoint": None}], paths) as (_s, resolver): assert rce.with_resolved_extensions(argv) is argv resolver.assert_not_called() + def it_drops_native_configs_but_resolves_the_toml_ones(): + paths = ["cfg.py", "/frag/dirsql.toml", "other.cjs"] + with _patch(None, paths) as (_scan, resolver): + rce.with_resolved_extensions(["-c", "cfg.py", "-c", "/frag/dirsql.toml"]) + resolver.assert_called_once_with(["/frag/dirsql.toml"]) + def it_passes_through_when_the_resolver_does_not_intervene(): argv = ["--config", "/x/.dirsql.toml"] - with _patch(None) as resolver: + with _patch(None) as (_scan, resolver): assert rce.with_resolved_extensions(argv) is argv resolver.assert_called_once_with(["/x/.dirsql.toml"]) @@ -45,7 +75,7 @@ def it_appends_extension_flags_for_resolved_specs(): {"path": "R:sqlite_vec", "entrypoint": "sqlite3_vec_init"}, {"path": "R:ext/local.so", "entrypoint": None}, ] - with _patch(specs): + with _patch(specs, ["/cfg/.dirsql.toml"]): out = rce.with_resolved_extensions(["--config", "/cfg/.dirsql.toml"]) assert out == [ "--config", @@ -56,112 +86,9 @@ def it_appends_extension_flags_for_resolved_specs(): "R:ext/local.so", ] - def it_reads_the_config_equals_form(): - with _patch([{"path": "R:pkg", "entrypoint": None}]) as resolver: - out = rce.with_resolved_extensions(["--config=/c/.dirsql.toml"]) - assert out == ["--config=/c/.dirsql.toml", "--extension", "R:pkg"] - resolver.assert_called_once_with(["/c/.dirsql.toml"]) - - def it_reads_the_short_c_form(): - # Discovery injects fragments as `-c `; those must reach the - # resolver exactly like `--config `. - with _patch([{"path": "R:pkg", "entrypoint": None}]) as resolver: - out = rce.with_resolved_extensions(["-c", "/frag/dirsql.toml"]) - assert out == ["-c", "/frag/dirsql.toml", "--extension", "R:pkg"] - resolver.assert_called_once_with(["/frag/dirsql.toml"]) - - def it_reads_the_short_c_equals_form(): - with _patch(None) as resolver: - rce.with_resolved_extensions(["-c=/frag/dirsql.toml"]) - resolver.assert_called_once_with(["/frag/dirsql.toml"]) - - def it_reads_the_short_c_attached_form(): - with _patch(None) as resolver: - rce.with_resolved_extensions(["-c/frag/dirsql.toml"]) - resolver.assert_called_once_with(["/frag/dirsql.toml"]) - - def it_collects_every_config_flag_in_argv_order(): - with _patch(None) as resolver: - rce.with_resolved_extensions( - ["-c", "a.toml", "--config", "b.toml", "--config=c.toml", "-cd.toml"] - ) - resolver.assert_called_once_with(["a.toml", "b.toml", "c.toml", "d.toml"]) - - def it_resolves_a_discovery_injected_fragment_not_the_default(): - # The user passed no config; discovery appended `--include-default -c - # `. The fragment -- not `./.dirsql.toml` -- must be resolved. - with _patch(None) as resolver: - rce.with_resolved_extensions( - ["query", "SELECT 1", "--include-default", "-c", "/frag/dirsql.toml"] - ) - resolver.assert_called_once_with(["/frag/dirsql.toml"]) - - def it_drops_native_configs_but_resolves_the_toml_ones(): - with _patch(None) as resolver: - rce.with_resolved_extensions( - ["-c", "cfg.py", "-c", "/frag/dirsql.toml", "-c", "other.cjs"] - ) - resolver.assert_called_once_with(["/frag/dirsql.toml"]) - - def it_defaults_to_dot_dirsql_toml_when_no_config_given(): - with _patch([{"path": "R:pkg", "entrypoint": None}]) as resolver: - out = rce.with_resolved_extensions(["--port", "9000"]) - assert out == ["--port", "9000", "--extension", "R:pkg"] - resolver.assert_called_once_with(["./.dirsql.toml"]) - - def it_treats_a_bare_trailing_config_as_empty_path(): - with _patch(None) as resolver: - argv = ["--config"] - assert rce.with_resolved_extensions(argv) is argv - resolver.assert_called_once_with([""]) - - def it_treats_a_bare_trailing_short_c_as_empty_path(): - with _patch(None) as resolver: - argv = ["-c"] - assert rce.with_resolved_extensions(argv) is argv - resolver.assert_called_once_with([""]) - - def it_consumes_a_config_value_that_looks_like_a_flag(): - # The token after `--config` / `-c` is that flag's value; it is never - # re-parsed as another config flag. - with _patch(None) as resolver: - rce.with_resolved_extensions(["--config", "-cx.toml"]) - resolver.assert_called_once_with(["-cx.toml"]) - - def it_reads_the_config_value_at_any_argv_position(): - with _patch(None) as resolver: - rce.with_resolved_extensions(["-v", "--config", "/x/y", "tail"]) - resolver.assert_called_once_with(["/x/y"]) - - def it_matches_the_config_flag_by_value_not_identity_or_ordering(): - # A runtime-built "--config" (not the interned literal) must match, - # and flags sorting on either side of "--config" must not. - with _patch(None) as resolver: - rce.with_resolved_extensions(["".join(["--con", "fig"]), "/x/y"]) - resolver.assert_called_once_with(["/x/y"]) - with _patch(None) as resolver: - rce.with_resolved_extensions(["--a", "val"]) - resolver.assert_called_once_with(["./.dirsql.toml"]) - - def it_does_not_mistake_other_short_flags_for_config(): - with _patch(None) as resolver: - rce.with_resolved_extensions(["-v", "-x", "val"]) - resolver.assert_called_once_with(["./.dirsql.toml"]) - - def it_matches_init_by_value_not_identity_or_ordering(): - # Only the exact first argument "init" skips resolution: a - # runtime-built "init" still skips; other subcommands (sorting above - # or below "init") do not. - with _patch(None) as resolver: - argv = ["".join(["in", "it"])] - assert rce.with_resolved_extensions(argv) is argv - resolver.assert_not_called() - with _patch(None) as resolver: - rce.with_resolved_extensions(["zzz"]) - resolver.assert_called_once_with(["./.dirsql.toml"]) - def it_consults_the_resolver_for_an_empty_argv(): - with _patch(None) as resolver: + with _patch(None) as (scan, resolver): argv: list[str] = [] assert rce.with_resolved_extensions(argv) is argv - resolver.assert_called_once_with(["./.dirsql.toml"]) + scan.assert_called_once_with([]) + resolver.assert_called_once_with(["/x/.dirsql.toml"]) diff --git a/packages/python/e2e-attestations/claude-tackle-1042-py12.json b/packages/python/e2e-attestations/claude-tackle-1042-py12.json new file mode 100644 index 000000000..d88dec448 --- /dev/null +++ b/packages/python/e2e-attestations/claude-tackle-1042-py12.json @@ -0,0 +1,7 @@ +{ + "command": "just test-e2e", + "ran_at": 1788565282, + "exit_code": 0, + "commit": "9257c5204b6cf9b491cf5487aedbf026c5255338", + "branch": "claude/tackle-1042-py12" +}