relm 3.0.2__tar.gz → 5.0.0__tar.gz

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.
Files changed (60) hide show
  1. {relm-3.0.2 → relm-5.0.0}/PKG-INFO +2 -1
  2. {relm-3.0.2 → relm-5.0.0}/pyproject.toml +3 -2
  3. {relm-3.0.2 → relm-5.0.0}/src/relm/__init__.py +1 -1
  4. {relm-3.0.2 → relm-5.0.0}/src/relm/commands/base.py +1 -0
  5. {relm-3.0.2 → relm-5.0.0}/src/relm/commands/clean_command.py +27 -8
  6. {relm-3.0.2 → relm-5.0.0}/src/relm/commands/create_command.py +4 -5
  7. {relm-3.0.2 → relm-5.0.0}/src/relm/commands/gc_command.py +4 -3
  8. relm-5.0.0/src/relm/commands/install_command.py +97 -0
  9. {relm-3.0.2 → relm-5.0.0}/src/relm/commands/list_command.py +8 -3
  10. relm-5.0.0/src/relm/commands/pytest_command.py +195 -0
  11. {relm-3.0.2 → relm-5.0.0}/src/relm/commands/release_command.py +8 -3
  12. relm-5.0.0/src/relm/commands/run_command.py +102 -0
  13. {relm-3.0.2 → relm-5.0.0}/src/relm/commands/status_command.py +25 -8
  14. {relm-3.0.2 → relm-5.0.0}/src/relm/commands/verify_command.py +27 -8
  15. {relm-3.0.2 → relm-5.0.0}/src/relm/core.py +39 -26
  16. {relm-3.0.2 → relm-5.0.0}/src/relm/main.py +64 -15
  17. relm-5.0.0/src/relm/runner.py +172 -0
  18. {relm-3.0.2 → relm-5.0.0}/src/relm.egg-info/PKG-INFO +2 -1
  19. {relm-3.0.2 → relm-5.0.0}/src/relm.egg-info/SOURCES.txt +2 -0
  20. {relm-3.0.2 → relm-5.0.0}/src/relm.egg-info/requires.txt +1 -0
  21. {relm-3.0.2 → relm-5.0.0}/tests/test_core.py +1 -1
  22. {relm-3.0.2 → relm-5.0.0}/tests/test_custom_commit_message.py +1 -1
  23. {relm-3.0.2 → relm-5.0.0}/tests/test_dependency_sorting.py +21 -4
  24. {relm-3.0.2 → relm-5.0.0}/tests/test_execution_order.py +10 -25
  25. {relm-3.0.2 → relm-5.0.0}/tests/test_gc.py +10 -3
  26. {relm-3.0.2 → relm-5.0.0}/tests/test_main.py +126 -165
  27. relm-5.0.0/tests/test_pytest_command.py +80 -0
  28. {relm-3.0.2 → relm-5.0.0}/tests/test_release.py +11 -11
  29. relm-5.0.0/tests/test_runner.py +97 -0
  30. {relm-3.0.2 → relm-5.0.0}/tests/test_verify.py +1 -1
  31. {relm-3.0.2 → relm-5.0.0}/tests/test_verify_command.py +3 -3
  32. relm-3.0.2/src/relm/commands/install_command.py +0 -50
  33. relm-3.0.2/src/relm/commands/run_command.py +0 -55
  34. relm-3.0.2/src/relm/runner.py +0 -22
  35. relm-3.0.2/tests/test_runner.py +0 -28
  36. {relm-3.0.2 → relm-5.0.0}/README.md +0 -0
  37. {relm-3.0.2 → relm-5.0.0}/setup.cfg +0 -0
  38. {relm-3.0.2 → relm-5.0.0}/src/relm/banner.py +0 -0
  39. {relm-3.0.2 → relm-5.0.0}/src/relm/changelog.py +0 -0
  40. {relm-3.0.2 → relm-5.0.0}/src/relm/clean.py +0 -0
  41. {relm-3.0.2 → relm-5.0.0}/src/relm/commands/__init__.py +0 -0
  42. {relm-3.0.2 → relm-5.0.0}/src/relm/config.py +0 -0
  43. {relm-3.0.2 → relm-5.0.0}/src/relm/gc.py +0 -0
  44. {relm-3.0.2 → relm-5.0.0}/src/relm/git_ops.py +0 -0
  45. {relm-3.0.2 → relm-5.0.0}/src/relm/install.py +0 -0
  46. {relm-3.0.2 → relm-5.0.0}/src/relm/release.py +0 -0
  47. {relm-3.0.2 → relm-5.0.0}/src/relm/verify.py +0 -0
  48. {relm-3.0.2 → relm-5.0.0}/src/relm/versioning.py +0 -0
  49. {relm-3.0.2 → relm-5.0.0}/src/relm.egg-info/dependency_links.txt +0 -0
  50. {relm-3.0.2 → relm-5.0.0}/src/relm.egg-info/entry_points.txt +0 -0
  51. {relm-3.0.2 → relm-5.0.0}/src/relm.egg-info/top_level.txt +0 -0
  52. {relm-3.0.2 → relm-5.0.0}/tests/test_banner.py +0 -0
  53. {relm-3.0.2 → relm-5.0.0}/tests/test_changed_since.py +0 -0
  54. {relm-3.0.2 → relm-5.0.0}/tests/test_changelog.py +0 -0
  55. {relm-3.0.2 → relm-5.0.0}/tests/test_clean.py +0 -0
  56. {relm-3.0.2 → relm-5.0.0}/tests/test_config.py +0 -0
  57. {relm-3.0.2 → relm-5.0.0}/tests/test_create_command.py +0 -0
  58. {relm-3.0.2 → relm-5.0.0}/tests/test_git_ops.py +0 -0
  59. {relm-3.0.2 → relm-5.0.0}/tests/test_install.py +0 -0
  60. {relm-3.0.2 → relm-5.0.0}/tests/test_versioning.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: relm
