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
offline_docker/scan.py ADDED
@@ -0,0 +1,496 @@
1
+ """Recursive project inventory for discover/init."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+
9
+ from offline_docker.config import DockerfilesConfig, ImagesConfig, ProjectConfig
10
+ from offline_docker.defaults import (
11
+ DEFAULT_CRITICAL_WHEELS,
12
+ DEFAULT_DEB_PACKAGES,
13
+ DEFAULT_DOCKER_IMAGES,
14
+ )
15
+ from offline_docker.utils import parse_apt_packages_from_dockerfiles
16
+
17
+ SKIP_DIR_NAMES = frozenset(
18
+ {
19
+ ".git",
20
+ "node_modules",
21
+ ".venv",
22
+ "venv",
23
+ "__pycache__",
24
+ "offline-cache",
25
+ ".tox",
26
+ "dist",
27
+ "build",
28
+ ".mypy_cache",
29
+ ".ruff_cache",
30
+ "coverage",
31
+ ".idea",
32
+ ".cursor",
33
+ ".pytest_cache",
34
+ ".eggs",
35
+ ".nox",
36
+ "htmlcov",
37
+ ".next",
38
+ ".nuxt",
39
+ "vendor",
40
+ }
41
+ )
42
+
43
+ _FRONTEND_DIR_PRIORITY = ("frontend", "web", "ui", "client", "fe_mt_react")
44
+ _FRONTEND_LOCK_NAMES = ("package-lock.json", "yarn.lock", "pnpm-lock.yaml")
45
+ _FROM_RE = re.compile(
46
+ r"^\s*FROM\s+(?:--platform=\S+\s+)?(?P<img>[^\s]+)(?:\s+AS\s+\S+)?\s*$",
47
+ re.IGNORECASE | re.MULTILINE,
48
+ )
49
+ _VAR_FROM_RE = re.compile(r"^\$\{|^[A-Z_][A-Z0-9_]*$")
50
+
51
+
52
+ @dataclass
53
+ class ScanInventory:
54
+ compose_files: list[str] = field(default_factory=list)
55
+ dockerfiles: list[str] = field(default_factory=list)
56
+ python_files: list[str] = field(default_factory=list)
57
+ package_json_dirs: list[str] = field(default_factory=list)
58
+ frontend_lock_paths: list[str] = field(default_factory=list)
59
+ ambiguities: list[str] = field(default_factory=list)
60
+ used_default_debs: bool = False
61
+
62
+
63
+ def _rel(root: Path, path: Path) -> str:
64
+ return path.relative_to(root).as_posix()
65
+
66
+
67
+ def _should_skip_dir(name: str) -> bool:
68
+ return name in SKIP_DIR_NAMES or name.startswith(".")
69
+
70
+
71
+ def recursive_scan(root: Path) -> ScanInventory:
72
+ """Walk project tree and collect compose/Dockerfile/python/frontend paths."""
73
+ root = root.resolve()
74
+ inv = ScanInventory()
75
+ compose: list[str] = []
76
+ dockerfiles: list[str] = []
77
+ python_files: list[str] = []
78
+ package_json_dirs: list[str] = []
79
+ frontend_locks: list[str] = []
80
+
81
+ for dirpath, dirnames, filenames in os_walk_filtered(root):
82
+ base = Path(dirpath)
83
+ for name in filenames:
84
+ path = base / name
85
+ rel = _rel(root, path)
86
+ lower = name.lower()
87
+
88
+ if name.startswith("docker-compose") and lower.endswith((".yml", ".yaml")):
89
+ compose.append(rel)
90
+ elif name == "Dockerfile" or name.startswith("Dockerfile."):
91
+ dockerfiles.append(rel)
92
+ elif name in ("pyproject.toml", "uv.lock") or (
93
+ name.startswith("requirements") and name.endswith(".txt")
94
+ ):
95
+ python_files.append(rel)
96
+ elif name == "package.json":
97
+ parent = _rel(root, base) if base != root else "."
98
+ package_json_dirs.append(parent)
99
+ elif name in _FRONTEND_LOCK_NAMES:
100
+ frontend_locks.append(rel)
101
+
102
+ inv.compose_files = sorted(set(compose))
103
+ inv.dockerfiles = sorted(set(dockerfiles))
104
+ inv.python_files = sorted(set(python_files))
105
+ inv.package_json_dirs = sorted(set(package_json_dirs))
106
+ inv.frontend_lock_paths = sorted(set(frontend_locks))
107
+ return inv
108
+
109
+
110
+ def os_walk_filtered(root: Path):
111
+ """os.walk-like generator that prunes SKIP_DIR_NAMES in-place."""
112
+ import os
113
+
114
+ for dirpath, dirnames, filenames in os.walk(root):
115
+ dirnames[:] = sorted(d for d in dirnames if not _should_skip_dir(d))
116
+ yield dirpath, dirnames, filenames
117
+
118
+
119
+ def _path_depth(rel: str) -> int:
120
+ if rel in (".", ""):
121
+ return 0
122
+ return len(Path(rel).parts)
123
+
124
+
125
+ def _pick_frontend(inv: ScanInventory) -> tuple[str, str]:
126
+ """Return (frontend_dir, lock_file_name)."""
127
+ lock_by_dir: dict[str, str] = {}
128
+ for rel in inv.frontend_lock_paths:
129
+ p = Path(rel)
130
+ parent = "." if p.parent == Path(".") else p.parent.as_posix()
131
+ # Prefer package-lock over yarn/pnpm if multiple in same dir
132
+ existing = lock_by_dir.get(parent)
133
+ if existing is None or p.name == "package-lock.json":
134
+ lock_by_dir[parent] = p.name
135
+
136
+ candidates = list(lock_by_dir.keys()) or list(inv.package_json_dirs)
137
+ if not candidates:
138
+ return "frontend", "package-lock.json"
139
+
140
+ def score(d: str) -> tuple[int, int, str]:
141
+ name = Path(d).name if d != "." else d
142
+ try:
143
+ prio = _FRONTEND_DIR_PRIORITY.index(name)
144
+ except ValueError:
145
+ prio = len(_FRONTEND_DIR_PRIORITY)
146
+ return (prio, _path_depth(d), d)
147
+
148
+ chosen = min(candidates, key=score)
149
+ lock_name = lock_by_dir.get(chosen, "package-lock.json")
150
+ return chosen, lock_name
151
+
152
+
153
+ def _is_frontend_dockerfile(rel: str, frontend_dir: str) -> bool:
154
+ if frontend_dir != ".":
155
+ return rel == f"{frontend_dir}/Dockerfile" or rel.startswith(
156
+ f"{frontend_dir}/Dockerfile."
157
+ )
158
+ parts_lower = {p.lower() for p in Path(rel).parts[:-1]}
159
+ return bool(parts_lower & set(_FRONTEND_DIR_PRIORITY))
160
+
161
+
162
+ def _classify_dockerfiles(inv: ScanInventory, frontend_dir: str) -> DockerfilesConfig:
163
+ df = DockerfilesConfig()
164
+
165
+ for rel in inv.dockerfiles:
166
+ name = Path(rel).name
167
+ parent = Path(rel).parent.as_posix()
168
+ offline = "offline" in name.lower()
169
+
170
+ if _is_frontend_dockerfile(rel, frontend_dir):
171
+ if offline:
172
+ df.frontend_offline = rel
173
+ else:
174
+ df.frontend = rel
175
+ continue
176
+
177
+ if name.startswith("Dockerfile.base"):
178
+ if offline:
179
+ df.base_offline = rel
180
+ else:
181
+ df.base = rel
182
+ continue
183
+
184
+ if name.startswith("Dockerfile") and offline:
185
+ if parent == "." or any(
186
+ x in parent.lower() for x in ("backend", "api", "server")
187
+ ):
188
+ df.backend_offline = rel
189
+ continue
190
+
191
+ if name == "Dockerfile":
192
+ if parent == "." or any(
193
+ x in parent.lower() for x in ("backend", "api", "server")
194
+ ):
195
+ df.backend = rel
196
+ elif "base" in parent.lower():
197
+ df.base = rel
198
+
199
+ # Prefer canonical root names when present
200
+ preferred = {
201
+ "base": "Dockerfile.base",
202
+ "base_offline": "Dockerfile.base.offline",
203
+ "backend": "Dockerfile",
204
+ "backend_offline": "Dockerfile.offline",
205
+ }
206
+ for attr, default_name in preferred.items():
207
+ if default_name in inv.dockerfiles:
208
+ setattr(df, attr, default_name)
209
+
210
+ if frontend_dir != ".":
211
+ fe_docker = f"{frontend_dir}/Dockerfile"
212
+ fe_offline = f"{frontend_dir}/Dockerfile.offline"
213
+ if fe_docker in inv.dockerfiles:
214
+ df.frontend = fe_docker
215
+ if fe_offline in inv.dockerfiles:
216
+ df.frontend_offline = fe_offline
217
+
218
+ return df
219
+
220
+ def _pick_compose(inv: ScanInventory) -> tuple[list[str], str, str]:
221
+ files = list(inv.compose_files)
222
+ # Prefer root-level compose in listing order (already sorted; root first by depth)
223
+ files_sorted = sorted(files, key=lambda f: (_path_depth(f), f))
224
+
225
+ compose_offline = "docker-compose.offline.yml"
226
+ compose_online = "docker-compose.yml"
227
+
228
+ offline_candidates = [f for f in files_sorted if "offline" in Path(f).name.lower()]
229
+ online_candidates = [
230
+ f
231
+ for f in files_sorted
232
+ if "offline" not in Path(f).name.lower() and "lab" not in Path(f).name.lower()
233
+ ]
234
+
235
+ if offline_candidates:
236
+ compose_offline = offline_candidates[0]
237
+ if online_candidates:
238
+ compose_online = online_candidates[0]
239
+ elif files_sorted:
240
+ compose_online = files_sorted[0]
241
+
242
+ return files_sorted, compose_online, compose_offline
243
+
244
+
245
+ def _guess_app_name(root: Path, compose_files: list[str]) -> str:
246
+ for compose in compose_files:
247
+ path = root / compose
248
+ if not path.is_file():
249
+ continue
250
+ text = path.read_text(encoding="utf-8", errors="replace")
251
+ m = re.search(r"^name:\s*(\S+)", text, re.MULTILINE)
252
+ if m:
253
+ return m.group(1).strip('"').strip("'")
254
+ return root.name.replace("-", "").replace("_", "")[:20] or "app"
255
+
256
+
257
+ def _guess_image_names(root: Path, compose_online: str) -> ImagesConfig:
258
+ images = ImagesConfig()
259
+ online_compose = root / compose_online
260
+ if online_compose.is_file():
261
+ text = online_compose.read_text(encoding="utf-8", errors="replace")
262
+ for svc in ("backend", "frontend"):
263
+ pattern = rf"(?:\w+_)?{svc.upper()}_IMAGE:-([^}}]+)\}}"
264
+ match = re.search(pattern, text, re.IGNORECASE)
265
+ if match:
266
+ setattr(images, svc, match.group(1).strip())
267
+
268
+ base_match = re.search(r"BASE_IMAGE:\s*\$\{{[^:}]+:-([^}]+)\}", text)
269
+ if base_match:
270
+ images.base = base_match.group(1).strip()
271
+
272
+ if images.backend == "app-backend:latest":
273
+ images.backend = f"{root.name}-backend:latest"
274
+ if images.frontend == "app-frontend:latest":
275
+ images.frontend = f"{root.name}-frontend:latest"
276
+ return images
277
+
278
+
279
+ def _pick_python_lock_files(inv: ScanInventory, dockerfiles: DockerfilesConfig) -> list[str]:
280
+ if not inv.python_files:
281
+ return []
282
+
283
+ def score(rel: str) -> tuple[int, int, int, str]:
284
+ depth = _path_depth(rel)
285
+ # Prefer next to backend dockerfile
286
+ backend_parent = Path(dockerfiles.backend).parent.as_posix()
287
+ near_backend = 0
288
+ if backend_parent != "." and rel.startswith(f"{backend_parent}/"):
289
+ near_backend = -1
290
+ elif depth == 0 or Path(rel).parent == Path("."):
291
+ near_backend = -2 # root wins
292
+ name_prio = {
293
+ "pyproject.toml": 0,
294
+ "uv.lock": 1,
295
+ "requirements-base.txt": 2,
296
+ "requirements.txt": 3,
297
+ }.get(Path(rel).name, 9)
298
+ return (near_backend, depth, name_prio, rel)
299
+
300
+ ordered = sorted(inv.python_files, key=score)
301
+ # Keep reasonable set: all root files + files in chosen project dir
302
+ root_files = [f for f in ordered if Path(f).parent == Path(".")]
303
+ if root_files:
304
+ return root_files
305
+
306
+ # Group by directory; take the shallowest directory's files
307
+ by_dir: dict[str, list[str]] = {}
308
+ for f in ordered:
309
+ parent = "." if Path(f).parent == Path(".") else Path(f).parent.as_posix()
310
+ by_dir.setdefault(parent, []).append(f)
311
+ best_dir = min(by_dir.keys(), key=lambda d: (_path_depth(d), d))
312
+ return by_dir[best_dir]
313
+
314
+
315
+ def _detect_torch_file(root: Path, python_lock_files: list[str]) -> str:
316
+ for rel in python_lock_files:
317
+ if Path(rel).name.startswith("requirements") and (root / rel).is_file():
318
+ text = (root / rel).read_text(encoding="utf-8", errors="replace")
319
+ if "torch==" in text:
320
+ return rel
321
+ for rel in python_lock_files:
322
+ if Path(rel).name.startswith("requirements"):
323
+ return rel
324
+ return "requirements-base.txt"
325
+
326
+
327
+ def parse_from_images(*dockerfiles: Path) -> list[str]:
328
+ """Extract simple FROM image:tag references (skip variables / scratch)."""
329
+ images: list[str] = []
330
+ seen: set[str] = set()
331
+ for dockerfile in dockerfiles:
332
+ if not dockerfile.is_file():
333
+ continue
334
+ text = dockerfile.read_text(encoding="utf-8", errors="replace")
335
+ for m in _FROM_RE.finditer(text):
336
+ img = m.group("img").strip()
337
+ if not img or img.lower() == "scratch":
338
+ continue
339
+ if img.startswith("$") or _VAR_FROM_RE.match(img):
340
+ continue
341
+ if img not in seen:
342
+ seen.add(img)
343
+ images.append(img)
344
+ return images
345
+
346
+
347
+ def _collect_ambiguities(
348
+ inv: ScanInventory, frontend_dir: str, python_lock_files: list[str]
349
+ ) -> list[str]:
350
+ notes: list[str] = []
351
+ lock_dirs = {
352
+ ("." if Path(p).parent == Path(".") else Path(p).parent.as_posix())
353
+ for p in inv.frontend_lock_paths
354
+ }
355
+ pkg_dirs = set(inv.package_json_dirs)
356
+ fe_candidates = lock_dirs or pkg_dirs
357
+ if len(fe_candidates) > 1:
358
+ others = sorted(fe_candidates - {frontend_dir})
359
+ notes.append(
360
+ f"несколько frontend-кандидатов; выбран {frontend_dir}/ "
361
+ f"(ещё: {', '.join(others)})"
362
+ )
363
+
364
+ py_dirs = {
365
+ ("." if Path(p).parent == Path(".") else Path(p).parent.as_posix())
366
+ for p in inv.python_files
367
+ }
368
+ chosen_py_dirs = {
369
+ ("." if Path(p).parent == Path(".") else Path(p).parent.as_posix())
370
+ for p in python_lock_files
371
+ }
372
+ if len(py_dirs) > 1:
373
+ others = sorted(py_dirs - chosen_py_dirs)
374
+ if others:
375
+ notes.append(
376
+ f"несколько python-деревьев; в конфиг: {', '.join(sorted(chosen_py_dirs)) or '(пусто)'} "
377
+ f"(ещё: {', '.join(others)})"
378
+ )
379
+ return notes
380
+
381
+
382
+ def inventory_to_draft_config(root: Path, inventory: ScanInventory) -> ProjectConfig:
383
+ """Map scan inventory to ProjectConfig using broader heuristics."""
384
+ root = root.resolve()
385
+ frontend_dir, frontend_lock = _pick_frontend(inventory)
386
+ dockerfiles = _classify_dockerfiles(inventory, frontend_dir)
387
+ compose_files, compose_online, compose_offline = _pick_compose(inventory)
388
+ python_lock_files = _pick_python_lock_files(inventory, dockerfiles)
389
+ inventory.ambiguities = _collect_ambiguities(
390
+ inventory, frontend_dir, python_lock_files
391
+ )
392
+
393
+ app_name = _guess_app_name(root, compose_files)
394
+
395
+ deb_from_docker = parse_apt_packages_from_dockerfiles(
396
+ root / dockerfiles.base,
397
+ root / dockerfiles.backend,
398
+ )
399
+ inventory.used_default_debs = not bool(deb_from_docker)
400
+ deb_packages = deb_from_docker if deb_from_docker else list(DEFAULT_DEB_PACKAGES)
401
+
402
+ from_imgs = parse_from_images(
403
+ *(root / rel for rel in inventory.dockerfiles)
404
+ )
405
+ docker_images: list[str] = []
406
+ seen_img: set[str] = set()
407
+ for img in list(DEFAULT_DOCKER_IMAGES) + from_imgs:
408
+ if img not in seen_img:
409
+ seen_img.add(img)
410
+ docker_images.append(img)
411
+
412
+ log_dir = root / "storage" / "logs"
413
+ if not log_dir.parent.is_dir():
414
+ log_dir = root / "logs"
415
+
416
+ cfg = ProjectConfig(
417
+ root=root,
418
+ offline_cache=root / "offline-cache",
419
+ log_dir=log_dir,
420
+ compose_files=compose_files,
421
+ compose_offline=compose_offline,
422
+ compose_online=compose_online,
423
+ compose_build=[compose_online, compose_offline],
424
+ compose_up_offline=[compose_online, compose_offline],
425
+ frontend_dir=frontend_dir,
426
+ frontend_lock=frontend_lock,
427
+ python_lock_files=python_lock_files
428
+ or ["pyproject.toml", "uv.lock", "requirements-base.txt"],
429
+ torch_version_file=_detect_torch_file(root, python_lock_files),
430
+ deb_packages=deb_packages,
431
+ docker_images=docker_images,
432
+ dockerfiles=dockerfiles,
433
+ images=_guess_image_names(root, compose_online),
434
+ critical_wheels=list(DEFAULT_CRITICAL_WHEELS),
435
+ app_name=app_name,
436
+ )
437
+ return cfg
438
+
439
+
440
+ def compute_init_gaps(cfg: ProjectConfig, inventory: ScanInventory) -> list[str]:
441
+ """Human-readable list of missing offline artifacts / ambiguities to fix."""
442
+ gaps: list[str] = []
443
+ root = cfg.root
444
+
445
+ def missing(rel: str, label: str) -> None:
446
+ if not (root / rel).is_file():
447
+ gaps.append(f"нет файла {label}: {rel}")
448
+
449
+ missing(cfg.dockerfiles.base_offline, "Dockerfile.base.offline")
450
+ missing(cfg.dockerfiles.backend_offline, "Dockerfile.offline (backend)")
451
+ missing(cfg.dockerfiles.frontend_offline, "frontend Dockerfile.offline")
452
+
453
+ offline_compose = [
454
+ f for f in inventory.compose_files if "offline" in Path(f).name.lower()
455
+ ]
456
+ if not offline_compose:
457
+ gaps.append(
458
+ f"нет compose с offline в имени (ожидается например {cfg.compose_offline})"
459
+ )
460
+
461
+ if not (root / cfg.dockerfiles.base).is_file():
462
+ gaps.append(f"нет base Dockerfile: {cfg.dockerfiles.base}")
463
+ if not (root / cfg.dockerfiles.backend).is_file():
464
+ gaps.append(f"нет backend Dockerfile: {cfg.dockerfiles.backend}")
465
+ if not (root / cfg.dockerfiles.frontend).is_file():
466
+ gaps.append(f"нет frontend Dockerfile: {cfg.dockerfiles.frontend}")
467
+
468
+ fe_lock = root / cfg.frontend_dir / cfg.frontend_lock
469
+ if not fe_lock.is_file():
470
+ gaps.append(
471
+ f"не найден frontend lock: {cfg.frontend_dir}/{cfg.frontend_lock}"
472
+ )
473
+
474
+ existing_py = [f for f in cfg.python_lock_files if (root / f).is_file()]
475
+ if not existing_py and not inventory.python_files:
476
+ gaps.append("не найдены python lock/requirements (pyproject.toml, uv.lock, requirements*.txt)")
477
+
478
+ if inventory.used_default_debs:
479
+ gaps.append(
480
+ "APT-пакеты не извлечены из Dockerfile — в черновике DEFAULT_DEB_PACKAGES; "
481
+ "проверьте debs.packages"
482
+ )
483
+
484
+ for note in inventory.ambiguities:
485
+ gaps.append(f"ambiguity: {note}")
486
+
487
+ return gaps
488
+
489
+
490
+ def resolve_init_output_path(root: Path) -> Path:
491
+ """Canonical config if absent, else .offline-docker.generated.json."""
492
+ from offline_docker.config import find_project_config_file
493
+
494
+ if find_project_config_file(root) is not None:
495
+ return root / ".offline-docker.generated.json"
496
+ return root / ".offline-docker.json"
@@ -0,0 +1,18 @@
1
+ """Package / cache source backends."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from offline_docker.sources.base import Ecosystem, PackageSource
6
+ from offline_docker.sources.filesystem import FilesystemSource
7
+ from offline_docker.sources.nexus import NexusSource
8
+ from offline_docker.sources.proget import ProGetSource
9
+ from offline_docker.sources.upstream import UpstreamSource
10
+
11
+ __all__ = [
12
+ "Ecosystem",
13
+ "FilesystemSource",
14
+ "NexusSource",
15
+ "PackageSource",
16
+ "ProGetSource",
17
+ "UpstreamSource",
18
+ ]
@@ -0,0 +1,18 @@
1
+ """Package source protocol."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal, Protocol, runtime_checkable
6
+
7
+ Ecosystem = Literal["pypi", "npm", "apt"]
8
+
9
+
10
+ @runtime_checkable
11
+ class PackageSource(Protocol):
12
+ name: str
13
+ source_type: str
14
+ enabled: bool
15
+
16
+ def index_urls(self, ecosystem: Ecosystem) -> list[str]:
17
+ """Ordered index/registry URLs for the ecosystem (may be empty)."""
18
+ ...
@@ -0,0 +1,17 @@
1
+ """Local offline-cache filesystem source."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from offline_docker.sources.base import Ecosystem
6
+
7
+
8
+ class FilesystemSource:
9
+ source_type = "filesystem"
10
+
11
+ def __init__(self, name: str = "local", *, enabled: bool = True) -> None:
12
+ self.name = name
13
+ self.enabled = enabled
14
+
15
+ def index_urls(self, ecosystem: Ecosystem) -> list[str]:
16
+ # Local is consumed via --find-links / npm cache mount, not HTTP indexes.
17
+ return []
@@ -0,0 +1,61 @@
1
+ """Sonatype Nexus Repository source."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from urllib.parse import urljoin
6
+
7
+ from offline_docker.sources.base import Ecosystem
8
+
9
+
10
+ class NexusSource:
11
+ source_type = "nexus"
12
+
13
+ def __init__(
14
+ self,
15
+ name: str = "nexus",
16
+ *,
17
+ url: str,
18
+ repos: dict[str, str] | None = None,
19
+ auth_env: str = "NEXUS_AUTH",
20
+ enabled: bool = True,
21
+ publish: bool = False,
22
+ raw_repo: str | None = None,
23
+ ) -> None:
24
+ self.name = name
25
+ self.url = url.rstrip("/")
26
+ self.repos = dict(repos or {})
27
+ self.auth_env = auth_env
28
+ self.enabled = enabled and bool(url.strip())
29
+ self.publish = publish
30
+ self.raw_repo = raw_repo or self.repos.get("raw")
31
+
32
+ def _repo_url(self, repo: str, suffix: str = "") -> str:
33
+ base = f"{self.url}/repository/{repo.strip('/')}/"
34
+ if not suffix:
35
+ return base
36
+ return urljoin(base, suffix.lstrip("/"))
37
+
38
+ def index_urls(self, ecosystem: Ecosystem) -> list[str]:
39
+ if not self.enabled:
40
+ return []
41
+ if ecosystem == "pypi":
42
+ repo = self.repos.get("pypi")
43
+ if not repo:
44
+ return []
45
+ return [self._repo_url(repo, "simple").rstrip("/") ]
46
+ if ecosystem == "npm":
47
+ repo = self.repos.get("npm")
48
+ if not repo:
49
+ return []
50
+ return [self._repo_url(repo)]
51
+ if ecosystem == "apt":
52
+ repo = self.repos.get("apt") or self.repos.get("deb")
53
+ if not repo:
54
+ return []
55
+ return [self._repo_url(repo)]
56
+ return []
57
+
58
+ def raw_base_url(self) -> str | None:
59
+ if not self.enabled or not self.raw_repo:
60
+ return None
61
+ return self._repo_url(self.raw_repo)
@@ -0,0 +1,67 @@
1
+ """HTTP helpers for Nexus raw repository sync."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import os
7
+ import urllib.error
8
+ import urllib.request
9
+ from pathlib import Path
10
+
11
+
12
+ def _auth_header(auth_env: str) -> dict[str, str]:
13
+ raw = os.environ.get(auth_env, "").strip()
14
+ if not raw:
15
+ return {}
16
+ if raw.lower().startswith("basic ") or raw.lower().startswith("bearer "):
17
+ return {"Authorization": raw}
18
+ if ":" in raw:
19
+ token = base64.b64encode(raw.encode("utf-8")).decode("ascii")
20
+ return {"Authorization": f"Basic {token}"}
21
+ return {"Authorization": f"Bearer {raw}"}
22
+
23
+
24
+ def _request(
25
+ method: str,
26
+ url: str,
27
+ *,
28
+ auth_env: str,
29
+ data: bytes | None = None,
30
+ content_type: str | None = None,
31
+ ) -> bytes:
32
+ headers = dict(_auth_header(auth_env))
33
+ if content_type:
34
+ headers["Content-Type"] = content_type
35
+ req = urllib.request.Request(url, data=data, headers=headers, method=method)
36
+ try:
37
+ with urllib.request.urlopen(req, timeout=120) as resp:
38
+ return resp.read()
39
+ except urllib.error.HTTPError as exc:
40
+ body = exc.read().decode("utf-8", errors="replace")[:500]
41
+ raise RuntimeError(f"Nexus HTTP {exc.code} {url}: {body}") from exc
42
+ except urllib.error.URLError as exc:
43
+ raise RuntimeError(f"Nexus unreachable {url}: {exc}") from exc
44
+
45
+
46
+ def download_file(url: str, dest: Path, *, auth_env: str) -> None:
47
+ dest.parent.mkdir(parents=True, exist_ok=True)
48
+ data = _request("GET", url, auth_env=auth_env)
49
+ dest.write_bytes(data)
50
+
51
+
52
+ def upload_file(url: str, src: Path, *, auth_env: str) -> None:
53
+ data = src.read_bytes()
54
+ _request(
55
+ "PUT",
56
+ url,
57
+ auth_env=auth_env,
58
+ data=data,
59
+ content_type="application/octet-stream",
60
+ )
61
+
62
+
63
+ def join_url(base: str, *parts: str) -> str:
64
+ url = base.rstrip("/")
65
+ for part in parts:
66
+ url = f"{url}/{part.strip('/')}"
67
+ return url