Coverage for src / tracekit / __main__.py: 100%
155 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 23:04 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 23:04 +0000
1"""TraceKit command-line interface.
3This module provides the main entry point for TraceKit CLI operations,
4including sample data download.
7Example:
8 python -m tracekit download_samples
9 python -m tracekit --help
10"""
12from __future__ import annotations
14import argparse
15import hashlib
16import sys
17from pathlib import Path
18from typing import Any
21def get_samples_dir() -> Path:
22 """Get the samples directory path (~/.tracekit/samples/).
24 Returns:
25 Path to the samples directory.
26 """
27 return Path.home() / ".tracekit" / "samples"
30def get_sample_files() -> dict[str, dict[str, Any]]:
31 """Get the list of sample files to download.
33 Returns:
34 Dictionary mapping filename to file metadata.
35 """
36 # Sample files configuration
37 # In production, these would be hosted on a public CDN or GitHub releases
38 return {
39 "sine_1khz.csv": {
40 "description": "1 kHz sine wave, 100 kS/s, CSV format",
41 "format": "csv",
42 "size": 1024 * 50, # ~50 KB
43 "checksum": None, # Would be populated with actual checksum
44 "url": "https://raw.githubusercontent.com/tracekit/sample-data/main/sine_1khz.csv",
45 },
46 "square_wave.csv": {
47 "description": "10 kHz square wave with ringing, CSV format",
48 "format": "csv",
49 "size": 1024 * 100, # ~100 KB
50 "checksum": None,
51 "url": "https://raw.githubusercontent.com/tracekit/sample-data/main/square_wave.csv",
52 },
53 "uart_9600.bin": {
54 "description": "UART signal at 9600 baud, binary format",
55 "format": "binary",
56 "size": 1024 * 20, # ~20 KB
57 "checksum": None,
58 "url": "https://raw.githubusercontent.com/tracekit/sample-data/main/uart_9600.bin",
59 },
60 "i2c_capture.bin": {
61 "description": "I2C bus capture with multiple devices",
62 "format": "binary",
63 "size": 1024 * 50, # ~50 KB
64 "checksum": None,
65 "url": "https://raw.githubusercontent.com/tracekit/sample-data/main/i2c_capture.bin",
66 },
67 "spi_flash.bin": {
68 "description": "SPI flash read operation",
69 "format": "binary",
70 "size": 1024 * 30, # ~30 KB
71 "checksum": None,
72 "url": "https://raw.githubusercontent.com/tracekit/sample-data/main/spi_flash.bin",
73 },
74 "noisy_signal.csv": {
75 "description": "Noisy analog signal for filtering examples",
76 "format": "csv",
77 "size": 1024 * 80, # ~80 KB
78 "checksum": None,
79 "url": "https://raw.githubusercontent.com/tracekit/sample-data/main/noisy_signal.csv",
80 },
81 "eye_diagram.npz": {
82 "description": "High-speed serial data for eye diagram",
83 "format": "npz",
84 "size": 1024 * 200, # ~200 KB
85 "checksum": None,
86 "url": "https://raw.githubusercontent.com/tracekit/sample-data/main/eye_diagram.npz",
87 },
88 }
91def download_file(url: str, dest: Path, checksum: str | None = None) -> bool:
92 """Download a file from URL to destination.
94 Args:
95 url: URL to download from.
96 dest: Destination file path.
97 checksum: Optional SHA256 checksum to verify.
99 Returns:
100 True if download successful, False otherwise.
101 """
102 try:
103 import ssl
104 import urllib.request
106 # Create SSL context that works in most environments
107 context = ssl.create_default_context()
109 print(f" Downloading: {url}")
111 with urllib.request.urlopen(url, context=context, timeout=30) as response:
112 data = response.read()
114 # Verify checksum if provided
115 if checksum:
116 computed = hashlib.sha256(data).hexdigest()
117 if computed != checksum:
118 print(f" ERROR: Checksum mismatch for {dest.name}")
119 print(f" Expected: {checksum}")
120 print(f" Got: {computed}")
121 return False
123 # Write to destination
124 dest.parent.mkdir(parents=True, exist_ok=True)
125 dest.write_bytes(data)
127 print(f" Saved: {dest}")
128 return True
130 except Exception as e:
131 print(f" ERROR: Failed to download {url}: {e}")
132 return False
135def generate_sample_file(filename: str, dest: Path) -> bool:
136 """Generate a sample file locally when download is not available.
138 This is a fallback when the remote repository is not available.
140 Args:
141 filename: Name of the sample file to generate.
142 dest: Destination file path.
144 Returns:
145 True if generation successful, False otherwise.
146 """
147 try:
148 import numpy as np
150 dest.parent.mkdir(parents=True, exist_ok=True)
152 if filename == "sine_1khz.csv":
153 # Generate 1 kHz sine wave at 100 kS/s, 1000 samples
154 sample_rate = 100_000
155 duration = 0.01 # 10 ms
156 t = np.arange(0, duration, 1 / sample_rate)
157 signal = 0.5 * np.sin(2 * np.pi * 1000 * t)
158 np.savetxt(
159 dest,
160 np.column_stack([t, signal]),
161 delimiter=",",
162 header="time,voltage",
163 comments="",
164 )
165 return True
167 elif filename == "square_wave.csv":
168 # Generate 10 kHz square wave with some ringing
169 sample_rate = 1_000_000
170 duration = 0.001 # 1 ms
171 t = np.arange(0, duration, 1 / sample_rate)
172 signal = 0.5 * np.sign(np.sin(2 * np.pi * 10000 * t))
173 # Add some ringing/noise
174 signal += 0.05 * np.random.randn(len(signal))
175 np.savetxt(
176 dest,
177 np.column_stack([t, signal]),
178 delimiter=",",
179 header="time,voltage",
180 comments="",
181 )
182 return True
184 elif filename == "noisy_signal.csv":
185 # Generate noisy sine wave
186 sample_rate = 10_000
187 duration = 0.1 # 100 ms
188 t = np.arange(0, duration, 1 / sample_rate)
189 signal = 0.5 * np.sin(2 * np.pi * 100 * t)
190 signal += 0.1 * np.random.randn(len(signal))
191 np.savetxt(
192 dest,
193 np.column_stack([t, signal]),
194 delimiter=",",
195 header="time,voltage",
196 comments="",
197 )
198 return True
200 elif filename.endswith(".bin"):
201 # Generate placeholder binary data
202 data = np.random.randint(0, 256, 1000, dtype=np.uint8)
203 data.tofile(dest)
204 return True
206 elif filename.endswith(".npz"):
207 # Generate high-speed signal for eye diagram
208 sample_rate = 10_000_000 # 10 MS/s
209 samples_per_ui = 100
210 num_ui = 100
211 t = np.arange(samples_per_ui * num_ui) / sample_rate
212 # Generate random bit pattern
213 bits = np.random.randint(0, 2, num_ui)
214 signal = np.repeat(bits.astype(float), samples_per_ui)
215 # Add some jitter and noise
216 signal += 0.1 * np.random.randn(len(signal))
217 np.savez(dest, time=t, signal=signal, sample_rate=sample_rate)
218 return True
220 else:
221 print(f" WARNING: Unknown file type: {filename}")
222 return False
224 except Exception as e:
225 print(f" ERROR: Failed to generate {filename}: {e}")
226 return False
229def download_samples(force: bool = False, generate: bool = True) -> int:
230 """Download sample waveform files for testing and tutorials.
232 Args:
233 force: Force re-download even if files exist.
234 generate: Generate files locally if download fails.
236 Returns:
237 Exit code (0 for success, 1 for failure).
238 """
239 samples_dir = get_samples_dir()
240 sample_files = get_sample_files()
242 print("TraceKit Sample Data Download")
243 print("==============================")
244 print(f"Destination: {samples_dir}")
245 print()
247 success_count = 0
248 fail_count = 0
250 for filename, info in sample_files.items():
251 dest = samples_dir / filename
253 if dest.exists() and not force:
254 print(f"[SKIP] {filename} (already exists)")
255 success_count += 1
256 continue
258 print(f"[DOWNLOAD] {filename}")
259 print(f" Description: {info['description']}")
261 # Try to download first
262 url = info.get("url")
263 checksum = info.get("checksum")
265 if url and download_file(url, dest, checksum):
266 success_count += 1
267 continue
269 # Fall back to local generation
270 if generate:
271 print(" Falling back to local generation...")
272 if generate_sample_file(filename, dest):
273 print(f" Generated: {dest}")
274 success_count += 1
275 continue
277 fail_count += 1
278 print(f" FAILED: {filename}")
280 print()
281 print(f"Summary: {success_count} succeeded, {fail_count} failed")
283 if fail_count > 0:
284 print()
285 print("Some downloads failed. Sample files are optional and used for")
286 print("tutorials and testing. You can proceed without them.")
287 return 1
289 print()
290 print("Sample files downloaded successfully!")
291 print()
292 print("Example usage:")
293 print(" >>> import tracekit as tk")
294 print(f" >>> trace = tk.load('{samples_dir / 'sine_1khz.csv'}')")
295 print(" >>> trace.plot()")
297 return 0
300def list_samples() -> int:
301 """List available sample files.
303 Returns:
304 Exit code.
305 """
306 samples_dir = get_samples_dir()
307 sample_files = get_sample_files()
309 print("Available sample files:")
310 print()
312 for filename, info in sample_files.items():
313 dest = samples_dir / filename
314 status = "[EXISTS]" if dest.exists() else "[NOT DOWNLOADED]"
315 print(f" {status} {filename}")
316 print(f" {info['description']}")
317 print()
319 return 0
322def main() -> int:
323 """Main entry point for TraceKit CLI.
325 Returns:
326 Exit code.
327 """
328 parser = argparse.ArgumentParser(
329 prog="tracekit",
330 description="TraceKit signal analysis toolkit",
331 )
333 subparsers = parser.add_subparsers(dest="command", help="Available commands")
335 # download_samples command
336 download_parser = subparsers.add_parser(
337 "download_samples",
338 aliases=["download"],
339 help="Download sample waveform files",
340 )
341 download_parser.add_argument(
342 "--force",
343 "-f",
344 action="store_true",
345 help="Force re-download even if files exist",
346 )
347 download_parser.add_argument(
348 "--no-generate",
349 action="store_true",
350 help="Do not generate files locally if download fails",
351 )
353 # list_samples command
354 subparsers.add_parser(
355 "list_samples",
356 aliases=["list"],
357 help="List available sample files",
358 )
360 # version command
361 subparsers.add_parser(
362 "version",
363 help="Show version information",
364 )
366 args = parser.parse_args()
368 if args.command in ("download_samples", "download"):
369 return download_samples(
370 force=args.force,
371 generate=not args.no_generate,
372 )
374 elif args.command in ("list_samples", "list"):
375 return list_samples()
377 elif args.command == "version":
378 try:
379 from tracekit import __version__
381 print(f"TraceKit version {__version__}")
382 except ImportError:
383 print("TraceKit version unknown")
384 return 0
386 else:
387 parser.print_help()
388 return 0
391if __name__ == "__main__":
392 sys.exit(main())