Repository: jdkandersson/OpenAlchemy

Issue:
Add good __str__ and __repr__
As a user when I print a model instance I want to see good information about the instance so that debugging is easier.

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 17d4c413..66b12700 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,7 @@
 
 - Remove several bugs from the generated models file and integrate with `sqlalchemy-stubs`.
 - Ring fence `black` dependency.
+- add support for `__str__` and `__repr__` for model instances.
 
 ## Version 1.1.1 - 2020-05-17
 
diff --git a/README.md b/README.md
index 8ed248d7..6fea1ef7 100644
--- a/README.md
+++ b/README.md
@@ -138,6 +138,8 @@ An example API has been defined using connexion and Flask here:
 - `from_str` model methods to construct from JSON string,
 - `from_dict` model methods to construct from dictionaries,
 - `to_str` model methods to convert instances to JSON string,
+- `__str__` model methods to support the python `str` function,
+- `__repr__` model methods to support the python `repr` function,
 - `to_dict` model methods to convert instances to dictionaries and
 - exposing created models under `open_alchemy.models` removing the need for `models.py` files.
 
diff --git a/docs/source/index.rst b/docs/source/index.rst
index 9cd39ebc..26fe842a 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -21,15 +21,15 @@ If you have the following OpenAPI specification:
     :language: yaml
     :linenos:
 
-To use SQLAlchemy to retrieve *Employees* from a database you need the
-following *models.py* file:
+To use SQLAlchemy to retrieve :samp:`Employees` from a database you need the
+following :samp:`models.py` file:
 
 .. literalinclude:: ../../examples/simple/models.py
     :language: python
     :linenos:
 
-The *Base* for the SQLAlchemy models and the *Employee* model is now available
-from *open_alchemy.models*::
+The :samp:`Base` for the SQLAlchemy models and the :samp:`Employee` model is
+now available from :samp:`open_alchemy.models`::
 
     from open_alchemy.models import Base
     from open_alchemy.models import Employee
@@ -44,83 +44,86 @@ friendly.
 
 .. _init-yaml:
 
-*init_yaml*
-^^^^^^^^^^^
+:samp:`init_yaml`
+^^^^^^^^^^^^^^^^^
 
-The *init_yaml* interface requires the *PyYAML* library to be installed. The
-*init_yaml* interface accepts the following arguments:
+The :samp:`init_yaml` interface requires the :samp:`PyYAML` library to be
+installed. The :samp:`init_yaml` interface accepts the following arguments:
 
-* *spec_filename*: The name of the file as a positional argument. The file
-  must by a YAML file.
-* *base*: The SQLAlchemy declarative base as an optional keyword only
+* :samp:`spec_filename`: The name of the file as a positional argument. The
+  file must by a YAML file.
+* :samp:`base`: The SQLAlchemy declarative base as an optional keyword only
   argument. It is used to as the base class for all SQLAlchemy models. If it
   is not passed in, a new declarative base is constructed.
-* *define_all*: Whether to pre-define the SQLAlchemy models as an optional
-  keyword only argument. If it is *True*, all schemas with the *x-tablename*
-  property are constructed as a part of the initialization. Defaults to
-  *True*.
-* *models_filename*: The name of the file where the SQLAlchemy models will be
-  written as an optional keyword only argument.
-* *spec_path*: The path to the OpenAPI specification (what would need to be
-  passed to the *open* function to read the file) as an optional keyword only
-  argument. Used to support remote references.
+* :samp:`define_all`: Whether to pre-define the SQLAlchemy models as an optional
+  keyword only argument. If it is :samp:`True`, all schemas with the
+  :samp:`x-tablename` property are constructed as a part of the initialization.
+  Defaults to :samp:`True`.
+* :samp:`models_filename`: The name of the file where the SQLAlchemy models
+  will be written as an optional keyword only argument.
+* :samp:`spec_path`: The path to the OpenAPI specification (what would need to
+  be passed to the :samp:`open` function to read the file) as an optional
+  keyword only argument. Used to support remote references.
 
 The return value is a tuple consisting of:
 
