Repository: mido/mido

Issue:
proper __repr__ for MidiFile, etc.
Per Python docs, `__repr__()` output "should normally look like a valid Python expression that can be used to recreate an object with the same value".

Currently for MidiFile, repr() gives the same result as str(), which is just a summary including number of tracks, etc.  Likewise MidiTrack gives a summary with number of messages.

One place a proper repr() would be immediately useful is for tests, e.g. to confirm that code changes don't alter generated MIDI messages, tracks, or files.  It would also facilitate viewing differences between MIDI data in text form.

With a proper repr(), it would also be trivial to implement MidiFile `__eq__` (see #150).

Discussion:

Patch:
diff --git a/mido/frozen.py b/mido/frozen.py
index 2c04f1f..20d629d 100644
--- a/mido/frozen.py
+++ b/mido/frozen.py
@@ -3,10 +3,15 @@ from .midifiles import MetaMessage, UnknownMetaMessage
 
 
 class Frozen(object):
-    def __repr__(self):
-        text = super(Frozen, self).__repr__()
+    def __str__(self):
+        text = super(Frozen, self).__str__()
         return '<frozen {}'.format(text[1:])
 
+    def __repr__(self):
+        # canonicalize to mutable objects
+        r = super(Frozen, self).__repr__()
+        return r[len('Frozen'):]
+
     def __setattr__(self, *_):
         raise ValueError('frozen message is immutable')
 
diff --git a/mido/messages/messages.py b/mido/messages/messages.py
index f8f7e36..f1387d4 100644
--- a/mido/messages/messages.py
+++ b/mido/messages/messages.py
@@ -54,6 +54,15 @@ class BaseMessage(object):
         """
         return cl(**data)
 
+    def __repr__(self):
+        d = self.dict()
+        msg_type = d.pop('type')
+        items = getattr(d, 'iteritems', d.items)
+        return "%s('%s', %s)" % (
+            type(self).__name__,
+            msg_type,
+            ', '.join('%s=%s' % item for item in items()))
+
     @property
     def is_realtime(self):
         """True if the message is a system realtime message."""
@@ -162,9 +171,6 @@ class Message(BaseMessage):
     def __str__(self):
         return msg2str(vars(self))
 
-    def __repr__(self):
-        return '<message {}>'.format(str(self))
-
     def _setattr(self, name, value):
         if name == 'type':
             raise AttributeError('type attribute is read only')
diff --git a/mido/midifiles/meta.py b/mido/midifiles/meta.py
index be9552c..8b73da0 100644
--- a/mido/midifiles/meta.py
+++ b/mido/midifiles/meta.py
@@ -535,7 +535,7 @@ class MetaMessage(BaseMessage):
                 encode_variable_int(len(data)) +
                 data)
 
-    def __repr__(self):
+    def __str__(self):
         spec = _META_SPEC_BY_TYPE[self.type]
         attributes = []
         for name in spec.attributes:
@@ -561,7 +561,7 @@ class UnknownMetaMessage(MetaMessage):
             'data': data,
             'time': time})
 
-    def __repr__(self):
+    def __str__(self):
         return ('<unknown meta message'
                 ' type_byte=0x{:02x} '
                 'data={!r} time={}>').format(self.type_byte,
@@ -569,6 +569,11 @@ class UnknownMetaMessage(MetaMessage):
                                              self.time
                                              )
 
+    def __repr__(self):
+        # fix message type artifact
+        r = super(UnknownMetaMessage, self).__repr__()
+        return r.replace("'unknown_meta', ", '')
+
     def __setattr__(self, name, value):
         # This doesn't do any checking.
         # It probably should.
diff --git a/mido/midifiles/midifiles.py b/mido/midifiles/midifiles.py
index bb885a2..f3db7cb 100644
--- a/mido/midifiles/midifiles.py
+++ b/mido/midifiles/midifiles.py
@@ -293,7 +293,8 @@ class MidiFile(object):
                  type=1, ticks_per_beat=DEFAULT_TICKS_PER_BEAT,
                  charset='latin1',
                  debug=False,
-                 clip=False
+                 clip=False,
+                 tracks=None
                  ):
 
         self.filename = filename
@@ -309,7 +310,9 @@ class MidiFile(object):
             raise ValueError(
                 'invalid format {} (must be 0, 1 or 2)'.format(format))
 
-        if file is not None:
+        if tracks is not None:
+            self.tracks = tracks
+        elif file is not None:
             self._load(file)
         elif self.filename is not None:
             with io.open(filename, 'rb') as file:
@@ -461,11 +464,18 @@ class MidiFile(object):
                 else:
                     print('{!r}'.format(msg))
 
-    def __repr__(self):
+    def __str__(self):
         return '<midi file {!r} type {}, {} tracks, {} messages>'.format(
             self.filename, self.type, len(self.tracks),
             sum([len(track) for track in self.tracks]))
 
+    def __repr__(self):
+        tracks_str = ',\n'.join(repr(track) for track in self.tracks)
+        tracks_str = '\n'.join('  ' + line for line in tracks_str.splitlines())
+        tracks_str = (', tracks=[\n%s\n]' % tracks_str) if self.tracks else ''
+        return 'MidiFile(type=%s, ticks_per_beat=%s%s)' % (
+            self.type, self.ticks_per_beat, tracks_str)
+
     # The context manager has no purpose but is kept around since it was
     # used in examples in the past.
     def __enter__(self):
diff --git a/mido/midifiles/tracks.py b/mido/midifiles/tracks.py
index 59ad1df..6140688 100644
--- a/mido/midifiles/tracks.py
+++ b/mido/midifiles/tracks.py
@@ -50,9 +50,16 @@ class MidiTrack(list):
     def __mul__(self, other):
         return self.__class__(list.__mul__(self, other))
 
-    def __repr__(self):
+    def __str__(self):
         return '<midi track {!r} {} messages>'.format(self.name, len(self))
 
+    def __repr__(self):
+        messages = ''
+        if len(self) > 0:
+            template = '[\n  %s]' if len(self) > 1 else '[%s]'
+            messages = template % ',\n  '.join(repr(m) for m in self)
+        return 'MidiTrack(%s)' % messages
+
 
 def _to_abstime(messages):
     """Convert messages to absolute time."""


Test patch:
diff --git a/tests/messages/test_messages.py b/tests/messages/test_messages.py
index a834fbd..1b076c0 100644
--- a/tests/messages/test_messages.py
+++ b/tests/messages/test_messages.py
@@ -108,3 +108,9 @@ def test_dict_sysex_data():
 def test_from_hex_sysex_data_type():
     msg = Message.from_hex('F0 01 02 03 F7')
     assert isinstance(msg.data, SysexData)
+
+
+def test_repr():
+    msg = Message('note_on', channel=1, note=2, time=3)
+    msg_eval = eval(repr(msg))
+    assert msg == msg_eval
diff --git a/tests/midifiles/test_meta.py b/tests/midifiles/test_meta.py
index 3ea3c71..4ac6145 100644
--- a/tests/midifiles/test_meta.py
+++ b/tests/midifiles/test_meta.py
@@ -1,5 +1,5 @@
 import pytest
-from mido.midifiles.meta import MetaMessage, MetaSpec_key_signature, KeySignatureError
+from mido.midifiles.meta import MetaMessage, UnknownMetaMessage, MetaSpec_key_signature, KeySignatureError
 
 
 def test_copy_invalid_argument():
@@ -30,3 +30,13 @@ class TestKeySignature:
         msg = MetaMessage('key_signature')
         MetaSpec_key_signature().decode(msg, input_bytes)
         assert msg.key == expect_sig
+
+def test_meta_message_repr():
+    msg = MetaMessage('end_of_track', time=10)
+    msg_eval = eval(repr(msg))
+    assert msg == msg_eval
+
+def test_unknown_meta_message_repr():
+    msg = UnknownMetaMessage(type_byte=99, data=[1, 2], time=10)
+    msg_eval = eval(repr(msg))
+    assert msg == msg_eval
diff --git a/tests/midifiles/test_midifiles.py b/tests/midifiles/test_midifiles.py
index 8231e45..a579ed7 100644
--- a/tests/midifiles/test_midifiles.py
+++ b/tests/midifiles/test_midifiles.py
@@ -1,7 +1,7 @@
 import io
 from pytest import raises
 from mido.messages import Message
-from mido.midifiles.midifiles import MidiFile
+from mido.midifiles.midifiles import MidiFile, MidiTrack
 from mido.midifiles.meta import MetaMessage, KeySignatureError
 
 HEADER_ONE_TRACK = """
@@ -163,3 +163,19 @@ def test_meta_messages_with_length_0():
 
         MetaMessage('end_of_track'),
     ]
