Source code for mkrecipe

#!/usr/bin/env python3
#
#  __init__.py
"""
A tool to create recipes for building conda packages from distributions on PyPI.

.. autosummary-widths:: 7/16
"""
#
#  Copyright © 2020-2021 Dominic Davis-Foster <dominic@davis-foster.co.uk>
#
#  Permission is hereby granted, free of charge, to any person obtaining a copy
#  of this software and associated documentation files (the "Software"), to deal
#  in the Software without restriction, including without limitation the rights
#  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
#  copies of the Software, and to permit persons to whom the Software is
#  furnished to do so, subject to the following conditions:
#
#  The above copyright notice and this permission notice shall be included in all
#  copies or substantial portions of the Software.
#
#  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
#  EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
#  MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
#  IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
#  DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
#  OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
#  OR OTHER DEALINGS IN THE SOFTWARE.
#

# stdlib
import os
import re
from itertools import chain
from time import sleep
from typing import Any, Callable, Dict, Iterable, List, Union

# 3rd party
from click import echo
from domdf_python_tools.compat import importlib_resources
from domdf_python_tools.paths import PathPlus
from domdf_python_tools.typing import PathLike
from jinja2 import BaseLoader, Environment, StrictUndefined
from packaging.requirements import InvalidRequirement
from packaging.version import Version
from shippinglabel.requirements import ComparableRequirement, combine_requirements
from shippinglabel_conda import make_conda_description, prepare_requirements, validate_requirements
from shippinglabel_pypi import get_sdist_url, get_wheel_url
from whey.config.whey import license_lookup

# this package
from mkrecipe.config import load_toml

__author__: str = "Dominic Davis-Foster"
__copyright__: str = "2020-2021 Dominic Davis-Foster"
__license__: str = "MIT License"
__version__: str = "0.6.0.post1"
__email__: str = "dominic@davis-foster.co.uk"

__all__ = ("MaryBerry", "make_recipe")

RETRIES = int(os.environ.get("MKRECIPE_HTTP_RETRIES", 3)) + 1
RETRY_DELAY = int(os.environ.get("MKRECIPE_RETRY_DELAY", 10))


