expr-tracker 0.3.2__tar.gz → 0.3.5__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 (114) hide show
  1. expr_tracker-0.3.5/.github/workflows/ci.yaml +39 -0
  2. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/.github/workflows/release.yaml +4 -0
  3. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/PKG-INFO +4 -2
  4. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/README.md +1 -1
  5. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/docs/guide/artifacts.md +3 -0
  6. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/pyproject.toml +2 -0
  7. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/alerts/__init__.py +4 -1
  8. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/alerts/backends/__init__.py +9 -2
  9. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/alerts/backends/base.py +8 -3
  10. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/alerts/dispatch.py +11 -4
  11. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/alerts/expr/functions.py +8 -2
  12. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/artifacts.py +3 -1
  13. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/history/writer.py +5 -1
  14. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_alert_backends.py +57 -2
  15. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_alert_dispatch.py +73 -0
  16. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_alert_engine.py +29 -0
  17. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_artifacts.py +58 -1
  18. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_examples.py +19 -13
  19. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_expr_functions.py +19 -1
  20. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_lark.py +2 -2
  21. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_review_regressions.py +7 -4
  22. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_writer_durability.py +45 -1
  23. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/uv.lock +4 -0
  24. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/.github/workflows/docs.yaml +0 -0
  25. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/.gitignore +0 -0
  26. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/LICENSE +0 -0
  27. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/docs/architecture.md +0 -0
  28. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/docs/design.md +0 -0
  29. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/docs/examples.md +0 -0
  30. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/docs/getting-started.md +0 -0
  31. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/docs/guide/alerts.md +0 -0
  32. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/docs/guide/backends.md +0 -0
  33. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/docs/guide/cli.md +0 -0
  34. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/docs/guide/distributed.md +0 -0
  35. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/docs/guide/history.md +0 -0
  36. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/docs/guide/logging.md +0 -0
  37. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/docs/guide/spans.md +0 -0
  38. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/docs/guide/streams.md +0 -0
  39. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/docs/index.md +0 -0
  40. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/docs/reference/api.md +0 -0
  41. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/docs/reference/configuration.md +0 -0
  42. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/docs/reference/expressions.md +0 -0
  43. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/examples/README.md +0 -0
  44. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/examples/alert_rules.py +0 -0
  45. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/examples/checkpoints.py +0 -0
  46. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/examples/early_stopping.py +0 -0
  47. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/examples/multiprocess_pipeline.py +0 -0
  48. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/examples/profile_step.py +0 -0
  49. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/examples/quickstart.py +0 -0
  50. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/mkdocs.yml +0 -0
  51. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/__init__.py +0 -0
  52. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/_compat.py +0 -0
  53. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/alerts/backends/cards.py +0 -0
  54. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/alerts/engine.py +0 -0
  55. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/alerts/expr/__init__.py +0 -0
  56. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/alerts/expr/eval.py +0 -0
  57. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/alerts/expr/lexer.py +0 -0
  58. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/alerts/expr/nodes.py +0 -0
  59. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/alerts/expr/parser.py +0 -0
  60. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/alerts/expr/rule.py +0 -0
  61. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/alerts/models.py +0 -0
  62. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/cli.py +0 -0
  63. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/encoders.py +0 -0
  64. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/history/__init__.py +0 -0
  65. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/history/codec.py +0 -0
  66. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/history/frame.py +0 -0
  67. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/history/naming.py +0 -0
  68. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/history/reader.py +0 -0
  69. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/history/series.py +0 -0
  70. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/history/store.py +0 -0
  71. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/plugins.py +0 -0
  72. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/py.typed +0 -0
  73. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/run.py +0 -0
  74. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/spans.py +0 -0
  75. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/summary.py +0 -0
  76. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/trace.py +0 -0
  77. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/tracker.py +0 -0
  78. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/types.py +0 -0
  79. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/conftest.py +0 -0
  80. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_alert_delivery.py +0 -0
  81. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_alert_models.py +0 -0
  82. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_alert_routing.py +0 -0
  83. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_benchmark.py +0 -0
  84. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_cache.py +0 -0
  85. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_cli.py +0 -0
  86. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_correctness.py +0 -0
  87. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_distributed.py +0 -0
  88. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_e2e.py +0 -0
  89. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_expr_builder.py +0 -0
  90. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_expr_eval.py +0 -0
  91. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_expr_parser.py +0 -0
  92. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_expr_properties.py +0 -0
  93. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_failure_modes.py +0 -0
  94. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_features.py +0 -0
  95. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_frame_codec_summary.py +0 -0
  96. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_history.py +0 -0
  97. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_hot_paths.py +0 -0
  98. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_integration.py +0 -0
  99. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_perf.py +0 -0
  100. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_public_surfaces.py +0 -0
  101. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_rule_lifecycle.py +0 -0
  102. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_run_backends.py +0 -0
  103. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_run_dir.py +0 -0
  104. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_scenarios.py +0 -0
  105. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_span_plugins.py +0 -0
  106. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_spans.py +0 -0
  107. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_step_commit.py +0 -0
  108. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_streams.py +0 -0
  109. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_stress.py +0 -0
  110. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_trace.py +0 -0
  111. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_trackio.py +0 -0
  112. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_value_encoding.py +0 -0
  113. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_wandb.py +0 -0
  114. {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_writer_buffer.py +0 -0
@@ -0,0 +1,39 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ workflow_call:
8
+
9
+ permissions:
10
+ contents: read
11
+
12
+ jobs:
13
+ test:
14
+ name: Python ${{ matrix.python }}
15
+ runs-on: ubuntu-latest
16
+ strategy:
17
+ fail-fast: false
18
+ matrix:
19
+ python: ["3.10", "3.11", "3.12", "3.13"]
20
+ steps:
21
+ - name: Checkout
22
+ uses: actions/checkout@v5
23
+ with:
24
+ fetch-depth: 0
25
+
26
+ - name: Install uv
27
+ uses: astral-sh/setup-uv@v6
28
+ with:
29
+ enable-cache: true
30
+ python-version: ${{ matrix.python }}
31
+
32
+ - name: Install dependencies
33
+ run: uv sync --frozen --all-extras
34
+
35
+ - name: Lint
36
+ run: uv run --frozen ruff check src tests
37
+
38
+ - name: Test
39
+ run: uv run --frozen pytest -m "not slow and not benchmark"
@@ -10,8 +10,12 @@ permissions:
10
10
  contents: read
11
11
 
12
12
  jobs:
13
+ checks:
14
+ uses: ./.github/workflows/ci.yaml
15
+
13
16
  release:
14
17
  name: Build and publish to PyPI
18
+ needs: checks
15
19
  runs-on: ubuntu-latest
16
20
  permissions:
17
21
  # required for PyPI trusted publishing (OIDC)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: expr_tracker
3
- Version: 0.3.2
3
+ Version: 0.3.5
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/
@@ -25,9 +25,11 @@ Classifier: Topic :: System :: Monitoring
25
25
  Classifier: Typing :: Typed
26
26
  Requires-Python: >=3.10
27
27
  Requires-Dist: click>=8.1.0
28
+ Requires-Dist: filelock>=3.12
28
29
  Requires-Dist: loguru>=0.7.3
29
30
  Requires-Dist: pydantic>=2.0
30
31
  Requires-Dist: python-dotenv>=1.0
32
+ Requires-Dist: tomli>=2.0; python_version < '3.11'
31
33
  Provides-Extra: all
32
34
  Requires-Dist: nvidia-ml-py>=12.0; extra == 'all'
33
35
  Requires-Dist: pandas>=1.5; extra == 'all'
@@ -87,7 +89,7 @@ et.history(-1, output_type="pd") # everything, as a DataFrame
87
89
  ## Install
88
90
 
89
91
  ```bash
90
- uv add expr_tracker # local-first: click, loguru, pydantic only
92
+ uv add expr_tracker # local-first; no remote backend required
91
93
  uv add "expr_tracker[wandb]" # mirror to Weights & Biases
92
94
  uv add "expr_tracker[trackio]" # mirror to trackio
93
95
  uv add "expr_tracker[pandas]" # history(output_type="pandas")
@@ -39,7 +39,7 @@ et.history(-1, output_type="pd") # everything, as a DataFrame
39
39
  ## Install
40
40
 
41
41
  ```bash
42
- uv add expr_tracker # local-first: click, loguru, pydantic only
42
+ uv add expr_tracker # local-first; no remote backend required
43
43
  uv add "expr_tracker[wandb]" # mirror to Weights & Biases
44
44
  uv add "expr_tracker[trackio]" # mirror to trackio
45
45
  uv add "expr_tracker[pandas]" # history(output_type="pandas")
@@ -26,6 +26,9 @@ Artifacts are **deduplicated by content**: logging the same files again reuses t
26
26
  existing version rather than creating a new one, while any new aliases are still
27
27
  recorded against it.
28
28
 
29
+ Publishers sharing a store serialize version allocation, file copying, and index
30
+ updates with a filesystem lock, including when they run in separate processes.
31
+
29
32
  ```python
30
33
  a = et.log_artifact("ckpt.pt", name="model") # v0
31
34
  b = et.log_artifact("ckpt.pt", name="model", aliases=["best"])
@@ -32,9 +32,11 @@ classifiers = [
32
32
  ]
33
33
  dependencies = [
34
34
  "click>=8.1.0",
35
+ "filelock>=3.12",
35
36
  "loguru>=0.7.3",
36
37
  "pydantic>=2.0",
37
38
  "python-dotenv>=1.0",
39
+ "tomli>=2.0; python_version < '3.11'",
38
40
  ]
39
41
 
40
42
  dynamic = ["version"]
@@ -99,7 +99,10 @@ def _read_config_file(path: str | Path) -> dict:
99
99
  file = Path(path)
100
100
  text = file.read_text(encoding="utf-8")
101
101
  if file.suffix == ".toml":
102
- import tomllib
102
+ try:
103
+ import tomllib
104
+ except ModuleNotFoundError:
105
+ import tomli as tomllib
103
106
 
104
107
  data = tomllib.loads(text)
105
108
  elif file.suffix in (".yaml", ".yml"):
@@ -6,7 +6,7 @@ import json
6
6
  import smtplib
7
7
  from email.message import EmailMessage
8
8
 
9
- from ..models import AlertLevel, AlertMessage
9
+ from ..models import AlertLevel, AlertMessage, WebhookPolicy
10
10
  from .base import (
11
11
  AlertBackend,
12
12
  SendError,
@@ -53,8 +53,15 @@ class UrlBackend(AlertBackend):
53
53
  return policy.timeout if policy else 10.0
54
54
 
55
55
  def post(self, payload: dict) -> str:
56
+ policy = self.config.policy
56
57
  return post_json(
57
- self.url, payload, self.timeout, self.config.options.get("headers")
58
+ self.url,
59
+ payload,
60
+ self.timeout,
61
+ self.config.options.get("headers"),
62
+ retry_on_status=(
63
+ policy.retry_on_status if policy else WebhookPolicy.retry_on_status
64
+ ),
58
65
  )
59
66
 
60
67
  def post_reply(self, payload: dict) -> dict:
@@ -8,7 +8,7 @@ import urllib.error
8
8
  import urllib.parse
9
9
  import urllib.request
10
10
 
11
- from ..models import AlertMessage, ChannelConfig
11
+ from ..models import AlertMessage, ChannelConfig, WebhookPolicy
12
12
 
13
13
 
14
14
  class SendError(Exception):
@@ -56,7 +56,12 @@ def create_backend(config: ChannelConfig) -> AlertBackend:
56
56
 
57
57
 
58
58
  def post_json(
59
- url: str, payload: dict, timeout: float, headers: dict | None = None
59
+ url: str,
60
+ payload: dict,
61
+ timeout: float,
62
+ headers: dict | None = None,
63
+ *,
64
+ retry_on_status: tuple[int, ...] = WebhookPolicy.retry_on_status,
60
65
  ) -> str:
61
66
  """POST a JSON body, raising :class:`SendError` on failure."""
62
67
  data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
@@ -75,7 +80,7 @@ def post_json(
75
80
  )
76
81
  raise SendError(
77
82
  f"HTTP {e.code} from {_redact(url)}: {e.reason}",
78
- retryable=e.code in (408, 429, 500, 502, 503, 504),
83
+ retryable=e.code in retry_on_status,
79
84
  retry_after=retry_after,
80
85
  ) from e
81
86
  except urllib.error.URLError as e:
@@ -278,10 +278,17 @@ class Dispatcher:
278
278
 
279
279
  def flush(self, timeout: float = 5.0):
280
280
  deadline = time.monotonic() + timeout
281
- while time.monotonic() < deadline:
282
- if all(rt.pending == 0 for rt in self.channels.values()):
283
- return
284
- time.sleep(0.02)
281
+ for runtime in self.channels.values():
282
+ work = runtime.queue
283
+ if work is None:
284
+ continue
285
+ # A dequeued message remains unfinished until delivery (and retries) ends.
286
+ with work.all_tasks_done:
287
+ while work.unfinished_tasks:
288
+ remaining = deadline - time.monotonic()
289
+ if remaining <= 0:
290
+ return
291
+ work.all_tasks_done.wait(remaining)
285
292
 
286
293
  def close(self, timeout: float = 5.0):
287
294
  self.flush(timeout)
@@ -191,13 +191,19 @@ def _stalled(w: Window, eps: float = 0.0):
191
191
  def _increasing(w: Window):
192
192
  if not _need(w, 2):
193
193
  return UNKNOWN
194
- return all(b > a for a, b in itertools.pairwise(w.values))
194
+ values = w.values
195
+ if any(not math.isfinite(v) for v in values):
196
+ return UNKNOWN
197
+ return all(b > a for a, b in itertools.pairwise(values))
195
198
 
196
199
 
197
200
  def _decreasing(w: Window):
198
201
  if not _need(w, 2):
199
202
  return UNKNOWN
200
- return all(b < a for a, b in itertools.pairwise(w.values))
203
+ values = w.values
204
+ if any(not math.isfinite(v) for v in values):
205
+ return UNKNOWN
206
+ return all(b < a for a, b in itertools.pairwise(values))
201
207
 
202
208
 
203
209
  # ---------------------------------------------------------------------- scalar
@@ -20,6 +20,7 @@ from dataclasses import dataclass, field
20
20
  from pathlib import Path
21
21
  from typing import Any
22
22
 
23
+ from filelock import FileLock
23
24
  from loguru import logger
24
25
 
25
26
  DEFAULT_TYPE = "dataset"
@@ -220,7 +221,8 @@ class ArtifactStore:
220
221
  )
221
222
  artifact.aliases = sorted({*artifact.aliases, *(aliases or [])} - {LATEST})
222
223
  artifact.digest = self._digest(artifact)
223
- with self._lock:
224
+ self.root.mkdir(parents=True, exist_ok=True)
225
+ with self._lock, FileLock(self.root / ".lock"):
224
226
  # One pass over the index answers both questions: has this exact content
225
227
  # been stored before, and what is the next free version?
226
228
  versions = [a for a in self.entries() if a.name == artifact.name]
@@ -146,7 +146,11 @@ class JsonlWriter:
146
146
 
147
147
  def _rebuild_from_disk(self, size: int):
148
148
  """Rescan from the last anchor that still lies inside the file."""
149
- usable = [entry for entry in self.index if entry[2] < size]
149
+ # Unsorted prefixes can contain a maximum between anchors. Rescan them
150
+ # fully; after truncation even the old metadata's maximum may be gone.
151
+ usable = (
152
+ [entry for entry in self.index if entry[2] < size] if self.sorted else []
153
+ )
150
154
  anchor_step, start_line, start_offset = usable[-1] if usable else (None, 0, 0)
151
155
  self.index = usable[:-1] # the last anchor is re-added while rescanning
152
156
  # No predecessor for the first rescanned line: the anchor line is read again,
@@ -1,22 +1,31 @@
1
1
  """Channel payload construction and HTTP error mapping."""
2
2
 
3
+ import io
3
4
  import json
4
5
  import urllib.error
5
6
  import urllib.request
7
+ from unittest.mock import Mock
6
8
 
7
9
  import pytest
8
10
 
9
11
  from expr_tracker.alerts import backends as backend_module
10
12
  from expr_tracker.alerts.backends import create_backend
11
13
  from expr_tracker.alerts.backends.base import SendError, post_json
12
- from expr_tracker.alerts.models import AlertLevel, AlertMessage, ChannelConfig
14
+ from expr_tracker.alerts.dispatch import Dispatcher
15
+ from expr_tracker.alerts.models import (
16
+ AlertConfig,
17
+ AlertLevel,
18
+ AlertMessage,
19
+ ChannelConfig,
20
+ WebhookPolicy,
21
+ )
13
22
 
14
23
 
15
24
  @pytest.fixture
16
25
  def captured(monkeypatch):
17
26
  sink: list = []
18
27
 
19
- def fake_post(url, payload, timeout, headers=None):
28
+ def fake_post(url, payload, timeout, headers=None, *, retry_on_status=None):
20
29
  sink.append(
21
30
  {"url": url, "payload": payload, "timeout": timeout, "headers": headers}
22
31
  )
@@ -122,6 +131,52 @@ def test_post_json_maps_http_errors(monkeypatch, status, retryable):
122
131
  assert info.value.retry_after == 2.0
123
132
 
124
133
 
134
+ @pytest.mark.parametrize("kind", ["webhook", "slack", "lark", "dingtalk", "wecom"])
135
+ @pytest.mark.parametrize("channel_policy", [False, True])
136
+ @pytest.mark.parametrize(
137
+ ("status", "statuses", "attempts", "sent"),
138
+ [
139
+ (418, (418,), 2, 1),
140
+ (500, (), 1, 0),
141
+ (500, None, 2, 1),
142
+ (418, None, 1, 0),
143
+ ],
144
+ )
145
+ def test_webhook_http_retries_follow_policy(
146
+ monkeypatch, kind, channel_policy, status, statuses, attempts, sent
147
+ ):
148
+ urlopen = Mock(
149
+ side_effect=[
150
+ urllib.error.HTTPError("http://hook", status, "boom", {}, None),
151
+ io.BytesIO(b"{}"),
152
+ ]
153
+ )
154
+ monkeypatch.setattr(urllib.request, "urlopen", urlopen)
155
+ policy = WebhookPolicy(
156
+ async_send=False,
157
+ dedup_window=0,
158
+ rate_limit_per_minute=None,
159
+ max_retries=1,
160
+ backoff_initial=0,
161
+ **({"retry_on_status": statuses} if statuses is not None else {}),
162
+ )
163
+ channel = ChannelConfig(
164
+ type=kind,
165
+ url="http://hook",
166
+ policy=policy if channel_policy else None,
167
+ )
168
+ dispatcher = Dispatcher(
169
+ AlertConfig(
170
+ channels=[channel],
171
+ default_policy=WebhookPolicy() if channel_policy else policy,
172
+ )
173
+ )
174
+ dispatcher.send(message())
175
+ assert urlopen.call_count == attempts
176
+ assert dispatcher.stats()[kind]["sent"] == sent
177
+ assert dispatcher.stats()[kind]["failed"] == 1 - sent
178
+
179
+
125
180
  def test_post_json_maps_network_errors(monkeypatch):
126
181
  def raise_url(*args, **kwargs):
127
182
  raise urllib.error.URLError("no route")
@@ -246,6 +246,79 @@ def test_async_delivery_and_flush():
246
246
  assert len(received) == 10
247
247
 
248
248
 
249
+ @pytest.fixture
250
+ def blocked_delivery():
251
+ started = threading.Event()
252
+ release = threading.Event()
253
+ completed = threading.Event()
254
+
255
+ def handler(msg):
256
+ started.set()
257
+ release.wait()
258
+ completed.set()
259
+
260
+ policy = WebhookPolicy(
261
+ async_send=True, dedup_window=0, rate_limit_per_minute=None, max_retries=0
262
+ )
263
+ dispatcher, _ = make(policy=policy)
264
+ dispatcher.channels["test"].config.options["handler"] = handler
265
+ try:
266
+ dispatcher.send(message())
267
+ assert started.wait(2.0)
268
+ assert dispatcher.channels["test"].pending == 0
269
+ yield dispatcher, release, completed
270
+ finally:
271
+ release.set()
272
+ dispatcher.close(timeout=2.0)
273
+
274
+
275
+ def test_flush_waits_for_inflight_delivery(blocked_delivery, monkeypatch):
276
+ dispatcher, release, completed = blocked_delivery
277
+ work = dispatcher.channels["test"].queue
278
+ waiting = threading.Event()
279
+ flushed = threading.Event()
280
+ original_wait = work.all_tasks_done.wait
281
+
282
+ def wait(timeout=None):
283
+ waiting.set()
284
+ return original_wait(timeout)
285
+
286
+ monkeypatch.setattr(work.all_tasks_done, "wait", wait)
287
+
288
+ def flush():
289
+ dispatcher.flush(timeout=2.0)
290
+ flushed.set()
291
+
292
+ thread = threading.Thread(target=flush)
293
+ thread.start()
294
+ try:
295
+ assert waiting.wait(1.0)
296
+ assert not flushed.is_set()
297
+ assert not completed.is_set()
298
+ release.set()
299
+ assert flushed.wait(1.0)
300
+ assert completed.is_set()
301
+ assert dispatcher.stats()["test"]["sent"] == 1
302
+ assert work.unfinished_tasks == 0
303
+ finally:
304
+ release.set()
305
+ thread.join(timeout=3.0)
306
+ assert not thread.is_alive()
307
+
308
+
309
+ def test_flush_timeout_leaves_inflight_delivery_running(blocked_delivery):
310
+ dispatcher, release, completed = blocked_delivery
311
+ started = time.monotonic()
312
+ dispatcher.flush(timeout=0.05)
313
+ assert time.monotonic() - started >= 0.05
314
+ assert not completed.is_set()
315
+ assert dispatcher.channels["test"].queue.unfinished_tasks == 1
316
+ release.set()
317
+ dispatcher.flush(timeout=2.0)
318
+ assert completed.is_set()
319
+ assert dispatcher.stats()["test"]["sent"] == 1
320
+
321
+
249
322
  def test_queue_full_drops_oldest():
250
323
  policy = WebhookPolicy(
251
324
  async_send=True, dedup_window=0, rate_limit_per_minute=None, queue_size=2
@@ -106,6 +106,35 @@ def test_recovery_notification():
106
106
  assert h.messages[1].level.value == "info"
107
107
 
108
108
 
109
+ @pytest.mark.parametrize(
110
+ ("function", "direction"), [("increasing", 1), ("decreasing", -1)]
111
+ )
112
+ def test_nan_in_trend_window_does_not_recover(function, direction):
113
+ h = Harness(
114
+ {
115
+ "condition": f"{function}(loss[2])",
116
+ "notify_recovery": True,
117
+ "cooldown": None,
118
+ }
119
+ )
120
+ h.feed(loss=direction)
121
+ h.feed(loss=2 * direction)
122
+ assert len(h.messages) == 1
123
+ state = next(iter(h.engine.rules.values())).state
124
+ assert state.firing
125
+
126
+ for metric in (float("nan"), 3 * direction, 4 * direction):
127
+ h.feed(loss=metric)
128
+ assert state.firing
129
+ assert state.fires == 1
130
+ assert len(h.messages) == 1
131
+
132
+ h.feed(loss=4 * direction)
133
+ assert not state.firing
134
+ assert len(h.messages) == 2
135
+ assert h.messages[-1].title.startswith("[recovered]")
136
+
137
+
109
138
  def test_max_fires():
110
139
  h = Harness({"condition": "loss > 5", "max_fires": 1})
111
140
  for value in (10, 1, 10, 1, 10):
@@ -2,12 +2,13 @@
2
2
 
3
3
  import inspect
4
4
  import json
5
+ import multiprocessing
5
6
  from pathlib import Path
6
7
 
7
8
  import pytest
8
9
 
9
10
  import expr_tracker as et
10
- from expr_tracker.artifacts import Artifact, ArtifactStore, coerce_artifact
11
+ from expr_tracker.artifacts import Artifact, ArtifactStore, coerce_artifact, file_digest
11
12
  from expr_tracker.summary import Summary
12
13
 
13
14
 
@@ -60,6 +61,62 @@ def test_identical_contents_reuse_a_version(run, payload):
60
61
  assert third.version == 1
61
62
 
62
63
 
64
+ def _log_shared_artifact(root, source, ready, entered, release):
65
+ store = ArtifactStore(root)
66
+ original_entries = store.entries
67
+
68
+ def entries():
69
+ entered.set()
70
+ if release is not None and not release.wait(10):
71
+ raise TimeoutError("Artifact publisher was not released")
72
+ return original_entries()
73
+
74
+ store.entries = entries
75
+ artifact = Artifact("model").add_file(source, name="weights")
76
+ ready.set()
77
+ store.log(artifact)
78
+
79
+
80
+ @pytest.mark.parametrize("identical", [False, True])
81
+ def test_concurrent_artifact_publishers_share_a_lock(tmp_path, identical):
82
+ context = multiprocessing.get_context("spawn")
83
+ root = tmp_path / "artifacts"
84
+ sources = [tmp_path / "first", tmp_path / "second"]
85
+ sources[0].write_bytes(b"weights-A")
86
+ sources[1].write_bytes(b"weights-A" if identical else b"weights-B")
87
+ ready = [context.Event(), context.Event()]
88
+ entered = [context.Event(), context.Event()]
89
+ release = context.Event()
90
+ processes = [
91
+ context.Process(
92
+ target=_log_shared_artifact,
93
+ args=(root, source, ready[i], entered[i], release if i == 0 else None),
94
+ )
95
+ for i, source in enumerate(sources)
96
+ ]
97
+ started = []
98
+ try:
99
+ processes[0].start()
100
+ started.append(processes[0])
101
+ assert entered[0].wait(10)
102
+ processes[1].start()
103
+ started.append(processes[1])
104
+ assert ready[1].wait(10)
105
+ assert not entered[1].wait(0.2)
106
+ finally:
107
+ release.set()
108
+ for process in started:
109
+ process.join(10)
110
+ if process.is_alive():
111
+ process.terminate()
112
+ process.join(5)
113
+ assert all(process.exitcode == 0 for process in processes)
114
+ artifacts = ArtifactStore(root).entries()
115
+ assert [artifact.version for artifact in artifacts] == ([0] if identical else [0, 1])
116
+ for artifact in artifacts:
117
+ assert artifact.entries[0].digest == file_digest(artifact.get_path("weights"))
118
+
119
+
63
120
  def test_use_artifact_resolution(run, payload, tmp_path):
64
121
  run(backends=[])
65
122
  et.log_artifact(
@@ -86,23 +86,28 @@ def run_pipeline(module, tmp_path, **overrides):
86
86
  return module.main(flat), tmp_path / "pipeline" / "run"
87
87
 
88
88
 
89
- def test_every_worker_gets_its_own_stream(pipeline, tmp_path):
90
- from expr_tracker.history import list_streams
89
+ def test_every_active_worker_gets_its_own_stream(pipeline, tmp_path):
90
+ from expr_tracker.history import list_streams, read_history
91
91
 
92
92
  _, run_dir = run_pipeline(pipeline, tmp_path)
93
- assert sorted(list_streams(run_dir)) == [
94
- "producer0",
95
- "producer1",
96
- "trainer0",
97
- "trainer1",
98
- ]
93
+ trainers = {"trainer0", "trainer1"}
94
+ # A producer that starts after all batches are claimed has no metrics or spans.
95
+ producers = {
96
+ f"producer{row['train/from_producer']}"
97
+ for trainer in trainers
98
+ for row in read_history(run_dir, -1, stream=trainer)
99
+ }
100
+ assert producers
101
+ assert set(list_streams(run_dir)) == trainers | producers
99
102
 
100
103
 
101
- def test_the_trace_has_one_lane_per_worker(pipeline, tmp_path):
102
- output, _ = run_pipeline(pipeline, tmp_path)
104
+ def test_the_trace_has_one_lane_per_active_worker(pipeline, tmp_path):
105
+ from expr_tracker.history import list_streams
106
+
107
+ output, run_dir = run_pipeline(pipeline, tmp_path)
103
108
  events = json.loads(output.read_text())["traceEvents"]
104
109
  names = {e["args"]["name"] for e in events if e.get("name") == "process_name"}
105
- assert names == {"producer0", "producer1", "trainer0", "trainer1"}
110
+ assert names == set(list_streams(run_dir))
106
111
 
107
112
 
108
113
  def test_the_trace_holds_the_whole_span_tree(pipeline, tmp_path):
@@ -135,12 +140,13 @@ def test_the_trainers_consume_exactly_what_they_asked_for(pipeline, tmp_path):
135
140
 
136
141
 
137
142
  def test_the_producers_stop_at_what_was_asked_for(pipeline, tmp_path):
138
- from expr_tracker.history import read_history
143
+ from expr_tracker.history import list_streams, read_history
139
144
 
140
145
  _, run_dir = run_pipeline(pipeline, tmp_path, **{"--steps": 4})
141
146
  made = sum(
142
147
  read_history(run_dir, -1, stream=worker)[-1]["produce/made"]
143
- for worker in ("producer0", "producer1")
148
+ for worker in list_streams(run_dir)
149
+ if worker.startswith("producer")
144
150
  )
145
151
  assert made == 8 # 4 steps x 2 trainers, no overrun
146
152
 
@@ -148,6 +148,15 @@ def test_increasing_and_decreasing_are_strict():
148
148
  assert value("decreasing(m)", context({"m": [3, 3, 1]})) is False
149
149
 
150
150
 
151
+ @pytest.mark.parametrize("function", ["increasing", "decreasing"])
152
+ @pytest.mark.parametrize("nonfinite", [float("nan"), float("inf"), float("-inf")])
153
+ @pytest.mark.parametrize("position", range(4))
154
+ def test_trends_are_unknown_with_nonfinite_values(function, nonfinite, position):
155
+ values = [1, 2, 2, 1]
156
+ values[position] = nonfinite
157
+ assert value(f"{function}(m)", context({"m": values})) is UNKNOWN
158
+
159
+
151
160
  # ------------------------------------------------------------------ empty input
152
161
 
153
162
 
@@ -182,7 +191,16 @@ def test_count_of_an_empty_series_is_zero():
182
191
 
183
192
 
184
193
  @pytest.mark.parametrize(
185
- "expression", ["std(m)", "var(m)", "diff(m)", "slope(m)", "zscore(m)"]
194
+ "expression",
195
+ [
196
+ "std(m)",
197
+ "var(m)",
198
+ "diff(m)",
199
+ "slope(m)",
200
+ "zscore(m)",
201
+ "increasing(m)",
202
+ "decreasing(m)",
203
+ ],
186
204
  )
187
205
  def test_functions_needing_two_points_reject_one(expression):
188
206
  assert value(expression, context({"m": [1]})) is UNKNOWN
@@ -53,7 +53,7 @@ class Posted(list):
53
53
  def posted(monkeypatch):
54
54
  sink = Posted()
55
55
 
56
- def fake_post(url, payload, timeout, headers=None):
56
+ def fake_post(url, payload, timeout, headers=None, *, retry_on_status=None):
57
57
  sink.append({"url": url, "payload": payload, "headers": headers})
58
58
  return sink.reply
59
59
 
@@ -256,7 +256,7 @@ def test_an_unparsable_reply_is_accepted(posted):
256
256
  def test_a_transport_failure_is_never_swallowed(posted, monkeypatch):
257
257
  """Decoding the reply must not shield a POST that failed outright."""
258
258
 
259
- def explode(url, payload, timeout, headers=None):
259
+ def explode(url, payload, timeout, headers=None, *, retry_on_status=None):
260
260
  raise SendError("HTTP 500 from the webhook", retryable=True)
261
261
 
262
262
  monkeypatch.setattr(backend_module, "post_json", explode)
@@ -385,12 +385,15 @@ def test_concurrent_logging_keeps_row_order_and_history(tmp_path):
385
385
  for thread in threads:
386
386
  thread.join()
387
387
  total = 6 * 150
388
- assert [r["_step"] for r in s.get(30)] == list(range(total - 30, total))
389
- s.finish()
390
- assert [r["_step"] for r in s.get(-1)] == list(range(total))
388
+ s.flush()
391
389
  with open(s.log_fp, "rb") as f:
392
390
  steps = [json.loads(line)["_step"] for line in f if line.strip()]
393
- assert len(steps) == total
391
+ assert sorted(steps) == list(range(total))
392
+ # Threads can emit out of step order; tail selection follows write recency,
393
+ # while merge mode sorts the selected rows by step.
394
+ assert [r["_step"] for r in s.get(30)] == sorted(steps[-30:])
395
+ s.finish()
396
+ assert [r["_step"] for r in s.get(-1)] == list(range(total))
394
397
  # An out-of-order file is only safe because merge mode is switched on
395
398
  assert all(b >= a for a, b in itertools.pairwise(steps)) or s._needs_merge
396
399
  finally:
@@ -5,7 +5,12 @@ import os
5
5
 
6
6
  import pytest
7
7
 
8
- from expr_tracker.history import JsonlWriter, read_history, resolve_run_path
8
+ from expr_tracker.history import (
9
+ HistoryStore,
10
+ JsonlWriter,
11
+ read_history,
12
+ resolve_run_path,
13
+ )
9
14
  from expr_tracker.history.writer import MAX_INDEX_ANCHORS
10
15
 
11
16
 
@@ -151,6 +156,45 @@ def test_resume_detects_out_of_order_steps(writer, tmp_path):
151
156
  assert writer().sorted is False
152
157
 
153
158
 
159
+ def test_stale_unsorted_sidecar_preserves_unindexed_maximum(writer, tmp_path):
160
+ first = writer(index_every=2)
161
+ write_steps(first, [0, 100, 1, 2, 3, 4])
162
+ first.close()
163
+ with open(tmp_path / "metrics.jsonl", "ab") as f:
164
+ f.write(line_for(5))
165
+
166
+ store = HistoryStore().init(
167
+ project="p",
168
+ name="r",
169
+ run_dir=str(tmp_path),
170
+ step_policy="allow",
171
+ max_open_seconds=None,
172
+ spans=False,
173
+ )
174
+ try:
175
+ assert store.current_step == 101
176
+ store.log({"v": 101})
177
+ assert store.get(1)[0]["_step"] == 101
178
+ assert store.writer.lines == 8
179
+ finally:
180
+ store.finish()
181
+
182
+
183
+ def test_truncated_unsorted_sidecar_drops_removed_maximum(writer, tmp_path):
184
+ first = writer(index_every=2)
185
+ steps = [0, 10, 1, 2, 100, 3]
186
+ write_steps(first, steps)
187
+ first.close()
188
+ path = tmp_path / "metrics.jsonl"
189
+ path.write_bytes(b"".join(line_for(step, v=step) for step in steps[:4]))
190
+
191
+ resumed = writer(index_every=2)
192
+
193
+ assert resumed.max_step == 10
194
+ assert resumed.lines == 4
195
+ assert resumed.sorted is False
196
+
197
+
154
198
  # ------------------------------------------------------------------ write fail
155
199
 
156
200
 
@@ -461,9 +461,11 @@ name = "expr-tracker"
461
461
  source = { editable = "." }
462
462
  dependencies = [
463
463
  { name = "click" },
464
+ { name = "filelock" },
464
465
  { name = "loguru" },
465
466
  { name = "pydantic" },
466
467
  { name = "python-dotenv" },
468
+ { name = "tomli", marker = "python_full_version < '3.11'" },
467
469
  ]
468
470
 
469
471
  [package.optional-dependencies]
@@ -510,6 +512,7 @@ docs = [
510
512
  [package.metadata]
511
513
  requires-dist = [
512
514
  { name = "click", specifier = ">=8.1.0" },
515
+ { name = "filelock", specifier = ">=3.12" },
513
516
  { name = "loguru", specifier = ">=0.7.3" },
514
517
  { name = "nvidia-ml-py", marker = "extra == 'all'", specifier = ">=12.0" },
515
518
  { name = "nvidia-ml-py", marker = "extra == 'gpu'", specifier = ">=12.0" },
@@ -519,6 +522,7 @@ requires-dist = [
519
522
  { name = "polars", marker = "extra == 'polars'", specifier = ">=0.20" },
520
523
  { name = "pydantic", specifier = ">=2.0" },
521
524
  { name = "python-dotenv", specifier = ">=1.0" },
525
+ { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.0" },
522
526
  { name = "trackio", marker = "extra == 'all'", specifier = ">=0.4.0" },
523
527
  { name = "trackio", marker = "extra == 'trackio'", specifier = ">=0.4.0" },
524
528
  { name = "wandb", marker = "extra == 'all'", specifier = ">=0.21.0" },
File without changes
File without changes
File without changes
File without changes