Repository: eralchemy/eralchemy

Issue:
Does python package support in-memory output?
## Description
The `render_er` method does not seem to support in-memory buffers (e.g., io.BytesIO). This is inconvenient in use cases where e.g., generating an ER diagram and sending it over a network without saving a file is desired.

Is there currently a way to output the ER diagram directly into memory, either as a byte array or as a python object?

Anyways, thank you for this excellent package!

Discussion:
@maurerle: Hi, this is due to graphviz not supporting this..

To be consistent, all the conversion methods behave the same..
We could simply remove graphviz support as done here:
https://github.com/eralchemy/eralchemy/compare/main...refactor_function_call

As pygraphviz supports writing to out, however the graphviz library works a lot smoother than pygraphviz (e.g. does not require graphviz-dev), so removing it is not an ideal solution either..@jacksonjacobs1: I see - thank you for your response.

It sounds like breaking compatibility with graphviz is not desirable, but I wonder if it will be inevitable if future versions of eralchemy require more fine-grained control over the graph data structure.

Additionally from a user perspective, actually having fine grained control of the graphviz graph object may be desired.

Thoughts?@maurerle: There are also other half-done PRs about more configuration for Graphviz options.
I did not yet decide if the best way to tackle this is to just have more args from the cli, an additional config object or only provide access if called as function.
The problem from the User-Perspective there is, that not all outputs support these options, so I am open for ideas there.

Regarding the graphviz issue, I created a PR at their end to support writing to stdout: https://github.com/xflr6/graphviz/pull/234
But I am also not too happy with all the edge cases there..

Solution patch:
diff --git a/eralchemy/helpers.py b/eralchemy/helpers.py
index a9af915..5bd9441 100644
--- a/eralchemy/helpers.py
+++ b/eralchemy/helpers.py
@@ -22,8 +22,6 @@ def check_args(args: Namespace) -> None:
         return
     if args.i is None:
         fail("Cannot draw ER diagram of no database.")
-    if args.o is None:
-        fail("Cannot draw ER diagram with no output file.")
 
 
 def check_args_has_attributes(args: Namespace) -> None:
diff --git a/eralchemy/main.py b/eralchemy/main.py
index e7408e3..9182966 100644
--- a/eralchemy/main.py
+++ b/eralchemy/main.py
@@ -2,8 +2,11 @@ import argparse
 import base64
 import copy
 import logging
+import os
 import re
 import sys
+import typing
+from functools import partial
 from importlib.metadata import PackageNotFoundError, version
 
 from sqlalchemy.engine.url import make_url
@@ -53,7 +56,7 @@ def cli(args=None) -> None:
     if args.v:
         print(f"eralchemy version {__version__}.")
         exit(0)
