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
8 changes: 6 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
12 changes: 9 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
16 changes: 8 additions & 8 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
12 changes: 10 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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",
Expand Down Expand Up @@ -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" }]
2 changes: 1 addition & 1 deletion python/emry/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__"]
6 changes: 6 additions & 0 deletions python/emry/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""``python -m emry`` → the bundled ``emry`` CLI."""

from emry._cli import main

if __name__ == "__main__":
main()
43 changes: 43 additions & 0 deletions python/emry/_cli.py
Original file line number Diff line number Diff line change
@@ -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
10 changes: 8 additions & 2 deletions python/emry/live.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
18 changes: 17 additions & 1 deletion python/emry/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")

Expand All @@ -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
Expand Down
40 changes: 40 additions & 0 deletions python/tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -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)
14 changes: 14 additions & 0 deletions python/tests/test_live.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
[
Expand Down
2 changes: 1 addition & 1 deletion python/tests/test_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
13 changes: 13 additions & 0 deletions scripts/build-cli.sh
Original file line number Diff line number Diff line change
@@ -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"
Loading