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.
Files changed (113) hide show
  1. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/PKG-INFO +2 -1
  2. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/docs/design.md +5 -0
  3. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/docs/guide/backends.md +6 -4
  4. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/docs/guide/cli.md +14 -0
  5. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/pyproject.toml +1 -0
  6. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/cli.py +17 -2
  7. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/run.py +20 -22
  8. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_artifacts.py +2 -2
  9. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_cli.py +79 -1
  10. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_hot_paths.py +1 -1
  11. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_review_regressions.py +2 -2
  12. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_run_backends.py +24 -4
  13. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_trackio.py +4 -2
  14. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_wandb.py +2 -2
  15. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/uv.lock +2 -0
  16. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/.github/workflows/docs.yaml +0 -0
  17. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/.github/workflows/release.yaml +0 -0
  18. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/.gitignore +0 -0
  19. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/LICENSE +0 -0
  20. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/README.md +0 -0
  21. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/docs/architecture.md +0 -0
  22. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/docs/examples.md +0 -0
  23. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/docs/getting-started.md +0 -0
  24. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/docs/guide/alerts.md +0 -0
  25. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/docs/guide/artifacts.md +0 -0
  26. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/docs/guide/distributed.md +0 -0
  27. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/docs/guide/history.md +0 -0
  28. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/docs/guide/logging.md +0 -0
  29. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/docs/guide/spans.md +0 -0
  30. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/docs/guide/streams.md +0 -0
  31. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/docs/index.md +0 -0
  32. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/docs/reference/api.md +0 -0
  33. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/docs/reference/configuration.md +0 -0
  34. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/docs/reference/expressions.md +0 -0
  35. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/examples/README.md +0 -0
  36. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/examples/alert_rules.py +0 -0
  37. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/examples/checkpoints.py +0 -0
  38. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/examples/early_stopping.py +0 -0
  39. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/examples/multiprocess_pipeline.py +0 -0
  40. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/examples/profile_step.py +0 -0
  41. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/examples/quickstart.py +0 -0
  42. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/mkdocs.yml +0 -0
  43. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/__init__.py +0 -0
  44. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/_compat.py +0 -0
  45. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/alerts/__init__.py +0 -0
  46. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/alerts/backends/__init__.py +0 -0
  47. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/alerts/backends/base.py +0 -0
  48. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/alerts/backends/cards.py +0 -0
  49. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/alerts/dispatch.py +0 -0
  50. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/alerts/engine.py +0 -0
  51. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/alerts/expr/__init__.py +0 -0
  52. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/alerts/expr/eval.py +0 -0
  53. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/alerts/expr/functions.py +0 -0
  54. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/alerts/expr/lexer.py +0 -0
  55. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/alerts/expr/nodes.py +0 -0
  56. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/alerts/expr/parser.py +0 -0
  57. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/alerts/expr/rule.py +0 -0
  58. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/alerts/models.py +0 -0
  59. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/artifacts.py +0 -0
  60. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/encoders.py +0 -0
  61. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/history/__init__.py +0 -0
  62. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/history/codec.py +0 -0
  63. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/history/frame.py +0 -0
  64. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/history/naming.py +0 -0
  65. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/history/reader.py +0 -0
  66. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/history/series.py +0 -0
  67. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/history/store.py +0 -0
  68. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/history/writer.py +0 -0
  69. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/plugins.py +0 -0
  70. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/py.typed +0 -0
  71. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/spans.py +0 -0
  72. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/summary.py +0 -0
  73. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/trace.py +0 -0
  74. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/tracker.py +0 -0
  75. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/src/expr_tracker/types.py +0 -0
  76. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/conftest.py +0 -0
  77. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_alert_backends.py +0 -0
  78. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_alert_delivery.py +0 -0
  79. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_alert_dispatch.py +0 -0
  80. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_alert_engine.py +0 -0
  81. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_alert_models.py +0 -0
  82. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_alert_routing.py +0 -0
  83. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_benchmark.py +0 -0
  84. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_cache.py +0 -0
  85. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_correctness.py +0 -0
  86. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_distributed.py +0 -0
  87. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_e2e.py +0 -0
  88. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_examples.py +0 -0
  89. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_expr_builder.py +0 -0
  90. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_expr_eval.py +0 -0
  91. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_expr_functions.py +0 -0
  92. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_expr_parser.py +0 -0
  93. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_expr_properties.py +0 -0
  94. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_failure_modes.py +0 -0
  95. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_features.py +0 -0
  96. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_frame_codec_summary.py +0 -0
  97. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_history.py +0 -0
  98. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_integration.py +0 -0
  99. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_lark.py +0 -0
  100. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_perf.py +0 -0
  101. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_public_surfaces.py +0 -0
  102. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_rule_lifecycle.py +0 -0
  103. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_run_dir.py +0 -0
  104. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_scenarios.py +0 -0
  105. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_span_plugins.py +0 -0
  106. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_spans.py +0 -0
  107. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_step_commit.py +0 -0
  108. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_streams.py +0 -0
  109. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_stress.py +0 -0
  110. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_trace.py +0 -0
  111. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_value_encoding.py +0 -0
  112. {expr_tracker-0.3.0 → expr_tracker-0.3.2}/tests/test_writer_buffer.py +0 -0
  113. {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.0
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
- | `et.log(data, step, commit)` | `backend.log(data, step=<resolved>, commit=<resolved>)` |
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
- The **resolved** step matters: two `log()` calls for one step stay one step on the
26
- backend too, and a step dropped by the local step policy is never forwarded. A
27
- backend that does not accept `commit` (trackio) simply does not receive it.
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.
@@ -34,6 +34,7 @@ dependencies = [
34
34
  "click>=8.1.0",
35
35
  "loguru>=0.7.3",
36
36
  "pydantic>=2.0",
37
+ "python-dotenv>=1.0",
37
38
  ]
38
39
 
39
40
  dynamic = ["version"]
@@ -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
- def alert(msg: str, title: str, level: str, channels: tuple[str, ...]):
30
- """Send a manual alert."""
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, resolve_commit
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
- if self.alerts is not None:
249
- self.alerts.on_step(record)
250
-
251
- def log(self, data: dict, step: int | None = None, commit: bool | None = None):
252
- """Log metrics, mirroring ``wandb.log(data, step=..., commit=...)``.
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(data)
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
- # Forward the resolved step and commit so a backend's row layout
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(data, step=resolved_step, **extra)
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, False)]
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 test_summary_ignores_non_string_keys(tmp_path):
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
- # an explicit step defers the commit locally, so the backend defers it too
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, 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 == 7 # positioned, not yet advanced
145
+ assert wandb.run.step == 0
146
146
  instance.log({"b": 2}, step=7)
147
- assert wandb.run.step == 7
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