3
- Version: 3.0.2
3
+ Version: 5.0.0
4
4
  Summary: A unified CLI tool to manage versioning, git, and PyPI releases for multiple projects.
5
5
  Author-email: dhruv13x <dhruv13x@gmail.com>
6
6
  License: MIT © dhruv13x
@@ -20,6 +20,7 @@ Requires-Dist: rich>=13.0.0
20
20
  Requires-Dist: rich-argparse>=1.0.0
21
21
  Requires-Dist: build>=1.0.0
22
22
  Requires-Dist: twine>=4.0.0
23
+ Requires-Dist: pyfakefs>=5.10.2
23
24
  Provides-Extra: dev
24
25
  Requires-Dist: pytest>=8.0.0; extra == "dev"
25
26
  Requires-Dist: pytest-cov>=5.0.0; extra == "dev"
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "relm"
7
- version = "3.0.2"
7
+ version = "5.0.0"
8
8
  description = "A unified CLI tool to manage versioning, git, and PyPI releases for multiple projects."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -37,7 +37,8 @@ dependencies = [
37
37
  "rich>=13.0.0",
38
38
  "rich-argparse>=1.0.0",
39
39
  "build>=1.0.0",
40
- "twine>=4.0.0"
40
+ "twine>=4.0.0",
41
+ "pyfakefs>=5.10.2",
41
42
  ]
42
43
 
43
44
  [project.urls]
@@ -1,3 +1,3 @@
1
1
  # src/relm/__init__.py
2
2
 
3
- __version__ = "3.0.2"
3
+ __version__ = "5.0.0"
@@ -1,3 +1,4 @@
1
+ import argparse
1
2
  from argparse import ArgumentParser, Namespace
2
3
  from rich.console import Console
3
4
 
@@ -1,3 +1,4 @@
1
+ import argparse
1
2
  import sys
2
3
  from argparse import Namespace, _SubParsersAction
3
4
  from pathlib import Path
@@ -5,27 +6,45 @@ from rich.console import Console
5
6
  from ..core import find_projects
6
7
  from ..clean import clean_project
7
8
 
8
- def register(subparsers: _SubParsersAction):
9
+ def register(subparsers: _SubParsersAction, base_parser: argparse.ArgumentParser):
9
10
  """Register the clean command."""
10
- clean_parser = subparsers.add_parser("clean", help="Recursively remove build artifacts (dist/, build/, __pycache__)")
11
+ clean_parser = subparsers.add_parser("clean", help="Recursively remove build artifacts (dist/, build/, __pycache__)", parents=[base_parser])
11
12
  clean_parser.add_argument("project_name", help="Name of the project to clean or 'all'", nargs="?", default="all")
12
13
  clean_parser.set_defaults(func=execute)
13
14
 
14
15
  def execute(args: Namespace, console: Console):
15
16
  """Execute the clean command."""
16
17
  root_path = Path(args.path).resolve()
