pandm 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. pandm-0.1.0/.github/workflows/ci.yml +31 -0
  2. pandm-0.1.0/.github/workflows/release.yml +68 -0
  3. pandm-0.1.0/.gitignore +16 -0
  4. pandm-0.1.0/PKG-INFO +103 -0
  5. pandm-0.1.0/README.md +87 -0
  6. pandm-0.1.0/docs/screenshot.png +0 -0
  7. pandm-0.1.0/examples/train_demo.py +70 -0
  8. pandm-0.1.0/justfile +33 -0
  9. pandm-0.1.0/pyproject.toml +34 -0
  10. pandm-0.1.0/pyrightconfig.json +4 -0
  11. pandm-0.1.0/src/pandm/__init__.py +6 -0
  12. pandm-0.1.0/src/pandm/cli.py +135 -0
  13. pandm-0.1.0/src/pandm/client.py +112 -0
  14. pandm-0.1.0/src/pandm/sdk.py +245 -0
  15. pandm-0.1.0/src/pandm/server/__init__.py +3 -0
  16. pandm-0.1.0/src/pandm/server/app.py +163 -0
  17. pandm-0.1.0/src/pandm/static/assets/index-C2Her4H1.js +53 -0
  18. pandm-0.1.0/src/pandm/static/assets/index-C_SZSafT.css +1 -0
  19. pandm-0.1.0/src/pandm/static/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  20. pandm-0.1.0/src/pandm/static/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  21. pandm-0.1.0/src/pandm/static/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  22. pandm-0.1.0/src/pandm/static/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  23. pandm-0.1.0/src/pandm/static/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  24. pandm-0.1.0/src/pandm/static/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  25. pandm-0.1.0/src/pandm/static/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  26. pandm-0.1.0/src/pandm/static/index.html +18 -0
  27. pandm-0.1.0/src/pandm/storage.py +302 -0
  28. pandm-0.1.0/tests/test_smoke.py +179 -0
  29. pandm-0.1.0/uv.lock +987 -0
  30. pandm-0.1.0/web/index.html +17 -0
  31. pandm-0.1.0/web/package.json +21 -0
  32. pandm-0.1.0/web/pnpm-lock.yaml +1698 -0
  33. pandm-0.1.0/web/src/App.vue +107 -0
  34. pandm-0.1.0/web/src/api.ts +56 -0
  35. pandm-0.1.0/web/src/colors.ts +26 -0
  36. pandm-0.1.0/web/src/components/Lightbox.vue +32 -0
  37. pandm-0.1.0/web/src/components/MediaGroup.vue +93 -0
  38. pandm-0.1.0/web/src/components/MediaPanel.vue +41 -0
  39. pandm-0.1.0/web/src/components/MetricChart.vue +163 -0
  40. pandm-0.1.0/web/src/components/MetricsPanel.vue +92 -0
  41. pandm-0.1.0/web/src/components/Sidebar.vue +82 -0
  42. pandm-0.1.0/web/src/components/TablePanel.vue +92 -0
  43. pandm-0.1.0/web/src/components/TopBar.vue +126 -0
  44. pandm-0.1.0/web/src/fmt.ts +61 -0
  45. pandm-0.1.0/web/src/main.ts +10 -0
  46. pandm-0.1.0/web/src/store.ts +0 -0
  47. pandm-0.1.0/web/src/styles/main.css +95 -0
  48. pandm-0.1.0/web/tsconfig.json +17 -0
  49. pandm-0.1.0/web/uno.config.ts +32 -0
  50. pandm-0.1.0/web/vite.config.ts +16 -0
