Skip to content

Commit d30d42c

Browse files
authored
Split parsing of Poetry versions into a separate module (#557)
* 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
1 parent 2c1caa2 commit d30d42c

File tree

7 files changed

+272
-242
lines changed

7 files changed

+272
-242
lines changed

.github/workflows/tests.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,21 @@ jobs:
3535
conda info --all
3636
conda list
3737
38+
- name: Running doctests
39+
shell: bash -l {0}
40+
run: |
41+
pytest grayskull \
42+
-vv \
43+
-n 0 \
44+
--color=yes \
45+
--cov=./ \
46+
--cov-append \
47+
--cov-report html:coverage-serial-html \
48+
--cov-report xml:coverage-serial.xml \
49+
--cov-config=.coveragerc \
50+
--junit-xml=Linux-py${{ matrix.py_ver }}-serial.xml \
51+
--junit-prefix=Linux-py${{ matrix.py_ver }}-serial
52+
3853
- name: Running serial tests
3954
shell: bash -l {0}
4055
run: |

grayskull/strategy/cran.py

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -76,23 +76,24 @@ def dict_from_cran_lines(lines):
7676
def remove_package_line_continuations(chunk):
7777
"""
7878
>>> chunk = [
79-
'Package: A3',
80-
'Version: 0.9.2',
81-
'Depends: R (>= 2.15.0), xtable, pbapply',
82-
'Suggests: randomForest, e1071',
83-
'Imports: MASS, R.methodsS3 (>= 1.5.2), R.oo (>= 1.15.8), R.utils (>=',
84-
' 1.27.1), matrixStats (>= 0.8.12), R.filesets (>= 2.3.0), ',
85-
' sampleSelection, scatterplot3d, strucchange, systemfit',
86-
'License: GPL (>= 2)',
87-
'NeedsCompilation: no']
88-
>>> remove_package_line_continuations(chunk)
79+
... 'Package: A3',
80+
... 'Version: 0.9.2',
81+
... 'Depends: R (>= 2.15.0), xtable, pbapply',
82+
... 'Suggests: randomForest, e1071',
83+
... 'Imports: MASS, R.methodsS3 (>= 1.5.2), R.oo (>= 1.15.8), R.utils (>=',
84+
... ' 1.27.1), matrixStats (>= 0.8.12), R.filesets (>= 2.3.0), ',
85+
... ' sampleSelection, scatterplot3d, strucchange, systemfit',
86+
... 'License: GPL (>= 2)',
87+
... 'NeedsCompilation: no']
88+
>>> remove_package_line_continuations(chunk) # doctest: +NORMALIZE_WHITESPACE
8989
['Package: A3',
9090
'Version: 0.9.2',
9191
'Depends: R (>= 2.15.0), xtable, pbapply',
9292
'Suggests: randomForest, e1071',
93-
'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,'
93+
'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',
9494
'License: GPL (>= 2)',
95-
'NeedsCompilation: no']
95+
'NeedsCompilation: no',
96+
'']
9697
""" # NOQA
9798
continuation = (" ", "\t")
9899
continued_ix = None
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
import re
2+
from typing import Dict, Optional
3+
4+
import semver
5+
6+
VERSION_REGEX = re.compile(
7+
r"""^[vV]?
8+
(?P<major>0|[1-9]\d*)
9+
(\.
10+
(?P<minor>0|[1-9]\d*)
11+
(\.
12+
(?P<patch>0|[1-9]\d*)
13+
)?
14+
)?$
15+
""",
16+
re.VERBOSE,
17+
)
18+
19+
20+
class InvalidVersion(BaseException):
21+
pass
22+
23+
24+
def parse_version(version: str) -> Dict[str, Optional[int]]:
25+
"""
26+
Parses a version string (not necessarily semver) to a dictionary with keys
27+
"major", "minor", and "patch". "minor" and "patch" are possibly None.
28+
29+
>>> parse_version("0")
30+
{'major': 0, 'minor': None, 'patch': None}
31+
>>> parse_version("1")
32+
{'major': 1, 'minor': None, 'patch': None}
33+
>>> parse_version("1.2")
34+
{'major': 1, 'minor': 2, 'patch': None}
35+
>>> parse_version("1.2.3")
36+
{'major': 1, 'minor': 2, 'patch': 3}
37+
"""
38+
match = VERSION_REGEX.search(version)
39+
if not match:
40+
raise InvalidVersion(f"Could not parse version {version}.")
41+
42+
return {
43+
key: None if value is None else int(value)
44+
for key, value in match.groupdict().items()
45+
}
46+
47+
48+
def vdict_to_vinfo(version_dict: Dict[str, Optional[int]]) -> semver.VersionInfo:
49+
"""
50+
Coerces version dictionary to a semver.VersionInfo object. If minor or patch
51+
numbers are missing, 0 is substituted in their place.
52+
"""
53+
ver = {key: 0 if value is None else value for key, value in version_dict.items()}
54+
return semver.VersionInfo(**ver)
55+
56+
57+
def coerce_to_semver(version: str) -> str:
58+
"""
59+
Coerces a version string to a semantic version.
60+
"""
61+
if semver.VersionInfo.is_valid(version):
62+
return version
63+
64+
parsed_version = parse_version(version)
65+
vinfo = vdict_to_vinfo(parsed_version)
66+
return str(vinfo)
67+
68+
69+
def get_caret_ceiling(target: str) -> str:
70+
"""
71+
Accepts a Poetry caret target and returns the exclusive version ceiling.
72+
73+
Targets that are invalid semver strings (e.g. "1.2", "0") are handled
74+
according to the Poetry caret requirements specification, which is based on
75+
whether the major version is 0:
76+
77+
- If the major version is 0, the ceiling is determined by bumping the
78+
rightmost specified digit and then coercing it to semver.
79+
Example: 0 => 1.0.0, 0.1 => 0.2.0, 0.1.2 => 0.1.3
80+
81+
- If the major version is not 0, the ceiling is determined by
82+
coercing it to semver and then bumping the major version.
83+
Example: 1 => 2.0.0, 1.2 => 2.0.0, 1.2.3 => 2.0.0
84+
85+
# Examples from Poetry docs
86+
>>> get_caret_ceiling("0")
87+
'1.0.0'
88+
>>> get_caret_ceiling("0.0")
89+
'0.1.0'
90+
>>> get_caret_ceiling("0.0.3")
91+
'0.0.4'
92+
>>> get_caret_ceiling("0.2.3")
93+
'0.3.0'
94+
>>> get_caret_ceiling("1")
95+
'2.0.0'
96+
>>> get_caret_ceiling("1.2")
97+
'2.0.0'
98+
>>> get_caret_ceiling("1.2.3")
99+
'2.0.0'
100+
"""
101+
if not semver.VersionInfo.is_valid(target):
102+
target_dict = parse_version(target)
103+
104+
if target_dict["major"] == 0:
105+
if target_dict["minor"] is None:
106+
target_dict["major"] += 1
107+
elif target_dict["patch"] is None:
108+
target_dict["minor"] += 1
109+
else:
110+
target_dict["patch"] += 1
111+
return str(vdict_to_vinfo(target_dict))
112+
113+
vdict_to_vinfo(target_dict)
114+
return str(vdict_to_vinfo(target_dict).bump_major())
115+
116+
target_vinfo = semver.VersionInfo.parse(target)
117+
118+
if target_vinfo.major == 0:
119+
if target_vinfo.minor == 0:
120+
return str(target_vinfo.bump_patch())
121+
else:
122+
return str(target_vinfo.bump_minor())
123+
else:
124+
return str(target_vinfo.bump_major())
125+
126+
127+
def get_tilde_ceiling(target: str) -> str:
128+
"""
129+
Accepts a Poetry tilde target and returns the exclusive version ceiling.
130+
131+
# Examples from Poetry docs
132+
>>> get_tilde_ceiling("1")
133+
'2.0.0'
134+
>>> get_tilde_ceiling("1.2")
135+
'1.3.0'
136+
>>> get_tilde_ceiling("1.2.3")
137+
'1.3.0'
138+
"""
139+
target_dict = parse_version(target)
140+
if target_dict["minor"]:
141+
return str(vdict_to_vinfo(target_dict).bump_minor())
142+
143+
return str(vdict_to_vinfo(target_dict).bump_major())
144+
145+
146+
def encode_poetry_version(poetry_specifier: str) -> str:
147+
"""
148+
Encodes Poetry version specifier as a Conda version specifier.
149+
150+
Example: ^1 => >=1.0.0,<2.0.0
151+
152+
# should be unchanged
153+
>>> encode_poetry_version("1.*")
154+
'1.*'
155+
>>> encode_poetry_version(">=1,<2")
156+
'>=1,<2'
157+
>>> encode_poetry_version("==1.2.3")
158+
'==1.2.3'
159+
>>> encode_poetry_version("!=1.2.3")
160+
'!=1.2.3'
161+
162+
# strip spaces
163+
>>> encode_poetry_version(">= 1, < 2")
164+
'>=1,<2'
165+
166+
# handle exact version specifiers correctly
167+
>>> encode_poetry_version("1.2.3")
168+
'1.2.3'
169+
>>> encode_poetry_version("==1.2.3")
170+
'==1.2.3'
171+
172+
# handle caret operator correctly
173+
# examples from Poetry docs
174+
>>> encode_poetry_version("^0")
175+
'>=0.0.0,<1.0.0'
176+
>>> encode_poetry_version("^0.0")
177+
'>=0.0.0,<0.1.0'
178+
>>> encode_poetry_version("^0.0.3")
179+
'>=0.0.3,<0.0.4'
180+
>>> encode_poetry_version("^0.2.3")
181+
'>=0.2.3,<0.3.0'
182+
>>> encode_poetry_version("^1")
183+
'>=1.0.0,<2.0.0'
184+
>>> encode_poetry_version("^1.2")
185+
'>=1.2.0,<2.0.0'
186+
>>> encode_poetry_version("^1.2.3")
187+
'>=1.2.3,<2.0.0'
188+
189+
# handle tilde operator correctly
190+
# examples from Poetry docs
191+
>>> encode_poetry_version("~1")
192+
'>=1.0.0,<2.0.0'
193+
>>> encode_poetry_version("~1.2")
194+
'>=1.2.0,<1.3.0'
195+
>>> encode_poetry_version("~1.2.3")
196+
'>=1.2.3,<1.3.0'
197+
"""
198+
poetry_clauses = poetry_specifier.split(",")
199+
200+
conda_clauses = []
201+
for poetry_clause in poetry_clauses:
202+
poetry_clause = poetry_clause.replace(" ", "")
203+
if poetry_clause.startswith("^"):
204+
# handle ^ operator
205+
target = poetry_clause[1:]
206+
floor = coerce_to_semver(target)
207+
ceiling = get_caret_ceiling(target)
208+
conda_clauses.append(">=" + floor)
209+
conda_clauses.append("<" + ceiling)
210+
continue
211+
212+
if poetry_clause.startswith("~"):
213+
# handle ~ operator
214+
target = poetry_clause[1:]
215+
floor = coerce_to_semver(target)
216+
ceiling = get_tilde_ceiling(target)
217+
conda_clauses.append(">=" + floor)
218+
conda_clauses.append("<" + ceiling)
219+
continue
220+
221+
# other poetry clauses should be conda-compatible
222+
conda_clauses.append(poetry_clause)
223+
224+
return ",".join(conda_clauses)

0 commit comments

Comments
 (0)