cupli 0.1.1__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 (52) hide show
  1. cupli/__init__.py +1 -0
  2. cupli/__main__.py +6 -0
  3. cupli/cli/__init__.py +7 -0
  4. cupli/cli/_completion.py +157 -0
  5. cupli/cli/container.py +28 -0
  6. cupli/cli/dashboard.py +106 -0
  7. cupli/cli/diagnostics.py +74 -0
  8. cupli/cli/exec.py +364 -0
  9. cupli/cli/git.py +189 -0
  10. cupli/cli/hooks.py +133 -0
  11. cupli/cli/lifecycle.py +232 -0
  12. cupli/cli/mounts.py +114 -0
  13. cupli/cli/root.py +395 -0
  14. cupli/cli/workspace.py +459 -0
  15. cupli/core/__init__.py +1 -0
  16. cupli/core/c3.py +116 -0
  17. cupli/core/cache.py +149 -0
  18. cupli/core/env_resolver.py +189 -0
  19. cupli/core/loader.py +461 -0
  20. cupli/core/parser.py +159 -0
  21. cupli/core/registry.py +277 -0
  22. cupli/domain/__init__.py +1 -0
  23. cupli/domain/consts.py +137 -0
  24. cupli/domain/enums.py +58 -0
  25. cupli/domain/errors.py +285 -0
  26. cupli/domain/models.py +477 -0
  27. cupli/domain/plan.py +92 -0
  28. cupli/domain/runtime.py +42 -0
  29. cupli/manage.py +10 -0
  30. cupli/services/__init__.py +1 -0
  31. cupli/services/compose_service.py +745 -0
  32. cupli/services/filter_service.py +140 -0
  33. cupli/services/git_service.py +366 -0
  34. cupli/services/hooks_service.py +472 -0
  35. cupli/services/ide_setup_service.py +233 -0
  36. cupli/services/mounts_service.py +122 -0
  37. cupli/services/workspace_service.py +329 -0
  38. cupli/utils/__init__.py +1 -0
  39. cupli/utils/console.py +161 -0
  40. cupli/utils/exceptions.py +90 -0
  41. cupli/utils/fuzzy.py +39 -0
  42. cupli/utils/git.py +141 -0
  43. cupli/utils/json.py +14 -0
  44. cupli/utils/lock.py +101 -0
  45. cupli/utils/path.py +85 -0
  46. cupli/utils/subprocess.py +74 -0
  47. cupli/version.py +42 -0
  48. cupli-0.1.1.dist-info/METADATA +906 -0
  49. cupli-0.1.1.dist-info/RECORD +52 -0
  50. cupli-0.1.1.dist-info/WHEEL +4 -0
  51. cupli-0.1.1.dist-info/entry_points.txt +5 -0
  52. cupli-0.1.1.dist-info/licenses/LICENSE +21 -0
