Source code for flpq_data.tools.check_archive_structure

"""Validate the structure of a graph archive.

Every graph archive must have the same fixed, self-contained structure
(documented in ``docs/file_structure.rst``):
a description document answering the mandatory questions, one Boolean
MatrixMarket file per stored edge label, and all queries of the graph as
directories grouped by class — each query directory holding every
representation of the query plus one ``results.mtx`` with the constrained
reachability facts — described in a common document.

A partial archive provides new queries for an existing graph: it contains
only ``queries/`` (the new query directories plus a ``README.md`` fragment
with their sections) and is validated with ``--partial``.

The command-line entry point is installed with the package::

    flpq-check-archive ARCHIVE.tar.gz
    flpq-check-archive UNPACKED_DIR
    flpq-check-archive PARTIAL.tar.gz --partial
"""

import argparse
import pathlib
import re
import shutil
import tarfile
import tempfile
from typing import Optional, Sequence, Set, Union

from pyformlang.regular_expression import Regex
from pyformlang.rsa import RecursiveAutomaton as RSA

from flpq_data.graphs.readwrite.mtx import _MTX_HEADER, graph_from_mtx_dir
from flpq_data.queries.cfpq.converters.cfg import cfg_from_rsa
from flpq_data.queries.cfpq.readwrite.cnf_template import cnf_template_from_text
from flpq_data.queries.mcfpq.readwrite.mcfg import mcfg_from_text
from flpq_data.queries.rpq.readwrite.rsa import rsa_from_text

__all__ = [
    "MANDATORY_README_SECTIONS",
    "QUERY_CLASSES",
    "stored_reverse_forward",
    "parse_readme_sections",
    "unpack_single_dir",
    "validate_archive",
    "main",
]

#: The questions the archive ``README.md`` must answer (one ``##`` section each).
MANDATORY_README_SECTIONS = (
    "What is this graph?",
    "Source",
    "Construction",
    "Nodes",
    "Edges and labels",
    "Query classes",
    "License",
    "Caveats",
)

#: Query class -> file extensions of the representation files in a query directory.
QUERY_CLASSES = {"cfpq": (".cnf", ".rsm"), "rpq": (".re", ".rsm"), "mcfpq": (".mcfg",)}

_VARIABLE = re.compile(r"[a-z][0-9]+")


def _query_terminals(path: pathlib.Path) -> set[str]:
    """Return the terminals used by one query representation file.

    Parameters
    ----------
    path : Path
        The representation file to read (its extension selects the format).

    Returns
    -------
    terminals : set[str]
        The edge labels the query uses.

    Raises
    ------
    Exception
        If the file cannot be parsed as a query of its class.
    """
    text = path.read_text(encoding="utf-8")
    if path.suffix == ".cnf":
        cfg = cnf_template_from_text(text)
        return {symbol.value for symbol in cfg.terminals}
    if path.suffix == ".rsm":
        rsa = rsa_from_text(text)
        cfg = cfg_from_rsa(rsa)
        return {symbol.value for symbol in cfg.terminals}
    if path.suffix == ".re":
        regex = Regex(text)
        nfa = regex.to_epsilon_nfa()
        if nfa is None:
            return set()
        return {symbol.value for symbol in nfa.symbols}
    mcfg = mcfg_from_text(text)
    terminals = set()
    for rule in mcfg.rules:
        for arg in rule.head_args:
            for item in arg:
                if item != "eps" and not _VARIABLE.fullmatch(item):
                    terminals.add(item)
    return terminals


