diff --git a/conda_lock/conda_lock.py b/conda_lock/conda_lock.py index 279cc71e2..a0a17d308 100644 --- a/conda_lock/conda_lock.py +++ b/conda_lock/conda_lock.py @@ -21,6 +21,7 @@ from types import TracebackType from typing import Any, Optional, Union from urllib.parse import urlsplit +import warnings import click import yaml @@ -117,8 +118,10 @@ KIND_EXPLICIT: Literal["explicit"] = "explicit" KIND_LOCK: Literal["lock"] = "lock" KIND_ENV: Literal["env"] = "env" -TKindAll = Union[Literal["explicit"], Literal["lock"], Literal["env"]] -TKindRendarable = Union[Literal["explicit"], Literal["lock"], Literal["env"]] +KIND_RECIPE: Literal["recipe"] = "recipe" +KINDS = [KIND_EXPLICIT, KIND_LOCK, KIND_ENV, KIND_RECIPE] +TKindAll = Union[KIND_EXPLICIT, KIND_LOCK, KIND_ENV, KIND_RECIPE] +TKindRendarable = Union[KIND_EXPLICIT, KIND_LOCK, KIND_ENV, KIND_RECIPE] DEFAULT_KINDS: list[Union[Literal["explicit"], Literal["lock"]]] = [ @@ -135,6 +138,7 @@ KIND_EXPLICIT: "conda create --name YOURENV --file {lockfile}", KIND_ENV: "conda env create --name YOURENV --file {lockfile}", KIND_LOCK: "conda-lock install --name YOURENV {lockfile}", + KIND_RECIPE: "rattler build {lockfile}", } _implicit_cuda_message = """ @@ -497,6 +501,7 @@ def make_lock_files( # noqa: C901 filename_template=filename_template, extras=extras, check_input_hash=check_input_hash, + output_recipe=lock_spec.output_recipe, ) @@ -508,6 +513,7 @@ def do_render( extras: Optional[Set[str]] = None, check_input_hash: bool = False, override_platform: Optional[Sequence[str]] = None, + output_recipe: Optional[dict] = None, ) -> None: """Render the lock content for each platform in lockfile @@ -527,6 +533,8 @@ def do_render( Do not re-render if specifications are unchanged override_platform : Generate only this subset of the platform files + output_recipe : + Additional content for rendering a rattler-build recipe """ platforms = lockfile.metadata.platforms if override_platform is not None and len(override_platform) > 0: @@ -583,11 +591,17 @@ def do_render( extras=extras, kind=kind, platform=plat, + output_recipe=output_recipe, ) - filename += KIND_FILE_EXT[kind] - with open(filename, "w") as fo: - fo.write("\n".join(lockfile_contents) + "\n") + if kind == "recipe": + filename = f"{output_recipe['name']}-{output_recipe['version']}-{output_recipe['build']['string']}-{plat}.yaml" + with open(filename, "w") as fo: + fo.write("\n".join(lockfile_contents) + "\n") + else: + filename += KIND_FILE_EXT[kind] + with open(filename, "w") as fo: + fo.write("\n".join(lockfile_contents) + "\n") print( f" - Install lock using {'(see warning below)' if kind == 'env' else ''}:", @@ -616,6 +630,7 @@ def render_lockfile_for_platform( # noqa: C901 kind: Union[Literal["env"], Literal["explicit"]], platform: str, suppress_warning_for_pip_and_explicit: bool = False, + output_recipe: Optional[dict] = None, ) -> list[str]: """ Render lock content into a single-platform lockfile that can be installed @@ -636,6 +651,8 @@ def render_lockfile_for_platform( # noqa: C901 suppress_warning_for_pip_and_explicit : When rendering internally for `conda-lock install`, we should suppress the warning about pip dependencies not being supported by all tools. + output_recipe : + Additional content for rendering a rattler-build recipe """ lockfile_contents = [ "# Generated by conda-lock.", @@ -757,6 +774,29 @@ def sanitize_lockfile_line(line: str) -> str: "newer unified lockfile format (i.e. removing the --kind=explicit " "argument." ) + elif kind == "recipe": + lockfile_contents = [ + "package:", + f" name: {output_recipe['name']}", + f" version: {output_recipe['version']}", + f"build:", + f" string: {output_recipe['build']['string']}", + "requirements:", + " run_constraints:", + ] + lockfile.alphasort_inplace() + + excludes = output_recipe.get("exclude_patterns", []) + exclude_pattern = None + if excludes: + exclude_pattern = re.compile("|".join([f".*{e}.*" for e in excludes if e])) + for p in conda_deps: + # exclude virtual packages + if not p.name.startswith("__") and exclude_pattern and not exclude_pattern.match(p.name): + lockfile_contents.append(f" - {p.name} {p.version} {p.build}") + + lockfile_contents.append("about:") + lockfile_contents.append(f" summary: A pinning package for a {output_recipe['name']} environment, to be used as a set of constraints at build time for other recipes.") else: raise ValueError(f"Unrecognised lock kind {kind}.") @@ -1289,9 +1329,9 @@ def main() -> None: "-k", "--kind", default=["lock"], - type=str, + type=click.Choice(KINDS), multiple=True, - help="Kind of lock file(s) to generate [should be one of 'lock', 'explicit', or 'env'].", + help=f"Kind of lock file(s) to generate.", ) @click.option( "--filename-template", @@ -1668,7 +1708,7 @@ def install( "-k", "--kind", default=["explicit"], - type=click.Choice(["explicit", "env"]), + type=click.Choice(KINDS), multiple=True, help="Kind of lock file(s) to generate.", ) diff --git a/conda_lock/conda_solver.py b/conda_lock/conda_solver.py index e1bae2332..e3e42dfee 100644 --- a/conda_lock/conda_solver.py +++ b/conda_lock/conda_solver.py @@ -98,7 +98,6 @@ def solve_conda( Channels to query """ - conda_specs = [ _to_match_spec(dep.name, dep.version, dep.build, dep.conda_channel) for dep in specs.values() @@ -140,6 +139,7 @@ def solve_conda( locked_dependency = LockedDependency( name=action["name"], version=action["version"], + build=action["build"], manager="conda", platform=platform, dependencies=dependencies, diff --git a/conda_lock/lockfile/v1/models.py b/conda_lock/lockfile/v1/models.py index c69845fe3..7c4eac780 100644 --- a/conda_lock/lockfile/v1/models.py +++ b/conda_lock/lockfile/v1/models.py @@ -114,6 +114,9 @@ def to_fetch_action(self) -> FetchAction: f"Expected '{self.platform}' but URL '{self.url}' contains '{parsed_platform}'." ) filename_with_extension = path.name # e.g. "tzdata-2022g-h191b570_0.conda" + build = self.build or filename_with_extension.split("-")[-1].split(".")[0] + if not self.build: + self.build = build # base_url is everything up to the platform directory base_url_path = str(path.parent.parent) # e.g. "/conda-forge" @@ -135,6 +138,7 @@ def to_fetch_action(self) -> FetchAction: version=self.version, channel=channel_url, url=self.url, + build=build, fn=filename_with_extension, md5=self.hash.md5, sha256=self.hash.sha256, diff --git a/conda_lock/models/dry_run_install.py b/conda_lock/models/dry_run_install.py index 0502e19e2..4f95bba8d 100644 --- a/conda_lock/models/dry_run_install.py +++ b/conda_lock/models/dry_run_install.py @@ -18,6 +18,7 @@ class FetchAction(TypedDict): timestamp: int url: str version: str + build: Optional[str] class LinkAction(TypedDict): diff --git a/conda_lock/models/lock_spec.py b/conda_lock/models/lock_spec.py index 348f344c5..51b7b120b 100644 --- a/conda_lock/models/lock_spec.py +++ b/conda_lock/models/lock_spec.py @@ -72,6 +72,7 @@ class LockSpecification(BaseModel): sources: list[pathlib.Path] pip_repositories: list[PipRepository] = Field(default_factory=list) allow_pypi_requests: bool = True + output_recipe: dict = {} @property def platforms(self) -> list[str]: diff --git a/conda_lock/src_parser/__init__.py b/conda_lock/src_parser/__init__.py index 5fc200ad2..cac0559f7 100644 --- a/conda_lock/src_parser/__init__.py +++ b/conda_lock/src_parser/__init__.py @@ -141,4 +141,5 @@ def dep_has_category(d: Dependency, categories: Set[str]) -> bool: pip_repositories=pip_repositories, sources=aggregated_lock_spec.sources, allow_pypi_requests=aggregated_lock_spec.allow_pypi_requests, + output_recipe=aggregated_lock_spec.output_recipe, ) diff --git a/conda_lock/src_parser/aggregation.py b/conda_lock/src_parser/aggregation.py index 4e4807af5..b4e540020 100644 --- a/conda_lock/src_parser/aggregation.py +++ b/conda_lock/src_parser/aggregation.py @@ -53,6 +53,12 @@ def aggregate_lock_specs( ) except ValueError as e: raise ChannelAggregationError(*e.args) + try: + output_recipe = unify_output_recipe( + [lock_spec.output_recipe for lock_spec in lock_specs] + ) + except ValueError as e: + raise ChannelAggregationError(*e.args) return LockSpecification( dependencies=dependencies, @@ -64,6 +70,7 @@ def aggregate_lock_specs( allow_pypi_requests=all( lock_spec.allow_pypi_requests for lock_spec in lock_specs ), + output_recipe=output_recipe, ) @@ -107,3 +114,16 @@ def unify_package_sources( f"{collection} is not an ordered subset at the end of {result}" ) return result + +def unify_output_recipe( + collections: list[dict], +) -> dict: + """Unify the output recipe from multiple lock specs. + + The output recipe must be the same for all lock specs. + """ + if not collections: + return {} + if not all(c == collections[0] for c in collections): + raise ValueError("Output recipe must be the same for all lock specs.") + return collections[0] diff --git a/conda_lock/src_parser/environment_yaml.py b/conda_lock/src_parser/environment_yaml.py index 7cadf313e..f0bc6a808 100644 --- a/conda_lock/src_parser/environment_yaml.py +++ b/conda_lock/src_parser/environment_yaml.py @@ -134,6 +134,7 @@ def parse_environment_file( # These extension fields are nonstandard category: str = env_yaml_data.get("category") or "main" + output_recipe: dict = env_yaml_data.get("output_recipe", {}) # Parse with selectors for each target platform dep_map = { @@ -148,4 +149,5 @@ def parse_environment_file( channels=channels, # type: ignore pip_repositories=pip_repositories, # type: ignore sources=[environment_file], + output_recipe=output_recipe, )