Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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)
28 changes: 28 additions & 0 deletions packages/python/dirsql/cli/config_paths_from_argv.py
Original file line number Diff line number Diff line change
@@ -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"]
70 changes: 70 additions & 0 deletions packages/python/dirsql/cli/config_paths_from_argv_test.py
Original file line number Diff line number Diff line change
@@ -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 <path>`; those must be collected
# exactly like `--config <path>`.
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
# <fragment>`. 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", ""]
27 changes: 2 additions & 25 deletions packages/python/dirsql/cli/resolve_config_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -20,40 +16,21 @@
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
package name cannot be resolved (the launcher surfaces a clean error)."""
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
Expand Down
169 changes: 48 additions & 121 deletions packages/python/dirsql/cli/resolve_config_extensions_test.py
Original file line number Diff line number Diff line change
@@ -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"])

Expand All @@ -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",
Expand All @@ -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 <path>`; those must reach the
# resolver exactly like `--config <path>`.
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
# <fragment>`. 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"])
7 changes: 7 additions & 0 deletions packages/python/e2e-attestations/claude-tackle-1042-py12.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"command": "just test-e2e",
"ran_at": 1788565282,
"exit_code": 0,
"commit": "9257c5204b6cf9b491cf5487aedbf026c5255338",
"branch": "claude/tackle-1042-py12"
}
Loading