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.
pintest/cli.py ADDED
@@ -0,0 +1,681 @@
1
+ """CLI for Pintest."""
2
+
3
+ import argparse
4
+ import getpass
5
+ import subprocess
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Set
9
+
10
+ from .git_diff_parser import GitDiffParser
11
+ from .coverage_mapper import CoverageMapper
12
+ from .config import Config
13
+ from .cloud_mapping_db import CloudMappingDB
14
+ from .test_mapping_db_v2 import TestMappingDBV2
15
+
16
+
17
+ class PintestRunner:
18
+ """Select and run tests based on code coverage and git changes."""
19
+
20
+ def __init__(self, repo_root: Path, mapping_db=None, verbose: bool = False):
21
+ """
22
+ Initialize runner.
23
+
24
+ Args:
25
+ repo_root: Repository root directory
26
+ mapping_db: Mapping database object (TestMappingDBV2 or CloudMappingDB)
27
+ verbose: Enable verbose output
28
+ """
29
+ self.repo_root = Path(repo_root).resolve()
30
+ self.git_parser = GitDiffParser(self.repo_root)
31
+ self.mapping_db = mapping_db
32
+ self.verbose = verbose
33
+
34
+ def find_affected_tests(self, base_branch: str = "master") -> Set[str]:
35
+ """
36
+ Find all tests affected by changes since base_branch.
37
+
38
+ Returns:
39
+ Set of test identifiers (pytest format)
40
+ """
41
+ # Get git diff
42
+ try:
43
+ diff_output = self.git_parser.get_diff(base_branch)
44
+ except RuntimeError as e:
45
+ print(f"Error getting git diff: {e}", file=sys.stderr)
46
+ return set()
47
+
48
+ changes = self.git_parser.parse_diff(diff_output)
49
+ python_changes = self.git_parser.filter_python_files(changes)
50
+
51
+ if self.verbose:
52
+ print(f"🔍 Found {len(python_changes)} changed Python files", file=sys.stderr)
53
+
54
+ # Get changed test files (always run these)
55
+ changed_test_files = self.git_parser.get_changed_test_files(python_changes)
56
+
57
+ if self.verbose and changed_test_files:
58
+ print(f"🔍 Changed test files: {len(changed_test_files)}", file=sys.stderr)
59
+ for test_file in sorted(changed_test_files):
60
+ print(f" - {test_file}", file=sys.stderr)
61
+
62
+ # Find tests that cover changed lines
63
+ affected_tests = set()
64
+
65
+ if self.mapping_db:
66
+ if not python_changes:
67
+ if self.verbose:
68
+ print("â„šī¸ No Python source changes detected. Skipping mapping query.", file=sys.stderr)
69
+ elif hasattr(self.mapping_db, 'find_tests_for_changes'):
70
+ # CloudMappingDB or optimized interface
71
+ # Format changes for the API: [{"file": "path", "lines": [1, 2]}, ...]
72
+ formatted_changes = [
73
+ {"file": path, "lines": change.get_all_changed_lines()}
74
+ for path, change in python_changes.items()
75
+ if not change.is_new and change.get_all_changed_lines()
76
+ ]
77
+
78
+ if formatted_changes:
79
+ # Pass base_branch as the target branch for mapping lookup
80
+ affected_tests, unmapped = self.mapping_db.find_tests_for_changes(
81
+ formatted_changes,
82
+ branch=base_branch
83
+ )
84
+ if self.verbose and unmapped:
85
+ print(f"âš ī¸ {len(unmapped)} files have no coverage mapping", file=sys.stderr)
86
+ elif self.verbose:
87
+ print("â„šī¸ Changes contain no covered lines. Skipping mapping query.", file=sys.stderr)
88
+ else:
89
+ # Local TestMappingDBV2
90
+ for file_path, change in python_changes.items():
91
+ # Skip test files (we already have them)
92
+ if file_path in changed_test_files:
93
+ continue
94
+
95
+ if change.is_new:
96
+ continue
97
+
98
+ changed_lines = change.get_all_changed_lines()
99
+ if not changed_lines:
100
+ continue
101
+
102
+ # Find tests covering these lines
103
+ tests = self.mapping_db.find_tests_for_file_lines(file_path, changed_lines)
104
+ affected_tests.update(tests)
105
+
106
+ if self.verbose and tests:
107
+ print(f" ✓ {file_path}: {len(tests)} tests affected", file=sys.stderr)
108
+ else:
109
+ print("âš ī¸ No mapping database found. Only running changed test files.", file=sys.stderr)
110
+ print(" Run 'pintest push' or 'pintest build-mapping' to create one.", file=sys.stderr)
111
+
112
+ # Always include changed test files
113
+ affected_tests.update(changed_test_files)
114
+
115
+ return affected_tests
116
+
117
+ def run_tests(
118
+ self,
119
+ test_selection: Set[str],
120
+ dry_run: bool = False,
121
+ verbose: bool = False,
122
+ pytest_args: list = None
123
+ ) -> int:
124
+ """
125
+ Run selected tests with pytest.
126
+
127
+ Args:
128
+ test_selection: Set of test identifiers
129
+ dry_run: If True, only print tests without running
130
+ verbose: Show detailed output
131
+ pytest_args: Additional pytest arguments
132
+
133
+ Returns:
134
+ Exit code from pytest (0 = success)
135
+ """
136
+ if not test_selection:
137
+ print("No tests to run!")
138
+ return 0
139
+
140
+ # Build pytest command
141
+ cmd = ["pytest"]
142
+
143
+ if verbose:
144
+ cmd.append("-v")
145
+
146
+ # Add custom pytest args
147
+ if pytest_args:
148
+ cmd.extend(pytest_args)
149
+
150
+ # Add test files
151
+ for test in sorted(test_selection):
152
+ cmd.append(test)
153
+
154
+ if dry_run:
155
+ print(f"Would run {len(test_selection)} test(s):")
156
+ for test in sorted(test_selection):
157
+ print(f" {test}")
158
+ print(f"\nCommand: {' '.join(cmd)}")
159
+ return 0
160
+
161
+ print(f"Running {len(test_selection)} selected test(s)...")
162
+ result = subprocess.run(cmd, cwd=self.repo_root)
163
+ return result.returncode
164
+
165
+
166
+ def cmd_run(args):
167
+ """Run affected tests."""
168
+ repo_root = args.repo_root.resolve()
169
+ if not repo_root.exists():
170
+ print(f"❌ Error: Repository not found: {repo_root}", file=sys.stderr)
171
+ sys.exit(1)
172
+
173
+ # ── Mapping source detection ───────────────────────────────────────────
174
+ mapping_db_obj = None
175
+
176
+ # 1. Try Cloud Mode
177
+ cfg = Config.load()
178
+ use_cloud = cfg.is_cloud_enabled and bool(cfg.cloud.repo_id)
179
+
180
+ if use_cloud:
181
+ if args.verbose:
182
+ print(f"â˜ī¸ Mapping Service: Pintest Cloud (repo {cfg.cloud.repo_id[:8]}...)", file=sys.stderr)
183
+ mapping_db_obj = CloudMappingDB(cfg.cloud)
184
+ else:
185
+ # 2. Try Local V2 Mapping DB
186
+ mapping_db = args.mapping_db if hasattr(args, 'mapping_db') else None
187
+ if not mapping_db:
188
+ mapping_db = repo_root / ".test_mapping.db"
189
+
190
+ if mapping_db.exists():
191
+ if args.verbose:
192
+ print(f"đŸ–Ĩī¸ Local mode: {mapping_db}", file=sys.stderr)
193
+ mapping_db_obj = TestMappingDBV2(mapping_db)
194
+ mapping_db_obj.connect()
195
+
196
+ # Initialize runner
197
+ runner = PintestRunner(
198
+ repo_root,
199
+ mapping_db=mapping_db_obj,
200
+ verbose=args.verbose
201
+ )
202
+
203
+ try:
204
+ # Find affected tests
205
+ affected_tests = runner.find_affected_tests(
206
+ args.base_branch
207
+ )
208
+
209
+ # Unmapped tests discovery (Cloud mode only)
210
+ if use_cloud:
211
+ from .pre_commit_hook import find_unmapped_tests
212
+ unmapped = find_unmapped_tests(
213
+ repo_root,
214
+ mapping_db_obj,
215
+ verbose=args.verbose
216
+ )
217
+ if unmapped:
218
+ if args.verbose:
219
+ print(f"âš ī¸ Found {len(unmapped)} unmapped tests", file=sys.stderr)
220
+ affected_tests.update(unmapped)
221
+
222
+ # Check minimum threshold
223
+ if args.min_tests > 0 and len(affected_tests) < args.min_tests:
224
+ print(
225
+ f"Only {len(affected_tests)} test(s) found, below minimum {args.min_tests}",
226
+ file=sys.stderr
227
+ )
228
+ print("Exiting with error (run full test suite instead)", file=sys.stderr)
229
+ sys.exit(1)
230
+
231
+ # Remove '--' separator if present in pytest_args
232
+ pytest_extra_args = args.pytest_args
233
+ if pytest_extra_args and pytest_extra_args[0] == '--':
234
+ pytest_extra_args = pytest_extra_args[1:]
235
+
236
+ # Run tests
237
+ exit_code = runner.run_tests(
238
+ affected_tests,
239
+ dry_run=args.dry_run,
240
+ verbose=args.verbose,
241
+ pytest_args=pytest_extra_args
242
+ )
243
+ sys.exit(exit_code)
244
+
245
+ except KeyboardInterrupt:
246
+ print("\nInterrupted by user", file=sys.stderr)
247
+ sys.exit(130)
248
+ except Exception as e:
249
+ print(f"❌ Error: {e}", file=sys.stderr)
250
+ if args.verbose:
251
+ import traceback
252
+ traceback.print_exc()
253
+ sys.exit(1)
254
+ finally:
255
+ if mapping_db_obj and hasattr(mapping_db_obj, 'close'):
256
+ mapping_db_obj.close()
257
+
258
+
259
+ def cmd_update_mapping(args):
260
+ """Update test mapping database from coverage."""
261
+ from .update_mapping import update_mapping
262
+ sys.exit(update_mapping(
263
+ args.repo_root,
264
+ args.coverage_file,
265
+ args.mapping_db,
266
+ args.verbose
267
+ ))
268
+
269
+
270
+ def cmd_build_mapping(args):
271
+ """Build test mapping database iteratively (resumable)."""
272
+ from .build_mapping_iterative import build_mapping_iteratively
273
+
274
+ repo_root = args.repo_root.resolve()
275
+ mapping_db = args.mapping_db or (repo_root / ".test_mapping.db")
276
+
277
+ sys.exit(build_mapping_iteratively(
278
+ repo_root,
279
+ mapping_db,
280
+ verbose=args.verbose
281
+ ))
282
+
283
+
284
+ def cmd_login(args):
285
+ """Authenticate with Pintest and save API key to ~/.pintest/config.toml."""
286
+ from .config import Config, CloudConfig
287
+
288
+ api_url = args.api_url.rstrip("/")
289
+
290
+ print(f"\n🔐 Pintest Login ({api_url})")
291
+ print("────────────────────────────────────────")
292
+
293
+ # Offer two paths: API key directly, or email/password
294
+ print("\nOptions:")
295
+ print(" 1) Paste an existing API key (from https://pintest.dev/dashboard/keys)")
296
+ print(" 2) Login with email + password to generate a new key")
297
+ choice = input("\nChoice [1/2]: ").strip()
298
+
299
+ try:
300
+ import requests
301
+ except ImportError:
302
+ print("❌ 'requests' is required: pip install requests")
303
+ sys.exit(1)
304
+
305
+ if choice == "1":
306
+ api_key = getpass.getpass("Paste API key (pt_live_...): ").strip()
307
+ if not api_key.startswith("pt_live_"):
308
+ print("❌ Invalid key format — expected 'pt_live_...'")
309
+ sys.exit(1)
310
+ else:
311
+ email = input("Email: ").strip()
312
+ password = getpass.getpass("Password: ")
313
+
314
+ # Login to get JWT, then create a new API key
315
+ resp = requests.post(
316
+ f"{api_url}/api/v1/auth/login",
317
+ json={"email": email, "password": password},
318
+ timeout=15,
319
+ )
320
+ if resp.status_code == 401:
321
+ print("❌ Invalid email or password")
322
+ sys.exit(1)
323
+ resp.raise_for_status()
324
+ jwt_token = resp.json()["access_token"]
325
+
326
+ # Create a new API key scoped to query + push
327
+ key_resp = requests.post(
328
+ f"{api_url}/api/v1/auth/keys",
329
+ json={"name": "cli-key", "scopes": ["query", "push"]},
330
+ headers={"Authorization": f"Bearer {jwt_token}"},
331
+ timeout=15,
332
+ )
333
+ key_resp.raise_for_status()
334
+ api_key = key_resp.json()["key"]
335
+ print(f"✓ New API key created (ID: {key_resp.json()['id']})")
336
+
337
+ # Save to config
338
+ cfg = Config.load()
339
+ cfg.cloud = CloudConfig(api_key=api_key, api_url=api_url)
340
+ cfg.save()
341
+
342
+ print(f"\n✅ Logged in! Config saved to {Config.CONFIG_FILE}")
343
+ print(f" Next step: pintest track")
344
+
345
+
346
+ def cmd_register(args):
347
+ """Register a new account with Pintest."""
348
+ api_url = args.api_url.rstrip("/")
349
+ print(f"\n📝 Pintest Registration ({api_url})")
350
+ print("────────────────────────────────────────")
351
+
352
+ email = input("Email: ").strip()
353
+ password = getpass.getpass("Password: ")
354
+ org_name = input("Organization Name: ").strip()
355
+
356
+ try:
357
+ import requests
358
+ resp = requests.post(
359
+ f"{api_url}/api/v1/auth/register",
360
+ json={"email": email, "password": password, "org_name": org_name},
361
+ timeout=15,
362
+ )
363
+ if resp.status_code == 400:
364
+ print(f"❌ {resp.json().get('detail', 'Registration failed')}")
365
+ sys.exit(1)
366
+ resp.raise_for_status()
367
+ print("✅ Account created successfully!")
368
+ print(f" Organization '{org_name}' registered on the Free plan.")
369
+ print(" You can now log in using 'pintest login'")
370
+ except Exception as e:
371
+ print(f"❌ Error: {e}")
372
+ sys.exit(1)
373
+
374
+
375
+ def cmd_track(args):
376
+ """Register a repository with Pintest and save repo_id to config."""
377
+ import os
378
+ import sys
379
+ from .config import Config
380
+
381
+ repo_name = args.name
382
+ if not repo_name:
383
+ repo_name = os.path.basename(os.getcwd())
384
+
385
+ branch = args.branch
386
+ if not branch:
387
+ try:
388
+ branch = input("đŸŒŋ Target branch to track [main]: ").strip()
389
+ if not branch:
390
+ branch = "main"
391
+ except (KeyboardInterrupt, EOFError):
392
+ print("\n❌ Cancelled")
393
+ sys.exit(1)
394
+
395
+ cfg = Config.load()
396
+ if not cfg.is_cloud_enabled:
397
+ print("❌ Not logged in. Run: pintest login")
398
+ sys.exit(1)
399
+
400
+ try:
401
+ import requests
402
+ except ImportError:
403
+ print("❌ 'requests' is required: pip install requests")
404
+ sys.exit(1)
405
+
406
+ api_url = cfg.cloud.api_url
407
+ headers = {
408
+ "Authorization": f"Bearer {cfg.cloud.api_key}",
409
+ "Content-Type": "application/json",
410
+ }
411
+
412
+ print(f"\nđŸ“Ļ Registering repo '{repo_name}' with Pintest...")
413
+
414
+ resp = requests.post(
415
+ f"{api_url}/api/v1/repos",
416
+ json={
417
+ "name": repo_name,
418
+ "remote_url": args.remote_url,
419
+ "default_branch": branch,
420
+ },
421
+ headers=headers,
422
+ timeout=15,
423
+ )
424
+
425
+ if resp.status_code == 400:
426
+ detail = resp.json().get("detail", "")
427
+ print(f"❌ {detail}")
428
+ sys.exit(1)
429
+
430
+ if resp.status_code == 402:
431
+ detail = resp.json().get("detail", "")
432
+ print(f"\n❌ {detail}")
433
+ sys.exit(1)
434
+
435
+ resp.raise_for_status()
436
+
437
+ repo = resp.json()
438
+ repo_id = repo["id"]
439
+
440
+ # Save repo_id and branch to config
441
+ cfg.cloud.repo_id = repo_id
442
+ cfg.cloud.branch = branch
443
+ cfg.save()
444
+
445
+ print(f"\n✅ Repository registered!")
446
+ print(f" Name: {repo['name']}")
447
+ print(f" ID: {repo_id}")
448
+ print(f" Branch: {branch}")
449
+ print(f" Config: ~/.pintest/config.toml")
450
+ print(f"\n Your pre-commit hook will now use the Pintest cloud API automatically.")
451
+
452
+
453
+ def cmd_push(args):
454
+ """Sync local coverage mappings to Pintest cloud."""
455
+ from .cloud_mapping_db import CloudMappingDB
456
+ from .config import Config
457
+
458
+ repo_root = Path.cwd()
459
+ config = Config.load()
460
+
461
+ if not config.is_cloud_enabled or not config.cloud.repo_id:
462
+ print("❌ Repository not registered. Run 'pintest track' first.")
463
+ sys.exit(1)
464
+
465
+ coverage_file = repo_root / ".coverage"
466
+ if not coverage_file.exists():
467
+ print(f"❌ Coverage file not found at {coverage_file}")
468
+ print("Run your tests first (e.g. 'pytest --cov --cov-context=test')")
469
+ sys.exit(1)
470
+
471
+ print(f"â˜ī¸ Pintest Cloud Sync (repo: {config.cloud.repo_id[:8]}...)")
472
+ db = CloudMappingDB(config.cloud)
473
+
474
+ success = db.push_coverage(coverage_file, verbose=args.verbose)
475
+
476
+ if success:
477
+ print("✅ Success!")
478
+ else:
479
+ print("❌ Push failed.")
480
+ sys.exit(1)
481
+
482
+
483
+ def main():
484
+ """CLI entry point."""
485
+ parser = argparse.ArgumentParser(
486
+ description="Pintest - Run only tests affected by code changes",
487
+ formatter_class=argparse.RawDescriptionHelpFormatter
488
+ )
489
+
490
+ subparsers = parser.add_subparsers(dest='command', help='Commands')
491
+
492
+ # Run command (default behavior)
493
+ run_parser = subparsers.add_parser(
494
+ 'run',
495
+ help='Run affected tests',
496
+ formatter_class=argparse.RawDescriptionHelpFormatter,
497
+ epilog="""
498
+ Examples:
499
+ pintest run --repo-root ~/workspace/myproject
500
+ pintest run --repo-root ~/workspace/myproject --dry-run
501
+ pintest run --repo-root ~/workspace/myproject --base-branch develop
502
+ """
503
+ )
504
+ run_parser.add_argument(
505
+ "--repo-root",
506
+ type=Path,
507
+ default=Path.cwd(),
508
+ help="Repository root directory (default: current directory)"
509
+ )
510
+ run_parser.add_argument(
511
+ "--base-branch",
512
+ default="master",
513
+ help="Base branch to compare against (default: master)"
514
+ )
515
+ run_parser.add_argument(
516
+ "--coverage-file",
517
+ type=Path,
518
+ help="Path to coverage database file (default: <repo>/.coverage)"
519
+ )
520
+ run_parser.add_argument(
521
+ "--dry-run",
522
+ action="store_true",
523
+ help="Print tests without running them"
524
+ )
525
+ run_parser.add_argument(
526
+ "--min-tests",
527
+ type=int,
528
+ default=0,
529
+ help="Minimum number of tests to run (exit with error if below)"
530
+ )
531
+ run_parser.add_argument(
532
+ "-v", "--verbose",
533
+ action="store_true",
534
+ help="Show detailed output"
535
+ )
536
+ run_parser.add_argument(
537
+ "pytest_args",
538
+ nargs=argparse.REMAINDER,
539
+ help="Additional arguments to pass to pytest (after --)"
540
+ )
541
+ run_parser.set_defaults(func=cmd_run)
542
+
543
+ # Update mapping command
544
+ update_parser = subparsers.add_parser(
545
+ 'update-mapping',
546
+ help='Update test mapping database from coverage',
547
+ formatter_class=argparse.RawDescriptionHelpFormatter,
548
+ epilog="""
549
+ Examples:
550
+ pintest update-mapping --repo-root ~/workspace/myproject
551
+ pintest update-mapping --repo-root ~/workspace/myproject --verbose
552
+ """
553
+ )
554
+ update_parser.add_argument(
555
+ "--repo-root",
556
+ type=Path,
557
+ default=Path.cwd(),
558
+ help="Repository root directory (default: current directory)"
559
+ )
560
+ update_parser.add_argument(
561
+ "--coverage-file",
562
+ type=Path,
563
+ help="Path to .coverage file (default: <repo>/.coverage)"
564
+ )
565
+ update_parser.add_argument(
566
+ "--mapping-db",
567
+ type=Path,
568
+ help="Path to mapping database (default: <repo>/.test_mapping.db)"
569
+ )
570
+ update_parser.add_argument(
571
+ "-v", "--verbose",
572
+ action="store_true",
573
+ help="Verbose output"
574
+ )
575
+ update_parser.set_defaults(func=cmd_update_mapping)
576
+
577
+ # Build mapping iteratively command
578
+ build_parser = subparsers.add_parser(
579
+ 'build-mapping',
580
+ help='Build test mapping database iteratively (resumable)',
581
+ formatter_class=argparse.RawDescriptionHelpFormatter,
582
+ epilog="""
583
+ Examples:
584
+ pintest build-mapping --repo-root ~/workspace/myproject
585
+ pintest build-mapping --repo-root ~/workspace/myproject --resume
586
+ pintest build-mapping --repo-root ~/workspace/myproject --verbose
587
+ """
588
+ )
589
+ build_parser.add_argument(
590
+ "--repo-root",
591
+ type=Path,
592
+ default=Path.cwd(),
593
+ help="Repository root directory (default: current directory)"
594
+ )
595
+ build_parser.add_argument(
596
+ "--mapping-db",
597
+ type=Path,
598
+ help="Path to mapping database (default: <repo>/.test_mapping.db)"
599
+ )
600
+ build_parser.add_argument(
601
+ "-v", "--verbose",
602
+ action="store_true",
603
+ help="Verbose output"
604
+ )
605
+ build_parser.set_defaults(func=cmd_build_mapping)
606
+
607
+ # Login command
608
+ login_parser = subparsers.add_parser(
609
+ 'login',
610
+ help='Authenticate with Pintest cloud (saves API key to ~/.pintest/config.toml)',
611
+ )
612
+ login_parser.add_argument(
613
+ "--api-url",
614
+ default="https://api.pintest.dev",
615
+ help="Pintest API URL (default: https://api.pintest.dev)"
616
+ )
617
+ login_parser.set_defaults(func=cmd_login)
618
+
619
+ # Register command
620
+ register_parser = subparsers.add_parser(
621
+ 'register',
622
+ help='Create a new Pintest account',
623
+ )
624
+ register_parser.add_argument(
625
+ "--api-url",
626
+ default="https://api.pintest.dev",
627
+ help="Pintest API URL (default: https://api.pintest.dev)"
628
+ )
629
+ register_parser.set_defaults(func=cmd_register)
630
+
631
+ # Track command
632
+ track_parser = subparsers.add_parser(
633
+ 'track',
634
+ help='Register this repository with Pintest cloud to track it',
635
+ )
636
+ track_parser.add_argument(
637
+ '--name', required=False,
638
+ help='Repository name (defaults to current directory name)'
639
+ )
640
+ track_parser.add_argument(
641
+ '--branch', '-b', default=None,
642
+ help='Default branch to track (prompts if not provided)'
643
+ )
644
+ track_parser.add_argument(
645
+ '--remote-url',
646
+ default=None,
647
+ help='Remote URL (e.g. https://github.com/org/repo) — optional'
648
+ )
649
+ track_parser.set_defaults(func=cmd_track)
650
+
651
+ # Push command
652
+ push_parser = subparsers.add_parser(
653
+ 'push',
654
+ help='Sync local coverage mappings to Pintest cloud',
655
+ )
656
+ push_parser.add_argument(
657
+ "-v", "--verbose",
658
+ action="store_true",
659
+ help="Verbose output"
660
+ )
661
+ push_parser.set_defaults(func=cmd_push)
662
+
663
+ # Parse arguments
664
+ args = parser.parse_args()
665
+
666
+ # If no command specified, default to 'run'
667
+ if not args.command:
668
+ # Parse as 'run' command for backward compatibility
669
+ run_args = ['run'] + sys.argv[1:]
670
+ args = parser.parse_args(run_args)
671
+
672
+ # Execute command
673
+ if hasattr(args, 'func'):
674
+ args.func(args)
675
+ else:
676
+ parser.print_help()
677
+ sys.exit(1)
678
+
679
+
680
+ if __name__ == "__main__":
681
+ main()