def _skeleton_problems(root: pathlib.Path) -> list[str]:
    """Check the fixed archive skeleton under ``root``."""
    problems = []
    entries = {path.name for path in root.iterdir()}
    expected = {"README.md", "graph", "queries"}
    for name in sorted(entries - expected):
        problems.append(f"unexpected entry {name!r} (expected only {sorted(expected)})")
    for name in sorted(expected - entries):
        problems.append(f"missing entry {name!r}")
    if problems:
        return problems

    graph_dir = root / "graph"
    if not any(
        path.suffix == ".mtx" and path.is_file() for path in graph_dir.iterdir()
    ):
        problems.append("graph/ must contain at least one .mtx file")
    for path in sorted(graph_dir.iterdir()):
        if not path.is_file() or path.suffix != ".mtx":
            problems.append(f"graph/{path.name}: only .mtx files are allowed in graph/")

    queries_dir = root / "queries"
    query_entries = {path.name for path in queries_dir.iterdir()}
    allowed = {"README.md", *QUERY_CLASSES}
    for name in sorted(query_entries - allowed):
        problems.append(
            f"queries/{name}: unexpected entry (expected only README.md and "
            f"class directories {sorted(QUERY_CLASSES)})"
        )
    if "README.md" not in query_entries:
        problems.append("queries/README.md: missing")
    present_classes = query_entries & set(QUERY_CLASSES)
    if not present_classes:
        problems.append(
            f"queries/: at least one class directory is required "
            f"({sorted(QUERY_CLASSES)})"
        )
    for cls, exts in QUERY_CLASSES.items():
        cls_dir = queries_dir / cls
        if not cls_dir.is_dir():
            continue
        for path in sorted(cls_dir.iterdir()):
            if not path.is_dir():
                problems.append(
                    f"queries/{cls}/{path.name}: must be a query directory "
                    "(flat query files are no longer allowed)"
                )
                continue
            problems.extend(_query_dir_problems(path, cls, exts))
    return problems


def _partial_skeleton_problems(root: pathlib.Path) -> list[str]:
    """Check the skeleton of a partial archive under ``root``."""
    problems = []
    entries = {path.name for path in root.iterdir()}
    for name in sorted(entries - {"queries"}):
        problems.append(
            f"unexpected entry {name!r} (a partial archive contains only queries/)"
        )
    if "queries" not in entries:
        problems.append("missing entry 'queries'")
    if problems:
        return problems

    queries_dir = root / "queries"
    query_entries = {path.name for path in queries_dir.iterdir()}
    for name in sorted(query_entries - {"README.md", *QUERY_CLASSES}):
        problems.append(
            f"queries/{name}: unexpected entry (expected only README.md and "
            f"the class directories {sorted(QUERY_CLASSES)})"
        )
    if "README.md" not in query_entries:
        problems.append("queries/README.md: missing")
    for cls in sorted(query_entries & set(QUERY_CLASSES)):
        cls_dir = queries_dir / cls
        if not any(path.is_dir() for path in cls_dir.iterdir()):
            problems.append(
                f"queries/{cls}/: a partial archive must add at least one query"
            )
        for path in sorted(cls_dir.iterdir()):
            if not path.is_dir():
                problems.append(
                    f"queries/{cls}/{path.name}: must be a query directory "
                    "(flat query files are no longer allowed)"
                )
                continue
            problems.extend(_query_dir_problems(path, cls, QUERY_CLASSES[cls]))
    if not (query_entries & set(QUERY_CLASSES)):
        problems.append(
            f"queries/: at least one class directory is required "
            f"({sorted(QUERY_CLASSES)})"
        )
    return problems


def _query_dir_problems(
    query_dir: pathlib.Path, cls: str, exts: tuple[str, ...]
) -> list[str]:
    """Check one ``queries/<class>/<query>/`` directory."""
    problems = []
    rel = f"queries/{cls}/{query_dir.name}"
    has_representation = False
    for path in sorted(query_dir.iterdir()):
        if not path.is_file():
            problems.append(f"{rel}/{path.name}: only files are allowed")
            continue
        if path.name == "results.mtx":
            continue
        if path.suffix not in exts:
            problems.append(
                f"{rel}/{path.name}: only {' '.join(exts)} representation files "
                "and results.mtx are allowed"
            )
            continue
        has_representation = True
    if not has_representation:
        problems.append(f"{rel}/: no {cls} representation file ({' or '.join(exts)})")
    if not (query_dir / "results.mtx").is_file():
        problems.append(f"{rel}/: missing results.mtx")
    return problems


