Files
md_to_gost/md2gost/dfd.py
T
Igor20264 510f7e7adf
Python application / build (push) Waiting to run
v0.5.2
Что то сделал
2026-09-08 19:37:54 +03:00

478 lines
15 KiB
Python

"""DFD via pbauermeister/data-flow-diagram (Graphviz).
Fence: ```dfd (aliases ```uml-dfd, ```data-flow-diagram, ```yourdon, …).
DSL: https://github.com/pbauermeister/dfd
Pipeline:
1. Compile DSL → Graphviz DOT (`data-flow-diagram` Python API).
2. Rasterize: bundled/system Graphviz (`dot`/`neato`), else Kroki `graphviz`.
"""
from __future__ import annotations
import logging
import os
import re
import shutil
import subprocess
import sys
import tempfile
import zipfile
from pathlib import Path
import requests
from . import package_dir
_log = logging.getLogger(__name__)
CACHE_KEY_PREFIX = "dfd-v3-pbauermeister\n"
# Context diagrams use neato; everything else uses dot (matches upstream templates).
_ENGINE_CONTEXT = "neato"
_ENGINE_DEFAULT = "dot"
# Official Windows portable build (same pattern as plantuml.jar vendor).
GRAPHVIZ_VERSION = "16.1.0"
GRAPHVIZ_ZIP_URL = (
"https://gitlab.com/api/v4/projects/4207231/packages/generic/graphviz-releases/"
f"{GRAPHVIZ_VERSION}/windows_10_cmake_Release_Graphviz-{GRAPHVIZ_VERSION}-win64.zip"
)
# Portable Graphviz ships Tcl 8.6.10 as tcl86t.dll. CPython 3.11's tkinter needs
# exactly 8.6.12 — if both land in the onefile extract, GUI dies on Tk().
# `dot` does not need the Tcl bindings.
GRAPHVIZ_TCL_DLL_NAMES = frozenset(
{
"tcl86t.dll",
"tk86t.dll",
"tcl86.dll",
"tk86.dll",
"tcldot.dll",
"tcldot_builtin.dll",
"tclplan.dll",
"gdtclft.dll",
}
)
class DfdParseError(ValueError):
"""Invalid DFD DSL or render tooling failure surfaced as parse/render error."""
SAMPLE_CONTEXT = """\
style context
entity Client Клиент
entity Warehouse Склад
process System Система учёта заявок
Client --> System заявка
System --> Client статус
System --> Warehouse накладная
"""
SAMPLE_LEVEL0 = """\
entity Client Клиент
entity Warehouse Склад
process Accept Принять заявку
process Register Зарегистрировать
process Ship Сформировать накладную
store Orders Заявки
Client --> Accept заявка
Accept --> Orders новая заявка
Orders --> Register данные заявки
Register --> Ship подтверждённая заявка
Ship --> Warehouse накладная
Ship --> Client уведомление
"""
def vendor_graphviz_root() -> Path:
return Path(package_dir()) / "vendor" / "graphviz"
def cached_graphviz_root() -> Path:
if os.name == "nt":
root = Path(os.environ.get("LOCALAPPDATA") or Path.home()) / "md2gost"
else:
root = Path.home() / ".md2gost"
return root / "graphviz"
def _graphviz_bin_candidates() -> list[Path]:
roots: list[Path] = [vendor_graphviz_root()]
if getattr(sys, "frozen", False):
mei = getattr(sys, "_MEIPASS", None)
if mei:
roots.append(Path(mei) / "md2gost" / "vendor" / "graphviz")
roots.append(Path(mei) / "vendor" / "graphviz")
roots.append(Path(sys.executable).resolve().parent / "graphviz")
roots.append(cached_graphviz_root())
bins: list[Path] = []
for root in roots:
bins.append(root / "bin")
bins.append(root)
return bins
def _engine_in_bin(bin_dir: Path, context: bool) -> Path | None:
if not bin_dir.is_dir():
return None
names = (
(_ENGINE_CONTEXT, _ENGINE_DEFAULT)
if context
else (_ENGINE_DEFAULT, _ENGINE_CONTEXT)
)
for name in names:
for cand in (bin_dir / f"{name}.exe", bin_dir / name):
if cand.is_file():
return cand
return None
def resolve_graphviz_engine(context: bool = False) -> str | None:
"""Path to Graphviz layout engine: vendor → cache → PATH."""
for bin_dir in _graphviz_bin_candidates():
hit = _engine_in_bin(bin_dir, context)
if hit is not None:
return str(hit)
if context:
return _which_many(
_ENGINE_CONTEXT, f"{_ENGINE_CONTEXT}.exe", _ENGINE_DEFAULT, "dot.exe",
)
return _which_many(_ENGINE_DEFAULT, "dot.exe", _ENGINE_CONTEXT, "neato.exe")
def _which_many(*names: str) -> str | None:
for name in names:
hit = shutil.which(name)
if hit:
return hit
return None
def _normalize_extracted_graphviz(extract_dir: Path, dest_root: Path) -> bool:
"""Find bin/dot(.exe) under extract_dir and copy tree into dest_root."""
dot = None
for path in extract_dir.rglob("dot.exe"):
if path.is_file():
dot = path
break
if dot is None:
for path in extract_dir.rglob("dot"):
if path.is_file() and os.access(path, os.X_OK):
dot = path
break
if dot is None:
return False
src_bin = dot.parent
src_root = src_bin.parent if src_bin.name.lower() == "bin" else src_bin
if dest_root.exists():
shutil.rmtree(dest_root)
dest_root.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(src_root, dest_root)
strip_graphviz_tcl_libs(dest_root)
return (
_engine_in_bin(dest_root / "bin", False) is not None
or _engine_in_bin(dest_root, False) is not None
)
def strip_graphviz_tcl_libs(root: Path) -> int:
"""Remove Graphviz Tcl/Tk DLLs so they cannot shadow CPython's tkinter."""
root = Path(root)
if not root.is_dir():
return 0
removed = 0
bins = [root / "bin", root]
seen: set[Path] = set()
for bin_dir in bins:
if not bin_dir.is_dir():
continue
resolved = bin_dir.resolve()
if resolved in seen:
continue
seen.add(resolved)
for name in GRAPHVIZ_TCL_DLL_NAMES:
path = bin_dir / name
if path.is_file():
path.unlink()
removed += 1
tcl_mod = root / "lib" / "graphviz" / "tcl"
if tcl_mod.is_dir():
shutil.rmtree(tcl_mod)
removed += 1
return removed
def fetch_graphviz(dest_root: Path | None = None, url: str = GRAPHVIZ_ZIP_URL) -> bool:
"""Download official Graphviz win64 ZIP into dest_root (default: vendor)."""
dest = Path(dest_root) if dest_root is not None else vendor_graphviz_root()
if _engine_in_bin(dest / "bin", False) or _engine_in_bin(dest, False):
strip_graphviz_tcl_libs(dest)
return True
try:
_log.info("Downloading Graphviz %s", GRAPHVIZ_VERSION)
with requests.get(url, stream=True, timeout=180) as resp:
resp.raise_for_status()
with tempfile.TemporaryDirectory(prefix="md2gost-gv-") as tmp:
zpath = Path(tmp) / "graphviz.zip"
with zpath.open("wb") as fh:
for chunk in resp.iter_content(chunk_size=1024 * 256):
if chunk:
fh.write(chunk)
extract_dir = Path(tmp) / "out"
extract_dir.mkdir()
with zipfile.ZipFile(zpath) as zf:
zf.extractall(extract_dir)
if not _normalize_extracted_graphviz(extract_dir, dest):
_log.warning("Graphviz ZIP распакован, но dot.exe не найден")
return False
return True
except Exception as exc:
_log.warning("Не удалось скачать Graphviz: %s", exc)
return False
def resolve_dfd_cli() -> list[str] | None:
"""Argv prefix that compiles DSL → DOT without needing Graphviz for compile."""
py = _which_many("py", "py.exe")
if py:
for ver in ("-3.14", "-3.13", "-3.12", "-3.11"):
try:
probe = subprocess.run(
[py, ver, "-c", "import data_flow_diagram"],
capture_output=True,
timeout=20,
)
if probe.returncode == 0:
return [py, ver, "-c", _API_DOT_RUNNER]
except (OSError, subprocess.TimeoutExpired):
continue
exe = _which_many("data-flow-diagram", "data-flow-diagram.exe")
if exe:
return [exe]
return None
_API_DOT_RUNNER = r"""
import sys
from data_flow_diagram.dfd import build
from data_flow_diagram import model
src_path = sys.argv[1]
text = open(src_path, encoding="utf-8").read()
root = model.SourceLine("", "<md2gost>", None, 0)
opts = model.Options(
format="dot",
background_color=None,
no_graph_title=True,
no_check_dependencies=False,
debug=False,
)
dot, graph_options = build(root, text, "", opts)
sys.stdout.reconfigure(encoding="utf-8") if hasattr(sys.stdout, "reconfigure") else None
sys.stdout.write(dot)
sys.stderr.write("\n__MD2GOST_DFD_CONTEXT__=%s\n" % int(bool(graph_options.is_context)))
"""
def _is_context_diagram(source: str) -> bool:
return bool(re.search(r"(?im)^\s*style\s+context\b", source))
def _compile_dot(source: str) -> tuple[str, bool]:
"""Return (dot_text, is_context). Raises DfdParseError."""
text = (source or "").strip()
if not text:
raise DfdParseError("пустой DFD")
try:
from data_flow_diagram.dfd import build as dfd_build
from data_flow_diagram import model as dfd_model
root = dfd_model.SourceLine("", "<md2gost>", None, 0)
opts = dfd_model.Options(
format="dot",
background_color=None,
no_graph_title=True,
no_check_dependencies=False,
debug=False,
)
dot, graph_options = dfd_build(root, text, "", opts)
return dot, bool(graph_options.is_context)
except ImportError:
pass
except Exception as exc:
raise DfdParseError(str(exc)) from exc
cli = resolve_dfd_cli()
if not cli:
raise DfdParseError(
"Пакет data-flow-diagram не найден. "
"Установите зависимости проекта (Python 3.11+) — "
"https://github.com/pbauermeister/dfd"
)
with tempfile.TemporaryDirectory(prefix="md2gost-dfd-") as tmp:
src_path = Path(tmp) / "diagram.dfd"
src_path.write_text(text + "\n", encoding="utf-8")
if cli[-1] == _API_DOT_RUNNER or (len(cli) >= 2 and cli[-2] == "-c"):
cmd = list(cli) + [str(src_path)]
elif len(cli) == 1:
cmd = cli + [str(src_path), "-f", "dot", "-o", "-", "--no-graph-title"]
else:
cmd = list(cli) + [str(src_path)]
try:
proc = subprocess.run(
cmd,
capture_output=True,
timeout=120,
text=True,
encoding="utf-8",
errors="replace",
)
except FileNotFoundError as exc:
raise DfdParseError(f"Не удалось запустить data-flow-diagram: {exc}") from exc
except subprocess.TimeoutExpired as exc:
raise DfdParseError("Таймаут data-flow-diagram") from exc
if proc.returncode != 0:
err = (proc.stderr or proc.stdout or "").strip() or f"код {proc.returncode}"
raise DfdParseError(f"DFD: {err}")
dot = proc.stdout or ""
if not dot.strip():
raise DfdParseError("data-flow-diagram вернул пустой DOT")
is_ctx = _is_context_diagram(text)
m = re.search(r"__MD2GOST_DFD_CONTEXT__=(\d+)", proc.stderr or "")
if m:
is_ctx = bool(int(m.group(1)))
return dot, is_ctx
def _graphviz_run_env(engine: str) -> dict[str, str]:
env = os.environ.copy()
bin_dir = str(Path(engine).resolve().parent)
env["PATH"] = bin_dir + os.pathsep + env.get("PATH", "")
env["GVBINDIR"] = bin_dir
return env
def _readable_dot(dot: str) -> str:
"""Make Graphviz DFD easier to read in printed Word reports."""
text = dot
text = re.sub(
r'edge\[color=gray fontname="times-italic" fontsize=\d+\]',
'edge[color=black fontname="helvetica" fontsize=13]',
text,
)
text = re.sub(
r'node\[fontname="helvetica" fontsize=\d+\]',
'node[fontname="helvetica" fontsize=13]',
text,
)
if "dpi=" not in text:
text = text.replace("digraph D {", 'digraph D {\n graph[dpi=180 nodesep=0.55 ranksep=0.7]', 1)
return text
def _render_dot_local(dot: str, out_path: Path, fmt: str, *, context: bool) -> bool:
engine = resolve_graphviz_engine(context=context)
if not engine:
return False
try:
subprocess.run(
[engine, f"-T{fmt}", f"-o{out_path}"],
input=_readable_dot(dot),
encoding="utf-8",
check=True,
capture_output=True,
timeout=120,
env=_graphviz_run_env(engine),
)
except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc:
_log.warning("Graphviz %s failed: %s", engine, exc)
return False
return out_path.is_file() and out_path.stat().st_size > 0
def _render_dot_kroki(dot: str, out_path: Path, fmt: str) -> bool:
try:
from .diagram_renderer import (
DEFAULT_KROKI_URL,
REMOTE_KROKI_URL,
_CONFIG,
_render_kroki,
)
except Exception:
return False
local = (_CONFIG.kroki_url or os.environ.get("KROKI_URL") or DEFAULT_KROKI_URL).rstrip("/")
if _render_kroki(dot, "graphviz", local, out_path, fmt):
return True
if _CONFIG.fallback == "remote":
return _render_kroki(dot, "graphviz", REMOTE_KROKI_URL, out_path, fmt)
return False
def dfd_engine_status() -> str:
cli_ok = False
try:
import data_flow_diagram # noqa: F401
cli_ok = True
except ImportError:
cli_ok = resolve_dfd_cli() is not None
gv = resolve_graphviz_engine(False)
parts = ["DFD (pbauermeister/data-flow-diagram), оградка ```dfd"]
parts.append(
"компилятор: data-flow-diagram OK"
if cli_ok
else "компилятор: не найден (зависимость md2gost, Python 3.11+)"
)
if gv:
parts.append(f"Graphviz: {gv}")
else:
parts.append(
"Graphviz: нет — «Скачать Graphviz» / scripts/fetch_graphviz.py или Kroki"
)
return "\n".join(parts)
def write_dfd_outputs(
source: str,
out_png: Path,
out_svg: Path | None,
*,
scale: float = 2.0,
) -> bool:
"""Compile DSL and write PNG (+ optional SVG). Raises DfdParseError."""
del scale
dot, is_context = _compile_dot(source)
out_png = Path(out_png)
out_png.parent.mkdir(parents=True, exist_ok=True)
ok_png = _render_dot_local(dot, out_png, "png", context=is_context)
if not ok_png:
ok_png = _render_dot_kroki(dot, out_png, "png")
if not ok_png:
raise DfdParseError(
"Не удалось отрисовать DFD: скачайте Graphviz "
"(GUI: Диаграммы → Скачать Graphviz, или scripts/fetch_graphviz.py) "
"или включите Kroki (--diagram-fallback remote). "
"DSL: https://github.com/pbauermeister/dfd"
)
if out_svg is not None:
out_svg = Path(out_svg)
ok_svg = _render_dot_local(dot, out_svg, "svg", context=is_context)
if not ok_svg:
_render_dot_kroki(dot, out_svg, "svg")
_log.info("DFD ok → %s", out_png.name)
return True