diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 00cee7e..7cb19e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -113,17 +113,21 @@ jobs: with: python-version: "3.12" - - name: Build sdist + abi3 wheel + - name: Build sdist + abi3 wheel (CLI binary bundled) run: | pip install "maturin>=1.4,<2" + ./scripts/build-cli.sh # compiles emry -> python/emry/_bin/ maturin build --release --out dist maturin sdist --out dist - - name: Verify the wheel installs and imports + - name: Verify the wheel installs, imports, and ships the CLI run: | python -m venv /tmp/wheeltest /tmp/wheeltest/bin/pip install --no-index --find-links dist emry /tmp/wheeltest/bin/python -c "import emry, emry._native; print(emry.__version__)" + # The `emry` console script must be present and runnable from the wheel. + /tmp/wheeltest/bin/emry --version + /tmp/wheeltest/bin/python -m emry runs --help >/dev/null - uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5d981cf..0548746 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,13 +24,19 @@ jobs: # maturin-action builds inside a manylinux container so the abi3 wheel is # PyPI-acceptable (a bare `maturin build` would emit a linux_x86_64 wheel - # that PyPI rejects). - - name: Build wheel + # that PyPI rejects). before-script-linux runs IN the container so the + # bundled `emry` binary links against the same (old) glibc as the wheel. + # manylinux_2_28 has a modern-enough toolchain to build rustls/aws-lc-rs + # (emry-cli -> emry-web); cmake + perl are needed for the aws-lc build. + - name: Build wheel (CLI binary bundled) uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1 with: command: build args: --release --out dist - manylinux: auto + manylinux: "2_28" + before-script-linux: | + dnf install -y cmake perl || yum install -y cmake perl + bash scripts/build-cli.sh - name: Build sdist uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1 diff --git a/.gitignore b/.gitignore index 3ac471f..5e95da3 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,6 @@ logs/ # Compiled native extension (built locally via maturin) python/emry/_native*.so + +# Bundled CLI binary (compiled at wheel-build time, not source) +python/emry/_bin/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d18a54..2b5b19f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,22 @@ All notable changes to Emry are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/), and Emry adheres to [Semantic Versioning](https://semver.org/). +## [0.2.1] - 2026-07-19 + +### Fixed + +- **`pip install emry` now ships the `emry` CLI.** The 0.2.0 wheel contained only + the Python SDK + native extension, so the documented `emry watch/web/compare/ + export` commands weren't runnable and the SDK's live dashboard (which launches + `emry`) silently degraded. The compiled CLI is now bundled in the wheel and + exposed as the `emry` console script (and `python -m emry`). The SDK also + resolves the bundled binary directly, so the live dashboard works even when the + interpreter's `bin/` isn't on `PATH`. + +### Added + +- `emry.run(name=...)` is accepted as an alias for the positional run name. + ## [0.2.0] - 2026-07-07 A big batch of backward-compatible features. Upgrading from 0.1.0 requires no @@ -48,5 +64,6 @@ code changes — everything below is additive, and existing defaults are unchang - Minimum supported Rust version raised to 1.88 (ratatui 0.30); this affects building from source only, not installing the wheel. +[0.2.1]: https://github.com/femboyisp/emry/releases/tag/v0.2.1 [0.2.0]: https://github.com/femboyisp/emry/releases/tag/v0.2.0 [0.1.0]: https://github.com/femboyisp/emry/releases/tag/v0.1.0 diff --git a/Cargo.lock b/Cargo.lock index e639a2f..4b1dbb2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -809,7 +809,7 @@ checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] name = "emry-cli" -version = "0.2.0" +version = "0.2.1" dependencies = [ "clap", "crossbeam-channel", @@ -824,7 +824,7 @@ dependencies = [ [[package]] name = "emry-core" -version = "0.2.0" +version = "0.2.1" dependencies = [ "criterion", "crossbeam-channel", @@ -838,7 +838,7 @@ dependencies = [ [[package]] name = "emry-engine" -version = "0.2.0" +version = "0.2.1" dependencies = [ "criterion", "crossbeam-channel", @@ -850,7 +850,7 @@ dependencies = [ [[package]] name = "emry-ingest" -version = "0.2.0" +version = "0.2.1" dependencies = [ "emry-core", "rmp-serde", @@ -861,7 +861,7 @@ dependencies = [ [[package]] name = "emry-py" -version = "0.2.0" +version = "0.2.1" dependencies = [ "emry-core", "emry-engine", @@ -871,7 +871,7 @@ dependencies = [ [[package]] name = "emry-store" -version = "0.2.0" +version = "0.2.1" dependencies = [ "arrow-array", "arrow-schema", @@ -886,7 +886,7 @@ dependencies = [ [[package]] name = "emry-tui" -version = "0.2.0" +version = "0.2.1" dependencies = [ "crossbeam-channel", "emry-core", @@ -898,7 +898,7 @@ dependencies = [ [[package]] name = "emry-web" -version = "0.2.0" +version = "0.2.1" dependencies = [ "axum", "axum-server", diff --git a/Cargo.toml b/Cargo.toml index 41b9d1f..720cc50 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ members = [ ] [workspace.package] -version = "0.2.0" +version = "0.2.1" edition = "2021" license = "Apache-2.0" rust-version = "1.88" diff --git a/pyproject.toml b/pyproject.toml index e407612..ada9a3e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "emry" -version = "0.2.0" +version = "0.2.1" description = "Gentle observability for long training runs" readme = "README.md" requires-python = ">=3.10" @@ -19,6 +19,11 @@ classifiers = [ "Topic :: Scientific/Engineering :: Artificial Intelligence", ] +# The `emry` command is a thin Python shim (emry._cli) that execs the compiled +# Rust CLI bundled in the wheel under emry/_bin/. +[project.scripts] +emry = "emry._cli:main" + [project.optional-dependencies] dev = [ "pre-commit>=3.0.0", @@ -66,4 +71,7 @@ python-source = "python" features = ["extension-module"] # Bundle the license into the sdist so it sits at the path maturin records in # the metadata's `License-File: LICENSE` (PyPI rejects the upload otherwise). -include = ["LICENSE"] +# The `emry` CLI binary is compiled into python/emry/_bin/ before the wheel +# build (see the release workflow / scripts/build-cli.sh) and bundled — wheel +# only, since the sdist rebuilds it from the Rust sources. +include = ["LICENSE", { path = "python/emry/_bin/emry*", format = "wheel" }] diff --git a/python/emry/__init__.py b/python/emry/__init__.py index d103b97..9bcb1e2 100644 --- a/python/emry/__init__.py +++ b/python/emry/__init__.py @@ -5,6 +5,6 @@ from emry.phase import Phase from emry.run import Run, run -__version__ = "0.2.0" +__version__ = "0.2.1" __all__ = ["DeployMode", "Phase", "Run", "run", "to_wandb", "__version__"] diff --git a/python/emry/__main__.py b/python/emry/__main__.py new file mode 100644 index 0000000..2b356e3 --- /dev/null +++ b/python/emry/__main__.py @@ -0,0 +1,6 @@ +"""``python -m emry`` → the bundled ``emry`` CLI.""" + +from emry._cli import main + +if __name__ == "__main__": + main() diff --git a/python/emry/_cli.py b/python/emry/_cli.py new file mode 100644 index 0000000..09f7d4f --- /dev/null +++ b/python/emry/_cli.py @@ -0,0 +1,43 @@ +"""Locate and run the bundled ``emry`` Rust CLI binary. + +The wheel bundles the compiled ``emry`` executable under ``emry/_bin/``. This +module backs the ``emry`` console script (so ``pip install emry`` gives you the +``emry`` command) and also lets the SDK's live dashboard find the binary even +when the interpreter's ``bin/`` directory is not on ``PATH``. +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path +from typing import Optional + +__all__ = ["binary_path", "main"] + +_EXE = "emry.exe" if os.name == "nt" else "emry" + + +def binary_path() -> Optional[Path]: + """Path to the bundled ``emry`` binary, or ``None`` if it isn't present.""" + candidate = Path(__file__).resolve().parent / "_bin" / _EXE + return candidate if candidate.is_file() else None + + +def main() -> None: + """Console-script entry point: exec the bundled ``emry`` with our argv.""" + binary = binary_path() + if binary is None: + sys.exit( + "emry: the bundled CLI binary is missing from this install.\n" + "Reinstall with `pip install --force-reinstall emry`, or build it " + "from source with `cargo install --path crates/emry-cli`." + ) + argv = [str(binary), *sys.argv[1:]] + if os.name == "nt": + # Windows has no execv that replaces the process cleanly for a console + # app; run as a child and propagate the exit code. + import subprocess + + raise SystemExit(subprocess.run(argv).returncode) # noqa: S603 + os.execv(str(binary), argv) # replace this process with the CLI diff --git a/python/emry/live.py b/python/emry/live.py index 10aa3e9..4bf917e 100644 --- a/python/emry/live.py +++ b/python/emry/live.py @@ -58,10 +58,16 @@ def observer_command( """ if kind not in ("tui", "web"): return None + # Prefer the binary bundled in the wheel (so it works even when the venv's + # bin/ isn't on PATH); fall back to `emry` on PATH for source installs. + from emry._cli import binary_path + + binary = binary_path() + exe = str(binary) if binary is not None else "emry" if socket_path: - return ["emry", kind, "--socket", socket_path] + return [exe, kind, "--socket", socket_path] if run_dir is not None: - return ["emry", kind, "--run-dir", str(run_dir)] + return [exe, kind, "--run-dir", str(run_dir)] return None diff --git a/python/emry/run.py b/python/emry/run.py index 318114b..d71cc10 100644 --- a/python/emry/run.py +++ b/python/emry/run.py @@ -196,8 +196,9 @@ def _resolve_gpu(gpu: object) -> "Optional[GpuSampler]": def run( - project: str, + project: Optional[str] = None, *, + name: Optional[str] = None, config: Optional[Mapping[str, Any]] = None, metrics: Optional[Iterable[str]] = None, live: str = "auto", # noqa: ARG001 — observer spawning is EMRY-035 @@ -218,9 +219,14 @@ def run( when it's available, `True`/`False` force it on/off. `alert_webhook` (or the `EMRY_ALERT_WEBHOOK` env var) POSTs a Slack-compatible message when a metric goes non-finite. + + The run name is the first positional argument; `name=` is accepted as an + alias (pass exactly one). """ import os + project = _resolve_run_name(project, name) + if alert_webhook is None: alert_webhook = os.environ.get("EMRY_ALERT_WEBHOOK") @@ -242,6 +248,16 @@ def run( ) +def _resolve_run_name(project: Optional[str], name: Optional[str]) -> str: + """Reconciles the positional `project` and the `name=` alias into one value.""" + if project is not None and name is not None: + raise TypeError("run(): pass the run name positionally or as name=, not both") + resolved = project if project is not None else name + if resolved is None: + raise TypeError("run(): a run name is required (first positional argument, or name=)") + return resolved + + def _spawn_live(live: object, backend: Backend) -> None: """Launches the live dashboard observer(s) selected by `live`.""" import os diff --git a/python/tests/test_cli.py b/python/tests/test_cli.py new file mode 100644 index 0000000..6ebc92a --- /dev/null +++ b/python/tests/test_cli.py @@ -0,0 +1,40 @@ +"""Tests for the bundled-CLI shim (``emry._cli``).""" + +import os +import sys +from pathlib import Path + +import pytest + +from emry import _cli + + +def test_binary_path_present(monkeypatch, tmp_path): + (tmp_path / "_bin").mkdir() + exe = tmp_path / "_bin" / _cli._EXE + exe.write_text("#!/bin/sh\n") + monkeypatch.setattr(_cli, "__file__", str(tmp_path / "_cli.py")) + assert _cli.binary_path() == exe + + +def test_binary_path_absent(monkeypatch, tmp_path): + monkeypatch.setattr(_cli, "__file__", str(tmp_path / "_cli.py")) + assert _cli.binary_path() is None + + +def test_main_execs_bundled_binary(monkeypatch): + called = {} + monkeypatch.setattr(_cli, "binary_path", lambda: Path("/opt/emry/_bin/emry")) + monkeypatch.setattr(sys, "argv", ["emry", "runs", "--log-dir", "x"]) + monkeypatch.setattr(os, "execv", lambda path, argv: called.update(path=path, argv=argv)) + _cli.main() + assert called["path"] == "/opt/emry/_bin/emry" + assert called["argv"] == ["/opt/emry/_bin/emry", "runs", "--log-dir", "x"] + + +def test_main_errors_when_binary_missing(monkeypatch): + monkeypatch.setattr(_cli, "binary_path", lambda: None) + monkeypatch.setattr(sys, "argv", ["emry"]) + with pytest.raises(SystemExit) as exc: + _cli.main() + assert "bundled CLI binary is missing" in str(exc.value) diff --git a/python/tests/test_live.py b/python/tests/test_live.py index 545c23a..bf02fac 100644 --- a/python/tests/test_live.py +++ b/python/tests/test_live.py @@ -7,6 +7,20 @@ from emry import live, run +@pytest.fixture(autouse=True) +def _no_bundled_binary(monkeypatch): + """Default the observer resolver to the PATH `emry` (no wheel-bundled binary), + so tests are deterministic whether or not `emry/_bin/` is staged locally.""" + monkeypatch.setattr("emry._cli.binary_path", lambda: None) + + +def test_observer_command_prefers_bundled_binary(monkeypatch): + # When the wheel bundles the binary, its absolute path is used (not PATH). + monkeypatch.setattr("emry._cli.binary_path", lambda: Path("/opt/emry/_bin/emry")) + cmd = live.observer_command("tui", run_dir=Path("/logs/r"), socket_path=None) + assert cmd == ["/opt/emry/_bin/emry", "tui", "--run-dir", "/logs/r"] + + @pytest.mark.parametrize( "value,ssh,tty,force,expected", [ diff --git a/python/tests/test_version.py b/python/tests/test_version.py index c68fbc6..59630fa 100644 --- a/python/tests/test_version.py +++ b/python/tests/test_version.py @@ -7,4 +7,4 @@ def test_version_is_string() -> None: assert isinstance(emry.__version__, str) - assert emry.__version__ == "0.2.0" + assert emry.__version__ == "0.2.1" diff --git a/scripts/build-cli.sh b/scripts/build-cli.sh new file mode 100755 index 0000000..46d70f3 --- /dev/null +++ b/scripts/build-cli.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Compile the `emry` CLI and stage it where the wheel build bundles it +# (python/emry/_bin/). Run before `maturin build` so the binary lands in the +# wheel and `pip install emry` provides the `emry` command. +set -euo pipefail + +root="$(cd "$(dirname "$0")/.." && pwd)" +cd "$root" + +cargo build --release -p emry-cli +mkdir -p python/emry/_bin +cp target/release/emry python/emry/_bin/emry +echo "staged $(python/emry/_bin/emry --version 2>/dev/null || echo emry) at python/emry/_bin/emry"