This commit is contained in:
2026-04-13 11:07:58 -05:00
parent 995fc91b0e
commit e53a813713
2 changed files with 39 additions and 4 deletions
-2
View File
@@ -13,8 +13,6 @@ Known and addressed issues for newly released versions are easily added using th
- Fix up the products.json file manually or run `update_products_from_issues.py`. - Fix up the products.json file manually or run `update_products_from_issues.py`.
- Submit a pull request. - Submit a pull request.
The date on the end of the Markdown file names is to have some idea of when the data was grabbed. I would imagine that sometimes there are updates. (I plan to remove the dates in the future. The git history should be sufficient.)
There is intentionally no automated scaping of Palo Alto's website, to avoid abuse of server resources. Also releases are not that frequent. A crawler to grab some data from the Common Crawl dataset was started but never really finished. There is intentionally no automated scaping of Palo Alto's website, to avoid abuse of server resources. Also releases are not that frequent. A crawler to grab some data from the Common Crawl dataset was started but never really finished.
Some data was collected early on when the HTMLTable -> Markdown code was kind of bad, so the formatting of the issue write-up tends to be bad on those. Mostly PAN-OS 10 and 11 stuff. Some data was collected early on when the HTMLTable -> Markdown code was kind of bad, so the formatting of the issue write-up tends to be bad on those. Mostly PAN-OS 10 and 11 stuff.
+39 -2
View File
@@ -1,4 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Update products.json entries from discovered issue data files."""
from __future__ import annotations from __future__ import annotations
@@ -18,6 +19,8 @@ HOTFIX_RE = re.compile(r"^(?P<base>\d+(?:\.\d+)*)(?:-h(?P<hotfix>\d+))?$")
@dataclass(frozen=True) @dataclass(frozen=True)
class FileIdentity: class FileIdentity:
"""Unique identity for a product/version issue bucket."""
product: str product: str
issue_type: str issue_type: str
version: str version: str
@@ -25,12 +28,16 @@ class FileIdentity:
@dataclass(frozen=True) @dataclass(frozen=True)
class FileRecord: class FileRecord:
"""Parsed issue file metadata used for de-duplication and sorting."""
identity: FileIdentity identity: FileIdentity
filename: str filename: str
release_date: date release_date: date
def parse_args() -> argparse.Namespace: def parse_args() -> argparse.Namespace:
"""Parse command line arguments for input and output paths."""
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Update products.json from issue files, keeping latest date per version." description="Update products.json from issue files, keeping latest date per version."
) )
@@ -48,6 +55,8 @@ def parse_args() -> argparse.Namespace:
def collect_latest_issue_files(issues_dir: Path) -> dict[FileIdentity, FileRecord]: def collect_latest_issue_files(issues_dir: Path) -> dict[FileIdentity, FileRecord]:
"""Collect the newest file per product, issue type, and version."""
latest: dict[FileIdentity, FileRecord] = {} latest: dict[FileIdentity, FileRecord] = {}
for file_path in sorted(p for p in issues_dir.rglob("*") if p.is_file()): for file_path in sorted(p for p in issues_dir.rglob("*") if p.is_file()):
@@ -78,6 +87,8 @@ def collect_latest_issue_files(issues_dir: Path) -> dict[FileIdentity, FileRecor
def parse_issue_filename(filename: str) -> tuple[str, date] | None: def parse_issue_filename(filename: str) -> tuple[str, date] | None:
"""Parse '<version>_<YYYY-MM-DD>.<ext>' issue filenames."""
match = FILENAME_RE.match(filename) match = FILENAME_RE.match(filename)
if not match: if not match:
return None return None
@@ -92,6 +103,8 @@ def parse_issue_filename(filename: str) -> tuple[str, date] | None:
def clear_leaf_issue_arrays(node: Any) -> None: def clear_leaf_issue_arrays(node: Any) -> None:
"""Reset addressed/known arrays on every issue leaf in products.json."""
if isinstance(node, dict): if isinstance(node, dict):
if "addressed" in node or "known" in node: if "addressed" in node or "known" in node:
node["addressed"] = [] node["addressed"] = []
@@ -103,10 +116,14 @@ def clear_leaf_issue_arrays(node: Any) -> None:
def is_issue_leaf(node: Any) -> bool: def is_issue_leaf(node: Any) -> bool:
"""Return whether a node is a leaf containing issue arrays."""
return isinstance(node, dict) and ("addressed" in node or "known" in node) return isinstance(node, dict) and ("addressed" in node or "known" in node)
def pick_target_leaf_path(products: dict[str, Any], product: str, version: str) -> list[str] | None: def pick_target_leaf_path(products: dict[str, Any], product: str, version: str) -> list[str] | None:
"""Choose the best matching product tree path for a version."""
parts = version.split(".") parts = version.split(".")
if not parts: if not parts:
return None return None
@@ -129,6 +146,8 @@ def pick_target_leaf_path(products: dict[str, Any], product: str, version: str)
def get_path(data: Any, path: list[str]) -> Any: def get_path(data: Any, path: list[str]) -> Any:
"""Traverse nested dict keys and return the value at path, if present."""
current = data current = data
for key in path: for key in path:
if not isinstance(current, dict) or key not in current: if not isinstance(current, dict) or key not in current:
@@ -138,6 +157,8 @@ def get_path(data: Any, path: list[str]) -> Any:
def parse_release_prefix(prefix: str) -> tuple[tuple[int, ...], int]: def parse_release_prefix(prefix: str) -> tuple[tuple[int, ...], int]:
"""Extract numeric base version parts and optional hotfix number."""
match = HOTFIX_RE.match(prefix) match = HOTFIX_RE.match(prefix)
if not match: if not match:
return tuple(), -1 return tuple(), -1
@@ -149,13 +170,22 @@ def parse_release_prefix(prefix: str) -> tuple[tuple[int, ...], int]:
def filename_sort_key(filename: str) -> tuple[tuple[int, ...], int, int, str]: def filename_sort_key(filename: str) -> tuple[tuple[int, ...], int, int, str]:
"""Build a sort key that orders base versions before hotfixes."""
prefix = filename.split("_", 1)[0] prefix = filename.split("_", 1)[0]
base_parts, hotfix = parse_release_prefix(prefix) base_parts, hotfix = parse_release_prefix(prefix)
is_hotfix = 1 if hotfix >= 0 else 0 is_hotfix = 1 if hotfix >= 0 else 0
return base_parts, is_hotfix, hotfix, filename return base_parts, is_hotfix, hotfix, filename
def add_file_to_leaf(products: dict[str, Any], path: list[str], issue_type: str, filename: str) -> None: def add_file_to_leaf(
products: dict[str, Any],
path: list[str],
issue_type: str,
filename: str,
) -> None:
"""Insert an issue filename into the target leaf with stable sorting."""
leaf = get_path(products, path) leaf = get_path(products, path)
if not isinstance(leaf, dict): if not isinstance(leaf, dict):
return return
@@ -169,6 +199,8 @@ def add_file_to_leaf(products: dict[str, Any], path: list[str], issue_type: str,
def update_products(issues_dir: Path, products_path: Path) -> None: def update_products(issues_dir: Path, products_path: Path) -> None:
"""Rebuild product issue file lists from crawled issue files."""
if not issues_dir.is_dir(): if not issues_dir.is_dir():
raise FileNotFoundError(f"Issues directory not found: {issues_dir}") raise FileNotFoundError(f"Issues directory not found: {issues_dir}")
if not products_path.is_file(): if not products_path.is_file():
@@ -183,7 +215,10 @@ def update_products(issues_dir: Path, products_path: Path) -> None:
clear_leaf_issue_arrays(products) clear_leaf_issue_arrays(products)
latest_files = collect_latest_issue_files(issues_dir) latest_files = collect_latest_issue_files(issues_dir)
for record in sorted(latest_files.values(), key=lambda r: (r.identity.product, r.identity.issue_type, r.identity.version)): for record in sorted(
latest_files.values(),
key=lambda r: (r.identity.product, r.identity.issue_type, r.identity.version),
):
path = pick_target_leaf_path(products, record.identity.product, record.identity.version) path = pick_target_leaf_path(products, record.identity.product, record.identity.version)
if path is None: if path is None:
continue continue
@@ -195,6 +230,8 @@ def update_products(issues_dir: Path, products_path: Path) -> None:
def main() -> int: def main() -> int:
"""Entrypoint for command-line usage."""
args = parse_args() args = parse_args()
script_dir = Path(__file__).resolve().parent script_dir = Path(__file__).resolve().parent
repo_root = script_dir.parent repo_root = script_dir.parent