From bf19fe7032fb429984ef2552350af180b2d00d9e Mon Sep 17 00:00:00 2001 From: Yannik Tausch Date: Mon, 14 Sep 2026 11:32:50 +0200 Subject: [PATCH] feat: add requirements.extras for optional dependency groups (CEP 44) --- conda_recipe_v2_schema/model.py | 13 +++++++++ examples/valid/extras/recipe.yaml | 47 +++++++++++++++++++++++++++++++ schema.json | 39 +++++++++++++++++++++++++ tests/test_recipe.py | 45 ++++++++++++++++++++++++++++- 4 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 examples/valid/extras/recipe.yaml diff --git a/conda_recipe_v2_schema/model.py b/conda_recipe_v2_schema/model.py index 6b96f78..cbee912 100644 --- a/conda_recipe_v2_schema/model.py +++ b/conda_recipe_v2_schema/model.py @@ -432,6 +432,15 @@ class StagingRequirements(StrictBaseModel): ######################### +# Name of an optional dependency group (extras) as defined by CEP 44. +ExtrasGroupName = constr(pattern=r"^[a-z0-9_.+-]{1,64}$") + +Extras = Annotated[ + dict[ExtrasGroupName, ConditionalList[MatchSpec]], + Field(json_schema_extra={"additionalProperties": False}), +] + + class Requirements(StrictBaseModel): build: ConditionalList[MatchSpec] | None = Field( None, @@ -448,6 +457,10 @@ class Requirements(StrictBaseModel): run_constraints: ConditionalList[MatchSpec] | None = Field( None, description="constraints optional dependencies at runtime." ) + extras: Extras | None = Field( + None, + description="Optional dependency groups (CEP 44). Maps a group name to additional dependencies that are only installed when the group is selected via the `extras` MatchSpec keyword (e.g. `mypkg[extras=[group-name]]`).", + ) run_exports: ConditionalList[MatchSpec] | RunExports = Field( None, description="The run exports of this package" ) diff --git a/examples/valid/extras/recipe.yaml b/examples/valid/extras/recipe.yaml new file mode 100644 index 0000000..65710ca --- /dev/null +++ b/examples/valid/extras/recipe.yaml @@ -0,0 +1,47 @@ +context: + version: "0.0.15" + python_min: "3.13" + +package: + name: pixi-browse + version: ${{ version }} + +source: + url: https://pypi.org/packages/source/p/pixi-browse/pixi_browse-${{ version }}.tar.gz + sha256: 883f4f4e2ab8f07c8e04ebca8ad77a5bae82ba37cc085e85d6fa07d6871c8230 + +build: + number: 0 + noarch: python + python: + entry_points: + - pixi-browse = pixi_browse.__main__:cli + script: python -m pip install . -vv --no-deps --no-build-isolation + +requirements: + host: + - python ${{ python_min }}.* + - pip + - hatchling + run: + - python >=${{ python_min }} + - textual >=8.2.8,<9 + - py-rattler >=0.26.0,<0.27 + - typer >=0.27.2,<0.28 + - pyyaml >=6.0.3,<7 + run_constraints: + - textual-diff-view >=0.1.5,<0.2 + extras: + diff: + - textual-diff-view >=0.1.5,<0.2 + +tests: + - python: + imports: + - pixi_browse + +about: + homepage: https://github.com/pavelzw/pixi-browse + repository: https://github.com/pavelzw/pixi-browse + summary: A TUI to browse conda packages + license: BSD-3-Clause diff --git a/schema.json b/schema.json index e5dcb41..cd7693e 100644 --- a/schema.json +++ b/schema.json @@ -3773,6 +3773,45 @@ "description": "constraints optional dependencies at runtime.", "title": "Run Constraints" }, + "extras": { + "anyOf": [ + { + "additionalProperties": false, + "patternProperties": { + "^[a-z0-9_.+-]{1,64}$": { + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/IfStatement" + }, + { + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/IfStatement" + } + ] + }, + "type": "array" + } + ] + } + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional dependency groups (CEP 44). Maps a group name to additional dependencies that are only installed when the group is selected via the `extras` MatchSpec keyword (e.g. `mypkg[extras=[group-name]]`).", + "title": "Extras" + }, "run_exports": { "anyOf": [ { diff --git a/tests/test_recipe.py b/tests/test_recipe.py index 9538393..7a05166 100644 --- a/tests/test_recipe.py +++ b/tests/test_recipe.py @@ -11,7 +11,7 @@ @pytest.fixture( scope="module", - params=["mamba", "xtensor", "single-output", "zlib", "staging", "post-process"], + params=["mamba", "xtensor", "single-output", "zlib", "staging", "post-process", "extras"], ) def valid_recipe(request) -> str: recipe_name = request.param @@ -57,6 +57,49 @@ def test_recipe_schema_not_changed(recipe_schema): assert recipe_schema == top_level_schema() +def test_requirements_extras_valid(recipe_schema): + """Recipes with requirements.extras pass validation (CEP 44).""" + recipe_yaml = """ + package: + name: test + version: 1.0.0 + requirements: + run: + - main-dependency + extras: + diff: + - textual-diff-view >=0.1.5,<0.2 + "full-extra.v2": + - extra-dependency >=2 + - if: unix + then: posix-only-dependency + """ + recipe_dict = yaml.safe_load(recipe_yaml) + + Recipe.validate_python(recipe_dict) + validate(instance=recipe_dict, schema=recipe_schema) + + +def test_requirements_extras_invalid_group_name(recipe_schema): + """Extras group names must match the CEP 44 grammar `[a-z0-9_.+-]{1,64}`.""" + recipe_yaml = """ + package: + name: test + version: 1.0.0 + requirements: + extras: + Invalid_Name: + - extra-dependency + """ + recipe_dict = yaml.safe_load(recipe_yaml) + + with pytest.raises(PydanticValidationError): + Recipe.validate_python(recipe_dict) + + with pytest.raises(ValidationError): + validate(instance=recipe_dict, schema=recipe_schema) + + def test_patches_valid_conditional(): """Test that patches with conditionals inside a list pass validation.""" recipe_yaml = """