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
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
8 changes: 4 additions & 4 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 2 additions & 2 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <command>`. 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.
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
<p align="center">
<img src="https://raw.githubusercontent.com/vgreg/iolaus/main/docs/assets/iolaus.webp"
alt="Iolaus, the companion who helped Heracles defeat the Hydra" width="200">
</p>

# Iolaus

> **Warning:** This project is under active development and is not yet stable. APIs may change without notice.
Expand Down Expand Up @@ -35,6 +40,11 @@ def analyze(
"""Run the analysis pipeline."""
...

@cmd
def report(settings=None):
"""Summarize the latest run."""
...

if __name__ == "__main__":
app()
```
Expand All @@ -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:

```
Expand Down
Binary file added docs/assets/iolaus.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 8 additions & 0 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()
```
Expand All @@ -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
Expand Down
9 changes: 9 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
<p align="center">
<img src="assets/iolaus.webp" alt="Iolaus, the companion who helped Heracles defeat the Hydra" width="220">
</p>

# 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.
Expand Down Expand Up @@ -28,6 +32,11 @@ def analyze(
"""Run the analysis pipeline."""
...

@cmd
def report(settings=None):
"""Summarize the latest run."""
...

if __name__ == "__main__":
app()
```
Expand Down
2 changes: 2 additions & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ repo_name: vgreg/iolaus

theme:
name: material
logo: assets/iolaus.webp
favicon: assets/iolaus.webp
palette:
- scheme: default
primary: indigo
Expand Down
5 changes: 4 additions & 1 deletion src/iolaus/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
37 changes: 33 additions & 4 deletions tests/test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"


Expand All @@ -22,15 +22,15 @@ 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:
"""CLI --set overrides apply on top of base settings."""
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:
Expand All @@ -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(
Expand All @@ -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"