def _mtx_header(path: pathlib.Path) -> Optional[tuple[int, int, int]]:
    """The (rows, cols, nnz) declared by one Boolean MatrixMarket file.

    Returns None when the file does not follow the dataset format (the
    two-line header and a dimension line). Streams — only the first lines
    are read.
    """
    try:
        with open(path, "r", encoding="utf-8") as f:
            if f.readline().rstrip() != _MTX_HEADER[0]:
                return None
            if f.readline().rstrip() != _MTX_HEADER[1]:
                return None
            rows, cols, nnz = (int(value) for value in f.readline().split())
    except (OSError, ValueError):
        return None
    return rows, cols, nnz


def _mtx_entries(path: pathlib.Path):
    """Yields the (tail, head) entry pairs of one Boolean MatrixMarket file,
    streaming line by line (O(1) memory).

    Raises ValueError on a malformed entry line.
    """
    with open(path, "r", encoding="utf-8") as f:
        for _ in range(3):  # the two header lines and the dimension line
            f.readline()
        for line in f:
            if not line.strip():
                continue
            parts = line.split()
            yield int(parts[0]), int(parts[1])


def _graph_dimensions(graph_dir: pathlib.Path) -> set[tuple[int, int]]:
    """The declared dimensions of every parseable graph label matrix."""
    dimensions = set()
    for mtx_file in sorted(graph_dir.glob("*.mtx")):
        header = _mtx_header(mtx_file)
        if header is not None:
            dimensions.add((header[0], header[1]))
    return dimensions


def _mtx_range_problems(graph_dir: pathlib.Path) -> list[str]:
    """Check that edge endpoints fit the declared matrix dimensions."""
    problems = []
    for mtx_file in sorted(graph_dir.glob("*.mtx")):
        header = _mtx_header(mtx_file)
        if header is None:
            continue  # a format problem graph_from_mtx_dir already reports
        rows, cols, nnz = header
        count = 0
        try:
            for tail, head in _mtx_entries(mtx_file):
                count += 1
                if not 0 <= tail < rows or not 0 <= head < cols:
                    problems.append(
                        f"graph/{mtx_file.name}: edge ({tail}, {head}) is outside "
                        f"the declared {rows}x{cols} matrix"
                    )
                    break
        except ValueError:
            continue  # a format problem graph_from_mtx_dir already reports
        if count != nnz:
            continue  # a format problem graph_from_mtx_dir already reports
    dimensions = _graph_dimensions(graph_dir)
    if len(dimensions) > 1:
        problems.append(
            "graph/: all label matrices must declare the same dimensions "
            f"(found {sorted(dimensions)})"
        )
    return problems


# --------------------------------------------------------------------------- #
# Naming consistency rules
# --------------------------------------------------------------------------- #

_FORBIDDEN_COMPONENTS = ("bar", "rev")
_INDEXED_REV_RE = re.compile(r"^(.*)_r_(\d+)$")


