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.
- expr_tracker/alerts/backends/__init__.py +5 -1
- expr_tracker/alerts/backends/base.py +70 -1
- expr_tracker/alerts/engine.py +6 -1
- expr_tracker/alerts/expr/lexer.py +24 -5
- expr_tracker/alerts/expr/nodes.py +20 -2
- expr_tracker/alerts/models.py +3 -0
- expr_tracker/py.typed +0 -0
- {expr_tracker-0.2.0.dist-info → expr_tracker-0.2.2.dist-info}/METADATA +31 -2
- {expr_tracker-0.2.0.dist-info → expr_tracker-0.2.2.dist-info}/RECORD +12 -10
- expr_tracker-0.2.2.dist-info/licenses/LICENSE +21 -0
- {expr_tracker-0.2.0.dist-info → expr_tracker-0.2.2.dist-info}/WHEEL +0 -0
- {expr_tracker-0.2.0.dist-info → expr_tracker-0.2.2.dist-info}/entry_points.txt +0 -0
|
@@ -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)
|
expr_tracker/alerts/engine.py
CHANGED
|
@@ -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:
|
expr_tracker/alerts/models.py
CHANGED
|
@@ -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.
|
|
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)
|
|
@@ -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=
|
|
13
|
-
expr_tracker/alerts/models.py,sha256=
|
|
14
|
-
expr_tracker/alerts/backends/__init__.py,sha256=
|
|
15
|
-
expr_tracker/alerts/backends/base.py,sha256=
|
|
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=
|
|
20
|
-
expr_tracker/alerts/expr/nodes.py,sha256=
|
|
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.
|
|
31
|
-
expr_tracker-0.2.
|
|
32
|
-
expr_tracker-0.2.
|
|
33
|
-
expr_tracker-0.2.
|
|
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.
|
|
File without changes
|
|
File without changes
|