pugflow 2026.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pugflow/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Pugflow diagram editor and renderer."""
2
+
3
+ __version__ = "2026.1"
4
+
5
+ from .server import DiagramServer, create_server, serve
6
+
7
+ __all__ = ["DiagramServer", "create_server", "serve", "__version__"]
pugflow/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ from pugflow.cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ main()
pugflow/cli.py ADDED
@@ -0,0 +1,59 @@
1
+ """Command-line interface for Pugflow."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from pathlib import Path
7
+
8
+ from . import __version__
9
+ from .server import serve
10
+ from .render import render_png
11
+
12
+
13
+ def build_parser():
14
+ parser = argparse.ArgumentParser(
15
+ prog="pugflow",
16
+ description="Open the editor or render a Pug diagram to PNG.",
17
+ )
18
+ parser.add_argument("input", nargs="?", type=Path, help="Pug source to render. Omit to open the editor.")
19
+ parser.add_argument("-o", "--output", type=Path, help="PNG output path (default: input filename with .png).")
20
+ parser.add_argument("--css", type=Path, help="External reusable-style CSS file.")
21
+ parser.add_argument("--scale", type=float, default=2, help="PNG scale multiplier (default: 2).")
22
+ parser.add_argument("--timeout", type=float, default=30, help="Headless rendering timeout in seconds (default: 30).")
23
+ parser.add_argument("--host", default="127.0.0.1", help="Interface to bind (default: 127.0.0.1).")
24
+ parser.add_argument("--port", default=4173, type=int, help="Port to bind; use 0 for an available port (default: 4173).")
25
+ parser.add_argument("--no-browser", action="store_true", help="Do not open the application in the default browser.")
26
+ parser.add_argument("--vim", action="store_true", help="Open the GUI with Vim editing mode enabled.")
27
+ parser.add_argument("--gui", action="store_true", help="Open the input Pug and optional CSS in the GUI instead of rendering.")
28
+ parser.add_argument("--demo", action="store_true", help="Open the GUI with the full feature-tour example.")
29
+ parser.add_argument("--quiet", action="store_true", help="Suppress HTTP request logging.")
30
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
31
+ return parser
32
+
33
+
34
+ def main(argv=None):
35
+ args = build_parser().parse_args(argv)
36
+ if args.input and not args.gui:
37
+ if args.scale <= 0:
38
+ raise SystemExit("--scale must be greater than zero")
39
+ output = args.output or args.input.with_suffix(".png")
40
+ try:
41
+ render_png(args.input, output, css_path=args.css, scale=args.scale, timeout=args.timeout)
42
+ except (OSError, RuntimeError) as error:
43
+ raise SystemExit(f"error: {error}") from error
44
+ print(f"Wrote {output}")
45
+ return
46
+ if args.output:
47
+ raise SystemExit("--output requires rendering an input .pug file")
48
+ if args.css and not args.input:
49
+ raise SystemExit("--css requires an input .pug file")
50
+ css_path = args.css
51
+ if args.input and css_path is None:
52
+ candidate = args.input.with_suffix(".css")
53
+ css_path = candidate if candidate.is_file() else None
54
+ serve(args.host, args.port, open_browser=not args.no_browser, quiet=args.quiet, vim=args.vim,
55
+ demo=args.demo, pug_path=args.input, css_path=css_path)
56
+
57
+
58
+ if __name__ == "__main__":
59
+ main()
pugflow/render.py ADDED
@@ -0,0 +1,71 @@
1
+ """Headless browser-backed PNG rendering."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ import tempfile
9
+ import threading
10
+ from pathlib import Path
11
+
12
+ from .server import create_server
13
+
14
+
15
+ def find_browser() -> str:
16
+ configured = os.environ.get("PUGFLOW_BROWSER")
17
+ candidates = [configured] if configured else []
18
+ candidates += [shutil.which(name) for name in ("msedge", "chrome", "chromium", "chromium-browser")]
19
+ if os.name == "nt":
20
+ candidates += [
21
+ str(Path(os.environ.get("PROGRAMFILES(X86)", "C:/Program Files (x86)")) / "Microsoft/Edge/Application/msedge.exe"),
22
+ str(Path(os.environ.get("PROGRAMFILES", "C:/Program Files")) / "Google/Chrome/Application/chrome.exe"),
23
+ ]
24
+ for candidate in candidates:
25
+ if candidate and Path(candidate).is_file():
26
+ return candidate
27
+ raise RuntimeError("No Chromium browser found. Install Edge/Chrome/Chromium or set PUGFLOW_BROWSER.")
28
+
29
+
30
+ def render_png(source_path: Path, output_path: Path, *, css_path: Path | None = None, scale: float = 2, timeout: float = 30) -> None:
31
+ source_path = source_path.resolve()
32
+ output_path = output_path.resolve()
33
+ css_path = css_path.resolve() if css_path else None
34
+ if not source_path.is_file():
35
+ raise FileNotFoundError(f"Diagram source not found: {source_path}")
36
+ if css_path and not css_path.is_file():
37
+ raise FileNotFoundError(f"Stylesheet not found: {css_path}")
38
+ server = create_server("127.0.0.1", 0, quiet=True)
39
+ server.render_source = source_path.read_text(encoding="utf-8")
40
+ server.render_styles = css_path.read_text(encoding="utf-8") if css_path else ""
41
+ server.render_result = None
42
+ server.render_error = None
43
+ server.render_event = threading.Event()
44
+ server.asset_root = source_path.parent
45
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
46
+ thread.start()
47
+ browser = None
48
+ profile = Path(tempfile.mkdtemp(prefix="pugflow-render-"))
49
+ try:
50
+ url = f"http://127.0.0.1:{server.server_address[1]}/render.html?scale={scale}"
51
+ browser = subprocess.Popen([find_browser(), "--headless=new", "--disable-gpu", "--no-first-run", "--disable-extensions", f"--user-data-dir={profile}", url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
52
+ if not server.render_event.wait(timeout):
53
+ raise RuntimeError(f"Rendering timed out after {timeout:g} seconds.")
54
+ if server.render_error:
55
+ raise RuntimeError(server.render_error)
56
+ output_path.parent.mkdir(parents=True, exist_ok=True)
57
+ output_path.write_bytes(server.render_result)
58
+ finally:
59
+ if browser and browser.poll() is None:
60
+ try:
61
+ browser.wait(timeout=3)
62
+ except subprocess.TimeoutExpired:
63
+ browser.terminate()
64
+ try:
65
+ browser.wait(timeout=3)
66
+ except subprocess.TimeoutExpired:
67
+ browser.kill()
68
+ server.shutdown()
69
+ server.server_close()
70
+ thread.join(timeout=2)
71
+ shutil.rmtree(profile, ignore_errors=True)
pugflow/server.py ADDED
@@ -0,0 +1,133 @@
1
+ """Dependency-free HTTP server for the Pugflow frontend."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import threading
7
+ import webbrowser
8
+ from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
9
+ from pathlib import Path
10
+ from urllib.parse import quote, urlsplit
11
+
12
+ from . import __version__
13
+
14
+
15
+ STATIC_ROOT = Path(__file__).resolve().with_name("web")
16
+
17
+
18
+ class DiagramRequestHandler(SimpleHTTPRequestHandler):
19
+ """Serve packaged assets and expose a small health endpoint."""
20
+
21
+ server_version = f"Pugflow/{__version__}"
22
+ extensions_map = {**SimpleHTTPRequestHandler.extensions_map, ".mjs": "text/javascript"}
23
+
24
+ def __init__(self, *args, directory=None, **kwargs):
25
+ super().__init__(*args, directory=str(directory or STATIC_ROOT), **kwargs)
26
+
27
+ def do_GET(self): # noqa: N802 - required by BaseHTTPRequestHandler
28
+ path = urlsplit(self.path).path
29
+ if path == "/healthz":
30
+ payload = json.dumps({"status": "ok", "version": __version__}).encode("utf-8")
31
+ self.send_response(200)
32
+ self.send_header("Content-Type", "application/json; charset=utf-8")
33
+ self.send_header("Content-Length", str(len(payload)))
34
+ self.send_header("Cache-Control", "no-store")
35
+ self.end_headers()
36
+ self.wfile.write(payload)
37
+ return
38
+ if path == "/__render_source.pug" and hasattr(self.server, "render_source"):
39
+ return self._send_text(self.server.render_source, "text/plain; charset=utf-8")
40
+ if path == "/__render_styles.css" and hasattr(self.server, "render_styles"):
41
+ return self._send_text(self.server.render_styles, "text/css; charset=utf-8")
42
+ if path == "/__project.pug" and hasattr(self.server, "project_pug"):
43
+ return self._send_text(self.server.project_pug, "text/plain; charset=utf-8")
44
+ if path == "/__project.css" and hasattr(self.server, "project_css"):
45
+ return self._send_text(self.server.project_css, "text/css; charset=utf-8")
46
+ if hasattr(self.server, "asset_root") and path not in {"/", "/render.html"}:
47
+ asset = (self.server.asset_root / path.lstrip("/")).resolve()
48
+ if asset.is_relative_to(self.server.asset_root.resolve()) and asset.is_file():
49
+ self.directory = str(self.server.asset_root)
50
+ super().do_GET()
51
+
52
+ def do_POST(self): # noqa: N802 - required by BaseHTTPRequestHandler
53
+ path = urlsplit(self.path).path
54
+ if path not in {"/__render_output", "/__render_error"} or not hasattr(self.server, "render_event"):
55
+ self.send_error(404)
56
+ return
57
+ payload = self.rfile.read(int(self.headers.get("Content-Length", "0")))
58
+ if path == "/__render_output":
59
+ self.server.render_result = payload
60
+ else:
61
+ self.server.render_error = payload.decode("utf-8", errors="replace")
62
+ self.send_response(204)
63
+ self.end_headers()
64
+ self.server.render_event.set()
65
+
66
+ def _send_text(self, value, content_type):
67
+ payload = value.encode("utf-8")
68
+ self.send_response(200)
69
+ self.send_header("Content-Type", content_type)
70
+ self.send_header("Content-Length", str(len(payload)))
71
+ self.end_headers()
72
+ self.wfile.write(payload)
73
+
74
+ def end_headers(self):
75
+ if urlsplit(self.path).path != "/healthz":
76
+ self.send_header("Cache-Control", "no-cache")
77
+ self.send_header("X-Content-Type-Options", "nosniff")
78
+ self.send_header("Referrer-Policy", "no-referrer")
79
+ super().end_headers()
80
+
81
+ def log_message(self, format, *args):
82
+ if not getattr(self.server, "quiet", False):
83
+ super().log_message(format, *args)
84
+
85
+
86
+ class DiagramServer(ThreadingHTTPServer):
87
+ """Threaded development/desktop server with clean process shutdown."""
88
+
89
+ allow_reuse_address = True
90
+ daemon_threads = True
91
+
92
+
93
+ def create_server(host="127.0.0.1", port=4173, *, quiet=False):
94
+ """Create a configured server without starting its blocking loop."""
95
+
96
+ if not STATIC_ROOT.joinpath("index.html").is_file():
97
+ raise RuntimeError(
98
+ f"Packaged web assets are missing from {STATIC_ROOT}."
99
+ )
100
+ server = DiagramServer((host, port), DiagramRequestHandler)
101
+ server.quiet = quiet
102
+ return server
103
+
104
+
105
+ def serve(host="127.0.0.1", port=4173, *, open_browser=True, quiet=False, vim=False, demo=False, pug_path=None, css_path=None):
106
+ """Run the web application until interrupted and return the bound URL."""
107
+
108
+ server = create_server(host, port, quiet=quiet)
109
+ if pug_path:
110
+ server.project_pug = Path(pug_path).read_text(encoding="utf-8")
111
+ server.project_css = Path(css_path).read_text(encoding="utf-8") if css_path else ""
112
+ actual_port = server.server_address[1]
113
+ browser_host = "127.0.0.1" if host in {"0.0.0.0", "::"} else host
114
+ query = []
115
+ if vim: query.append("vim=1")
116
+ if demo: query.append("demo=1")
117
+ if pug_path:
118
+ query.extend(["project=1", f"name={quote(Path(pug_path).stem)}", f"pug_name={quote(Path(pug_path).name)}"])
119
+ if css_path: query.append(f"css_name={quote(Path(css_path).name)}")
120
+ url = f"http://{browser_host}:{actual_port}" + ("?" + "&".join(query) if query else "")
121
+ print(f"Pugflow {__version__}: {url}")
122
+ print("Press Ctrl+C to stop.")
123
+
124
+ if open_browser:
125
+ threading.Timer(0.25, webbrowser.open, args=(url,)).start()
126
+
127
+ try:
128
+ server.serve_forever()
129
+ except KeyboardInterrupt:
130
+ print("\nStopping Pugflow.")
131
+ finally:
132
+ server.server_close()
133
+ return url