Skip to content

Commit 2c9674c

Browse files
committed
Command-line interface
1 parent 2fbebcb commit 2c9674c

9 files changed

Lines changed: 1488 additions & 26 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ v0.4.0 (in development)
44
`subversions()` now take optional `unreleased` arguments for including
55
unreleased versions
66
- `is_supported()` now accepts major and micro versions
7+
- `UnknownVersionError` now inherits `ValueError`
8+
- Added a command-line interface
79

810
v0.3.0 (2021-10-01)
911
-------------------

README.rst

Lines changed: 101 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -166,10 +166,10 @@ Utilities
166166
---------
167167

168168
``UnknownVersionError``
169-
Exception raised when ``PyVersionInfo`` is asked for information about a
170-
version that does not appear in its database. Operations that result in an
171-
``UnknownVersionError`` may succeed later as more Python versions are
172-
announced & released.
169+
Subclass of ``ValueError`` raised when ``PyVersionInfo`` is asked for
170+
information about a version that does not appear in its database.
171+
Operations that result in an ``UnknownVersionError`` may succeed later as
172+
more Python versions are announced & released.
173173

174174
The unknown version is stored in an ``UnknownVersionError`` instance's
175175
``version`` attribute.
@@ -181,8 +181,103 @@ Utilities
181181
disable caching).
182182

183183

184-
Restrictions
185-
============
184+
Command
185+
=======
186+
187+
*New in version 0.4.0*
188+
189+
``pyversion-info`` also provides a command of the same name for querying
190+
information about Python versions from the command line::
191+
192+
pyversion-info [<global-options>] <command> [<args> ...]
193+
194+
Currently, ``pyversion-info`` has two subcommands, ``list`` and ``show``.
195+
196+
197+
Global Options
198+
--------------
199+
200+
-d DATABASE, --database DATABASE
201+
Use the given JSON file as the version
202+
information database instead of fetching data
203+
from the default URL. ``DATABASE`` can be
204+
either an HTTP or HTTPS URL or a path to a
205+
local file.
206+
207+
208+
``pyversion-info list``
209+
-----------------------
210+
211+
::
212+
213+
pyversion-info [<global-options>] list [<options>] {major|minor|micro}
214+
215+
List all major, minor, or micro Python versions, one per line.
216+
217+
218+
Options
219+
^^^^^^^
220+
221+
-a, --all List all known versions of the given level
222+
-n, --not-eol Only list versions that have not yet reached
223+
end-of-life (i.e., all supported versions plus
224+
all unreleased versions)
225+
-r, --released Only list released versions. This is the
226+
default.
227+
-s, --supported Only list currently-supported versions
228+
229+
230+
``pyversion-info show``
231+
-----------------------
232+
233+
::
234+
235+
pyversion-info [<global-options>] show [<options>] <version>
236+
237+
Show various information about a given Python version.
238+
239+
For a major version, the output is of the form::
240+
241+
Version: 3
242+
Level: major
243+
Release-date: 2008-12-03
244+
Is-released: yes
245+
Is-supported: yes
246+
Subversions: 3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7, 3.8, 3.9
247+
248+
For a minor version, the output is of the form::
249+
250+
Version: 3.3
251+
Level: minor
252+
Release-date: 2012-09-29
253+
Is-released: yes
254+
Is-supported: no
255+
EOL-date: 2017-09-29
256+
Is-EOL: yes
257+
Subversions: 3.3.0, 3.3.1, 3.3.2, 3.3.3, 3.3.4, 3.3.5, 3.3.6, 3.3.7
258+
259+
For a micro version, the output is of the form::
260+
261+
Version: 3.9.5
262+
Level: micro
263+
Release-date: 2021-05-03
264+
Is-released: yes
265+
Is-supported: yes
266+
267+
268+
Options
269+
^^^^^^^
270+
271+
-J, --json Output JSON
272+
273+
-S, --subversions [all|not-eol|released|supported]
274+
Which subversions to list; the choices have the
275+
same meanings as the ``list`` options of the
276+
same name [default: released]
277+
278+
279+
Caveats
280+
=======
186281

187282
The database is generally only updated when an edit is made to a release
188283
schedule PEP. Occasionally, a deadline listed in a PEP is missed, but the PEP

setup.cfg

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,17 @@ include_package_data = True
4444
python_requires = ~=3.6
4545
install_requires =
4646
cachecontrol[filecache] ~= 0.12.0
47+
click >= 8.0
4748
platformdirs ~= 2.1
4849
requests ~= 2.20
4950

5051
[options.packages.find]
5152
where = src
5253

