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.
- expr_tracker-0.3.5/.github/workflows/ci.yaml +39 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/.github/workflows/release.yaml +4 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/PKG-INFO +5 -2
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/README.md +1 -1
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/docs/guide/artifacts.md +3 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/docs/guide/cli.md +14 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/pyproject.toml +3 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/alerts/__init__.py +4 -1
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/alerts/backends/__init__.py +9 -2
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/alerts/backends/base.py +8 -3
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/alerts/dispatch.py +11 -4
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/alerts/expr/functions.py +8 -2
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/artifacts.py +3 -1
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/cli.py +17 -2
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/history/writer.py +5 -1
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_alert_backends.py +57 -2
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_alert_dispatch.py +73 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_alert_engine.py +29 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_artifacts.py +58 -1
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_cli.py +79 -1
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_examples.py +19 -13
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_expr_functions.py +19 -1
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_lark.py +2 -2
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_review_regressions.py +7 -4
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_writer_durability.py +45 -1
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/uv.lock +6 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/.github/workflows/docs.yaml +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/.gitignore +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/LICENSE +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/docs/architecture.md +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/docs/design.md +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/docs/examples.md +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/docs/getting-started.md +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/docs/guide/alerts.md +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/docs/guide/backends.md +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/docs/guide/distributed.md +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/docs/guide/history.md +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/docs/guide/logging.md +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/docs/guide/spans.md +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/docs/guide/streams.md +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/docs/index.md +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/docs/reference/api.md +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/docs/reference/configuration.md +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/docs/reference/expressions.md +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/examples/README.md +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/examples/alert_rules.py +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/examples/checkpoints.py +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/examples/early_stopping.py +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/examples/multiprocess_pipeline.py +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/examples/profile_step.py +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/examples/quickstart.py +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/mkdocs.yml +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/__init__.py +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/_compat.py +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/alerts/backends/cards.py +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/alerts/engine.py +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/alerts/expr/__init__.py +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/alerts/expr/eval.py +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/alerts/expr/lexer.py +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/alerts/expr/nodes.py +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/alerts/expr/parser.py +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/alerts/expr/rule.py +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/alerts/models.py +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/encoders.py +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/history/__init__.py +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/history/codec.py +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/history/frame.py +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/history/naming.py +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/history/reader.py +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/history/series.py +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/history/store.py +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/plugins.py +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/py.typed +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/run.py +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/spans.py +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/summary.py +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/trace.py +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/tracker.py +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/src/expr_tracker/types.py +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/conftest.py +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_alert_delivery.py +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_alert_models.py +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_alert_routing.py +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_benchmark.py +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_cache.py +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_correctness.py +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_distributed.py +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_e2e.py +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_expr_builder.py +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_expr_eval.py +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_expr_parser.py +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_expr_properties.py +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_failure_modes.py +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_features.py +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_frame_codec_summary.py +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_history.py +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_hot_paths.py +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_integration.py +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_perf.py +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_public_surfaces.py +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_rule_lifecycle.py +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_run_backends.py +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_run_dir.py +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_scenarios.py +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_span_plugins.py +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_spans.py +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_step_commit.py +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_streams.py +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_stress.py +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_trace.py +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_trackio.py +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_value_encoding.py +0 -0
- {expr_tracker-0.3.1 → expr_tracker-0.3.5}/tests/test_wandb.py +0 -0
- {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"
|
|
@@ -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,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
|
|
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
|
|
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.
|
|
@@ -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]
|
|
@@ -4,8 +4,10 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
6
|
import time
|
|
7
|
+
from pathlib import Path
|
|
7
8
|
|
|
8
9
|
import click
|
|
10
|
+
from dotenv import load_dotenv
|
|
9
11
|
|
|
10
12
|
from .alerts import alert as send_alert
|
|
11
13
|
from .alerts.dispatch import Dispatcher
|
|
@@ -26,8 +28,21 @@ def main():
|
|
|
26
28
|
@click.option("--title", default="Alert", help="Title of the alert")
|
|
27
29
|
@click.option("--level", default="info", help="info | warning | error | critical")
|
|
28
30
|
@click.option("--channel", "channels", multiple=True, help="Restrict to these channels")
|
|
29
|
-
|
|
30
|
-
"""
|
|
31
|
+
@click.option(
|
|
32
|
+
"--no-dotenv", is_flag=True, help="Skip loading .env from the current directory"
|
|
33
|
+
)
|
|
34
|
+
def alert(
|
|
35
|
+
msg: str, title: str, level: str, channels: tuple[str, ...], no_dotenv: bool
|
|
36
|
+
):
|
|
37
|
+
"""Send a manual alert, loading the current directory's .env by default."""
|
|
38
|
+
if not no_dotenv:
|
|
39
|
+
try:
|
|
40
|
+
with Path(".env").open(encoding="utf-8") as stream:
|
|
41
|
+
load_dotenv(stream=stream, override=False)
|
|
42
|
+
except FileNotFoundError:
|
|
43
|
+
pass
|
|
44
|
+
except (OSError, UnicodeError) as exc:
|
|
45
|
+
raise click.ClickException(f"Could not read .env: {exc}") from exc
|
|
31
46
|
send_alert(title=title, text=msg, level=level, channels=list(channels) or None)
|
|
32
47
|
|
|
33
48
|
|
|
@@ -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(
|