c4studio 0.2.0__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.
Files changed (45) hide show
  1. c4studio/__init__.py +28 -0
  2. c4studio/__main__.py +5 -0
  3. c4studio/cli/__init__.py +0 -0
  4. c4studio/cli/main.py +329 -0
  5. c4studio/diagnostics.py +84 -0
  6. c4studio/generators/__init__.py +4 -0
  7. c4studio/generators/flowchart.py +354 -0
  8. c4studio/generators/json_export.py +678 -0
  9. c4studio/generators/mermaid.py +169 -0
  10. c4studio/generators/mermaid_common.py +65 -0
  11. c4studio/graph/__init__.py +37 -0
  12. c4studio/graph/view_graph.py +1176 -0
  13. c4studio/icons.py +100 -0
  14. c4studio/models/__init__.py +135 -0
  15. c4studio/models/deployment.py +96 -0
  16. c4studio/models/documentation.py +63 -0
  17. c4studio/models/elements.py +141 -0
  18. c4studio/models/enums.py +152 -0
  19. c4studio/models/views.py +336 -0
  20. c4studio/models/workspace.py +226 -0
  21. c4studio/parser/__init__.py +4 -0
  22. c4studio/parser/docs.py +109 -0
  23. c4studio/parser/dsl.py +2254 -0
  24. c4studio/parser/expressions.py +310 -0
  25. c4studio/parser/implied.py +79 -0
  26. c4studio/parser/json_parser.py +726 -0
  27. c4studio/parser/locations.py +156 -0
  28. c4studio/parser/sourcemap.py +52 -0
  29. c4studio/py.typed +0 -0
  30. c4studio/render.py +146 -0
  31. c4studio/renderer/diagram-render.mjs +9757 -0
  32. c4studio/themes.py +146 -0
  33. c4studio/webapp/__init__.py +12 -0
  34. c4studio/webapp/graph.py +132 -0
  35. c4studio/webapp/loader.py +64 -0
  36. c4studio/webapp/model_graph.py +227 -0
  37. c4studio/webapp/server.py +713 -0
  38. c4studio/webapp/static/assets/index-CibNm4nc.js +68 -0
  39. c4studio/webapp/static/assets/index-Px2v-U7I.css +1 -0
  40. c4studio/webapp/static/index.html +13 -0
  41. c4studio-0.2.0.dist-info/METADATA +283 -0
  42. c4studio-0.2.0.dist-info/RECORD +45 -0
  43. c4studio-0.2.0.dist-info/WHEEL +4 -0
  44. c4studio-0.2.0.dist-info/entry_points.txt +3 -0
  45. c4studio-0.2.0.dist-info/licenses/LICENSE +21 -0
