I'm trying to learn Python and am working on a simple Tiled Map Format (.tmx) reader as practice. So as to not post too much code at once, I'm only publishing the <data>
element which stores the tiles displayed in a tiled map.
I'm looking for any critique, no matter how harsh, on quality of code, the Pythonic way of coding, my unit tests, etc.
First, the description of the <data>
element from the documentation:
<data>
encoding: The encoding used to encode the tile layer data.
When used, it can be "base64" and "csv" at the moment.
compression: The compression used to compress the tile layer data.
Supports "gzip" and "zlib".
First you need to base64-decode it, then you may need to decompress it.
Now you have an array of bytes, which should be interpreted as an array of
unsigned 32-bit integers using little-endian byte ordering.
Whatever format you choose for your layer data,
you will always end up with so called "global tile IDs" (gids).
The code:
@value_equality
class Data:
"""
Contains the information on how the current map is put together.
Data is compressed first, then encoded. Then the data is an array of bytes which should be interpreted
as an array of unsigned 32-bit integers in little-endian format.
If neither encoding nor compression is used, then the data is stored as an xml list of tile elements.
All formats represent global tile ids and may refer to any tilesets used by the map. In order to correctly map the
global id to the tileset, find the tileset with the highest firstgid that is still lower or equal than the gid. The
tilesets are always stored with increasing firstgids
"""
@property
def encoding(self):
"""
The encoding used to encode the tile layer data.
Can be either "base64" or "csv".
(Optional)
"""
self._encoding
@property
def compression(self):
"""
The compression used to compress the tile layer data.
Can be either "gzip" or "zlib"
(Optional)
"""
self._compression
@property
def tiles(self):
self._tiles
@property
def global_tile_ids(self):
self._global_tile_ids
def __init__(self, encoding=None, compression=None, global_tile_ids=[], tiles=None):
self._encoding = encoding
self._compression = compression
self._global_tile_ids = global_tile_ids
self._tiles = tiles
def __eq__(self, other):
return self.__dict__ == other.__dict__ if isinstance(other, self.__class__) else False
def __ne__(self, other):
return not self.__eq__(other)
@classmethod
def from_tmx(cls, element):
"""Parse <data> element from .tmx file"""
attributes = {
'encoding': element.attrib['encoding'] if 'encoding' in element.attrib else None,
'compression': element.attrib['compression'] if 'compression' in element.attrib else None,
}
tile_ids = []
if attributes['encoding'] is None and attributes['compression'] is None:
map(tile_ids.append, element.iter('tile'))
else:
decoded = Data._decode(element.text, attributes['encoding'])
decompressed = Data._decompress(decoded, attributes['compression'])
tile_ids = list(decompressed)
attributes['global_tile_ids'] = list(map(DataTile.from_bytes, tile_ids))
return cls(**attributes)
@staticmethod
def _decode(data, encoding):
decoders = {
'base64': (lambda x: base64.b64decode(x)),
'csv': (lambda x: map(int, x.strip().split(','))),
None: (lambda x: x)
}
return decoders[encoding](data)
@staticmethod
def _decompress(data, compression):
decompressors = {
'gzip': (lambda x: gzip.decompress(data)),
'zlib': (lambda x: zlib.decompress(data)),
None: (lambda x: x)
}
return decompressors[compression](data)
@value_equality
class DataTile():
HORIZONTAL_FLIP_BIT = 0x80000000
VERTICAL_FLIP_BIT = 0x40000000
DIAGONAL_FLIP_BIT = 0x20000000
@property
def global_tile_id(self):
return self._global_tile_id
@property
def flipped_horizontally(self):
return self._flipped_horizontally
@property
def flipped_vertically(self):
return self._flipped_vertically
@property
def flipped_diagonally(self):
return self._flipped_diagonally
def __init__(self, global_tile_id, flipped_horizontally=False, flipped_vertically=False, flipped_diagonally=False):
self._global_tile_id = global_tile_id
self._flipped_horizontally = flipped_horizontally
self._flipped_vertically = flipped_vertically
self._flipped_diagonally = flipped_diagonally
def __eq__(self, other):
return self.__dict__ == other.__dict__ if isinstance(other, self.__class__) else False
def __ne__(self, other):
return not self.__eq__(other)
@classmethod
def from_bytes(cls, tile_id):
attributes = {
'global_tile_id': tile_id & ~(cls.HORIZONTAL_FLIP_BIT | cls.VERTICAL_FLIP_BIT | cls.DIAGONAL_FLIP_BIT),
'flipped_horizontally': (tile_id & cls.HORIZONTAL_FLIP_BIT != 0),
'flipped_vertically': (tile_id & cls.VERTICAL_FLIP_BIT != 0),
'flipped_diagonally': (tile_id & cls.DIAGONAL_FLIP_BIT != 0)
}
return cls(**attributes)
My first unit tests:
class TiledMapReaderTest(unittest.TestCase):
def setUp(self):
self._unknowntmx = self._resource('Unknown.tmx')
self._completexml = self._resource('Complete.xml')
self._minimaltmx = self._resource('Minimal.tmx')
self._completetmx = self._resource('Complete.tmx')
self._tilesettmx = self._resource('Tileset.tmx')
self._imagetmx = self._resource('Image.tmx')
self._datatmx = self._resource('Data.tmx')
self._csvtmx = self._resource('CsvData.tmx')
def _resource(self, name):
return os.path.join(os.path.dirname(__file__), 'res', name)
def _root(self, file):
return ET.parse(file).getroot()
# Snip unrelated tests
def test_datatag_constructsdata(self):
root = self._root(self._datatmx)
actual = Data.from_tmx(root)
expected = Data(encoding='base64', compression='gzip')
self.assertEqual(expected._encoding, actual._encoding)
self.assertEqual(expected._compression, actual._compression)
def test_datatag_base64_decodesdata(self):
data = b'H4sIAAAAAAAAAO3NoREAMAgEsLedAfafE4+s6l0jolNJiif18tt/Fj8AAMC9ARtYg28AEAAA'
actual = Data._decode(data, 'base64')
expected = base64.b64decode(data)
self.assertEqual(expected, actual)
def test_datatag_gzip_decompressesdata(self):
data = b'H4sIAAAAAAAAAO3NoREAMAgEsLedAfafE4+s6l0jolNJiif18tt/Fj8AAMC9ARtYg28AEAAA'
actual = Data._decompress(Data._decode(data, 'base64'), 'gzip')
expected = gzip.decompress(base64.b64decode(data))
self.assertEqual(expected, actual)
def test_datatile_frombytes(self):
flipped_horizontally = 0x80000001
flipped_vertically = 0x40000002
flipped_diagonally = 0x20000003
flipped_everyway = 0xe0000004
expected = DataTile(global_tile_id=1, flipped_horizontally=True)
self._datatile_equal(flipped_horizontally, expected)
expected = DataTile(global_tile_id=2, flipped_vertically=True)
self._datatile_equal(flipped_vertically, expected)
expected = DataTile(global_tile_id=3, flipped_diagonally=True)
self._datatile_equal(flipped_diagonally, expected)
expected = DataTile(global_tile_id=4, flipped_horizontally=True, flipped_vertically=True, flipped_diagonally=True)
self._datatile_equal(flipped_everyway, expected)
def _datatile_equal(self, bytes, expected):
actual = DataTile.from_bytes(bytes)
self.assertEqual(expected, actual)
Custom @value_equality
decorator
def value_equality(cls):
"""Implement __eq__ and __ne__; caution, does not work with polymorphic yet."""
cls.__eq__ = lambda self, other: self.__dict__ == other.__dict__ if isinstance(other, self.__class__) else False
cls.__ne__ = lambda self, other: not self.__eq__(other)
return cls