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.
Files changed (92) hide show
  1. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/.gitignore +5 -0
  2. expr_tracker-0.2.2/LICENSE +21 -0
  3. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/PKG-INFO +31 -2
  4. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/README.md +9 -0
  5. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/docs/design.md +10 -2
  6. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/docs/guide/alerts.md +66 -2
  7. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/docs/reference/configuration.md +2 -1
  8. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/docs/reference/expressions.md +38 -3
  9. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/pyproject.toml +34 -1
  10. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/src/expr_tracker/alerts/backends/__init__.py +5 -1
  11. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/src/expr_tracker/alerts/backends/base.py +70 -1
  12. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/src/expr_tracker/alerts/engine.py +6 -1
  13. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/src/expr_tracker/alerts/expr/lexer.py +24 -5
  14. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/src/expr_tracker/alerts/expr/nodes.py +20 -2
  15. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/src/expr_tracker/alerts/models.py +3 -0
  16. expr_tracker-0.2.2/src/expr_tracker/py.typed +0 -0
  17. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_alert_backends.py +179 -1
  18. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_expr_builder.py +2 -1
  19. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_expr_parser.py +16 -7
  20. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_expr_properties.py +7 -3
  21. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_features.py +119 -0
  22. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_rule_lifecycle.py +31 -0
  23. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/.github/workflows/docs.yaml +0 -0
  24. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/.github/workflows/release.yaml +0 -0
  25. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/docs/architecture.md +0 -0
  26. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/docs/getting-started.md +0 -0
  27. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/docs/guide/artifacts.md +0 -0
  28. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/docs/guide/backends.md +0 -0
  29. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/docs/guide/cli.md +0 -0
  30. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/docs/guide/distributed.md +0 -0
  31. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/docs/guide/history.md +0 -0
  32. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/docs/guide/logging.md +0 -0
  33. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/docs/index.md +0 -0
  34. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/docs/reference/api.md +0 -0
  35. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/mkdocs.yml +0 -0
  36. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/src/expr_tracker/__init__.py +0 -0
  37. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/src/expr_tracker/_compat.py +0 -0
  38. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/src/expr_tracker/alerts/__init__.py +0 -0
  39. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/src/expr_tracker/alerts/dispatch.py +0 -0
  40. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/src/expr_tracker/alerts/expr/__init__.py +0 -0
  41. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/src/expr_tracker/alerts/expr/eval.py +0 -0
  42. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/src/expr_tracker/alerts/expr/functions.py +0 -0
  43. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/src/expr_tracker/alerts/expr/parser.py +0 -0
  44. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/src/expr_tracker/alerts/expr/rule.py +0 -0
  45. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/src/expr_tracker/artifacts.py +0 -0
  46. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/src/expr_tracker/cli.py +0 -0
  47. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/src/expr_tracker/encoders.py +0 -0
  48. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/src/expr_tracker/history/__init__.py +0 -0
  49. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/src/expr_tracker/history/codec.py +0 -0
  50. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/src/expr_tracker/history/frame.py +0 -0
  51. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/src/expr_tracker/history/reader.py +0 -0
  52. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/src/expr_tracker/history/series.py +0 -0
  53. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/src/expr_tracker/history/store.py +0 -0
  54. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/src/expr_tracker/history/writer.py +0 -0
  55. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/src/expr_tracker/run.py +0 -0
  56. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/src/expr_tracker/summary.py +0 -0
  57. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/src/expr_tracker/tracker.py +0 -0
  58. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/src/expr_tracker/types.py +0 -0
  59. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/conftest.py +0 -0
  60. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_alert_delivery.py +0 -0
  61. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_alert_dispatch.py +0 -0
  62. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_alert_engine.py +0 -0
  63. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_alert_models.py +0 -0
  64. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_alert_routing.py +0 -0
  65. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_artifacts.py +0 -0
  66. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_benchmark.py +0 -0
  67. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_cache.py +0 -0
  68. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_cli.py +0 -0
  69. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_correctness.py +0 -0
  70. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_distributed.py +0 -0
  71. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_e2e.py +0 -0
  72. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_expr_eval.py +0 -0
  73. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_expr_functions.py +0 -0
  74. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_failure_modes.py +0 -0
  75. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_frame_codec_summary.py +0 -0
  76. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_history.py +0 -0
  77. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_hot_paths.py +0 -0
  78. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_integration.py +0 -0
  79. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_lark_live.py +0 -0
  80. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_perf.py +0 -0
  81. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_public_surfaces.py +0 -0
  82. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_review_regressions.py +0 -0
  83. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_run_backends.py +0 -0
  84. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_scenarios.py +0 -0
  85. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_step_commit.py +0 -0
  86. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_stress.py +0 -0
  87. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_trackio.py +0 -0
  88. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_value_encoding.py +0 -0
  89. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_wandb.py +0 -0
  90. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_writer_buffer.py +0 -0
  91. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/tests/test_writer_durability.py +0 -0
  92. {expr_tracker-0.2.0 → expr_tracker-0.2.2}/uv.lock +0 -0
@@ -14,3 +14,8 @@ test.ipynb
14
14
  .coverage
15
15
  htmlcov/
16
16
  site/
17
+
18
+ # Tracker run output: the default dir, and whatever people point --dir at
19
+ tracker/
20
+ outputs/
21
+ runs/
@@ -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.0
4
- Summary: Add your description here
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
+ [![PyPI](https://img.shields.io/pypi/v/expr-tracker)](https://pypi.org/project/expr-tracker/)
51
+ [![Python](https://img.shields.io/pypi/pyversions/expr-tracker)](https://pypi.org/project/expr-tracker/)
52
+ [![License](https://img.shields.io/pypi/l/expr-tracker)](LICENSE)
53
+ [![Docs](https://img.shields.io/badge/docs-github.io-blue)](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
+ [![PyPI](https://img.shields.io/pypi/v/expr-tracker)](https://pypi.org/project/expr-tracker/)
4
+ [![Python](https://img.shields.io/pypi/pyversions/expr-tracker)](https://pypi.org/project/expr-tracker/)
5
+ [![License](https://img.shields.io/pypi/l/expr-tracker)](LICENSE)
6
+ [![Docs](https://img.shields.io/badge/docs-github.io-blue)](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_.]* | `anything`
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`; use backticks for exotic names.
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
- - `.` in a metric name resolves to `/`, so `eval.acc` finds `eval/acc`. Use backticks
33
- for anything exotic: `` `a-b c` ``.
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
- - Bare names: `loss`, `grad_norm`.
34
- - `.` resolves to `/`: `eval.acc` finds the metric `eval/acc`.
35
- - Backticks for anything else: `` `a-b c` ``, `` `中文指标` ``.
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 = "Add your description here"
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
- mail.set_content(render_text(message))
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" &middot; {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
- title = _format(rule.title, values) if rule.title else rule.name
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("`", i + 1)
55
+ if ch in QUOTES:
56
+ end = source.find(ch, i + 1)
56
57
  if end < 0:
57
- raise ExprSyntaxError("Unterminated `identifier`", source, i)
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
- while i < n and (source[i].isalnum() or source[i] in "_."):
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 f"`{self.name}`"
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
- return all(ch.isalnum() or ch in "_." for ch in name)
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