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
pymelos/__init__.py ADDED
@@ -0,0 +1,63 @@
1
+ """pymelos - Python monorepo manager.
2
+
3
+ A Melos-like monorepo management tool for Python, providing:
4
+ - Workspace discovery and package management
5
+ - Parallel command execution with dependency ordering
6
+ - Git-based change detection
7
+ - Semantic versioning and release management
8
+ - VS Code integration
9
+ """
10
+
11
+ from pymelos.config import PyMelosConfig, load_config
12
+ from pymelos.errors import (
13
+ BootstrapError,
14
+ ConfigurationError,
15
+ CyclicDependencyError,
16
+ ExecutionError,
17
+ GitError,
18
+ PackageNotFoundError,
19
+ PublishError,
20
+ PyMelosError,
21
+ ReleaseError,
22
+ ScriptNotFoundError,
23
+ ValidationError,
24
+ WorkspaceNotFoundError,
25
+ )
26
+ from pymelos.execution import (
27
+ BatchResult,
28
+ ExecutionResult,
29
+ ExecutionStatus,
30
+ ParallelExecutor,
31
+ )
32
+ from pymelos.workspace import DependencyGraph, Package, Workspace
33
+
34
+ __version__ = "0.1.3"
35
+
36
+ __all__ = [
37
+ # Version
38
+ "__version__",
39
+ # Core
40
+ "Workspace",
41
+ "Package",
42
+ "DependencyGraph",
43
+ "PyMelosConfig",
44
+ "load_config",
45
+ # Execution
46
+ "ExecutionResult",
47
+ "ExecutionStatus",
48
+ "BatchResult",
49
+ "ParallelExecutor",
50
+ # Errors
51
+ "PyMelosError",
52
+ "ConfigurationError",
53
+ "WorkspaceNotFoundError",
54
+ "PackageNotFoundError",
55
+ "CyclicDependencyError",
56
+ "ScriptNotFoundError",
57
+ "ExecutionError",
58
+ "BootstrapError",
59
+ "GitError",
60
+ "ReleaseError",
61
+ "PublishError",
62
+ "ValidationError",
63
+ ]
pymelos/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow running pymelos as a module: python -m pymelos."""
2
+
3
+ from pymelos.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1,5 @@
1
+ """pymelos CLI."""
2
+
3
+ from pymelos.cli.app import app, main
4
+
5
+ __all__ = ["app", "main"]
@@ -0,0 +1,6 @@
1
+ """CLI entry point for `python -m pymelos.cli`."""
2
+
3
+ from pymelos.cli.app import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
pymelos/cli/app.py ADDED
@@ -0,0 +1,527 @@
1
+ """pymelos CLI application."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from pathlib import Path
7
+ from typing import Annotated, Literal
8
+
9
+ import typer
10
+ from rich.console import Console
11
+ from rich.markup import escape
12
+ from rich.table import Table
13
+
14
+ from pymelos.errors import PyMelosError
15
+ from pymelos.workspace import Workspace
16
+
17
+
18
+ def version_callback(value: bool) -> None:
19
+ """Print version and exit."""
20
+ if value:
21
+ from pymelos import __version__
22
+
23
+ print(f"pymelos {__version__}")
24
+ raise typer.Exit()
25
+
26
+
27
+ app = typer.Typer(
28
+ name="pymelos",
29
+ help="Python monorepo manager powered",
30
+ no_args_is_help=True,
31
+ add_completion=False,
32
+ )
33
+
34
+
35
+ @app.callback()
36
+ def _app_callback(
37
+ version: Annotated[ # noqa: ARG001
38
+ bool,
39
+ typer.Option("--version", "-V", help="Show version and exit", callback=version_callback),
40
+ ] = False,
41
+ ) -> None:
42
+ """Python monorepo manager ."""
43
+ pass
44
+
45
+
46
+ console = Console()
47
+ error_console = Console(stderr=True)
48
+
49
+
50
+ def parse_comma_list(value: str | None) -> list[str] | None:
51
+ """Parse comma-separated string into list."""
52
+ return value.split(",") if value else None
53
+
54
+
55
+ def get_workspace(path: Path | None = None) -> Workspace:
56
+ """Load workspace from current directory or specified path."""
57
+ try:
58
+ return Workspace.discover(path)
59
+ except PyMelosError as e:
60
+ error_console.print(f"[red]Error:[/red] {e.message}")
61
+ raise typer.Exit(1) from e
62
+
63
+
64
+ @app.command()
65
+ def init(
66
+ path: Annotated[
67
+ Path | None,
68
+ typer.Argument(help="Directory to initialize"),
69
+ ] = None,
70
+ name: Annotated[
71
+ str | None,
72
+ typer.Option("--name", "-n", help="Workspace name"),
73
+ ] = None,
74
+ ) -> None:
75
+ """Initialize a new pymelos workspace."""
76
+ from pymelos.cli.commands.init import init_workspace
77
+
78
+ try:
79
+ init_workspace(path or Path.cwd(), name)
80
+ console.print("[green]Workspace initialized![/green]")
81
+ console.print("Run [bold]pymelos bootstrap[/bold] to install dependencies.")
82
+ except PyMelosError as e:
83
+ error_console.print(f"[red]Error:[/red] {e.message}")
84
+ raise typer.Exit(1) from e
85
+
86
+
87
+ @app.command()
88
+ def bootstrap(
89
+ clean: Annotated[
90
+ bool,
91
+ typer.Option("--clean", help="Clean before bootstrap"),
92
+ ] = False,
93
+ frozen: Annotated[
94
+ bool,
95
+ typer.Option("--frozen", help="Use frozen dependencies"),
96
+ ] = False,
97
+ skip_hooks: Annotated[
98
+ bool,
99
+ typer.Option("--skip-hooks", help="Skip bootstrap hooks"),
100
+ ] = False,
101
+ ) -> None:
102
+ """Install dependencies and link packages."""
103
+ from pymelos.commands import bootstrap as do_bootstrap
104
+
105
+ workspace = get_workspace()
106
+
107
+ async def run() -> None:
108
+ result = await do_bootstrap(
109
+ workspace,
110
+ clean_first=clean,
111
+ frozen=frozen,
112
+ skip_hooks=skip_hooks,
113
+ )
114
+ if result.success:
115
+ console.print(f"[green]Bootstrapped {result.packages_installed} packages[/green]")
116
+ else:
117
+ error_console.print(f"[red]Bootstrap failed:[/red] {result.uv_output}")
118
+ raise typer.Exit(1)
119
+
120
+ asyncio.run(run())
121
+
122
+
123
+ @app.command(name="add")
124
+ def run_add_project(
125
+ name: Annotated[
126
+ str,
127
+ typer.Argument(help="Project name"),
128
+ ],
129
+ project_type: Annotated[
130
+ Literal["lib", "app"],
131
+ typer.Option("--project-type", "-t", help="Project type"),
132
+ ] = "lib",
133
+ folder: Annotated[
134
+ str | None,
135
+ typer.Option("--folder", "-f", help="Target folder"),
136
+ ] = None,
137
+ editable: Annotated[
138
+ bool,
139
+ typer.Option("--editable", help="Install project as editable"),
140
+ ] = True,
141
+ ) -> None:
142
+ from pymelos.commands import add_project
143
+
144
+ workspace = get_workspace()
145
+
146
+ async def run() -> None:
147
+ result = await add_project(workspace, name, project_type, folder, editable)
148
+ if result.success:
149
+ console.print(f"[green]Added project {name}[/green]")
150
+ else:
151
+ error_console.print(f"[red]Failed to add project {name}:[/red] {result.message}")
152
+ raise typer.Exit(1)
153
+
154
+ asyncio.run(run())
155
+
156
+
157
+ @app.command("run")
158
+ def run_cmd(
159
+ script: Annotated[str, typer.Argument(help="Script name to run")],
160
+ scope: Annotated[
161
+ str | None,
162
+ typer.Option("--scope", "-s", help="Package scope filter"),
163
+ ] = None,
164
+ since: Annotated[
165
+ str | None,
166
+ typer.Option("--since", help="Only packages changed since git ref"),
167
+ ] = None,
168
+ ignore: Annotated[
169
+ str | None,
170
+ typer.Option("--ignore", "-i", help="Patterns to ignore (comma-separated)"),
171
+ ] = None,
172
+ concurrency: Annotated[
173
+ int,
174
+ typer.Option("--concurrency", "-c", help="Parallel jobs"),
175
+ ] = 4,
176
+ fail_fast: Annotated[
177
+ bool,
178
+ typer.Option("--fail-fast", help="Stop on first failure"),
179
+ ] = False,
180
+ no_topological: Annotated[
181
+ bool,
182
+ typer.Option("--no-topological", help="Ignore dependency order"),
183
+ ] = False,
184
+ ) -> None:
185
+ """Run a defined script across packages."""
186
+ from pymelos.commands import run_script
187
+
188
+ workspace = get_workspace()
189
+ ignore_list = parse_comma_list(ignore)
190
+
191
+ async def run() -> None:
192
+ result = await run_script(
193
+ workspace,
194
+ script,
195
+ scope=scope,
196
+ since=since,
197
+ ignore=ignore_list,
198
+ concurrency=concurrency,
199
+ fail_fast=fail_fast,
200
+ topological=not no_topological,
201
+ )
202
+
203
+ for r in result:
204
+ package_name = escape(f"[{r.package_name}]")
205
+ if r.success:
206
+ console.print(f"[green]✓[/green] {package_name} ({r.duration_ms}ms)")
207
+ if r.stdout:
208
+ console.print(r.stdout)
209
+ else:
210
+ console.print(f"[red]✗[/red] {package_name} (exit {r.exit_code})")
211
+ if r.stderr:
212
+ console.print(r.stderr)
213
+
214
+ if result.all_success:
215
+ console.print(f"\n[green]All {len(result)} packages passed[/green]")
216
+ else:
217
+ console.print(
218
+ f"\n[red]{result.failure_count} failed, {result.success_count} passed[/red]"
219
+ )
220
+ raise typer.Exit(1)
221
+
222
+ asyncio.run(run())
223
+
224
+
225
+ @app.command("exec")
226
+ def exec_cmd(
227
+ command: Annotated[str, typer.Argument(help="Command to execute")],
228
+ scope: Annotated[
229
+ str | None,
230
+ typer.Option("--scope", "-s", help="Package scope filter"),
231
+ ] = None,
232
+ since: Annotated[
233
+ str | None,
234
+ typer.Option("--since", help="Only packages changed since git ref"),
235
+ ] = None,
236
+ ignore: Annotated[
237
+ str | None,
238
+ typer.Option("--ignore", "-i", help="Patterns to ignore"),
239
+ ] = None,
240
+ concurrency: Annotated[
241
+ int,
242
+ typer.Option("--concurrency", "-c", help="Parallel jobs"),
243
+ ] = 4,
244
+ fail_fast: Annotated[
245
+ bool,
246
+ typer.Option("--fail-fast", help="Stop on first failure"),
247
+ ] = False,
248
+ ) -> None:
249
+ """Execute an arbitrary command across packages."""
250
+ from pymelos.commands import exec_command
251
+
252
+ workspace = get_workspace()
253
+ ignore_list = parse_comma_list(ignore)
254
+
255
+ async def run() -> None:
256
+ result = await exec_command(
257
+ workspace,
258
+ command,
259
+ scope=scope,
260
+ since=since,
261
+ ignore=ignore_list,
262
+ concurrency=concurrency,
263
+ fail_fast=fail_fast,
264
+ )
265
+
266
+ for r in result:
267
+ console.print(f"\n[bold][{r.package_name}][/bold]")
268
+ if r.stdout:
269
+ console.print(r.stdout)
270
+ if r.stderr:
271
+ error_console.print(r.stderr)
272
+
273
+ if not result.all_success:
274
+ raise typer.Exit(1)
275
+
276
+ asyncio.run(run())
277
+
278
+
279
+ @app.command("list")
280
+ def list_cmd(
281
+ scope: Annotated[
282
+ str | None,
283
+ typer.Option("--scope", "-s", help="Package scope filter"),
284
+ ] = None,
285
+ since: Annotated[
286
+ str | None,
287
+ typer.Option("--since", help="Only packages changed since git ref"),
288
+ ] = None,
289
+ json_output: Annotated[
290
+ bool,
291
+ typer.Option("--json", help="Output as JSON"),
292
+ ] = False,
293
+ graph: Annotated[
294
+ bool,
295
+ typer.Option("--graph", help="Show dependency graph"),
296
+ ] = False,
297
+ ) -> None:
298
+ """List workspace packages."""
299
+ from pymelos.commands import ListFormat, list_packages
300
+
301
+ workspace = get_workspace()
302
+
303
+ fmt = ListFormat.TABLE
304
+ if json_output:
305
+ fmt = ListFormat.JSON
306
+ elif graph:
307
+ fmt = ListFormat.GRAPH
308
+
309
+ result = list_packages(workspace, scope=scope, since=since, format=fmt)
310
+
311
+ if json_output:
312
+ import json
313
+
314
+ data = [
315
+ {
316
+ "name": p.name,
317
+ "version": p.version,
318
+ "path": p.path,
319
+ "description": p.description,
320
+ "dependencies": p.dependencies,
321
+ }
322
+ for p in result.packages
323
+ ]
324
+ console.print(json.dumps(data, indent=2))
325
+ elif graph:
326
+ # Simple tree output
327
+ for pkg in result.packages:
328
+ if not pkg.dependencies:
329
+ console.print(f"[bold]{pkg.name}[/bold] v{pkg.version}")
330
+ else:
331
+ deps_str = ", ".join(pkg.dependencies)
332
+ console.print(f"[bold]{pkg.name}[/bold] v{pkg.version} -> {deps_str}")
333
+ else:
334
+ table = Table(title="Packages")
335
+ table.add_column("Name", style="bold")
336
+ table.add_column("Version")
337
+ table.add_column("Path")
338
+ table.add_column("Dependencies")
339
+
340
+ for pkg in result.packages:
341
+ deps = ", ".join(pkg.dependencies) if pkg.dependencies else "-"
342
+ table.add_row(pkg.name, pkg.version, pkg.path, deps)
343
+
344
+ console.print(table)
345
+
346
+
347
+ @app.command()
348
+ def clean(
349
+ scope: Annotated[
350
+ str | None,
351
+ typer.Option("--scope", "-s", help="Package scope filter"),
352
+ ] = None,
353
+ dry_run: Annotated[
354
+ bool,
355
+ typer.Option("--dry-run", help="Show what would be cleaned"),
356
+ ] = False,
357
+ ) -> None:
358
+ """Clean build artifacts."""
359
+ from pymelos.commands import clean as do_clean
360
+
361
+ workspace = get_workspace()
362
+
363
+ async def run() -> None:
364
+ result = await do_clean(workspace, scope=scope, dry_run=dry_run)
365
+
366
+ if dry_run:
367
+ console.print("[yellow]Dry run - no files removed[/yellow]")
368
+
369
+ console.print(
370
+ f"{'Would clean' if dry_run else 'Cleaned'} "
371
+ f"{result.files_removed} files, {result.dirs_removed} directories "
372
+ f"({result.bytes_freed / 1024:.1f} KB)"
373
+ )
374
+
375
+ asyncio.run(run())
376
+
377
+
378
+ @app.command()
379
+ def changed(
380
+ since: Annotated[str, typer.Argument(help="Git reference (branch, tag, commit)")],
381
+ no_dependents: Annotated[
382
+ bool,
383
+ typer.Option("--no-dependents", help="Exclude dependent packages"),
384
+ ] = False,
385
+ json_output: Annotated[
386
+ bool,
387
+ typer.Option("--json", help="Output as JSON"),
388
+ ] = False,
389
+ ) -> None:
390
+ """List packages changed since a git reference."""
391
+ from pymelos.commands import get_changed_packages
392
+
393
+ workspace = get_workspace()
394
+ result = get_changed_packages(
395
+ workspace,
396
+ since,
397
+ include_dependents=not no_dependents,
398
+ )
399
+
400
+ if json_output:
401
+ import json
402
+
403
+ data = [
404
+ {
405
+ "name": p.name,
406
+ "path": p.path,
407
+ "files_changed": p.files_changed,
408
+ "is_dependent": p.is_dependent,
409
+ }
410
+ for p in result.changed
411
+ ]
412
+ console.print(json.dumps(data, indent=2))
413
+ else:
414
+ console.print(f"Packages changed since [bold]{since}[/bold]:")
415
+ for pkg in result.changed:
416
+ suffix = " [dim](dependent)[/dim]" if pkg.is_dependent else ""
417
+ console.print(f" - {pkg.name} ({pkg.files_changed} files){suffix}")
418
+
419
+ if not result.changed:
420
+ console.print(" [dim]No packages changed[/dim]")
421
+
422
+
423
+ @app.command()
424
+ def release(
425
+ scope: Annotated[
426
+ str | None,
427
+ typer.Option("--scope", "-s", help="Package scope filter"),
428
+ ] = None,
429
+ bump: Annotated[
430
+ str | None,
431
+ typer.Option("--bump", "-b", help="Force bump type (major, minor, patch)"),
432
+ ] = None,
433
+ prerelease: Annotated[
434
+ str | None,
435
+ typer.Option("--prerelease", help="Prerelease tag (alpha, beta, rc)"),
436
+ ] = None,
437
+ dry_run: Annotated[
438
+ bool,
439
+ typer.Option("--dry-run", help="Show what would be released"),
440
+ ] = False,
441
+ publish: Annotated[
442
+ bool,
443
+ typer.Option("--publish", help="Publish to PyPI"),
444
+ ] = False,
445
+ no_git_tag: Annotated[
446
+ bool,
447
+ typer.Option("--no-git-tag", help="Skip creating git tags"),
448
+ ] = False,
449
+ no_changelog: Annotated[
450
+ bool,
451
+ typer.Option("--no-changelog", help="Skip changelog generation"),
452
+ ] = False,
453
+ no_commit: Annotated[
454
+ bool,
455
+ typer.Option("--no-commit", help="Skip git commit"),
456
+ ] = False,
457
+ ) -> None:
458
+ """Version and publish packages."""
459
+ from pymelos.commands import release as do_release
460
+ from pymelos.versioning import BumpType
461
+
462
+ workspace = get_workspace()
463
+
464
+ bump_type = None
465
+ if bump:
466
+ try:
467
+ bump_type = BumpType[bump.upper()]
468
+ except KeyError:
469
+ error_console.print(f"[red]Invalid bump type:[/red] {bump}")
470
+ raise typer.Exit(1) from None
471
+
472
+ async def run() -> None:
473
+ result = await do_release(
474
+ workspace,
475
+ scope=scope,
476
+ bump=bump_type,
477
+ prerelease=prerelease,
478
+ dry_run=dry_run,
479
+ publish=publish,
480
+ no_git_tag=no_git_tag,
481
+ no_changelog=no_changelog,
482
+ no_commit=no_commit,
483
+ )
484
+
485
+ if not result.releases:
486
+ console.print("[yellow]No packages to release[/yellow]")
487
+ return
488
+
489
+ if dry_run:
490
+ console.print("[yellow]Dry run - no changes made[/yellow]\n")
491
+ console.print("Pending releases:")
492
+
493
+ table = Table()
494
+ table.add_column("Package")
495
+ table.add_column("Current")
496
+ table.add_column("Next")
497
+ table.add_column("Bump")
498
+
499
+ for r in result.releases:
500
+ table.add_row(
501
+ r.name,
502
+ r.old_version,
503
+ r.new_version,
504
+ r.bump_type.name.lower(),
505
+ )
506
+
507
+ console.print(table)
508
+
509
+ if not dry_run:
510
+ if result.success:
511
+ console.print(f"\n[green]Released {len(result.releases)} packages[/green]")
512
+ if result.commit_sha:
513
+ console.print(f"Commit: {result.commit_sha[:8]}")
514
+ else:
515
+ error_console.print(f"\n[red]Release failed:[/red] {result.error}")
516
+ raise typer.Exit(1)
517
+
518
+ asyncio.run(run())
519
+
520
+
521
+ def main() -> None:
522
+ """Main entry point."""
523
+ app()
524
+
525
+
526
+ if __name__ == "__main__":
527
+ main()
@@ -0,0 +1 @@
1
+ """CLI command implementations."""