Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 53 additions & 6 deletions ebuild/packages/recipe.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,29 +82,73 @@ def validate(self) -> None:
f"Package '{self.name}': plaintext http:// is not accepted for "
f"'{self.url}'. Use https://."
)

if self.build_system not in self.VALID_BUILD_SYSTEMS:
raise RecipeError(
f"Package '{self.name}': invalid build system '{self.build_system}'. "
f"Must be one of {self.VALID_BUILD_SYSTEMS}."
)


def _parse_recipe(raw: Dict[str, Any], source_path: Optional[Path] = None) -> PackageRecipe:
def _parse_string_list(
raw: Dict[str, Any],
field_name: str,
fallback_field: Optional[str] = None,
) -> List[str]:
"""Parse a recipe field that must be a list of strings.

Args:
raw: Raw recipe mapping loaded from YAML.
field_name: Preferred field name.
fallback_field: Optional legacy/alternate field name used when the
preferred field is absent.

Returns:
A copy of the validated list.

Raises:
RecipeError: If the field is not a list or contains non-string items.
"""
if field_name in raw:
value = raw[field_name]
elif fallback_field is not None and fallback_field in raw:
value = raw[fallback_field]
else:
value = []

if not isinstance(value, list):
raise RecipeError(f"'{field_name}' must be a list.")

if not all(isinstance(item, str) for item in value):
raise RecipeError(f"'{field_name}' must contain only strings.")

return list(value)


def _parse_recipe(
raw: Dict[str, Any],
source_path: Optional[Path] = None,
) -> PackageRecipe:
"""Parse a raw YAML dict into a PackageRecipe."""
recipe = PackageRecipe(
name=raw.get("package", raw.get("name", "")),
version=str(raw.get("version", "")),
url=raw.get("url", ""),
checksum=raw.get("checksum", ""),
build_system=raw.get("build", raw.get("build_system", "cmake")),
dependencies=raw.get("dependencies", raw.get("depends", [])),
patches=raw.get("patches", []),
configure_args=raw.get("configure_args", []),
build_args=raw.get("build_args", []),
install_args=raw.get("install_args", []),
dependencies=_parse_string_list(
raw,
"dependencies",
fallback_field="depends",
),
patches=_parse_string_list(raw, "patches"),
configure_args=_parse_string_list(raw, "configure_args"),
build_args=_parse_string_list(raw, "build_args"),
install_args=_parse_string_list(raw, "install_args"),
description=raw.get("description", ""),
license=raw.get("license", ""),
)

recipe.validate()
return recipe

Expand All @@ -123,6 +167,7 @@ def load_recipe(recipe_path: str | Path) -> PackageRecipe:
FileNotFoundError: If the file doesn't exist.
"""
recipe_path = Path(recipe_path)

if not recipe_path.exists():
raise FileNotFoundError(f"Recipe file not found: {recipe_path}")

Expand All @@ -138,6 +183,8 @@ def load_recipe(recipe_path: str | Path) -> PackageRecipe:
def load_recipe_from_string(content: str) -> PackageRecipe:
"""Load a package recipe from a YAML string."""
raw = yaml.safe_load(content)

if not isinstance(raw, dict):
raise RecipeError("Invalid recipe format: expected a YAML mapping.")

return _parse_recipe(raw)
107 changes: 107 additions & 0 deletions tests/ebuild/test_package_recipe.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 EoS Project

"""Tests for ebuild.packages.recipe."""

import pytest

from ebuild.packages.recipe import RecipeError, load_recipe_from_string


BASE_RECIPE = """
package: demo
version: 1.0.0
url: https://example.com/demo.tar.gz
checksum: sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
"""


@pytest.mark.parametrize(
"field_name",
[
"dependencies",
"patches",
"configure_args",
"build_args",
"install_args",
],
)
def test_recipe_list_fields_must_be_lists(field_name):
"""List-valued recipe fields must reject scalar YAML values."""
content = BASE_RECIPE + f"""
{field_name}: "not-a-list"
"""

with pytest.raises(RecipeError, match=field_name):
load_recipe_from_string(content)


@pytest.mark.parametrize(
"field_name",
[
"dependencies",
"patches",
"configure_args",
"build_args",
"install_args",
],
)
def test_recipe_list_fields_must_contain_only_strings(field_name):
"""List-valued recipe fields must reject non-string items."""
content = BASE_RECIPE + f"""
{field_name}:
- valid-value
- 123
"""

with pytest.raises(RecipeError, match=field_name):
load_recipe_from_string(content)


def test_recipe_accepts_valid_list_fields():
"""Valid lists of strings should continue to load normally."""
recipe = load_recipe_from_string(
BASE_RECIPE
+ """
dependencies:
- zlib
patches:
- fix-build.patch
configure_args:
- -DENABLE_FEATURE=ON
build_args:
- VERBOSE=1
install_args:
- DESTDIR=/tmp/install
"""
)

assert recipe.dependencies == ["zlib"]
assert recipe.patches == ["fix-build.patch"]
assert recipe.configure_args == ["-DENABLE_FEATURE=ON"]
assert recipe.build_args == ["VERBOSE=1"]
assert recipe.install_args == ["DESTDIR=/tmp/install"]


def test_depends_alias_accepts_a_list():
"""The legacy 'depends' alias should remain supported."""
recipe = load_recipe_from_string(
BASE_RECIPE
+ """
depends:
- zlib
- openssl
"""
)

assert recipe.dependencies == ["zlib", "openssl"]


def test_depends_alias_must_be_a_list():
"""The legacy 'depends' alias must follow the same list validation."""
content = BASE_RECIPE + """
depends: zlib
"""

with pytest.raises(RecipeError, match="dependencies"):
load_recipe_from_string(content)