Skip to content

Commit 375a5ef

Browse files
committed
Make stricter writing, align Addresses fields to the library style
1 parent 55a3e83 commit 375a5ef

File tree

8 files changed

+106
-31
lines changed

8 files changed

+106
-31
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
### Changelog
22

3+
### NEXT
4+
5+
* Enforce required nodes/fields at write time in class `to_xml` (raises on missing required Source/Projections/Geometries unless auto-filled).
6+
* Auto-fill minimal IDs for non-multipatch fixtures/truss/support/video/projector when missing (`FixtureID="0"`, `FixtureIDNumeric=0`, fixtures also `UnitNumber=0`).
7+
* Preserve `UserData/Data` payload content (text/children) on round-trip.
8+
* Default `Gobo` rotation to `0.0` to avoid invalid `None` serialization.
9+
* Switch `Addresses` to plural fields (`addresses`/`networks`) to match spec semantics; remove singular access and update tests/docs.
10+
311
### 1.0.4
412

513
* Add test for MVR read-write round-trip

README.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,12 @@ for layer_index, layer in enumerate(mvr_file.scene.layers):
5454

5555
### Writing MVR
5656

57+
> Validation notes
58+
> - Each object now enforces required children/fields when writing. Missing mandatory data will raise `ValueError`.
59+
> - For convenience, fixtures/truss/support/video/projector auto-fill missing IDs with minimal defaults (`FixtureID="0"`, `FixtureIDNumeric=0`, fixtures also set `UnitNumber=0`) when not a multipatch child.
60+
> - `Addresses` uses plural fields `addresses`/`networks` to align with the spec’s container semantics.
61+
> - Required nodes such as `Geometries`, `Source` inside `MappingDefinition`/`Projection`, and `Projections` on `Projector` must be present; empty `Sources`/`Projections` will raise.
62+
5763
#### Load and Export an MVR
5864

