"""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("", "", 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("", "", 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