offline-docker-cli 2.2.2__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 (49) hide show
  1. offline_docker/__init__.py +3 -0
  2. offline_docker/__main__.py +4 -0
  3. offline_docker/actions.py +173 -0
  4. offline_docker/build.py +245 -0
  5. offline_docker/cli.py +486 -0
  6. offline_docker/config.py +692 -0
  7. offline_docker/configure.py +282 -0
  8. offline_docker/context.py +111 -0
  9. offline_docker/defaults.py +48 -0
  10. offline_docker/discover.py +139 -0
  11. offline_docker/docker_cmd.py +136 -0
  12. offline_docker/download_debs.py +142 -0
  13. offline_docker/download_images.py +26 -0
  14. offline_docker/download_npm.py +106 -0
  15. offline_docker/download_trivy.py +90 -0
  16. offline_docker/download_wheels.py +363 -0
  17. offline_docker/download_yarn.py +208 -0
  18. offline_docker/ensure_base.py +61 -0
  19. offline_docker/hooks.py +84 -0
  20. offline_docker/init_cmd.py +54 -0
  21. offline_docker/load_images.py +34 -0
  22. offline_docker/oc_discovery.py +206 -0
  23. offline_docker/resolve.py +306 -0
  24. offline_docker/scan.py +496 -0
  25. offline_docker/sources/__init__.py +18 -0
  26. offline_docker/sources/base.py +18 -0
  27. offline_docker/sources/filesystem.py +17 -0
  28. offline_docker/sources/nexus.py +61 -0
  29. offline_docker/sources/nexus_client.py +67 -0
  30. offline_docker/sources/proget.py +49 -0
  31. offline_docker/sources/proget_client.py +48 -0
  32. offline_docker/sources/upstream.py +36 -0
  33. offline_docker/status.py +247 -0
  34. offline_docker/sync_cache.py +125 -0
  35. offline_docker/trivy_scan.py +86 -0
  36. offline_docker/tui/__init__.py +19 -0
  37. offline_docker/tui/app.py +56 -0
  38. offline_docker/tui/runner.py +87 -0
  39. offline_docker/tui/screens.py +333 -0
  40. offline_docker/user_config.py +89 -0
  41. offline_docker/utils.py +73 -0
  42. offline_docker/validate.py +53 -0
  43. offline_docker/wheel_prune.py +141 -0
  44. offline_docker_cli-2.2.2.dist-info/METADATA +417 -0
  45. offline_docker_cli-2.2.2.dist-info/RECORD +49 -0
  46. offline_docker_cli-2.2.2.dist-info/WHEEL +5 -0
  47. offline_docker_cli-2.2.2.dist-info/entry_points.txt +2 -0
  48. offline_docker_cli-2.2.2.dist-info/licenses/LICENSE +21 -0
  49. offline_docker_cli-2.2.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,87 @@
