Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions src/distro_support/rhel.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
{
"6": {
"distribution": "rhel",
"version": "6",
"begin_support": "2010-11-10",
"end_support": "2020-11-30",
"begin_dev": "2010-11-10",
"end_extended_support": "2024-06-30"
},
"7": {
"distribution": "rhel",
"version": "7",
"begin_support": "2014-06-10",
"end_support": "2024-06-30",
"begin_dev": "2014-06-10",
"end_extended_support": "2029-05-31"
},
"8": {
"distribution": "rhel",
"version": "8",
"begin_support": "2019-05-07",
"end_support": "2029-05-31",
"begin_dev": "2019-05-07",
"end_extended_support": "2032-05-31"
},
"9": {
"distribution": "rhel",
"version": "9",
"begin_support": "2022-05-18",
"end_support": "2032-05-31",
"begin_dev": "2022-05-18",
"end_extended_support": "2035-05-31"
},
"10": {
"distribution": "rhel",
"version": "10",
"begin_support": "2025-05-20",
"end_support": "2035-05-31",
"begin_dev": "2025-05-20",
"end_extended_support": "2038-05-31"
}
}
50 changes: 50 additions & 0 deletions src/distro_support/rhel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"""Information about Red Hat Enterprise Linux support."""

import json
from urllib import request

SUPPORT_INFO_URL = "https://access.redhat.com/product-life-cycles/api/v1/products?name=Red+Hat+Enterprise+Linux"

_PHASE_GA = "general availability"
_PHASE_MAINTENANCE = "maintenance support"
_PHASE_ELS = "extended life cycle support (els) add-on"


def _parse_date(value: str | None) -> str | None:
"""Return an ISO date string (YYYY-MM-DD), or None for missing/non-date values."""
if not value or value.upper() == "N/A" or value.lower() == "ongoing":
return None
return value[:10]


def get_distro_info() -> dict[str, dict[str, str | None]]:
req = request.Request(SUPPORT_INFO_URL, headers={"User-Agent": "distro-support"})
with request.urlopen(req) as response: # nosec B310

Check warning on line 22 in src/distro_support/rhel.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

src/distro_support/rhel.py#L22

Detected a dynamic value being used with urllib. urllib supports 'file://' schemes, so a dynamic value controlled by a malicious actor may allow them to read arbitrary files.

Check warning on line 22 in src/distro_support/rhel.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

src/distro_support/rhel.py#L22

The application was found passing in a non-literal value to the `urllib` methods which issue requests. `urllib` supports the `file://` scheme, which may allow an adversary who can control the URL value to read arbitrary files on the file system.
Comment on lines +21 to +22
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

urlopen() is called without a timeout, so an unresponsive Red Hat API can hang indefinitely. The Alpine downloader uses an explicit timeout; consider adding a reasonable timeout here as well to avoid blocking callers when get_online=True.

Copilot uses AI. Check for mistakes.
if response.status != 200:
raise RuntimeError(
f"Unexpected HTTP status from Red Hat API: {response.status}"
)
Comment on lines +24 to +26
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Other downloaders raise ConnectionError(response.status) for non-200 responses (e.g., alpine.py and _debian_like_downloader.py). For consistency (and easier reuse of error-handling/tests), consider raising ConnectionError(response.status) here instead of RuntimeError.

Suggested change
raise RuntimeError(
f"Unexpected HTTP status from Red Hat API: {response.status}"
)
raise ConnectionError(response.status)

Copilot uses AI. Check for mistakes.
data = json.load(response)

if not data.get("data"):
return {}

series = {}
for version in data["data"][0].get("versions", []):
ver = version["name"]
phases = {p["name"].lower(): p for p in version.get("phases", [])}
ga_date = _parse_date(phases.get(_PHASE_GA, {}).get("end_date"))

