ruyi 0.39.0__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 (101) hide show
  1. ruyi/__init__.py +21 -0
  2. ruyi/__main__.py +98 -0
  3. ruyi/cli/__init__.py +5 -0
  4. ruyi/cli/builtin_commands.py +14 -0
  5. ruyi/cli/cmd.py +224 -0
  6. ruyi/cli/completer.py +50 -0
  7. ruyi/cli/completion.py +26 -0
  8. ruyi/cli/config_cli.py +153 -0
  9. ruyi/cli/main.py +111 -0
  10. ruyi/cli/self_cli.py +295 -0
  11. ruyi/cli/user_input.py +127 -0
  12. ruyi/cli/version_cli.py +45 -0
  13. ruyi/config/__init__.py +401 -0
  14. ruyi/config/editor.py +92 -0
  15. ruyi/config/errors.py +76 -0
  16. ruyi/config/news.py +39 -0
  17. ruyi/config/schema.py +197 -0
  18. ruyi/device/__init__.py +0 -0
  19. ruyi/device/provision.py +591 -0
  20. ruyi/device/provision_cli.py +40 -0
  21. ruyi/log/__init__.py +272 -0
  22. ruyi/mux/.gitignore +1 -0
  23. ruyi/mux/__init__.py +0 -0
  24. ruyi/mux/runtime.py +213 -0
  25. ruyi/mux/venv/__init__.py +12 -0
  26. ruyi/mux/venv/emulator_cfg.py +41 -0
  27. ruyi/mux/venv/maker.py +782 -0
  28. ruyi/mux/venv/venv_cli.py +92 -0
  29. ruyi/mux/venv_cfg.py +214 -0
  30. ruyi/pluginhost/__init__.py +0 -0
  31. ruyi/pluginhost/api.py +206 -0
  32. ruyi/pluginhost/ctx.py +222 -0
  33. ruyi/pluginhost/paths.py +135 -0
  34. ruyi/pluginhost/plugin_cli.py +37 -0
  35. ruyi/pluginhost/unsandboxed.py +246 -0
  36. ruyi/py.typed +0 -0
  37. ruyi/resource_bundle/__init__.py +20 -0
  38. ruyi/resource_bundle/__main__.py +55 -0
  39. ruyi/resource_bundle/data.py +26 -0
  40. ruyi/ruyipkg/__init__.py +0 -0
  41. ruyi/ruyipkg/admin_checksum.py +88 -0
  42. ruyi/ruyipkg/admin_cli.py +83 -0
  43. ruyi/ruyipkg/atom.py +184 -0
  44. ruyi/ruyipkg/augmented_pkg.py +212 -0
  45. ruyi/ruyipkg/canonical_dump.py +320 -0
  46. ruyi/ruyipkg/checksum.py +39 -0
  47. ruyi/ruyipkg/cli_completion.py +42 -0
  48. ruyi/ruyipkg/distfile.py +208 -0
  49. ruyi/ruyipkg/entity.py +387 -0
  50. ruyi/ruyipkg/entity_cli.py +123 -0
  51. ruyi/ruyipkg/entity_provider.py +273 -0
  52. ruyi/ruyipkg/fetch.py +271 -0
  53. ruyi/ruyipkg/host.py +55 -0
  54. ruyi/ruyipkg/install.py +554 -0
  55. ruyi/ruyipkg/install_cli.py +150 -0
  56. ruyi/ruyipkg/list.py +126 -0
  57. ruyi/ruyipkg/list_cli.py +79 -0
  58. ruyi/ruyipkg/list_filter.py +173 -0
  59. ruyi/ruyipkg/msg.py +99 -0
  60. ruyi/ruyipkg/news.py +123 -0
  61. ruyi/ruyipkg/news_cli.py +78 -0
  62. ruyi/ruyipkg/news_store.py +183 -0
  63. ruyi/ruyipkg/pkg_manifest.py +657 -0
  64. ruyi/ruyipkg/profile.py +208 -0
  65. ruyi/ruyipkg/profile_cli.py +33 -0
  66. ruyi/ruyipkg/protocols.py +55 -0
  67. ruyi/ruyipkg/repo.py +763 -0
  68. ruyi/ruyipkg/state.py +345 -0
  69. ruyi/ruyipkg/unpack.py +369 -0
  70. ruyi/ruyipkg/unpack_method.py +91 -0
  71. ruyi/ruyipkg/update_cli.py +54 -0
  72. ruyi/telemetry/__init__.py +0 -0
  73. ruyi/telemetry/aggregate.py +72 -0
  74. ruyi/telemetry/event.py +41 -0
  75. ruyi/telemetry/node_info.py +192 -0
  76. ruyi/telemetry/provider.py +411 -0
  77. ruyi/telemetry/scope.py +43 -0
  78. ruyi/telemetry/store.py +238 -0
  79. ruyi/telemetry/telemetry_cli.py +127 -0
  80. ruyi/utils/__init__.py +0 -0
  81. ruyi/utils/ar.py +74 -0
  82. ruyi/utils/ci.py +63 -0
  83. ruyi/utils/frontmatter.py +38 -0
  84. ruyi/utils/git.py +169 -0
  85. ruyi/utils/global_mode.py +204 -0
  86. ruyi/utils/l10n.py +83 -0
  87. ruyi/utils/markdown.py +73 -0
  88. ruyi/utils/nuitka.py +33 -0
  89. ruyi/utils/porcelain.py +51 -0
  90. ruyi/utils/prereqs.py +77 -0
  91. ruyi/utils/ssl_patch.py +170 -0
  92. ruyi/utils/templating.py +34 -0
  93. ruyi/utils/toml.py +115 -0
  94. ruyi/utils/url.py +7 -0
  95. ruyi/utils/xdg_basedir.py +80 -0
  96. ruyi/version.py +67 -0
  97. ruyi-0.39.0.dist-info/LICENSE-Apache.txt +201 -0
  98. ruyi-0.39.0.dist-info/METADATA +403 -0
  99. ruyi-0.39.0.dist-info/RECORD +101 -0
  100. ruyi-0.39.0.dist-info/WHEEL +4 -0
  101. ruyi-0.39.0.dist-info/entry_points.txt +3 -0
