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

Split parsing of Poetry versions into a separate module #557

Merged
merged 18 commits into from
Sep 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Copy link
Member

@marcelotrevisani marcelotrevisani Sep 20, 2024

Choose a reason for hiding this comment

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

can you revert this and add a new step to run the doctests please?
I think it would be better to have the doctests running step separated from the unittests

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Cool, thanks so much for the review!

I can certainly rearrange the tests, but could you explain a bit about the current structure so that I can implement it correctly? In particular, should the doctests execute before or after the serial/parallel tests?

I don't understand why the tests are currently split into serial/parallel, and from what I can see they are executing in exactly the same context, so I'm confused. By default, unless there's some good reason, I'd expect the tests to run all together in the same job, so that if some of the tests fail then you see all the failing tests rather than only the failing tests in the particular job.

Copy link
Member

Choose a reason for hiding this comment

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

The tests are split into serial and parallel because the serial tests can interfere with other tests and make them fail.

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
Loading