weaveforge 0.6.0__py3-none-any.whl

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 (55) hide show
  1. weaveforge/__init__.py +72 -0
  2. weaveforge/cli.py +73 -0
  3. weaveforge/config.py +68 -0
  4. weaveforge/container.py +89 -0
  5. weaveforge/features/__init__.py +3 -0
  6. weaveforge/features/experiments/__init__.py +38 -0
  7. weaveforge/features/experiments/application/__init__.py +5 -0
  8. weaveforge/features/experiments/application/git.py +46 -0
  9. weaveforge/features/experiments/application/manage_experiment.py +109 -0
  10. weaveforge/features/experiments/application/run.py +174 -0
  11. weaveforge/features/experiments/domain/__init__.py +30 -0
  12. weaveforge/features/experiments/domain/experiment.py +101 -0
  13. weaveforge/features/experiments/domain/experiment_repository.py +19 -0
  14. weaveforge/features/experiments/domain/metric_point.py +74 -0
  15. weaveforge/features/experiments/domain/metric_repository.py +26 -0
  16. weaveforge/features/experiments/infrastructure/__init__.py +5 -0
  17. weaveforge/features/experiments/infrastructure/api_artifact_storage.py +29 -0
  18. weaveforge/features/experiments/infrastructure/api_experiment_repository.py +74 -0
  19. weaveforge/features/experiments/infrastructure/api_metric_repository.py +34 -0
  20. weaveforge/features/experiments/infrastructure/supabase_artifact_storage.py +50 -0
  21. weaveforge/features/experiments/infrastructure/supabase_experiment_repository.py +91 -0
  22. weaveforge/features/experiments/infrastructure/supabase_metric_repository.py +49 -0
  23. weaveforge/features/projects/__init__.py +10 -0
  24. weaveforge/features/projects/domain/__init__.py +3 -0
  25. weaveforge/features/projects/domain/project_reader.py +16 -0
  26. weaveforge/features/projects/infrastructure/__init__.py +1 -0
  27. weaveforge/features/projects/infrastructure/api_project_reader.py +14 -0
  28. weaveforge/features/projects/infrastructure/supabase_project_reader.py +25 -0
  29. weaveforge/infrastructure/api_client.py +54 -0
  30. weaveforge/integrations/__init__.py +13 -0
  31. weaveforge/integrations/_common.py +55 -0
  32. weaveforge/integrations/keras.py +59 -0
  33. weaveforge/integrations/lightning.py +81 -0
  34. weaveforge/py.typed +0 -0
  35. weaveforge/shared/__init__.py +26 -0
  36. weaveforge/shared/clock.py +43 -0
  37. weaveforge/shared/repository.py +59 -0
  38. weaveforge/sync/__init__.py +38 -0
  39. weaveforge/sync/matplotlib.py +69 -0
  40. weaveforge/sync/registry.py +42 -0
  41. weaveforge/sync/source.py +93 -0
  42. weaveforge/sync/tensorboard.py +64 -0
  43. weaveforge/sync/wandb.py +136 -0
  44. weaveforge/testing/__init__.py +20 -0
  45. weaveforge/testing/contracts.py +78 -0
  46. weaveforge/testing/fakes.py +21 -0
  47. weaveforge/testing/in_memory_experiment_repository.py +37 -0
  48. weaveforge/testing/in_memory_metric_repository.py +26 -0
  49. weaveforge/testing/memory_container.py +43 -0
  50. weaveforge/tracking.py +171 -0
  51. weaveforge-0.6.0.dist-info/METADATA +175 -0
  52. weaveforge-0.6.0.dist-info/RECORD +55 -0
  53. weaveforge-0.6.0.dist-info/WHEEL +4 -0
  54. weaveforge-0.6.0.dist-info/entry_points.txt +2 -0
  55. weaveforge-0.6.0.dist-info/licenses/LICENSE +661 -0