+
+
+def test_midifile_repr():
+    midifile = MidiFile(type=1, ticks_per_beat=123, tracks=[
+        MidiTrack([
+            Message('note_on', channel=1, note=2, time=3),
+            Message('note_off', channel=1, note=2, time=3)]),
+        MidiTrack([
+            MetaMessage('sequence_number', number=5),
+            Message('note_on', channel=2, note=6, time=9),
+            Message('note_off', channel=2, note=6, time=9)]),
+    ])
+    midifile_eval = eval(repr(midifile))
+    for track, track_eval in zip(midifile.tracks, midifile_eval.tracks):
+        for m1, m2 in zip(track, track_eval):
+            assert m1 == m2
diff --git a/tests/midifiles/test_tracks.py b/tests/midifiles/test_tracks.py
index 6dddcde..54467b8 100644
--- a/tests/midifiles/test_tracks.py
+++ b/tests/midifiles/test_tracks.py
@@ -1,6 +1,10 @@
+import itertools
+from mido.messages import Message
 from mido.midifiles.meta import MetaMessage
 from mido.midifiles.tracks import MidiTrack
 
+zip = getattr(itertools, 'izip', zip)
+
 
 def test_track_slice():
     track = MidiTrack()
@@ -16,3 +20,13 @@ def test_track_name():
     # The track should use the first name it finds.
     track = MidiTrack([name1, name2])
     assert track.name == name1.name
