expr_tracker 0.1.6__tar.gz → 0.1.7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,53 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+ workflow_dispatch:
8
+
9
+ permissions:
10
+ contents: read
11
+
12
+ jobs:
13
+ release:
14
+ name: Build and publish to PyPI
15
+ runs-on: ubuntu-latest
16
+ permissions:
17
+ # required for PyPI trusted publishing (OIDC)
18
+ id-token: write
19
+ # required to create the GitHub release
20
+ contents: write
21
+ steps:
22
+ - name: Checkout
23
+ uses: actions/checkout@v5
24
+ with:
25
+ # uv-dynamic-versioning derives the version from git tags
26
+ fetch-depth: 0
27
+
28
+ - name: Install uv
29
+ uses: astral-sh/setup-uv@v6
30
+ with:
31
+ enable-cache: true
32
+
33
+ - name: Build sdist and wheel
34
+ run: |
35
+ uv build
36
+ ls -l dist/
37
+
38
+ - name: Check distribution metadata
39
+ run: uvx twine check dist/*
40
+
41
+ - name: Publish to PyPI
42
+ if: startsWith(github.ref, 'refs/tags/v')
43
+ uses: pypa/gh-action-pypi-publish@release/v1
44
+
45
+ - name: Create GitHub release
46
+ if: startsWith(github.ref, 'refs/tags/v')
47
+ env:
48
+ GH_TOKEN: ${{ github.token }}
49
+ run: |
50
+ gh release create "${GITHUB_REF_NAME}" dist/* \
51
+ --repo "${GITHUB_REPOSITORY}" \
52
+ --title "${GITHUB_REF_NAME}" \
53
+ --generate-notes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: expr_tracker
3
- Version: 0.1.6
3
+ Version: 0.1.7
4
4
  Summary: Add your description here
5
5
  Author-email: HSPK <whxway@whu.edu.cn>
6
6
  Requires-Python: >=3.10
@@ -36,4 +36,32 @@ et.init(project="my_project", name="my_experiment", backends=["wandb", "jsonl"])
36
36
  et.log({"accuracy": 0.95, "loss": 0.05})
37
37
  et.alert("Experiment completed!", text="Your experiment has finished successfully.", subtitle="Experiment Status")
38
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
+ )
39
67
  ```
@@ -0,0 +1,55 @@
1
+ # Experiment Tracker
2
+
3
+ A simple experiment tracker that supports `wandb`, `trackio` and local `jsonl` storage. Features include:
4
+
5
+ ## Features
6
+ 1. **Multi-Backend Support**: Compatible with `wandb`, `trackio`, and local `jsonl` storage.
7
+ 2. **Alert System**: Send alerts via Lark, with email and other platforms to be added.
8
+ 3. **Resume Functionality**: Allows resuming experiments using project and name as unique identifiers.
9
+ 4. **Simple API**: Provides a straightforward API similar to `wandb`, making it easy to integrate into existing workflows.
10
+
11
+ ## Usage
12
+
13
+ Add dependency to your project:
14
+
15
+ ```bash
16
+ uv add expr_tracker
17
+ ```
18
+
19
+ Simple usage example:
20
+ ```python
21
+ import expr_tracker as et
22
+
23
+ et.init(project="my_project", name="my_experiment", backends=["wandb", "jsonl"])
24
+ et.log({"accuracy": 0.95, "loss": 0.05})
25
+ et.alert("Experiment completed!", text="Your experiment has finished successfully.", subtitle="Experiment Status")
26
+ et.finish()
27
+ ```
28
+
29
+ ### JSONL buffering
30
+
31
+ The `jsonl` backend adapts its buffering to how often you call `log()`, so that
32
+ high-frequency logging doesn't hammer the disk (helpful on network mounts such as
33
+ BlobFuse) while low-frequency logging still lands on disk immediately:
34
+
35
+ - If the gap since the previous `log()` call is `>= buffer_interval` (default `1.0s`),
36
+ the call is considered low-frequency and is written straight through.
37
+ - Otherwise records are batched in memory and flushed once `buffer_size` (default `50`)
38
+ records accumulate.
39
+ - A background timer flushes records that have been buffered for more than
40
+ `max_buffer_seconds` (default `5.0s`), so a burst that suddenly stops is never stranded
41
+ in memory. `finish()` and the `atexit` hook flush any remainder.
42
+
43
+ Set `buffer_interval=None` to disable the frequency check, or `max_buffer_seconds=None`
44
+ to disable the background timer. Tune them via `backend_kwargs`:
45
+
46
+ ```python
47
+ et.init(
48
+ project="my_project",
49
+ name="my_experiment",
50
+ backends=["jsonl"],
51
+ backend_kwargs={
52
+ "jsonl": {"buffer_size": 200, "buffer_interval": 0.5, "max_buffer_seconds": 10},
53
+ },
54
+ )
55
+ ```
@@ -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,113 @@
1
+ import tempfile
2
+ import threading
3
+ import time
4
+ from pathlib import Path
5
+
6
+ import jsonlines
7
+
8
+ from expr_tracker.jsonl import JsonlTracker
9
+
10
+
11
+ def _count_lines(fp: Path) -> int:
12
+ if not fp.exists():
13
+ return 0
14
+ with open(fp, "rb") as f:
15
+ return sum(1 for _ in f)
16
+
17
+
18
+ def _new_tracker(name: str, **kwargs):
19
+ tracker = JsonlTracker()
20
+ tracker.init(project="p", name=name, dir=tempfile.mkdtemp(), **kwargs)
21
+ return tracker
22
+
23
+
24
+ def test_low_frequency_writes_through():
25
+ """间隔大于 buffer_interval 的低频 log 应该直接写盘"""
26
+ tracker = _new_tracker("low_freq", buffer_size=50, buffer_interval=0.2)
27
+ for i in range(4):
28
+ tracker.log({"v": i})
29
+ time.sleep(0.25)
30
+ assert _count_lines(tracker.log_fp) == 4
31
+ assert tracker.buffer == []
32
+ tracker.finish()
33
+
34
+
35
+ def test_high_frequency_is_buffered():
36
+ """高频 log 攒批,直到 buffer_size 满才写盘"""
37
+ tracker = _new_tracker(
38
+ "high_freq", buffer_size=10, buffer_interval=0.2, max_buffer_seconds=None
39
+ )
40
+ for i in range(9):
41
+ tracker.log({"v": i})
42
+
43
+ # 第一条直接落盘,其余 8 条留在 buffer 中
44
+ assert _count_lines(tracker.log_fp) == 1
45
+ assert len(tracker.buffer) == 8
46
+
47
+ for i in range(2):
48
+ tracker.log({"v": 100 + i})
49
+ assert _count_lines(tracker.log_fp) == 11
50
+ assert tracker.buffer == []
51
+ tracker.finish()
52
+
53
+
54
+ def test_max_buffer_seconds_flushes_stale_records():
55
+ """高频写入突然停止时,后台定时器应在超时后写盘"""
56
+ tracker = _new_tracker(
57
+ "timer", buffer_size=1000, buffer_interval=1.0, max_buffer_seconds=0.5
58
+ )
59
+ for i in range(5):
60
+ tracker.log({"v": i})
61
+ assert _count_lines(tracker.log_fp) == 1
62
+
63
+ time.sleep(0.9)
64
+ assert _count_lines(tracker.log_fp) == 5
65
+ assert tracker.buffer == []
66
+ tracker.finish()
67
+
68
+
69
+ def test_steps_are_continuous_across_resume():
70
+ tracker = _new_tracker(
71
+ "resume", buffer_size=3, buffer_interval=None, max_buffer_seconds=None
72
+ )
73
+ log_dir = tracker.log_dir.parent.parent
74
+ for i in range(6):
75
+ tracker.log({"v": i})
76
+ tracker.finish()
77
+ assert _count_lines(tracker.log_fp) == 6
78
+
79
+ resumed = JsonlTracker()
80
+ resumed.init(project="p", name="resume", dir=str(log_dir))
81
+ assert resumed.current_step == 6
82
+ resumed.log({"v": 6})
83
+ resumed.finish()
84
+
85
+ with jsonlines.open(resumed.log_fp) as reader:
86
+ assert [record["_step"] for record in reader] == list(range(7))
87
+
88
+
89
+ def test_concurrent_logging_does_not_lose_records():
90
+ tracker = _new_tracker(
91
+ "threads", buffer_size=25, buffer_interval=0.05, max_buffer_seconds=0.2
92
+ )
93
+
94
+ def worker():
95
+ for i in range(200):
96
+ tracker.log({"v": i})
97
+
98
+ threads = [threading.Thread(target=worker) for _ in range(4)]
99
+ for thread in threads:
100
+ thread.start()
101
+ for thread in threads:
102
+ thread.join()
103
+ tracker.finish()
104
+
105
+ assert _count_lines(tracker.log_fp) == 800
106
+
107
+
108
+ if __name__ == "__main__":
109
+ for _name, _fn in sorted(globals().items()):
110
+ if _name.startswith("test_") and callable(_fn):
111
+ _fn()
112
+ print(f"{_name} OK")
113
+ print("ALL OK")
@@ -1,27 +0,0 @@
1
- # Experiment Tracker
2
-
3
- A simple experiment tracker that supports `wandb`, `trackio` and local `jsonl` storage. Features include:
4
-
5
- ## Features
6
- 1. **Multi-Backend Support**: Compatible with `wandb`, `trackio`, and local `jsonl` storage.
7
- 2. **Alert System**: Send alerts via Lark, with email and other platforms to be added.
8
- 3. **Resume Functionality**: Allows resuming experiments using project and name as unique identifiers.
9
- 4. **Simple API**: Provides a straightforward API similar to `wandb`, making it easy to integrate into existing workflows.
10
-
11
- ## Usage
12
-
13
- Add dependency to your project:
14
-
15
- ```bash
16
- uv add expr_tracker
17
- ```
18
-
19
- Simple usage example:
20
- ```python
21
- import expr_tracker as et
22
-
23
- et.init(project="my_project", name="my_experiment", backends=["wandb", "jsonl"])
24
- et.log({"accuracy": 0.95, "loss": 0.05})
25
- et.alert("Experiment completed!", text="Your experiment has finished successfully.", subtitle="Experiment Status")
26
- et.finish()
27
- ```
@@ -1,107 +0,0 @@
1
- import atexit
2
- import json
3
- from pathlib import Path
4
-
5
- import jsonlines
6
- from loguru import logger
7
-
8
- from expr_tracker.encoders import jsonable_encoder
9
-
10
-
11
- class JsonlTracker:
12
- def __init__(self):
13
- self.buffer = []
14
- self.buffer_size = 50
15
- self.log_fp = None
16
-
17
- def init(
18
- self,
19
- project: str,
20
- name: str | None = None,
21
- config: dict | None = None,
22
- dir: str | None = None,
23
- print_to_screen: bool = False,
24
- print_handle=print,
25
- buffer_size: int = 50, # 新增 buffer_size 参数
26
- **kwargs,
27
- ):
28
- self.project = project
29
- self.name = name
30
- if dir is None:
31
- dir = "./tracker/jsonl"
32
- self.log_dir = Path(dir) / self.project / self.name
33
- self.config_fp = self.log_dir / "config.json"
34
- self.log_fp = self.log_dir / "metrics.jsonl"
35
-
36
- # 初始化 Buffer 配置
37
- self.buffer = []
38
- self.buffer_size = buffer_size
39
-
40
- self.log_dir.mkdir(parents=True, exist_ok=True)
41
-
42
- if self.config_fp.exists():
43
- logger.warning(
44
- f"Config file {self.config_fp} already exists. It will be overwritten."
45
- )
46
-
47
- if config is not None:
48
- # Config 通常只写一次,直接写入即可
49
- with open(self.config_fp, "w") as f:
50
- json.dump(jsonable_encoder(config), f, indent=4)
51
-
52
- self.print_to_screen = print_to_screen
53
- self.print_handle = print_handle
54
-
55
- # 优化:流式计算行数,避免一次性加载大文件到内存 (对 BlobFuse 友好)
56
- self.current_step = 0
57
- if self.log_fp.exists():
58
- try:
59
- with open(self.log_fp, "rb") as f:
60
- self.current_step = sum(1 for _ in f)
61
- except Exception as e:
62
- logger.warning(f"Could not count existing lines in {self.log_fp}: {e}")
63
-
64
- # 注册退出钩子:确保程序意外终止时也能写入剩余数据
65
- atexit.register(self.flush)
66
-
67
- def log(self, metrics: dict, step: int | None = None):
68
- if step is not None:
69
- self.current_step = step
70
-
71
- record = {"_step": self.current_step, **metrics}
72
-
73
- # 1. 写入内存 Buffer
74
- self.buffer.append(record)
75
-
76
- # 2. 屏幕打印
77
- if self.print_to_screen:
78
- self.print_handle(f"{record}")
79
-
80
- # 3. 检查 Buffer 是否已满
81
- if len(self.buffer) >= self.buffer_size:
82
- self.flush()
83
-
84
- self.current_step += 1
85
-
86
- def flush(self):
87
- """强制将内存中的 Buffer 写入磁盘"""
88
- if not self.buffer:
89
- return
90
-
91
- # 确保目录存在 (防止运行时目录被删)
92
- if self.log_fp and not self.log_fp.parent.exists():
93
- self.log_fp.parent.mkdir(parents=True, exist_ok=True)
94
-
95
- try:
96
- # 批量追加写入
97
- with jsonlines.open(self.log_fp, mode="a") as writer:
98
- writer.write_all(self.buffer)
99
- self.buffer = [] # 写入成功后清空 Buffer
100
- except Exception as e:
101
- logger.error(f"Failed to flush metrics to {self.log_fp}: {e}")
102
-
103
- def finish(self):
104
- """结束时显式调用"""
105
- self.flush()
106
- # 如果手动调用了 finish,取消 atexit 注册,防止重复调用
107
- atexit.unregister(self.flush)
File without changes
File without changes