Skip to content

Commit 59b1a4b

Browse files
committed
Imports: replace distro inputs consistently and use ros2 names
1 parent dddb4d3 commit 59b1a4b

4 files changed

Lines changed: 589 additions & 36 deletions

File tree

‎README.md‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ combines the two, and the files next to each:
7070

7171
- **`vinca.yaml`:**
7272
- The package selection (`packages_select_by_deps`) lives in `shared/` for every distribution; vinca skips packages a distribution's rosdistro doesn't have.
73+
- `package_name_mode: new`: recipes and dependencies use `ros2-<pkg>` names only; no legacy alias packages are generated.
7374
- Exceptions, in `shared/` or a distribution: `packages_exclude` (not built, dropped from other packages' dependencies) and `packages_skip` (not built, dependents keep the dependency), each as plain names or under `- if: <condition>`. ROS packages mapped to conda-forge in `robostack.yaml` are skipped automatically.
7475
- `mutex_package`: name, `upper_bound` and the common `run_constraints` are shared. Each distribution sets its own `version`, and its `run_constraints` replace the shared constraint on the same package (e.g. jazzy's `libprotobuf 7.35.*`) or add new ones.
7576
- Other keys come from the distribution if it sets them, else from `shared/`. A distribution that differs from a shared setting just sets it.
@@ -152,5 +153,13 @@ pixi run rs <distro> render-pinning && pixi run sort && pixi run rs check
152153
```
153154

154155
The import leaves out what `shared/` already provides (shared package selections and entries, derived keys) and keeps comments. It writes `distro.yaml` from the old repository's `pixi.toml` and `vinca_pinning.yaml`. Run it again to pick up changes made in the old repository until that is archived.
156+
Imports validate and stage inputs before replacing the destination, and roll back
157+
if replacement fails. The importer owns `vinca.yaml`, `distro.yaml`, `ci.yaml`,
158+
`pkg_additional_info.yaml`, both rosdistro snapshot/additional-recipe files, and
159+
the whole `patch/` directory. Optional files removed upstream are removed on
160+
re-import. Unrelated local entries, including `work/`, `tests/`, and rendered pins,
161+
are preserved. `--distro` changes the destination identity; legacy package names
162+
are still converted using the source distribution's name.
163+
Modern package lists retain scalar and nested `if`/`then`/`else` branches, including
164+
empty branches; importing does not resolve away platform-specific selections.
155165

156-
**Check:** re-importing lyrical from its repository reproduces `distros/lyrical/` exactly. The full linux-64 recipe sets of rolling, humble and lyrical generated here are byte-identical to those of their own repositories.

‎shared/vinca.yaml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# vinca settings shared by every distribution; distros/<d>/vinca.yaml adds its own
22
# (lists are combined, other keys from the distribution win). See tools/robostack.py.
3-
package_name_mode: both
3+
package_name_mode: new
44
# relative to this file; each distribution adds its own (see distros/<d>/vinca.yaml)
55
conda_index:
66
- robostack.yaml

‎tools/import_distro.py‎

Lines changed: 187 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@
1919
differ from shared/pinning/overrides.yaml (vinca_pinning.yaml)
2020
- ci.yaml reset to the defaults
2121
22+
Reimports replace these importer-owned files and the entire patch/ tree, including
23+
local edits; absent optional files are removed. Other distro-local entries (such
24+
as work/, tests/, and rendered pinning) are retained without copying them. Inputs
25+
are staged and validated first; replacement uses same-filesystem renames with
26+
rollback on failure, rather than replacing a nonempty directory on Windows.
27+
2228
Afterwards: `pixi run rs <distro> render-pinning`, `pixi run sort`, `pixi run rs check`.
2329
"""
2430

@@ -32,6 +38,8 @@
3238
import subprocess
3339
import sys
3440
import tarfile
41+
import tempfile
42+
import tomllib
3543
from pathlib import Path
3644

3745
import yaml
@@ -40,6 +48,11 @@
4048
import robostack as rs
4149

4250
DERIVED_KEYS = ("conda_index", "patch_dir", "rosdistro_snapshot", "rosdistro_additional_recipes", "skip_existing")
51+
OWNED_FILES = {
52+
"vinca.yaml", "pkg_additional_info.yaml", "patch",
53+
"rosdistro_snapshot.yaml", "rosdistro_additional_recipes.yaml",
54+
"distro.yaml", "ci.yaml",
55+
}
4356

4457

4558
def _ry() -> YAML:
@@ -112,44 +125,108 @@ def __init__(self, repo: Path, ref: str | None):
112125
def text(self, path: str) -> str | None:
113126
if self.ref is None:
114127
p = self.repo / path
115-
return p.read_text() if p.is_file() else None
116-
out = subprocess.run(["git", "-C", str(self.repo), "show", f"{self.ref}:{path}"], capture_output=True, text=True)
117-
return out.stdout if out.returncode == 0 else None
128+
return p.read_text() if p.exists() else None
129+
# A missing optional file is different from a failed Git operation.
130+
listing = subprocess.run(
131+
["git", "-C", str(self.repo), "ls-tree", self.ref, "--", path],
132+
capture_output=True, text=True, check=True,
133+
)
134+
if not listing.stdout:
135+
return None
136+
return subprocess.run(
137+
["git", "-C", str(self.repo), "show", f"{self.ref}:{path}"],
138+
capture_output=True, text=True, check=True,
139+
).stdout
118140

119141
def copy_dir(self, path: str, dest: Path) -> None:
120142
if self.ref is None:
121143
shutil.copytree(self.repo / path, dest / path, dirs_exist_ok=True)
122144
return
123145
data = subprocess.run(["git", "-C", str(self.repo), "archive", self.ref, path], capture_output=True, check=True).stdout
124-
tarfile.open(fileobj=io.BytesIO(data)).extractall(dest, filter="data")
125-
126-
127-
def import_distro(src: Source, distro: str | None) -> str:
146+
with tarfile.open(fileobj=io.BytesIO(data)) as archive:
147+
archive.extractall(dest, filter="data")
148+
149+
150+
def _mapping(text: str | None, name: str, *, required: bool = False):
151+
if text is None:
152+
if required:
153+
raise ValueError(f"Missing required input: {name}")
154+
return {}
155+
data = _ry().load(text)
156+
if data is None and not required:
157+
return {}
158+
if not isinstance(data, dict):
159+
raise ValueError(f"{name} must contain a YAML mapping")
160+
return data
161+
162+
163+
def _distro_name(value: str | None) -> str:
164+
if not isinstance(value, str) or not re.fullmatch(r"[a-z][a-z0-9_-]*", value):
165+
raise ValueError(f"Invalid ros_distro name: {value!r}")
166+
return value
167+
168+
169+
def _validate_package_entries(value, key: str) -> None:
170+
"""Validate modern package lists without evaluating away selector branches."""
171+
if value is None or isinstance(value, str):
172+
return
173+
if isinstance(value, list):
174+
for item in value:
175+
_validate_package_entries(item, key)
176+
return
177+
if isinstance(value, dict) and "if" in value and "then" in value:
178+
_validate_package_entries(value["then"], key)
179+
_validate_package_entries(value.get("else"), key)
180+
return
181+
raise ValueError(f"vinca.yaml {key} must contain package names or if/then selectors")
182+
183+
184+
def _build_import(src: Source, distro: str, source_distro: str, vinca, dest: Path) -> None:
128185
ry = _ry()
129-
vinca = ry.load(src.text("vinca.yaml"))
130-
distro = distro or vinca["ros_distro"]
131-
dest = rs.DISTROS / distro
132-
dest.mkdir(parents=True, exist_ok=True)
133-
186+
for key in (*rs.LIST_KEYS, "packages_skip_by_deps", "packages_remove_from_deps"):
187+
items = vinca.get(key)
188+
if items is None:
189+
continue
190+
if not isinstance(items, list):
191+
raise ValueError(f"vinca.yaml {key} must be a list")
192+
if key in rs.LIST_KEYS:
193+
_validate_package_entries(items, key)
194+
continue
195+
# Legacy exclusion conversion below expects flat if/then lists.
196+
for item in items:
197+
if isinstance(item, str):
198+
continue
199+
if (not isinstance(item, dict) or "if" not in item
200+
or not isinstance(item.get("then"), list)
201+
or any(not isinstance(name, str) for name in item["then"])):
202+
raise ValueError(f"vinca.yaml {key} must contain package names or if/then lists")
203+
snapshots = {
204+
name: src.text(name)
205+
for name in ("rosdistro_snapshot.yaml", "rosdistro_additional_recipes.yaml")
206+
}
207+
for name, text in snapshots.items():
208+
data = _mapping(text, name, required=name == "rosdistro_snapshot.yaml")
209+
if any(not isinstance(key, str) or not isinstance(value, dict) for key, value in data.items()):
210+
raise ValueError(f"{name} must map package names to metadata mappings")
134211
# vinca.yaml: drop what shared/ and the tooling provide
135-
shared = yaml.safe_load((rs.SHARED / "vinca.yaml").read_text()) or {}
212+
shared = _mapping((rs.SHARED / "vinca.yaml").read_text(), "shared/vinca.yaml", required=True)
136213
for key in DERIVED_KEYS:
137214
vinca.pop(key, None)
138215
# on top of shared/vinca.yaml, with its own patches and snapshot
139216
for i, (key, value) in enumerate([
140217
("extends", "../../shared/vinca.yaml"),
141-
("ros_distro", vinca.get("ros_distro", distro)),
218+
("ros_distro", distro),
142219
("patch_dir", "patch"),
143220
("rosdistro_snapshot", "rosdistro_snapshot.yaml"),
144-
("rosdistro_additional_recipes", "rosdistro_additional_recipes.yaml"),
221+
("rosdistro_additional_recipes",
222+
"rosdistro_additional_recipes.yaml" if snapshots["rosdistro_additional_recipes.yaml"] is not None else None),
145223
]):
146224
vinca.pop(key, None)
147225
vinca.insert(i, key, value)
148-
mode = vinca.get("package_name_mode", "legacy")
149-
if mode == shared.get("package_name_mode"):
150-
vinca.pop("package_name_mode", None)
151-
elif "package_name_mode" not in vinca:
152-
vinca.insert(5, "package_name_mode", mode)
226+
# This repository has cut over to ros2-* names; never import legacy aliases.
227+
vinca.pop("package_name_mode", None)
228+
if shared.get("package_name_mode") != "new":
229+
vinca.insert(5, "package_name_mode", "new")
153230
_convert_exclusions(vinca)
154231
for key in rs.LIST_KEYS:
155232
common = {_key(i) for i in shared.get(key) or []}
@@ -163,12 +240,16 @@ def import_distro(src: Source, distro: str | None) -> str:
163240
ry.dump(vinca, fh)
164241

165242
# as they are (patch/dependencies.yaml is filtered below)
166-
shutil.rmtree(dest / "patch", ignore_errors=True)
167243
src.copy_dir("patch", dest)
168-
for patch in (dest / "patch").glob(f"ros-{distro}-*.patch"):
169-
patch.rename(patch.with_name("ros2-" + patch.name.removeprefix(f"ros-{distro}-")))
170-
for name in ("rosdistro_snapshot.yaml", "rosdistro_additional_recipes.yaml"):
171-
if (text := src.text(name)) is not None:
244+
if not (dest / "patch").is_dir():
245+
raise ValueError("Missing required input: patch/")
246+
for patch in (dest / "patch").glob(f"ros-{source_distro}-*.patch"):
247+
renamed = patch.with_name("ros2-" + patch.name.removeprefix(f"ros-{source_distro}-"))
248+
if renamed.exists():
249+
raise ValueError(f"Patch rename would overwrite {renamed.name}")
250+
patch.rename(renamed)
251+
for name, text in snapshots.items():
252+
if text is not None:
172253
(dest / name).write_text(text)
173254

174255
# per-package files: drop entries identical to shared/ (keeping build numbers)
@@ -179,9 +260,11 @@ def import_distro(src: Source, distro: str | None) -> str:
179260
text = src.text(name)
180261
if text is None:
181262
continue
182-
text = re.sub(rf"\bros-{distro}-(?=[a-z0-9])", "ros2-", text)
183-
data = ry.load(text) or {}
184-
common = yaml.safe_load((rs.SHARED / shared_file).read_text()) or {}
263+
text = re.sub(rf"\bros-{re.escape(source_distro)}-(?=[a-z0-9])", "ros2-", text)
264+
data = _mapping(text, name)
265+
if any(not isinstance(key, str) or not isinstance(value, dict) for key, value in data.items()):
266+
raise ValueError(f"{name} must map package names to settings mappings")
267+
common = _mapping((rs.SHARED / shared_file).read_text(), f"shared/{shared_file}")
185268
for key in list(data):
186269
if key not in common:
187270
continue
@@ -199,12 +282,17 @@ def import_distro(src: Source, distro: str | None) -> str:
199282

200283
# distro.yaml
201284
pixi = src.text("pixi.toml") or ""
285+
tomllib.loads(pixi)
202286
settings: dict = {}
203-
if m := re.search(r'upload\s*=\s*"rattler-build upload prefix -c (\S+)', pixi):
287+
if m := re.search(r'upload\s*=\s*"rattler-build upload prefix -c ([^\s"]+)', pixi):
204288
settings.update(channel_name=m.group(1), upload_target="prefix")
205-
elif m := re.search(r'upload\s*=\s*"rattler-build upload anaconda -o (\S+)', pixi):
289+
elif m := re.search(r'upload\s*=\s*"rattler-build upload anaconda -o ([^\s"]+)', pixi):
206290
settings.update(channel_name=m.group(1), upload_target="anaconda")
207-
pinning = yaml.safe_load(src.text("vinca_pinning.yaml") or "") or {}
291+
pinning = _mapping(src.text("vinca_pinning.yaml"), "vinca_pinning.yaml")
292+
if pinning.get("migrations") is not None and not isinstance(pinning["migrations"], list):
293+
raise ValueError("vinca_pinning.yaml migrations must be a list")
294+
if pinning.get("pinning_overrides") is not None and not isinstance(pinning["pinning_overrides"], dict):
295+
raise ValueError("vinca_pinning.yaml pinning_overrides must be a mapping")
208296
if pinning.get("conda_forge_pinning_version"):
209297
settings["conda_forge_pinning_version"] = str(pinning["conda_forge_pinning_version"])
210298
settings["conda_forge_migrations"] = list(pinning.get("migrations") or [])
@@ -216,14 +304,79 @@ def import_distro(src: Source, distro: str | None) -> str:
216304
(dest / "distro.yaml").write_text(
217305
f"# Settings of ros-{distro} that differ from the other distributions\n"
218306
"# (everything else is shared, see the README).\n"
219-
+ yaml.safe_dump(settings, sort_keys=False, default_flow_style=None, width=100)
307+
+ yaml.safe_dump(json.loads(json.dumps(settings)), sort_keys=False, default_flow_style=None, width=100)
220308
)
221-
(dest / "ci.yaml").write_text((rs.ROOT / "tools" / "ci_default.yaml").read_text())
309+
ci = (rs.ROOT / "tools" / "ci_default.yaml").read_text()
310+
_mapping(ci, "tools/ci_default.yaml", required=True)
311+
(dest / "ci.yaml").write_text(ci)
312+
313+
314+
def _publish(staged: Path, dest: Path, backup: Path) -> None:
315+
if not dest.exists():
316+
staged.rename(dest)
317+
return
318+
preserved = []
319+
dest.rename(backup)
320+
try:
321+
# Moving these entries preserves large work trees, symlinks and metadata.
322+
for entry in backup.iterdir():
323+
if entry.name not in OWNED_FILES:
324+
entry.rename(staged / entry.name)
325+
preserved.append(entry.name)
326+
staged.rename(dest)
327+
except BaseException:
328+
try:
329+
for name in reversed(preserved):
330+
(staged / name).rename(backup / name)
331+
backup.rename(dest)
332+
except BaseException as rollback_error:
333+
raise RuntimeError(
334+
f"Import rollback failed; retained all recovery data in {staged.parent}"
335+
) from rollback_error
336+
raise
337+
338+
339+
def import_distro(src: Source, distro: str | None) -> str:
340+
vinca = _mapping(src.text("vinca.yaml"), "vinca.yaml", required=True)
341+
source_distro = _distro_name(vinca.get("ros_distro"))
342+
distro = _distro_name(distro if distro is not None else source_distro)
343+
dest = rs.DISTROS / distro
344+
if dest.is_symlink() or (dest.exists() and not dest.is_dir()):
345+
raise ValueError(f"Import destination must be a real directory: {dest}")
346+
for name in OWNED_FILES - {"patch"}:
347+
if (dest / name).is_dir():
348+
raise ValueError(f"Refusing to replace a directory with an imported file: {dest / name}")
349+
350+
rs.DISTROS.mkdir(parents=True, exist_ok=True)
351+
transaction = Path(tempfile.mkdtemp(prefix=f".import-{distro}-", dir=rs.DISTROS))
352+
staged, backup = transaction / "new", transaction / "previous"
353+
staged.mkdir()
354+
published = False
355+
try:
356+
_build_import(src, distro, source_distro, vinca, staged)
357+
_publish(staged, dest, backup)
358+
published = True
359+
finally:
360+
# Failed rollback must never cause cleanup to delete preserved user data.
361+
if published or not backup.exists():
362+
try:
363+
shutil.rmtree(transaction)
364+
except OSError as error:
365+
print(f"Warning: could not remove import staging directory {transaction}: {error}", file=sys.stderr)
222366
return distro
223367

224368

225369
def main() -> int:
226-
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
370+
parser = argparse.ArgumentParser(
371+
description=__doc__.splitlines()[0],
372+
epilog=(
373+
"Reimports overwrite local edits to vinca.yaml, distro.yaml, ci.yaml, "
374+
"snapshot/additional-recipes YAML, pkg_additional_info.yaml and all of patch/. "
375+
"Missing optional inputs remove their old copies. Other distro-local files, "
376+
"including work/, tests/ and rendered pinning, are preserved without copying. "
377+
"Inputs are validated before replacement; failed replacement is rolled back."
378+
),
379+
)
227380
parser.add_argument("repo", type=Path, help="checkout of RoboStack/ros-<distro>")
228381
parser.add_argument("--ref", help="git ref to import (default: the working tree)")
229382
parser.add_argument("--distro", help="default: ros_distro of its vinca.yaml")

0 commit comments

Comments
 (0)