crate_diagram.py 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246
  1. #!/usr/bin/env python3
  2. # Copyright Materialize, Inc. and contributors. All rights reserved.
  3. #
  4. # Use of this software is governed by the Business Source License
  5. # included in the LICENSE file at the root of this repository.
  6. #
  7. # As of the Change Date specified in that file, in accordance with
  8. # the Business Source License, use of this software will be governed
  9. # by the Apache License, Version 2.0.
  10. """
  11. Generate a dependency graph of our local crates
  12. We have hundreds of crates in our dependency tree, so visualizing the full set of dependencies is
  13. fairly useless (but can be done with crates like cargo-graph).
  14. This script just prints the relationship of our internal crates to help see how things fit into the
  15. materialize ecosystem.
  16. """
  17. import subprocess
  18. import webbrowser
  19. from collections import defaultdict
  20. from pathlib import Path
  21. from tempfile import NamedTemporaryFile
  22. from typing import IO, Any
  23. import click
  24. import toml
  25. from materialize import MZ_ROOT, spawn
  26. DepBuilder = defaultdict[str, list[str]]
  27. DepMap = dict[str, list[str]]
  28. def split_list(items: str) -> list[str]:
  29. if items:
  30. return items.split(",")
  31. return []
  32. @click.command(context_settings=dict(help_option_names=["-h", "--help"]))
  33. @click.option(
  34. "--roots",
  35. default="",
  36. type=split_list,
  37. help="Only include these crates and their dependencies.",
  38. )
  39. @click.option(
  40. "--show/--no-show",
  41. default=True,
  42. help="Wheather or not to immediatly show the generated diagram",
  43. )
  44. @click.option(
  45. "--diagram-file",
  46. default=None,
  47. help="The diagram file to generate. Default is 'crates{roots}.svg'",
  48. )
  49. def main(show: bool, diagram_file: str | None, roots: list[str]) -> None:
  50. if diagram_file is None:
  51. if roots:
  52. diagram_file = "crates-{}.svg".format("-".join(sorted(roots)))
  53. else:
  54. diagram_file = "crates.svg"
  55. root_cargo = MZ_ROOT / "Cargo.toml"
  56. with root_cargo.open() as fh:
  57. data = toml.load(fh)
  58. members = set()
  59. areas: defaultdict[str, list[str]] = defaultdict(list)
  60. member_meta = {}
  61. all_deps = {}
  62. for member_path in data["workspace"]["members"]:
  63. path = MZ_ROOT / member_path / "Cargo.toml"
  64. with path.open() as fh:
  65. member = toml.load(fh)
  66. has_bin = any(MZ_ROOT.joinpath(member_path).glob("src/**/main.rs"))
  67. name = member["package"]["name"]
  68. member_meta[name] = {
  69. "has_bin": has_bin,
  70. "description": member["package"].get("description", name),
  71. }
  72. area = member_path.split("/")[0]
  73. areas[area].append(name)
  74. members.add(name)
  75. all_deps[name] = [dep for dep in member.get("dependencies", [])]
  76. # timely is "local" but not in our repo
  77. members.add("timely")
  78. members.add("differential-dataflow")
  79. local_deps: DepMap = {
  80. dep_name: [dep for dep in dep_deps if dep in members]
  81. for dep_name, dep_deps in all_deps.items()
  82. }
  83. local_deps["differential-dataflow"] = []
  84. local_deps["timely"] = []
  85. areas["timely"] = ["differential-dataflow", "timely"]
  86. member_meta["differential-dataflow"] = {"has_bin": False, "description": ""}
  87. member_meta["timely"] = {"has_bin": False, "description": ""}
  88. if roots:
  89. (local_deps, areas) = filter_to_roots(areas, local_deps, roots)
  90. diagram_file_path = MZ_ROOT / diagram_file
  91. with NamedTemporaryFile(mode="w+", prefix="mz-arch-diagram-") as out:
  92. write_dot_graph(member_meta, local_deps, areas, out)
  93. cmd = ["dot", "-Tsvg", "-o", str(diagram_file_path), out.name]
  94. try:
  95. spawn.runv(cmd)
  96. except subprocess.CalledProcessError:
  97. out.seek(0)
  98. debug = "/tmp/debug.gv"
  99. with open(debug, "w") as fh:
  100. fh.write(out.read())
  101. print(f"ERROR running dot, source in {debug}")
  102. except FileNotFoundError as e:
  103. raise click.ClickException(
  104. f"This script requires the dot program (part of the graphviz package): {e}"
  105. )
  106. add_hover_style(diagram_file)
  107. if show:
  108. uri = f"file:///{diagram_file}"
  109. webbrowser.open(uri)
  110. def filter_to_roots(
  111. areas: DepBuilder, local_deps: DepMap, roots: list[str]
  112. ) -> tuple[DepMap, DepBuilder]:
  113. new_deps = defaultdict(set)
  114. try:
  115. add_deps(local_deps, new_deps, roots)
  116. except KeyError as e:
  117. raise click.ClickException(f"Unknown crate {e}")
  118. new_dep_map: DepMap = {root: list(deps) for root, deps in new_deps.items()}
  119. filtered_crates = set()
  120. for root, deps in new_deps.items():
  121. filtered_crates.add(root)
  122. filtered_crates.update(deps)
  123. new_areas = defaultdict(list)
  124. for area, children in areas.items():
  125. for child in children:
  126. if child in filtered_crates:
  127. new_areas[area].append(child)
  128. return (new_dep_map, new_areas)
  129. def add_deps(
  130. deps: DepMap, new_deps: defaultdict[str, set[str]], roots: list[str]
  131. ) -> None:
  132. for root in roots:
  133. for dep in deps[root]:
  134. new_deps[root].add(dep)
  135. add_deps(deps, new_deps, deps[root])
  136. def write_dot_graph(
  137. member_meta: dict[str, dict[str, str]],
  138. local_deps: DepMap,
  139. areas: dict[str, list[str]],
  140. out: IO,
  141. ) -> None:
  142. def disp(val: str, out: IO = out, **kwargs: Any) -> None:
  143. print(val, file=out, **kwargs)
  144. disp("digraph packages {")
  145. for area, members in areas.items():
  146. disp(f" subgraph cluster_{area} " "{")
  147. disp(f' label = "/{area}";')
  148. disp(" color = blue;")
  149. for member in members:
  150. description = member_meta[member]["description"]
  151. disp(f' "{member}" [tooltip="{description}"', end="")
  152. if member_meta[member]["has_bin"]:
  153. disp(",shape=Mdiamond,color=red", end="")
  154. disp("];")
  155. disp(" }")
  156. for package, deps in local_deps.items():
  157. for dep in deps:
  158. disp(
  159. f' "{package}" -> "{dep}" [edgetooltip="{package} -> {dep}",URL="none"',
  160. end="",
  161. )
  162. if dep in ("timely", "differential-dataflow"):
  163. disp("color=green,style=dashed", end="")
  164. disp("];")
  165. disp("}")
  166. out.flush()
  167. def add_hover_style(diagram_file: Path | str) -> None:
  168. found_svg = False
  169. with open(diagram_file) as fh:
  170. lines = fh.readlines()
  171. for i, line in enumerate(lines):
  172. if "<svg" in line:
  173. found_svg = True
  174. if found_svg and ">" in line:
  175. lines.insert(i + 1, HOVER_STYLE)
  176. break
  177. with open(diagram_file, "w") as fh:
  178. fh.write("".join(lines))
  179. HOVER_STYLE = """\
  180. <style>
  181. /* edge lines */
  182. .edge:active path,
  183. .edge:hover path {
  184. stroke: fuchsia;
  185. stroke-width: 3;
  186. stroke-opacity: 1;
  187. }
  188. /* edge finishing arrows */
  189. .edge:active polygon,
  190. .edge:hover polygon {
  191. stroke: fuchsia;
  192. stroke-width: 3;
  193. fill: fuchsia;
  194. stroke-opacity: 1;
  195. fill-opacity: 1;
  196. }
  197. /* edge decoration text */
  198. .edge:active text,
  199. .edge:hover text {
  200. fill: fuchsia;
  201. }
  202. </style>
  203. """
  204. if __name__ == "__main__":
  205. main()