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,161 @@
1
+ """Changed 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 CommandContext, SyncCommand
9
+ from pymelos.workspace.workspace import Workspace
10
+
11
+ if TYPE_CHECKING:
12
+ pass
13
+
14
+
15
+ @dataclass
16
+ class ChangedPackage:
17
+ """Information about a changed package."""
18
+
19
+ name: str
20
+ path: str
21
+ files_changed: int
22
+ is_dependent: bool # True if changed due to dependency
23
+
24
+
25
+ @dataclass
26
+ class ChangedResult:
27
+ """Result of changed command."""
28
+
29
+ since: str
30
+ changed: list[ChangedPackage]
31
+ total_files_changed: int
32
+
33
+
34
+ @dataclass
35
+ class ChangedOptions:
36
+ """Options for changed command."""
37
+
38
+ since: str
39
+ include_dependents: bool = True
40
+ scope: str | None = None
41
+ ignore: list[str] | None = None
42
+
43
+
44
+ class ChangedCommand(SyncCommand[ChangedResult]):
45
+ """List packages that have changed since a git reference."""
46
+
47
+ def __init__(self, context: CommandContext, options: ChangedOptions) -> None:
48
+ super().__init__(context)
49
+ self.options = options
50
+
51
+ def execute(self) -> ChangedResult:
52
+ """Execute the changed command."""
53
+ from pymelos.filters import apply_filters
54
+ from pymelos.git import get_changed_files_since
55
+
56
+ # Get all changed files
57
+ changed_files = get_changed_files_since(self.workspace.root, self.options.since)
58
+
59
+ # Map files to packages
60
+ directly_changed: dict[str, list[str]] = {} # package -> files
61
+
62
+ for pkg in self.workspace.packages.values():
63
+ pkg_files: list[str] = []
64
+ for file_path in changed_files:
65
+ abs_path = self.workspace.root / file_path
66
+ try:
67
+ abs_path.relative_to(pkg.path)
68
+ pkg_files.append(str(file_path))
69
+ except ValueError:
70
+ continue
71
+
72
+ if pkg_files:
73
+ directly_changed[pkg.name] = pkg_files
74
+
75
+ # Get dependents if requested
76
+ dependent_packages: set[str] = set()
77
+ if self.options.include_dependents:
78
+ for pkg_name in list(directly_changed.keys()):
79
+ dependents = self.workspace.graph.get_transitive_dependents(pkg_name)
80
+ for dep in dependents:
81
+ if dep.name not in directly_changed:
82
+ dependent_packages.add(dep.name)
83
+
84
+ # Build result
85
+ changed_pkgs: list[ChangedPackage] = []
86
+
87
+ # Add directly changed packages
88
+ for pkg_name, files in directly_changed.items():
89
+ pkg = self.workspace.get_package(pkg_name)
90
+ changed_pkgs.append(
91
+ ChangedPackage(
92
+ name=pkg_name,
93
+ path=str(pkg.path.relative_to(self.workspace.root)),
94
+ files_changed=len(files),
95
+ is_dependent=False,
96
+ )
97
+ )
98
+
99
+ # Add dependent packages
100
+ for pkg_name in dependent_packages:
101
+ pkg = self.workspace.get_package(pkg_name)
102
+ changed_pkgs.append(
103
+ ChangedPackage(
104
+ name=pkg_name,
105
+ path=str(pkg.path.relative_to(self.workspace.root)),
106
+ files_changed=0,
107
+ is_dependent=True,
108
+ )
109
+ )
110
+
111
+ # Apply filters
112
+ if self.options.scope or self.options.ignore:
113
+ pkg_list = [self.workspace.get_package(p.name) for p in changed_pkgs]
114
+ filtered = apply_filters(
115
+ pkg_list,
116
+ scope=self.options.scope,
117
+ ignore=self.options.ignore,
118
+ )
119
+ filtered_names = {p.name for p in filtered}
120
+ changed_pkgs = [p for p in changed_pkgs if p.name in filtered_names]
121
+
122
+ # Sort by name
123
+ changed_pkgs.sort(key=lambda p: p.name)
124
+
125
+ return ChangedResult(
126
+ since=self.options.since,
127
+ changed=changed_pkgs,
128
+ total_files_changed=len(changed_files),
129
+ )
130
+
131
+
132
+ def get_changed_packages(
133
+ workspace: Workspace,
134
+ since: str,
135
+ *,
136
+ include_dependents: bool = True,
137
+ scope: str | None = None,
138
+ ignore: list[str] | None = None,
139
+ ) -> ChangedResult:
140
+ """Convenience function to get changed packages.
141
+
142
+ Args:
143
+ workspace: Workspace to check.
144
+ since: Git reference.
145
+ include_dependents: Include transitive dependents.
146
+ scope: Package scope filter.
147
+ ignore: Patterns to exclude.
148
+
149
+ Returns:
150
+ Changed result.
151
+ """
152
+
153
+ context = CommandContext(workspace=workspace)
154
+ options = ChangedOptions(
155
+ since=since,
156
+ include_dependents=include_dependents,
157
+ scope=scope,
158
+ ignore=ignore,
159
+ )
160
+ cmd = ChangedCommand(context, options)
161
+ return cmd.execute()
@@ -0,0 +1,142 @@
1
+ """Clean command implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING
9
+
10
+ from pymelos.commands.base import Command, CommandContext
11
+
12
+ if TYPE_CHECKING:
13
+ from pymelos.workspace import Package
14
+ from pymelos.workspace.workspace import Workspace
15
+
16
+
17
+ @dataclass
18
+ class CleanResult:
19
+ """Result of clean command."""
20
+
21
+ files_removed: int
22
+ dirs_removed: int
23
+ bytes_freed: int
24
+ packages_cleaned: list[str]
25
+
26
+
27
+ @dataclass
28
+ class CleanOptions:
29
+ """Options for clean command."""
30
+
31
+ scope: str | None = None
32
+ patterns: list[str] | None = None
33
+ protected: list[str] | None = None
34
+ dry_run: bool = False
35
+
36
+
37
+ class CleanCommand(Command[CleanResult]):
38
+ """Clean build artifacts from packages."""
39
+
40
+ def __init__(self, context: CommandContext, options: CleanOptions | None = None) -> None:
41
+ super().__init__(context)
42
+ self.options = options or CleanOptions()
43
+
44
+ def get_packages(self) -> list[Package]:
45
+ """Get packages to clean."""
46
+ from pymelos.filters import apply_filters
47
+
48
+ packages = list(self.workspace.packages.values())
49
+ return apply_filters(packages, scope=self.options.scope)
50
+
51
+ def get_patterns(self) -> list[str]:
52
+ """Get patterns to clean."""
53
+ if self.options.patterns:
54
+ return self.options.patterns
55
+ return self.workspace.config.clean.patterns
56
+
57
+ def get_protected(self) -> set[str]:
58
+ """Get protected patterns."""
59
+ if self.options.protected:
60
+ return set(self.options.protected)
61
+ return set(self.workspace.config.clean.protected)
62
+
63
+ def is_protected(self, path: Path, protected: set[str]) -> bool:
64
+ """Check if a path is protected."""
65
+ import fnmatch
66
+
67
+ return any(fnmatch.fnmatch(path.name, pattern) for pattern in protected)
68
+
69
+ def _calculate_size(self, path: Path) -> int:
70
+ """Calculate total size of a path (file or directory)."""
71
+ if path.is_file():
72
+ return path.stat().st_size
73
+ return sum(f.stat().st_size for f in path.rglob("*") if f.is_file())
74
+
75
+ def _get_paths_to_clean(
76
+ self, packages: list[Package], patterns: list[str], protected: set[str]
77
+ ) -> list[tuple[str, Path]]:
78
+ """Get all paths to clean with their package names."""
79
+ return [
80
+ (pkg.name, path)
81
+ for pkg in packages
82
+ for pattern in patterns
83
+ for path in pkg.path.glob(pattern)
84
+ if not self.is_protected(path, protected)
85
+ ]
86
+
87
+ async def execute(self) -> CleanResult:
88
+ """Execute the clean command."""
89
+ paths_to_clean = self._get_paths_to_clean(
90
+ self.get_packages(), self.get_patterns(), self.get_protected()
91
+ )
92
+
93
+ files_removed = 0
94
+ dirs_removed = 0
95
+ bytes_freed = 0
96
+ packages_cleaned: set[str] = set()
97
+ is_dry_run = self.options.dry_run or self.context.dry_run
98
+
99
+ for pkg_name, path in paths_to_clean:
100
+ bytes_freed += self._calculate_size(path)
101
+ packages_cleaned.add(pkg_name)
102
+
103
+ if path.is_file():
104
+ files_removed += 1
105
+ if not is_dry_run:
106
+ path.unlink()
107
+ elif path.is_dir():
108
+ dirs_removed += 1
109
+ if not is_dry_run:
110
+ shutil.rmtree(path)
111
+
112
+ return CleanResult(
113
+ files_removed=files_removed,
114
+ dirs_removed=dirs_removed,
115
+ bytes_freed=bytes_freed,
116
+ packages_cleaned=list(packages_cleaned),
117
+ )
118
+
119
+
120
+ async def clean(
121
+ workspace: Workspace,
122
+ *,
123
+ scope: str | None = None,
124
+ patterns: list[str] | None = None,
125
+ dry_run: bool = False,
126
+ ) -> CleanResult:
127
+ """Convenience function to clean packages.
128
+
129
+ Args:
130
+ workspace: Workspace to clean.
131
+ scope: Package scope filter.
132
+ patterns: Override clean patterns.
133
+ dry_run: Show what would be cleaned.
134
+
135
+ Returns:
136
+ Clean result.
137
+ """
138
+
139
+ context = CommandContext(workspace=workspace, dry_run=dry_run)
140
+ options = CleanOptions(scope=scope, patterns=patterns, dry_run=dry_run)
141
+ cmd = CleanCommand(context, options)
142
+ return await cmd.execute()
@@ -0,0 +1,116 @@
1
+ """Exec 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.execution import BatchResult, ParallelExecutor
10
+
11
+ if TYPE_CHECKING:
12
+ from pymelos.workspace import Package
13
+ from pymelos.workspace.workspace import Workspace
14
+
15
+
16
+ @dataclass
17
+ class ExecOptions:
18
+ """Options for exec command."""
19
+
20
+ command: str
21
+ scope: str | None = None
22
+ since: str | None = None
23
+ ignore: list[str] | None = None
24
+ concurrency: int = 4
25
+ fail_fast: bool = False
26
+ topological: bool = False # exec doesn't default to topological
27
+ include_dependents: bool = False
28
+
29
+
30
+ class ExecCommand(Command[BatchResult]):
31
+ """Execute an arbitrary command across packages.
32
+
33
+ Unlike 'run', exec takes a direct command string rather
34
+ than a script name from configuration.
35
+ """
36
+
37
+ def __init__(self, context: CommandContext, options: ExecOptions) -> None:
38
+ super().__init__(context)
39
+ self.options = options
40
+
41
+ def get_packages(self) -> list[Package]:
42
+ """Get packages to execute command in."""
43
+ from pymelos.filters import apply_filters_with_since
44
+
45
+ packages = list(self.workspace.packages.values())
46
+
47
+ return apply_filters_with_since(
48
+ packages,
49
+ self.workspace,
50
+ scope=self.options.scope,
51
+ since=self.options.since,
52
+ ignore=self.options.ignore,
53
+ include_dependents=self.options.include_dependents,
54
+ )
55
+
56
+ async def execute(self) -> BatchResult:
57
+ """Execute the command."""
58
+ packages = self.get_packages()
59
+ if not packages:
60
+ return BatchResult(results=[])
61
+
62
+ # Build environment
63
+ env = dict(self.context.env)
64
+ env.update(self.workspace.config.env)
65
+
66
+ executor = ParallelExecutor(
67
+ concurrency=self.options.concurrency,
68
+ fail_fast=self.options.fail_fast,
69
+ )
70
+
71
+ if self.options.topological:
72
+ batches = self.workspace.parallel_batches(packages)
73
+ return await executor.execute_batches(batches, self.options.command, env=env)
74
+ else:
75
+ return await executor.execute(packages, self.options.command, env=env)
76
+
77
+
78
+ async def exec_command(
79
+ workspace: Workspace,
80
+ command: str,
81
+ *,
82
+ scope: str | None = None,
83
+ since: str | None = None,
84
+ ignore: list[str] | None = None,
85
+ concurrency: int = 4,
86
+ fail_fast: bool = False,
87
+ topological: bool = False,
88
+ ) -> BatchResult:
89
+ """Convenience function to execute a command.
90
+
91
+ Args:
92
+ workspace: Workspace to run in.
93
+ command: Command to execute.
94
+ scope: Package scope filter.
95
+ since: Git reference.
96
+ ignore: Patterns to exclude.
97
+ concurrency: Parallel jobs.
98
+ fail_fast: Stop on first failure.
99
+ topological: Respect dependency order.
100
+
101
+ Returns:
102
+ Batch result.
103
+ """
104
+
105
+ context = CommandContext(workspace=workspace)
106
+ options = ExecOptions(
107
+ command=command,
108
+ scope=scope,
109
+ since=since,
110
+ ignore=ignore,
111
+ concurrency=concurrency,
112
+ fail_fast=fail_fast,
113
+ topological=topological,
114
+ )
115
+ cmd = ExecCommand(context, options)
116
+ return await cmd.execute()
@@ -0,0 +1,128 @@
1
+ """List command implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import Enum
7
+ from typing import TYPE_CHECKING
8
+
9
+ from pymelos.commands.base import CommandContext, SyncCommand
10
+
11
+ if TYPE_CHECKING:
12
+ from pymelos.workspace import Package
13
+ from pymelos.workspace.workspace import Workspace
14
+
15
+
16
+ class ListFormat(Enum):
17
+ """Output format for list command."""
18
+
19
+ TABLE = "table"
20
+ JSON = "json"
21
+ GRAPH = "graph"
22
+ NAMES = "names"
23
+
24
+
25
+ @dataclass
26
+ class PackageInfo:
27
+ """Information about a package for display."""
28
+
29
+ name: str
30
+ version: str
31
+ path: str
32
+ description: str | None
33
+ dependencies: list[str]
34
+ dependents: list[str]
35
+
36
+
37
+ @dataclass
38
+ class ListResult:
39
+ """Result of list command."""
40
+
41
+ packages: list[PackageInfo]
42
+
43
+
44
+ @dataclass
45
+ class ListOptions:
46
+ """Options for list command."""
47
+
48
+ scope: str | None = None
49
+ since: str | None = None
50
+ ignore: list[str] | None = None
51
+ format: ListFormat = ListFormat.TABLE
52
+ include_dependents: bool = False
53
+
54
+
55
+ class ListCommand(SyncCommand[ListResult]):
56
+ """List packages in the workspace."""
57
+
58
+ def __init__(self, context: CommandContext, options: ListOptions | None = None) -> None:
59
+ super().__init__(context)
60
+ self.options = options or ListOptions()
61
+
62
+ def get_packages(self) -> list[Package]:
63
+ """Get packages to list."""
64
+ from pymelos.filters import apply_filters_with_since
65
+
66
+ packages = list(self.workspace.packages.values())
67
+
68
+ return apply_filters_with_since(
69
+ packages,
70
+ self.workspace,
71
+ scope=self.options.scope,
72
+ since=self.options.since,
73
+ ignore=self.options.ignore,
74
+ include_dependents=self.options.include_dependents,
75
+ )
76
+
77
+ def execute(self) -> ListResult:
78
+ """Execute the list command."""
79
+ packages = self.get_packages()
80
+ graph = self.workspace.graph
81
+
82
+ infos: list[PackageInfo] = []
83
+ for pkg in packages:
84
+ deps = graph.get_dependencies(pkg.name)
85
+ dependents = graph.get_dependents(pkg.name)
86
+
87
+ infos.append(
88
+ PackageInfo(
89
+ name=pkg.name,
90
+ version=pkg.version,
91
+ path=str(pkg.path.relative_to(self.workspace.root)),
92
+ description=pkg.description,
93
+ dependencies=[d.name for d in deps],
94
+ dependents=[d.name for d in dependents],
95
+ )
96
+ )
97
+
98
+ # Sort by name
99
+ infos.sort(key=lambda p: p.name)
100
+
101
+ return ListResult(packages=infos)
102
+
103
+
104
+ def list_packages(
105
+ workspace: Workspace,
106
+ *,
107
+ scope: str | None = None,
108
+ since: str | None = None,
109
+ ignore: list[str] | None = None,
110
+ format: ListFormat = ListFormat.TABLE,
111
+ ) -> ListResult:
112
+ """Convenience function to list packages.
113
+
114
+ Args:
115
+ workspace: Workspace to list.
116
+ scope: Package scope filter.
117
+ since: Git reference.
118
+ ignore: Patterns to exclude.
119
+ format: Output format.
120
+
121
+ Returns:
122
+ List result with package info.
123
+ """
124
+
125
+ context = CommandContext(workspace=workspace)
126
+ options = ListOptions(scope=scope, since=since, ignore=ignore, format=format)
127
+ cmd = ListCommand(context, options)
128
+ return cmd.execute()