resolvescript 0.1.2__py3-none-any.whl → 0.1.4a0__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.
- ResolveScript/__init__.py +366 -0
- ResolveScript/_version.py +8 -0
- {resolve_script → ResolveScript}/cli.py +17 -6
- ResolveScript/fetch.py +186 -0
- {resolve_script → ResolveScript}/install/installer.py +60 -8
- ResolveScript/manifest/__init__.py +70 -0
- {resolve_script → ResolveScript}/manifest/xml_reader.py +18 -1
- {resolve_script → ResolveScript}/resolver.py +46 -12
- {resolve_script → ResolveScript}/sandbox/__init__.py +32 -2
- {resolve_script → ResolveScript}/sandbox/loader.py +20 -0
- ResolveScript/sources/__init__.py +61 -0
- ResolveScript/sources/archive.py +167 -0
- {resolve_script → ResolveScript}/sources/git.py +32 -0
- {resolve_script → ResolveScript}/templates/extension/conftest.py +1 -1
- {resolve_script → ResolveScript}/testing/__init__.py +1 -1
- {resolve_script → ResolveScript}/workspace.py +10 -3
- {resolvescript-0.1.2.dist-info → resolvescript-0.1.4a0.dist-info}/METADATA +58 -3
- resolvescript-0.1.4a0.dist-info/RECORD +50 -0
- resolvescript-0.1.4a0.dist-info/entry_points.txt +2 -0
- resolvescript-0.1.4a0.dist-info/top_level.txt +1 -0
- resolve_script/__init__.py +0 -3
- resolve_script/fetch.py +0 -90
- resolve_script/manifest/__init__.py +0 -1
- resolve_script/sources/__init__.py +0 -15
- resolve_script/sources/archive.py +0 -82
- resolvescript-0.1.2.dist-info/RECORD +0 -49
- resolvescript-0.1.2.dist-info/entry_points.txt +0 -2
- resolvescript-0.1.2.dist-info/top_level.txt +0 -1
- {resolve_script → ResolveScript}/analyze.py +0 -0
- {resolve_script → ResolveScript}/config.py +0 -0
- {resolve_script → ResolveScript}/consolidate.py +0 -0
- {resolve_script → ResolveScript}/install/__init__.py +0 -0
- {resolve_script → ResolveScript}/install/discovery.py +0 -0
- {resolve_script → ResolveScript}/install/registry.py +0 -0
- {resolve_script → ResolveScript}/manifest/json_reader.py +0 -0
- {resolve_script → ResolveScript}/manifest/model.py +0 -0
- {resolve_script → ResolveScript}/manifest/validation.py +0 -0
- {resolve_script → ResolveScript}/package.py +0 -0
- {resolve_script → ResolveScript}/sandbox/api.py +0 -0
- {resolve_script → ResolveScript}/sandbox/env.py +0 -0
- {resolve_script → ResolveScript}/sandbox/repl.py +0 -0
- {resolve_script → ResolveScript}/sandbox/smoke.py +0 -0
- {resolve_script → ResolveScript}/scaffold.py +0 -0
- {resolve_script → ResolveScript}/semver.py +0 -0
- {resolve_script → ResolveScript}/sources/known.py +0 -0
- {resolve_script → ResolveScript}/sources/release.py +0 -0
- {resolve_script → ResolveScript}/spec.py +0 -0
- {resolve_script → ResolveScript}/templates/extension/@NAME@/__init__.py +0 -0
- {resolve_script → ResolveScript}/templates/extension/@NAME@/menu.py +0 -0
- {resolve_script → ResolveScript}/templates/extension/@NAME@.py +0 -0
- {resolve_script → ResolveScript}/templates/extension/README.md +0 -0
- {resolve_script → ResolveScript}/templates/extension/manifest.json.j2 +0 -0
- {resolve_script → ResolveScript}/templates/extension/manifest.xml.j2 +0 -0
- {resolve_script → ResolveScript}/templates/extension/tests/test_smoke.py +0 -0
- {resolve_script → ResolveScript}/templates/inapp/register.py +0 -0
- {resolve_script → ResolveScript}/testing/fixtures.py +0 -0
- {resolvescript-0.1.2.dist-info → resolvescript-0.1.4a0.dist-info}/WHEEL +0 -0
- {resolvescript-0.1.2.dist-info → resolvescript-0.1.4a0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
"""ResolveScript — script framework for DaVinci Resolve.
|
|
2
|
+
|
|
3
|
+
ResolveScript is both a CLI and a normal Python package, so you can drive
|
|
4
|
+
every feature directly from a script or an interactive session.
|
|
5
|
+
|
|
6
|
+
Quick start — create and inspect a new script project::
|
|
7
|
+
|
|
8
|
+
import ResolveScript as rs
|
|
9
|
+
|
|
10
|
+
root, written = rs.scaffold_project(
|
|
11
|
+
"my-tool", destination=".", fmt="json", template="default"
|
|
12
|
+
)
|
|
13
|
+
print(root, written)
|
|
14
|
+
|
|
15
|
+
issues = rs.analyze_project(root)
|
|
16
|
+
for issue in issues:
|
|
17
|
+
print(issue)
|
|
18
|
+
|
|
19
|
+
cfg = rs.config_from_manifest(root / "resolve-script.manifest.json")
|
|
20
|
+
result = rs.consolidate(cfg)
|
|
21
|
+
print(result.target) # single-file build
|
|
22
|
+
print(result.sources) # files folded into the build
|
|
23
|
+
|
|
24
|
+
Borrow a ``Source`` spec from a known GitHub project::
|
|
25
|
+
|
|
26
|
+
import ResolveScript as rs
|
|
27
|
+
|
|
28
|
+
print(rs.lookup("hello")) # canonical source for a known name
|
|
29
|
+
values = rs.build_values("my-tool") # template substition values
|
|
30
|
+
|
|
31
|
+
Sit scripts in the Resolve environment::
|
|
32
|
+
|
|
33
|
+
from ResolveScript.sandbox import build_default_env, fake_resolve_module
|
|
34
|
+
from ResolveScript.install import install_project
|
|
35
|
+
|
|
36
|
+
env = build_default_env()
|
|
37
|
+
fake_resolve_module(env) # injects a fake ``resolve`` module
|
|
38
|
+
install_project(env, run_smoke=True, root=".")
|
|
39
|
+
|
|
40
|
+
Every public name is available both at the package root and from its owning
|
|
41
|
+
module, e.g. ``ResolveScript.consolidate`` is also the top-level
|
|
42
|
+
``consolidate`` function.
|
|
43
|
+
|
|
44
|
+
Subpackages ship their own convenience imports:
|
|
45
|
+
|
|
46
|
+
* ``ResolveScript.manifest`` — manifest model, loaders, validators
|
|
47
|
+
* ``ResolveScript.sandbox`` — mock Resolve API, smoke runs, REPL
|
|
48
|
+
* ``ResolveScript.sources`` — archive/git/known-source helpers
|
|
49
|
+
* ``ResolveScript.install`` — install, registry and target resolution
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
from __future__ import annotations
|
|
53
|
+
|
|
54
|
+
from ._version import __version__, get_version
|
|
55
|
+
|
|
56
|
+
# --- pipeline ----------------------------------------------------------
|
|
57
|
+
from .analyze import KNOWN_ROOTS, Analysis, Issue, analyze_project, issues_to_json
|
|
58
|
+
|
|
59
|
+
# --- config ------------------------------------------------------------
|
|
60
|
+
from .config import (
|
|
61
|
+
allow_remote,
|
|
62
|
+
is_editable_install,
|
|
63
|
+
normalize_path,
|
|
64
|
+
plugins_dir,
|
|
65
|
+
python_spec,
|
|
66
|
+
scripts_root_override,
|
|
67
|
+
user_config_dir,
|
|
68
|
+
)
|
|
69
|
+
from .consolidate import (
|
|
70
|
+
BuildConfig,
|
|
71
|
+
ConsolidateError,
|
|
72
|
+
ConsolidateResult,
|
|
73
|
+
config_from_manifest,
|
|
74
|
+
consolidate,
|
|
75
|
+
summarize,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# --- fetch / CLI -------------------------------------------------------
|
|
79
|
+
from .fetch import Fetched, FetchError, fetch, fetch_json, sha256_file
|
|
80
|
+
|
|
81
|
+
# --- install -----------------------------------------------------------
|
|
82
|
+
from .install import (
|
|
83
|
+
REGISTRY_REL,
|
|
84
|
+
SCHEMA_VERSION,
|
|
85
|
+
InstalledFile,
|
|
86
|
+
InstallError,
|
|
87
|
+
InstallOptions,
|
|
88
|
+
InstallResult,
|
|
89
|
+
RegistryError,
|
|
90
|
+
add_or_update_entry,
|
|
91
|
+
default_scripts_root,
|
|
92
|
+
discover_entrypoint,
|
|
93
|
+
get_extension,
|
|
94
|
+
install_package,
|
|
95
|
+
install_project,
|
|
96
|
+
read_registry,
|
|
97
|
+
registry_path,
|
|
98
|
+
remove_entry,
|
|
99
|
+
resolve_scripts_root,
|
|
100
|
+
select_files,
|
|
101
|
+
target_dir,
|
|
102
|
+
uninstall_package,
|
|
103
|
+
write_registry,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
# --- manifest ----------------------------------------------------------
|
|
107
|
+
from .manifest import (
|
|
108
|
+
TARGET_SUGGESTIONS,
|
|
109
|
+
Compat,
|
|
110
|
+
ConsolidateConfig,
|
|
111
|
+
InstallConfig,
|
|
112
|
+
Manifest,
|
|
113
|
+
ManifestError,
|
|
114
|
+
Release,
|
|
115
|
+
Target,
|
|
116
|
+
dumps,
|
|
117
|
+
is_valid_semver,
|
|
118
|
+
load_manifest,
|
|
119
|
+
loads,
|
|
120
|
+
manifest_from_dict,
|
|
121
|
+
validate_manifest,
|
|
122
|
+
validate_manifest_or_throw,
|
|
123
|
+
validate_target,
|
|
124
|
+
)
|
|
125
|
+
from .package import PackageError, PackageResult, package_project
|
|
126
|
+
|
|
127
|
+
# --- spec / resolver ---------------------------------------------------
|
|
128
|
+
from .resolver import (
|
|
129
|
+
Resolved,
|
|
130
|
+
ResolveError,
|
|
131
|
+
lockfile_satisfies,
|
|
132
|
+
resolve_spec,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
# --- sandbox -----------------------------------------------------------
|
|
136
|
+
from .sandbox import (
|
|
137
|
+
DEFAULT_PROJECT,
|
|
138
|
+
FUSION_SCRIPT_MODULE,
|
|
139
|
+
FakeClip,
|
|
140
|
+
FakeComp,
|
|
141
|
+
FakeFolder,
|
|
142
|
+
FakeFusion,
|
|
143
|
+
FakeKey,
|
|
144
|
+
FakeMediaPool,
|
|
145
|
+
FakeMediaPoolItem,
|
|
146
|
+
FakeProject,
|
|
147
|
+
FakeProjectManager,
|
|
148
|
+
FakeResolve,
|
|
149
|
+
FakeSpline,
|
|
150
|
+
FakeStroke,
|
|
151
|
+
FakeTimeline,
|
|
152
|
+
FakeTool,
|
|
153
|
+
SmokeCheck,
|
|
154
|
+
SmokeResult,
|
|
155
|
+
build_default_env,
|
|
156
|
+
default_namespace,
|
|
157
|
+
discover_exports,
|
|
158
|
+
fake_resolve_module,
|
|
159
|
+
install_fake_resolve,
|
|
160
|
+
load_built_module,
|
|
161
|
+
load_source_module,
|
|
162
|
+
purge_module,
|
|
163
|
+
run_smoke,
|
|
164
|
+
start_repl,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
# --- scaffold ----------------------------------------------------------
|
|
168
|
+
from .scaffold import (
|
|
169
|
+
DEFAULT_VERSION,
|
|
170
|
+
TEMPLATES_DIR,
|
|
171
|
+
ScaffoldError,
|
|
172
|
+
build_values,
|
|
173
|
+
normalize_name,
|
|
174
|
+
render,
|
|
175
|
+
scaffold_project,
|
|
176
|
+
)
|
|
177
|
+
from .semver import SemVerError, Version, matches, pick_best
|
|
178
|
+
|
|
179
|
+
# --- sources -----------------------------------------------------------
|
|
180
|
+
from .sources import (
|
|
181
|
+
ARCHIVE_SUFFIXES,
|
|
182
|
+
CONVENTIONS,
|
|
183
|
+
ArchiveError,
|
|
184
|
+
GitSourceError,
|
|
185
|
+
ReleaseError,
|
|
186
|
+
ReleaseSpec,
|
|
187
|
+
asset_download_url,
|
|
188
|
+
canonical_source,
|
|
189
|
+
codeload_url,
|
|
190
|
+
default_branch,
|
|
191
|
+
download_github,
|
|
192
|
+
is_archive_path,
|
|
193
|
+
known_names,
|
|
194
|
+
list_tags,
|
|
195
|
+
lookup,
|
|
196
|
+
make_archive,
|
|
197
|
+
resolve_tag,
|
|
198
|
+
search,
|
|
199
|
+
tags_have_version,
|
|
200
|
+
unpack_archive,
|
|
201
|
+
)
|
|
202
|
+
from .spec import Spec, SpecError, parse_specifier
|
|
203
|
+
from .workspace import (
|
|
204
|
+
WORKSPACE_FILE,
|
|
205
|
+
WorkspaceError,
|
|
206
|
+
add_dependency,
|
|
207
|
+
has_workspace,
|
|
208
|
+
read_workspace,
|
|
209
|
+
remove_dependency,
|
|
210
|
+
save,
|
|
211
|
+
workspace_path,
|
|
212
|
+
write_workspace,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
__all__ = [
|
|
216
|
+
# version
|
|
217
|
+
"__version__",
|
|
218
|
+
"get_version",
|
|
219
|
+
# manifest
|
|
220
|
+
"Compat",
|
|
221
|
+
"ConsolidateConfig",
|
|
222
|
+
"InstallConfig",
|
|
223
|
+
"Manifest",
|
|
224
|
+
"ManifestError",
|
|
225
|
+
"Release",
|
|
226
|
+
"TARGET_SUGGESTIONS",
|
|
227
|
+
"Target",
|
|
228
|
+
"dumps",
|
|
229
|
+
"is_valid_semver",
|
|
230
|
+
"load_manifest",
|
|
231
|
+
"loads",
|
|
232
|
+
"manifest_from_dict",
|
|
233
|
+
"validate_manifest",
|
|
234
|
+
"validate_manifest_or_throw",
|
|
235
|
+
"validate_target",
|
|
236
|
+
# sources
|
|
237
|
+
"ARCHIVE_SUFFIXES",
|
|
238
|
+
"CONVENTIONS",
|
|
239
|
+
"ArchiveError",
|
|
240
|
+
"GitSourceError",
|
|
241
|
+
"ReleaseError",
|
|
242
|
+
"ReleaseSpec",
|
|
243
|
+
"asset_download_url",
|
|
244
|
+
"canonical_source",
|
|
245
|
+
"codeload_url",
|
|
246
|
+
"default_branch",
|
|
247
|
+
"download_github",
|
|
248
|
+
"is_archive_path",
|
|
249
|
+
"known_names",
|
|
250
|
+
"list_tags",
|
|
251
|
+
"lookup",
|
|
252
|
+
"make_archive",
|
|
253
|
+
"resolve_tag",
|
|
254
|
+
"search",
|
|
255
|
+
"tags_have_version",
|
|
256
|
+
"unpack_archive",
|
|
257
|
+
# install
|
|
258
|
+
"REGISTRY_REL",
|
|
259
|
+
"SCHEMA_VERSION",
|
|
260
|
+
"InstallError",
|
|
261
|
+
"InstallOptions",
|
|
262
|
+
"InstallResult",
|
|
263
|
+
"InstalledFile",
|
|
264
|
+
"RegistryError",
|
|
265
|
+
"add_or_update_entry",
|
|
266
|
+
"default_scripts_root",
|
|
267
|
+
"discover_entrypoint",
|
|
268
|
+
"get_extension",
|
|
269
|
+
"install_package",
|
|
270
|
+
"install_project",
|
|
271
|
+
"read_registry",
|
|
272
|
+
"registry_path",
|
|
273
|
+
"remove_entry",
|
|
274
|
+
"resolve_scripts_root",
|
|
275
|
+
"select_files",
|
|
276
|
+
"target_dir",
|
|
277
|
+
"uninstall_package",
|
|
278
|
+
"write_registry",
|
|
279
|
+
# sandbox
|
|
280
|
+
"DEFAULT_PROJECT",
|
|
281
|
+
"FUSION_SCRIPT_MODULE",
|
|
282
|
+
"FakeClip",
|
|
283
|
+
"FakeComp",
|
|
284
|
+
"FakeFolder",
|
|
285
|
+
"FakeFusion",
|
|
286
|
+
"FakeKey",
|
|
287
|
+
"FakeMediaPool",
|
|
288
|
+
"FakeMediaPoolItem",
|
|
289
|
+
"FakeProject",
|
|
290
|
+
"FakeProjectManager",
|
|
291
|
+
"FakeResolve",
|
|
292
|
+
"FakeSpline",
|
|
293
|
+
"FakeStroke",
|
|
294
|
+
"FakeTimeline",
|
|
295
|
+
"FakeTool",
|
|
296
|
+
"SmokeCheck",
|
|
297
|
+
"SmokeResult",
|
|
298
|
+
"build_default_env",
|
|
299
|
+
"default_namespace",
|
|
300
|
+
"discover_exports",
|
|
301
|
+
"fake_resolve_module",
|
|
302
|
+
"install_fake_resolve",
|
|
303
|
+
"load_built_module",
|
|
304
|
+
"load_source_module",
|
|
305
|
+
"purge_module",
|
|
306
|
+
"run_smoke",
|
|
307
|
+
"start_repl",
|
|
308
|
+
# config
|
|
309
|
+
"allow_remote",
|
|
310
|
+
"is_editable_install",
|
|
311
|
+
"normalize_path",
|
|
312
|
+
"plugins_dir",
|
|
313
|
+
"python_spec",
|
|
314
|
+
"scripts_root_override",
|
|
315
|
+
"user_config_dir",
|
|
316
|
+
# spec / resolver
|
|
317
|
+
"ResolveError",
|
|
318
|
+
"Resolved",
|
|
319
|
+
"lockfile_satisfies",
|
|
320
|
+
"resolve_spec",
|
|
321
|
+
"SemVerError",
|
|
322
|
+
"Version",
|
|
323
|
+
"matches",
|
|
324
|
+
"pick_best",
|
|
325
|
+
"Spec",
|
|
326
|
+
"SpecError",
|
|
327
|
+
"parse_specifier",
|
|
328
|
+
"WORKSPACE_FILE",
|
|
329
|
+
"WorkspaceError",
|
|
330
|
+
"add_dependency",
|
|
331
|
+
"has_workspace",
|
|
332
|
+
"read_workspace",
|
|
333
|
+
"remove_dependency",
|
|
334
|
+
"save",
|
|
335
|
+
"workspace_path",
|
|
336
|
+
"write_workspace",
|
|
337
|
+
# pipeline
|
|
338
|
+
"KNOWN_ROOTS",
|
|
339
|
+
"Analysis",
|
|
340
|
+
"Issue",
|
|
341
|
+
"analyze_project",
|
|
342
|
+
"issues_to_json",
|
|
343
|
+
"BuildConfig",
|
|
344
|
+
"ConsolidateError",
|
|
345
|
+
"ConsolidateResult",
|
|
346
|
+
"config_from_manifest",
|
|
347
|
+
"consolidate",
|
|
348
|
+
"summarize",
|
|
349
|
+
"PackageError",
|
|
350
|
+
"PackageResult",
|
|
351
|
+
"package_project",
|
|
352
|
+
# scaffold
|
|
353
|
+
"DEFAULT_VERSION",
|
|
354
|
+
"TEMPLATES_DIR",
|
|
355
|
+
"ScaffoldError",
|
|
356
|
+
"build_values",
|
|
357
|
+
"normalize_name",
|
|
358
|
+
"render",
|
|
359
|
+
"scaffold_project",
|
|
360
|
+
# fetch
|
|
361
|
+
"Fetched",
|
|
362
|
+
"FetchError",
|
|
363
|
+
"fetch",
|
|
364
|
+
"fetch_json",
|
|
365
|
+
"sha256_file",
|
|
366
|
+
]
|
|
@@ -429,6 +429,7 @@ def _cmd_manage_remove(args: argparse.Namespace) -> int:
|
|
|
429
429
|
import shutil
|
|
430
430
|
|
|
431
431
|
from .install.discovery import target_dir
|
|
432
|
+
from .install.installer import InstallError, validate_registry_name, validate_registry_relpath
|
|
432
433
|
from .install.registry import get_extension, read_registry, remove_entry
|
|
433
434
|
|
|
434
435
|
scripts_root = _resolve_scripts_root(args.scripts_root)
|
|
@@ -441,17 +442,27 @@ def _cmd_manage_remove(args: argparse.Namespace) -> int:
|
|
|
441
442
|
)
|
|
442
443
|
return 1
|
|
443
444
|
as_directory = bool(entry.get("as_directory", True))
|
|
444
|
-
|
|
445
|
+
try:
|
|
446
|
+
key = validate_registry_name(entry.get("id") or args.name)
|
|
447
|
+
container_name = validate_registry_name(entry.get("name") or key)
|
|
448
|
+
except InstallError as exc:
|
|
449
|
+
print(f"resolvescript: manage remove: {exc}", file=sys.stderr)
|
|
450
|
+
return 1
|
|
445
451
|
for target in entry.get("targets", []):
|
|
446
452
|
base = target_dir(scripts_root, target)
|
|
447
453
|
if as_directory:
|
|
448
|
-
container = base /
|
|
454
|
+
container = base / container_name
|
|
449
455
|
if container.is_dir():
|
|
450
456
|
shutil.rmtree(container)
|
|
451
457
|
print(f"removed {container}")
|
|
452
458
|
else:
|
|
453
|
-
for
|
|
454
|
-
|
|
459
|
+
for raw_rel in entry.get("files", []):
|
|
460
|
+
try:
|
|
461
|
+
rel = validate_registry_relpath(str(raw_rel))
|
|
462
|
+
except InstallError as exc:
|
|
463
|
+
print(f"resolvescript: manage remove: {exc}", file=sys.stderr)
|
|
464
|
+
return 1
|
|
465
|
+
path = base / rel
|
|
455
466
|
if path.is_file() or (path.is_symlink() and not path.exists()):
|
|
456
467
|
path.unlink()
|
|
457
468
|
print(f"removed {path}")
|
|
@@ -495,8 +506,8 @@ def _cmd_test(args: argparse.Namespace) -> int:
|
|
|
495
506
|
env["RESOLVESCRIPT_BUILT_PATH"] = str(built_file)
|
|
496
507
|
runner = (
|
|
497
508
|
"import os, sys, pathlib\n"
|
|
498
|
-
"from
|
|
499
|
-
"from
|
|
509
|
+
"from ResolveScript.sandbox.env import install_fake_resolve\n"
|
|
510
|
+
"from ResolveScript.sandbox.loader import load_built_module\n"
|
|
500
511
|
"install_fake_resolve()\n"
|
|
501
512
|
"if os.environ.get('RESOLVESCRIPT_TARGET') == 'built':\n"
|
|
502
513
|
" name = os.environ['RESOLVESCRIPT_MODULE']\n"
|
ResolveScript/fetch.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""HTTP(S) download + SHA-256 integrity verification (stdlib only)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import ipaddress
|
|
7
|
+
import socket
|
|
8
|
+
import tempfile
|
|
9
|
+
import urllib.request
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from urllib.parse import urlparse
|
|
13
|
+
|
|
14
|
+
# Maximum download size (100 MB) to prevent DoS via unbounded downloads
|
|
15
|
+
_MAX_DOWNLOAD_SIZE = 100 * 1024 * 1024
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class FetchError(RuntimeError):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# Non-public address ranges that must never be fetched. Kept explicit (rather
|
|
23
|
+
# than relying on ipaddress.is_private/is_reserved, whose ranges vary across
|
|
24
|
+
# Python versions) so behavior is deterministic. Includes loopback, RFC 1918,
|
|
25
|
+
# CGNAT/shared space, link-local (cloud-metadata), documentation, benchmark and
|
|
26
|
+
# multicast ranges for both address families.
|
|
27
|
+
_BLOCKED_NETS = tuple(
|
|
28
|
+
ipaddress.ip_network(prefix)
|
|
29
|
+
for prefix in (
|
|
30
|
+
"0.0.0.0/8",
|
|
31
|
+
"10.0.0.0/8",
|
|
32
|
+
"100.64.0.0/10",
|
|
33
|
+
"127.0.0.0/8",
|
|
34
|
+
"169.254.0.0/16",
|
|
35
|
+
"172.16.0.0/12",
|
|
36
|
+
"192.0.0.0/24",
|
|
37
|
+
"192.168.0.0/16",
|
|
38
|
+
"198.18.0.0/15",
|
|
39
|
+
"198.51.100.0/24",
|
|
40
|
+
"203.0.113.0/24",
|
|
41
|
+
"224.0.0.0/4",
|
|
42
|
+
"240.0.0.0/4",
|
|
43
|
+
"255.255.255.255/32",
|
|
44
|
+
"::/128",
|
|
45
|
+
"::1/128",
|
|
46
|
+
"100::/64",
|
|
47
|
+
"2001:db8::/32",
|
|
48
|
+
"fc00::/7",
|
|
49
|
+
"fe80::/10",
|
|
50
|
+
"ff00::/8",
|
|
51
|
+
)
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _assert_public_host(url: str) -> None:
|
|
56
|
+
"""Reject URLs whose host is not a public internet address (SSRF guard).
|
|
57
|
+
|
|
58
|
+
Loopback, private (RFC 1918 / CGNAT / ULA) and link-local addresses
|
|
59
|
+
(including the cloud-metadata ``169.254.169.254``) are blocked, along
|
|
60
|
+
with documentation, benchmark, multicast, unspecified and reserved ranges.
|
|
61
|
+
"""
|
|
62
|
+
host = urlparse(url).hostname
|
|
63
|
+
if not host:
|
|
64
|
+
raise FetchError(f"URL has no host: {url!r}")
|
|
65
|
+
try:
|
|
66
|
+
addresses = [ipaddress.ip_address(host)]
|
|
67
|
+
except ValueError:
|
|
68
|
+
try:
|
|
69
|
+
resolved = socket.getaddrinfo(host, None)
|
|
70
|
+
except socket.gaierror as exc:
|
|
71
|
+
raise FetchError(f"cannot resolve host {host!r}: {exc}") from exc
|
|
72
|
+
addresses = [ipaddress.ip_address(info[4][0]) for info in resolved]
|
|
73
|
+
for addr in addresses:
|
|
74
|
+
if (
|
|
75
|
+
addr.is_loopback
|
|
76
|
+
or addr.is_link_local
|
|
77
|
+
or addr.is_multicast
|
|
78
|
+
or addr.is_unspecified
|
|
79
|
+
or any(addr in net for net in _BLOCKED_NETS)
|
|
80
|
+
):
|
|
81
|
+
raise FetchError(
|
|
82
|
+
f"{host!r} resolves to non-public address {addr} "
|
|
83
|
+
"(loopback, private or link-local hosts are blocked)"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class _SafeRedirectHandler(urllib.request.HTTPRedirectHandler):
|
|
88
|
+
"""Reject redirects to non-http(s) schemes, plaintext downgrades, or
|
|
89
|
+
non-public hosts (SSRF guard)."""
|
|
90
|
+
|
|
91
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: PLR0913
|
|
92
|
+
old_scheme = urlparse(req.full_url).scheme.lower()
|
|
93
|
+
new_scheme = urlparse(newurl).scheme.lower()
|
|
94
|
+
if new_scheme not in ("http", "https"):
|
|
95
|
+
raise FetchError(f"redirect to non-http(s) scheme blocked: {newurl!r}")
|
|
96
|
+
if old_scheme == "https" and new_scheme != "https":
|
|
97
|
+
raise FetchError(
|
|
98
|
+
f"refusing to downgrade https connection to plaintext http via redirect: {newurl!r}"
|
|
99
|
+
)
|
|
100
|
+
_assert_public_host(newurl)
|
|
101
|
+
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
_OPENER = urllib.request.build_opener(_SafeRedirectHandler())
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass
|
|
108
|
+
class Fetched:
|
|
109
|
+
path: Path
|
|
110
|
+
sha256: str
|
|
111
|
+
size: int
|
|
112
|
+
url: str
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def sha256_file(path: Path) -> str:
|
|
116
|
+
digest = hashlib.sha256()
|
|
117
|
+
with path.open("rb") as handle:
|
|
118
|
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
119
|
+
digest.update(chunk)
|
|
120
|
+
return digest.hexdigest()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def allow_remote() -> bool:
|
|
124
|
+
"""Whether remote downloads are allowed (default on; off via env)."""
|
|
125
|
+
import os
|
|
126
|
+
|
|
127
|
+
return os.environ.get("RESOLVESCRIPT_ALLOW_NETWORK", "1").lower() not in (
|
|
128
|
+
"0",
|
|
129
|
+
"false",
|
|
130
|
+
"no",
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def fetch(
|
|
135
|
+
url: str,
|
|
136
|
+
*,
|
|
137
|
+
dest: Path,
|
|
138
|
+
expected_sha256: str | None = None,
|
|
139
|
+
timeout: float = 45.0,
|
|
140
|
+
) -> Fetched:
|
|
141
|
+
"""Download ``url`` to a fresh ``dest``; verify integrity when provided.
|
|
142
|
+
|
|
143
|
+
Only ``http://`` and ``https://`` schemes are allowed; other schemes
|
|
144
|
+
(e.g. ``file://``, ``ftp://``) raise :class:`FetchError` to prevent
|
|
145
|
+
local‑file reads and SSRF.
|
|
146
|
+
"""
|
|
147
|
+
if not url.lower().startswith(("http://", "https://")):
|
|
148
|
+
raise FetchError(f"only http/https URLs are allowed (got {url!r})")
|
|
149
|
+
_assert_public_host(url)
|
|
150
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
151
|
+
if dest.exists():
|
|
152
|
+
dest.unlink()
|
|
153
|
+
if not allow_remote():
|
|
154
|
+
raise FetchError("network downloads are disabled (RESOLVESCRIPT_ALLOW_NETWORK=0)")
|
|
155
|
+
previous = socket.getdefaulttimeout()
|
|
156
|
+
socket.setdefaulttimeout(timeout)
|
|
157
|
+
try:
|
|
158
|
+
try:
|
|
159
|
+
with _OPENER.open(url, timeout=timeout) as response:
|
|
160
|
+
data = response.read(_MAX_DOWNLOAD_SIZE + 1)
|
|
161
|
+
except Exception as exc: # URLError, HTTPError, timeout…
|
|
162
|
+
raise FetchError(f"failed to download {url}: {exc}") from exc
|
|
163
|
+
finally:
|
|
164
|
+
socket.setdefaulttimeout(previous)
|
|
165
|
+
if len(data) > _MAX_DOWNLOAD_SIZE:
|
|
166
|
+
raise FetchError(f"download exceeds maximum allowed size ({_MAX_DOWNLOAD_SIZE} bytes)")
|
|
167
|
+
size = len(data)
|
|
168
|
+
digest = hashlib.sha256(data).hexdigest()
|
|
169
|
+
if expected_sha256 and digest != expected_sha256:
|
|
170
|
+
raise FetchError(
|
|
171
|
+
f"integrity check failed for {url}: expected sha256 "
|
|
172
|
+
f"{expected_sha256}, got {digest}"
|
|
173
|
+
)
|
|
174
|
+
dest.write_bytes(data)
|
|
175
|
+
return Fetched(path=dest, sha256=digest, size=size, url=url)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def fetch_json(
|
|
179
|
+
url: str, *, timeout: float = 45.0
|
|
180
|
+
) -> object:
|
|
181
|
+
"""Download a JSON document (used for the GitHub tags API in tests)."""
|
|
182
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
183
|
+
result = fetch(url, dest=Path(tmp) / "payload", timeout=timeout)
|
|
184
|
+
import json
|
|
185
|
+
|
|
186
|
+
return json.loads(result.path.read_text("utf-8"))
|