+
+
+def test_track_repr():
+    track = MidiTrack([
+        Message('note_on', channel=1, note=2, time=3),
+        Message('note_off', channel=1, note=2, time=3),
+    ])
+    track_eval = eval(repr(track))
+    for m1, m2 in zip(track, track_eval):
+        assert m1 == m2
diff --git a/tests/test_frozen.py b/tests/test_frozen.py
index b26f010..0551d2f 100644
--- a/tests/test_frozen.py
+++ b/tests/test_frozen.py
@@ -1,4 +1,5 @@
 from mido.messages import Message
+from mido.midifiles.meta import MetaMessage, UnknownMetaMessage
 from mido.frozen import (is_frozen, freeze_message, thaw_message,
                          FrozenMessage, FrozenMetaMessage,
                          FrozenUnknownMetaMessage)
@@ -27,3 +28,24 @@ def test_thawed_message_is_copy():
 def test_is_frozen():
     assert is_frozen(FrozenMessage('note_on'))
     assert not is_frozen(Message('note_on'))
+
+
+def test_frozen_repr():
+    msg = FrozenMessage('note_on', channel=1, note=2, time=3)
+    msg_eval = eval(repr(msg))
+    assert type(msg_eval) == Message
+    assert msg == msg_eval
+
+
+def test_frozen_meta_repr():
+    msg = FrozenMetaMessage('end_of_track', time=10)
+    msg_eval = eval(repr(msg))
+    assert type(msg_eval) == MetaMessage
+    assert msg == msg_eval
+
+
+def test_frozen_unknown_meta_repr():
+    msg = FrozenUnknownMetaMessage(type_byte=99, data=[1, 2], time=10)
+    msg_eval = eval(repr(msg))
+    assert type(msg_eval) == UnknownMetaMessage
+    assert msg == msg_eval