diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9cd83fd05..d413d8a7a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -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: | diff --git a/grayskull/strategy/cran.py b/grayskull/strategy/cran.py index 0ebc64ff1..3ec559034 100644 --- a/grayskull/strategy/cran.py +++ b/grayskull/strategy/cran.py @@ -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 diff --git a/grayskull/strategy/parse_poetry_version.py b/grayskull/strategy/parse_poetry_version.py new file mode 100644 index 000000000..65aba425f --- /dev/null +++ b/grayskull/strategy/parse_poetry_version.py @@ -0,0 +1,224 @@ +import re +from typing import Dict, Optional + +import semver + +VERSION_REGEX = re.compile( + r"""^[vV]? + (?P0|[1-9]\d*) + (\. + (?P0|[1-9]\d*) + (\. + (?P0|[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) diff --git a/grayskull/strategy/py_toml.py b/grayskull/strategy/py_toml.py index 8edbaa25d..8681dd203 100644 --- a/grayskull/strategy/py_toml.py +++ b/grayskull/strategy/py_toml.py @@ -1,160 +1,20 @@ -import re from collections import defaultdict from functools import singledispatch from pathlib import Path -from typing import Dict, Optional, Tuple, Union +from typing import Tuple, Union -import semver import tomli +from grayskull.strategy.parse_poetry_version import encode_poetry_version from grayskull.utils import nested_dict -VERSION_REGEX = re.compile( - r"""[vV]? - (?P0|[1-9]\d*) - (\. - (?P0|[1-9]\d*) - (\. - (?P0|[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.is_valid(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.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. - """ - 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): +def get_constrained_dep(dep_spec: Union[str, dict], dep_name: str) -> str: raise InvalidPoetryDependency( "Expected Poetry dependency specification to be of type str or dict, " f"received {type(dep_spec).__name__}" @@ -162,13 +22,13 @@ def get_constrained_dep(dep_spec, dep_name): @get_constrained_dep.register -def __get_constrained_dep_dict(dep_spec: dict, dep_name: str): +def __get_constrained_dep_dict(dep_spec: dict, dep_name: str) -> str: conda_version = encode_poetry_version(dep_spec.get("version", "")) return f"{dep_name} {conda_version}".strip() @get_constrained_dep.register -def __get_constrained_dep_str(dep_spec: str, dep_name: str): +def __get_constrained_dep_str(dep_spec: str, dep_name: str) -> str: conda_version = encode_poetry_version(dep_spec) return f"{dep_name} {conda_version}" diff --git a/pytest.ini b/pytest.ini index 048f43276..9e80a78d6 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,8 +1,8 @@ [pytest] junit_family=xunit2 junit_duration_report=call -addopts = -ra -q -testpaths = tests +addopts = -ra -q --doctest-modules +testpaths = grayskull tests markers = serial: Mark for tests which cannot be executed in parallel github: Tests which need to communicate with github and might reach the limit of github requisitions diff --git a/tests/test_parse_poetry_version.py b/tests/test_parse_poetry_version.py new file mode 100644 index 000000000..96fdeb1bb --- /dev/null +++ b/tests/test_parse_poetry_version.py @@ -0,0 +1,13 @@ +"""Unit tests for parsing Poetry versions.""" + +import pytest + +from grayskull.strategy.parse_poetry_version import InvalidVersion, parse_version + + +@pytest.mark.parametrize( + "invalid_version", ["asdf", "", ".", "x.2.3", "1.x.3", "1.2.x"] +) +def test_parse_version_failure(invalid_version): + with pytest.raises(InvalidVersion): + parse_version(invalid_version) diff --git a/tests/test_py_toml.py b/tests/test_py_toml.py index 498fcbb6c..1c3cdf9c0 100644 --- a/tests/test_py_toml.py +++ b/tests/test_py_toml.py @@ -7,16 +7,11 @@ from grayskull.main import generate_recipes_from_list, init_parser from grayskull.strategy.py_toml import ( - InvalidVersion, add_flit_metadata, add_pep725_metadata, add_poetry_metadata, - encode_poetry_version, get_all_toml_info, - get_caret_ceiling, get_constrained_dep, - get_tilde_ceiling, - parse_version, ) @@ -27,84 +22,6 @@ def test_add_flit_metadata(): assert result == {"build": {"entry_points": ["key = value"]}} -@pytest.mark.parametrize( - "version, major, minor, patch", - [ - ("0", 0, None, None), - ("1", 1, None, None), - ("1.2", 1, 2, None), - ("1.2.3", 1, 2, 3), - ], -) -def test_parse_version_success(version, major, minor, patch): - assert parse_version(version) == {"major": major, "minor": minor, "patch": patch} - - -@pytest.mark.parametrize("invalid_version", ["asdf", "", "."]) -def test_parse_version_failure(invalid_version): - with pytest.raises(InvalidVersion): - parse_version(invalid_version) - - -@pytest.mark.parametrize( - "version, ceiling_version", - [ - ("0", "1.0.0"), - ("0.0", "0.1.0"), - ("0.0.3", "0.0.4"), - ("0.2.3", "0.3.0"), - ("1", "2.0.0"), - ("1.2", "2.0.0"), - ("1.2.3", "2.0.0"), - ], -) -def test_get_caret_ceiling(version, ceiling_version): - # examples from Poetry docs - assert get_caret_ceiling(version) == ceiling_version - - -@pytest.mark.parametrize( - "version, ceiling_version", - [("1", "2.0.0"), ("1.2", "1.3.0"), ("1.2.3", "1.3.0")], -) -def test_get_tilde_ceiling(version, ceiling_version): - # examples from Poetry docs - assert get_tilde_ceiling(version) == ceiling_version - - -@pytest.mark.parametrize( - "version, encoded_version", - [ - # should be unchanged - ("1.*", "1.*"), - (">=1,<2", ">=1,<2"), - ("==1.2.3", "==1.2.3"), - ("!=1.2.3", "!=1.2.3"), - # strip spaces - (">= 1, < 2", ">=1,<2"), - # handle exact version specifiers correctly - ("1.2.3", "1.2.3"), - ("==1.2.3", "==1.2.3"), - # handle caret operator correctly - # examples from Poetry docs - ("^0", ">=0.0.0,<1.0.0"), - ("^0.0", ">=0.0.0,<0.1.0"), - ("^0.0.3", ">=0.0.3,<0.0.4"), - ("^0.2.3", ">=0.2.3,<0.3.0"), - ("^1", ">=1.0.0,<2.0.0"), - ("^1.2", ">=1.2.0,<2.0.0"), - ("^1.2.3", ">=1.2.3,<2.0.0"), - # handle tilde operator correctly - # examples from Poetry docs - ("~1", ">=1.0.0,<2.0.0"), - ("~1.2", ">=1.2.0,<1.3.0"), - ("~1.2.3", ">=1.2.3,<1.3.0"), - ], -) -def test_encode_poetry_version(version, encoded_version): - assert encode_poetry_version(version) == encoded_version - - def test_add_poetry_metadata(): toml_metadata = { "tool": {