diff --git a/pyproject.toml b/pyproject.toml index 809afea..8de3368 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ classifiers = [ keywords = ["spec0", "package support", "cli"] dependencies = [ + "packaging", "requests", ] @@ -38,6 +39,9 @@ dev = [ "pre-commit", ] +[project.scripts] +spec0 = "spec0.cli:cli_main" + [tool.setuptools] packages = ["spec0"] package-dir = { "" = "src" } diff --git a/src/spec0/cli.py b/src/spec0/cli.py new file mode 100644 index 0000000..c2bfc9b --- /dev/null +++ b/src/spec0/cli.py @@ -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() diff --git a/src/spec0/main.py b/src/spec0/main.py new file mode 100644 index 0000000..b4b1928 --- /dev/null +++ b/src/spec0/main.py @@ -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 diff --git a/src/spec0/output.py b/src/spec0/output.py new file mode 100644 index 0000000..d0dc1cd --- /dev/null +++ b/src/spec0/output.py @@ -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) diff --git a/src/spec0/releasefilters.py b/src/spec0/releasefilters.py index d3867d4..c26dbca 100644 --- a/src/spec0/releasefilters.py +++ b/src/spec0/releasefilters.py @@ -6,48 +6,15 @@ from packaging.version import Version from .releasesource import Release +from .utils.dates import next_quarter, quarter_to_date, shift_date_by_months +import logging -class ReleaseFilter: - def filter(self, package, releases): ... - - -# utils/dates -def get_quarter(date): - """Convert a date to a quarter as tuple (year, quarter). - - Quarters are 1-indexed. - """ - return (date.year, ((date.month - 1) // 3) + 1) - +_logger = logging.getLogger(__name__) -def next_quarter(date): - """Get the next quarter after the given date.""" - year, quarter = get_quarter(date) - quarter += 1 - if quarter > 4: - quarter = 1 - year += 1 - return (year, quarter) - -def quarter_to_date(quarter): - """ - Convert a quarter to the date associated with the start of that quarter. - """ - year, quarter = quarter - return datetime.datetime( - year, (quarter - 1) * 3 + 1, 1, tzinfo=datetime.timezone.utc - ) - - -def shift_date_by_months(date, n_months): - """Shift a date by a number of months.""" - # used to set the cutoff; if there's a better way to do this, go for it. - # Months are weird because they aren't all the same length. - dyears = n_months // 12 - dmonths = n_months % 12 - return date.replace(year=date.year + dyears, month=date.month + dmonths) +class ReleaseFilter: + def filter(self, package, releases): ... def get_oldest_minor_release(releases: Iterable[Release]): @@ -108,6 +75,10 @@ def _get_minimum_supported(self, package: str, releases: Iterable[Release]): for key, release in oldest_minor_release.items(): drop_date = self.drop_date(package, release) if now < drop_date: + _logger.debug( + f"Supporting {key} until {drop_date}, release date: " + f"{release.release_date}" + ) supported[key] = release return supported diff --git a/src/spec0/releasesource.py b/src/spec0/releasesource.py index 028a090..06166ad 100644 --- a/src/spec0/releasesource.py +++ b/src/spec0/releasesource.py @@ -9,6 +9,10 @@ from spec0.cacheddownload import get_file, CACHE_DIR +import logging + +_logger = logging.getLogger(__name__) + @dataclasses.dataclass class Release: @@ -36,6 +40,7 @@ class PyPIReleaseSource(ReleaseSource): def _get_releases(self, package: str) -> Generator[Release, None, None]: url = f"https://pypi.org/pypi/{package}/json" + _logger.debug(f"Fetching {url}") response = requests.get(url) response.raise_for_status() data = response.json() @@ -64,9 +69,9 @@ def _get_releases(self, package: str) -> Generator[Release, None, None]: # Only add to list if we successfully found an upload date if earliest_date is not None: - release_list.append( - Release(version=parsed_version, release_date=earliest_date) - ) + release = Release(version=parsed_version, release_date=earliest_date) + _logger.debug(f"Found release: {release}") + release_list.append(release) release_list.sort(key=lambda r: r.release_date, reverse=True) for release in release_list: diff --git a/src/spec0/utils/__init__.py b/src/spec0/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/spec0/utils/dates.py b/src/spec0/utils/dates.py new file mode 100644 index 0000000..4e9630f --- /dev/null +++ b/src/spec0/utils/dates.py @@ -0,0 +1,49 @@ +import datetime + + +# utils/dates +def get_quarter(date): + """Convert a date to a quarter as tuple (year, quarter). + + Quarters are 1-indexed. + """ + return (date.year, ((date.month - 1) // 3) + 1) + + +def next_quarter(date): + """Get the next quarter after the given date.""" + year, quarter = get_quarter(date) + quarter += 1 + if quarter > 4: + quarter = 1 + year += 1 + return (year, quarter) + + +def quarter_to_date(quarter): + """ + Convert a quarter to the date associated with the start of that quarter. + """ + year, quarter = quarter + return datetime.datetime( + year, (quarter - 1) * 3 + 1, 1, tzinfo=datetime.timezone.utc + ) + + +def shift_date_by_months(date, n_months): + """Shift a date by a number of months.""" + # Months are weird because they aren't all the same length. + total_month = date.month + n_months + new_year = date.year + (total_month - 1) // 12 + new_month = (total_month - 1) % 12 + 1 + + try: + new_date = date.replace(year=new_year, month=new_month, day=date.day) + except ValueError: + # If the target month doesn't have the original day (e.g. February 31), + # shift to the first day of the next month. (In principle, we could + # need special casing for December to also bump to the new year, but + # since no month has more days than December, we're okay with this.) + new_month += 1 + new_date = date.replace(year=new_year, month=new_month, day=1) + return new_date diff --git a/src/spec0/utils/packaging.py b/src/spec0/utils/packaging.py new file mode 100644 index 0000000..d7cced2 --- /dev/null +++ b/src/spec0/utils/packaging.py @@ -0,0 +1,21 @@ +from packaging.specifiers import SpecifierSet +from packaging.version import Version + + +def make_specifier(pkg_info, include_upper_bound=True): + versions = [release["version"] for release in pkg_info["releases"]] + min_version = min(versions) + spec = SpecifierSet(f">={min_version}") + if include_upper_bound: + max_version = max(versions) + upper = Version(f"{max_version.epoch}!{max_version.major + 1}.0") + spec = spec & SpecifierSet(f"<{upper}") + return spec + + +def major_minor_str(version): + major_minor_str = f"{version.major}.{version.minor}" + if version.epoch != 0: + major_minor_str = f"{version.epoch}!{major_minor_str}" + + return major_minor_str diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..83467b3 --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,100 @@ +import datetime +from packaging.version import Version + +from spec0.releasesource import PyPIReleaseSource, CondaReleaseSource, Release +from spec0.releasefilters import SPEC0StrictDate + +from spec0.main import * + + +class DummySource: + """ + A dummy source that implements get_releases. + It returns a dictionary of releases if provided, + otherwise returns None. + """ + + def __init__(self, releases): + self._releases = releases + + def get_releases(self, package): + return self._releases + + +class DummyFilter: + """ + A dummy filter that simply passes through releases. + Its drop_date method returns a fixed date. + """ + + def filter(self, package, releases): + return releases + + def drop_date(self, package, release): + return datetime.datetime(2021, 1, 1) + + +def test_default_sources(): + sources = default_sources() + assert isinstance(sources, list) + assert len(sources) == 2 + + assert isinstance(sources[0], PyPIReleaseSource) + assert isinstance(sources[1], CondaReleaseSource) + + +def test_default_filter(): + filter_obj = default_filter() + assert isinstance(filter_obj, SPEC0StrictDate) + assert filter_obj.n_months == 24 + assert filter_obj.python_override is True + + +def test_main_uses_first_source_with_releases(): + v = Version("1.0") + release_date = datetime.datetime(2020, 1, 1) + dummy_release = Release(v, release_date) + + # The first source returns a valid releases dict. + # The second source should not be used. + source1 = DummySource({"r1": dummy_release}) + v2 = Version("2.0") + source2 = DummySource({"r2": Release(v2, datetime.datetime(2020, 2, 2))}) + + filter_obj = DummyFilter() + result = main("testpkg", sources=[source1, source2], filter_=filter_obj) + + assert result["package"] == "testpkg" + assert isinstance(result["releases"], list) + assert len(result["releases"]) == 1 + release_info = result["releases"][0] + assert release_info["version"] == v + assert release_info["release-date"] == release_date + assert release_info["drop-date"] == datetime.datetime(2021, 1, 1) + + +def test_main_uses_second_source_when_first_returns_none(): + v = Version("1.0") + release_date = datetime.datetime(2020, 1, 1) + dummy_release = Release(v, release_date) + + source1 = DummySource(None) + source2 = DummySource({"r1": dummy_release}) + + filter_obj = DummyFilter() + result = main("testpkg", sources=[source1, source2], filter_=filter_obj) + + assert result["package"] == "testpkg" + assert isinstance(result["releases"], list) + assert len(result["releases"]) == 1 + + +def test_main_default(): + # smoke test for integration with default params + results = main("numpy") + assert results["package"] == "numpy" + assert len(results["releases"]) > 0 + recent_release = results["releases"][0] + assert isinstance(recent_release["version"], Version) + assert isinstance(recent_release["release-date"], datetime.datetime) + assert isinstance(recent_release["drop-date"], datetime.datetime) diff --git a/tests/test_output.py b/tests/test_output.py new file mode 100644 index 0000000..bf9c5d4 --- /dev/null +++ b/tests/test_output.py @@ -0,0 +1,92 @@ +import pytest +import json + +from spec0.output import * +from packaging.version import Version +import datetime + + +@pytest.fixture +def pkg_info(): + return { + "package": "mypackage", + "releases": [ + { + "version": Version("1.0"), + "release-date": datetime.datetime(2020, 1, 1), + "drop-date": datetime.datetime(2021, 1, 1), + }, + { + "version": Version("1!2.3"), + "release-date": datetime.datetime(2020, 2, 2), + "drop-date": datetime.datetime(2021, 2, 2), + }, + ], + } + + +def test_json_output(capsys, pkg_info): + json_output(pkg_info) + captured = capsys.readouterr().out + output_dict = json.loads(captured) + expected = { + "package": "mypackage", + "releases": [ + { + "version": "1.0", + "release-date": "2020-01-01T00:00:00", + "drop-date": "2021-01-01T00:00:00", + }, + { + "version": "1!2.3", + "release-date": "2020-02-02T00:00:00", + "drop-date": "2021-02-02T00:00:00", + }, + ], + } + assert output_dict == expected + + +@pytest.mark.parametrize( + "include_upper_bound, expected", + [ + (True, "mypackage <1!3.0,>=1.0\n"), + (False, "mypackage >=1.0\n"), + ], +) +def test_specifier_output(capsys, pkg_info, include_upper_bound, expected): + specifier_output(pkg_info, include_upper_bound=include_upper_bound) + captured = capsys.readouterr().out + assert captured == expected + + +@pytest.mark.parametrize("release_date", [True, False]) +@pytest.mark.parametrize("drop_date", [True, False]) +def test_terminal_output_combined(capsys, pkg_info, release_date, drop_date): + terminal_output(pkg_info, release_date=release_date, drop_date=drop_date) + captured = capsys.readouterr().out + header_line, _, first_row, second_row = captured.splitlines() + + # Fixed assertions that are always expected. + assert "Package" in header_line + assert "mypackage 1.0" in first_row + assert "mypackage 1!2.3" in second_row + + # Check header for release date and drop date based on flags. + if release_date: + assert "Release Date" in header_line + assert "2020-01-01" in first_row + assert "2020-02-02" in second_row + else: + assert "Release Date" not in header_line + assert "2020-01-01" not in first_row + assert "2020-02-02" not in second_row + + if drop_date: + assert "Drop Date" in header_line + assert "2021-01-01" in first_row + assert "2021-02-02" in second_row + else: + assert "Drop Date" not in header_line + assert "2021-01-01" not in first_row + assert "2021-02-02" not in second_row diff --git a/tests/test_releasefilters.py b/tests/test_releasefilters.py index 8c1bd42..6839b53 100644 --- a/tests/test_releasefilters.py +++ b/tests/test_releasefilters.py @@ -32,45 +32,6 @@ def releases(): return releases -@pytest.mark.parametrize( - "datetime, expected", - [ - (datetime.datetime(2023, 5, 15), (2023, 2)), - (datetime.datetime(2023, 1, 1), (2023, 1)), - (datetime.datetime(2023, 12, 31), (2023, 4)), - ], -) -def test_get_quarter(datetime, expected): - assert get_quarter(datetime) == expected - - -@pytest.mark.parametrize( - "datetime, expected", - [ - (datetime.datetime(2023, 5, 15), (2023, 3)), - (datetime.datetime(2023, 11, 1), (2024, 1)), # bump the year - ], -) -def test_next_quarter(datetime, expected): - assert next_quarter(datetime) == expected - - -def test_quarter_to_date(): - expected = datetime.datetime(2023, 4, 1, tzinfo=datetime.timezone.utc) - assert quarter_to_date((2023, 2)) == expected - - -@pytest.mark.parametrize( - "datetime, n_months, expected", - [ - (datetime.datetime(2023, 1, 15), 3, datetime.datetime(2023, 4, 15)), - (datetime.datetime(2023, 7, 15), 4, datetime.datetime(2023, 11, 15)), - ], -) -def test_shift_date_by_months(datetime, n_months, expected): - assert shift_date_by_months(datetime, n_months) == expected - - def test_get_oldest_minor_release(): r1 = Release( version=Version("1.0.0"), diff --git a/tests/utils/test_dates.py b/tests/utils/test_dates.py new file mode 100644 index 0000000..f6780e1 --- /dev/null +++ b/tests/utils/test_dates.py @@ -0,0 +1,53 @@ +import datetime +import pytest + +from spec0.utils.dates import * + + +@pytest.mark.parametrize( + "dt, expected", + [ + (datetime.datetime(2023, 5, 15), (2023, 2)), + (datetime.datetime(2023, 1, 1), (2023, 1)), + (datetime.datetime(2023, 12, 31), (2023, 4)), + ], +) +def test_get_quarter(dt, expected): + assert get_quarter(dt) == expected + + +@pytest.mark.parametrize( + "dt, expected", + [ + (datetime.datetime(2023, 5, 15), (2023, 3)), + (datetime.datetime(2023, 11, 1), (2024, 1)), # bump the year + ], +) +def test_next_quarter(dt, expected): + assert next_quarter(dt) == expected + + +def test_quarter_to_date(): + expected = datetime.datetime(2023, 4, 1, tzinfo=datetime.timezone.utc) + assert quarter_to_date((2023, 2)) == expected + + +@pytest.mark.parametrize( + "dt, n_months, expected", + [ + (datetime.datetime(2023, 1, 15), 3, datetime.datetime(2023, 4, 15)), + (datetime.datetime(2023, 7, 15), 4, datetime.datetime(2023, 11, 15)), + (datetime.datetime(2022, 6, 15), 0, datetime.datetime(2022, 6, 15)), + (datetime.datetime(2022, 6, 15), 3, datetime.datetime(2022, 9, 15)), + (datetime.datetime(2022, 11, 15), 1, datetime.datetime(2022, 12, 15)), + (datetime.datetime(2022, 3, 15), 12, datetime.datetime(2023, 3, 15)), + (datetime.datetime(2022, 5, 15), 15, datetime.datetime(2023, 8, 15)), + (datetime.datetime(2020, 2, 29), 1, datetime.datetime(2020, 3, 29)), + (datetime.datetime(2022, 11, 15), 2, datetime.datetime(2023, 1, 15)), + (datetime.datetime(2021, 12, 31), 1, datetime.datetime(2022, 1, 31)), + (datetime.datetime(2022, 1, 31), 1, datetime.datetime(2022, 3, 1)), + (datetime.datetime(2020, 2, 29), 12, datetime.datetime(2021, 3, 1)), + ], +) +def test_shift_date_by_months(dt, n_months, expected): + assert shift_date_by_months(dt, n_months) == expected diff --git a/tests/utils/test_packaging.py b/tests/utils/test_packaging.py new file mode 100644 index 0000000..c2ce472 --- /dev/null +++ b/tests/utils/test_packaging.py @@ -0,0 +1,104 @@ +from packaging.version import Version +from packaging.specifiers import SpecifierSet + +import pytest + +from spec0.utils.packaging import * + + +@pytest.mark.parametrize( + "pkg_info, include_upper_bound, expected", + [ + # Single release: only one version provided. + ( + {"releases": [{"version": Version("1.2.3")}]}, + True, + ">=1.2.3,<2.0", + ), + ( + {"releases": [{"version": Version("1.2.3")}]}, + False, + ">=1.2.3", + ), + # Multiple releases with the same major version. + ( + { + "releases": [ + {"version": Version("1.2.3")}, + {"version": Version("1.3.0")}, + ] + }, + True, + ">=1.2.3,<2.0", + ), + ( + { + "releases": [ + {"version": Version("1.2.3")}, + {"version": Version("1.3.0")}, + ] + }, + False, + ">=1.2.3", + ), + # Multiple releases with different major versions. + ( + { + "releases": [ + {"version": Version("1.2.3")}, + {"version": Version("2.0.0")}, + ] + }, + True, + ">=1.2.3,<3.0", + ), + ( + { + "releases": [ + {"version": Version("1.2.3")}, + {"version": Version("2.0.0")}, + ] + }, + False, + ">=1.2.3", + ), + # Releases with a nonzero epoch. + ( + { + "releases": [ + {"version": Version("2!1.0")}, + {"version": Version("2!3.0")}, + ] + }, + True, + ">=2!1.0,<2!4.0", + ), + ( + { + "releases": [ + {"version": Version("2!1.0")}, + {"version": Version("2!3.0")}, + ] + }, + False, + ">=2!1.0", + ), + ], +) +def test_make_specifier(pkg_info, include_upper_bound, expected): + spec = make_specifier(pkg_info, include_upper_bound) + assert spec == SpecifierSet(expected) + + +@pytest.mark.parametrize( + "version_str,expected", + [ + ("1.2", "1.2"), # For "1.2", epoch is 0, so just "major.minor" + ("1!1.2", "1!1.2"), # For "1!1.2", non-zero epoch is included + ("0.3.4", "0.3"), # For "0.3.4", only major and minor are used + ], +) +def test_major_minor_str(version_str, expected): + v = Version(version_str) + result = major_minor_str(v) + assert result == expected