debian-forge/tools/osbuild-dev
2022-11-02 17:55:13 +01:00

209 lines
5.9 KiB
Python
Executable file

#!/usr/bin/env python3
"""`osbuild-dev` provides helper functionality for `osbuild` development
mostly centered around manifest reading."""
# pylint: disable=unsupported-membership-test,unsupported-delete-operation
# pylint: disable=unsubscriptable-object
import json
import os
import secrets
import subprocess
import tempfile
from typing import Any, Optional
try:
import attrs
import rich
import typer
from rich.tree import Tree
except ImportError as import_error:
print(
"You are missing dependencies, please install `python3-attrs`, `python3-rich`, `python3-typer` or their `pip` equivalents."
)
raise
cli = typer.Typer() # Main command.
man = typer.Typer() # Manifest subcommand.
cli.add_typer(man, name="manifest")
con = rich.console.Console()
@cli.callback()
def main() -> int:
return 0
def json_as_terminal_tree(tree: Optional[Tree], data: Any, name: str) -> Tree:
"""Convert JSON into a `rich` tree."""
if tree is None:
tree = Tree("")
if isinstance(data, (int, str)):
subtree = tree.add(f"{name}: [bold]{data}[/bold]")
elif isinstance(data, dict):
subtree = tree.add(str(name))
for key, val in data.items():
json_as_terminal_tree(subtree, val, key)
elif isinstance(data, list):
name = f"{name} [italic]({len(data)})[/italic]"
subtree = tree.add(name)
for index, item in enumerate(data):
json_as_terminal_tree(subtree, item, index)
else:
raise ValueError(
f"json_as_terminal_tree does not know how to deal with {type(data)}"
)
return subtree
@attrs.define()
class Manifest:
name: str
data: dict[str, Any] = attrs.Factory(dict)
def ignore_a_stage(self, name: str) -> None:
"""Remove a stage from the data we represent."""
for pipeline in self.data["pipelines"]:
to_pop = []
for index, stage in enumerate(pipeline["stages"]):
if stage["type"] == name:
to_pop.append(index)
for index in to_pop:
pipeline["stages"].pop(index)
def ignore_sources(self) -> None:
"""Remove the `sources` section from the manifest."""
if "sources" in self.data:
del self.data["sources"]
def resolve_content_hashes(self) -> None:
# If we're resolving content hashes back to names we adjust the data structure
# in-place.
sources = {}
# We can't handle all source types but some we can
if "org.osbuild.curl" in self.data["sources"]:
for name, source in self.data["sources"]["org.osbuild.curl"][
"items"
].items():
sources[name] = source["url"]
for pipeline in self.data["pipelines"]:
for stage in pipeline["stages"]:
if stage["type"] == "org.osbuild.rpm":
for index, reference in enumerate(
stage["inputs"]["packages"]["references"]
):
stage["inputs"]["packages"]["references"][
index
] = sources[reference["id"]].split("/")[-1]
def print_for_terminal(self, path: Optional[str] = None) -> None:
if path is None:
con.print(json_as_terminal_tree(None, self.data, self.name))
else:
with open(path, "w", encoding="utf8") as f:
rich.print(
json_as_terminal_tree(None, self.data, self.name), file=f
)
def print_for_html(self) -> None:
pass
@classmethod
def from_path(cls, path: str) -> "Manifest":
try:
with open(path, encoding="utf8") as f:
data = json.load(f)
except FileNotFoundError:
con.print(f"[bold][red]Could not open file {path!r}[/red][/bold]")
return 1
# We deal with this possibly being a 'wrapped' manifest, one produced
# by `osbuild-composer`.
if "manifest" in data:
data = data["manifest"]
return cls(os.path.basename(path), data)
@man.command(name="print")
def pretty_print(
manifest_path: str,
ignore_stage: list[str] = typer.Option([]),
resolve_sources: bool = typer.Option(
True, help="Resolve content hashes of sources to their names."
),
skip_sources: bool = typer.Option(
True, help="Skips display of the sources in the manifest."
),
) -> int:
"""Pretty print an `osbuild` manifest file."""
manifest = Manifest.from_path(manifest_path)
for name in ignore_stage:
manifest.ignore_a_stage(name)
if resolve_sources:
manifest.resolve_content_hashes()
if skip_sources:
manifest.ignore_sources()
manifest.print_for_terminal()
return 0
@man.command(name="diff")
def pretty_diff(
manifest_paths: list[str],
ignore_stage: list[str] = typer.Option([]),
resolve_sources: bool = typer.Option(
True, help="Resolve content hashes of sources to their names."
),
skip_sources: bool = typer.Option(
True, help="Skips display of the sources in the manifest."
),
) -> int:
"""Pretty print a diff of `osbuild` manifest files."""
with tempfile.TemporaryDirectory() as temporary:
paths = []
for manifest_path in manifest_paths:
manifest = Manifest.from_path(manifest_path)
for name in ignore_stage:
manifest.ignore_a_stage(name)
if resolve_sources:
manifest.resolve_content_hashes()
if skip_sources:
manifest.ignore_sources()
path = f"{temporary}/{os.path.basename(manifest_path)}-{secrets.token_hex(2)}"
manifest.print_for_terminal(path)
paths.append(path)
subprocess.run(["vimdiff"] + paths, check=True)
return 0
if __name__ == "__main__":
cli()