diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 00000000000..90ef61adc78 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,7 @@ +[run] +omit = + release_management/__main__.py + +[report] +exclude_also = + if __name__ == .__main__.: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 28dc3a896aa..d3943ddc58a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -12,7 +12,7 @@ default_stages: [pre-commit] repos: # General file checks and fixers - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v5.0.0 + rev: v6.0.0 hooks: - id: end-of-file-fixer name: "Ensure files end with a single newline" @@ -48,37 +48,34 @@ repos: name: "Check YAML" - repo: https://github.com/psf/black-pre-commit-mirror - rev: 24.10.0 + rev: 25.11.0 hooks: - id: black name: "Format with Black" args: - - '--target-version=py39' - '--target-version=py310' files: '^(peps/conf\.py|pep_sphinx_extensions/tests/.*)$' - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.7.0 + rev: v0.14.4 hooks: - - id: ruff + - id: ruff-check name: "Lint with Ruff" args: - '--exit-non-zero-on-fix' - files: '^pep_sphinx_extensions/tests/' + files: '^(pep_sphinx_extensions/tests/|release_management/)' - id: ruff-format name: "Format with Ruff" - args: - - '--check' files: '^release_management/' - repo: https://github.com/tox-dev/tox-ini-fmt - rev: 1.4.1 + rev: 1.7.0 hooks: - id: tox-ini-fmt name: "Format tox.ini" - repo: https://github.com/sphinx-contrib/sphinx-lint - rev: v1.0.0 + rev: v1.0.1 hooks: - id: sphinx-lint name: "Sphinx lint" @@ -99,7 +96,7 @@ repos: # Manual codespell check - repo: https://github.com/codespell-project/codespell - rev: v2.3.0 + rev: v2.4.1 hooks: - id: codespell name: "Check for common misspellings in text files" diff --git a/.ruff.toml b/.ruff.toml index 48a164b16f7..6a80c6bf614 100644 --- a/.ruff.toml +++ b/.ruff.toml @@ -1,5 +1,6 @@ output-format = "full" target-version = "py310" +fix = true [lint] ignore = [ diff --git a/pep_sphinx_extensions/pep_zero_generator/pep_index_generator.py b/pep_sphinx_extensions/pep_zero_generator/pep_index_generator.py index 84787aabb85..02710c2ee2b 100644 --- a/pep_sphinx_extensions/pep_zero_generator/pep_index_generator.py +++ b/pep_sphinx_extensions/pep_zero_generator/pep_index_generator.py @@ -26,7 +26,7 @@ from pep_sphinx_extensions.pep_zero_generator import subindices from pep_sphinx_extensions.pep_zero_generator import writer from pep_sphinx_extensions.pep_zero_generator.constants import SUBINDICES_BY_TOPIC -from release_management.serialize import create_release_cycle, create_release_json +from release_management.serialize import create_release_cycle, create_release_ical, create_release_json if TYPE_CHECKING: from sphinx.application import Sphinx @@ -77,5 +77,8 @@ def create_pep_zero(app: Sphinx, env: BuildEnvironment, docnames: list[str]) -> release_cycle = create_release_cycle() app.outdir.joinpath('api/release-cycle.json').write_text(release_cycle, encoding="utf-8") + release_ical = create_release_ical() + app.outdir.joinpath('api/python-releases.ics').write_text(release_ical, encoding="utf-8") + release_json = create_release_json() app.outdir.joinpath('api/python-releases.json').write_text(release_json, encoding="utf-8") diff --git a/pytest.ini b/pytest.ini index 10404cc0b3b..c8086d043ef 100644 --- a/pytest.ini +++ b/pytest.ini @@ -5,12 +5,15 @@ addopts = --strict-config --strict-markers --import-mode=importlib - --cov check_peps --cov pep_sphinx_extensions - --cov-report html --cov-report xml + --cov check_peps + --cov pep_sphinx_extensions + --cov release_management + --cov-report html + --cov-report xml empty_parameter_set_mark = fail_at_collect filterwarnings = error minversion = 6.0 -testpaths = pep_sphinx_extensions +testpaths = pep_sphinx_extensions release_management xfail_strict = True disable_test_id_escaping_and_forfeit_all_rights_to_community_support = True diff --git a/release_management/__init__.py b/release_management/__init__.py index 9846219fdc0..7ad23a4e3e2 100644 --- a/release_management/__init__.py +++ b/release_management/__init__.py @@ -2,6 +2,7 @@ import sys from dataclasses import dataclass +from functools import cache from pathlib import Path try: @@ -67,6 +68,7 @@ def schedule_bullet(self): return f'- {self.stage}: {self.date:%A, %Y-%m-%d}' +@cache def load_python_releases() -> PythonReleases: with open(RELEASE_DIR / 'python-releases.toml', 'rb') as f: python_releases = tomllib.load(f) diff --git a/release_management/serialize.py b/release_management/serialize.py index 3418098278c..ef298ca5161 100644 --- a/release_management/serialize.py +++ b/release_management/serialize.py @@ -2,8 +2,11 @@ import dataclasses import json +import re -from release_management import ROOT_DIR, load_python_releases +from icalendar import Calendar, Event + +from release_management import load_python_releases TYPE_CHECKING = False if TYPE_CHECKING: @@ -48,3 +51,42 @@ def version_info(metadata: VersionMetadata, /) -> dict[str, str | int]: 'end_of_life': end_of_life, 'release_manager': metadata.release_manager, } + + +def ical_uid(name: str) -> str: + user = re.sub(r'[^a-z0-9.]+', '', name.lower()) + return f'python-{user}@python.org' + + +def create_release_ical() -> str: + python_releases = load_python_releases() + + calendar = Calendar() + calendar.add('version', '2.0') + calendar.add('prodid', '-//Python Software Foundation//Python releases//EN') + calendar.add('x-wr-calname', 'Python releases') + calendar.add( + 'x-wr-caldesc', 'Python releases schedule from https://peps.python.org' + ) + + all_releases = [] + for version, releases in python_releases.releases.items(): + for release in releases: + all_releases.append((version, release)) + + all_releases.sort(key=lambda r: r[1].date) + + for version, release in all_releases: + event = Event() + event.add('summary', f'Python {release.stage}') + event.add('uid', ical_uid(release.stage)) + event.add('dtstart', release.date) + pep_number = python_releases.metadata[version].pep + event.add('url', f'https://peps.python.org/pep-{pep_number:04d}/') + + if release.note: + event.add('description', f'Note: {release.note}') + + calendar.add_component(event) + + return calendar.to_ical().decode('utf-8') diff --git a/release_management/tests/test_serialize.py b/release_management/tests/test_serialize.py new file mode 100644 index 00000000000..a34fc5c4747 --- /dev/null +++ b/release_management/tests/test_serialize.py @@ -0,0 +1,67 @@ +import pytest +from icalendar import Calendar +from release_management import serialize + + +@pytest.mark.parametrize( + ('test_input', 'expected'), + [ + ('3.14.0 alpha 1', 'python-3.14.0alpha1@python.org'), + ('3.14.0 beta 2', 'python-3.14.0beta2@python.org'), + ('3.14.0 candidate 3', 'python-3.14.0candidate3@python.org'), + ('3.14.1', 'python-3.14.1@python.org'), + ], +) +def test_ical_uid(test_input, expected): + assert serialize.ical_uid(test_input) == expected + + +def test_create_release_ical_returns_valid_icalendar(): + # Act + ical_str = serialize.create_release_ical() + + # Assert + # Non-empty string + assert isinstance(ical_str, str) + assert len(ical_str) > 0 + + # Parseable as a valid iCalendar + cal = Calendar.from_ical(ical_str) + assert cal is not None + + +def test_create_release_ical_has_calendar_metadata(): + # Act + ical_str = serialize.create_release_ical() + + # Assert + cal = Calendar.from_ical(ical_str) + + # Check calendar metadata + assert cal.get('version') == '2.0' + assert cal.get('prodid') == '-//Python Software Foundation//Python releases//EN' + assert cal.get('x-wr-calname') == 'Python releases' + assert 'peps.python.org' in cal.get('x-wr-caldesc') + + +def test_create_release_ical_first_event(): + # Act + ical_str = serialize.create_release_ical() + + # Assert + cal = Calendar.from_ical(ical_str) + first_event = cal.events[0] + assert first_event.get('summary') == 'Python 1.6.0 alpha 1' + assert str(first_event.get('dtstart').dt) == '2000-03-31' + assert first_event.get('uid') == 'python-1.6.0alpha1@python.org' + assert first_event.get('url') == 'https://peps.python.org/pep-0160/' + + +def test_create_release_ical_sorted_by_date(): + # Act + ical_str = serialize.create_release_ical() + + # Assert + cal = Calendar.from_ical(ical_str) + dates = [event.get('dtstart').dt for event in cal.events] + assert dates == sorted(dates) diff --git a/requirements.txt b/requirements.txt index 07d631cee6f..5a2c03d20cb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,5 +10,8 @@ sphinx-notfound-page >= 1.0.2 pytest pytest-cov +# For python-releases.ical +icalendar + # For python-releases.toml tomli >= 1.1.0 ; python_version < "3.11"