17
- all_projects = find_projects(root_path)
18
+ all_projects = find_projects(
19
+ root_path,
20
+ recursive=getattr(args, "recursive", False),
21
+ max_depth=getattr(args, "depth", 2)
22
+ )
18
23
  target_projects = []
19
24
 
20
25
  if args.project_name == "all":
21
26
  target_projects = all_projects
27
+ if getattr(args, "from_root", False):
28
+ target_projects = [p for p in target_projects if p.path.resolve() != root_path.resolve()]
22
29
  console.print(f"[bold]Cleaning workspace for {len(target_projects)} projects...[/bold]")
23
30
  else:
24
- target = next((p for p in all_projects if p.name == args.project_name), None)
25
- if not target:
26
- console.print(f"[red]Project '{args.project_name}' not found in {root_path}[/red]")
27
- sys.exit(1)
28
- target_projects = [target]
31
+ # 1. Try path-based matching
32
+ target_dir = (root_path / args.project_name).resolve()
33
+ if target_dir.exists() and target_dir.is_dir():
34
+ target_projects = [
35
+ p for p in all_projects
36
+ if p.path.resolve() == target_dir or target_dir in p.path.resolve().parents
37
+ ]
38
+ if target_projects:
39
+ console.print(f"[bold]Targeting {len(target_projects)} projects in folder: [cyan]{args.project_name}[/cyan][/bold]")
40
+
41
+ # 2. Try exact name match
42
+ if not target_projects:
43
+ target = next((p for p in all_projects if p.name == args.project_name), None)
44
+ if not target:
45
+ console.print(f"[red]Project or folder '{args.project_name}' not found in {root_path}[/red]")
46
+ sys.exit(1)
47
+ target_projects = [target]
29
48
 
30
49
  total_cleaned_paths = 0
31
50
 
@@ -1,14 +1,13 @@
1
+ import argparse
1
2
  import os
2
3
  from argparse import Namespace, _SubParsersAction
3
4
  from pathlib import Path
4
5
  from rich.console import Console
5
6
 
6
- def register(subparsers: _SubParsersAction):
7
+ def register(subparsers: _SubParsersAction, base_parser: argparse.ArgumentParser):
7
8
  """Register the create command."""
8
- parser = subparsers.add_parser("create", help="Create a new Python project")
9
- parser.add_argument("name", help="Name of the project")
10
- parser.add_argument("path", nargs="?", help="Directory to create the project in (defaults to current directory)")
11
- parser.set_defaults(func=execute)
9
+ create_parser = subparsers.add_parser("create", help="Create a new Python project", parents=[base_parser])
10
+ create_parser.add_argument("name", help="Name of the new project")
12
11
 
13
12
  def execute(args: Namespace, console: Console):
14
13
  """Execute the create command."""
@@ -1,3 +1,4 @@
1
+ import argparse
1
2
  import sys
2
3
  from argparse import Namespace, _SubParsersAction
3
4
  from pathlib import Path
@@ -5,10 +6,10 @@ from rich.console import Console
5
6
  from ..core import find_projects
6
7
  from ..gc import gc_project
7
8
 
8
- def register(subparsers: _SubParsersAction):
9
+ def register(subparsers: _SubParsersAction, base_parser: argparse.ArgumentParser):
9
10
  """Register the gc command."""
10
- gc_parser = subparsers.add_parser("gc", help="Run git gc on project(s)")
11
- gc_parser.add_argument("project_name", help="Name of the project to clean or 'all'", nargs="?", default="all")
11
+ gc_parser = subparsers.add_parser("gc", help="Run git gc on project(s)", parents=[base_parser])
12
+ gc_parser.add_argument("project_name", help="Name of the project to gc or 'all'", nargs="?", default="all")
12
13
  gc_parser.set_defaults(func=execute)
13
14
 
14
15
  def execute(args: Namespace, console: Console):
