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

Poetry deps w/ python version and platform, and multiple constraints dependencies #592

106 changes: 106 additions & 0 deletions grayskull/strategy/parse_poetry_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,3 +236,109 @@ def encode_poetry_version(poetry_specifier: str) -> str:
conda_clauses.append(poetry_clause)

return ",".join(conda_clauses)


def encode_poetry_platform_to_selector_item(poetry_platform: str) -> str:
"""
Encodes Poetry Platform specifier as a Conda selector.

Example: "darwin" => "osx"
"""

platform_selectors = {"windows": "win", "linux": "linux", "darwin": "osx"}
poetry_platform = poetry_platform.lower().strip()
if poetry_platform in platform_selectors:
return platform_selectors[poetry_platform]
else: # unknown
return ""


def encode_poetry_python_version_to_selector_item(poetry_specifier: str) -> str:
"""
Encodes Poetry Python version specifier as a Conda selector.

Example: ">=3.8,<3.12" => "py>=38 or py<312"

# handle exact version specifiers correctly
>>> encode_poetry_python_version_to_selector_item("3.8")
"py==38"
Copy link
Member

Choose a reason for hiding this comment

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

pytest is running your doctests here as well, that is why it is failing on ci

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is interesting, I've fixed the examples output format. Thanks for having a look at it!

>>> encode_poetry_python_version_to_selector_item("==3.8")
"py==38"
>>> encode_poetry_python_version_to_selector_item("!=3.8")
"py!=38"

# handle caret operator correctly
>>> encode_poetry_python_version_to_selector_item("^3.10")
# renders '>=3.10.0,<4.0.0'
"py>=310 or py<4"
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
"py>=310 or py<4"
"py>=310 and py<4"

Copy link
Contributor Author

Choose a reason for hiding this comment

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

fixed


# handle tilde operator correctly
>>> encode_poetry_python_version_to_selector_item("~3.10")
# renders '>=3.10.0,<3.11.0'
"py>=310 or py<311"
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
"py>=310 or py<311"
"py>=310 and py<311"

Copy link
Contributor Author

Choose a reason for hiding this comment

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

fixed

"""

if not poetry_specifier:
return ""

version_specifier = encode_poetry_version(poetry_specifier)

conda_clauses = version_specifier.split(",")

conda_selectors = []
for conda_clause in conda_clauses:
operator, version = parse_python_version(conda_clause)
version_selector = version.replace(".", "")
conda_selectors.append(f"py{operator}{version_selector}")
selectors = " or ".join(conda_selectors)
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
selectors = " or ".join(conda_selectors)
selectors = " and ".join(conda_selectors)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

fixed

return selectors


def parse_python_version(selector: str):
"""
Return operator and normalized version from a version selector

Examples:
">=3.8" -> ">=", "3.8"
">=3.8.0" -> ">=", "3.8"
"<4.0.0" -> "<", "4"
"3.12" -> "==", 3.12"
"=3.8" -> "==", "3.8"

The version is normalized to "major.minor" (drop patch if present)
or "major" if minor is 0
"""
# Regex to split operator and version
pattern = r"^(?P<operator>\^|~|>=|<=|!=|==|>|<|=)?(?P<version>\d+(\.\d+){0,2})$"
match = re.match(pattern, selector)
if not match:
raise ValueError(f"Invalid version selector: {selector}")

# Extract operator and version
operator = match.group("operator")
# Default to "==" if no operator is provided or "="
operator = "==" if operator in {None, "="} else operator
version = match.group("version")

# Split into major, minor, and discard the rest (patch or additional parts)
major, minor, *_ = version.split(".")

# Return only major if minor is "0", otherwise return major.minor
return operator, major if minor == "0" else f"{major}.{minor}"
Copy link
Contributor

Choose a reason for hiding this comment

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

I think we probably need this to also support:

parse_python_version('>=3')

When I tried that, I see (as I expected I might):

  File "/home/xylar/code/grayskull/poetry-deps-w-python-ver-platform-and-multiple-deps/./test.py", line 6, in <module>
    print(parse_python_version('>=3'))
          ~~~~~~~~~~~~~~~~~~~~^^^^^^^
  File "/home/xylar/code/grayskull/poetry-deps-w-python-ver-platform-and-multiple-deps/grayskull/strategy/parse_poetry_version.py", line 324, in parse_python_version
    major, minor, *_ = version.split(".")
    ^^^^^^^^^^^^^^^^
ValueError: not enough values to unpack (expected at least 2, got 1)

Could you add a test for this situation and make the necessary changes to support a single digit as well?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added support for 1 digit and three dots case (e.g. 3.8.0.1, never seen for Python version specifier but valid syntax). Thanks for checking that out!



def combine_conda_selectors(python_selector: str, platform_selector: str):
"""
Combine selectors based on presence
"""
if python_selector and platform_selector:
if " or " in python_selector:
python_selector = f"({python_selector})"
selector = f"{python_selector} and {platform_selector}"
elif python_selector:
selector = f"{python_selector}"
elif platform_selector:
selector = f"{platform_selector}"
else:
selector = ""
return f" # [{selector}]" if selector else ""
65 changes: 52 additions & 13 deletions grayskull/strategy/py_toml.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import sys
from collections import defaultdict
from collections.abc import Iterator
from functools import singledispatch
from pathlib import Path

