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.
- expr_tracker-0.3.5/.github/workflows/ci.yaml +39 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/.github/workflows/release.yaml +4 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/PKG-INFO +4 -2
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/README.md +1 -1
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/docs/guide/artifacts.md +3 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/pyproject.toml +2 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/alerts/__init__.py +4 -1
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/alerts/backends/__init__.py +9 -2
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/alerts/backends/base.py +8 -3
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/alerts/dispatch.py +11 -4
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/alerts/expr/functions.py +8 -2
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/artifacts.py +3 -1
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/history/writer.py +5 -1
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_alert_backends.py +57 -2
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_alert_dispatch.py +73 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_alert_engine.py +29 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_artifacts.py +58 -1
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_examples.py +19 -13
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_expr_functions.py +19 -1
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_lark.py +2 -2
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_review_regressions.py +7 -4
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_writer_durability.py +45 -1
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/uv.lock +4 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/.github/workflows/docs.yaml +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/.gitignore +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/LICENSE +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/docs/architecture.md +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/docs/design.md +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/docs/examples.md +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/docs/getting-started.md +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/docs/guide/alerts.md +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/docs/guide/backends.md +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/docs/guide/cli.md +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/docs/guide/distributed.md +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/docs/guide/history.md +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/docs/guide/logging.md +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/docs/guide/spans.md +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/docs/guide/streams.md +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/docs/index.md +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/docs/reference/api.md +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/docs/reference/configuration.md +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/docs/reference/expressions.md +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/examples/README.md +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/examples/alert_rules.py +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/examples/checkpoints.py +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/examples/early_stopping.py +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/examples/multiprocess_pipeline.py +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/examples/profile_step.py +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/examples/quickstart.py +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/mkdocs.yml +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/__init__.py +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/_compat.py +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/alerts/backends/cards.py +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/alerts/engine.py +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/alerts/expr/__init__.py +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/alerts/expr/eval.py +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/alerts/expr/lexer.py +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/alerts/expr/nodes.py +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/alerts/expr/parser.py +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/alerts/expr/rule.py +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/alerts/models.py +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/cli.py +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/encoders.py +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/history/__init__.py +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/history/codec.py +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/history/frame.py +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/history/naming.py +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/history/reader.py +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/history/series.py +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/history/store.py +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/plugins.py +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/py.typed +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/run.py +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/spans.py +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/summary.py +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/trace.py +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/tracker.py +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/src/expr_tracker/types.py +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/conftest.py +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_alert_delivery.py +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_alert_models.py +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_alert_routing.py +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_benchmark.py +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_cache.py +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_cli.py +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_correctness.py +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_distributed.py +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_e2e.py +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_expr_builder.py +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_expr_eval.py +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_expr_parser.py +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_expr_properties.py +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_failure_modes.py +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_features.py +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_frame_codec_summary.py +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_history.py +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_hot_paths.py +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_integration.py +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_perf.py +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_public_surfaces.py +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_rule_lifecycle.py +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_run_backends.py +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_run_dir.py +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_scenarios.py +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_span_plugins.py +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_spans.py +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_step_commit.py +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_streams.py +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_stress.py +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_trace.py +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_trackio.py +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_value_encoding.py +0 -0
- {expr_tracker-0.3.2 → expr_tracker-0.3.5}/tests/test_wandb.py +0 -0
- {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"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: expr_tracker
|
|
3
|
-
Version: 0.3.
|
|
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
|
|
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
|
|
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"])
|
|
@@ -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
|
-
|
|
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,
|
|
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,
|
|
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
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
"
|
|
97
|
-
|
|
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
|
|
102
|
-
|
|
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 ==
|
|
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 (
|
|
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",
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|