diff --git a/src/distro_support/_debian_like_downloader.py b/src/distro_support/_debian_like_downloader.py index 2699bfb..447d960 100644 --- a/src/distro_support/_debian_like_downloader.py +++ b/src/distro_support/_debian_like_downloader.py @@ -5,7 +5,9 @@ from urllib import request -def get_distro_info(url: str, *, name: str, esm_name: str) -> dict[str, dict[str, str]]: +def get_distro_info( + url: str, *, name: str, esm_name: str | None = None +) -> dict[str, dict[str, str | None]]: response: http.client.HTTPResponse = request.urlopen(url) if response.status != 200: raise ConnectionError(response.status) @@ -19,6 +21,8 @@ def get_distro_info(url: str, *, name: str, esm_name: str) -> dict[str, dict[str "begin_support": row["release"], "end_support": row["eol"], "begin_dev": row["created"], - "end_extended_support": row[f"eol-{esm_name}"], + "end_extended_support": (row.get(f"eol-{esm_name}") or None) + if esm_name + else None, } return series diff --git a/src/distro_support/_distro.py b/src/distro_support/_distro.py index 4b7f0a5..0639d8b 100644 --- a/src/distro_support/_distro.py +++ b/src/distro_support/_distro.py @@ -54,20 +54,24 @@ def is_esm_on(self, date: datetime.date) -> bool: ) @classmethod - def from_json(cls, data: dict[str, str]) -> Self: + def from_json(cls, data: dict[str, str | None]) -> Self: + begin_support = data.get("begin_support") + end_support = data.get("end_support") + begin_dev = data.get("begin_dev") + end_extended_support = data.get("end_extended_support") return cls( - distribution=data["distribution"], - version=data["version"], + distribution=data["distribution"] or "", + version=data["version"] or "", begin_support=None - if data.get("begin_support") is None - else datetime.date.fromisoformat(data["begin_support"]), + if begin_support is None + else datetime.date.fromisoformat(begin_support), end_support=None - if data.get("end_support") is None - else datetime.date.fromisoformat(data["end_support"]), + if end_support is None + else datetime.date.fromisoformat(end_support), begin_dev=None - if data.get("begin_dev") is None - else datetime.date.fromisoformat(data["begin_dev"]), + if begin_dev is None + else datetime.date.fromisoformat(begin_dev), end_extended_support=None - if data.get("end_extended_support") is None - else datetime.date.fromisoformat(data["end_extended_support"]), + if end_extended_support is None + else datetime.date.fromisoformat(end_extended_support), ) diff --git a/src/distro_support/debian.py b/src/distro_support/debian.py index 2e2dd10..6974158 100644 --- a/src/distro_support/debian.py +++ b/src/distro_support/debian.py @@ -5,7 +5,7 @@ SUPPORT_INFO_URL = "https://salsa.debian.org/debian/distro-info-data/-/raw/main/debian.csv?ref_type=heads&inline=false" -def get_distro_info() -> dict[str, dict[str, str]]: +def get_distro_info() -> dict[str, dict[str, str | None]]: return _debian_like_downloader.get_distro_info( SUPPORT_INFO_URL, name="debian", esm_name="elts" ) diff --git a/src/distro_support/devuan.json b/src/distro_support/devuan.json new file mode 100644 index 0000000..f5b2376 --- /dev/null +++ b/src/distro_support/devuan.json @@ -0,0 +1,74 @@ +{ + "1": { + "distribution": "devuan", + "version": "1", + "begin_support": "2017-05-25", + "end_support": "2020-06-30", + "begin_dev": "2014-11-26", + "end_extended_support": null + }, + "2": { + "distribution": "devuan", + "version": "2", + "begin_support": "2018-06-08", + "end_support": "2022-06-30", + "begin_dev": "2017-05-25", + "end_extended_support": null + }, + "3": { + "distribution": "devuan", + "version": "3", + "begin_support": "2020-06-01", + "end_support": "2024-06-30", + "begin_dev": "2018-06-08", + "end_extended_support": null + }, + "4": { + "distribution": "devuan", + "version": "4", + "begin_support": "2021-10-14", + "end_support": "2026-08-31", + "begin_dev": "2020-01-07", + "end_extended_support": null + }, + "5": { + "distribution": "devuan", + "version": "5", + "begin_support": "2023-08-14", + "end_support": "2028-06-30", + "begin_dev": "2021-10-14", + "end_extended_support": null + }, + "6": { + "distribution": "devuan", + "version": "6", + "begin_support": "2025-11-02", + "end_support": "2030-06-30", + "begin_dev": "2023-03-29", + "end_extended_support": null + }, + "7": { + "distribution": "devuan", + "version": "7", + "begin_support": null, + "end_support": null, + "begin_dev": "2025-07-26", + "end_extended_support": null + }, + "8": { + "distribution": "devuan", + "version": "8", + "begin_support": null, + "end_support": null, + "begin_dev": "2027-07-01", + "end_extended_support": null + }, + "": { + "distribution": "devuan", + "version": "", + "begin_support": null, + "end_support": null, + "begin_dev": "2014-11-26", + "end_extended_support": null + } +} diff --git a/src/distro_support/devuan.py b/src/distro_support/devuan.py new file mode 100644 index 0000000..a2bb20c --- /dev/null +++ b/src/distro_support/devuan.py @@ -0,0 +1,11 @@ +"""Information about Devuan support.""" + +from . import _debian_like_downloader + +SUPPORT_INFO_URL = ( + "https://salsa.debian.org/debian/distro-info-data/-/raw/main/devuan.csv" +) + + +def get_distro_info() -> dict[str, dict[str, str | None]]: + return _debian_like_downloader.get_distro_info(SUPPORT_INFO_URL, name="devuan") diff --git a/src/distro_support/ubuntu.py b/src/distro_support/ubuntu.py index 5285f61..28e7401 100644 --- a/src/distro_support/ubuntu.py +++ b/src/distro_support/ubuntu.py @@ -7,7 +7,7 @@ ) -def get_distro_info() -> dict[str, dict[str, str]]: +def get_distro_info() -> dict[str, dict[str, str | None]]: return _debian_like_downloader.get_distro_info( SUPPORT_INFO_URL, name="ubuntu", esm_name="esm" ) diff --git a/tests/test_debian_like_downloader.py b/tests/test_debian_like_downloader.py new file mode 100644 index 0000000..e96e670 --- /dev/null +++ b/tests/test_debian_like_downloader.py @@ -0,0 +1,83 @@ +"""Tests for the debian-like downloader.""" + +import unittest.mock + +import pytest + +from distro_support import _debian_like_downloader +from distro_support._distro import SupportRange + + +def _make_response(csv_text: str, status: int = 200): + mock_response = unittest.mock.MagicMock() + mock_response.status = status + mock_response.read.return_value = csv_text.encode() + mock_response.__enter__ = lambda self: self + mock_response.__exit__ = unittest.mock.MagicMock(return_value=False) + return mock_response + + +CSV_WITH_EMPTY_ESM = """\ +version,release,eol,created,eol-esm +22.04 LTS,2022-04-21,2027-04-01,2021-10-14,2032-04-09 +24.04 LTS,2024-04-25,2029-04-25,2023-10-12, +""" + +CSV_WITHOUT_ESM = """\ +version,release,eol,created +5,2021-01-01,2026-06-15,2020-01-01 +""" + + +@unittest.mock.patch("distro_support._debian_like_downloader.request.urlopen") +def test_empty_esm_column_returns_none(mock_urlopen): + """An empty eol-esm column must produce None, not an empty string. + + Without the `or None` fix, row.get() returns "" for an empty CSV cell. + SupportRange.from_json() checks `is None`, so "" bypasses the guard and + datetime.date.fromisoformat("") raises a ValueError when the data is used. + """ + mock_urlopen.return_value = _make_response(CSV_WITH_EMPTY_ESM) + + result = _debian_like_downloader.get_distro_info( + "https://example.com/data.csv", name="ubuntu", esm_name="esm" + ) + + assert result["24.04"]["end_extended_support"] is None + # Verify the data round-trips through SupportRange.from_json without error + support_range = SupportRange.from_json(result["24.04"]) + assert support_range.end_extended_support is None + + +@unittest.mock.patch("distro_support._debian_like_downloader.request.urlopen") +def test_populated_esm_column_returns_date_string(mock_urlopen): + """A populated eol-esm column must be returned as-is.""" + mock_urlopen.return_value = _make_response(CSV_WITH_EMPTY_ESM) + + result = _debian_like_downloader.get_distro_info( + "https://example.com/data.csv", name="ubuntu", esm_name="esm" + ) + + assert result["22.04"]["end_extended_support"] == "2032-04-09" + + +@unittest.mock.patch("distro_support._debian_like_downloader.request.urlopen") +def test_no_esm_name_returns_none(mock_urlopen): + """When esm_name is not provided, end_extended_support must be None.""" + mock_urlopen.return_value = _make_response(CSV_WITHOUT_ESM) + + result = _debian_like_downloader.get_distro_info( + "https://example.com/data.csv", name="devuan" + ) + + assert result["5"]["end_extended_support"] is None + + +@unittest.mock.patch("distro_support._debian_like_downloader.request.urlopen") +def test_http_error_raises(mock_urlopen): + mock_urlopen.return_value = _make_response("", status=404) + + with pytest.raises(ConnectionError): + _debian_like_downloader.get_distro_info( + "https://example.com/data.csv", name="ubuntu" + ) diff --git a/tests/test_get_support_range.py b/tests/test_get_support_range.py index 9b98fab..9a7d037 100644 --- a/tests/test_get_support_range.py +++ b/tests/test_get_support_range.py @@ -16,6 +16,9 @@ ("ubuntu", "25.10", date(2025, 8, 12), False, True, None), ("debian", "1.1", date(2000, 1, 1), False, False, None), ("debian", "", date(3000, 1, 1), False, True, None), + ("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), ], ) def test_get_support_range( diff --git a/tools/update.py b/tools/update.py index 7729d47..520e83e 100644 --- a/tools/update.py +++ b/tools/update.py @@ -3,12 +3,12 @@ import json import pathlib -from distro_support import debian, ubuntu +from distro_support import debian, devuan, ubuntu def update(module): ubuntu_data = pathlib.Path(module.__file__).with_suffix(".json") - ubuntu_data.write_text(json.dumps(module.get_distro_info(), indent=" ")) + ubuntu_data.write_text(json.dumps(module.get_distro_info(), indent=" ") + "\n") if __name__ == "__main__": @@ -16,3 +16,5 @@ def update(module): update(ubuntu) print("Updating Debian data") update(debian) + print("Updating Devuan data") + update(devuan)