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,151 @@
1
+ """Init command implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import subprocess
7
+ from pathlib import Path
8
+
9
+ from pymelos.errors import ConfigurationError
10
+
11
+ DEFAULT_PYMELOS_YAML = """# pymelos workspace configuration
12
+ name: {name}
13
+
14
+ packages:
15
+ - packages/*
16
+
17
+ scripts:
18
+ test:
19
+ run: pytest tests/ -v
20
+ description: Run tests
21
+
22
+ lint:
23
+ run: ruff check .
24
+ description: Run linting
25
+
26
+ format:
27
+ run: ruff format .
28
+ description: Format code
29
+
30
+ typecheck:
31
+ run: type check
32
+ description: Run type checking
33
+
34
+ command_defaults:
35
+ concurrency: 4
36
+ fail_fast: false
37
+ topological: true
38
+
39
+ clean:
40
+ patterns:
41
+ - "__pycache__"
42
+ - "*.pyc"
43
+ - ".pytest_cache"
44
+ - ".mypy_cache"
45
+ - ".ruff_cache"
46
+ - "*.egg-info"
47
+ - "dist"
48
+ - "build"
49
+ protected:
50
+ - ".venv"
51
+ - ".git"
52
+
53
+ versioning:
54
+ commit_format: conventional
55
+ tag_format: "{{name}}@{{version}}"
56
+ changelog:
57
+ enabled: true
58
+ filename: CHANGELOG.md
59
+ """
60
+
61
+ DEFAULT_PYPROJECT_TOML = """[project]
62
+ name = "{name}"
63
+ version = "0.0.0"
64
+ description = "Python monorepo"
65
+ requires-python = ">=3.12"
66
+
67
+ [tool.uv]
68
+ workspace = {{ members = ["packages/*"] }}
69
+
70
+ dev-dependencies = [
71
+ "pytest>=8.0.0",
72
+ "ruff>=0.8.0",
73
+ "mypy>=1.10.0",
74
+ ]
75
+ """
76
+
77
+
78
+ def init_workspace(path: Path, name: str | None = None) -> None:
79
+ """Initialize a new pymelos workspace.
80
+
81
+ Args:
82
+ path: Directory to initialize.
83
+ name: Workspace name (defaults to directory name).
84
+
85
+ Raises:
86
+ ConfigurationError: If workspace already exists.
87
+ """
88
+ path = path.resolve()
89
+
90
+ if not path.exists():
91
+ path.mkdir(parents=True)
92
+
93
+ # Check if already initialized
94
+ if (path / "pymelos.yaml").exists():
95
+ raise ConfigurationError("Workspace already initialized", path=path / "pymelos.yaml")
96
+
97
+ # Use directory name as default
98
+ if not name:
99
+ name = path.name
100
+
101
+ # Create pymelos.yaml
102
+ pymelos_yaml = path / "pymelos.yaml"
103
+ pymelos_yaml.write_text(DEFAULT_PYMELOS_YAML.format(name=name), encoding="utf-8")
104
+
105
+ # Create pyproject.toml if it doesn't exist
106
+ pyproject = path / "pyproject.toml"
107
+ if not pyproject.exists():
108
+ pyproject.write_text(DEFAULT_PYPROJECT_TOML.format(name=name), encoding="utf-8")
109
+
110
+ # Create packages directory
111
+ packages_dir = path / "packages"
112
+ packages_dir.mkdir(exist_ok=True)
113
+
114
+ # Create .gitignore if it doesn't exist
115
+ gitignore = path / ".gitignore"
116
+ if not gitignore.exists():
117
+ gitignore.write_text(
118
+ """# Python
119
+ __pycache__/
120
+ *.py[cod]
121
+ *.so
122
+ .venv/
123
+ dist/
124
+ build/
125
+ *.egg-info/
126
+
127
+ # Testing
128
+ .pytest_cache/
129
+ .coverage
130
+ htmlcov/
131
+
132
+ # Type checking
133
+ .mypy_cache/
134
+
135
+ # Linting
136
+ .ruff_cache/
137
+
138
+ # IDE
139
+ .vscode/
140
+ .idea/
141
+
142
+ # OS
143
+ .DS_Store
144
+ """,
145
+ encoding="utf-8",
146
+ )
147
+
148
+ # Initialize git if not already a repo
149
+ if not (path / ".git").exists():
150
+ with contextlib.suppress(subprocess.CalledProcessError, FileNotFoundError):
151
+ subprocess.run(["git", "init"], cwd=path, capture_output=True, check=True)
@@ -0,0 +1,84 @@
1
+ """pymelos commands."""
2
+
3
+ from pymelos.commands.add import AddProjectCommand, AddProjectOptions, AddProjectResult, add_project
4
+ from pymelos.commands.base import Command, CommandContext, SyncCommand
5
+ from pymelos.commands.bootstrap import (
6
+ BootstrapCommand,
7
+ BootstrapOptions,
8
+ BootstrapResult,
9
+ bootstrap,
10
+ )
11
+ from pymelos.commands.changed import (
12
+ ChangedCommand,
13
+ ChangedOptions,
14
+ ChangedPackage,
15
+ ChangedResult,
16
+ get_changed_packages,
17
+ )
18
+ from pymelos.commands.clean import CleanCommand, CleanOptions, CleanResult, clean
19
+ from pymelos.commands.exec import ExecCommand, ExecOptions, exec_command
20
+ from pymelos.commands.list import (
21
+ ListCommand,
22
+ ListFormat,
23
+ ListOptions,
24
+ ListResult,
25
+ PackageInfo,
26
+ list_packages,
27
+ )
28
+ from pymelos.commands.release import (
29
+ PackageRelease,
30
+ ReleaseCommand,
31
+ ReleaseOptions,
32
+ ReleaseResult,
33
+ release,
34
+ )
35
+ from pymelos.commands.run import RunCommand, RunOptions, run_script
36
+
37
+ __all__ = [
38
+ # Base
39
+ "Command",
40
+ "SyncCommand",
41
+ "CommandContext",
42
+ # Bootstrap
43
+ "BootstrapCommand",
44
+ "BootstrapOptions",
45
+ "BootstrapResult",
46
+ "bootstrap",
47
+ # Run
48
+ "RunCommand",
49
+ "RunOptions",
50
+ "run_script",
51
+ # Exec
52
+ "ExecCommand",
53
+ "ExecOptions",
54
+ "exec_command",
55
+ # List
56
+ "ListCommand",
57
+ "ListOptions",
58
+ "ListResult",
59
+ "ListFormat",
60
+ "PackageInfo",
61
+ "list_packages",
62
+ # Clean
63
+ "CleanCommand",
64
+ "CleanOptions",
65
+ "CleanResult",
66
+ "clean",
67
+ # Changed
68
+ "ChangedCommand",
69
+ "ChangedOptions",
70
+ "ChangedResult",
71
+ "ChangedPackage",
72
+ "get_changed_packages",
73
+ # Release
74
+ "ReleaseCommand",
75
+ "ReleaseOptions",
76
+ "ReleaseResult",
77
+ "PackageRelease",
78
+ "release",
79
+ # Add Project
80
+ "AddProjectCommand",
81
+ "AddProjectOptions",
82
+ "AddProjectResult",
83
+ "add_project",
84
+ ]
@@ -0,0 +1,77 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import Literal
6
+
7
+ from pymelos import Workspace
8
+ from pymelos.commands.base import Command, CommandContext, pip_install_editable
9
+ from pymelos.execution import run_command
10
+
11
+
12
+ @dataclass
13
+ class AddProjectOptions:
14
+ name: str
15
+ project_type: Literal["lib", "app"] = "lib"
16
+ folder: str | None = None # default to workspace default folder
17
+ bare: bool = False
18
+ editable: bool = True # whether to pip install -e after creation
19
+
20
+
21
+ class AddProjectResult:
22
+ """Result of AddProjectCommand."""
23
+
24
+ def __init__(self, success: bool, path: Path, message: str):
25
+ self.success = success
26
+ self.path = path
27
+ self.message = message
28
+
29
+
30
+ class AddProjectCommand(Command[AddProjectResult]):
31
+ """Add a new library or app to the workspace."""
32
+
33
+ DEFAULT_FOLDERS = {
34
+ "lib": "packages",
35
+ "app": "examples",
36
+ }
37
+
38
+ def __init__(self, context: CommandContext, options: AddProjectOptions) -> None:
39
+ super().__init__(context)
40
+ self.options = options
41
+
42
+ async def execute(self) -> AddProjectResult:
43
+ name = self.options.name
44
+ project_type = self.options.project_type
45
+
46
+ # determine target folder
47
+ folder = self.options.folder or self.DEFAULT_FOLDERS[project_type]
48
+ target_dir = self.workspace.root / folder
49
+ project_path = target_dir / name
50
+
51
+ if project_path.exists():
52
+ raise RuntimeError(f"Project {name} already exists at {project_path}")
53
+
54
+ # run command
55
+ exit_code, stdout, stderr, _ = await run_command(
56
+ f"uv init {name} --{project_type} {'--bare' if self.options.bare else ''}",
57
+ cwd=target_dir,
58
+ env=self.context.env,
59
+ )
60
+
61
+ if project_type == "lib" and self.options.editable:
62
+ pip_install_editable(project_path)
63
+ # return result
64
+ return AddProjectResult(True, project_path, f"Created project {name} at {project_path}")
65
+
66
+
67
+ async def add_project(
68
+ workspace: Workspace,
69
+ name: str,
70
+ project_type: Literal["lib", "app"] = "lib",
71
+ folder: str | None = None,
72
+ editable: bool = True,
73
+ ) -> AddProjectResult:
74
+ context = CommandContext(workspace=workspace)
75
+ return await AddProjectCommand(
76
+ context, AddProjectOptions(name, project_type, folder, editable=editable)
77
+ ).execute()
@@ -0,0 +1,108 @@
1
+ """Base command infrastructure."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from collections.abc import Sequence
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+ from subprocess import run
10
+ from typing import Generic, TypeVar
11
+
12
+ from pymelos.workspace import Workspace
13
+
14
+ TResult = TypeVar("TResult")
15
+
16
+
17
+ @dataclass
18
+ class CommandContext:
19
+ """Context passed to all commands.
20
+
21
+ Attributes:
22
+ workspace: The workspace instance.
23
+ dry_run: If True, show what would happen without making changes.
24
+ verbose: If True, show detailed output.
25
+ """
26
+
27
+ workspace: Workspace
28
+ dry_run: bool = False
29
+ verbose: bool = False
30
+ env: dict[str, str] = field(default_factory=dict)
31
+
32
+
33
+ class Command(ABC, Generic[TResult]):
34
+ """Base class for all pymelos commands.
35
+
36
+ Commands encapsulate the logic for a specific operation.
37
+ They receive a context and return a result.
38
+ """
39
+
40
+ def __init__(self, context: CommandContext) -> None:
41
+ """Initialize command.
42
+
43
+ Args:
44
+ context: Command context.
45
+ """
46
+ self.context = context
47
+ self.workspace = context.workspace
48
+
49
+ @abstractmethod
50
+ async def execute(self) -> TResult:
51
+ """Execute the command.
52
+
53
+ Returns:
54
+ Command-specific result.
55
+ """
56
+ ...
57
+
58
+ def validate(self) -> list[str]:
59
+ """Validate that the command can be executed.
60
+
61
+ Returns:
62
+ List of validation errors (empty if valid).
63
+ """
64
+ return []
65
+
66
+
67
+ class SyncCommand(ABC, Generic[TResult]):
68
+ """Base class for synchronous commands."""
69
+
70
+ def __init__(self, context: CommandContext) -> None:
71
+ """Initialize command.
72
+
73
+ Args:
74
+ context: Command context.
75
+ """
76
+ self.context = context
77
+ self.workspace = context.workspace
78
+
79
+ @abstractmethod
80
+ def execute(self) -> TResult:
81
+ """Execute the command synchronously.
82
+
83
+ Returns:
84
+ Command-specific result.
85
+ """
86
+ ...
87
+
88
+ def validate(self) -> list[str]:
89
+ """Validate that the command can be executed.
90
+
91
+ Returns:
92
+ List of validation errors (empty if valid).
93
+ """
94
+ return []
95
+
96
+
97
+ def pip_install_editable(paths: Path | Sequence[Path | str]) -> None:
98
+ """
99
+ Install one or more workspace packages in editable mode.
100
+ """
101
+ if isinstance(paths, Path | str):
102
+ paths = [paths] # wrap single path into list
103
+
104
+ # Convert all to strings
105
+ paths_str = [str(p) for p in paths]
106
+
107
+ # Run uv pip install -e for all packages
108
+ run(["uv", "pip", "install", "-e", *paths_str], check=True)
@@ -0,0 +1,154 @@
1
+ """Bootstrap command implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from pymelos.commands.base import Command, CommandContext, pip_install_editable
8
+ from pymelos.execution import ExecutionResult
9
+ from pymelos.uv import sync
10
+ from pymelos.workspace.workspace import Workspace
11
+
12
+
13
+ @dataclass
14
+ class BootstrapResult:
15
+ """Result of bootstrap command.
16
+
17
+ Attributes:
18
+ success: Whether bootstrap succeeded.
19
+ packages_installed: Number of packages in workspace.
20
+ hook_results: Results of bootstrap hooks.
21
+ uv_output: Output from uv sync.
22
+ """
23
+
24
+ success: bool
25
+ packages_installed: int
26
+ hook_results: list[ExecutionResult]
27
+ uv_output: str
28
+
29
+
30
+ @dataclass
31
+ class BootstrapOptions:
32
+ """Options for bootstrap command."""
33
+
34
+ clean_first: bool = False
35
+ frozen: bool = False
36
+ locked: bool = True
37
+ skip_hooks: bool = False
38
+ editable: bool = True
39
+
40
+
41
+ class BootstrapCommand(Command[BootstrapResult]):
42
+ """Bootstrap the workspace.
43
+
44
+ This command:
45
+ 1. Runs uv sync to install dependencies
46
+ 2. Verifies workspace package linking
47
+ 3. Runs bootstrap hooks from configuration
48
+ """
49
+
50
+ def __init__(self, context: CommandContext, options: BootstrapOptions | None = None) -> None:
51
+ super().__init__(context)
52
+ self.options = options or BootstrapOptions()
53
+
54
+ async def execute(self) -> BootstrapResult:
55
+ """Execute bootstrap."""
56
+ from pymelos.commands.clean import CleanCommand, CleanOptions
57
+ from pymelos.execution import run_in_package
58
+
59
+ hook_results: list[ExecutionResult] = []
60
+
61
+ # Optionally clean first
62
+ if self.options.clean_first:
63
+ clean_cmd = CleanCommand(self.context, CleanOptions())
64
+ await clean_cmd.execute()
65
+
66
+ # Check if lockfile exists - if not, don't use --locked flag
67
+ lockfile = self.workspace.root / "uv.lock"
68
+ use_locked = self.options.locked and lockfile.exists()
69
+
70
+ # Run uv sync
71
+ exit_code, stdout, stderr = sync(
72
+ self.workspace.root,
73
+ frozen=self.options.frozen,
74
+ locked=use_locked,
75
+ dev=True,
76
+ all_packages=True,
77
+ )
78
+
79
+ # If --locked failed due to outdated lockfile, retry without --locked
80
+ if exit_code != 0 and use_locked and "needs to be updated" in stderr:
81
+ exit_code, stdout, stderr = sync(
82
+ self.workspace.root,
83
+ frozen=self.options.frozen,
84
+ locked=False,
85
+ )
86
+
87
+ if exit_code != 0:
88
+ return BootstrapResult(
89
+ success=False,
90
+ packages_installed=0,
91
+ hook_results=[],
92
+ uv_output=stderr or stdout,
93
+ )
94
+ # Install workspace packages (editable)
95
+ if self.options.editable and self.workspace.packages:
96
+ package_paths = [pkg.path for pkg in self.workspace.packages.values()]
97
+ pip_install_editable(package_paths)
98
+
99
+ # Run bootstrap hooks
100
+ if not self.options.skip_hooks:
101
+ for hook in self.workspace.config.bootstrap.hooks:
102
+ if hook.run_once:
103
+ # Run at workspace root
104
+ from pymelos.execution.runner import run_command
105
+
106
+ _, out, err, _ = await run_command(
107
+ hook.run,
108
+ self.workspace.root,
109
+ env=self.context.env,
110
+ )
111
+ else:
112
+ # Run in matching packages
113
+ packages = self.workspace.filter_packages(scope=hook.scope)
114
+ for pkg in packages:
115
+ result = await run_in_package(pkg, hook.run, env=self.context.env)
116
+ hook_results.append(result)
117
+
118
+ return BootstrapResult(
119
+ success=True,
120
+ packages_installed=len(self.workspace.packages),
121
+ hook_results=hook_results,
122
+ uv_output=stdout,
123
+ )
124
+
125
+
126
+ async def bootstrap(
127
+ workspace: Workspace,
128
+ *,
129
+ clean_first: bool = False,
130
+ frozen: bool = False,
131
+ skip_hooks: bool = False,
132
+ verbose: bool = False,
133
+ ) -> BootstrapResult:
134
+ """Convenience function to bootstrap a workspace.
135
+
136
+ Args:
137
+ workspace: Workspace to bootstrap.
138
+ clean_first: Clean before bootstrap.
139
+ frozen: Use frozen dependencies.
140
+ skip_hooks: Skip bootstrap hooks.
141
+ verbose: Show verbose output.
142
+
143
+ Returns:
144
+ Bootstrap result.
145
+ """
146
+
147
+ context = CommandContext(workspace=workspace, verbose=verbose)
148
+ options = BootstrapOptions(
149
+ clean_first=clean_first,
150
+ frozen=frozen,
151
+ skip_hooks=skip_hooks,
152
+ )
153
+ cmd = BootstrapCommand(context, options)
154
+ return await cmd.execute()