pymelos 0.1.3__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.
Files changed (57) hide show
  1. pymelos/__init__.py +63 -0
  2. pymelos/__main__.py +6 -0
  3. pymelos/cli/__init__.py +5 -0
  4. pymelos/cli/__main__.py +6 -0
  5. pymelos/cli/app.py +527 -0
  6. pymelos/cli/commands/__init__.py +1 -0
  7. pymelos/cli/commands/init.py +151 -0
  8. pymelos/commands/__init__.py +84 -0
  9. pymelos/commands/add.py +77 -0
  10. pymelos/commands/base.py +108 -0
  11. pymelos/commands/bootstrap.py +154 -0
  12. pymelos/commands/changed.py +161 -0
  13. pymelos/commands/clean.py +142 -0
  14. pymelos/commands/exec.py +116 -0
  15. pymelos/commands/list.py +128 -0
  16. pymelos/commands/release.py +258 -0
  17. pymelos/commands/run.py +160 -0
  18. pymelos/compat.py +14 -0
  19. pymelos/config/__init__.py +47 -0
  20. pymelos/config/loader.py +132 -0
  21. pymelos/config/schema.py +236 -0
  22. pymelos/errors.py +139 -0
  23. pymelos/execution/__init__.py +32 -0
  24. pymelos/execution/parallel.py +249 -0
  25. pymelos/execution/results.py +172 -0
  26. pymelos/execution/runner.py +171 -0
  27. pymelos/filters/__init__.py +27 -0
  28. pymelos/filters/chain.py +101 -0
  29. pymelos/filters/ignore.py +60 -0
  30. pymelos/filters/scope.py +90 -0
  31. pymelos/filters/since.py +98 -0
  32. pymelos/git/__init__.py +69 -0
  33. pymelos/git/changes.py +153 -0
  34. pymelos/git/commits.py +174 -0
  35. pymelos/git/repo.py +210 -0
  36. pymelos/git/tags.py +242 -0
  37. pymelos/py.typed +0 -0
  38. pymelos/types.py +16 -0
  39. pymelos/uv/__init__.py +44 -0
  40. pymelos/uv/client.py +167 -0
  41. pymelos/uv/publish.py +162 -0
  42. pymelos/uv/sync.py +168 -0
  43. pymelos/versioning/__init__.py +57 -0
  44. pymelos/versioning/changelog.py +189 -0
  45. pymelos/versioning/conventional.py +216 -0
  46. pymelos/versioning/semver.py +249 -0
  47. pymelos/versioning/updater.py +146 -0
  48. pymelos/workspace/__init__.py +33 -0
  49. pymelos/workspace/discovery.py +138 -0
  50. pymelos/workspace/graph.py +238 -0
  51. pymelos/workspace/package.py +191 -0
  52. pymelos/workspace/workspace.py +218 -0
  53. pymelos-0.1.3.dist-info/METADATA +106 -0
  54. pymelos-0.1.3.dist-info/RECORD +57 -0
  55. pymelos-0.1.3.dist-info/WHEEL +4 -0
  56. pymelos-0.1.3.dist-info/entry_points.txt +2 -0
  57. pymelos-0.1.3.dist-info/licenses/LICENSE +21 -0