series[ver] = {
"distribution": "rhel",
"version": ver,
"begin_support": ga_date,
"end_support": _parse_date(
phases.get(_PHASE_MAINTENANCE, {}).get("end_date")
),
"begin_dev": ga_date,
"end_extended_support": _parse_date(
Comment on lines +41 to +46
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description notes begin_dev is set to the GA date so is_in_development_on always returns False, but if GA is missing (_parse_date() returns None for "N/A"/"Ongoing"), then begin_dev becomes None and SupportRange.is_in_development_on() will raise NoDevelopmentInfoError. Either ensure GA is always present for supported RHEL versions (so this invariant holds), or adjust the stated behavior/tests to reflect that development info may be unavailable when GA is missing.

Copilot uses AI. Check for mistakes.
phases.get(_PHASE_ELS, {}).get("end_date")
),
}
return series
3 changes: 3 additions & 0 deletions tests/test_get_support_range.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@
# 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),
("rhel", "9", date(2023, 1, 1), True, False, False),
("rhel", "9", date(2033, 1, 1), False, False, True),
("rhel", "7", date(2025, 1, 1), False, False, True),
],
)
def test_get_support_range(
Expand Down
178 changes: 178 additions & 0 deletions tests/test_rhel_downloader.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
"""Tests for the RHEL downloader."""

import json
from unittest.mock import MagicMock, patch

Check warning on line 5 in tests/test_rhel_downloader.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/test_rhel_downloader.py#L5

Import "pytest" could not be resolved (reportMissingImports)
import pytest

from distro_support.rhel import _parse_date, get_distro_info

# Minimal realistic API response mirroring the Red Hat lifecycle API structure.
_FAKE_API_RESPONSE = {
"data": [
{
"versions": [
{
"name": "9",
"phases": [
{
"name": "General availability",
"end_date": "2022-05-18T00:00:00.000Z",
},
{
"name": "Full support",
"end_date": "2027-05-31T00:00:00.000Z",
},
{
"name": "Maintenance support",
"end_date": "2032-05-31T00:00:00.000Z",
},
{
"name": "Extended life cycle support (ELS) add-on",
"end_date": "2035-05-31T00:00:00.000Z",
},
{
"name": "Extended life phase",
"end_date": "Ongoing",
},
],
},
{
"name": "7",
"phases": [
{
"name": "General availability",
"end_date": "2014-06-10T00:00:00.000Z",
},
{
"name": "Full support",
"end_date": "2019-08-06T00:00:00.000Z",
},
{
"name": "Maintenance support",
"end_date": "2024-06-30T00:00:00.000Z",
},
{
"name": "Extended life cycle support (ELS) add-on",
"end_date": "2029-05-31T00:00:00.000Z",
},
{
"name": "Extended life phase",
"end_date": "Ongoing",
},
],
},
{
# Version with N/A GA date (as seen in some older entries)
"name": "6",
"phases": [
{
"name": "General availability",
"end_date": "N/A",
},
{
"name": "Maintenance support",
"end_date": "2020-11-30T00:00:00.000Z",
},
# No ELS phase for this entry
],
},
]
}
]
}


def _make_mock_response(data: dict):
mock_response = MagicMock()
mock_response.status = 200
mock_response.read.return_value = json.dumps(data).encode()
mock_response.__enter__ = lambda s: s
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_make_mock_response() defines __enter__ as lambda s: s, but context managers call __enter__() with no arguments. This will raise TypeError when get_distro_info() executes with request.urlopen(...) as response:. Use a zero-arg __enter__ (or a MagicMock with return_value=mock_response) so the mocked response can be used in a with block.

Suggested change
mock_response.__enter__ = lambda s: s
mock_response.__enter__ = MagicMock(return_value=mock_response)

Copilot uses AI. Check for mistakes.
mock_response.__exit__ = MagicMock(return_value=False)
return mock_response


@pytest.fixture
def mock_urlopen():
with patch("distro_support.rhel.request.urlopen") as mock:
mock.return_value = _make_mock_response(_FAKE_API_RESPONSE)
yield mock


class TestParseDate:
def test_iso_timestamp(self):
assert _parse_date("2022-05-18T00:00:00.000Z") == "2022-05-18"

Check warning on line 104 in tests/test_rhel_downloader.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/test_rhel_downloader.py#L104

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. (B101)

def test_plain_iso_date(self):
assert _parse_date("2022-05-18") == "2022-05-18"

Check warning on line 107 in tests/test_rhel_downloader.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/test_rhel_downloader.py#L107

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. (B101)

def test_ongoing_returns_none(self):
assert _parse_date("Ongoing") is None

Check warning on line 110 in tests/test_rhel_downloader.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/test_rhel_downloader.py#L110

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. (B101)

def test_ongoing_case_insensitive(self):
assert _parse_date("ongoing") is None

Check warning on line 113 in tests/test_rhel_downloader.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/test_rhel_downloader.py#L113

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. (B101)

def test_na_returns_none(self):
assert _parse_date("N/A") is None

Check warning on line 116 in tests/test_rhel_downloader.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/test_rhel_downloader.py#L116

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. (B101)

def test_na_case_insensitive(self):
assert _parse_date("n/a") is None

Check warning on line 119 in tests/test_rhel_downloader.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/test_rhel_downloader.py#L119

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. (B101)

def test_none_returns_none(self):
assert _parse_date(None) is None

Check warning on line 122 in tests/test_rhel_downloader.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/test_rhel_downloader.py#L122

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. (B101)

def test_empty_string_returns_none(self):
assert _parse_date("") is None

Check warning on line 125 in tests/test_rhel_downloader.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/test_rhel_downloader.py#L125

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. (B101)


class TestGetDistroInfo:
def test_returns_all_versions(self, mock_urlopen):

Check warning on line 129 in tests/test_rhel_downloader.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/test_rhel_downloader.py#L129

Redefining name 'mock_urlopen' from outer scope (line 96)
result = get_distro_info()
assert set(result.keys()) == {"9", "7", "6"}

Check warning on line 131 in tests/test_rhel_downloader.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/test_rhel_downloader.py#L131

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. (B101)

def test_distribution_name(self, mock_urlopen):

Check warning on line 133 in tests/test_rhel_downloader.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/test_rhel_downloader.py#L133

Redefining name 'mock_urlopen' from outer scope (line 96)
result = get_distro_info()
for ver in result.values():
assert ver["distribution"] == "rhel"

Check warning on line 136 in tests/test_rhel_downloader.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/test_rhel_downloader.py#L136

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. (B101)

def test_version_field_matches_key(self, mock_urlopen):

Check warning on line 138 in tests/test_rhel_downloader.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/test_rhel_downloader.py#L138

Redefining name 'mock_urlopen' from outer scope (line 96)
result = get_distro_info()
for key, ver in result.items():
assert ver["version"] == key

Check warning on line 141 in tests/test_rhel_downloader.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/test_rhel_downloader.py#L141

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. (B101)

def test_begin_dev_equals_begin_support(self, mock_urlopen):

Check warning on line 143 in tests/test_rhel_downloader.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/test_rhel_downloader.py#L143

Redefining name 'mock_urlopen' from outer scope (line 96)
"""begin_dev is set to the GA date so is_in_development_on always returns False."""
result = get_distro_info()
for ver in result.values():
assert ver["begin_dev"] == ver["begin_support"]

Check warning on line 147 in tests/test_rhel_downloader.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/test_rhel_downloader.py#L147

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. (B101)

def test_full_version_dates(self, mock_urlopen):

Check warning on line 149 in tests/test_rhel_downloader.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/test_rhel_downloader.py#L149

Redefining name 'mock_urlopen' from outer scope (line 96)
result = get_distro_info()
assert result["9"]["begin_support"] == "2022-05-18"

Check warning on line 151 in tests/test_rhel_downloader.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/test_rhel_downloader.py#L151

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. (B101)
assert result["9"]["end_support"] == "2032-05-31"

Check warning on line 152 in tests/test_rhel_downloader.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/test_rhel_downloader.py#L152

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. (B101)
assert result["9"]["end_extended_support"] == "2035-05-31"

Check warning on line 153 in tests/test_rhel_downloader.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/test_rhel_downloader.py#L153

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. (B101)

def test_ongoing_eol_is_none(self, mock_urlopen):

Check warning on line 155 in tests/test_rhel_downloader.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/test_rhel_downloader.py#L155

Redefining name 'mock_urlopen' from outer scope (line 96)
# Extended life phase is "Ongoing" — should not bleed into our fields
result = get_distro_info()
assert result["9"]["end_extended_support"] == "2035-05-31"

def test_na_ga_date_is_none(self, mock_urlopen):

Check warning on line 160 in tests/test_rhel_downloader.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/test_rhel_downloader.py#L160

Redefining name 'mock_urlopen' from outer scope (line 96)
result = get_distro_info()
assert result["6"]["begin_support"] is None

Check warning on line 162 in tests/test_rhel_downloader.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/test_rhel_downloader.py#L162

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. (B101)

def test_missing_els_phase_is_none(self, mock_urlopen):

Check warning on line 164 in tests/test_rhel_downloader.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/test_rhel_downloader.py#L164

Redefining name 'mock_urlopen' from outer scope (line 96)
result = get_distro_info()
assert result["6"]["end_extended_support"] is None

Check warning on line 166 in tests/test_rhel_downloader.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/test_rhel_downloader.py#L166

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. (B101)

def test_sends_user_agent(self, mock_urlopen):

Check warning on line 168 in tests/test_rhel_downloader.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/test_rhel_downloader.py#L168

Redefining name 'mock_urlopen' from outer scope (line 96)
get_distro_info()
req = mock_urlopen.call_args[0][0]
assert req.get_header("User-agent") == "distro-support"

Check warning on line 171 in tests/test_rhel_downloader.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/test_rhel_downloader.py#L171

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. (B101)

def test_http_error_raises(self):
mock_response = _make_mock_response(_FAKE_API_RESPONSE)
mock_response.status = 503
with patch("distro_support.rhel.request.urlopen", return_value=mock_response):
with pytest.raises(RuntimeError):
get_distro_info()
22 changes: 19 additions & 3 deletions tools/update.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,27 @@

import json
import pathlib
import sys

from distro_support import alpine, debian, devuan, ubuntu
from distro_support import alpine, debian, devuan, rhel, ubuntu


def _version_sort_key(version: str) -> tuple[int, ...]:
if not version:
return (sys.maxsize,)
try:
return tuple(int(x) for x in version.split("."))
except ValueError:
return (0,)


def update(module):
ubuntu_data = pathlib.Path(module.__file__).with_suffix(".json")
ubuntu_data.write_text(json.dumps(module.get_distro_info(), indent=" ") + "\n")
data_path = pathlib.Path(module.__file__).with_suffix(".json")
data = module.get_distro_info()
sorted_data = dict(
sorted(data.items(), key=lambda item: _version_sort_key(item[0]))
)
data_path.write_text(json.dumps(sorted_data, indent=" ") + "\n")


if __name__ == "__main__":
Expand All @@ -20,3 +34,5 @@ def update(module):
update(devuan)
print("Updating Alpine data")
update(alpine)
print("Updating RHEL data")
update(rhel)