@@ -0,0 +1,97 @@
1
+ import argparse
2
+ import sys
3
+ from argparse import Namespace, _SubParsersAction
4
+ from pathlib import Path
5
+ from rich.console import Console
6
+ from ..core import find_projects, sort_projects_by_dependency
7
+ from ..install import install_project
8
+
9
+ def register(subparsers: _SubParsersAction, base_parser: argparse.ArgumentParser):
10
+ """Register the install command."""
11
+ install_parser = subparsers.add_parser("install", help="Install projects into the current environment", parents=[base_parser])
12
+ install_parser.add_argument("project_name", help="Name of the project to install or 'all'")
13
+ install_parser.add_argument("--no-editable", action="store_true", help="Install in standard mode instead of editable")
14
+ install_parser.set_defaults(func=execute)
15
+
16
+ def execute(args: Namespace, console: Console):
17
+ """Execute the install command."""
18
+ root_path = Path(args.path).resolve()
19
+ all_projects = find_projects(
20
+ root_path,
21
+ recursive=getattr(args, "recursive", False),
22
+ max_depth=getattr(args, "depth", 2)
23
+ )
24
+ target_projects = []
25
+
26
+ if args.project_name == "all":
27
+ try:
28
+ target_projects = sort_projects_by_dependency(all_projects)
29
+ if getattr(args, "from_root", False):
30
+ target_projects = [p for p in target_projects if p.path.resolve() != root_path.resolve()]
31
+ except ValueError as e:
32
+ console.print(f"[red]Dependency sorting failed: {e}[/red]")
33
+ sys.exit(1)
34
+ else:
35
+ # 1. Try path-based matching
36
+ target_dir = (root_path / args.project_name).resolve()
37
+ if target_dir.exists() and target_dir.is_dir():
38
+ target_projects = [
39
+ p for p in all_projects
40
+ if p.path.resolve() == target_dir or target_dir in p.path.resolve().parents
41
+ ]
42
+ if target_projects:
43
+ try:
44
+ target_projects = sort_projects_by_dependency(target_projects)
45
+ except ValueError as e:
46
+ console.print(f"[red]Dependency sorting failed: {e}[/red]")
47
+ sys.exit(1)
48
+ console.print(f"[bold]Targeting {len(target_projects)} projects in folder: [cyan]{args.project_name}[/cyan][/bold]")
49
+
50
+ # 2. Try exact name match
51
+ if not target_projects:
52
+ target = next((p for p in all_projects if p.name == args.project_name), None)
53
+ if not target:
54
+ console.print(f"[red]Project or folder '{args.project_name}' not found in {root_path}[/red]")
55
+ sys.exit(1)
56
+ target_projects = [target]
57
+
58
+ results = {"installed": [], "failed": []}
59
+ editable_mode = not args.no_editable
60
+
61
+ if getattr(args, "parallel", False):
62
+ from ..runner import execute_in_parallel
63
+
64
+ def cmd_provider(p):
65
+ # We need to construct the pip install command manually for parallel runner
66
+ # since install_project uses subprocess directly.
67
+ mode = "-e" if editable_mode else ""
68
+ return [sys.executable, "-m", "pip", "install", mode, "."]
69
+
70
+ results_data = execute_in_parallel(
71
+ target_projects,
72
+ command_provider=cmd_provider,
73
+ max_workers=args.jobs,
74
+ fail_fast=True # Always fail-fast for installation dependencies
75
+ )
76
+
77
+ for res in results_data:
78
+ if res["success"]:
79
+ results["installed"].append(res["name"])
80
+ else:
81
+ results["failed"].append(res["name"])
82
+ console.rule(f"[red]Install FAILED for: {res['name']}[/red]")
83
+ if res["stdout"]: console.print(res["stdout"])
84
+ if res["stderr"]: console.print(res["stderr"], style="red")
85
+ else:
86
+ for project in target_projects:
87
+ success = install_project(project, editable=editable_mode)
88
+ if success:
89
+ results["installed"].append(project.name)
90
+ else:
91
+ results["failed"].append(project.name)
92
+
93
+ if args.project_name == "all":
94
+ console.rule("Bulk Install Summary")
95
+ console.print(f"[green]Installed: {len(results['installed'])}[/green] {results['installed']}")
96
+ if results["failed"]:
97
+ console.print(f"[red]Failed: {len(results['failed'])}[/red] {results['failed']}")
@@ -1,3 +1,4 @@
1
+ import argparse
1
2
  from argparse import Namespace, _SubParsersAction
2
3
  from pathlib import Path
3
4
  from rich.console import Console
@@ -5,16 +6,20 @@ from rich.table import Table
5
6
  from ..core import find_projects
6
7
  from ..git_ops import git_has_changes_since
7
8
 
8
- def register(subparsers: _SubParsersAction):
9
+ def register(subparsers: _SubParsersAction, base_parser: argparse.ArgumentParser):
9
10
  """Register the list command."""
