+477
@@ -0,0 +1,477 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user