sixtyseven 0.1.0__cp312-cp312-macosx_11_0_arm64.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.
- sixtyseven-0.1.0.data/purelib/sixtyseven/__init__.py +36 -0
- sixtyseven-0.1.0.data/purelib/sixtyseven/cli.py +64 -0
- sixtyseven-0.1.0.data/purelib/sixtyseven/client.py +190 -0
- sixtyseven-0.1.0.data/purelib/sixtyseven/config.py +161 -0
- sixtyseven-0.1.0.data/purelib/sixtyseven/exceptions.py +40 -0
- sixtyseven-0.1.0.data/purelib/sixtyseven/local.py +335 -0
- sixtyseven-0.1.0.data/purelib/sixtyseven/metrics.py +157 -0
- sixtyseven-0.1.0.data/purelib/sixtyseven/run.py +445 -0
- sixtyseven-0.1.0.data/purelib/sixtyseven/server.py +383 -0
- sixtyseven-0.1.0.data/purelib/sixtyseven/utils.py +171 -0
- sixtyseven-0.1.0.dist-info/METADATA +84 -0
- sixtyseven-0.1.0.dist-info/RECORD +15 -0
- sixtyseven-0.1.0.dist-info/WHEEL +5 -0
- sixtyseven-0.1.0.dist-info/entry_points.txt +2 -0
- sixtyseven-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""Local file-based storage for Sixtyseven SDK."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sqlite3
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
import uuid
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Dict, List, Optional
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class LocalWriter:
|
|
14
|
+
"""
|
|
15
|
+
Writes metrics directly to local SQLite + JSON files.
|
|
16
|
+
|
|
17
|
+
Directory structure:
|
|
18
|
+
{logdir}/{project}/{run_name}/
|
|
19
|
+
run.db # SQLite database with metrics
|
|
20
|
+
meta.json # Run metadata (name, status, git info, etc.)
|
|
21
|
+
config.json # Hyperparameters
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
logdir: str,
|
|
27
|
+
project: str,
|
|
28
|
+
run_name: str,
|
|
29
|
+
tags: Optional[List[str]] = None,
|
|
30
|
+
config: Optional[Dict[str, Any]] = None,
|
|
31
|
+
git_info: Optional[Dict[str, Any]] = None,
|
|
32
|
+
system_info: Optional[Dict[str, Any]] = None,
|
|
33
|
+
):
|
|
34
|
+
"""
|
|
35
|
+
Initialize the local writer.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
logdir: Base directory for all logs
|
|
39
|
+
project: Project name (used as directory name)
|
|
40
|
+
run_name: Run name (used as directory name)
|
|
41
|
+
tags: Optional list of tags
|
|
42
|
+
config: Optional initial configuration
|
|
43
|
+
git_info: Optional git information
|
|
44
|
+
system_info: Optional system information
|
|
45
|
+
"""
|
|
46
|
+
# Sanitize names for filesystem
|
|
47
|
+
self._project = self._sanitize_name(project)
|
|
48
|
+
self._run_name = self._sanitize_name(run_name)
|
|
49
|
+
self._run_id = str(uuid.uuid4())
|
|
50
|
+
|
|
51
|
+
# Create run directory
|
|
52
|
+
self._run_dir = Path(logdir).expanduser() / self._project / self._run_name
|
|
53
|
+
self._run_dir.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
|
|
55
|
+
# Initialize SQLite database
|
|
56
|
+
self._db_path = self._run_dir / "run.db"
|
|
57
|
+
self._db: Optional[sqlite3.Connection] = None
|
|
58
|
+
self._lock = threading.Lock()
|
|
59
|
+
self._init_database()
|
|
60
|
+
|
|
61
|
+
# Write initial metadata
|
|
62
|
+
self._write_meta(
|
|
63
|
+
{
|
|
64
|
+
"id": self._run_id,
|
|
65
|
+
"name": run_name,
|
|
66
|
+
"project": project,
|
|
67
|
+
"status": "running",
|
|
68
|
+
"tags": tags or [],
|
|
69
|
+
"git_info": git_info,
|
|
70
|
+
"system_info": system_info,
|
|
71
|
+
"started_at": datetime.now(timezone.utc).isoformat(),
|
|
72
|
+
"ended_at": None,
|
|
73
|
+
"error_message": None,
|
|
74
|
+
}
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# Write initial config
|
|
78
|
+
if config:
|
|
79
|
+
self._write_config(config)
|
|
80
|
+
else:
|
|
81
|
+
self._write_config({})
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def run_id(self) -> str:
|
|
85
|
+
"""Return the run ID."""
|
|
86
|
+
return self._run_id
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def run_dir(self) -> Path:
|
|
90
|
+
"""Return the run directory path."""
|
|
91
|
+
return self._run_dir
|
|
92
|
+
|
|
93
|
+
def _init_database(self) -> None:
|
|
94
|
+
"""Initialize the SQLite database schema."""
|
|
95
|
+
self._db = sqlite3.connect(str(self._db_path), check_same_thread=False)
|
|
96
|
+
self._db.execute("PRAGMA journal_mode=WAL") # Better concurrent access
|
|
97
|
+
self._db.execute("""
|
|
98
|
+
CREATE TABLE IF NOT EXISTS metrics (
|
|
99
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
100
|
+
time REAL NOT NULL,
|
|
101
|
+
name TEXT NOT NULL,
|
|
102
|
+
step INTEGER NOT NULL,
|
|
103
|
+
value REAL NOT NULL
|
|
104
|
+
)
|
|
105
|
+
""")
|
|
106
|
+
self._db.execute("""
|
|
107
|
+
CREATE INDEX IF NOT EXISTS idx_metrics_name_step
|
|
108
|
+
ON metrics(name, step)
|
|
109
|
+
""")
|
|
110
|
+
self._db.execute("""
|
|
111
|
+
CREATE INDEX IF NOT EXISTS idx_metrics_time
|
|
112
|
+
ON metrics(time)
|
|
113
|
+
""")
|
|
114
|
+
self._db.commit()
|
|
115
|
+
|
|
116
|
+
def log_metrics(
|
|
117
|
+
self,
|
|
118
|
+
metrics: Dict[str, float],
|
|
119
|
+
step: int,
|
|
120
|
+
timestamp: Optional[float] = None,
|
|
121
|
+
) -> None:
|
|
122
|
+
"""
|
|
123
|
+
Log metrics to the SQLite database.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
metrics: Dictionary of metric name -> value
|
|
127
|
+
step: Step number
|
|
128
|
+
timestamp: Unix timestamp (current time if not provided)
|
|
129
|
+
"""
|
|
130
|
+
ts = timestamp or time.time()
|
|
131
|
+
rows = [(ts, name, step, float(value)) for name, value in metrics.items()]
|
|
132
|
+
|
|
133
|
+
with self._lock:
|
|
134
|
+
if self._db is None:
|
|
135
|
+
return
|
|
136
|
+
self._db.executemany(
|
|
137
|
+
"INSERT INTO metrics (time, name, step, value) VALUES (?, ?, ?, ?)",
|
|
138
|
+
rows,
|
|
139
|
+
)
|
|
140
|
+
self._db.commit()
|
|
141
|
+
|
|
142
|
+
def log_config(self, config: Dict[str, Any]) -> None:
|
|
143
|
+
"""
|
|
144
|
+
Merge new config with existing config.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
config: Configuration to merge
|
|
148
|
+
"""
|
|
149
|
+
existing = self._read_config()
|
|
150
|
+
existing.update(config)
|
|
151
|
+
self._write_config(existing)
|
|
152
|
+
|
|
153
|
+
def update_status(
|
|
154
|
+
self,
|
|
155
|
+
status: str,
|
|
156
|
+
error: Optional[str] = None,
|
|
157
|
+
) -> None:
|
|
158
|
+
"""
|
|
159
|
+
Update the run status.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
status: New status (completed, failed, aborted)
|
|
163
|
+
error: Optional error message
|
|
164
|
+
"""
|
|
165
|
+
meta = self._read_meta()
|
|
166
|
+
meta["status"] = status
|
|
167
|
+
meta["ended_at"] = datetime.now(timezone.utc).isoformat()
|
|
168
|
+
if error:
|
|
169
|
+
meta["error_message"] = error
|
|
170
|
+
|
|
171
|
+
# Calculate duration
|
|
172
|
+
if meta.get("started_at"):
|
|
173
|
+
started = datetime.fromisoformat(meta["started_at"])
|
|
174
|
+
ended = datetime.now(timezone.utc)
|
|
175
|
+
meta["duration_seconds"] = (ended - started).total_seconds()
|
|
176
|
+
|
|
177
|
+
self._write_meta(meta)
|
|
178
|
+
|
|
179
|
+
def close(self) -> None:
|
|
180
|
+
"""Close the database connection."""
|
|
181
|
+
with self._lock:
|
|
182
|
+
if self._db:
|
|
183
|
+
self._db.close()
|
|
184
|
+
self._db = None
|
|
185
|
+
|
|
186
|
+
def _read_meta(self) -> Dict[str, Any]:
|
|
187
|
+
"""Read metadata from JSON file."""
|
|
188
|
+
meta_path = self._run_dir / "meta.json"
|
|
189
|
+
if meta_path.exists():
|
|
190
|
+
return json.loads(meta_path.read_text())
|
|
191
|
+
return {}
|
|
192
|
+
|
|
193
|
+
def _write_meta(self, meta: Dict[str, Any]) -> None:
|
|
194
|
+
"""Write metadata to JSON file."""
|
|
195
|
+
meta_path = self._run_dir / "meta.json"
|
|
196
|
+
meta_path.write_text(json.dumps(meta, indent=2, default=str))
|
|
197
|
+
|
|
198
|
+
def _read_config(self) -> Dict[str, Any]:
|
|
199
|
+
"""Read config from JSON file."""
|
|
200
|
+
config_path = self._run_dir / "config.json"
|
|
201
|
+
if config_path.exists():
|
|
202
|
+
return json.loads(config_path.read_text())
|
|
203
|
+
return {}
|
|
204
|
+
|
|
205
|
+
def _write_config(self, config: Dict[str, Any]) -> None:
|
|
206
|
+
"""Write config to JSON file."""
|
|
207
|
+
config_path = self._run_dir / "config.json"
|
|
208
|
+
config_path.write_text(json.dumps(config, indent=2, default=str))
|
|
209
|
+
|
|
210
|
+
@staticmethod
|
|
211
|
+
def _sanitize_name(name: str) -> str:
|
|
212
|
+
"""
|
|
213
|
+
Sanitize a name for use as a directory name.
|
|
214
|
+
|
|
215
|
+
Replaces unsafe characters with underscores.
|
|
216
|
+
"""
|
|
217
|
+
# Replace path separators and other unsafe chars
|
|
218
|
+
unsafe_chars = ["/", "\\", ":", "*", "?", '"', "<", ">", "|", "\0"]
|
|
219
|
+
result = name
|
|
220
|
+
for char in unsafe_chars:
|
|
221
|
+
result = result.replace(char, "_")
|
|
222
|
+
# Collapse multiple underscores
|
|
223
|
+
while "__" in result:
|
|
224
|
+
result = result.replace("__", "_")
|
|
225
|
+
# Strip leading/trailing underscores and dots
|
|
226
|
+
result = result.strip("_.")
|
|
227
|
+
return result or "unnamed"
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class LocalBatcher:
|
|
231
|
+
"""
|
|
232
|
+
Batches metrics for efficient local writes.
|
|
233
|
+
|
|
234
|
+
Similar to MetricsBatcher but writes to local SQLite instead of HTTP.
|
|
235
|
+
"""
|
|
236
|
+
|
|
237
|
+
def __init__(
|
|
238
|
+
self,
|
|
239
|
+
writer: LocalWriter,
|
|
240
|
+
batch_size: int = 100,
|
|
241
|
+
flush_interval: float = 1.0,
|
|
242
|
+
):
|
|
243
|
+
"""
|
|
244
|
+
Initialize the local batcher.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
writer: The local writer instance
|
|
248
|
+
batch_size: Number of metrics to batch before writing
|
|
249
|
+
flush_interval: Seconds between automatic flushes
|
|
250
|
+
"""
|
|
251
|
+
self._writer = writer
|
|
252
|
+
self._batch_size = batch_size
|
|
253
|
+
self._flush_interval = flush_interval
|
|
254
|
+
|
|
255
|
+
self._buffer: List[tuple] = [] # (name, value, step, timestamp)
|
|
256
|
+
self._lock = threading.Lock()
|
|
257
|
+
self._stop_event = threading.Event()
|
|
258
|
+
self._flush_thread: Optional[threading.Thread] = None
|
|
259
|
+
|
|
260
|
+
def start(self) -> None:
|
|
261
|
+
"""Start the background flush thread."""
|
|
262
|
+
self._flush_thread = threading.Thread(
|
|
263
|
+
target=self._flush_loop,
|
|
264
|
+
daemon=True,
|
|
265
|
+
name="sixtyseven-local-flusher",
|
|
266
|
+
)
|
|
267
|
+
self._flush_thread.start()
|
|
268
|
+
|
|
269
|
+
def stop(self) -> None:
|
|
270
|
+
"""Stop the background flush thread and flush remaining metrics."""
|
|
271
|
+
self._stop_event.set()
|
|
272
|
+
if self._flush_thread:
|
|
273
|
+
self._flush_thread.join(timeout=10)
|
|
274
|
+
self.flush()
|
|
275
|
+
|
|
276
|
+
def add(
|
|
277
|
+
self,
|
|
278
|
+
name: str,
|
|
279
|
+
value: float,
|
|
280
|
+
step: int,
|
|
281
|
+
timestamp: Optional[float] = None,
|
|
282
|
+
) -> None:
|
|
283
|
+
"""
|
|
284
|
+
Add a metric point to the buffer.
|
|
285
|
+
|
|
286
|
+
Args:
|
|
287
|
+
name: Metric name
|
|
288
|
+
value: Metric value
|
|
289
|
+
step: Step number
|
|
290
|
+
timestamp: Unix timestamp (current time if not provided)
|
|
291
|
+
"""
|
|
292
|
+
ts = timestamp or time.time()
|
|
293
|
+
|
|
294
|
+
with self._lock:
|
|
295
|
+
self._buffer.append((name, value, step, ts))
|
|
296
|
+
|
|
297
|
+
if len(self._buffer) >= self._batch_size:
|
|
298
|
+
self._do_flush()
|
|
299
|
+
|
|
300
|
+
def flush(self) -> None:
|
|
301
|
+
"""Force flush all buffered metrics."""
|
|
302
|
+
with self._lock:
|
|
303
|
+
self._do_flush()
|
|
304
|
+
|
|
305
|
+
def _do_flush(self) -> None:
|
|
306
|
+
"""Internal flush (must hold lock)."""
|
|
307
|
+
if not self._buffer:
|
|
308
|
+
return
|
|
309
|
+
|
|
310
|
+
# Group by step for efficient writing
|
|
311
|
+
metrics_by_step: Dict[int, Dict[str, tuple]] = {}
|
|
312
|
+
for name, value, step, ts in self._buffer:
|
|
313
|
+
if step not in metrics_by_step:
|
|
314
|
+
metrics_by_step[step] = {}
|
|
315
|
+
metrics_by_step[step][name] = (value, ts)
|
|
316
|
+
|
|
317
|
+
self._buffer.clear()
|
|
318
|
+
|
|
319
|
+
# Write each step's metrics
|
|
320
|
+
for step, metrics in metrics_by_step.items():
|
|
321
|
+
# Use the latest timestamp for this step
|
|
322
|
+
latest_ts = max(ts for _, ts in metrics.values())
|
|
323
|
+
metric_dict = {name: value for name, (value, _) in metrics.items()}
|
|
324
|
+
self._writer.log_metrics(metric_dict, step, latest_ts)
|
|
325
|
+
|
|
326
|
+
def _flush_loop(self) -> None:
|
|
327
|
+
"""Background thread that flushes periodically."""
|
|
328
|
+
while not self._stop_event.wait(self._flush_interval):
|
|
329
|
+
self.flush()
|
|
330
|
+
|
|
331
|
+
@property
|
|
332
|
+
def pending_count(self) -> int:
|
|
333
|
+
"""Return the number of pending metrics in the buffer."""
|
|
334
|
+
with self._lock:
|
|
335
|
+
return len(self._buffer)
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Metrics batching and buffering for efficient network transmission."""
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
from collections import deque
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import TYPE_CHECKING, Deque, Optional
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from sixtyseven.client import SixtySevenClient
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class MetricPoint:
|
|
15
|
+
"""A single metric data point."""
|
|
16
|
+
|
|
17
|
+
name: str
|
|
18
|
+
value: float
|
|
19
|
+
step: int
|
|
20
|
+
timestamp: float
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class MetricsBatcher:
|
|
24
|
+
"""
|
|
25
|
+
Batches metrics for efficient network transmission.
|
|
26
|
+
|
|
27
|
+
Implements:
|
|
28
|
+
- Automatic batching by count (batch_size)
|
|
29
|
+
- Automatic flushing by time (flush_interval)
|
|
30
|
+
- Thread-safe operation
|
|
31
|
+
- Graceful shutdown
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
client: "SixtySevenClient",
|
|
37
|
+
run_id: str,
|
|
38
|
+
batch_size: int = 100,
|
|
39
|
+
flush_interval: float = 5.0,
|
|
40
|
+
):
|
|
41
|
+
"""
|
|
42
|
+
Initialize the metrics batcher.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
client: The API client
|
|
46
|
+
run_id: The run ID
|
|
47
|
+
batch_size: Number of metrics to batch before sending
|
|
48
|
+
flush_interval: Seconds between automatic flushes
|
|
49
|
+
"""
|
|
50
|
+
self._client = client
|
|
51
|
+
self._run_id = run_id
|
|
52
|
+
self._batch_size = batch_size
|
|
53
|
+
self._flush_interval = flush_interval
|
|
54
|
+
|
|
55
|
+
self._buffer: Deque[MetricPoint] = deque()
|
|
56
|
+
self._lock = threading.Lock()
|
|
57
|
+
self._stop_event = threading.Event()
|
|
58
|
+
self._flush_thread: Optional[threading.Thread] = None
|
|
59
|
+
|
|
60
|
+
def start(self) -> None:
|
|
61
|
+
"""Start the background flush thread."""
|
|
62
|
+
self._flush_thread = threading.Thread(
|
|
63
|
+
target=self._flush_loop,
|
|
64
|
+
daemon=True,
|
|
65
|
+
name="sixtyseven-metrics-flusher",
|
|
66
|
+
)
|
|
67
|
+
self._flush_thread.start()
|
|
68
|
+
|
|
69
|
+
def stop(self) -> None:
|
|
70
|
+
"""Stop the background flush thread and flush remaining metrics."""
|
|
71
|
+
self._stop_event.set()
|
|
72
|
+
if self._flush_thread:
|
|
73
|
+
self._flush_thread.join(timeout=10)
|
|
74
|
+
|
|
75
|
+
# Final flush
|
|
76
|
+
self.flush()
|
|
77
|
+
|
|
78
|
+
def add(
|
|
79
|
+
self,
|
|
80
|
+
name: str,
|
|
81
|
+
value: float,
|
|
82
|
+
step: int,
|
|
83
|
+
timestamp: Optional[float] = None,
|
|
84
|
+
) -> None:
|
|
85
|
+
"""
|
|
86
|
+
Add a metric point to the buffer.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
name: Metric name
|
|
90
|
+
value: Metric value
|
|
91
|
+
step: Step number
|
|
92
|
+
timestamp: Unix timestamp (current time if not provided)
|
|
93
|
+
"""
|
|
94
|
+
point = MetricPoint(
|
|
95
|
+
name=name,
|
|
96
|
+
value=value,
|
|
97
|
+
step=step,
|
|
98
|
+
timestamp=timestamp or time.time(),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
with self._lock:
|
|
102
|
+
self._buffer.append(point)
|
|
103
|
+
|
|
104
|
+
# Flush if batch is full
|
|
105
|
+
if len(self._buffer) >= self._batch_size:
|
|
106
|
+
self._do_flush()
|
|
107
|
+
|
|
108
|
+
def flush(self) -> None:
|
|
109
|
+
"""Force flush all buffered metrics."""
|
|
110
|
+
with self._lock:
|
|
111
|
+
self._do_flush()
|
|
112
|
+
|
|
113
|
+
def _do_flush(self) -> None:
|
|
114
|
+
"""Internal flush (must hold lock)."""
|
|
115
|
+
if not self._buffer:
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
# Collect all points
|
|
119
|
+
points = list(self._buffer)
|
|
120
|
+
self._buffer.clear()
|
|
121
|
+
|
|
122
|
+
# Convert to API format
|
|
123
|
+
metrics = [
|
|
124
|
+
{
|
|
125
|
+
"name": p.name,
|
|
126
|
+
"value": p.value,
|
|
127
|
+
"step": p.step,
|
|
128
|
+
"timestamp": self._format_timestamp(p.timestamp),
|
|
129
|
+
}
|
|
130
|
+
for p in points
|
|
131
|
+
]
|
|
132
|
+
|
|
133
|
+
# Send to server
|
|
134
|
+
try:
|
|
135
|
+
self._client.batch_log_metrics(self._run_id, metrics)
|
|
136
|
+
except Exception as e:
|
|
137
|
+
# Log error but don't lose metrics - put them back
|
|
138
|
+
# In production, you might want to implement a dead letter queue
|
|
139
|
+
print(f"Warning: Failed to send metrics: {e}")
|
|
140
|
+
|
|
141
|
+
def _flush_loop(self) -> None:
|
|
142
|
+
"""Background thread that flushes periodically."""
|
|
143
|
+
while not self._stop_event.wait(self._flush_interval):
|
|
144
|
+
self.flush()
|
|
145
|
+
|
|
146
|
+
@staticmethod
|
|
147
|
+
def _format_timestamp(ts: float) -> str:
|
|
148
|
+
"""Format timestamp as ISO 8601 string."""
|
|
149
|
+
from datetime import datetime, timezone
|
|
150
|
+
|
|
151
|
+
return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat()
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def pending_count(self) -> int:
|
|
155
|
+
"""Return the number of pending metrics in the buffer."""
|
|
156
|
+
with self._lock:
|
|
157
|
+
return len(self._buffer)
|