weaveforge/__init__.py ADDED
@@ -0,0 +1,72 @@
1
+ """weaveforge — Python SDK for WeaveForge.
2
+
3
+ Push experiments, training curves, and figures into the same Supabase database
4
+ the PWA reads, using the decorator as the one entry point::
5
+
6
+ from weaveforge import track_experiment
7
+
8
+ @track_experiment(name="beta-vae sweep", config={"beta": 4}, sync=["tensorboard"])
9
+ def train(run):
10
+ run.log_metric("val_loss", 0.11, step=100)
11
+ run.log_figure(fig, name="reconstruction")
12
+ return {"val_loss": 0.11} # recorded as summary metrics
13
+
14
+ Mirrors the web app's modular shape (``features/<name>/...``); the Supabase
15
+ migrations are the shared source of truth (see ``docs/building/design.md`` §5.4).
16
+ """
17
+
18
+ from .features.experiments import (
19
+ EXPERIMENT_STATUSES,
20
+ Experiment,
21
+ ExperimentStatus,
22
+ ExperimentValidationError,
23
+ ManageExperimentUseCase,
24
+ MetricPoint,
25
+ MetricSeries,
26
+ NewExperimentInput,
27
+ short_sha,
28
+ )
29
+ from .tracking import track, track_experiment
30
+
31
+ # Hatch reads this as the package version (see pyproject [tool.hatch.version]).
32
+ # The PyPI publish runs off the repo's vX.Y.Z tag, so this must be bumped in
33
+ # step with it — PyPI refuses a re-upload of a version it already has.
34
+ __version__ = "0.6.0"
35
+
36
+ __all__ = [
37
+ "Experiment",
38
+ "ExperimentStatus",
39
+ "EXPERIMENT_STATUSES",
40
+ "NewExperimentInput",
41
+ "ExperimentValidationError",
42
+ "ManageExperimentUseCase",
43
+ "MetricPoint",
44
+ "MetricSeries",
45
+ "short_sha",
46
+ "track",
47
+ "track_experiment",
48
+ "Run",
49
+ "connect",
50
+ "Container",
51
+ "Settings",
52
+ "__version__",
53
+ ]
54
+
55
+
56
+ def __getattr__(name: str):
57
+ """Lazily expose the connection layer so ``import weaveforge`` (and the
58
+ decorator) work without the ``supabase`` package installed — it's only
59
+ needed when you actually ``connect()``."""
60
+ if name == "Run":
61
+ from .features.experiments.application.run import Run
62
+
63
+ return Run
64
+ if name in ("connect", "Container"):
65
+ from . import container as _c
66
+
67
+ return getattr(_c, name)
68
+ if name == "Settings":
69
+ from .config import Settings
70
+
71
+ return Settings
72
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
weaveforge/cli.py ADDED
@@ -0,0 +1,73 @@
1
+ """``weaveforge`` command-line interface.
2
+
3
+ A thin presentation layer over the SDK (SRP): it parses args and calls the same
4
+ ``connect`` / ``track`` the library exposes — no business logic of its own.
5
+
6
+ weaveforge list [--project NAME]
7
+ weaveforge import-tb <logdir> [--name N] [--project P]
8
+ weaveforge import-wandb <entity/proj/id> [--name N] [--project P]
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import sys
15
+ from collections.abc import Sequence
16
+
17
+ from . import __version__
18
+
19
+
20
+ def _cmd_list(args: argparse.Namespace) -> int:
21
+ from .container import connect
22
+
23
+ c = connect(project=args.project)
24
+ rows = c.experiments.list()
25
+ if not rows:
26
+ print("No experiments.")
27
+ return 0
28
+ for e in rows:
29
+ metrics = ", ".join(f"{k}={v}" for k, v in list(e.metrics.items())[:4])
30
+ print(f"{e.status:<9} {e.name}" + (f" [{metrics}]" if metrics else ""))
31
+ return 0
32
+
33
+
34
+ def _import(source_id: str, ref: str, args: argparse.Namespace) -> int:
35
+ from .tracking import track
36
+
37
+ name = args.name or f"{source_id} import"
38
+ with track(name, sync={source_id: ref}, project=args.project) as run:
39
+ print(f"Imported {source_id} '{ref}' into experiment {run.id}")
40
+ return 0
41
+
42
+
43
+ def build_parser() -> argparse.ArgumentParser:
44
+ parser = argparse.ArgumentParser(prog="weaveforge", description=__doc__)
45
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
46
+ sub = parser.add_subparsers(dest="command", required=True)
47
+
48
+ p_list = sub.add_parser("list", help="list experiments")
49
+ p_list.add_argument("--project", help="project name to scope to")
50
+ p_list.set_defaults(func=_cmd_list)
51
+
52
+ p_tb = sub.add_parser("import-tb", help="import TensorBoard scalars")
53
+ p_tb.add_argument("logdir")
54
+ p_tb.add_argument("--name")
55
+ p_tb.add_argument("--project")
56
+ p_tb.set_defaults(func=lambda a: _import("tensorboard", a.logdir, a))
57
+
58
+ p_wb = sub.add_parser("import-wandb", help="import a wandb run")
59
+ p_wb.add_argument("run_path", help="entity/project/run_id")
60
+ p_wb.add_argument("--name")
61
+ p_wb.add_argument("--project")
62
+ p_wb.set_defaults(func=lambda a: _import("wandb", a.run_path, a))
63
+
64
+ return parser
65
+
66
+
67
+ def main(argv: Sequence[str] | None = None) -> int:
68
+ args = build_parser().parse_args(argv)
69
+ return args.func(args)
70
+
71
+
72
+ if __name__ == "__main__":
73
+ sys.exit(main())
weaveforge/config.py ADDED
@@ -0,0 +1,68 @@
1
+ """Environment-driven configuration for the SDK.
2
+
3
+ The SDK talks to the WeaveForge web app over HTTP using a single bearer
4
+ token (copied from the dashboard) so users don't need to paste Supabase URL /
5
+ keys into their training environment.
6
+
7
+ Kept separate from ``container.py`` so wiring stays a pure function of a
8
+ ``Settings`` object.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ from dataclasses import dataclass
15
+
16
+ _PROJECT_VARS = ("WEAVEFORGE_PROJECT_ID",)
17
+ _PROJECT_NAME_VARS = ("WEAVEFORGE_PROJECT",)
18
+ _TOKEN_VARS = ("WEAVEFORGE_TOKEN", "WEAVEFORGE_API_TOKEN")
19
+ _API_URL_VARS = ("WEAVEFORGE_API_URL",)
20
+
21
+
22
+ class ConfigError(RuntimeError):
23
+ pass
24
+
25
+
26
+ def _first(names: tuple[str, ...]) -> str | None:
27
+ for name in names:
28
+ value = os.environ.get(name)
29
+ if value:
30
+ return value
31
+ return None
32
+
33
+
34
+ @dataclass
35
+ class Settings:
36
+ api_url: str
37
+ token: str
38
+ #: Optional active project to scope experiments to (matches the web app's
39
+ #: project switcher). ``None`` = the user's default/unscoped rows.
40
+ project_id: str | None = None
41
+ #: Optional project *name*, resolved to an id at connect time when
42
+ #: ``project_id`` isn't given — friendlier than hunting for a UUID.
43
+ project_name: str | None = None
44
+
45
+ @classmethod
46
+ def from_env(cls) -> Settings:
47
+ api_url = _first(_API_URL_VARS)
48
+ token = _first(_TOKEN_VARS)
49
+ missing = [
50
+ label
51
+ for label, value in (
52
+ ("WEAVEFORGE_TOKEN", token),
53
+ ("WEAVEFORGE_API_URL", api_url),
54
+ )
55
+ if not value
56
+ ]
57
+ if missing:
58
+ raise ConfigError(
59
+ "Missing required environment variables: "
60
+ + ", ".join(missing)
61
+ + ". Set it to the dashboard-issued bearer token (see python/README.md)."
62
+ )
63
+ return cls(
64
+ api_url=api_url, # type: ignore[arg-type]
65
+ token=token, # type: ignore[arg-type]
66
+ project_id=_first(_PROJECT_VARS),
67
+ project_name=_first(_PROJECT_NAME_VARS),
68
+ )
@@ -0,0 +1,89 @@
1
+ """Composition root.
2
+
3
+ The ONE place that knows concrete implementations and wires them to the
4
+ interfaces the rest of the SDK depends on (Dependency Inversion — the Python
5
+ mirror of ``apps/web/src/bootstrap.ts``).
6
+
7
+ The SDK authenticates with a single bearer token and talks to the web app's
8
+ `/api/sdk/*` endpoints (which apply RLS using the user's session token). This
9
+ keeps Supabase URL/keys out of training environments.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass
15
+
16
+ from .config import Settings
17
+ from .features.experiments.application.manage_experiment import ManageExperimentUseCase
18
+ from .features.experiments.infrastructure.api_artifact_storage import ApiArtifactStorage
19
+ from .features.experiments.infrastructure.api_experiment_repository import ApiExperimentRepository
20
+ from .features.experiments.infrastructure.api_metric_repository import ApiMetricRepository
21
+ from .features.projects.infrastructure.api_project_reader import ApiProjectReader
22
+ from .infrastructure.api_client import ApiClient
23
+ from .shared.clock import SystemClock, UuidGenerator
24
+
25
+
26
+ @dataclass
27
+ class Container:
28
+ """Wired dependencies. Everything downstream depends on the interfaces these
29
+ implement, never on Supabase directly."""
30
+
31
+ user_id: str
32
+ project_id: str | None
33
+ experiments: ApiExperimentRepository
34
+ metrics: ApiMetricRepository
35
+ artifacts: ApiArtifactStorage
36
+ manage_experiment: ManageExperimentUseCase
37
+ projects: ApiProjectReader
38
+ api: ApiClient
39
+ #: Kept so a long run can re-authenticate if the token rotates.
40
+ _settings: Settings
41
+
42
+ def resolve_project(self, name: str) -> str | None:
43
+ """Look up a project id by name (RLS-scoped to you)."""
44
+ return self.projects.id_by_name(name)
45
+
46
+ def reauth(self) -> None:
47
+ """No-op for bearer-token auth. If you rotate the token, create a new
48
+ Container via connect()."""
49
+ return None
50
+
51
+
52
+ def connect(settings: Settings | None = None, project: str | None = None) -> Container:
53
+ """Build the container. ``project`` (a name) overrides the configured
54
+ project for this connection."""
55
+ settings = settings or Settings.from_env()
56
+ api = ApiClient(settings.api_url, settings.token)
57
+ who = api.get("/api/sdk/whoami")
58
+ user_id = who.get("userId")
59
+ if not user_id:
60
+ raise RuntimeError("Token rejected by server (no userId).")
61
+
62
+ projects = ApiProjectReader(api)
63
+ project_name = project or settings.project_name
64
+ project_id = settings.project_id if project is None else None
65
+ if project_id is None and project_name:
66
+ project_id = projects.id_by_name(project_name)
67
+ if project_id is None:
68
+ raise RuntimeError(
69
+ f'No project named "{project_name}" found for this account. '
70
+ "Create it in the dashboard, or set WEAVEFORGE_PROJECT_ID."
71
+ )
72
+
73
+ experiments = ApiExperimentRepository(api, project_id)
74
+ metrics = ApiMetricRepository(api)
75
+ artifacts = ApiArtifactStorage(api)
76
+ manage = ManageExperimentUseCase(
77
+ experiments, SystemClock(), UuidGenerator(), metrics
78
+ )
79
+ return Container(
80
+ user_id=user_id,
81
+ project_id=project_id,
82
+ experiments=experiments,
83
+ metrics=metrics,
84
+ artifacts=artifacts,
85
+ manage_experiment=manage,
86
+ projects=projects,
87
+ api=api,
88
+ _settings=settings,
89
+ )
@@ -0,0 +1,3 @@
1
+ """Feature modules. Each is a vertical slice with the same internal shape
2
+ (``domain`` / ``application`` / ``infrastructure``) as the web app, so adding
3
+ one never requires editing another (see ``docs/building/design.md`` §3.1)."""
@@ -0,0 +1,38 @@
1
+ """experiments — the public API of the experiments feature module.
2
+
3
+ Others import from here, never from the internals (``docs/building/design.md`` §3.1).
4
+ The Supabase adapters in ``infrastructure`` are intentionally *not* re-exported:
5
+ they are wired only at the composition root (``weaveforge.container``).
6
+ """
7
+
8
+ from .application import ManageExperimentUseCase
9
+ from .domain import (
10
+ EXPERIMENT_STATUSES,
11
+ Experiment,
12
+ ExperimentFilter,
13
+ ExperimentStatus,
14
+ ExperimentValidationError,
15
+ IExperimentRepository,
16
+ IMetricRepository,
17
+ MetricPoint,
18
+ MetricSeries,
19
+ NewExperimentInput,
20
+ create_experiment,
21
+ short_sha,
22
+ )
23
+
24
+ __all__ = [
25
+ "Experiment",
26
+ "ExperimentStatus",
27
+ "EXPERIMENT_STATUSES",
28
+ "NewExperimentInput",
29
+ "ExperimentFilter",
30
+ "ExperimentValidationError",
31
+ "create_experiment",
32
+ "short_sha",
33
+ "IExperimentRepository",
34
+ "IMetricRepository",
35
+ "MetricPoint",
36
+ "MetricSeries",
37
+ "ManageExperimentUseCase",
38
+ ]
@@ -0,0 +1,5 @@
1
+ """Experiments application layer — orchestration only (no SDK calls)."""
2
+
3
+ from .manage_experiment import ManageExperimentUseCase
4
+
5
+ __all__ = ["ManageExperimentUseCase"]
@@ -0,0 +1,46 @@
1
+ """Best-effort capture of the local git state, so a run is automatically pinned
2
+ to a repo / branch / commit (the whole point of the experiments feature). Never
3
+ raises: outside a repo, or with git absent, every field is just ``None``.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import subprocess
9
+
10
+
11
+ def _git(*args: str, cwd: str | None = None) -> str | None:
12
+ try:
13
+ out = subprocess.run(
14
+ ["git", *args],
15
+ cwd=cwd,
16
+ capture_output=True,
17
+ text=True,
18
+ timeout=5,
19
+ check=True,
20
+ )
21
+ return out.stdout.strip() or None
22
+ except (subprocess.SubprocessError, OSError):
23
+ return None
24
+
25
+
26
+ def _normalize_remote(url: str | None) -> str | None:
27
+ """Turn an ssh/`.git` remote into a browsable https URL (matches the web
28
+ app's commit-link expectations)."""
29
+ if not url:
30
+ return None
31
+ url = url.strip()
32
+ if url.startswith("git@") and ":" in url:
33
+ host, path = url[4:].split(":", 1)
34
+ url = f"https://{host}/{path}"
35
+ if url.endswith(".git"):
36
+ url = url[:-4]
37
+ return url
38
+
39
+
40
+ def capture_git_state(cwd: str | None = None) -> dict[str, str | None]:
41
+ branch = _git("rev-parse", "--abbrev-ref", "HEAD", cwd=cwd)
42
+ return {
43
+ "repo_url": _normalize_remote(_git("config", "--get", "remote.origin.url", cwd=cwd)),
44
+ "commit_sha": _git("rev-parse", "HEAD", cwd=cwd),
45
+ "branch": None if branch == "HEAD" else branch,
46
+ }
@@ -0,0 +1,109 @@
1
+ """Use-case for experiments. Orchestration only (DIP): add, change status
2
+ (stamping started/finished timestamps), record summary metrics, and append
3
+ step-indexed history. Port of ``manage-experiment.use-case.ts``, extended with
4
+ ``record_history`` for the metric-curve table (0016).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Iterable
10
+ from typing import Any
11
+
12
+ from ....shared.clock import Clock, IdGenerator
13
+ from ..domain.experiment import (
14
+ Experiment,
15
+ ExperimentStatus,
16
+ ExperimentValidationError,
17
+ NewExperimentInput,
18
+ create_experiment,
19
+ )
20
+ from ..domain.experiment_repository import IExperimentRepository
21
+ from ..domain.metric_point import MetricPoint
22
+ from ..domain.metric_repository import IMetricRepository
23
+
24
+ _TERMINAL: tuple[ExperimentStatus, ...] = ("done", "failed", "abandoned")
25
+
26
+
27
+ class ManageExperimentUseCase:
28
+ def __init__(
29
+ self,
30
+ repository: IExperimentRepository,
31
+ clock: Clock,
32
+ ids: IdGenerator,
33
+ metrics: IMetricRepository | None = None,
34
+ ) -> None:
35
+ self._repo = repository
36
+ self._clock = clock
37
+ self._ids = ids
38
+ self._metrics = metrics
39
+
40
+ def add(self, data: NewExperimentInput) -> Experiment:
41
+ exp = create_experiment(data, clock=self._clock, ids=self._ids)
42
+ self._repo.save(exp)
43
+ return exp
44
+
45
+ def set_status(
46
+ self,
47
+ id: str,
48
+ status: ExperimentStatus,
49
+ *,
50
+ existing: Experiment | None = None,
51
+ ) -> Experiment:
52
+ def change(e: Experiment) -> Experiment:
53
+ now = self._clock.now_iso()
54
+ e.status = status
55
+ if status == "running" and not e.started_at:
56
+ e.started_at = now
57
+ if status in _TERMINAL:
58
+ e.finished_at = now
59
+ return e
60
+
61
+ return self._mutate(id, change, existing=existing)
62
+
63
+ def record_metrics(
64
+ self,
65
+ id: str,
66
+ metrics: dict[str, Any],
67
+ *,
68
+ existing: Experiment | None = None,
69
+ ) -> Experiment:
70
+ def change(e: Experiment) -> Experiment:
71
+ e.metrics = {**e.metrics, **metrics}
72
+ return e
73
+
74
+ return self._mutate(id, change, existing=existing)
75
+
76
+ def add_artifacts(
77
+ self,
78
+ id: str,
79
+ links: Iterable[str],
80
+ *,
81
+ existing: Experiment | None = None,
82
+ ) -> Experiment:
83
+ def change(e: Experiment) -> Experiment:
84
+ existing_links = set(e.artifacts)
85
+ for link in links:
86
+ if link not in existing_links:
87
+ e.artifacts.append(link)
88
+ existing_links.add(link)
89
+ return e
90
+
91
+ return self._mutate(id, change, existing=existing)
92
+
93
+ def record_history(self, points: Iterable[MetricPoint]) -> None:
94
+ if self._metrics is None:
95
+ raise ExperimentValidationError(
96
+ "No metric repository wired — cannot record step history."
97
+ )
98
+ self._metrics.append(points)
99
+
100
+ def remove(self, id: str) -> None:
101
+ self._repo.delete(id)
102
+
103
+ def _mutate(self, id: str, change, *, existing: Experiment | None = None) -> Experiment:
104
+ base = existing if existing is not None else self._repo.get_by_id(id)
105
+ if base is None:
106
+ raise ExperimentValidationError(f'No experiment with id "{id}".')
107
+ updated = change(base)
108
+ self._repo.save(updated)
109
+ return updated
@@ -0,0 +1,174 @@
1
+ """The ``Run`` — the one handle every capability hangs off.
2
+
3
+ A run wraps a single experiment row and buffers step-indexed metric history so
4
+ training loops can log cheaply. It's created and finalized by :func:`track` /
5
+ :func:`track_experiment`, which stamp status and flush on exit. New capabilities
6
+ (more loggers, more artifact kinds) become new ``Run`` methods or new registered
7
+ sync sources — never new call sites in user code. That's the design intent
8
+ behind "the decorator does everything down the line."
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import io
14
+ from collections.abc import Callable, Iterable
15
+ from datetime import datetime, timezone
16
+ from typing import Any
17
+
18
+ from ....sync.registry import SyncRegistry, default_registry
19
+ from ....sync.source import ArtifactSource, MetricSource, Mirror
20
+ from ...experiments.domain.experiment import Experiment, ExperimentStatus
21
+ from ...experiments.domain.metric_point import MetricPoint
22
+ from .manage_experiment import ManageExperimentUseCase
23
+
24
+ #: Uploader signature: (experiment_id, name, data, content_type) -> link URL.
25
+ Uploader = Callable[[str, str, bytes, str], str]
26
+
27
+ _FLUSH_EVERY = 1000
28
+ _CONTENT_TYPES = {
29
+ "png": "image/png",
30
+ "webp": "image/webp",
31
+ "svg": "image/svg+xml",
32
+ "pdf": "application/pdf",
33
+ }
34
+
35
+
36
+ class Run:
37
+ def __init__(
38
+ self,
39
+ manage: ManageExperimentUseCase,
40
+ experiment: Experiment,
41
+ *,
42
+ uploader: Uploader | None = None,
43
+ registry: SyncRegistry = default_registry,
44
+ mirror: Mirror | None = None,
45
+ ) -> None:
46
+ self._manage = manage
47
+ self.experiment = experiment
48
+ self._uploader = uploader
49
+ self._registry = registry
50
+ self._mirror = mirror
51
+ self._buffer: list[MetricPoint] = []
52
+ self._last: dict[str, float] = {}
53
+ self._fig_seq = 0
54
+
55
+ @property
56
+ def id(self) -> str:
57
+ return self.experiment.id
58
+
59
+ # --- metrics ---------------------------------------------------------
60
+ def log_metric(self, name: str, value: float, step: int | None = None) -> None:
61
+ """One metric sample. With ``step`` it joins the curve (history); without
62
+ a step it's a summary value written to ``experiments.metrics``."""
63
+ if step is None:
64
+ self.log_summary({name: value})
65
+ return
66
+ wall = datetime.now(timezone.utc).isoformat()
67
+ self._buffer.append(MetricPoint(self.id, name, int(step), float(value), wall))
68
+ self._last[name] = float(value)
69
+ if self._mirror is not None:
70
+ self._mirror.log({name: float(value)}, int(step))
71
+ if len(self._buffer) >= _FLUSH_EVERY:
72
+ self.flush()
73
+
74
+ def log_metrics(self, metrics: dict[str, float], step: int | None = None) -> None:
75
+ if step is None:
76
+ self.log_summary(metrics)
77
+ return
78
+ for name, value in metrics.items():
79
+ self.log_metric(name, value, step)
80
+
81
+ def log_summary(self, metrics: dict[str, Any]) -> None:
82
+ """Merge flat summary values into ``experiments.metrics`` (the chips the
83
+ dashboard shows)."""
84
+ self.experiment = self._manage.record_metrics(
85
+ self.id, dict(metrics), existing=self.experiment
86
+ )
87
+
88
+ # --- artifacts -------------------------------------------------------
89
+ def log_figure(self, figure: Any, name: str | None = None, fmt: str = "png") -> str:
90
+ """Save a matplotlib (or any ``savefig``-capable) figure and attach it.
91
+
92
+ Duck-typed on ``savefig`` so this needs no matplotlib import; the
93
+ ``[figures]`` extra only adds WebP compression on top (see sync.matplotlib).
94
+ """
95
+ buf = io.BytesIO()
96
+ figure.savefig(buf, format=fmt, bbox_inches="tight")
97
+ self._fig_seq += 1
98
+ fname = name or f"figure-{self._fig_seq}"
99
+ if "." not in fname:
100
+ fname = f"{fname}.{fmt}"
101
+ ctype = _CONTENT_TYPES.get(fmt, "application/octet-stream")
102
+ return self.log_bytes(fname, buf.getvalue(), ctype)
103
+
104
+ def log_bytes(
105
+ self, name: str, data: bytes, content_type: str = "application/octet-stream"
106
+ ) -> str:
107
+ if self._uploader is None:
108
+ raise RuntimeError(
109
+ "This run has no artifact storage wired — cannot upload bytes. "
110
+ "Use a run created via track()/track_experiment(), or pass log_artifact(url)."
111
+ )
112
+ url = self._uploader(self.id, name, data, content_type)
113
+ self.experiment = self._manage.add_artifacts(self.id, [url], existing=self.experiment)
114
+ return url
115
+
116
+ def log_artifact(self, url: str) -> None:
117
+ """Attach an already-hosted link (a wandb run, an S3 object, …)."""
118
+ self.experiment = self._manage.add_artifacts(self.id, [url], existing=self.experiment)
119
+
120
+ # --- status ----------------------------------------------------------
121
+ def set_status(self, status: ExperimentStatus) -> None:
122
+ self.experiment = self._manage.set_status(self.id, status, existing=self.experiment)
123
+ # A run has one ending, and this is where every path reaches it: the
124
+ # mirror is told once and then forgotten, so a later status change
125
+ # cannot reopen a run somebody else has already closed.
126
+ if self._mirror is not None:
127
+ self._mirror.finish(status)
128
+ self._mirror = None
129
+
130
+ # --- sync sources ----------------------------------------------------
131
+ def sync(self, source_id: str, ref: Any) -> None:
132
+ """Pull curves and/or artifacts from a registered source.
133
+
134
+ Works with any source in the registry, so a user's own ``MetricSource``
135
+ is driven by the exact same call as the built-in ones (Open/Closed)."""
136
+ source = self._registry.get(source_id)
137
+ # A source may be a MetricSource, an ArtifactSource, or both; the
138
+ # runtime-checkable protocols let us dispatch on the roles it fills.
139
+ if isinstance(source, MetricSource):
140
+ self._ingest_series(source.read(ref))
141
+ if isinstance(source, ArtifactSource):
142
+ self._ingest_artifacts(source.collect(ref))
143
+
144
+ def sync_tensorboard(self, logdir: str) -> None:
145
+ self.sync("tensorboard", logdir)
146
+
147
+ def sync_wandb(self, run_path: str) -> None:
148
+ self.sync("wandb", run_path)
149
+
150
+ def _ingest_series(self, series: Iterable[Any]) -> None:
151
+ for s in series:
152
+ self._buffer.extend(s.to_points(self.id))
153
+ if s.last_value is not None:
154
+ self._last[s.metric] = s.last_value
155
+ if len(self._buffer) >= _FLUSH_EVERY:
156
+ self.flush()
157
+
158
+ def _ingest_artifacts(self, artifacts: Iterable[Any]) -> None:
159
+ for art in artifacts:
160
+ if art.url:
161
+ self.log_artifact(art.url)
162
+ elif art.data is not None:
163
+ self.log_bytes(art.name, art.data, art.content_type)
164
+
165
+ # --- lifecycle -------------------------------------------------------
166
+ def flush(self) -> None:
167
+ """Persist buffered history and fold the latest per-metric value into the
168
+ summary so the dashboard shows final numbers even without the chart."""
169
+ if self._buffer:
170
+ self._manage.record_history(self._buffer)
171
+ self._buffer.clear()
172
+ if self._last:
173
+ self.log_summary(dict(self._last))
174
+ self._last.clear()