expr-tracker 0.3.1__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.1 → expr_tracker-0.3.5}/.github/workflows/release.yaml +4 -0
  3. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/PKG-INFO +5 -2
  4. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/README.md +1 -1
  5. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/docs/guide/artifacts.md +3 -0
  6. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/docs/guide/cli.md +14 -0
  7. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/pyproject.toml +3 -0
  8. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/alerts/__init__.py +4 -1
  9. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/alerts/backends/__init__.py +9 -2
  10. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/alerts/backends/base.py +8 -3
  11. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/alerts/dispatch.py +11 -4
  12. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/alerts/expr/functions.py +8 -2
  13. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/artifacts.py +3 -1
  14. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/cli.py +17 -2
  15. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/history/writer.py +5 -1
  16. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_alert_backends.py +57 -2
  17. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_alert_dispatch.py +73 -0
  18. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_alert_engine.py +29 -0
  19. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_artifacts.py +58 -1
  20. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_cli.py +79 -1
  21. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_examples.py +19 -13
  22. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_expr_functions.py +19 -1
  23. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_lark.py +2 -2
  24. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_review_regressions.py +7 -4
  25. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_writer_durability.py +45 -1
  26. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/uv.lock +6 -0
  27. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/.github/workflows/docs.yaml +0 -0
  28. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/.gitignore +0 -0
  29. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/LICENSE +0 -0
  30. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/docs/architecture.md +0 -0
  31. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/docs/design.md +0 -0
  32. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/docs/examples.md +0 -0
  33. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/docs/getting-started.md +0 -0
  34. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/docs/guide/alerts.md +0 -0
  35. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/docs/guide/backends.md +0 -0
  36. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/docs/guide/distributed.md +0 -0
  37. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/docs/guide/history.md +0 -0
  38. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/docs/guide/logging.md +0 -0
  39. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/docs/guide/spans.md +0 -0
  40. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/docs/guide/streams.md +0 -0
  41. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/docs/index.md +0 -0
  42. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/docs/reference/api.md +0 -0
  43. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/docs/reference/configuration.md +0 -0
  44. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/docs/reference/expressions.md +0 -0
  45. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/examples/README.md +0 -0
  46. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/examples/alert_rules.py +0 -0
  47. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/examples/checkpoints.py +0 -0
  48. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/examples/early_stopping.py +0 -0
  49. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/examples/multiprocess_pipeline.py +0 -0
  50. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/examples/profile_step.py +0 -0
  51. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/examples/quickstart.py +0 -0
  52. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/mkdocs.yml +0 -0
  53. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/__init__.py +0 -0
  54. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/_compat.py +0 -0
  55. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/alerts/backends/cards.py +0 -0
  56. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/alerts/engine.py +0 -0
  57. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/alerts/expr/__init__.py +0 -0
  58. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/alerts/expr/eval.py +0 -0
  59. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/alerts/expr/lexer.py +0 -0
  60. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/alerts/expr/nodes.py +0 -0
  61. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/alerts/expr/parser.py +0 -0
  62. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/alerts/expr/rule.py +0 -0
  63. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/alerts/models.py +0 -0
  64. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/encoders.py +0 -0
  65. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/history/__init__.py +0 -0
  66. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/history/codec.py +0 -0
  67. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/history/frame.py +0 -0
  68. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/history/naming.py +0 -0
  69. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/history/reader.py +0 -0
  70. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/history/series.py +0 -0
  71. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/history/store.py +0 -0
  72. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/plugins.py +0 -0
  73. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/py.typed +0 -0
  74. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/run.py +0 -0
  75. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/spans.py +0 -0
  76. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/summary.py +0 -0
  77. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/trace.py +0 -0
  78. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/tracker.py +0 -0
  79. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/types.py +0 -0
  80. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/conftest.py +0 -0
  81. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_alert_delivery.py +0 -0
  82. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_alert_models.py +0 -0
  83. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_alert_routing.py +0 -0
  84. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_benchmark.py +0 -0
  85. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_cache.py +0 -0
  86. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_correctness.py +0 -0
  87. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_distributed.py +0 -0
  88. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_e2e.py +0 -0
  89. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_expr_builder.py +0 -0
  90. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_expr_eval.py +0 -0
  91. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_expr_parser.py +0 -0
  92. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_expr_properties.py +0 -0
  93. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_failure_modes.py +0 -0
  94. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_features.py +0 -0
  95. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_frame_codec_summary.py +0 -0
  96. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_history.py +0 -0
  97. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_hot_paths.py +0 -0
  98. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_integration.py +0 -0
  99. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_perf.py +0 -0
  100. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_public_surfaces.py +0 -0
  101. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_rule_lifecycle.py +0 -0
  102. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_run_backends.py +0 -0
  103. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_run_dir.py +0 -0
  104. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_scenarios.py +0 -0
  105. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_span_plugins.py +0 -0
  106. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_spans.py +0 -0
  107. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_step_commit.py +0 -0
  108. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_streams.py +0 -0
  109. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_stress.py +0 -0
  110. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_trace.py +0 -0
  111. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_trackio.py +0 -0
  112. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_value_encoding.py +0 -0
  113. {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_wandb.py +0 -0
  114. {expr_tracker-0.3.1 → 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.1
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,8 +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
31
+ Requires-Dist: python-dotenv>=1.0
32
+ Requires-Dist: tomli>=2.0; python_version < '3.11'
30
33
  Provides-Extra: all
31
34
  Requires-Dist: nvidia-ml-py>=12.0; extra == 'all'
32
35
  Requires-Dist: pandas>=1.5; extra == 'all'
@@ -86,7 +89,7 @@ et.history(-1, output_type="pd") # everything, as a DataFrame
86
89
  ## Install
87
90
 
88
91
  ```bash
89
- uv add expr_tracker # local-first: click, loguru, pydantic only
92
+ uv add expr_tracker # local-first; no remote backend required
90
93
  uv add "expr_tracker[wandb]" # mirror to Weights & Biases
91
94
  uv add "expr_tracker[trackio]" # mirror to trackio
92
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"])
@@ -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.
@@ -32,8 +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",
38
+ "python-dotenv>=1.0",
39
+ "tomli>=2.0; python_version < '3.11'",
37
40
  ]
38
41
 
39
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]
@@ -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
 
@@ -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(