5965
```python
@@ -67,10 +73,10 @@ mvr_read = pymvr.GeneralSceneDescription("mvr_file.mvr")
6773
mvr_writer = pymvr.GeneralSceneDescriptionWriter()
6874

6975
# 3. Serialize the scene object into the writer's XML root
70-
mvr_read.scene.to_xml(parent=mvr_writer.xml_root)
76+
mvr_writer.serialize_scene(mvr_read.scene)
7177

7278
# 4. Serialize the user_data object into the writer's XML root
73-
mvr_read.user_data.to_xml(parent=mvr_writer.xml_root)
79+
mvr_writer.serialize_user_data(mvr_read.user_data)
7480

7581
# 5. Add necesarry files like GDTF fixtures, trusses, 3D objects and so on
7682
# Skipped in this example
@@ -111,7 +117,7 @@ fixture = pymvr.Fixture(name="Test Fixture")
111117
child_list.fixtures.append(fixture)
112118

113119
# 3. Serialize the scene object into the writer's XML root
114-
scene_obj.to_xml(parent=mvr_writer.xml_root)
120+
mvr_writer.serialize_scene(scene_obj)
115121

116122
# 4. Add any necessary files (like GDTF fixtures, trusses...) to the MVR archive
117123
# The list should contain tuples of (file_path, GDTF_file_name)

pymvr/__init__.py

Lines changed: 80 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
2323
# SOFTWARE.
2424

25+
from copy import deepcopy
2526
from typing import List, Union, Optional, Tuple
2627
from xml.etree import ElementTree
2728
from xml.etree.ElementTree import Element
@@ -80,7 +81,9 @@ def __exit__(self, exc_type, exc_val, exc_tb):
8081
class GeneralSceneDescriptionWriter:
8182
"""Creates MVR zip archive with packed GeneralSceneDescription xml and other files"""
8283

83-
def __init__(self):
84+
def __init__(
85+
self,
86+
):
8487
self.version_major: str = "1"
8588
self.version_minor: str = "6"
8689
self.provider: str = "pymvr"
@@ -94,6 +97,13 @@ def __init__(self):
9497
providerVersion=self.provider_version,
9598
)
9699

100+
def serialize_scene(self, scene: "Scene"):
101+
scene.to_xml(parent=self.xml_root)
102+
103+
def serialize_user_data(self, user_data: "UserData"):
104+
if user_data:
105+
user_data.to_xml(parent=self.xml_root)
106+
97107
def write_mvr(self, path: Optional[str] = None):
98108
if path is not None:
99109
if sys.version_info >= (3, 9):
@@ -381,32 +391,38 @@ def to_xml(self, parent: Element):
381391
class Addresses(BaseNode):
382392
def __init__(
383393
self,
384-
address: Optional[List["Address"]] = None,
385-
network: Optional[List["Network"]] = None,
394+
addresses: Optional[List["Address"]] = None,
395+
networks: Optional[List["Network"]] = None,
386396
xml_node: Optional["Element"] = None,
387397
*args,
388398
**kwargs,
389399
):
390-
self.address = address if address is not None else []
391-
self.network = network if network is not None else []
400+
# Use plural names as primary fields
401+
# Accept legacy keyword names if passed
402+
if addresses is None and "address" in kwargs:
403+
addresses = kwargs.pop("address")
404+
if networks is None and "network" in kwargs:
405+
networks = kwargs.pop("network")
406+
self.addresses: List["Address"] = addresses if addresses is not None else []
407+
self.networks: List["Network"] = networks if networks is not None else []
392408
super().__init__(xml_node, *args, **kwargs)
393409

394410
def _read_xml(self, xml_node: "Element"):
395-
self.address = [Address(xml_node=i) for i in xml_node.findall("Address")]
396-
self.network = [Network(xml_node=i) for i in xml_node.findall("Network")]
411+
self.addresses = [Address(xml_node=i) for i in xml_node.findall("Address")]
412+
self.networks = [Network(xml_node=i) for i in xml_node.findall("Network")]
397413

398414
def to_xml(self, parent: Element) -> Optional[Element]:
399-
if not self.address and not self.network:
415+
if not self.addresses and not self.networks:
400416
return None
401417
element = ElementTree.SubElement(parent, "Addresses")
402-
for dmx_address in self.address:
418+
for dmx_address in self.addresses:
403419
dmx_address.to_xml(element)
404-
for network_address in self.network:
420+
for network_address in self.networks:
405421
network_address.to_xml(element)
406422
return element
407423

408424
def __len__(self):
409-
return len(self.address) + len(self.network)
425+
return len(self.addresses) + len(self.networks)
410426

411427

412428
class BaseChildNode(BaseNode):
@@ -574,12 +590,10 @@ def populate_xml(self, element: Element):
574590

575591
if self.fixture_id is not None:
576592
ElementTree.SubElement(element, "FixtureID").text = str(self.fixture_id)
577-
578593
if self.fixture_id_numeric is not None:
579594
ElementTree.SubElement(element, "FixtureIDNumeric").text = str(
580595
self.fixture_id_numeric
581596
)
582-
583597
if self.unit_number is not None:
584598
ElementTree.SubElement(element, "UnitNumber").text = str(self.unit_number)
585599
if self.custom_id_type is not None:
@@ -620,8 +634,11 @@ def __str__(self):
620634

621635
def populate_xml(self, element: Element):
622636
super().populate_xml(element)
623-
if self.geometries:
624-
self.geometries.to_xml(element)
637+
if self.geometries is None:
638+
raise ValueError(
639+
f"{type(self).__name__} '{self.name}' missing required Geometries"
640+
)
641+
self.geometries.to_xml(element)
625642

626643

627644
class Data(BaseNode):
@@ -634,6 +651,8 @@ def __init__(
634651
):
635652
self.provider = provider
636653
self.ver = ver
654+
self.text: Optional[str] = None
655+
self.extra_children: List[Element] = []
637656
super().__init__(*args, **kwargs)
638657

639658
def _read_xml(self, xml_node: "Element"):
@@ -643,14 +662,20 @@ def _read_xml(self, xml_node: "Element"):
643662
ver = xml_node.attrib.get("ver")
644663
if ver is not None:
645664
self.ver = ver
665+
self.text = xml_node.text
666+
self.extra_children = [deepcopy(child) for child in list(xml_node)]
646667

647668
def __str__(self):
648669
return f"{self.provider} {self.ver}"
649670

650671
def to_xml(self):
651-
return ElementTree.Element(
672+
element = ElementTree.Element(
652673
type(self).__name__, provider=self.provider, ver=self.ver
653674
)
675+
element.text = self.text
676+
for child in self.extra_children:
677+
element.append(deepcopy(child))
678+
return element
654679

655680

656681
class AUXData(BaseNode):
@@ -740,6 +765,8 @@ def _read_xml(self, xml_node: "Element"):
740765
self.scale_handling = ScaleHandeling(xml_node=scale_handling_node)
741766

742767
def to_xml(self):
768+
if self.source is None:
769+
raise ValueError(f"MappingDefinition '{self.name}' missing required Source")
743770
element = ElementTree.Element(
744771
type(self).__name__, name=self.name, uuid=self.uuid
745772
)
@@ -832,6 +859,13 @@ def to_xml(self):
832859
if self.multipatch:
833860
attributes["multipatch"] = self.multipatch
834861
element = ElementTree.Element(type(self).__name__, attributes)
862+
if self.multipatch is None:
863+
if self.fixture_id is None:
864+
self.fixture_id = "0"
865+
if self.fixture_id_numeric is None:
866+
self.fixture_id_numeric = 0
867+
if self.unit_number is None:
868+
self.unit_number = 0
835869
self.populate_xml(element)
836870

837871
if self.focus:
@@ -1416,6 +1450,11 @@ def to_xml(self):
14161450
if self.multipatch:
14171451
attributes["multipatch"] = self.multipatch
14181452
element = ElementTree.Element(type(self).__name__, attributes)
1453+
if self.multipatch is None:
1454+
if self.fixture_id is None:
1455+
self.fixture_id = "0"
1456+
if self.fixture_id_numeric is None:
1457+
self.fixture_id_numeric = 0
14191458
self.populate_xml(element)
14201459
if self.position:
14211460
ElementTree.SubElement(element, "Position").text = self.position
@@ -1459,6 +1498,11 @@ def to_xml(self):
14591498
if self.multipatch:
14601499
attributes["multipatch"] = self.multipatch
14611500
element = ElementTree.Element(type(self).__name__, attributes)
1501+
if self.multipatch is None:
1502+
if self.fixture_id is None:
1503+
self.fixture_id = "0"
1504+
if self.fixture_id_numeric is None:
1505+
self.fixture_id_numeric = 0
14621506
self.populate_xml(element)
14631507

14641508
if self.position:
@@ -1499,6 +1543,11 @@ def to_xml(self):
14991543
if self.multipatch:
15001544
attributes["multipatch"] = self.multipatch
15011545
element = ElementTree.Element(type(self).__name__, attributes)
1546+
if self.multipatch is None:
1547+
if self.fixture_id is None:
1548+
self.fixture_id = "0"
1549+
if self.fixture_id_numeric is None:
1550+
self.fixture_id_numeric = 0
15021551
self.populate_xml(element)
15031552

15041553
if self.sources:
@@ -1530,10 +1579,17 @@ def to_xml(self):
15301579
if self.multipatch:
15311580
attributes["multipatch"] = self.multipatch
15321581
element = ElementTree.Element(type(self).__name__, attributes)
1582+
if self.multipatch is None:
1583+
if self.fixture_id is None:
1584+
self.fixture_id = "0"
1585+
if self.fixture_id_numeric is None:
1586+
self.fixture_id_numeric = 0
15331587
self.populate_xml(element)
15341588

15351589
if self.projections:
15361590
self.projections.to_xml(element)
1591+
else:
1592+
raise ValueError(f"Projector '{self.name}' missing Projections")
15371593

15381594
return element
15391595

@@ -1749,7 +1805,7 @@ def __init__(
17491805
*args,
17501806
**kwargs,
17511807
):
1752-
self.rotation = rotation
1808+
self.rotation = 0.0 if rotation is None else rotation
17531809
self.filename = filename
17541810
super().__init__(xml_node, *args, **kwargs)
17551811

@@ -1814,9 +1870,10 @@ def _read_xml(self, xml_node: "Element"):
18141870
self.scale_handling = ScaleHandeling(xml_node=scale_handling_node)
18151871

18161872
def to_xml(self):
1873+
if self.source is None:
1874+
raise ValueError("Projection missing required Source")
18171875
element = ElementTree.Element(type(self).__name__)
1818-
if self.source:
1819-
element.append(self.source.to_xml())
1876+
element.append(self.source.to_xml())
18201877
if self.scale_handling:
18211878
self.scale_handling.to_xml(element)
18221879
return element
@@ -1840,6 +1897,8 @@ def _read_xml(self, xml_node: "Element"):
18401897

18411898
def to_xml(self, parent: Element):
18421899
element = ElementTree.SubElement(parent, type(self).__name__)
1900+
if len(self.projections) == 0:
1901+
raise ValueError("Projections missing Projection entries")
18431902
for projection in self.projections:
18441903
element.append(projection.to_xml())
18451904
return element
@@ -1895,6 +1954,8 @@ def _read_xml(self, xml_node: "Element"):
18951954

18961955
def to_xml(self, parent: Element):
18971956
element = ElementTree.SubElement(parent, type(self).__name__)
1957+
if len(self.sources) == 0:
1958+
raise ValueError("Sources missing Source entries")
18981959
for source in self.sources:
18991960
element.append(source.to_xml())
19001961
return element

tests/test_fixture_1_5.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,9 @@ def process_mvr_child_list(child_list, mvr_scene):
4747
def process_mvr_fixture(fixture):
4848
assert fixture.gdtf_spec == "LED PAR 64 RGBW.gdtf"
4949
assert (
50-
fixture.addresses.address[0].universe == 1
50+
fixture.addresses.addresses[0].universe == 1
5151
) # even though the uni is 0 in the file, 1 is by the spec
52-
assert fixture.addresses.address[0].address == 1 # dtto
52+
assert fixture.addresses.addresses[0].address == 1 # dtto
5353
assert fixture.gdtf_mode == "Default"
5454
assert fixture.matrix.matrix[3] == [5.0, 5.0, 5.0, 0]
5555

tests/test_mvr_02_read_ours.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,8 @@ def process_mvr_child_list(child_list, mvr_scene):
4646

4747
def process_mvr_fixture(fixture):
4848
assert fixture.gdtf_spec == "LED PAR 64 RGBW.gdtf"
49-
assert fixture.addresses.address[0].universe == 1
50-
assert fixture.addresses.address[0].address == 1
49+
assert fixture.addresses.addresses[0].universe == 1
50+
assert fixture.addresses.addresses[0].address == 1
5151
assert fixture.gdtf_mode == "Default"
5252
assert fixture.matrix.matrix[3] == [5.0, 5.0, 5.0, 0]
5353

tests/test_mvr_03_write_ours_json.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ def test_write_from_json():
7575
gdtf_spec=fixture_data["gdtf_spec"],
7676
gdtf_mode=fixture_data["gdtf_mode"],
7777
fixture_id=fixture_data["fixture_id"],
78-
addresses=pymvr.Addresses(address=new_addresses),
78+
addresses=pymvr.Addresses(addresses=new_addresses),
7979
)
8080

8181
child_list.fixtures.append(new_fixture)

tests/test_mvr_04_read_ours_json.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,8 @@ def process_mvr_child_list(child_list, mvr_scene):
4747

4848
def process_mvr_fixture(fixture):
4949
assert fixture.gdtf_spec == "BlenderDMX@Basic_LED_Bulb@ver2.gdtf"
50-
assert fixture.addresses.address[0].dmx_break == 1
51-
assert fixture.addresses.address[0].universe == 1
50+
assert fixture.addresses.addresses[0].dmx_break == 1
51+
assert fixture.addresses.addresses[0].universe == 1
5252
assert fixture.gdtf_mode == "Standard mode"
5353
assert fixture.matrix.matrix[3] == [0.0, 0.0, 0.0, 0]
5454

tests/test_read_write_round_trip.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ def test_read_write_round_trip(request, pymvr_module):
4545
with pymvr_module.GeneralSceneDescription(file_read_path) as mvr_read:
4646
mvr_writer = pymvr_module.GeneralSceneDescriptionWriter()
4747

48-
mvr_read.scene.to_xml(parent=mvr_writer.xml_root)
49-
mvr_read.user_data.to_xml(parent=mvr_writer.xml_root)
48+
mvr_writer.serialize_scene(mvr_read.scene)
49+
mvr_writer.serialize_user_data(mvr_read.user_data)
5050

5151
mvr_writer.write_mvr(file_write_path)

0 commit comments

Comments
 (0)