ruyi/ruyipkg/state.py ADDED
@@ -0,0 +1,345 @@
1
+ import datetime
2
+ import json
3
+ import os
4
+ import pathlib
5
+ from typing import Any, Iterable, Iterator, TypedDict, TYPE_CHECKING
6
+ from dataclasses import dataclass
7
+
8
+ from .pkg_manifest import BoundPackageManifest
9
+ from .protocols import ProvidesPackageManifests
10
+
11
+ if TYPE_CHECKING:
12
+ # for avoiding heavy import
13
+ from .repo import MetadataRepo
14
+
15
+
16
+ class PackageInstallationRecord(TypedDict):
17
+ """Record of a package installation."""
18
+
19
+ repo_id: str
20
+ category: str
21
+ name: str
22
+ version: str
23
+ host: str # For binary packages, empty for blobs
24
+ install_path: str
25
+ install_time: str # ISO format datetime
26
+
27
+
28
+ @dataclass
29
+ class PackageInstallationInfo:
30
+ """Information about an installed package."""
31
+
32
+ repo_id: str
33
+ category: str
34
+ name: str
35
+ version: str
36
+ host: str # For binary packages, empty for blobs
37
+ install_path: str
38
+ install_time: datetime.datetime
39
+
40
+ def to_record(self) -> PackageInstallationRecord:
41
+ """Convert to a record for JSON serialization."""
42
+ return PackageInstallationRecord(
43
+ repo_id=self.repo_id,
44
+ category=self.category,
45
+ name=self.name,
46
+ version=self.version,
47
+ host=self.host,
48
+ install_path=self.install_path,
49
+ install_time=self.install_time.isoformat(),
50
+ )
51
+
52
+ @classmethod
53
+ def from_record(
54
+ cls,
55
+ record: PackageInstallationRecord,
56
+ ) -> "PackageInstallationInfo":
57
+ """Create from a record."""
58
+ return cls(
59
+ repo_id=record["repo_id"],
60
+ category=record["category"],
61
+ name=record["name"],
62
+ version=record["version"],
63
+ host=record["host"],
64
+ install_path=record["install_path"],
65
+ install_time=datetime.datetime.fromisoformat(record["install_time"]),
66
+ )
67
+
68
+
69
+ class RuyipkgGlobalStateStore:
70
+ def __init__(self, root: os.PathLike[Any]) -> None:
71
+ self.root = pathlib.Path(root)
72
+ self._installs_file = self.root / "installs.json"
73
+ self._installs_cache: dict[str, PackageInstallationInfo] | None = None
74
+
75
+ def ensure_state_dir(self) -> None:
76
+ """Ensure the state directory exists."""
77
+ self.root.mkdir(parents=True, exist_ok=True)
78
+
79
+ def _load_installs(self) -> dict[str, PackageInstallationInfo]:
80
+ """Load installation records from disk."""
81
+ if self._installs_cache is not None:
82
+ return self._installs_cache
83
+
84
+ self.ensure_state_dir()
85
+
86
+ if not self._installs_file.exists():
87
+ self._installs_cache = {}
88
+ return self._installs_cache
89
+
90
+ try:
91
+ with open(self._installs_file, "r", encoding="utf-8") as f:
92
+ data = json.load(f)
93
+
94
+ installs = {}
95
+ for key, record in data.items():
96
+ installs[key] = PackageInstallationInfo.from_record(record)
97
+
98
+ self._installs_cache = installs
99
+ return self._installs_cache
100
+ except (json.JSONDecodeError, KeyError, ValueError):
101
+ # If file is corrupted, start fresh
102
+ self._installs_cache = {}
103
+ return self._installs_cache
104
+
105
+ def _save_installs(self) -> None:
106
+ """Save installation records to disk."""
107
+ if self._installs_cache is None:
108
+ return
109
+
110
+ self.ensure_state_dir()
111
+
112
+ data = {}
113
+ for key, info in self._installs_cache.items():
114
+ data[key] = info.to_record()
115
+
116
+ # Write atomically by writing to temp file then renaming
117
+ temp_file = self._installs_file.with_suffix(".tmp")
118
+ try:
119
+ with open(temp_file, "w", encoding="utf-8") as f:
120
+ json.dump(data, f, indent=2, ensure_ascii=False)
121
+ temp_file.replace(self._installs_file)
122
+ except Exception:
123
+ # Clean up temp file if something went wrong
124
+ if temp_file.exists():
125
+ temp_file.unlink()
126
+ raise
127
+
128
+ def _get_installation_key(
129
+ self,
130
+ repo_id: str,
131
+ category: str,
132
+ name: str,
133
+ version: str,
134
+ host: str = "",
135
+ ) -> str:
136
+ """Get the key used to store installation info."""
137
+ if host:
138
+ # Use a format that includes host for binary packages
139
+ return f"{repo_id}:{category}/{name} {version} host={host}"
140
+ return f"{repo_id}:{category}/{name} {version}"
141
+
142
+ def record_installation(
143
+ self,
144
+ repo_id: str,
145
+ category: str,
146
+ name: str,
147
+ version: str,
148
+ host: str,
149
+ install_path: str,
150
+ ) -> None:
151
+ """Record a successful package installation."""
152
+ installs = self._load_installs()
153
+
154
+ key = self._get_installation_key(repo_id, category, name, version, host)
155
+ info = PackageInstallationInfo(
156
+ repo_id=repo_id,
157
+ category=category,
158
+ name=name,
159
+ version=version,
160
+ host=host,
161
+ install_path=install_path,
162
+ install_time=datetime.datetime.now(),
163
+ )
164
+
165
+ installs[key] = info
166
+ self._save_installs()
167
+
168
+ def remove_installation(
169
+ self,
170
+ repo_id: str,
171
+ category: str,
172
+ name: str,
173
+ version: str,
174
+ host: str = "",
175
+ ) -> bool:
176
+ """Remove an installation record."""
177
+ installs = self._load_installs()
178
+ key = self._get_installation_key(repo_id, category, name, version, host)
179
+
180
+ if key in installs:
181
+ del installs[key]
182
+ self._save_installs()
183
+ return True
184
+ return False
185
+
186
+ def get_installation(
187
+ self,
188
+ repo_id: str,
189
+ category: str,
190
+ name: str,
191
+ version: str,
192
+ host: str = "",
193
+ ) -> PackageInstallationInfo | None:
194
+ """Get information about a specific installation."""
195
+ installs = self._load_installs()
196
+ key = self._get_installation_key(repo_id, category, name, version, host)
197
+ return installs.get(key)
198
+
199
+ def is_package_installed(
200
+ self,
201
+ repo_id: str,
202
+ category: str,
203
+ name: str,
204
+ version: str,
205
+ host: str = "",
206
+ ) -> bool:
207
+ """Check if a package is installed."""
208
+ return self.get_installation(repo_id, category, name, version, host) is not None
209
+
210
+ def list_installed_packages(self) -> list[PackageInstallationInfo]:
211
+ """List all installed packages."""
212
+ installs = self._load_installs()
213
+ return list(installs.values())
214
+
215
+
216
+ class BoundInstallationStateStore(ProvidesPackageManifests):
217
+ def __init__(self, rgs: RuyipkgGlobalStateStore, mr: "MetadataRepo") -> None:
218
+ self._rgs = rgs
219
+ self._mr = mr
220
+
221
+ def _get_installed_manifest(
222
+ self,
223
+ info: PackageInstallationInfo,
224
+ ) -> BoundPackageManifest | None:
225
+ """Get the bound manifest for an installed package, or None if not found in repo."""
226
+
227
+ return self._mr.get_pkg(info.name, info.category, info.version)
228
+
229
+ def iter_pkg_manifests(self) -> Iterable[BoundPackageManifest]:
230
+ """Iterate over all installed package manifests."""
231
+
232
+ installed_pkgs = self._rgs.list_installed_packages()
233
+ for info in installed_pkgs:
234
+ if m := self._get_installed_manifest(info):
235
+ yield m
236
+
237
+ def iter_pkgs(
238
+ self,
239
+ ) -> Iterable[tuple[str, str, dict[str, BoundPackageManifest]]]:
240
+ """Iterate over installed packages grouped by category and name."""
241
+
242
+ installed_pkgs = self._rgs.list_installed_packages()
243
+
244
+ # Group by category and name
245
+ result: dict[str, dict[str, dict[str, BoundPackageManifest]]] = {}
246
+ for info in installed_pkgs:
247
+ if m := self._get_installed_manifest(info):
248
+ if info.category not in result:
249
+ result[info.category] = {}
250
+ if info.name not in result[info.category]:
251
+ result[info.category][info.name] = {}
252
+ result[info.category][info.name][info.version] = m
253
+
254
+ for category, cat_pkgs in result.items():
255
+ for pkg_name, pkg_vers in cat_pkgs.items():
256
+ yield (category, pkg_name, pkg_vers)
257
+
258
+ def iter_pkg_vers(
259
+ self,
260
+ name: str,
261
+ category: str | None = None,
262
+ ) -> Iterable[BoundPackageManifest]:
263
+ """Iterate over installed versions of a specific package."""
264
+
265
+ installed_pkgs = self._rgs.list_installed_packages()
266
+ for info in installed_pkgs:
267
+ if info.name == name and (category is None or info.category == category):
268
+ if m := self._get_installed_manifest(info):
269
+ yield m
270
+
271
+ def get_pkg(
272
+ self,
273
+ name: str,
274
+ category: str,
275
+ ver: str,
276
+ ) -> BoundPackageManifest | None:
277
+ """Returns the package manifest by exact match, or None if not found."""
278
+ installed_pkgs = self._rgs.list_installed_packages()
279
+ for info in installed_pkgs:
280
+ if info.name == name and info.category == category and info.version == ver:
281
+ if m := self._get_installed_manifest(info):
282
+ return m
283
+ # Package is installed but not found in current repo
284
+ break
285
+
286
+ return None
287
+
288
+ def get_pkg_latest_ver(
289
+ self,
290
+ name: str,
291
+ category: str | None = None,
292
+ include_prerelease_vers: bool = False,
293
+ ) -> BoundPackageManifest:
294
+ """Get the latest installed version of a package."""
295
+
296
+ from .pkg_manifest import is_prerelease
297
+
298
+ installed_vers = list(self.iter_pkg_vers(name, category))
299
+ if not installed_vers:
300
+ raise KeyError(f"No installed versions found for package '{name}'")
301
+
302
+ if not include_prerelease_vers:
303
+ installed_vers = [
304
+ pm for pm in installed_vers if not is_prerelease(pm.semver)
305
+ ]
306
+ if not installed_vers:
307
+ raise KeyError(
308
+ f"No non-prerelease installed versions found for package '{name}'"
309
+ )
310
+
311
+ # Find the latest version
312
+ latest = max(installed_vers, key=lambda pm: pm.semver)
313
+ return latest
314
+
315
+ # To be removed later along with slug support
316
+ def get_pkg_by_slug(self, slug: str) -> BoundPackageManifest | None:
317
+ """Get an installed package by its slug."""
318
+
319
+ installed_pkgs = self._rgs.list_installed_packages()
320
+ for info in installed_pkgs:
321
+ if m := self._get_installed_manifest(info):
322
+ if m.slug == slug:
323
+ return m
324
+ return None
325
+
326
+ # Useful helpers
327
+
328
+ def iter_upgradable_pkgs(
329
+ self,
330
+ include_prereleases: bool = False,
331
+ ) -> Iterator[tuple[BoundPackageManifest, str]]:
332
+ for installed_pm in self.iter_pkg_manifests():
333
+ latest_pm: BoundPackageManifest
334
+ try:
335
+ latest_pm = self._mr.get_pkg_latest_ver(
336
+ installed_pm.name,
337
+ installed_pm.category,
338
+ include_prereleases,
339
+ )
340
+ except KeyError:
341
+ # package not found in the repo, skip it
342
+ continue
343
+
344
+ if latest_pm.semver > installed_pm.semver:
345
+ yield (installed_pm, str(latest_pm.semver))
ruyi/ruyipkg/unpack.py ADDED
@@ -0,0 +1,369 @@
1
+ import mmap
2
+ import os
3
+ import shutil
4
+ import subprocess
5
+ from typing import BinaryIO, NoReturn, Protocol
6
+
7
+ from ..log import RuyiLogger
8
+ from ..utils import ar, prereqs
9
+ from .unpack_method import (
10
+ UnpackMethod,
11
+ UnrecognizedPackFormatError,
12
+ determine_unpack_method,
13
+ )
14
+
15
+
16
+ class SupportsRead(Protocol):
17
+ def read(self, n: int = -1, /) -> bytes: ...
18
+
19
+
20
+ def do_unpack(
21
+ logger: RuyiLogger,
22
+ filename: str,
23
+ dest: str | None,
24
+ strip_components: int,
25
+ unpack_method: UnpackMethod,
26
+ stream: BinaryIO | SupportsRead | None = None,
27
+ prefixes_to_unpack: list[str] | None = None,
28
+ ) -> None:
29
+ match unpack_method:
30
+ case UnpackMethod.AUTO:
31
+ raise ValueError("the auto unpack method must be resolved prior to use")
32
+ case UnpackMethod.RAW:
33
+ return _do_copy_raw(filename, dest)
34
+ case (
35
+ UnpackMethod.TAR_AUTO
36
+ | UnpackMethod.TAR
37
+ | UnpackMethod.TAR_BZ2
38
+ | UnpackMethod.TAR_GZ
39
+ | UnpackMethod.TAR_LZ4
40
+ | UnpackMethod.TAR_XZ
41
+ | UnpackMethod.TAR_ZST
42
+ ):
43
+ return _do_unpack_tar(
44
+ logger,
45
+ filename,
46
+ dest,
47
+ strip_components,
48
+ unpack_method,
49
+ stream,
50
+ prefixes_to_unpack,
51
+ )
52
+ case UnpackMethod.ZIP:
53
+ # TODO: handle strip_components somehow; the unzip(1) command currently
54
+ # does not have such support.
55
+ return _do_unpack_zip(logger, filename, dest)
56
+ case UnpackMethod.DEB:
57
+ return _do_unpack_deb(logger, filename, dest)
58
+ case UnpackMethod.GZ:
59
+ # bare gzip file
60
+ return _do_unpack_bare_gz(logger, filename, dest)
61
+ case UnpackMethod.BZ2:
62
+ # bare bzip2 file
63
+ return _do_unpack_bare_bzip2(logger, filename, dest)
64
+ case UnpackMethod.LZ4:
65
+ # bare lz4 file
66
+ return _do_unpack_bare_lz4(logger, filename, dest)
67
+ case UnpackMethod.XZ:
68
+ # bare xz file
69
+ return _do_unpack_bare_xz(logger, filename, dest)
70
+ case UnpackMethod.ZST:
71
+ # bare zstd file
72
+ return _do_unpack_bare_zstd(logger, filename, dest)
73
+ case _:
74
+ raise UnrecognizedPackFormatError(filename)
75
+
76
+
77
+ def do_unpack_or_symlink(
78
+ logger: RuyiLogger,
79
+ filename: str,
80
+ dest: str | None,
81
+ strip_components: int,
82
+ unpack_method: UnpackMethod,
83
+ stream: BinaryIO | SupportsRead | None = None,
84
+ prefixes_to_unpack: list[str] | None = None,
85
+ ) -> None:
86
+ try:
87
+ return do_unpack(
88
+ logger,
89
+ filename,
90
+ dest,
91
+ strip_components,
92
+ unpack_method,
93
+ stream,
94
+ prefixes_to_unpack,
95
+ )
96
+ except UnrecognizedPackFormatError:
97
+ # just symlink into destination
98
+ return do_symlink(filename, dest)
99
+
100
+
101
+ def _do_copy_raw(
102
+ src_path: str,
103
+ destdir: str | None,
104
+ ) -> None:
105
+ src_filename = os.path.basename(src_path)
106
+ if destdir is None:
107
+ # symlink into CWD
108
+ dest = src_filename
109
+ else:
110
+ dest = os.path.join(destdir, src_filename)
111
+
112
+ shutil.copy(src_path, dest)
113
+
114
+
115
+ def do_symlink(
116
+ src_path: str,
117
+ destdir: str | None,
118
+ ) -> None:
119
+ src_filename = os.path.basename(src_path)
120
+ if destdir is None:
121
+ # symlink into CWD
122
+ dest = src_filename
123
+ else:
124
+ dest = os.path.join(destdir, src_filename)
125
+
126
+ # avoid the hassle and pitfalls around relative paths and symlinks, and
127
+ # just point to the target using absolute path
128
+ symlink_target = os.path.abspath(src_path)
129
+ os.symlink(symlink_target, dest)
130
+
131
+
132
+ def _do_unpack_tar(
133
+ logger: RuyiLogger,
134
+ filename: str,
135
+ dest: str | None,
136
+ strip_components: int,
137
+ unpack_method: UnpackMethod,
138
+ stream: SupportsRead | None,
139
+ prefixes_to_unpack: list[str] | None = None,
140
+ ) -> None:
141
+ argv = ["tar", "-x"]
142
+
143
+ match unpack_method:
144
+ case UnpackMethod.TAR | UnpackMethod.TAR_AUTO:
145
+ pass
146
+ case UnpackMethod.TAR_GZ:
147
+ argv.append("-z")
148
+ case UnpackMethod.TAR_BZ2:
149
+ argv.append("-j")
150
+ case UnpackMethod.TAR_LZ4:
151
+ argv.append("--use-compress-program=lz4")
152
+ case UnpackMethod.TAR_XZ:
153
+ argv.append("-J")
154
+ case UnpackMethod.TAR_ZST:
155
+ argv.append("--zstd")
156
+ case _:
157
+ raise ValueError(
158
+ f"do_unpack_tar cannot handle non-tar unpack method {unpack_method}"
159
+ )
160
+
161
+ stdin: int | None = None
162
+ if stream is not None:
163
+ filename = "-"
164
+ stdin = subprocess.PIPE
165
+
166
+ argv.extend(("-f", filename, f"--strip-components={strip_components}"))
167
+ if dest is not None:
168
+ argv.extend(("-C", dest))
169
+ if prefixes_to_unpack:
170
+ if any(p.startswith("-") for p in prefixes_to_unpack):
171
+ raise ValueError(
172
+ "prefixes_to_unpack must not contain any item starting with '-'"
173
+ )
174
+ argv.extend(prefixes_to_unpack)
175
+ logger.D(f"about to call tar: argv={argv}")
176
+ p = subprocess.Popen(argv, cwd=dest, stdin=stdin)
177
+
178
+ retcode: int
179
+ if stream is None:
180
+ retcode = p.wait()
181
+ else:
182
+ # this is only for pleasing the type-checker; it's statically true
183
+ # because the assignment always happens due to the earlier
184
+ # "stream is not None" branch.
185
+ assert p.stdin is not None
186
+
187
+ bufsize = 4 * mmap.PAGESIZE
188
+ while True:
189
+ buf = stream.read(bufsize)
190
+ if not buf:
191
+ break
192
+ p.stdin.write(buf)
193
+ p.stdin.close()
194
+ retcode = p.wait()
195
+
196
+ if retcode != 0:
197
+ raise RuntimeError(f"untar failed: command {' '.join(argv)} returned {retcode}")
198
+
199
+
200
+ def _do_unpack_zip(
201
+ logger: RuyiLogger,
202
+ filename: str,
203
+ dest: str | None,
204
+ ) -> None:
205
+ argv = ["unzip", filename]
206
+ if dest is not None:
207
+ argv.extend(("-d", dest))
208
+ logger.D(f"about to call unzip: argv={argv}")
209
+ retcode = subprocess.call(argv, cwd=dest)
210
+ if retcode != 0:
211
+ raise RuntimeError(f"unzip failed: command {' '.join(argv)} returned {retcode}")
212
+
213
+
214
+ def _do_unpack_bare_gz(
215
+ logger: RuyiLogger,
216
+ filename: str,
217
+ destdir: str | None,
218
+ ) -> None:
219
+ # the suffix may not be ".gz" so do this generically
220
+ dest_filename = os.path.splitext(os.path.basename(filename))[0]
221
+
222
+ argv = ["gunzip", "-c", filename]
223
+ if destdir is not None:
224
+ os.chdir(destdir)
225
+
226
+ logger.D(f"about to call gunzip: argv={argv}")
227
+ with open(dest_filename, "wb") as out:
228
+ retcode = subprocess.call(argv, stdout=out)
229
+ if retcode != 0:
230
+ raise RuntimeError(
231
+ f"gunzip failed: command {' '.join(argv)} returned {retcode}"
232
+ )
233
+
234
+
235
+ def _do_unpack_bare_bzip2(
236
+ logger: RuyiLogger,
237
+ filename: str,
238
+ destdir: str | None,
239
+ ) -> None:
240
+ # the suffix may not be ".bz2" so do this generically
241
+ dest_filename = os.path.splitext(os.path.basename(filename))[0]
242
+
243
+ argv = ["bzip2", "-dc", filename]
244
+ if destdir is not None:
245
+ os.chdir(destdir)
246
+
247
+ logger.D(f"about to call bzip2: argv={argv}")
248
+ with open(dest_filename, "wb") as out:
249
+ retcode = subprocess.call(argv, stdout=out)
250
+ if retcode != 0:
251
+ raise RuntimeError(
252
+ f"bzip2 failed: command {' '.join(argv)} returned {retcode}"
253
+ )
254
+
255
+
256
+ def _do_unpack_bare_lz4(
257
+ logger: RuyiLogger,
258
+ filename: str,
259
+ destdir: str | None,
260
+ ) -> None:
261
+ # the suffix may not be ".lz4" so do this generically
262
+ dest_filename = os.path.splitext(os.path.basename(filename))[0]
263
+
264
+ argv = ["lz4", "-dk", filename, f"./{dest_filename}"]
265
+ logger.D(f"about to call lz4: argv={argv}")
266
+ retcode = subprocess.call(argv, cwd=destdir)
267
+ if retcode != 0:
268
+ raise RuntimeError(f"lz4 failed: command {' '.join(argv)} returned {retcode}")
269
+
270
+
271
+ def _do_unpack_bare_xz(
272
+ logger: RuyiLogger,
273
+ filename: str,
274
+ destdir: str | None,
275
+ ) -> None:
276
+ # the suffix may not be ".xz" so do this generically
277
+ dest_filename = os.path.splitext(os.path.basename(filename))[0]
278
+
279
+ argv = ["xz", "-d", "-c", filename]
280
+ if destdir is not None:
281
+ os.chdir(destdir)
282
+
283
+ logger.D(f"about to call xz: argv={argv}")
284
+ with open(dest_filename, "wb") as out:
285
+ retcode = subprocess.call(argv, stdout=out)
286
+ if retcode != 0:
287
+ raise RuntimeError(
288
+ f"xz failed: command {' '.join(argv)} returned {retcode}"
289
+ )
290
+
291
+
292
+ def _do_unpack_bare_zstd(
293
+ logger: RuyiLogger,
294
+ filename: str,
295
+ destdir: str | None,
296
+ ) -> None:
297
+ # the suffix may not be ".zst" so do this generically
298
+ dest_filename = os.path.splitext(os.path.basename(filename))[0]
299
+
300
+ argv = ["zstd", "-d", filename, "-o", f"./{dest_filename}"]
301
+ logger.D(f"about to call zstd: argv={argv}")
302
+ retcode = subprocess.call(argv, cwd=destdir)
303
+ if retcode != 0:
304
+ raise RuntimeError(f"zstd failed: command {' '.join(argv)} returned {retcode}")
305
+
306
+
307
+ def _do_unpack_deb(
308
+ logger: RuyiLogger,
309
+ filename: str,
310
+ destdir: str | None,
311
+ ) -> None:
312
+ with ar.ArpyArchiveWrapper(filename) as a:
313
+ for f in a.infolist():
314
+ name = f.name.decode("utf-8")
315
+ if name.startswith("data.tar"):
316
+ inner_unpack_method = determine_unpack_method(name)
317
+ return _do_unpack_tar(
318
+ logger,
319
+ name,
320
+ destdir,
321
+ 0,
322
+ inner_unpack_method,
323
+ a.open(f),
324
+ )
325
+
326
+ raise RuntimeError(f"file '{filename}' does not appear to be a deb")
327
+
328
+
329
+ def _get_unpack_cmds_for_method(m: UnpackMethod) -> list[str]:
330
+ match m:
331
+ case UnpackMethod.UNKNOWN | UnpackMethod.RAW | UnpackMethod.DEB:
332
+ return []
333
+ case UnpackMethod.GZ:
334
+ return ["gunzip"]
335
+ case UnpackMethod.BZ2:
336
+ return ["bzip2"]
337
+ case UnpackMethod.LZ4:
338
+ return ["lz4"]
339
+ case UnpackMethod.XZ:
340
+ return ["xz"]
341
+ case UnpackMethod.ZST:
342
+ return ["zstd"]
343
+ case UnpackMethod.TAR | UnpackMethod.TAR_AUTO:
344
+ return ["tar"]
345
+ case UnpackMethod.TAR_GZ:
346
+ return ["tar", "gunzip"]
347
+ case UnpackMethod.TAR_BZ2:
348
+ return ["tar", "bzip2"]
349
+ case UnpackMethod.TAR_LZ4:
350
+ return ["tar", "lz4"]
351
+ case UnpackMethod.TAR_XZ:
352
+ return ["tar", "xz"]
353
+ case UnpackMethod.TAR_ZST:
354
+ return ["tar", "zstd"]
355
+ case UnpackMethod.ZIP:
356
+ return ["unzip"]
357
+ case UnpackMethod.AUTO:
358
+ raise ValueError(f"the unpack method {m} must be resolved prior to use")
359
+
360
+
361
+ def ensure_unpack_cmd_for_method(
362
+ logger: RuyiLogger,
363
+ m: UnpackMethod,
364
+ ) -> None | NoReturn:
365
+ required_cmds = _get_unpack_cmds_for_method(m)
366
+ if not required_cmds:
367
+ return None
368
+
369
+ return prereqs.ensure_cmds(logger, required_cmds, interactive_retry=True)