diff --git a/GenerateReport.py b/GenerateReport.py index f845b48..f7d1cc3 100644 --- a/GenerateReport.py +++ b/GenerateReport.py @@ -37,7 +37,8 @@ GetPyPiInfo ) from utils.VersionSuggester import ( - suggest_safe_minor_upgrade + suggest_safe_minor_upgrade, + find_latest_safe_version_for_major ) from utils.VulnChecker import ( check_cv_uv @@ -216,6 +217,59 @@ def main() -> None: else: instruction = generate_upgrade_instruction(pkg, suggested) + outdated_instruction = None + outdated_target_version = None + + try: + cur_ver_obj = version.parse(cur_ver) + except InvalidVersion: + cur_ver_obj = None + + if cur_ver_obj and all_vs: + available_majors = sorted({version.parse(v).major for v in all_vs}) + if available_majors: + latest_major_available = available_majors[-1] + if cur_ver_obj.major == latest_major_available: + target_major = cur_ver_obj.major + else: + lower_majors = [m for m in available_majors if m < latest_major_available] + target_major = lower_majors[-1] if lower_majors else cur_ver_obj.major + if target_major < cur_ver_obj.major: + target_major = cur_ver_obj.major + + if ( + target_major in available_majors + and not (target_major == cur_ver_obj.major and not newer) + ): + try: + outdated_target_version = asyncio.run( + find_latest_safe_version_for_major( + pkg, cur_ver, all_vs, target_major + ) + ) + except Exception as e: + logger.warning( + f"Failed to compute outdated-oriented upgrade for {pkg}: {e}" + ) + + if ( + outdated_target_version + and outdated_target_version != cur_ver + ): + try: + outdated_instruction = generate_upgrade_instruction( + pkg, outdated_target_version + ) + except Exception as e: + logger.warning( + "Failed to generate outdated-oriented instruction for " + f"{pkg}=={outdated_target_version}: {e}" + ) + outdated_instruction = { + "base_package": f"{pkg}=={outdated_target_version}", + "dependencies": [] + } + # Current version dependency JSON (only for base packages) if pkg.lower() in base_packages: current_json = generate_current_dependency_json(pkg, cur_ver, cur_ver_deps) @@ -253,6 +307,7 @@ def main() -> None: 'Upgrade Vulnerability Details': upgrade_vuln_details, 'Suggested Upgrade': suggested, 'Upgrade Instruction': instruction, + 'Outdated-oriented Upgrade Instruction': outdated_instruction, 'Remarks': Remarks }) logger.debug(f"Custodian for {pkg}: {custodian}") @@ -319,7 +374,7 @@ def main() -> None: 'Dependencies for Current', 'Newer Versions', 'Dependencies for Latest', 'Latest Version', 'Current Version Vulnerable?', 'Current Version Vulnerability Details', 'Upgrade Version Vulnerable?', 'Upgrade Vulnerability Details', - 'Suggested Upgrade', 'Remarks' + 'Suggested Upgrade', 'Outdated-oriented Upgrade Instruction', 'Remarks' ]] # Overview Sheet total_packages = len(monthly_df) diff --git a/utils/VersionSuggester.py b/utils/VersionSuggester.py index 9daf308..3de740c 100644 --- a/utils/VersionSuggester.py +++ b/utils/VersionSuggester.py @@ -133,6 +133,82 @@ async def suggest_safe_minor_upgrade( return "unknown" +async def find_latest_safe_version_for_major( + pkg: str, + current_version: str, + all_versions: list[str], + target_major: int, +) -> str | None: + """Return the newest vulnerability-free version within ``target_major``. + + The candidate list is restricted to the specified major version. When the + target major matches the current version's major, only versions greater + than or equal to the current version are evaluated to avoid unnecessary + downgrades. The function iterates from the newest candidate backwards and + returns the first version confirmed to be free of known vulnerabilities. + + Parameters + ---------- + pkg: + Package name on PyPI. + current_version: + Currently installed version string. Used to filter out downgrades + when ``target_major`` equals the current major release. + all_versions: + Collection of available release versions (string representation). + target_major: + Major version number that should be evaluated. + + Returns + ------- + str | None + The newest vulnerability-free version string within the selected + major. ``None`` if no suitable release could be found or validated. + """ + + try: + cur_ver = version.parse(current_version) + except InvalidVersion: + cur_ver = None + + candidates: list[tuple[version.Version, str]] = [] + for ver_str in all_versions: + try: + parsed = version.parse(ver_str) + except InvalidVersion: + continue + + if parsed.major != target_major: + continue + + if cur_ver and parsed.major == cur_ver.major and parsed < cur_ver: + # Skip downgrades within the same major release + continue + + candidates.append((parsed, ver_str)) + + if not candidates: + return None + + candidates.sort(reverse=True, key=lambda item: item[0]) + + async with aiohttp.ClientSession() as session: + sem = asyncio.Semaphore(5) + for _, ver_str in candidates: + try: + _, status, _ = await fetch_osv(session, pkg, ver_str, sem) + except Exception as exc: # pragma: no cover - network safety + logger.warning( + f"Failed to verify vulnerabilities for {pkg}=={ver_str}: {exc}" + ) + continue + + if status == "No": + return ver_str + + return None + + def main(): """ Parses command-line arguments and suggests upgrade versions for a specified Python package.