10
- list_parser = subparsers.add_parser("list", help="List all discovered projects")
11
+ list_parser = subparsers.add_parser("list", help="List all discovered projects", parents=[base_parser])
11
12
  list_parser.add_argument("--since", help="List only projects changed since the given git ref")
12
13
  list_parser.set_defaults(func=execute)
13
14
 
14
15
  def execute(args: Namespace, console: Console):
15
16
  """Execute the list command."""
16
17
  root_path = Path(args.path).resolve()
17
- projects = find_projects(root_path)
18
+ projects = find_projects(
19
+ root_path,
20
+ recursive=getattr(args, "recursive", False),
21
+ max_depth=getattr(args, "depth", 2)
22
+ )
18
23
 
19
24
  if args.since:
20
25
  projects = [p for p in projects if git_has_changes_since(p.path, args.since)]
@@ -0,0 +1,195 @@
1
+ import argparse
2
+ import sys
3
+ import subprocess
4
+ from argparse import Namespace, _SubParsersAction
5
+ from pathlib import Path
6
+ from rich.console import Console
7
+ from rich.table import Table
8
+ from ..core import find_projects, sort_projects_by_dependency
9
+ from ..runner import execute_in_parallel
10
+
11
+ def register(subparsers: _SubParsersAction, base_parser: argparse.ArgumentParser):
12
+ """Register the pytest command."""
13
+ pytest_parser = subparsers.add_parser("pytest", help="Run pytest across projects and summarize results", parents=[base_parser])
14
+ pytest_parser.add_argument(
15
+ "project_name",
16
+ nargs="?",
17
+ default="all",
18
+ help="Name of the project to run on or 'all' (default: all)"
19
+ )
20
+ pytest_parser.add_argument(
21
+ "--fail-fast",
22
+ action="store_true",
23
+ help="Stop execution if a project's tests fail"
24
+ )
25
+ # We handle pytest arguments manually via -- separator in execute()
26
+ pytest_parser.set_defaults(func=execute)
27
+
28
+ def execute(args: Namespace, console: Console):
29
+ """Execute the pytest command."""
30
+ # Manual extraction of pytest arguments from sys.argv
31
+ pytest_args = []
32
+ if "--" in sys.argv:
33
+ idx = sys.argv.index("--")
34
+ pytest_args = sys.argv[idx + 1:]
35
+
36
+ root_path = Path(args.path).resolve()
37
+ all_projects = find_projects(
38
+ root_path,
39
+ recursive=getattr(args, "recursive", False),
40
+ max_depth=getattr(args, "depth", 2)
41
+ )
42
+ target_projects = []
43
+
44
+ if args.project_name == "all":
45
+ try:
46
+ target_projects = sort_projects_by_dependency(all_projects)
47
+ # If running from root, skip the project that IS the root to avoid double execution
48
+ if getattr(args, "from_root", False):
49
+ target_projects = [p for p in target_projects if p.path.resolve() != root_path.resolve()]
50
+ except ValueError as e:
51
+ console.print(f"[red]Dependency sorting failed: {e}[/red]")
52
+ sys.exit(1)
53
+ else:
54
+ # 1. Try path-based matching (e.g. relm pytest packages)
55
+ target_dir = (root_path / args.project_name).resolve()
56
+ if target_dir.exists() and target_dir.is_dir():
57
+ # Filter all projects that are under this directory
58
+ target_projects = [
59
+ p for p in all_projects
60
+ if p.path.resolve() == target_dir or target_dir in p.path.resolve().parents
61
+ ]
62
+ if target_projects:
63
+ try:
64
+ target_projects = sort_projects_by_dependency(target_projects)
65
+ except ValueError as e:
66
+ console.print(f"[red]Dependency sorting failed: {e}[/red]")
67
+ sys.exit(1)
68
+ console.print(f"[bold]Targeting {len(target_projects)} projects in folder: [cyan]{args.project_name}[/cyan][/bold]")
69
+
70
+ # 2. If no projects found via path, try exact name match
71
+ if not target_projects:
72
+ target = next((p for p in all_projects if p.name == args.project_name), None)
73
+ if not target:
74
+ console.print(f"[red]Project or folder '{args.project_name}' not found in {root_path}[/red]")
75
+ sys.exit(1)
76
+ target_projects = [target]
77
+
78
+ console.print(f"[bold]Running pytest on {len(target_projects)} projects...[/bold]")
79
+ if pytest_args:
80
+ console.print(f"[dim]Pytest arguments: {' '.join(pytest_args)}[/dim]")
81
+
82
+ use_from_root = getattr(args, "from_root", False)
83
+ cwd = root_path if use_from_root else None
84
+
85
+ if getattr(args, "parallel", False):
86
+ def cmd_provider(p):
87
+ base_cmd = [sys.executable, "-m", "pytest"]
88
+ if use_from_root:
89
+ # Use relative path if possible for cleaner output
90
+ try:
91
+ target_path = str(p.path.relative_to(root_path))
92
+ except ValueError:
93
+ target_path = str(p.path)
94
+ return base_cmd + [target_path] + pytest_args
95
+ return base_cmd + pytest_args
96
+
97
+ results_data = execute_in_parallel(
98
+ target_projects,
99
+ command_provider=cmd_provider,
100
+ max_workers=args.jobs,
101
+ fail_fast=args.fail_fast,
102
+ cwd=cwd
103
+ )
104
+ # Map back to simple results format for summary
105
+ results = results_data
106
+
107
+ # In parallel mode, show output for failed projects since it was captured
108
+ for res in results:
109
+ if not res["success"]:
110
+ console.rule(f"[red]Summary for FAILED project: {res['name']}[/red]")
111
+ if res["stdout"]:
112
+ from rich.panel import Panel
113
+ console.print(Panel(
114
+ res["stdout"],
115
+ title="Last 50 lines of output",
116
+ subtitle="Truncated to prevent system crash",
117
+ border_style="red"
118
+ ))
119
+ if res["stderr"]:
120
+ console.print(res["stderr"], style="red")
121
+ else:
122
+ results = []
123
+ for project in target_projects:
124
+ console.rule(f"Testing {project.name}")
125
+
126
+ base_cmd = [sys.executable, "-m", "pytest"]
127
+ if use_from_root:
128
+ try:
129
+ target_path = str(project.path.relative_to(root_path))
130
+ except ValueError:
131
+ target_path = str(project.path)
132
+ cmd = base_cmd + [target_path] + pytest_args
133
+ else:
134
+ cmd = base_cmd + pytest_args
135
+
136
+ try:
137
+ from ..runner import run_project_command_tail
138
+ res_data = run_project_command_tail(cwd or project.path, cmd, tail_lines=50)
139
+ success = (res_data["returncode"] == 0)
140
+
141
+ if not success:
142
+ from rich.panel import Panel
143
+ console.print(Panel(
144
+ res_data["stdout"],
145
+ title=f"Failure Summary: {project.name}",
146
+ subtitle="Last 50 lines of output",
147
+ border_style="red"
148
+ ))
149
+ except Exception as e:
150
+ console.print(f"[red]Error executing pytest in {project.name}: {e}[/red]")
151
+ success = False
152
+ res_data = {"stdout": str(e), "stderr": ""}
153
+
154
+ results.append({
155
+ "name": project.name,
156
+ "success": success,
157
+ "path": project.path,
158
+ "stdout": res_data["stdout"]
159
+ })
160
+
161
+ if not success and args.fail_fast:
162
+ console.print(f"[red]Fail-fast enabled. Stopping further tests.[/red]")
163
+ break
164
+
165
+ # Final Summary
166
+ console.rule("Pytest Summary")
167
+
168
+ table = Table(show_header=True, header_style="bold")
169
+ table.add_column("Project", style="cyan")
170
+ table.add_column("Status", justify="center")
171
+ table.add_column("Path", style="dim")
172
+
173
+ passed_count = 0
174
+ for res in results:
175
+ status = "[green]PASSED[/green]" if res["success"] else "[red]FAILED[/red]"
176
+ if res["success"]:
177
+ passed_count += 1
178
+ table.add_row(res["name"], status, str(res["path"]))
179
+
180
+ console.print(table)
181
+
182
+ total = len(target_projects)
183
+ run_count = len(results)
184
+ failed_count = run_count - passed_count
185
+
186
+ summary_msg = f"[bold]Ran tests for {run_count}/{total} projects.[/bold] "
187
+ summary_msg += f"[green]{passed_count} passed[/green], [red]{failed_count} failed[/red]."
188
+
189
+ if run_count < total:
190
+ summary_msg += f" [yellow]({total - run_count} skipped due to fail-fast)[/yellow]"
191
+
192
+ console.print(summary_msg)
193
+
194
+ if failed_count > 0:
195
+ sys.exit(1)
@@ -1,3 +1,4 @@
1
+ import argparse
1
2
  import sys
