Skip to content

Commit bf864c8

Browse files
authored
feat: add automatic ROS distro detection from robostack channels (#425)
1 parent 1e14dad commit bf864c8

File tree

18 files changed

+309
-39
lines changed

18 files changed

+309
-39
lines changed

.github/workflows/ros-build.yml

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,11 @@ jobs:
3535
- uses: prefix-dev/setup-pixi@28eb668aafebd9dede9d97c4ba1cd9989a4d0004 # v0.9.2
3636
with:
3737
manifest-path: backends/pixi-build-ros/pixi.toml
38-
environments: test
3938
- name: Run mypy
4039
run: |
4140
cd backends/pixi-build-ros
42-
pixi run -e test lint-mypy
41+
pixi run lint
4342
- name: Run tests
4443
run: |
4544
cd backends/pixi-build-ros
46-
pixi run -e test test --color=yes
45+
pixi run test --color=yes

backends/pixi-build-ros/pixi.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ preview = ["pixi-build"]
99
[activation.env]
1010
CARGO_TARGET_DIR = "target/pixi"
1111

12+
[tasks]
13+
lint.depends-on = ["fmt", "lint-ruff", "lint-mypy"]
14+
1215
[target.linux-64.dependencies]
1316
clang = ">=21.1,<21.2"
1417
mold = ">=2.33.0,<3.0"

backends/pixi-build-ros/src/pixi_build_ros/config.py

Lines changed: 95 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import os
2-
import pydantic
3-
import yaml
2+
import re
43
from pathlib import Path
54
from typing import Any
65

6+
import pydantic
7+
import yaml
8+
79
from 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+
2252
PackageMapEntry = 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_

backends/pixi-build-ros/src/pixi_build_ros/ros_generator.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,23 @@ def generate_recipe(
4242
manifest_path: str,
4343
host_platform: Platform,
4444
_python_params: PythonParams | None = None,
45+
channels: list[str] | None = None,
4546
) -> GeneratedRecipe:
4647
"""Generate a recipe for a Python package."""
4748
manifest_root = Path(manifest_path)
4849
backend_config: ROSBackendConfig = ROSBackendConfig.model_validate(
49-
config, context={"manifest_root": manifest_root}
50+
config,
51+
context={
52+
"manifest_root": manifest_root,
53+
"channels": channels,
54+
},
5055
)
56+
# Resolve distro after validation, using channels from build system
57+
backend_config = ROSBackendConfig.resolve_distro(
58+
backend_config,
59+
channels=channels,
60+
)
61+
5162
# Create metadata provider for package.xml
5263
package_xml_path = manifest_root / "package.xml"
5364
# Get package mapping file paths to include in input globs
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
"""Tests for automatic distro detection from robostack channels."""
2+
3+
import pytest
4+
5+
from pixi_build_ros.config import ROSBackendConfig, _extract_distro_from_channels_list
6+
7+
8+
def test_extract_distro_from_full_url():
9+
"""Test extracting distro from full robostack URL."""
10+
channels = [
11+
"https://prefix.dev/pixi-build-backends",
12+
"https://prefix.dev/robostack-jazzy",
13+
"https://prefix.dev/conda-forge",
14+
]
15+
16+
distro = _extract_distro_from_channels_list(channels)
17+
assert distro == "jazzy"
18+
19+
20+
def test_extract_distro_from_short_channel_name():
21+
"""Test extracting distro from short robostack channel name."""
22+
channels = ["robostack-humble", "conda-forge"]
23+
24+
distro = _extract_distro_from_channels_list(channels)
25+
assert distro == "humble"
26+
27+
28+
def test_dont_extract_from_staging():
29+
"""Test extracting distro from short robostack channel name."""
30+
channels = ["robostack-staging", "conda-forge"]
31+
32+
distro = _extract_distro_from_channels_list(channels)
33+
assert distro is None
34+
35+
36+
def test_extract_distro_with_trailing_slash():
37+
"""Test extracting distro from URL with trailing slash."""
38+
channels = ["https://prefix.dev/robostack-noetic/"]
39+
40+
distro = _extract_distro_from_channels_list(channels)
41+
assert distro == "noetic"
42+
43+
44+
def test_extract_distro_multiple_robostack_channels():
45+
"""Test that the first robostack channel is used when multiple exist."""
46+
channels = [
47+
"https://prefix.dev/robostack-humble",
48+
"https://prefix.dev/robostack-jazzy",
49+
]
50+
51+
distro = _extract_distro_from_channels_list(channels)
52+
assert distro == "humble"
53+
54+
55+
def test_extract_distro_no_robostack_channel():
56+
"""Test that None is returned when no robostack channel exists."""
57+
channels = [
58+
"https://prefix.dev/conda-forge",
59+
"https://prefix.dev/some-other-channel",
60+
]
61+
62+
distro = _extract_distro_from_channels_list(channels)
63+
assert distro is None
64+
65+
66+
def test_config_auto_detects_distro_from_channel():
67+
"""Test that ROSBackendConfig auto-detects distro from channel list provided by the caller."""
68+
# Create config without distro specified
69+
config = ROSBackendConfig.model_validate({}, context={"channels": ["https://prefix.dev/robostack-jazzy"]})
70+
71+
assert config.distro is not None
72+
assert config.distro.name == "jazzy"
73+
74+
75+
def test_config_explicit_distro_overrides_channel():
76+
"""Test that explicit distro config takes precedence over channel detection."""
77+
# Create config with explicit distro
78+
config = ROSBackendConfig.model_validate(
79+
{"distro": "humble"},
80+
context={"channels": ["https://prefix.dev/robostack-jazzy"]},
81+
)
82+
83+
assert config.distro is not None
84+
assert config.distro.name == "humble"
85+
86+
87+
def test_config_fails_without_distro_or_channel():
88+
"""Test that config validation fails when distro cannot be determined."""
89+
with pytest.raises(ValueError, match="ROS distro must be either"):
90+
ROSBackendConfig.model_validate({}, context={"channels": ["conda-forge"]})
91+
92+
93+
def test_config_fails_without_channels_context():
94+
"""Test that config validation fails when no channels are provided."""
95+
with pytest.raises(ValueError, match="ROS distro must be either"):
96+
ROSBackendConfig.model_validate({})

crates/pixi-build-backend/src/generated_recipe.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use miette::Diagnostic;
22
use pixi_build_types::ProjectModelV1;
33
use rattler_build::{NormalizedKey, recipe::variable::Variable};
4-
use rattler_conda_types::{Platform, Version};
4+
use rattler_conda_types::{ChannelUrl, Platform, Version};
55
use recipe_stage0::recipe::{About, IntermediateRecipe, Package, Value};
66
use serde::de::DeserializeOwned;
77
use std::collections::HashSet;
@@ -50,6 +50,9 @@ pub trait GenerateRecipe {
5050
/// be removed when profiles will be implemented.
5151
/// * `variants` - The variant names that are available to the recipe. This might
5252
/// influence how the recipe is generated.
53+
/// * `channels` - The channels that are being used for this build. This can be
54+
/// used for backend-specific logic that depends on which channels are available.
55+
#[allow(clippy::too_many_arguments)]
5356
fn generate_recipe(
5457
&self,
5558
model: &ProjectModelV1,
@@ -58,6 +61,7 @@ pub trait GenerateRecipe {
5861
host_platform: Platform,
5962
python_params: Option<PythonParams>,
6063
variants: &HashSet<NormalizedKey>,
64+
channels: Vec<ChannelUrl>,
6165
) -> miette::Result<GeneratedRecipe>;
6266

6367
/// Returns a list of globs that should be used to find the input files

crates/pixi-build-backend/src/intermediate_backend.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,7 @@ where
284284
params.host_platform,
285285
Some(PythonParams { editable: false }),
286286
&variant_config.variants.keys().cloned().collect(),
287+
params.channels,
287288
)?;
288289

289290
// Convert the recipe to source code.
@@ -542,6 +543,7 @@ where
542543
editable: params.editable.unwrap_or_default(),
543544
}),
544545
&variants.keys().cloned().collect(),
546+
params.channels,
545547
)?;
546548

547549
// Convert the recipe to source code.

crates/pixi-build-backend/tests/integration/protocol.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ mod imp {
1616
use pixi_build_backend::generated_recipe::{
1717
BackendConfig, DefaultMetadataProvider, GenerateRecipe, GeneratedRecipe, PythonParams,
1818
};
19+
use rattler_conda_types::ChannelUrl;
1920
use serde::{Deserialize, Serialize};
2021
use std::{
2122
collections::HashSet,
@@ -61,6 +62,7 @@ mod imp {
6162
_host_platform: rattler_conda_types::Platform,
6263
_python_params: Option<PythonParams>,
6364
_variants: &HashSet<pixi_build_backend::variants::NormalizedKey>,
65+
_channels: Vec<ChannelUrl>,
6466
) -> miette::Result<GeneratedRecipe> {
6567
GeneratedRecipe::from_model(model.clone(), &mut DefaultMetadataProvider)
6668
.into_diagnostic()

0 commit comments

Comments
 (0)