-* *Base*: The SQLAlchemy declarative based used for the models. It is also
-  importable: :python:`from open_alchemy.models import Base`.
-* *model_factory*: The factory that can be used to construct the SQLAlchemy
-  models using the name of the schema in the OpenAPI specification. All
-  constructed models are added to the *open_alchemy.models* module and are
-  importable. For example: :python:`from open_alchemy.models import Employee`.
+* :samp:`Base`: The SQLAlchemy declarative based used for the models. It is
+  also importable: :python:`from open_alchemy.models import Base`.
+* :samp:`model_factory`: The factory that can be used to construct the
+  SQLAlchemy models using the name of the schema in the OpenAPI specification.
+  All constructed models are added to the :samp:`open_alchemy.models` module
+  and are importable. For example:
+  :python:`from open_alchemy.models import Employee`.
 
 .. _init-json:
 
-*init_json*
-^^^^^^^^^^^
+:samp:`init_json`
+^^^^^^^^^^^^^^^^^
 
-The *init_json* interface is similar to the :ref:`init-yaml` interface except
-that *spec_filename* must be a JSON file and *PyYAML* is not a required
-dependency.
+The :samp:`init_json` interface is similar to the :ref:`init-yaml` interface
+except that :samp:`spec_filename` must be a JSON file and :samp:`PyYAML` is not
+a required dependency.
 
 .. _init-model-factory:
 
-*init_model_factory*
-^^^^^^^^^^^^^^^^^^^^
+:samp:`init_model_factory`
+^^^^^^^^^^^^^^^^^^^^^^^^^^
 
-The *init_model_factory* interface is less user friendly but perhaps of
+The :samp:`init_model_factory` interface is less user friendly but perhaps of
 interest to advanced users. It accepts the specification in dictionary format
 (so it has fewer dependencies than :ref:`init-yaml` and :ref:`init-json`) and
 does not construct a declarative base. It accepts the following parameters:
 
-* *base*: The SQLAlchemy declarative base as a keyword only argument. It is
-  used to as the base class for all SQLAlchemy models.
-* *spec*: The OpenAPI specification as a dictionary as a keyword only
+* :samp:`base`: The SQLAlchemy declarative base as a keyword only argument. It
+  is used to as the base class for all SQLAlchemy models.
+* :samp:`spec`: The OpenAPI specification as a dictionary as a keyword only
   argument.
-* *define_all*: Whether to pre-define the SQLAlchemy models as an optional
-  keyword only argument. If it is *True*, all schemas with the *x-tablename*
-  property are constructed as a part of the initialization. Defaults to
-  *False*.
-* *models_filename*: The name of the file where the SQLAlchemy models will be
-  written as an optional keyword only argument.
+* :samp:`define_all`: Whether to pre-define the SQLAlchemy models as an
+  optional keyword only argument. If it is :samp:`True`, all schemas with the
+  :samp:`x-tablename` property are constructed as a part of the initialization.
+  Defaults to :samp:`False`.
+* :samp:`models_filename`: The name of the file where the SQLAlchemy models
+  will be written as an optional keyword only argument.
 
-The return value is the *model_factory* as defined as part of the return value
-of :ref:`init-yaml`.
+The return value is the :samp:`model_factory` as defined as part of the return
+value of :ref:`init-yaml`.
 
 .. _models-file:
 
 Models File
 -----------
 
-*OpenAlchemy* can optionally generate a file with all the SQLAlchemy models.
-Each model is constructed based on the *OpenApi* schema. The class inherits
-from the SQLAlchemy model defined on *open_alchemy.models*. The generated
-classes contain type information only, they do not provide any additional
-functionality on top of what the SQLAlchemy model provides. They are primarily
+:samp:`OpenAlchemy` can optionally generate a file with all the
+:samp:`SQLAlchemy` models. Each model is constructed based on the
+:samp:`OpenApi` schema. The class inherits from the :samp:`SQLAlchemy` model
+defined on :samp:`open_alchemy.models`. The generated classes contain type
+information only, they do not provide any additional functionality on top of
+what the :samp:`SQLAlchemy` model provides. They are primarily
 used to enable IDE auto-complete and type hint support. The models can be used