pymelos/uv/sync.py ADDED
@@ -0,0 +1,168 @@
1
+ """uv sync operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from pymelos.uv.client import run_uv, run_uv_async
8
+
9
+
10
+ def sync(
11
+ cwd: Path,
12
+ *,
13
+ frozen: bool = False,
14
+ locked: bool = True,
15
+ all_extras: bool = False,
16
+ dev: bool = True,
17
+ all_packages: bool = True,
18
+ ) -> tuple[int, str, str]:
19
+ """Run uv sync at the specified path.
20
+
21
+ Args:
22
+ cwd: Working directory (workspace root).
23
+ frozen: Use frozen dependencies (no updates).
24
+ locked: Require lock file to be up to date.
25
+ all_extras: Install all extras.
26
+ dev: Install dev dependencies.
27
+
28
+ Returns:
29
+ Tuple of (exit_code, stdout, stderr).
30
+ """
31
+ args = ["sync"]
32
+
33
+ if frozen:
34
+ args.append("--frozen")
35
+ if locked:
36
+ args.append("--locked")
37
+ if all_extras:
38
+ args.append("--all-extras")
39
+ if all_packages:
40
+ args.append("--all-packages")
41
+ if not dev:
42
+ args.append("--no-dev")
43
+
44
+ result = run_uv(args, cwd=cwd, check=False)
45
+ return result.returncode, result.stdout, result.stderr
46
+
47
+
48
+ async def sync_async(
49
+ cwd: Path,
50
+ *,
51
+ frozen: bool = False,
52
+ locked: bool = True,
53
+ all_extras: bool = False,
54
+ dev: bool = True,
55
+ all_packages: bool = True,
56
+ ) -> tuple[int, str, str]:
57
+ """Run uv sync asynchronously.
58
+
59
+ Args:
60
+ cwd: Working directory.
61
+ frozen: Use frozen dependencies.
62
+ locked: Require lock file to be up to date.
63
+ all_extras: Install all extras.
64
+ dev: Install dev dependencies.
65
+
66
+ Returns:
67
+ Tuple of (exit_code, stdout, stderr).
68
+ """
69
+ args = ["sync"]
70
+
71
+ if frozen:
72
+ args.append("--frozen")
73
+ if locked:
74
+ args.append("--locked")
75
+ if all_extras:
76
+ args.append("--all-extras")
77
+ if all_packages:
78
+ args.append("--all-packages")
79
+
80
+ if not dev:
81
+ args.append("--no-dev")
82
+
83
+ return await run_uv_async(args, cwd=cwd, check=False)
84
+
85
+
86
+ def lock(cwd: Path) -> tuple[int, str, str]:
87
+ """Update the lock file.
88
+
89
+ Args:
90
+ cwd: Working directory.
91
+
92
+ Returns:
93
+ Tuple of (exit_code, stdout, stderr).
94
+ """
95
+ result = run_uv(["lock"], cwd=cwd, check=False)
96
+ return result.returncode, result.stdout, result.stderr
97
+
98
+
99
+ def add_dependency(
100
+ cwd: Path,
101
+ package: str,
102
+ *,
103
+ dev: bool = False,
104
+ extras: list[str] | None = None,
105
+ ) -> tuple[int, str, str]:
106
+ """Add a dependency.
107
+
108
+ Args:
109
+ cwd: Working directory.
110
+ package: Package to add (e.g., "requests>=2.0").
111
+ dev: Add as dev dependency.
112
+ extras: Extras to include.
113
+
114
+ Returns:
115
+ Tuple of (exit_code, stdout, stderr).
116
+ """
117
+ args = ["add", package]
118
+
119
+ if dev:
120
+ args.append("--dev")
121
+ if extras:
122
+ for extra in extras:
123
+ args.extend(["--extra", extra])
124
+
125
+ result = run_uv(args, cwd=cwd, check=False)
126
+ return result.returncode, result.stdout, result.stderr
127
+
128
+
129
+ def remove_dependency(
130
+ cwd: Path,
131
+ package: str,
132
+ *,
133
+ dev: bool = False,
134
+ ) -> tuple[int, str, str]:
135
+ """Remove a dependency.
136
+
137
+ Args:
138
+ cwd: Working directory.
139
+ package: Package to remove.
140
+ dev: Remove from dev dependencies.
141
+
142
+ Returns:
143
+ Tuple of (exit_code, stdout, stderr).
144
+ """
145
+ args = ["remove", package]
146
+
147
+ if dev:
148
+ args.append("--dev")
149
+
150
+ result = run_uv(args, cwd=cwd, check=False)
151
+ return result.returncode, result.stdout, result.stderr
152
+
153
+
154
+ def pip_list(cwd: Path) -> list[tuple[str, str]]:
155
+ """List installed packages.
156
+
157
+ Args:
158
+ cwd: Working directory.
159
+
160
+ Returns:
161
+ List of (name, version) tuples.
162
+ """
163
+ result = run_uv(["pip", "list", "--format", "json"], cwd=cwd)
164
+
165
+ import json
166
+
167
+ packages = json.loads(result.stdout)
168
+ return [(p["name"], p["version"]) for p in packages]
@@ -0,0 +1,57 @@
1
+ """Semantic versioning and release management."""
2
+
3
+ from pymelos.versioning.changelog import (
4
+ generate_changelog_entry,
5
+ get_latest_version_from_changelog,
6
+ prepend_to_changelog,
7
+ read_changelog,
8
+ )
9
+ from pymelos.versioning.conventional import (
10
+ ParsedCommit,
11
+ determine_bump,
12
+ filter_commits_by_type,
13
+ group_commits_by_type,
14
+ is_conventional_commit,
15
+ parse_commit,
16
+ parse_commit_message,
17
+ )
18
+ from pymelos.versioning.semver import (
19
+ BumpType,
20
+ Version,
21
+ compare_versions,
22
+ is_valid_semver,
23
+ )
24
+ from pymelos.versioning.updater import (
25
+ find_version_files,
26
+ get_pyproject_version,
27
+ update_all_versions,
28
+ update_init_version,
29
+ update_pyproject_version,
30
+ )
31
+
32
+ __all__ = [
33
+ # SemVer
34
+ "Version",
35
+ "BumpType",
36
+ "is_valid_semver",
37
+ "compare_versions",
38
+ # Conventional Commits
39
+ "ParsedCommit",
40
+ "parse_commit",
41
+ "parse_commit_message",
42
+ "determine_bump",
43
+ "is_conventional_commit",
44
+ "filter_commits_by_type",
45
+ "group_commits_by_type",
46
+ # Changelog
47
+ "generate_changelog_entry",
48
+ "prepend_to_changelog",
49
+ "read_changelog",
50
+ "get_latest_version_from_changelog",
51
+ # Updater
52
+ "update_pyproject_version",
53
+ "get_pyproject_version",
54
+ "update_init_version",
55
+ "find_version_files",
56
+ "update_all_versions",
57
+ ]
@@ -0,0 +1,189 @@
1
+ """Changelog generation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+
8
+ from pymelos.versioning.conventional import ParsedCommit, group_commits_by_type
9
+
10
+
11
+ def generate_changelog_entry(
12
+ version: str,
13
+ commits: list[ParsedCommit],
14
+ *,
15
+ date: datetime | None = None,
16
+ package_name: str | None = None,
17
+ sections: list[tuple[str, str]] | None = None,
18
+ hidden_types: set[str] | None = None,
19
+ ) -> str:
20
+ """Generate a changelog entry for a release.
21
+
22
+ Args:
23
+ version: New version string.
24
+ commits: Commits included in this release.
25
+ date: Release date (defaults to now).
26
+ package_name: Package name (for multi-package repos).
27
+ sections: List of (type, title) tuples for section ordering.
28
+ hidden_types: Commit types to exclude from changelog.
29
+
30
+ Returns:
31
+ Markdown changelog entry.
32
+ """
33
+ if date is None:
34
+ date = datetime.now(timezone.utc)
35
+
36
+ date_str = date.strftime("%Y-%m-%d")
37
+
38
+ # Build header
39
+ if package_name:
40
+ header = f"## [{package_name}@{version}] - {date_str}\n"
41
+ else:
42
+ header = f"## [{version}] - {date_str}\n"
43
+
44
+ # Default sections
45
+ if sections is None:
46
+ sections = [
47
+ ("feat", "Features"),
48
+ ("fix", "Bug Fixes"),
49
+ ("perf", "Performance"),
50
+ ("refactor", "Refactoring"),
51
+ ("docs", "Documentation"),
52
+ ("style", "Style"),
53
+ ("test", "Tests"),
54
+ ("chore", "Chores"),
55
+ ("ci", "CI"),
56
+ ("build", "Build"),
57
+ ("revert", "Reverts"),
58
+ ]
59
+
60
+ hidden = hidden_types or {"docs", "style", "chore", "ci", "test"}
61
+
62
+ # Group commits
63
+ grouped = group_commits_by_type(commits)
64
+
65
+ # Build sections
66
+ lines: list[str] = [header]
67
+
68
+ # Breaking changes first
69
+ breaking_commits = [c for c in commits if c.breaking]
70
+ if breaking_commits:
71
+ lines.append("\n### BREAKING CHANGES\n")
72
+ for commit in breaking_commits:
73
+ scope = f"**{commit.scope}:** " if commit.scope else ""
74
+ lines.append(f"- {scope}{commit.description}")
75
+ if commit.body and "BREAKING CHANGE:" in commit.body:
76
+ # Extract breaking change description
77
+ for line in commit.body.split("\n"):
78
+ if line.startswith("BREAKING CHANGE:"):
79
+ bc_desc = line.replace("BREAKING CHANGE:", "").strip()
80
+ lines.append(f" - {bc_desc}")
81
+ break
82
+ lines.append("")
83
+
84
+ # Other sections
85
+ for commit_type, title in sections:
86
+ if commit_type in hidden:
87
+ continue
88
+
89
+ type_commits = grouped.get(commit_type, [])
90
+ # Filter out breaking changes (already shown)
91
+ type_commits = [c for c in type_commits if not c.breaking]
92
+
93
+ if not type_commits:
94
+ continue
95
+
96
+ lines.append(f"\n### {title}\n")
97
+ for commit in type_commits:
98
+ scope = f"**{commit.scope}:** " if commit.scope else ""
99
+ sha_link = f"([{commit.sha[:7]}])" if commit.sha else ""
100
+ lines.append(f"- {scope}{commit.description} {sha_link}".rstrip())
101
+ lines.append("")
102
+
103
+ return "\n".join(lines)
104
+
105
+
106
+ def _create_new_changelog(entry: str) -> str:
107
+ """Create new changelog content with header."""
108
+ return f"""# Changelog
109
+
110
+ All notable changes to this project will be documented in this file.
111
+
112
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
113
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
114
+
115
+ {entry}
116
+ """
117
+
118
+
119
+ def _find_insert_index(lines: list[str]) -> int:
120
+ """Find index to insert new changelog entry."""
121
+ for i, line in enumerate(lines):
122
+ if line.startswith("## ["):
123
+ return i
124
+ if line.startswith("# ") or not line.strip():
125
+ continue
126
+ return len(lines)
127
+
128
+
129
+ def prepend_to_changelog(
130
+ changelog_path: Path,
131
+ entry: str,
132
+ *,
133
+ create_if_missing: bool = True,
134
+ ) -> None:
135
+ """Prepend a changelog entry to an existing changelog file.
136
+
137
+ Args:
138
+ changelog_path: Path to CHANGELOG.md.
139
+ entry: Changelog entry to prepend.
140
+ create_if_missing: Create the file if it doesn't exist.
141
+ """
142
+ if not changelog_path.exists():
143
+ if not create_if_missing:
144
+ raise FileNotFoundError(f"Changelog not found: {changelog_path}")
145
+ changelog_path.write_text(_create_new_changelog(entry), encoding="utf-8")
146
+ return
147
+
148
+ lines = changelog_path.read_text(encoding="utf-8").split("\n")
149
+ insert_index = _find_insert_index(lines)
150
+ new_lines = lines[:insert_index] + [entry.strip(), ""] + lines[insert_index:]
151
+ changelog_path.write_text("\n".join(new_lines), encoding="utf-8")
152
+
153
+
154
+ def read_changelog(changelog_path: Path) -> str | None:
155
+ """Read changelog content.
156
+
157
+ Args:
158
+ changelog_path: Path to CHANGELOG.md.
159
+
160
+ Returns:
161
+ Changelog content or None if not found.
162
+ """
163
+ if not changelog_path.exists():
164
+ return None
165
+ return changelog_path.read_text(encoding="utf-8")
166
+
167
+
168
+ def get_latest_version_from_changelog(changelog_path: Path) -> str | None:
169
+ """Extract the latest version from a changelog.
170
+
171
+ Args:
172
+ changelog_path: Path to CHANGELOG.md.
173
+
174
+ Returns:
175
+ Latest version string or None if not found.
176
+ """
177
+ content = read_changelog(changelog_path)
178
+ if not content:
179
+ return None
180
+
181
+ import re
182
+
183
+ # Match version headers like: ## [1.2.3] or ## [pkg@1.2.3]
184
+ pattern = r"## \[(?:[\w-]+@)?(\d+\.\d+\.\d+(?:-[\w.]+)?)\]"
185
+ match = re.search(pattern, content)
186
+
187
+ if match:
188
+ return match.group(1)
189
+ return None
@@ -0,0 +1,216 @@
1
+ """Conventional commit parsing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from typing import TYPE_CHECKING
8
+
9
+ from pymelos.versioning.semver import BumpType
10
+
11
+ if TYPE_CHECKING:
12
+ from pymelos.git.commits import Commit
13
+
14
+ # Conventional commit regex
15
+ # Matches: type(scope)!: description
16
+ # Examples:
17
+ # feat: add new feature
18
+ # fix(core): fix bug
19
+ # feat!: breaking change
20
+ # refactor(api)!: breaking refactor
21
+ CONVENTIONAL_PATTERN = re.compile(
22
+ r"^(?P<type>feat|fix|docs|style|refactor|perf|test|chore|ci|build|revert)"
23
+ r"(?:\((?P<scope>[^)]+)\))?"
24
+ r"(?P<breaking>!)?"
25
+ r": (?P<description>.+)$",
26
+ re.IGNORECASE,
27
+ )
28
+
29
+ # Mapping from commit type to bump type
30
+ TYPE_TO_BUMP: dict[str, BumpType] = {
31
+ "feat": BumpType.MINOR,
32
+ "fix": BumpType.PATCH,
33
+ "perf": BumpType.PATCH,
34
+ "refactor": BumpType.NONE,
35
+ "docs": BumpType.NONE,
36
+ "style": BumpType.NONE,
37
+ "test": BumpType.NONE,
38
+ "chore": BumpType.NONE,
39
+ "ci": BumpType.NONE,
40
+ "build": BumpType.NONE,
41
+ "revert": BumpType.PATCH,
42
+ }
43
+
44
+
45
+ @dataclass(frozen=True, slots=True)
46
+ class ParsedCommit:
47
+ """A parsed conventional commit.
48
+
49
+ Attributes:
50
+ sha: Commit SHA.
51
+ type: Commit type (feat, fix, etc.).
52
+ scope: Optional scope.
53
+ description: Commit description.
54
+ body: Commit body.
55
+ breaking: Whether this is a breaking change.
56
+ raw_message: Original commit message.
57
+ """
58
+
59
+ sha: str
60
+ type: str
61
+ scope: str | None
62
+ description: str
63
+ body: str | None
64
+ breaking: bool
65
+ raw_message: str
66
+
67
+ @property
68
+ def bump_type(self) -> BumpType:
69
+ """Get the version bump type for this commit."""
70
+ if self.breaking:
71
+ return BumpType.MAJOR
72
+ return TYPE_TO_BUMP.get(self.type.lower(), BumpType.NONE)
73
+
74
+ @property
75
+ def formatted_scope(self) -> str:
76
+ """Get scope formatted for display."""
77
+ if self.scope:
78
+ return f"({self.scope})"
79
+ return ""
80
+
81
+ @property
82
+ def formatted_type(self) -> str:
83
+ """Get type formatted for changelog."""
84
+ type_labels = {
85
+ "feat": "Features",
86
+ "fix": "Bug Fixes",
87
+ "perf": "Performance",
88
+ "refactor": "Refactoring",
89
+ "docs": "Documentation",
90
+ "style": "Style",
91
+ "test": "Tests",
92
+ "chore": "Chores",
93
+ "ci": "CI",
94
+ "build": "Build",
95
+ "revert": "Reverts",
96
+ }
97
+ return type_labels.get(self.type.lower(), self.type.capitalize())
98
+
99
+
100
+ def parse_commit_message(message: str, sha: str = "") -> ParsedCommit | None:
101
+ """Parse a commit message in conventional commit format.
102
+
103
+ Args:
104
+ message: Commit message to parse.
105
+ sha: Commit SHA.
106
+
107
+ Returns:
108
+ ParsedCommit if the message follows conventional commit format, None otherwise.
109
+ """
110
+ lines = message.strip().split("\n")
111
+ first_line = lines[0]
112
+
113
+ match = CONVENTIONAL_PATTERN.match(first_line)
114
+ if not match:
115
+ return None
116
+
117
+ body = "\n".join(lines[1:]).strip() if len(lines) > 1 else None
118
+
119
+ # Check for breaking change in body
120
+ breaking = bool(match.group("breaking"))
121
+ if body and ("BREAKING CHANGE:" in body or "BREAKING-CHANGE:" in body):
122
+ breaking = True
123
+
124
+ return ParsedCommit(
125
+ sha=sha,
126
+ type=match.group("type").lower(),
127
+ scope=match.group("scope"),
128
+ description=match.group("description"),
129
+ body=body,
130
+ breaking=breaking,
131
+ raw_message=message,
132
+ )
133
+
134
+
135
+ def parse_commit(commit: Commit) -> ParsedCommit | None:
136
+ """Parse a Commit object into a ParsedCommit.
137
+
138
+ Args:
139
+ commit: Git commit object.
140
+
141
+ Returns:
142
+ ParsedCommit if valid, None otherwise.
143
+ """
144
+ return parse_commit_message(commit.message, commit.sha)
145
+
146
+
147
+ def determine_bump(commits: list[ParsedCommit]) -> BumpType:
148
+ """Determine the highest bump type from a list of commits.
149
+
150
+ Args:
151
+ commits: List of parsed commits.
152
+
153
+ Returns:
154
+ The highest bump type needed.
155
+ """
156
+ if not commits:
157
+ return BumpType.NONE
158
+
159
+ bump = BumpType.NONE
160
+ for commit in commits:
161
+ if commit.bump_type > bump:
162
+ bump = commit.bump_type
163
+ if bump == BumpType.MAJOR:
164
+ # Can't go higher
165
+ break
166
+
167
+ return bump
168
+
169
+
170
+ def filter_commits_by_type(
171
+ commits: list[ParsedCommit],
172
+ types: list[str],
173
+ ) -> list[ParsedCommit]:
174
+ """Filter commits by type.
175
+
176
+ Args:
177
+ commits: List of commits.
178
+ types: Types to include (e.g., ["feat", "fix"]).
179
+
180
+ Returns:
181
+ Filtered commits.
182
+ """
183
+ type_set = {t.lower() for t in types}
184
+ return [c for c in commits if c.type.lower() in type_set]
185
+
186
+
187
+ def group_commits_by_type(
188
+ commits: list[ParsedCommit],
189
+ ) -> dict[str, list[ParsedCommit]]:
190
+ """Group commits by their type.
191
+
192
+ Args:
193
+ commits: List of commits.
194
+
195
+ Returns:
196
+ Dictionary mapping type to commits.
197
+ """
198
+ groups: dict[str, list[ParsedCommit]] = {}
199
+ for commit in commits:
200
+ commit_type = commit.type.lower()
201
+ if commit_type not in groups:
202
+ groups[commit_type] = []
203
+ groups[commit_type].append(commit)
204
+ return groups
205
+
206
+
207
+ def is_conventional_commit(message: str) -> bool:
208
+ """Check if a message follows conventional commit format.
209
+
210
+ Args:
211
+ message: Commit message to check.
212
+
213
+ Returns:
214
+ True if it's a valid conventional commit.
215
+ """
216
+ return parse_commit_message(message) is not None