54+
[options.entry_points]
55+
console_scripts =
56+
pyversion-info = pyversion_info.__main__:main
57+
5358
[mypy]
5459
allow_incomplete_defs = False
5560
allow_untyped_defs = False

src/pyversion_info/__init__.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,7 @@ def is_supported(self, version: str) -> bool:
273273
"""
274274
v = parse_version(version)
275275
if len(v) == 1:
276-
return any(map(lambda u: not self.is_eol(u), self.subversions(version)))
276+
return not all(map(self.is_eol, self.subversions(version)))
277277
elif len(v) == 2:
278278
return (not self.is_eol(version)) and bool(self.subversions(version))
279279
else:
@@ -323,12 +323,12 @@ def subversions(self, version: str, unreleased: bool = False) -> List[str]:
323323
return list(filter(self.is_released, subs))
324324

325325

326-
class UnknownVersionError(Exception):
326+
class UnknownVersionError(ValueError):
327327
"""
328-
Exception raised when `PyVersionInfo` is asked for information about a
329-
version that does not appear in its database. Operations that result in an
330-
`UnknownVersionError` may succeed later as more Python versions are
331-
announced & released.
328+
Subclass of `ValueError` raised when `PyVersionInfo` is asked for
329+
information about a version that does not appear in its database.
330+
Operations that result in an `UnknownVersionError` may succeed later as
331+
more Python versions are announced & released.
332332
"""
333333

334334
def __init__(self, version: str) -> None:

src/pyversion_info/__main__.py

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
from datetime import date
2+
from functools import partial, wraps
3+
import json
4+
import re
5+
from typing import Any, Callable, Dict, Iterator, List, Optional
6+
import click
7+
from . import (
8+
PyVersionInfo,
9+
__version__,
10+
get_pyversion_info,
11+
parse_version,
12+
unparse_version,
13+
)
14+
15+
16+
def map_exc_to_click(func: Callable) -> Callable:
17+
@wraps(func)
18+
def wrapped(*args: Any, **kwargs: Any) -> Any:
19+
try:
20+
return func(*args, **kwargs)
21+
except ValueError as e:
22+
raise click.UsageError(str(e))
23+
24+
return wrapped
25+
26+
27+
@click.group()
28+
@click.version_option(
29+
__version__,
30+
"-V",
31+
"--version",
32+
message="%(prog)s %(version)s",
33+
)
34+
@click.option(
35+
"-d",
36+
"--database",
37+
metavar="FILE|URL",
38+
help="Fetch version information from the given database",
39+
)
40+
@click.pass_context
41+
def main(ctx: click.Context, database: Optional[str]) -> None:
42+
"""Show details about Python versions"""
43+
if database is None:
44+
ctx.obj = get_pyversion_info()
45+
elif database.lower().startswith(("http://", "https://")):
46+
ctx.obj = get_pyversion_info(database)
47+
else:
48+
with open(database, "rb") as fp:
49+
ctx.obj = PyVersionInfo(json.load(fp))
50+
51+
52+
@main.command("list")
53+
@click.option("-a", "--all", "mode", flag_value="all", help="List all known versions")
54+
@click.option(
55+
"-n",
56+
"--not-eol",
57+
"mode",
58+
flag_value="not-eol",
59+
help="List only versions that are not EOL (supported versions + unreleased versions)",
60+
)
61+
@click.option(
62+
"-r",
63+
"--released",
64+
"mode",
65+
flag_value="released",
66+
help="List only released versions [default]",
67+
default=True,
68+
)
69+
@click.option(
70+
"-s",
71+
"--supported",
72+
"mode",
73+
flag_value="supported",
74+
help="List only supported versions",
75+
)
76+
@click.argument("level", type=click.Choice(["major", "minor", "micro"]))
77+
@click.pass_obj
78+
@map_exc_to_click
79+
def list_cmd(pyvinfo: PyVersionInfo, level: str, mode: str) -> None:
80+
"""List known versions at the given version level"""
81+
func = {
82+
"major": pyvinfo.major_versions,
83+
"minor": pyvinfo.minor_versions,
84+
"micro": pyvinfo.micro_versions,
85+
}[level]
86+
for v in filter_versions(mode, pyvinfo, func):
87+
print(v)
88+
89+
90+
@main.command()
91+
@click.option("-J", "--json", "do_json", is_flag=True, help="Output JSON")
92+
@click.option(
93+
"-S",
94+
"--subversions",
95+
type=click.Choice(["all", "not-eol", "released", "supported"]),
96+
help="Which subversions to list",
97+
default="released",
98+
show_default=True,
99+
)
100+
@click.argument("version")
101+
@click.pass_obj
102+
@map_exc_to_click
103+
def show(pyvinfo: PyVersionInfo, version: str, subversions: str, do_json: bool) -> None:
104+
"""Show information about a Python version"""
105+
v = parse_version(version)
106+
data: Dict[str, Any] = {
107+
"version": unparse_version(v),
108+
"level": None, # So it will show up on line 2
109+
"release_date": pyvinfo.release_date(version),
110+
"is_released": pyvinfo.is_released(version),
111+
"is_supported": pyvinfo.is_supported(version),
112+
}
113+
if len(v) == 1:
114+
data["level"] = "major"
115+
data["subversions"] = list(
116+
filter_versions(subversions, pyvinfo, partial(pyvinfo.subversions, version))
117+
)
118+
elif len(v) == 2:
119+
data["level"] = "minor"
120+
data["eol_date"] = pyvinfo.eol_date(version)
121+
data["is_eol"] = pyvinfo.is_eol(version)
122+
data["subversions"] = list(
123+
filter_versions(subversions, pyvinfo, partial(pyvinfo.subversions, version))
124+
)
125+
else:
126+
data["level"] = "micro"
127+
if do_json:
128+
print(json.dumps(data, indent=4, default=str))
129+
else:
130+
for k, val in data.items():
131+
label = re.sub(r"[Ee]ol", "EOL", k.replace("_", "-").capitalize())
132+
if k in ("release_date", "eol_date"):
133+
if isinstance(val, date):
134+
val = str(val)
135+
else:
136+
val = "UNKNOWN"
137+
elif isinstance(val, bool):
138+
val = "yes" if val else "no"
139+
elif isinstance(val, list):
140+
val = ", ".join(val)
141+
print(f"{label}: {val}")
142+
143+
144+
def is_not_eol(pyvinfo: PyVersionInfo, version: str) -> bool:
145+
v = parse_version(version)
146+
if len(v) == 1:
147+
return not all(
148+
map(pyvinfo.is_eol, pyvinfo.subversions(version, unreleased=True))
149+
)
150+
elif len(v) == 2:
151+
return not pyvinfo.is_eol(version)
152+
else:
153+
x, y, _ = v
154+
return not pyvinfo.is_eol(unparse_version((x, y)))
155+
156+
157+
def yes(version: str) -> bool: # noqa: U100
158+
return True
159+
160+
161+
def filter_versions(
162+
mode: str, pyvinfo: PyVersionInfo, vfunc: Callable[[bool], List[str]]
163+
) -> Iterator[str]:
164+
if mode == "all":
165+
filterer = yes
166+
unreleased = True
167+
elif mode == "released":
168+
filterer = yes
169+
unreleased = False
170+
elif mode == "supported":
171+
filterer = pyvinfo.is_supported
172+
unreleased = False
173+
elif mode == "not-eol":
174+
filterer = partial(is_not_eol, pyvinfo)
175+
unreleased = True
176+
else:
177+
raise AssertionError(f"Unexpected mode: {mode!r}") # pragma: no cover
178+
return filter(filterer, vfunc(unreleased))
179+
180+
181+
if __name__ == "__main__":
182+
main() # pragma: no cover

test/conftest.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import pytest
2+
from pytest_mock import MockerFixture
3+
4+
5+
@pytest.fixture(autouse=True)
6+
def use_fixed_date(mocker: MockerFixture) -> None:
7+
# Mocking/monkeypatching just the `today()` method of `date` doesn't seem
8+
# to be an option, and monkeypatching the `date` class with a custom class
9+
# that just implements `today()` causes problems on PyPy. Fortunately,
10+
# both CPython and PyPy implement `date.today()` by calling `time.time()`,
11+
# so we just need to mock that and hope the implementation never changes.
12+
mocker.patch("time.time", return_value=1556052408)
13+
# Time is now 2019-04-23T16:46:48-04:00.

test/data/pyversion-info-data.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@
141141
"3.7.3": "2019-03-25",
142142
"3.7.4": "2019-06-24",
143143
"3.8.0": "2019-10-21",
144-
"4.0.0": "9999-12-31"
144+
"4.0.0": "9999-01-01"
145145
},
146146
"series_eol_dates": {
147147
"0.9": true,
@@ -168,6 +168,7 @@
168168
"3.5": "2020-09-13",
169169
"3.6": "2021-12-23",
170170
"3.7": "2023-06-27",
171-
"3.8": "2024-10-01"
171+
"3.8": "2024-10-01",
172+
"4.0": "9999-12-31"
172173
}
173174
}

0 commit comments

Comments
 (0)