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+
2228Afterwards: `pixi run rs <distro> render-pinning`, `pixi run sort`, `pixi run rs check`.
2329"""
2430
3238import subprocess
3339import sys
3440import tarfile
41+ import tempfile
42+ import tomllib
3543from pathlib import Path
3644
3745import yaml
4048import robostack as rs
4149
4250DERIVED_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
4558def _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
225369def 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