cupli/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Cupli — command-line orchestrator for multi-repository docker-compose workspaces."""
cupli/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allows ``python -m cupli`` to invoke the CLI."""
2
+
3
+ from cupli.manage import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
cupli/cli/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """CLI surface for cupli.
2
+
3
+ Composed of small typer apps merged in :mod:`cupli.cli.root`. Each
4
+ sub-module covers one area (workspace, lifecycle, exec, hooks, mounts,
5
+ diagnostics) so that ``cupli --help`` can group commands and so that each
6
+ team area lives in its own file.
7
+ """
@@ -0,0 +1,157 @@
1
+ """Shell-completion callbacks shared across the typer surface.
2
+
3
+ Every callback returns a list of candidate strings that ``startswith`` the
4
+ incomplete token. Loading the space is best-effort — on any error the
5
+ callback returns an empty list so completion never breaks the prompt.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING
11
+
12
+ if TYPE_CHECKING:
13
+ from cupli.core.loader import ResolvedSpace
14
+
15
+
16
+ def _resolved_space_quiet() -> ResolvedSpace | None:
17
+ """Detect + load the effective space without ANY side effects.
18
+
19
+ Returns ``None`` on any error so completion stays silent.
20
+ """
21
+ from pathlib import Path
22
+
23
+ from cupli.core import registry
24
+ from cupli.core.loader import load_space
25
+
26
+ try:
27
+ detected = registry.detect_current_space(Path.cwd())
28
+ return load_space(detected.path, auto_register=False, auto_cache=False)
29
+ except Exception:
30
+ return None
31
+
32
+
33
+ def complete_space_names(incomplete: str) -> list[str]:
34
+ """Complete every registered space name."""
35
+ from cupli.core import registry
36
+ from cupli.domain.errors import CupliError
37
+
38
+ try:
39
+ known = registry.list_known_spaces()
40
+ except CupliError:
41
+ return []
42
+ return [name for name in sorted(known) if name.startswith(incomplete)]
43
+
44
+
45
+ def complete_shortcut_names(incomplete: str) -> list[str]:
46
+ """Complete every ``commands.<name>`` entry declared in the current space.
47
+
48
+ Reads from ``~/.cache/cupli/<space>/cache.json`` when available (fast
49
+ path); falls back to a fresh load when the cache is cold or stale.
50
+ """
51
+ from pathlib import Path
52
+
53
+ from cupli.core import cache, registry
54
+
55
+ try:
56
+ detected = registry.detect_current_space(Path.cwd())
57
+ except Exception:
58
+ return []
59
+ cached = cache.read_commands(detected.path)
60
+ if cached is not None:
61
+ return [name for name in sorted(cached.commands) if name.startswith(incomplete)]
62
+ resolved = _resolved_space_quiet()
63
+ if resolved is None:
64
+ return []
65
+ return [name for name in sorted(resolved.space.commands) if name.startswith(incomplete)]
66
+
67
+
68
+ def complete_service_names(incomplete: str) -> list[str]:
69
+ """Complete docker-compose service names (``apps[*].service`` or app key)."""
70
+ resolved = _resolved_space_quiet()
71
+ if resolved is None:
72
+ return []
73
+ names = {app.primary_service_name(name) for name, app in resolved.space.apps.items()}
74
+ return sorted(name for name in names if name.startswith(incomplete))
75
+
76
+
77
+ def complete_app_names(incomplete: str) -> list[str]:
78
+ """Complete app keys from the current space (for ``cupli with -c``)."""
79
+ resolved = _resolved_space_quiet()
80
+ if resolved is None:
81
+ return []
82
+ return sorted(name for name in resolved.space.apps if name.startswith(incomplete))
83
+
84
+
85
+ def complete_mount_names(incomplete: str) -> list[str]:
86
+ """Complete declared mount names (``mounts[*]``)."""
87
+ resolved = _resolved_space_quiet()
88
+ if resolved is None:
89
+ return []
90
+ return sorted(name for name in resolved.space.mounts if name.startswith(incomplete))
91
+
92
+
93
+ def complete_tag_names(incomplete: str) -> list[str]:
94
+ """Complete every tag declared across ``apps[*].tags``."""
95
+ resolved = _resolved_space_quiet()
96
+ if resolved is None:
97
+ return []
98
+ tags: set[str] = set()
99
+ for app in resolved.space.apps.values():
100
+ tags.update(app.tags)
101
+ return sorted(tag for tag in tags if tag.startswith(incomplete))
102
+
103
+
104
+ def complete_hook_scope(incomplete: str) -> list[str]:
105
+ """Complete the fixed ``--scope`` choices for the hooks command."""
106
+ return [scope for scope in ("all", "apps", "bases", "mounts") if scope.startswith(incomplete)]
107
+
108
+
109
+ def complete_hook_targets(incomplete: str) -> list[str]:
110
+ """Complete target names across ``apps`` / ``bases`` / ``mounts``."""
111
+ resolved = _resolved_space_quiet()
112
+ if resolved is None:
113
+ return []
114
+ names: set[str] = set()
115
+ names.update(resolved.space.apps)
116
+ names.update(resolved.space.bases)
117
+ names.update(resolved.space.mounts)
118
+ return sorted(name for name in names if name.startswith(incomplete))
119
+
120
+
121
+ def complete_error_codes(incomplete: str) -> list[str]:
122
+ """Complete every known cupli error code (``E001`` … ``E0NN``)."""
123
+ from cupli.domain.errors import ERRORS
124
+
125
+ return sorted(code for code in ERRORS if code.startswith(incomplete.upper()))
126
+
127
+
128
+ def complete_branch_map(incomplete: str) -> list[str]:
129
+ """Complete ``name=`` candidates for ``--map`` options on git checkout.
130
+
131
+ Returns the component name with a trailing ``=`` so the shell stops at the
132
+ point where the user has to type the branch.
133
+ """
134
+ if "=" in incomplete:
135
+ return []
136
+ resolved = _resolved_space_quiet()
137
+ if resolved is None:
138
+ return []
139
+ names: set[str] = set()
140
+ names.update(resolved.space.apps)
141
+ names.update(resolved.space.bases)
142
+ names.update(resolved.space.mounts)
143
+ return sorted(f"{name}=" for name in names if name.startswith(incomplete))
144
+
145
+
146
+ __all__ = (
147
+ "complete_app_names",
148
+ "complete_branch_map",
149
+ "complete_error_codes",
150
+ "complete_hook_scope",
151
+ "complete_hook_targets",
152
+ "complete_mount_names",
153
+ "complete_service_names",
154
+ "complete_shortcut_names",
155
+ "complete_space_names",
156
+ "complete_tag_names",
157
+ )
cupli/cli/container.py ADDED
@@ -0,0 +1,28 @@
1
+ """DI container carried on :attr:`typer.Context.obj`.
2
+
3
+ Each typer command pulls dependencies out of the container instead of
4
+ instantiating them inline. Services are added by later milestones — M3
5
+ ships only the runtime context.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from typing import TYPE_CHECKING
12
+
13
+ if TYPE_CHECKING:
14
+ from cupli.domain.runtime import RuntimeContext
15
+
16
+
17
+ @dataclass
18
+ class Container:
19
+ """Per-invocation DI container.
20
+
21
+ Attributes:
22
+ runtime: immutable :class:`RuntimeContext` for this invocation.
23
+ """
24
+
25
+ runtime: RuntimeContext | None = None
26
+
27
+
28
+ __all__ = ("Container",)
cupli/cli/dashboard.py ADDED
@@ -0,0 +1,106 @@
1
+ """``cupli dashboard`` — minimal live status of workspace services.
2
+
3
+ A small Rich ``Live`` display that polls ``docker compose ps`` on a 2-second
4
+ cadence and re-renders the table. Press Ctrl-C to exit.
5
+
6
+ Designed as a low-overhead first step toward the v2-plan §0 idea of a
7
+ Textual TUI; if/when we adopt Textual, this command rebinds to the new app.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import subprocess
13
+ import time
14
+ from typing import Annotated
15
+
16
+ import typer
17
+ from rich.live import Live
18
+ from rich.table import Table
19
+
20
+ from cupli.cli.workspace import _resolve_space_path, _strict_vars
21
+ from cupli.core.loader import load_space
22
+ from cupli.services.compose_service import build_argv, make_plan
23
+ from cupli.utils.console import console
24
+ from cupli.utils.exceptions import suppress_known_exceptions
25
+
26
+
27
+ @suppress_known_exceptions
28
+ def dashboard_command(
29
+ ctx: typer.Context,
30
+ interval: Annotated[float, typer.Option("--interval", "-i", help="Polling interval in seconds.")] = 2.0,
31
+ ) -> None:
32
+ """Live status table of workspace services (Ctrl-C to exit)."""
33
+ space_path = _resolve_space_path(ctx)
34
+ resolved = load_space(space_path, strict_vars=_strict_vars(ctx))
35
+ plan = make_plan(resolved)
36
+ argv = build_argv(plan, ["ps", "--format", "json", "-a"])
37
+
38
+ with Live(_blank_table(), console=console, refresh_per_second=4, transient=False) as live:
39
+ try:
40
+ while True:
41
+ live.update(_render_table(argv))
42
+ time.sleep(max(0.5, interval))
43
+ except KeyboardInterrupt:
44
+ pass
45
+
46
+
47
+ def _render_table(argv: list[str]) -> Table:
48
+ """Run ``docker compose ps --format json`` and render the output as a table."""
49
+ rows = _read_rows(argv)
50
+ table = Table(title="Cupli dashboard (Ctrl-C to exit)", show_lines=False)
51
+ table.add_column("service", style="cyan", no_wrap=True)
52
+ table.add_column("state", style="white")
53
+ table.add_column("image", style="white")
54
+ table.add_column("ports", style="white")
55
+ if not rows:
56
+ table.add_row("(no services)", "—", "—", "—")
57
+ return table
58
+ for row in rows:
59
+ state = row.get("State", "?")
60
+ styled_state = f"[green]{state}[/green]" if state == "running" else f"[yellow]{state}[/yellow]"
61
+ table.add_row(
62
+ row.get("Service", row.get("Name", "?")),
63
+ styled_state,
64
+ row.get("Image", "?"),
65
+ row.get("Publishers") and str(row["Publishers"]) or "—",
66
+ )
67
+ return table
68
+
69
+
70
+ def _blank_table() -> Table:
71
+ """Initial table shown before the first poll completes."""
72
+ table = Table(title="Cupli dashboard (warming up)")
73
+ table.add_column("status")
74
+ table.add_row("polling …")
75
+ return table
76
+
77
+
78
+ def _read_rows(argv: list[str]) -> list[dict]:
79
+ """Parse the JSON output of ``docker compose ps --format json``."""
80
+ import json
81
+
82
+ try:
83
+ completed = subprocess.run(
84
+ argv,
85
+ capture_output=True,
86
+ text=True,
87
+ check=False,
88
+ timeout=10,
89
+ )
90
+ except (OSError, subprocess.TimeoutExpired):
91
+ return []
92
+ if completed.returncode != 0:
93
+ return []
94
+ rows: list[dict] = []
95
+ for line in completed.stdout.splitlines():
96
+ line = line.strip()
97
+ if not line:
98
+ continue
99
+ try:
100
+ rows.append(json.loads(line))
101
+ except json.JSONDecodeError:
102
+ continue
103
+ return rows
104
+
105
+
106
+ __all__ = ("dashboard_command",)
@@ -0,0 +1,74 @@
1
+ """``cupli graph`` / ``cupli stats`` — at-a-glance discovery commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ import typer
8
+ from rich.tree import Tree
9
+
10
+ from cupli.cli.workspace import _resolve_space_path, _strict_vars
11
+ from cupli.core.loader import load_space
12
+ from cupli.services.compose_service import build_env, make_plan
13
+ from cupli.utils.console import console
14
+ from cupli.utils.exceptions import suppress_known_exceptions
15
+ from cupli.utils.subprocess import run_command
16
+
17
+
18
+ @suppress_known_exceptions
19
+ def graph_command(ctx: typer.Context) -> None:
20
+ """Print a tree of bases / apps + deps + mounts for the current space."""
21
+ space_path = _resolve_space_path(ctx)
22
+ resolved = load_space(space_path, strict_vars=_strict_vars(ctx))
23
+
24
+ root = Tree(f"[bold cyan]{resolved.space.name}[/bold cyan] [dim]({resolved.space_dir})[/dim]")
25
+ if resolved.space.bases:
26
+ bases_node = root.add("[bold]bases[/bold]")
27
+ for name in sorted(resolved.space.bases):
28
+ bases_node.add(f"[white]{name}[/white]")
29
+ apps_node = root.add("[bold]apps[/bold]")
30
+ for name in sorted(resolved.space.apps):
31
+ app = resolved.space.apps[name]
32
+ label = f"[white]{name}[/white]"
33
+ if app.tags:
34
+ label += f" [dim]tags={','.join(app.tags)}[/dim]"
35
+ label += f" [dim]mode={app.mode.value}[/dim]"
36
+ node = apps_node.add(label)
37
+ if app.bases:
38
+ node.add(f"[dim]bases: {', '.join(app.bases)}[/dim]")
39
+ if app.deps:
40
+ deps_str = ", ".join(f"{dep} [{','.join(m.value for m in modes)}]" for dep, modes in app.deps.items())
41
+ node.add(f"[yellow]deps:[/yellow] {deps_str}")
42
+ if resolved.space.mounts:
43
+ mounts_node = root.add("[bold]mounts[/bold]")
44
+ for name in sorted(resolved.space.mounts):
45
+ mount = resolved.space.mounts[name]
46
+ mounts_node.add(
47
+ f"[white]{name}[/white] -> [green]{', '.join(mount.hosted_in)}[/green]"
48
+ f" [dim]exec_path={mount.exec_path}[/dim]",
49
+ )
50
+ if resolved.space.commands:
51
+ cmd_node = root.add("[bold]commands[/bold]")
52
+ for cmd_name in sorted(resolved.space.commands):
53
+ sc = resolved.space.commands[cmd_name]
54
+ cmd_node.add(f"[cyan]{cmd_name}[/cyan] [dim]in {sc.container}: {sc.run}[/dim]")
55
+ console.print(root)
56
+
57
+
58
+ @suppress_known_exceptions
59
+ def stats_command(
60
+ ctx: typer.Context,
61
+ follow: Annotated[bool, typer.Option("--follow", "-f", help="Stream stats live.")] = False,
62
+ ) -> None:
63
+ """Show docker resource usage for workspace services (wrapper over ``docker stats``)."""
64
+ space_path = _resolve_space_path(ctx)
65
+ resolved = load_space(space_path, strict_vars=_strict_vars(ctx))
66
+ plan = make_plan(resolved)
67
+ env = build_env(plan)
68
+ args = ["docker", "stats"]
69
+ if not follow:
70
+ args.append("--no-stream")
71
+ run_command(args, cwd=plan.project_dir, env=env)
72
+
73
+
74
+ __all__ = ("graph_command", "stats_command")