From 83b91534bbce622f58c14fa3bb31246866d5c972 Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Wed, 22 Apr 2026 00:14:25 -0400 Subject: [PATCH 1/3] feat: add Alpine Linux support Uses the official https://alpinelinux.org/releases.json JSON API, which provides complete historical data back to Alpine 2.1. Alpine does not publish pre-release/development dates, so begin_dev is always null and is_in_development_on() will raise NoDevelopmentInfoError for all Alpine entries. Closes #41 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/distro_support/alpine.json | 250 ++++++++++++++++++++++++++++++++ src/distro_support/alpine.py | 33 +++++ tests/test_alpine_downloader.py | 104 +++++++++++++ tests/test_get_support_range.py | 13 +- tools/update.py | 4 +- 5 files changed, 400 insertions(+), 4 deletions(-) create mode 100644 src/distro_support/alpine.json create mode 100644 src/distro_support/alpine.py create mode 100644 tests/test_alpine_downloader.py diff --git a/src/distro_support/alpine.json b/src/distro_support/alpine.json new file mode 100644 index 0000000..0ddefff --- /dev/null +++ b/src/distro_support/alpine.json @@ -0,0 +1,250 @@ +{ + "3.23": { + "distribution": "alpine", + "version": "3.23", + "begin_support": "2025-12-03", + "end_support": "2027-11-01", + "begin_dev": null, + "end_extended_support": null + }, + "3.22": { + "distribution": "alpine", + "version": "3.22", + "begin_support": "2025-05-30", + "end_support": "2027-05-01", + "begin_dev": null, + "end_extended_support": null + }, + "3.21": { + "distribution": "alpine", + "version": "3.21", + "begin_support": "2024-12-05", + "end_support": "2026-11-01", + "begin_dev": null, + "end_extended_support": null + }, + "3.20": { + "distribution": "alpine", + "version": "3.20", + "begin_support": "2024-05-22", + "end_support": "2026-04-01", + "begin_dev": null, + "end_extended_support": null + }, + "3.19": { + "distribution": "alpine", + "version": "3.19", + "begin_support": "2023-12-07", + "end_support": "2025-11-01", + "begin_dev": null, + "end_extended_support": null + }, + "3.18": { + "distribution": "alpine", + "version": "3.18", + "begin_support": "2023-05-09", + "end_support": "2025-05-09", + "begin_dev": null, + "end_extended_support": null + }, + "3.17": { + "distribution": "alpine", + "version": "3.17", + "begin_support": "2022-11-22", + "end_support": "2024-11-22", + "begin_dev": null, + "end_extended_support": null + }, + "3.16": { + "distribution": "alpine", + "version": "3.16", + "begin_support": "2022-05-23", + "end_support": "2024-05-23", + "begin_dev": null, + "end_extended_support": null + }, + "3.15": { + "distribution": "alpine", + "version": "3.15", + "begin_support": "2021-11-24", + "end_support": "2023-11-01", + "begin_dev": null, + "end_extended_support": null + }, + "3.14": { + "distribution": "alpine", + "version": "3.14", + "begin_support": "2021-06-15", + "end_support": "2023-05-01", + "begin_dev": null, + "end_extended_support": null + }, + "3.13": { + "distribution": "alpine", + "version": "3.13", + "begin_support": "2021-01-14", + "end_support": "2022-11-01", + "begin_dev": null, + "end_extended_support": null + }, + "3.12": { + "distribution": "alpine", + "version": "3.12", + "begin_support": "2020-05-29", + "end_support": "2022-05-01", + "begin_dev": null, + "end_extended_support": null + }, + "3.11": { + "distribution": "alpine", + "version": "3.11", + "begin_support": "2019-12-29", + "end_support": "2021-11-01", + "begin_dev": null, + "end_extended_support": null + }, + "3.10": { + "distribution": "alpine", + "version": "3.10", + "begin_support": "2019-06-19", + "end_support": "2021-05-01", + "begin_dev": null, + "end_extended_support": null + }, + "3.9": { + "distribution": "alpine", + "version": "3.9", + "begin_support": "2019-01-29", + "end_support": "2020-11-01", + "begin_dev": null, + "end_extended_support": null + }, + "3.8": { + "distribution": "alpine", + "version": "3.8", + "begin_support": "2018-06-26", + "end_support": "2020-05-01", + "begin_dev": null, + "end_extended_support": null + }, + "3.7": { + "distribution": "alpine", + "version": "3.7", + "begin_support": "2017-11-30", + "end_support": "2019-11-01", + "begin_dev": null, + "end_extended_support": null + }, + "3.6": { + "distribution": "alpine", + "version": "3.6", + "begin_support": "2017-05-24", + "end_support": "2019-05-01", + "begin_dev": null, + "end_extended_support": null + }, + "3.5": { + "distribution": "alpine", + "version": "3.5", + "begin_support": "2016-12-22", + "end_support": "2018-11-01", + "begin_dev": null, + "end_extended_support": null + }, + "3.4": { + "distribution": "alpine", + "version": "3.4", + "begin_support": "2016-05-31", + "end_support": "2018-05-01", + "begin_dev": null, + "end_extended_support": null + }, + "3.3": { + "distribution": "alpine", + "version": "3.3", + "begin_support": "2015-12-18", + "end_support": "2017-11-01", + "begin_dev": null, + "end_extended_support": null + }, + "3.2": { + "distribution": "alpine", + "version": "3.2", + "begin_support": "2015-05-26", + "end_support": "2017-05-01", + "begin_dev": null, + "end_extended_support": null + }, + "3.1": { + "distribution": "alpine", + "version": "3.1", + "begin_support": "2014-12-10", + "end_support": "2016-11-01", + "begin_dev": null, + "end_extended_support": null + }, + "3.0": { + "distribution": "alpine", + "version": "3.0", + "begin_support": "2014-06-04", + "end_support": "2016-05-01", + "begin_dev": null, + "end_extended_support": null + }, + "2.7": { + "distribution": "alpine", + "version": "2.7", + "begin_support": "2013-11-08", + "end_support": "2015-11-01", + "begin_dev": null, + "end_extended_support": null + }, + "2.6": { + "distribution": "alpine", + "version": "2.6", + "begin_support": "2013-05-17", + "end_support": "2015-05-01", + "begin_dev": null, + "end_extended_support": null + }, + "2.5": { + "distribution": "alpine", + "version": "2.5", + "begin_support": "2012-11-07", + "end_support": "2014-11-01", + "begin_dev": null, + "end_extended_support": null + }, + "2.4": { + "distribution": "alpine", + "version": "2.4", + "begin_support": "2012-05-02", + "end_support": "2014-05-01", + "begin_dev": null, + "end_extended_support": null + }, + "2.3": { + "distribution": "alpine", + "version": "2.3", + "begin_support": "2011-11-01", + "end_support": "2013-11-01", + "begin_dev": null, + "end_extended_support": null + }, + "2.2": { + "distribution": "alpine", + "version": "2.2", + "begin_support": "2011-05-06", + "end_support": "2013-05-01", + "begin_dev": null, + "end_extended_support": null + }, + "2.1": { + "distribution": "alpine", + "version": "2.1", + "begin_support": "2010-11-01", + "end_support": "2012-11-01", + "begin_dev": null, + "end_extended_support": null + } +} diff --git a/src/distro_support/alpine.py b/src/distro_support/alpine.py new file mode 100644 index 0000000..906cd33 --- /dev/null +++ b/src/distro_support/alpine.py @@ -0,0 +1,33 @@ +"""Information about Alpine Linux support.""" + +import http.client +import json +from urllib import request + +RELEASES_URL = "https://alpinelinux.org/releases.json" + + +def get_distro_info() -> dict[str, dict[str, str | None]]: + response: http.client.HTTPResponse = request.urlopen(RELEASES_URL) + if response.status != 200: + raise ConnectionError(response.status) + + data = json.loads(response.read().decode()) + series: dict[str, dict[str, str | None]] = {} + + for branch in data.get("release_branches", []): + rel_branch: str = branch.get("rel_branch", "") + if not rel_branch.startswith("v"): + continue # skip 'edge' + + version = rel_branch.lstrip("v") + series[version] = { + "distribution": "alpine", + "version": version, + "begin_support": branch.get("branch_date") or None, + "end_support": branch.get("eol_date") or None, + "begin_dev": None, + "end_extended_support": None, + } + + return series diff --git a/tests/test_alpine_downloader.py b/tests/test_alpine_downloader.py new file mode 100644 index 0000000..acc64d4 --- /dev/null +++ b/tests/test_alpine_downloader.py @@ -0,0 +1,104 @@ +"""Tests for the Alpine Linux downloader.""" + +import json +import unittest.mock + +import pytest + +from distro_support import alpine +from distro_support._distro import SupportRange + +SAMPLE_JSON = json.dumps( + { + "latest_stable": "v3.21", + "release_branches": [ + { + "rel_branch": "edge", + "git_branch": "master", + }, + { + "rel_branch": "v3.21", + "branch_date": "2024-12-05", + "eol_date": "2026-11-01", + "git_branch": "3.21-stable", + }, + { + "rel_branch": "v3.20", + "branch_date": "2024-05-22", + "eol_date": "2026-04-01", + "git_branch": "3.20-stable", + }, + { + "rel_branch": "v3.19", + "branch_date": "2023-12-07", + "eol_date": "2025-11-01", + "git_branch": "3.19-stable", + }, + ], + } +) + + +def _make_response(body: str, status: int = 200): + mock_response = unittest.mock.MagicMock() + mock_response.status = status + mock_response.read.return_value = body.encode() + return mock_response + + +@unittest.mock.patch("distro_support.alpine.request.urlopen") +def test_parses_all_versioned_branches(mock_urlopen): + mock_urlopen.return_value = _make_response(SAMPLE_JSON) + + result = alpine.get_distro_info() + + assert set(result.keys()) == {"3.21", "3.20", "3.19"} + + +@unittest.mock.patch("distro_support.alpine.request.urlopen") +def test_skips_edge(mock_urlopen): + mock_urlopen.return_value = _make_response(SAMPLE_JSON) + + result = alpine.get_distro_info() + + assert "edge" not in result + + +@unittest.mock.patch("distro_support.alpine.request.urlopen") +def test_correct_dates(mock_urlopen): + mock_urlopen.return_value = _make_response(SAMPLE_JSON) + + result = alpine.get_distro_info() + + assert result["3.21"]["begin_support"] == "2024-12-05" + assert result["3.21"]["end_support"] == "2026-11-01" + + +@unittest.mock.patch("distro_support.alpine.request.urlopen") +def test_no_dev_or_esm_fields(mock_urlopen): + mock_urlopen.return_value = _make_response(SAMPLE_JSON) + + result = alpine.get_distro_info() + + assert result["3.21"]["begin_dev"] is None + assert result["3.21"]["end_extended_support"] is None + + +@unittest.mock.patch("distro_support.alpine.request.urlopen") +def test_roundtrip_through_support_range(mock_urlopen): + mock_urlopen.return_value = _make_response(SAMPLE_JSON) + + result = alpine.get_distro_info() + sr = SupportRange.from_json(result["3.21"]) + + assert sr.distribution == "alpine" + assert sr.version == "3.21" + assert sr.end_extended_support is None + + +@unittest.mock.patch("distro_support.alpine.request.urlopen") +def test_http_error_raises(mock_urlopen): + mock_urlopen.return_value = _make_response("", status=404) + + with pytest.raises(ConnectionError): + alpine.get_distro_info() diff --git a/tests/test_get_support_range.py b/tests/test_get_support_range.py index 9a7d037..7e67cd4 100644 --- a/tests/test_get_support_range.py +++ b/tests/test_get_support_range.py @@ -2,7 +2,7 @@ import pytest import distro_support -from distro_support.errors import NoESMInfoError +from distro_support.errors import NoDevelopmentInfoError, NoESMInfoError @pytest.mark.parametrize( @@ -19,6 +19,9 @@ ("devuan", "4", date(2022, 1, 1), True, False, None), ("devuan", "4", date(2030, 1, 1), False, False, None), ("devuan", "7", date(2026, 1, 1), False, True, None), + # Alpine has no begin_dev, so in_dev=None signals NoDevelopmentInfoError + ("alpine", "3.20", date(2025, 1, 1), True, None, None), + ("alpine", "3.17", date(2025, 1, 1), False, None, None), ], ) def test_get_support_range( @@ -26,14 +29,18 @@ def test_get_support_range( version: str, today: date, supported: bool, - in_dev: bool, + in_dev: bool | None, esm: bool | None, ): """Test that get_support_range returns a valid object.""" distro = distro_support.get_support_range(distribution, version) assert distro.is_supported_on(today) == supported - assert distro.is_in_development_on(today) == in_dev + if in_dev is None: + with pytest.raises(NoDevelopmentInfoError): + distro.is_in_development_on(today) + else: + assert distro.is_in_development_on(today) == in_dev if esm is None: with pytest.raises(NoESMInfoError): distro.is_esm_on(today) diff --git a/tools/update.py b/tools/update.py index 520e83e..0e47d7f 100644 --- a/tools/update.py +++ b/tools/update.py @@ -3,7 +3,7 @@ import json import pathlib -from distro_support import debian, devuan, ubuntu +from distro_support import alpine, debian, devuan, ubuntu def update(module): @@ -18,3 +18,5 @@ def update(module): update(debian) print("Updating Devuan data") update(devuan) + print("Updating Alpine data") + update(alpine) From b365d1f912379b60078615389e34a3ad9bf1a27b Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Wed, 22 Apr 2026 00:40:23 -0400 Subject: [PATCH 2/3] fix: apply Gemini code review suggestions - Add timeout=10 to urlopen to prevent indefinite hangs - Use json.load(response) instead of read().decode() for more efficient stream-based JSON parsing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/distro_support/alpine.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/distro_support/alpine.py b/src/distro_support/alpine.py index 906cd33..ce513ea 100644 --- a/src/distro_support/alpine.py +++ b/src/distro_support/alpine.py @@ -8,11 +8,11 @@ def get_distro_info() -> dict[str, dict[str, str | None]]: - response: http.client.HTTPResponse = request.urlopen(RELEASES_URL) + response: http.client.HTTPResponse = request.urlopen(RELEASES_URL, timeout=10) if response.status != 200: raise ConnectionError(response.status) - data = json.loads(response.read().decode()) + data = json.load(response) series: dict[str, dict[str, str | None]] = {} for branch in data.get("release_branches", []): From b71f1bec91efbabca44f20ec32e35e8f483a5096 Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Wed, 22 Apr 2026 00:46:24 -0400 Subject: [PATCH 3/3] chore: configure Bandit to exclude tests directory B101 (assert_used) is a false positive for pytest test code. Exclude the tests/ directory from Bandit scanning so assert statements in tests don't generate noise. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 8e9156b..4e6e402 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,9 @@ types = [ ] docs = [] +[tool.bandit] +exclude_dirs = ["tests"] + [tool.hatch.version] source = "vcs" tag-pattern = "(?P\\d{4}\\.\\d\\d\\.\\d\\d)"