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.
@@ -0,0 +1,9 @@
1
+ from . import tracker
2
+ from .alert import alert
3
+
4
+ init = tracker.init
5
+ finish = tracker.finish
6
+ log = tracker.log
7
+ info = tracker.info
8
+
9
+ __all__ = ["init", "finish", "log", "info", "alert"]
@@ -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)
@@ -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)
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ et = expr_tracker.cli:main