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.
Files changed (58) hide show
  1. ResolveScript/__init__.py +366 -0
  2. ResolveScript/_version.py +8 -0
  3. {resolve_script → ResolveScript}/cli.py +17 -6
  4. ResolveScript/fetch.py +186 -0
  5. {resolve_script → ResolveScript}/install/installer.py +60 -8
  6. ResolveScript/manifest/__init__.py +70 -0
  7. {resolve_script → ResolveScript}/manifest/xml_reader.py +18 -1
  8. {resolve_script → ResolveScript}/resolver.py +46 -12
  9. {resolve_script → ResolveScript}/sandbox/__init__.py +32 -2
  10. {resolve_script → ResolveScript}/sandbox/loader.py +20 -0
  11. ResolveScript/sources/__init__.py +61 -0
  12. ResolveScript/sources/archive.py +167 -0
  13. {resolve_script → ResolveScript}/sources/git.py +32 -0
  14. {resolve_script → ResolveScript}/templates/extension/conftest.py +1 -1
  15. {resolve_script → ResolveScript}/testing/__init__.py +1 -1
  16. {resolve_script → ResolveScript}/workspace.py +10 -3
  17. {resolvescript-0.1.2.dist-info → resolvescript-0.1.4a0.dist-info}/METADATA +58 -3
  18. resolvescript-0.1.4a0.dist-info/RECORD +50 -0
  19. resolvescript-0.1.4a0.dist-info/entry_points.txt +2 -0
  20. resolvescript-0.1.4a0.dist-info/top_level.txt +1 -0
  21. resolve_script/__init__.py +0 -3
  22. resolve_script/fetch.py +0 -90
  23. resolve_script/manifest/__init__.py +0 -1
  24. resolve_script/sources/__init__.py +0 -15
  25. resolve_script/sources/archive.py +0 -82
  26. resolvescript-0.1.2.dist-info/RECORD +0 -49
  27. resolvescript-0.1.2.dist-info/entry_points.txt +0 -2
  28. resolvescript-0.1.2.dist-info/top_level.txt +0 -1
  29. {resolve_script → ResolveScript}/analyze.py +0 -0
  30. {resolve_script → ResolveScript}/config.py +0 -0
  31. {resolve_script → ResolveScript}/consolidate.py +0 -0
  32. {resolve_script → ResolveScript}/install/__init__.py +0 -0
  33. {resolve_script → ResolveScript}/install/discovery.py +0 -0
  34. {resolve_script → ResolveScript}/install/registry.py +0 -0
  35. {resolve_script → ResolveScript}/manifest/json_reader.py +0 -0
  36. {resolve_script → ResolveScript}/manifest/model.py +0 -0
  37. {resolve_script → ResolveScript}/manifest/validation.py +0 -0
  38. {resolve_script → ResolveScript}/package.py +0 -0
  39. {resolve_script → ResolveScript}/sandbox/api.py +0 -0
  40. {resolve_script → ResolveScript}/sandbox/env.py +0 -0
  41. {resolve_script → ResolveScript}/sandbox/repl.py +0 -0
  42. {resolve_script → ResolveScript}/sandbox/smoke.py +0 -0
  43. {resolve_script → ResolveScript}/scaffold.py +0 -0
  44. {resolve_script → ResolveScript}/semver.py +0 -0
  45. {resolve_script → ResolveScript}/sources/known.py +0 -0
  46. {resolve_script → ResolveScript}/sources/release.py +0 -0
  47. {resolve_script → ResolveScript}/spec.py +0 -0
  48. {resolve_script → ResolveScript}/templates/extension/@NAME@/__init__.py +0 -0
  49. {resolve_script → ResolveScript}/templates/extension/@NAME@/menu.py +0 -0
  50. {resolve_script → ResolveScript}/templates/extension/@NAME@.py +0 -0
  51. {resolve_script → ResolveScript}/templates/extension/README.md +0 -0
  52. {resolve_script → ResolveScript}/templates/extension/manifest.json.j2 +0 -0
  53. {resolve_script → ResolveScript}/templates/extension/manifest.xml.j2 +0 -0
  54. {resolve_script → ResolveScript}/templates/extension/tests/test_smoke.py +0 -0
  55. {resolve_script → ResolveScript}/templates/inapp/register.py +0 -0
  56. {resolve_script → ResolveScript}/testing/fixtures.py +0 -0
  57. {resolvescript-0.1.2.dist-info → resolvescript-0.1.4a0.dist-info}/WHEEL +0 -0
  58. {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
+ ]
@@ -0,0 +1,8 @@
1
+ """Package version metadata (imported by ``ResolveScript.__init__``)."""
2
+
3
+ __version__ = "0.1.4-alpha"
4
+
5
+
6
+ def get_version() -> str:
7
+ """Return the installed ``resolvescript`` version string."""
8
+ return __version__
@@ -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
- key = entry.get("id") or args.name
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 / (entry.get("name") or key)
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 rel in entry.get("files", []):
454
- path = base / str(rel)
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 resolve_script.sandbox.env import install_fake_resolve\n"
499
- "from resolve_script.sandbox.loader import load_built_module\n"
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"))