2
3
  from argparse import Namespace, _SubParsersAction
3
4
  from pathlib import Path
@@ -5,9 +6,9 @@ from rich.console import Console
5
6
  from ..core import find_projects, sort_projects_by_dependency
6
7
  from ..release import perform_release
7
8
 
8
- def register(subparsers: _SubParsersAction):
9
+ def register(subparsers: _SubParsersAction, base_parser: argparse.ArgumentParser):
9
10
  """Register the release command."""
10
- release_parser = subparsers.add_parser("release", help="Release a new version of a project")
11
+ release_parser = subparsers.add_parser("release", help="Release a new version of a project", parents=[base_parser])
11
12
  release_parser.add_argument("project_name", help="Name of the project to release (must match pyproject.toml name)")
12
13
  release_parser.add_argument("type", choices=["major", "minor", "patch", "alpha", "beta", "rc", "release"], default="patch", nargs="?", help="Type of version bump")
13
14
  release_parser.add_argument("-y", "--yes", action="store_true", help="Skip confirmation prompts (assume yes)")
@@ -17,7 +18,11 @@ def register(subparsers: _SubParsersAction):
17
18
  def execute(args: Namespace, console: Console):
18
19
  """Execute the release command."""
19
20
  root_path = Path(args.path).resolve()
20
- all_projects = find_projects(root_path)
21
+ all_projects = find_projects(
22
+ root_path,
23
+ recursive=getattr(args, "recursive", False),
24
+ max_depth=getattr(args, "depth", 2)
25
+ )
21
26
 
