478 lines
15 KiB
Python
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
|