← Files VeraARCHIVED FILE
modules/bilancio-xbrl-it/scripts/validate_xbrl.py
9.91 KB · Oct 2, 2026 · 00:29 UTC
#!/usr/bin/env python3
"""Run pinned Arelle validation behind a replaceable subprocess interface."""
from __future__ import annotations
import argparse
import hashlib
import json
import logging
import shutil
# The validator runs one fixed local Arelle argv and never invokes a shell.
import subprocess # nosec B404
import sys
import tempfile
from datetime import datetime, timezone
from pathlib import Path
from zipfile import BadZipFile, ZipFile
from defusedxml import ElementTree
__all__ = ["main", "validate_instance"]
LOGGER = logging.getLogger(__name__)
MAX_MEMBERS = 20_000
MAX_EXPANDED_BYTES = 512 * 1024 * 1024
XBRLI_NS = "http://www.xbrl.org/2003/instance"
LINK_NS = "http://www.xbrl.org/2003/linkbase"
XLINK_NS = "http://www.w3.org/1999/xlink"
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _safe_extract(package: Path, destination: Path) -> None:
"""Extract a bounded taxonomy ZIP without links or path traversal."""
try:
archive = ZipFile(package)
except BadZipFile as exc:
raise ValueError("Taxonomy package is not a readable ZIP archive") from exc
with archive:
members = archive.infolist()
if len(members) > MAX_MEMBERS:
raise ValueError("Taxonomy package has too many members")
if sum(member.file_size for member in members) > MAX_EXPANDED_BYTES:
raise ValueError("Taxonomy package exceeds the expanded-size limit")
destination_resolved = destination.resolve()
for member in members:
relative = Path(member.filename)
if relative.is_absolute() or ".." in relative.parts:
raise ValueError("Taxonomy package contains an unsafe path")
if (member.external_attr >> 16) & 0o170000 == 0o120000:
raise ValueError("Taxonomy package contains a symbolic link")
target = (destination / relative).resolve()
if (
target != destination_resolved
and destination_resolved not in target.parents
):
raise ValueError("Taxonomy member escapes the extraction directory")
if member.is_dir():
target.mkdir(parents=True, exist_ok=True)
continue
target.parent.mkdir(parents=True, exist_ok=True)
with archive.open(member) as source, target.open("xb") as output:
shutil.copyfileobj(source, output)
def _parse_log(log_file: Path) -> tuple[list[dict[str, str]], bool]:
"""Return Arelle messages and whether any validation error was recorded."""
if not log_file.is_file():
return [], True
root = ElementTree.parse(log_file).getroot()
messages: list[dict[str, str]] = []
has_error = False
for entry in root.iter():
if entry.tag.rsplit("}", 1)[-1] != "entry":
continue
level = entry.attrib.get("level", "").lower()
code = entry.attrib.get("code", "")
text = " ".join("".join(entry.itertext()).split())
messages.append({"level": level, "code": code, "message": text})
if level in {
"critical",
"error",
"error-semantic",
"exception",
"fatal",
"assertion-not-satisfied",
"inconsistency",
}:
has_error = True
return messages, has_error
def _preflight_instance(instance: Path) -> list[dict[str, str]]:
"""Reject files that Arelle would otherwise treat as generic XML."""
try:
root = ElementTree.parse(instance).getroot()
except ElementTree.ParseError as exc:
return [{"level": "error", "code": "VERA.XML", "message": str(exc)}]
errors: list[dict[str, str]] = []
if root.tag != f"{{{XBRLI_NS}}}xbrl":
errors.append(
{
"level": "error",
"code": "VERA.XBRL_ROOT",
"message": "The document root is not xbrli:xbrl.",
}
)
return errors
schema_refs = root.findall(f"{{{LINK_NS}}}schemaRef")
if len(schema_refs) != 1:
errors.append(
{
"level": "error",
"code": "VERA.SCHEMA_REF",
"message": "Exactly one link:schemaRef is required.",
}
)
elif schema_refs[0].get(f"{{{XLINK_NS}}}type") != "simple" or not schema_refs[
0
].get(f"{{{XLINK_NS}}}href"):
errors.append(
{
"level": "error",
"code": "VERA.SCHEMA_REF",
"message": "The schemaRef must have xlink:type='simple' and a non-empty href.",
}
)
if not root.findall(f"{{{XBRLI_NS}}}context"):
errors.append(
{
"level": "error",
"code": "VERA.CONTEXT",
"message": "At least one XBRL context is required.",
}
)
return errors
def validate_instance(
instance: Path,
report: Path,
taxonomy_package: Path | None = None,
expected_taxonomy_sha256: str | None = None,
) -> dict[str, object]:
"""Validate one local XBRL instance and persist the exact processor output."""
if instance.is_symlink() or not instance.is_file():
raise ValueError("XBRL instance must be a regular local file")
if report.is_symlink():
raise ValueError("Validation report must not be a symbolic link")
if expected_taxonomy_sha256 and taxonomy_package is None:
raise ValueError(
"A taxonomy package is required when an expected checksum is supplied"
)
if taxonomy_package is not None:
if taxonomy_package.is_symlink() or not taxonomy_package.is_file():
raise ValueError("Taxonomy package must be a regular local file")
actual_checksum = _sha256(taxonomy_package)
if expected_taxonomy_sha256 and actual_checksum != expected_taxonomy_sha256:
raise ValueError(
"Taxonomy package checksum does not match the expected checksum"
)
else:
actual_checksum = None
preflight_messages = _preflight_instance(instance)
with tempfile.TemporaryDirectory(prefix="vera-xbrl-validation-") as temporary:
root = Path(temporary)
if taxonomy_package is not None:
_safe_extract(taxonomy_package, root)
validation_instance = root / instance.name
shutil.copy2(instance, validation_instance)
log_file = root / "arelle-log.xml"
cache_dir = root / "arelle-cache"
try:
import arelle
except ImportError as exc:
raise RuntimeError(
"arelle-release is required for XBRL validation"
) from exc
bundled_cache = Path(arelle.__file__).parent / "resources" / "cache"
if bundled_cache.is_dir():
shutil.copytree(bundled_cache, cache_dir)
else:
cache_dir.mkdir()
command = [
sys.executable,
"-m",
"arelle.CntlrCmdLine",
"--disablePersistentConfig",
"--cacheDirectory",
str(cache_dir),
"--file",
str(validation_instance),
"--validate",
"--calc",
"xbrl21",
"--internetConnectivity",
"offline",
"--logFile",
str(log_file),
"--logFileMode",
"w",
]
# Every variable argv member is a validated path under this temporary run.
completed = subprocess.run( # nosec B603
command,
cwd=root,
capture_output=True,
check=False,
text=True,
timeout=120,
)
messages, has_validation_error = _parse_log(log_file)
messages = [*preflight_messages, *messages]
has_validation_error = has_validation_error or bool(preflight_messages)
payload: dict[str, object] = {
"schema_version": 1,
"processor": "arelle-release",
"command": [
"python",
"-m",
"arelle.CntlrCmdLine",
"--disablePersistentConfig",
"--file",
instance.name,
"--validate",
"--calc",
"xbrl21",
"--internetConnectivity",
"offline",
],
"taxonomy_package_sha256": actual_checksum,
"returncode": completed.returncode,
"status": (
"PASS" if completed.returncode == 0 and not has_validation_error else "FAIL"
),
"messages": messages,
"stdout": completed.stdout,
"stderr": completed.stderr,
"validated_at": datetime.now(tz=timezone.utc)
.replace(microsecond=0)
.isoformat(),
}
report.parent.mkdir(parents=True, exist_ok=True)
report.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
return payload
def main(argv: list[str] | None = None) -> int:
"""Run Arelle validation and return nonzero when it fails."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("instance", type=Path)
parser.add_argument("--report", type=Path, required=True)
parser.add_argument("--taxonomy-package", type=Path)
parser.add_argument("--expected-taxonomy-sha256")
args = parser.parse_args(argv)
try:
result = validate_instance(
args.instance,
args.report,
args.taxonomy_package,
args.expected_taxonomy_sha256,
)
LOGGER.info("Arelle validation: %s", result["status"])
return 0 if result["status"] == "PASS" else 1
except (OSError, RuntimeError, subprocess.SubprocessError, ValueError) as exc:
LOGGER.error("%s", exc)
return 2
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO, format="%(message)s")
raise SystemExit(main())
SHA-256: 63ca86dd240de7d99a3026596564fb9b68a8aff380b8f53498c907b276d07b83