diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ca737c5..42304f2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,6 +57,9 @@ jobs: - name: Ruff format check run: uv run ruff format src/ tests/ benchmarks/ --check + - name: Template compile check + run: uv run python scripts/check_templates.py + typecheck: name: Type Check runs-on: ubuntu-latest diff --git a/AGENTS.md b/AGENTS.md index 817514b..f3c29c3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,6 +84,8 @@ Things that look reasonable and are wrong here: - **Top-level imports in `milo/__init__.py`.** Every downstream CLI pays the cost on every invocation. Use `__getattr__`. - **Refactoring during a bug fix.** Separate PR. Exception: the refactor *is* the fix, or it's a rename-across-files cleanup. - **`print()` in library code.** T20 is enabled. Use the context's output path or raise. +- **Undeclared template vars, globals, or filters.** Kida 0.7 runs `strict_undefined=True` and milo's `get_env()` defaults `validate_calls=True`. `{{ bar(...) }}` or `{{ x | oops }}` that "used to work" will now raise. Put defaults on your state dataclass, use `| default(...)` / `.get()` at the boundary, and never reference a name that isn't in `env.globals` or passed to `render()`. +- **`{% def %}` nested inside `{% if %}` / `{% for %}`.** Kida requires top-level defs — the renderer only sees definitions declared at the outermost scope. Declare the macro first, then call it conditionally. --- @@ -92,6 +94,7 @@ Things that look reasonable and are wrong here: A change is done when all of these hold: - [ ] `make lint`, `make ty`, `make test-cov` clean. No new `type: ignore`, no new S110 suppressions without a `# silent:` justification and per-file entry. +- [ ] `uv run python scripts/check_templates.py` clean — every `.kida` file under `src/milo/templates/` and `examples/*/templates/` compiles under strict-undefined + `validate_calls=True`. This runs in CI's lint job; run it locally when you touch a template. - [ ] Coverage floor (80%, branch-aware) still holds. - [ ] Tests exercise the *interesting* path: both modes of a flag, MCP dispatch *and* CLI dispatch *and* `call()` for command changes, the failure path for saga effects, malformed input for schema inference. - [ ] Hot-path changes (`state.py` dispatch, `commands.py` resolution, `schema.py` inference) include a benchmark in the PR. "Didn't benchmark" is OK only if you say why. diff --git a/README.md b/README.md index 8a758d1..9cc6194 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,7 @@ Pick the example closest to your use case, copy its `app.py`, and adapt. Every d | Sagas for async side effects | [examples/fetcher](examples/fetcher) | `Call`, `Put`, `Select`, `Retry` | | Parallel concurrent work | [examples/downloader](examples/downloader) | `Fork`, `Call`, `Delay`, `Timeout` | | Bubbletea-style Cmd thunks | [examples/spinner](examples/spinner) | `Cmd`, `Batch`, `TickCmd`, `ViewState` | +| Live rendering outside an App | [examples/liverender](examples/liverender) | `milo.live.LiveRenderer`, `Spinner`, `terminal_env` | > Don't see your use case? Run `milo new ` to scaffold a fresh CLI with tests, then `milo verify app.py` to confirm it works. diff --git a/changelog.d/+kida-0.7-adoption.added.md b/changelog.d/+kida-0.7-adoption.added.md new file mode 100644 index 0000000..d46dd1f --- /dev/null +++ b/changelog.d/+kida-0.7-adoption.added.md @@ -0,0 +1 @@ +Adopt Kida 0.7 capabilities: `inline_components=True` and `validate_calls=True` defaults in `get_env()`; `enable_capture` opt-in kwarg on `get_env()` for static-site / capture flows; new `milo components` subcommand listing bundled and user-defined template defs (with `--json` for tooling, `--path` to scan extra dirs); `milo.live` re-exports for `LiveRenderer`, `Spinner`, `stream_to_terminal`, `terminal_env`; `kida.get_optimal_workers` now sizes the gateway, registry, and saga executor pools by workload type (IO_BOUND for I/O fan-out, RENDER for saga effects); `{% flush %}` boundaries added to `pipeline_progress` and `pipeline_detail` defs to encode streaming contract; CI gains a template compile-check via `scripts/check_templates.py`; new `examples/liverender` shows `LiveRenderer` outside the App harness; new docs page `usage/live.md`. diff --git a/changelog.d/+strict-undefined.changed.md b/changelog.d/+strict-undefined.changed.md new file mode 100644 index 0000000..d8e7a6c --- /dev/null +++ b/changelog.d/+strict-undefined.changed.md @@ -0,0 +1 @@ +Bumped to `kida-templates>=0.7.0,<0.8.0`. Kida 0.7 makes `strict_undefined=True` the default — milo's bundled templates already conformed, so no behaviour changes for callers using stock templates. User templates that relied on silent-undefined fallbacks now raise `UndefinedError` at render; opt back into the loose mode by passing `get_env(strict_undefined=False)`. diff --git a/examples/filepicker/templates/filepicker.kida b/examples/filepicker/templates/filepicker.kida index 33c52fb..b789f51 100644 --- a/examples/filepicker/templates/filepicker.kida +++ b/examples/filepicker/templates/filepicker.kida @@ -1,4 +1,5 @@ {% from "components/_defs.kida" import header, status_line, key_hints, scrollable_list %} +{% def render_entry(entry, is_selected) %}{% if is_selected %}{% if entry.is_dir %}{{ (entry.name ~ "/") | bold | cyan }}{% else %}{{ entry.name }}{% endif %}{% else %}{% if entry.is_dir %}{{ (entry.name ~ "/") | cyan }}{% else %}{{ entry.name | dim }}{% endif %}{% endif %}{% if not entry.is_dir and entry.size > 0 %} {{ format_size(entry.size) | dim }}{% endif %}{% enddef %} {{ header("File Picker") }} {{ hr() }} @@ -11,7 +12,6 @@ {% elif state.entries | length == 0 %} {{ "(empty directory)" | dim }} {% else %} -{% def render_entry(entry, is_selected) %}{% if is_selected %}{% if entry.is_dir %}{{ (entry.name ~ "/") | bold | cyan }}{% else %}{{ entry.name }}{% endif %}{% else %}{% if entry.is_dir %}{{ (entry.name ~ "/") | cyan }}{% else %}{{ entry.name | dim }}{% endif %}{% endif %}{% if not entry.is_dir and entry.size > 0 %} {{ format_size(entry.size) | dim }}{% endif %}{% enddef %} {{ scrollable_list(state.entries, state.cursor, render_entry, height=viewport_height, scroll_offset=state.scroll_offset) }} {% endif %} diff --git a/examples/liverender/app.py b/examples/liverender/app.py new file mode 100644 index 0000000..cd1cbc4 --- /dev/null +++ b/examples/liverender/app.py @@ -0,0 +1,45 @@ +"""LiveRenderer outside the App harness — a one-shot progress view. + +Use :mod:`milo.live` when you have a straight-line script or subroutine that +wants in-place terminal updates without the ceremony of a reducer-driven +:class:`milo.App`. For keyboard input, message filters, or state that persists +beyond the task, use :class:`milo.App` + :class:`milo.TickCmd` instead. + + uv run python examples/liverender/app.py +""" + +from __future__ import annotations + +import time + +from milo.live import LiveRenderer, terminal_env + +TEMPLATE = """\ +{{- spinner() }} {{ label }} + progress {{ (progress * 100)|round|int }}% +""" + +STEPS = ( + ("Resolving dependencies", 0.15), + ("Fetching packages", 0.45), + ("Compiling", 0.80), + ("Finalizing", 1.00), +) + + +def main() -> None: + env = terminal_env() + tpl = env.from_string(TEMPLATE, name="liverender") + + with LiveRenderer(tpl, refresh_rate=0.08) as live: + live.start_auto(label="Starting", progress=0.0) + for label, progress in STEPS: + live.update(label=label, progress=progress) + time.sleep(0.6) + live.stop_auto() + + print("done.") + + +if __name__ == "__main__": + main() diff --git a/examples/spinner/app.py b/examples/spinner/app.py index ed0d325..0de80e1 100644 --- a/examples/spinner/app.py +++ b/examples/spinner/app.py @@ -25,8 +25,9 @@ TickCmd, ViewState, ) +from milo.live import Spinner -SPINNER = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏") +SPINNER = Spinner.BRAILLE URLS = ( "https://example.com", diff --git a/pyproject.toml b/pyproject.toml index 639ce65..b3757af 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ description = "Template-driven CLI applications for free-threaded Python" readme = "README.md" requires-python = ">=3.14" license = "MIT" -dependencies = ["kida-templates>=0.6.0"] +dependencies = ["kida-templates>=0.7.0,<0.8.0"] keywords = ["cli", "terminal", "forms", "free-threading", "template", "elm"] classifiers = [ "Development Status :: 3 - Alpha", diff --git a/scripts/check_templates.py b/scripts/check_templates.py new file mode 100644 index 0000000..2e7c82d --- /dev/null +++ b/scripts/check_templates.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Compile every .kida template under milo's built-ins and examples. + +Uses ``milo.templates.get_env()`` so validation runs in terminal autoescape +mode with ``inline_components=True`` and ``validate_calls=True`` — the same +configuration that ships at runtime. This catches unknown filters, unknown +globals, arity mismatches, and syntax errors that the upstream +``kida check`` CLI misses because it only knows HTML-autoescape filters. + +Exit code 0 = clean, 1 = one or more templates failed to compile. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +BUILTIN = ROOT / "src" / "milo" / "templates" +EXAMPLES = ROOT / "examples" + + +def _iter_templates(root: Path) -> list[Path]: + return sorted(p for p in root.rglob("*.kida") if "__pycache__" not in p.parts) + + +def _check_root(root: Path, label: str) -> list[str]: + from kida import FileSystemLoader + + from milo.templates import get_env + + env = get_env(loader=FileSystemLoader(str(root))) + errors: list[str] = [] + for path in _iter_templates(root): + rel = path.relative_to(root).as_posix() + try: + env.get_template(rel) + except Exception as exc: + formatter = getattr(exc, "format_compact", None) + detail = formatter() if callable(formatter) else f"{type(exc).__name__}: {exc}" + errors.append(f"[{label}] {rel}\n{detail}") + return errors + + +def main() -> int: + all_errors: list[str] = [] + all_errors.extend(_check_root(BUILTIN, "builtin")) + + if EXAMPLES.exists(): + for example in sorted(EXAMPLES.iterdir()): + tmpl_dir = example / "templates" + if tmpl_dir.is_dir(): + all_errors.extend(_check_root(tmpl_dir, f"examples/{example.name}")) + + if all_errors: + for err in all_errors: + sys.stderr.write(err + "\n\n") + sys.stderr.write(f"FAIL: {len(all_errors)} template(s) failed to compile\n") + return 1 + + sys.stdout.write("OK: all templates compile cleanly\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/site/content/docs/usage/live.md b/site/content/docs/usage/live.md new file mode 100644 index 0000000..9c795b7 --- /dev/null +++ b/site/content/docs/usage/live.md @@ -0,0 +1,106 @@ +--- +title: Live Rendering +nav_title: Live +description: In-place terminal updates via milo.live for scripts and one-shot commands. +weight: 61 +draft: false +lang: en +tags: [live, rendering, spinner, streaming, kida] +keywords: [live, spinner, liverenderer, stream_to_terminal, milo.live] +category: usage +icon: activity +--- + +`milo.live` re-exports Kida's terminal live-rendering primitives for use +outside a full [`App`](state.md) event loop. Reach for it when you have a +straight-line script, a one-shot CLI command, or a background subroutine +that wants in-place updates without a reducer. + +```python +from milo.live import LiveRenderer, Spinner, stream_to_terminal, terminal_env +``` + +## When to use which + +| Situation | Use | +| ----------------------------------------------------- | ---------------------------- | +| Keyboard input, persistent state, multi-screen flow | `App` + `TickCmd` | +| One-shot progress for a script or command | `LiveRenderer` (context mgr) | +| Emit a template in chunks as it renders | `stream_to_terminal` | +| Just a spinner frame tuple | `Spinner.BRAILLE` / `.DOTS` | + +The App harness owns the render loop, message filter, view state, and cursor +lifecycle. `milo.live` hands you raw primitives — simpler, but you write the +loop. + +## `LiveRenderer` + +Context manager that overwrites its previous output on each `update()`. +Falls back to log-style appends when stdout is not a TTY. + +```python +from milo.live import LiveRenderer, terminal_env + +env = terminal_env() +tpl = env.from_string("{{ spinner() }} {{ label }}", name="live") + +with LiveRenderer(tpl, refresh_rate=0.08) as live: + live.start_auto(label="Working") + do_slow_thing() + live.update(label="Finalizing") + finalize() +``` + +`LiveRenderer` auto-injects a `spinner` context variable — call it in the +template (`{{ spinner() }}`) to emit and advance a frame on each render. + +`start_auto()` / `stop_auto()` run a background refresh thread so animations +keep ticking between explicit `update()` calls. + +See `examples/liverender/app.py` for a runnable version. + +## `Spinner` + +Animated spinner with four built-in frame sets: + +- `Spinner.BRAILLE` — ten-frame Braille dots (also aliased `DOTS`) +- `Spinner.LINE` — four-frame ASCII (`- \ | /`) +- `Spinner.ARROW` — eight-frame directional arrow + +Use the class attributes when you just need the frame tuple (for example, +feeding `TickCmd` animation inside an `App`): + +```python +# examples/spinner/app.py +from milo.live import Spinner + +SPINNER = Spinner.BRAILLE # ('⠋', '⠙', '⠹', ...) +``` + +Instantiate `Spinner(frames)` only when you want a stateful, advancing +spinner outside a `LiveRenderer` (which already provides one). + +## `stream_to_terminal` + +Render a template in chunks separated by `{% flush %}` boundaries, with a +configurable delay between chunks: + +```python +from milo.live import stream_to_terminal, terminal_env + +env = terminal_env() +tpl = env.from_string( + "Starting...\n{% flush %}Step 1 done.\n{% flush %}Step 2 done.\n", + name="stream", +) +stream_to_terminal(tpl, delay=0.3) +``` + +Milo's built-in pipeline defs already place `{% flush %}` boundaries between +phases, so passing a pipeline template here will stream one phase at a time. + +## `terminal_env` + +Returns a pre-configured Kida `Environment` with terminal autoescape — the +same autoescape milo uses internally. Prefer this over `milo.templates.get_env` +for one-off live rendering that doesn't need milo's component loader chain. diff --git a/src/milo/cli.py b/src/milo/cli.py index 9be3843..459528c 100644 --- a/src/milo/cli.py +++ b/src/milo/cli.py @@ -75,6 +75,14 @@ def _cmd_new(args: argparse.Namespace) -> None: ) +def _cmd_components(args: argparse.Namespace) -> None: + """List bundled and user-defined template components.""" + from milo.components_cli import run + + paths = tuple(Path(p) for p in args.path or ()) + sys.exit(run(paths=paths, as_json=args.json)) + + def _cmd_replay(args: argparse.Namespace) -> None: """Replay a recorded session.""" from milo.testing._record import load_recording @@ -171,6 +179,21 @@ def main(argv: list[str] | None = None) -> None: "--poll", type=float, default=0.5, help="Poll interval in seconds (default: 0.5)" ) + # milo components + components_parser = subparsers.add_parser( + "components", help="List bundled + user template components (defs)" + ) + components_parser.add_argument( + "--path", + "-p", + action="append", + default=[], + help="Extra templates dir to scan (repeatable)", + ) + components_parser.add_argument( + "--json", action="store_true", help="Emit full def metadata as JSON" + ) + # milo replay replay_parser = subparsers.add_parser("replay", help="Replay a recorded session") replay_parser.add_argument("session", help="Path to session JSONL file") @@ -199,6 +222,8 @@ def main(argv: list[str] | None = None) -> None: _cmd_verify(args) elif args.command == "dev": _cmd_dev(args) + elif args.command == "components": + _cmd_components(args) elif args.command == "replay": _cmd_replay(args) else: diff --git a/src/milo/components_cli.py b/src/milo/components_cli.py new file mode 100644 index 0000000..3d55948 --- /dev/null +++ b/src/milo/components_cli.py @@ -0,0 +1,111 @@ +"""`milo components` — discover bundled and user-defined template components. + +Walks the bundled ``src/milo/templates/components/`` tree (and any extra path +the caller supplies) and lists the ``{% def %}`` macros each template exposes. +Backed by Kida's ``Template.def_metadata()`` introspection. +""" + +from __future__ import annotations + +import json +import sys +from dataclasses import asdict, is_dataclass +from pathlib import Path +from typing import Any + +from kida import FileSystemLoader + +_BUNDLED_ROOT = Path(__file__).parent / "templates" + + +def _collect_defs(roots: tuple[Path, ...]) -> list[dict[str, Any]]: + """Walk *roots* and return one row per (template_name, def_name) pair.""" + from milo.templates import get_env + + seen: set[tuple[str, str]] = set() + rows: list[dict[str, Any]] = [] + for root in roots: + if not root.is_dir(): + continue + env = get_env(loader=FileSystemLoader(str(root))) + for path in sorted(root.rglob("*.kida")): + rel = path.relative_to(root).as_posix() + try: + tpl = env.get_template(rel) + except Exception as exc: + rows.append({"template": rel, "error": f"{type(exc).__name__}: {exc}"}) + continue + for name, meta in tpl.def_metadata().items(): + key = (rel, name) + if key in seen: + continue + seen.add(key) + rows.append(_metadata_row(rel, name, meta, root)) + return rows + + +def _metadata_row(template: str, name: str, meta: Any, root: Path) -> dict[str, Any]: + params = [ + { + "name": p.name, + "annotation": p.annotation, + "required": p.is_required, + "has_default": p.has_default, + } + for p in getattr(meta, "params", ()) + ] + return { + "template": template, + "root": str(root), + "name": name, + "lineno": getattr(meta, "lineno", None), + "params": params, + "slots": list(getattr(meta, "slots", ())), + "has_default_slot": getattr(meta, "has_default_slot", False), + "depends_on": sorted(getattr(meta, "depends_on", frozenset()) or ()), + } + + +def _format_plain(rows: list[dict[str, Any]]) -> str: + if not rows: + return "(no components found)\n" + by_template: dict[str, list[dict[str, Any]]] = {} + for row in rows: + by_template.setdefault(row["template"], []).append(row) + + lines: list[str] = [] + for template in sorted(by_template): + lines.append(template) + for row in by_template[template]: + if "error" in row: + lines.append(f" ! {row['error']}") + continue + params = ", ".join(_format_param(p) for p in row["params"]) + lines.append(f" {row['name']}({params})") + lines.append("") + return "\n".join(lines).rstrip() + "\n" + + +def _format_param(param: dict[str, Any]) -> str: + suffix = "" if param["required"] else "?" + return f"{param['name']}{suffix}" + + +def _to_jsonable(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + for row in rows: + clean = {k: (asdict(v) if is_dataclass(v) else v) for k, v in row.items()} + out.append(clean) + return out + + +def run(*, paths: tuple[Path, ...] = (), as_json: bool = False) -> int: + """Entry point used by ``milo components``. Returns exit code.""" + roots = (_BUNDLED_ROOT, *paths) + rows = _collect_defs(roots) + if as_json: + json.dump(_to_jsonable(rows), sys.stdout, indent=2, default=str) + sys.stdout.write("\n") + else: + sys.stdout.write(_format_plain(rows)) + return 0 diff --git a/src/milo/gateway.py b/src/milo/gateway.py index 51ffeca..a3f932f 100644 --- a/src/milo/gateway.py +++ b/src/milo/gateway.py @@ -270,7 +270,9 @@ def _discover_all( if not valid_children: return GatewayState([], {}, [], {}, [], {}) - max_workers = min(8, len(valid_children)) + from kida import WorkloadType, get_optimal_workers + + max_workers = get_optimal_workers(len(valid_children), workload_type=WorkloadType.IO_BOUND) results: dict[str, tuple[str, list, list, list]] = {} with ThreadPoolExecutor(max_workers=max_workers) as pool: diff --git a/src/milo/live.py b/src/milo/live.py new file mode 100644 index 0000000..0beb9ae --- /dev/null +++ b/src/milo/live.py @@ -0,0 +1,15 @@ +"""Live-rendering primitives for non-event-loop use. + +Re-exports from :mod:`kida.terminal`. Reach for these when you need a small +animated or streamed view *outside* a full :class:`milo.App` event loop — a +background job, a one-shot CLI command, or a script. When you need a +reducer-driven app, use :class:`milo.App` with :class:`milo.TickCmd` instead. + + from milo.live import LiveRenderer, Spinner, stream_to_terminal +""" + +from __future__ import annotations + +from kida.terminal import LiveRenderer, Spinner, stream_to_terminal, terminal_env + +__all__ = ["LiveRenderer", "Spinner", "stream_to_terminal", "terminal_env"] diff --git a/src/milo/registry.py b/src/milo/registry.py index 83e341b..a192d1b 100644 --- a/src/milo/registry.py +++ b/src/milo/registry.py @@ -202,7 +202,9 @@ def check_all(clis: dict[str, dict[str, Any]] | None = None) -> list[HealthResul if not clis: return [] - max_workers = min(8, len(clis)) + from kida import WorkloadType, get_optimal_workers + + max_workers = get_optimal_workers(len(clis), workload_type=WorkloadType.IO_BOUND) results: dict[str, HealthResult] = {} with ThreadPoolExecutor(max_workers=max_workers) as pool: futures = { diff --git a/src/milo/state.py b/src/milo/state.py index 26d1e03..90ab7aa 100644 --- a/src/milo/state.py +++ b/src/milo/state.py @@ -383,6 +383,13 @@ class Store: Thread-safety: reads are lock-free (frozen state). Dispatch serializes through a lock. Sagas run on a ThreadPoolExecutor. + + ``max_workers`` defaults to ``None``, which auto-sizes via + ``kida.get_optimal_workers`` under the IO_BOUND profile — returning the + OS-aware pool size (capped at 8). Sagas execute side-effect code (Call, + Take, Retry) which is I/O-shaped, not CPU-bound rendering. Pass an + explicit integer to override when you know the workload (e.g. + ``max_workers=N`` for benchmarks that fire N concurrent blocking sagas). """ def __init__( @@ -392,7 +399,7 @@ def __init__( middleware: tuple[Callable, ...] = (), *, record: bool | str | Path = False, - max_workers: int = 4, + max_workers: int | None = None, on_pool_pressure: Callable[[int, int], None] | None = None, pool_pressure_threshold: float = 0.8, ) -> None: @@ -400,6 +407,14 @@ def __init__( self._state = initial_state self._lock = threading.Lock() self._listeners: list[Callable] = [] + if max_workers is None: + import os + + from kida import WorkloadType, get_optimal_workers + + max_workers = get_optimal_workers( + os.cpu_count() or 4, workload_type=WorkloadType.IO_BOUND + ) self._max_workers = max_workers self._executor = ThreadPoolExecutor(max_workers=max_workers) self._on_pool_pressure = on_pool_pressure diff --git a/src/milo/templates/__init__.py b/src/milo/templates/__init__.py index e80afad..4834a54 100644 --- a/src/milo/templates/__init__.py +++ b/src/milo/templates/__init__.py @@ -9,7 +9,12 @@ _default_env: Any = None -def get_env(*, theme: dict | None = None, **kwargs: Any) -> Any: +def get_env( + *, + theme: dict | None = None, + enable_capture: bool = False, + **kwargs: Any, +) -> Any: """Create a kida Environment with the built-in template loader. Returns a kida Environment with a chained loader: @@ -20,12 +25,19 @@ def get_env(*, theme: dict | None = None, **kwargs: Any) -> Any: When *autoescape* is ``"terminal"`` (the default), a ``style`` filter and ``theme`` global are registered automatically. Pass a custom dict to override the default palette. + enable_capture: Forward to kida as ``enable_capture``. When ``True``, + renders made under :func:`kida.captured_render` populate a + :class:`~kida.RenderCapture` exposing per-block output, content + keys, and changed-from data — the building blocks for + :class:`~kida.FreezeCache` / :class:`~kida.RenderManifest` static + site flows. Off by default to keep compile cost identical to + kida's own default. **kwargs: Forwarded to ``kida.Environment``. """ global _default_env # Return cached singleton when called with default args - is_default = theme is None and not kwargs + is_default = theme is None and not enable_capture and not kwargs if is_default and _default_env is not None: return _default_env @@ -50,6 +62,9 @@ def get_env(*, theme: dict | None = None, **kwargs: Any) -> Any: loader = ChoiceLoader(loaders) kwargs.setdefault("autoescape", "terminal") + kwargs.setdefault("inline_components", True) + kwargs.setdefault("validate_calls", True) + kwargs["enable_capture"] = enable_capture env = Environment(loader=loader, **kwargs) # Register theme system when in terminal mode diff --git a/src/milo/templates/components/_defs.kida b/src/milo/templates/components/_defs.kida index 0850902..315c662 100644 --- a/src/milo/templates/components/_defs.kida +++ b/src/milo/templates/components/_defs.kida @@ -256,7 +256,9 @@ {% endmatch -%} {%- if state.expanded and is_selected %} {{ phase_detail(phase, state.log_scroll, state.log_height, state.auto_follow) }} +{% flush %} {% endif -%} +{% flush %} {%- endfor -%} {%- match ps.status -%} {%- case "running" -%} @@ -271,6 +273,7 @@ {{ "pipeline failed" | red | bold }} {%- endmatch %} +{% flush %} {%- if state.expanded -%} {{ key_hints([{"key": "↑↓", "action": "scroll"}, {"key": "⏎", "action": "collapse"}, {"key": "f", "action": "follow"}, {"key": "q", "action": "quit"}]) }} @@ -292,6 +295,7 @@ {% case _ -%} {{ icons.dot | dim }} {{ phase.name | dim }} {% endmatch -%} +{% flush %} {%- endfor -%} {%- match state.status -%} {%- case "running" -%} @@ -306,4 +310,5 @@ {{ "pipeline failed" | red | bold }} {%- endmatch -%} +{% flush %} {%- enddef -%} diff --git a/src/milo/templates/progress.kida b/src/milo/templates/progress.kida index c6ceff0..7d4f314 100644 --- a/src/milo/templates/progress.kida +++ b/src/milo/templates/progress.kida @@ -1 +1,3 @@ -{{ state.label | default("Progress") | bold }}: {{ bar(state.progress, width=20) | green }} {{ (state.progress * 100) | int }}% +{%- set pct = [[0, (state.progress * 100) | int] | max, 100] | min -%} +{%- set filled = [[0, pct // 5] | max, 20] | min -%} +{{ state.label | default("Progress") | bold }}: {{ ("█" * filled ~ "░" * (20 - filled)) | green }} {{ pct }}% diff --git a/tests/test_cli.py b/tests/test_cli.py index a3b6de0..9f395cc 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -37,6 +37,46 @@ def test_replay_missing_session(self): main(["replay"]) +class TestComponentsCommand: + """`milo components` lists bundled defs and supports --json/--path.""" + + def test_lists_bundled_defs(self, capsys): + with pytest.raises(SystemExit) as exc_info: + main(["components"]) + assert exc_info.value.code == 0 + out = capsys.readouterr().out + assert "components/_defs.kida" in out + for name in ("section", "status_line", "kv_pair", "pipeline_progress", "header"): + assert name in out + + def test_json_output_is_valid_and_includes_metadata(self, capsys): + with pytest.raises(SystemExit) as exc_info: + main(["components", "--json"]) + assert exc_info.value.code == 0 + out = capsys.readouterr().out + data = json.loads(out) + assert isinstance(data, list) + assert data, "expected at least one component" + names = {row["name"] for row in data if "name" in row} + assert {"section", "pipeline_progress"}.issubset(names) + first = next(r for r in data if r.get("name") == "pipeline_progress") + assert first["params"][0]["name"] == "state" + assert first["params"][0]["required"] is True + + def test_extra_path_dedupes_by_template_def_name(self, capsys, tmp_path): + user_dir = tmp_path / "templates" + user_dir.mkdir() + (user_dir / "myapp.kida").write_text("{% def banner(title) %}== {{ title }}{% enddef %}") + with pytest.raises(SystemExit) as exc_info: + main(["components", "--path", str(user_dir)]) + assert exc_info.value.code == 0 + out = capsys.readouterr().out + assert "myapp.kida" in out + assert "banner" in out + # bundled defs still listed exactly once + assert out.count("section(") == 1 + + class TestPythonPreflight: """main() exits 2 with an actionable message on pre-3.14 Pythons.""" diff --git a/tests/test_components.py b/tests/test_components.py index ed1afde..d85721f 100644 --- a/tests/test_components.py +++ b/tests/test_components.py @@ -396,6 +396,37 @@ def test_auto_follow_indicator(self, env): assert "AUTO" in out +class TestPipelineFlushBoundaries: + """Pipeline defs declare {% flush %} markers where streaming consumers + should see incremental output. Interpolated calls (``{{ def(...) }}``) + don't propagate flushes today; the markers document the streaming contract + and become load-bearing whenever a caller consumes the def as a stream.""" + + @pytest.fixture + def defs_source(self) -> str: + from pathlib import Path + + from milo import templates as _templates + + return (Path(_templates.__file__).parent / "components" / "_defs.kida").read_text() + + def _def_body(self, source: str, name: str) -> str: + start_marker = f"{{%- def {name}" + start = source.index(start_marker) + end = source.index("{%- enddef -%}", start) + return source[start:end] + + def test_pipeline_progress_flush_boundaries(self, defs_source: str) -> None: + body = self._def_body(defs_source, "pipeline_progress") + # One per-phase flush inside the for loop, one after overall-status match. + assert body.count("{% flush %}") == 2 + + def test_pipeline_detail_flush_boundaries(self, defs_source: str) -> None: + body = self._def_body(defs_source, "pipeline_detail") + # Per-phase + expanded-detail pane + overall-status = 3 boundaries. + assert body.count("{% flush %}") == 3 + + class TestPipelineDetail: def _render_detail(self, env, state): prefix = '{% from "components/_defs.kida" import pipeline_detail %}' diff --git a/tests/test_effects.py b/tests/test_effects.py index d8c3622..b4d8620 100644 --- a/tests/test_effects.py +++ b/tests/test_effects.py @@ -1325,7 +1325,8 @@ def parent(): def reducer(state, action): return state or 0 - store = Store(reducer, None) + # Use extra workers to prevent pool starvation when Race+All nest + store = Store(reducer, None, max_workers=8) store.run_saga(parent()) time.sleep(1.0) store._executor.shutdown(wait=True) @@ -1441,7 +1442,8 @@ def dispatcher(): def reducer(state, action): return state or 0 - store = Store(reducer, None) + # Use extra workers to prevent pool starvation when Fork+All+Take nest + store = Store(reducer, None, max_workers=8) store.run_saga(parent()) time.sleep(1.0) store._executor.shutdown(wait=True) @@ -1702,14 +1704,20 @@ def reducer(state, action): store._executor.shutdown(wait=True) def test_default_max_workers(self): - """Default max_workers is 4.""" + """Default max_workers is auto-sized by kida's IO_BOUND profile.""" + import os + + from kida import WorkloadType, get_optimal_workers + from milo.state import Store def reducer(state, action): return state or 0 + expected = get_optimal_workers(os.cpu_count() or 4, workload_type=WorkloadType.IO_BOUND) store = Store(reducer, None) - assert store._max_workers == 4 + assert store._max_workers == expected + assert store._executor._max_workers == expected store._executor.shutdown(wait=True) def test_pool_pressure_callback_fires(self): diff --git a/tests/test_gateway.py b/tests/test_gateway.py index 445a68b..ef9c24a 100644 --- a/tests/test_gateway.py +++ b/tests/test_gateway.py @@ -183,6 +183,37 @@ def test_discovery_order_deterministic(self): assert names == ["aaa.x", "zzz.y"] +# --------------------------------------------------------------------------- +# Worker sizing +# --------------------------------------------------------------------------- + + +class TestWorkerSizing: + def test_discover_all_uses_io_bound_profile(self): + """_discover_all sizes the pool via kida's IO_BOUND profile.""" + import kida + + clis = {f"cli{i}": {"command": ["x"]} for i in range(3)} + children = { + name: _make_child(name, tools=[{"name": "t", "inputSchema": {}}]) for name in clis + } + + calls: list[tuple[int, Any]] = [] + real = kida.get_optimal_workers + + def spy(task_count, *, workload_type, **kw): + calls.append((task_count, workload_type)) + return real(task_count, workload_type=workload_type, **kw) + + with patch("kida.get_optimal_workers", side_effect=spy): + _discover_all(clis, children) + + assert calls, "get_optimal_workers was not invoked" + task_count, workload = calls[0] + assert task_count == 3 + assert workload == kida.WorkloadType.IO_BOUND + + # --------------------------------------------------------------------------- # Tool call proxying # --------------------------------------------------------------------------- diff --git a/tests/test_registry_v2.py b/tests/test_registry_v2.py index 6a6d6ae..4817b54 100644 --- a/tests/test_registry_v2.py +++ b/tests/test_registry_v2.py @@ -7,7 +7,7 @@ import pytest -from milo.registry import HealthResult, doctor, fingerprint, health_check +from milo.registry import HealthResult, check_all, doctor, fingerprint, health_check class TestHealthResult: @@ -105,6 +105,34 @@ def test_v1_entry_works(self, mock_load) -> None: assert result.stale is False # no fingerprint to compare +class TestWorkerSizing: + def test_check_all_uses_io_bound_profile(self) -> None: + """check_all sizes the pool via kida's IO_BOUND profile.""" + import kida + + clis = {f"cli{i}": {"command": ["x"]} for i in range(3)} + calls: list[tuple[int, object]] = [] + real = kida.get_optimal_workers + + def spy(task_count, *, workload_type, **kw): + calls.append((task_count, workload_type)) + return real(task_count, workload_type=workload_type, **kw) + + with ( + patch("kida.get_optimal_workers", side_effect=spy), + patch( + "milo.registry._health_check_entry", + return_value=HealthResult(name="x", reachable=True, latency_ms=1.0), + ), + ): + check_all(clis) + + assert calls, "get_optimal_workers was not invoked" + task_count, workload = calls[0] + assert task_count == 3 + assert workload == kida.WorkloadType.IO_BOUND + + class TestDoctor: @patch("milo.registry.list_clis") def test_no_clis(self, mock_list) -> None: diff --git a/tests/test_templates.py b/tests/test_templates.py index f22a952..012f132 100644 --- a/tests/test_templates.py +++ b/tests/test_templates.py @@ -91,6 +91,28 @@ def test_builtin_templates_render(self): output = tmpl.render(state=state) assert "testprog" in output + def test_enable_capture_default_off(self): + """Default get_env() leaves enable_capture off (kida's default).""" + from milo.templates import get_env + + env = get_env() + assert env.enable_capture is False + + def test_enable_capture_opt_in_smoke(self): + """get_env(enable_capture=True) returns env where captured_render works.""" + from kida import captured_render + + from milo.templates import get_env + + env = get_env(enable_capture=True) + assert env.enable_capture is True + tpl = env.from_string("hello {{ name }}", name="cap_smoke") + with captured_render(capture_context=frozenset({"name"})) as cap: + out = tpl.render(name="world") + assert out == "hello world" + # capture_context populated under enable_capture=True + assert cap.context_keys.get("name") == "world" + class TestComponentTemplatesIncluded: def test_components_directory_exists(self): diff --git a/uv.lock b/uv.lock index c06ceda..f71f8e1 100644 --- a/uv.lock +++ b/uv.lock @@ -389,11 +389,11 @@ sdist = { url = "https://files.pythonhosted.org/packages/5e/73/e01e4c5e11ad0494f [[package]] name = "kida-templates" -version = "0.6.0" +version = "0.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bf/3a/07c7cdd1ff8a96ca277663cb5358a3104ee115a0b5a98a5540a82111a31c/kida_templates-0.6.0.tar.gz", hash = "sha256:d01b38442cd0d2f10dfaa0215f6aeb474577596081486b23b482309bf8d68c3f", size = 529973, upload-time = "2026-04-13T22:32:35.335Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/d0/261198d9c8667272ee87c2012fa42fea6628053e34c1ec7bf66ed147ece8/kida_templates-0.7.0.tar.gz", hash = "sha256:81fcbc167f9754e0fc503d46ea50b4e558f0da1c468bcf28b57f670152856f65", size = 570668, upload-time = "2026-04-20T21:40:22.864Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/07/4d08e9333f009cf578e557f28dfa22a190af89c4c4ac9934848528c66309/kida_templates-0.6.0-py3-none-any.whl", hash = "sha256:e5da2e0531c9256e0db19b6c7afc4b9220eb426677f9b4ed0ac12ee59a56b5f3", size = 384796, upload-time = "2026-04-13T22:32:33.668Z" }, + { url = "https://files.pythonhosted.org/packages/b6/fb/52359cf08ed6547f03be43958af2ebfd5df2bb65cda08e68dd3e6ab6f27b/kida_templates-0.7.0-py3-none-any.whl", hash = "sha256:b7b7e1061dbf21ba5ba296efcba40f003576c4428006ff4531fa5861e34dbc60", size = 400534, upload-time = "2026-04-20T21:40:20.927Z" }, ] [[package]] @@ -515,7 +515,7 @@ docs = [ [package.metadata] requires-dist = [ { name = "bengal", marker = "extra == 'docs'", specifier = ">=0.2.6" }, - { name = "kida-templates", specifier = ">=0.6.0" }, + { name = "kida-templates", specifier = ">=0.7.0,<0.8.0" }, { name = "pyyaml", marker = "extra == 'yaml'", specifier = ">=6.0" }, { name = "watchfiles", marker = "extra == 'watch'", specifier = ">=1.0" }, ]