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.
- offline_docker/__init__.py +3 -0
- offline_docker/__main__.py +4 -0
- offline_docker/actions.py +173 -0
- offline_docker/build.py +245 -0
- offline_docker/cli.py +486 -0
- offline_docker/config.py +692 -0
- offline_docker/configure.py +282 -0
- offline_docker/context.py +111 -0
- offline_docker/defaults.py +48 -0
- offline_docker/discover.py +139 -0
- offline_docker/docker_cmd.py +136 -0
- offline_docker/download_debs.py +142 -0
- offline_docker/download_images.py +26 -0
- offline_docker/download_npm.py +106 -0
- offline_docker/download_trivy.py +90 -0
- offline_docker/download_wheels.py +363 -0
- offline_docker/download_yarn.py +208 -0
- offline_docker/ensure_base.py +61 -0
- offline_docker/hooks.py +84 -0
- offline_docker/init_cmd.py +54 -0
- offline_docker/load_images.py +34 -0
- offline_docker/oc_discovery.py +206 -0
- offline_docker/resolve.py +306 -0
- offline_docker/scan.py +496 -0
- offline_docker/sources/__init__.py +18 -0
- offline_docker/sources/base.py +18 -0
- offline_docker/sources/filesystem.py +17 -0
- offline_docker/sources/nexus.py +61 -0
- offline_docker/sources/nexus_client.py +67 -0
- offline_docker/sources/proget.py +49 -0
- offline_docker/sources/proget_client.py +48 -0
- offline_docker/sources/upstream.py +36 -0
- offline_docker/status.py +247 -0
- offline_docker/sync_cache.py +125 -0
- offline_docker/trivy_scan.py +86 -0
- offline_docker/tui/__init__.py +19 -0
- offline_docker/tui/app.py +56 -0
- offline_docker/tui/runner.py +87 -0
- offline_docker/tui/screens.py +333 -0
- offline_docker/user_config.py +89 -0
- offline_docker/utils.py +73 -0
- offline_docker/validate.py +53 -0
- offline_docker/wheel_prune.py +141 -0
- offline_docker_cli-2.2.2.dist-info/METADATA +417 -0
- offline_docker_cli-2.2.2.dist-info/RECORD +49 -0
- offline_docker_cli-2.2.2.dist-info/WHEEL +5 -0
- offline_docker_cli-2.2.2.dist-info/entry_points.txt +2 -0
- offline_docker_cli-2.2.2.dist-info/licenses/LICENSE +21 -0
- offline_docker_cli-2.2.2.dist-info/top_level.txt +1 -0
offline_docker/hooks.py
ADDED
|
@@ -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
|