-in the same way as the models that can be imported from *open_alchemy.models*
-and provide the full functionality of *SQLAlchemy* models. The following is a
-sample file generated for the above example:
+in the same way as the models that can be imported from
+:samp:`open_alchemy.models` and provide the full functionality of
+:samp:`SQLAlchemy` models. The following is a sample file generated for the
+above example:
 
 .. literalinclude:: ../../examples/simple/models_auto.py
     :language: python
@@ -140,13 +143,13 @@ The following information is recorded in the models file:
 
 .. _backrefs:
 
-.. note:: To be able to add relationships created by *x-backrefs* to the type
-    annotations of the models file, the schema stored alongside a model, which
-    is accessible at the *_schema* class variable (not a public interface so it
-    should not be used or relied upon), will use the *x-backrefs* extension
-    property to record the schema for all back references for the model.
-    *x-backrefs* is not a public interface and should not be relied upon as it
-    is subject to change.
+.. note:: To be able to add relationships created by :samp:`x-backrefs` to the
+    type annotations of the models file, the schema stored alongside a model,
+    which is accessible at the :samp:`_schema` class variable (not a public
+    interface so it should not be used or relied upon), will use the
+    :samp:`x-backrefs` extension property to record the schema for all back
+    references for the model. :samp:`x-backrefs` is not a public interface and
+    should not be relied upon as it is subject to change.
 
 .. _model-utilities:
 
@@ -160,12 +163,12 @@ dictionary.
 
 .. _from-dict:
 
-*from_dict*
-^^^^^^^^^^^
+:samp:`from_dict`
+^^^^^^^^^^^^^^^^^
 
-The *from_dict* function is available on all constructed models. It accepts a
-dictionary and constructs a model instance based on the dictionary. It is
-similar to :python:`Employee(**employee_dict)` with a few advantages:
+The :samp:`from_dict` function is available on all constructed models. It
+accepts a dictionary and constructs a model instance based on the dictionary.
+It is similar to :python:`Employee(**employee_dict)` with a few advantages:
 
 * The dictionary based on which the model is constructed is checked against
   the schema used to define the model.
@@ -187,20 +190,20 @@ For example::
 .. _de-ref:
 
 .. note:: To be able to support relationships, the schema stored alongside a
-    model, which is accessible at the *_schema* class variable (not a public
-    interface so it should not be used or relied upon), won't store the actual
-    schema for the referenced object. Instead, the *object* type is noted for
-    the property alongside the *x-de-$ref* extension property which stores the
-    name of the referenced model.
+    model, which is accessible at the :samp:`_schema` class variable (not a
+    public interface so it should not be used or relied upon), won't store the
+    actual schema for the referenced object. Instead, the :samp:`object` type
+    is noted for the property alongside the :samp:`x-de-$ref` extension
+    property which stores the name of the referenced model.
 
 .. _from-str:
 
-*from_str*
-^^^^^^^^^^^
+:samp:`from_str`
+^^^^^^^^^^^^^^^^
 
