expr_tracker 0.2.0__py3-none-any.whl → 0.2.2__py3-none-any.whl

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.
@@ -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:
expr_tracker/py.typed ADDED
File without changes
@@ -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)
@@ -3,21 +3,22 @@ expr_tracker/_compat.py,sha256=qf8oYIK_lkoLzcPp6wPCYLJtVatCl7ROYRNPwc5F1Pg,981
3
3
  expr_tracker/artifacts.py,sha256=yRVXmuqdZUxiZa0c_CwZi4rBxaItwhu9bZ1x21yP1S0,12560
4
4
  expr_tracker/cli.py,sha256=YM1tuPX19Y_oNWtCi1DmAY3wyKd-uGILzM1twvv5il4,6305
5
5
  expr_tracker/encoders.py,sha256=X5ySQasSrYPsdYCdk7nhpJFGFB9f_8HWQuGOQjTvh9E,11991
6
+ expr_tracker/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
7
  expr_tracker/run.py,sha256=7hHuLaa5Ox-cP891eMuxZ6_Uy9iTaaqMjjshl32kmVk,15744
7
8
  expr_tracker/summary.py,sha256=lpHSh3pci2Ym6wJhVZwNUJbUp2A64EN_rC7Ym2d7PPI,3150
8
9
  expr_tracker/tracker.py,sha256=NabstZ2Ysme64RkPooZj-r7AA8Wst49H9au5Q7yTn9o,5073
9
10
  expr_tracker/types.py,sha256=69gFtUaKGl4asLK7nJci9XyTyV0yTIPorB-bcfGrCBA,383
10
11
  expr_tracker/alerts/__init__.py,sha256=dl96OYZh75kV3NLBSA35zNvTFcL6XFMuPoYvRoz7hlQ,8192
11
12
  expr_tracker/alerts/dispatch.py,sha256=k2pDZVJy6ajlQ7kUdvaBBZ_AmdtZ8opaQWjADn_i8uI,11652
12
- expr_tracker/alerts/engine.py,sha256=t7MfyqHZefPa9qQajue7YZCFp2lFsM17HQaONgBOde8,13547
13
- expr_tracker/alerts/models.py,sha256=NurzlMusmpL2trl9STAwZr4LBQAVVg5BunDX3ilq-Fg,11739
14
- expr_tracker/alerts/backends/__init__.py,sha256=a4XGFOTkP1aaZWypu_1lUPYi8c1iw5VIBBOlktMWpFU,8387
15
- expr_tracker/alerts/backends/base.py,sha256=Vwdo3rYoAYykVHRW4Ga4fKH1BsrzDvtzlBWHu4UScV8,3592
13
+ expr_tracker/alerts/engine.py,sha256=Lcfc3VchFegmbAwHwTE6mWuG4gUYAlbsAoq5gDd6cr8,13774
14
+ expr_tracker/alerts/models.py,sha256=LjPGBKXroxGP1qYm456shNQAbkm1PEVga0vBy4nxRZI,11946
15
+ expr_tracker/alerts/backends/__init__.py,sha256=6j2wyfG1mMuWkdI5i99gOUlxOTwfuMOeg78dYs_ciT0,8620
16
+ expr_tracker/alerts/backends/base.py,sha256=tILaNVDQ4rt9WoxO-KXHfWkIza_lX-eFO70LsSf2aTo,6479
16
17
  expr_tracker/alerts/expr/__init__.py,sha256=qXBt6ih1pMAplUozsDzYY_ZWHlLABX4NoshbjJO_oJk,616
17
18
  expr_tracker/alerts/expr/eval.py,sha256=UccIwg1r-A_yzUf16TGyx3MO8e0kegCsrRHDyHirsNg,11609
18
19
  expr_tracker/alerts/expr/functions.py,sha256=wuuFKplta_5KkFHIsvEmpryFtPCdfHV4wjqnx3t2g9A,7043
19
- expr_tracker/alerts/expr/lexer.py,sha256=_EsZR17-8KDJaqwMWDzQ4F9dpQdufoY3deEfFZwppBQ,4047
20
- expr_tracker/alerts/expr/nodes.py,sha256=bsfdULXPl9PQvtFfJTYgy1V9GFRhGO_3-W3BxSxtavk,9251
20
+ expr_tracker/alerts/expr/lexer.py,sha256=NrvDi-f_8F9V1_vQT-vAGENNA8nauD9IbJH4r21-7oQ,4563
21
+ expr_tracker/alerts/expr/nodes.py,sha256=ZsXX4VJDolVQsVoEtIhkDj6QspMz7lTfRYaEZ8F6grg,9896
21
22
  expr_tracker/alerts/expr/parser.py,sha256=heGXAVjCZ1AM0u5-Usld4G_P38N0nUaXMlh8C7CHqjQ,6315
22
23
  expr_tracker/alerts/expr/rule.py,sha256=nO8vzhWyGDxSmSXqJrXOU_Yoy7Tzot3_c2W2bZ4s2Qw,3653
23
24
  expr_tracker/history/__init__.py,sha256=sppio0jJ2E8WBr3HcZ7UO5vo7GNqQA1rhEgN6wuhQ_o,522
@@ -27,7 +28,8 @@ expr_tracker/history/reader.py,sha256=YYrQluS5dopax84ltd5gr6xO4yrygRiOo3emIhjR6K
27
28
  expr_tracker/history/series.py,sha256=cFdUO1FRs5KW0jYsNPzda3tcu645NH1wwfXJitQfXFM,4042
28
29
  expr_tracker/history/store.py,sha256=Q7F0I_dF2zxHUTtSnzfVCviSeAvW49i3OqLMjoQh1sQ,33072
29
30
  expr_tracker/history/writer.py,sha256=q5kNANcZCq5nykvOK5jZ2xc6Z1B4oZxEn3uSXzDCtlY,16096
30
- expr_tracker-0.2.0.dist-info/METADATA,sha256=UWReC8eKpPLsgmA-HYizoa7LD7wBP1n6Y7ax_f-o7fo,6867
31
- expr_tracker-0.2.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
32
- expr_tracker-0.2.0.dist-info/entry_points.txt,sha256=GfZCAG2JFsd5CxTjRsGJAhYInpBCuLjrU3csmUVBDSs,45
33
- expr_tracker-0.2.0.dist-info/RECORD,,
31
+ expr_tracker-0.2.2.dist-info/METADATA,sha256=525p8soaoyMXiBApvXQjcJ5JVTJOb1b0v6lakS6ZB5Y,8388
32
+ expr_tracker-0.2.2.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
33
+ expr_tracker-0.2.2.dist-info/entry_points.txt,sha256=GfZCAG2JFsd5CxTjRsGJAhYInpBCuLjrU3csmUVBDSs,45
34
+ expr_tracker-0.2.2.dist-info/licenses/LICENSE,sha256=ORsYTnF-2SLUaM8FgXlJ-uXuN4ceGACAAU3JZiS8pvs,1069
35
+ expr_tracker-0.2.2.dist-info/RECORD,,
@@ -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.