[docs] def stored_reverse_forward(stem: str, stems: Set[str]) -> Optional[str]: """Return the forward label of a stored reversed matrix ``stem``, if any. A matrix stem is a stored reverse when its forward label is also stored: the unindexed form ``L_r`` (forward ``L``) or the indexed form ``<base>_r_<n>`` (forward ``<base>_<n>``). Reversed edges must not be stored — they are auto-generated (see :func:`flpq_data.graphs.utils.add_reverse_edges`). Parameters ---------- stem : str The file stem (name without the ``.mtx`` suffix). stems : Set[str] The stems of all label matrices stored in the ``graph/`` directory. Examples -------- >>> stored_reverse_forward("a_r", {"a", "a_r"}) 'a' >>> stored_reverse_forward("load_r_0", {"load_0", "load_r_0"}) 'load_0' >>> stored_reverse_forward("load_r_0", {"load_r_0"}) is None True Returns ------- forward : str or None The forward label ``stem`` reverses, or None when ``stem`` is not a stored reverse. """ if stem.endswith("_r"): forward: Optional[str] = stem[:-2] else: indexed = _INDEXED_REV_RE.fullmatch(stem) forward = f"{indexed.group(1)}_{indexed.group(2)}" if indexed else None return forward if forward is not None and forward in stems else None
def _label_problems(graph_dir: pathlib.Path) -> list[str]: """Check edge-label naming conventions in ``graph/``.""" problems = [] stems = {path.stem for path in graph_dir.glob("*.mtx")} for stem in sorted(stems): # No forbidden components (bar, rev) parts = stem.replace("_", " ").split() for part in parts: if part in _FORBIDDEN_COMPONENTS: problems.append( f"graph/{stem}.mtx: label contains forbidden component " f"{part!r} (use '_r' for reversed edges)" ) break # No stored reverses — if the forward label is stored, neither its # unindexed reverse (L_r) nor its indexed reverse (B_r_<n>) may be stored. for stem in sorted(stems): forward = stored_reverse_forward(stem, stems) if forward is not None: problems.append( f"graph/{stem}.mtx: reversed edge is stored but must be " f"auto-generated from {forward}.mtx" ) return problems def _same_dir_consistency_problems(queries_dir: pathlib.Path) -> list[str]: """Check that all representations in one query directory use the same terminal set (they must define the same language).""" problems = [] if not queries_dir.is_dir(): return problems for cls_dir in sorted(queries_dir.iterdir()): if not cls_dir.is_dir() or cls_dir.name not in QUERY_CLASSES: continue for query_dir in sorted(cls_dir.iterdir()): if not query_dir.is_dir(): continue term_sets: dict[str, frozenset[str]] = {} for path in sorted(query_dir.iterdir()): if path.suffix not in (".cnf", ".rsm", ".re", ".mcfg"): continue try: terminals = _query_terminals(path) except Exception: continue term_sets[path.name] = frozenset(terminals) # Compare all pairs names = list(term_sets) for i in range(len(names)): for j in range(i + 1, len(names)): if term_sets[names[i]] != term_sets[names[j]]: only_i = term_sets[names[i]] - term_sets[names[j]] only_j = term_sets[names[j]] - term_sets[names[i]] detail = "" if only_i: detail += f" {names[i]} has extra {sorted(only_i)}" if only_j: detail += f" {names[j]} has extra {sorted(only_j)}" problems.append( f"queries/{cls_dir.name}/{query_dir.name}/: " f"{names[i]} and {names[j]} use different terminal " f"sets (must represent the same language){detail}" ) return problems def _naming_problems(root: pathlib.Path) -> list[str]: """Run all naming-consistency checks on the unpacked archive.""" problems = [] graph_dir = root / "graph" queries_dir = root / "queries" if graph_dir.is_dir(): problems.extend(_label_problems(graph_dir)) if queries_dir.is_dir(): problems.extend(_same_dir_consistency_problems(queries_dir)) return problems def _results_problems(root: pathlib.Path) -> list[str]: """Check every per-query results.mtx against the graph.""" problems = [] dimensions = _graph_dimensions(root / "graph") if len(dimensions) != 1: return problems # the matrix shape is undefined; already reported expected = next(iter(dimensions)) queries_dir = root / "queries" for cls in QUERY_CLASSES: cls_dir = queries_dir / cls if not cls_dir.is_dir(): continue for query_dir in sorted(cls_dir.iterdir()): if not query_dir.is_dir(): continue results = query_dir / "results.mtx" rel = f"queries/{cls}/{query_dir.name}/results.mtx" header = _mtx_header(results) if header is None: problems.append( f"{rel}: cannot be parsed as a Boolean MatrixMarket file" ) continue rows, cols, nnz = header if (rows, cols) != expected: problems.append( f"{rel}: must be a {expected[0]}x{expected[1]} matrix " f"(the graph declares {expected[0]}x{expected[1]})" ) continue count = 0 done = False try: for tail, head in _mtx_entries(results): count += 1 if not 0 <= tail < rows or not 0 <= head < cols: problems.append( f"{rel}: entry ({tail}, {head}) is outside the " f"declared {rows}x{cols} matrix" ) done = True break except ValueError: problems.append( f"{rel}: cannot be parsed as a Boolean MatrixMarket file" ) done = True if not done and count != nnz: problems.append(f"{rel}: declares {nnz} entries but has {count}") return problems def _readme_problems(root: pathlib.Path) -> list[str]: """Check that README.md answers all the mandatory questions.""" text = (root / "README.md").read_text(encoding="utf-8") headers = {line[3:].strip() for line in text.splitlines() if line.startswith("## ")} return [ f"README.md: missing mandatory section '## {question}'" for question in MANDATORY_README_SECTIONS if question not in headers ]
[docs] def parse_readme_sections(text: str) -> dict[str, list[str]]: """Parse the ``## <name>`` sections of a queries README into an ordered mapping section name -> body lines.""" sections: dict[str, list[str]] = {} current = None for line in text.splitlines(): if line.startswith("## "): current = line[3:].strip() sections[current] = [] elif current is not None: sections[current].append(line) return sections
def _rsm_recurses(rsa: RSA) -> bool: """Whether any box transition is labelled by a nonterminal (box name).""" names = {label.value for label in rsa.labels} for label in rsa.labels: box = rsa.get_box(label) if box is not None and any(symbol.value in names for symbol in box.dfa.symbols): return True return False def _query_problems(root: pathlib.Path) -> list[str]: """Check that queries parse, use at least one terminal, and are all described. A terminal matching no stored label is legal: it stays inert on materialization (see :func:`flpq_data.materialize`) and the query yields an empty result — the RDF category ships such non-applicable variants.""" problems = [] queries_dir = root / "queries" existing: dict[str, tuple[str, list[pathlib.Path]]] = {} for cls, exts in QUERY_CLASSES.items(): cls_dir = queries_dir / cls if not cls_dir.is_dir(): continue for query_dir in sorted(cls_dir.iterdir()): if not query_dir.is_dir(): continue # reported by the skeleton check representations = [ path for path in sorted(query_dir.iterdir()) if path.is_file() and path.suffix in exts ] existing[f"{cls}/{query_dir.name}"] = (cls, representations) for rel, (cls, representations) in sorted(existing.items()): for path in representations: where = f"queries/{rel}/{path.name}" try: terminals = _query_terminals(path) except Exception as error: problems.append(f"{where}: cannot be parsed as a {cls} query ({error})") continue if not terminals: problems.append( f"{where}: uses no terminal (an empty query is meaningless)" ) if cls == "rpq" and path.suffix == ".rsm": try: rsa = rsa_from_text(path.read_text(encoding="utf-8")) except Exception: continue # already reported above if _rsm_recurses(rsa): problems.append( f"{where}: an rpq query must be regular (no box " "transition may be labelled by a nonterminal)" ) doc = (queries_dir / "README.md").read_text(encoding="utf-8") sections = parse_readme_sections(doc) for name in sorted(set(sections) - set(existing)): problems.append( f"queries/README.md: section {name!r} does not match any query directory" ) for name in sorted(set(existing) - set(sections)): problems.append(f"queries/README.md: no section describing {name!r}") for name, body in sections.items(): if name in existing and not any(line.strip() for line in body): problems.append(f"queries/README.md: the section for {name!r} is empty") return problems def _validate_root(root: pathlib.Path, partial: bool = False) -> list[str]: """Validate the unpacked archive directory ``root``.""" if partial: problems = _partial_skeleton_problems(root) if problems: return problems # the deeper checks are meaningless without the skeleton problems.extend(_query_problems(root)) return problems problems = _skeleton_problems(root) if problems: return problems # the deeper checks are meaningless without the skeleton try: graph_from_mtx_dir(root / "graph") except (ValueError, OSError) as error: problems.append(f"graph/: {error}") problems.extend(_mtx_range_problems(root / "graph")) problems.extend(_results_problems(root)) problems.extend(_readme_problems(root)) problems.extend(_query_problems(root)) problems.extend(_naming_problems(root)) return problems
[docs] def unpack_single_dir(path: pathlib.Path, dest: pathlib.Path) -> pathlib.Path: """Unpack ``path`` (a ``.tar.gz``) into ``dest`` and return its single top-level directory. Raises ------ ValueError If the archive does not contain a single top-level directory. """ shutil.unpack_archive(path, dest) entries = list(dest.iterdir()) if len(entries) != 1 or not entries[0].is_dir(): raise ValueError(f"{path.name}: must contain a single top-level directory") return entries[0]
[docs] def validate_archive( path: Union[str, pathlib.Path], *, partial: bool = False ) -> list[str]: """Validate one graph archive (a ``.tar.gz`` or an unpacked directory). Parameters ---------- path : Union[str, Path] The archive to validate. partial : bool Validate a partial archive (new queries for an existing graph: ``queries/`` only) instead of a full graph archive. Returns ------- problems : list[str] All structure violations found; an empty list means the archive is valid. """ path = pathlib.Path(path) if path.is_dir(): return _validate_root(path, partial=partial) try: is_tar = tarfile.is_tarfile(path) except OSError: is_tar = False if path.suffix != ".gz" or not is_tar: return [f"{path.name}: not a .tar.gz archive or an unpacked directory"] with tempfile.TemporaryDirectory() as tmp: try: root = unpack_single_dir(path, pathlib.Path(tmp)) except ValueError as error: return [str(error)] problems = [] expected_name = path.name[: -len(".tar.gz")] if root.name != expected_name: problems.append( f"top-level directory {root.name!r} must be named after the " f"archive ({expected_name!r})" ) problems.extend(_validate_root(root, partial=partial)) return problems
def _audit(client, bucket: str, prefix: str) -> list[str]: """Validate every ``.tar.gz`` object stored under the bucket prefix.""" problems = [] key_prefix = f"{prefix.rstrip('/')}/" if prefix else "" objects = [] for page in client.get_paginator("list_objects_v2").paginate( Bucket=bucket, Prefix=key_prefix ): objects.extend( obj["Key"] for obj in page.get("Contents", []) if obj["Key"].endswith(".tar.gz") ) if not objects: problems.append(f"no .tar.gz objects found under s3://{bucket}/{key_prefix}") return problems with tempfile.TemporaryDirectory() as tmp: for object_key in sorted(objects): # A per-key sandbox keeps the original file name, which the # top-level-directory check depends on. sandbox = pathlib.Path(tmp) / object_key.replace("/", "__") sandbox.mkdir() local = sandbox / pathlib.Path(object_key).name client.download_file(bucket, object_key, str(local)) problems.extend( f"{object_key}: {problem}" for problem in validate_archive(local) ) return problems def main(argv: Optional[Sequence[str]] = None) -> int: """Command-line entry point. Validates one local graph archive, or — with ``--audit`` — every ``.tar.gz`` object under a bucket prefix, and prints every structure violation found. Returns ------- status : int 0 when everything is valid, 1 otherwise. """ parser = argparse.ArgumentParser( description="Validate the fixed self-contained structure of a graph archive." ) parser.add_argument( "path", nargs="?", help="path to a .tar.gz archive or an unpacked archive directory", ) parser.add_argument( "--partial", action="store_true", help=( "validate a partial archive (queries/ only — new queries for an " "existing graph) instead of a full graph archive" ), ) parser.add_argument( "--audit", action="store_true", help=( "validate every .tar.gz object under --prefix in the bucket " "instead of a local path" ), ) parser.add_argument( "--prefix", default="", help="bucket prefix to audit (with --audit)" ) parser.add_argument( "--access-key-id", default=None, help="Yandex Cloud IAM key ID (with --audit)" ) parser.add_argument( "--secret-access-key", default=None, help="Yandex Cloud IAM secret key (with --audit)", ) parser.add_argument( "--endpoint-url", default=None, help="S3 API endpoint (with --audit; default: the upload tool's)", ) parser.add_argument( "--bucket", default=None, help="target bucket (with --audit; default: the upload tool's)", ) args = parser.parse_args(argv) if args.audit: from flpq_data.dataset.s3 import ( DEFAULT_BUCKET, DEFAULT_ENDPOINT_URL, create_s3_client, ) if not args.access_key_id or not args.secret_access_key: print( "error: --access-key-id and --secret-access-key are " "required for --audit" ) return 1 try: client = create_s3_client( args.access_key_id, args.secret_access_key, args.endpoint_url or DEFAULT_ENDPOINT_URL, ) except ImportError: print("error: --audit requires boto3 (pip install boto3)") return 1 problems = _audit(client, args.bucket or DEFAULT_BUCKET, args.prefix) elif args.path is None: print("error: give a path to validate or use --audit") return 1 else: problems = validate_archive(args.path, partial=args.partial) if problems: for problem in problems: print(f"error: {problem}") print(f"{len(problems)} structure violation(s) found") return 1 kind = "partial archive" if args.partial else "archive" print(f"{kind} structure ok") return 0