expr-tracker 0.3.0__tar.gz → 0.3.2__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.
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/PKG-INFO +2 -1
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/docs/design.md +5 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/docs/guide/backends.md +6 -4
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/docs/guide/cli.md +14 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/pyproject.toml +1 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/cli.py +17 -2
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/run.py +20 -22
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_artifacts.py +2 -2
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_cli.py +79 -1
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_hot_paths.py +1 -1
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_review_regressions.py +2 -2
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_run_backends.py +24 -4
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_trackio.py +4 -2
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_wandb.py +2 -2
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/uv.lock +2 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/.github/workflows/docs.yaml +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/.github/workflows/release.yaml +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/.gitignore +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/LICENSE +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/README.md +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/docs/architecture.md +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/docs/examples.md +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/docs/getting-started.md +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/docs/guide/alerts.md +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/docs/guide/artifacts.md +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/docs/guide/distributed.md +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/docs/guide/history.md +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/docs/guide/logging.md +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/docs/guide/spans.md +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/docs/guide/streams.md +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/docs/index.md +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/docs/reference/api.md +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/docs/reference/configuration.md +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/docs/reference/expressions.md +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/examples/README.md +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/examples/alert_rules.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/examples/checkpoints.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/examples/early_stopping.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/examples/multiprocess_pipeline.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/examples/profile_step.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/examples/quickstart.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/mkdocs.yml +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/__init__.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/_compat.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/alerts/__init__.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/alerts/backends/__init__.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/alerts/backends/base.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/alerts/backends/cards.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/alerts/dispatch.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/alerts/engine.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/alerts/expr/__init__.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/alerts/expr/eval.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/alerts/expr/functions.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/alerts/expr/lexer.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/alerts/expr/nodes.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/alerts/expr/parser.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/alerts/expr/rule.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/alerts/models.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/artifacts.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/encoders.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/history/__init__.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/history/codec.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/history/frame.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/history/naming.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/history/reader.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/history/series.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/history/store.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/history/writer.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/plugins.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/py.typed +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/spans.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/summary.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/trace.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/tracker.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/types.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/conftest.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_alert_backends.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_alert_delivery.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_alert_dispatch.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_alert_engine.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_alert_models.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_alert_routing.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_benchmark.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_cache.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_correctness.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_distributed.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_e2e.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_examples.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_expr_builder.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_expr_eval.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_expr_functions.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_expr_parser.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_expr_properties.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_failure_modes.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_features.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_frame_codec_summary.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_history.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_integration.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_lark.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_perf.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_public_surfaces.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_rule_lifecycle.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_run_dir.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_scenarios.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_span_plugins.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_spans.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_step_commit.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_streams.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_stress.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_trace.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_value_encoding.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_writer_buffer.py +0 -0
- {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_writer_durability.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: expr_tracker
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Summary: Local-first experiment tracking with queryable history and expression-based alerts on your training metrics
|
|
5
5
|
Project-URL: Homepage, https://hspk.github.io/expr_tracker/
|
|
6
6
|
Project-URL: Documentation, https://hspk.github.io/expr_tracker/
|
|
@@ -27,6 +27,7 @@ Requires-Python: >=3.10
|
|
|
27
27
|
Requires-Dist: click>=8.1.0
|
|
28
28
|
Requires-Dist: loguru>=0.7.3
|
|
29
29
|
Requires-Dist: pydantic>=2.0
|
|
30
|
+
Requires-Dist: python-dotenv>=1.0
|
|
30
31
|
Provides-Extra: all
|
|
31
32
|
Requires-Dist: nvidia-ml-py>=12.0; extra == 'all'
|
|
32
33
|
Requires-Dist: pandas>=1.5; extra == 'all'
|
|
@@ -118,6 +118,11 @@ so timing a region adds no row. The tree, with timestamps and attributes, goes t
|
|
|
118
118
|
`spans[.stream][.rankN].jsonl` through a second `JsonlWriter`, enqueued rather
|
|
119
119
|
than appended so a span does not pay for a flush decision of its own.
|
|
120
120
|
|
|
121
|
+
The committed history row is also the fan-out boundary. Summary, alerts and
|
|
122
|
+
remote backends consume the exact row emitted by `HistoryStore`, rather than the
|
|
123
|
+
individual `log()` calls that assembled it. Span and plugin metrics therefore
|
|
124
|
+
reach every sink without a second metric path.
|
|
125
|
+
|
|
121
126
|
Plugins hang off the same two hooks. `start` runs before the clock starts and
|
|
122
127
|
`end` after it stops, so a plugin never inflates the duration it reports on; what
|
|
123
128
|
`end` returns is merged into the span's metrics under the span's own path, which
|
|
@@ -16,15 +16,17 @@ optional.
|
|
|
16
16
|
|
|
17
17
|
| Call | Forwarded as |
|
|
18
18
|
| --- | --- |
|
|
19
|
-
|
|
|
19
|
+
| committed history row | `backend.log(metrics, step=<resolved>, commit=True)` |
|
|
20
20
|
| `et.init(...)` | `backend.init(project, name, config, entity, tags, notes, resume, ...)` |
|
|
21
21
|
| `et.define_metric(name, **kw)` | `backend.define_metric` if it has one |
|
|
22
22
|
| `et.log_artifact(...)` | `backend.log_artifact` if it has one |
|
|
23
23
|
| `et.finish(exit_code)` | `backend.finish()` |
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
Local history assembles the row first. Summary, alerts and remote backends then
|
|
26
|
+
receive that same committed row, including span and plugin metrics. Two `log()`
|
|
27
|
+
calls for one step therefore produce one backend call, and a step dropped by the
|
|
28
|
+
local step policy is never forwarded. A backend that does not accept `commit`
|
|
29
|
+
(trackio) simply does not receive it.
|
|
28
30
|
|
|
29
31
|
## Failure handling
|
|
30
32
|
|
|
@@ -89,3 +89,17 @@ et alert "node 3 is on fire" --level critical --channel oncall
|
|
|
89
89
|
|
|
90
90
|
Channels come from the same configuration the library uses: `ET_ALERT_CONFIG` or the
|
|
91
91
|
`ET_LARK_WEBHOOK_URL` environment variable. See [Alerts](alerts.md).
|
|
92
|
+
|
|
93
|
+
`et alert` automatically loads `.env` from the directory where you invoke it,
|
|
94
|
+
before resolving alert configuration. Existing environment variables take
|
|
95
|
+
precedence, including empty values. It does not search parent directories; a
|
|
96
|
+
missing `.env` is fine. Use `--no-dotenv` to disable loading.
|
|
97
|
+
|
|
98
|
+
```dotenv
|
|
99
|
+
# .env
|
|
100
|
+
ET_LARK_WEBHOOK_URL=https://example.com/your-webhook
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Quoted values, comments, and `${VAR}` expansion are supported; shell commands are
|
|
104
|
+
not executed. Keep secrets out of version control. This loading applies only to
|
|
105
|
+
`et alert`, not other CLI commands or the Python API.
|
|
@@ -4,8 +4,10 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
6
|
import time
|
|
7
|
+
from pathlib import Path
|
|
7
8
|
|
|
8
9
|
import click
|
|
10
|
+
from dotenv import load_dotenv
|
|
9
11
|
|
|
10
12
|
from .alerts import alert as send_alert
|
|
11
13
|
from .alerts.dispatch import Dispatcher
|
|
@@ -26,8 +28,21 @@ def main():
|
|
|
26
28
|
@click.option("--title", default="Alert", help="Title of the alert")
|
|
27
29
|
@click.option("--level", default="info", help="info | warning | error | critical")
|
|
28
30
|
@click.option("--channel", "channels", multiple=True, help="Restrict to these channels")
|
|
29
|
-
|
|
30
|
-
"""
|
|
31
|
+
@click.option(
|
|
32
|
+
"--no-dotenv", is_flag=True, help="Skip loading .env from the current directory"
|
|
33
|
+
)
|
|
34
|
+
def alert(
|
|
35
|
+
msg: str, title: str, level: str, channels: tuple[str, ...], no_dotenv: bool
|
|
36
|
+
):
|
|
37
|
+
"""Send a manual alert, loading the current directory's .env by default."""
|
|
38
|
+
if not no_dotenv:
|
|
39
|
+
try:
|
|
40
|
+
with Path(".env").open(encoding="utf-8") as stream:
|
|
41
|
+
load_dotenv(stream=stream, override=False)
|
|
42
|
+
except FileNotFoundError:
|
|
43
|
+
pass
|
|
44
|
+
except (OSError, UnicodeError) as exc:
|
|
45
|
+
raise click.ClickException(f"Could not read .env: {exc}") from exc
|
|
31
46
|
send_alert(title=title, text=msg, level=level, channels=list(channels) or None)
|
|
32
47
|
|
|
33
48
|
|
|
@@ -15,7 +15,7 @@ from typing import Any, Literal
|
|
|
15
15
|
from loguru import logger
|
|
16
16
|
|
|
17
17
|
from .artifacts import Artifact, ArtifactStore, coerce_artifact
|
|
18
|
-
from .history import HistoryStore, current_rank
|
|
18
|
+
from .history import HistoryStore, current_rank
|
|
19
19
|
from .history.naming import resolve_artifact_root, sidecar_filename
|
|
20
20
|
from .summary import Summary
|
|
21
21
|
|
|
@@ -245,34 +245,32 @@ class Run:
|
|
|
245
245
|
# ------------------------------------------------------------------ logging
|
|
246
246
|
|
|
247
247
|
def _on_commit(self, record: dict):
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
All sinks share one timeline: if local history rejects the call (closed run,
|
|
255
|
-
or a backward step), the summary and remote backends are skipped too.
|
|
256
|
-
"""
|
|
257
|
-
resolved_step = self.history.log(data, step=step, commit=commit)
|
|
258
|
-
if resolved_step is None:
|
|
259
|
-
return
|
|
248
|
+
metrics = {
|
|
249
|
+
key: value
|
|
250
|
+
for key, value in record.items()
|
|
251
|
+
if isinstance(key, str) and not key.startswith("_")
|
|
252
|
+
}
|
|
253
|
+
step = record["_step"]
|
|
260
254
|
try:
|
|
261
|
-
self.summary.observe(
|
|
255
|
+
self.summary.observe(metrics)
|
|
262
256
|
except Exception as e: # a sink must never break the training loop
|
|
263
257
|
logger.warning(f"Failed to update summary: {e}")
|
|
264
258
|
for name, backend in self.backends.items():
|
|
265
|
-
|
|
266
|
-
# matches the local history instead of drifting on its own counter.
|
|
267
|
-
extra = (
|
|
268
|
-
{"commit": resolve_commit(step, commit)}
|
|
269
|
-
if self._takes_commit.get(name)
|
|
270
|
-
else {}
|
|
271
|
-
)
|
|
259
|
+
extra = {"commit": True} if self._takes_commit.get(name) else {}
|
|
272
260
|
try:
|
|
273
|
-
backend.log(
|
|
261
|
+
backend.log(metrics, step=step, **extra)
|
|
274
262
|
except Exception as e:
|
|
275
263
|
logger.warning(f"Failed to log metrics to {name}: {e}")
|
|
264
|
+
if self.alerts is not None:
|
|
265
|
+
self.alerts.on_step(record)
|
|
266
|
+
|
|
267
|
+
def log(self, data: dict, step: int | None = None, commit: bool | None = None):
|
|
268
|
+
"""Log metrics, mirroring ``wandb.log(data, step=..., commit=...)``.
|
|
269
|
+
|
|
270
|
+
Local history owns row assembly. Summary, alerts and remote backends receive
|
|
271
|
+
the same complete row only after it commits, including span/plugin metrics.
|
|
272
|
+
"""
|
|
273
|
+
self.history.log(data, step=step, commit=commit)
|
|
276
274
|
|
|
277
275
|
def history_query(self, *args, **kwargs):
|
|
278
276
|
return self.history.get(*args, **kwargs)
|
|
@@ -252,7 +252,7 @@ def test_backend_receives_commit_argument(tmp_path):
|
|
|
252
252
|
et.log({"v": 1}, step=3, commit=False)
|
|
253
253
|
finally:
|
|
254
254
|
et.finish()
|
|
255
|
-
assert calls == [({"v": 1}, 3,
|
|
255
|
+
assert calls == [({"v": 1}, 3, True)]
|
|
256
256
|
|
|
257
257
|
|
|
258
258
|
def test_legacy_backend_without_commit_still_works(tmp_path):
|
|
@@ -313,7 +313,7 @@ def test_rejected_step_reaches_no_sink(tmp_path):
|
|
|
313
313
|
|
|
314
314
|
et.init(project="p", name="mono", dir=str(tmp_path), backends=[Backend()])
|
|
315
315
|
try:
|
|
316
|
-
et.log({"loss": 1.0}, step=10)
|
|
316
|
+
et.log({"loss": 1.0}, step=10, commit=True)
|
|
317
317
|
et.log({"loss": 99.0}, step=5) # rejected under the default step policy
|
|
318
318
|
assert forwarded == [{"loss": 1.0}]
|
|
319
319
|
assert dict(et.summary()) == {"loss": 1.0}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""The full CLI surface: every command, every format, and their error paths."""
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
+
import os
|
|
4
5
|
|
|
5
6
|
import pytest
|
|
6
7
|
from click.testing import CliRunner
|
|
@@ -10,7 +11,9 @@ from expr_tracker.history import HistoryStore
|
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
@pytest.fixture
|
|
13
|
-
def runner():
|
|
14
|
+
def runner(monkeypatch, tmp_path):
|
|
15
|
+
monkeypatch.chdir(tmp_path)
|
|
16
|
+
monkeypatch.setattr(os, "environ", os.environ.copy())
|
|
14
17
|
return CliRunner()
|
|
15
18
|
|
|
16
19
|
|
|
@@ -237,6 +240,81 @@ def test_alert_without_a_message_fails(runner, channel):
|
|
|
237
240
|
assert runner.invoke(cli.main, ["alert"]).exit_code != 0
|
|
238
241
|
|
|
239
242
|
|
|
243
|
+
def test_alert_loads_dotenv_before_resolving_channels(runner, monkeypatch, tmp_path):
|
|
244
|
+
from expr_tracker.alerts import load_config
|
|
245
|
+
|
|
246
|
+
monkeypatch.delenv("ET_LARK_WEBHOOK_URL", raising=False)
|
|
247
|
+
monkeypatch.delenv("ET_ALERT_CONFIG", raising=False)
|
|
248
|
+
(tmp_path / ".env").write_text(
|
|
249
|
+
'# Alert settings\nexport ET_LARK_WEBHOOK_URL="https://example.test/hook"\n',
|
|
250
|
+
encoding="utf-8",
|
|
251
|
+
)
|
|
252
|
+
configs = []
|
|
253
|
+
monkeypatch.setattr(cli, "send_alert", lambda **kwargs: configs.append(load_config()))
|
|
254
|
+
|
|
255
|
+
invoke(runner, "alert", "hello")
|
|
256
|
+
|
|
257
|
+
assert any(
|
|
258
|
+
c.type == "lark" and c.resolve_url() == "https://example.test/hook"
|
|
259
|
+
for c in configs[0].channels
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@pytest.mark.parametrize("existing", ["from-shell", ""])
|
|
264
|
+
def test_alert_dotenv_preserves_existing_environment(
|
|
265
|
+
runner, channel, monkeypatch, tmp_path, existing
|
|
266
|
+
):
|
|
267
|
+
monkeypatch.setenv("ET_TEST_DOTENV", existing)
|
|
268
|
+
(tmp_path / ".env").write_text("ET_TEST_DOTENV=from-file\n", encoding="utf-8")
|
|
269
|
+
|
|
270
|
+
invoke(runner, "alert", "hello")
|
|
271
|
+
|
|
272
|
+
assert os.environ["ET_TEST_DOTENV"] == existing
|
|
273
|
+
assert len(channel) == 1
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def test_alert_does_not_search_parent_dotenv(runner, channel, monkeypatch, tmp_path):
|
|
277
|
+
monkeypatch.delenv("ET_TEST_DOTENV", raising=False)
|
|
278
|
+
(tmp_path / ".env").write_text("ET_TEST_DOTENV=parent\n", encoding="utf-8")
|
|
279
|
+
child = tmp_path / "child"
|
|
280
|
+
child.mkdir()
|
|
281
|
+
monkeypatch.chdir(child)
|
|
282
|
+
|
|
283
|
+
invoke(runner, "alert", "hello")
|
|
284
|
+
|
|
285
|
+
assert "ET_TEST_DOTENV" not in os.environ
|
|
286
|
+
assert len(channel) == 1
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def test_alert_can_skip_dotenv(runner, channel, monkeypatch, tmp_path):
|
|
290
|
+
monkeypatch.delenv("ET_TEST_DOTENV", raising=False)
|
|
291
|
+
(tmp_path / ".env").write_text("ET_TEST_DOTENV=from-file\n", encoding="utf-8")
|
|
292
|
+
|
|
293
|
+
invoke(runner, "alert", "hello", "--no-dotenv")
|
|
294
|
+
|
|
295
|
+
assert "ET_TEST_DOTENV" not in os.environ
|
|
296
|
+
assert len(channel) == 1
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def test_alert_reports_unreadable_dotenv(runner, channel, tmp_path):
|
|
300
|
+
(tmp_path / ".env").mkdir()
|
|
301
|
+
|
|
302
|
+
result = runner.invoke(cli.main, ["alert", "hello"])
|
|
303
|
+
|
|
304
|
+
assert result.exit_code != 0
|
|
305
|
+
assert "Could not read .env" in result.output
|
|
306
|
+
assert not channel
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def test_other_commands_do_not_load_dotenv(runner, monkeypatch, tmp_path):
|
|
310
|
+
monkeypatch.delenv("ET_TEST_DOTENV", raising=False)
|
|
311
|
+
(tmp_path / ".env").write_text("ET_TEST_DOTENV=from-file\n", encoding="utf-8")
|
|
312
|
+
|
|
313
|
+
invoke(runner, "rules", "explain", "loss > 1")
|
|
314
|
+
|
|
315
|
+
assert "ET_TEST_DOTENV" not in os.environ
|
|
316
|
+
|
|
317
|
+
|
|
240
318
|
# ------------------------------------------------------------------ rules
|
|
241
319
|
|
|
242
320
|
|
|
@@ -236,7 +236,7 @@ def test_the_summary_survives_finish(run, tmp_path):
|
|
|
236
236
|
|
|
237
237
|
def test_a_dropped_step_does_not_touch_the_summary(run):
|
|
238
238
|
run()
|
|
239
|
-
et.log({"loss": 1.0}, step=5)
|
|
239
|
+
et.log({"loss": 1.0}, step=5, commit=True)
|
|
240
240
|
et.log({"loss": 99.0}, step=1)
|
|
241
241
|
assert dict(et.summary())["loss"] == 1.0
|
|
242
242
|
|
|
@@ -473,11 +473,11 @@ def test_alias_added_on_a_dedup_reuse_is_persisted(tmp_path):
|
|
|
473
473
|
et.finish()
|
|
474
474
|
|
|
475
475
|
|
|
476
|
-
def
|
|
476
|
+
def test_summary_matches_history_key_encoding(tmp_path):
|
|
477
477
|
et.init(project="p", name="s", dir=str(tmp_path), backends=[])
|
|
478
478
|
try:
|
|
479
479
|
et.log({(1, 2): 3, b"k": 4, "loss": 5})
|
|
480
|
-
assert dict(et.summary()) == {"loss": 5}
|
|
480
|
+
assert dict(et.summary()) == {"(1, 2)": 3, "k": 4, "loss": 5}
|
|
481
481
|
finally:
|
|
482
482
|
et.finish()
|
|
483
483
|
|
|
@@ -137,7 +137,7 @@ def test_backend_receives_init_log_and_finish(tmp_path):
|
|
|
137
137
|
tags=["a"],
|
|
138
138
|
notes="hello",
|
|
139
139
|
)
|
|
140
|
-
run.log({"loss": 1.0}, step=0)
|
|
140
|
+
run.log({"loss": 1.0}, step=0, commit=True)
|
|
141
141
|
run.define_metric("loss", summary="min")
|
|
142
142
|
run.finish()
|
|
143
143
|
|
|
@@ -146,8 +146,7 @@ def test_backend_receives_init_log_and_finish(tmp_path):
|
|
|
146
146
|
assert init_kwargs["project"] == "p" and init_kwargs["config"] == {"lr": 0.1}
|
|
147
147
|
assert init_kwargs["tags"] == ["a"] and init_kwargs["notes"] == "hello"
|
|
148
148
|
assert backend.calls[1][1] == ({"loss": 1.0},)
|
|
149
|
-
|
|
150
|
-
assert backend.calls[1][2] == {"step": 0, "commit": False}
|
|
149
|
+
assert backend.calls[1][2] == {"step": 0, "commit": True}
|
|
151
150
|
|
|
152
151
|
|
|
153
152
|
def test_backend_is_named_after_its_class(tmp_path):
|
|
@@ -286,7 +285,7 @@ def test_rejected_step_reaches_no_sink(tmp_path):
|
|
|
286
285
|
backend = FakeBackend()
|
|
287
286
|
run = Run(project="p", name="r", dir=str(tmp_path), backends=[backend])
|
|
288
287
|
try:
|
|
289
|
-
run.log({"loss": 1.0}, step=5)
|
|
288
|
+
run.log({"loss": 1.0}, step=5, commit=True)
|
|
290
289
|
run.log({"loss": 2.0}, step=1) # backwards: dropped
|
|
291
290
|
assert [c[1][0] for c in run.backends["fakebackend"].calls[1:]] == [
|
|
292
291
|
{"loss": 1.0}
|
|
@@ -296,6 +295,27 @@ def test_rejected_step_reaches_no_sink(tmp_path):
|
|
|
296
295
|
run.finish()
|
|
297
296
|
|
|
298
297
|
|
|
298
|
+
def test_committed_row_fans_out_span_metrics(tmp_path):
|
|
299
|
+
backend = FakeBackend()
|
|
300
|
+
et.init(project="p", name="r", dir=str(tmp_path), backends=[backend])
|
|
301
|
+
try:
|
|
302
|
+
et.log({"loss": 1.0}, commit=False)
|
|
303
|
+
with et.span("forward"):
|
|
304
|
+
pass
|
|
305
|
+
et.log({"accuracy": 0.5})
|
|
306
|
+
|
|
307
|
+
log_call = backend.calls[1]
|
|
308
|
+
metrics = log_call[1][0]
|
|
309
|
+
assert metrics["loss"] == 1.0
|
|
310
|
+
assert metrics["accuracy"] == 0.5
|
|
311
|
+
assert metrics["count/forward"] == 1
|
|
312
|
+
assert "time_ms/forward" in metrics
|
|
313
|
+
assert et.summary()["count/forward"] == 1
|
|
314
|
+
assert "time_ms/forward" in et.summary()
|
|
315
|
+
finally:
|
|
316
|
+
et.finish()
|
|
317
|
+
|
|
318
|
+
|
|
299
319
|
def test_finish_is_idempotent(tmp_path):
|
|
300
320
|
backend = FakeBackend()
|
|
301
321
|
run = Run(project="p", name="r", dir=str(tmp_path), backends=[backend])
|
|
@@ -140,9 +140,11 @@ def test_trackio_receives_the_resolved_step(tmp_path, fake_trackio):
|
|
|
140
140
|
run.log({"loss": 1.0})
|
|
141
141
|
run.log({"acc": 0.5}, step=0, commit=False)
|
|
142
142
|
run.log({"acc2": 0.6}, step=0)
|
|
143
|
+
run.history.flush(commit_open=True)
|
|
143
144
|
logs = [call for name, call in fake_trackio.calls if name == "log"]
|
|
144
|
-
assert [entry["step"] for entry in logs] == [0, 0
|
|
145
|
+
assert [entry["step"] for entry in logs] == [0, 0]
|
|
145
146
|
assert logs[0]["metrics"] == {"loss": 1.0}
|
|
147
|
+
assert logs[1]["metrics"] == {"acc": 0.5, "acc2": 0.6}
|
|
146
148
|
finally:
|
|
147
149
|
run.finish()
|
|
148
150
|
|
|
@@ -150,7 +152,7 @@ def test_trackio_receives_the_resolved_step(tmp_path, fake_trackio):
|
|
|
150
152
|
def test_a_dropped_step_never_reaches_trackio(tmp_path, fake_trackio):
|
|
151
153
|
run = Run(project="p", name="r", dir=str(tmp_path), backends=["trackio"])
|
|
152
154
|
try:
|
|
153
|
-
run.log({"loss": 1.0}, step=5)
|
|
155
|
+
run.log({"loss": 1.0}, step=5, commit=True)
|
|
154
156
|
run.log({"loss": 2.0}, step=1) # backwards: dropped locally
|
|
155
157
|
logs = [call for name, call in fake_trackio.calls if name == "log"]
|
|
156
158
|
assert [entry["step"] for entry in logs] == [5]
|
|
@@ -142,9 +142,9 @@ def test_several_logs_for_one_step_stay_one_wandb_step(run):
|
|
|
142
142
|
def test_an_explicit_step_defers_the_commit_on_wandb_too(run):
|
|
143
143
|
instance = run(name="explicit")
|
|
144
144
|
instance.log({"a": 1}, step=7)
|
|
145
|
-
assert wandb.run.step ==
|
|
145
|
+
assert wandb.run.step == 0
|
|
146
146
|
instance.log({"b": 2}, step=7)
|
|
147
|
-
assert wandb.run.step ==
|
|
147
|
+
assert wandb.run.step == 0
|
|
148
148
|
instance.log({"c": 3}, step=8, commit=True)
|
|
149
149
|
assert wandb.run.step == 9
|
|
150
150
|
|
|
@@ -463,6 +463,7 @@ dependencies = [
|
|
|
463
463
|
{ name = "click" },
|
|
464
464
|
{ name = "loguru" },
|
|
465
465
|
{ name = "pydantic" },
|
|
466
|
+
{ name = "python-dotenv" },
|
|
466
467
|
]
|
|
467
468
|
|
|
468
469
|
[package.optional-dependencies]
|
|
@@ -517,6 +518,7 @@ requires-dist = [
|
|
|
517
518
|
{ name = "polars", marker = "extra == 'all'", specifier = ">=0.20" },
|
|
518
519
|
{ name = "polars", marker = "extra == 'polars'", specifier = ">=0.20" },
|
|
519
520
|
{ name = "pydantic", specifier = ">=2.0" },
|
|
521
|
+
{ name = "python-dotenv", specifier = ">=1.0" },
|
|
520
522
|
{ name = "trackio", marker = "extra == 'all'", specifier = ">=0.4.0" },
|
|
521
523
|
{ name = "trackio", marker = "extra == 'trackio'", specifier = ">=0.4.0" },
|
|
522
524
|
{ name = "wandb", marker = "extra == 'all'", specifier = ">=0.21.0" },
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|