[docs]class MaryBerry: # Get it? """ Builder of Conda ``meta.yaml`` recipes. :param project_dir: The project directory. .. autosummary-widths:: 6/16 """ def __init__(self, project_dir: PathLike) -> None: self.project_dir = PathPlus(project_dir) self.config = self.load_config()
[docs] def load_config(self) -> Dict[str, Any]: """ Load the ``mkrecipe`` configuration. """ return load_toml(self.project_dir / "pyproject.toml")
[docs] def make(self) -> str: """ Make the recipe. :returns: The ``meta.yaml`` recipe as a string. """ # find the download URL sdist_url = self.get_sdist_url() runtime_requirements = self.get_runtime_requirements() host_requirements = sorted( set(combine_requirements( runtime_requirements, *self.config["requires"], normalize_func=str, )) ) project_license = license_lookup.get(self.config["license-key"], self.config["license-key"]) environment = Environment(loader=BaseLoader(), undefined=StrictUndefined) # nosec: B701 template = environment.from_string(importlib_resources.read_text("mkrecipe", "recipe_template.ymlt")) config = {k.replace('-', '_'): v for k, v in self.config.items()} return template.render( sdist_url=sdist_url, host_requirements=host_requirements, runtime_requirements=runtime_requirements, conda_full_description=self.make_conda_description(), url_lines=list(self.get_urls()), all_maintainers=sorted(self.get_maintainers()), project_license=project_license, **config, )
# TODO: Entry points # entry_points: # - {{ import_name }} = {{ import_name }}:main # skip_compile_pyc: # - "*/templates/*.py" # These should not (and cannot) be compiled
[docs] def make_for_wheel(self) -> str: """ Make the recipe for creating a conda package from a wheel. .. versionadded:: 0.3.0 :returns: The ``meta.yaml`` recipe as a string. """ # find the download URL wheel_url = self.get_wheel_url() runtime_requirements = self.get_runtime_requirements() host_requirements = sorted( set(combine_requirements( runtime_requirements, "setuptools", "wheel", normalize_func=str, )) ) project_license = license_lookup.get(self.config["license-key"], self.config["license-key"]) environment = Environment(loader=BaseLoader(), undefined=StrictUndefined) # nosec: B701 template = environment.from_string(importlib_resources.read_text("mkrecipe", "recipe_template.ymlt")) config = {k.replace('-', '_'): v for k, v in self.config.items() if k != "requires"} return template.render( wheel_url=wheel_url, host_requirements=host_requirements, runtime_requirements=runtime_requirements, conda_full_description=self.make_conda_description(), url_lines=list(self.get_urls()), all_maintainers=sorted(self.get_maintainers()), project_license=project_license, requires=["setuptools", "wheel"], wheel=True, **config, )
[docs] def get_sdist_url(self) -> str: """ Returns the URL of the project's source distribution on PyPI. """ sdist_url = self._try_again(get_sdist_url) if not sdist_url.endswith(".tar.gz"): raise InvalidRequirement( f"Cannot find source distribution for {self.config['name']} version {self.config['version']}." ) return sdist_url
[docs] def get_wheel_url(self) -> str: """ Returns the URL of the project's binary wheel on PyPI. .. versionadded:: 0.3.0 """ wheel_url = self._try_again(get_wheel_url) if not wheel_url.endswith(".whl"): raise InvalidRequirement( f"Cannot find wheel for {self.config['name']} version {self.config['version']}." ) return wheel_url
def _try_again(self, func: Callable[[str, Union[str, int, Version]], str]) -> str: name, version = self.config["name"], self.config["version"] for retry in range(0, RETRIES): # pylint: disable=W8202 try: # pylint: disable=R8203 url = func(name, version) return url except InvalidRequirement as e: # pragma: no cover # pylint: disable=W8201 echo(f"{e} Trying again in 10s", err=True) # click.echo # pylint: disable=W8201 sleep(RETRY_DELAY) # time.sleep # pylint: disable=W8202 raise InvalidRequirement(f"Cannot find {self.config['name']} version {self.config['version']} on PyPI.")
[docs] def get_runtime_requirements(self) -> List[ComparableRequirement]: """ Returns a list of the project's runtime requirements. :rtype: .. latex:clearpage:: """ extras: List[Union[str, ComparableRequirement]] = [] if self.config["extras"] == "all": extras.extend(chain.from_iterable(self.config["optional-dependencies"].values())) elif self.config["extras"] == "none": pass else: for extra in self.config["extras"]: extras.extend(list(self.config["optional-dependencies"].get(extra, ()))) extra_requirements = [ComparableRequirement(str(r)) for r in extras] # TODO: handle extras from the dependencies. Lookup the requirements in the wheel metadata. # Perhaps wait until exposed in PyPI API all_requirements: List[ComparableRequirement] = [] for req in chain(self.config["dependencies"], extra_requirements): # pylint: disable=W8201 if req.marker is not None: marker = str(req.marker).lower() if 'platform_system != "linux"' in marker: continue elif 'platform_python_implementation != "cpython"' in marker: continue all_requirements.append(req) all_requirements = validate_requirements( prepare_requirements(all_requirements), self.config["conda-channels"], ) requirements_entries = [req for req in all_requirements if req and req != "numpy"] if [v.specifier for v in all_requirements if v == "numpy"]: requirements_entries.append(ComparableRequirement("numpy>=1.19.0")) return requirements_entries
[docs] def get_maintainers(self) -> Iterable[str]: """ Returns an iterable over the names of the project's maintainers. """ all_maintainers = set() if self.config["maintainers"]: for maintainer in self.config["maintainers"]: if "name" in maintainer: all_maintainers.add(repr(maintainer["name"])) elif self.config["authors"]: for maintainer in self.config["authors"]: if "name" in maintainer: all_maintainers.add(repr(maintainer["name"])) return all_maintainers
[docs] def make_conda_description(self) -> str: """ Create a description for the Conda package from its summary and a list of channels required to install it. """ return make_conda_description(self.config["description"], self.config["conda-channels"])
[docs] def get_urls(self) -> Iterable[str]: """ Returns an iterable of URL entries for the "about" section of the recipe. """ for label, url in self.config["urls"].items(): if label.lower() == "homepage": yield f"home: {str(url)!r}" # elif re.match("issue[s\s_-]*(tracker)?", label, flags=re.IGNORECASE): # yield f"home: {str(url)!r}" elif _source_code_re.match(label): # pylint: disable=W8202 yield f"dev_url: {str(url)!r}" elif _documentation_re.match(label): # pylint: disable=W8202 yield f"doc_url: {str(url)!r}"
_source_code_re = re.compile(r"source[\s_-]*(code)?", flags=re.IGNORECASE) _documentation_re = re.compile(r"doc(s|umentation)?", flags=re.IGNORECASE)
[docs]def make_recipe(project_dir: PathLike, recipe_file: PathLike) -> None: """ Make a Conda ``meta.yaml`` recipe. :param project_dir: The project directory. :param recipe_file: The file to save the recipe as. """ recipe_file = PathPlus(recipe_file) recipe_file.parent.maybe_make(parents=True) recipe_file.write_clean(MaryBerry(project_dir).make())