swagger2drawio 1.0.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.
@@ -0,0 +1,3 @@
1
+ """swagger2drawio — Generate Draw.io diagrams from OpenAPI/Swagger specifications."""
2
+
3
+ __version__ = "1.0.0"
@@ -0,0 +1,73 @@
1
+ """Logging configuration for the CLI.
2
+
3
+ A single :func:`configure` entrypoint sets up the root ``swagger2drawio``
4
+ logger so that all module-level loggers obtained via
5
+ ``logging.getLogger(__name__)`` honour the user's ``-v / -vv / -q /
6
+ --log-file`` choices.
7
+
8
+ Console output goes through Rich's :class:`~rich.logging.RichHandler`
9
+ (colourised, with the module path). File output (when ``--log-file`` is
10
+ set) uses a plain text formatter so the log is grep-friendly.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ from pathlib import Path
17
+
18
+ from rich.logging import RichHandler
19
+
20
+ _ROOT_LOGGER = "swagger2drawio"
21
+
22
+
23
+ def configure(verbosity: int = 0, quiet: bool = False, log_file: Path | None = None) -> None:
24
+ """Configure the package's root logger.
25
+
26
+ Args:
27
+ verbosity: ``0`` for default (WARNING+ on stderr), ``1`` for
28
+ INFO, ``2+`` for DEBUG.
29
+ quiet: When ``True``, force the console level to WARNING+ even
30
+ if *verbosity* is non-zero.
31
+ log_file: Optional path. When supplied, DEBUG-and-up messages
32
+ are appended to this file regardless of console verbosity.
33
+ """
34
+ root = logging.getLogger(_ROOT_LOGGER)
35
+ # Reset on every call so repeated CLI invocations in the same
36
+ # process (e.g. inside tests) don't stack handlers.
37
+ for handler in list(root.handlers):
38
+ root.removeHandler(handler)
39
+
40
+ # ── Console handler ──────────────────────────────────────
41
+ if quiet:
42
+ console_level = logging.WARNING
43
+ elif verbosity >= 2:
44
+ console_level = logging.DEBUG
45
+ elif verbosity == 1:
46
+ console_level = logging.INFO
47
+ else:
48
+ console_level = logging.WARNING
49
+
50
+ console = RichHandler(
51
+ rich_tracebacks=True,
52
+ show_path=False,
53
+ markup=False,
54
+ show_time=False,
55
+ show_level=True,
56
+ )
57
+ console.setLevel(console_level)
58
+ console.setFormatter(logging.Formatter("%(message)s"))
59
+ root.addHandler(console)
60
+
61
+ # ── File handler (always DEBUG) ──────────────────────────
62
+ if log_file is not None:
63
+ log_file.parent.mkdir(parents=True, exist_ok=True)
64
+ file_handler = logging.FileHandler(log_file, encoding="utf-8")
65
+ file_handler.setLevel(logging.DEBUG)
66
+ file_handler.setFormatter(
67
+ logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s")
68
+ )
69
+ root.addHandler(file_handler)
70
+
71
+ # Root level is the minimum so handlers can filter upward.
72
+ root.setLevel(min(console_level, logging.DEBUG if log_file else console_level))
73
+ root.propagate = False
swagger2drawio/cli.py ADDED
@@ -0,0 +1,464 @@
1
+ """Command-line interface for swagger2drawio.
2
+
3
+ This module defines the Typer application and exposes the ``generate``
4
+ command that drives the full pipeline:
5
+
6
+ 1. Load theme / layout configuration (YAML).
7
+ 2. Parse an OpenAPI specification (file or URL).
8
+ 3. Build NetworkX graphs & compute layout coordinates.
9
+ 4. Render the final ``.drawio`` file via *drawpyo*.
10
+
11
+ Usage examples::
12
+
13
+ swagger2drawio generate --input openapi.yaml --output api.drawio
14
+ swagger2drawio generate --input https://petstore3.swagger.io/api/v3/openapi.json
15
+ swagger2drawio generate --input spec.yaml --config my_theme.yaml
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+ from pathlib import Path
22
+
23
+ import typer
24
+
25
+ from swagger2drawio import __version__
26
+ from swagger2drawio._logging import configure as configure_logging
27
+ from swagger2drawio.config_loader import (
28
+ list_builtin_themes,
29
+ load_config,
30
+ read_theme,
31
+ )
32
+ from swagger2drawio.exceptions import EXIT_CODE, Swagger2DrawioError
33
+ from swagger2drawio.layout.graph_engine import build_endpoint_graph, build_schema_graph
34
+ from swagger2drawio.parser.openapi_parser import OpenAPIParser
35
+ from swagger2drawio.renderer.drawio_builder import render
36
+
37
+ logger = logging.getLogger("swagger2drawio.cli")
38
+
39
+ # ── Typer app ────────────────────────────────────────────────
40
+ app = typer.Typer(
41
+ name="swagger2drawio",
42
+ help="Generate structured Draw.io diagrams from OpenAPI / Swagger specs.",
43
+ add_completion=True,
44
+ )
45
+
46
+
47
+ def _version_callback(value: bool) -> None:
48
+ """Print version and exit when ``--version`` is passed.
49
+
50
+ Args:
51
+ value: ``True`` when the flag is supplied.
52
+
53
+ Raises:
54
+ typer.Exit: Always exits after printing.
55
+ """
56
+ if value:
57
+ typer.echo(f"swagger2drawio {__version__}")
58
+ raise typer.Exit()
59
+
60
+
61
+ @app.callback()
62
+ def main(
63
+ version: bool | None = typer.Option(
64
+ None,
65
+ "--version",
66
+ help="Show the application version and exit.",
67
+ callback=_version_callback,
68
+ is_eager=True,
69
+ ),
70
+ verbose: int = typer.Option(
71
+ 0,
72
+ "--verbose",
73
+ "-v",
74
+ count=True,
75
+ help="Increase log verbosity (-v INFO, -vv DEBUG).",
76
+ ),
77
+ quiet: bool = typer.Option(
78
+ False,
79
+ "--quiet",
80
+ "-q",
81
+ help="Quiet mode: only WARNING and above.",
82
+ ),
83
+ log_file: Path | None = typer.Option(
84
+ None,
85
+ "--log-file",
86
+ help="Append a DEBUG-level log to this file (in addition to console output).",
87
+ ),
88
+ ) -> None:
89
+ """swagger2drawio — OpenAPI → Draw.io diagram generator."""
90
+ configure_logging(verbosity=verbose, quiet=quiet, log_file=log_file)
91
+
92
+
93
+ @app.command()
94
+ def generate(
95
+ input_source: str = typer.Option(
96
+ ...,
97
+ "--input",
98
+ "-i",
99
+ help="Path or URL to the OpenAPI/Swagger spec (JSON or YAML).",
100
+ ),
101
+ output: Path = typer.Option(
102
+ Path("output.drawio"),
103
+ "--output",
104
+ "-o",
105
+ help="Destination path for the generated .drawio file.",
106
+ ),
107
+ config: Path | None = typer.Option(
108
+ None,
109
+ "--config",
110
+ "-c",
111
+ help="Path to an optional YAML theme/config file.",
112
+ ),
113
+ theme: str | None = typer.Option(
114
+ None,
115
+ "--theme",
116
+ help="Name of a bundled theme (see `swagger2drawio themes list`).",
117
+ ),
118
+ no_validate: bool = typer.Option(
119
+ False,
120
+ "--no-validate",
121
+ help="Skip OpenAPI / Swagger schema validation before rendering.",
122
+ ),
123
+ timeout: float = typer.Option(
124
+ 10.0,
125
+ "--timeout",
126
+ help="HTTP timeout in seconds when --input is a URL.",
127
+ ),
128
+ header: list[str] = typer.Option(
129
+ None,
130
+ "--header",
131
+ "-H",
132
+ help="Extra HTTP header for URL input (repeatable, format: 'Name: value').",
133
+ ),
134
+ insecure: bool = typer.Option(
135
+ False,
136
+ "--insecure",
137
+ help="Skip TLS verification (self-signed certs). Use with care.",
138
+ ),
139
+ group_by: str = typer.Option(
140
+ "auto",
141
+ "--group-by",
142
+ help="Endpoint grouping: 'tag', 'path', or 'auto' (tag if any tags exist).",
143
+ ),
144
+ layout: str = typer.Option(
145
+ "hierarchical",
146
+ "--layout",
147
+ help="Layout strategy for the endpoint graph (e.g. 'hierarchical', 'grid').",
148
+ ),
149
+ ) -> None:
150
+ """Parse an OpenAPI spec and generate a Draw.io diagram.
151
+
152
+ The command performs the following pipeline:
153
+
154
+ 1. **Config** — Loads the theme/layout YAML (falls back to built-in
155
+ defaults when *--config* is not supplied).
156
+ 2. **Parse** — Reads the OpenAPI spec from a local file **or** a
157
+ remote URL, extracting paths/methods and component schemas.
158
+ 3. **Layout** — Builds in-memory NetworkX graphs and computes (x, y)
159
+ coordinates using the *dot* algorithm.
160
+ 4. **Render** — Writes a ``.drawio`` XML file with two pages:
161
+ *Endpoints Map* and *Schema (ER) Map*.
162
+
163
+ Args:
164
+ input_source: A local file path **or** an HTTP(S) URL pointing
165
+ to a valid OpenAPI 3.x / Swagger 2.x specification.
166
+ output: The filesystem path where the ``.drawio`` file will be
167
+ written. Defaults to ``output.drawio`` in the current
168
+ working directory.
169
+ config: Optional path to a YAML file that overrides the default
170
+ visual theme (colours, node sizes, spacing, etc.).
171
+ theme: Optional name of a bundled theme (see
172
+ ``swagger2drawio themes list``). Stacked under ``config`` so
173
+ an explicit ``--config`` file can override individual theme
174
+ keys.
175
+ no_validate: When ``True``, skip the OpenAPI / Swagger schema
176
+ validation step. Useful when the spec is known-noncompliant
177
+ but you still want a diagram.
178
+ timeout: HTTP request timeout in seconds when ``input_source``
179
+ is a URL.
180
+ header: Repeatable list of ``"Name: value"`` HTTP headers for
181
+ URL input (e.g. ``--header "Authorization: Bearer ..."``).
182
+ insecure: When ``True``, skip TLS certificate verification.
183
+ group_by: How to group operations on the endpoints page.
184
+ ``"tag"`` roots the tree at API tags; ``"path"`` keeps the
185
+ flat structure; ``"auto"`` picks tag when any operation has
186
+ a tag, falling back to path.
187
+ layout: Name of the layout strategy. Built-ins are
188
+ ``"hierarchical"`` (the default BFS tree placement) and
189
+ ``"grid"`` (one row per weakly-connected component).
190
+ Third-party strategies registered via the entry-point
191
+ group ``swagger2drawio.layouts`` are also discoverable.
192
+ """
193
+ try:
194
+ if insecure:
195
+ typer.secho(
196
+ "⚠ TLS verification disabled (--insecure). Don't use against untrusted hosts.",
197
+ fg=typer.colors.YELLOW,
198
+ err=True,
199
+ )
200
+
201
+ parsed_headers = _parse_headers(header)
202
+
203
+ from rich.progress import (
204
+ BarColumn,
205
+ Progress,
206
+ SpinnerColumn,
207
+ TextColumn,
208
+ TimeElapsedColumn,
209
+ )
210
+
211
+ with Progress(
212
+ SpinnerColumn(),
213
+ TextColumn("[bold]{task.description}"),
214
+ BarColumn(bar_width=20),
215
+ TimeElapsedColumn(),
216
+ transient=False,
217
+ ) as progress:
218
+ task = progress.add_task("Loading configuration", total=4)
219
+ cfg = load_config(config, theme=theme)
220
+
221
+ progress.update(
222
+ task,
223
+ advance=1,
224
+ description="Parsing & validating spec" if not no_validate else "Parsing spec",
225
+ )
226
+ spec_data = OpenAPIParser(
227
+ source=input_source,
228
+ timeout=timeout,
229
+ headers=parsed_headers,
230
+ verify_ssl=not insecure,
231
+ ).parse(validate=not no_validate)
232
+
233
+ progress.update(task, advance=1, description="Computing layout")
234
+ layout_cfg = cfg.get("layout", {})
235
+ nw = layout_cfg.get("node_width", 200)
236
+ nh = layout_cfg.get("node_height", 60)
237
+ hs = layout_cfg.get("horizontal_spacing", 80)
238
+ vs = layout_cfg.get("vertical_spacing", 120)
239
+ rd = layout_cfg.get("rankdir", "TB")
240
+
241
+ ep_layout = build_endpoint_graph(
242
+ spec_data,
243
+ node_width=nw,
244
+ node_height=nh,
245
+ h_spacing=hs,
246
+ v_spacing=vs,
247
+ rankdir=rd,
248
+ group_by=group_by,
249
+ layout=layout,
250
+ )
251
+ sc_layout = build_schema_graph(
252
+ spec_data,
253
+ node_width=nw,
254
+ node_height=nh,
255
+ h_spacing=hs * 1.5,
256
+ v_spacing=vs * 1.3,
257
+ )
258
+
259
+ progress.update(task, advance=1, description=f"Rendering → {output.name}")
260
+ render(ep_layout, sc_layout, output, cfg, node_width=nw, node_height=nh)
261
+ progress.update(task, advance=1, description="Done")
262
+
263
+ # ── Final summary (after the live progress bar has closed) ──
264
+ endpoint_count = sum(len(p.methods) for p in spec_data.paths)
265
+ schema_count = len(spec_data.schemas)
266
+ size_bytes = output.stat().st_size if output.exists() else 0
267
+ size_kb = size_bytes / 1024
268
+ typer.secho(
269
+ f"✔ {output} written ({size_kb:.1f} KB) — "
270
+ f"{len(spec_data.paths)} path(s), {endpoint_count} method(s), "
271
+ f"{schema_count} schema(s).",
272
+ fg=typer.colors.GREEN,
273
+ )
274
+ except Swagger2DrawioError as exc:
275
+ _abort(exc)
276
+
277
+
278
+ @app.command()
279
+ def diff(
280
+ old: str = typer.Argument(..., metavar="OLD", help="Path or URL to the old spec."),
281
+ new: str = typer.Argument(..., metavar="NEW", help="Path or URL to the new spec."),
282
+ output: Path = typer.Option(
283
+ Path("changes.drawio"),
284
+ "--output",
285
+ "-o",
286
+ help="Destination .drawio file for the rendered diff.",
287
+ ),
288
+ ) -> None:
289
+ """Compare two specs and emit a colour-coded ``.drawio`` change report.
290
+
291
+ Args:
292
+ old: Path or URL to the earlier spec.
293
+ new: Path or URL to the later spec.
294
+ output: Destination ``.drawio`` file.
295
+ """
296
+ from swagger2drawio.diff import compute_diff
297
+ from swagger2drawio.renderer.diff_renderer import render_diff
298
+
299
+ try:
300
+ typer.echo(f"⏳ Parsing OLD spec: {old}")
301
+ old_parsed = OpenAPIParser(source=old).parse(validate=False)
302
+ typer.echo(f"⏳ Parsing NEW spec: {new}")
303
+ new_parsed = OpenAPIParser(source=new).parse(validate=False)
304
+
305
+ typer.echo("⏳ Computing diff …")
306
+ result = compute_diff(old_parsed, new_parsed)
307
+
308
+ if result.is_empty:
309
+ typer.secho("✔ No structural changes detected.", fg=typer.colors.GREEN)
310
+ else:
311
+ typer.echo(f" paths +{len(result.paths_added)} -{len(result.paths_removed)}")
312
+ typer.echo(
313
+ f" methods +{len(result.methods_added)} -{len(result.methods_removed)} "
314
+ f"~{len(result.methods_changed)}"
315
+ )
316
+ typer.echo(
317
+ f" schemas +{len(result.schemas_added)} -{len(result.schemas_removed)} "
318
+ f"~{len(result.schemas_changed)}"
319
+ )
320
+
321
+ typer.echo(f"⏳ Rendering diff → {output}")
322
+ render_diff(old_parsed, new_parsed, result, output)
323
+ typer.secho(f"✔ Diff diagram saved to {output}", fg=typer.colors.GREEN)
324
+ except Swagger2DrawioError as exc:
325
+ _abort(exc)
326
+
327
+
328
+ @app.command()
329
+ def validate(
330
+ input_source: str = typer.Argument(
331
+ ...,
332
+ metavar="SPEC",
333
+ help="Path or URL to the OpenAPI/Swagger spec to validate.",
334
+ ),
335
+ ) -> None:
336
+ """Validate an OpenAPI / Swagger spec without rendering anything.
337
+
338
+ Exits 0 when valid; otherwise prints the validator's message and
339
+ exits with the SpecValidationError exit code so CI scripts can react.
340
+
341
+ Args:
342
+ input_source: Local file path or HTTP(S) URL to the spec.
343
+ """
344
+ from swagger2drawio.parser.openapi_parser import _load_raw_spec
345
+ from swagger2drawio.validator import validate_spec
346
+
347
+ try:
348
+ typer.echo(f"⏳ Validating: {input_source}")
349
+ raw = _load_raw_spec(input_source)
350
+ validate_spec(raw)
351
+ except Swagger2DrawioError as exc:
352
+ _abort(exc)
353
+ else:
354
+ typer.secho("✔ Spec is valid.", fg=typer.colors.GREEN)
355
+
356
+
357
+ # ── themes sub-app ───────────────────────────────────────────
358
+
359
+
360
+ themes_app = typer.Typer(name="themes", help="List or print bundled themes.", no_args_is_help=True)
361
+ app.add_typer(themes_app, name="themes")
362
+
363
+
364
+ @themes_app.command("list")
365
+ def themes_list() -> None:
366
+ """Print every bundled theme name, one per line."""
367
+ for name in list_builtin_themes():
368
+ typer.echo(name)
369
+
370
+
371
+ @themes_app.command("show")
372
+ def themes_show(name: str = typer.Argument(..., help="Theme name (e.g. 'dark').")) -> None:
373
+ """Print the resolved YAML of one bundled theme.
374
+
375
+ Args:
376
+ name: Theme name (without ``.yaml``).
377
+ """
378
+ try:
379
+ data = read_theme(name)
380
+ except Swagger2DrawioError as exc:
381
+ _abort(exc)
382
+ import yaml as _yaml
383
+
384
+ typer.echo(_yaml.safe_dump(data, sort_keys=False))
385
+
386
+
387
+ # ── init subcommand ──────────────────────────────────────────
388
+
389
+
390
+ @app.command()
391
+ def init(
392
+ theme: str = typer.Option(
393
+ "light",
394
+ "--theme",
395
+ help="Bundled theme to scaffold from (light / dark / pastel / high-contrast).",
396
+ ),
397
+ force: bool = typer.Option(False, "--force", help="Overwrite an existing config file."),
398
+ ) -> None:
399
+ """Scaffold a ``.swagger2drawio.yaml`` in the current directory.
400
+
401
+ Args:
402
+ theme: Name of the bundled theme to copy as the starter file.
403
+ force: Overwrite an existing file instead of refusing.
404
+ """
405
+ import yaml as _yaml
406
+
407
+ target = Path.cwd() / ".swagger2drawio.yaml"
408
+ if target.exists() and not force:
409
+ typer.secho(
410
+ f"✖ {target.name} already exists; pass --force to overwrite.",
411
+ fg=typer.colors.RED,
412
+ err=True,
413
+ )
414
+ raise typer.Exit(code=1)
415
+ try:
416
+ data = read_theme(theme)
417
+ except Swagger2DrawioError as exc:
418
+ _abort(exc)
419
+ target.write_text(_yaml.safe_dump(data, sort_keys=False), encoding="utf-8")
420
+ typer.secho(f"✔ Wrote {target}", fg=typer.colors.GREEN)
421
+
422
+
423
+ def _parse_headers(raw: list[str] | None) -> dict[str, str] | None:
424
+ """Convert a list of ``"Name: value"`` strings into a header dict.
425
+
426
+ Args:
427
+ raw: List of strings as collected from ``--header / -H`` (may be
428
+ ``None`` if the flag was not passed).
429
+
430
+ Returns:
431
+ ``None`` when no headers were supplied; otherwise a dict mapping
432
+ header name to value.
433
+
434
+ Raises:
435
+ typer.BadParameter: If any entry is missing the ``:`` separator.
436
+ """
437
+ if not raw:
438
+ return None
439
+ parsed: dict[str, str] = {}
440
+ for entry in raw:
441
+ if ":" not in entry:
442
+ raise typer.BadParameter(f"Invalid --header value: {entry!r}. Expected 'Name: value'.")
443
+ name, _, value = entry.partition(":")
444
+ parsed[name.strip()] = value.strip()
445
+ return parsed
446
+
447
+
448
+ def _abort(exc: Swagger2DrawioError) -> None:
449
+ """Print a clean error and exit with the code mapped to *exc*'s class.
450
+
451
+ Args:
452
+ exc: The typed exception to report.
453
+
454
+ Raises:
455
+ typer.Exit: Always raised with the appropriate code.
456
+ """
457
+ code = EXIT_CODE.get(type(exc), EXIT_CODE[Swagger2DrawioError])
458
+ typer.secho(f"✖ {type(exc).__name__}: {exc}", fg=typer.colors.RED, err=True)
459
+ raise typer.Exit(code=code) from exc
460
+
461
+
462
+ # Allow ``python -m swagger2drawio.cli`` execution.
463
+ if __name__ == "__main__":
464
+ app()