| Server IP : 104.21.21.239 / Your IP : 216.73.216.101 Web Server : Apache/2.4.68 (Amazon Linux) OpenSSL/3.5.5 System : Linux ip-172-31-69-123.ec2.internal 6.1.176-223.369.amzn2023.x86_64 #1 SMP PREEMPT_DYNAMIC Fri Jul 24 13:34:27 UTC 2026 x86_64 User : ec2-user ( 1000) PHP Version : 8.4.23 Disable Function : NONE MySQL : OFF | cURL : ON | WGET : ON | Perl : ON | Python : OFF | Sudo : ON | Pkexec : ON Directory : /lib/python3.9/site-packages/supportinfo/ |
Upload File : |
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: MIT
import logging
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import date, datetime, timezone
from enum import Enum
from pathlib import Path
from typing import Dict, List, Optional, Set, Union
import xmlschema
from lxml import objectify
from xmlschema import XMLSchema10, XMLSchema11
# Setup logger
log = logging.getLogger(__name__)
class SupportinfoSchemaVersions(Enum):
LEGACY = "unversioned"
V1_0 = "1.0"
class SupportInfoStates(str, Enum):
UNKNOWN = "unknown"
SUPPORTED = "supported"
UNSUPPORTED = "unsupported"
@dataclass
class PhaseInfo:
"""Information about a single lifecycle phase.
For legacy format:
- name: Status value ("supported", "unsupported", "unknown")
- start_date: Date when this phase begins (may be None for first phase)
- support_level_info: Not used (None)
- milestone: Not used (None)
- display_name: Not used (None)
For v1.0 format:
- name: Phase name (e.g., "full_support", "maintenance_support", "end_of_life")
- start_date: Resolved date string when phase begins (from start_date or milestone)
- support_level_info: SupportLevelInfo with name, severities, description, and display_name
- milestone: Original milestone name if start_date was resolved from milestone
- display_name: Customer-facing display name for this phase
"""
name: str
start_date: Optional[str] = None
support_level_info: Optional["SupportLevelInfo"] = None
milestone: Optional[str] = None
display_name: Optional[str] = None
@dataclass
class PackageEntry:
"""Unified package entry structure.
For legacy format:
- name: Package name
- statement_id: Statement ID from the XML (e.g., "al2023_core_support")
- note_ref: Per-package note reference ID (e.g., "note_1")
For v1.0 format:
- name: Package name
- statement_id: Lifecycle name the package references (e.g., "al2023_standard")
- note_ref: Not used (None) - v1.0 has lifecycle-level notes instead
"""
name: str
statement_id: str
note_ref: Optional[str] = None
@dataclass
class LifecycleInfo:
"""Information about a support lifecycle.
Attributes:
name: Unique identifier for the lifecycle (e.g., "eol_redis6_lc").
note: Optional reference to a note providing additional context.
display_name: Customer-facing display name for this lifecycle.
description: Human-readable description of what this lifecycle represents.
"""
name: str
note: Optional[str] = None
display_name: Optional[str] = None
description: Optional[str] = None
@dataclass
class OriginInfo:
"""Information about a package origin.
Attributes:
name: Unique identifier for the origin (e.g., "al2023_core").
repo_id: Repository identifier.
dist: Distribution name.
vendor: Package vendor.
signing_key: GPG signing key identifier.
display_name: Customer-facing display name for this origin.
description: Human-readable description of this package origin.
"""
name: str
repo_id: Optional[str] = None
dist: Optional[str] = None
vendor: Optional[str] = None
signing_key: Optional[str] = None
display_name: Optional[str] = None
description: Optional[str] = None
@dataclass
class MilestoneInfo:
"""Information about a support milestone.
Attributes:
name: Unique identifier for the milestone (e.g., "AL2023_GA", "AL2023_EOS").
date: The date associated with this milestone.
display_name: Customer-facing display name for this milestone.
description: Human-readable description of what this milestone represents.
"""
name: str
date: date
display_name: Optional[str] = None
description: Optional[str] = None
@dataclass
class SupportLevelInfo:
"""Information about a support level.
Attributes:
name: Unique identifier for the support level (e.g., "default", "limited", "eos").
severities: Comma-separated severity values addressed at this level.
description: Human-readable description of what this support level means.
display_name: Customer-facing display name for this support level.
"""
name: str
severities: Optional[str]
description: Optional[str]
display_name: Optional[str] = None
@dataclass
class SupportStatement:
"""Consistent structure for support statement/lifecycle data.
For legacy format:
- current_phase: Calculated string ("supported", "unsupported", "unknown") based on marker and date
- phases: Dict of phase_name -> PhaseInfo (supported -> unsupported or vice versa)
For v1.0 format:
- current_phase: Current phase name (e.g., "full_support", "maintenance_support")
- phases: Dict mapping phase name to PhaseInfo for direct lookup
- Client can look up support_level_info via phases.get(current_phase)
"""
current_phase: Optional[str] = None
phases: Dict[str, PhaseInfo] = field(default_factory=dict)
summary: str = ""
text: str = ""
link: Optional[str] = None
lifecycle_note: Optional[str] = None
package_class: Optional[str] = None
origin: Optional[OriginInfo] = None
class SupportinfoHandler(ABC):
"""Abstract base class for handling supportinfo files."""
# Declare schema attribute for type checking
schema: Union[XMLSchema10, XMLSchema11]
schema_path: Path
schema_version: SupportinfoSchemaVersions
def __init__(self, supportinfo_path: str, schema_path: Optional[str] = None):
"""Initialize handler with supportinfo file path.
Args:
supportinfo_path: Path to the supportinfo XML file.
schema_path: Path to custom schema file, or None to use default.
"""
self.supportinfo_path = supportinfo_path
self.xml_tree = objectify.parse(self.supportinfo_path)
self._initialize_schema(schema_path)
self.schema.validate(self.supportinfo_path)
self._root = self.xml_tree.getroot()
@abstractmethod
def _initialize_schema(self, schema_path: Optional[str]) -> None:
"""Initialize the schema for validation.
Args:
schema_path: Path to custom schema file, or None to use default.
"""
pass
@abstractmethod
def get_package_entries(
self, package_name: Optional[str] = None, origin: Optional[str] = None
) -> List[PackageEntry]:
"""Get package entries for a given package name.
Args:
package_name: Filter by package name, or None for all packages.
origin: Filter by origin (V1.0 only, ignored for legacy).
Returns:
List of PackageEntry.
"""
pass
# Methods for DNF plugin compatibility
# Note: These methods are provisional pending DNF plugin integration CR
# The actual interface may change based on plugin requirements
@abstractmethod
def get_support_metadata(self, day: Optional[date] = None) -> Dict[str, SupportStatement]:
"""Return statement metadata for DNF plugin compatibility.
Args:
day: Date to check against (defaults to today).
This returns structured metadata that DNF plugin expects.
"""
pass
@abstractmethod
def get_support_notes(self) -> Dict[str, str]:
"""Return notes data for DNF plugin compatibility.
Returns:
Dict mapping note IDs to note text strings.
"""
pass
@abstractmethod
def get_package_support_info(
self, package_name: str, origin: Optional[str] = None, day: Optional[date] = None
) -> Optional[SupportStatement]:
"""Get full support info for a specific package.
Args:
package_name: Name of the package.
origin: Origin filter (V1.0 only, ignored for legacy).
day: Date to check against (defaults to today).
Returns:
SupportStatement with support data, or None if package not found.
"""
pass
@abstractmethod
def get_support_level_info(
self, support_level: Optional[str] = None
) -> Optional[List[SupportLevelInfo]]:
"""Get support level metadata.
Args:
support_level: Name of the support level (e.g., "default", "limited", "eos").
If None, returns all available support levels.
Returns:
List of SupportLevelInfo objects. If support_level parameter is provided,
returns a list with one item (or None if not found). If support_level is None,
returns list of all support levels.
For legacy format: Returns list of SupportLevelInfo objects where each phase name
(e.g., "supported", "unsupported") becomes the name field, with severities and
description set to None.
"""
pass
@abstractmethod
def get_milestone_info(
self, milestone_name: Optional[str] = None
) -> Optional[List[MilestoneInfo]]:
"""Get milestone metadata.
Args:
milestone_name: Name of the milestone (e.g., "AL2023_GA", "AL2023_EOS").
If None, returns all available milestones.
Returns:
List of MilestoneInfo objects. If milestone_name is provided,
returns a list with one item (or None if not found). If milestone_name
is None, returns list of all milestones.
For legacy format: Returns empty list (no milestones in legacy schema).
"""
pass
@abstractmethod
def get_lifecycle_info(
self, lifecycle_name: Optional[str] = None
) -> Optional[List[LifecycleInfo]]:
"""Get lifecycle metadata.
Args:
lifecycle_name: Name of the lifecycle (e.g., "default_lc", "redis6_lc").
If None, returns all available lifecycles.
Returns:
List of LifecycleInfo objects. If lifecycle_name is provided,
returns a list with one item (or None if not found). If lifecycle_name
is None, returns list of all lifecycles.
For legacy format: Returns empty list (no lifecycles in legacy schema).
"""
pass
@staticmethod
def _resolve_day(day: Optional[date] = None) -> date:
"""Return the provided date, or today's date (UTC) if None.
Args:
day: Date to use, or None for today.
Returns:
The provided date, or today's date (UTC) if None.
"""
return day or datetime.now(timezone.utc).date()
@staticmethod
def create(supportinfo_path: str, schema_path: Optional[str] = None) -> "SupportinfoHandler":
"""Factory method to create the appropriate handler based on schema version.
Args:
supportinfo_path: Path to the supportinfo XML file.
schema_path: Path to custom schema file, or None to use default.
Returns:
SupportinfoHandlerLegacy or SupportinfoHandlerV10 instance.
Raises:
FileNotFoundError: If supportinfo file does not exist.
"""
# Check if file exists first
if not Path(supportinfo_path).exists():
raise FileNotFoundError(f"Supportinfo file not found: {supportinfo_path}")
# Parse XML to detect version
xml_tree = objectify.parse(supportinfo_path)
root = xml_tree.getroot()
# Check if schema_version attribute exists in package_support element
if hasattr(root, "attrib") and "schema_version" in root.attrib:
schema_version = SupportinfoSchemaVersions(root.attrib["schema_version"])
else:
schema_version = SupportinfoSchemaVersions.LEGACY
if schema_version is SupportinfoSchemaVersions.V1_0:
return SupportinfoHandlerV10(supportinfo_path, schema_path)
return SupportinfoHandlerLegacy(supportinfo_path, schema_path)
class SupportinfoHandlerLegacy(SupportinfoHandler):
"""Handler for legacy (unversioned) supportinfo files."""
def _initialize_schema(self, schema_path: Optional[str]) -> None:
"""Initialize legacy schema.
Args:
schema_path: Path to custom schema file, or None to use default.
"""
self.schema_version = SupportinfoSchemaVersions.LEGACY
if schema_path is None:
schemas_dir = Path(__file__).parent / "schemas"
self.schema_path = schemas_dir / "supportinfo.xsd"
else:
self.schema_path = Path(schema_path)
if not self.schema_path.exists():
raise FileNotFoundError(f"Schema file not found: {self.schema_path}")
self.schema: XMLSchema10 = xmlschema.XMLSchema10(str(self.schema_path))
log.info("Schema version: unversioned (legacy)")
def get_package_entries(
self, package_name: Optional[str] = None, origin: Optional[str] = None
) -> List[PackageEntry]:
"""Get package entries for legacy format.
Args:
package_name: Filter by package name, or None for all packages.
origin: Not used for legacy format (included for interface compatibility).
Returns:
List of PackageEntry with (name, statement_id, note_ref).
"""
statements = self._get_statement_xml_elements(package_name)
entries = []
for stmt in statements:
for pkg in stmt.packages.package:
# If filtering by package_name, only include matching packages
if package_name is None or pkg.get("name") == package_name:
entries.append(
PackageEntry(
name=pkg.get("name"),
statement_id=stmt.get("id"),
note_ref=pkg.get("note"),
)
)
return entries
def get_support_metadata(self, day: Optional[date] = None) -> Dict[str, SupportStatement]:
"""Return all statement metadata with status calculated for a given date.
Iterates through all statements and calculates the support status
for each based on marker and date comparisons.
Args:
day: Date to check against. Defaults to today (UTC) if None.
Returns:
Dict mapping statement IDs to SupportStatement objects containing:
- status: Calculated SupportInfoStates based on marker and date
- start_date: Statement start date string
- end_date: Statement end date string
- summary: Statement summary text
- text: Statement description text
- link: Associated URL
"""
day = self._resolve_day(day)
statements: Dict[str, SupportStatement] = {}
for statement in self._root.statements.statement:
stmt_id = statement.get("id")
statements[stmt_id] = self._build_statement_from_xml(statement, day)
return statements
def get_support_notes(self) -> Dict[str, str]:
"""Return notes data for DNF plugin.
Returns:
Dict mapping note IDs to note text strings.
Empty dict if no notes section exists.
"""
notes: Dict[str, str] = {}
if hasattr(self._root, "notes") and self._root.notes is not None:
for note in self._root.notes.note:
note_id = note.get("id")
notes[note_id] = str(note)
return notes
def get_package_support_info(
self, package_name: str, origin: Optional[str] = None, day: Optional[date] = None
) -> Optional[SupportStatement]:
"""Get full support info for a specific package.
Args:
package_name: Name of the package.
origin: Not used for legacy format (included for interface compatibility).
day: Date to check against (defaults to today).
Returns:
SupportStatement with support data, or None if package not found.
"""
day = self._resolve_day(day)
statements = self._get_statement_xml_elements(package_name)
if not statements:
return None
# Return first matching statement's support info
return self._build_statement_from_xml(statements[0], day)
def get_support_level_info(
self, support_level: Optional[str] = None
) -> Optional[List[SupportLevelInfo]]:
"""Get support level metadata from legacy format.
Args:
support_level: If provided, returns list with one item matching that phase name,
or None if not found. If None, returns all unique phase names.
Returns:
List of SupportLevelInfo objects with phase names as the name field.
Severities and description are None for legacy format.
"""
phase_names: Set[str] = set()
for statement in self._root.statements.statement:
phases = self._build_phases(statement)
phase_names.update(phases.keys())
if support_level is not None:
if support_level in phase_names:
return [SupportLevelInfo(name=support_level, severities=None, description=None)]
return None
# Convert phase names to SupportLevelInfo objects
result = []
for name in sorted(phase_names):
result.append(SupportLevelInfo(name=name, severities=None, description=None))
return result
def get_milestone_info(
self, milestone_name: Optional[str] = None
) -> Optional[List[MilestoneInfo]]:
"""Get milestone metadata from legacy format.
Legacy format does not have milestones.
Returns:
Empty list if milestone_name is None, None if milestone_name is provided.
"""
if milestone_name is not None:
return None
return []
def get_lifecycle_info(
self, lifecycle_name: Optional[str] = None
) -> Optional[List[LifecycleInfo]]:
"""Get lifecycle metadata from legacy format.
Legacy format does not have lifecycles.
Returns:
Empty list if lifecycle_name is None, None if lifecycle_name is provided.
"""
if lifecycle_name is not None:
return None
return []
def _get_statement_xml_elements(
self, package_name: Optional[str] = None
) -> List[objectify.ObjectifiedElement]:
"""Get raw XML statement elements for a package.
Args:
package_name: Name of the package to query, or None for all statements.
Returns:
List of XML statement elements containing the package.
"""
if package_name is None:
return list(self._root.statements.statement)
return self._root.xpath(".//statement[packages/package[@name=$pname]]", pname=package_name)
def _calculate_statement_status(
self, statement: objectify.ObjectifiedElement, day: date
) -> SupportInfoStates:
"""Calculate status for a statement based on marker and date.
Args:
statement: XML statement element
day: Date to check against
Returns:
SupportInfoStates enum value (acts as string)
"""
start_date: Optional[date] = None
end_date: Optional[date] = None
if statement.get("start_date") is not None:
start_date = datetime.fromisoformat(statement.get("start_date")).date()
if statement.get("end_date") is not None:
end_date = datetime.fromisoformat(statement.get("end_date")).date()
marker = statement.get("marker")
if marker == SupportInfoStates.SUPPORTED.value and end_date is not None:
if day < end_date:
return SupportInfoStates.SUPPORTED
else:
return SupportInfoStates.UNSUPPORTED
elif marker == SupportInfoStates.UNSUPPORTED.value and start_date is not None:
if day >= start_date:
return SupportInfoStates.UNSUPPORTED
else:
return SupportInfoStates.SUPPORTED
else:
log.warning(f"Unknown marker: {marker}")
return SupportInfoStates.UNKNOWN
def _build_phases(self, statement: objectify.ObjectifiedElement) -> Dict[str, PhaseInfo]:
"""Build phases dict for legacy format, sorted by start_date.
Legacy format has two implicit phases based on marker:
- marker="supported" with end_date: supported until end_date, then unsupported
- marker="unsupported" with start_date: supported until start_date, then unsupported
Args:
statement: XML statement element
Returns:
Dict mapping phase name to PhaseInfo, sorted by start_date
"""
start_date_str = statement.get("start_date")
end_date_str = statement.get("end_date")
marker = statement.get("marker")
if marker == SupportInfoStates.SUPPORTED.value and end_date_str:
# Supported phase starts first (with start_date), unsupported starts at end_date
phase_list = [
PhaseInfo(SupportInfoStates.SUPPORTED.value, start_date_str),
PhaseInfo(SupportInfoStates.UNSUPPORTED.value, end_date_str),
]
elif marker == SupportInfoStates.UNSUPPORTED.value and start_date_str:
# Supported phase has no start_date, unsupported starts at start_date
phase_list = [
PhaseInfo(SupportInfoStates.SUPPORTED.value),
PhaseInfo(SupportInfoStates.UNSUPPORTED.value, start_date_str),
]
else:
phase_list = [PhaseInfo(SupportInfoStates.UNKNOWN.value, start_date_str)]
# Sort by start_date (phases without dates go first)
phase_list.sort(key=lambda p: p.start_date or "")
return {phase.name: phase for phase in phase_list}
def _build_statement_from_xml(
self, statement: objectify.ObjectifiedElement, day: date
) -> SupportStatement:
"""Build SupportStatement from XML statement element.
Args:
statement: XML statement element
day: Date to check against
Returns:
SupportStatement with calculated status and phases
"""
# Note: "text" is a reserved attribute in lxml (returns whitespace between tags)
# We need to find the <text> child element directly
text_elements = statement.xpath("text")
text_content = str(text_elements[0]) if text_elements else ""
return SupportStatement(
current_phase=self._calculate_statement_status(statement, day).value,
phases=self._build_phases(statement),
summary=str(statement.summary) if hasattr(statement, "summary") else "",
text=text_content,
link=str(statement.link) if hasattr(statement, "link") else None,
)
class SupportinfoHandlerV10(SupportinfoHandler):
"""Handler for v1.0 supportinfo files."""
def _initialize_schema(self, schema_path: Optional[str]) -> None:
"""Initialize v1.0 schema.
Args:
schema_path: Path to custom schema file, or None to use default.
"""
self.schema_version = SupportinfoSchemaVersions.V1_0
if schema_path is None:
schemas_dir = Path(__file__).parent / "schemas"
self.schema_path = schemas_dir / f"supportinfo-{self.schema_version.value}.xsd"
else:
self.schema_path = Path(schema_path)
if not self.schema_path.exists():
raise FileNotFoundError(f"Schema file not found: {self.schema_path}")
self.schema: XMLSchema11 = xmlschema.XMLSchema11(str(self.schema_path))
log.info("Schema version: 1.0")
def get_package_entries(
self, package_name: Optional[str] = None, origin: Optional[str] = None
) -> List[PackageEntry]:
"""Get package entries for v1.0 format.
Args:
package_name: Filter by package name, or None for all packages.
origin: Filter by origin name, or None for all origins.
Returns:
List of PackageEntry with (name, statement_id, note_ref).
"""
packages = self._get_package_xml_elements(package_name, origin)
# V1.0 doesn't have per-package notes, note_ref is None
entries = []
for pkg in packages:
entries.append(
PackageEntry(
name=pkg.get("name"),
statement_id=pkg.get("lifecycle"),
note_ref=None,
)
)
return entries
def get_package_support_info(
self, package_name: str, origin: Optional[str] = None, day: Optional[date] = None
) -> Optional[SupportStatement]:
"""Get support info for a specific package.
Args:
package_name: Name of the package.
origin: Origin filter. Required if package exists in multiple origins.
day: Date to check against (defaults to today).
Returns:
SupportStatement with raw support data, or None if package not found.
Raises:
ValueError: If package exists in multiple origins and origin not specified.
"""
packages = self._get_package_xml_elements(package_name, origin)
if not packages:
return None
if len(packages) > 1 and origin is None:
origins = [p.get("origin") for p in packages]
raise ValueError(
f"Package '{package_name}' exists in multiple origins: {origins}. "
f"Please specify origin parameter or use get_all_package_support_info()."
)
return self._build_support_statement(packages[0], day)
def get_all_package_support_info(
self, package_name: str, day: Optional[date] = None
) -> Dict[str, SupportStatement]:
"""Get support info for a package across all origins.
Args:
package_name: Name of the package.
day: Date to check against (defaults to today).
Returns:
Dict keyed by origin name, with SupportStatement values.
Empty dict if package not found.
"""
packages = self._get_package_xml_elements(package_name)
if not packages:
return {}
results: Dict[str, SupportStatement] = {}
for package in packages:
results[package.get("origin")] = self._build_support_statement(package, day)
return results
def get_support_metadata(self, day: Optional[date] = None) -> Dict[str, SupportStatement]:
"""Return all lifecycles with current phase calculated at call time.
Args:
day: Date to check against (defaults to today).
Returns:
Dict mapping lifecycle names to SupportStatement objects containing
current phase, support level, severities, and milestone information.
"""
statements: Dict[str, SupportStatement] = {}
for lifecycle in self._root.lifecycles.lifecycle:
lifecycle_name = lifecycle.get("name")
statement = self._build_lifecycle_statement(lifecycle_name, day)
# Add metadata-specific fields
statement.summary = f"Lifecycle: {lifecycle_name}"
statement.text = "V1.0 format - lifecycle-based support"
statements[lifecycle_name] = statement
return statements
def get_support_notes(self) -> Dict[str, str]:
"""Return notes data for DNF plugin.
Returns:
Dict mapping note names to note text strings.
Empty dict if no notes exist.
"""
notes: Dict[str, str] = {}
for note in self._root.xpath("notes/note"):
note_id = note.get("name")
notes[note_id] = str(note)
return notes
def get_support_level_info(
self, support_level: Optional[str] = None
) -> Optional[List[SupportLevelInfo]]:
"""Get support level metadata.
Args:
support_level: Name of the support level (e.g., "default", "limited", "eos").
If None, returns all available support levels.
Returns:
List of SupportLevelInfo objects. If support_level parameter is provided,
returns a list with one item (or None if not found). If support_level is None,
returns list of all support levels.
"""
if support_level is None:
return [
self._build_support_level_info(level)
for level in self._root.xpath("support_levels/support_level")
]
levels = self._root.xpath("support_levels/support_level[@name=$name]", name=support_level)
if not levels:
return None
return [self._build_support_level_info(levels[0])]
def get_package_class_info(self, class_name: str) -> Optional[Dict[str, str]]:
"""Get package class metadata.
Args:
class_name: Name of the package class.
Returns:
Dict with 'name', 'summary', 'text' keys, or None if class not found.
"""
classes = self._root.xpath("package_classes/package_class[@name=$name]", name=class_name)
if not classes:
return None
pkg_class = classes[0]
text_elements = pkg_class.xpath("text")
return {
"name": pkg_class.get("name"),
"summary": str(pkg_class.summary) if hasattr(pkg_class, "summary") else "",
"text": str(text_elements[0]) if text_elements else "",
}
def get_package_origin_info(self, origin_name: str) -> Optional[OriginInfo]:
"""Get package origin metadata.
Args:
origin_name: Name of the package origin.
Returns:
OriginInfo with origin metadata, or None if origin not found.
"""
origins = self._root.xpath("package_origins/package_origin[@name=$name]", name=origin_name)
if not origins:
return None
return self._build_origin_info(origin_name)
def get_milestone_info(
self, milestone_name: Optional[str] = None
) -> Optional[List[MilestoneInfo]]:
"""Get milestone metadata.
Args:
milestone_name: Name of the milestone (e.g., "AL2023_GA", "AL2023_EOS").
If None, returns all available milestones.
Returns:
List of MilestoneInfo objects. If milestone_name is provided,
returns a list with one item (or None if not found). If milestone_name
is None, returns list of all milestones.
"""
if milestone_name is None:
return [
self._build_milestone_info(m)
for m in self._root.xpath("support_milestones/milestone")
]
milestones = self._root.xpath(
"support_milestones/milestone[@name=$name]", name=milestone_name
)
if not milestones:
return None
return [self._build_milestone_info(milestones[0])]
def get_lifecycle_info(
self, lifecycle_name: Optional[str] = None
) -> Optional[List[LifecycleInfo]]:
"""Get lifecycle metadata.
Args:
lifecycle_name: Name of the lifecycle (e.g., "eol_redis6_lc").
If None, returns all available lifecycles.
Returns:
List of LifecycleInfo objects. If lifecycle_name is provided,
returns a list with one item (or None if not found). If lifecycle_name
is None, returns list of all lifecycles.
"""
if lifecycle_name is None:
return [
self._build_lifecycle_info(lc) for lc in self._root.xpath("lifecycles/lifecycle")
]
lifecycles = self._root.xpath("lifecycles/lifecycle[@name=$name]", name=lifecycle_name)
if not lifecycles:
return None
return [self._build_lifecycle_info(lifecycles[0])]
def _get_package_xml_elements(
self, package_name: Optional[str] = None, origin: Optional[str] = None
) -> List[objectify.ObjectifiedElement]:
"""Get raw XML package elements for internal use.
Args:
package_name: Filter by package name, or None for all packages.
origin: Filter by origin name, or None for all origins.
Returns:
List of XML package elements matching the filter criteria.
"""
conditions = []
params = {}
if package_name is not None:
conditions.append("@name=$pname")
params["pname"] = package_name
if origin is not None:
conditions.append("@origin=$p_origin")
params["p_origin"] = origin
predicate = f"[{' and '.join(conditions)}]" if conditions else ""
return self._root.xpath(f"packages/package{predicate}", **params)
def _get_lifecycle_note(self, lifecycle_name: str) -> Optional[str]:
"""Get note reference for a lifecycle.
Args:
lifecycle_name: Name of the lifecycle to query.
Returns:
The note reference string if the lifecycle has a note attribute,
None if lifecycle not found or has no note.
"""
lifecycle_matches = self._root.xpath(
"lifecycles/lifecycle[@name=$name]", name=lifecycle_name
)
if lifecycle_matches:
return lifecycle_matches[0].get("note")
return None
def _find_current_phase(
self, phases: Dict[str, PhaseInfo], day: Optional[date] = None
) -> Optional[str]:
"""Find the current phase name from phases dict.
Args:
phases: Dict mapping phase name to PhaseInfo.
day: Date to check against.
Returns:
Name of the current phase (phase with latest start_date <= day),
or None if no phase is active.
"""
day = self._resolve_day(day)
current_phase_name: Optional[str] = None
current_phase_date: Optional[date] = None
for phase in phases.values():
if phase.start_date:
phase_date = datetime.fromisoformat(phase.start_date).date()
if phase_date <= day:
if current_phase_date is None or phase_date > current_phase_date:
current_phase_date = phase_date
current_phase_name = phase.name
return current_phase_name
def _resolve_milestone_date(self, milestone_name: str) -> Optional[date]:
"""Resolve a milestone name to its date.
Args:
milestone_name: Name of the milestone to resolve.
Returns:
The date associated with the milestone, or None if not found
"""
milestone = self._root.xpath(
"support_milestones/milestone[@name=$name]", name=milestone_name
)
if milestone:
date_str = milestone[0].get("date")
if date_str:
return datetime.fromisoformat(date_str).date()
return None
def _build_milestone_info(self, milestone: objectify.ObjectifiedElement) -> MilestoneInfo:
"""Build MilestoneInfo from an XML milestone element.
Args:
milestone: XML milestone element from an XPath query.
Returns:
MilestoneInfo populated with name, date, display_name, and description.
"""
return MilestoneInfo(
name=milestone.get("name"),
date=datetime.fromisoformat(milestone.get("date")).date(),
display_name=milestone.get("display_name"),
description=milestone.get("description"),
)
def _build_lifecycle_info(self, lifecycle: objectify.ObjectifiedElement) -> LifecycleInfo:
"""Build LifecycleInfo from an XML lifecycle element.
Args:
lifecycle: XML lifecycle element from an XPath query.
Returns:
LifecycleInfo populated with name, note, display_name, and description.
"""
return LifecycleInfo(
name=lifecycle.get("name"),
note=lifecycle.get("note"),
display_name=lifecycle.get("display_name"),
description=lifecycle.get("description"),
)
def _resolve_phase_start_date(self, phase: objectify.ObjectifiedElement) -> Optional[str]:
"""Resolve start date for a phase (from start_date or milestone).
Args:
phase: XML phase element.
Returns:
ISO format date string, or None if no date can be resolved.
"""
start_date_str = phase.get("start_date")
if start_date_str:
return start_date_str
milestone_name = phase.get("start_milestone")
if milestone_name:
resolved_date = self._resolve_milestone_date(milestone_name)
return resolved_date.isoformat() if resolved_date else None
return None
def _build_support_level_info(self, level: objectify.ObjectifiedElement) -> SupportLevelInfo:
"""Build SupportLevelInfo from an XML support_level element.
Note:
objectify.ObjectifiedElement is the type lxml assigns to every XML
element parsed via lxml.objectify. XPath queries on the parsed tree
return lists of these elements.
Args:
level: XML support_level element from an XPath query.
Returns:
SupportLevelInfo populated with name, severities, description, and display_name.
"""
return SupportLevelInfo(
name=level.get("name"),
severities=level.get("severities"),
description=level.get("description"),
display_name=level.get("display_name"),
)
def _build_phases(self, lifecycle_name: str) -> Dict[str, PhaseInfo]:
"""Build dict of phase name to PhaseInfo for a lifecycle, sorted by start_date.
Args:
lifecycle_name: Name of the lifecycle to query.
Returns:
Dict mapping phase name to PhaseInfo, sorted by start_date.
Empty dict if lifecycle not found or has no phases.
"""
lifecycle_matches = self._root.xpath(
"lifecycles/lifecycle[@name=$name]", name=lifecycle_name
)
if not lifecycle_matches or not hasattr(lifecycle_matches[0], "phase"):
return {}
# Build list first to sort by start_date
phase_list: List[PhaseInfo] = []
for phase in lifecycle_matches[0].phase:
support_level_name = phase.get("support_level")
info_list = (
self.get_support_level_info(support_level_name) if support_level_name else None
)
info = info_list[0] if (info_list and len(info_list) > 0) else None
phase_list.append(
PhaseInfo(
name=phase.get("name"),
start_date=self._resolve_phase_start_date(phase),
support_level_info=info,
milestone=phase.get("start_milestone"),
display_name=phase.get("display_name"),
)
)
# Sort by start_date (phases without dates go first)
phase_list.sort(key=lambda p: p.start_date or "")
# Convert to dict maintaining sorted order
return {phase.name: phase for phase in phase_list}
def _build_lifecycle_statement(
self, lifecycle_name: str, day: Optional[date] = None
) -> SupportStatement:
"""Build a SupportStatement from lifecycle data.
Args:
lifecycle_name: Name of the lifecycle to query.
day: Date to check against (defaults to today).
Returns:
SupportStatement populated with lifecycle data (no package-specific fields).
Client can look up support_level/severities from current phase in phases list.
"""
phases = self._build_phases(lifecycle_name)
current_phase = self._find_current_phase(phases, day)
lifecycle_note = self._get_lifecycle_note(lifecycle_name)
return SupportStatement(
current_phase=current_phase,
phases=phases,
lifecycle_note=lifecycle_note,
)
def _build_support_statement(
self, package: objectify.ObjectifiedElement, day: Optional[date] = None
) -> SupportStatement:
"""Build a SupportStatement from a package XML element.
Args:
package: XML package element with lifecycle, package_class, origin attributes.
day: Date to check against (defaults to today).
Returns:
SupportStatement populated with lifecycle data and package-specific fields.
"""
lifecycle_name = package.get("lifecycle")
statement = self._build_lifecycle_statement(lifecycle_name, day)
# Add package-specific fields
statement.package_class = package.get("package_class")
origin_name = package.get("origin")
if origin_name:
statement.origin = self._build_origin_info(origin_name)
return statement
def _build_origin_info(self, origin_name: str) -> OriginInfo:
"""Build OriginInfo from origin name.
Args:
origin_name: Name of the origin to query.
Returns:
OriginInfo with origin metadata, including display_name and description.
If origin not found in XML, returns OriginInfo with only name populated.
"""
origins = self._root.xpath("package_origins/package_origin[@name=$name]", name=origin_name)
if not origins:
return OriginInfo(name=origin_name)
origin = origins[0]
return OriginInfo(
name=origin.get("name"),
repo_id=origin.get("repo_id"),
dist=origin.get("dist"),
vendor=origin.get("vendor"),
signing_key=origin.get("signing_key"),
display_name=origin.get("display_name"),
description=origin.get("description"),
)