"""
Get information about released & unreleased CPython and PyPy versions
Ever needed to know what Python versions were currently supported, or how many
subversions a given Python version had? Wondering how long until a given
version came out or reached end-of-life? Need to know what CPython versions a
given PyPy version corresponds to? The answers to these and some other
questions can be found with this library.
``pyversion-info`` pulls its data every run from
`jwodder/pyversion-info-data <https://github.com/jwodder/pyversion-info-data>`_
on GitHub. Prerelease versions are not (currently) included. I promise
24-hour turnaround times for keeping the database up-to-date until I am hit by
a bus.
Visit <https://github.com/jwodder/pyversion-info> or
<https://pyversion-info.rtfd.io> for more information.
"""
from __future__ import annotations
from collections import OrderedDict
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import date, datetime
import json
from pathlib import Path
from typing import Optional
from cachecontrol import CacheControl
from cachecontrol.caches.file_cache import FileCache
from platformdirs import user_cache_dir
import requests
from .util import MajorVersion, MicroVersion, MinorVersion, RawDatabase
__version__ = "1.2.2"
__author__ = "John Thorvald Wodder II"
__author_email__ = "pyversion-info@varonathe.org"
__license__ = "MIT"
__url__ = "https://github.com/jwodder/pyversion-info"
__all__ = [
"CACHE_DIR",
"CPythonVersionInfo",
"DATA_URL",
"PyPyVersionInfo",
"UnknownVersionError",
"VersionDatabase",
"VersionInfo",
]
#: The default URL from which the version database is downloaded
DATA_URL = (
"https://raw.githubusercontent.com/jwodder/pyversion-info-data/master"
"/pyversion-info-data.v1.json"
)
#: The default directory in which the downloaded version database is cached
CACHE_DIR = user_cache_dir("pyversion-info", "jwodder")
[docs]
@dataclass
class VersionDatabase:
"""
.. versionadded:: 1.0.0
A database of CPython and PyPy version information. Instances are
constructed from JSON objects following `this JSON Schema`__.
__ https://raw.githubusercontent.com/jwodder/pyversion-info-data/master/
pyversion-info-data.v1.schema.json
"""
#: The date & time when the database was last updated
last_modified: datetime
#: A database of CPython version information
cpython: CPythonVersionInfo
#: A database of PyPy version information
pypy: PyPyVersionInfo
[docs]
@classmethod
def fetch(
cls, url: str = DATA_URL, cache_dir: str | Path | None = CACHE_DIR
) -> VersionDatabase:
"""
Fetches the latest version information from the JSON document at
``url`` and returns a new `VersionDatabase` instance
:param str url: The URL from which to fetch the data
:param cache_dir: The directory to use for caching HTTP requests. May
be `None` to disable caching.
:type cache_dir: str | Path | None
:rtype: VersionDatabase
"""
s = requests.Session()
if cache_dir is not None:
s = CacheControl(s, cache=FileCache(str(cache_dir)))
with s:
r = s.get(url)
r.raise_for_status()
return cls.parse_obj(r.json())
[docs]
@classmethod
def parse_file(cls, filepath: str | Path) -> VersionDatabase:
"""
Parses a version database from a JSON file and returns a new
`VersionDatabase` instance
"""
with open(filepath, "rb") as fp:
return cls.parse_obj(json.load(fp))
[docs]
@classmethod
def parse_obj(cls, data: dict) -> VersionDatabase:
"""
Parses a version database from a `dict` deserialized from a JSON
document and returns a new `VersionDatabase` instance
"""
rawdb = RawDatabase.model_validate(data)
return cls(
last_modified=rawdb.last_modified,
cpython=CPythonVersionInfo(
rawdb.cpython.release_dates, rawdb.cpython.eol_dates
),
pypy=PyPyVersionInfo(rawdb.pypy.release_dates, rawdb.pypy.cpython_versions),
)
[docs]
class VersionInfo:
"""
.. versionadded:: 1.0.0
A base class for storing & querying versions and their release dates
"""
def __init__(self, release_dates: Mapping[MicroVersion, date | bool]) -> None:
self.release_dates: dict[MicroVersion, date | bool] = dict(release_dates)
self.version_trie: dict[int, dict[int, list[int]]] = OrderedDict()
for v in sorted(release_dates.keys()):
self.version_trie.setdefault(v.x, OrderedDict()).setdefault(v.y, []).append(
v.z
)
[docs]
def major_versions(self) -> list[str]:
"""
Returns a list in version order of all known major versions (as
strings).
.. versionchanged:: 1.0.0
Now returns all known versions, released & unreleased
"""
return list(map(str, self.version_trie.keys()))
[docs]
def minor_versions(self) -> list[str]:
"""
Returns a list in version order of all known minor versions
.. versionchanged:: 1.0.0
Now returns all known versions, released & unreleased
"""
minors: list[str] = []
for major, subtrie in self.version_trie.items():
minors.extend(f"{major}.{minor}" for minor in subtrie.keys())
return minors
[docs]
def micro_versions(self) -> list[str]:
"""
Returns a list in version order of all known micro versions
.. versionchanged:: 1.0.0
Now returns all known versions, released & unreleased
"""
micros: list[str] = []
for major, subtrie in self.version_trie.items():
for minor, sublist in subtrie.items():
micros.extend(f"{major}.{minor}.{mc}" for mc in sublist)
return micros
[docs]
def subversions(self, version: str) -> list[str]:
"""
Returns a list in version order of all known subversions of the given
version. If ``version`` is a major version, this is all of its minor
versions. If ``version`` is a minor version, this is all of its micro
versions.
.. versionchanged:: 1.0.0
Now returns all known subversions, released & unreleased
:param str version: a major or minor version
:raises UnknownVersionError: if there is no entry for ``version`` in
the database
:raises ValueError: if ``version`` is not a valid major or minor
version string
"""
v = parse_version(version)
try:
if isinstance(v, MajorVersion):
subs = [f"{v.x}.{y}" for y in self.version_trie[v.x].keys()]
elif isinstance(v, MinorVersion):
subs = [f"{v.x}.{v.y}.{z}" for z in self.version_trie[v.x][v.y]]
else:
assert isinstance(v, MicroVersion)
raise ValueError(f"Micro versions do not have subversions: {version!r}")
except KeyError:
raise UnknownVersionError(version)
return subs
def _release_date(self, version: str) -> date | bool:
v = parse_version(version)
try:
if isinstance(v, MajorVersion):
y, zs = next(iter(self.version_trie[v.x].items()))
m = MicroVersion.construct(v.x, y, zs[0])
elif isinstance(v, MinorVersion):
m = MicroVersion.construct(v.x, v.y, self.version_trie[v.x][v.y][0])
else:
assert isinstance(v, MicroVersion)
m = v
return self.release_dates[m]
except KeyError:
raise UnknownVersionError(version)
[docs]
def release_date(self, version: str) -> Optional[date]:
"""
Returns the release date of the given version. For a major or minor
version, this is the release date of its first (in version order) micro
version. The return value may be `None`, indicating that, though the
version is known to the database, its release date is not; use
`is_released()` to determine whether such a version has been released
yet.
.. versionchanged:: 1.0.0
Unknown release dates are now always returned as `None`
:param str version: the version to fetch the release date of
:rtype: Optional[datetime.date]
:raises UnknownVersionError: if there is no micro version corresponding
to ``version`` in the database
:raises ValueError: if ``version`` is not a valid version string
"""
d = self._release_date(version)
if isinstance(d, date):
return d
else:
return None
[docs]
def is_released(self, version: str) -> bool:
"""
Returns whether the given version has been released yet. For a major
or minor version, this is the whether the first (in version order)
micro version has been released.
:param str version: the version to query the release status of
:rtype: bool
:raises UnknownVersionError: if there is no micro version corresponding
to ``version`` in the database
:raises ValueError: if ``version`` is not a valid version string
"""
d = self._release_date(version)
if isinstance(d, date):
return d <= date.today()
else:
return d
[docs]
class CPythonVersionInfo(VersionInfo):
"""
A class for storing & querying CPython versions, their release dates, and
series EOL dates
.. versionchanged:: 1.0.0
This class was previously named ``PyVersionInfo``
"""
def __init__(
self,
release_dates: Mapping[MicroVersion, date | bool],
eol_dates: Mapping[MinorVersion, date | bool],
) -> None:
super().__init__(release_dates)
self.eol_dates: dict[MinorVersion, date | bool] = dict(eol_dates)
[docs]
def supported_series(self) -> list[str]:
"""
Returns a list in version order of all CPython version series (i.e.,
minor versions like 3.5) that are currently supported (i.e., that have
at least one release made and are not yet end-of-life)
"""
return [
v
for v in self.minor_versions()
if self.is_released(v) and not self.is_eol(v)
]
def _eol_date(self, version: str) -> date | bool:
v = parse_version(version)
try:
if isinstance(v, MajorVersion):
subdates = [
self.eol_dates[MinorVersion.construct(v.x, y)]
for y in self.version_trie[v.x].keys()
]
if all(
(isinstance(d, date) and d <= date.today()) or d is True
for d in subdates
):
return subdates[-1]
else:
return False
elif isinstance(v, MinorVersion):
return self.eol_dates[v]
else:
assert isinstance(v, MicroVersion)
return self.eol_dates[v.minor]
except KeyError:
raise UnknownVersionError(version)
[docs]
def eol_date(self, version: str) -> Optional[date]:
"""
Returns the end-of-life date of the given CPython version. The return
value may be `None`, indicating that, though the version is known to
the database, its EOL date is not; use `is_eol()` to determine whether
such a version has reached end-of-life yet.
For a major version, this method returns the EOL date of the last
subversion **if** every subversion is already end-of-life; otherwise,
it returns `None`. For a micro version, this returns the EOL date of
the corresponding minor version.
.. versionchanged:: 1.0.0
Unknown end-of-life dates are now always returned as `None`
.. versionchanged:: 1.1.0
Major and micro versions are now accepted
:param str version: the version to fetch the end-of-life date of
:rtype: Optional[datetime.date]
:raises UnknownVersionError: if there is no entry for ``version`` in
the end-of-life table
:raises ValueError: if ``version`` is not a valid version string
"""
d = self._eol_date(version)
if isinstance(d, date):
return d
else:
return None
[docs]
def is_eol(self, series: str) -> bool:
"""
Returns whether the given version has reached end-of-life yet. For a
major version, this is whether every subversion has reached
end-of-life. For a micro version, this is whether the corresponding
minor version has reached end-of-life.
.. versionchanged:: 1.1.0
Major and micro versions are now accepted
:param str series: a Python version number
:rtype: bool
:raises UnknownVersionError: if there is no entry for ``version`` in
the end-of-life table
:raises ValueError: if ``version`` is not a valid version string
"""
d = self._eol_date(series)
if isinstance(d, date):
return d <= date.today()
else:
return d
[docs]
def is_supported(self, version: str) -> bool:
"""
Returns whether the given version is currently supported. For a micro
version, this is whether it has been released and the corresponding
minor version is not yet end-of-life. For a major or minor version,
this is whether at least one subversion is supported.
:param str version: the version to query the support status of
:rtype: bool
:raises UnknownVersionError: if there is no entry for ``version`` in
the database
"""
v = parse_version(version)
if isinstance(v, MajorVersion):
return any(map(self.is_supported, self.subversions(v)))
elif isinstance(v, MinorVersion):
return (not self.is_eol(v)) and any(
map(self.is_released, self.subversions(v))
)
else:
assert isinstance(v, MicroVersion)
return (not self.is_eol(v.minor)) and self.is_released(version)
[docs]
class PyPyVersionInfo(VersionInfo):
"""
.. versionadded:: 1.0.0
A class for storing & querying PyPy versions, their release dates, and
their corresponding CPython versions
"""
def __init__(
self,
release_dates: Mapping[MicroVersion, date | bool],
cpython_versions: Mapping[MicroVersion, list[MicroVersion]],
) -> None:
super().__init__(release_dates)
self.cpython_versions: dict[MicroVersion, list[MicroVersion]] = {
v: sorted(versions) for v, versions in cpython_versions.items()
}
[docs]
def supported_cpython(self, version: str) -> list[str]:
"""
Given a PyPy micro version, returns a list of the corresponding CPython
micro versions in version order.
:raises UnknownVersionError: if there is no entry for ``version`` in
the database
:raises ValueError: if ``version`` is not a valid micro version string
"""
try:
v = MicroVersion(version)
except ValueError:
raise ValueError(f"Invalid micro version: {version!r}")
try:
return list(self.cpython_versions[v])
except KeyError:
raise UnknownVersionError(version)
[docs]
def supported_cpython_series(
self, version: str, released: bool = False
) -> list[str]:
"""
Given a PyPy version, returns a list of all CPython series supported by
that version or its subversions in version order. If ``released`` is
true, only released versions are considered.
>>> db.supported_cpython_series("7.3.5")
['2.7', '3.7']
>>> db.supported_cpython_series("7.3")
['2.7', '3.6', '3.7', '3.8']
>>> db.supported_cpython_series("7")
['2.7', '3.5', '3.6', '3.7', '3.8']
:raises UnknownVersionError: if there is no entry for ``version`` in
the database
:raises ValueError: if ``version`` is not a valid version string
"""
v = parse_version(version)
try:
if isinstance(v, MajorVersion):
micros = [
MicroVersion.construct(v.x, y, z)
for y, zs in self.version_trie[v.x].items()
for z in zs
]
elif isinstance(v, MinorVersion):
micros = [
MicroVersion.construct(v.x, v.y, z)
for z in self.version_trie[v.x][v.y]
]
else:
assert isinstance(v, MicroVersion)
micros = [v]
series_set = {
cpyv.minor
for m in micros
if not released or self.is_released(m)
for cpyv in self.cpython_versions[m]
}
return [str(v) for v in sorted(series_set)]
except KeyError:
raise UnknownVersionError(version)
[docs]
class UnknownVersionError(ValueError):
"""
Subclass of `ValueError` raised when a `VersionInfo` instance is asked for
information about a version that does not appear in its database.
Operations that result in an `UnknownVersionError` may succeed later as
more Python versions are announced & released.
"""
def __init__(self, version: str) -> None:
#: The unknown version the caller asked about
self.version = str(version)
def __str__(self) -> str:
return f"Unknown version: {self.version!r}"
def parse_version(s: str) -> MajorVersion | MinorVersion | MicroVersion:
"""
Convert a version string of the form ``X``, ``X.Y``, or ``X.Y.Z`` to a
`Version` instance
:raises ValueError: if ``s`` is not a valid version string
"""
dots = s.count(".")
try:
if dots == 0:
return MajorVersion(s)
elif dots == 1:
return MinorVersion(s)
else:
return MicroVersion(s)
except ValueError:
raise ValueError(f"Invalid version string: {s!r}")