docker-orb 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.
docker_orb/__init__.py ADDED
File without changes
docker_orb/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allows `python -m docker_orb` to work alongside the `docker-orb` script."""
2
+ from .cli import main
3
+
4
+ if __name__ == "__main__":
5
+ main()
@@ -0,0 +1,136 @@
1
+ """
2
+ Enforces a minimum scrollbar thumb size, app-wide.
3
+
4
+ Textual's default ScrollBarRender lets the thumb shrink to a single cell for
5
+ very long content (e.g. the main log stream, which buffers up to 20,000
6
+ lines — see ViewerApp._buffer_cap). A 1-cell thumb is nearly impossible to
7
+ grab with a real mouse in a terminal: clicks land on the track instead,
8
+ which pages the view rather than dragging it, making click-drag scrolling
9
+ feel broken. Clamping the *visual* thumb size doesn't affect drag math
10
+ (ScrollBar._on_mouse_move scales purely from window_size/virtual_size, not
11
+ from how big the thumb is drawn), so this is purely a grabbability fix.
12
+
13
+ render_bar() below is a fork of textual.scrollbar.ScrollBarRender.render_bar
14
+ (textual==8.2.7) with a single change: the thumb-size floor is MIN_THUMB_SIZE
15
+ instead of 1. It can't be done by pre-inflating the `window_size` argument
16
+ and delegating to super().render_bar() — window_size also drives the thumb's
17
+ *position* ratio there, so inflating it shifts the thumb as well as
18
+ resizing it. The size floor has to be applied after position is derived from
19
+ the real window_size, which means owning the whole method body.
20
+
21
+ Call install() once, early, before any scrollable widgets are composed.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ from math import ceil
26
+
27
+ from rich.color import Color
28
+ from rich.segment import Segment
29
+ from rich.segment import Segments
30
+ from rich.style import Style
31
+
32
+ from textual.scrollbar import ScrollBar, ScrollBarRender
33
+
34
+ MIN_THUMB_SIZE = 3
35
+
36
+
37
+ class _MinThumbScrollBarRender(ScrollBarRender):
38
+ @classmethod
39
+ def render_bar(
40
+ cls,
41
+ size: int = 25,
42
+ virtual_size: float = 50,
43
+ window_size: float = 20,
44
+ position: float = 0,
45
+ thickness: int = 1,
46
+ vertical: bool = True,
47
+ back_color: Color = Color.parse("#555555"),
48
+ bar_color: Color = Color.parse("bright_magenta"),
49
+ ) -> Segments:
50
+ if vertical:
51
+ bars = cls.VERTICAL_BARS
52
+ else:
53
+ bars = cls.HORIZONTAL_BARS
54
+
55
+ back = back_color
56
+ bar = bar_color
57
+
58
+ len_bars = len(bars)
59
+
60
+ width_thickness = thickness if vertical else 1
61
+
62
+ _Segment = Segment
63
+ _Style = Style
64
+ blank = cls.BLANK_GLYPH * width_thickness
65
+
66
+ foreground_meta = {"@mouse.down": "grab"}
67
+ if window_size and size and virtual_size and size != virtual_size:
68
+ bar_ratio = virtual_size / size
69
+ thumb_size = max(MIN_THUMB_SIZE, window_size / bar_ratio)
70
+
71
+ position_ratio = position / (virtual_size - window_size)
72
+ position = (size - thumb_size) * position_ratio
73
+
74
+ start = int(position * len_bars)
75
+ end = start + ceil(thumb_size * len_bars)
76
+
77
+ start_index, start_bar = divmod(max(0, start), len_bars)
78
+ end_index, end_bar = divmod(max(0, end), len_bars)
79
+
80
+ upper = {"@mouse.down": "scroll_up"}
81
+ lower = {"@mouse.down": "scroll_down"}
82
+
83
+ upper_back_segment = Segment(blank, _Style(bgcolor=back, meta=upper))
84
+ lower_back_segment = Segment(blank, _Style(bgcolor=back, meta=lower))
85
+
86
+ segments = [upper_back_segment] * int(size)
87
+ segments[end_index:] = [lower_back_segment] * (size - end_index)
88
+
89
+ segments[start_index:end_index] = [
90
+ _Segment(blank, _Style(color=bar, reverse=True, meta=foreground_meta))
91
+ ] * (end_index - start_index)
92
+
93
+ # Apply the smaller bar characters to head and tail of scrollbar for more "granularity"
94
+ if start_index < len(segments):
95
+ bar_character = bars[len_bars - 1 - start_bar]
96
+ if bar_character != " ":
97
+ segments[start_index] = _Segment(
98
+ bar_character * width_thickness,
99
+ (
100
+ _Style(bgcolor=back, color=bar, meta=foreground_meta)
101
+ if vertical
102
+ else _Style(
103
+ bgcolor=back,
104
+ color=bar,
105
+ meta=foreground_meta,
106
+ reverse=True,
107
+ )
108
+ ),
109
+ )
110
+ if end_index < len(segments):
111
+ bar_character = bars[len_bars - 1 - end_bar]
112
+ if bar_character != " ":
113
+ segments[end_index] = _Segment(
114
+ bar_character * width_thickness,
115
+ (
116
+ _Style(
117
+ bgcolor=back,
118
+ color=bar,
119
+ meta=foreground_meta,
120
+ reverse=True,
121
+ )
122
+ if vertical
123
+ else _Style(bgcolor=back, color=bar, meta=foreground_meta)
124
+ ),
125
+ )
126
+ else:
127
+ style = _Style(bgcolor=back)
128
+ segments = [_Segment(blank, style=style)] * int(size)
129
+ if vertical:
130
+ return Segments(segments, new_lines=True)
131
+ else:
132
+ return Segments((segments + [_Segment.line()]) * thickness, new_lines=False)
133
+
134
+
135
+ def install() -> None:
136
+ ScrollBar.renderer = _MinThumbScrollBarRender
docker_orb/cli.py ADDED
@@ -0,0 +1,167 @@
1
+ """
2
+ CLI entry point for docker-orb.
3
+
4
+ If enough arguments are provided to fully specify a session, the viewer
5
+ launches directly. Otherwise (or with --wizard), the interactive wizard runs.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import sys
10
+
11
+ import click
12
+
13
+ from .models import HealthConfig, LogMode, SessionConfig
14
+
15
+
16
+ @click.command(context_settings={"help_option_names": ["-h", "--help"]})
17
+ @click.option("-n", "--project", default=None,
18
+ help="Docker Compose project name (default: current directory's compose project).")
19
+ @click.option("-p", "--container", "containers", multiple=True, metavar="NAME",
20
+ help="Service name to watch. Repeatable. Use multiple times for multiple services.")
21
+ @click.option("--all-containers", "all_containers", is_flag=True,
22
+ help="Watch all services in the project.")
23
+ @click.option("--stream", "mode", flag_value="stream", default=True,
24
+ help="Live tail mode (default).")
25
+ @click.option("--dump", "mode", flag_value="dump",
26
+ help="Fetch existing logs and exit. No live stream.")
27
+ @click.option("--tail", type=int, default=None, metavar="N",
28
+ help="(Dump mode) Last N lines to fetch.")
29
+ @click.option("--since", default=None, metavar="DURATION",
30
+ help="(Dump mode) Fetch logs from this far back, e.g. 1h, 30m.")
31
+ @click.option("-c", "--config", "config_name", default=None, metavar="NAME",
32
+ help="Load a saved config by name (scoped to project).")
33
+ @click.option("--save-config", default=None, metavar="NAME",
34
+ help="Save this session's config under NAME before launching.")
35
+ @click.option("-f", "--filter", "filters", multiple=True, metavar="PATTERN",
36
+ help="Hide lines matching PATTERN. Repeatable. Use /regex/ for regex.")
37
+ @click.option("-H", "--highlight", "highlights", multiple=True, metavar="PATTERN",
38
+ help="Highlight lines matching PATTERN. Repeatable.")
39
+ @click.option("-m", "--monitor", "monitors", multiple=True, metavar="PATTERN",
40
+ help="(Stream mode) Collect matching lines in monitor panel. Repeatable.")
41
+ @click.option("--health", is_flag=True,
42
+ help="(Stream mode) Enable container health monitoring panel.")
43
+ @click.option("--health-interval", type=int, default=5, show_default=True, metavar="MINUTES",
44
+ help="Container health check interval in minutes (min 1).")
45
+ @click.option("--health-restart-threshold", type=int, default=1, show_default=True,
46
+ metavar="N", hidden=True,
47
+ help="Show containers in health panel when restart delta >= N (default 1).")
48
+ @click.option("--wizard", is_flag=True,
49
+ help="Force the interactive setup wizard even if all args are provided.")
50
+ def main(
51
+ project: str | None,
52
+ containers: tuple[str, ...],
53
+ all_containers: bool,
54
+ mode: str,
55
+ tail: int | None,
56
+ since: str | None,
57
+ config_name: str | None,
58
+ save_config: str | None,
59
+ filters: tuple[str, ...],
60
+ highlights: tuple[str, ...],
61
+ monitors: tuple[str, ...],
62
+ health: bool,
63
+ health_interval: int,
64
+ health_restart_threshold: int,
65
+ wizard: bool,
66
+ ) -> None:
67
+ """
68
+ docker-orb — Interactive Docker Compose log viewer.
69
+
70
+ Run with no arguments to launch the setup wizard.
71
+ Provide --project and --container flags to skip the wizard.
72
+
73
+ \b
74
+ Examples:
75
+ docker-orb # wizard
76
+ docker-orb -n myapp -p api -p worker --stream
77
+ docker-orb -n myapp --all-containers --dump --tail 500
78
+ docker-orb -n myapp -p worker -f DEBUG -H ERROR --health
79
+ """
80
+ from . import compose as k
81
+ from .config import (
82
+ add_to_saved_strings,
83
+ load_session_config,
84
+ parse_string_input,
85
+ save_session_config,
86
+ )
87
+
88
+ # ── Resolve project ────────────────────────────────────────────────────
89
+ if project is None:
90
+ project = k.get_current_project()
91
+
92
+ # ── Decide: wizard or direct launch ─────────────────────────────────────
93
+ needs_wizard = wizard or (not containers and not all_containers and not config_name)
94
+ if needs_wizard:
95
+ _run_wizard(project)
96
+ return
97
+
98
+ # ── Load saved config (pre-fill) ─────────────────────────────────────────
99
+ session: SessionConfig | None = None
100
+ if config_name:
101
+ session = load_session_config(project, config_name)
102
+ if session is None:
103
+ click.echo(f"No saved config '{config_name}' found for project '{project}'.",
104
+ err=True)
105
+ sys.exit(1)
106
+
107
+ # ── Build / override SessionConfig ───────────────────────────────────────
108
+ # CLI flags override anything from the loaded config
109
+ if session is None:
110
+ session = SessionConfig(project=project, services=[])
111
+
112
+ if containers:
113
+ session.services = list(containers)
114
+ elif all_containers:
115
+ session.services = [svc.name for svc in k.get_services(project)]
116
+
117
+ session.mode = LogMode(mode)
118
+ if tail is not None:
119
+ session.tail = tail
120
+ if since is not None:
121
+ session.since = since
122
+
123
+ # Merge CLI strings with any loaded-config strings
124
+ def _merge(existing: list[str], incoming: tuple[str, ...]) -> list[str]:
125
+ extra: list[str] = []
126
+ for raw in incoming:
127
+ extra.extend(parse_string_input(raw))
128
+ return existing + [s for s in extra if s not in existing]
129
+
130
+ session.filters = _merge(session.filters, filters)
131
+ session.highlights = _merge(session.highlights, highlights)
132
+ session.monitors = _merge(session.monitors, monitors)
133
+
134
+ if health:
135
+ session.health.enabled = True
136
+ session.health.interval_minutes = max(1, health_interval)
137
+ session.health.restart_threshold = health_restart_threshold
138
+
139
+ # ── Validate ─────────────────────────────────────────────────────────────
140
+ if not session.services:
141
+ click.echo("No services specified. Use --container NAME or --all-containers.", err=True)
142
+ sys.exit(1)
143
+
144
+ # ── Optionally save config ────────────────────────────────────────────────
145
+ if save_config:
146
+ session.name = save_config
147
+ save_session_config(session)
148
+ click.echo(f"Config saved as '{save_config}' for project '{project}'.")
149
+
150
+ # ── Launch viewer ─────────────────────────────────────────────────────────
151
+ _run_viewer(session)
152
+
153
+
154
+ def _run_wizard(project: str) -> None:
155
+ from .wizard.app import WizardApp
156
+ app = WizardApp(initial_project=project)
157
+ result = app.run()
158
+ if result is None:
159
+ # User cancelled
160
+ sys.exit(0)
161
+ _run_viewer(result)
162
+
163
+
164
+ def _run_viewer(config: SessionConfig) -> None:
165
+ from .viewer.app import ViewerApp
166
+ app = ViewerApp(config=config)
167
+ app.run()
docker_orb/colors.py ADDED
@@ -0,0 +1,60 @@
1
+ """
2
+ Color assignment for containers using the golden angle method.
3
+
4
+ Distributes hues at 137.508° (the golden angle) intervals in HSL space,
5
+ which mathematically maximises perceptual distance between consecutive colors
6
+ regardless of how many containers are selected.
7
+
8
+ With 2 containers → hues ~180° apart (near-complementary)
9
+ With 5 containers → hues evenly spread ~137° apart
10
+ With 10 containers → still well-separated, no two adjacent colors look alike
11
+
12
+ Saturation and lightness are fixed for good readability on dark terminals.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import colorsys
17
+
18
+ # Golden angle in degrees
19
+ GOLDEN_ANGLE = 137.508
20
+
21
+ # HSL fixed axes — tuned for dark terminal backgrounds
22
+ SATURATION = 0.75 # vivid but not neon
23
+ LIGHTNESS = 0.65 # bright enough to read, not washed out
24
+
25
+ # Starting hue offset — avoids starting at pure red (0°) which can look like error color
26
+ _START_HUE = 30.0 # degrees
27
+
28
+
29
+ def _hsl_to_rgb(h_deg: float, s: float, l: float) -> tuple[int, int, int]:
30
+ """Convert HSL (h in degrees, s and l in [0,1]) to RGB tuple (0–255)."""
31
+ h = h_deg / 360.0
32
+ r, g, b = colorsys.hls_to_rgb(h, l, s)
33
+ return int(r * 255), int(g * 255), int(b * 255)
34
+
35
+
36
+ def _rgb_to_hex(r: int, g: int, b: int) -> str:
37
+ return f"#{r:02x}{g:02x}{b:02x}"
38
+
39
+
40
+ def assign_colors(container_names: list[str]) -> dict[str, str]:
41
+ """
42
+ Return a mapping of container_name → hex color string.
43
+
44
+ Colors are assigned in the order containers appear in the list.
45
+ The same container list always produces the same colors (deterministic).
46
+ """
47
+ result: dict[str, str] = {}
48
+ for i, name in enumerate(container_names):
49
+ hue = (_START_HUE + i * GOLDEN_ANGLE) % 360.0
50
+ rgb = _hsl_to_rgb(hue, SATURATION, LIGHTNESS)
51
+ result[name] = _rgb_to_hex(*rgb)
52
+ return result
53
+
54
+
55
+ def get_color(container_name: str, color_map: dict[str, str]) -> str:
56
+ """
57
+ Look up a container's color, returning a safe fallback if not found.
58
+ The fallback is white, which is always readable on dark backgrounds.
59
+ """
60
+ return color_map.get(container_name, "#ffffff")