11import os
2- import pydantic
3- import yaml
2+ import re
43from pathlib import Path
54from typing import Any
65
6+ import pydantic
7+ import yaml
8+
79from pixi_build_ros .distro import Distro
810
911
@@ -19,6 +21,34 @@ def _parse_str_as_abs_path(value: str | Path, manifest_root: Path) -> Path:
1921 return value
2022
2123
24+ def _extract_distro_from_channels_list (channels : list [str ] | None ) -> str | None :
25+ """Extract ROS distro from a list of channel URLs/names.
26+
27+ Looks for channels matching the pattern 'robostack-<distro>' and returns
28+ the first distro found.
29+
30+ Args:
31+ channels: List of channel URLs or names
32+
33+ Returns:
34+ The distro name if found, None otherwise
35+ """
36+ if not channels :
37+ return None
38+
39+ # Pattern to match robostack-<distro> in channel URLs or names
40+ robostack_pattern = re .compile (r".?robostack-(?!staging\b)(\w+)" )
41+
42+ for channel in channels :
43+ # Extract the last path component or the whole channel name if it's not a URL
44+ # This handles both "robostack-humble" and "https://prefix.dev/robostack-humble"
45+ channel_name = channel .rstrip ("/" ).split ("/" )[- 1 ]
46+ match = robostack_pattern .search (channel_name )
47+ if match :
48+ return match .group (1 )
49+ return None
50+
51+
2252PackageMapEntry = dict [str , list [str ] | dict [str , list [str ]]]
2353
2454
@@ -64,8 +94,8 @@ class ROSBackendConfig(pydantic.BaseModel, extra="forbid", arbitrary_types_allow
6494 """ROS backend configuration."""
6595
6696 # ROS distribution to use, e.g., "foxy", "galactic", "humble"
67- # TODO: This should be figured out in some other way, not from the config.
68- distro : Distro
97+ # Can be auto-detected from robostack- channel if not explicitly specified
98+ distro_ : Distro | None = pydantic . Field ( default = None , alias = "distro" )
6999
70100 noarch : bool | None = None
71101 # Environment variables to set during the build
@@ -94,13 +124,62 @@ def get_package_mapping_file_paths(self) -> list[Path]:
94124 file_paths .append (source_file )
95125 return file_paths
96126
97- @pydantic .field_validator ("distro " , mode = "before" )
127+ @pydantic .field_validator ("distro_ " , mode = "before" )
98128 @classmethod
99- def _parse_distro (cls , value : str | Distro ) -> Distro :
129+ def _parse_distro (cls , value : Any ) -> Distro | None :
100130 """Parse a distro string."""
101131 if isinstance (value , str ):
102132 return Distro (value )
103- return value
133+ if isinstance (value , Distro ):
134+ return value
135+ return None
136+
137+ def resolve_distro (
138+ self ,
139+ channels : list [str ] | None = None ,
140+ ) -> "ROSBackendConfig" :
141+ """Resolve the distro field, auto-detecting from channels if not explicitly set.
142+
143+ This should be called after config validation to fill in the distro if needed.
144+
145+ Args:
146+ config: The config instance (possibly with distro=None)
147+ channels: List of channel URLs from the build system
148+
149+ Returns:
150+ Config with distro resolved
151+
152+ Raises:
153+ ValueError: If distro cannot be determined
154+ """
155+ # If distro is already set, nothing to do
156+ if self .distro_ :
157+ return self
158+
159+ # Try to auto-detect from channels
160+ detected_distro_name = None
161+ if channels :
162+ detected_distro_name = _extract_distro_from_channels_list (channels )
163+
164+ if detected_distro_name :
165+ self .distro_ = Distro (detected_distro_name )
166+ return self
167+
168+ # If we couldn't detect a distro, raise an error
169+ raise ValueError (
170+ "ROS distro must be either explicitly configured or auto-detected from robostack channels."
171+ f"A 'robostack-<distro>' channel (e.g. 'robostack-kilted') was not found in the provided channels: { channels } ."
172+ )
173+
174+ @pydantic .model_validator (mode = "after" )
175+ def _ensure_distro (
176+ self ,
177+ info : pydantic .ValidationInfo ,
178+ ) -> "ROSBackendConfig" :
179+ """Ensure distro is resolved after validation."""
180+ context = info .context or {}
181+ channels = context .get ("channels" )
182+ return self .resolve_distro (channels = channels )
104183
105184 @pydantic .field_validator ("debug_dir" , mode = "before" )
106185 @classmethod
@@ -121,6 +200,8 @@ def _parse_package_mappings(
121200 """Parse additional package mappings if set."""
122201 if input_value is None :
123202 return []
203+
204+ base_path = Path .cwd ()
124205 if info .context and "manifest_root" in info .context :
125206 base_path = Path (info .context ["manifest_root" ])
126207
@@ -139,11 +220,17 @@ def _parse_package_mappings(
139220 entry = PackageMappingSource .from_mapping (mapping_value )
140221 else :
141222 entry = PackageMappingSource .from_mapping (raw_entry )
142- elif isinstance (raw_entry , str | Path ):
223+ elif isinstance (raw_entry , ( str , Path ) ):
143224 entry = PackageMappingSource .from_file (_parse_str_as_abs_path (raw_entry , base_path ))
144225 else :
145226 raise ValueError (
146227 f"Unrecognized entry for extra-package-mappings: { raw_entry } of type { type (raw_entry )} ."
147228 )
148229 result .append (entry )
149230 return result
231+
232+ @property
233+ def distro (self ) -> Distro :
234+ if not self .distro_ :
235+ raise ValueError ("Distro could not be resolved from the channels or the `distro` build config." )
236+ return self .distro_
0 commit comments