diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3b1f8a2..6f6cfe1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,15 +12,15 @@ jobs: matrix: python-version: ["3.11", "3.12", "3.13", "3.14"] steps: - - uses: actions/checkout@v4 - - uses: astral-sh/setup-uv@v5 + - uses: actions/checkout@v7 + - uses: astral-sh/setup-uv@v10.0.1 with: python-version: ${{ matrix.python-version }} - run: uv sync --all-extras - run: uv run ruff check src/ tests/ - run: uv run mypy src/ - run: uv run pytest --cov=iolaus --cov-report=xml - - uses: codecov/codecov-action@v4 + - uses: codecov/codecov-action@v7 if: matrix.python-version == '3.14' with: token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index cdbb6ed..3eaae6a 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -15,12 +15,12 @@ jobs: name: github-pages url: ${{ steps.deployment.outputs.page_url }} steps: - - uses: actions/checkout@v4 - - uses: astral-sh/setup-uv@v5 + - uses: actions/checkout@v7 + - uses: astral-sh/setup-uv@v10.0.1 - run: uv sync --extra docs - run: uv run mkdocs build --strict - - uses: actions/upload-pages-artifact@v3 + - uses: actions/upload-pages-artifact@v5 with: path: site/ - id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v5 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 1de90d3..57ecb6e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -17,9 +17,9 @@ jobs: id-token: write # required for PyPI Trusted Publishing steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - - uses: astral-sh/setup-uv@v5 + - uses: astral-sh/setup-uv@v10.0.1 - run: uv sync --all-extras diff --git a/CHANGELOG.md b/CHANGELOG.md index b6eb091..8bbce10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### Fixed + +- `--set` overrides are now parsed as TOML, so `--set model__lr=0.01` yields the float `0.01` rather than the string `"0.01"`. Values that are not valid TOML (bare paths, unquoted text, values containing `=`) still fall back to strings. +- Corrected the quick-start examples, which registered a single command but were documented as `python cli.py `. Typer collapses single-command applications, so that invocation failed with `Got unexpected extra argument(s)`. The examples now register two commands. + ## [0.1.0] - 2026-08-22 Initial release. diff --git a/README.md b/README.md index 4d02d9e..ff4e9cd 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,8 @@ +

+ Iolaus, the companion who helped Heracles defeat the Hydra +

+ # Iolaus > **Warning:** This project is under active development and is not yet stable. APIs may change without notice. @@ -35,6 +40,11 @@ def analyze( """Run the analysis pipeline.""" ... +@cmd +def report(settings=None): + """Summarize the latest run.""" + ... + if __name__ == "__main__": app() ``` @@ -50,6 +60,8 @@ python cli.py analyze data.csv --config prod.yaml python cli.py analyze data.csv --set model__lr=0.01 --set db__host=remote ``` +> **Note:** Typer treats an application with exactly one registered command as a single-command CLI, and the command name is then left off the command line. These examples register two commands, so every invocation names the one to run. + Every run produces: ``` diff --git a/docs/assets/iolaus.webp b/docs/assets/iolaus.webp new file mode 100644 index 0000000..a694365 Binary files /dev/null and b/docs/assets/iolaus.webp differ diff --git a/docs/getting-started.md b/docs/getting-started.md index fdb66ea..8a7258d 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -53,6 +53,11 @@ def train(settings=None, run_dir=None): print(f"Learning rate: {settings.model.lr}") print(f"Run artifacts in: {run_dir}") +@cmd +def evaluate(settings=None): + """Evaluate the trained model.""" + print(f"Epochs: {settings.model.epochs}") + if __name__ == "__main__": app() ``` @@ -63,6 +68,9 @@ if __name__ == "__main__": python cli.py train ``` +!!! note + Typer treats an application with exactly one registered command as a single-command CLI, and the command name is then left off the command line (`python cli.py --set model__lr=0.01`). These examples register two commands, so every invocation names the one to run. + This creates a timestamped directory under `outputs/train/` containing: - `run.log` — log output from the run diff --git a/docs/index.md b/docs/index.md index 0896d97..09e136a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,3 +1,7 @@ +

+ Iolaus, the companion who helped Heracles defeat the Hydra +

