sixtyseven 0.1.0__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.
sixtyseven/server.py ADDED
@@ -0,0 +1,383 @@
1
+ """Server management for automatically starting the Sixtyseven viewer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import atexit
6
+ import os
7
+ import platform
8
+ import shutil
9
+ import socket
10
+ import subprocess
11
+ import time
12
+ from pathlib import Path
13
+ from typing import Optional
14
+
15
+ from sixtyseven.exceptions import ServerError
16
+
17
+
18
+ class ServerManager:
19
+ """
20
+ Manages the Sixtyseven server lifecycle.
21
+
22
+ Handles discovering, starting, and stopping the sixtyseven binary
23
+ for viewing training metrics in real-time.
24
+
25
+ Usage:
26
+ manager = ServerManager(logdir="./logs")
27
+ manager.start()
28
+ # ... do training ...
29
+ manager.stop() # or let it auto-cleanup on exit
30
+ """
31
+
32
+ BINARY_NAME = "sixtyseven"
33
+ DEFAULT_PORT = 6767
34
+ DEFAULT_HOST = "localhost"
35
+ HEALTH_CHECK_TIMEOUT = 10 # seconds
36
+ HEALTH_CHECK_INTERVAL = 0.2 # seconds
37
+
38
+ def __init__(
39
+ self,
40
+ logdir: str,
41
+ port: int = DEFAULT_PORT,
42
+ host: str = DEFAULT_HOST,
43
+ open_browser: bool = True,
44
+ binary_path: Optional[str] = None,
45
+ keep_running: bool = False,
46
+ project: Optional[str] = None,
47
+ run_id: Optional[str] = None,
48
+ ):
49
+ """
50
+ Initialize the server manager.
51
+
52
+ Args:
53
+ logdir: Directory containing the logs to serve
54
+ port: Port to run the server on
55
+ host: Host to bind the server to
56
+ open_browser: Whether to open the browser automatically
57
+ binary_path: Explicit path to the sixtyseven binary (auto-discovered if not provided)
58
+ keep_running: If True, don't stop the server when the manager is garbage collected
59
+ project: Project name (used as fallback if run_id not provided)
60
+ run_id: Run ID to open in the browser (opens specific run view)
61
+ """
62
+ self.logdir = logdir
63
+ self.port = port
64
+ self.host = host
65
+ self.open_browser = open_browser
66
+ self.binary_path = binary_path
67
+ self.keep_running = keep_running
68
+ self.project = project
69
+ self.run_id = run_id
70
+
71
+ self._process: Optional[subprocess.Popen] = None
72
+ self._started = False
73
+ self._we_started_server = False # Track if we started it vs reusing existing
74
+
75
+ def start(self) -> str:
76
+ """
77
+ Start the server.
78
+
79
+ Returns:
80
+ The URL where the server is running
81
+
82
+ Raises:
83
+ ServerError: If the binary cannot be found or the server fails to start
84
+ """
85
+ if self._started:
86
+ return self.url
87
+
88
+ # Check if a server is already running on this port
89
+ if self._is_port_in_use():
90
+ # Server already running - just set the active run so UI navigates
91
+ self._set_active_run()
92
+ print(f"Sixtyseven: Navigating to run in existing viewer at {self.url}")
93
+ self._started = True
94
+ return self.url
95
+
96
+ # Find the binary
97
+ binary = self._find_binary()
98
+ if not binary:
99
+ raise ServerError(
100
+ "Could not find 'sixtyseven' binary. Please ensure it's installed and in your PATH, "
101
+ "or specify the path explicitly with server_binary='/path/to/sixtyseven'"
102
+ )
103
+
104
+ # Build the command (we handle browser opening ourselves for project-specific URLs)
105
+ cmd = [
106
+ binary,
107
+ "serve",
108
+ f"--logdir={self.logdir}",
109
+ f"--port={self.port}",
110
+ f"--host={self.host}",
111
+ "--open=false", # We'll open the browser ourselves with the right URL
112
+ ]
113
+
114
+ # Start the server process
115
+ try:
116
+ # Use DEVNULL for stdin to prevent the process from waiting for input
117
+ # Redirect stdout/stderr to suppress server logs in the training output
118
+ self._process = subprocess.Popen(
119
+ cmd,
120
+ stdin=subprocess.DEVNULL,
121
+ stdout=subprocess.PIPE,
122
+ stderr=subprocess.PIPE,
123
+ start_new_session=True, # Detach from parent process group
124
+ )
125
+ except OSError as e:
126
+ raise ServerError(f"Failed to start server: {e}")
127
+
128
+ # Wait for the server to be ready
129
+ if not self._wait_for_health():
130
+ # Server didn't start properly - get error output
131
+ if self._process.poll() is not None:
132
+ _, stderr = self._process.communicate()
133
+ error_msg = stderr.decode().strip() if stderr else "Unknown error"
134
+ raise ServerError(f"Server failed to start: {error_msg}")
135
+ else:
136
+ self._process.terminate()
137
+ raise ServerError(
138
+ f"Server did not respond within {self.HEALTH_CHECK_TIMEOUT}s"
139
+ )
140
+
141
+ self._started = True
142
+ self._we_started_server = True
143
+
144
+ # Register cleanup handler (unless keep_running is True)
145
+ if not self.keep_running:
146
+ atexit.register(self.stop)
147
+
148
+ # Set active run so UI navigates to it
149
+ self._set_active_run()
150
+
151
+ # Open browser to the project-specific URL
152
+ if self.open_browser:
153
+ self._open_browser()
154
+
155
+ print(f"Sixtyseven: Server started at {self.url}")
156
+ return self.url
157
+
158
+ def stop(self) -> None:
159
+ """Stop the server if it was started by this manager."""
160
+ if not self._we_started_server:
161
+ return # Don't stop a server we didn't start
162
+
163
+ if self._process is not None and self._process.poll() is None:
164
+ self._process.terminate()
165
+ try:
166
+ self._process.wait(timeout=5)
167
+ except subprocess.TimeoutExpired:
168
+ self._process.kill()
169
+ self._process = None
170
+ self._we_started_server = False
171
+ print("Sixtyseven: Server stopped")
172
+
173
+ @property
174
+ def url(self) -> str:
175
+ """Return the server URL."""
176
+ return f"http://{self.host}:{self.port}"
177
+
178
+ @property
179
+ def run_url(self) -> str:
180
+ """Return the URL to open in the browser."""
181
+ if self.run_id:
182
+ return f"{self.url}/runs/{self.run_id}"
183
+ if self.project:
184
+ return f"{self.url}/projects/{self.project}"
185
+ return f"{self.url}/projects"
186
+
187
+ def _open_browser(self) -> None:
188
+ """Open the browser to the run URL."""
189
+ import webbrowser
190
+
191
+ webbrowser.open(self.run_url)
192
+
193
+ def _set_active_run(self) -> None:
194
+ """Tell the server about the active run so the UI can navigate to it."""
195
+ if not self.run_id:
196
+ return
197
+
198
+ import urllib.request
199
+ import urllib.error
200
+ import json
201
+
202
+ url = f"{self.url}/api/v1/active-run"
203
+ data = json.dumps({"run_id": self.run_id}).encode("utf-8")
204
+
205
+ try:
206
+ req = urllib.request.Request(
207
+ url,
208
+ data=data,
209
+ headers={"Content-Type": "application/json"},
210
+ method="POST",
211
+ )
212
+ with urllib.request.urlopen(req, timeout=2):
213
+ pass
214
+ except (urllib.error.URLError, OSError):
215
+ # Server might not support this endpoint yet, ignore
216
+ pass
217
+
218
+ @property
219
+ def is_running(self) -> bool:
220
+ """Check if the server is running."""
221
+ if self._process is not None:
222
+ return self._process.poll() is None
223
+ # Check if something else is running on the port
224
+ return self._is_port_in_use()
225
+
226
+ def _find_binary(self) -> Optional[str]:
227
+ """
228
+ Find the sixtyseven binary.
229
+
230
+ Search order:
231
+ 1. Explicit binary_path if provided
232
+ 2. SIXTYSEVEN_BINARY environment variable
233
+ 3. Bundled binary inside the Python package
234
+ 4. System PATH
235
+ 5. Common installation locations
236
+ """
237
+ # Check explicit path
238
+ if self.binary_path:
239
+ if os.path.isfile(self.binary_path) and os.access(
240
+ self.binary_path, os.X_OK
241
+ ):
242
+ return self.binary_path
243
+ raise ServerError(
244
+ f"Specified binary not found or not executable: {self.binary_path}"
245
+ )
246
+
247
+ # Check environment variable
248
+ env_binary = os.environ.get("SIXTYSEVEN_BINARY")
249
+ if env_binary:
250
+ if os.path.isfile(env_binary) and os.access(env_binary, os.X_OK):
251
+ return env_binary
252
+ raise ServerError(f"SIXTYSEVEN_BINARY points to invalid path: {env_binary}")
253
+
254
+ # Check bundled binary in the package
255
+ bundled_binary = self._bundled_binary_path()
256
+ if bundled_binary:
257
+ return bundled_binary
258
+
259
+ # Check system PATH
260
+ binary_name = (
261
+ f"{self.BINARY_NAME}.exe"
262
+ if platform.system() == "Windows"
263
+ else self.BINARY_NAME
264
+ )
265
+ path_binary = shutil.which(binary_name)
266
+ if path_binary:
267
+ return path_binary
268
+
269
+ # Check current working directory (for local development)
270
+ cwd_binary = os.path.join(os.getcwd(), binary_name)
271
+ if os.path.isfile(cwd_binary) and os.access(cwd_binary, os.X_OK):
272
+ return cwd_binary
273
+
274
+ # Check common installation locations
275
+ common_paths = self._get_common_paths()
276
+ for path in common_paths:
277
+ if os.path.isfile(path) and os.access(path, os.X_OK):
278
+ return path
279
+
280
+ return None
281
+
282
+ def _bundled_binary_path(self) -> Optional[str]:
283
+ """Return the path to a bundled binary inside the package, if present."""
284
+ platform_id = self._platform_id()
285
+ if not platform_id:
286
+ return None
287
+
288
+ binary_name = (
289
+ f"{self.BINARY_NAME}.exe"
290
+ if platform.system() == "Windows"
291
+ else self.BINARY_NAME
292
+ )
293
+ base_dir = Path(__file__).resolve().parent
294
+ candidate = base_dir / "bin" / platform_id / binary_name
295
+ if candidate.is_file() and os.access(candidate, os.X_OK):
296
+ return str(candidate)
297
+ return None
298
+
299
+ def _platform_id(self) -> Optional[str]:
300
+ """Return platform identifier used for bundled binaries."""
301
+ system = platform.system().lower()
302
+ machine = platform.machine().lower()
303
+
304
+ if machine in {"x86_64", "amd64"}:
305
+ arch = "amd64"
306
+ elif machine in {"aarch64", "arm64"}:
307
+ arch = "arm64"
308
+ else:
309
+ return None
310
+
311
+ if system == "darwin":
312
+ return f"darwin-{arch}"
313
+ if system == "linux":
314
+ return f"linux-{arch}"
315
+ if system == "windows":
316
+ return f"windows-{arch}"
317
+ return None
318
+
319
+ def _get_common_paths(self) -> list[str]:
320
+ """Get common installation paths based on platform."""
321
+ system = platform.system()
322
+ home = Path.home()
323
+
324
+ if system == "Darwin":
325
+ return [
326
+ "/usr/local/bin/sixtyseven",
327
+ "/opt/homebrew/bin/sixtyseven",
328
+ str(home / ".local" / "bin" / "sixtyseven"),
329
+ str(home / "bin" / "sixtyseven"),
330
+ ]
331
+ elif system == "Windows":
332
+ return [
333
+ str(home / "AppData" / "Local" / "sixtyseven" / "sixtyseven.exe"),
334
+ str(home / "scoop" / "shims" / "sixtyseven.exe"),
335
+ "C:\\Program Files\\sixtyseven\\sixtyseven.exe",
336
+ ]
337
+ else: # Linux
338
+ return [
339
+ "/usr/local/bin/sixtyseven",
340
+ "/usr/bin/sixtyseven",
341
+ str(home / ".local" / "bin" / "sixtyseven"),
342
+ str(home / "bin" / "sixtyseven"),
343
+ ]
344
+
345
+ def _is_port_in_use(self) -> bool:
346
+ """Check if something is actually listening on the port."""
347
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
348
+ s.settimeout(1)
349
+ try:
350
+ s.connect((self.host, self.port))
351
+ return True # Connection succeeded, something is listening
352
+ except (OSError, ConnectionRefusedError):
353
+ return False # Nothing listening
354
+
355
+ def _wait_for_health(self) -> bool:
356
+ """Wait for the server to respond to health checks."""
357
+ import urllib.request
358
+ import urllib.error
359
+
360
+ health_url = f"{self.url}/health"
361
+ start_time = time.time()
362
+
363
+ while time.time() - start_time < self.HEALTH_CHECK_TIMEOUT:
364
+ # Check if process died
365
+ if self._process.poll() is not None:
366
+ return False
367
+
368
+ try:
369
+ req = urllib.request.Request(health_url, method="GET")
370
+ with urllib.request.urlopen(req, timeout=1) as response:
371
+ if response.status == 200:
372
+ return True
373
+ except (urllib.error.URLError, OSError):
374
+ pass
375
+
376
+ time.sleep(self.HEALTH_CHECK_INTERVAL)
377
+
378
+ return False
379
+
380
+ def __del__(self):
381
+ """Cleanup on garbage collection."""
382
+ if not self.keep_running and self._we_started_server:
383
+ self.stop()
sixtyseven/utils.py ADDED
@@ -0,0 +1,171 @@
1
+ """Utility functions for the Sixtyseven SDK."""
2
+
3
+ import os
4
+ import platform
5
+ import subprocess
6
+ from typing import Any, Dict, List, Optional
7
+
8
+
9
+ def get_git_info() -> Optional[Dict[str, Any]]:
10
+ """
11
+ Capture git repository information.
12
+
13
+ Returns:
14
+ Dictionary with commit, branch, remote, dirty status, and message.
15
+ None if not in a git repository.
16
+ """
17
+ try:
18
+ # Check if in git repo
19
+ subprocess.run(
20
+ ["git", "rev-parse", "--git-dir"],
21
+ capture_output=True,
22
+ check=True,
23
+ timeout=5,
24
+ )
25
+
26
+ # Get commit hash
27
+ commit = subprocess.run(
28
+ ["git", "rev-parse", "HEAD"],
29
+ capture_output=True,
30
+ text=True,
31
+ timeout=5,
32
+ ).stdout.strip()
33
+
34
+ # Get branch name
35
+ branch = subprocess.run(
36
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
37
+ capture_output=True,
38
+ text=True,
39
+ timeout=5,
40
+ ).stdout.strip()
41
+
42
+ # Get remote URL
43
+ remote = subprocess.run(
44
+ ["git", "config", "--get", "remote.origin.url"],
45
+ capture_output=True,
46
+ text=True,
47
+ timeout=5,
48
+ ).stdout.strip()
49
+
50
+ # Check for uncommitted changes
51
+ status = subprocess.run(
52
+ ["git", "status", "--porcelain"],
53
+ capture_output=True,
54
+ text=True,
55
+ timeout=5,
56
+ )
57
+ dirty = bool(status.stdout.strip())
58
+
59
+ # Get commit message
60
+ message = subprocess.run(
61
+ ["git", "log", "-1", "--pretty=%B"],
62
+ capture_output=True,
63
+ text=True,
64
+ timeout=5,
65
+ ).stdout.strip()
66
+
67
+ return {
68
+ "commit": commit,
69
+ "branch": branch,
70
+ "remote": remote,
71
+ "dirty": dirty,
72
+ "message": message[:200] if message else None, # Truncate long messages
73
+ }
74
+
75
+ except (
76
+ subprocess.CalledProcessError,
77
+ subprocess.TimeoutExpired,
78
+ FileNotFoundError,
79
+ ):
80
+ return None
81
+
82
+
83
+ def get_system_info() -> Dict[str, Any]:
84
+ """
85
+ Capture system and hardware information.
86
+
87
+ Returns:
88
+ Dictionary with hostname, OS, Python version, CPU, memory, and GPU info.
89
+ """
90
+ import sys
91
+
92
+ info = {
93
+ "hostname": platform.node(),
94
+ "os": f"{platform.system()} {platform.release()}",
95
+ "python_version": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
96
+ "cpu_count": os.cpu_count(),
97
+ }
98
+
99
+ # Try to get memory info
100
+ try:
101
+ import psutil
102
+
103
+ mem = psutil.virtual_memory()
104
+ info["memory_gb"] = round(mem.total / (1024**3), 2)
105
+ except ImportError:
106
+ pass
107
+
108
+ # Try to get GPU info
109
+ gpu_info = get_gpu_info()
110
+ if gpu_info:
111
+ info["gpu_info"] = gpu_info
112
+
113
+ return info
114
+
115
+
116
+ def get_gpu_info() -> Optional[List[str]]:
117
+ """
118
+ Get GPU information using nvidia-smi.
119
+
120
+ Returns:
121
+ List of GPU names, or None if no NVIDIA GPUs found.
122
+ """
123
+ try:
124
+ result = subprocess.run(
125
+ ["nvidia-smi", "--query-gpu=name", "--format=csv,noheader"],
126
+ capture_output=True,
127
+ text=True,
128
+ timeout=10,
129
+ )
130
+
131
+ if result.returncode == 0:
132
+ gpus = [
133
+ line.strip()
134
+ for line in result.stdout.strip().split("\n")
135
+ if line.strip()
136
+ ]
137
+ return gpus if gpus else None
138
+
139
+ except (
140
+ subprocess.CalledProcessError,
141
+ subprocess.TimeoutExpired,
142
+ FileNotFoundError,
143
+ ):
144
+ pass
145
+
146
+ # Try PyTorch
147
+ try:
148
+ import torch
149
+
150
+ if torch.cuda.is_available():
151
+ return [
152
+ torch.cuda.get_device_name(i) for i in range(torch.cuda.device_count())
153
+ ]
154
+ except ImportError:
155
+ pass
156
+
157
+ return None
158
+
159
+
160
+ def generate_run_name() -> str:
161
+ """Generate a unique run name."""
162
+ import time
163
+ from uuid import uuid4
164
+
165
+ adjectives = ["swift", "bright", "calm", "bold", "keen", "wise", "pure", "warm"]
166
+ nouns = ["falcon", "river", "forest", "peak", "star", "wave", "cloud", "dawn"]
167
+
168
+ adj = adjectives[int(time.time() * 1000) % len(adjectives)]
169
+ noun = nouns[int(time.time() * 1000 // 7) % len(nouns)]
170
+
171
+ return f"{adj}-{noun}-{uuid4().hex[:8]}"
@@ -0,0 +1,81 @@
1
+ Metadata-Version: 2.3
2
+ Name: sixtyseven
3
+ Version: 0.1.0
4
+ Summary: Python SDK for Sixtyseven ML experiment tracking
5
+ Keywords: ml,machine-learning,experiment-tracking,metrics,training
6
+ Author: Sixtyseven Team
7
+ License: MIT
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Intended Audience :: Science/Research
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.8
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
19
+ Requires-Dist: requests>=2.28.0
20
+ Requires-Dist: websocket-client>=1.4.0
21
+ Requires-Dist: pytest>=7.0.0 ; extra == 'dev'
22
+ Requires-Dist: pytest-asyncio>=0.21.0 ; extra == 'dev'
23
+ Requires-Dist: responses>=0.23.0 ; extra == 'dev'
24
+ Requires-Dist: black>=23.0.0 ; extra == 'dev'
25
+ Requires-Dist: ruff>=0.1.0 ; extra == 'dev'
26
+ Requires-Dist: mypy>=1.0.0 ; extra == 'dev'
27
+ Requires-Python: >=3.8
28
+ Project-URL: Homepage, https://github.com/sixtyseven/sixtyseven
29
+ Project-URL: Documentation, https://docs.sixtyseven.ai
30
+ Project-URL: Repository, https://github.com/sixtyseven/sixtyseven
31
+ Provides-Extra: dev
32
+ Description-Content-Type: text/markdown
33
+
34
+ # Sixtyseven Python SDK
35
+
36
+ Track ML experiments locally. No server setup required.
37
+
38
+ ## Install
39
+
40
+ ```bash
41
+ pip install sixtyseven
42
+ ```
43
+
44
+ ## Usage
45
+
46
+ ```python
47
+ from sixtyseven import Run
48
+
49
+ with Run(project="my-project", name="experiment-1") as run:
50
+ run.log_config({"learning_rate": 0.001, "epochs": 10})
51
+
52
+ for epoch in range(10):
53
+ loss = train_one_epoch()
54
+ run.log_metrics({"loss": loss}, step=epoch)
55
+ ```
56
+
57
+ ## View Results
58
+
59
+ ```bash
60
+ sixtyseven --logdir ~/.sixtyseven/logs
61
+ ```
62
+
63
+ Opens a dashboard at http://localhost:6767
64
+
65
+ ## API
66
+
67
+ ```python
68
+ run.log_metrics({"loss": 0.5, "accuracy": 0.85}, step=epoch) # Log metrics
69
+ run.log_config({"lr": 0.001}) # Log config
70
+ run.add_tags(["baseline"]) # Add tags
71
+ ```
72
+
73
+ ## Environment Variables
74
+
75
+ | Variable | Description | Default |
76
+ | ------------------- | ------------------ | -------------------- |
77
+ | `SIXTYSEVEN_LOGDIR` | Where to save logs | `~/.sixtyseven/logs` |
78
+
79
+ ## License
80
+
81
+ MIT
@@ -0,0 +1,14 @@
1
+ sixtyseven/__init__.py,sha256=ULLmUAn4APB0YFDG6RJogbPo5nV4i1ISDgWdkuo0Dz8,773
2
+ sixtyseven/cli.py,sha256=Fmd3IpOuZleoZZwhp6gE8ja2jFTamK1wBQIRHttudv0,1634
3
+ sixtyseven/client.py,sha256=DQNuZjfqjveSuvSKySBOICig76mcBBtNGZ-0MlogwCg,5583
4
+ sixtyseven/config.py,sha256=SAPs3kIlPYQtuYxbu7zZG0-PFI_n0f_NncMYGQsq8uM,4942
5
+ sixtyseven/exceptions.py,sha256=OOaouNgOhm2ki4rqoBs7Uf6L5XpoKEorGcEvZPUtF04,853
6
+ sixtyseven/local.py,sha256=QHY-UJi7qIfd0f9NtulWBeS0OIDCEyRESiexNxCAgy8,10490
7
+ sixtyseven/metrics.py,sha256=qPczP_5IHiY3fEY7oHVvRpASY-0cDe309wG6GmZah38,4311
8
+ sixtyseven/run.py,sha256=S8tp_7i9upz9mZ_psfgDrEzuo3aTI8lEVn0VWNr0dUw,15554
9
+ sixtyseven/server.py,sha256=mqi8wb41B4QjnCgMvBUKEroCw75jhRZT43KpNBNh_ds,13004
10
+ sixtyseven/utils.py,sha256=R8LzO3JmrF3NV4gCva1kWGziBX506kQFC0ag-QqXEtI,4339
11
+ sixtyseven-0.1.0.dist-info/WHEEL,sha256=5DEXXimM34_d4Gx1AuF9ysMr1_maoEtGKjaILM3s4w4,80
12
+ sixtyseven-0.1.0.dist-info/entry_points.txt,sha256=pWMrSTyvkHocLm4tPhkoK2FvhloTKBAt2wkaXbIS_TY,52
13
+ sixtyseven-0.1.0.dist-info/METADATA,sha256=W65U2nisitVAAG8hVHLh1u5la-slx4wgOUXwjRNEmH8,2422
14
+ sixtyseven-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.9.29
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ sixtyseven = sixtyseven.cli:main
3
+