rig-cli 1.0.0__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 (59) hide show
  1. rig/__init__.py +6 -0
  2. rig/__main__.py +8 -0
  3. rig/cli.py +107 -0
  4. rig/commands/__init__.py +23 -0
  5. rig/commands/check.py +125 -0
  6. rig/commands/common.py +149 -0
  7. rig/commands/dispatch.py +87 -0
  8. rig/commands/down/__init__.py +139 -0
  9. rig/commands/down/runner.py +136 -0
  10. rig/commands/init.py +148 -0
  11. rig/commands/logs.py +146 -0
  12. rig/commands/prune.py +142 -0
  13. rig/commands/ps.py +149 -0
  14. rig/commands/status.py +149 -0
  15. rig/commands/up/__init__.py +111 -0
  16. rig/commands/up/context.py +36 -0
  17. rig/commands/up/loop.py +150 -0
  18. rig/commands/up/relink.py +88 -0
  19. rig/commands/up/rollback.py +49 -0
  20. rig/commands/up/runner.py +118 -0
  21. rig/commands/up/service.py +136 -0
  22. rig/compose/__init__.py +1 -0
  23. rig/compose/client.py +144 -0
  24. rig/compose/context.py +56 -0
  25. rig/compose/discovery.py +121 -0
  26. rig/compose/docker.py +117 -0
  27. rig/compose/starter.py +145 -0
  28. rig/compose/stopper.py +71 -0
  29. rig/compose/supervisor.py +78 -0
  30. rig/core/__init__.py +1 -0
  31. rig/core/constants.py +65 -0
  32. rig/core/env.py +83 -0
  33. rig/core/errors.py +74 -0
  34. rig/core/identity.py +141 -0
  35. rig/core/locks.py +112 -0
  36. rig/core/state.py +150 -0
  37. rig/core/terminal.py +145 -0
  38. rig/manifest/__init__.py +1 -0
  39. rig/manifest/detector.py +138 -0
  40. rig/manifest/inspect.py +18 -0
  41. rig/manifest/loader.py +146 -0
  42. rig/manifest/models.py +129 -0
  43. rig/manifest/parser.py +123 -0
  44. rig/manifest/schema.py +89 -0
  45. rig/net/__init__.py +1 -0
  46. rig/net/health.py +76 -0
  47. rig/net/ports.py +141 -0
  48. rig/net/probe.py +56 -0
  49. rig/net/registry.py +132 -0
  50. rig/parser.py +72 -0
  51. rig/proc/__init__.py +1 -0
  52. rig/proc/process.py +133 -0
  53. rig/proc/record.py +54 -0
  54. rig/proc/spawn.py +130 -0
  55. rig/proc/teardown.py +139 -0
  56. rig_cli-1.0.0.dist-info/METADATA +503 -0
  57. rig_cli-1.0.0.dist-info/RECORD +59 -0
  58. rig_cli-1.0.0.dist-info/WHEEL +4 -0
  59. rig_cli-1.0.0.dist-info/entry_points.txt +3 -0
