Cleanup
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user