c4studio/__init__.py ADDED
@@ -0,0 +1,28 @@
1
+ """c4studio – parse Structurizr DSL/JSON and generate C4 Mermaid diagrams."""
2
+
3
+ from c4studio.models import (
4
+ Component,
5
+ Container,
6
+ Person,
7
+ Relationship,
8
+ SoftwareSystem,
9
+ View,
10
+ ViewType,
11
+ Workspace,
12
+ )
13
+ from c4studio.parser import parse_dsl, parse_json
14
+ from c4studio.generators import MermaidGenerator
15
+
16
+ __all__ = [
17
+ "parse_dsl",
18
+ "parse_json",
19
+ "MermaidGenerator",
20
+ "Workspace",
21
+ "SoftwareSystem",
22
+ "Container",
23
+ "Component",
24
+ "Person",
25
+ "Relationship",
26
+ "View",
27
+ "ViewType",
28
+ ]
c4studio/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow ``python -m c4studio`` as an alternative to the console script."""
2
+
3
+ from c4studio.cli.main import cli
4
+
5
+ cli()
File without changes
c4studio/cli/main.py ADDED
@@ -0,0 +1,329 @@
1
+ """c4studio CLI entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import click
8
+
9
+ from c4studio.diagnostics import Diagnostic, Severity
10
+ from c4studio.generators.flowchart import FlowchartGenerator
11
+ from c4studio.generators.mermaid import MermaidGenerator
12
+ from c4studio.models import Workspace
13
+ from c4studio.render import RenderError, render_view
14
+ from c4studio.webapp.graph import is_supported
15
+ from c4studio.webapp.loader import WorkspaceLoadError, load_workspace
16
+
17
+
18
+ def _load_workspace(path: Path) -> Workspace:
19
+ """Load a workspace, mapping loader errors to a CLI-friendly message."""
20
+ try:
21
+ workspace = load_workspace(path)
22
+ except WorkspaceLoadError as exc:
23
+ raise click.BadParameter(str(exc)) from exc
24
+ for warning in workspace.parse_warnings:
25
+ click.echo(f"warning: {warning}", err=True)
26
+ return workspace
27
+
28
+
29
+ @click.group()
30
+ @click.version_option()
31
+ def cli() -> None:
32
+ """c4studio – parse Structurizr files and generate C4 diagrams."""
33
+
34
+
35
+ @cli.command("generate")
36
+ @click.argument("input_file", type=click.Path(exists=True, path_type=Path))
37
+ @click.option(
38
+ "--output",
39
+ "-o",
40
+ type=click.Path(path_type=Path),
41
+ default=None,
42
+ help="Output directory (default: print to stdout).",
43
+ )
44
+ @click.option(
45
+ "--view",
46
+ "-v",
47
+ "view_key",
48
+ default=None,
49
+ help="Only generate for this view key.",
50
+ )
51
+ @click.option(
52
+ "--format",
53
+ "-f",
54
+ "fmt",
55
+ type=click.Choice(["mermaid", "flowchart"], case_sensitive=False),
56
+ default="mermaid",
57
+ show_default=True,
58
+ help=(
59
+ "mermaid: Mermaid C4 syntax (C4Context/C4Container/C4Component). "
60
+ "flowchart: Mermaid flowchart + subgraph — renders more reliably on "
61
+ "GitHub and dense models, and covers dynamic, deployment and "
62
+ "filtered views too."
63
+ ),
64
+ )
65
+ def generate(
66
+ input_file: Path, output: Path | None, view_key: str | None, fmt: str
67
+ ) -> None:
68
+ """Generate diagrams from INPUT_FILE (DSL or JSON)."""
69
+ workspace = _load_workspace(input_file)
70
+ if fmt.lower() == "flowchart":
71
+ diagrams = FlowchartGenerator(workspace).generate_all()
72
+ else:
73
+ diagrams = MermaidGenerator(workspace).generate_all()
74
+ if view_key:
75
+ if view_key not in diagrams:
76
+ available = ", ".join(diagrams) or "(none)"
77
+ raise click.ClickException(
78
+ f"View '{view_key}' not found. Available: {available}"
79
+ )
80
+ diagrams = {view_key: diagrams[view_key]}
81
+
82
+ if output is None:
83
+ for key, content in diagrams.items():
84
+ if len(diagrams) > 1:
85
+ click.echo(f"--- {key} ---")
86
+ click.echo(content)
87
+ if len(diagrams) > 1:
88
+ click.echo()
89
+ else:
90
+ output.mkdir(parents=True, exist_ok=True)
91
+ ext = ".mmd"
92
+ for key, content in diagrams.items():
93
+ out_path = output / f"{key}{ext}"
94
+ out_path.write_text(content, encoding="utf-8")
95
+ click.echo(f"Written: {out_path}")
96
+
97
+
98
+ @cli.command("render")
99
+ @click.argument("input_file", type=click.Path(exists=True, path_type=Path))
100
+ @click.option(
101
+ "--output",
102
+ "-o",
103
+ type=click.Path(path_type=Path),
104
+ default=None,
105
+ help="Output directory (default: print to stdout).",
106
+ )
107
+ @click.option(
108
+ "--view",
109
+ "-v",
110
+ "view_key",
111
+ default=None,
112
+ help="Only render this view key.",
113
+ )
114
+ @click.option(
115
+ "--padding",
116
+ type=int,
117
+ default=24,
118
+ show_default=True,
119
+ help="Blank margin around the diagram, in pixels.",
120
+ )
121
+ @click.option(
122
+ "--no-title",
123
+ is_flag=True,
124
+ default=False,
125
+ help="Omit the heading above the diagram (the SVG <title> stays).",
126
+ )
127
+ @click.option(
128
+ "--no-legend",
129
+ is_flag=True,
130
+ default=False,
131
+ help="Omit the legend of element styles used by the view.",
132
+ )
133
+ def render(
134
+ input_file: Path,
135
+ output: Path | None,
136
+ view_key: str | None,
137
+ padding: int,
138
+ no_title: bool,
139
+ no_legend: bool,
140
+ ) -> None:
141
+ """Render diagrams from INPUT_FILE as standalone SVG.
142
+
143
+ No browser and no server: the diagrams are laid out and painted by the
144
+ same code the web app runs, bundled for Node. This is the only command
145
+ that needs Node.js installed; set C4STUDIO_NODE if it is not on
146
+ PATH. Views the renderer cannot draw (custom, image) are skipped.
147
+ """
148
+ workspace = _load_workspace(input_file)
149
+ views = [v for v in workspace.views if is_supported(v)]
150
+ if view_key:
151
+ views = [v for v in views if v.key == view_key]
152
+ if not views:
153
+ available = ", ".join(v.key for v in workspace.views if is_supported(v))
154
+ raise click.ClickException(
155
+ f"View '{view_key}' not found or not renderable. "
156
+ f"Available: {available or '(none)'}"
157
+ )
158
+ if not views:
159
+ raise click.ClickException("this workspace has no renderable views")
160
+
161
+ if output is not None:
162
+ output.mkdir(parents=True, exist_ok=True)
163
+ try:
164
+ for view in views:
165
+ svg = render_view(
166
+ workspace,
167
+ view,
168
+ padding=padding,
169
+ show_title=not no_title,
170
+ show_legend=not no_legend,
171
+ )
172
+ if output is None:
173
+ click.echo(svg, nl=False)
174
+ else:
175
+ out_path = output / f"{view.key}.svg"
176
+ out_path.write_text(svg, encoding="utf-8")
177
+ click.echo(f"Written: {out_path}")
178
+ except RenderError as exc:
179
+ raise click.ClickException(str(exc)) from exc
180
+
181
+
182
+ @cli.command("export")
183
+ @click.argument("input_file", type=click.Path(exists=True, path_type=Path))
184
+ @click.option(
185
+ "--output",
186
+ "-o",
187
+ type=click.Path(path_type=Path),
188
+ default=None,
189
+ help="Output file (default: print to stdout).",
190
+ )
191
+ def export(input_file: Path, output: Path | None) -> None:
192
+ """Export INPUT_FILE (DSL or JSON) as Structurizr workspace JSON.
193
+
194
+ The output round-trips with structurizr.com, Structurizr Lite, and
195
+ this package's own JSON parser.
196
+ """
197
+ from c4studio.generators.json_export import export_json, export_json_file
198
+
199
+ workspace = _load_workspace(input_file)
200
+ if output is None:
201
+ click.echo(export_json(workspace), nl=False)
202
+ else:
203
+ if output.parent != Path(""):
204
+ output.parent.mkdir(parents=True, exist_ok=True)
205
+ export_json_file(workspace, output)
206
+ click.echo(f"Written: {output}")
207
+
208
+
209
+ @cli.command("check")
210
+ @click.argument("input_file", type=click.Path(allow_dash=True, path_type=Path))
211
+ @click.option(
212
+ "--path",
213
+ "source_path",
214
+ type=click.Path(path_type=Path),
215
+ help=(
216
+ "File the source on stdin belongs to. Diagnostics are reported "
217
+ "against it, and relative !include/!docs/!adrs targets resolve "
218
+ "next to it."
219
+ ),
220
+ )
221
+ @click.option(
222
+ "--json",
223
+ "as_json",
224
+ is_flag=True,
225
+ help="Emit diagnostics as JSON, for editors and other tools.",
226
+ )
227
+ @click.option(
228
+ "--strict",
229
+ is_flag=True,
230
+ help="Exit non-zero on warnings as well as errors.",
231
+ )
232
+ def check(
233
+ input_file: Path, source_path: Path | None, as_json: bool, strict: bool
234
+ ) -> None:
235
+ """Report problems in INPUT_FILE without generating anything.
236
+
237
+ INPUT_FILE may be ``-`` to read DSL from stdin, which is how an editor
238
+ checks a buffer that has not been saved. Pass ``--path`` with it so
239
+ diagnostics name the real file and relative directives resolve.
240
+
241
+ Exits 1 when the file has errors (or, with --strict, warnings), so it
242
+ can gate a CI job. Warnings cover DSL the parser understood but did not
243
+ implement — those constructs are skipped, and this is where that
244
+ becomes visible.
245
+ """
246
+ import json as json_module
247
+
248
+ from c4studio.parser.dsl import ParseError, parse_dsl, parse_dsl_file
249
+
250
+ diagnostics: list[Diagnostic] = []
251
+ from_stdin = str(input_file) == "-"
252
+ try:
253
+ if from_stdin:
254
+ resolved = source_path.resolve() if source_path is not None else None
255
+ workspace = parse_dsl(
256
+ click.get_text_stream("stdin").read(),
257
+ base_dir=resolved.parent if resolved is not None else None,
258
+ path=resolved,
259
+ )
260
+ elif input_file.suffix.lower() == ".json":
261
+ workspace = _load_workspace(input_file)
262
+ else:
263
+ workspace = parse_dsl_file(input_file)
264
+ diagnostics = list(workspace.diagnostics)
265
+ except ParseError as error:
266
+ # Every problem found, not just the one that stopped parsing.
267
+ diagnostics = list(error.diagnostics)
268
+
269
+ if as_json:
270
+ click.echo(json_module.dumps([d.to_dict() for d in diagnostics], indent=2))
271
+ else:
272
+ for diagnostic in diagnostics:
273
+ click.echo(f"{diagnostic.severity.value}: {diagnostic}", err=True)
274
+ if not diagnostics:
275
+ label = source_path if from_stdin and source_path else input_file
276
+ click.echo(f"{label}: no problems found")
277
+
278
+ errors = [d for d in diagnostics if d.severity is Severity.ERROR]
279
+ if errors or (strict and diagnostics):
280
+ raise SystemExit(1)
281
+
282
+
283
+ @cli.command("list-views")
284
+ @click.argument("input_file", type=click.Path(exists=True, path_type=Path))
285
+ def list_views(input_file: Path) -> None:
286
+ """List all views defined in INPUT_FILE."""
287
+ workspace = _load_workspace(input_file)
288
+ if not workspace.views:
289
+ click.echo("No views found.")
290
+ return
291
+ click.echo(f"{'Key':<30} {'Type':<20} {'Element ID'}")
292
+ click.echo("-" * 65)
293
+ for view in workspace.views:
294
+ click.echo(f"{view.key:<30} {view.type:<20} {view.element_id}")
295
+
296
+
297
+ @cli.command("webapp")
298
+ @click.argument("path", type=click.Path(exists=True, path_type=Path))
299
+ @click.option("--port", default=8090, show_default=True, help="Port to listen on.")
300
+ @click.option("--host", default="127.0.0.1", show_default=True)
301
+ @click.option(
302
+ "--no-browser",
303
+ is_flag=True,
304
+ default=False,
305
+ help="Do not open a browser window automatically.",
306
+ )
307
+ def webapp(path: Path, port: int, host: str, no_browser: bool) -> None:
308
+ """Launch the React web application backend for PATH.
309
+
310
+ PATH may be a directory (browsed as the source root) or a single source
311
+ file (loaded eagerly, with its parent directory as the root).
312
+ """
313
+ from c4studio.webapp.server import run_server
314
+
315
+ if path.is_dir():
316
+ root: Path = path
317
+ initial: Path | None = None
318
+ else:
319
+ root = path.parent
320
+ initial = path
321
+
322
+ if not no_browser:
323
+ import threading
324
+ import webbrowser
325
+
326
+ url = f"http://{host}:{port}"
327
+ threading.Timer(1.0, webbrowser.open, args=(url,)).start()
328
+
329
+ run_server(root=root, initial=initial, host=host, port=port)
@@ -0,0 +1,84 @@
1
+ """Structured parse problems, for editors and CI.
2
+
3
+ A diagnostic is a machine-readable parse problem: where it happened, how
4
+ bad it is, and what to say about it. The parser produces these instead of
5
+ formatting positions into message strings, so an editor can place a marker
6
+ and a CI job can decide whether to fail.
7
+
8
+ Positions are 1-based lines, matching how editors and compilers report
9
+ them. Columns are optional: the tokeniser tracks lines only, so a
10
+ diagnostic without a column marks the whole line.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass
16
+ from enum import Enum
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+
21
+ class Severity(str, Enum):
22
+ """How much a problem matters.
23
+
24
+ ``ERROR`` means the source could not be understood as written;
25
+ ``WARNING`` means it parsed but something was skipped or ignored, which
26
+ is the usual outcome for DSL features this parser does not implement.
27
+ """
28
+
29
+ ERROR = "error"
30
+ WARNING = "warning"
31
+
32
+
33
+ @dataclass
34
+ class Diagnostic:
35
+ """One parse problem.
36
+
37
+ Attributes:
38
+ message: Human-readable description, without position information.
39
+ severity: :class:`Severity` of the problem.
40
+ path: File the problem is in. ``None`` when parsing a bare string,
41
+ and resolved through the include source map otherwise — a
42
+ problem inside an ``!include``-ed fragment names that fragment,
43
+ not the file that included it.
44
+ line: 1-based line within ``path``.
45
+ column: 1-based start column, when known.
46
+ end_column: Exclusive 1-based end column, when known.
47
+ code: Short stable identifier for the kind of problem, so editors
48
+ and CI can filter without matching on message text.
49
+ """
50
+
51
+ message: str
52
+ severity: Severity = Severity.ERROR
53
+ path: Path | None = None
54
+ line: int | None = None
55
+ column: int | None = None
56
+ end_column: int | None = None
57
+ code: str = ""
58
+
59
+ def __str__(self) -> str:
60
+ """Render as ``path:line: message``, omitting unknown parts.
61
+
62
+ This is the form the CLI prints and the form retained in the legacy
63
+ ``Workspace.parse_warnings`` list of strings.
64
+ """
65
+ where = ""
66
+ if self.path is not None:
67
+ where = f"{self.path}:"
68
+ if self.line is not None:
69
+ where += f"{self.line}:"
70
+ elif self.line is not None:
71
+ where = f"Line {self.line}:"
72
+ return f"{where} {self.message}".strip()
73
+
74
+ def to_dict(self) -> dict[str, Any]:
75
+ """Serialise for ``c4studio check --json``."""
76
+ return {
77
+ "path": str(self.path) if self.path is not None else None,
78
+ "line": self.line,
79
+ "column": self.column,
80
+ "endColumn": self.end_column,
81
+ "severity": self.severity.value,
82
+ "code": self.code,
83
+ "message": self.message,
84
+ }
@@ -0,0 +1,4 @@
1
+ from .flowchart import FlowchartGenerator
2
+ from .mermaid import MermaidGenerator
3
+
4
+ __all__ = ["FlowchartGenerator", "MermaidGenerator"]