Skip to content

Commit

Permalink
Support Poetry version syntax and optional dependencies (#454)
Browse files Browse the repository at this point in the history
* implement support for Poetry version syntax and optional dependencies

* add semver to environment.yaml
  • Loading branch information
dlqqq authored Mar 27, 2023
1 parent 8a47423 commit 413b1a1
Show file tree
Hide file tree
Showing 11 changed files with 464 additions and 71 deletions.
1 change: 1 addition & 0 deletions environment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,4 @@ dependencies:
- tomli-w
- libcblas
- beautifulsoup4
- semver
13 changes: 9 additions & 4 deletions grayskull/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,7 @@
logging.basicConfig(format="%(levelname)s:%(message)s")


def main(args=None):
if not args:
args = sys.argv[1:] or ["--help"]

def init_parser():
# create the top-level parser
parser = argparse.ArgumentParser(description="Grayskull - Conda recipe generator")
subparsers = parser.add_subparsers(help="sub-command help")
Expand Down Expand Up @@ -246,6 +243,14 @@ def main(args=None):
help="Exclude folders when searching for licence.",
)

return parser


def main(args=None):
if not args:
args = sys.argv[1:] or ["--help"]

parser = init_parser()
args = parser.parse_args(args)

if args.version:
Expand Down
4 changes: 4 additions & 0 deletions grayskull/strategy/py_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -732,6 +732,10 @@ def merge_setup_toml_metadata(setup_metadata: dict, pyproject_metadata: dict) ->
setup_metadata.get("install_requires", []),
pyproject_metadata["requirements"]["run"],
)
if pyproject_metadata["requirements"]["run_constrained"]:
setup_metadata["requirements_run_constrained"] = pyproject_metadata[
"requirements"
]["run_constrained"]
return setup_metadata


Expand Down
224 changes: 198 additions & 26 deletions grayskull/strategy/py_toml.py
Original file line number Diff line number Diff line change
@@ -1,47 +1,219 @@
import re
from collections import defaultdict
from functools import singledispatch
from pathlib import Path
from typing import Union
from typing import Dict, Optional, Tuple, Union

import semver
import tomli

from grayskull.utils import nested_dict

VERSION_REGEX = re.compile(
r"""[vV]?
(?P<major>0|[1-9]\d*)
(\.
(?P<minor>0|[1-9]\d*)
(\.
(?P<patch>0|[1-9]\d*)
)?
)?
""",
re.VERBOSE,
)


class InvalidVersion(BaseException):
pass


class InvalidPoetryDependency(BaseException):
pass


def parse_version(version: str) -> Dict[str, Optional[str]]:
"""
Parses a version string (not necessarily semver) to a dictionary with keys
"major", "minor", and "patch". "minor" and "patch" are possibly None.
"""
match = VERSION_REGEX.search(version)
if not match:
raise InvalidVersion(f"Could not parse version {version}.")

return {
key: None if value is None else int(value)
for key, value in match.groupdict().items()
}


def vdict_to_vinfo(version_dict: Dict[str, Optional[str]]) -> semver.VersionInfo:
"""
Coerces version dictionary to a semver.VersionInfo object. If minor or patch
numbers are missing, 0 is substituted in their place.
"""
ver = {key: 0 if value is None else value for key, value in version_dict.items()}
return semver.VersionInfo(**ver)


def coerce_to_semver(version: str) -> str:
"""
Coerces a version string to a semantic version.
"""
if semver.VersionInfo.isvalid(version):
return version

return str(vdict_to_vinfo(parse_version(version)))


def get_caret_ceiling(target: str) -> str:
"""
Accepts a Poetry caret target and returns the exclusive version ceiling.
Targets that are invalid semver strings (e.g. "1.2", "0") are handled
according to the Poetry caret requirements specification, which is based on
whether the major version is 0:
- If the major version is 0, the ceiling is determined by bumping the
rightmost specified digit and then coercing it to semver.
Example: 0 => 1.0.0, 0.1 => 0.2.0, 0.1.2 => 0.1.3
- If the major version is not 0, the ceiling is determined by
coercing it to semver and then bumping the major version.
Example: 1 => 2.0.0, 1.2 => 2.0.0, 1.2.3 => 2.0.0
"""
if not semver.VersionInfo.isvalid(target):
target_dict = parse_version(target)

if target_dict["major"] == 0:
if target_dict["minor"] is None:
target_dict["major"] += 1
elif target_dict["patch"] is None:
target_dict["minor"] += 1
else:
target_dict["patch"] += 1
return str(vdict_to_vinfo(target_dict))

vdict_to_vinfo(target_dict)
return str(vdict_to_vinfo(target_dict).bump_major())

target_vinfo = semver.VersionInfo.parse(target)

if target_vinfo.major == 0:
if target_vinfo.minor == 0:
return str(target_vinfo.bump_patch())
else:
return str(target_vinfo.bump_minor())
else:
return str(target_vinfo.bump_major())


def get_tilde_ceiling(target: str) -> str:
"""
Accepts a Poetry tilde target and returns the exclusive version ceiling.
"""
target_dict = parse_version(target)
if target_dict["minor"]:
return str(vdict_to_vinfo(target_dict).bump_minor())

return str(vdict_to_vinfo(target_dict).bump_major())