22
27
  target_projects = []
23
28
  check_changes_flag = False
@@ -0,0 +1,102 @@
1
+ import argparse
2
+ import sys
3
+ from argparse import Namespace, _SubParsersAction
4
+ from pathlib import Path
5
+ from rich.console import Console
6
+ from ..core import find_projects, sort_projects_by_dependency
7
+ from ..runner import run_project_command
8
+
9
+ def register(subparsers: _SubParsersAction, base_parser: argparse.ArgumentParser):
10
+ """Register the run command."""
11
+ run_parser = subparsers.add_parser("run", help="Run a shell command across projects", parents=[base_parser])
12
+ run_parser.add_argument("command_string", help="The shell command to execute")
13
+ run_parser.add_argument("project_name", nargs="?", default="all", help="Name of the project to run on or 'all'")
14
+ run_parser.add_argument("--fail-fast", action="store_true", help="Stop execution if a command fails")
15
+ run_parser.set_defaults(func=execute)
16
+
17
+ def execute(args: Namespace, console: Console):
18
+ """Execute the run command."""
19
+ root_path = Path(args.path).resolve()
20
+ all_projects = find_projects(
21
+ root_path,
22
+ recursive=getattr(args, "recursive", False),
23
+ max_depth=getattr(args, "depth", 2)
24
+ )
25
+ target_projects = []
26
+
27
+ if args.project_name == "all":
28
+ try:
29
+ target_projects = sort_projects_by_dependency(all_projects)
30
+ if getattr(args, "from_root", False):
31
+ target_projects = [p for p in target_projects if p.path.resolve() != root_path.resolve()]
32
+ except ValueError as e:
33
+ console.print(f"[red]Dependency sorting failed: {e}[/red]")
34
+ sys.exit(1)
35
+ else:
36
+ # 1. Try path-based matching (e.g. relm run "ls" packages)
37
+ target_dir = (root_path / args.project_name).resolve()
38
+ if target_dir.exists() and target_dir.is_dir():
39
+ target_projects = [
40
+ p for p in all_projects
41
+ if p.path.resolve() == target_dir or target_dir in p.path.resolve().parents
42
+ ]
43
+ if target_projects:
44
+ try:
45
+ target_projects = sort_projects_by_dependency(target_projects)
46
+ except ValueError as e:
47
+ console.print(f"[red]Dependency sorting failed: {e}[/red]")
48
+ sys.exit(1)
49
+ console.print(f"[bold]Targeting {len(target_projects)} projects in folder: [cyan]{args.project_name}[/cyan][/bold]")
50
+
51
+ # 2. Try exact name match
52
+ if not target_projects:
53
+ target = next((p for p in all_projects if p.name == args.project_name), None)
54
+ if not target:
55
+ console.print(f"[red]Project or folder '{args.project_name}' not found in {root_path}[/red]")
56
+ sys.exit(1)
57
+ target_projects = [target]
58
+
59
+ results = {"success": [], "failed": []}
60
+ use_from_root = getattr(args, "from_root", False)
61
+ cwd = root_path if use_from_root else None
62
+
63
+ if getattr(args, "parallel", False):
64
+ from ..runner import execute_in_parallel
65
+
66
+ def cmd_provider(p):
67
+ return args.command_string
68
+
69
+ results_data = execute_in_parallel(
70
+ target_projects,
71
+ command_provider=cmd_provider,
72
+ max_workers=args.jobs,
73
+ fail_fast=args.fail_fast,
74
+ cwd=cwd
75
+ )
76
+
77
+ for res in results_data:
78
+ if res["success"]:
79
+ results["success"].append(res["name"])
80
+ else:
81
+ results["failed"].append(res["name"])
82
+ console.rule(f"[red]Output for FAILED project: {res['name']}[/red]")
83
+ if res["stdout"]: console.print(res["stdout"])
84
+ if res["stderr"]: console.print(res["stderr"], style="red")
85
+ else:
86
+ for project in target_projects:
87
+ console.rule(f"Running on {project.name}")
88
+ success = run_project_command(cwd or project.path, args.command_string)
89
+ if success:
90
+ results["success"].append(project.name)
91
+ else:
92
+ results["failed"].append(project.name)
93
+ if args.fail_fast:
94
+ console.print(f"[red]Fail-fast enabled. Stopping execution.[/red]")
95
+ break
96
+
97
+ console.rule("Execution Summary")
98
+ if results["success"]:
99
+ console.print(f"[green]Success: {len(results['success'])}[/green] {results['success']}")
100
+ if results["failed"]:
101
+ console.print(f"[red]Failed: {len(results['failed'])}[/red] {results['failed']}")
102
+ sys.exit(1)
@@ -1,3 +1,4 @@
1
+ import argparse
1
2
  import sys
