Skip to content
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
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ classifiers = [
keywords = ["spec0", "package support", "cli"]

dependencies = [
"packaging",
"requests",
]

Expand All @@ -38,6 +39,9 @@ dev = [
"pre-commit",
]

[project.scripts]
spec0 = "spec0.cli:cli_main"

[tool.setuptools]
packages = ["spec0"]
package-dir = { "" = "src" }
Expand Down
197 changes: 197 additions & 0 deletions src/spec0/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import argparse
import logging

from functools import partial

from spec0.releasesource import PyPIReleaseSource, CondaReleaseSource
from spec0.releasefilters import SPEC0StrictDate, SPEC0Quarter
from spec0.output import terminal_output, json_output, specifier_output
from spec0.main import main


def make_parser():
"""Make the command line parser for the spec0 CLI."""
parser = argparse.ArgumentParser(
prog="spec0",
description=(
"List versions of a given package that should be supported "
"according to spec0-style rules. This can be customized in "
"3 ways: the source of the release information, the filter "
"that defines supported versions, and the output format."
"By default, we search first if this package is known as "
"a GitHub release, then we check for it on PyPI, and finally "
"on conda-forge (in noarch are linux-64). The default uses "
"SPEC0 according to the exact date of the release, and outputs "
"as a table with release dates and drop dates."
),
)
parser.add_argument("package", help="Python package to look up")
parser.add_argument(
"--log-level",
default="WARNING",
help="Set the logging level (default: WARNING)",
)

source = parser.add_argument_group(
"Source",
description=(
"Select the source of the release information. Only one source "
"can be selected."
),
)
source.add_argument(
"--pypi",
action="store_true",
help="Use PyPI (only) as the source for release information",
)
source.add_argument(
"--conda-channel",
type=str,
help="Use a conda channel as the source for release information",
)
source.add_argument(
"--conda-arch",
nargs="+",
default=["noarch", "linux-64"],
help=("Conda architectures to check, only used if conda-channel is specified"),
)
# source.add_argument('--github', action='store_true')

# filter options
filterg = parser.add_argument_group(
"Filter",
description=("Select the filter to select which releases are supported."),
)
filterg.add_argument(
"--filter",
choices=["spec0", "spec0quarterly"],
default="spec0",
help="Filter type to apply to release data",
)
filterg.add_argument(
"--n-months",
type=int,
default=24,
help="Number of months to support releases (default: 24)",
)

# output options
output = parser.add_argument_group(
"Output",
description=(
"Select the output format. Only one output can be selected. "
"output-columns selects the columns to be printed in the table, "
"and is ignored if output-json or output-specifier is selected."
),
)
output.add_argument(
"--output-columns",
action="append",
choices=["release-date", "drop-date"],
help=(
"Columns to include in the output table. Package (including "
"version) is always included."
),
)
output.add_argument(
"--output-json",
action="store_true",
help="Output the results as JSON",
)
output.add_argument(
"--output-specifier",
action="store_true",
help="Output the results as a version specifier, e.g. '>=1.2'",
)
return parser


def select_source(opts):
"""Use CLI arguments to select the source of the release information.

Parameters
----------
opts : argparse.Namespace
The command line arguments.
"""
selected_pypi = opts.pypi
selected_conda = opts.conda_channel is not None
# selected_github = opts.github
n_selected = sum([selected_pypi, selected_conda])
if n_selected == 0:
source = None
elif n_selected > 1:
raise ValueError("Only one source can be selected")
else:
if selected_pypi:
source = [PyPIReleaseSource()]
elif selected_conda:
platforms = [f"{opts.conda_channel}/{arch}" for arch in opts.conda_arch]
source = [CondaReleaseSource(platforms)]
# elif selected_github:
# source = GitHubReleaseSource()
return source


def select_filter(opts):
"""Use CLI arguments to select the support filter.

Parameters
----------
opts : argparse.Namespace
The command line arguments.
"""
if opts.filter == "spec0":
filter_ = SPEC0StrictDate(opts.n_months)
elif opts.filter == "spec0quarterly":
filter_ = SPEC0Quarter(opts.n_months)
return filter_


def select_output(opts):
"""Use CLI arguments to select the output format.

Parameters
----------
opts : argparse.Namespace
The command line arguments.
"""
n_selected = sum([opts.output_json, opts.output_specifier])
if n_selected == 0:
if not opts.output_columns:
# default
opts.output_columns = ["package", "release-date", "drop-date"]
release_date = "release-date" in opts.output_columns
drop_date = "drop-date" in opts.output_columns
output = partial(
terminal_output, release_date=release_date, drop_date=drop_date
)
elif n_selected > 1:
raise ValueError("Only one output can be selected")
else:
if opts.output_json:
output = json_output
elif opts.output_specifier:
output = specifier_output
else: # pragma: no cover
raise RuntimeError("This should never happen")
return output


def cli_main():
parser = make_parser()
opts = parser.parse_args()
# maybe in the future be a little more precise in setting logging to our
# loggers, not the root logger
logging.basicConfig(level=opts.log_level)

sources = select_source(opts)
filter_ = select_filter(opts)
output = select_output(opts)

results = main(opts.package, sources, filter_)
output(results)


if __name__ == "__main__":
cli_main()
69 changes: 69 additions & 0 deletions src/spec0/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
from spec0.releasesource import PyPIReleaseSource, CondaReleaseSource
from spec0.releasefilters import SPEC0StrictDate

import logging

_logger = logging.getLogger(__name__)


def default_sources():
"""Default release sources if none are provided."""
return [
PyPIReleaseSource(),
CondaReleaseSource(["conda-forge/noarch", "conda-forge/linux-64"]),
]


def default_filter():
"""Default support filter if none is provided."""
return SPEC0StrictDate()


def main(package, sources=None, filter_=None):
"""Main function to get release info for a package.

Parameters
----------
package : str
The name of the package to get release info for.
sources : list, optional
A list of release sources to use. If None, default sources are used.
filter_ : ReleaseFilter, optional
A release filter to use. If None, default filter is used.

Returns
-------
pkg_info : dict
A dictionary describing the support requirements. This is a dict key
with keys "package" and "releases". The value of "package" is the
name of the package. The value of "releases" is a list of dicts with
keys "version", "release-date", and "drop-date", where "version" is
the packaging.version.Version of the release, "release-date" is the
(datetime) release date of the release, and "drop-date" is the
(datetime) drop date of the release, according to the input filter.
"""
if sources is None:
sources = default_sources()

if filter_ is None:
filter_ = default_filter()

# we take the releases from the first source that has them
for source in sources:
if releases := source.get_releases(package):
break

filtered = filter_.filter(package, releases)
result = {
"package": package,
"releases": [
{
"version": release.version,
"release-date": release.release_date,
"drop-date": filter_.drop_date(package, release),
}
for release in filtered.values()
],
}
_logger.info(result)
return result
96 changes: 96 additions & 0 deletions src/spec0/output.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import json
from packaging.version import Version
from datetime import datetime
from .utils.packaging import make_specifier, major_minor_str


def json_output(pkg_info):
"""Print package information in JSON format.

Parameters
----------
pkg_info : dict
Dictionary containing package information. See the output of
:func:`.main` for details.
"""

def default(obj):
if isinstance(obj, Version):
return str(obj)
elif isinstance(obj, datetime):
return obj.isoformat()
else: # pragma: no cover
raise TypeError(f"Object of type {type(obj)} is not JSON serializable")

print(json.dumps(pkg_info, indent=4, default=default))


def specifier_output(pkg_info, include_upper_bound=True):
"""Print package information as a specifier.

This allows you to provide the SPEC0-style requirements as version
specifier suitable for use in a requirements file, for example
``mypackage >=1.1,<2``.

Parameters
----------
pkg_info : dict
Dictionary containing package information. See the output of
:func:`.main` for details.
include_upper_bound : bool
If True, include an upper bound of the specifier, defined as not
allowing the next major version.
"""
spec = make_specifier(pkg_info, include_upper_bound)
print(f"{pkg_info['package']} {spec}")


def terminal_output(pkg_info, release_date=True, drop_date=True):
"""Print package information in a terminal-friendly table format.

Parameters
----------
pkg_info : dict
Dictionary containing package information. See the output of
:func:`.main` for details.
release_date : bool
If True (default), include the release date of each version.
drop_date : bool
If True (default), include the drop date of each version.
"""
package = pkg_info["package"]
release_names = [
f"{package} {major_minor_str(release['version'])}"
for release in pkg_info["releases"]
]
release_dates = [release["release-date"] for release in pkg_info["releases"]]
drop_dates = [release["drop-date"] for release in pkg_info["releases"]]
name_width = max(len("Package"), max(len(name) for name in release_names))
date_format = "%Y-%m-%d"
if release_date:
release_date_width = len("Release Date")
else:
release_date_width = 0

if drop_date:
drop_date_width = 10 # YYYY-MM-DD ; longer than "Drop Date"
else:
drop_date_width = 0

# print header
line = f"{'Package':<{name_width}}"
if release_date:
line += f" | {'Release Date':<{release_date_width}}"
if drop_date:
line += f" | {'Drop Date':<{drop_date_width}}"

print(line)
print("-" * len(line))
for name, date_release, date_drop in zip(release_names, release_dates, drop_dates):
line = f"{name:<{name_width}}"
if release_date:
line += f" | {date_release.strftime(date_format):<{release_date_width}}"
if drop_date:
line += f" | {date_drop.strftime(date_format):<{drop_date_width}}"

print(line)
Loading
Loading