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,84 @@
1
+ """Project hooks for offline-cache (e.g. vendor binaries)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from offline_docker import docker_cmd
10
+ from offline_docker.config import CacheHook
11
+ from offline_docker.context import ProjectContext
12
+
13
+
14
+ def _resolve_command(hook: CacheHook, root: Path) -> list[str] | None:
15
+ """Pick win/sh/script command for the current platform."""
16
+ is_win = os.name == "nt"
17
+ if is_win and hook.win:
18
+ return list(hook.win)
19
+ if (not is_win) and hook.sh:
20
+ return list(hook.sh)
21
+ if hook.script:
22
+ script = root / hook.script
23
+ if not script.is_file():
24
+ raise SystemExit(
25
+ f"ERROR: hooks.after_cache[{hook.name}]: нет файла {hook.script}"
26
+ )
27
+ if script.suffix.lower() == ".ps1":
28
+ return [
29
+ "powershell",
30
+ "-NoProfile",
31
+ "-ExecutionPolicy",
32
+ "Bypass",
33
+ "-File",
34
+ str(script),
35
+ ]
36
+ if script.suffix.lower() in {".sh", ".bash"}:
37
+ return ["bash", str(script)]
38
+ if script.suffix.lower() in {".bat", ".cmd"}:
39
+ return ["cmd", "/c", str(script)]
40
+ if os.access(script, os.X_OK):
41
+ return [str(script)]
42
+ return [sys.executable, str(script)]
43
+ if is_win and hook.sh and not hook.win:
44
+ # fallback: try bash (Git Bash / WSL path) only if sh provided alone
45
+ return list(hook.sh)
46
+ if (not is_win) and hook.win and not hook.sh:
47
+ return list(hook.win)
48
+ return None
49
+
50
+
51
+ def run_after_cache_hooks(ctx: ProjectContext) -> None:
52
+ """Run project hooks.after_cache (download vendor, etc.)."""
53
+ hooks = list(ctx.config.after_cache_hooks)
54
+ if not hooks:
55
+ return
56
+
57
+ print("========================================")
58
+ print(" hooks.after_cache")
59
+ print(f" Project: {ctx.root}")
60
+ print("========================================")
61
+ print()
62
+
63
+ env = {
64
+ "OFFLINE_CACHE": str(ctx.offline_cache),
65
+ f"{ctx.config.app_name.upper()}_OFFLINE_CACHE": str(ctx.offline_cache),
66
+ }
67
+
68
+ for hook in hooks:
69
+ cmd = _resolve_command(hook, ctx.root)
70
+ if not cmd:
71
+ raise SystemExit(
72
+ f"ERROR: hooks.after_cache[{hook.name}]: укажите win/sh или script"
73
+ )
74
+ # Resolve relative script tokens in argv to absolute under project root
75
+ resolved: list[str] = []
76
+ for part in cmd:
77
+ p = Path(part)
78
+ if (not p.is_absolute()) and (ctx.root / part).is_file():
79
+ resolved.append(str(ctx.root / part))
80
+ else:
81
+ resolved.append(part)
82
+ print(f">>> hook {hook.name}: {' '.join(resolved)}")
83
+ docker_cmd.run(resolved, cwd=ctx.root, env=env)
84
+ print()
@@ -0,0 +1,54 @@
1
+ """offline-docker init: recursive scan → draft .offline-docker.json."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ from offline_docker.config import project_config_to_dict
9
+ from offline_docker.discover import discover_with_inventory, format_discovery_report
10
+ from offline_docker.scan import compute_init_gaps, resolve_init_output_path
11
+
12
+
13
+ def run_init(root: Path, *, dry_run: bool = False) -> Path:
14
+ """Build draft config JSON. Returns output path (even on dry-run)."""
15
+ root = root.resolve()
16
+ cfg, inventory = discover_with_inventory(root)
17
+ cfg.config_source = "(init draft)"
18
+ gaps = compute_init_gaps(cfg, inventory)
19
+ out_path = resolve_init_output_path(root)
20
+ out_rel = out_path.name
21
+
22
+ data = project_config_to_dict(cfg)
23
+ data["_init"] = {
24
+ "tool": "offline-docker",
25
+ "command": "init",
26
+ "output": out_rel,
27
+ "gaps": gaps,
28
+ }
29
+
30
+ report = format_discovery_report(cfg, inventory=inventory, gaps=gaps)
31
+ print(report)
32
+ print()
33
+
34
+ if dry_run:
35
+ print(f"[dry-run] черновик НЕ записан (цель: {out_rel})")
36
+ if gaps:
37
+ print(f"[dry-run] gaps: {len(gaps)}")
38
+ return out_path
39
+
40
+ out_path.write_text(
41
+ json.dumps(data, ensure_ascii=False, indent=2) + "\n",
42
+ encoding="utf-8",
43
+ )
44
+ print(f"Черновик записан: {out_path}")
45
+ if out_rel == ".offline-docker.generated.json":
46
+ print(
47
+ "Канонический .offline-docker.json/.yaml не изменён "
48
+ "(уже существует в проекте)."
49
+ )
50
+ if gaps:
51
+ print(f"Доработайте вручную ({len(gaps)} пункт(ов) в _init.gaps и секции Missing).")
52
+ else:
53
+ print("Gaps пусты — проверьте пути и при необходимости переименуйте в канон.")
54
+ return out_path
@@ -0,0 +1,34 @@
1
+ """Load Docker images from offline-cache/docker/*.tar."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from offline_docker import docker_cmd
6
+ from offline_docker.context import ProjectContext
7
+
8
+
9
+ def load_docker_images(ctx: ProjectContext) -> None:
10
+ print("=== load-docker-images ===")
11
+ print(f"Каталог: {ctx.docker_cache_dir}")
12
+ print()
13
+
14
+ if not ctx.docker_cache_dir.is_dir():
15
+ print(
16
+ f"WARN: каталог {ctx.docker_cache_dir} не найден - пропуск",
17
+ flush=True,
18
+ )
19
+ return
20
+
21
+ tars = sorted(ctx.docker_cache_dir.glob("*.tar"))
22
+ if not tars:
23
+ print(
24
+ f"WARN: нет .tar в {ctx.docker_cache_dir} - выполните offdock cache images",
25
+ flush=True,
26
+ )
27
+ return
28
+
29
+ for tar_path in tars:
30
+ print(f">>> load {tar_path.name}")
31
+ docker_cmd.run(["docker", "load", "-i", str(tar_path)])
32
+
33
+ print()
34
+ print(f"=== Готово: загружено {len(tars)} образов ===")
@@ -0,0 +1,206 @@
1
+ """Discover offline-cache oc:// bind mounts from project Dockerfiles."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ from offline_docker.config import ProjectConfig
11
+
12
+ _OC_MOUNT_RE = re.compile(
13
+ r"from=oc,source=([^,]+),target=([^,\s]+)",
14
+ re.IGNORECASE,
15
+ )
16
+ _TAR_ARCHIVE_RE = re.compile(
17
+ r'tar\s+-xzf\s+"/oc-[^/]+/([^"]+)"',
18
+ re.IGNORECASE,
19
+ )
20
+ _NODE_FROM_RE = re.compile(r"^FROM\s+(node:[^\s]+)\s+AS\s+", re.MULTILINE | re.IGNORECASE)
21
+ _WORKDIR_RE = re.compile(r"^WORKDIR\s+(/[^\s#]+)", re.MULTILINE)
22
+ _GIT_CLONE_RE = re.compile(
23
+ r'git\s+clone\b[^\n]*--branch\s+"([^"]+)"\s*\\?\s*\n\s*(\S+\.git)',
24
+ re.MULTILINE | re.IGNORECASE,
25
+ )
26
+ _ARG_DEFAULT_RE = re.compile(r"^ARG\s+(\w+)=([^\s#]+)", re.MULTILINE)
27
+ _COMPOSE_ARG_DEFAULT_RE = re.compile(
28
+ r"^\s*(\w+):\s*\$\{\1:-([^}]+)\}",
29
+ re.MULTILINE,
30
+ )
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class YarnCacheSpec:
35
+ cache_subdir: str
36
+ node_image: str
37
+ archive_subdir: str
38
+ archive_name_template: str
39
+ version_arg: str
40
+ frontend_dir: str
41
+ git_url: str
42
+ git_tag_prefix: str
43
+ source_dockerfile: str
44
+
45
+
46
+ def offline_dockerfile_paths(cfg: ProjectConfig) -> list[Path]:
47
+ found: list[Path] = []
48
+ seen: set[Path] = set()
49
+ for pattern in ("**/*Dockerfile.offline", "**/*.Dockerfile.offline"):
50
+ for path in sorted(cfg.root.glob(pattern)):
51
+ if path.is_file() and path not in seen:
52
+ seen.add(path)
53
+ found.append(path)
54
+ return found
55
+
56
+
57
+ def discover_yarn_cache_specs(cfg: ProjectConfig) -> list[YarnCacheSpec]:
58
+ specs: list[YarnCacheSpec] = []
59
+ seen_keys: set[tuple[str, str]] = set()
60
+
61
+ for dockerfile in offline_dockerfile_paths(cfg):
62
+ text = dockerfile.read_text(encoding="utf-8", errors="replace")
63
+ if "source=yarn" not in text:
64
+ continue
65
+ spec = _parse_yarn_spec(cfg, dockerfile, text)
66
+ if spec is None:
67
+ continue
68
+ key = (spec.cache_subdir, spec.archive_subdir)
69
+ if key in seen_keys:
70
+ continue
71
+ seen_keys.add(key)
72
+ specs.append(spec)
73
+ return specs
74
+
75
+
76
+ def yarn_cache_needed(cfg: ProjectConfig) -> bool:
77
+ return bool(discover_yarn_cache_specs(cfg))
78
+
79
+
80
+ def archive_tar_path(cfg: ProjectConfig, spec: YarnCacheSpec) -> Path:
81
+ version = resolve_build_arg(cfg, spec.version_arg, cfg.path(spec.source_dockerfile))
82
+ name = spec.archive_name_template.format(version=version)
83
+ return cfg.offline_cache / spec.archive_subdir / name
84
+
85
+
86
+ def resolve_build_arg(cfg: ProjectConfig, arg_name: str, dockerfile: Path) -> str:
87
+ env_val = os.environ.get(arg_name, "").strip()
88
+ if env_val:
89
+ return env_val
90
+
91
+ if dockerfile.is_file():
92
+ for match in _ARG_DEFAULT_RE.finditer(dockerfile.read_text(encoding="utf-8", errors="replace")):
93
+ if match.group(1) == arg_name:
94
+ return match.group(2).strip('"').strip("'")
95
+
96
+ for compose_name in (
97
+ cfg.compose_offline,
98
+ cfg.compose_online,
99
+ *cfg.compose_build,
100
+ *cfg.compose_files,
101
+ ):
102
+ compose = cfg.path(compose_name)
103
+ if not compose.is_file():
104
+ continue
105
+ text = compose.read_text(encoding="utf-8", errors="replace")
106
+ pattern = re.compile(
107
+ rf"^\s*{re.escape(arg_name)}:\s*\$\{{{re.escape(arg_name)}:-([^}}]+)\}}",
108
+ re.MULTILINE,
109
+ )
110
+ match = pattern.search(text)
111
+ if match:
112
+ return match.group(1).strip().strip('"').strip("'")
113
+
114
+ raise RuntimeError(
115
+ f"не удалось определить значение build-arg {arg_name} "
116
+ f"(Dockerfile/compose/env {arg_name})"
117
+ )
118
+
119
+
120
+ def _parse_yarn_spec(cfg: ProjectConfig, dockerfile: Path, text: str) -> YarnCacheSpec | None:
121
+ yarn_subdir: str | None = None
122
+ archive_subdir: str | None = None
123
+ for source, _target in _OC_MOUNT_RE.findall(text):
124
+ if source == "yarn":
125
+ yarn_subdir = source
126
+ elif source not in {"wheels", "npm", "debs"} and archive_subdir is None:
127
+ archive_subdir = source
128
+
129
+ if yarn_subdir is None:
130
+ return None
131
+
132
+ tar_match = _TAR_ARCHIVE_RE.search(text)
133
+ if tar_match is None or archive_subdir is None:
134
+ return None
135
+
136
+ archive_literal = tar_match.group(1)
137
+ version_arg, template = _split_archive_template(archive_literal)
138
+ if version_arg is None:
139
+ return None
140
+
141
+ node_image = _NODE_FROM_RE.search(text)
142
+ if node_image is None:
143
+ node_image_name = cfg.node_image
144
+ else:
145
+ node_image_name = node_image.group(1)
146
+
147
+ frontend_dir = _frontend_dir_from_workdir(text)
148
+ git_url, tag_prefix = _find_git_source(cfg, version_arg, dockerfile)
149
+
150
+ rel = dockerfile.relative_to(cfg.root).as_posix()
151
+ return YarnCacheSpec(
152
+ cache_subdir=yarn_subdir,
153
+ node_image=node_image_name,
154
+ archive_subdir=archive_subdir,
155
+ archive_name_template=template,
156
+ version_arg=version_arg,
157
+ frontend_dir=frontend_dir,
158
+ git_url=git_url,
159
+ git_tag_prefix=tag_prefix,
160
+ source_dockerfile=rel,
161
+ )
162
+
163
+
164
+ def _split_archive_template(archive_literal: str) -> tuple[str | None, str]:
165
+ match = re.search(r"\$\{(\w+)\}", archive_literal)
166
+ if not match:
167
+ return None, archive_literal
168
+ arg = match.group(1)
169
+ template = archive_literal.replace(f"${{{arg}}}", "{version}")
170
+ return arg, template
171
+
172
+
173
+ def _frontend_dir_from_workdir(text: str) -> str:
174
+ workdirs = _WORKDIR_RE.findall(text)
175
+ for path in reversed(workdirs):
176
+ if path.endswith("/frontend") or path == "/frontend":
177
+ return path.split("/")[-1] if "/" in path else path
178
+ return "frontend"
179
+
180
+
181
+ def _find_git_source(cfg: ProjectConfig, version_arg: str, offline_dockerfile: Path) -> tuple[str, str]:
182
+ candidates: list[Path] = []
183
+ if offline_dockerfile.name.endswith(".offline"):
184
+ online_name = offline_dockerfile.name[: -len(".offline")]
185
+ sibling = offline_dockerfile.parent / online_name
186
+ if sibling.is_file():
187
+ candidates.append(sibling)
188
+
189
+ for dockerfile in sorted(cfg.root.glob("**/Dockerfile*")):
190
+ if dockerfile.is_file() and dockerfile not in candidates:
191
+ candidates.append(dockerfile)
192
+
193
+ for dockerfile in candidates:
194
+ if dockerfile.name.endswith(".offline"):
195
+ continue
196
+ text = dockerfile.read_text(encoding="utf-8", errors="replace")
197
+ for branch_tpl, repo in _GIT_CLONE_RE.findall(text):
198
+ if f"${{{version_arg}}}" not in branch_tpl and version_arg not in branch_tpl:
199
+ continue
200
+ prefix = branch_tpl.split("${", 1)[0]
201
+ if prefix in {"", "v"}:
202
+ return repo, prefix or "v"
203
+ return repo, prefix
204
+ raise RuntimeError(
205
+ f"не найден git clone для build-arg {version_arg} в Dockerfile проекта"
206
+ )
@@ -0,0 +1,306 @@
1
+ """Build ordered package index chains from configured sources."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass, field
7
+ from typing import Any, Iterable
8
+
9
+ from offline_docker.sources.base import Ecosystem, PackageSource
10
+ from offline_docker.sources.filesystem import FilesystemSource
11
+ from offline_docker.sources.nexus import NexusSource
12
+ from offline_docker.sources.proget import ProGetSource
13
+ from offline_docker.sources.upstream import UpstreamSource
14
+
15
+
16
+ @dataclass
17
+ class ResolvePolicy:
18
+ order: list[str] = field(default_factory=lambda: ["local"])
19
+ fill_local: bool = True
20
+ on_miss: str = "fail" # fail | warn
21
+
22
+
23
+ @dataclass
24
+ class CacheSettings:
25
+ sources: list[PackageSource] = field(default_factory=list)
26
+ resolve: ResolvePolicy = field(default_factory=ResolvePolicy)
27
+ cache_config_origin: str = "default" # default | user | project
28
+
29
+ def by_name(self) -> dict[str, PackageSource]:
30
+ return {s.name: s for s in self.sources}
31
+
32
+ def ordered_enabled(self) -> list[PackageSource]:
33
+ by_name = self.by_name()
34
+ result: list[PackageSource] = []
35
+ seen: set[str] = set()
36
+ for name in self.resolve.order:
37
+ src = by_name.get(name)
38
+ if src is None or not src.enabled or name in seen:
39
+ continue
40
+ result.append(src)
41
+ seen.add(name)
42
+ return result
43
+
44
+
45
+ class PackageResolver:
46
+ def __init__(self, settings: CacheSettings) -> None:
47
+ self.settings = settings
48
+
49
+ def sources(self) -> list[PackageSource]:
50
+ return self.settings.ordered_enabled()
51
+
52
+ def index_urls(self, ecosystem: Ecosystem) -> list[str]:
53
+ urls: list[str] = []
54
+ seen: set[str] = set()
55
+ for src in self.sources():
56
+ for url in src.index_urls(ecosystem):
57
+ key = url.rstrip("/")
58
+ if key in seen:
59
+ continue
60
+ seen.add(key)
61
+ urls.append(url)
62
+ return urls
63
+
64
+ def pypi_index_chain(self) -> list[str]:
65
+ return self.index_urls("pypi")
66
+
67
+ def npm_registries(self) -> list[str]:
68
+ return self.index_urls("npm")
69
+
70
+ def primary_npm_registry(self) -> str | None:
71
+ regs = self.npm_registries()
72
+ return regs[0] if regs else None
73
+
74
+ def apt_mirrors(self) -> list[str]:
75
+ return self.index_urls("apt")
76
+
77
+ def has_remote_indexes(self) -> bool:
78
+ return bool(self.pypi_index_chain() or self.npm_registries() or self.apt_mirrors())
79
+
80
+ def is_local_only(self) -> bool:
81
+ return all(getattr(s, "source_type", "") == "filesystem" for s in self.sources())
82
+
83
+ def nexus_sources(self) -> list[NexusSource]:
84
+ return [s for s in self.sources() if isinstance(s, NexusSource)]
85
+
86
+ def proget_sources(self) -> list[ProGetSource]:
87
+ return [s for s in self.sources() if isinstance(s, ProGetSource)]
88
+
89
+ def summary_lines(self) -> list[str]:
90
+ lines = [
91
+ f"Cache config: {self.settings.cache_config_origin}",
92
+ f"Resolve order: {', '.join(self.settings.resolve.order)}",
93
+ f"fill_local: {self.settings.resolve.fill_local}",
94
+ f"on_miss: {self.settings.resolve.on_miss}",
95
+ "Sources:",
96
+ ]
97
+ by_name = self.settings.by_name()
98
+ for name in self.settings.resolve.order:
99
+ src = by_name.get(name)
100
+ if src is None:
101
+ lines.append(f" - {name}: (missing)")
102
+ continue
103
+ status = "on" if src.enabled else "off"
104
+ lines.append(f" - {name} [{src.source_type}] {status}")
105
+ for eco in ("pypi", "npm", "apt"):
106
+ urls = src.index_urls(eco) # type: ignore[arg-type]
107
+ for u in urls:
108
+ lines.append(f" {eco}: {u}")
109
+ pypi = self.pypi_index_chain()
110
+ if pypi:
111
+ lines.append(f"Effective pypi: {' -> '.join(pypi)}")
112
+ npm = self.npm_registries()
113
+ if npm:
114
+ lines.append(f"Effective npm: {' -> '.join(npm)}")
115
+ return lines
116
+
117
+
118
+ def _parse_resolve(raw: Any) -> ResolvePolicy:
119
+ if not isinstance(raw, dict):
120
+ return ResolvePolicy()
121
+ order = raw.get("order")
122
+ order_list = [str(x) for x in order] if isinstance(order, list) and order else ["local"]
123
+ return ResolvePolicy(
124
+ order=order_list,
125
+ fill_local=bool(raw.get("fill_local", True)),
126
+ on_miss=str(raw.get("on_miss", "fail")),
127
+ )
128
+
129
+
130
+ def source_from_dict(item: dict[str, Any]) -> PackageSource | None:
131
+ stype = str(item.get("type", "")).lower()
132
+ name = str(item.get("name", stype or "source"))
133
+ enabled = bool(item.get("enabled", True))
134
+ if stype in ("filesystem", "local"):
135
+ return FilesystemSource(name=name, enabled=enabled)
136
+ if stype == "upstream":
137
+ return UpstreamSource(
138
+ name=name,
139
+ enabled=enabled,
140
+ pypi_index=str(item.get("pypi_index", "https://pypi.org/simple")),
141
+ npm_registry=str(item.get("npm_registry", "https://registry.npmjs.org/")),
142
+ pytorch_index=str(item["pytorch_index"]) if item.get("pytorch_index") else None,
143
+ )
144
+ if stype == "nexus":
145
+ repos = item.get("repos") if isinstance(item.get("repos"), dict) else {}
146
+ return NexusSource(
147
+ name=name,
148
+ url=str(item.get("url", "")),
149
+ repos={str(k): str(v) for k, v in repos.items()},
150
+ auth_env=str(item.get("auth_env", "NEXUS_AUTH")),
151
+ enabled=enabled,
152
+ publish=bool(item.get("publish", False)),
153
+ raw_repo=str(item["raw_repo"]) if item.get("raw_repo") else None,
154
+ )
155
+ if stype == "proget":
156
+ feeds = item.get("feeds") if isinstance(item.get("feeds"), dict) else {}
157
+ return ProGetSource(
158
+ name=name,
159
+ url=str(item.get("url", "")),
160
+ feeds={str(k): str(v) for k, v in feeds.items()},
161
+ auth_env=str(item.get("auth_env", "PROGET_API_KEY")),
162
+ enabled=enabled,
163
+ publish=bool(item.get("publish", False)),
164
+ )
165
+ return None
166
+
167
+
168
+ def sources_from_cache_dict(data: dict[str, Any]) -> CacheSettings:
169
+ sources: list[PackageSource] = []
170
+ raw_sources = data.get("sources")
171
+ if isinstance(raw_sources, list):
172
+ for item in raw_sources:
173
+ if isinstance(item, dict):
174
+ src = source_from_dict(item)
175
+ if src is not None:
176
+ sources.append(src)
177
+ if not any(getattr(s, "source_type", "") == "filesystem" for s in sources):
178
+ sources.insert(0, FilesystemSource())
179
+ resolve = _parse_resolve(data.get("resolve"))
180
+ # Drop empty remote URLs from order effectively via enabled=False on sources
181
+ return CacheSettings(sources=sources, resolve=resolve)
182
+
183
+
184
+ def default_local_only() -> CacheSettings:
185
+ return CacheSettings(
186
+ sources=[FilesystemSource()],
187
+ resolve=ResolvePolicy(order=["local"]),
188
+ cache_config_origin="default",
189
+ )
190
+
191
+
192
+ def apply_env_order_override(settings: CacheSettings) -> CacheSettings:
193
+ raw = os.environ.get("OFFLINE_RESOLVE_ORDER", "").strip()
194
+ if not raw:
195
+ return settings
196
+ order = [part.strip() for part in raw.split(",") if part.strip()]
197
+ if order:
198
+ settings.resolve.order = order
199
+ return settings
200
+
201
+
202
+ def merge_cache_settings(
203
+ *,
204
+ user_data: dict[str, Any] | None,
205
+ project_data: dict[str, Any] | None,
206
+ pypi_index: str,
207
+ pytorch_index: str,
208
+ ) -> CacheSettings:
209
+ """Project cache section wins entirely when present; else user; else local-only.
210
+
211
+ Legacy python.pypi_index is exposed as optional upstream when remotes exist
212
+ or when explicitly enabled in sources.
213
+ """
214
+ origin = "default"
215
+ data: dict[str, Any] | None = None
216
+ if project_data is not None:
217
+ data = project_data
218
+ origin = "project"
219
+ elif user_data is not None:
220
+ data = user_data
221
+ origin = "user"
222
+
223
+ if data is None:
224
+ settings = default_local_only()
225
+ else:
226
+ settings = sources_from_cache_dict(data)
227
+ settings.cache_config_origin = origin
228
+
229
+ # Ensure filesystem local always present
230
+ if not any(getattr(s, "source_type", "") == "filesystem" for s in settings.sources):
231
+ settings.sources.insert(0, FilesystemSource())
232
+
233
+ # If no remote HTTP indexes and no explicit upstream, keep local-only.
234
+ # Attach implicit upstream only when present in sources or when order asks for it.
235
+ by_name = {s.name: s for s in settings.sources}
236
+ if "upstream" in settings.resolve.order and "upstream" not in by_name:
237
+ settings.sources.append(
238
+ UpstreamSource(
239
+ enabled=True,
240
+ pypi_index=pypi_index,
241
+ pytorch_index=pytorch_index,
242
+ )
243
+ )
244
+ else:
245
+ for s in settings.sources:
246
+ if isinstance(s, UpstreamSource) and s.enabled:
247
+ if not s.pytorch_index:
248
+ s.pytorch_index = pytorch_index
249
+ if s.pypi_index == "https://pypi.org/simple" and pypi_index:
250
+ s.pypi_index = pypi_index.rstrip("/")
251
+
252
+ return apply_env_order_override(settings)
253
+
254
+
255
+ def build_resolver_from_dicts(
256
+ *,
257
+ user_data: dict[str, Any] | None,
258
+ project_cache: dict[str, Any] | None,
259
+ pypi_index: str,
260
+ pytorch_index: str,
261
+ ) -> PackageResolver:
262
+ settings = merge_cache_settings(
263
+ user_data=user_data,
264
+ project_data=project_cache,
265
+ pypi_index=pypi_index,
266
+ pytorch_index=pytorch_index,
267
+ )
268
+ return PackageResolver(settings)
269
+
270
+
271
+ def pip_index_chain_for_download(
272
+ resolver: PackageResolver,
273
+ *,
274
+ pypi_index: str,
275
+ pytorch_index: str,
276
+ ) -> tuple[list[str], bool]:
277
+ """HTTP indexes for download-wheels.
278
+
279
+ Returns (chain, used_local_only_fallback).
280
+ When sources.json is local-only, chain is empty — we temporarily use
281
+ project/env PyPI + PyTorch indexes so download-* can fill the cache.
282
+ Prefer `offdock configure --with-upstream` to make that explicit.
283
+ """
284
+ chain = list(resolver.pypi_index_chain())
285
+ used_fallback = False
286
+ if not chain:
287
+ chain = [pypi_index, pytorch_index]
288
+ used_fallback = True
289
+ elif pytorch_index and pytorch_index.rstrip("/") not in {
290
+ u.rstrip("/") for u in chain
291
+ }:
292
+ chain.append(pytorch_index)
293
+
294
+ # PYPI_INDEX env forces first index when set explicitly
295
+ if os.environ.get("PYPI_INDEX"):
296
+ forced = pypi_index.rstrip("/")
297
+ chain = [pypi_index] + [
298
+ u for u in chain if u.rstrip("/") != forced
299
+ ]
300
+ return chain, used_fallback
301
+
302
+
303
+ def iter_publish_targets(resolver: PackageResolver) -> Iterable[PackageSource]:
304
+ for src in resolver.sources():
305
+ if getattr(src, "publish", False):
306
+ yield src