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.
- {relm-3.0.2 → relm-5.0.0}/PKG-INFO +2 -1
- {relm-3.0.2 → relm-5.0.0}/pyproject.toml +3 -2
- {relm-3.0.2 → relm-5.0.0}/src/relm/__init__.py +1 -1
- {relm-3.0.2 → relm-5.0.0}/src/relm/commands/base.py +1 -0
- {relm-3.0.2 → relm-5.0.0}/src/relm/commands/clean_command.py +27 -8
- {relm-3.0.2 → relm-5.0.0}/src/relm/commands/create_command.py +4 -5
- {relm-3.0.2 → relm-5.0.0}/src/relm/commands/gc_command.py +4 -3
- relm-5.0.0/src/relm/commands/install_command.py +97 -0
- {relm-3.0.2 → relm-5.0.0}/src/relm/commands/list_command.py +8 -3
- relm-5.0.0/src/relm/commands/pytest_command.py +195 -0
- {relm-3.0.2 → relm-5.0.0}/src/relm/commands/release_command.py +8 -3
- relm-5.0.0/src/relm/commands/run_command.py +102 -0
- {relm-3.0.2 → relm-5.0.0}/src/relm/commands/status_command.py +25 -8
- {relm-3.0.2 → relm-5.0.0}/src/relm/commands/verify_command.py +27 -8
- {relm-3.0.2 → relm-5.0.0}/src/relm/core.py +39 -26
- {relm-3.0.2 → relm-5.0.0}/src/relm/main.py +64 -15
- relm-5.0.0/src/relm/runner.py +172 -0
- {relm-3.0.2 → relm-5.0.0}/src/relm.egg-info/PKG-INFO +2 -1
- {relm-3.0.2 → relm-5.0.0}/src/relm.egg-info/SOURCES.txt +2 -0
- {relm-3.0.2 → relm-5.0.0}/src/relm.egg-info/requires.txt +1 -0
- {relm-3.0.2 → relm-5.0.0}/tests/test_core.py +1 -1
- {relm-3.0.2 → relm-5.0.0}/tests/test_custom_commit_message.py +1 -1
- {relm-3.0.2 → relm-5.0.0}/tests/test_dependency_sorting.py +21 -4
- {relm-3.0.2 → relm-5.0.0}/tests/test_execution_order.py +10 -25
- {relm-3.0.2 → relm-5.0.0}/tests/test_gc.py +10 -3
- {relm-3.0.2 → relm-5.0.0}/tests/test_main.py +126 -165
- relm-5.0.0/tests/test_pytest_command.py +80 -0
- {relm-3.0.2 → relm-5.0.0}/tests/test_release.py +11 -11
- relm-5.0.0/tests/test_runner.py +97 -0
- {relm-3.0.2 → relm-5.0.0}/tests/test_verify.py +1 -1
- {relm-3.0.2 → relm-5.0.0}/tests/test_verify_command.py +3 -3
- relm-3.0.2/src/relm/commands/install_command.py +0 -50
- relm-3.0.2/src/relm/commands/run_command.py +0 -55
- relm-3.0.2/src/relm/runner.py +0 -22
- relm-3.0.2/tests/test_runner.py +0 -28
- {relm-3.0.2 → relm-5.0.0}/README.md +0 -0
- {relm-3.0.2 → relm-5.0.0}/setup.cfg +0 -0
- {relm-3.0.2 → relm-5.0.0}/src/relm/banner.py +0 -0
- {relm-3.0.2 → relm-5.0.0}/src/relm/changelog.py +0 -0
- {relm-3.0.2 → relm-5.0.0}/src/relm/clean.py +0 -0
- {relm-3.0.2 → relm-5.0.0}/src/relm/commands/__init__.py +0 -0
- {relm-3.0.2 → relm-5.0.0}/src/relm/config.py +0 -0
- {relm-3.0.2 → relm-5.0.0}/src/relm/gc.py +0 -0
- {relm-3.0.2 → relm-5.0.0}/src/relm/git_ops.py +0 -0
- {relm-3.0.2 → relm-5.0.0}/src/relm/install.py +0 -0
- {relm-3.0.2 → relm-5.0.0}/src/relm/release.py +0 -0
- {relm-3.0.2 → relm-5.0.0}/src/relm/verify.py +0 -0
- {relm-3.0.2 → relm-5.0.0}/src/relm/versioning.py +0 -0
- {relm-3.0.2 → relm-5.0.0}/src/relm.egg-info/dependency_links.txt +0 -0
- {relm-3.0.2 → relm-5.0.0}/src/relm.egg-info/entry_points.txt +0 -0
- {relm-3.0.2 → relm-5.0.0}/src/relm.egg-info/top_level.txt +0 -0
- {relm-3.0.2 → relm-5.0.0}/tests/test_banner.py +0 -0
- {relm-3.0.2 → relm-5.0.0}/tests/test_changed_since.py +0 -0
- {relm-3.0.2 → relm-5.0.0}/tests/test_changelog.py +0 -0
- {relm-3.0.2 → relm-5.0.0}/tests/test_clean.py +0 -0
- {relm-3.0.2 → relm-5.0.0}/tests/test_config.py +0 -0
- {relm-3.0.2 → relm-5.0.0}/tests/test_create_command.py +0 -0
- {relm-3.0.2 → relm-5.0.0}/tests/test_git_ops.py +0 -0
- {relm-3.0.2 → relm-5.0.0}/tests/test_install.py +0 -0
- {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
|
+
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 = "
|
|
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,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(
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
9
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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)
|