relm 0.1.1__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.
- relm/__init__.py +1 -0
- relm/core.py +75 -0
- relm/git_ops.py +85 -0
- relm/main.py +108 -0
- relm/release.py +166 -0
- relm/versioning.py +111 -0
- relm-0.1.1.dist-info/METADATA +58 -0
- relm-0.1.1.dist-info/RECORD +11 -0
- relm-0.1.1.dist-info/WHEEL +5 -0
- relm-0.1.1.dist-info/entry_points.txt +2 -0
- relm-0.1.1.dist-info/top_level.txt +1 -0
relm/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.1"
|
relm/core.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import tomllib
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import List, Optional
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class Project:
|
|
9
|
+
name: str
|
|
10
|
+
version: str
|
|
11
|
+
path: Path
|
|
12
|
+
description: Optional[str] = None
|
|
13
|
+
|
|
14
|
+
@property
|
|
15
|
+
def pyproject_path(self) -> Path:
|
|
16
|
+
return self.path / "pyproject.toml"
|
|
17
|
+
|
|
18
|
+
def __str__(self) -> str:
|
|
19
|
+
return f"{self.name} (v{self.version}) - {self.path}"
|
|
20
|
+
|
|
21
|
+
def load_project(path: Path) -> Optional[Project]:
|
|
22
|
+
"""
|
|
23
|
+
Loads a project from a directory if it contains a valid pyproject.toml.
|
|
24
|
+
"""
|
|
25
|
+
pyproject_file = path / "pyproject.toml"
|
|
26
|
+
if not pyproject_file.exists():
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
with open(pyproject_file, "rb") as f:
|
|
31
|
+
data = tomllib.load(f)
|
|
32
|
+
|
|
33
|
+
project_data = data.get("project", {})
|
|
34
|
+
name = project_data.get("name")
|
|
35
|
+
version = project_data.get("version")
|
|
36
|
+
description = project_data.get("description")
|
|
37
|
+
|
|
38
|
+
if name and version:
|
|
39
|
+
return Project(
|
|
40
|
+
name=name,
|
|
41
|
+
version=version,
|
|
42
|
+
path=path,
|
|
43
|
+
description=description
|
|
44
|
+
)
|
|
45
|
+
except Exception as e:
|
|
46
|
+
# We might want to log this error in a real app
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
def find_projects(root_path: Path) -> List[Project]:
|
|
52
|
+
"""
|
|
53
|
+
Scans the immediate subdirectories of root_path for valid projects.
|
|
54
|
+
"""
|
|
55
|
+
projects = []
|
|
56
|
+
if not root_path.exists() or not root_path.is_dir():
|
|
57
|
+
return projects
|
|
58
|
+
|
|
59
|
+
# Check if the root itself is a project
|
|
60
|
+
root_project = load_project(root_path)
|
|
61
|
+
if root_project:
|
|
62
|
+
projects.append(root_project)
|
|
63
|
+
|
|
64
|
+
# Check subdirectories
|
|
65
|
+
for item in root_path.iterdir():
|
|
66
|
+
if item.is_dir() and item != root_path:
|
|
67
|
+
# Avoid recursing too deep or checking hidden dirs for now
|
|
68
|
+
if item.name.startswith("."):
|
|
69
|
+
continue
|
|
70
|
+
|
|
71
|
+
project = load_project(item)
|
|
72
|
+
if project:
|
|
73
|
+
projects.append(project)
|
|
74
|
+
|
|
75
|
+
return sorted(projects, key=lambda p: p.name)
|
relm/git_ops.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import List
|
|
4
|
+
|
|
5
|
+
def run_git_command(args: List[str], cwd: Path) -> str:
|
|
6
|
+
"""
|
|
7
|
+
Runs a git command in the specified directory.
|
|
8
|
+
Raises subprocess.CalledProcessError on failure.
|
|
9
|
+
"""
|
|
10
|
+
result = subprocess.run(
|
|
11
|
+
["git"] + args,
|
|
12
|
+
cwd=cwd,
|
|
13
|
+
capture_output=True,
|
|
14
|
+
text=True,
|
|
15
|
+
check=True
|
|
16
|
+
)
|
|
17
|
+
return result.stdout.strip()
|
|
18
|
+
|
|
19
|
+
def is_git_clean(path: Path) -> bool:
|
|
20
|
+
"""
|
|
21
|
+
Checks if the git repository is clean (no uncommitted changes).
|
|
22
|
+
"""
|
|
23
|
+
try:
|
|
24
|
+
# update-index -q --refresh is good practice before diff-index
|
|
25
|
+
subprocess.run(["git", "update-index", "-q", "--refresh"], cwd=path, check=False)
|
|
26
|
+
# check for unstaged changes
|
|
27
|
+
subprocess.run(["git", "diff-files", "--quiet"], cwd=path, check=True)
|
|
28
|
+
# check for staged changes
|
|
29
|
+
subprocess.run(["git", "diff-index", "--cached", "--quiet", "HEAD", "--"], cwd=path, check=True)
|
|
30
|
+
return True
|
|
31
|
+
except subprocess.CalledProcessError:
|
|
32
|
+
return False
|
|
33
|
+
|
|
34
|
+
def git_add(path: Path, files: List[str]):
|
|
35
|
+
run_git_command(["add"] + files, cwd=path)
|
|
36
|
+
|
|
37
|
+
def git_commit(path: Path, message: str):
|
|
38
|
+
run_git_command(["commit", "-m", message], cwd=path)
|
|
39
|
+
|
|
40
|
+
def git_tag(path: Path, tag_name: str, message: str = None):
|
|
41
|
+
args = ["tag", tag_name]
|
|
42
|
+
if message:
|
|
43
|
+
args.extend(["-m", message])
|
|
44
|
+
run_git_command(args, cwd=path)
|
|
45
|
+
|
|
46
|
+
def git_push(path: Path):
|
|
47
|
+
run_git_command(["push"], cwd=path)
|
|
48
|
+
run_git_command(["push", "--tags"], cwd=path)
|
|
49
|
+
|
|
50
|
+
def git_fetch_tags(path: Path):
|
|
51
|
+
"""
|
|
52
|
+
Fetches tags from the remote to ensure local knowledge is up to date.
|
|
53
|
+
"""
|
|
54
|
+
run_git_command(["fetch", "--tags"], cwd=path)
|
|
55
|
+
|
|
56
|
+
def git_tag_exists(path: Path, tag_name: str) -> bool:
|
|
57
|
+
"""
|
|
58
|
+
Checks if a specific tag exists locally.
|
|
59
|
+
"""
|
|
60
|
+
try:
|
|
61
|
+
# git rev-parse -q --verify "refs/tags/v1.0.0"
|
|
62
|
+
run_git_command(["rev-parse", "-q", "--verify", f"refs/tags/{tag_name}"], cwd=path)
|
|
63
|
+
return True
|
|
64
|
+
except subprocess.CalledProcessError:
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
def git_has_changes(path: Path, tag_name: str) -> bool:
|
|
68
|
+
"""
|
|
69
|
+
Checks if there are changes between the given tag and HEAD.
|
|
70
|
+
Returns True if changes exist, False otherwise.
|
|
71
|
+
"""
|
|
72
|
+
try:
|
|
73
|
+
# git diff --quiet tag_name HEAD -- .
|
|
74
|
+
# If exit code is 1, there are changes. If 0, no changes.
|
|
75
|
+
# We use check=True which raises error on non-zero... wait.
|
|
76
|
+
# diff --quiet returns 1 if diffs found. So we want to catch the error.
|
|
77
|
+
|
|
78
|
+
subprocess.run(
|
|
79
|
+
["git", "diff", "--quiet", tag_name, "HEAD", "--", "."],
|
|
80
|
+
cwd=path,
|
|
81
|
+
check=True
|
|
82
|
+
)
|
|
83
|
+
return False # Exit code 0 means NO differences
|
|
84
|
+
except subprocess.CalledProcessError:
|
|
85
|
+
return True # Exit code 1 means differences exist (or error, but usually diffs)
|
relm/main.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.panel import Panel
|
|
6
|
+
from rich.table import Table
|
|
7
|
+
from .core import find_projects
|
|
8
|
+
from .release import perform_release
|
|
9
|
+
|
|
10
|
+
console = Console()
|
|
11
|
+
|
|
12
|
+
def list_projects(path: Path):
|
|
13
|
+
projects = find_projects(path)
|
|
14
|
+
if not projects:
|
|
15
|
+
console.print("[yellow]No projects found in this directory.[/yellow]")
|
|
16
|
+
return
|
|
17
|
+
|
|
18
|
+
table = Table(title=f"Found {len(projects)} Projects")
|
|
19
|
+
table.add_column("Name", style="cyan", no_wrap=True)
|
|
20
|
+
table.add_column("Version", style="magenta")
|
|
21
|
+
table.add_column("Path", style="green")
|
|
22
|
+
table.add_column("Description")
|
|
23
|
+
|
|
24
|
+
for project in projects:
|
|
25
|
+
table.add_row(
|
|
26
|
+
project.name,
|
|
27
|
+
project.version,
|
|
28
|
+
str(project.path),
|
|
29
|
+
project.description or ""
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
console.print(table)
|
|
33
|
+
|
|
34
|
+
def main():
|
|
35
|
+
parser = argparse.ArgumentParser(
|
|
36
|
+
description="Manage releases and versioning for local Python projects."
|
|
37
|
+
)
|
|
38
|
+
parser.add_argument(
|
|
39
|
+
"--path",
|
|
40
|
+
default=".",
|
|
41
|
+
help="Path to the root directory containing projects (default: current dir)."
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
45
|
+
|
|
46
|
+
# List command
|
|
47
|
+
list_parser = subparsers.add_parser("list", help="List all discovered projects")
|
|
48
|
+
|
|
49
|
+
# Release command
|
|
50
|
+
release_parser = subparsers.add_parser("release", help="Release a new version of a project")
|
|
51
|
+
release_parser.add_argument("project_name", help="Name of the project to release (must match pyproject.toml name)")
|
|
52
|
+
release_parser.add_argument("type", choices=["major", "minor", "patch"], default="patch", nargs="?", help="Type of version bump")
|
|
53
|
+
release_parser.add_argument("-y", "--yes", action="store_true", help="Skip confirmation prompts (assume yes)")
|
|
54
|
+
|
|
55
|
+
args = parser.parse_args()
|
|
56
|
+
root_path = Path(args.path).resolve()
|
|
57
|
+
|
|
58
|
+
if args.command == "list":
|
|
59
|
+
list_projects(root_path)
|
|
60
|
+
|
|
61
|
+
elif args.command == "release":
|
|
62
|
+
all_projects = find_projects(root_path)
|
|
63
|
+
|
|
64
|
+
target_projects = []
|
|
65
|
+
check_changes_flag = False
|
|
66
|
+
|
|
67
|
+
if args.project_name == "all":
|
|
68
|
+
target_projects = all_projects
|
|
69
|
+
check_changes_flag = True
|
|
70
|
+
console.print(f"[bold]Running Bulk Release on {len(target_projects)} projects...[/bold]")
|
|
71
|
+
else:
|
|
72
|
+
# Find single project
|
|
73
|
+
target = next((p for p in all_projects if p.name == args.project_name), None)
|
|
74
|
+
if not target:
|
|
75
|
+
console.print(f"[red]Project '{args.project_name}' not found in {root_path}[/red]")
|
|
76
|
+
sys.exit(1)
|
|
77
|
+
target_projects = [target]
|
|
78
|
+
|
|
79
|
+
# Execute releases
|
|
80
|
+
results = {"released": [], "skipped": [], "failed": []}
|
|
81
|
+
|
|
82
|
+
for project in target_projects:
|
|
83
|
+
# Skip template/meta repos if needed, but git_has_changes handles most logic
|
|
84
|
+
try:
|
|
85
|
+
success = perform_release(
|
|
86
|
+
project,
|
|
87
|
+
args.type,
|
|
88
|
+
yes_mode=args.yes,
|
|
89
|
+
check_changes=check_changes_flag
|
|
90
|
+
)
|
|
91
|
+
if success:
|
|
92
|
+
results["released"].append(project.name)
|
|
93
|
+
else:
|
|
94
|
+
results["skipped"].append(project.name)
|
|
95
|
+
except Exception as e:
|
|
96
|
+
console.print(f"[red]Critical error releasing {project.name}: {e}[/red]")
|
|
97
|
+
results["failed"].append(project.name)
|
|
98
|
+
|
|
99
|
+
# Summary
|
|
100
|
+
if args.project_name == "all":
|
|
101
|
+
console.rule("Bulk Release Summary")
|
|
102
|
+
console.print(f"[green]Released: {len(results['released'])}[/green] {results['released']}")
|
|
103
|
+
console.print(f"[yellow]Skipped: {len(results['skipped'])}[/yellow]")
|
|
104
|
+
if results["failed"]:
|
|
105
|
+
console.print(f"[red]Failed: {len(results['failed'])}[/red] {results['failed']}")
|
|
106
|
+
|
|
107
|
+
if __name__ == "__main__":
|
|
108
|
+
main()
|
relm/release.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import subprocess
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.prompt import Confirm, Prompt
|
|
8
|
+
|
|
9
|
+
from .core import Project
|
|
10
|
+
from .versioning import bump_version_string, update_file_content, update_version_tests
|
|
11
|
+
from .git_ops import is_git_clean, git_add, git_commit, git_tag, git_push, git_fetch_tags, git_tag_exists, git_has_changes
|
|
12
|
+
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
def run_tests(project_path: Path) -> bool:
|
|
16
|
+
"""
|
|
17
|
+
Runs pytest in the project directory. Returns True if successful.
|
|
18
|
+
"""
|
|
19
|
+
# Check if pytest is installed/available?
|
|
20
|
+
# We assume the user has the env set up correctly or it's in path.
|
|
21
|
+
console.print("[bold blue]Running tests...[/bold blue]")
|
|
22
|
+
try:
|
|
23
|
+
# We use sys.executable -m pytest to use the same env
|
|
24
|
+
subprocess.run(
|
|
25
|
+
[sys.executable, "-m", "pytest"],
|
|
26
|
+
cwd=project_path,
|
|
27
|
+
check=True
|
|
28
|
+
)
|
|
29
|
+
return True
|
|
30
|
+
except subprocess.CalledProcessError:
|
|
31
|
+
return False
|
|
32
|
+
except FileNotFoundError:
|
|
33
|
+
console.print("[yellow]pytest not found. Skipping tests.[/yellow]")
|
|
34
|
+
return True # Treat missing pytest as "pass" (or warn?) - safer to warn and pass for now
|
|
35
|
+
|
|
36
|
+
def revert_changes(project_path: Path):
|
|
37
|
+
"""
|
|
38
|
+
Reverts local changes using git checkout.
|
|
39
|
+
"""
|
|
40
|
+
console.print("[yellow]Reverting changes...[/yellow]")
|
|
41
|
+
try:
|
|
42
|
+
subprocess.run(["git", "checkout", "."], cwd=project_path, check=True)
|
|
43
|
+
except Exception as e:
|
|
44
|
+
console.print(f"[red]Failed to revert changes: {e}[/red]")
|
|
45
|
+
|
|
46
|
+
def perform_release(project: Project, part: Literal['major', 'minor', 'patch'], yes_mode: bool = False, check_changes: bool = False) -> bool:
|
|
47
|
+
console.rule(f"Releasing {project.name}")
|
|
48
|
+
|
|
49
|
+
# 0. Fetch Tags & Check State
|
|
50
|
+
console.print("[dim]Fetching remote tags...[/dim]")
|
|
51
|
+
try:
|
|
52
|
+
git_fetch_tags(project.path)
|
|
53
|
+
except Exception:
|
|
54
|
+
console.print("[yellow]Warning: Could not fetch remote tags. Proceeding with local info.[/yellow]")
|
|
55
|
+
|
|
56
|
+
current_version_tag = f"v{project.version}"
|
|
57
|
+
is_already_tagged = git_tag_exists(project.path, current_version_tag)
|
|
58
|
+
|
|
59
|
+
# Smart Skip Logic
|
|
60
|
+
if check_changes and is_already_tagged:
|
|
61
|
+
if not git_has_changes(project.path, current_version_tag):
|
|
62
|
+
console.print(f"[dim]No changes detected since {current_version_tag}. Skipping.[/dim]")
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
should_bump = True
|
|
66
|
+
target_version = project.version # Default to current if not bumping
|
|
67
|
+
|
|
68
|
+
if not is_already_tagged:
|
|
69
|
+
console.print(f"[yellow]Notice: Current version [bold]{project.version}[/bold] is NOT tagged locally.[/yellow]")
|
|
70
|
+
# If yes_mode is on, we default to RETRY (True), i.e. skip bump
|
|
71
|
+
if yes_mode or Confirm.ask(f"Retry release for v{project.version} (skip bump)?", default=True):
|
|
72
|
+
should_bump = False
|
|
73
|
+
target_version = project.version
|
|
74
|
+
|
|
75
|
+
# 1. Check Git Cleanliness
|
|
76
|
+
if not is_git_clean(project.path):
|
|
77
|
+
console.print("[red]Error: Git repository is not clean. Commit or stash changes first.[/red]")
|
|
78
|
+
# We could potentially auto-commit if yes_mode is on, but that's risky.
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
# 2. Calculate New Version (if bumping)
|
|
82
|
+
if should_bump:
|
|
83
|
+
try:
|
|
84
|
+
target_version = bump_version_string(project.version, part)
|
|
85
|
+
console.print(f"Current version: [cyan]{project.version}[/cyan]")
|
|
86
|
+
console.print(f"New version: [green]{target_version}[/green]")
|
|
87
|
+
except ValueError as e:
|
|
88
|
+
console.print(f"[red]Error parsing version: {e}[/red]")
|
|
89
|
+
return False
|
|
90
|
+
else:
|
|
91
|
+
console.print(f"Releasing existing version: [green]{target_version}[/green]")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
if not yes_mode and not Confirm.ask("Proceed with release?"):
|
|
95
|
+
console.print("[yellow]Release cancelled.[/yellow]")
|
|
96
|
+
return False
|
|
97
|
+
|
|
98
|
+
# 3. Update Files (Only if bumping)
|
|
99
|
+
if should_bump:
|
|
100
|
+
console.print("[bold blue]Updating files...[/bold blue]")
|
|
101
|
+
files_updated = []
|
|
102
|
+
|
|
103
|
+
# Update pyproject.toml
|
|
104
|
+
if update_file_content(project.pyproject_path, project.version, target_version):
|
|
105
|
+
files_updated.append("pyproject.toml")
|
|
106
|
+
|
|
107
|
+
# Update __init__.py
|
|
108
|
+
# Try src/{name}/__init__.py first
|
|
109
|
+
init_path = project.path / "src" / project.name.replace("-", "_") / "__init__.py"
|
|
110
|
+
if not init_path.exists():
|
|
111
|
+
# Try {name}/__init__.py
|
|
112
|
+
init_path = project.path / project.name.replace("-", "_") / "__init__.py"
|
|
113
|
+
|
|
114
|
+
if init_path.exists():
|
|
115
|
+
if update_file_content(init_path, project.version, target_version):
|
|
116
|
+
files_updated.append(str(init_path.relative_to(project.path)))
|
|
117
|
+
|
|
118
|
+
# Update Tests (Hardcoded versions)
|
|
119
|
+
updated_tests = update_version_tests(project.path, project.version, target_version)
|
|
120
|
+
if updated_tests:
|
|
121
|
+
console.print(f"[green]Automatically updated version assertions in {len(updated_tests)} test files.[/green]")
|
|
122
|
+
files_updated.extend(updated_tests)
|
|
123
|
+
|
|
124
|
+
if not files_updated:
|
|
125
|
+
console.print("[red]No files were updated! Check version strings.[/red]")
|
|
126
|
+
return False
|
|
127
|
+
|
|
128
|
+
# 4. Run Tests Locally
|
|
129
|
+
if not run_tests(project.path):
|
|
130
|
+
console.print("[bold red]Tests failed! Aborting release.[/bold red]")
|
|
131
|
+
if yes_mode or Confirm.ask("Revert changes to files?", default=True):
|
|
132
|
+
revert_changes(project.path)
|
|
133
|
+
return False
|
|
134
|
+
|
|
135
|
+
# 5. Git Commit
|
|
136
|
+
console.print("[bold blue]Committing...[/bold blue]")
|
|
137
|
+
try:
|
|
138
|
+
git_add(project.path, files_updated)
|
|
139
|
+
git_commit(project.path, f"release: bump version to {target_version}")
|
|
140
|
+
except Exception as e:
|
|
141
|
+
console.print(f"[red]Git commit error: {e}[/red]")
|
|
142
|
+
return False
|
|
143
|
+
|
|
144
|
+
# 6. Tag (Always needed)
|
|
145
|
+
# We double check if tag exists now, just in case
|
|
146
|
+
if git_tag_exists(project.path, f"v{target_version}"):
|
|
147
|
+
console.print(f"[yellow]Tag v{target_version} already exists locally. Skipping creation.[/yellow]")
|
|
148
|
+
else:
|
|
149
|
+
console.print(f"[bold blue]Tagging v{target_version}...[/bold blue]")
|
|
150
|
+
try:
|
|
151
|
+
git_tag(project.path, f"v{target_version}", f"Release v{target_version}")
|
|
152
|
+
except Exception as e:
|
|
153
|
+
console.print(f"[red]Git tag error: {e}[/red]")
|
|
154
|
+
return False
|
|
155
|
+
|
|
156
|
+
# 7. Push
|
|
157
|
+
if yes_mode or Confirm.ask("Push changes to remote? (This will trigger the GitHub Action release)"):
|
|
158
|
+
try:
|
|
159
|
+
git_push(project.path)
|
|
160
|
+
except Exception as e:
|
|
161
|
+
console.print(f"[red]Push error: {e}[/red]")
|
|
162
|
+
return False
|
|
163
|
+
|
|
164
|
+
console.print(f"[bold green]Successfully tagged and pushed {project.name} v{target_version}![/bold green]")
|
|
165
|
+
console.print("[dim]The GitHub Action workflow should now handle the PyPI release.[/dim]")
|
|
166
|
+
return True
|
relm/versioning.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Tuple, List
|
|
4
|
+
|
|
5
|
+
def parse_version(version: str) -> Tuple[int, int, int]:
|
|
6
|
+
"""
|
|
7
|
+
Parses a version string 'x.y.z' into a tuple of integers.
|
|
8
|
+
"""
|
|
9
|
+
try:
|
|
10
|
+
parts = version.split('.')
|
|
11
|
+
if len(parts) < 3:
|
|
12
|
+
# Handle cases like '0.1' -> '0.1.0'
|
|
13
|
+
parts.extend(['0'] * (3 - len(parts)))
|
|
14
|
+
return int(parts[0]), int(parts[1]), int(parts[2])
|
|
15
|
+
except ValueError:
|
|
16
|
+
raise ValueError(f"Invalid version format: {version}")
|
|
17
|
+
|
|
18
|
+
def bump_version_string(version: str, part: str) -> str:
|
|
19
|
+
"""
|
|
20
|
+
Bumps the version string based on the part ('major', 'minor', 'patch').
|
|
21
|
+
"""
|
|
22
|
+
major, minor, patch = parse_version(version)
|
|
23
|
+
|
|
24
|
+
if part == 'major':
|
|
25
|
+
major += 1
|
|
26
|
+
minor = 0
|
|
27
|
+
patch = 0
|
|
28
|
+
elif part == 'minor':
|
|
29
|
+
minor += 1
|
|
30
|
+
patch = 0
|
|
31
|
+
elif part == 'patch':
|
|
32
|
+
patch += 1
|
|
33
|
+
else:
|
|
34
|
+
raise ValueError(f"Invalid bump part: {part}")
|
|
35
|
+
|
|
36
|
+
return f"{major}.{minor}.{patch}"
|
|
37
|
+
|
|
38
|
+
def update_file_content(path: Path, old_version: str, new_version: str) -> bool:
|
|
39
|
+
"""
|
|
40
|
+
Replaces occurrences of old_version with new_version in the file at path.
|
|
41
|
+
Returns True if changes were made.
|
|
42
|
+
"""
|
|
43
|
+
if not path.exists():
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
content = path.read_text(encoding="utf-8")
|
|
48
|
+
|
|
49
|
+
new_content = content
|
|
50
|
+
|
|
51
|
+
# Pattern for pyproject.toml: version = "1.0.0"
|
|
52
|
+
toml_pattern = re.compile(rf'version\s*=\s*"{re.escape(old_version)}"')
|
|
53
|
+
if toml_pattern.search(content):
|
|
54
|
+
new_content = toml_pattern.sub(f'version = "{new_version}"', new_content)
|
|
55
|
+
|
|
56
|
+
# Pattern for __init__.py: __version__ = "1.0.0"
|
|
57
|
+
init_pattern = re.compile(rf'__version__\s*=\s*"{re.escape(old_version)}"')
|
|
58
|
+
if init_pattern.search(content):
|
|
59
|
+
new_content = init_pattern.sub(f'__version__ = "{new_version}"', new_content)
|
|
60
|
+
|
|
61
|
+
if new_content != content:
|
|
62
|
+
path.write_text(new_content, encoding="utf-8")
|
|
63
|
+
return True
|
|
64
|
+
|
|
65
|
+
except Exception as e:
|
|
66
|
+
print(f"Error updating {path}: {e}")
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
def update_version_tests(project_path: Path, old_version: str, new_version: str) -> List[str]:
|
|
72
|
+
"""
|
|
73
|
+
Scans the 'tests' directory for files containing the old version string
|
|
74
|
+
in an assertion context and updates them. returns list of updated files.
|
|
75
|
+
"""
|
|
76
|
+
updated_files = []
|
|
77
|
+
tests_dir = project_path / "tests"
|
|
78
|
+
if not tests_dir.exists():
|
|
79
|
+
return updated_files
|
|
80
|
+
|
|
81
|
+
# Regex to match: assert ... == "1.2.3" or assert "1.2.3" == ...
|
|
82
|
+
# We are generous with whitespace
|
|
83
|
+
# This might need refinement but covers standard cases.
|
|
84
|
+
# We actually just look for the literal string "1.2.3" inside test files
|
|
85
|
+
# because replacing it strictly in context is safer than broad replace,
|
|
86
|
+
# but parsing python AST is too heavy.
|
|
87
|
+
# Let's look for the exact string "old_version" to be safe,
|
|
88
|
+
# but only if it looks like a version check?
|
|
89
|
+
# actually, if a test file has the version string "1.0.0", it is 99% likely the version check.
|
|
90
|
+
|
|
91
|
+
for test_file in tests_dir.rglob("*.py"):
|
|
92
|
+
try:
|
|
93
|
+
content = test_file.read_text(encoding="utf-8")
|
|
94
|
+
if old_version in content:
|
|
95
|
+
# Check if it's surrounded by quotes to avoid partial matches
|
|
96
|
+
# e.g. matching "1.0" in "1.0.0" (though old_version is usually full)
|
|
97
|
+
|
|
98
|
+
# Simple string replace for "old_version" -> "new_version"
|
|
99
|
+
# We use quotes to ensure we match string literals
|
|
100
|
+
if f'"{old_version}"' in content:
|
|
101
|
+
new_content = content.replace(f'"{old_version}"', f'"{new_version}"')
|
|
102
|
+
test_file.write_text(new_content, encoding="utf-8")
|
|
103
|
+
updated_files.append(str(test_file.relative_to(project_path)))
|
|
104
|
+
elif f"'{old_version}'" in content:
|
|
105
|
+
new_content = content.replace(f"'{old_version}'", f"'{new_version}'")
|
|
106
|
+
test_file.write_text(new_content, encoding="utf-8")
|
|
107
|
+
updated_files.append(str(test_file.relative_to(project_path)))
|
|
108
|
+
except Exception:
|
|
109
|
+
pass
|
|
110
|
+
|
|
111
|
+
return updated_files
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: relm
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: A unified CLI tool to manage versioning, git, and PyPI releases for multiple projects.
|
|
5
|
+
Author-email: dhruv13x <dhruv13x@gmail.com>
|
|
6
|
+
License: MIT © dhruv13x
|
|
7
|
+
Project-URL: Homepage, https://github.com/dhruv13x/relm
|
|
8
|
+
Project-URL: Source, https://github.com/dhruv13x/relm
|
|
9
|
+
Project-URL: Issues, https://github.com/dhruv13x/relm/issues
|
|
10
|
+
Keywords: cli,release,versioning,automation,pypi,git
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Environment :: Console
|
|
15
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
16
|
+
Requires-Python: >=3.8
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
Requires-Dist: tomli; python_version < "3.11"
|
|
19
|
+
Requires-Dist: rich>=13.0.0
|
|
20
|
+
Requires-Dist: rich-argparse>=1.0.0
|
|
21
|
+
Requires-Dist: build>=1.0.0
|
|
22
|
+
Requires-Dist: twine>=4.0.0
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
25
|
+
Requires-Dist: pytest-cov>=5.0.0; extra == "dev"
|
|
26
|
+
Requires-Dist: pytest-timeout>=2.2.0; extra == "dev"
|
|
27
|
+
Requires-Dist: pytest-json-report>=1.5.0; extra == "dev"
|
|
28
|
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
|
|
29
|
+
Requires-Dist: pytest-mock>=3.10.0; extra == "dev"
|
|
30
|
+
Requires-Dist: pyfakefs>=5.0.0; extra == "dev"
|
|
31
|
+
Requires-Dist: ruff>=0.6.0; extra == "dev"
|
|
32
|
+
Requires-Dist: black>=24.3.0; extra == "dev"
|
|
33
|
+
Requires-Dist: mypy>=1.11.0; extra == "dev"
|
|
34
|
+
Requires-Dist: PyYAML>=6.0; extra == "dev"
|
|
35
|
+
Requires-Dist: types-PyYAML>=6.0; extra == "dev"
|
|
36
|
+
|
|
37
|
+
# Repo Manager
|
|
38
|
+
|
|
39
|
+
A unified CLI tool to automate versioning, git operations, and PyPI releases for the dhruv13x tool suite.
|
|
40
|
+
|
|
41
|
+
## Features
|
|
42
|
+
|
|
43
|
+
- **Project Discovery**: Automatically detects Python projects with `pyproject.toml`.
|
|
44
|
+
- **Smart Versioning**: Bumps versions (major, minor, patch) in `pyproject.toml` and `__init__.py`.
|
|
45
|
+
- **Git Automation**: Stages, commits, and pushes release changes.
|
|
46
|
+
- **PyPI Release**: Builds and uploads packages to PyPI.
|
|
47
|
+
|
|
48
|
+
## Installation
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install -e .
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Usage
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
relm --help
|
|
58
|
+
```
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
relm/__init__.py,sha256=rnObPjuBcEStqSO0S6gsdS_ot8ITOQjVj_-P1LUUYpg,22
|
|
2
|
+
relm/core.py,sha256=EqsB9DaUD987OZXOQ2bZXoW5yMolkJ27ExNhtpl7X_Q,2080
|
|
3
|
+
relm/git_ops.py,sha256=bL0k2fiW7IOBUtpY6lb_wBaCkmUUxRZ11pEnnoKqoa4,2871
|
|
4
|
+
relm/main.py,sha256=P78DY4QSABudY16CkYoPrWbSNA0YWcXHNTZ4SvkbedM,3973
|
|
5
|
+
relm/release.py,sha256=xuJUkaSVsazQguB1tBVfEROCzOJInVXPQSzEvCzn_n8,6947
|
|
6
|
+
relm/versioning.py,sha256=D4_MoPHd31mPGe0EBeHmYEOUDMYMh3ySBI2CrT3EZV0,4337
|
|
7
|
+
relm-0.1.1.dist-info/METADATA,sha256=uFYKrDxqOb0qDaa8OzXQcEUpCGkc9WGGH1Ut8gEtYqI,2042
|
|
8
|
+
relm-0.1.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
9
|
+
relm-0.1.1.dist-info/entry_points.txt,sha256=mt1ZiWqOl3MbE8ZrRhryrxE1owGgNH-v84px6w23pmg,40
|
|
10
|
+
relm-0.1.1.dist-info/top_level.txt,sha256=0-YNy4YWXcpbBb5DdW6wnOzqYGz2PMhQC0x2g85-MPE,5
|
|
11
|
+
relm-0.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
relm
|