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.
- pymelos/__init__.py +63 -0
- pymelos/__main__.py +6 -0
- pymelos/cli/__init__.py +5 -0
- pymelos/cli/__main__.py +6 -0
- pymelos/cli/app.py +527 -0
- pymelos/cli/commands/__init__.py +1 -0
- pymelos/cli/commands/init.py +151 -0
- pymelos/commands/__init__.py +84 -0
- pymelos/commands/add.py +77 -0
- pymelos/commands/base.py +108 -0
- pymelos/commands/bootstrap.py +154 -0
- pymelos/commands/changed.py +161 -0
- pymelos/commands/clean.py +142 -0
- pymelos/commands/exec.py +116 -0
- pymelos/commands/list.py +128 -0
- pymelos/commands/release.py +258 -0
- pymelos/commands/run.py +160 -0
- pymelos/compat.py +14 -0
- pymelos/config/__init__.py +47 -0
- pymelos/config/loader.py +132 -0
- pymelos/config/schema.py +236 -0
- pymelos/errors.py +139 -0
- pymelos/execution/__init__.py +32 -0
- pymelos/execution/parallel.py +249 -0
- pymelos/execution/results.py +172 -0
- pymelos/execution/runner.py +171 -0
- pymelos/filters/__init__.py +27 -0
- pymelos/filters/chain.py +101 -0
- pymelos/filters/ignore.py +60 -0
- pymelos/filters/scope.py +90 -0
- pymelos/filters/since.py +98 -0
- pymelos/git/__init__.py +69 -0
- pymelos/git/changes.py +153 -0
- pymelos/git/commits.py +174 -0
- pymelos/git/repo.py +210 -0
- pymelos/git/tags.py +242 -0
- pymelos/py.typed +0 -0
- pymelos/types.py +16 -0
- pymelos/uv/__init__.py +44 -0
- pymelos/uv/client.py +167 -0
- pymelos/uv/publish.py +162 -0
- pymelos/uv/sync.py +168 -0
- pymelos/versioning/__init__.py +57 -0
- pymelos/versioning/changelog.py +189 -0
- pymelos/versioning/conventional.py +216 -0
- pymelos/versioning/semver.py +249 -0
- pymelos/versioning/updater.py +146 -0
- pymelos/workspace/__init__.py +33 -0
- pymelos/workspace/discovery.py +138 -0
- pymelos/workspace/graph.py +238 -0
- pymelos/workspace/package.py +191 -0
- pymelos/workspace/workspace.py +218 -0
- pymelos-0.1.3.dist-info/METADATA +106 -0
- pymelos-0.1.3.dist-info/RECORD +57 -0
- pymelos-0.1.3.dist-info/WHEEL +4 -0
- pymelos-0.1.3.dist-info/entry_points.txt +2 -0
- 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()
|
pymelos/commands/run.py
ADDED
|
@@ -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
|
+
]
|
pymelos/config/loader.py
ADDED
|
@@ -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()
|