genesis-forge-runtime 1.0.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.
@@ -0,0 +1,100 @@
1
+ """Simulation-free runtime for deploying Genesis Forge policies to real robots.
2
+
3
+ This package reproduces the observation-assembly and action-processing pipelines
4
+ from training, reading them out of a bundle exported by ``genesis_forge``. It
5
+ depends on **numpy only** -- importing anything here must never pull in torch or
6
+ the Genesis simulator, so it installs on a Raspberry Pi or Jetson.
7
+
8
+ Typical robot-side use::
9
+
10
+ from genesis_forge_runtime import load_bundle
11
+
12
+ bundle = load_bundle("./go2_walk")
13
+ print(bundle.describe()) # what to wire up
14
+
15
+ observation_assembler = bundle.create_observation_assembler()
16
+ action_processor = bundle.create_action_processor()
17
+
18
+ while True:
19
+ obs = observation_assembler.assemble({
20
+ "robot_ang_vel": gyro,
21
+ "actions": action_processor.last_raw_actions_by_manager["action_manager"],
22
+ })
23
+ targets = action_processor.process(policy(obs))
24
+ send_to_motors(targets.by_joint)
25
+
26
+ The modules behind it, roughly in dependency order:
27
+
28
+ * :mod:`~genesis_forge_runtime.constants` -- the manifest's vocabulary
29
+ * :mod:`~genesis_forge_runtime.errors` -- every exception raised here
30
+ * :mod:`~genesis_forge_runtime.serialization` -- JSON to numpy and back
31
+ * :mod:`~genesis_forge_runtime.observation_schema` /
32
+ :mod:`~genesis_forge_runtime.action_schema` -- the two halves of the contract
33
+ * :mod:`~genesis_forge_runtime.manifest` -- the contract as a whole
34
+ * :mod:`~genesis_forge_runtime.bundle` -- reading and writing a bundle directory
35
+ * :mod:`~genesis_forge_runtime.observations` /
36
+ :mod:`~genesis_forge_runtime.processors` / :mod:`~genesis_forge_runtime.actions`
37
+ -- the runtime itself
38
+
39
+ **Trust model:** a bundle is trusted input, equivalent to executable code --
40
+ loading one may import processor classes it names. Only load bundles you produced.
41
+ """
42
+
43
+ from importlib.metadata import version
44
+
45
+ from .action_schema import ActionManagerSpec, ActuatorSpec
46
+ from .actions import ActionProcessor, ProcessedActions
47
+ from .bundle import Bundle, load_bundle, load_manifest, save_bundle
48
+ from .constants import (
49
+ ARCHIVE_SUFFIX,
50
+ EXTRACT_MARKER,
51
+ GOLDEN_FILENAME,
52
+ MANIFEST_FILENAME,
53
+ MIN_SUPPORTED_SCHEMA_VERSION,
54
+ POLICY_DIRNAME,
55
+ SCHEMA_VERSION,
56
+ )
57
+ from .errors import (
58
+ ActionError,
59
+ BundleError,
60
+ MalformedBundleError,
61
+ ObservationError,
62
+ SchemaVersionError,
63
+ )
64
+ from .manifest import Manifest, Provenance
65
+ from .observation_schema import ObservationEntry, ObservationLayout
66
+ from .observations import ObservationAssembler
67
+ from .processors import ActionManagerProcessor, AffineProcessor
68
+
69
+ __version__ = version("genesis-forge-runtime")
70
+
71
+ __all__ = [
72
+ "ARCHIVE_SUFFIX",
73
+ "ActionError",
74
+ "ActionManagerProcessor",
75
+ "ActionManagerSpec",
76
+ "ActionProcessor",
77
+ "ActuatorSpec",
78
+ "AffineProcessor",
79
+ "Bundle",
80
+ "BundleError",
81
+ "EXTRACT_MARKER",
82
+ "GOLDEN_FILENAME",
83
+ "MANIFEST_FILENAME",
84
+ "MIN_SUPPORTED_SCHEMA_VERSION",
85
+ "MalformedBundleError",
86
+ "Manifest",
87
+ "ObservationAssembler",
88
+ "ObservationEntry",
89
+ "ObservationError",
90
+ "ObservationLayout",
91
+ "POLICY_DIRNAME",
92
+ "ProcessedActions",
93
+ "Provenance",
94
+ "SCHEMA_VERSION",
95
+ "SchemaVersionError",
96
+ "__version__",
97
+ "load_bundle",
98
+ "load_manifest",
99
+ "save_bundle",
100
+ ]
@@ -0,0 +1,155 @@
1
+ """How the policy's output maps onto real joints.
2
+
3
+ The half of the manifest that :mod:`genesis_forge_runtime.processors` consumes: which
4
+ slice of the policy vector belongs to each action manager, how to process it, and
5
+ the actuator settings the robot should match.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from typing import Any
12
+
13
+ from .errors import MalformedBundleError
14
+ from .serialization import require
15
+
16
+
17
+ def _read_action_index(value: Any, name: str) -> tuple[int, ...] | None:
18
+ """Read the joint-to-action mapping as integers."""
19
+ if value is None:
20
+ return None
21
+ if not isinstance(value, (list, tuple)):
22
+ raise MalformedBundleError(
23
+ f"Action manager '{name}' has a 'joint_action_index' that is not a list."
24
+ )
25
+ try:
26
+ return tuple(int(index) for index in value)
27
+ except (TypeError, ValueError) as error:
28
+ raise MalformedBundleError(
29
+ f"Action manager '{name}' has a non-integer entry in 'joint_action_index'."
30
+ ) from error
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class ActionManagerSpec:
35
+ """How one action manager's slice of the policy output is processed."""
36
+
37
+ name: str
38
+ deploy_type: str
39
+ joint_names: tuple[str, ...]
40
+ slice_start: int
41
+ slice_end: int
42
+ config: dict[str, Any]
43
+ processor_import_path: str | None = None
44
+ #: One action index per joint, positionally matched to :attr:`joint_names`.
45
+ #: None when every joint has its own action.
46
+ joint_action_index: tuple[int, ...] | None = None
47
+
48
+ @property
49
+ def num_actions(self) -> int:
50
+ """How many policy outputs this manager consumes."""
51
+ return self.slice_end - self.slice_start
52
+
53
+ @property
54
+ def num_joints(self) -> int:
55
+ """How many joint targets it produces.
56
+
57
+ Larger than :attr:`num_actions` when joints share an action.
58
+ """
59
+ return len(self.joint_names)
60
+
61
+ @classmethod
62
+ def from_dict(cls, data: dict[str, Any], *, where: str) -> ActionManagerSpec:
63
+ name = require(data, "name", where=where)
64
+ scope = f"{where}.{name}"
65
+ bounds = require(data, "slice", where=scope)
66
+ if not isinstance(bounds, list) or len(bounds) != 2:
67
+ raise MalformedBundleError(
68
+ f"Action manager '{name}' has a malformed 'slice': expected [start, end], "
69
+ f"got {bounds!r}."
70
+ )
71
+ joint_names = tuple(require(data, "joint_names", where=scope))
72
+ spec = cls(
73
+ name=name,
74
+ deploy_type=require(data, "deploy_type", where=scope),
75
+ joint_names=joint_names,
76
+ slice_start=int(bounds[0]),
77
+ slice_end=int(bounds[1]),
78
+ config=data.get("config", {}),
79
+ processor_import_path=data.get("processor_import_path"),
80
+ joint_action_index=_read_action_index(data.get("joint_action_index"), name),
81
+ )
82
+ grouped = spec.joint_action_index is not None
83
+ if grouped and len(spec.joint_action_index) != len(joint_names):
84
+ raise MalformedBundleError(
85
+ f"Action manager '{name}' maps "
86
+ f"{len(spec.joint_action_index)} joint(s) to actions but names "
87
+ f"{len(joint_names)}."
88
+ )
89
+ if grouped and any(
90
+ index < 0 or index >= spec.num_actions for index in spec.joint_action_index
91
+ ):
92
+ raise MalformedBundleError(
93
+ f"Action manager '{name}' maps a joint to an action outside its "
94
+ f"slice of {spec.num_actions} action(s)."
95
+ )
96
+ if not grouped and spec.num_actions != len(joint_names):
97
+ raise MalformedBundleError(
98
+ f"Action manager '{name}' covers {spec.num_actions} actions but names "
99
+ f"{len(joint_names)} joints, and records no mapping between them; "
100
+ f"the bundle is inconsistent."
101
+ )
102
+ if grouped and len(joint_names) < spec.num_actions:
103
+ raise MalformedBundleError(
104
+ f"Action manager '{name}' groups {len(joint_names)} joints across "
105
+ f"{spec.num_actions} actions, so at least one action drives nothing; "
106
+ f"the bundle is inconsistent."
107
+ )
108
+ return spec
109
+
110
+ def to_dict(self) -> dict[str, Any]:
111
+ data: dict[str, Any] = {
112
+ "name": self.name,
113
+ "deploy_type": self.deploy_type,
114
+ "slice": [self.slice_start, self.slice_end],
115
+ "joint_names": list(self.joint_names),
116
+ "config": self.config,
117
+ }
118
+ if self.processor_import_path is not None:
119
+ data["processor_import_path"] = self.processor_import_path
120
+ if self.joint_action_index is not None:
121
+ data["joint_action_index"] = [
122
+ int(index) for index in self.joint_action_index
123
+ ]
124
+ return data
125
+
126
+
127
+ @dataclass(frozen=True)
128
+ class ActuatorSpec:
129
+ """Nominal actuator gains and defaults, recorded so the robot can match training."""
130
+
131
+ name: str
132
+ joint_names: tuple[str, ...]
133
+ values: dict[str, list[float]]
134
+ randomized: tuple[str, ...] = ()
135
+
136
+ @classmethod
137
+ def from_dict(cls, data: dict[str, Any], *, where: str) -> ActuatorSpec:
138
+ name = require(data, "name", where=where)
139
+ scope = f"{where}.{name}"
140
+ return cls(
141
+ name=name,
142
+ joint_names=tuple(require(data, "joint_names", where=scope)),
143
+ values=data.get("values", {}),
144
+ randomized=tuple(data.get("randomized", ())),
145
+ )
146
+
147
+ def to_dict(self) -> dict[str, Any]:
148
+ data: dict[str, Any] = {
149
+ "name": self.name,
150
+ "joint_names": list(self.joint_names),
151
+ "values": self.values,
152
+ }
153
+ if self.randomized:
154
+ data["randomized"] = list(self.randomized)
155
+ return data
@@ -0,0 +1,246 @@
1
+ """Turning a full policy output vector into named joint targets.
2
+
3
+ Slices the vector across every action manager, hands each slice to its processor,
4
+ and remembers the result so the caller can feed it back into the next observation.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ import numpy as np
12
+
13
+ from .action_schema import ActionManagerSpec
14
+ from .errors import ActionError
15
+ from .processors import ActionManagerProcessor, resolve_processor_class
16
+
17
+
18
+ class ProcessedActions:
19
+ """The result of one process, viewable per joint, per manager, or as a vector."""
20
+
21
+ __slots__ = ("_joint_names", "by_manager", "targets")
22
+
23
+ def __init__(
24
+ self,
25
+ targets: np.ndarray,
26
+ by_manager: dict[str, np.ndarray],
27
+ joint_names: tuple[str, ...],
28
+ ) -> None:
29
+ self.targets = targets
30
+ self.by_manager = by_manager
31
+ self._joint_names = joint_names
32
+
33
+ @property
34
+ def joint_names(self) -> tuple[str, ...]:
35
+ return self._joint_names
36
+
37
+ @property
38
+ def by_joint(self) -> dict[str, float]:
39
+ """Joint name to target value -- what you hand to the motor controllers."""
40
+ return {
41
+ name: float(value)
42
+ for name, value in zip(self._joint_names, self.targets, strict=True)
43
+ }
44
+
45
+ def __len__(self) -> int:
46
+ return int(self.targets.size)
47
+
48
+ def __repr__(self) -> str: # pragma: no cover - debugging aid
49
+ return f"ProcessedActions({self.by_joint})"
50
+
51
+
52
+ class ActionProcessor:
53
+ """Processes a full policy output vector across every action manager.
54
+
55
+ Args:
56
+ specs: The action manager specs from a loaded bundle's manifest.
57
+ check_finite: Raise when the policy emits NaN or infinity. On by default:
58
+ motors must never be handed those values.
59
+ dtype: Output dtype, float32 by default.
60
+ """
61
+
62
+ def __init__(
63
+ self,
64
+ specs: tuple[ActionManagerSpec, ...],
65
+ *,
66
+ check_finite: bool = True,
67
+ dtype: Any = np.float32,
68
+ ) -> None:
69
+ self._specs = tuple(sorted(specs, key=lambda spec: spec.slice_start))
70
+ self._check_finite = check_finite
71
+ self._dtype = np.dtype(dtype)
72
+
73
+ self._processors: list[ActionManagerProcessor] = [
74
+ resolve_processor_class(spec)(spec, dtype=self._dtype)
75
+ for spec in self._specs
76
+ ]
77
+ self._joint_names = tuple(
78
+ name for spec in self._specs for name in spec.joint_names
79
+ )
80
+ self.reset()
81
+
82
+ """
83
+ Properties
84
+ """
85
+
86
+ @property
87
+ def num_actions(self) -> int:
88
+ """Width of the policy output vector this processor consumes."""
89
+ return self._specs[-1].slice_end if self._specs else 0
90
+
91
+ @property
92
+ def num_joints(self) -> int:
93
+ """How many joint targets one call produces.
94
+
95
+ Larger than :attr:`num_actions` when joints share an action.
96
+ """
97
+ return len(self._joint_names)
98
+
99
+ @property
100
+ def joint_names(self) -> tuple[str, ...]:
101
+ """Every joint this processor produces a target for, in output order."""
102
+ return self._joint_names
103
+
104
+ @property
105
+ def clip_range_by_joint(self) -> dict[str, tuple[float, float]]:
106
+ """Per joint, the clip its processor applies to the target it produces.
107
+
108
+ The range a joint's targets can span, which is what a robot needs to map
109
+ them onto whatever its motors take. Joints whose targets are unbounded
110
+ are absent; see :meth:`ActionManagerProcessor.clip_range_by_joint`.
111
+ """
112
+ clips: dict[str, tuple[float, float]] = {}
113
+ for processor in self._processors:
114
+ clips.update(processor.clip_range_by_joint)
115
+ return clips
116
+
117
+ @property
118
+ def processors(self) -> tuple[ActionManagerProcessor, ...]:
119
+ return tuple(self._processors)
120
+
121
+ @property
122
+ def last_raw_actions(self) -> np.ndarray:
123
+ """The policy's raw output from the previous process.
124
+
125
+ Zeros before the first process, matching how training starts an episode.
126
+ Pass this to :meth:`ObservationAssembler.assemble` for an observation entry
127
+ that echoes raw policy output.
128
+ """
129
+ return self._last_raw_actions.copy()
130
+
131
+ @property
132
+ def last_target_actions(self) -> np.ndarray:
133
+ """The processed joint targets from the previous process.
134
+
135
+ Zeros before the first process.
136
+
137
+ Note this is *not* what the built-in ``current_actions`` observation feeds
138
+ back -- that one is raw policy output at both of its call shapes, so use
139
+ :attr:`last_raw_actions` or :attr:`last_raw_actions_by_manager`. This
140
+ property is for an observation that genuinely echoes processed targets,
141
+ which you would have written yourself.
142
+ """
143
+ return self._last_target_actions.copy()
144
+
145
+ @property
146
+ def last_target_actions_by_manager(self) -> dict[str, np.ndarray]:
147
+ """Per-manager view of :attr:`last_target_actions`, for multi-manager robots.
148
+
149
+ As with :attr:`last_target_actions`, this is processed output -- not what
150
+ ``current_actions`` feeds back.
151
+ """
152
+ return {name: values.copy() for name, values in self._last_by_manager.items()}
153
+
154
+ @property
155
+ def last_raw_actions_by_manager(self) -> dict[str, np.ndarray]:
156
+ """What the policy last emitted for each manager, before it was processed.
157
+
158
+ This is what ``current_actions(action_manager=...)`` feeds back during
159
+ training, and it is the whole policy vector only when a single manager is
160
+ registered.
161
+ """
162
+ return {name: value.copy() for name, value in self._last_raw_by_manager.items()}
163
+
164
+ """
165
+ Public methods
166
+ """
167
+
168
+ def reset(self) -> None:
169
+ """Clear processor state and remembered outputs."""
170
+ for processor in self._processors:
171
+ processor.reset()
172
+ self._last_raw_actions = np.zeros(self.num_actions, dtype=self._dtype)
173
+ self._last_target_actions = np.zeros(self.num_joints, dtype=self._dtype)
174
+ self._last_by_manager = {
175
+ spec.name: np.zeros(spec.num_joints, dtype=self._dtype)
176
+ for spec in self._specs
177
+ }
178
+ self._last_raw_by_manager = {
179
+ spec.name: np.zeros(spec.num_actions, dtype=self._dtype)
180
+ for spec in self._specs
181
+ }
182
+
183
+ def process(self, actions: Any) -> ProcessedActions:
184
+ """Process one policy output vector into joint targets.
185
+
186
+ Args:
187
+ actions: The policy's raw output, length :attr:`num_actions`. A
188
+ leading batch dimension of 1 is accepted and squeezed.
189
+
190
+ Returns:
191
+ A :class:`ProcessedActions` view of the result.
192
+
193
+ Raises:
194
+ ActionError: Wrong length, or non-finite values when
195
+ ``check_finite`` is on.
196
+ """
197
+ values = np.asarray(actions, dtype=self._dtype).ravel()
198
+ if values.size != self.num_actions:
199
+ raise ActionError(
200
+ f"Expected {self.num_actions} action(s) from the policy, got "
201
+ f"{values.size}."
202
+ )
203
+ if self._check_finite and not np.all(np.isfinite(values)):
204
+ bad = np.flatnonzero(~np.isfinite(values))
205
+ raise ActionError(
206
+ f"Policy produced non-finite action(s) at index/indices "
207
+ f"{bad.tolist()}: {values[bad].tolist()}. Refusing to send these to "
208
+ f"the actuators. Pass check_finite=False to override for debugging."
209
+ )
210
+
211
+ by_manager: dict[str, np.ndarray] = {}
212
+ raw_by_manager: dict[str, np.ndarray] = {}
213
+ pieces: list[np.ndarray] = []
214
+ for spec, processor in zip(self._specs, self._processors, strict=True):
215
+ chunk = values[spec.slice_start : spec.slice_end]
216
+ raw_by_manager[spec.name] = chunk.copy()
217
+ processed = processor.process(chunk)
218
+ by_manager[spec.name] = processed
219
+ pieces.append(processed)
220
+
221
+ # copy() so `targets` does not alias `by_manager[name]`; concatenate
222
+ # already returns a fresh array.
223
+ targets = (
224
+ pieces[0].copy() if len(pieces) == 1 else np.concatenate(pieces)
225
+ ).astype(self._dtype, copy=False)
226
+
227
+ self._last_raw_actions = values.copy()
228
+ self._last_target_actions = targets.copy()
229
+ self._last_raw_by_manager = raw_by_manager
230
+ self._last_by_manager = {
231
+ name: chunk.copy() for name, chunk in by_manager.items()
232
+ }
233
+
234
+ return ProcessedActions(targets, by_manager, self._joint_names)
235
+
236
+ def describe_outputs(self) -> str:
237
+ """Human-readable listing of the joint targets this processor produces."""
238
+ lines = [f"Joint targets produced ({self.num_joints}):"]
239
+ for spec in self._specs:
240
+ joints = ", ".join(spec.joint_names)
241
+ lines.append(f" - [{spec.deploy_type}] {joints}")
242
+ return "\n".join(lines)
243
+
244
+ """
245
+ Internal methods
246
+ """
@@ -0,0 +1,122 @@
1
+ """Bundles stored as a single file.
2
+
3
+ A bundle is a directory. An *archive* is that same directory written as one zip,
4
+ which is what you copy to a robot -- one artifact instead of a folder, and a
5
+ transfer that either arrives whole or does not arrive.
6
+
7
+ Reading one extracts it beside the archive, into a dot-directory, and reuses that
8
+ on later loads. The extracted copy records a fingerprint of the archive it came
9
+ from, so replacing the archive re-extracts rather than leaving a robot quietly
10
+ running the bundle it had before.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import shutil
17
+ import zipfile
18
+ from pathlib import Path
19
+
20
+ from .constants import EXTRACT_MARKER, MANIFEST_FILENAME
21
+ from .errors import MalformedBundleError
22
+
23
+ #: Every zip begins with this. Read by content rather than by extension, so a
24
+ #: bundle someone renamed still loads.
25
+ _ZIP_MAGIC = b"PK\x03\x04"
26
+
27
+
28
+ def is_archive(path: Path) -> bool:
29
+ """True when this file is a zip, whatever it happens to be called."""
30
+ if not path.is_file():
31
+ return False
32
+ with path.open("rb") as handle:
33
+ return handle.read(4) == _ZIP_MAGIC
34
+
35
+
36
+ def fingerprint(archive: Path) -> str:
37
+ """Identify an archive's contents without reading them.
38
+
39
+ A zip's central directory already holds a CRC and a size for every entry, so
40
+ this costs the same whether the bundle is a kilobyte or a gigabyte -- no
41
+ payload is read. It is enough to notice that an archive has been replaced,
42
+ which is what it is for; it is not a tamper check, and a bundle is trusted
43
+ input either way.
44
+ """
45
+ with zipfile.ZipFile(archive) as zipped:
46
+ entries = sorted(
47
+ (item.filename, item.CRC, item.file_size) for item in zipped.infolist()
48
+ )
49
+ digest = hashlib.sha256()
50
+ for name, crc, size in entries:
51
+ digest.update(f"{name}\0{crc}\0{size}\0".encode())
52
+ return digest.hexdigest()
53
+
54
+
55
+ def extract_dir_for(archive: Path) -> Path:
56
+ """Where an archive's contents live once unpacked: ``.<name>`` beside it."""
57
+ return archive.parent / f".{archive.stem}"
58
+
59
+
60
+ def ensure_extracted(archive: Path) -> Path:
61
+ """Unpack an archive beside itself, reusing a previous extraction when valid.
62
+
63
+ Returns:
64
+ The directory holding the bundle's files.
65
+
66
+ Raises:
67
+ MalformedBundleError: The archive holds no manifest, the directory it
68
+ would extract into belongs to something else, or there is nowhere
69
+ writable to put it.
70
+ """
71
+ with zipfile.ZipFile(archive) as zipped:
72
+ if MANIFEST_FILENAME not in zipped.namelist():
73
+ raise MalformedBundleError(
74
+ f"'{archive.name}' is a zip archive but holds no "
75
+ f"{MANIFEST_FILENAME}, so it is not a deployment bundle."
76
+ )
77
+
78
+ destination = extract_dir_for(archive)
79
+ marker = destination / EXTRACT_MARKER
80
+ expected = fingerprint(archive)
81
+
82
+ if destination.exists():
83
+ if not destination.is_dir():
84
+ raise MalformedBundleError(
85
+ f"'{destination}' already exists and is not a directory, so "
86
+ f"'{archive.name}' cannot be unpacked beside itself."
87
+ )
88
+ if not marker.is_file():
89
+ raise MalformedBundleError(
90
+ f"'{destination}' already exists but was not unpacked from an "
91
+ f"archive, so it will not be overwritten -- it may be someone's "
92
+ f"work. If you know it came from '{archive.name}', deleting it is "
93
+ f"safe and the next load will unpack it again."
94
+ )
95
+ if marker.read_text().strip() == expected:
96
+ return destination
97
+
98
+ # Unpack elsewhere and swap it in, so an interrupted unpack leaves either the
99
+ # previous extraction or nothing -- never a half-filled directory, which the
100
+ # next load would mistake for someone else's and refuse.
101
+ staging = destination.with_name(f"{destination.name}.unpacking")
102
+ try:
103
+ if staging.exists():
104
+ shutil.rmtree(staging)
105
+ staging.mkdir(parents=True)
106
+ with zipfile.ZipFile(archive) as zipped:
107
+ # extractall keeps every member inside the target directory, so a
108
+ # crafted path cannot escape it.
109
+ zipped.extractall(staging)
110
+ (staging / EXTRACT_MARKER).write_text(expected)
111
+
112
+ if destination.exists():
113
+ shutil.rmtree(destination)
114
+ staging.rename(destination)
115
+ except OSError as error:
116
+ shutil.rmtree(staging, ignore_errors=True)
117
+ raise MalformedBundleError(
118
+ f"Could not unpack '{archive.name}' into '{destination}' ({error}). "
119
+ f"Unpack it yourself somewhere writable and load that directory."
120
+ ) from error
121
+
122
+ return destination