@littledragon_wxl/drawio-style-graph 1.0.0
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.
- package/CHANGELOG.md +32 -0
- package/LICENSE +21 -0
- package/README.md +175 -0
- package/README_zh.md +171 -0
- package/SKILL.md +419 -0
- package/data/SHAPE-INDEX-NOTICE.md +17 -0
- package/data/lobe-icons.json +878 -0
- package/data/shape-index.json.gz +0 -0
- package/package.json +43 -0
- package/references/autolayout.md +125 -0
- package/references/diagram-types.md +83 -0
- package/references/shapes.md +151 -0
- package/references/style-application-guide.md +120 -0
- package/references/style-diagram-matrix.md +159 -0
- package/references/style-extraction.md +255 -0
- package/references/style-presets.md +110 -0
- package/references/styles/style-1-flat-icon.md +79 -0
- package/references/styles/style-2-dark-terminal.md +80 -0
- package/references/styles/style-3-blueprint.md +84 -0
- package/references/styles/style-4-notion-clean.md +78 -0
- package/references/styles/style-5-glassmorphism.md +85 -0
- package/references/styles/style-6-claude-official.md +84 -0
- package/references/styles/style-7-openai.md +94 -0
- package/references/styles/style-8-dark-luxury.md +109 -0
- package/references/troubleshooting.md +63 -0
- package/scripts/aiicons.py +201 -0
- package/scripts/autolayout.py +341 -0
- package/scripts/encode_drawio_url.py +58 -0
- package/scripts/goimports.py +141 -0
- package/scripts/jsimports.py +162 -0
- package/scripts/pyclasses.py +156 -0
- package/scripts/pyimports.py +153 -0
- package/scripts/repair_png.py +37 -0
- package/scripts/rustimports.py +203 -0
- package/scripts/shapesearch.py +162 -0
- package/scripts/validate.py +137 -0
- package/styles/built-in/corporate.json +49 -0
- package/styles/built-in/default.json +49 -0
- package/styles/built-in/handdrawn.json +49 -0
- package/styles/schema-drawio.json +112 -0
- package/styles/schema.json +213 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Extract a Go module's package-import graph as autolayout graph JSON.
|
|
3
|
+
|
|
4
|
+
The Go counterpart to pyimports.py / jsimports.py. Reads the module path from
|
|
5
|
+
go.mod, walks the module, treats each directory of .go files as one package
|
|
6
|
+
(node = its import path), and records the intra-module package imports. Stdlib
|
|
7
|
+
and third-party imports are ignored. Transitive reduction is on by default so
|
|
8
|
+
the diagram stays readable.
|
|
9
|
+
|
|
10
|
+
python3 goimports.py ./mymodule -o graph.json
|
|
11
|
+
python3 autolayout.py graph.json -o diagram.drawio
|
|
12
|
+
|
|
13
|
+
Parsing is regex-based over `import` statements (single and block form),
|
|
14
|
+
which is enough for a structural package graph. *_test.go files and vendor/
|
|
15
|
+
are skipped.
|
|
16
|
+
|
|
17
|
+
Usage: python3 goimports.py <module_dir> [-o graph.json] [--direction TB|LR]
|
|
18
|
+
[--group] [--no-reduce]
|
|
19
|
+
"""
|
|
20
|
+
import argparse
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import re
|
|
24
|
+
import subprocess
|
|
25
|
+
import sys
|
|
26
|
+
|
|
27
|
+
MODULE = re.compile(r"^module\s+(\S+)", re.MULTILINE)
|
|
28
|
+
BLOCK = re.compile(r"import\s*\((.*?)\)", re.DOTALL)
|
|
29
|
+
SINGLE = re.compile(r'import\s+(?:[\w.]+\s+|_\s+)?"([^"]+)"')
|
|
30
|
+
QUOTED = re.compile(r'"([^"]+)"')
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def module_path(root):
|
|
34
|
+
"""Read the `module` path from go.mod at the module root, or None."""
|
|
35
|
+
gomod = os.path.join(root, "go.mod")
|
|
36
|
+
if not os.path.exists(gomod):
|
|
37
|
+
return None
|
|
38
|
+
m = MODULE.search(open(gomod, encoding="utf-8", errors="ignore").read())
|
|
39
|
+
return m.group(1) if m else None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def discover(root, modpath):
|
|
43
|
+
"""Map package import path -> list of .go files (one entry per directory)."""
|
|
44
|
+
root = os.path.abspath(root)
|
|
45
|
+
pkgs = {}
|
|
46
|
+
for dirpath, dirs, files in os.walk(root):
|
|
47
|
+
dirs[:] = [d for d in dirs
|
|
48
|
+
if d not in ("vendor", "testdata") and not d.startswith(".")]
|
|
49
|
+
gofiles = [os.path.join(dirpath, f) for f in files
|
|
50
|
+
if f.endswith(".go") and not f.endswith("_test.go")]
|
|
51
|
+
if not gofiles:
|
|
52
|
+
continue
|
|
53
|
+
rel = os.path.relpath(dirpath, root).replace(os.sep, "/")
|
|
54
|
+
ip = modpath if rel == "." else f"{modpath}/{rel}"
|
|
55
|
+
pkgs[ip] = gofiles
|
|
56
|
+
return pkgs
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def imports_of(files, modpath, pkgs):
|
|
60
|
+
"""Intra-module package import paths referenced by a package's files."""
|
|
61
|
+
found = set()
|
|
62
|
+
for path in files:
|
|
63
|
+
try:
|
|
64
|
+
src = open(path, encoding="utf-8", errors="ignore").read()
|
|
65
|
+
except OSError:
|
|
66
|
+
continue
|
|
67
|
+
specs = []
|
|
68
|
+
for block in BLOCK.findall(src):
|
|
69
|
+
specs += QUOTED.findall(block)
|
|
70
|
+
specs += SINGLE.findall(src)
|
|
71
|
+
for spec in specs:
|
|
72
|
+
if (spec == modpath or spec.startswith(modpath + "/")) and spec in pkgs:
|
|
73
|
+
found.add(spec)
|
|
74
|
+
return found
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def transitive_reduce(nodes, edges):
|
|
78
|
+
"""Drop edges implied by a longer path, via Graphviz `tred`."""
|
|
79
|
+
idx = {n: i for i, n in enumerate(nodes)}
|
|
80
|
+
dot = "digraph{" + "".join(f"{idx[s]}->{idx[t]};" for s, t in edges) + "}"
|
|
81
|
+
try:
|
|
82
|
+
out = subprocess.run(["tred"], input=dot, capture_output=True,
|
|
83
|
+
text=True, check=True).stdout
|
|
84
|
+
except (FileNotFoundError, subprocess.CalledProcessError) as exc:
|
|
85
|
+
sys.stderr.write(f"warning: tred unavailable, keeping all edges ({exc})\n")
|
|
86
|
+
return edges
|
|
87
|
+
rev = {i: n for n, i in idx.items()}
|
|
88
|
+
return [(rev[int(a)], rev[int(b)]) for a, b in re.findall(r"(\d+)\s*->\s*(\d+)", out)]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def main():
|
|
92
|
+
ap = argparse.ArgumentParser(description="Go import graph -> autolayout graph JSON.")
|
|
93
|
+
ap.add_argument("module", help="module directory (contains go.mod)")
|
|
94
|
+
ap.add_argument("-o", "--output", help="output JSON path (default: stdout)")
|
|
95
|
+
ap.add_argument("--direction", default="TB", choices=["TB", "LR"])
|
|
96
|
+
ap.add_argument("--group", action="store_true",
|
|
97
|
+
help="group nodes into containers by top-level package dir")
|
|
98
|
+
ap.add_argument("--no-reduce", action="store_true",
|
|
99
|
+
help="keep every edge (skip transitive reduction)")
|
|
100
|
+
args = ap.parse_args()
|
|
101
|
+
|
|
102
|
+
modpath = module_path(args.module)
|
|
103
|
+
if not modpath:
|
|
104
|
+
sys.exit(f"error: no go.mod with a module path found in {args.module}")
|
|
105
|
+
pkgs = discover(args.module, modpath)
|
|
106
|
+
if not pkgs:
|
|
107
|
+
sys.exit(f"error: no Go packages found under {args.module}")
|
|
108
|
+
edges = sorted({(ip, t) for ip, files in pkgs.items()
|
|
109
|
+
for t in imports_of(files, modpath, pkgs) if t != ip})
|
|
110
|
+
raw = len(edges)
|
|
111
|
+
if not args.no_reduce:
|
|
112
|
+
edges = transitive_reduce(list(pkgs), edges)
|
|
113
|
+
# Drop the module prefix from labels for readability; ids stay full.
|
|
114
|
+
strip = modpath + "/"
|
|
115
|
+
label = lambda ip: ip[len(strip):] if ip.startswith(strip) else os.path.basename(ip)
|
|
116
|
+
|
|
117
|
+
def node(ip):
|
|
118
|
+
d = {"id": ip, "label": label(ip)}
|
|
119
|
+
if args.group:
|
|
120
|
+
rest = label(ip).split("/")
|
|
121
|
+
if len(rest) > 1: # nested under a sub-package
|
|
122
|
+
d["group"] = "/".join(rest[:-1]) # full sub-package path -> nested boxes
|
|
123
|
+
return d
|
|
124
|
+
|
|
125
|
+
graph = {
|
|
126
|
+
"direction": args.direction,
|
|
127
|
+
"nodes": [node(ip) for ip in pkgs],
|
|
128
|
+
"edges": [{"source": s, "target": t} for s, t in edges],
|
|
129
|
+
}
|
|
130
|
+
text = json.dumps(graph, indent=2)
|
|
131
|
+
if args.output:
|
|
132
|
+
open(args.output, "w", encoding="utf-8").write(text)
|
|
133
|
+
sys.stderr.write(f"wrote {args.output}\n")
|
|
134
|
+
else:
|
|
135
|
+
sys.stdout.write(text)
|
|
136
|
+
note = "" if args.no_reduce else f" (reduced from {raw})"
|
|
137
|
+
sys.stderr.write(f"{len(pkgs)} packages, {len(edges)} edges{note}\n")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
if __name__ == "__main__":
|
|
141
|
+
main()
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Extract a JS/TS project's module-import graph as autolayout graph JSON.
|
|
3
|
+
|
|
4
|
+
The JavaScript/TypeScript counterpart to pyimports.py: walks a source
|
|
5
|
+
directory, scans each module for static and dynamic import specifiers
|
|
6
|
+
(`import`/`export ... from`, `require()`, `import()`), keeps only the
|
|
7
|
+
intra-project edges (relative specifiers that resolve to a file under the
|
|
8
|
+
root), and applies transitive reduction so the diagram stays readable.
|
|
9
|
+
|
|
10
|
+
python3 jsimports.py src -o graph.json
|
|
11
|
+
python3 autolayout.py graph.json -o diagram.drawio
|
|
12
|
+
|
|
13
|
+
Resolution is path-based: relative specifiers are resolved against the
|
|
14
|
+
importing file's directory, trying the .ts/.tsx/.js/.jsx/.mjs/.cjs extensions
|
|
15
|
+
and directory index files. Bare specifiers (node_modules packages such as
|
|
16
|
+
"react") are ignored. Scanning is regex-based rather than a full parser, so a
|
|
17
|
+
specifier inside a comment or string literal is counted in rare cases.
|
|
18
|
+
|
|
19
|
+
Usage: python3 jsimports.py <src_dir> [-o graph.json] [--direction TB|LR]
|
|
20
|
+
[--group] [--no-reduce]
|
|
21
|
+
"""
|
|
22
|
+
import argparse
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
import subprocess
|
|
27
|
+
import sys
|
|
28
|
+
|
|
29
|
+
EXTS = (".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs")
|
|
30
|
+
SPEC = re.compile(
|
|
31
|
+
r"(?:import|export)\b[^'\";]*?\bfrom\s*['\"]([^'\"]+)['\"]" # import/export ... from "x"
|
|
32
|
+
r"|import\s*['\"]([^'\"]+)['\"]" # import "x" (side effect)
|
|
33
|
+
r"|require\s*\(\s*['\"]([^'\"]+)['\"]\s*\)" # require("x")
|
|
34
|
+
r"|import\s*\(\s*['\"]([^'\"]+)['\"]\s*\)" # import("x") dynamic
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def modid(path, root):
|
|
39
|
+
"""Module id = path relative to root, extension stripped, posix separators."""
|
|
40
|
+
rel = os.path.relpath(path, root)
|
|
41
|
+
for ext in EXTS:
|
|
42
|
+
if rel.endswith(ext):
|
|
43
|
+
rel = rel[: -len(ext)]
|
|
44
|
+
break
|
|
45
|
+
return rel.replace(os.sep, "/")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def discover(root):
|
|
49
|
+
"""Map module id -> absolute file path for every source file under root."""
|
|
50
|
+
root = os.path.abspath(root)
|
|
51
|
+
modules = {}
|
|
52
|
+
for dirpath, dirs, files in os.walk(root):
|
|
53
|
+
dirs[:] = [d for d in dirs if d != "node_modules" and not d.startswith(".")]
|
|
54
|
+
for fn in files:
|
|
55
|
+
if fn.endswith(EXTS) and not fn.endswith(".d.ts"):
|
|
56
|
+
full = os.path.join(dirpath, fn)
|
|
57
|
+
modules[modid(full, root)] = full
|
|
58
|
+
return modules, root
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def resolve(spec, importer, root, modules):
|
|
62
|
+
"""Resolve a relative specifier to a known module id, or None (external)."""
|
|
63
|
+
if not spec.startswith("."):
|
|
64
|
+
return None
|
|
65
|
+
base = os.path.normpath(os.path.join(os.path.dirname(importer), spec))
|
|
66
|
+
candidates = ([base + e for e in EXTS]
|
|
67
|
+
+ [os.path.join(base, "index" + e) for e in EXTS]
|
|
68
|
+
+ [base])
|
|
69
|
+
for cand in candidates:
|
|
70
|
+
mid = modid(cand, root)
|
|
71
|
+
if mid in modules and modules[mid] != importer:
|
|
72
|
+
return mid
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def edges_of(mid, path, root, modules):
|
|
77
|
+
"""Intra-project modules imported by module `mid`."""
|
|
78
|
+
found = set()
|
|
79
|
+
try:
|
|
80
|
+
src = open(path, encoding="utf-8", errors="ignore").read()
|
|
81
|
+
except OSError:
|
|
82
|
+
return found
|
|
83
|
+
for m in SPEC.finditer(src):
|
|
84
|
+
spec = m.group(1) or m.group(2) or m.group(3) or m.group(4)
|
|
85
|
+
target = resolve(spec, path, root, modules)
|
|
86
|
+
if target and target != mid:
|
|
87
|
+
found.add(target)
|
|
88
|
+
return found
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def transitive_reduce(nodes, edges):
|
|
92
|
+
"""Drop edges implied by a longer path, via Graphviz `tred`."""
|
|
93
|
+
idx = {n: i for i, n in enumerate(nodes)}
|
|
94
|
+
dot = "digraph{" + "".join(f"{idx[s]}->{idx[t]};" for s, t in edges) + "}"
|
|
95
|
+
try:
|
|
96
|
+
out = subprocess.run(["tred"], input=dot, capture_output=True,
|
|
97
|
+
text=True, check=True).stdout
|
|
98
|
+
except (FileNotFoundError, subprocess.CalledProcessError) as exc:
|
|
99
|
+
sys.stderr.write(f"warning: tred unavailable, keeping all edges ({exc})\n")
|
|
100
|
+
return edges
|
|
101
|
+
rev = {i: n for n, i in idx.items()}
|
|
102
|
+
return [(rev[int(a)], rev[int(b)]) for a, b in re.findall(r"(\d+)\s*->\s*(\d+)", out)]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def common_dir(ids):
|
|
106
|
+
"""Longest shared leading path segment across module ids (e.g. 'src/')."""
|
|
107
|
+
common = []
|
|
108
|
+
for parts in zip(*[m.split("/") for m in ids]):
|
|
109
|
+
if len(set(parts)) == 1:
|
|
110
|
+
common.append(parts[0])
|
|
111
|
+
else:
|
|
112
|
+
break
|
|
113
|
+
return "/".join(common) + "/" if common else ""
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def main():
|
|
117
|
+
ap = argparse.ArgumentParser(description="JS/TS import graph -> autolayout graph JSON.")
|
|
118
|
+
ap.add_argument("src", help="source directory")
|
|
119
|
+
ap.add_argument("-o", "--output", help="output JSON path (default: stdout)")
|
|
120
|
+
ap.add_argument("--direction", default="TB", choices=["TB", "LR"])
|
|
121
|
+
ap.add_argument("--group", action="store_true",
|
|
122
|
+
help="group nodes into containers by top-level directory")
|
|
123
|
+
ap.add_argument("--no-reduce", action="store_true",
|
|
124
|
+
help="keep every edge (skip transitive reduction)")
|
|
125
|
+
args = ap.parse_args()
|
|
126
|
+
|
|
127
|
+
modules, root = discover(args.src)
|
|
128
|
+
if not modules:
|
|
129
|
+
sys.exit(f"error: no JS/TS modules found under {args.src}")
|
|
130
|
+
edges = sorted({(m, t) for m, p in modules.items()
|
|
131
|
+
for t in edges_of(m, p, root, modules)})
|
|
132
|
+
raw = len(edges)
|
|
133
|
+
if not args.no_reduce:
|
|
134
|
+
edges = transitive_reduce(list(modules), edges)
|
|
135
|
+
strip = common_dir(list(modules))
|
|
136
|
+
label = lambda m: (m[len(strip):] if strip and m.startswith(strip) else m) or m
|
|
137
|
+
|
|
138
|
+
def node(m):
|
|
139
|
+
d = {"id": m, "label": label(m)}
|
|
140
|
+
if args.group:
|
|
141
|
+
rest = label(m).split("/")
|
|
142
|
+
if len(rest) > 1: # has a sub-directory
|
|
143
|
+
d["group"] = "/".join(rest[:-1]) # full directory path -> nested boxes
|
|
144
|
+
return d
|
|
145
|
+
|
|
146
|
+
graph = {
|
|
147
|
+
"direction": args.direction,
|
|
148
|
+
"nodes": [node(m) for m in modules],
|
|
149
|
+
"edges": [{"source": s, "target": t} for s, t in edges],
|
|
150
|
+
}
|
|
151
|
+
text = json.dumps(graph, indent=2)
|
|
152
|
+
if args.output:
|
|
153
|
+
open(args.output, "w", encoding="utf-8").write(text)
|
|
154
|
+
sys.stderr.write(f"wrote {args.output}\n")
|
|
155
|
+
else:
|
|
156
|
+
sys.stdout.write(text)
|
|
157
|
+
note = "" if args.no_reduce else f" (reduced from {raw})"
|
|
158
|
+
sys.stderr.write(f"{len(modules)} modules, {len(edges)} edges{note}\n")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
if __name__ == "__main__":
|
|
162
|
+
main()
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Extract a Python project's class-inheritance graph as autolayout graph JSON.
|
|
3
|
+
|
|
4
|
+
A finer-grained companion to pyimports.py: instead of module->module imports,
|
|
5
|
+
it emits one node per class and an edge from each subclass to the project base
|
|
6
|
+
classes it extends. With --group, classes are boxed by their module (nested by
|
|
7
|
+
sub-package), so the result reads as an auto-generated class hierarchy.
|
|
8
|
+
|
|
9
|
+
python3 pyclasses.py myproject --group -o graph.json
|
|
10
|
+
python3 autolayout.py graph.json -o diagram.drawio
|
|
11
|
+
|
|
12
|
+
Only inheritance is resolved (statically reliable); base classes are matched
|
|
13
|
+
by name, preferring a class in the same module. External bases (object,
|
|
14
|
+
Exception, third-party) are ignored. This is a *class structure* view, not a
|
|
15
|
+
function-level call graph — static call resolution in Python is unreliable, so
|
|
16
|
+
that is deliberately out of scope.
|
|
17
|
+
|
|
18
|
+
Usage: python3 pyclasses.py <project_dir> [-o graph.json] [--direction TB|LR]
|
|
19
|
+
[--group] [--no-reduce]
|
|
20
|
+
"""
|
|
21
|
+
import argparse
|
|
22
|
+
import ast
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
import subprocess
|
|
27
|
+
import sys
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def discover(root):
|
|
31
|
+
"""Map dotted module name -> file path; qualify with the package name when
|
|
32
|
+
root is itself a package (mirrors pyimports.py)."""
|
|
33
|
+
root = os.path.abspath(root)
|
|
34
|
+
base = os.path.basename(root) if os.path.exists(os.path.join(root, "__init__.py")) else ""
|
|
35
|
+
modules = {}
|
|
36
|
+
for dirpath, _, files in os.walk(root):
|
|
37
|
+
for fn in files:
|
|
38
|
+
if not fn.endswith(".py"):
|
|
39
|
+
continue
|
|
40
|
+
rel = os.path.relpath(os.path.join(dirpath, fn), root)[:-3]
|
|
41
|
+
parts = rel.split(os.sep)
|
|
42
|
+
if parts[-1] == "__init__":
|
|
43
|
+
parts = parts[:-1]
|
|
44
|
+
parts = ([base] + parts) if base else parts
|
|
45
|
+
if parts:
|
|
46
|
+
modules[".".join(parts)] = os.path.join(dirpath, fn)
|
|
47
|
+
return modules, base
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def base_name(node):
|
|
51
|
+
"""Simple name of a base-class expression (`Foo` or `pkg.Foo` -> 'Foo')."""
|
|
52
|
+
if isinstance(node, ast.Name):
|
|
53
|
+
return node.id
|
|
54
|
+
if isinstance(node, ast.Attribute):
|
|
55
|
+
return node.attr
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def classes_in(module, path):
|
|
60
|
+
"""Top-level classes of a module: list of (qualified_id, simple_name, [base names])."""
|
|
61
|
+
try:
|
|
62
|
+
tree = ast.parse(open(path, encoding="utf-8").read(), filename=path)
|
|
63
|
+
except SyntaxError:
|
|
64
|
+
return []
|
|
65
|
+
out = []
|
|
66
|
+
for node in tree.body:
|
|
67
|
+
if isinstance(node, ast.ClassDef):
|
|
68
|
+
bases = [b for b in (base_name(b) for b in node.bases) if b]
|
|
69
|
+
out.append((f"{module}.{node.name}", node.name, bases))
|
|
70
|
+
return out
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def transitive_reduce(nodes, edges):
|
|
74
|
+
"""Drop edges implied by a longer path, via Graphviz `tred`."""
|
|
75
|
+
idx = {n: i for i, n in enumerate(nodes)}
|
|
76
|
+
dot = "digraph{" + "".join(f"{idx[s]}->{idx[t]};" for s, t in edges) + "}"
|
|
77
|
+
try:
|
|
78
|
+
out = subprocess.run(["tred"], input=dot, capture_output=True,
|
|
79
|
+
text=True, check=True).stdout
|
|
80
|
+
except (FileNotFoundError, subprocess.CalledProcessError) as exc:
|
|
81
|
+
sys.stderr.write(f"warning: tred unavailable, keeping all edges ({exc})\n")
|
|
82
|
+
return edges
|
|
83
|
+
rev = {i: n for n, i in idx.items()}
|
|
84
|
+
return [(rev[int(a)], rev[int(b)]) for a, b in re.findall(r"(\d+)\s*->\s*(\d+)", out)]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def main():
|
|
88
|
+
ap = argparse.ArgumentParser(description="Python class-inheritance graph -> autolayout graph JSON.")
|
|
89
|
+
ap.add_argument("project", help="package or project directory")
|
|
90
|
+
ap.add_argument("-o", "--output", help="output JSON path (default: stdout)")
|
|
91
|
+
ap.add_argument("--direction", default="TB", choices=["TB", "LR"])
|
|
92
|
+
ap.add_argument("--group", action="store_true",
|
|
93
|
+
help="box classes by their module (nested by sub-package)")
|
|
94
|
+
ap.add_argument("--no-reduce", action="store_true",
|
|
95
|
+
help="keep every edge (skip transitive reduction)")
|
|
96
|
+
args = ap.parse_args()
|
|
97
|
+
|
|
98
|
+
modules, base = discover(args.project)
|
|
99
|
+
classes = {} # qualified id -> (module, bases)
|
|
100
|
+
by_name = {} # simple name -> [qualified ids]
|
|
101
|
+
for mod, path in modules.items():
|
|
102
|
+
for cid, name, bases in classes_in(mod, path):
|
|
103
|
+
classes[cid] = (mod, bases)
|
|
104
|
+
by_name.setdefault(name, []).append(cid)
|
|
105
|
+
if not classes:
|
|
106
|
+
sys.exit(f"error: no classes found under {args.project}")
|
|
107
|
+
|
|
108
|
+
def resolve(name, module):
|
|
109
|
+
cands = by_name.get(name, [])
|
|
110
|
+
same = [c for c in cands if classes[c][0] == module]
|
|
111
|
+
if same:
|
|
112
|
+
return same[0] # prefer a class in the same module
|
|
113
|
+
return cands[0] if len(cands) == 1 else None # else only if unambiguous
|
|
114
|
+
|
|
115
|
+
edges = set()
|
|
116
|
+
for cid, (mod, bases) in classes.items():
|
|
117
|
+
for b in bases:
|
|
118
|
+
target = resolve(b, mod)
|
|
119
|
+
if target and target != cid:
|
|
120
|
+
edges.add((cid, target))
|
|
121
|
+
edges = sorted(edges)
|
|
122
|
+
raw = len(edges)
|
|
123
|
+
if not args.no_reduce:
|
|
124
|
+
edges = transitive_reduce(list(classes), edges)
|
|
125
|
+
|
|
126
|
+
strip = base + "." if base else ""
|
|
127
|
+
short = lambda m: m[len(strip):] if strip and m.startswith(strip) else m
|
|
128
|
+
|
|
129
|
+
def node(cid):
|
|
130
|
+
# No hard-coded colour: autolayout tints nodes by their group (module),
|
|
131
|
+
# so a grouped class hierarchy reads as coloured-by-module.
|
|
132
|
+
d = {"id": cid, "label": cid.rsplit(".", 1)[1]}
|
|
133
|
+
if args.group:
|
|
134
|
+
mod = classes[cid][0]
|
|
135
|
+
path = short(mod).replace(".", "/") # module path -> nested boxes
|
|
136
|
+
if path:
|
|
137
|
+
d["group"] = path
|
|
138
|
+
return d
|
|
139
|
+
|
|
140
|
+
graph = {
|
|
141
|
+
"direction": args.direction,
|
|
142
|
+
"nodes": [node(cid) for cid in classes],
|
|
143
|
+
"edges": [{"source": s, "target": t} for s, t in edges],
|
|
144
|
+
}
|
|
145
|
+
text = json.dumps(graph, indent=2)
|
|
146
|
+
if args.output:
|
|
147
|
+
open(args.output, "w", encoding="utf-8").write(text)
|
|
148
|
+
sys.stderr.write(f"wrote {args.output}\n")
|
|
149
|
+
else:
|
|
150
|
+
sys.stdout.write(text)
|
|
151
|
+
note = "" if args.no_reduce else f" (reduced from {raw})"
|
|
152
|
+
sys.stderr.write(f"{len(classes)} classes, {len(edges)} inheritance edges{note}\n")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
if __name__ == "__main__":
|
|
156
|
+
main()
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Extract a Python project's module-import graph as autolayout graph JSON.
|
|
3
|
+
|
|
4
|
+
Walks a package/project directory, parses each module with `ast`, builds the
|
|
5
|
+
intra-project import edges, and (by default) applies transitive reduction so
|
|
6
|
+
the diagram stays readable instead of becoming a hairball. The output feeds
|
|
7
|
+
autolayout.py:
|
|
8
|
+
|
|
9
|
+
python3 pyimports.py myproject -o graph.json
|
|
10
|
+
python3 autolayout.py graph.json -o diagram.drawio
|
|
11
|
+
|
|
12
|
+
Transitive reduction uses Graphviz `tred` (drops edges implied by a longer
|
|
13
|
+
path); pass --no-reduce to keep every import edge. Only intra-project imports
|
|
14
|
+
are kept — third-party and stdlib imports are ignored.
|
|
15
|
+
|
|
16
|
+
Usage: python3 pyimports.py <project_dir> [-o graph.json] [--direction TB|LR] [--no-reduce]
|
|
17
|
+
"""
|
|
18
|
+
import argparse
|
|
19
|
+
import ast
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
import subprocess
|
|
24
|
+
import sys
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def discover(root):
|
|
28
|
+
"""Map dotted module name -> file path for every .py under root, plus the
|
|
29
|
+
package prefix. If root is itself a package (has __init__.py), module names
|
|
30
|
+
are qualified with its name so the project's own absolute imports resolve."""
|
|
31
|
+
root = os.path.abspath(root)
|
|
32
|
+
base = os.path.basename(root) if os.path.exists(os.path.join(root, "__init__.py")) else ""
|
|
33
|
+
modules = {}
|
|
34
|
+
for dirpath, _, files in os.walk(root):
|
|
35
|
+
for fn in files:
|
|
36
|
+
if not fn.endswith(".py"):
|
|
37
|
+
continue
|
|
38
|
+
rel = os.path.relpath(os.path.join(dirpath, fn), root)[:-3] # strip .py
|
|
39
|
+
parts = rel.split(os.sep)
|
|
40
|
+
if parts[-1] == "__init__":
|
|
41
|
+
parts = parts[:-1] # package = its dir
|
|
42
|
+
parts = ([base] + parts) if base else parts
|
|
43
|
+
if parts:
|
|
44
|
+
modules[".".join(parts)] = os.path.join(dirpath, fn)
|
|
45
|
+
return modules, base
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def resolve(name, current, modules):
|
|
49
|
+
"""Resolve a dotted name to the longest known module prefix (or None)."""
|
|
50
|
+
parts = name.split(".") if name else []
|
|
51
|
+
while parts:
|
|
52
|
+
cand = ".".join(parts)
|
|
53
|
+
if cand in modules and cand != current:
|
|
54
|
+
return cand
|
|
55
|
+
parts = parts[:-1]
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def edges_of(name, path, modules):
|
|
60
|
+
"""Intra-project modules imported by `name`."""
|
|
61
|
+
pkg = name if path.endswith("__init__.py") else name.rsplit(".", 1)[0] if "." in name else ""
|
|
62
|
+
found = set()
|
|
63
|
+
try:
|
|
64
|
+
tree = ast.parse(open(path, encoding="utf-8").read(), filename=path)
|
|
65
|
+
except SyntaxError:
|
|
66
|
+
return found
|
|
67
|
+
for node in ast.walk(tree):
|
|
68
|
+
if isinstance(node, ast.Import): # import a.b.c
|
|
69
|
+
for alias in node.names:
|
|
70
|
+
target = resolve(alias.name, name, modules)
|
|
71
|
+
if target:
|
|
72
|
+
found.add(target)
|
|
73
|
+
elif isinstance(node, ast.ImportFrom): # from a.b import c
|
|
74
|
+
if node.level: # relative: climb level-1 packages
|
|
75
|
+
base = pkg.split(".") if pkg else []
|
|
76
|
+
base = base[: len(base) - (node.level - 1)]
|
|
77
|
+
prefix = ".".join(base)
|
|
78
|
+
mod = f"{prefix}.{node.module}" if prefix and node.module else (node.module or prefix)
|
|
79
|
+
else:
|
|
80
|
+
mod = node.module or ""
|
|
81
|
+
target = resolve(mod, name, modules)
|
|
82
|
+
if target:
|
|
83
|
+
found.add(target)
|
|
84
|
+
for alias in node.names: # `from pkg import submodule`
|
|
85
|
+
sub = f"{mod}.{alias.name}" if mod else alias.name
|
|
86
|
+
target = resolve(sub, name, modules)
|
|
87
|
+
if target:
|
|
88
|
+
found.add(target)
|
|
89
|
+
return found
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def transitive_reduce(nodes, edges):
|
|
93
|
+
"""Drop edges implied by a longer path, via Graphviz `tred`."""
|
|
94
|
+
idx = {n: i for i, n in enumerate(nodes)}
|
|
95
|
+
dot = "digraph{" + "".join(f"{idx[s]}->{idx[t]};" for s, t in edges) + "}"
|
|
96
|
+
try:
|
|
97
|
+
out = subprocess.run(["tred"], input=dot, capture_output=True,
|
|
98
|
+
text=True, check=True).stdout
|
|
99
|
+
except (FileNotFoundError, subprocess.CalledProcessError) as exc:
|
|
100
|
+
sys.stderr.write(f"warning: tred unavailable, keeping all edges ({exc})\n")
|
|
101
|
+
return edges
|
|
102
|
+
rev = {i: n for n, i in idx.items()}
|
|
103
|
+
return [(rev[int(a)], rev[int(b)]) for a, b in re.findall(r"(\d+)\s*->\s*(\d+)", out)]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def main():
|
|
107
|
+
ap = argparse.ArgumentParser(description="Python import graph -> autolayout graph JSON.")
|
|
108
|
+
ap.add_argument("project", help="package or project directory")
|
|
109
|
+
ap.add_argument("-o", "--output", help="output JSON path (default: stdout)")
|
|
110
|
+
ap.add_argument("--direction", default="TB", choices=["TB", "LR"])
|
|
111
|
+
ap.add_argument("--group", action="store_true",
|
|
112
|
+
help="group nodes into containers by sub-package")
|
|
113
|
+
ap.add_argument("--no-reduce", action="store_true",
|
|
114
|
+
help="keep every edge (skip transitive reduction)")
|
|
115
|
+
args = ap.parse_args()
|
|
116
|
+
|
|
117
|
+
modules, base = discover(args.project)
|
|
118
|
+
if not modules:
|
|
119
|
+
sys.exit(f"error: no .py modules found under {args.project}")
|
|
120
|
+
edges = sorted({(name, t) for name, path in modules.items()
|
|
121
|
+
for t in edges_of(name, path, modules)})
|
|
122
|
+
raw = len(edges)
|
|
123
|
+
if not args.no_reduce:
|
|
124
|
+
edges = transitive_reduce(list(modules), edges)
|
|
125
|
+
# Drop the shared package prefix from labels for readability; ids stay full.
|
|
126
|
+
strip = base + "." if base else ""
|
|
127
|
+
label = lambda m: m[len(strip):] if strip and m.startswith(strip) else m
|
|
128
|
+
|
|
129
|
+
def node(m):
|
|
130
|
+
d = {"id": m, "label": label(m)}
|
|
131
|
+
if args.group:
|
|
132
|
+
rest = label(m).split(".")
|
|
133
|
+
if len(rest) > 1: # nested under a sub-package
|
|
134
|
+
d["group"] = "/".join(rest[:-1]) # full sub-package path -> nested boxes
|
|
135
|
+
return d
|
|
136
|
+
|
|
137
|
+
graph = {
|
|
138
|
+
"direction": args.direction,
|
|
139
|
+
"nodes": [node(m) for m in modules],
|
|
140
|
+
"edges": [{"source": s, "target": t} for s, t in edges],
|
|
141
|
+
}
|
|
142
|
+
text = json.dumps(graph, indent=2)
|
|
143
|
+
if args.output:
|
|
144
|
+
open(args.output, "w", encoding="utf-8").write(text)
|
|
145
|
+
sys.stderr.write(f"wrote {args.output}\n")
|
|
146
|
+
else:
|
|
147
|
+
sys.stdout.write(text)
|
|
148
|
+
note = "" if args.no_reduce else f" (reduced from {raw})"
|
|
149
|
+
sys.stderr.write(f"{len(modules)} modules, {len(edges)} edges{note}\n")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
if __name__ == "__main__":
|
|
153
|
+
main()
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Repair truncated IEND chunk in draw.io -e PNG exports (issue #8).
|
|
3
|
+
|
|
4
|
+
draw.io's CLI emits -e PNGs with the 4-byte IEND length field but missing
|
|
5
|
+
the 8 bytes of "IEND" type + CRC. Strict PNG decoders and vision APIs
|
|
6
|
+
(Anthropic included) reject the file with 400 "Could not process image".
|
|
7
|
+
SVG/PDF are unaffected.
|
|
8
|
+
|
|
9
|
+
Usage: python3 repair_png.py <path/to/diagram.drawio.png>
|
|
10
|
+
|
|
11
|
+
Idempotent: the endswith(IEND) guard makes this a no-op once draw.io
|
|
12
|
+
fixes the bug upstream, so it's safe to run unconditionally after every
|
|
13
|
+
-e PNG export.
|
|
14
|
+
"""
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
IEND = b"\x00\x00\x00\x00IEND\xaeB`\x82"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def repair(path: str) -> bool:
|
|
21
|
+
with open(path, "rb") as f:
|
|
22
|
+
data = f.read()
|
|
23
|
+
if data.endswith(IEND):
|
|
24
|
+
return False
|
|
25
|
+
if data.endswith(b"\x00\x00\x00\x00"):
|
|
26
|
+
data = data[:-4]
|
|
27
|
+
with open(path, "wb") as f:
|
|
28
|
+
f.write(data + IEND)
|
|
29
|
+
return True
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
if __name__ == "__main__":
|
|
33
|
+
if len(sys.argv) != 2:
|
|
34
|
+
print("usage: repair_png.py <path>", file=sys.stderr)
|
|
35
|
+
sys.exit(2)
|
|
36
|
+
if repair(sys.argv[1]):
|
|
37
|
+
print(f"repaired {sys.argv[1]}")
|