+ # Iolaus **Iolaus** is a lightweight Python framework for research data analysis projects. It wires together [Dynaconf](https://www.dynaconf.com/), [Typer](https://typer.tiangolo.com/), and a custom run-logging system into a single decorator-based API. @@ -28,6 +32,11 @@ def analyze( """Run the analysis pipeline.""" ... +@cmd +def report(settings=None): + """Summarize the latest run.""" + ... + if __name__ == "__main__": app() ``` diff --git a/mkdocs.yml b/mkdocs.yml index 385bb2b..649a0a2 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -6,6 +6,8 @@ repo_name: vgreg/iolaus theme: name: material + logo: assets/iolaus.webp + favicon: assets/iolaus.webp palette: - scheme: default primary: indigo diff --git a/src/iolaus/settings.py b/src/iolaus/settings.py index b570fbb..6273e26 100644 --- a/src/iolaus/settings.py +++ b/src/iolaus/settings.py @@ -34,6 +34,9 @@ def build_settings( for item in overrides: key, _, value = item.partition("=") - merged.set(key.replace("__", "."), value) + # tomlfy parses the value as TOML so numbers, booleans, and lists keep + # their types instead of arriving as bare strings. Values that are not + # valid TOML (bare paths, unquoted text) fall back to str. + merged.set(key.replace("__", "."), value, tomlfy=True) return merged diff --git a/tests/test_settings.py b/tests/test_settings.py index 3e4f400..2e219f2 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -11,7 +11,7 @@ def test_build_settings_base_only(base_settings: Dynaconf) -> None: """Base settings are preserved when no extras are provided.""" merged = build_settings(base_settings, extra_config=None, overrides=[]) - assert float(merged.model.lr) == pytest.approx(0.001) + assert merged.model.lr == pytest.approx(0.001) assert merged.db.host == "localhost" @@ -22,7 +22,7 @@ def test_build_settings_extra_config(base_settings: Dynaconf, tmp_path: Path) -> merged = build_settings(base_settings, extra_config=extra, overrides=[]) assert merged.db.host == "remote" # Non-overridden keys should still be present - assert float(merged.model.lr) == pytest.approx(0.001) + assert merged.model.lr == pytest.approx(0.001) def test_build_settings_overrides(base_settings: Dynaconf) -> None: @@ -30,7 +30,7 @@ def test_build_settings_overrides(base_settings: Dynaconf) -> None: merged = build_settings( base_settings, extra_config=None, overrides=["model__lr=0.1"] ) - assert float(merged.model.lr) == pytest.approx(0.1) + assert merged.model.lr == pytest.approx(0.1) def test_build_settings_nested_override(base_settings: Dynaconf) -> None: @@ -41,7 +41,7 @@ def test_build_settings_nested_override(base_settings: Dynaconf) -> None: overrides=["db__host=newhost", "model__lr=0.5"], ) assert merged.db.host == "newhost" - assert float(merged.model.lr) == pytest.approx(0.5) + assert merged.model.lr == pytest.approx(0.5) def test_build_settings_extra_and_overrides( @@ -54,3 +54,32 @@ def test_build_settings_extra_and_overrides( base_settings, extra_config=extra, overrides=["db__host=production"] ) assert merged.db.host == "production" + + +def test_build_settings_override_preserves_types(base_settings: Dynaconf) -> None: + """Overrides are parsed as TOML so they keep their types, not bare strings.""" + merged = build_settings( + base_settings, + extra_config=None, + overrides=["model__lr=0.01", "model__epochs=50", "debug=true"], + ) + assert isinstance(merged.model.lr, float) + assert isinstance(merged.model.epochs, int) + assert isinstance(merged.debug, bool) + assert merged.model.lr == pytest.approx(0.01) + assert merged.model.epochs == 50 + assert merged.debug is True + + +def test_build_settings_override_non_toml_stays_string( + base_settings: Dynaconf, +) -> None: + """Values that are not valid TOML fall back to plain strings.""" + merged = build_settings( + base_settings, + extra_config=None, + overrides=["db__host=remote", "out__path=/tmp/data.csv", "note=a=b"], + ) + assert merged.db.host == "remote" + assert merged.out.path == "/tmp/data.csv" + assert merged.note == "a=b"