From 0a67f0ea91320959b8049a5e040a158a6441129d Mon Sep 17 00:00:00 2001 From: Cedrik Neumann <7921017+m1racoli@users.noreply.github.com> Date: Fri, 9 May 2025 19:33:04 -0600 Subject: [PATCH] feat(scripts): provide `diagrams` CLI (#524) * feat(scripts): provide `diagrams` CLI This change addresses https://github.com/mingrammer/diagrams/issues/369 while providing a simple CLI entry point which can be used outside the virtual environment of the installed package. This works well with for example with [pipx](https://pipxproject.github.io/pipx/) or [uv](https://docs.astral.sh/uv/). * feat(scripts): add docstring and tests * feat(scripts): Fix pre-commit error * feat(scripts): Fix pre-commit error 2nd try --------- Co-authored-by: tessier --- diagrams/cli.py | 38 +++++++++++ docs/getting-started/installation.md | 11 ++++ pyproject.toml | 3 + tests/test_cli.py | 95 ++++++++++++++++++++++++++++ 4 files changed, 147 insertions(+) create mode 100644 diagrams/cli.py create mode 100644 tests/test_cli.py diff --git a/diagrams/cli.py b/diagrams/cli.py new file mode 100644 index 00000000..c75eb3d9 --- /dev/null +++ b/diagrams/cli.py @@ -0,0 +1,38 @@ +import argparse +import sys + + +def run() -> int: + """ + Run diagrams code files in a diagrams environment. + Args: + paths: A list of paths to Python files containing diagrams code. + + Returns: + The exit code. + """ + parser = argparse.ArgumentParser( + description="Run diagrams code files in a diagrams environment.", + ) + parser.add_argument( + "paths", + metavar="path", + type=str, + nargs="+", + help="a Python file containing diagrams code", + ) + args = parser.parse_args() + + for path in args.paths: + with open(path, encoding='utf-8') as f: + exec(f.read()) + + return 0 + + +def main(): + sys.exit(run()) + + +if __name__ == "__main__": + main() diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 2cfbb54e..cdbc86f8 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -20,6 +20,9 @@ $ pipenv install diagrams # using poetry $ poetry add diagrams + +# using uv +$ uv tool install diagrams ``` ## Quick Start @@ -47,6 +50,14 @@ This generates the diagram below: It will be saved as `web_service.png` in your working directory. +### CLI + +With the `diagrams` CLI you can process one or more diagram files at once. + +```shell +$ diagrams diagram1.py diagram2.py +``` + ## Next See more [Examples](/docs/getting-started/examples) or see the [Guides](/docs/guides/diagram) page for more details. diff --git a/pyproject.toml b/pyproject.toml index 7e9b68b5..3e169152 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,9 @@ homepage = "https://diagrams.mingrammer.com" repository = "https://github.com/mingrammer/diagrams" include = ["resources/**/*"] +[tool.poetry.scripts] +diagrams="diagrams.cli:main" + [tool.poetry.dependencies] python = "^3.9" graphviz = ">=0.13.2,<0.21.0" diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 00000000..77759f59 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,95 @@ +import os +import unittest +from io import StringIO +from unittest.mock import mock_open, patch + +from diagrams.cli import run + + +class CliTest(unittest.TestCase): + def setUp(self): + self.test_file = "test_diagram.py" + # dummy content for the test file + self.test_content_1 = """ +from diagrams import Diagram +with Diagram(name="Test", show=False): + pass +""" + # content from getting started examples with utf-8 + # only support the installed fonts defined in Dockerfile + self.test_content_2 = """ +from diagrams import Diagram +from diagrams.aws.compute import EC2 +from diagrams.aws.database import RDS +from diagrams.aws.network import ELB + +with Diagram("test_2", show=False, direction="TB"): + ELB("lb") >> [EC2("ワーカー1"), + EC2("작업자 2를"), + EC2("робітник 3"), + EC2("worker4"), + EC2("työntekijä 4")] >> RDS("events") +""" + + def tearDown(self): + try: + os.remove("test.png") + except FileNotFoundError: + pass + + def test_run_with_valid_file(self): + # write the test file + with open(self.test_file, "w") as f: + f.write(self.test_content_1) + with patch("sys.argv", ["diagrams", self.test_file]): + exit_code = run() + self.assertEqual(exit_code, 0) + try: + os.remove(self.test_file) + except FileNotFoundError: + pass + + def test_run_with_multiple_files(self): + + multiple_files = ["file1.py", "file2.py"] + + # write the code files + with open("file1.py", "w") as f: + f.write(self.test_content_1) + with open("file2.py", "w") as f: + f.write(self.test_content_2) + + with patch("sys.argv", ["diagrams"] + multiple_files): + exit_code = run() + self.assertEqual(exit_code, 0) + + # cleanup code file + for one_file in multiple_files: + try: + os.remove(one_file) + except FileNotFoundError: + pass + # cleanup generated image + try: + os.remove("test_2.png") + except FileNotFoundError: + pass + + def test_run_with_no_arguments(self): + with patch("sys.argv", ["diagrams"]): + with patch("sys.stderr", new=StringIO()) as fake_stderr: + with self.assertRaises(SystemExit): + run() + self.assertIn("the following arguments are required: path", fake_stderr.getvalue()) + + def test_run_with_nonexistent_file(self): + with patch("sys.argv", ["diagrams", "nonexistent.py"]): + with self.assertRaises(FileNotFoundError): + run() + + def test_run_with_invalid_python_code(self): + invalid_content = "this is not valid python code" + with patch("builtins.open", mock_open(read_data=invalid_content)): + with patch("sys.argv", ["diagrams", self.test_file]): + with self.assertRaises(SyntaxError): + run()