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,172 @@
1
+ """Execution result models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from enum import Enum
7
+
8
+
9
+ class ExecutionStatus(Enum):
10
+ """Status of a command execution."""
11
+
12
+ SUCCESS = "success"
13
+ FAILURE = "failure"
14
+ SKIPPED = "skipped"
15
+ CANCELLED = "cancelled"
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class ExecutionResult:
20
+ """Result of executing a command on a package.
21
+
22
+ Attributes:
23
+ package_name: Name of the package the command ran in.
24
+ status: Execution status.
25
+ exit_code: Process exit code.
26
+ stdout: Standard output.
27
+ stderr: Standard error.
28
+ duration_ms: Execution duration in milliseconds.
29
+ error: Exception if any occurred.
30
+ command: The command that was executed.
31
+ """
32
+
33
+ package_name: str
34
+ status: ExecutionStatus
35
+ exit_code: int
36
+ stdout: str = ""
37
+ stderr: str = ""
38
+ duration_ms: int = 0
39
+ error: Exception | None = None
40
+ command: str = ""
41
+
42
+ @property
43
+ def success(self) -> bool:
44
+ """Check if execution was successful."""
45
+ return self.status == ExecutionStatus.SUCCESS
46
+
47
+ @property
48
+ def failed(self) -> bool:
49
+ """Check if execution failed."""
50
+ return self.status == ExecutionStatus.FAILURE
51
+
52
+ @property
53
+ def skipped(self) -> bool:
54
+ """Check if execution was skipped."""
55
+ return self.status == ExecutionStatus.SKIPPED
56
+
57
+ @classmethod
58
+ def success_result(
59
+ cls,
60
+ package_name: str,
61
+ stdout: str = "",
62
+ stderr: str = "",
63
+ duration_ms: int = 0,
64
+ command: str = "",
65
+ ) -> ExecutionResult:
66
+ """Create a success result."""
67
+ return cls(
68
+ package_name=package_name,
69
+ status=ExecutionStatus.SUCCESS,
70
+ exit_code=0,
71
+ stdout=stdout,
72
+ stderr=stderr,
73
+ duration_ms=duration_ms,
74
+ command=command,
75
+ )
76
+
77
+ @classmethod
78
+ def failure_result(
79
+ cls,
80
+ package_name: str,
81
+ exit_code: int,
82
+ stdout: str = "",
83
+ stderr: str = "",
84
+ duration_ms: int = 0,
85
+ error: Exception | None = None,
86
+ command: str = "",
87
+ ) -> ExecutionResult:
88
+ """Create a failure result."""
89
+ return cls(
90
+ package_name=package_name,
91
+ status=ExecutionStatus.FAILURE,
92
+ exit_code=exit_code,
93
+ stdout=stdout,
94
+ stderr=stderr,
95
+ duration_ms=duration_ms,
96
+ error=error,
97
+ command=command,
98
+ )
99
+
100
+ @classmethod
101
+ def skipped_result(
102
+ cls,
103
+ package_name: str,
104
+ reason: str = "",
105
+ ) -> ExecutionResult:
106
+ """Create a skipped result."""
107
+ return cls(
108
+ package_name=package_name,
109
+ status=ExecutionStatus.SKIPPED,
110
+ exit_code=0,
111
+ stdout=reason,
112
+ )
113
+
114
+
115
+ @dataclass
116
+ class BatchResult:
117
+ """Result of executing a command across multiple packages.
118
+
119
+ Attributes:
120
+ results: Individual execution results.
121
+ total_duration_ms: Total execution duration.
122
+ """
123
+
124
+ results: list[ExecutionResult] = field(default_factory=list)
125
+ total_duration_ms: int = 0
126
+
127
+ @property
128
+ def all_success(self) -> bool:
129
+ """Check if all executions were successful."""
130
+ return all(r.success or r.skipped for r in self.results)
131
+
132
+ @property
133
+ def any_failure(self) -> bool:
134
+ """Check if any execution failed."""
135
+ return any(r.failed for r in self.results)
136
+
137
+ @property
138
+ def success_count(self) -> int:
139
+ """Count of successful executions."""
140
+ return sum(1 for r in self.results if r.success)
141
+
142
+ @property
143
+ def failure_count(self) -> int:
144
+ """Count of failed executions."""
145
+ return sum(1 for r in self.results if r.failed)
146
+
147
+ @property
148
+ def skipped_count(self) -> int:
149
+ """Count of skipped executions."""
150
+ return sum(1 for r in self.results if r.skipped)
151
+
152
+ @property
153
+ def failed_packages(self) -> list[str]:
154
+ """Get names of packages that failed."""
155
+ return [r.package_name for r in self.results if r.failed]
156
+
157
+ @property
158
+ def successful_packages(self) -> list[str]:
159
+ """Get names of packages that succeeded."""
160
+ return [r.package_name for r in self.results if r.success]
161
+
162
+ def add(self, result: ExecutionResult) -> None:
163
+ """Add a result to the batch."""
164
+ self.results.append(result)
165
+
166
+ def __len__(self) -> int:
167
+ """Number of results."""
168
+ return len(self.results)
169
+
170
+ def __iter__(self):
171
+ """Iterate over results."""
172
+ return iter(self.results)
@@ -0,0 +1,171 @@
1
+ """Command execution engine with standard asynchronous capture."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ import time
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING
10
+
11
+ from pymelos.execution.results import ExecutionResult
12
+
13
+ if TYPE_CHECKING:
14
+ from pymelos.workspace.package import Package
15
+
16
+
17
+ async def run_command(
18
+ command: str,
19
+ cwd: Path,
20
+ *,
21
+ env: dict[str, str] | None = None,
22
+ timeout: float | None = None,
23
+ ) -> tuple[int, str, str, int]:
24
+ """Run a shell command asynchronously.
25
+
26
+ Args:
27
+ command: Shell command to execute.
28
+ cwd: Working directory.
29
+ env: Environment variables (merged with current env).
30
+ timeout: Timeout in seconds.
31
+
32
+ Returns:
33
+ Tuple of (exit_code, stdout, stderr, duration_ms).
34
+ """
35
+ # Merge environment
36
+ run_env = os.environ.copy()
37
+ if env:
38
+ run_env.update(env)
39
+
40
+ start_time = time.monotonic()
41
+
42
+ try:
43
+ process = await asyncio.create_subprocess_shell(
44
+ command,
45
+ cwd=str(cwd),
46
+ stdout=asyncio.subprocess.PIPE,
47
+ stderr=asyncio.subprocess.PIPE,
48
+ env=run_env,
49
+ )
50
+
51
+ try:
52
+ # Use communicate to wait for the process and capture all output at once
53
+ stdout_bytes, stderr_bytes = await asyncio.wait_for(
54
+ process.communicate(),
55
+ timeout=timeout,
56
+ )
57
+ except (asyncio.TimeoutError, TimeoutError):
58
+ process.kill()
59
+ await process.wait()
60
+ duration_ms = int((time.monotonic() - start_time) * 1000)
61
+ return -1, "", f"Command timed out after {timeout}s", duration_ms
62
+
63
+ duration_ms = int((time.monotonic() - start_time) * 1000)
64
+
65
+ stdout = stdout_bytes.decode("utf-8", errors="replace")
66
+ stderr = stderr_bytes.decode("utf-8", errors="replace")
67
+
68
+ return process.returncode or 0, stdout, stderr, duration_ms
69
+
70
+ except Exception as e:
71
+ duration_ms = int((time.monotonic() - start_time) * 1000)
72
+ return -1, "", str(e), duration_ms
73
+
74
+
75
+ async def run_in_package(
76
+ package: Package,
77
+ command: str,
78
+ *,
79
+ env: dict[str, str] | None = None,
80
+ timeout: float | None = None,
81
+ ) -> ExecutionResult:
82
+ """Run a command in a package directory.
83
+
84
+ Args:
85
+ package: Package to run command in.
86
+ command: Shell command to execute.
87
+ env: Additional environment variables.
88
+ timeout: Timeout in seconds.
89
+
90
+ Returns:
91
+ Execution result.
92
+ """
93
+ print(package.name, command)
94
+ # Build environment with package-specific variables
95
+ run_env = env.copy() if env else {}
96
+ run_env["PYMELOS_PACKAGE_NAME"] = package.name
97
+ run_env["PYMELOS_PACKAGE_PATH"] = str(package.path)
98
+ run_env["PYMELOS_PACKAGE_VERSION"] = package.version
99
+
100
+ exit_code, stdout, stderr, duration_ms = await run_command(
101
+ command,
102
+ cwd=package.path,
103
+ env=run_env,
104
+ timeout=timeout,
105
+ )
106
+
107
+ if exit_code == 0:
108
+ return ExecutionResult.success_result(
109
+ package_name=package.name,
110
+ stdout=stdout,
111
+ stderr=stderr,
112
+ duration_ms=duration_ms,
113
+ command=command,
114
+ )
115
+ else:
116
+ return ExecutionResult.failure_result(
117
+ package_name=package.name,
118
+ exit_code=exit_code,
119
+ stdout=stdout,
120
+ stderr=stderr,
121
+ duration_ms=duration_ms,
122
+ command=command,
123
+ )
124
+
125
+
126
+ def run_command_sync(
127
+ command: str,
128
+ cwd: Path,
129
+ *,
130
+ env: dict[str, str] | None = None,
131
+ timeout: float | None = None,
132
+ ) -> tuple[int, str, str, int]:
133
+ """Run a shell command synchronously.
134
+
135
+ Args:
136
+ command: Shell command to execute.
137
+ cwd: Working directory.
138
+ env: Environment variables.
139
+ timeout: Timeout in seconds.
140
+
141
+ Returns:
142
+ Tuple of (exit_code, stdout, stderr, duration_ms).
143
+ """
144
+ import subprocess
145
+
146
+ run_env = os.environ.copy()
147
+ if env:
148
+ run_env.update(env)
149
+
150
+ start_time = time.monotonic()
151
+
152
+ try:
153
+ result = subprocess.run(
154
+ command,
155
+ shell=True,
156
+ cwd=cwd,
157
+ capture_output=True,
158
+ text=True,
159
+ env=run_env,
160
+ timeout=timeout,
161
+ )
162
+ duration_ms = int((time.monotonic() - start_time) * 1000)
163
+ return result.returncode, result.stdout, result.stderr, duration_ms
164
+
165
+ except subprocess.TimeoutExpired:
166
+ duration_ms = int((time.monotonic() - start_time) * 1000)
167
+ return -1, "", f"Command timed out after {timeout}s", duration_ms
168
+
169
+ except Exception as e:
170
+ duration_ms = int((time.monotonic() - start_time) * 1000)
171
+ return -1, "", str(e), duration_ms
@@ -0,0 +1,27 @@
1
+ """Package filtering utilities."""
2
+
3
+ from pymelos.filters.chain import apply_filters, apply_filters_with_since
4
+ from pymelos.filters.ignore import filter_by_ignore, should_ignore
5
+ from pymelos.filters.scope import filter_by_scope, match_scope, parse_scope
6
+ from pymelos.filters.since import (
7
+ filter_by_since,
8
+ get_changed_files,
9
+ get_changed_packages,
10
+ )
11
+
12
+ __all__ = [
13
+ # Chain
14
+ "apply_filters",
15
+ "apply_filters_with_since",
16
+ # Scope
17
+ "filter_by_scope",
18
+ "match_scope",
19
+ "parse_scope",
20
+ # Ignore
21
+ "filter_by_ignore",
22
+ "should_ignore",
23
+ # Since
24
+ "filter_by_since",
25
+ "get_changed_files",
26
+ "get_changed_packages",
27
+ ]
@@ -0,0 +1,101 @@
1
+ """Filter chain composition."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from pymelos.filters.ignore import filter_by_ignore
8
+ from pymelos.filters.scope import filter_by_scope
9
+
10
+ if TYPE_CHECKING:
11
+ from pymelos.workspace.package import Package
12
+ from pymelos.workspace.workspace import Workspace
13
+
14
+
15
+ def apply_filters(
16
+ packages: list[Package],
17
+ *,
18
+ scope: str | None = None,
19
+ ignore: list[str] | None = None,
20
+ names: list[str] | None = None,
21
+ ) -> list[Package]:
22
+ """Apply multiple filters to a package list.
23
+
24
+ Filters are applied in order:
25
+ 1. Explicit names (if provided, only these packages)
26
+ 2. Scope pattern matching
27
+ 3. Ignore pattern exclusion
28
+
29
+ Args:
30
+ packages: List of packages to filter.
31
+ scope: Comma-separated names or glob patterns.
32
+ ignore: Patterns to exclude.
33
+ names: Explicit list of package names (overrides scope).
34
+
35
+ Returns:
36
+ Filtered list of packages.
37
+ """
38
+ result = packages
39
+
40
+ # If explicit names provided, filter to just those first
41
+ if names:
42
+ name_set = set(names)
43
+ result = [p for p in result if p.name in name_set]
44
+ else:
45
+ # Apply scope filter
46
+ result = filter_by_scope(result, scope)
47
+
48
+ # Apply ignore filter
49
+ result = filter_by_ignore(result, ignore)
50
+
51
+ return result
52
+
53
+
54
+ def apply_filters_with_since(
55
+ packages: list[Package],
56
+ workspace: Workspace,
57
+ *,
58
+ scope: str | None = None,
59
+ since: str | None = None,
60
+ ignore: list[str] | None = None,
61
+ include_dependents: bool = False,
62
+ ) -> list[Package]:
63
+ """Apply filters including git-based since filter.
64
+
65
+ Args:
66
+ packages: List of packages to filter.
67
+ workspace: Workspace instance (needed for since filter).
68
+ scope: Comma-separated names or glob patterns.
69
+ since: Git reference for change detection.
70
+ ignore: Patterns to exclude.
71
+ include_dependents: Include packages that depend on changed packages.
72
+
73
+ Returns:
74
+ Filtered list of packages.
75
+ """
76
+ from pymelos.filters.since import filter_by_since
77
+
78
+ result = packages
79
+
80
+ # Apply scope filter first
81
+ result = filter_by_scope(result, scope)
82
+
83
+ # Apply since filter (git-based)
84
+ result = filter_by_since(
85
+ result,
86
+ workspace,
87
+ since,
88
+ include_dependents=include_dependents,
89
+ )
90
+
91
+ # Apply ignore filter last
92
+ result = filter_by_ignore(result, ignore)
93
+
94
+ return result
95
+
96
+
97
+ # Type alias for filter function signature
98
+ if TYPE_CHECKING:
99
+ from collections.abc import Callable
100
+
101
+ FilterFunc = Callable[[list["Package"]], list["Package"]]
@@ -0,0 +1,60 @@
1
+ """Ignore-based package filtering."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fnmatch
6
+ from typing import TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ from pymelos.workspace.package import Package
10
+
11
+
12
+ def should_ignore(package: Package, patterns: list[str]) -> bool:
13
+ """Check if a package matches any ignore pattern.
14
+
15
+ Args:
16
+ package: Package to check.
17
+ patterns: List of ignore patterns.
18
+
19
+ Returns:
20
+ True if package should be ignored.
21
+ """
22
+ if not patterns:
23
+ return False
24
+
25
+ name = package.name
26
+ path_str = str(package.path)
27
+
28
+ for pattern in patterns:
29
+ # Match by name
30
+ if fnmatch.fnmatch(name, pattern):
31
+ return True
32
+
33
+ # Match by normalized name
34
+ if fnmatch.fnmatch(name.replace("-", "_"), pattern.replace("-", "_")):
35
+ return True
36
+
37
+ # Match by path
38
+ if fnmatch.fnmatch(path_str, pattern):
39
+ return True
40
+
41
+ return False
42
+
43
+
44
+ def filter_by_ignore(
45
+ packages: list[Package],
46
+ ignore: list[str] | None,
47
+ ) -> list[Package]:
48
+ """Filter out packages matching ignore patterns.
49
+
50
+ Args:
51
+ packages: List of packages to filter.
52
+ ignore: List of ignore patterns.
53
+
54
+ Returns:
55
+ Filtered list of packages (not matching any ignore pattern).
56
+ """
57
+ if not ignore:
58
+ return packages
59
+
60
+ return [p for p in packages if not should_ignore(p, ignore)]
@@ -0,0 +1,90 @@
1
+ """Scope-based package filtering."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fnmatch
6
+ from typing import TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ from pymelos.workspace.package import Package
10
+
11
+
12
+ def parse_scope(scope: str) -> list[str]:
13
+ """Parse a scope string into individual patterns.
14
+
15
+ Scope can be comma-separated names or glob patterns:
16
+ - "core,api" -> ["core", "api"]
17
+ - "*-lib" -> ["*-lib"]
18
+ - "core,*-lib" -> ["core", "*-lib"]
19
+
20
+ Args:
21
+ scope: Comma-separated scope string.
22
+
23
+ Returns:
24
+ List of individual patterns.
25
+ """
26
+ if not scope:
27
+ return []
28
+
29
+ patterns = [p.strip() for p in scope.split(",")]
30
+ return [p for p in patterns if p]
31
+
32
+
33
+ def match_scope(package: Package, patterns: list[str]) -> bool:
34
+ """Check if a package matches any of the scope patterns.
35
+
36
+ Args:
37
+ package: Package to check.
38
+ patterns: List of name or glob patterns.
39
+
40
+ Returns:
41
+ True if package matches any pattern.
42
+ """
43
+ if not patterns:
44
+ return True # No filter means match all
45
+
46
+ name = package.name
47
+
48
+ for pattern in patterns:
49
+ # Exact match
50
+ if name == pattern:
51
+ return True
52
+
53
+ # Case-insensitive exact match
54
+ if name.lower() == pattern.lower():
55
+ return True
56
+
57
+ # Glob pattern match
58
+ if fnmatch.fnmatch(name, pattern):
59
+ return True
60
+
61
+ # Try with normalized names (replace - with _)
62
+ normalized_name = name.replace("-", "_")
63
+ normalized_pattern = pattern.replace("-", "_")
64
+ if fnmatch.fnmatch(normalized_name, normalized_pattern):
65
+ return True
66
+
67
+ return False
68
+
69
+
70
+ def filter_by_scope(
71
+ packages: list[Package],
72
+ scope: str | None,
73
+ ) -> list[Package]:
74
+ """Filter packages by scope pattern.
75
+
76
+ Args:
77
+ packages: List of packages to filter.
78
+ scope: Comma-separated names or glob patterns.
79
+
80
+ Returns:
81
+ Filtered list of packages.
82
+ """
83
+ if not scope:
84
+ return packages
85
+
86
+ patterns = parse_scope(scope)
87
+ if not patterns:
88
+ return packages
89
+
90
+ return [p for p in packages if match_scope(p, patterns)]