Skip to content

Commit

Permalink
Split parsing of Poetry versions into a separate module (#557)
Browse files Browse the repository at this point in the history
* Fix type hints

* Split nested function calls in coerce_to_semver

* Add more tests to fail on invalid semver

* Fix semver regex

Ensure that the regex applies to the entire string.

* Split history py_toml.py to parse_poetry_version.py - rename file to target-name

* Split history py_toml.py to parse_poetry_version.py - rename source-file to temp

* Split history py_toml.py to parse_poetry_version.py - restore name of source-file

* Complete the splitoff into parse_poetry_version

* Split history test_py_toml.py to test_parse_poetry_version.py - rename file to target-name

* Split history test_py_toml.py to test_parse_poetry_version.py - rename source-file to temp

* Split history test_py_toml.py to test_parse_poetry_version.py - restore name of source-file

* Complete the splitoff of the tests

* Convert most parse_poetry_version tests to doctests

This makes it more self-contained

* Update failing CRAN test

Please review carefully. The results changed but I know neither R nor the intention.

* Enable doctests
  • Loading branch information
maresb authored Sep 26, 2024
1 parent 2c1caa2 commit d30d42c
Show file tree
Hide file tree
Showing 7 changed files with 272 additions and 242 deletions.
15 changes: 15 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,21 @@ jobs:
conda info --all
conda list
- name: Running doctests
shell: bash -l {0}
run: |
pytest grayskull \
-vv \
-n 0 \
--color=yes \
--cov=./ \
--cov-append \
--cov-report html:coverage-serial-html \
--cov-report xml:coverage-serial.xml \
--cov-config=.coveragerc \
--junit-xml=Linux-py${{ matrix.py_ver }}-serial.xml \
--junit-prefix=Linux-py${{ matrix.py_ver }}-serial
- name: Running serial tests
shell: bash -l {0}
run: |
Expand Down
25 changes: 13 additions & 12 deletions grayskull/strategy/cran.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,23 +76,24 @@ def dict_from_cran_lines(lines):
def remove_package_line_continuations(chunk):
"""
>>> chunk = [
'Package: A3',
'Version: 0.9.2',
'Depends: R (>= 2.15.0), xtable, pbapply',
'Suggests: randomForest, e1071',
'Imports: MASS, R.methodsS3 (>= 1.5.2), R.oo (>= 1.15.8), R.utils (>=',
' 1.27.1), matrixStats (>= 0.8.12), R.filesets (>= 2.3.0), ',
' sampleSelection, scatterplot3d, strucchange, systemfit',
'License: GPL (>= 2)',
'NeedsCompilation: no']
>>> remove_package_line_continuations(chunk)
... 'Package: A3',
... 'Version: 0.9.2',
... 'Depends: R (>= 2.15.0), xtable, pbapply',
... 'Suggests: randomForest, e1071',
... 'Imports: MASS, R.methodsS3 (>= 1.5.2), R.oo (>= 1.15.8), R.utils (>=',
... ' 1.27.1), matrixStats (>= 0.8.12), R.filesets (>= 2.3.0), ',
... ' sampleSelection, scatterplot3d, strucchange, systemfit',
... 'License: GPL (>= 2)',
... 'NeedsCompilation: no']
>>> remove_package_line_continuations(chunk) # doctest: +NORMALIZE_WHITESPACE
['Package: A3',
'Version: 0.9.2',
'Depends: R (>= 2.15.0), xtable, pbapply',
'Suggests: randomForest, e1071',
'Imports: MASS, R.methodsS3 (>= 1.5.2), R.oo (>= 1.15.8), R.utils (>= 1.27.1), matrixStats (>= 0.8.12), R.filesets (>= 2.3.0), sampleSelection, scatterplot3d, strucchange, systemfit, rgl,'
'Imports: MASS, R.methodsS3 (>= 1.5.2), R.oo (>= 1.15.8), R.utils (>= 1.27.1), matrixStats (>= 0.8.12), R.filesets (>= 2.3.0), sampleSelection, scatterplot3d, strucchange, systemfit',
'License: GPL (>= 2)',
'NeedsCompilation: no']
'NeedsCompilation: no',
'']
""" # NOQA
continuation = (" ", "\t")
continued_ix = None
Expand Down
224 changes: 224 additions & 0 deletions grayskull/strategy/parse_poetry_version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
import re
from typing import Dict, Optional

import semver

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


def parse_version(version: str) -> Dict[str, Optional[int]]:
"""
Parses a version string (not necessarily semver) to a dictionary with keys
"major", "minor", and "patch". "minor" and "patch" are possibly None.
>>> parse_version("0")
{'major': 0, 'minor': None, 'patch': None}
>>> parse_version("1")
{'major': 1, 'minor': None, 'patch': None}
>>> parse_version("1.2")
{'major': 1, 'minor': 2, 'patch': None}
>>> parse_version("1.2.3")
{'major': 1, 'minor': 2, 'patch': 3}
"""
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[int]]) -> 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.is_valid(version):
return version

parsed_version = parse_version(version)
vinfo = vdict_to_vinfo(parsed_version)
return str(vinfo)


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
# Examples from Poetry docs
>>> get_caret_ceiling("0")
'1.0.0'
>>> get_caret_ceiling("0.0")
'0.1.0'
>>> get_caret_ceiling("0.0.3")
'0.0.4'
>>> get_caret_ceiling("0.2.3")
'0.3.0'
>>> get_caret_ceiling("1")
'2.0.0'
>>> get_caret_ceiling("1.2")
'2.0.0'
>>> get_caret_ceiling("1.2.3")
'2.0.0'
"""
if not semver.VersionInfo.is_valid(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.
# Examples from Poetry docs
>>> get_tilde_ceiling("1")
'2.0.0'
>>> get_tilde_ceiling("1.2")
'1.3.0'
>>> get_tilde_ceiling("1.2.3")
'1.3.0'
"""
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
# should be unchanged
>>> encode_poetry_version("1.*")
'1.*'
>>> encode_poetry_version(">=1,<2")
'>=1,<2'
>>> encode_poetry_version("==1.2.3")
'==1.2.3'
>>> encode_poetry_version("!=1.2.3")
'!=1.2.3'
# strip spaces
>>> encode_poetry_version(">= 1, < 2")
'>=1,<2'
# handle exact version specifiers correctly
>>> encode_poetry_version("1.2.3")
'1.2.3'
>>> encode_poetry_version("==1.2.3")
'==1.2.3'
# handle caret operator correctly
# examples from Poetry docs
>>> encode_poetry_version("^0")
'>=0.0.0,<1.0.0'
>>> encode_poetry_version("^0.0")
'>=0.0.0,<0.1.0'
>>> encode_poetry_version("^0.0.3")
'>=0.0.3,<0.0.4'
>>> encode_poetry_version("^0.2.3")
'>=0.2.3,<0.3.0'
>>> encode_poetry_version("^1")
'>=1.0.0,<2.0.0'
>>> encode_poetry_version("^1.2")
'>=1.2.0,<2.0.0'
>>> encode_poetry_version("^1.2.3")
'>=1.2.3,<2.0.0'
# handle tilde operator correctly
# examples from Poetry docs
>>> encode_poetry_version("~1")
'>=1.0.0,<2.0.0'
>>> encode_poetry_version("~1.2")
'>=1.2.0,<1.3.0'
>>> encode_poetry_version("~1.2.3")
'>=1.2.3,<1.3.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)
Loading

0 comments on commit d30d42c

Please sign in to comment.