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
@@ -0,0 +1,258 @@
1
+ """Release command implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING
7
+
8
+ from pymelos.commands.base import Command, CommandContext
9
+ from pymelos.versioning import (
10
+ BumpType,
11
+ Version,
12
+ determine_bump,
13
+ generate_changelog_entry,
14
+ parse_commit,
15
+ prepend_to_changelog,
16
+ update_all_versions,
17
+ )
18
+
19
+ if TYPE_CHECKING:
20
+ from pymelos.workspace import Package
21
+ from pymelos.workspace.workspace import Workspace
22
+
23
+
24
+ @dataclass
25
+ class PackageRelease:
26
+ """Information about a package release."""
27
+
28
+ name: str
29
+ old_version: str
30
+ new_version: str
31
+ bump_type: BumpType
32
+ changelog_entry: str
33
+ commits: list[str]
34
+ tag: str
35
+ published: bool = False
36
+
37
+
38
+ @dataclass
39
+ class ReleaseResult:
40
+ """Result of release command."""
41
+
42
+ releases: list[PackageRelease]
43
+ commit_sha: str | None = None
44
+ success: bool = True
45
+ error: str | None = None
46
+
47
+
48
+ @dataclass
49
+ class ReleaseOptions:
50
+ """Options for release command."""
51
+
52
+ scope: str | None = None
53
+ since: str | None = None
54
+ bump: BumpType | None = None # Override auto-detection
55
+ prerelease: str | None = None # e.g., "alpha", "beta"
56
+ dry_run: bool = False
57
+ publish: bool = False
58
+ no_git_tag: bool = False
59
+ no_changelog: bool = False
60
+ no_commit: bool = False
61
+
62
+
63
+ class ReleaseCommand(Command[ReleaseResult]):
64
+ """Release packages with semantic versioning.
65
+
66
+ This command:
67
+ 1. Determines which packages need release
68
+ 2. Parses commits to determine bump type
69
+ 3. Updates versions in pyproject.toml
70
+ 4. Generates changelog entries
71
+ 5. Creates git commit and tags
72
+ 6. Optionally publishes to PyPI
73
+ """
74
+
75
+ def __init__(self, context: CommandContext, options: ReleaseOptions | None = None) -> None:
76
+ super().__init__(context)
77
+ self.options = options or ReleaseOptions()
78
+
79
+ @property
80
+ def is_dry_run(self) -> bool:
81
+ """Check if this is a dry run."""
82
+ return self.options.dry_run or self.context.dry_run
83
+
84
+ def get_packages_to_release(self) -> list[Package]:
85
+ """Get packages that need release (scope-filtered only)."""
86
+ from pymelos.filters import filter_by_scope
87
+
88
+ return filter_by_scope(list(self.workspace.packages.values()), self.options.scope)
89
+
90
+ def _prepare_package_release(self, pkg: Package) -> PackageRelease | None:
91
+ """Prepare release info for a single package. Returns None if package should be skipped."""
92
+ from pymelos.git import get_commits, get_latest_package_tag
93
+
94
+ last_tag = get_latest_package_tag(self.workspace.root, pkg.name)
95
+ since_ref = last_tag.name if last_tag else None
96
+
97
+ commits = get_commits(self.workspace.root, since=since_ref, path=pkg.path)
98
+
99
+ # Skip unchanged packages
100
+ if not commits:
101
+ return None
102
+
103
+ # Skip packages with only initial commit unless explicitly scoped
104
+ if not last_tag and len(commits) <= 1 and not self.options.scope:
105
+ return None
106
+
107
+ # Parse and filter conventional commits
108
+ parsed = [p for c in commits if (p := parse_commit(c)) is not None]
109
+
110
+ if not parsed and not self.options.bump:
111
+ return None
112
+
113
+ bump = self.options.bump or determine_bump(parsed)
114
+ if bump == BumpType.NONE:
115
+ return None
116
+
117
+ old_version = Version.parse(pkg.version)
118
+ new_version = old_version.bump(bump, self.options.prerelease)
119
+ tag_format = self.workspace.config.versioning.tag_format
120
+
121
+ changelog = generate_changelog_entry(str(new_version), parsed, package_name=pkg.name)
122
+
123
+ return PackageRelease(
124
+ name=pkg.name,
125
+ old_version=str(old_version),
126
+ new_version=str(new_version),
127
+ bump_type=bump,
128
+ changelog_entry=changelog,
129
+ commits=[c.sha[:7] for c in commits],
130
+ tag=tag_format.format(name=pkg.name, version=str(new_version)),
131
+ )
132
+
133
+ def _apply_release_changes(self, release: PackageRelease) -> None:
134
+ """Apply version and changelog changes for a release."""
135
+ pkg = self.workspace.get_package(release.name)
136
+ update_all_versions(pkg.path, pkg.name, release.new_version)
137
+
138
+ if not self.options.no_changelog:
139
+ prepend_to_changelog(pkg.path / "CHANGELOG.md", release.changelog_entry)
140
+
141
+ def _create_git_commit(self, releases: list[PackageRelease]) -> str | None:
142
+ """Create git commit for releases. Returns commit SHA."""
143
+ from pymelos.git import run_git_command
144
+
145
+ if self.options.no_commit:
146
+ return None
147
+
148
+ run_git_command(["add", "-A"], cwd=self.workspace.root)
149
+
150
+ pkg_versions = ", ".join(f"{r.name}@{r.new_version}" for r in releases)
151
+ commit_msg = self.workspace.config.versioning.commit_message.format(packages=pkg_versions)
152
+
153
+ run_git_command(["commit", "-m", commit_msg], cwd=self.workspace.root)
154
+ result = run_git_command(["rev-parse", "HEAD"], cwd=self.workspace.root)
155
+ return result.stdout.strip()
156
+
157
+ def _create_git_tags(self, releases: list[PackageRelease]) -> None:
158
+ """Create git tags for releases."""
159
+ from pymelos.git import create_tag
160
+
161
+ if self.options.no_git_tag:
162
+ return
163
+
164
+ for release in releases:
165
+ create_tag(
166
+ self.workspace.root,
167
+ release.tag,
168
+ message=f"Release {release.name}@{release.new_version}",
169
+ )
170
+
171
+ def _publish_releases(self, releases: list[PackageRelease]) -> str | None:
172
+ """Publish releases to PyPI. Returns error message on failure."""
173
+ from pymelos.uv import build_and_publish
174
+
175
+ if not self.options.publish:
176
+ return None
177
+
178
+ for release in releases:
179
+ pkg = self.workspace.get_package(release.name)
180
+ try:
181
+ build_and_publish(pkg.path, repository=self.workspace.config.publish.registry)
182
+ release.published = True
183
+ except Exception as e:
184
+ return str(e)
185
+ return None
186
+
187
+ async def execute(self) -> ReleaseResult:
188
+ """Execute the release command."""
189
+ packages = self.get_packages_to_release()
190
+ if not packages:
191
+ return ReleaseResult(releases=[], success=True)
192
+
193
+ # Prepare releases (filter out packages that shouldn't be released)
194
+ releases = [r for pkg in packages if (r := self._prepare_package_release(pkg)) is not None]
195
+
196
+ if not releases:
197
+ return ReleaseResult(releases=[], success=True)
198
+
199
+ # Apply changes if not dry run
200
+ if not self.is_dry_run:
201
+ for release in releases:
202
+ self._apply_release_changes(release)
203
+
204
+ commit_sha = self._create_git_commit(releases)
205
+ self._create_git_tags(releases)
206
+
207
+ if error := self._publish_releases(releases):
208
+ return ReleaseResult(
209
+ releases=releases, commit_sha=commit_sha, success=False, error=error
210
+ )
211
+
212
+ return ReleaseResult(releases=releases, commit_sha=commit_sha, success=True)
213
+
214
+ return ReleaseResult(releases=releases, success=True)
215
+
216
+
217
+ async def release(
218
+ workspace: Workspace,
219
+ *,
220
+ scope: str | None = None,
221
+ bump: BumpType | None = None,
222
+ prerelease: str | None = None,
223
+ dry_run: bool = False,
224
+ publish: bool = False,
225
+ no_git_tag: bool = False,
226
+ no_changelog: bool = False,
227
+ no_commit: bool = False,
228
+ ) -> ReleaseResult:
229
+ """Convenience function to release packages.
230
+
231
+ Args:
232
+ workspace: Workspace to release.
233
+ scope: Package scope filter.
234
+ bump: Override bump type.
235
+ prerelease: Prerelease tag.
236
+ dry_run: Show what would happen.
237
+ publish: Publish to PyPI.
238
+ no_git_tag: Skip creating git tags.
239
+ no_changelog: Skip changelog generation.
240
+ no_commit: Skip git commit.
241
+
242
+ Returns:
243
+ Release result.
244
+ """
245
+
246
+ context = CommandContext(workspace=workspace, dry_run=dry_run)
247
+ options = ReleaseOptions(
248
+ scope=scope,
249
+ bump=bump,
250
+ prerelease=prerelease,
251
+ dry_run=dry_run,
252
+ publish=publish,
253
+ no_git_tag=no_git_tag,
254
+ no_changelog=no_changelog,
255
+ no_commit=no_commit,
256
+ )
257
+ cmd = ReleaseCommand(context, options)
258
+ return await cmd.execute()
@@ -0,0 +1,160 @@
1
+ """Run command implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING
7
+
8
+ from pymelos.commands.base import Command, CommandContext
9
+ from pymelos.errors import ScriptNotFoundError
10
+ from pymelos.execution import BatchResult, ParallelExecutor
11
+
12
+ if TYPE_CHECKING:
13
+ from pymelos.workspace import Package
14
+ from pymelos.workspace.workspace import Workspace
15
+
16
+
17
+ @dataclass
18
+ class RunOptions:
19
+ """Options for run command."""
20
+
21
+ script_name: str
22
+ scope: str | None = None
23
+ since: str | None = None
24
+ ignore: list[str] | None = None
25
+ concurrency: int = 4
26
+ fail_fast: bool = False
27
+ topological: bool = True
28
+ include_dependents: bool = False
29
+
30
+
31
+ class RunCommand(Command[BatchResult]):
32
+ """Run a defined script across packages.
33
+
34
+ Scripts are defined in pymelos.yaml and can be filtered
35
+ by scope, git changes, or ignored patterns.
36
+ """
37
+
38
+ def __init__(self, context: CommandContext, options: RunOptions) -> None:
39
+ super().__init__(context)
40
+ self.options = options
41
+
42
+ def validate(self) -> list[str]:
43
+ """Validate the command."""
44
+ errors = super().validate()
45
+
46
+ script = self.workspace.config.get_script(self.options.script_name)
47
+ if not script:
48
+ errors.append(
49
+ f"Script '{self.options.script_name}' not found. "
50
+ f"Available: {', '.join(self.workspace.config.script_names)}"
51
+ )
52
+
53
+ return errors
54
+
55
+ def get_packages(self) -> list[Package]:
56
+ """Get packages to run script in."""
57
+ from pymelos.filters import apply_filters_with_since
58
+
59
+ packages = list(self.workspace.packages.values())
60
+
61
+ # Get script-specific scope if defined
62
+ script = self.workspace.config.get_script(self.options.script_name)
63
+ scope = self.options.scope
64
+ if not scope and script and script.scope:
65
+ scope = script.scope
66
+
67
+ return apply_filters_with_since(
68
+ packages,
69
+ self.workspace,
70
+ scope=scope,
71
+ since=self.options.since,
72
+ ignore=self.options.ignore,
73
+ include_dependents=self.options.include_dependents,
74
+ )
75
+
76
+ async def execute(self) -> BatchResult:
77
+ """Execute the script."""
78
+ # Validate first
79
+ errors = self.validate()
80
+ if errors:
81
+ raise ScriptNotFoundError(
82
+ self.options.script_name,
83
+ self.workspace.config.script_names,
84
+ )
85
+
86
+ script = self.workspace.config.get_script(self.options.script_name)
87
+ assert script is not None # validate() already checked
88
+
89
+ # Get matching packages
90
+ packages = self.get_packages()
91
+ if not packages:
92
+ return BatchResult(results=[])
93
+
94
+ # Build environment
95
+ env = dict(self.context.env)
96
+ env.update(self.workspace.config.env)
97
+ env.update(script.env)
98
+
99
+ # Get execution settings
100
+ concurrency = self.options.concurrency
101
+ fail_fast = self.options.fail_fast or script.fail_fast
102
+ topological = self.options.topological and script.topological
103
+
104
+ executor = ParallelExecutor(
105
+ concurrency=concurrency,
106
+ fail_fast=fail_fast,
107
+ )
108
+
109
+ if topological:
110
+ # Execute in dependency order
111
+ batches = self.workspace.parallel_batches(packages)
112
+ return await executor.execute_batches(batches, script.run, env=env)
113
+ else:
114
+ # Execute all in parallel
115
+ return await executor.execute(
116
+ packages,
117
+ script.run,
118
+ env=env,
119
+ )
120
+
121
+
122
+ async def run_script(
123
+ workspace: Workspace,
124
+ script_name: str,
125
+ *,
126
+ scope: str | None = None,
127
+ since: str | None = None,
128
+ ignore: list[str] | None = None,
129
+ concurrency: int = 4,
130
+ fail_fast: bool = False,
131
+ topological: bool = True,
132
+ ) -> BatchResult:
133
+ """Convenience function to run a script.
134
+
135
+ Args:
136
+ workspace: Workspace to run in.
137
+ script_name: Name of script to run.
138
+ scope: Package scope filter.
139
+ since: Git reference for change detection.
140
+ ignore: Patterns to exclude.
141
+ concurrency: Parallel jobs.
142
+ fail_fast: Stop on first failure.
143
+ topological: Respect dependency order.
144
+
145
+ Returns:
146
+ Batch result with all execution results.
147
+ """
148
+
149
+ context = CommandContext(workspace=workspace)
150
+ options = RunOptions(
151
+ script_name=script_name,
152
+ scope=scope,
153
+ since=since,
154
+ ignore=ignore,
155
+ concurrency=concurrency,
156
+ fail_fast=fail_fast,
157
+ topological=topological,
158
+ )
159
+ cmd = RunCommand(context, options)
160
+ return await cmd.execute()
pymelos/compat.py ADDED
@@ -0,0 +1,14 @@
1
+ """Compatibility layer for Python version differences."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ # tomllib is only available in Python 3.11+
8
+ # Use tomli for Python 3.10
9
+ if sys.version_info >= (3, 11):
10
+ import tomllib # type: ignore[import-not-found]
11
+ else:
12
+ import tomli as tomllib # type: ignore[import-not-found]
13
+
14
+ __all__ = ["tomllib"]
@@ -0,0 +1,47 @@
1
+ """Configuration loading and validation."""
2
+
3
+ from pymelos.config.loader import (
4
+ CONFIG_FILENAME,
5
+ find_config_file,
6
+ get_workspace_root,
7
+ load_config,
8
+ load_yaml,
9
+ )
10
+ from pymelos.config.schema import (
11
+ BootstrapConfig,
12
+ BootstrapHook,
13
+ ChangelogConfig,
14
+ ChangelogSection,
15
+ CleanConfig,
16
+ CommandDefaults,
17
+ CommitFormat,
18
+ IDEConfig,
19
+ PublishConfig,
20
+ PyMelosConfig,
21
+ ScriptConfig,
22
+ VersioningConfig,
23
+ VSCodeConfig,
24
+ )
25
+
26
+ __all__ = [
27
+ # Loader
28
+ "CONFIG_FILENAME",
29
+ "find_config_file",
30
+ "get_workspace_root",
31
+ "load_config",
32
+ "load_yaml",
33
+ # Schema
34
+ "BootstrapConfig",
35
+ "BootstrapHook",
36
+ "ChangelogConfig",
37
+ "ChangelogSection",
38
+ "CleanConfig",
39
+ "CommandDefaults",
40
+ "CommitFormat",
41
+ "IDEConfig",
42
+ "PublishConfig",
43
+ "PyMelosConfig",
44
+ "ScriptConfig",
45
+ "VersioningConfig",
46
+ "VSCodeConfig",
47
+ ]
@@ -0,0 +1,132 @@
1
+ """Configuration file loading and discovery."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import yaml
9
+ from pydantic import ValidationError as PydanticValidationError
10
+
11
+ from pymelos.config.schema import PyMelosConfig
12
+ from pymelos.errors import ConfigurationError, WorkspaceNotFoundError
13
+
14
+ CONFIG_FILENAME = "pymelos.yaml"
15
+ ALT_CONFIG_FILENAME = "pymelos.yml"
16
+ CONFIG_FILENAMES = (CONFIG_FILENAME, ALT_CONFIG_FILENAME)
17
+
18
+
19
+ def find_config_file(start_path: Path | None = None) -> Path:
20
+ """Find pymelos.yaml by walking up from start_path.
21
+
22
+ Args:
23
+ start_path: Directory to start searching from. Defaults to cwd.
24
+
25
+ Returns:
26
+ Path to the pymelos.yaml file.
27
+
28
+ Raises:
29
+ WorkspaceNotFoundError: If no config file is found.
30
+ """
31
+ if start_path is None:
32
+ start_path = Path.cwd()
33
+ start_path = start_path.resolve()
34
+
35
+ current = start_path
36
+ while True:
37
+ # Check for both .yaml and .yml extensions
38
+ for filename in CONFIG_FILENAMES:
39
+ config_path = current / filename
40
+ if config_path.is_file():
41
+ return config_path
42
+
43
+ # Move to parent directory
44
+ parent = current.parent
45
+ if parent == current:
46
+ # Reached filesystem root
47
+ raise WorkspaceNotFoundError(start_path)
48
+ current = parent
49
+
50
+
51
+ def load_yaml(path: Path) -> dict[str, Any]:
52
+ """Load and parse a YAML file.
53
+
54
+ Args:
55
+ path: Path to the YAML file.
56
+
57
+ Returns:
58
+ Parsed YAML content as a dictionary.
59
+
60
+ Raises:
61
+ ConfigurationError: If the file cannot be read or parsed.
62
+ """
63
+ try:
64
+ with open(path, encoding="utf-8") as f:
65
+ content = yaml.safe_load(f)
66
+ if content is None:
67
+ return {}
68
+ if not isinstance(content, dict):
69
+ raise ConfigurationError(
70
+ "Configuration must be a YAML mapping (dictionary)",
71
+ path=path,
72
+ )
73
+ return content
74
+ except yaml.YAMLError as e:
75
+ raise ConfigurationError(f"Invalid YAML syntax: {e}", path=path) from e
76
+ except OSError as e:
77
+ raise ConfigurationError(f"Cannot read file: {e}", path=path) from e
78
+
79
+
80
+ def load_config(
81
+ path: Path | None = None,
82
+ *,
83
+ start_path: Path | None = None,
84
+ ) -> tuple[PyMelosConfig, Path]:
85
+ """Load and validate pymelos configuration.
86
+
87
+ Args:
88
+ path: Explicit path to config file. If provided, start_path is ignored.
89
+ start_path: Directory to search for config file. Defaults to cwd.
90
+
91
+ Returns:
92
+ Tuple of (validated config, path to config file).
93
+
94
+ Raises:
95
+ WorkspaceNotFoundError: If no config file is found.
96
+ ConfigurationError: If the config file is invalid.
97
+ """
98
+ if path is None:
99
+ path = find_config_file(start_path)
100
+ else:
101
+ path = path.resolve()
102
+ if not path.is_file():
103
+ raise ConfigurationError(f"Config file not found: {path}")
104
+
105
+ raw_config = load_yaml(path)
106
+
107
+ try:
108
+ config = PyMelosConfig(**raw_config)
109
+ except PydanticValidationError as e:
110
+ errors = []
111
+ for error in e.errors():
112
+ loc = ".".join(str(x) for x in error["loc"])
113
+ msg = error["msg"]
114
+ errors.append(f" {loc}: {msg}")
115
+ raise ConfigurationError(
116
+ "Invalid configuration:\n" + "\n".join(errors),
117
+ path=path,
118
+ ) from e
119
+
120
+ return config, path
121
+
122
+
123
+ def get_workspace_root(config_path: Path) -> Path:
124
+ """Get the workspace root directory from config file path.
125
+
126
+ Args:
127
+ config_path: Path to the pymelos.yaml file.
128
+
129
+ Returns:
130
+ Path to the workspace root directory.
131
+ """
132
+ return config_path.parent.resolve()