diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 6048d2b..f4ed9cc 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -28,4 +28,4 @@ jobs:
if: ${{ matrix.dependencies != 'null' }}
run: pip install ${{ matrix.dependencies }}
- name: Run Tests
- run: python -m unittest tests/pytmx/test_pytmx.py
+ run: python -m unittest discover -s tests/pytmx -p "test_*.py"
diff --git a/pytmx/pytmx.py b/pytmx/pytmx.py
index 7d40621..0be31e1 100644
--- a/pytmx/pytmx.py
+++ b/pytmx/pytmx.py
@@ -17,6 +17,7 @@
License along with pytmx. If not, see .
"""
+
from __future__ import annotations
import gzip
@@ -54,6 +55,8 @@
"convert_to_bool",
"resolve_to_class",
"parse_properties",
+ "decode_gid",
+ "unpack_gids",
)
logger = logging.getLogger(__name__)
@@ -85,6 +88,7 @@
TiledLayer = Union[
"TiledTileLayer", "TiledImageLayer", "TiledGroupLayer", "TiledObjectGroup"
]
+flag_cache: dict[int, TileFlags] = {}
# need a more graceful way to handle annotations for optional dependencies
if pygame:
@@ -125,15 +129,19 @@ def decode_gid(raw_gid: int) -> tuple[int, TileFlags]:
"""
if raw_gid < GID_TRANS_ROT:
return raw_gid, empty_flags
- return (
- raw_gid & ~GID_MASK,
- # TODO: cache all combinations of flags
- TileFlags(
- raw_gid & GID_TRANS_FLIPX == GID_TRANS_FLIPX,
- raw_gid & GID_TRANS_FLIPY == GID_TRANS_FLIPY,
- raw_gid & GID_TRANS_ROT == GID_TRANS_ROT,
- ),
+
+ # Check if the GID is already in the cache
+ if raw_gid in flag_cache:
+ return raw_gid & ~GID_MASK, flag_cache[raw_gid]
+
+ # Calculate and cache the flags
+ flags = TileFlags(
+ raw_gid & GID_TRANS_FLIPX == GID_TRANS_FLIPX,
+ raw_gid & GID_TRANS_FLIPY == GID_TRANS_FLIPY,
+ raw_gid & GID_TRANS_ROT == GID_TRANS_ROT,
)
+ flag_cache[raw_gid] = flags
+ return raw_gid & ~GID_MASK, flags
def reshape_data(
@@ -180,6 +188,8 @@ def unpack_gids(
fmt = "<%dL" % (len(data) // 4)
return list(struct.unpack(fmt, data))
elif encoding == "csv":
+ if not text.strip():
+ return []
return [int(i) for i in text.split(",")]
elif encoding:
raise ValueError(f"layer encoding {encoding} is not supported.")
diff --git a/tests/pytmx/test_gid.py b/tests/pytmx/test_gid.py
new file mode 100644
index 0000000..fc84129
--- /dev/null
+++ b/tests/pytmx/test_gid.py
@@ -0,0 +1,231 @@
+import base64
+import gzip
+import struct
+import unittest
+import zlib
+
+from pytmx import TiledMap, TileFlags, decode_gid, unpack_gids
+
+# Tiled gid flags
+GID_TRANS_FLIPX = 1 << 31
+GID_TRANS_FLIPY = 1 << 30
+GID_TRANS_ROT = 1 << 29
+GID_MASK = GID_TRANS_FLIPX | GID_TRANS_FLIPY | GID_TRANS_ROT
+
+
+class TestDecodeGid(unittest.TestCase):
+ def test_no_flags(self):
+ raw_gid = 100
+ expected_gid, expected_flags = 100, TileFlags(False, False, False)
+ self.assertEqual(decode_gid(raw_gid), (expected_gid, expected_flags))
+
+ def test_individual_flags(self):
+ # Test for each flag individually
+ test_cases = [
+ (GID_TRANS_FLIPX + 1, 1, TileFlags(True, False, False)),
+ (GID_TRANS_FLIPY + 1, 1, TileFlags(False, True, False)),
+ (GID_TRANS_ROT + 1, 1, TileFlags(False, False, True)),
+ ]
+ for raw_gid, expected_gid, expected_flags in test_cases:
+ self.assertEqual(decode_gid(raw_gid), (expected_gid, expected_flags))
+
+ def test_combinations_of_flags(self):
+ # Test combinations of flags
+ test_cases = [
+ (GID_TRANS_FLIPX + GID_TRANS_FLIPY + 1, 1, TileFlags(True, True, False)),
+ (GID_TRANS_FLIPX + GID_TRANS_ROT + 1, 1, TileFlags(True, False, True)),
+ (GID_TRANS_FLIPY + GID_TRANS_ROT + 1, 1, TileFlags(False, True, True)),
+ (
+ GID_TRANS_FLIPX + GID_TRANS_FLIPY + GID_TRANS_ROT + 1,
+ 1,
+ TileFlags(True, True, True),
+ ),
+ ]
+ for raw_gid, expected_gid, expected_flags in test_cases:
+ self.assertEqual(decode_gid(raw_gid), (expected_gid, expected_flags))
+
+ def test_edge_cases(self):
+ # Maximum GID
+ max_gid = 2**29 - 1
+ self.assertEqual(
+ decode_gid(max_gid), (max_gid & ~GID_MASK, TileFlags(False, False, False))
+ )
+
+ # Minimum GID
+ min_gid = 0
+ self.assertEqual(decode_gid(min_gid), (min_gid, TileFlags(False, False, False)))
+
+ # GID with all flags set
+ gid_all_flags = GID_TRANS_FLIPX + GID_TRANS_FLIPY + GID_TRANS_ROT + 1
+ self.assertEqual(decode_gid(gid_all_flags), (1, TileFlags(True, True, True)))
+
+ # GID with flags in different orders
+ test_cases = [
+ (GID_TRANS_FLIPX + GID_TRANS_FLIPY + 1, 1, TileFlags(True, True, False)),
+ (GID_TRANS_FLIPY + GID_TRANS_FLIPX + 1, 1, TileFlags(True, True, False)),
+ (GID_TRANS_FLIPX + GID_TRANS_ROT + 1, 1, TileFlags(True, False, True)),
+ (GID_TRANS_ROT + GID_TRANS_FLIPX + 1, 1, TileFlags(True, False, True)),
+ ]
+ for raw_gid, expected_gid, expected_flags in test_cases:
+ self.assertEqual(decode_gid(raw_gid), (expected_gid, expected_flags))
+
+ def test_flag_cache_identity(self):
+ raw_gid = GID_TRANS_FLIPX + GID_TRANS_ROT + 1
+ _, flags1 = decode_gid(raw_gid)
+ _, flags2 = decode_gid(raw_gid)
+ self.assertIs(flags1, flags2)
+
+ def test_flag_cache_separation(self):
+ gid1 = GID_TRANS_FLIPX + 1
+ gid2 = GID_TRANS_FLIPY + 1
+ _, f1 = decode_gid(gid1)
+ _, f2 = decode_gid(gid2)
+ self.assertNotEqual(f1, f2)
+
+
+class TestRegisterGid(unittest.TestCase):
+ def setUp(self):
+ self.tmx_map = TiledMap()
+
+ def test_register_gid_with_valid_tiled_gid(self):
+ gid = self.tmx_map.register_gid(123)
+ self.assertIsNotNone(gid)
+
+ def test_register_gid_with_flags(self):
+ flags = TileFlags(1, 0, 1)
+ gid = self.tmx_map.register_gid(456, flags)
+ self.assertIsNotNone(gid)
+
+ def test_register_gid_zero(self):
+ gid = self.tmx_map.register_gid(0)
+ self.assertEqual(gid, 0)
+
+ def test_register_gid_max_gid(self):
+ max_gid = self.tmx_map.maxgid
+ self.tmx_map.register_gid(max_gid)
+ self.assertEqual(self.tmx_map.maxgid, max_gid + 1)
+
+ def test_register_gid_duplicate_gid(self):
+ gid1 = self.tmx_map.register_gid(123)
+ gid2 = self.tmx_map.register_gid(123)
+ self.assertEqual(gid1, gid2)
+
+ def test_register_gid_duplicate_gid_different_flags(self):
+ gid1 = self.tmx_map.register_gid(123, TileFlags(1, 0, 0))
+ gid2 = self.tmx_map.register_gid(123, TileFlags(0, 1, 0))
+ self.assertNotEqual(gid1, gid2)
+
+ def test_register_gid_empty_flags(self):
+ gid = self.tmx_map.register_gid(123, TileFlags(0, 0, 0))
+ self.assertIsNotNone(gid)
+
+ def test_register_gid_all_flags_set(self):
+ gid = self.tmx_map.register_gid(123, TileFlags(1, 1, 1))
+ self.assertIsNotNone(gid)
+
+ def test_register_gid_repeated_registration(self):
+ gid1 = self.tmx_map.register_gid(123)
+ gid2 = self.tmx_map.register_gid(123)
+ self.assertEqual(gid1, gid2)
+
+ def test_register_gid_flag_equivalence(self):
+ gid1 = self.tmx_map.register_gid(42, TileFlags(True, False, False))
+ gid2 = self.tmx_map.register_gid(42, TileFlags(1, 0, 0))
+ self.assertEqual(gid1, gid2)
+
+ def test_gid_mapping_growth(self):
+ initial_max = self.tmx_map.maxgid
+ for i in range(5):
+ self.tmx_map.register_gid(100 + i)
+ self.assertEqual(self.tmx_map.maxgid, initial_max + 5)
+
+
+class TestUnpackGids(unittest.TestCase):
+ def test_base64_no_compression(self):
+ gids = [123, 456, 789]
+ data = struct.pack(" None:
@@ -64,6 +71,15 @@ def test_non_boolean_number_raises_error(self) -> None:
with self.assertRaises(ValueError):
convert_to_bool("200")
+ def test_edge_cases(self):
+ # Whitespace
+ self.assertTrue(convert_to_bool(" t "))
+ self.assertFalse(convert_to_bool(" f "))
+
+ # Numeric edge cases
+ self.assertTrue(convert_to_bool(1e-10)) # Very small positive number
+ self.assertFalse(convert_to_bool(-1e-10)) # Very small negative number
+
class TiledMapTest(unittest.TestCase):
filename = "tests/resources/test01.tmx"
@@ -138,10 +154,12 @@ def test_contains_reserved_property_name(self) -> None:
specification. We check that new properties are not named same
as existing attributes.
"""
+ logging.disable(logging.CRITICAL) # disable logging
self.element.name = "foo"
items = {"name": None}
result = self.element._contains_invalid_property_name(items.items())
self.assertTrue(result)
+ logging.disable(logging.NOTSET) # reset logging
def test_not_contains_reserved_property_name(self) -> None:
"""Reserved names are checked from any attributes in the instance
diff --git a/tests/scripts/bench_decode_gid.py b/tests/scripts/bench_decode_gid.py
new file mode 100644
index 0000000..c7bad2f
--- /dev/null
+++ b/tests/scripts/bench_decode_gid.py
@@ -0,0 +1,55 @@
+import timeit
+
+from pytmx import TileFlags, decode_gid
+
+# Constants
+GID_TRANS_FLIPX = 1 << 31
+GID_TRANS_FLIPY = 1 << 30
+GID_TRANS_ROT = 1 << 29
+GID_MASK = GID_TRANS_FLIPX | GID_TRANS_FLIPY | GID_TRANS_ROT
+
+
+def decode_no_cache(raw_gid: int) -> tuple[int, TileFlags]:
+ if raw_gid < GID_TRANS_ROT:
+ return raw_gid, TileFlags(False, False, False)
+ return (
+ raw_gid & ~GID_MASK,
+ TileFlags(
+ raw_gid & GID_TRANS_FLIPX == GID_TRANS_FLIPX,
+ raw_gid & GID_TRANS_FLIPY == GID_TRANS_FLIPY,
+ raw_gid & GID_TRANS_ROT == GID_TRANS_ROT,
+ ),
+ )
+
+
+def run_benchmark(size: int) -> tuple[float, float]:
+ raw_gids = [GID_TRANS_FLIPX | GID_TRANS_FLIPY | (i % 1000 + 1) for i in range(size)]
+
+ def run_no_cache():
+ for gid in raw_gids:
+ decode_no_cache(gid)
+
+ def run_with_cache():
+ for gid in raw_gids:
+ decode_gid(gid)
+
+ time_no_cache = timeit.timeit(run_no_cache, number=1)
+ time_cache = timeit.timeit(run_with_cache, number=1)
+ return time_no_cache, time_cache
+
+
+def main():
+ print("Running decode_gid benchmarks...\n")
+ gid_sizes = [1_000, 5_000, 10_000, 50_000, 100_000, 250_000, 500_000, 1_000_000]
+
+ print(f"{'GID Count':>10} | {'No Cache':>10} | {'With Cache':>11} | {'Speedup':>8}")
+ print("-" * 50)
+
+ for size in gid_sizes:
+ no_cache, cache = run_benchmark(size)
+ speedup = no_cache / cache if cache > 0 else float("inf")
+ print(f"{size:>10} | {no_cache:.4f}s | {cache:.4f}s | {speedup:>7.2f}x")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/scripts/bench_tmx_maps.py b/tests/scripts/bench_tmx_maps.py
new file mode 100644
index 0000000..b2872bd
--- /dev/null
+++ b/tests/scripts/bench_tmx_maps.py
@@ -0,0 +1,79 @@
+import time
+from pathlib import Path
+
+from pytmx import TiledMap, decode_gid
+
+# Optional: enable tracking cache hits
+flag_cache_stats = {"hits": 0, "misses": 0}
+flag_cache = {}
+
+# TMX flag constants
+GID_TRANS_FLIPX = 1 << 31
+GID_TRANS_FLIPY = 1 << 30
+GID_TRANS_ROT = 1 << 29
+GID_MASK = GID_TRANS_FLIPX | GID_TRANS_FLIPY | GID_TRANS_ROT
+
+DATA_DIR = Path("./apps/data/")
+RUNS_PER_MAP = 3
+
+
+def decode_gid_tracked(raw_gid: int):
+ if raw_gid < GID_TRANS_ROT:
+ return raw_gid, decode_gid(raw_gid)[1]
+ if raw_gid in flag_cache:
+ flag_cache_stats["hits"] += 1
+ return raw_gid & ~GID_MASK, flag_cache[raw_gid]
+ flag_cache_stats["misses"] += 1
+ flags = decode_gid(raw_gid)[1]
+ flag_cache[raw_gid] = flags
+ return raw_gid & ~GID_MASK, flags
+
+
+def benchmark_map(map_path: Path) -> float:
+ times = []
+ for _ in range(RUNS_PER_MAP):
+ start = time.time()
+ TiledMap(str(map_path))
+ elapsed = time.time() - start
+ times.append(elapsed)
+ return sum(times) / RUNS_PER_MAP
+
+
+def summarize_map(map_path: Path):
+ try:
+ tmx = TiledMap(str(map_path))
+ num_layers = len(tmx.layers)
+ num_tilesets = len(tmx.tilesets)
+ num_tiles = sum(
+ len(layer.data) for layer in tmx.layers if hasattr(layer, "data")
+ )
+ return num_layers, num_tilesets, num_tiles
+ except Exception:
+ return None, None, None
+
+
+def main():
+ print("TMX Load Benchmark (real maps)\n")
+ print(
+ f"{'Map Name':<25} | {'Avg Time (s)':>12} | {'Layers':>6} | {'Tilesets':>9} | {'Tiles':>7}"
+ )
+ print("-" * 70)
+
+ for map_file in DATA_DIR.glob("*.tmx"):
+ try:
+ avg_time = benchmark_map(map_file)
+ layers, tilesets, tiles = summarize_map(map_file)
+ print(
+ f"{map_file.name:<25} | {avg_time:>12.4f} | {layers:>6} | {tilesets:>9} | {tiles:>7}"
+ )
+ except Exception as e:
+ print(f"{map_file.name:<25} | Error: {str(e)}")
+
+ if flag_cache_stats["hits"] or flag_cache_stats["misses"]:
+ print("\nGID Cache Stats")
+ print(f" Hits: {flag_cache_stats['hits']}")
+ print(f" Misses: {flag_cache_stats['misses']}")
+
+
+if __name__ == "__main__":
+ main()