pintest-cli 0.2.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.
@@ -0,0 +1,218 @@
1
+ """
2
+ Cloud mapping database client for Pintest.
3
+
4
+ Drop-in replacement for TestMappingDBV2 when a Pintest API key is configured.
5
+ Makes direct API calls — no local state, no caching.
6
+ """
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Set, List, Dict, Optional
10
+
11
+ try:
12
+ import requests
13
+ except ImportError:
14
+ requests = None # will raise at init time if cloud is used
15
+
16
+ from .config import CloudConfig
17
+ from .test_mapping_db_v2 import normalize_test_name
18
+ from .range_set import RangeSet
19
+
20
+
21
+ class CloudMappingDB:
22
+ """
23
+ Talks to api.pintest.dev instead of a local SQLite file.
24
+ Interface mirrors TestMappingDBV2 so pre_commit_hook.py needs minimal changes.
25
+ """
26
+
27
+ def __init__(self, config: CloudConfig):
28
+ if requests is None:
29
+ raise ImportError(
30
+ "The 'requests' library is required for cloud mode.\n"
31
+ "Install it with: pip install requests"
32
+ )
33
+ if not config.repo_id:
34
+ raise ValueError(
35
+ "No repo_id configured. Run: pintest repo init --name <your-repo>"
36
+ )
37
+
38
+ self.config = config
39
+ self.session = requests.Session()
40
+ self.session.headers.update({
41
+ "Authorization": f"Bearer {config.api_key}",
42
+ "Content-Type": "application/json",
43
+ })
44
+ self._api = config.api_url.rstrip("/")
45
+ self._repo_id = config.repo_id
46
+ self._branch = config.branch
47
+
48
+ # ── Query (called by pre_commit_hook during commit) ────────────────────────
49
+
50
+ def find_tests_for_changes(self, changes: List[Dict], branch: Optional[str] = None) -> tuple[Set[str], List[str]]:
51
+ """
52
+ Query the cloud API for affected tests.
53
+
54
+ Args:
55
+ changes: [{"file": "src/auth.py", "lines": [42, 43]}, ...]
56
+ branch: Optional branch to query mapping for (overrides default)
57
+
58
+ Returns:
59
+ (affected_tests: set[str], unmapped_files: list[str])
60
+ """
61
+ payload = {
62
+ "branch": branch or self._branch,
63
+ "changes": changes,
64
+ }
65
+ try:
66
+ resp = self.session.post(
67
+ f"{self._api}/api/v1/repos/{self._repo_id}/query",
68
+ json=payload,
69
+ timeout=30,
70
+ )
71
+ resp.raise_for_status()
72
+ except requests.Timeout:
73
+ print("⚠️ Pintest API timed out — falling back to running all tests", file=sys.stderr)
74
+ return set(), [c["file"] for c in changes]
75
+ except requests.RequestException as e:
76
+ print(f"⚠️ Pintest API error: {e}", file=sys.stderr)
77
+ return set(), [c["file"] for c in changes]
78
+
79
+ data = resp.json()
80
+ return set(data["affected_tests"]), data.get("unmapped_files", [])
81
+
82
+ def find_tests_for_file_lines(self, file_path: str, line_numbers: Set[int]) -> Set[str]:
83
+ """
84
+ Single-file wrapper — matches TestMappingDBV2 interface used by pre_commit_hook.
85
+ """
86
+ tests, _ = self.find_tests_for_changes([
87
+ {"file": file_path, "lines": sorted(line_numbers)}
88
+ ])
89
+ return tests
90
+
91
+ # ── Push (called after test run to update mappings) ────────────────────────
92
+
93
+ def push_coverage(
94
+ self,
95
+ coverage_file: Path,
96
+ run_stats: Optional[Dict] = None,
97
+ verbose: bool = False,
98
+ ) -> bool:
99
+ """
100
+ Parse a .coverage file and push new mappings to the cloud API.
101
+
102
+ Args:
103
+ coverage_file: Path to the .coverage sqlite file
104
+ run_stats: Optional dict with tests_selected, tests_total, duration_ms, result
105
+ verbose: Print progress
106
+
107
+ Returns:
108
+ True on success, False on failure
109
+ """
110
+ from .coverage_mapper import CoverageMapper # existing module
111
+
112
+ if not coverage_file.exists():
113
+ if verbose:
114
+ print(f"⚠️ No coverage file at {coverage_file} — skipping cloud push")
115
+ return False
116
+
117
+ if verbose:
118
+ print("☁️ Parsing coverage data for cloud upload...", flush=True)
119
+
120
+ mapper = CoverageMapper(coverage_file)
121
+ try:
122
+ mapper.load_coverage()
123
+ except Exception as e:
124
+ print(f"⚠️ Could not load coverage: {e}", file=sys.stderr)
125
+ return False
126
+
127
+ # Build range-compressed mappings (test -> file -> RangeSet)
128
+ test_file_ranges: Dict[tuple[str, str], RangeSet] = {}
129
+
130
+ for file_path, coverage_data in mapper.coverage_data.items():
131
+ for line_num, test_contexts in coverage_data.test_contexts.items():
132
+ for test_name in test_contexts:
133
+ # Normalize test name to aggregate parametrized tests
134
+ norm_name = normalize_test_name(test_name)
135
+
136
+ key = (norm_name, file_path)
137
+ if key not in test_file_ranges:
138
+ test_file_ranges[key] = RangeSet()
139
+ test_file_ranges[key].add_range(line_num, line_num)
140
+
141
+ mappings = []
142
+ for (test_name, file_path), rs in test_file_ranges.items():
143
+ mappings.append({
144
+ "test_name": test_name,
145
+ "file_path": file_path,
146
+ "ranges": rs.to_compact_string(),
147
+ })
148
+
149
+ if not mappings:
150
+ if verbose:
151
+ print("ℹ️ No coverage mappings to push")
152
+ return True
153
+
154
+ if verbose:
155
+ print(f"☁️ Pushing {len(mappings)} mappings to Pintest...", flush=True)
156
+
157
+ payload = {
158
+ "branch": self._branch,
159
+ "mappings": mappings,
160
+ }
161
+ if run_stats:
162
+ payload["run_stats"] = run_stats
163
+
164
+ try:
165
+ resp = self.session.post(
166
+ f"{self._api}/api/v1/repos/{self._repo_id}/coverage",
167
+ json=payload,
168
+ timeout=60,
169
+ )
170
+ resp.raise_for_status()
171
+ data = resp.json()
172
+ if verbose:
173
+ print(
174
+ f"☁️ Cloud sync: {data['inserted']} new, "
175
+ f"{data['updated']} updated, "
176
+ f"{data['total_tests']} total tests"
177
+ )
178
+ return True
179
+ except requests.RequestException as e:
180
+ print(f"⚠️ Cloud push failed: {e}", file=sys.stderr)
181
+ return False
182
+
183
+ def get_all_test_names(self) -> Set[str]:
184
+ """Fetch all unique test names from the cloud."""
185
+ try:
186
+ resp = self.session.get(
187
+ f"{self._api}/api/v1/repos/{self._repo_id}/tests",
188
+ timeout=10,
189
+ )
190
+ resp.raise_for_status()
191
+ return set(resp.json())
192
+ except Exception as e:
193
+ print(f"⚠️ Could not fetch test list from cloud: {e}", file=sys.stderr)
194
+ return set()
195
+
196
+ # ── Context manager support (mirrors TestMappingDBV2) ─────────────────────
197
+
198
+ def __enter__(self):
199
+ return self
200
+
201
+ def __exit__(self, *args):
202
+ self.session.close()
203
+
204
+ def is_initialized(self) -> bool:
205
+ """Always True for cloud mode — API handles initialization."""
206
+ return True
207
+
208
+ def get_stats(self) -> Dict:
209
+ """Fetch DB stats from API."""
210
+ try:
211
+ resp = self.session.get(
212
+ f"{self._api}/api/v1/repos/{self._repo_id}/stats",
213
+ timeout=10,
214
+ )
215
+ resp.raise_for_status()
216
+ return resp.json()
217
+ except Exception:
218
+ return {"total_tests": "?", "files_covered": "?"}
pintest/config.py ADDED
@@ -0,0 +1,102 @@
1
+ """
2
+ Config management for Pintest cloud integration.
3
+ Reads/writes ~/.pintest/config.toml
4
+ """
5
+ import os
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ try:
11
+ import tomllib # Python 3.11+
12
+ except ImportError:
13
+ try:
14
+ import tomli as tomllib # pip install tomli
15
+ except ImportError:
16
+ tomllib = None
17
+
18
+ CONFIG_DIR = Path.home() / ".pintest"
19
+ CONFIG_FILE = CONFIG_DIR / "config.toml"
20
+
21
+
22
+ @dataclass
23
+ class CloudConfig:
24
+ api_key: str
25
+ api_url: str = "https://api.pintest.dev"
26
+ repo_id: Optional[str] = None
27
+ branch: str = "main"
28
+
29
+
30
+ @dataclass
31
+ class Config:
32
+ cloud: Optional[CloudConfig] = field(default=None)
33
+
34
+ @classmethod
35
+ def load(cls) -> "Config":
36
+ """Load config from ~/.pintest/config.toml. Returns empty config if not found."""
37
+ if not CONFIG_FILE.exists():
38
+ return cls()
39
+
40
+ # Manual TOML parser (minimal, avoids requiring tomllib/tomli on older Python)
41
+ if tomllib:
42
+ with open(CONFIG_FILE, "rb") as f:
43
+ data = tomllib.load(f)
44
+ else:
45
+ data = _parse_simple_toml(CONFIG_FILE)
46
+
47
+ cloud_data = data.get("cloud", {})
48
+ if cloud_data.get("api_key"):
49
+ cloud = CloudConfig(
50
+ api_key=cloud_data["api_key"],
51
+ api_url=cloud_data.get("api_url", "https://api.pintest.dev"),
52
+ repo_id=cloud_data.get("repo_id"),
53
+ branch=cloud_data.get("branch", "main"),
54
+ )
55
+ else:
56
+ cloud = None
57
+
58
+ return cls(cloud=cloud)
59
+
60
+ def save(self):
61
+ """Write config to ~/.pintest/config.toml."""
62
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
63
+ CONFIG_FILE.chmod(0o600) if CONFIG_FILE.exists() else None
64
+
65
+ lines = []
66
+ if self.cloud:
67
+ lines.append("[cloud]")
68
+ lines.append(f'api_key = "{self.cloud.api_key}"')
69
+ lines.append(f'api_url = "{self.cloud.api_url}"')
70
+ if self.cloud.repo_id:
71
+ lines.append(f'repo_id = "{self.cloud.repo_id}"')
72
+ lines.append(f'branch = "{self.cloud.branch}"')
73
+ lines.append("")
74
+
75
+ CONFIG_FILE.write_text("\n".join(lines))
76
+ CONFIG_FILE.chmod(0o600) # owner read/write only — key is sensitive
77
+
78
+ @property
79
+ def is_cloud_enabled(self) -> bool:
80
+ return self.cloud is not None and bool(self.cloud.api_key)
81
+
82
+
83
+ def _parse_simple_toml(path: Path) -> dict:
84
+ """Minimal TOML parser for [section] + key = "value" files (no tomllib fallback)."""
85
+ result: dict = {}
86
+ current_section: dict = result
87
+
88
+ for line in path.read_text().splitlines():
89
+ line = line.strip()
90
+ if not line or line.startswith("#"):
91
+ continue
92
+ if line.startswith("[") and line.endswith("]"):
93
+ section = line[1:-1].strip()
94
+ current_section = {}
95
+ result[section] = current_section
96
+ elif "=" in line:
97
+ key, _, value = line.partition("=")
98
+ key = key.strip()
99
+ value = value.strip().strip('"').strip("'")
100
+ current_section[key] = value
101
+
102
+ return result
@@ -0,0 +1,356 @@
1
+ """Map test coverage to changed lines using pytest-cov data."""
2
+
3
+ import sqlite3
4
+ from pathlib import Path
5
+ from typing import Dict, Set
6
+ from dataclasses import dataclass, field
7
+
8
+
9
+ @dataclass
10
+ class CoverageData:
11
+ """Coverage information for a file."""
12
+ file_path: str
13
+ executed_lines: Set[int] = field(default_factory=set)
14
+ test_contexts: Dict[int, Set[str]] = field(default_factory=dict)
15
+
16
+
17
+ class CoverageMapper:
18
+ """Map code coverage to test cases using .coverage database."""
19
+
20
+ def __init__(self, coverage_file: Path = None):
21
+ """
22
+ Initialize mapper with coverage database.
23
+
24
+ Args:
25
+ coverage_file: Path to .coverage file (SQLite database)
26
+ """
27
+ self.coverage_file = coverage_file or Path(".coverage")
28
+ self.coverage_data: Dict[str, CoverageData] = {}
29
+ self._loaded = False
30
+
31
+ def load_coverage(self):
32
+ """Load coverage data from .coverage SQLite database."""
33
+ if not self.coverage_file.exists():
34
+ raise FileNotFoundError(
35
+ f"Coverage file not found: {self.coverage_file}\n"
36
+ "Generate it with: pytest --cov --cov-context=test --cov-report="
37
+ )
38
+
39
+ conn = sqlite3.connect(str(self.coverage_file))
40
+ cursor = conn.cursor()
41
+
42
+ # Check if we have the tables we need
43
+ cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
44
+ tables = {row[0] for row in cursor.fetchall()}
45
+
46
+ # Check for arc (branch) or line_bits (line) coverage
47
+ # Note: sometimes arc table exists but is empty if branch coverage is not enabled
48
+ has_arc = False
49
+ if 'arc' in tables:
50
+ cursor.execute("SELECT 1 FROM arc LIMIT 1")
51
+ if cursor.fetchone():
52
+ has_arc = True
53
+
54
+ has_line_bits = 'line_bits' in tables
55
+
56
+ if 'file' not in tables or (not has_arc and not has_line_bits):
57
+ conn.close()
58
+ raise RuntimeError(
59
+ "Coverage database doesn't have required tables. "
60
+ "Make sure to run: pytest --cov --cov-context=test"
61
+ )
62
+
63
+ # Check for context table (required for test-to-line mapping)
64
+ if 'context' not in tables:
65
+ conn.close()
66
+ raise RuntimeError(
67
+ "Coverage database doesn't have 'context' table. "
68
+ "This means coverage was run WITHOUT --cov-context=test flag.\n"
69
+ "Delete the old .coverage file and regenerate:\n"
70
+ " rm .coverage\n"
71
+ " pytest --cov=src --cov-context=test --cov-report="
72
+ )
73
+
74
+ # Get file IDs and paths
75
+ cursor.execute("SELECT id, path FROM file")
76
+ files = {file_id: path for file_id, path in cursor.fetchall()}
77
+
78
+ # Get context IDs and names
79
+ cursor.execute("SELECT id, context FROM context")
80
+ contexts = {context_id: context for context_id, context in cursor.fetchall()}
81
+
82
+ # Use arc (branch coverage) if available, otherwise line_bits
83
+ if has_arc:
84
+ # Get arc coverage with context (test names)
85
+ # Arc table has: file_id, context_id, fromno, tono
86
+ cursor.execute("""
87
+ SELECT file_id, context_id, fromno, tono
88
+ FROM arc
89
+ """)
90
+
91
+ for file_id, context_id, fromno, tono in cursor.fetchall():
92
+ if file_id not in files:
93
+ continue
94
+
95
+ file_path = files[file_id]
96
+
97
+ # Normalize file path
98
+ file_path = self._normalize_path(file_path)
99
+
100
+ if file_path not in self.coverage_data:
101
+ self.coverage_data[file_path] = CoverageData(file_path=file_path)
102
+
103
+ # Get context name
104
+ context = contexts.get(context_id, '')
105
+
106
+ # Extract lines from arc (both fromno and tono are line numbers)
107
+ # -1 means entry to the function
108
+ lines = set()
109
+ if fromno > 0:
110
+ lines.add(fromno)
111
+ if tono > 0:
112
+ lines.add(tono)
113
+
114
+ for line_num in lines:
115
+ self.coverage_data[file_path].executed_lines.add(line_num)
116
+
117
+ if line_num not in self.coverage_data[file_path].test_contexts:
118
+ self.coverage_data[file_path].test_contexts[line_num] = set()
119
+
120
+ # Context format: "testname|run" or "testname|setup" or just "testname"
121
+ if context:
122
+ # Extract test name from context
123
+ test_name = self._extract_test_name(context)
124
+ if test_name:
125
+ self.coverage_data[file_path].test_contexts[line_num].add(test_name)
126
+ else:
127
+ # Fallback to line_bits (line coverage)
128
+ cursor.execute("""
129
+ SELECT file_id, context_id, numbits
130
+ FROM line_bits
131
+ """)
132
+
133
+ for file_id, context_id, numbits in cursor.fetchall():
134
+ if file_id not in files:
135
+ continue
136
+
137
+ file_path = files[file_id]
138
+
139
+ # Normalize file path
140
+ file_path = self._normalize_path(file_path)
141
+
142
+ if file_path not in self.coverage_data:
143
+ self.coverage_data[file_path] = CoverageData(file_path=file_path)
144
+
145
+ # Get context name
146
+ context = contexts.get(context_id, '')
147
+
148
+ # Decode bit-packed line numbers
149
+ lines = self._decode_lines(numbits, 0)
150
+
151
+ for line_num in lines:
152
+ self.coverage_data[file_path].executed_lines.add(line_num)
153
+
154
+ if line_num not in self.coverage_data[file_path].test_contexts:
155
+ self.coverage_data[file_path].test_contexts[line_num] = set()
156
+
157
+ # Context format: "testname|run" or just "testname"
158
+ if context:
159
+ # Extract test name from context
160
+ test_name = self._extract_test_name(context)
161
+ if test_name:
162
+ self.coverage_data[file_path].test_contexts[line_num].add(test_name)
163
+
164
+ conn.close()
165
+ self._loaded = True
166
+
167
+ @staticmethod
168
+ def _decode_lines(numbits: bytes, number: int) -> Set[int]:
169
+ """
170
+ Decode line numbers from coverage database bit encoding.
171
+
172
+ The coverage.py database stores line numbers in a compressed format:
173
+ - number: base line number
174
+ - numbits: bit-packed offsets from base
175
+ """
176
+ lines = set()
177
+
178
+ if numbits:
179
+ # Bit-packed format: each bit represents an offset from 'number'
180
+ for byte_idx, byte_val in enumerate(numbits):
181
+ for bit_idx in range(8):
182
+ if byte_val & (1 << bit_idx):
183
+ line_num = number + (byte_idx * 8) + bit_idx
184
+ lines.add(line_num)
185
+ else:
186
+ # Single line number
187
+ if number > 0:
188
+ lines.add(number)
189
+
190
+ return lines
191
+
192
+ @staticmethod
193
+ def _extract_test_name(context: str) -> str:
194
+ """Extract test name from coverage context string."""
195
+ if not context:
196
+ return ""
197
+
198
+ # Context can be:
199
+ # - "test_file.py::TestClass::test_method|run"
200
+ # - "test_file.py::test_function"
201
+ # - Just the test path
202
+
203
+ # Remove "|run" suffix if present
204
+ if '|' in context:
205
+ context = context.split('|')[0]
206
+
207
+ return context
208
+
209
+ def find_tests_for_lines(
210
+ self,
211
+ file_path: str,
212
+ changed_lines: Set[int]
213
+ ) -> Set[str]:
214
+ """
215
+ Find all tests that executed any of the changed lines.
216
+
217
+ Args:
218
+ file_path: Path to source file
219
+ changed_lines: Set of changed line numbers
220
+
221
+ Returns:
222
+ Set of test names that cover those lines
223
+ """
224
+ if not self._loaded:
225
+ self.load_coverage()
226
+
227
+ # Normalize file path for lookup
228
+ normalized_path = self._normalize_path(file_path)
229
+
230
+ # Try to find coverage data with path matching
231
+ coverage_data = None
232
+ for stored_path in self.coverage_data.keys():
233
+ if self._paths_match(normalized_path, stored_path):
234
+ coverage_data = self.coverage_data[stored_path]
235
+ break
236
+
237
+ if not coverage_data:
238
+ return set()
239
+
240
+ # Collect all tests that executed any of the changed lines
241
+ tests = set()
242
+ for line_num in changed_lines:
243
+ if line_num in coverage_data.test_contexts:
244
+ tests.update(coverage_data.test_contexts[line_num])
245
+
246
+ return tests
247
+
248
+ @staticmethod
249
+ def _normalize_path(path: str) -> str:
250
+ """
251
+ Normalize file path for comparison.
252
+
253
+ Removes common prefixes and standardizes separators.
254
+ """
255
+ # Convert to forward slashes
256
+ path = path.replace('\\', '/')
257
+
258
+ import os
259
+ # Try to make relative to current directory if absolute
260
+ try:
261
+ cwd = os.getcwd().replace('\\', '/')
262
+ if path.startswith(cwd):
263
+ path = os.path.relpath(path, cwd).replace('\\', '/')
264
+ except Exception:
265
+ pass
266
+
267
+ # Remove common source prefix
268
+ for prefix in ['src/']:
269
+ if prefix in path:
270
+ idx = path.index(prefix)
271
+ path = path[idx + len(prefix):]
272
+ break
273
+
274
+ # Remove absolute path prefixes
275
+ if path.startswith('/'):
276
+ parts = Path(path).parts
277
+ # Find the first meaningful directory (not /, Users, home, etc.)
278
+ for i, part in enumerate(parts):
279
+ if part in ('src',):
280
+ path = '/'.join(parts[i:])
281
+ break
282
+
283
+ return path
284
+
285
+ @staticmethod
286
+ def _paths_match(path1: str, path2: str) -> bool:
287
+ """Check if two paths refer to the same file."""
288
+ # Exact match
289
+ if path1 == path2:
290
+ return True
291
+
292
+ # Suffix matching (handles absolute vs relative paths)
293
+ if path1.endswith(path2) or path2.endswith(path1):
294
+ return True
295
+
296
+ # Compare just the filename and parent directory
297
+ p1_parts = Path(path1).parts[-2:] if len(Path(path1).parts) >= 2 else Path(path1).parts
298
+ p2_parts = Path(path2).parts[-2:] if len(Path(path2).parts) >= 2 else Path(path2).parts
299
+
300
+ return p1_parts == p2_parts
301
+
302
+
303
+ def main():
304
+ """CLI for testing the mapper."""
305
+ import sys
306
+
307
+ if len(sys.argv) < 2:
308
+ print("Usage: coverage_mapper.py <coverage_file> [file_path] [line_numbers...]")
309
+ print("\nExample:")
310
+ print(" coverage_mapper.py .coverage src/myfile.py 10 20 30")
311
+ sys.exit(1)
312
+
313
+ coverage_file = Path(sys.argv[1])
314
+ mapper = CoverageMapper(coverage_file)
315
+
316
+ try:
317
+ mapper.load_coverage()
318
+
319
+ print(f"Loaded coverage for {len(mapper.coverage_data)} files")
320
+
321
+ if len(sys.argv) >= 3:
322
+ # Look up specific file and lines
323
+ file_path = sys.argv[2]
324
+ line_numbers = set(map(int, sys.argv[3:])) if len(sys.argv) > 3 else set()
325
+
326
+ if line_numbers:
327
+ tests = mapper.find_tests_for_lines(file_path, line_numbers)
328
+ print(f"\nTests covering {file_path} lines {sorted(line_numbers)}:")
329
+ for test in sorted(tests):
330
+ print(f" - {test}")
331
+ else:
332
+ print(f"\nCoverage data for {file_path}:")
333
+ for path, data in mapper.coverage_data.items():
334
+ if file_path in path:
335
+ print(f" Executed lines: {sorted(data.executed_lines)[:10]}...")
336
+ print(f" Total tests: {len(set().union(*data.test_contexts.values()))}")
337
+ else:
338
+ # Show summary
339
+ print("\nCovered files:")
340
+ for path in sorted(mapper.coverage_data.keys())[:20]:
341
+ data = mapper.coverage_data[path]
342
+ all_tests = set().union(*data.test_contexts.values()) if data.test_contexts else set()
343
+ print(f" {path}: {len(data.executed_lines)} lines, {len(all_tests)} tests")
344
+
345
+ if len(mapper.coverage_data) > 20:
346
+ print(f" ... and {len(mapper.coverage_data) - 20} more files")
347
+
348
+ except Exception as e:
349
+ print(f"Error: {e}", file=sys.stderr)
350
+ import traceback
351
+ traceback.print_exc()
352
+ sys.exit(1)
353
+
354
+
355
+ if __name__ == "__main__":
356
+ main()