-The *from_str* function is available on all constructed models. It accepts a
-JSON formatted string and constructs a model instance by de-serializing the
-JSON string and then using :ref:`from-dict`. For example::
+The :samp:`from_str` function is available on all constructed models. It
+accepts a JSON formatted string and constructs a model instance by
+de-serializing the JSON string and then using :ref:`from-dict`. For example::
 
     >>> employee_str = '''{
         "id": 1,
@@ -214,13 +217,13 @@ JSON string and then using :ref:`from-dict`. For example::
 
 .. _to-dict:
 
-*to_dict*
-^^^^^^^^^
+:samp:`to_dict`
+^^^^^^^^^^^^^^^
 
-The *to_dict* function is available on all constructed models. It converts a
-model instance into a dictionary based on the schema that was used to define
-the model. If the model includes a relationship, the *to_dict* function is
-called recursively on the relationship.
+The :samp:`to_dict` function is available on all constructed models. It
+converts a model instance into a dictionary based on the schema that was used
+to define the model. If the model includes a relationship, the :samp:`to_dict`
+function is called recursively on the relationship.
 
 For example::
 
@@ -239,11 +242,11 @@ For example::
 
 .. _to-str:
 
-*to_str*
-^^^^^^^^^
+:samp:`to_str`
+^^^^^^^^^^^^^^
 
-The *to_str* function is available on all constructed models. It converts a
-model instance into a JSON formatted string by serializing the output of
+The :samp:`to_str` function is available on all constructed models. It converts
+a model instance into a JSON formatted string by serializing the output of
 :ref:`to-dict`.
 
 For example::
@@ -258,6 +261,36 @@ For example::
     >>> employee.to_str()
     '{"id": 1, "name": "David Andersson", "division": "engineering", "salary": 1000000}'
 
+.. _str:
+
+:samp:`__str__`
+^^^^^^^^^^^^^^^
+
+It is possible to convert any model instance to a string using the
+:python:`str` function. This is supported as there is a :samp:`__str__` alias
+for the :ref:`to-str` function.
+
+.. _repr:
+
+:samp:`__repr__`
+^^^^^^^^^^^^^^^^
+
+Each model includes a :samp:`__repr__` implementation to support calling
+:python:`repr` in any model instance. The returned string is the source code
+required to construct an equivalent model instance.
+
+For example::
+
+    >>> employee_dict = {
+        "id": 1,
+        "name": "David Andersson",
+        "division": "engineering",
+        "salary": 1000000,
+    }
+    >>> employee = Employee.from_dict(**employee_dict)
+    >>> repr(employee)
+    "open_alchemy.models.Employee(id=1, name='David Andersson', division='engineering', salary=1000000)"
+
 .. _alembic:
 
 Alembic
@@ -274,11 +307,12 @@ alembic is supported. The following instructions show how to get started:
 How Does It Work?
 -----------------
 
-Given a name for a schema, *OpenAlchemy* looks for that schema in the
-schemas section of the specification. The schema must have the *x-tablename*
-property which defines the name of the table. The schema is required to be an
-*object*. For each *property* of the schema, a column is generated for the
-table mapping OpenAPI types to equivalent SQLAlchemy types.
+Given a name for a schema, :samp:`OpenAlchemy` looks for that schema in the
+schemas section of the specification. The schema must have the
+:samp:`x-tablename` property which defines the name of the table. The schema is
+required to be an :samp:`object`. For each :samp:`property` of the schema, a
+column is generated for the table mapping OpenAPI types to equivalent
+SQLAlchemy types.
 
 On top of the information in the OpenAPI specification, certain extension
 properties are used to define the database schema. The following specification
@@ -336,8 +370,8 @@ the documentation:
 | :samp:`x-foreign-key-kwargs` | :ref:`Foreign Key kwargs <foreign-key-kwargs>`     |
 +------------------------------+----------------------------------------------------+
 
-The SQLAlchemy *Base* and any constructed database models are dynamically added
-to the *models* module that is available from OpenAlchemy.
+The SQLAlchemy :samp:`Base` and any constructed database models are dynamically
+added to the :samp:`models` module that is available from OpenAlchemy.
 
 Technical Details
 -----------------
diff --git a/open_alchemy/utility_base/__init__.py b/open_alchemy/utility_base/__init__.py
index 76846c7a..ab196855 100644
--- a/open_alchemy/utility_base/__init__.py
+++ b/open_alchemy/utility_base/__init__.py
@@ -9,6 +9,7 @@ from .. import facades
 from .. import helpers
 from .. import types as oa_types
 from . import from_dict
+from . import repr_
 from . import to_dict
 
 TUtilityBase = typing.TypeVar("TUtilityBase", bound="UtilityBase")
@@ -268,3 +269,10 @@ class UtilityBase:
         """
         instance_dict = self.to_dict()
         return json.dumps(instance_dict)
+
+    __str__ = to_str
+
+    def __repr__(self) -> str:
+        """Calculate the repr for the model."""
+        properties = self.get_properties()
+        return repr_.calculate(instance=self, properties=properties)
diff --git a/open_alchemy/utility_base/repr_.py b/open_alchemy/utility_base/repr_.py
new file mode 100644
index 00000000..66cb9585
--- /dev/null
+++ b/open_alchemy/utility_base/repr_.py
@@ -0,0 +1,34 @@
+"""Calculate the repr for the model."""
+
+import typing
+
+from open_alchemy import types
+
+
+def calculate(*, instance: typing.Any, properties: types.Schema) -> str:
+    """
+    Calculate the repr for the model.
+
+    The repr is the string that would be needed to create an equivalent instance of the
+    model.
+
+    Args:
+        instance: The model instance to calculate the repr for.
+        properties: The properties of the model instance.
+
+    Returns:
+        The string that would be needed to create an equivalent instance of the model.
+
+    """
+    # Calculate the name
+    name = type(instance).__name__
+
+    # Retrieve property values
+    prop_repr_gen = (
+        (prop, repr(getattr(instance, prop, None))) for prop in properties.keys()
+    )
+    prop_repr_str_gen = (f"{name}={value}" for name, value in prop_repr_gen)
+    prop_repr_str = ", ".join(prop_repr_str_gen)
+
+    # Calculate repr
+    return f"open_alchemy.models.{name}({prop_repr_str})"


Test patch:
diff --git a/tests/open_alchemy/utility_base/test_repr_.py b/tests/open_alchemy/utility_base/test_repr_.py
new file mode 100644
index 00000000..1bf3ea7b
--- /dev/null
+++ b/tests/open_alchemy/utility_base/test_repr_.py
@@ -0,0 +1,61 @@
+"""Test for the repr of the model."""
+
+from unittest import mock
+
+import pytest
+
+from open_alchemy import utility_base
+
+
+class Model:
+    """Model class for testing."""
+
+    def __init__(self):
+        """Construct."""
+        self.property_int = 1
+        self.property_str = "value 1"
+        self.property_repr = mock.MagicMock(
+            spec=["__repr__"], __repr__=lambda _: "open_alchemy.models.RefModel()"
+        )
+
+
+@pytest.mark.parametrize(
+    "properties, expected_repr",
+    [
+        pytest.param({}, "open_alchemy.models.Model()", id="no properties",),
+        pytest.param(
+            {"property_not_def": {}},
+            "open_alchemy.models.Model(property_not_def=None)",
+            id="single property property simple no value",
+        ),
+        pytest.param(
+            {"property_int": {}},
+            "open_alchemy.models.Model(property_int=1)",
+            id="single property property simple value",
+        ),
+        pytest.param(
+            {"property_repr": {}},
+            "open_alchemy.models.Model(property_repr=open_alchemy.models.RefModel())",
+            id="single property property repr",
+        ),
+        pytest.param(
+            {"property_int": {}, "property_str": {}},
+            "open_alchemy.models.Model(property_int=1, property_str='value 1')",
+            id="multiple property property",
+        ),
+    ],
+)
+@pytest.mark.utility_base
+def test_calculate(properties, expected_repr):
+    """
+    GIVEN model instance, properties and expected repr result
+    WHEN calculate is called on the instance
+    THEN the expected repr is returned.
+    """
+    instance = Model()
+
+    returned_repr = utility_base.repr_.calculate(
+        instance=instance, properties=properties
+    )
+
+    assert returned_repr == expected_repr
diff --git a/tests/open_alchemy/utility_base/test_to_dict.py b/tests/open_alchemy/utility_base/test_to_dict.py
index 51713a44..da1e676b 100644
--- a/tests/open_alchemy/utility_base/test_to_dict.py
+++ b/tests/open_alchemy/utility_base/test_to_dict.py
@@ -172,7 +172,7 @@ def test_to_str(__init__):
     THEN the JSON representation of the properties is returned.
     """
     model = type(
-        "model",
+        "Model",
         (utility_base.UtilityBase,),
         {
             "_schema": {"properties": {"key_1": {"type": "integer"}}},
@@ -184,3 +184,5 @@ def test_to_str(__init__):
     returned_str = instance.to_str()
 
     assert returned_str == '{"key_1": 1}'
+    assert str(instance) == '{"key_1": 1}'
+    assert repr(instance) == "open_alchemy.models.Model(key_1=1)"