1
+ """Background action runner with stdout/stderr bridge for TUI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ import sys
7
+ import threading
8
+ import traceback
9
+ from collections.abc import Callable
10
+ from typing import Any
11
+
12
+ from offline_docker.actions import run_action
13
+ from offline_docker.context import ProjectContext
14
+
15
+
16
+ class _LineBridge(io.TextIOBase):
17
+ """Text stream that forwards complete lines to a callback."""
18
+
19
+ encoding = "utf-8"
20
+
21
+ def __init__(self, on_line: Callable[[str], None]) -> None:
22
+ super().__init__()
23
+ self._on_line = on_line
24
+ self._buf = ""
25
+
26
+ def writable(self) -> bool:
27
+ return True
28
+
29
+ def write(self, s: str) -> int: # type: ignore[override]
30
+ if not s:
31
+ return 0
32
+ self._buf += s
33
+ while "\n" in self._buf:
34
+ line, self._buf = self._buf.split("\n", 1)
35
+ self._on_line(line.rstrip("\r"))
36
+ return len(s)
37
+
38
+ def flush(self) -> None:
39
+ if self._buf:
40
+ self._on_line(self._buf.rstrip("\r"))
41
+ self._buf = ""
42
+
43
+
44
+ def run_action_captured(
45
+ name: str,
46
+ ctx: ProjectContext,
47
+ *,
48
+ on_line: Callable[[str], None],
49
+ on_done: Callable[[int, str | None], None],
50
+ **opts: Any,
51
+ ) -> threading.Thread:
52
+ """Start run_action in a daemon thread; stream prints via on_line."""
53
+
54
+ def worker() -> None:
55
+ bridge = _LineBridge(on_line)
56
+ old_out, old_err = sys.stdout, sys.stderr
57
+ sys.stdout = bridge # type: ignore[assignment]
58
+ sys.stderr = bridge # type: ignore[assignment]
59
+ code = 0
60
+ err: str | None = None
61
+ try:
62
+ run_action(name, ctx, **opts)
63
+ except SystemExit as exc:
64
+ if exc.code is None:
65
+ code = 0
66
+ elif isinstance(exc.code, int):
67
+ code = exc.code
68
+ else:
69
+ code = 1
70
+ err = str(exc.code)
71
+ except Exception as exc: # noqa: BLE001 — surface to TUI log
72
+ code = 1
73
+ err = f"{type(exc).__name__}: {exc}"
74
+ on_line(err)
75
+ on_line(traceback.format_exc())
76
+ finally:
77
+ try:
78
+ bridge.flush()
79
+ except Exception: # noqa: BLE001
80
+ pass
81
+ sys.stdout = old_out
82
+ sys.stderr = old_err
83
+ on_done(code, err)
84
+
85
+ thread = threading.Thread(target=worker, name=f"offline-docker-{name}", daemon=True)
86
+ thread.start()
87
+ return thread
@@ -0,0 +1,333 @@
1
+ """Textual screens for offline-docker TUI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from textual.app import ComposeResult
8
+ from textual.binding import Binding
9
+ from textual.containers import Horizontal, Vertical
10
+ from textual.screen import ModalScreen, Screen
11
+ from textual.widgets import (
12
+ Button,
13
+ Checkbox,
14
+ DataTable,
15
+ Footer,
16
+ Header,
17
+ Input,
18
+ Label,
19
+ ListItem,
20
+ ListView,
21
+ RichLog,
22
+ Static,
23
+ )
24
+
25
+ from offline_docker.actions import PROJECT_ACTIONS
26
+ from offline_docker.discover import format_discovery_report
27
+ from offline_docker.status import CacheStatusReport, collect_cache_status
28
+ from offline_docker.tui.runner import run_action_captured
29
+
30
+
31
+ class CommandListView(ListView):
32
+ """Click highlights an item; Enter posts Selected (run)."""
33
+
34
+ def _on_list_item__child_clicked(self, event: ListItem._ChildClicked) -> None:
35
+ event.stop()
36
+ self.focus()
37
+ self.index = self._nodes.index(event.item)
38
+
39
+
40
+ # User-facing command list (order matters).
41
+ _COMMANDS: list[tuple[str, str]] = [
42
+ ("discover", "Discover dependencies"),
43
+ ("cache-check", "Validate offline-cache"),
44
+ ("cache", "Download full offline-cache"),
45
+ ("cache-wheels", "Download wheels"),
46
+ ("cache-debs", "Download debs"),
47
+ ("cache-npm", "Download npm cache"),
48
+ ("cache-yarn", "Download yarn cache"),
49
+ ("cache-images", "Download docker images"),
50
+ ("cache-trivy", "Download Trivy DB"),
51
+ ("cache-load", "Load docker images"),
52
+ ("build", "Build offline (base/backend/frontend)"),
53
+ ("build-base", "Build base offline"),
54
+ ("up", "Compose up offline"),
55
+ ("up-online", "Up online (compose_online)"),
56
+ ("down", "Compose down"),
57
+ ("scan:backend", "Trivy scan backend"),
58
+ ("scan:frontend", "Trivy scan frontend"),
59
+ ]
60
+
61
+
62
+ class DiscoverModal(ModalScreen[None]):
63
+ """Modal with discover report text."""
64
+
65
+ BINDINGS = [Binding("escape", "dismiss", "Close", show=True)]
66
+
67
+ def __init__(self, report: str) -> None:
68
+ super().__init__()
69
+ self._report = report
70
+
71
+ def compose(self) -> ComposeResult:
72
+ with Vertical(id="discover-modal"):
73
+ yield Static("Discover report", classes="title")
74
+ log = RichLog(highlight=False, markup=False, id="discover-log")
75
+ yield log
76
+ yield Button("Close", id="close-discover", variant="primary")
77
+
78
+ def on_mount(self) -> None:
79
+ log = self.query_one("#discover-log", RichLog)
80
+ for line in self._report.splitlines():
81
+ log.write(line)
82
+
83
+ def on_button_pressed(self, event: Button.Pressed) -> None:
84
+ if event.button.id == "close-discover":
85
+ self.dismiss()
86
+
87
+
88
+ class HomeScreen(Screen[None]):
89
+ """Project path, cache status, commands, live log."""
90
+
91
+ BINDINGS = [
92
+ Binding("r", "refresh", "Refresh", show=True),
93
+ Binding("d", "discover", "Discover", show=True),
94
+ Binding("q", "quit", "Quit", show=True),
95
+ ]
96
+
97
+ CSS = """
98
+ #project-row {
99
+ height: auto;
100
+ margin: 0 1;
101
+ }
102
+ #project-input {
103
+ width: 1fr;
104
+ }
105
+ #summary {
106
+ margin: 0 1;
107
+ height: 1;
108
+ color: $text-muted;
109
+ }
110
+ #status-table {
111
+ height: 12;
112
+ margin: 0 1;
113
+ }
114
+ #flags {
115
+ height: auto;
116
+ margin: 0 1;
117
+ }
118
+ #commands {
119
+ height: 10;
120
+ margin: 0 1;
121
+ border: solid $primary;
122
+ }
123
+ #run-log {
124
+ height: 1fr;
125
+ margin: 0 1 1 1;
126
+ border: solid $accent;
127
+ }
128
+ #actions-row {
129
+ height: auto;
130
+ margin: 0 1;
131
+ }
132
+ """
133
+
134
+ def __init__(self) -> None:
135
+ super().__init__()
136
+ self._running = False
137
+ self._selected_command: str | None = None
138
+
139
+ def compose(self) -> ComposeResult:
140
+ app = self.app
141
+ from offline_docker.tui.app import OfflineDockerApp
142
+
143
+ assert isinstance(app, OfflineDockerApp)
144
+ yield Header(show_clock=False)
145
+ with Vertical():
146
+ yield Static("offdock TUI", classes="title")
147
+ with Horizontal(id="project-row"):
148
+ yield Label("Project:")
149
+ yield Input(value=str(app.project), id="project-input")
150
+ yield Button("Load", id="btn-load", variant="primary")
151
+ yield Button("Refresh", id="btn-refresh")
152
+ yield Static("", id="summary")
153
+ yield DataTable(id="status-table", zebra_stripes=True)
154
+ with Horizontal(id="flags"):
155
+ yield Checkbox("no-cache", id="flag-no-cache")
156
+ yield Checkbox("lab (up)", id="flag-lab")
157
+ yield Checkbox("online (up/down)", id="flag-online")
158
+ yield Checkbox("no-build", id="flag-no-build")
159
+ yield Checkbox("no-deps", id="flag-no-deps")
160
+ yield Label("Commands (Enter / Run):")
161
+ yield CommandListView(id="commands", *self._command_items())
162
+ with Horizontal(id="actions-row"):
163
+ yield Button("Run", id="btn-run", variant="success")
164
+ yield Button("Discover report", id="btn-discover")
165
+ yield RichLog(highlight=False, markup=False, id="run-log", wrap=True)
166
+ yield Footer()
167
+
168
+ @staticmethod
169
+ def _command_items() -> list[ListItem]:
170
+ items: list[ListItem] = []
171
+ for key, title in _COMMANDS:
172
+ items.append(ListItem(Label(f"{title} ({key})"), id=f"cmd-{key.replace(':', '-')}"))
173
+ return items
174
+
175
+ def on_mount(self) -> None:
176
+ table = self.query_one("#status-table", DataTable)
177
+ table.add_columns("Level", "Label", "Detail")
178
+ table.cursor_type = "row"
179
+ self.action_refresh()
180
+
181
+ def _app(self):
182
+ from offline_docker.tui.app import OfflineDockerApp
183
+
184
+ app = self.app
185
+ assert isinstance(app, OfflineDockerApp)
186
+ return app
187
+
188
+ def action_refresh(self) -> None:
189
+ self._reload_status()
190
+
191
+ def action_discover(self) -> None:
192
+ self._show_discover()
193
+
194
+ def action_quit(self) -> None:
195
+ self.app.exit()
196
+
197
+ def on_button_pressed(self, event: Button.Pressed) -> None:
198
+ bid = event.button.id
199
+ if bid == "btn-load":
200
+ self._load_project_from_input()
201
+ elif bid == "btn-refresh":
202
+ self._reload_status()
203
+ elif bid == "btn-discover":
204
+ self._show_discover()
205
+ elif bid == "btn-run":
206
+ self._run_selected()
207
+
208
+ def on_list_view_highlighted(self, event: ListView.Highlighted) -> None:
209
+ idx = event.list_view.index
210
+ if idx is None or idx < 0 or idx >= len(_COMMANDS):
211
+ return
212
+ self._selected_command = _COMMANDS[idx][0]
213
+
214
+ def on_list_view_selected(self, event: ListView.Selected) -> None:
215
+ idx = event.list_view.index
216
+ if idx is None or idx < 0 or idx >= len(_COMMANDS):
217
+ return
218
+ self._selected_command = _COMMANDS[idx][0]
219
+ self._run_selected()
220
+
221
+ def _load_project_from_input(self) -> None:
222
+ raw = self.query_one("#project-input", Input).value.strip()
223
+ if not raw:
224
+ self.notify("Укажите путь к проекту", severity="warning")
225
+ return
226
+ path = Path(raw).expanduser().resolve()
227
+ if not path.is_dir():
228
+ self.notify(f"Каталог не найден: {path}", severity="error")
229
+ return
230
+ try:
231
+ self._app().set_project(path)
232
+ except Exception as exc: # noqa: BLE001
233
+ self.notify(f"Не удалось загрузить конфиг: {exc}", severity="error")
234
+ return
235
+ self.notify(f"Проект: {path}")
236
+ self._reload_status()
237
+
238
+ def _reload_status(self) -> None:
239
+ app = self._app()
240
+ try:
241
+ report = collect_cache_status(app.ctx)
242
+ except Exception as exc: # noqa: BLE001
243
+ self.notify(f"Status error: {exc}", severity="error")
244
+ return
245
+ self._fill_status(report)
246
+
247
+ def _fill_status(self, report: CacheStatusReport) -> None:
248
+ summary = self.query_one("#summary", Static)
249
+ mode = " soft" if report.soft else ""
250
+ summary.update(
251
+ f"cache={report.offline_cache} "
252
+ f"OK={report.ok_count} WARN={report.warn_count} ERROR={report.error_count}{mode} "
253
+ f"resolve={' -> '.join(report.resolve_order)}"
254
+ )
255
+ table = self.query_one("#status-table", DataTable)
256
+ table.clear()
257
+ for item in report.items:
258
+ table.add_row(item.level.upper(), item.label, item.detail or "")
259
+
260
+ def _show_discover(self) -> None:
261
+ try:
262
+ text = format_discovery_report(self._app().ctx.config)
263
+ except Exception as exc: # noqa: BLE001
264
+ self.notify(f"Discover error: {exc}", severity="error")
265
+ return
266
+ self.app.push_screen(DiscoverModal(text))
267
+
268
+ def _flag(self, widget_id: str) -> bool:
269
+ return self.query_one(f"#{widget_id}", Checkbox).value
270
+
271
+ def _run_selected(self) -> None:
272
+ if self._running:
273
+ self.notify("Уже выполняется команда", severity="warning")
274
+ return
275
+ key = self._selected_command
276
+ if not key:
277
+ lv = self.query_one("#commands", ListView)
278
+ if lv.index is not None and 0 <= lv.index < len(_COMMANDS):
279
+ key = _COMMANDS[lv.index][0]
280
+ if not key:
281
+ self.notify("Выберите команду", severity="warning")
282
+ return
283
+
284
+ name = key
285
+ opts: dict = {
286
+ "no_cache": self._flag("flag-no-cache"),
287
+ "lab": self._flag("flag-lab"),
288
+ "online": self._flag("flag-online"),
289
+ "no_build": self._flag("flag-no-build"),
290
+ "no_deps": self._flag("flag-no-deps"),
291
+ }
292
+ if key.startswith("scan:"):
293
+ name = "scan"
294
+ opts["target"] = key.split(":", 1)[1]
295
+ if name == "up-online":
296
+ name = "up"
297
+ opts["online"] = True
298
+ opts["lab"] = False
299
+ if opts.get("lab") and opts.get("online"):
300
+ self.notify("--lab и --online взаимоисключающие; снимите один флаг", severity="warning")
301
+ return
302
+
303
+ if name not in PROJECT_ACTIONS:
304
+ self.notify(f"Неизвестная команда: {name}", severity="error")
305
+ return
306
+
307
+ log = self.query_one("#run-log", RichLog)
308
+ log.clear()
309
+ log.write(f">>> {name} {opts}")
310
+ self._running = True
311
+ self.query_one("#btn-run", Button).disabled = True
312
+
313
+ app = self._app()
314
+
315
+ def on_line(line: str) -> None:
316
+ app.call_from_thread(log.write, line)
317
+
318
+ def on_done(code: int, err: str | None) -> None:
319
+ def finish() -> None:
320
+ self._running = False
321
+ self.query_one("#btn-run", Button).disabled = False
322
+ if err:
323
+ log.write(f"!!! {err}")
324
+ log.write(f"=== exit {code} ===")
325
+ if code == 0:
326
+ self.notify(f"{name}: OK")
327
+ self._reload_status()
328
+ else:
329
+ self.notify(f"{name}: failed ({code})", severity="error")
330
+
331
+ app.call_from_thread(finish)
332
+
333
+ run_action_captured(name, app.ctx, on_line=on_line, on_done=on_done, **opts)
@@ -0,0 +1,89 @@
1
+ """User-level sources config (~/.config/offline-docker/sources.json)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+
12
+ def user_config_dir() -> Path:
13
+ override = os.environ.get("OFFLINE_DOCKER_CONFIG_DIR")
14
+ if override:
15
+ return Path(override).expanduser().resolve()
16
+ if sys.platform == "win32":
17
+ base = os.environ.get("APPDATA")
18
+ if base:
19
+ return Path(base) / "offline-docker"
20
+ xdg = os.environ.get("XDG_CONFIG_HOME")
21
+ if xdg:
22
+ return Path(xdg).expanduser() / "offline-docker"
23
+ return Path.home() / ".config" / "offline-docker"
24
+
25
+
26
+ def user_sources_path() -> Path:
27
+ return user_config_dir() / "sources.json"
28
+
29
+
30
+ def local_only_dict() -> dict[str, Any]:
31
+ """Offline / corp VM: только offline-cache (без HTTP index в resolve.order)."""
32
+ return {
33
+ "sources": [
34
+ {"name": "local", "type": "filesystem", "enabled": True},
35
+ ],
36
+ "resolve": {
37
+ "order": ["local"],
38
+ "fill_local": True,
39
+ "on_miss": "fail",
40
+ },
41
+ }
42
+
43
+
44
+ def online_fill_dict() -> dict[str, Any]:
45
+ """Машина с сетью: local + PyPI/npmjs upstream для download-* (fill_local)."""
46
+ return {
47
+ "sources": [
48
+ {"name": "local", "type": "filesystem", "enabled": True},
49
+ {
50
+ "name": "upstream",
51
+ "type": "upstream",
52
+ "enabled": True,
53
+ "pypi_index": "https://pypi.org/simple",
54
+ "npm_registry": "https://registry.npmjs.org/",
55
+ },
56
+ ],
57
+ "resolve": {
58
+ "order": ["local", "upstream"],
59
+ "fill_local": True,
60
+ "on_miss": "fail",
61
+ },
62
+ }
63
+
64
+
65
+ def load_user_sources() -> dict[str, Any] | None:
66
+ path = user_sources_path()
67
+ if not path.is_file():
68
+ return None
69
+ try:
70
+ data = json.loads(path.read_text(encoding="utf-8"))
71
+ except (OSError, json.JSONDecodeError):
72
+ return None
73
+ if not isinstance(data, dict):
74
+ return None
75
+ return data
76
+
77
+
78
+ def save_user_sources(data: dict[str, Any]) -> Path:
79
+ path = user_sources_path()
80
+ path.parent.mkdir(parents=True, exist_ok=True)
81
+ path.write_text(
82
+ json.dumps(data, ensure_ascii=False, indent=2) + "\n",
83
+ encoding="utf-8",
84
+ )
85
+ return path
86
+
87
+
88
+ def user_config_exists() -> bool:
89
+ return user_sources_path().is_file()
@@ -0,0 +1,73 @@
1
+ """Shared helpers: hashes, docker mount paths."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import re
7
+ from pathlib import Path
8
+
9
+
10
+ def docker_mount_path(path: Path | str) -> str:
11
+ """Host path for Docker -v mounts (Windows: D:/... with forward slashes)."""
12
+ p = Path(path).resolve()
13
+ return str(p).replace("\\", "/")
14
+
15
+
16
+ def file_sha256(path: Path) -> str:
17
+ h = hashlib.sha256()
18
+ with path.open("rb") as f:
19
+ for chunk in iter(lambda: f.read(1024 * 1024), b""):
20
+ h.update(chunk)
21
+ return h.hexdigest()
22
+
23
+
24
+ def combined_files_hash(paths: list[Path]) -> str:
25
+ parts = "|".join(file_sha256(p) for p in paths if p.is_file())
26
+ return hashlib.sha256(parts.encode()).hexdigest()
27
+
28
+
29
+ def read_torch_version(path: Path) -> str:
30
+ for line in path.read_text(encoding="utf-8").splitlines():
31
+ line = line.strip()
32
+ if line.startswith("torch=="):
33
+ return line.split("==", 1)[1].strip()
34
+ raise RuntimeError(f"torch==VERSION not found in {path}")
35
+
36
+
37
+ _APT_INSTALL_RE = re.compile(
38
+ r"apt-get\s+install\b(?:[^\n\\]|\\\s*\n)+",
39
+ re.MULTILINE,
40
+ )
41
+
42
+
43
+ def parse_apt_packages_from_dockerfiles(*dockerfiles: Path) -> list[str]:
44
+ """Extract package names from apt-get install lines in Dockerfiles."""
45
+ packages: list[str] = []
46
+ seen: set[str] = set()
47
+ skip = {
48
+ "apt-get",
49
+ "install",
50
+ "-y",
51
+ "--no-install-recommends",
52
+ "&&",
53
+ "rm",
54
+ "-rf",
55
+ "/var/lib/apt/lists/*",
56
+ }
57
+
58
+ for dockerfile in dockerfiles:
59
+ if not dockerfile.is_file():
60
+ continue
61
+ text = dockerfile.read_text(encoding="utf-8", errors="replace")
62
+ for block in _APT_INSTALL_RE.findall(text):
63
+ block = block.replace("\\\n", " ")
64
+ for token in re.split(r"[\s\\]+", block):
65
+ token = token.strip().strip('"').strip("'")
66
+ if not token or token in skip or token.startswith("-"):
67
+ continue
68
+ if token.startswith("/") or "=" in token:
69
+ continue
70
+ if token not in seen:
71
+ seen.add(token)
72
+ packages.append(token)
73
+ return packages
@@ -0,0 +1,53 @@
1
+ """Validate offline-cache completeness."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from offline_docker.context import ProjectContext
6
+ from offline_docker.status import collect_cache_status
7
+
8
+
9
+ def _format_item_line(level: str, label: str, detail: str) -> str:
10
+ if level == "ok":
11
+ if not detail:
12
+ return f"OK: {label}"
13
+ if detail.startswith("/") or ":\\" in detail or detail.startswith("\\\\"):
14
+ return f"OK: {label} -> {detail}"
15
+ if detail.endswith("файлов") or detail.isdigit():
16
+ return f"OK: {label} - {detail}"
17
+ if detail.startswith("соответствует"):
18
+ return f"OK: {label} {detail}" if "hash" in label or "cache" in label else f"OK: {label} {detail}"
19
+ # paths / archive names / generic
20
+ if "/" in detail or "\\" in detail or detail.endswith(".tar") or detail.endswith(".tar.gz"):
21
+ return f"OK: {label} -> {detail}"
22
+ return f"OK: {label} - {detail}" if " " in detail else f"OK: {label} -> {detail}"
23
+ if level == "skip":
24
+ return f"SKIP: {label} ({detail})" if detail else f"SKIP: {label}"
25
+ prefix = "WARN" if level == "warn" else "ERROR"
26
+ msg = detail or label
27
+ return f"{prefix}: {msg}"
28
+
29
+
30
+ def validate_offline_cache(ctx: ProjectContext) -> None:
31
+ report = collect_cache_status(ctx)
32
+
33
+ print("=== cache check ===")
34
+ print(f"Project: {report.project}")
35
+ print(f"OFFLINE_CACHE: {report.offline_cache}")
36
+ print(f"Resolve: {' -> '.join(report.resolve_order)}")
37
+ print(f"on_miss: {report.on_miss}")
38
+ if report.soft:
39
+ print("Mode: soft (WARN instead of ERROR when remotes configured)")
40
+ print()
41
+
42
+ for item in report.items:
43
+ line = _format_item_line(item.level, item.label, item.detail)
44
+ if item.level in ("warn", "error"):
45
+ print(line, flush=True)
46
+ else:
47
+ print(line)
48
+
49
+ print()
50
+ if report.error_count:
51
+ print(f"=== FAILED: {report.error_count} ошибок ===", flush=True)
52
+ raise SystemExit(1)
53
+ print("=== cache check: OK ===")