sixtyseven 0.1.0__py3-none-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.
@@ -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)