Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Recipe modifications #47

Merged
merged 9 commits into from
Aug 5, 2024
Merged
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
720 changes: 493 additions & 227 deletions pixi.lock

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion pixi.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ pre-commit = ">=3.7.1,<4"
pre-commit-hooks = ">=4.6.0,<5"
typos = ">=1.23.1,<2"
mypy = ">=1.10.1,<2"
types-pyyaml = ">=6.0.12.20240311,<6.0.13"
ruff = ">=0.5.0,<0.6"

[feature.lint.tasks]
Expand All @@ -43,6 +42,8 @@ pre-commit-run = "pre-commit run"

[feature.type-checking.dependencies]
mypy = ">=1.10.1,<2"
types-requests = ">=2.32.0.20240712,<3"
types-pyyaml = ">=6.0.12.20240311,<6.0.13"

[feature.type-checking.tasks]
type-check = "mypy src"
Expand Down
6 changes: 3 additions & 3 deletions src/rattler_build_conda_compat/jinja/jinja.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,11 +107,11 @@ def render_recipe_with_context(recipe_content: RecipeWithContext) -> dict[str, A
"""
env = jinja_env()
context = recipe_content.get("context", {})
# load all context templates
context_templates = load_recipe_context(context, env)
# render out the context section and retrieve dictionary
context_variables = load_recipe_context(context, env)

# render the rest of the document with the values from the context
# and keep undefined expressions _as is_.
template = env.from_string(yaml.dump(recipe_content))
rendered_content = template.render(context_templates)
rendered_content = template.render(context_variables)
return load_yaml(rendered_content)
176 changes: 176 additions & 0 deletions src/rattler_build_conda_compat/modify_recipe.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
from __future__ import annotations

import copy
import hashlib
import io
import logging
import re
from typing import TYPE_CHECKING, Any, Literal

import requests
from ruamel.yaml import YAML

from rattler_build_conda_compat.jinja.jinja import jinja_env, load_recipe_context
from rattler_build_conda_compat.recipe_sources import Source, get_all_sources

if TYPE_CHECKING:
from pathlib import Path

logger = logging.getLogger(__name__)

yaml = YAML()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wdyt about extracting YAML loader configuration in one place so it can be used by other loaders?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, I also want to get rid of any PyYAML and just use ruamel.YAML everywhere.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In fact, that's the next PR that I wanted to send, so let's maybe wait until then?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sounds good to me

yaml.preserve_quotes = True
yaml.width = 4096
yaml.indent(mapping=2, sequence=4, offset=2)


def _update_build_number_in_context(recipe: dict[str, Any], new_build_number: int) -> bool:
for key in recipe.get("context", {}):
if key.startswith("build_") or key == "build":
recipe["context"][key] = new_build_number
return True
return False


def _update_build_number_in_recipe(recipe: dict[str, Any], new_build_number: int) -> bool:
is_modified = False
if "build" in recipe and "number" in recipe["build"]:
recipe["build"]["number"] = new_build_number
is_modified = True

if "outputs" in recipe:
for output in recipe["outputs"]:
if "build" in output and "number" in output["build"]:
output["build"]["number"] = new_build_number
is_modified = True

return is_modified


def update_build_number(file: Path, new_build_number: int = 0) -> str:
"""
Update the build number in the recipe file.

Arguments:
----------
* `file` - The path to the recipe file.
* `new_build_number` - The new build number to use. (default: 0)

Returns:
--------
The updated recipe as a string.
"""
with file.open("r") as f:
data = yaml.load(f)
Comment on lines +63 to +64
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesnt it make more sense to leave the loading of the yaml to the caller? I can imagine that if we have more modification functions they will all require loading the yaml.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, and no - we do want to minimally change the YAML so I think that should all be handled by us (leave comments in tact, order, formatting, ...).

build_number_modified = _update_build_number_in_context(data, new_build_number)
if not build_number_modified:
_update_build_number_in_recipe(data, new_build_number)

with io.StringIO() as f:
yaml.dump(data, f)
return f.getvalue()


class CouldNotUpdateVersionError(Exception):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wdyt about moving errors in separate modules?

NO_CONTEXT = "Could not find context in recipe"
NO_VERSION = "Could not find version in recipe context"

def __init__(self, message: str = "Could not update version") -> None:
self.message = message
super().__init__(self.message)


class Hash:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe let's move it in separate module? like hash.py for example?

def __init__(self, hash_type: Literal["md5", "sha256"], hash_value: str) -> None:
self.hash_type = hash_type
self.hash_value = hash_value

def __str__(self) -> str:
return f"{self.hash_type}: {self.hash_value}"


def _has_jinja_version(url: str) -> bool:
"""Check if the URL has a jinja `${{ version }}` in it."""
pattern = r"\${{\s*version"
return re.search(pattern, url) is not None


def update_hash(source: Source, url: str, hash_: Hash | None) -> None:
"""
Update the sha256 hash in the source dictionary.

Arguments:
----------
* `source` - The source dictionary to update.
* `url` - The URL to download and hash (if no hash is provided).
* `hash_` - The hash to use. If not provided, the file will be downloaded and `sha256` hashed.
"""
if "md5" in source:
del source["md5"]
if "sha256" in source:
del source["sha256"]

if hash_ is not None:
source[hash_.hash_type] = hash_.hash_value
else:
# download and hash the file
hasher = hashlib.sha256()
logger.info("Retrieving and hashing %s", url)
with requests.get(url, stream=True, timeout=100) as r:
for chunk in r.iter_content(chunk_size=4096):
hasher.update(chunk)
source["sha256"] = hasher.hexdigest()


def update_version(file: Path, new_version: str, hash_: Hash | None) -> str:
"""
Update the version in the recipe file.

Arguments:
----------
* `file` - The path to the recipe file.
* `new_version` - The new version to use.
* `hash_type` - The hash type to use. If not provided, the file will be downloaded and `sha256` hashed.

Returns:
--------
The updated recipe as a string.
"""

with file.open("r") as f:
data = yaml.load(f)
Comment on lines +140 to +141
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, maybe it would make sense to have a variant that takes the loaded dictionary as input.


if "context" not in data:
raise CouldNotUpdateVersionError(CouldNotUpdateVersionError.NO_CONTEXT)
if "version" not in data["context"]:
raise CouldNotUpdateVersionError(CouldNotUpdateVersionError.NO_VERSION)

data["context"]["version"] = new_version

# set up the jinja context
env = jinja_env()
context = copy.deepcopy(data.get("context", {}))
context_variables = load_recipe_context(context, env)
# for r-recipes we add the default `cran_mirror` variable
context_variables["cran_mirror"] = "https://cran.r-project.org"

for source in get_all_sources(data):
# render the whole URL and find the hash
if "url" not in source:
continue

url = source["url"]
if isinstance(url, list):
url = url[0]

if not _has_jinja_version(url):
continue

template = env.from_string(url)
rendered_url = template.render(context_variables)

update_hash(source, rendered_url, hash_)

with io.StringIO() as f:
yaml.dump(data, f)
return f.getvalue()
39 changes: 30 additions & 9 deletions src/rattler_build_conda_compat/recipe_sources.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,14 @@


class Source(TypedDict):
url: NotRequired[str]
url: NotRequired[str | list[str]]
sha256: NotRequired[str]
md5: NotRequired[str]


def get_all_url_sources(recipe: Mapping[Any, Any]) -> Iterator[str]:
def get_all_sources(recipe: Mapping[Any, Any]) -> Iterator[Source]:
"""
Get all url sources from the recipe. This can be from a list of sources,
Get all sources from the recipe. This can be from a list of sources,
a single source, or conditional and its branches.

Arguments
Expand All @@ -32,18 +34,16 @@ def get_all_url_sources(recipe: Mapping[Any, Any]) -> Iterator[str]:

Returns
-------
A list of sources.
A list of source objects.
"""

sources = recipe.get("source", None)
sources = typing.cast(ConditionalList[Source], sources)

# Try getting all url top-level sources
if sources is not None:
source_list = visit_conditional_list(sources, None)
for source in source_list:
if url := source.get("url"):
yield url
yield source

outputs = recipe.get("outputs", None)
if outputs is None:
Expand All @@ -57,5 +57,26 @@ def get_all_url_sources(recipe: Mapping[Any, Any]) -> Iterator[str]:
continue
source_list = visit_conditional_list(sources, None)
for source in source_list:
if url := source.get("url"):
yield url
yield source


def get_all_url_sources(recipe: Mapping[Any, Any]) -> Iterator[str]:
"""
Get all url sources from the recipe. This can be from a list of sources,
a single source, or conditional and its branches.

Arguments
---------
* `recipe` - The recipe to inspect. This should be a yaml object.

Returns
-------
A list of URLs.
"""

def get_first_url(source: Mapping[str, Any]) -> str:
if isinstance(source["url"], list):
return source["url"][0]
return source["url"]

return (get_first_url(source) for source in get_all_sources(recipe) if "url" in source)
5 changes: 5 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@
import pytest


@pytest.fixture()
def data_dir() -> Path:
return Path(__file__).parent / "data"


@pytest.fixture()
def python_recipe(tmpdir: Path) -> str:
recipe_dir = tmpdir / "recipe"
Expand Down
10 changes: 10 additions & 0 deletions tests/data/build_number/test_1/expected.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# set the build number to something
context:
build: 0

package:
name: recipe_1
version: "0.1.0"

build:
number: ${{ build }}
10 changes: 10 additions & 0 deletions tests/data/build_number/test_1/recipe.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# set the build number to something
context:
build: 123

package:
name: recipe_1
version: "0.1.0"

build:
number: ${{ build }}
11 changes: 11 additions & 0 deletions tests/data/build_number/test_2/expected.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# set the build number to something
package:
name: recipe_1
version: "0.1.0"

# set the build number to something directly in the recipe text
build:
number: 0

source:
- url: foo
11 changes: 11 additions & 0 deletions tests/data/build_number/test_2/recipe.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# set the build number to something
package:
name: recipe_1
version: "0.1.0"

# set the build number to something directly in the recipe text
build:
number: 321

source:
- url: foo
12 changes: 12 additions & 0 deletions tests/data/version/test_1/expected.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
context:
name: xtensor
version: "0.25.0"


package:
name: ${{ name|lower }}
version: ${{ version }}

source:
url: https://github.com/xtensor-stack/xtensor/archive/${{ version }}.tar.gz
sha256: 32d5d9fd23998c57e746c375a544edf544b74f0a18ad6bc3c38cbba968d5e6c7
12 changes: 12 additions & 0 deletions tests/data/version/test_1/recipe.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
context:
name: xtensor
version: "0.23.5"


package:
name: ${{ name|lower }}
version: ${{ version }}

source:
url: https://github.com/xtensor-stack/xtensor/archive/${{ version }}.tar.gz
sha256: 0811011e448628f0dfa6ebb5e3f76dc7bf6a15ee65ea9c5a277b12ea976d35bc
15 changes: 15 additions & 0 deletions tests/data/version/test_2/expected.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
context:
name: xtensor
version: "0.25.0"


package:
name: ${{ name|lower }}
version: ${{ version }}

source:
# please update the version here.
- if: target_platform == linux-64
then:
url: https://github.com/xtensor-stack/xtensor/archive/${{ version }}.tar.gz
sha256: 32d5d9fd23998c57e746c375a544edf544b74f0a18ad6bc3c38cbba968d5e6c7
15 changes: 15 additions & 0 deletions tests/data/version/test_2/recipe.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
context:
name: xtensor
version: "0.23.5"


package:
name: ${{ name|lower }}
version: ${{ version }}

source:
# please update the version here.
- if: target_platform == linux-64
then:
url: https://github.com/xtensor-stack/xtensor/archive/${{ version }}.tar.gz
sha256: 0811011e448628f0dfa6ebb5e3f76dc7bf6a15ee65ea9c5a277b12ea976d35bc
Loading