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.
- cupli/__init__.py +1 -0
- cupli/__main__.py +6 -0
- cupli/cli/__init__.py +7 -0
- cupli/cli/_completion.py +157 -0
- cupli/cli/container.py +28 -0
- cupli/cli/dashboard.py +106 -0
- cupli/cli/diagnostics.py +74 -0
- cupli/cli/exec.py +364 -0
- cupli/cli/git.py +189 -0
- cupli/cli/hooks.py +133 -0
- cupli/cli/lifecycle.py +232 -0
- cupli/cli/mounts.py +114 -0
- cupli/cli/root.py +395 -0
- cupli/cli/workspace.py +459 -0
- cupli/core/__init__.py +1 -0
- cupli/core/c3.py +116 -0
- cupli/core/cache.py +149 -0
- cupli/core/env_resolver.py +189 -0
- cupli/core/loader.py +461 -0
- cupli/core/parser.py +159 -0
- cupli/core/registry.py +277 -0
- cupli/domain/__init__.py +1 -0
- cupli/domain/consts.py +137 -0
- cupli/domain/enums.py +58 -0
- cupli/domain/errors.py +285 -0
- cupli/domain/models.py +477 -0
- cupli/domain/plan.py +92 -0
- cupli/domain/runtime.py +42 -0
- cupli/manage.py +10 -0
- cupli/services/__init__.py +1 -0
- cupli/services/compose_service.py +745 -0
- cupli/services/filter_service.py +140 -0
- cupli/services/git_service.py +366 -0
- cupli/services/hooks_service.py +472 -0
- cupli/services/ide_setup_service.py +233 -0
- cupli/services/mounts_service.py +122 -0
- cupli/services/workspace_service.py +329 -0
- cupli/utils/__init__.py +1 -0
- cupli/utils/console.py +161 -0
- cupli/utils/exceptions.py +90 -0
- cupli/utils/fuzzy.py +39 -0
- cupli/utils/git.py +141 -0
- cupli/utils/json.py +14 -0
- cupli/utils/lock.py +101 -0
- cupli/utils/path.py +85 -0
- cupli/utils/subprocess.py +74 -0
- cupli/version.py +42 -0
- cupli-0.1.1.dist-info/METADATA +906 -0
- cupli-0.1.1.dist-info/RECORD +52 -0
- cupli-0.1.1.dist-info/WHEEL +4 -0
- cupli-0.1.1.dist-info/entry_points.txt +5 -0
- 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
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
|
+
"""
|
cupli/cli/_completion.py
ADDED
|
@@ -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",)
|
cupli/cli/diagnostics.py
ADDED
|
@@ -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")
|