from grayskull.strategy.parse_poetry_version import encode_poetry_version
from grayskull.strategy.parse_poetry_version import (
combine_conda_selectors,
encode_poetry_platform_to_selector_item,
encode_poetry_python_version_to_selector_item,
encode_poetry_version,
)
from grayskull.utils import nested_dict

if sys.version_info >= (3, 11):
Expand All @@ -17,35 +23,68 @@ class InvalidPoetryDependency(BaseException):


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


@get_constrained_dep.register
def __get_constrained_dep_dict(dep_spec: dict, dep_name: str) -> str:
def __get_constrained_dep_dict(
dep_spec: dict, dep_name: str
) -> Iterator[str, None, None]:
"""
Yield a dependency entry in conda format from a Poetry entry
with version, python version, and platform

Example:
dep_spec:
{"version": "^1.5", "python": ">=3.8,<3.12", "platform": "darwin"},
dep_name:
"pandas",
result yield:
"pandas >=1.5.0,<2.0.0 # [(py>=38 or py<312) and osx]"
"""
conda_version = encode_poetry_version(dep_spec.get("version", ""))
return f"{dep_name} {conda_version}".strip()
if conda_version:
conda_version = f" {conda_version}"
python_selector = encode_poetry_python_version_to_selector_item(
dep_spec.get("python", "")
)
platform_selector = encode_poetry_platform_to_selector_item(
dep_spec.get("platform", "")
)
conda_selector = combine_conda_selectors(python_selector, platform_selector)
yield f"{dep_name}{conda_version}{conda_selector}".strip()


@get_constrained_dep.register
def __get_constrained_dep_str(dep_spec: str, dep_name: str) -> str:
def __get_constrained_dep_str(
dep_spec: str, dep_name: str
) -> Iterator[str, None, None]:
conda_version = encode_poetry_version(dep_spec)
return f"{dep_name} {conda_version}"
yield f"{dep_name} {conda_version}"


@get_constrained_dep.register
def __get_constrained_dep_list(
dep_spec_list: list, dep_name: str
) -> Iterator[str, None, None]:
for dep_spec in dep_spec_list:
yield from get_constrained_dep(dep_spec, dep_name)


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)
for constrained_dep in 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


Expand Down
79 changes: 78 additions & 1 deletion tests/test_parse_poetry_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@

import pytest

from grayskull.strategy.parse_poetry_version import InvalidVersion, parse_version
from grayskull.strategy.parse_poetry_version import (
InvalidVersion,
combine_conda_selectors,
encode_poetry_python_version_to_selector_item,
parse_python_version,
parse_version,
)


@pytest.mark.parametrize(
Expand All @@ -11,3 +17,74 @@
def test_parse_version_failure(invalid_version):
with pytest.raises(InvalidVersion):
parse_version(invalid_version)


@pytest.mark.parametrize(
"poetry_python_specifier, exp_selector_item",
[
("", ""),
(">=3.5", "py>=35"),
(">=3.6", "py>=36"),
(">3.7", "py>37"),
("<=3.7", "py<=37"),
("<3.7", "py<37"),
("3.10", "py==310"),
("=3.10", "py==310"),
("==3.10", "py==310"),
("==3", "py==3"),
(">3.12", "py>312"),
("!=3.7", "py!=37"),
# multiple specifiers
(">3.7,<3.12", "py>37 or py<312"),
# poetry specifiers
("^3.10", "py>=310 or py<4"),
("~3.10", "py>=310 or py<311"),
# PEP 440 not common specifiers
# ("~=3.7", "<37", "<37"),
# ("3.*", "<37", "<37"),
# ("!=3.*", "<37", "<37"),
],
)
def test_encode_poetry_python_version_to_selector_item(
poetry_python_specifier, exp_selector_item
):
assert exp_selector_item == encode_poetry_python_version_to_selector_item(
poetry_python_specifier
)


@pytest.mark.parametrize(
"python_version, exp_operator_version",
[
(">=3.8", (">=", "3.8")),
(">=3.8.0", (">=", "3.8")),
("<4.0.0", ("<", "4")),
("3.12", ("==", "3.12")),
("=3.8", ("==", "3.8")),
("=3.8.1", ("==", "3.8")),
("3.8.1", ("==", "3.8")),
],
)
def test_parse_python_version(python_version, exp_operator_version):
operator, version = parse_python_version(python_version)
assert (operator, version) == exp_operator_version


@pytest.mark.parametrize(
"python_selector, platform_selector, exp_conda_selector_content",
[
("py>=38 or py<312", "osx", "(py>=38 or py<312) and osx"),
("py>=38 or py<312", "", "py>=38 or py<312"),
("", "osx", "osx"),
("py>=38", "", "py>=38"),
("py<310", "win", "py<310 and win"),
],
)
def test_combine_conda_selectors(
python_selector, platform_selector, exp_conda_selector_content
):
conda_selector = combine_conda_selectors(python_selector, platform_selector)
expected = (
f" # [{exp_conda_selector_content}]" if exp_conda_selector_content else ""
)
assert conda_selector == expected
Loading
Loading