def encode_poetry_version(poetry_specifier: str) -> str:
"""
Encodes Poetry version specifier as a Conda version specifier.
Example: ^1 => >=1.0.0,<2.0.0
"""
poetry_clauses = poetry_specifier.split(",")

conda_clauses = []
for poetry_clause in poetry_clauses:
poetry_clause = poetry_clause.replace(" ", "")
if poetry_clause.startswith("^"):
# handle ^ operator
target = poetry_clause[1:]
floor = coerce_to_semver(target)
ceiling = get_caret_ceiling(target)
conda_clauses.append(">=" + floor)
conda_clauses.append("<" + ceiling)
continue

if poetry_clause.startswith("~"):
# handle ~ operator
target = poetry_clause[1:]
floor = coerce_to_semver(target)
ceiling = get_tilde_ceiling(target)
conda_clauses.append(">=" + floor)
conda_clauses.append("<" + ceiling)
continue

# other poetry clauses should be conda-compatible
conda_clauses.append(poetry_clause)

return ",".join(conda_clauses)


@singledispatch
def get_constrained_dep(dep_spec, dep_name):
raise InvalidPoetryDependency(
"Expected Poetry dependency specification to be of type str or dict, "
f"received {type(dep_spec).__name__}"
)


@get_constrained_dep.register
def __get_constrained_dep_dict(dep_spec: dict, dep_name: str):
conda_version = encode_poetry_version(dep_spec["version"])
return f"{dep_name} {conda_version}"


@get_constrained_dep.register
def __get_constrained_dep_str(dep_spec: str, dep_name: str):
conda_version = encode_poetry_version(dep_spec)
return f"{dep_name} {conda_version}"


def encode_poetry_deps(poetry_deps: dict) -> Tuple[list, list]:
run = []
run_constrained = []
for dep_name, dep_spec in poetry_deps.items():
constrained_dep = get_constrained_dep(dep_spec, dep_name)
try:
assert dep_spec.get("optional", False)
run_constrained.append(constrained_dep)
except (AttributeError, AssertionError):
run.append(constrained_dep)
return run, run_constrained


def add_poetry_metadata(metadata: dict, toml_metadata: dict) -> dict:
if not is_poetry_present(toml_metadata):
return metadata

def flat_deps(dict_deps: dict) -> list:
result = []
for pkg_name, version in dict_deps.items():
if isinstance(version, dict):
version_spec = version["version"].strip()
del version["version"]
version = (
f"{version_spec}{' ; '.join(f'{k} {v}' for k,v in version.items())}"
)
version = f"=={version}" if version and version[0].isdigit() else version
result.append(f"{pkg_name} {version}".strip())
return result

poetry_metadata = toml_metadata["tool"]["poetry"]
if poetry_run := flat_deps(poetry_metadata.get("dependencies", {})):
if not metadata["requirements"]["run"]:
metadata["requirements"]["run"] = []
metadata["requirements"]["run"].extend(poetry_run)
poetry_deps = poetry_metadata.get("dependencies", {})
req_run, req_run_constrained = encode_poetry_deps(poetry_deps)

# add dependencies
metadata["requirements"].setdefault("run", [])
metadata["requirements"]["run"].extend(req_run)

# add optional dependencies
if len(req_run_constrained):
metadata["requirements"].setdefault("run_constrained", [])
metadata["requirements"]["run_constrained"].extend(req_run_constrained)

host_metadata = metadata["requirements"].get("host", [])
if "poetry" not in host_metadata and "poetry-core" not in host_metadata:
metadata["requirements"]["host"] = host_metadata + ["poetry-core"]

test_metadata = metadata["test"].get("requires", []) or []
if (
test_deps := poetry_metadata.get("group", {})
.get("test", {})
.get("dependencies", {})
):
test_deps = flat_deps(test_deps)
metadata["test"]["requires"] = test_metadata + test_deps
poetry_test_deps = (
poetry_metadata.get("group", {}).get("test", {}).get("dependencies", {})
)
# add required test dependencies and ignore optional test dependencies, as
# there doesn't appear to be a way to specify them in Conda recipe metadata.
test_reqs, _ = encode_poetry_deps(poetry_test_deps)
metadata["test"].get("requires", []).extend(test_reqs)
return metadata


Expand Down
4 changes: 4 additions & 0 deletions grayskull/strategy/pypi.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ def get_val(key):
"extras_require": get_val("extras_require"),
"requires_dist": requires_dist,
"sdist_path": get_val("sdist_path"),
"requirements_run_constrained": get_val("requirements_run_constrained"),
}


Expand Down Expand Up @@ -571,6 +572,9 @@ def extract_requirements(metadata: dict, config, recipe) -> Dict[str, List[str]]
"run": rm_duplicated_deps(sort_reqs(map(lambda x: x.lower(), run_req))),
}
)

if metadata.get("requirements_run_constrained", None):
result.update({"run_constrained": metadata["requirements_run_constrained"]})
update_requirements_with_pin(result)
return result

Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ dependencies = [
"ruamel.yaml >=0.16.10",
"ruamel.yaml.jinja2",
"setuptools >=30.3.0",
"semver~=2.13.0",
"stdlib-list",
"tomli",
"tomli-w",
Expand Down
Loading

0 comments on commit 413b1a1

Please sign in to comment.