@@ -0,0 +1,31 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v5
13
+
14
+ - uses: pnpm/action-setup@v4
15
+ with:
16
+ version: 10
17
+ - uses: actions/setup-node@v5
18
+ with:
19
+ node-version: 22
20
+ cache: pnpm
21
+ cache-dependency-path: web/pnpm-lock.yaml
22
+ - name: Build dashboard
23
+ run: pnpm install --frozen-lockfile && pnpm build
24
+ working-directory: web
25
+
26
+ - uses: astral-sh/setup-uv@v7
27
+ - run: uv sync
28
+ - name: Tests
29
+ run: uv run pytest tests -q
30
+ - name: Typecheck
31
+ run: uvx pyright
@@ -0,0 +1,68 @@
1
+ name: Release
2
+
3
+ # Publish to PyPI when a version tag is pushed:
4
+ # git tag v0.1.0 && git push origin v0.1.0
5
+ # Uses PyPI trusted publishing (OIDC) — no API token needed, but the
6
+ # publisher must be registered on pypi.org for this repo + workflow.
7
+
8
+ on:
9
+ push:
10
+ tags: ['v*']
11
+
12
+ jobs:
13
+ build:
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - uses: actions/checkout@v5
17
+
18
+ - uses: pnpm/action-setup@v4
19
+ with:
20
+ version: 10
21
+ - uses: actions/setup-node@v5
22
+ with:
23
+ node-version: 22
24
+ cache: pnpm
25
+ cache-dependency-path: web/pnpm-lock.yaml
26
+ - name: Build dashboard
27
+ run: pnpm install --frozen-lockfile && pnpm build
28
+ working-directory: web
29
+
30
+ - uses: astral-sh/setup-uv@v7
31
+
32
+ - name: Tag must match pyproject version
33
+ run: |
34
+ v="$(uv version --short)"
35
+ if [ "v$v" != "$GITHUB_REF_NAME" ]; then
36
+ echo "::error::tag $GITHUB_REF_NAME != pyproject version $v"
37
+ exit 1
38
+ fi
39
+
40
+ - name: Dashboard must be bundled
41
+ run: test -f src/pandm/static/index.html
42
+
43
+ - run: uv build
44
+
45
+ - name: Smoke-test wheel
46
+ run: |
47
+ uv run --no-project --isolated --with dist/*.whl python -c "import pandm; print(pandm.__version__)"
48
+ uv run --no-project --isolated --with dist/*.whl pandm version
49
+
50
+ - uses: actions/upload-artifact@v4
51
+ with:
52
+ name: dist
53
+ path: dist/
54
+
55
+ publish:
56
+ needs: build
57
+ runs-on: ubuntu-latest
58
+ environment:
59
+ name: pypi
60
+ url: https://pypi.org/p/pandm
61
+ permissions:
62
+ id-token: write # trusted publishing
63
+ steps:
64
+ - uses: actions/download-artifact@v4
65
+ with:
66
+ name: dist
67
+ path: dist/
68
+ - uses: pypa/gh-action-pypi-publish@release/v1
pandm-0.1.0/.gitignore ADDED
@@ -0,0 +1,16 @@
1
+ # python
2
+ __pycache__/
3
+ *.pyc
4
+ .venv/
5
+ dist/
6
+ *.egg-info/
7
+
8
+ # pandm data
9
+ .pandm/
10
+
11
+ # web
12
+ web/node_modules/
13
+ src/pandm/static/
14
+
15
+ # misc
16
+ .DS_Store
pandm-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,103 @@
1
+ Metadata-Version: 2.4
2
+ Name: pandm
3
+ Version: 0.1.0
4
+ Summary: Beautiful, local-first experiment tracking. A lightweight alternative to wandb / tensorboard.
5
+ Author-email: Jannchie <jannchie@gmail.com>
6
+ License: MIT
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: fastapi>=0.110
9
+ Requires-Dist: httpx>=0.27
10
+ Requires-Dist: pillow>=10
11
+ Requires-Dist: python-multipart>=0.0.9
12
+ Requires-Dist: rich>=13
13
+ Requires-Dist: typer>=0.12
14
+ Requires-Dist: uvicorn[standard]>=0.29
15
+ Description-Content-Type: text/markdown
16
+
17
+ # pandm
18
+
19
+ pandm tracks ML experiments locally. The Python SDK writes metrics and images straight to a `.pandm/` directory next to your code — no account, no daemon, no cloud — and `pandm ui` serves a dashboard to compare runs. Unlike wandb there is nothing to sign up for, and unlike tensorboard the data is plain SQLite + PNG files you can query yourself. The same scripts report to a shared server over HTTP when you set one env var.
20
+
21
+ ![dashboard](docs/screenshot.png)
22
+
23
+ ## Install
24
+
25
+ ```sh
26
+ pip install pandm
27
+ ```
28
+
29
+ ## Quick start
30
+
31
+ ```python
32
+ import pandm
33
+
34
+ run = pandm.init(project="mnist", config={"lr": 1e-3, "batch_size": 64})
35
+ for step in range(1000):
36
+ loss, acc = train_step()
37
+ run.log({"train/loss": loss, "train/acc": acc}, step=step)
38
+ if step % 100 == 0:
39
+ run.log_image("samples", sample_grid, step=step) # PIL / numpy / torch / path
40
+ run.finish()
41
+ ```
42
+
43
+ ```sh
44
+ pandm ui # opens http://127.0.0.1:7878
45
+ ```
46
+
47
+ The dashboard overlays selected runs per metric, with smoothing, log scale, step/time axes, an image browser with a step slider, and a config/summary comparison table. It polls while runs are alive, so curves grow during training.
48
+
49
+ ## Usage
50
+
51
+ `step` is optional (an internal counter is used). Runs end as `finished` or `crashed`: uncaught exceptions are detected via `sys.excepthook` (and the context manager), and hard-killed processes (`kill -9`, OOM) are presumed crashed once their 15s heartbeat goes quiet for 60s — self-healing if the process was merely suspended.
52
+
53
+ ```python
54
+ with pandm.init(project="mnist") as run:
55
+ run.log({"loss": 0.5})
56
+ ```
57
+
58
+ List or delete runs from the terminal:
59
+
60
+ ```sh
61
+ pandm ls
62
+ pandm delete <run_id>
63
+ ```
64
+
65
+ Data lives in `./.pandm` by default; override with `--dir` or `PANDM_DIR`.
66
+
67
+ ### Cloud mode
68
+
69
+ Run a server anywhere, then point training scripts at it — no code changes:
70
+
71
+ ```sh
72
+ pandm server --api-key my-secret # on the server (default port 7878)
73
+ ```
74
+
75
+ ```sh
76
+ export PANDM_REMOTE=http://my-host:7878
77
+ export PANDM_API_KEY=my-secret
78
+ python train.py # same script, now reports over HTTP
79
+ ```
80
+
81
+ The API key protects write endpoints only; put the server behind a reverse proxy if reads need auth too. If the server becomes unreachable mid-run, the SDK warns and keeps training: it retries every 30s, replays run creation on recovery, and drops whatever was logged while offline.
82
+
83
+ ## API
84
+
85
+ | | |
86
+ |---|---|
87
+ | `pandm.init(project, name=None, config=None, *, directory=None, remote=None, api_key=None)` | start a run |
88
+ | `run.log(metrics, step=None)` | log scalar metrics |
89
+ | `run.log_image(key, image, step=None, caption=None)` | log an image |
90
+ | `run.finish(status="finished")` | end the run (also via `atexit`) |
91
+ | `GET /api/docs` | REST API reference on any running server |
92
+
93
+ ## Development
94
+
95
+ ```sh
96
+ uv sync && uv run pytest # python sdk + server
97
+ cd web && pnpm install && pnpm dev # dashboard dev server (proxies to :7878)
98
+ pnpm build # bundles the dashboard into src/pandm/static
99
+ ```
100
+
101
+ ## License
102
+
103
+ MIT
pandm-0.1.0/README.md ADDED
@@ -0,0 +1,87 @@
1
+ # pandm
2
+
3
+ pandm tracks ML experiments locally. The Python SDK writes metrics and images straight to a `.pandm/` directory next to your code — no account, no daemon, no cloud — and `pandm ui` serves a dashboard to compare runs. Unlike wandb there is nothing to sign up for, and unlike tensorboard the data is plain SQLite + PNG files you can query yourself. The same scripts report to a shared server over HTTP when you set one env var.
4
+
5
+ ![dashboard](docs/screenshot.png)
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ pip install pandm
11
+ ```
12
+
13
+ ## Quick start
14
+
15
+ ```python
16
+ import pandm
17
+
18
+ run = pandm.init(project="mnist", config={"lr": 1e-3, "batch_size": 64})
19
+ for step in range(1000):
20
+ loss, acc = train_step()
21
+ run.log({"train/loss": loss, "train/acc": acc}, step=step)
22
+ if step % 100 == 0:
23
+ run.log_image("samples", sample_grid, step=step) # PIL / numpy / torch / path
24
+ run.finish()
25
+ ```
26
+
27
+ ```sh
28
+ pandm ui # opens http://127.0.0.1:7878
29
+ ```
30
+
31
+ The dashboard overlays selected runs per metric, with smoothing, log scale, step/time axes, an image browser with a step slider, and a config/summary comparison table. It polls while runs are alive, so curves grow during training.
32
+
33
+ ## Usage
34
+
35
+ `step` is optional (an internal counter is used). Runs end as `finished` or `crashed`: uncaught exceptions are detected via `sys.excepthook` (and the context manager), and hard-killed processes (`kill -9`, OOM) are presumed crashed once their 15s heartbeat goes quiet for 60s — self-healing if the process was merely suspended.
36
+
37
+ ```python
38
+ with pandm.init(project="mnist") as run:
39
+ run.log({"loss": 0.5})
40
+ ```
41
+
42
+ List or delete runs from the terminal:
43
+
44
+ ```sh
45
+ pandm ls
46
+ pandm delete <run_id>
47
+ ```
48
+
49
+ Data lives in `./.pandm` by default; override with `--dir` or `PANDM_DIR`.
50
+
51
+ ### Cloud mode
52
+
53
+ Run a server anywhere, then point training scripts at it — no code changes:
54
+
55
+ ```sh
56
+ pandm server --api-key my-secret # on the server (default port 7878)
57
+ ```
58
+
59
+ ```sh
60
+ export PANDM_REMOTE=http://my-host:7878
61
+ export PANDM_API_KEY=my-secret
62
+ python train.py # same script, now reports over HTTP
63
+ ```
64
+
65
+ The API key protects write endpoints only; put the server behind a reverse proxy if reads need auth too. If the server becomes unreachable mid-run, the SDK warns and keeps training: it retries every 30s, replays run creation on recovery, and drops whatever was logged while offline.
66
+
67
+ ## API
68
+
69
+ | | |
70
+ |---|---|
71
+ | `pandm.init(project, name=None, config=None, *, directory=None, remote=None, api_key=None)` | start a run |
72
+ | `run.log(metrics, step=None)` | log scalar metrics |
73
+ | `run.log_image(key, image, step=None, caption=None)` | log an image |
74
+ | `run.finish(status="finished")` | end the run (also via `atexit`) |
75
+ | `GET /api/docs` | REST API reference on any running server |
76
+
77
+ ## Development
78
+
79
+ ```sh
80
+ uv sync && uv run pytest # python sdk + server
81
+ cd web && pnpm install && pnpm dev # dashboard dev server (proxies to :7878)
82
+ pnpm build # bundles the dashboard into src/pandm/static
83
+ ```
84
+
85
+ ## License
86
+
87
+ MIT
Binary file
@@ -0,0 +1,70 @@
1
+ """Simulate a few training runs so the dashboard has something to show.
2
+
3
+ uv run python examples/train_demo.py
4
+ uv run pandm ui
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import math
10
+ import random
11
+
12
+ from PIL import Image, ImageDraw, ImageOps
13
+
14
+ import pandm
15
+
16
+ PALETTES = [
17
+ ((24, 26, 60), (139, 149, 246)),
18
+ ((40, 18, 40), (239, 125, 155)),
19
+ ((12, 40, 38), (72, 207, 173)),
20
+ ((45, 30, 12), (245, 163, 95)),
21
+ ]
22
+
23
+
24
+ def fake_sample(step: int, total: int, palette_idx: int) -> Image.Image:
25
+ """A 'denoising' image: noise fades out as training progresses."""
26
+ dark, bright = PALETTES[palette_idx % len(PALETTES)]
27
+ base = ImageOps.colorize(Image.linear_gradient("L").rotate(45 - palette_idx * 30, expand=False), dark, bright)
28
+ base = base.resize((256, 256))
29
+ sigma = 90 * (1 - step / total) + 6
30
+ noise = ImageOps.colorize(Image.effect_noise((256, 256), sigma), (0, 0, 0), (255, 255, 255))
31
+ img = Image.blend(base, noise, alpha=max(0.04, 0.8 * (1 - step / total)))
32
+ draw = ImageDraw.Draw(img)
33
+ draw.rectangle([8, 224, 116, 246], fill=(0, 0, 0, 160))
34
+ draw.text((14, 228), f"step {step}", fill=(235, 235, 240))
35
+ return img
36
+
37
+
38
+ def simulate(project: str, name: str, lr: float, bs: int, palette_idx: int, steps: int = 400, seed: int = 0) -> None:
39
+ rng = random.Random(seed)
40
+ run = pandm.init(
41
+ project=project,
42
+ name=name,
43
+ config={"lr": lr, "batch_size": bs, "optimizer": "adamw", "model": "resnet18", "seed": seed},
44
+ )
45
+ base = 2.0 + rng.uniform(-0.2, 0.4)
46
+ speed = lr * rng.uniform(800, 1400)
47
+ for step in range(steps):
48
+ progress = step / steps
49
+ loss = base * math.exp(-speed * progress) + 0.08 + rng.gauss(0, 0.02) * (1.2 - progress)
50
+ acc = 1 - 0.9 * math.exp(-3.5 * progress) + rng.gauss(0, 0.008)
51
+ lr_t = lr * 0.5 * (1 + math.cos(math.pi * progress)) # cosine schedule
52
+ run.log({"train/loss": loss, "train/acc": min(acc, 0.999), "lr": lr_t}, step=step)
53
+ if step % 10 == 0:
54
+ val_loss = loss + 0.12 + rng.gauss(0, 0.03)
55
+ run.log({"val/loss": val_loss, "val/acc": min(acc - 0.04 + rng.gauss(0, 0.01), 0.999)}, step=step)
56
+ if step % 80 == 0 or step == steps - 1:
57
+ run.log_image("samples", fake_sample(step, steps, palette_idx), step=step, caption=f"epoch {step // 80}")
58
+ run.finish()
59
+ print(f" ✓ {project}/{name}")
60
+
61
+
62
+ if __name__ == "__main__":
63
+ print("simulating runs…")
64
+ simulate("mnist-diffusion", "baseline", lr=1e-3, bs=64, palette_idx=0, seed=1)
65
+ simulate("mnist-diffusion", "high-lr", lr=3e-3, bs=64, palette_idx=1, seed=2)
66
+ simulate("mnist-diffusion", "big-batch", lr=1e-3, bs=256, palette_idx=2, seed=3)
67
+ simulate("mnist-diffusion", "low-lr", lr=3e-4, bs=64, palette_idx=3, seed=4)
68
+ simulate("llm-finetune", "lora-r8", lr=2e-4, bs=8, palette_idx=1, steps=300, seed=5)
69
+ simulate("llm-finetune", "lora-r32", lr=2e-4, bs=8, palette_idx=2, steps=300, seed=6)
70
+ print("done — run `pandm ui` to view")
pandm-0.1.0/justfile ADDED
@@ -0,0 +1,33 @@
1
+ # 列出所有命令
2
+ default:
3
+ @just --list
4
+
5
+ # Python 单测
6
+ test:
7
+ uv run pytest tests -q
8
+
9
+ # pyright 类型检查
10
+ typecheck:
11
+ pyright
12
+
13
+ # 单测 + 类型检查
14
+ check: test typecheck
15
+
16
+ # 构建前端到 src/pandm/static(打包进 wheel 前必跑)
17
+ web-build:
18
+ pnpm -C web build
19
+
20
+ # 前端开发服务器(/api 代理到 127.0.0.1:7878)
21
+ web-dev:
22
+ pnpm -C web dev
23
+
24
+ # 生成演示数据
25
+ demo:
26
+ uv run python examples/train_demo.py
27
+
28
+ # 启动本地 dashboard
29
+ ui:
30
+ uv run pandm ui
31
+
32
+ # 一条龙:建前端 → 造数据 → 开 dashboard
33
+ play: web-build demo ui
@@ -0,0 +1,34 @@
1
+ [project]
2
+ name = "pandm"
3
+ version = "0.1.0"
4
+ description = "Beautiful, local-first experiment tracking. A lightweight alternative to wandb / tensorboard."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = { text = "MIT" }
8
+ authors = [{ name = "Jannchie", email = "jannchie@gmail.com" }]
9
+ dependencies = [
10
+ "fastapi>=0.110",
11
+ "uvicorn[standard]>=0.29",
12
+ "typer>=0.12",
13
+ "rich>=13",
14
+ "pillow>=10",
15
+ "httpx>=0.27",
16
+ "python-multipart>=0.0.9",
17
+ ]
18
+
19
+ [project.scripts]
20
+ pandm = "pandm.cli:app"
21
+
22
+ [build-system]
23
+ requires = ["hatchling"]
24
+ build-backend = "hatchling.build"
25
+
26
+ [tool.hatch.build]
27
+ # the built dashboard is gitignored but must ship with the package
28
+ artifacts = ["src/pandm/static/**"]
29
+
30
+ [tool.hatch.build.targets.wheel]
31
+ packages = ["src/pandm"]
32
+
33
+ [dependency-groups]
34
+ dev = ["pytest>=8"]
@@ -0,0 +1,4 @@
1
+ {
2
+ "venvPath": ".",
3
+ "venv": ".venv"
4
+ }
@@ -0,0 +1,6 @@
1
+ """pandm — beautiful, local-first experiment tracking."""
2
+
3
+ from .sdk import Run, finish, init, log, log_image
4
+
5
+ __version__ = "0.1.0"
6
+ __all__ = ["init", "log", "log_image", "finish", "Run", "__version__"]
@@ -0,0 +1,135 @@
1
+ """pandm CLI: `pandm ui` (local dashboard), `pandm server` (cloud mode), `pandm ls`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ import webbrowser
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ import typer
11
+ from rich.console import Console
12
+ from rich.panel import Panel
13
+ from rich.table import Table
14
+
15
+ from . import __version__
16
+ from .storage import LocalStore, resolve_dir
17
+
18
+ app = typer.Typer(
19
+ help="pandm — beautiful, local-first experiment tracking.",
20
+ no_args_is_help=True,
21
+ add_completion=False,
22
+ )
23
+ console = Console()
24
+
25
+ DirOption = typer.Option(None, "--dir", "-d", help="Data directory (default: ./.pandm or $PANDM_DIR).")
26
+
27
+
28
+ def _banner(url: str, data_dir: Path, mode: str) -> None:
29
+ console.print(
30
+ Panel.fit(
31
+ f"[bold]pandm[/bold] [dim]v{__version__}[/dim] · {mode}\n\n"
32
+ f" [bold cyan]{url}[/bold cyan]\n"
33
+ f" [dim]data: {data_dir}[/dim]",
34
+ border_style="bright_black",
35
+ padding=(1, 3),
36
+ )
37
+ )
38
+
39
+
40
+ @app.command()
41
+ def ui(
42
+ directory: Optional[Path] = DirOption,
43
+ port: int = typer.Option(7878, "--port", "-p"),
44
+ host: str = typer.Option("127.0.0.1", "--host"),
45
+ open_browser: bool = typer.Option(True, "--open/--no-open", help="Open the dashboard in a browser."),
46
+ ) -> None:
47
+ """Start the local dashboard."""
48
+ import uvicorn
49
+
50
+ from .server import create_app
51
+
52
+ data_dir = resolve_dir(directory)
53
+ url = f"http://{host}:{port}"
54
+ _banner(url, data_dir, "local dashboard")
55
+ if open_browser:
56
+ threading.Timer(0.8, webbrowser.open, args=[url]).start()
57
+ uvicorn.run(create_app(data_dir), host=host, port=port, log_level="warning")
58
+
59
+
60
+ @app.command()
61
+ def server(
62
+ directory: Optional[Path] = DirOption,
63
+ port: int = typer.Option(7878, "--port", "-p"),
64
+ host: str = typer.Option("0.0.0.0", "--host"),
65
+ api_key: Optional[str] = typer.Option(
66
+ None, "--api-key", envvar="PANDM_API_KEY", help="Require x-api-key on write endpoints."
67
+ ),
68
+ ) -> None:
69
+ """Start a pandm server for cloud deployment (SDKs report via PANDM_REMOTE)."""
70
+ import uvicorn
71
+
72
+ from .server import create_app
73
+
74
+ data_dir = resolve_dir(directory)
75
+ _banner(f"http://{host}:{port}", data_dir, "server mode" + (" · api-key on" if api_key else ""))
76
+ uvicorn.run(create_app(data_dir, api_key=api_key), host=host, port=port, log_level="info")
77
+
78
+
79
+ @app.command("ls")
80
+ def list_runs(
81
+ project: Optional[str] = typer.Option(None, "--project", "-P"),
82
+ directory: Optional[Path] = DirOption,
83
+ ) -> None:
84
+ """List runs in the terminal."""
85
+ store = LocalStore(resolve_dir(directory))
86
+ runs = store.list_runs(project)
87
+ if not runs:
88
+ console.print("[dim]no runs yet — call pandm.init() in your training script[/dim]")
89
+ return
90
+
91
+ import datetime as dt
92
+
93
+ status_style = {"running": "bold cyan", "finished": "green", "crashed": "red"}
94
+ table = Table(box=None, header_style="bold dim", pad_edge=False)
95
+ for col in ("ID", "NAME", "PROJECT", "STATUS", "CREATED", "DURATION"):
96
+ table.add_column(col)
97
+ for run in runs:
98
+ created = dt.datetime.fromtimestamp(run["created_at"]).strftime("%Y-%m-%d %H:%M")
99
+ end = run["finished_at"] or run["updated_at"]
100
+ mins, secs = divmod(int(max(0, end - run["created_at"])), 60)
101
+ hours, mins = divmod(mins, 60)
102
+ duration = f"{hours}h{mins:02d}m" if hours else (f"{mins}m{secs:02d}s" if mins else f"{secs}s")
103
+ table.add_row(
104
+ f"[dim]{run['id']}[/dim]",
105
+ run["name"],
106
+ run["project"],
107
+ f"[{status_style.get(run['status'], 'white')}]{run['status']}[/]",
108
+ created,
109
+ duration,
110
+ )
111
+ console.print(table)
112
+
113
+
114
+ @app.command()
115
+ def delete(
116
+ run_id: str,
117
+ directory: Optional[Path] = DirOption,
118
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."),
119
+ ) -> None:
120
+ """Delete a run and its media files."""
121
+ store = LocalStore(resolve_dir(directory))
122
+ run = store.get_run(run_id)
123
+ if run is None:
124
+ console.print(f"[red]run {run_id} not found[/red]")
125
+ raise typer.Exit(1)
126
+ if not yes and not typer.confirm(f"delete run {run['name']} ({run_id})?"):
127
+ raise typer.Exit(0)
128
+ store.delete_run(run_id)
129
+ console.print(f"[dim]deleted {run_id}[/dim]")
130
+
131
+
132
+ @app.command()
133
+ def version() -> None:
134
+ """Print version."""
135
+ console.print(f"pandm v{__version__}")