-    render_er(
+    output = render_er(
         args.i,
         args.o,
         args.m or "auto",
@@ -64,6 +67,10 @@ def cli(args=None) -> None:
         exclude_columns=args.exclude_columns,
         schema=args.s,
     )
+    if output:
+        with os.fdopen(sys.stdout.fileno(), "wb", closefd=False) as stdout:
+            stdout.write(output)
+            stdout.flush()
 
 
 def get_argparser() -> argparse.ArgumentParser:
@@ -102,18 +109,17 @@ def get_argparser() -> argparse.ArgumentParser:
     return parser
 
 
-def intermediary_to_markdown(tables, relationships, output, title=""):
+def intermediary_to_markdown(tables, relationships, title=""):
     """Saves the intermediary representation to markdown."""
     er_markup = _intermediary_to_markdown(tables, relationships)
     if title:
         er_markup_with_config = f"{ER_FORMAT_TITLE.format(title)}\n{er_markup}"
     else:
         er_markup_with_config = er_markup
-    with open(output, "w") as file_out:
-        file_out.write(er_markup_with_config)
+    return er_markup_with_config
 
 
-def intermediary_to_mermaid(tables, relationships, output, title=""):
+def intermediary_to_mermaid(tables, relationships, title=""):
     """Saves the intermediary representation to markdown."""
     markup = _intermediary_to_mermaid(tables, relationships)
     if title:
@@ -125,11 +131,10 @@ title: {title}
     md_markup = f"<!--\n\n{markup}\n\n-->\n"
     markup_b64 = base64.urlsafe_b64encode(markup.encode("utf8")).decode("ascii")
     md_markup += f"![](https://mermaid.ink/img/{markup_b64})\n"
-    with open(output, "w") as file_out:
-        file_out.write(md_markup)
+    return md_markup.encode()
 
 
-def intermediary_to_mermaid_er(tables, relationships, output, title=""):
+def intermediary_to_mermaid_er(tables, relationships, title=""):
     """Saves the intermediary representation to markdown."""
     markup = _intermediary_to_mermaid_er(tables, relationships)
     if title:
@@ -141,31 +146,27 @@ title: {title}
     md_markup = f"<!--\n\n{markup}\n\n-->\n"
     markup_b64 = base64.urlsafe_b64encode(markup.encode("utf8")).decode("ascii")
     md_markup += f"![](https://mermaid.ink/img/{markup_b64})\n"
-    with open(output, "w") as file_out:
-        file_out.write(md_markup)
+    return md_markup.encode()
 
 
-def intermediary_to_dot(tables, relationships, output, title=""):
+def intermediary_to_dot(tables, relationships, title=""):
     """Save the intermediary representation to dot format."""
     dot_file = _intermediary_to_dot(tables, relationships, title)
-    with open(output, "w") as file_out:
-        file_out.write(dot_file)
+    return dot_file.encode()
 
 
-def intermediary_to_schema(tables, relationships, output, title=""):
+def intermediary_to_schema(tables, relationships, title="", extension="png"):
     """Transforms and save the intermediary representation to the file chosen."""
-    if not GRAPHVIZ_AVAILABLE:
-        raise Exception("neither graphviz or pygraphviz are available. Install either library!")
     dot_file = _intermediary_to_dot(tables, relationships, title)
-    extension = output.split(".")[-1]
+    if not GRAPHVIZ_AVAILABLE:
+        raise Exception("either pygraphviz or graphviz should be installed")
     if USE_PYGRAPHVIZ:
         graph = AGraph()
         graph = graph.from_string(dot_file)
-        graph.draw(path=output, prog="dot", format=extension)
+        return graph.draw(prog="dot", format=extension)
     else:
-        graph = Source(dot_file, engine="dot", format=extension)
-        graph.render(outfile=output, cleanup=True)
-    return graph
+        graph = Source(dot_file, engine="dot")
+        return graph.pipe(format=extension)
 
 
 def _intermediary_to_markdown(tables, relationships):
@@ -223,6 +224,7 @@ switch_output_mode_auto = {
     "mermaid": intermediary_to_mermaid,
     "mermaid_er": intermediary_to_mermaid_er,
     "graph": intermediary_to_schema,
+    "graph_svg": partial(intermediary_to_schema, extension="svg"),
     "dot": intermediary_to_dot,
 }
 
@@ -269,7 +271,7 @@ def all_to_intermediary(filename_or_input, schema=None):
         raise ValueError(f"Cannot process filename_or_input {input_class_name}: {e}")
 
 
-def get_output_mode(output, mode):
+def get_output_mode(output: typing.Union[str, None], mode: str):
     """From the output name and the mode returns a the function that will transform the intermediary representation to the output."""
     if mode != "auto":
         try:
@@ -277,11 +279,14 @@ def get_output_mode(output, mode):
         except KeyError:
             raise ValueError(f'Mode "{mode}" is not supported.')
 
+    if output is None:
+        raise ValueError("Mode must be set if output file is None")
+
     extension = output.split(".")[-1]
     try:
         return switch_output_mode[extension]
     except KeyError:
-        return intermediary_to_schema
+        return partial(intermediary_to_schema, extension=extension)
 
 
 def filter_resources(
@@ -351,7 +356,7 @@ def filter_resources(
 
 def render_er(
     input,
-    output,
+    output: typing.Union[str, None],
     mode="auto",
     include_tables=None,
     include_columns=None,
@@ -365,7 +370,8 @@ def render_er(
     :param input: Possible inputs are instances of:
         MetaData: SQLAlchemy Metadata
         DeclarativeMeta: SQLAlchemy declarative Base
-    :param output: name of the file to output the
+    :param output: name of the file to output the rendered graph to.
+        Returns text as str if set to None
     :param mode: str in list:
         'er': writes to a file the markup to generate an ER style diagram.
         'graph': writes the image of the ER diagram.
@@ -395,14 +401,21 @@ def render_er(
             exclude_columns=exclude_columns,
         )
         intermediary_to_output = get_output_mode(output, mode)
-        return intermediary_to_output(tables, relationships, output, title)
+        text = intermediary_to_output(tables, relationships, title)
+        # graphviz does not yet support printing to stdout
+        # but writes directly to the output file
+        # https://github.com/xflr6/graphviz/pull/234
+        if output is None:
+            return text
+        with open(output, "wb") as file_out:
+            file_out.write(text)
     except ImportError as e:
-        module_name = e.message.split()[-1]
-        print(f'Please install {module_name} using "pip install {module_name}".')
+        module_name = str(e).split()[-1]
+        print(f'Please install {module_name} using "pip install {module_name}".', file=sys.stderr)
     except (FileNotFoundError, ValueError) as e:
-        print(f"{e}")
+        print(f"rendering failed: {e}", file=sys.stderr)
     except ParsingException as e:
-        sys.stderr.write(e.message)
+        print(f"render failed: {e}", file=sys.stderr)
 
 
 if __name__ == "__main__":


Test patch:
diff --git a/tests/test_main.py b/tests/test_main.py
index 2631e95..1e9b068 100644
--- a/tests/test_main.py
+++ b/tests/test_main.py
@@ -124,7 +124,8 @@ def test_filter_exclude_columns(exclude_columns):
 
 
 def test_get_output_mode():
-    assert get_output_mode("hello.png", "auto") == intermediary_to_schema
+    # access .func for partial
+    assert get_output_mode("hello.png", "auto").func == intermediary_to_schema
     assert get_output_mode("hello.er", "auto") == intermediary_to_markdown
     assert get_output_mode("hello.dot", "auto") == intermediary_to_dot
     assert get_output_mode("hello.md", "auto") == intermediary_to_mermaid