expr-tracker 0.1.7__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/__init__.py +9 -0
- expr_tracker/_compat.py +31 -0
- expr_tracker/alert.py +73 -0
- expr_tracker/cli.py +15 -0
- expr_tracker/encoders.py +344 -0
- expr_tracker/jsonl.py +209 -0
- expr_tracker/tracker.py +167 -0
- expr_tracker/types.py +10 -0
- expr_tracker-0.1.7.dist-info/METADATA +67 -0
- expr_tracker-0.1.7.dist-info/RECORD +12 -0
- expr_tracker-0.1.7.dist-info/WHEEL +4 -0
- expr_tracker-0.1.7.dist-info/entry_points.txt +2 -0
expr_tracker/__init__.py
ADDED
expr_tracker/_compat.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from typing import Any, Type
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
from pydantic.version import VERSION as PYDANTIC_VERSION
|
|
5
|
+
from typing_extensions import Literal
|
|
6
|
+
|
|
7
|
+
PYDANTIC_VERSION_MINOR_TUPLE = tuple(int(x) for x in PYDANTIC_VERSION.split(".")[:2])
|
|
8
|
+
PYDANTIC_V2 = PYDANTIC_VERSION_MINOR_TUPLE[0] == 2
|
|
9
|
+
|
|
10
|
+
Url: Type[Any]
|
|
11
|
+
|
|
12
|
+
if PYDANTIC_V2:
|
|
13
|
+
from pydantic_core import PydanticUndefinedType
|
|
14
|
+
from pydantic_core import Url as Url
|
|
15
|
+
|
|
16
|
+
UndefinedType = PydanticUndefinedType
|
|
17
|
+
|
|
18
|
+
def _model_dump(
|
|
19
|
+
model: BaseModel, mode: Literal["json", "python"] = "json", **kwargs: Any
|
|
20
|
+
) -> Any:
|
|
21
|
+
return model.model_dump(mode=mode, **kwargs)
|
|
22
|
+
else:
|
|
23
|
+
from pydantic import AnyUrl as Url # noqa: F401
|
|
24
|
+
from pydantic.fields import ( # type: ignore[no-redef, attr-defined]
|
|
25
|
+
UndefinedType as UndefinedType, # noqa: F401
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
def _model_dump(
|
|
29
|
+
model: BaseModel, mode: Literal["json", "python"] = "json", **kwargs: Any
|
|
30
|
+
) -> Any:
|
|
31
|
+
return model.dict(**kwargs)
|
expr_tracker/alert.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from typing import Literal
|
|
2
|
+
from loguru import logger
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def ignore_exception(func):
|
|
6
|
+
def wrapper(*args, **kwargs):
|
|
7
|
+
try:
|
|
8
|
+
return func(*args, **kwargs)
|
|
9
|
+
except Exception as e:
|
|
10
|
+
logger.warning(f"Error occurred in {func.__name__}: {e}")
|
|
11
|
+
return None
|
|
12
|
+
|
|
13
|
+
return wrapper
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LarkBackend:
|
|
17
|
+
def __init__(self, **kwargs):
|
|
18
|
+
from slark import Lark
|
|
19
|
+
|
|
20
|
+
self.lark = Lark(**kwargs)
|
|
21
|
+
|
|
22
|
+
def publish_alert(
|
|
23
|
+
self,
|
|
24
|
+
title: str,
|
|
25
|
+
text: str,
|
|
26
|
+
subtitle: str | None = None,
|
|
27
|
+
level: Literal["info", "warning", "error"] = "info",
|
|
28
|
+
traceback: str | None = None,
|
|
29
|
+
):
|
|
30
|
+
if level == "info" or level == "warning":
|
|
31
|
+
self.lark.webhook.post_success_card(
|
|
32
|
+
msg=text, title=title, subtitle=subtitle
|
|
33
|
+
)
|
|
34
|
+
elif level == "error":
|
|
35
|
+
self.lark.webhook.post_error_card(
|
|
36
|
+
msg=text, traceback=traceback or "", title=title, subtitle=subtitle
|
|
37
|
+
)
|
|
38
|
+
else:
|
|
39
|
+
raise ValueError(
|
|
40
|
+
f"Unsupported level: {level}. Supported levels are 'info', 'warning', and 'error'."
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@ignore_exception
|
|
45
|
+
def alert(
|
|
46
|
+
title: str,
|
|
47
|
+
text: str,
|
|
48
|
+
subtitle: str | None = None,
|
|
49
|
+
traceback: str | None = None,
|
|
50
|
+
level: Literal["info", "warning", "error"] = "info",
|
|
51
|
+
backends: list[Literal["lark", "slack", "email"]] = ["lark"],
|
|
52
|
+
):
|
|
53
|
+
"""
|
|
54
|
+
Send an alert message to specified backends.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
title (str): The title of the alert.
|
|
58
|
+
text (str): The main content of the alert.
|
|
59
|
+
subtitle (str | None): Optional subtitle for the alert.
|
|
60
|
+
level (Literal["info", "warning", "error"]): The severity level of the alert.
|
|
61
|
+
backends (list[Literal["lark", "slack", "email"]]): List of backends to send the alert to.
|
|
62
|
+
"""
|
|
63
|
+
for backend in backends:
|
|
64
|
+
if backend == "lark":
|
|
65
|
+
lark_backend = LarkBackend()
|
|
66
|
+
if lark_backend.lark._webhook_url is None:
|
|
67
|
+
logger.warning(
|
|
68
|
+
"Lark webhook URL is not set. Please set the WEBHOOK_URL environment variable."
|
|
69
|
+
)
|
|
70
|
+
return
|
|
71
|
+
lark_backend.publish_alert(title, text, subtitle, level, traceback)
|
|
72
|
+
else:
|
|
73
|
+
raise NotImplementedError(f"Backend '{backend}' is not implemented.")
|
expr_tracker/cli.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import click
|
|
2
|
+
import expr_tracker as et
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@click.group()
|
|
6
|
+
def main():
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@main.command()
|
|
11
|
+
@click.argument("msg")
|
|
12
|
+
@click.option("--title", default="Alert", help="Title of the alert")
|
|
13
|
+
@click.option("--level", default="info", help="Level of the alert")
|
|
14
|
+
def alert(msg: str, title: str = "Alert", level: str = "info"):
|
|
15
|
+
et.alert(title=title, text=msg, level=level)
|
expr_tracker/encoders.py
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
import datetime
|
|
3
|
+
from collections import defaultdict, deque
|
|
4
|
+
from decimal import Decimal
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from ipaddress import (
|
|
7
|
+
IPv4Address,
|
|
8
|
+
IPv4Interface,
|
|
9
|
+
IPv4Network,
|
|
10
|
+
IPv6Address,
|
|
11
|
+
IPv6Interface,
|
|
12
|
+
IPv6Network,
|
|
13
|
+
)
|
|
14
|
+
from pathlib import Path, PurePath
|
|
15
|
+
from re import Pattern
|
|
16
|
+
from types import GeneratorType
|
|
17
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple, Type, Union
|
|
18
|
+
from uuid import UUID
|
|
19
|
+
|
|
20
|
+
from expr_tracker.types import IncEx
|
|
21
|
+
from pydantic import BaseModel
|
|
22
|
+
from pydantic.color import Color
|
|
23
|
+
from pydantic.networks import AnyUrl, NameEmail
|
|
24
|
+
from pydantic.types import SecretBytes, SecretStr
|
|
25
|
+
from typing_extensions import Annotated, Doc
|
|
26
|
+
|
|
27
|
+
from ._compat import PYDANTIC_V2, UndefinedType, Url, _model_dump
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# Taken from Pydantic v1 as is
|
|
31
|
+
def isoformat(o: Union[datetime.date, datetime.time]) -> str:
|
|
32
|
+
return o.isoformat()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# Taken from Pydantic v1 as is
|
|
36
|
+
# TODO: pv2 should this return strings instead?
|
|
37
|
+
def decimal_encoder(dec_value: Decimal) -> Union[int, float]:
|
|
38
|
+
"""
|
|
39
|
+
Encodes a Decimal as int of there's no exponent, otherwise float
|
|
40
|
+
|
|
41
|
+
This is useful when we use ConstrainedDecimal to represent Numeric(x,0)
|
|
42
|
+
where a integer (but not int typed) is used. Encoding this as a float
|
|
43
|
+
results in failed round-tripping between encode and parse.
|
|
44
|
+
Our Id type is a prime example of this.
|
|
45
|
+
|
|
46
|
+
>>> decimal_encoder(Decimal("1.0"))
|
|
47
|
+
1.0
|
|
48
|
+
|
|
49
|
+
>>> decimal_encoder(Decimal("1"))
|
|
50
|
+
1
|
|
51
|
+
"""
|
|
52
|
+
if dec_value.as_tuple().exponent >= 0: # type: ignore[operator]
|
|
53
|
+
return int(dec_value)
|
|
54
|
+
else:
|
|
55
|
+
return float(dec_value)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
ENCODERS_BY_TYPE: Dict[Type[Any], Callable[[Any], Any]] = {
|
|
59
|
+
bytes: lambda o: o.decode(),
|
|
60
|
+
Color: str,
|
|
61
|
+
datetime.date: isoformat,
|
|
62
|
+
datetime.datetime: isoformat,
|
|
63
|
+
datetime.time: isoformat,
|
|
64
|
+
datetime.timedelta: lambda td: td.total_seconds(),
|
|
65
|
+
Decimal: decimal_encoder,
|
|
66
|
+
Enum: lambda o: o.value,
|
|
67
|
+
frozenset: list,
|
|
68
|
+
deque: list,
|
|
69
|
+
GeneratorType: list,
|
|
70
|
+
IPv4Address: str,
|
|
71
|
+
IPv4Interface: str,
|
|
72
|
+
IPv4Network: str,
|
|
73
|
+
IPv6Address: str,
|
|
74
|
+
IPv6Interface: str,
|
|
75
|
+
IPv6Network: str,
|
|
76
|
+
NameEmail: str,
|
|
77
|
+
Path: str,
|
|
78
|
+
Pattern: lambda o: o.pattern,
|
|
79
|
+
SecretBytes: str,
|
|
80
|
+
SecretStr: str,
|
|
81
|
+
set: list,
|
|
82
|
+
UUID: str,
|
|
83
|
+
Url: str,
|
|
84
|
+
AnyUrl: str,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def generate_encoders_by_class_tuples(
|
|
89
|
+
type_encoder_map: Dict[Any, Callable[[Any], Any]],
|
|
90
|
+
) -> Dict[Callable[[Any], Any], Tuple[Any, ...]]:
|
|
91
|
+
encoders_by_class_tuples: Dict[Callable[[Any], Any], Tuple[Any, ...]] = defaultdict(
|
|
92
|
+
tuple
|
|
93
|
+
)
|
|
94
|
+
for type_, encoder in type_encoder_map.items():
|
|
95
|
+
encoders_by_class_tuples[encoder] += (type_,)
|
|
96
|
+
return encoders_by_class_tuples
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
encoders_by_class_tuples = generate_encoders_by_class_tuples(ENCODERS_BY_TYPE)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def jsonable_encoder(
|
|
103
|
+
obj: Annotated[
|
|
104
|
+
Any,
|
|
105
|
+
Doc(
|
|
106
|
+
"""
|
|
107
|
+
The input object to convert to JSON.
|
|
108
|
+
"""
|
|
109
|
+
),
|
|
110
|
+
],
|
|
111
|
+
include: Annotated[
|
|
112
|
+
Optional[IncEx],
|
|
113
|
+
Doc(
|
|
114
|
+
"""
|
|
115
|
+
Pydantic's `include` parameter, passed to Pydantic models to set the
|
|
116
|
+
fields to include.
|
|
117
|
+
"""
|
|
118
|
+
),
|
|
119
|
+
] = None,
|
|
120
|
+
exclude: Annotated[
|
|
121
|
+
Optional[IncEx],
|
|
122
|
+
Doc(
|
|
123
|
+
"""
|
|
124
|
+
Pydantic's `exclude` parameter, passed to Pydantic models to set the
|
|
125
|
+
fields to exclude.
|
|
126
|
+
"""
|
|
127
|
+
),
|
|
128
|
+
] = None,
|
|
129
|
+
by_alias: Annotated[
|
|
130
|
+
bool,
|
|
131
|
+
Doc(
|
|
132
|
+
"""
|
|
133
|
+
Pydantic's `by_alias` parameter, passed to Pydantic models to define if
|
|
134
|
+
the output should use the alias names (when provided) or the Python
|
|
135
|
+
attribute names. In an API, if you set an alias, it's probably because you
|
|
136
|
+
want to use it in the result, so you probably want to leave this set to
|
|
137
|
+
`True`.
|
|
138
|
+
"""
|
|
139
|
+
),
|
|
140
|
+
] = True,
|
|
141
|
+
exclude_unset: Annotated[
|
|
142
|
+
bool,
|
|
143
|
+
Doc(
|
|
144
|
+
"""
|
|
145
|
+
Pydantic's `exclude_unset` parameter, passed to Pydantic models to define
|
|
146
|
+
if it should exclude from the output the fields that were not explicitly
|
|
147
|
+
set (and that only had their default values).
|
|
148
|
+
"""
|
|
149
|
+
),
|
|
150
|
+
] = False,
|
|
151
|
+
exclude_defaults: Annotated[
|
|
152
|
+
bool,
|
|
153
|
+
Doc(
|
|
154
|
+
"""
|
|
155
|
+
Pydantic's `exclude_defaults` parameter, passed to Pydantic models to define
|
|
156
|
+
if it should exclude from the output the fields that had the same default
|
|
157
|
+
value, even when they were explicitly set.
|
|
158
|
+
"""
|
|
159
|
+
),
|
|
160
|
+
] = False,
|
|
161
|
+
exclude_none: Annotated[
|
|
162
|
+
bool,
|
|
163
|
+
Doc(
|
|
164
|
+
"""
|
|
165
|
+
Pydantic's `exclude_none` parameter, passed to Pydantic models to define
|
|
166
|
+
if it should exclude from the output any fields that have a `None` value.
|
|
167
|
+
"""
|
|
168
|
+
),
|
|
169
|
+
] = False,
|
|
170
|
+
custom_encoder: Annotated[
|
|
171
|
+
Optional[Dict[Any, Callable[[Any], Any]]],
|
|
172
|
+
Doc(
|
|
173
|
+
"""
|
|
174
|
+
Pydantic's `custom_encoder` parameter, passed to Pydantic models to define
|
|
175
|
+
a custom encoder.
|
|
176
|
+
"""
|
|
177
|
+
),
|
|
178
|
+
] = None,
|
|
179
|
+
sqlalchemy_safe: Annotated[
|
|
180
|
+
bool,
|
|
181
|
+
Doc(
|
|
182
|
+
"""
|
|
183
|
+
Exclude from the output any fields that start with the name `_sa`.
|
|
184
|
+
|
|
185
|
+
This is mainly a hack for compatibility with SQLAlchemy objects, they
|
|
186
|
+
store internal SQLAlchemy-specific state in attributes named with `_sa`,
|
|
187
|
+
and those objects can't (and shouldn't be) serialized to JSON.
|
|
188
|
+
"""
|
|
189
|
+
),
|
|
190
|
+
] = True,
|
|
191
|
+
) -> Any:
|
|
192
|
+
"""
|
|
193
|
+
Convert any object to something that can be encoded in JSON.
|
|
194
|
+
|
|
195
|
+
This is used internally by FastAPI to make sure anything you return can be
|
|
196
|
+
encoded as JSON before it is sent to the client.
|
|
197
|
+
|
|
198
|
+
You can also use it yourself, for example to convert objects before saving them
|
|
199
|
+
in a database that supports only JSON.
|
|
200
|
+
|
|
201
|
+
Read more about it in the
|
|
202
|
+
[FastAPI docs for JSON Compatible Encoder](https://fastapi.tiangolo.com/tutorial/encoder/).
|
|
203
|
+
"""
|
|
204
|
+
custom_encoder = custom_encoder or {}
|
|
205
|
+
if custom_encoder:
|
|
206
|
+
if type(obj) in custom_encoder:
|
|
207
|
+
return custom_encoder[type(obj)](obj)
|
|
208
|
+
else:
|
|
209
|
+
for encoder_type, encoder_instance in custom_encoder.items():
|
|
210
|
+
if isinstance(obj, encoder_type):
|
|
211
|
+
return encoder_instance(obj)
|
|
212
|
+
if include is not None and not isinstance(include, (set, dict)):
|
|
213
|
+
include = set(include)
|
|
214
|
+
if exclude is not None and not isinstance(exclude, (set, dict)):
|
|
215
|
+
exclude = set(exclude)
|
|
216
|
+
if isinstance(obj, BaseModel):
|
|
217
|
+
# TODO: remove when deprecating Pydantic v1
|
|
218
|
+
encoders: Dict[Any, Any] = {}
|
|
219
|
+
if not PYDANTIC_V2:
|
|
220
|
+
encoders = getattr(obj.__config__, "json_encoders", {}) # type: ignore[attr-defined]
|
|
221
|
+
if custom_encoder:
|
|
222
|
+
encoders = {**encoders, **custom_encoder}
|
|
223
|
+
obj_dict = _model_dump(
|
|
224
|
+
obj,
|
|
225
|
+
mode="json",
|
|
226
|
+
include=include,
|
|
227
|
+
exclude=exclude,
|
|
228
|
+
by_alias=by_alias,
|
|
229
|
+
exclude_unset=exclude_unset,
|
|
230
|
+
exclude_none=exclude_none,
|
|
231
|
+
exclude_defaults=exclude_defaults,
|
|
232
|
+
)
|
|
233
|
+
if "__root__" in obj_dict:
|
|
234
|
+
obj_dict = obj_dict["__root__"]
|
|
235
|
+
return jsonable_encoder(
|
|
236
|
+
obj_dict,
|
|
237
|
+
exclude_none=exclude_none,
|
|
238
|
+
exclude_defaults=exclude_defaults,
|
|
239
|
+
# TODO: remove when deprecating Pydantic v1
|
|
240
|
+
custom_encoder=encoders,
|
|
241
|
+
sqlalchemy_safe=sqlalchemy_safe,
|
|
242
|
+
)
|
|
243
|
+
if dataclasses.is_dataclass(obj):
|
|
244
|
+
assert not isinstance(obj, type)
|
|
245
|
+
obj_dict = dataclasses.asdict(obj)
|
|
246
|
+
return jsonable_encoder(
|
|
247
|
+
obj_dict,
|
|
248
|
+
include=include,
|
|
249
|
+
exclude=exclude,
|
|
250
|
+
by_alias=by_alias,
|
|
251
|
+
exclude_unset=exclude_unset,
|
|
252
|
+
exclude_defaults=exclude_defaults,
|
|
253
|
+
exclude_none=exclude_none,
|
|
254
|
+
custom_encoder=custom_encoder,
|
|
255
|
+
sqlalchemy_safe=sqlalchemy_safe,
|
|
256
|
+
)
|
|
257
|
+
if isinstance(obj, Enum):
|
|
258
|
+
return obj.value
|
|
259
|
+
if isinstance(obj, PurePath):
|
|
260
|
+
return str(obj)
|
|
261
|
+
if isinstance(obj, (str, int, float, type(None))):
|
|
262
|
+
return obj
|
|
263
|
+
if isinstance(obj, UndefinedType):
|
|
264
|
+
return None
|
|
265
|
+
if isinstance(obj, dict):
|
|
266
|
+
encoded_dict = {}
|
|
267
|
+
allowed_keys = set(obj.keys())
|
|
268
|
+
if include is not None:
|
|
269
|
+
allowed_keys &= set(include)
|
|
270
|
+
if exclude is not None:
|
|
271
|
+
allowed_keys -= set(exclude)
|
|
272
|
+
for key, value in obj.items():
|
|
273
|
+
if (
|
|
274
|
+
(
|
|
275
|
+
not sqlalchemy_safe
|
|
276
|
+
or (not isinstance(key, str))
|
|
277
|
+
or (not key.startswith("_sa"))
|
|
278
|
+
)
|
|
279
|
+
and (value is not None or not exclude_none)
|
|
280
|
+
and key in allowed_keys
|
|
281
|
+
):
|
|
282
|
+
encoded_key = jsonable_encoder(
|
|
283
|
+
key,
|
|
284
|
+
by_alias=by_alias,
|
|
285
|
+
exclude_unset=exclude_unset,
|
|
286
|
+
exclude_none=exclude_none,
|
|
287
|
+
custom_encoder=custom_encoder,
|
|
288
|
+
sqlalchemy_safe=sqlalchemy_safe,
|
|
289
|
+
)
|
|
290
|
+
encoded_value = jsonable_encoder(
|
|
291
|
+
value,
|
|
292
|
+
by_alias=by_alias,
|
|
293
|
+
exclude_unset=exclude_unset,
|
|
294
|
+
exclude_none=exclude_none,
|
|
295
|
+
custom_encoder=custom_encoder,
|
|
296
|
+
sqlalchemy_safe=sqlalchemy_safe,
|
|
297
|
+
)
|
|
298
|
+
encoded_dict[encoded_key] = encoded_value
|
|
299
|
+
return encoded_dict
|
|
300
|
+
if isinstance(obj, (list, set, frozenset, GeneratorType, tuple, deque)):
|
|
301
|
+
encoded_list = []
|
|
302
|
+
for item in obj:
|
|
303
|
+
encoded_list.append(
|
|
304
|
+
jsonable_encoder(
|
|
305
|
+
item,
|
|
306
|
+
include=include,
|
|
307
|
+
exclude=exclude,
|
|
308
|
+
by_alias=by_alias,
|
|
309
|
+
exclude_unset=exclude_unset,
|
|
310
|
+
exclude_defaults=exclude_defaults,
|
|
311
|
+
exclude_none=exclude_none,
|
|
312
|
+
custom_encoder=custom_encoder,
|
|
313
|
+
sqlalchemy_safe=sqlalchemy_safe,
|
|
314
|
+
)
|
|
315
|
+
)
|
|
316
|
+
return encoded_list
|
|
317
|
+
|
|
318
|
+
if type(obj) in ENCODERS_BY_TYPE:
|
|
319
|
+
return ENCODERS_BY_TYPE[type(obj)](obj)
|
|
320
|
+
for encoder, classes_tuple in encoders_by_class_tuples.items():
|
|
321
|
+
if isinstance(obj, classes_tuple):
|
|
322
|
+
return encoder(obj)
|
|
323
|
+
|
|
324
|
+
try:
|
|
325
|
+
data = dict(obj)
|
|
326
|
+
except Exception as e:
|
|
327
|
+
errors: List[Exception] = []
|
|
328
|
+
errors.append(e)
|
|
329
|
+
try:
|
|
330
|
+
data = vars(obj)
|
|
331
|
+
except Exception as e:
|
|
332
|
+
errors.append(e)
|
|
333
|
+
raise ValueError(errors) from e
|
|
334
|
+
return jsonable_encoder(
|
|
335
|
+
data,
|
|
336
|
+
include=include,
|
|
337
|
+
exclude=exclude,
|
|
338
|
+
by_alias=by_alias,
|
|
339
|
+
exclude_unset=exclude_unset,
|
|
340
|
+
exclude_defaults=exclude_defaults,
|
|
341
|
+
exclude_none=exclude_none,
|
|
342
|
+
custom_encoder=custom_encoder,
|
|
343
|
+
sqlalchemy_safe=sqlalchemy_safe,
|
|
344
|
+
)
|
expr_tracker/jsonl.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import atexit
|
|
2
|
+
import json
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import jsonlines
|
|
8
|
+
from loguru import logger
|
|
9
|
+
|
|
10
|
+
from expr_tracker.encoders import jsonable_encoder
|
|
11
|
+
|
|
12
|
+
DEFAULT_BUFFER_SIZE = 50
|
|
13
|
+
DEFAULT_BUFFER_INTERVAL = 1.0
|
|
14
|
+
DEFAULT_MAX_BUFFER_SECONDS = 5.0
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class JsonlTracker:
|
|
18
|
+
def __init__(self):
|
|
19
|
+
self.buffer = []
|
|
20
|
+
self.buffer_size = DEFAULT_BUFFER_SIZE
|
|
21
|
+
self.buffer_interval = DEFAULT_BUFFER_INTERVAL
|
|
22
|
+
self.max_buffer_seconds = DEFAULT_MAX_BUFFER_SECONDS
|
|
23
|
+
self.log_fp = None
|
|
24
|
+
self._lock = threading.RLock()
|
|
25
|
+
self._last_log_time = None
|
|
26
|
+
self._first_buffered_time = None
|
|
27
|
+
self._flush_timer = None
|
|
28
|
+
|
|
29
|
+
def init(
|
|
30
|
+
self,
|
|
31
|
+
project: str,
|
|
32
|
+
name: str | None = None,
|
|
33
|
+
config: dict | None = None,
|
|
34
|
+
dir: str | None = None,
|
|
35
|
+
print_to_screen: bool = False,
|
|
36
|
+
print_handle=print,
|
|
37
|
+
buffer_size: int = DEFAULT_BUFFER_SIZE,
|
|
38
|
+
buffer_interval: float | None = DEFAULT_BUFFER_INTERVAL,
|
|
39
|
+
max_buffer_seconds: float | None = DEFAULT_MAX_BUFFER_SECONDS,
|
|
40
|
+
**kwargs,
|
|
41
|
+
):
|
|
42
|
+
"""初始化 jsonl backend。
|
|
43
|
+
|
|
44
|
+
缓冲策略(按 log 频率自适应):
|
|
45
|
+
- ``buffer_size``: buffer 中记录数达到该值立即写盘。
|
|
46
|
+
- ``buffer_interval``: 相邻两次 ``log()`` 的间隔 >= 该值时认为不是高频写入,
|
|
47
|
+
直接写盘(低延迟);小于该值才认为是高频写入,先攒在内存里。
|
|
48
|
+
设为 ``None`` 表示关闭该判断(只按 buffer_size 攒批)。
|
|
49
|
+
- ``max_buffer_seconds``: 记录在 buffer 中的最长停留时间,超时后由后台定时器
|
|
50
|
+
强制写盘,避免高频写入突然停止时数据长期滞留内存。设为 ``None`` 关闭。
|
|
51
|
+
"""
|
|
52
|
+
self.project = project
|
|
53
|
+
self.name = name
|
|
54
|
+
if dir is None:
|
|
55
|
+
dir = "./tracker/jsonl"
|
|
56
|
+
self.log_dir = Path(dir) / self.project / self.name
|
|
57
|
+
self.config_fp = self.log_dir / "config.json"
|
|
58
|
+
self.log_fp = self.log_dir / "metrics.jsonl"
|
|
59
|
+
|
|
60
|
+
# 初始化 Buffer 配置
|
|
61
|
+
self._cancel_timer()
|
|
62
|
+
with self._lock:
|
|
63
|
+
self.buffer = []
|
|
64
|
+
self.buffer_size = max(1, int(buffer_size))
|
|
65
|
+
self.buffer_interval = (
|
|
66
|
+
None if buffer_interval is None else float(buffer_interval)
|
|
67
|
+
)
|
|
68
|
+
self.max_buffer_seconds = (
|
|
69
|
+
None if max_buffer_seconds is None else float(max_buffer_seconds)
|
|
70
|
+
)
|
|
71
|
+
self._last_log_time = None
|
|
72
|
+
self._first_buffered_time = None
|
|
73
|
+
|
|
74
|
+
self.log_dir.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
|
|
76
|
+
if self.config_fp.exists():
|
|
77
|
+
logger.warning(
|
|
78
|
+
f"Config file {self.config_fp} already exists. It will be overwritten."
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
if config is not None:
|
|
82
|
+
# Config 通常只写一次,直接写入即可
|
|
83
|
+
with open(self.config_fp, "w") as f:
|
|
84
|
+
json.dump(jsonable_encoder(config), f, indent=4)
|
|
85
|
+
|
|
86
|
+
self.print_to_screen = print_to_screen
|
|
87
|
+
self.print_handle = print_handle
|
|
88
|
+
|
|
89
|
+
# 优化:流式计算行数,避免一次性加载大文件到内存 (对 BlobFuse 友好)
|
|
90
|
+
self.current_step = 0
|
|
91
|
+
if self.log_fp.exists():
|
|
92
|
+
try:
|
|
93
|
+
with open(self.log_fp, "rb") as f:
|
|
94
|
+
self.current_step = sum(1 for _ in f)
|
|
95
|
+
except Exception as e:
|
|
96
|
+
logger.warning(f"Could not count existing lines in {self.log_fp}: {e}")
|
|
97
|
+
|
|
98
|
+
# 注册退出钩子:确保程序意外终止时也能写入剩余数据
|
|
99
|
+
atexit.register(self.flush)
|
|
100
|
+
|
|
101
|
+
def log(self, metrics: dict, step: int | None = None):
|
|
102
|
+
now = time.monotonic()
|
|
103
|
+
|
|
104
|
+
with self._lock:
|
|
105
|
+
if step is not None:
|
|
106
|
+
self.current_step = step
|
|
107
|
+
|
|
108
|
+
record = {"_step": self.current_step, **metrics}
|
|
109
|
+
|
|
110
|
+
# 1. 写入内存 Buffer
|
|
111
|
+
self.buffer.append(record)
|
|
112
|
+
if self._first_buffered_time is None:
|
|
113
|
+
self._first_buffered_time = now
|
|
114
|
+
|
|
115
|
+
# 2. 根据「距离上一次 log 的时间间隔」判断是否为高频写入
|
|
116
|
+
interval = None if self._last_log_time is None else now - self._last_log_time
|
|
117
|
+
self._last_log_time = now
|
|
118
|
+
|
|
119
|
+
should_flush = self._should_flush(now, interval)
|
|
120
|
+
self.current_step += 1
|
|
121
|
+
|
|
122
|
+
# 3. 屏幕打印(放在锁外,避免 print handle 阻塞其他线程)
|
|
123
|
+
if self.print_to_screen:
|
|
124
|
+
self.print_handle(f"{record}")
|
|
125
|
+
|
|
126
|
+
# 4. 立即写盘,或安排一次超时写盘
|
|
127
|
+
if should_flush:
|
|
128
|
+
self.flush()
|
|
129
|
+
else:
|
|
130
|
+
self._schedule_timer()
|
|
131
|
+
|
|
132
|
+
def _should_flush(self, now: float, interval: float | None) -> bool:
|
|
133
|
+
"""判断当前是否应该立即写盘(需在持锁状态下调用)"""
|
|
134
|
+
# Buffer 已满
|
|
135
|
+
if len(self.buffer) >= self.buffer_size:
|
|
136
|
+
return True
|
|
137
|
+
# 首次 log:直接落盘,尽快产生文件内容
|
|
138
|
+
if interval is None:
|
|
139
|
+
return True
|
|
140
|
+
# 低频写入:距离上次 log 已经过去足够久,没必要继续攒批
|
|
141
|
+
if self.buffer_interval is not None and interval >= self.buffer_interval:
|
|
142
|
+
return True
|
|
143
|
+
# 高频写入,但最早的记录已在内存中停留过久
|
|
144
|
+
return (
|
|
145
|
+
self.max_buffer_seconds is not None
|
|
146
|
+
and self._first_buffered_time is not None
|
|
147
|
+
and now - self._first_buffered_time >= self.max_buffer_seconds
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
def _schedule_timer(self):
|
|
151
|
+
"""为 buffer 中最早的记录安排一次超时写盘"""
|
|
152
|
+
if self.max_buffer_seconds is None:
|
|
153
|
+
return
|
|
154
|
+
with self._lock:
|
|
155
|
+
if self._flush_timer is not None or not self.buffer:
|
|
156
|
+
return
|
|
157
|
+
now = time.monotonic()
|
|
158
|
+
elapsed = now - (self._first_buffered_time or now)
|
|
159
|
+
delay = max(0.0, self.max_buffer_seconds - elapsed)
|
|
160
|
+
timer = threading.Timer(delay, self._on_timer)
|
|
161
|
+
timer.daemon = True
|
|
162
|
+
self._flush_timer = timer
|
|
163
|
+
timer.start()
|
|
164
|
+
|
|
165
|
+
def _on_timer(self):
|
|
166
|
+
with self._lock:
|
|
167
|
+
self._flush_timer = None
|
|
168
|
+
self.flush()
|
|
169
|
+
|
|
170
|
+
def _cancel_timer(self):
|
|
171
|
+
with self._lock:
|
|
172
|
+
timer = self._flush_timer
|
|
173
|
+
self._flush_timer = None
|
|
174
|
+
if timer is not None:
|
|
175
|
+
timer.cancel()
|
|
176
|
+
|
|
177
|
+
def flush(self):
|
|
178
|
+
"""强制将内存中的 Buffer 写入磁盘"""
|
|
179
|
+
self._cancel_timer()
|
|
180
|
+
|
|
181
|
+
with self._lock:
|
|
182
|
+
self._first_buffered_time = None
|
|
183
|
+
if not self.buffer:
|
|
184
|
+
return
|
|
185
|
+
records, self.buffer = self.buffer, []
|
|
186
|
+
log_fp = self.log_fp
|
|
187
|
+
|
|
188
|
+
# 确保目录存在 (防止运行时目录被删)
|
|
189
|
+
if log_fp and not log_fp.parent.exists():
|
|
190
|
+
log_fp.parent.mkdir(parents=True, exist_ok=True)
|
|
191
|
+
|
|
192
|
+
try:
|
|
193
|
+
# 批量追加写入
|
|
194
|
+
with jsonlines.open(log_fp, mode="a") as writer:
|
|
195
|
+
writer.write_all(records)
|
|
196
|
+
except Exception as e:
|
|
197
|
+
logger.error(f"Failed to flush metrics to {log_fp}: {e}")
|
|
198
|
+
# 写入失败:放回 buffer 头部,等待下次 flush 重试
|
|
199
|
+
with self._lock:
|
|
200
|
+
self.buffer[:0] = records
|
|
201
|
+
self._first_buffered_time = time.monotonic()
|
|
202
|
+
self._schedule_timer()
|
|
203
|
+
|
|
204
|
+
def finish(self):
|
|
205
|
+
"""结束时显式调用"""
|
|
206
|
+
self._cancel_timer()
|
|
207
|
+
self.flush()
|
|
208
|
+
# 如果手动调用了 finish,取消 atexit 注册,防止重复调用
|
|
209
|
+
atexit.unregister(self.flush)
|
expr_tracker/tracker.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from contextvars import ContextVar
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from loguru import logger
|
|
6
|
+
|
|
7
|
+
_tracker: ContextVar["Tracker | None"] = ContextVar("tracker", default=None)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_backend(backend: str | object):
|
|
11
|
+
if isinstance(backend, str):
|
|
12
|
+
backend = backend.lower()
|
|
13
|
+
if backend == "wandb":
|
|
14
|
+
try:
|
|
15
|
+
import wandb
|
|
16
|
+
|
|
17
|
+
wandb.login(
|
|
18
|
+
key=os.getenv("WANDB_API_KEY", None),
|
|
19
|
+
host=os.getenv("WANDB_HOST", None),
|
|
20
|
+
)
|
|
21
|
+
return wandb
|
|
22
|
+
except ImportError:
|
|
23
|
+
raise ImportError("WandB backend is not installed.")
|
|
24
|
+
elif backend == "jsonl":
|
|
25
|
+
from .jsonl import JsonlTracker
|
|
26
|
+
|
|
27
|
+
return JsonlTracker()
|
|
28
|
+
elif backend == "trackio":
|
|
29
|
+
try:
|
|
30
|
+
import trackio
|
|
31
|
+
|
|
32
|
+
return trackio
|
|
33
|
+
except ImportError:
|
|
34
|
+
raise ImportError("TrackIO backend is not installed.")
|
|
35
|
+
else:
|
|
36
|
+
return backend
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Tracker:
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
project: str,
|
|
43
|
+
name: str | None = None,
|
|
44
|
+
entity: str | None = None,
|
|
45
|
+
dir: str | None = None,
|
|
46
|
+
notes: str | None = None,
|
|
47
|
+
tags: list[str] | None = None,
|
|
48
|
+
resume: bool | Literal["allow", "never", "must", "auto"] | None = "allow",
|
|
49
|
+
config: dict | None = None,
|
|
50
|
+
backends: list[Literal["wandb", "jsonl", "trackio"]] = [
|
|
51
|
+
"wandb",
|
|
52
|
+
"trackio",
|
|
53
|
+
"jsonl",
|
|
54
|
+
],
|
|
55
|
+
backend_kwargs: dict[str, dict] | None = None,
|
|
56
|
+
**kwargs,
|
|
57
|
+
):
|
|
58
|
+
if kwargs:
|
|
59
|
+
logger.info(
|
|
60
|
+
f"Unrecognized keyword arguments passed to Tracker: {kwargs}. "
|
|
61
|
+
"These will be ignored."
|
|
62
|
+
)
|
|
63
|
+
self.backend = {b: get_backend(b) for b in backends}
|
|
64
|
+
if config is None:
|
|
65
|
+
config = {}
|
|
66
|
+
if backend_kwargs is None:
|
|
67
|
+
backend_kwargs = {}
|
|
68
|
+
for b, t in self.backend.items():
|
|
69
|
+
if b == "trackio":
|
|
70
|
+
t.init(
|
|
71
|
+
project=project,
|
|
72
|
+
name=name,
|
|
73
|
+
config=config.update(
|
|
74
|
+
{
|
|
75
|
+
"trackio.notes": notes,
|
|
76
|
+
"trackio.tags": tags,
|
|
77
|
+
"trackio.entity": entity,
|
|
78
|
+
"trackio.resume": resume,
|
|
79
|
+
}
|
|
80
|
+
),
|
|
81
|
+
**backend_kwargs.get(b, {}),
|
|
82
|
+
)
|
|
83
|
+
else:
|
|
84
|
+
t.init(
|
|
85
|
+
project=project,
|
|
86
|
+
name=name,
|
|
87
|
+
entity=entity,
|
|
88
|
+
dir=dir,
|
|
89
|
+
notes=notes,
|
|
90
|
+
tags=tags,
|
|
91
|
+
resume=resume,
|
|
92
|
+
id=name,
|
|
93
|
+
config=config,
|
|
94
|
+
**backend_kwargs.get(b, {}),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def init(
|
|
99
|
+
project: str,
|
|
100
|
+
name: str | None = None,
|
|
101
|
+
entity: str | None = None,
|
|
102
|
+
dir: str | None = None,
|
|
103
|
+
notes: str | None = None,
|
|
104
|
+
tags: list[str] | None = None,
|
|
105
|
+
resume: bool | Literal["allow", "never", "must", "auto"] | None = "allow",
|
|
106
|
+
config: dict | None = None,
|
|
107
|
+
backends: list[Literal["wandb", "jsonl", "trackio"]] = ["wandb", "jsonl"],
|
|
108
|
+
backend_kwargs: dict[str, dict] | None = None,
|
|
109
|
+
**kwargs,
|
|
110
|
+
) -> Tracker:
|
|
111
|
+
"""
|
|
112
|
+
Initialize the tracker with the given parameters.
|
|
113
|
+
"""
|
|
114
|
+
if _tracker.get() is not None:
|
|
115
|
+
raise RuntimeError("Tracker is already initialized. Call finish() first.")
|
|
116
|
+
|
|
117
|
+
tracker = Tracker(
|
|
118
|
+
project=project,
|
|
119
|
+
name=name,
|
|
120
|
+
entity=entity,
|
|
121
|
+
dir=dir,
|
|
122
|
+
notes=notes,
|
|
123
|
+
tags=tags,
|
|
124
|
+
resume=resume,
|
|
125
|
+
config=config,
|
|
126
|
+
backends=backends,
|
|
127
|
+
backend_kwargs=backend_kwargs,
|
|
128
|
+
**kwargs,
|
|
129
|
+
)
|
|
130
|
+
_tracker.set(tracker)
|
|
131
|
+
return tracker
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def log(metrics: dict, step: int | None = None):
|
|
135
|
+
tracker = _tracker.get()
|
|
136
|
+
if tracker is None:
|
|
137
|
+
raise RuntimeError("Tracker is not initialized. Call init() first.")
|
|
138
|
+
for backend, t in tracker.backend.items():
|
|
139
|
+
try:
|
|
140
|
+
t.log(metrics, step=step)
|
|
141
|
+
except Exception as e:
|
|
142
|
+
logger.warning(f"Failed to log metrics to {backend}: {e}")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def finish():
|
|
146
|
+
tracker = _tracker.get()
|
|
147
|
+
if tracker is None:
|
|
148
|
+
raise RuntimeError("Tracker is not initialized. Call init() first.")
|
|
149
|
+
for backend, t in tracker.backend.items():
|
|
150
|
+
try:
|
|
151
|
+
t.finish()
|
|
152
|
+
except Exception as e:
|
|
153
|
+
logger.warning(f"Failed to finish tracker for {backend}: {e}")
|
|
154
|
+
_tracker.set(None)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def info():
|
|
158
|
+
tracker = _tracker.get()
|
|
159
|
+
if tracker is None:
|
|
160
|
+
raise RuntimeError("Tracker is not initialized. Call init() first.")
|
|
161
|
+
info = {}
|
|
162
|
+
for backend, t in tracker.backend.items():
|
|
163
|
+
if backend == "wandb":
|
|
164
|
+
info["wandb"] = {"url": t.run.url}
|
|
165
|
+
if backend == "jsonl":
|
|
166
|
+
info["jsonl"] = {"log_dir": t.log_dir.as_posix()}
|
|
167
|
+
return info
|
expr_tracker/types.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import types
|
|
2
|
+
from enum import Enum
|
|
3
|
+
from typing import Any, Callable, Dict, Set, Type, TypeVar, Union
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
DecoratedCallable = TypeVar("DecoratedCallable", bound=Callable[..., Any])
|
|
8
|
+
UnionType = getattr(types, "UnionType", Union)
|
|
9
|
+
ModelNameMap = Dict[Union[Type[BaseModel], Type[Enum]], str]
|
|
10
|
+
IncEx = Union[Set[int], Set[str], Dict[int, Any], Dict[str, Any]]
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: expr_tracker
|
|
3
|
+
Version: 0.1.7
|
|
4
|
+
Summary: Add your description here
|
|
5
|
+
Author-email: HSPK <whxway@whu.edu.cn>
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Requires-Dist: jsonlines>=4.0.0
|
|
8
|
+
Requires-Dist: loguru>=0.7.3
|
|
9
|
+
Requires-Dist: slark>=0.1.28
|
|
10
|
+
Requires-Dist: wandb>=0.21.0
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# Experiment Tracker
|
|
14
|
+
|
|
15
|
+
A simple experiment tracker that supports `wandb`, `trackio` and local `jsonl` storage. Features include:
|
|
16
|
+
|
|
17
|
+
## Features
|
|
18
|
+
1. **Multi-Backend Support**: Compatible with `wandb`, `trackio`, and local `jsonl` storage.
|
|
19
|
+
2. **Alert System**: Send alerts via Lark, with email and other platforms to be added.
|
|
20
|
+
3. **Resume Functionality**: Allows resuming experiments using project and name as unique identifiers.
|
|
21
|
+
4. **Simple API**: Provides a straightforward API similar to `wandb`, making it easy to integrate into existing workflows.
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
Add dependency to your project:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
uv add expr_tracker
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Simple usage example:
|
|
32
|
+
```python
|
|
33
|
+
import expr_tracker as et
|
|
34
|
+
|
|
35
|
+
et.init(project="my_project", name="my_experiment", backends=["wandb", "jsonl"])
|
|
36
|
+
et.log({"accuracy": 0.95, "loss": 0.05})
|
|
37
|
+
et.alert("Experiment completed!", text="Your experiment has finished successfully.", subtitle="Experiment Status")
|
|
38
|
+
et.finish()
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### JSONL buffering
|
|
42
|
+
|
|
43
|
+
The `jsonl` backend adapts its buffering to how often you call `log()`, so that
|
|
44
|
+
high-frequency logging doesn't hammer the disk (helpful on network mounts such as
|
|
45
|
+
BlobFuse) while low-frequency logging still lands on disk immediately:
|
|
46
|
+
|
|
47
|
+
- If the gap since the previous `log()` call is `>= buffer_interval` (default `1.0s`),
|
|
48
|
+
the call is considered low-frequency and is written straight through.
|
|
49
|
+
- Otherwise records are batched in memory and flushed once `buffer_size` (default `50`)
|
|
50
|
+
records accumulate.
|
|
51
|
+
- A background timer flushes records that have been buffered for more than
|
|
52
|
+
`max_buffer_seconds` (default `5.0s`), so a burst that suddenly stops is never stranded
|
|
53
|
+
in memory. `finish()` and the `atexit` hook flush any remainder.
|
|
54
|
+
|
|
55
|
+
Set `buffer_interval=None` to disable the frequency check, or `max_buffer_seconds=None`
|
|
56
|
+
to disable the background timer. Tune them via `backend_kwargs`:
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
et.init(
|
|
60
|
+
project="my_project",
|
|
61
|
+
name="my_experiment",
|
|
62
|
+
backends=["jsonl"],
|
|
63
|
+
backend_kwargs={
|
|
64
|
+
"jsonl": {"buffer_size": 200, "buffer_interval": 0.5, "max_buffer_seconds": 10},
|
|
65
|
+
},
|
|
66
|
+
)
|
|
67
|
+
```
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
expr_tracker/__init__.py,sha256=I-Y4K79ZSh0thhpQehfFVG2CvWpXKoQSwNFn05UN4uY,184
|
|
2
|
+
expr_tracker/_compat.py,sha256=qf8oYIK_lkoLzcPp6wPCYLJtVatCl7ROYRNPwc5F1Pg,981
|
|
3
|
+
expr_tracker/alert.py,sha256=6egNe-DGp1U96kjRJ8bN4sV3Se_kMKX5q-rSj9kW3Kc,2342
|
|
4
|
+
expr_tracker/cli.py,sha256=Yhyh_rIjCjzkC2ri6q9ldVhCsTlrOlpp-jRgh4foyFU,368
|
|
5
|
+
expr_tracker/encoders.py,sha256=OUsoS5Hd499zOZPb5QdojhUKFmCAGzYQD4OwEqSHvEg,11124
|
|
6
|
+
expr_tracker/jsonl.py,sha256=OQHwuihKCbRFlUY-lPLduoeCcS8W4Y5wTLUpHyEBBh0,7646
|
|
7
|
+
expr_tracker/tracker.py,sha256=iGdsr-KzjHcUJnQR6EgMwfgra53KwhimHfV-mVA89qQ,5023
|
|
8
|
+
expr_tracker/types.py,sha256=nFb36sK3DSoqoyo7Miwy3meKK5UdFBgkAgLSzQlUVyI,383
|
|
9
|
+
expr_tracker-0.1.7.dist-info/METADATA,sha256=61rVcbtCL5W75bzEFmedfa1XdHo_aGnhOancBdB0UPM,2379
|
|
10
|
+
expr_tracker-0.1.7.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
11
|
+
expr_tracker-0.1.7.dist-info/entry_points.txt,sha256=GfZCAG2JFsd5CxTjRsGJAhYInpBCuLjrU3csmUVBDSs,45
|
|
12
|
+
expr_tracker-0.1.7.dist-info/RECORD,,
|