expr-tracker 0.2.0__tar.gz → 0.2.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/.gitignore +5 -0
- expr_tracker-0.2.2/LICENSE +21 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/PKG-INFO +31 -2
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/README.md +9 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/docs/design.md +10 -2
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/docs/guide/alerts.md +66 -2
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/docs/reference/configuration.md +2 -1
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/docs/reference/expressions.md +38 -3
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/pyproject.toml +34 -1
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/src/expr_tracker/alerts/backends/__init__.py +5 -1
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/src/expr_tracker/alerts/backends/base.py +70 -1
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/src/expr_tracker/alerts/engine.py +6 -1
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/src/expr_tracker/alerts/expr/lexer.py +24 -5
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/src/expr_tracker/alerts/expr/nodes.py +20 -2
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/src/expr_tracker/alerts/models.py +3 -0
- expr_tracker-0.2.2/src/expr_tracker/py.typed +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_alert_backends.py +179 -1
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_expr_builder.py +2 -1
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_expr_parser.py +16 -7
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_expr_properties.py +7 -3
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_features.py +119 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_rule_lifecycle.py +31 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/.github/workflows/docs.yaml +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/.github/workflows/release.yaml +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/docs/architecture.md +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/docs/getting-started.md +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/docs/guide/artifacts.md +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/docs/guide/backends.md +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/docs/guide/cli.md +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/docs/guide/distributed.md +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/docs/guide/history.md +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/docs/guide/logging.md +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/docs/index.md +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/docs/reference/api.md +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/mkdocs.yml +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/src/expr_tracker/__init__.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/src/expr_tracker/_compat.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/src/expr_tracker/alerts/__init__.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/src/expr_tracker/alerts/dispatch.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/src/expr_tracker/alerts/expr/__init__.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/src/expr_tracker/alerts/expr/eval.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/src/expr_tracker/alerts/expr/functions.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/src/expr_tracker/alerts/expr/parser.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/src/expr_tracker/alerts/expr/rule.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/src/expr_tracker/artifacts.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/src/expr_tracker/cli.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/src/expr_tracker/encoders.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/src/expr_tracker/history/__init__.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/src/expr_tracker/history/codec.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/src/expr_tracker/history/frame.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/src/expr_tracker/history/reader.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/src/expr_tracker/history/series.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/src/expr_tracker/history/store.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/src/expr_tracker/history/writer.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/src/expr_tracker/run.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/src/expr_tracker/summary.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/src/expr_tracker/tracker.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/src/expr_tracker/types.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/conftest.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_alert_delivery.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_alert_dispatch.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_alert_engine.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_alert_models.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_alert_routing.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_artifacts.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_benchmark.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_cache.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_cli.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_correctness.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_distributed.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_e2e.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_expr_eval.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_expr_functions.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_failure_modes.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_frame_codec_summary.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_history.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_hot_paths.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_integration.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_lark_live.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_perf.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_public_surfaces.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_review_regressions.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_run_backends.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_scenarios.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_step_commit.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_stress.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_trackio.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_value_encoding.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_wandb.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_writer_buffer.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_writer_durability.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.2}/uv.lock +0 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Hangxing Wei
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -1,8 +1,28 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: expr_tracker
|
|
3
|
-
Version: 0.2.
|
|
4
|
-
Summary:
|
|
3
|
+
Version: 0.2.2
|
|
4
|
+
Summary: Local-first experiment tracking with queryable history and expression-based alerts on your training metrics
|
|
5
|
+
Project-URL: Homepage, https://hspk.github.io/expr_tracker/
|
|
6
|
+
Project-URL: Documentation, https://hspk.github.io/expr_tracker/
|
|
7
|
+
Project-URL: Repository, https://github.com/HSPK/expr_tracker
|
|
8
|
+
Project-URL: Issues, https://github.com/HSPK/expr_tracker/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/HSPK/expr_tracker/releases
|
|
5
10
|
Author-email: HSPK <whxway@whu.edu.cn>
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: alerting,deep-learning,experiment-tracking,machine-learning,mlops,monitoring,training,wandb
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Intended Audience :: Science/Research
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
24
|
+
Classifier: Topic :: System :: Monitoring
|
|
25
|
+
Classifier: Typing :: Typed
|
|
6
26
|
Requires-Python: >=3.10
|
|
7
27
|
Requires-Dist: click>=8.1.0
|
|
8
28
|
Requires-Dist: loguru>=0.7.3
|
|
@@ -27,6 +47,11 @@ Description-Content-Type: text/markdown
|
|
|
27
47
|
|
|
28
48
|
# Experiment Tracker
|
|
29
49
|
|
|
50
|
+
[](https://pypi.org/project/expr-tracker/)
|
|
51
|
+
[](https://pypi.org/project/expr-tracker/)
|
|
52
|
+
[](LICENSE)
|
|
53
|
+
[](https://hspk.github.io/expr_tracker/)
|
|
54
|
+
|
|
30
55
|
A local-first experiment tracker. Metrics land in a JSONL file you own, stay
|
|
31
56
|
queryable while the run is live, and can trigger alerts from an expression language.
|
|
32
57
|
`wandb` and `trackio` are optional mirrors, not requirements.
|
|
@@ -141,3 +166,7 @@ Published to GitHub Pages by `.github/workflows/docs.yaml` on every push to `mai
|
|
|
141
166
|
Internals: [`docs/design.md`](docs/design.md) (data model and key invariants) and
|
|
142
167
|
[`docs/architecture.md`](docs/architecture.md) (module map, read/write paths,
|
|
143
168
|
concurrency model).
|
|
169
|
+
|
|
170
|
+
## License
|
|
171
|
+
|
|
172
|
+
[MIT](LICENSE)
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# Experiment Tracker
|
|
2
2
|
|
|
3
|
+
[](https://pypi.org/project/expr-tracker/)
|
|
4
|
+
[](https://pypi.org/project/expr-tracker/)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
[](https://hspk.github.io/expr_tracker/)
|
|
7
|
+
|
|
3
8
|
A local-first experiment tracker. Metrics land in a JSONL file you own, stay
|
|
4
9
|
queryable while the run is live, and can trigger alerts from an expression language.
|
|
5
10
|
`wandb` and `trackio` are optional mirrors, not requirements.
|
|
@@ -114,3 +119,7 @@ Published to GitHub Pages by `.github/workflows/docs.yaml` on every push to `mai
|
|
|
114
119
|
Internals: [`docs/design.md`](docs/design.md) (data model and key invariants) and
|
|
115
120
|
[`docs/architecture.md`](docs/architecture.md) (module map, read/write paths,
|
|
116
121
|
concurrency model).
|
|
122
|
+
|
|
123
|
+
## License
|
|
124
|
+
|
|
125
|
+
[MIT](LICENSE)
|
|
@@ -199,15 +199,23 @@ unary := ("-"|"+") unary | postfix
|
|
|
199
199
|
postfix := primary ("[" window "]")?
|
|
200
200
|
primary := NUMBER | DURATION | ident | ident "(" args ")" | "(" expr ")"
|
|
201
201
|
window := INT (points) | DURATION (30s / 5m / 2h)
|
|
202
|
-
ident := [A-Za-z_][A-Za-z0-9_
|
|
202
|
+
ident := [A-Za-z_][A-Za-z0-9_.@]* ("/" [A-Za-z0-9_.@]+)* # train/loss, val/m1/acc@16
|
|
203
|
+
| "quoted" | 'quoted' | `quoted` # anything else
|
|
203
204
|
```
|
|
204
205
|
|
|
206
|
+
A `/` joins an identifier only when a name character follows it immediately, so
|
|
207
|
+
division has to be spaced: `a/b` is one metric, `a / b` divides. That makes the
|
|
208
|
+
common case (`train/loss`) free of ceremony at the cost of `loss/2` naming a metric
|
|
209
|
+
rather than halving `loss`. Quoted forms produce names, never string literals — the
|
|
210
|
+
grammar has no strings, and the function table is a closed whitelist.
|
|
211
|
+
|
|
205
212
|
A hand-written lexer and Pratt parser, **not Python's `ast`**: Python reads
|
|
206
213
|
`diff(m1)>50 | m1>5` as `diff(m1) > (50|m1) > 5`. Precedence, low to high:
|
|
207
214
|
`or < and < not < comparison < +- < */% < unary < call/window`.
|
|
208
215
|
|
|
209
216
|
**Metric resolution**: exact name → `.` replaced by `/` → error with close-match
|
|
210
|
-
suggestions. So `eval.acc` finds `eval/acc`;
|
|
217
|
+
suggestions. So `eval.acc` finds `eval/acc`; quote anything the bare grammar cannot
|
|
218
|
+
express.
|
|
211
219
|
|
|
212
220
|
### C.2 Three-valued logic (Kleene)
|
|
213
221
|
|
|
@@ -29,8 +29,9 @@ Key points, covered fully in [Alert expressions](../reference/expressions.md):
|
|
|
29
29
|
`loss[5m]` the last 5 minutes.
|
|
30
30
|
- Precedence is `or < and < not < comparison < arithmetic` — **not** Python's. So
|
|
31
31
|
`diff(m1) > 50 | m1 > 5` parses the way it reads, as `(diff(m1) > 50) or (m1 > 5)`.
|
|
32
|
-
-
|
|
33
|
-
|
|
32
|
+
- Metric names may contain `/` and `@` directly (`train/loss`, `val/m1/acc@16`), so
|
|
33
|
+
division has to be spaced: `a/b` is one metric, `a / b` divides. Quote anything
|
|
34
|
+
else with `"`, `'` or backticks: `"train loss"`.
|
|
34
35
|
- Missing data, too few points, NaN and division by zero all evaluate to UNKNOWN,
|
|
35
36
|
which never fires and never changes state. Warm-up cannot produce false alarms.
|
|
36
37
|
|
|
@@ -92,6 +93,69 @@ Built-in types: `lark`, `slack`, `dingtalk`, `wecom`, `webhook` (a generic JSON
|
|
|
92
93
|
template), `email`, `callable`. All but `lark` use only the standard library. Add
|
|
93
94
|
your own with `register_backend()`.
|
|
94
95
|
|
|
96
|
+
### Email
|
|
97
|
+
|
|
98
|
+
Email needs an SMTP server to send *from*, even when the recipient is Gmail.
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
et.init(
|
|
102
|
+
project="demo",
|
|
103
|
+
alert={
|
|
104
|
+
"channels": [
|
|
105
|
+
{
|
|
106
|
+
"type": "email",
|
|
107
|
+
"name": "inbox",
|
|
108
|
+
"options": {
|
|
109
|
+
"host": "smtp.gmail.com",
|
|
110
|
+
"port": 587,
|
|
111
|
+
"tls": True,
|
|
112
|
+
"user": "you@gmail.com",
|
|
113
|
+
"password": os.environ["SMTP_PASSWORD"],
|
|
114
|
+
"sender": "you@gmail.com",
|
|
115
|
+
"to": ["you@gmail.com", "teammate@example.com"],
|
|
116
|
+
},
|
|
117
|
+
"min_level": "error",
|
|
118
|
+
}
|
|
119
|
+
]
|
|
120
|
+
},
|
|
121
|
+
alert_rules=["isnan(loss) => critical: loss diverged"],
|
|
122
|
+
)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
| Option | Default | Meaning |
|
|
126
|
+
| --- | --- | --- |
|
|
127
|
+
| `host` | — | SMTP server, **required** |
|
|
128
|
+
| `to` | — | one address or a list, **required** |
|
|
129
|
+
| `port` | 465 with `ssl`, else 25 | |
|
|
130
|
+
| `tls` | `false` | STARTTLS on a plain connection (port 587) |
|
|
131
|
+
| `ssl` | `false` | implicit TLS from the start (port 465) |
|
|
132
|
+
| `user` / `password` | — | omit both for an unauthenticated relay |
|
|
133
|
+
| `sender` | `user`, else `expr-tracker` | the `From` address |
|
|
134
|
+
| `html` | `true` | send the HTML part as well as the text |
|
|
135
|
+
|
|
136
|
+
Use `tls` **or** `ssl`, not both: `tls` upgrades a plain connection, `ssl` starts
|
|
137
|
+
encrypted.
|
|
138
|
+
|
|
139
|
+
!!! warning "Keep the password out of your code"
|
|
140
|
+
Read it from the environment, as above. Gmail additionally rejects account
|
|
141
|
+
passwords for SMTP — turn on 2-step verification and create an
|
|
142
|
+
[app password](https://myaccount.google.com/apppasswords), which you can
|
|
143
|
+
revoke independently of your account.
|
|
144
|
+
|
|
145
|
+
Mail is sent as `multipart/alternative`: a severity-coloured HTML card with the
|
|
146
|
+
fields as a table, plus the plain text as a fallback for clients that will not
|
|
147
|
+
render HTML. Set `html: false` for text only.
|
|
148
|
+
|
|
149
|
+
Common servers:
|
|
150
|
+
|
|
151
|
+
| Provider | host | port | setting |
|
|
152
|
+
| --- | --- | --- | --- |
|
|
153
|
+
| Gmail | `smtp.gmail.com` | 587 | `tls: true` (app password required) |
|
|
154
|
+
| Outlook / Office 365 | `smtp.office365.com` | 587 | `tls: true` |
|
|
155
|
+
| QQ / 163 | `smtp.qq.com`, `smtp.163.com` | 465 | `ssl: true` (authorisation code) |
|
|
156
|
+
| SendGrid | `smtp.sendgrid.net` | 587 | `tls: true`, user `apikey` |
|
|
157
|
+
| Internal relay | your host | 25 | often no `user`/`password` |
|
|
158
|
+
|
|
95
159
|
### Routing
|
|
96
160
|
|
|
97
161
|
Each channel filters independently:
|
|
@@ -79,6 +79,7 @@ alert:
|
|
|
79
79
|
| `levels` | an exact allowlist, overriding `min_level` |
|
|
80
80
|
| `tags` | deliver only if the message tags intersect |
|
|
81
81
|
| `options` | backend-specific settings |
|
|
82
|
+
| `options.html` | email only: send an HTML part alongside the text (default `true`) |
|
|
82
83
|
| `policy` | overrides the default delivery policy |
|
|
83
84
|
|
|
84
85
|
### Delivery policy
|
|
@@ -104,7 +105,7 @@ alert:
|
|
|
104
105
|
| `condition` | — | the expression (alias: `expr`) |
|
|
105
106
|
| `name` | derived | identifies the rule (alias: `alert`) |
|
|
106
107
|
| `level` | `warning` | `debug`, `info`, `warning`, `error`, `critical` |
|
|
107
|
-
| `title` / `message` | derived | supports templates |
|
|
108
|
+
| `title` / `message` | derived | supports templates; the title defaults to `name` when you set one, else to the condition |
|
|
108
109
|
| `mode` | `"edge"` | `edge` fires on transition, `level` keeps firing |
|
|
109
110
|
| `for_steps` | 1 | must hold this many consecutive steps (alias: `for`) |
|
|
110
111
|
| `cooldown` | 300.0 | seconds between repeats in level mode |
|
|
@@ -30,9 +30,44 @@ Precedence is `or < and < not < comparison < +- < */%`.
|
|
|
30
30
|
|
|
31
31
|
## Metric names
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
Bare names may contain letters, digits, `_`, `.`, `@` and `/`, so the names ML code
|
|
34
|
+
actually uses need no ceremony:
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
train/loss > 1
|
|
38
|
+
val/m1/acc@16 < 0.5
|
|
39
|
+
mean(train/loss[20]) > 1
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`.` also resolves to `/`, so `eval.acc` finds `eval/acc` if no metric is literally
|
|
43
|
+
called `eval.acc`.
|
|
44
|
+
|
|
45
|
+
### `/` is part of a name unless it is spaced
|
|
46
|
+
|
|
47
|
+
A `/` joins the name when a name character follows it immediately. Division must
|
|
48
|
+
therefore be written with spaces:
|
|
49
|
+
|
|
50
|
+
| Written | Means |
|
|
51
|
+
| --- | --- |
|
|
52
|
+
| `a/b` | the metric `a/b` |
|
|
53
|
+
| `a / b` | `a` divided by `b` |
|
|
54
|
+
| `a /b`, `a/ b` | `a` divided by `b` (any space makes it division) |
|
|
55
|
+
| `loss/2` | the metric `loss/2` |
|
|
56
|
+
| `loss / 2` | `loss` divided by 2 |
|
|
57
|
+
| `1/2` | division — a name cannot start with a digit |
|
|
58
|
+
|
|
59
|
+
### Quote anything else
|
|
60
|
+
|
|
61
|
+
A name with spaces or other characters must be quoted, with `"`, `'` or `` ` ``:
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
"train loss" > 1
|
|
65
|
+
'val acc@16' < 0.5
|
|
66
|
+
`a-b` > 1
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Quotes only ever produce a metric name; the language has no string literals.
|
|
70
|
+
`to_source()` renders with `"` (or another quote if the name contains one).
|
|
36
71
|
|
|
37
72
|
## Windows
|
|
38
73
|
|
|
@@ -1,9 +1,35 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "expr_tracker"
|
|
3
|
-
description = "
|
|
3
|
+
description = "Local-first experiment tracking with queryable history and expression-based alerts on your training metrics"
|
|
4
4
|
readme = "README.md"
|
|
5
|
+
license = "MIT"
|
|
6
|
+
license-files = ["LICENSE"]
|
|
5
7
|
authors = [{ name = "HSPK", email = "whxway@whu.edu.cn" }]
|
|
6
8
|
requires-python = ">=3.10"
|
|
9
|
+
keywords = [
|
|
10
|
+
"machine-learning",
|
|
11
|
+
"experiment-tracking",
|
|
12
|
+
"mlops",
|
|
13
|
+
"monitoring",
|
|
14
|
+
"alerting",
|
|
15
|
+
"training",
|
|
16
|
+
"deep-learning",
|
|
17
|
+
"wandb",
|
|
18
|
+
]
|
|
19
|
+
classifiers = [
|
|
20
|
+
"Development Status :: 4 - Beta",
|
|
21
|
+
"Intended Audience :: Developers",
|
|
22
|
+
"Intended Audience :: Science/Research",
|
|
23
|
+
"Operating System :: OS Independent",
|
|
24
|
+
"Programming Language :: Python :: 3",
|
|
25
|
+
"Programming Language :: Python :: 3.10",
|
|
26
|
+
"Programming Language :: Python :: 3.11",
|
|
27
|
+
"Programming Language :: Python :: 3.12",
|
|
28
|
+
"Programming Language :: Python :: 3.13",
|
|
29
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
30
|
+
"Topic :: System :: Monitoring",
|
|
31
|
+
"Typing :: Typed",
|
|
32
|
+
]
|
|
7
33
|
dependencies = [
|
|
8
34
|
"click>=8.1.0",
|
|
9
35
|
"loguru>=0.7.3",
|
|
@@ -27,6 +53,13 @@ all = [
|
|
|
27
53
|
"polars>=0.20",
|
|
28
54
|
]
|
|
29
55
|
|
|
56
|
+
[project.urls]
|
|
57
|
+
Homepage = "https://hspk.github.io/expr_tracker/"
|
|
58
|
+
Documentation = "https://hspk.github.io/expr_tracker/"
|
|
59
|
+
Repository = "https://github.com/HSPK/expr_tracker"
|
|
60
|
+
Issues = "https://github.com/HSPK/expr_tracker/issues"
|
|
61
|
+
Changelog = "https://github.com/HSPK/expr_tracker/releases"
|
|
62
|
+
|
|
30
63
|
[project.scripts]
|
|
31
64
|
et = "expr_tracker.cli:main"
|
|
32
65
|
|
|
@@ -13,6 +13,7 @@ from .base import (
|
|
|
13
13
|
create_backend,
|
|
14
14
|
post_json,
|
|
15
15
|
register_backend,
|
|
16
|
+
render_html,
|
|
16
17
|
render_text,
|
|
17
18
|
)
|
|
18
19
|
|
|
@@ -189,7 +190,10 @@ class EmailBackend(AlertBackend):
|
|
|
189
190
|
mail["Subject"] = f"[{message.level.value}] {message.title}"
|
|
190
191
|
mail["From"] = options.get("sender") or options.get("user") or "expr-tracker"
|
|
191
192
|
mail["To"] = ", ".join(recipients)
|
|
192
|
-
|
|
193
|
+
# multipart/alternative: rich clients show the card, the rest see the text
|
|
194
|
+
mail.set_content(f"{message.title}\n\n{render_text(message)}")
|
|
195
|
+
if options.get("html", True):
|
|
196
|
+
mail.add_alternative(render_html(message), subtype="html")
|
|
193
197
|
policy_timeout = self.config.policy.timeout if self.config.policy else 10.0
|
|
194
198
|
try:
|
|
195
199
|
factory = smtplib.SMTP_SSL if options.get("ssl") else smtplib.SMTP
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import html
|
|
5
6
|
import json
|
|
6
7
|
import urllib.error
|
|
7
8
|
import urllib.parse
|
|
@@ -85,7 +86,7 @@ def post_json(
|
|
|
85
86
|
|
|
86
87
|
def render_text(message: AlertMessage, include_fields: bool = True) -> str:
|
|
87
88
|
"""Render a message as plain text for channels without rich cards."""
|
|
88
|
-
parts = [message.text]
|
|
89
|
+
parts = [message.subtitle, message.text] if message.subtitle else [message.text]
|
|
89
90
|
if include_fields and message.fields:
|
|
90
91
|
parts.append("")
|
|
91
92
|
parts.extend(f"{key}: {value}" for key, value in message.fields.items())
|
|
@@ -97,6 +98,74 @@ def render_text(message: AlertMessage, include_fields: bool = True) -> str:
|
|
|
97
98
|
return "\n".join(parts)
|
|
98
99
|
|
|
99
100
|
|
|
101
|
+
LEVEL_COLORS = {
|
|
102
|
+
"debug": "#616161",
|
|
103
|
+
"info": "#0288d1",
|
|
104
|
+
"warning": "#ed6c02",
|
|
105
|
+
"error": "#d32f2f",
|
|
106
|
+
"critical": "#b71c1c",
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def render_html(message: AlertMessage) -> str:
|
|
111
|
+
"""Render a message as self-contained HTML for email.
|
|
112
|
+
|
|
113
|
+
Everything is inline-styled and table-based: mail clients strip stylesheets,
|
|
114
|
+
and several of them ignore ``div`` layout entirely.
|
|
115
|
+
"""
|
|
116
|
+
color = LEVEL_COLORS.get(message.level.value, "#616161")
|
|
117
|
+
esc = html.escape
|
|
118
|
+
rows = "".join(
|
|
119
|
+
f'<tr><td style="padding:4px 12px 4px 0;color:#666;'
|
|
120
|
+
f'white-space:nowrap;vertical-align:top">{esc(str(key))}</td>'
|
|
121
|
+
f'<td style="padding:4px 0;color:#111">{esc(str(value))}</td></tr>'
|
|
122
|
+
for key, value in message.fields.items()
|
|
123
|
+
)
|
|
124
|
+
blocks = [
|
|
125
|
+
f'<tr><td style="background:{color};padding:14px 20px;color:#fff">'
|
|
126
|
+
f'<div style="font-size:17px;font-weight:600">{esc(message.title)}</div>'
|
|
127
|
+
f'<div style="font-size:12px;opacity:.85;text-transform:uppercase;'
|
|
128
|
+
f'letter-spacing:.05em">{esc(message.level.value)}'
|
|
129
|
+
+ (f" · {esc(message.subtitle)}" if message.subtitle else "")
|
|
130
|
+
+ "</div></td></tr>"
|
|
131
|
+
]
|
|
132
|
+
if message.text:
|
|
133
|
+
blocks.append(
|
|
134
|
+
'<tr><td style="padding:18px 20px 0;font-size:14px;line-height:1.5;'
|
|
135
|
+
f'color:#111">{esc(message.text)}</td></tr>'
|
|
136
|
+
)
|
|
137
|
+
if rows:
|
|
138
|
+
blocks.append(
|
|
139
|
+
'<tr><td style="padding:16px 20px 0"><table cellpadding="0" '
|
|
140
|
+
f'cellspacing="0" style="font-size:13px">{rows}</table></td></tr>'
|
|
141
|
+
)
|
|
142
|
+
if message.traceback:
|
|
143
|
+
blocks.append(
|
|
144
|
+
'<tr><td style="padding:16px 20px 0"><pre style="margin:0;padding:12px;'
|
|
145
|
+
"background:#f6f6f6;border-radius:4px;font-size:12px;overflow:auto;"
|
|
146
|
+
f'white-space:pre-wrap">{esc(message.traceback)}</pre></td></tr>'
|
|
147
|
+
)
|
|
148
|
+
if message.link:
|
|
149
|
+
link = esc(message.link)
|
|
150
|
+
blocks.append(
|
|
151
|
+
f'<tr><td style="padding:18px 20px 0"><a href="{link}" '
|
|
152
|
+
f'style="display:inline-block;padding:8px 16px;background:{color};'
|
|
153
|
+
'color:#fff;text-decoration:none;border-radius:4px;font-size:13px">'
|
|
154
|
+
"Open run</a></td></tr>"
|
|
155
|
+
)
|
|
156
|
+
blocks.append('<tr><td style="padding:20px"></td></tr>')
|
|
157
|
+
return (
|
|
158
|
+
'<html><body style="margin:0;background:#f4f4f4;'
|
|
159
|
+
'font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif">'
|
|
160
|
+
'<table cellpadding="0" cellspacing="0" width="100%" '
|
|
161
|
+
'style="background:#f4f4f4;padding:24px 0"><tr><td align="center">'
|
|
162
|
+
'<table cellpadding="0" cellspacing="0" width="520" '
|
|
163
|
+
'style="background:#fff;border-radius:6px;overflow:hidden;max-width:520px">'
|
|
164
|
+
+ "".join(blocks)
|
|
165
|
+
+ "</table></td></tr></table></body></html>"
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
100
169
|
def _parse_retry_after(value) -> float | None:
|
|
101
170
|
try:
|
|
102
171
|
return float(value)
|
|
@@ -209,7 +209,12 @@ class AlertEngine:
|
|
|
209
209
|
except Exception as e: # pragma: no cover - format_map already tolerates gaps
|
|
210
210
|
logger.warning(f"Failed to render message for rule {rule.name!r}: {e}")
|
|
211
211
|
rendered = rule.condition
|
|
212
|
-
|
|
212
|
+
if rule.title:
|
|
213
|
+
title = _format(rule.title, values)
|
|
214
|
+
else:
|
|
215
|
+
# An auto-generated name is a hash; the condition says far more,
|
|
216
|
+
# especially as an email subject line
|
|
217
|
+
title = rule.condition if getattr(rule, "auto_named", False) else rule.name
|
|
213
218
|
level = AlertLevel.INFO if recovered else rule.level
|
|
214
219
|
message = AlertMessage(
|
|
215
220
|
title=f"[recovered] {title}" if recovered else title,
|
|
@@ -11,6 +11,7 @@ OP = "OP"
|
|
|
11
11
|
EOF = "EOF"
|
|
12
12
|
|
|
13
13
|
KEYWORDS = {"and", "or", "not", "true", "false"}
|
|
14
|
+
QUOTES = "\"'`"
|
|
14
15
|
TWO_CHAR_OPS = {">=", "<=", "==", "!=", "&&", "||"}
|
|
15
16
|
ONE_CHAR_OPS = set("><+-*/%()[],|&!~")
|
|
16
17
|
DURATION_UNITS = (
|
|
@@ -51,10 +52,10 @@ def tokenize(source: str) -> list[Token]:
|
|
|
51
52
|
i += 1
|
|
52
53
|
continue
|
|
53
54
|
start = i
|
|
54
|
-
if ch
|
|
55
|
-
end = source.find(
|
|
55
|
+
if ch in QUOTES:
|
|
56
|
+
end = source.find(ch, i + 1)
|
|
56
57
|
if end < 0:
|
|
57
|
-
raise ExprSyntaxError("Unterminated
|
|
58
|
+
raise ExprSyntaxError(f"Unterminated {ch}quoted name{ch}", source, i)
|
|
58
59
|
tokens.append(Token(NAME, source[i + 1 : end], start))
|
|
59
60
|
i = end + 1
|
|
60
61
|
continue
|
|
@@ -69,8 +70,7 @@ def tokenize(source: str) -> list[Token]:
|
|
|
69
70
|
tokens.append(Token(NUMBER, float(text), start))
|
|
70
71
|
continue
|
|
71
72
|
if ch.isalpha() or ch == "_":
|
|
72
|
-
|
|
73
|
-
i += 1
|
|
73
|
+
i = scan_name(source, i)
|
|
74
74
|
word = source[start:i]
|
|
75
75
|
tokens.append(
|
|
76
76
|
Token(
|
|
@@ -110,6 +110,25 @@ def parse_duration(text: str) -> float:
|
|
|
110
110
|
) from e
|
|
111
111
|
|
|
112
112
|
|
|
113
|
+
def is_name_char(ch: str) -> bool:
|
|
114
|
+
return ch.isalnum() or ch in "_.@"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def scan_name(source: str, i: int) -> int:
|
|
118
|
+
"""Consume a metric name, which may contain ``/`` as in ``val/m1/acc@16``.
|
|
119
|
+
|
|
120
|
+
A ``/`` only joins the name when a name character follows it immediately, so
|
|
121
|
+
division has to be spaced: ``a / b`` divides, ``a/b`` is one metric.
|
|
122
|
+
"""
|
|
123
|
+
n = len(source)
|
|
124
|
+
while i < n and (
|
|
125
|
+
is_name_char(source[i])
|
|
126
|
+
or (source[i] == "/" and i + 1 < n and is_name_char(source[i + 1]))
|
|
127
|
+
):
|
|
128
|
+
i += 1
|
|
129
|
+
return i
|
|
130
|
+
|
|
131
|
+
|
|
113
132
|
def _scan_number(source: str, i: int) -> int:
|
|
114
133
|
n = len(source)
|
|
115
134
|
while i < n and (source[i].isdigit() or source[i] == "."):
|
|
@@ -113,7 +113,7 @@ class MetricRef(Node):
|
|
|
113
113
|
name: str
|
|
114
114
|
|
|
115
115
|
def to_source(self) -> str:
|
|
116
|
-
return self.name if _is_plain(self.name) else
|
|
116
|
+
return self.name if _is_plain(self.name) else _quote(self.name)
|
|
117
117
|
|
|
118
118
|
def metrics(self) -> set[str]:
|
|
119
119
|
return {self.name}
|
|
@@ -342,9 +342,27 @@ def _wrap(node: Node, parent_op: str | None = None) -> str:
|
|
|
342
342
|
|
|
343
343
|
|
|
344
344
|
def _is_plain(name: str) -> bool:
|
|
345
|
+
"""Whether the name can be written bare and lex back to exactly itself."""
|
|
346
|
+
from .lexer import is_name_char
|
|
347
|
+
|
|
345
348
|
if not name or not (name[0].isalpha() or name[0] == "_"):
|
|
346
349
|
return False
|
|
347
|
-
|
|
350
|
+
for index, ch in enumerate(name):
|
|
351
|
+
if is_name_char(ch):
|
|
352
|
+
continue
|
|
353
|
+
# A `/` only joins the name when a name character follows it
|
|
354
|
+
if ch == "/" and index + 1 < len(name) and is_name_char(name[index + 1]):
|
|
355
|
+
continue
|
|
356
|
+
return False
|
|
357
|
+
return True
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _quote(name: str) -> str:
|
|
361
|
+
"""Wrap a name in whichever quote it does not already contain."""
|
|
362
|
+
for quote in ('"', "'", "`"):
|
|
363
|
+
if quote not in name:
|
|
364
|
+
return f"{quote}{name}{quote}"
|
|
365
|
+
return f'"{name}"' # pragma: no cover - a name with all three quote styles
|
|
348
366
|
|
|
349
367
|
|
|
350
368
|
def _format_duration(seconds: float | None) -> str:
|
|
@@ -230,6 +230,9 @@ class AlertRule:
|
|
|
230
230
|
f"Unknown alert rule mode {self.mode!r}; use 'edge' or 'level'."
|
|
231
231
|
)
|
|
232
232
|
self.for_steps = max(1, int(self.for_steps))
|
|
233
|
+
# A generated name identifies the rule but reads badly as a headline, so
|
|
234
|
+
# remember whether the user chose it. Not a field: it stays out of to_dict().
|
|
235
|
+
self.auto_named = not self.name
|
|
233
236
|
if not self.name:
|
|
234
237
|
self.name = f"rule_{self._auto_digest()}"
|
|
235
238
|
if not self.message:
|
|
File without changes
|