Repository: dr-leo/pandaSDMX (python)

Issue:
Pretty-print Message subclasses
Per @dr-leo, identified in discussion of #72:

`Message` and subclasses have attributes that may be empty. For instance:
https://github.com/dr-leo/pandaSDMX/blob/a670198f45f90777d26b5b83c8d9e171b266ff6b/pandasdmx/message.py#L65-L73
…if there is no OrganisationScheme provided in a particular StructureMessage, then the `.organisation_scheme` dictionary is empty.

In order to provide a good user experience for interactive use, override `__str__` and/or `__repr__` on these classes to print something like this (inspired by xarray):
```
StructureMessage:
     Header: [some header info]
     Dataflows (12): 'DATAFLOW1' 'DATAFLOW2' 'DATAFLOW' ...
     Codelists (3): 'CL_FOO' 'CL_BAR' 'CL_BAZ'
```
This shows, at a glance:
- which attributes contain actual (meta)data,
- the number of objects in each, and
- a quick visual indication of some of the IDs.

Pull request description:
Pretty-print Message subclasses
Closes #79.

Patch:
diff --git a/pandasdmx/message.py b/pandasdmx/message.py
index b3490d5..10ed986 100644
--- a/pandasdmx/message.py
+++ b/pandasdmx/message.py
@@ -24,10 +24,19 @@ from pandasdmx.model import (
     Item,
     ProvisionAgreement,
     )
-from pandasdmx.util import BaseModel, DictLike
+from pandasdmx.util import BaseModel, DictLike, summarize_dictlike
 from requests import Response
 
 
+def _summarize(obj, fields):
+    """Helper method for __repr__ on Header and Message (sub)classes."""
+    for name in fields:
+        attr = getattr(obj, name)
+        if attr is None:
+            continue
+        yield f'{name}: {attr!r}'
+
+
 class Header(BaseModel):
     error: Text = None
     id: Text = None
@@ -35,6 +44,12 @@ class Header(BaseModel):
     receiver: Text = None
     sender: Union[Item, Text] = None
 
+    def __repr__(self):
+        """String representation."""
+        lines = ['<Header>']
+        lines.extend(_summarize(self, self.__fields__.keys()))
+        return '\n  '.join(lines)
+
 
 class Footer(BaseModel):
     severity: Text
@@ -57,6 +72,18 @@ class Message(BaseModel):
     #: :class:`requests.Response` instance.
     response: Response = None
 
+    def __str__(self):
+        return repr(self)
+
+    def __repr__(self):
+        """String representation."""
+        lines = [
+            f'<pandasdmx.{self.__class__.__name__}>',
+            repr(self.header).replace('\n', '\n  '),
+        ]
+        lines.extend(_summarize(self, ['footer', 'response']))
+        return '\n  '.join(lines)
+
 
 class ErrorMessage(Message):
     pass
@@ -72,6 +99,19 @@ class StructureMessage(Message):
     organisation_scheme: DictLike[str, AgencyScheme] = DictLike()
     provisionagreement: DictLike[str, ProvisionAgreement] = DictLike()
 
+    def __repr__(self):
+        """String representation."""
+        lines = [super().__repr__()]
+
+        # StructureMessage contents
+        for name in dir(self):
+            attr = getattr(self, name)
+            if not isinstance(attr, DictLike) or len(attr) == 0:
+                continue
+            lines.append(summarize_dictlike(attr))
+
+        return '\n  '.join(lines)
+
 
 class DataMessage(Message):
     #: :class:`list` of :class:`pandasdmx.model.DataSet`
@@ -88,3 +128,14 @@ class DataMessage(Message):
     def structure(self):
         """The DataStructureDefinition used in the DataMessage.dataflow."""
         return self.dataflow.structure
+
+    def __repr__(self):
+        """String representation."""
+        lines = [super().__repr__()]
+
+        # DataMessage contents
+        if len(self.data):
+            lines.append('DataSet ({})'.format(len(self.data)))
+        lines.extend(_summarize(self, ('dataflow', 'observation_dimension')))
+
+        return '\n  '.join(lines)
diff --git a/pandasdmx/util.py b/pandasdmx/util.py
index 6480758..58dd1e1 100644
--- a/pandasdmx/util.py
+++ b/pandasdmx/util.py
@@ -162,6 +162,20 @@ class DictLike(OrderedDict[KT, VT]):
             return result
 
 
+def summarize_dictlike(dl, maxwidth=72):
+    """Return a string summary of the DictLike contents."""
+    value_cls = dl[0].__class__.__name__
+    count = len(dl)
+    keys = ' '.join(dl.keys())
+    result = f'{value_cls} ({count}): {keys}'
+
+    if len(result) > maxwidth:
+        # Truncate the list of keys
+        result = result[:maxwidth - 3] + '...'
+
+    return result
+
+
 def validate_dictlike(*fields):
     def decorator(cls):
         v = make_generic_validator(DictLike.validate)


Test patch:
diff --git a/tests/test_message.py b/tests/test_message.py
new file mode 100644
index 0000000..17b1067
--- /dev/null
+++ b/tests/test_message.py
@@ -0,0 +1,55 @@
+import pandasdmx as sdmx
+
+from . import specimen
+
+
+EXPECTED = {
+    # Structure messages
+    'insee-IPI-2010-A21-datastructure.xml': """<pandasdmx.StructureMessage>
+  <Header>
+    id: 'categorisation_1450864290565'
+    prepared: '2015-12-23T09:51:30.565Z'
+    receiver: 'unknown'
+    sender: 'FR1'
+  CategoryScheme (1): CLASSEMENT_DATAFLOWS
+  Codelist (3): CL_FREQ CL_NAF2_A21 CL_NATURE
+  ConceptScheme (1): CONCEPTS_INSEE
+  DataflowDefinition (1): IPI-2010-A21
+  DataStructureDefinition (1): IPI-2010-A21""",
+
+    # This message shows the summarization feature: the DFD list is truncated
+    'insee-dataflow.xml': """<pandasdmx.StructureMessage>
+  <Header>
+    id: 'dataflow_ENQ-CONJ-TRES-IND-PERSP_1450865196042'
+    prepared: '2015-12-23T10:06:36.042Z'
+    receiver: 'unknown'
+    sender: 'FR1'
+  DataflowDefinition (663): ACT-TRIM-ANC BPM6-CCAPITAL BPM6-CFINANCIER ...""",
+
+    # Data message
+    'ecb_exr_sg_xs.xml': """<pandasdmx.DataMessage>
+  <Header>
+    id: 'Generic'
+    prepared: '2010-01-04T16:21:49+01:00'
+    sender: 'ECB'
+  DataSet (1)
+  dataflow: <DataflowDefinition: 'STR1'=''>
+  observation_dimension: [<Dimension: CURRENCY>]""",
+
+    # This message has two DataSets:
+    'exr-action-delete.json': """<pandasdmx.DataMessage>
+  <Header>
+    id: '62b5f19d-f1c9-495d-8446-a3661ed24753'
+    prepared: '2012-11-29T08:40:26Z'
+    sender: <Item: 'ECB'='European Central Bank'>
+  DataSet (2)
+  dataflow: <DataflowDefinition: 'None'=''>
+  observation_dimension: [<Dimension: CURRENCY>]""",
+}
+
+
+def test_message_repr():
+    for pattern, result in EXPECTED.items():
+        with specimen(pattern) as f:
+            msg = sdmx.open_file(f)
+        assert str(msg) == result