rig/compose/docker.py ADDED
@@ -0,0 +1,117 @@
1
+ """Plain Docker container queries and lifecycle supervision."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from collections.abc import Mapping
7
+ from typing import Any
8
+
9
+ from rig.compose.client import run_docker
10
+ from rig.compose.context import INHERIT_DOCKER_HOST, record_compose_env, record_docker_endpoint
11
+ from rig.core.errors import RigError
12
+
13
+ DOCKER_ABSENT_MARKERS = ("no such object", "no such container")
14
+
15
+
16
+ def docker_label_container_ids(record: Mapping[str, Any]) -> list[str] | None:
17
+ """Return the container IDs Compose labelled with this record's project and service."""
18
+ context, docker_host = record_docker_endpoint(record)
19
+ filters = [
20
+ "--filter",
21
+ f"label=com.docker.compose.project={record.get('instance')}",
22
+ "--filter",
23
+ f"label=com.docker.compose.service={record.get('compose_service')}",
24
+ ]
25
+ try:
26
+ res = run_docker(
27
+ ["ps", "-q", "-a", *filters],
28
+ context,
29
+ docker_host=docker_host,
30
+ env=record_compose_env(record),
31
+ )
32
+ except RigError:
33
+ return None
34
+ if res.returncode != 0:
35
+ return None
36
+ return [line.strip() for line in res.stdout.splitlines() if line.strip()]
37
+
38
+
39
+ def docker_reports_no_such_object(probe: subprocess.CompletedProcess) -> bool:
40
+ answer = f"{probe.stderr or ''}\n{probe.stdout or ''}".lower()
41
+ return any(m in answer for m in DOCKER_ABSENT_MARKERS)
42
+
43
+
44
+ def docker_container_status(
45
+ container: str,
46
+ context: Any = None,
47
+ *args: Any,
48
+ **kwargs: Any,
49
+ ) -> str:
50
+ p = list(args)
51
+ docker_host = p.pop(0) if p else kwargs.get("docker_host", INHERIT_DOCKER_HOST)
52
+ env = p.pop(0) if p else kwargs.get("env")
53
+ try:
54
+ cmd = ["inspect", "--format", "{{.State.Status}}", str(container)]
55
+ probe = run_docker(cmd, context, docker_host=docker_host, env=env)
56
+ except RigError:
57
+ return "error"
58
+ if probe.returncode != 0:
59
+ return "absent" if docker_reports_no_such_object(probe) else "error"
60
+ return "alive" if probe.stdout.strip().lower() in ("running", "restarting") else "stopped"
61
+
62
+
63
+ def docker_record_targets(record: Mapping[str, Any]) -> tuple[list[str], bool]:
64
+ ids = docker_label_container_ids(record)
65
+ targets = list(ids or [])
66
+ recorded = str(record.get("container") or "")
67
+ if recorded and not any(recorded.startswith(f) or f.startswith(recorded) for f in targets):
68
+ targets.insert(0, recorded)
69
+ return targets, ids is not None
70
+
71
+
72
+ def docker_record_status(record: Mapping[str, Any]) -> str:
73
+ targets, answered = docker_record_targets(record)
74
+ if not targets:
75
+ return "absent" if answered else "error"
76
+ ctx, host = record_docker_endpoint(record)
77
+ cenv = record_compose_env(record)
78
+ states = [docker_container_status(t, ctx, host, cenv) for t in targets]
79
+ return next(
80
+ (s for s in ("alive", "stopped", "error") if s in states),
81
+ "absent" if answered else "error",
82
+ )
83
+
84
+
85
+ def _stop_target(
86
+ target: str,
87
+ endpoint: tuple[Any, Any, Any],
88
+ remove: bool,
89
+ ) -> bool:
90
+ ctx, host, cenv = endpoint
91
+ cmds = [["stop", target]]
92
+ if remove:
93
+ cmds.append(["rm", "-f", target])
94
+ for args in cmds:
95
+ try:
96
+ res = run_docker(args, ctx, docker_host=host, env=cenv)
97
+ except RigError:
98
+ return False
99
+ if res.returncode == 0:
100
+ continue
101
+ if docker_reports_no_such_object(res):
102
+ break
103
+ return False
104
+ return True
105
+
106
+
107
+ def docker_record_stop(record: Mapping[str, Any], remove: bool) -> str:
108
+ targets, answered = docker_record_targets(record)
109
+ if not targets:
110
+ return "stale" if answered else "failed"
111
+ ctx, host = record_docker_endpoint(record)
112
+ cenv = record_compose_env(record)
113
+ endpoint = (ctx, host, cenv)
114
+ for t in targets:
115
+ if not _stop_target(t, endpoint, remove):
116
+ return "failed"
117
+ return "failed" if not answered else "terminated"
rig/compose/starter.py ADDED
@@ -0,0 +1,145 @@
1
+ """Compose service startup and container tracking."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from collections.abc import Mapping
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from rig.compose.client import run_compose
11
+ from rig.compose.context import resolve_current_docker_context
12
+ from rig.compose.discovery import (
13
+ INTERRUPTION_EXCEPTIONS,
14
+ discover_container_and_port,
15
+ handle_compose_interruption,
16
+ init_compose_record,
17
+ stranded_error,
18
+ )
19
+ from rig.core.constants import DOCKER_CLIENT_ENV_PASSTHROUGH, EXIT_OP_FAILED
20
+ from rig.core.errors import RigError
21
+
22
+
23
+ def _inherit_docker_client_env(cmd_env: dict[str, str]) -> None:
24
+ cmd_env.update(
25
+ (name, os.environ[name])
26
+ for name in DOCKER_CLIENT_ENV_PASSTHROUGH
27
+ if name not in cmd_env and name in os.environ
28
+ )
29
+
30
+
31
+ def _resolve_compose_endpoints(
32
+ service: Any, env: Mapping[str, str] | None
33
+ ) -> tuple[dict[str, str] | None, str | None, str | None]:
34
+ docker_host = os.environ.get("DOCKER_HOST")
35
+ cmd_env = dict(env) if env is not None else None
36
+ if cmd_env is not None:
37
+ _inherit_docker_client_env(cmd_env)
38
+ docker_context = service.docker_context or os.environ.get("DOCKER_CONTEXT") or None
39
+ if docker_context is None and not docker_host:
40
+ docker_context = resolve_current_docker_context(cmd_env)
41
+ if docker_context:
42
+ docker_host = None
43
+ return cmd_env, docker_context, docker_host
44
+
45
+
46
+ def _run_cleanup_step(
47
+ target: tuple[str, Path, Path, str],
48
+ endpoint: tuple[str | None, dict[str, str] | None, str | None],
49
+ action: list[str],
50
+ ) -> bool | None:
51
+ instance, root, cfile, _ = target
52
+ ctx, env, host = endpoint
53
+ try:
54
+ res = run_compose(
55
+ instance, root, cfile, action, ctx, timeout=30.0, env=env, docker_host=host
56
+ )
57
+ except INTERRUPTION_EXCEPTIONS:
58
+ return None
59
+ else:
60
+ return res.returncode == 0
61
+
62
+
63
+ def _cleanup_compose(
64
+ target: tuple[str, Path, Path, str],
65
+ endpoint: tuple[str | None, dict[str, str] | None, str | None],
66
+ ) -> bool:
67
+ removed = True
68
+ for args in (["stop", target[3]], ["rm", "-f", target[3]]):
69
+ ok = _run_cleanup_step(target, endpoint, args)
70
+ if ok is None:
71
+ return False
72
+ if not ok:
73
+ removed = False
74
+ return removed
75
+
76
+
77
+ def _run_compose_up(
78
+ info: tuple[Any, dict[str, Any]],
79
+ compose_fn: Any,
80
+ handlers: tuple[Any, Any],
81
+ ) -> None:
82
+ service, record = info
83
+ fail_fn, cleanup_fn = handlers
84
+ try:
85
+ res = compose_fn(["up", "-d", "--no-deps", "--wait", str(service.compose_service)])
86
+ except (RigError, OSError) as exc:
87
+ if not cleanup_fn():
88
+ msg = f"compose could not start {service.name!r}: {exc}"
89
+ raise stranded_error(msg, record) from exc
90
+ raise
91
+ except INTERRUPTION_EXCEPTIONS as exc:
92
+ handle_compose_interruption(exc, info, cleanup_fn, "the start of")
93
+ if res.returncode != 0:
94
+ err = res.stderr.strip() or res.stdout.strip()
95
+ raise fail_fn(f"compose could not start {service.name!r}: {err}")
96
+
97
+
98
+ def _run_discovery_phase(
99
+ service: Any,
100
+ record: dict[str, Any],
101
+ helpers: tuple[Any, Any, Any],
102
+ ) -> None:
103
+ compose_fn, fail_fn, cleanup_fn = helpers
104
+ try:
105
+ discover_container_and_port(service, record, (compose_fn, fail_fn))
106
+ except RigError:
107
+ raise
108
+ except INTERRUPTION_EXCEPTIONS as exc:
109
+ handle_compose_interruption(exc, (service, record), cleanup_fn, "discovery for")
110
+
111
+
112
+ def _start_compose_service(
113
+ service: Any, root: Path, instance: str, *args: Any, **kwargs: Any
114
+ ) -> dict[str, Any]:
115
+ env = args[0] if args else kwargs.get("env")
116
+ cfile = Path(root) / str(service.compose_file)
117
+ cmd_env, ctx, host = _resolve_compose_endpoints(service, env)
118
+ record = init_compose_record(service, instance, (cfile, ctx, host, cmd_env))
119
+
120
+ target = (instance, Path(root), cfile, str(service.compose_service))
121
+ endpoint = (ctx, cmd_env, host)
122
+
123
+ def _compose(compose_args: list[str], timeout: float = 180.0):
124
+ return run_compose(
125
+ instance,
126
+ Path(root),
127
+ cfile,
128
+ compose_args,
129
+ ctx,
130
+ timeout=timeout,
131
+ env=cmd_env,
132
+ docker_host=host,
133
+ )
134
+
135
+ def _cleanup() -> bool:
136
+ return _cleanup_compose(target, endpoint)
137
+
138
+ def _fail(msg: str) -> RigError:
139
+ if _cleanup():
140
+ return RigError(msg, code="E_COMPOSE_FAILED", exit_code=EXIT_OP_FAILED)
141
+ return stranded_error(msg, record)
142
+
143
+ _run_compose_up((service, record), _compose, (_fail, _cleanup))
144
+ _run_discovery_phase(service, record, (_compose, _fail, _cleanup))
145
+ return record
rig/compose/stopper.py ADDED
@@ -0,0 +1,71 @@
1
+ """Compose service stop and teardown."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from rig.compose import client
10
+ from rig.compose.context import record_compose_env, record_docker_endpoint
11
+ from rig.compose.docker import docker_record_stop
12
+ from rig.compose.supervisor import compose_record_status
13
+ from rig.core.errors import RigError
14
+
15
+
16
+ def _run_compose_teardown(
17
+ target: tuple[str, Path, Path],
18
+ args: list[str],
19
+ endpoint: tuple[str | None, Any, Any],
20
+ ) -> bool:
21
+ instance, root, compose_file = target
22
+ ctx_name, docker_host, compose_env = endpoint
23
+ try:
24
+ res = client.run_compose(
25
+ instance,
26
+ root,
27
+ compose_file,
28
+ args,
29
+ ctx_name,
30
+ env=compose_env,
31
+ docker_host=docker_host,
32
+ )
33
+ except RigError:
34
+ return False
35
+ else:
36
+ return res.returncode == 0
37
+
38
+
39
+ def _teardown_services(
40
+ target: tuple[str, Path, Path, str],
41
+ endpoint: tuple[str | None, Any, Any],
42
+ remove: bool,
43
+ ) -> bool:
44
+ instance, root, compose_file, compose_service = target
45
+ cmds = [["stop", compose_service]]
46
+ if remove:
47
+ cmds.append(["rm", "-f", compose_service])
48
+ tup = (instance, root, compose_file)
49
+ return all(_run_compose_teardown(tup, cmd, endpoint) for cmd in cmds)
50
+
51
+
52
+ def compose_stop_record(record: Mapping[str, Any], root: Path, remove: bool = True) -> str:
53
+ status = compose_record_status(record, root)
54
+ if status in ("absent", "error"):
55
+ return "stale" if status == "absent" else "failed"
56
+ if not client.compose_file_present(record, root):
57
+ return docker_record_stop(record, remove)
58
+
59
+ target = (
60
+ str(record["instance"]),
61
+ Path(root),
62
+ Path(str(record["compose_file"])),
63
+ str(record["compose_service"]),
64
+ )
65
+ docker_context, docker_host = record_docker_endpoint(record)
66
+ endpoint = (docker_context, docker_host, record_compose_env(record))
67
+ should_remove = remove or not record.get("container")
68
+
69
+ if _teardown_services(target, endpoint, should_remove):
70
+ return "terminated"
71
+ return docker_record_stop(record, remove)
@@ -0,0 +1,78 @@
1
+ """Docker container lifecycle supervision, status queries, and reclamation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from rig.compose.client import compose_file_present, run_compose
10
+ from rig.compose.context import record_compose_env, record_docker_endpoint
11
+ from rig.compose.docker import (
12
+ docker_container_status,
13
+ docker_record_status,
14
+ )
15
+ from rig.core.errors import RigError
16
+
17
+
18
+ def _check_compose_ps(record: Mapping[str, Any], root: Path) -> list[str] | None:
19
+ context, docker_host = record_docker_endpoint(record)
20
+ compose_env = record_compose_env(record)
21
+ try:
22
+ res = run_compose(
23
+ str(record["instance"]),
24
+ Path(root),
25
+ Path(record["compose_file"]),
26
+ ["ps", "-q", "-a", str(record["compose_service"])],
27
+ context,
28
+ timeout=60.0,
29
+ env=compose_env,
30
+ docker_host=docker_host,
31
+ )
32
+ if res.returncode == 0:
33
+ return [line.strip() for line in res.stdout.splitlines() if line.strip()]
34
+ except RigError:
35
+ pass
36
+ return None
37
+
38
+
39
+ def _has_compose_metadata(record: Mapping[str, Any]) -> bool:
40
+ return bool(
41
+ record.get("instance") and record.get("compose_file") and record.get("compose_service")
42
+ )
43
+
44
+
45
+ def _evaluate_container_states(
46
+ ids: list[str], recorded: str, endpoint: tuple[Any, Any, Any]
47
+ ) -> str:
48
+ ctx, host, cenv = endpoint
49
+ states = [docker_container_status(found, ctx, host, cenv) for found in ids]
50
+ if recorded and not any(recorded.startswith(f) or f.startswith(recorded) for f in ids):
51
+ states.append(docker_container_status(recorded, ctx, host, cenv))
52
+ return next((state for state in ("alive", "stopped") if state in states), "error")
53
+
54
+
55
+ def compose_record_status(record: Mapping[str, Any], root: Path) -> str:
56
+ """Return 'alive', 'stopped', 'absent', or 'error' for one compose record."""
57
+ if not _has_compose_metadata(record):
58
+ return "absent"
59
+ ids = _check_compose_ps(record, root) if compose_file_present(record, root) else None
60
+ if ids is None:
61
+ return docker_record_status(record)
62
+
63
+ context, docker_host = record_docker_endpoint(record)
64
+ compose_env = record_compose_env(record)
65
+ recorded = str(record.get("container") or "")
66
+ if not ids:
67
+ return (
68
+ docker_container_status(recorded, context, docker_host, compose_env)
69
+ if recorded
70
+ else "absent"
71
+ )
72
+
73
+ return _evaluate_container_states(ids, recorded, (context, docker_host, compose_env))
74
+
75
+
76
+ def compose_record_alive(record: Mapping[str, Any], root: Path) -> bool:
77
+ """Return ``True`` when the recorded container is still running under this instance."""
78
+ return compose_record_status(record, root) == "alive"
rig/core/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Core primitives, constants, locks, and state for rig."""
rig/core/constants.py ADDED
@@ -0,0 +1,65 @@
1
+ """Constants and exit codes for rig."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ RUNTIME_DIR_NAME = ".local-run"
8
+ __version__ = "1.0.0"
9
+ LOCK_FILE_NAME = "checkout.lock"
10
+ STATE_FILE_NAME = "state.json"
11
+ LOG_DIR_NAME = "logs"
12
+ DIR_MODE_PRIVATE = 0o700
13
+ FILE_MODE_PRIVATE = 0o600
14
+
15
+ LOCK_TIMEOUT_SECS = 10.0
16
+ HEALTH_TIMEOUT_SECS = 45.0
17
+ TEARDOWN_TIMEOUT_SECS = 5.0
18
+ PORT_RELEASE_TIMEOUT_SECS = 5.0
19
+ PORT_RETRY_ATTEMPTS = 3
20
+ PORT_MIN = 1
21
+ PORT_MAX = 65535
22
+ COMPOSE_UP_TIMEOUT_SECS = 180.0
23
+ COMPOSE_DISCOVERY_TIMEOUT_SECS = 60.0
24
+ COMPOSE_TEARDOWN_TIMEOUT_SECS = 30.0
25
+ DEFAULT_STARTUP_WAIT_SECS = 0.3
26
+
27
+ SERVICE_TYPES = ("fd", "port", "compose")
28
+ SCOPES = ("full", "local", "backend", "ui")
29
+
30
+ BASE_ENV_ALLOWLIST = (
31
+ "PATH",
32
+ "HOME",
33
+ "USER",
34
+ "LOGNAME",
35
+ "SHELL",
36
+ "TERM",
37
+ "TMPDIR",
38
+ "TZ",
39
+ "LANG",
40
+ "LC_ALL",
41
+ "LC_CTYPE",
42
+ "SSL_CERT_FILE",
43
+ "SSL_CERT_DIR",
44
+ )
45
+
46
+ DOCKER_CLIENT_ENV_PASSTHROUGH = (
47
+ "DOCKER_CONFIG",
48
+ "DOCKER_CERT_PATH",
49
+ "DOCKER_TLS_VERIFY",
50
+ )
51
+
52
+ SECRET_NAME_PATTERN = re.compile(
53
+ r"TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|_KEY$|^KEY$|APIKEY|PRIVATE",
54
+ re.IGNORECASE,
55
+ )
56
+ REDACTED = "***"
57
+
58
+ EXIT_OK = 0
59
+ EXIT_OP_FAILED = 1
60
+ EXIT_USAGE = 2
61
+ EXIT_MUTEX_CONFLICT = 3
62
+ EXIT_NOT_FOUND = 4
63
+ EXIT_REFUSED = 5
64
+ EXIT_EXTERNAL_TOOL = 6
65
+ EXIT_INTERRUPTED = 130
rig/core/env.py ADDED
@@ -0,0 +1,83 @@
1
+ """Environment variable allowlisting and placeholder formatting."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import string
7
+ from collections.abc import Mapping, Sequence
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from rig.core.constants import BASE_ENV_ALLOWLIST
12
+ from rig.core.errors import RigError
13
+
14
+
15
+ class _StrictValues(dict):
16
+ def __missing__(self, key):
17
+ raise RigError(f"unknown placeholder {{{key}}}")
18
+
19
+
20
+ _FORMATTER = string.Formatter()
21
+
22
+
23
+ def render(value: Any, values: Mapping[str, Any]) -> Any:
24
+ """Substitute ``{name}`` placeholders in strings, lists and mappings."""
25
+ strict = _StrictValues(values)
26
+ if isinstance(value, str):
27
+ try:
28
+ return _FORMATTER.vformat(value, (), strict)
29
+ except (IndexError, KeyError) as exc:
30
+ raise RigError(f"cannot render {value!r}: {exc}") from None
31
+ if isinstance(value, list):
32
+ return [render(item, values) for item in value]
33
+ if isinstance(value, Mapping):
34
+ return {key: render(item, values) for key, item in value.items()}
35
+ return value
36
+
37
+
38
+ _MIN_QUOTED_LEN = 2
39
+
40
+
41
+ def _strip_env_quotes(item: str) -> str:
42
+ if len(item) >= _MIN_QUOTED_LEN and item[0] == item[-1] and item[0] in "\"'":
43
+ return item[1:-1]
44
+ return item
45
+
46
+
47
+ def parse_env_file(path: Path) -> dict[str, str]:
48
+ """Return ``KEY=VALUE`` pairs from a dotenv-style file, ignoring comments."""
49
+ values: dict[str, str] = {}
50
+ try:
51
+ text = Path(path).read_text()
52
+ except OSError:
53
+ return values
54
+ for line in text.splitlines():
55
+ stripped = line.strip()
56
+ if not stripped or stripped.startswith("#") or "=" not in stripped:
57
+ continue
58
+ key, _, raw = stripped.partition("=")
59
+ key = key.strip()
60
+ if key.startswith("export "):
61
+ key = key[len("export ") :].strip()
62
+ values[key] = _strip_env_quotes(raw.strip())
63
+ return values
64
+
65
+
66
+ def build_service_env(
67
+ declared: Mapping[str, Any],
68
+ *args: Any,
69
+ **kwargs: Any,
70
+ ) -> dict[str, str]:
71
+ """Compose a service environment from an allowlist, env files and manifest values."""
72
+ params = list(args)
73
+ inherit: Sequence[str] = params.pop(0) if params else kwargs.get("inherit", ())
74
+ root: Path = params.pop(0) if params else kwargs.get("root", Path.cwd())
75
+ values: Mapping[str, Any] = params.pop(0) if params else kwargs.get("values", {})
76
+ env_files: Sequence[str] = params.pop(0) if params else kwargs.get("env_files", ())
77
+
78
+ env = {name: os.environ[name] for name in (*BASE_ENV_ALLOWLIST, *inherit) if name in os.environ}
79
+ for relative in env_files:
80
+ env.update(parse_env_file(Path(root) / relative))
81
+ merged = {**values, "root": str(root)}
82
+ env.update((str(key), str(render(raw, merged))) for key, raw in declared.items())
83
+ return env
rig/core/errors.py ADDED
@@ -0,0 +1,74 @@
1
+ """Exceptions and JSON error formatting for rig."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ from rig.core.constants import EXIT_OP_FAILED, EXIT_USAGE
9
+
10
+
11
+ class RigError(RuntimeError):
12
+ """A rig operation cannot proceed safely."""
13
+
14
+ def __init__(self, message: str, **kwargs: Any) -> None:
15
+ super().__init__(message)
16
+ self.message = message
17
+ self.code: str = str(kwargs.get("code", "E_GENERIC"))
18
+ self.exit_code: int = int(kwargs.get("exit_code", EXIT_OP_FAILED))
19
+ self.hint: str | None = kwargs.get("hint")
20
+ self.headline: str | None = kwargs.get("headline")
21
+ self.context: str | None = kwargs.get("context")
22
+ self.details: dict[str, Any] = kwargs.get("details") or {}
23
+
24
+
25
+ StackError = RigError
26
+
27
+
28
+ def format_human_error(exc: RigError, th: Any) -> str:
29
+ """Format a RigError into a high-signal human structured error card."""
30
+ headline = exc.headline or exc.message
31
+ lines = [f" {th.red}✖ {headline}{th.r}"]
32
+ if exc.context:
33
+ for cl in exc.context.splitlines():
34
+ lines.append(f" {th.d}{cl}{th.r}")
35
+ elif exc.headline and exc.headline != exc.message:
36
+ lines.append(f" {th.d}{exc.message}{th.r}")
37
+ if exc.hint:
38
+ lines.append(f" {th.cyan}Hint:{th.r} {exc.hint} {th.d}[{exc.code}]{th.r}")
39
+ else:
40
+ lines.append(f" {th.d}[{exc.code}]{th.r}")
41
+ return "\n".join(lines) + "\n"
42
+
43
+
44
+ def manifest_error(message: str, *, hint: str | None = None) -> RigError:
45
+ """Return the error for a manifest that cannot be used as written."""
46
+ return RigError(message, code="E_USAGE", exit_code=EXIT_USAGE, hint=hint)
47
+
48
+
49
+ def print_json_envelope(command: str, data: Any, ok: bool | None = None) -> None:
50
+ """Output envelope for structured JSON responses."""
51
+ if ok is None:
52
+ ok = bool(data["ok"]) if isinstance(data, dict) and "ok" in data else True
53
+ envelope = {
54
+ "schema": f"rig.{command}/1",
55
+ "ok": ok,
56
+ "data": data,
57
+ }
58
+ print(json.dumps(envelope, indent=2))
59
+
60
+
61
+ def print_json_error(exc: RigError, command: str = "error") -> None:
62
+ """Output envelope for structured JSON errors."""
63
+ envelope = {
64
+ "schema": "rig.error/1",
65
+ "ok": False,
66
+ "error": {
67
+ "code": exc.code,
68
+ "exit_code": exc.exit_code,
69
+ "message": exc.message,
70
+ "hint": exc.hint,
71
+ "details": exc.details,
72
+ },
73
+ }
74
+ print(json.dumps(envelope, indent=2))