2
3
  from argparse import Namespace, _SubParsersAction
3
4
  from pathlib import Path
@@ -6,26 +7,42 @@ from rich.table import Table
6
7
  from ..core import find_projects
7
8
  from ..git_ops import is_git_clean, get_current_branch
8
9
 
9
- def register(subparsers: _SubParsersAction):
10
+ def register(subparsers: _SubParsersAction, base_parser: argparse.ArgumentParser):
10
11
  """Register the status command."""
11
- status_parser = subparsers.add_parser("status", help="Check git status of projects")
12
+ status_parser = subparsers.add_parser("status", help="Check git status of projects", parents=[base_parser])
12
13
  status_parser.add_argument("project_name", help="Name of the project to check or 'all'", nargs="?", default="all")
13
14
  status_parser.set_defaults(func=execute)
14
15
 
15
16
  def execute(args: Namespace, console: Console):
16
17
  """Execute the status command."""
17
18
  root_path = Path(args.path).resolve()
18
- all_projects = find_projects(root_path)
19
+ all_projects = find_projects(
20
+ root_path,
21
+ recursive=getattr(args, "recursive", False),
22
+ max_depth=getattr(args, "depth", 2)
23
+ )
19
24
  target_projects = []
20
25
 
21
26
  if args.project_name == "all":
22
27
  target_projects = all_projects
23
28
  else:
24
- target = next((p for p in all_projects if p.name == args.project_name), None)
25
- if not target:
26
- console.print(f"[red]Project '{args.project_name}' not found in {root_path}[/red]")
27
- sys.exit(1)
28
- target_projects = [target]
29
+ # 1. Try path-based matching
30
+ target_dir = (root_path / args.project_name).resolve()
31
+ if target_dir.exists() and target_dir.is_dir():
32
+ target_projects = [
33
+ p for p in all_projects
34
+ if p.path.resolve() == target_dir or target_dir in p.path.resolve().parents
35
+ ]
36
+ if target_projects:
37
+ console.print(f"[bold]Targeting {len(target_projects)} projects in folder: [cyan]{args.project_name}[/cyan][/bold]")
38
+
39
+ # 2. Try exact name match
40
+ if not target_projects:
41
+ target = next((p for p in all_projects if p.name == args.project_name), None)
42
+ if not target:
43
+ console.print(f"[red]Project or folder '{args.project_name}' not found in {root_path}[/red]")
44
+ sys.exit(1)
45
+ target_projects = [target]
29
46
 
30
47
  table = Table(title=f"Git Status for {len(target_projects)} Projects")
31
48
  table.add_column("Project", style="cyan", no_wrap=True)