From b7ac4b82bbef7347f690125987c0a51e98114301 Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Tue, 21 Apr 2026 18:55:16 -0400 Subject: [PATCH 1/5] refactor(debian-like-downloader): make esm_name optional Some distros (e.g. Devuan) use the same CSV format but do not provide an ESM/ELTS column. Making esm_name optional allows the downloader to be reused for those distributions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/distro_support/_debian_like_downloader.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/distro_support/_debian_like_downloader.py b/src/distro_support/_debian_like_downloader.py index 2699bfb..3ac86ea 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]]: response: http.client.HTTPResponse = request.urlopen(url) if response.status != 200: raise ConnectionError(response.status) @@ -19,6 +21,6 @@ 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}") if esm_name else None, } return series From 935eaa34e49e3f2efaa382b761250fbde779af0c Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Tue, 21 Apr 2026 18:55:36 -0400 Subject: [PATCH 2/5] feat(devuan): add Devuan distribution support Add support for Devuan using the distro-info-data CSV from Salsa (same source and format as Debian). Devuan does not have an ESM/ELTS programme so end_extended_support is always null. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/distro_support/devuan.json | 74 ++++++++++++++++++++++++++++++++++ src/distro_support/devuan.py | 11 +++++ tools/update.py | 6 ++- 3 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 src/distro_support/devuan.json create mode 100644 src/distro_support/devuan.py 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..c713791 --- /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]]: + return _debian_like_downloader.get_distro_info(SUPPORT_INFO_URL, name="devuan") 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) From 54774f8563f6e0700a5a86ae7f5413721b1e2cff Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Tue, 21 Apr 2026 18:55:47 -0400 Subject: [PATCH 3/5] test(devuan): add test cases for Devuan support Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/test_get_support_range.py | 3 +++ 1 file changed, 3 insertions(+) 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( From c8188d83ce68238cd1b862bb7a2e2be7b7c376c9 Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Tue, 21 Apr 2026 22:03:13 -0400 Subject: [PATCH 4/5] fix: correct return type annotations to include None get_distro_info() in _debian_like_downloader, ubuntu, debian, and devuan all return dicts where end_extended_support can be None. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/distro_support/_debian_like_downloader.py | 2 +- src/distro_support/debian.py | 2 +- src/distro_support/devuan.py | 2 +- src/distro_support/ubuntu.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/distro_support/_debian_like_downloader.py b/src/distro_support/_debian_like_downloader.py index 3ac86ea..5aa8cfd 100644 --- a/src/distro_support/_debian_like_downloader.py +++ b/src/distro_support/_debian_like_downloader.py @@ -7,7 +7,7 @@ def get_distro_info( url: str, *, name: str, esm_name: str | None = None -) -> dict[str, dict[str, str]]: +) -> dict[str, dict[str, str | None]]: response: http.client.HTTPResponse = request.urlopen(url) if response.status != 200: raise ConnectionError(response.status) 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.py b/src/distro_support/devuan.py index c713791..a2bb20c 100644 --- a/src/distro_support/devuan.py +++ b/src/distro_support/devuan.py @@ -7,5 +7,5 @@ ) -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="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" ) From 5f70c01fb71900eaa23067504db9c28d7c810cd3 Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Tue, 21 Apr 2026 22:15:02 -0400 Subject: [PATCH 5/5] Fix empty ESM string bug and add downloader tests Per Gemini's review feedback on PR #9: row.get() returns "" for an empty CSV cell, which passes the 'is None' guard in SupportRange.from_json and causes datetime.date.fromisoformat("") to raise ValueError. Fix: use (row.get(...) or None) so empty strings are coerced to None. Also: - Update SupportRange.from_json to accept dict[str, str | None] and use local variables for proper type narrowing - Add tests/test_debian_like_downloader.py demonstrating the bug and covering normal and edge-case behaviour Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/distro_support/_debian_like_downloader.py | 4 +- src/distro_support/_distro.py | 26 +++--- tests/test_debian_like_downloader.py | 83 +++++++++++++++++++ 3 files changed, 101 insertions(+), 12 deletions(-) create mode 100644 tests/test_debian_like_downloader.py diff --git a/src/distro_support/_debian_like_downloader.py b/src/distro_support/_debian_like_downloader.py index 5aa8cfd..447d960 100644 --- a/src/distro_support/_debian_like_downloader.py +++ b/src/distro_support/_debian_like_downloader.py @@ -21,6 +21,8 @@ def get_distro_info( "begin_support": row["release"], "end_support": row["eol"], "begin_dev": row["created"], - "end_extended_support": row.get(f"eol-{esm_name}") if esm_name else None, + "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/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" + )