Skip to content

Commit 292faa0

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

File tree

8 files changed

+98
-31
lines changed

8 files changed

+98
-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: 72 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,32 @@ 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+
self.addresses: List["Address"] = addresses if addresses is not None else []
401+
self.networks: List["Network"] = networks if networks is not None else []
392402
super().__init__(xml_node, *args, **kwargs)
393403

394404
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")]
405+
self.addresses = [Address(xml_node=i) for i in xml_node.findall("Address")]
406+
self.networks = [Network(xml_node=i) for i in xml_node.findall("Network")]
397407

398408
def to_xml(self, parent: Element) -> Optional[Element]:
399-
if not self.address and not self.network:
409+
if not self.addresses and not self.networks:
400410
return None
401411
element = ElementTree.SubElement(parent, "Addresses")
402-
for dmx_address in self.address:
412+
for dmx_address in self.addresses:
403413
dmx_address.to_xml(element)
404-
for network_address in self.network:
414+
for network_address in self.networks:
405415
network_address.to_xml(element)
406416
return element
407417

408418
def __len__(self):
409-
return len(self.address) + len(self.network)
419+
return len(self.addresses) + len(self.networks)
410420

411421

412422
class BaseChildNode(BaseNode):
@@ -574,12 +584,10 @@ def populate_xml(self, element: Element):
574584

575585
if self.fixture_id is not None:
576586
ElementTree.SubElement(element, "FixtureID").text = str(self.fixture_id)
577-
578587
if self.fixture_id_numeric is not None:
579588
ElementTree.SubElement(element, "FixtureIDNumeric").text = str(
580589
self.fixture_id_numeric
581590
)
582-
583591
if self.unit_number is not None:
584592
ElementTree.SubElement(element, "UnitNumber").text = str(self.unit_number)
585593
if self.custom_id_type is not None:
@@ -620,8 +628,11 @@ def __str__(self):
620628

621629
def populate_xml(self, element: Element):
622630
super().populate_xml(element)
623-
if self.geometries:
624-
self.geometries.to_xml(element)
631+
if self.geometries is None:
632+
raise ValueError(
633+
f"{type(self).__name__} '{self.name}' missing required Geometries"
634+
)
635+
self.geometries.to_xml(element)
625636

626637

627638
class Data(BaseNode):
@@ -634,6 +645,8 @@ def __init__(
634645
):
635646
self.provider = provider
636647
self.ver = ver
648+
self.text: Optional[str] = None
649+
self.extra_children: List[Element] = []
637650
super().__init__(*args, **kwargs)
638651

639652
def _read_xml(self, xml_node: "Element"):
@@ -643,14 +656,20 @@ def _read_xml(self, xml_node: "Element"):
643656
ver = xml_node.attrib.get("ver")
644657
if ver is not None:
645658
self.ver = ver
659+
self.text = xml_node.text
660+
self.extra_children = [deepcopy(child) for child in list(xml_node)]
646661

647662
def __str__(self):
648663
return f"{self.provider} {self.ver}"
649664

650665
def to_xml(self):
651-
return ElementTree.Element(
666+
element = ElementTree.Element(
652667
type(self).__name__, provider=self.provider, ver=self.ver
653668
)
669+
element.text = self.text
670+
for child in self.extra_children:
671+
element.append(deepcopy(child))
672+
return element
654673

655674

656675
class AUXData(BaseNode):
@@ -740,6 +759,8 @@ def _read_xml(self, xml_node: "Element"):
740759
self.scale_handling = ScaleHandeling(xml_node=scale_handling_node)
741760

742761
def to_xml(self):
762+
if self.source is None:
763+
raise ValueError(f"MappingDefinition '{self.name}' missing required Source")
743764
element = ElementTree.Element(
744765
type(self).__name__, name=self.name, uuid=self.uuid
745766
)
@@ -832,6 +853,11 @@ def to_xml(self):
832853
if self.multipatch:
833854
attributes["multipatch"] = self.multipatch
834855
element = ElementTree.Element(type(self).__name__, attributes)
856+
if self.multipatch is None:
857+
if self.fixture_id is None:
858+
self.fixture_id = "0"
859+
if self.fixture_id_numeric is None:
860+
self.fixture_id_numeric = 0
835861
self.populate_xml(element)
836862

837863
if self.focus:
@@ -1416,6 +1442,11 @@ def to_xml(self):
14161442
if self.multipatch:
14171443
attributes["multipatch"] = self.multipatch
14181444
element = ElementTree.Element(type(self).__name__, attributes)
1445+
if self.multipatch is None:
1446+
if self.fixture_id is None:
1447+
self.fixture_id = "0"
1448+
if self.fixture_id_numeric is None:
1449+
self.fixture_id_numeric = 0
14191450
self.populate_xml(element)
14201451
if self.position:
14211452
ElementTree.SubElement(element, "Position").text = self.position
@@ -1459,6 +1490,11 @@ def to_xml(self):
14591490
if self.multipatch:
14601491
attributes["multipatch"] = self.multipatch
14611492
element = ElementTree.Element(type(self).__name__, attributes)
1493+
if self.multipatch is None:
1494+
if self.fixture_id is None:
1495+
self.fixture_id = "0"
1496+
if self.fixture_id_numeric is None:
1497+
self.fixture_id_numeric = 0
14621498
self.populate_xml(element)
14631499

14641500
if self.position:
@@ -1499,6 +1535,11 @@ def to_xml(self):
14991535
if self.multipatch:
15001536
attributes["multipatch"] = self.multipatch
15011537
element = ElementTree.Element(type(self).__name__, attributes)
1538+
if self.multipatch is None:
1539+
if self.fixture_id is None:
1540+
self.fixture_id = "0"
1541+
if self.fixture_id_numeric is None:
1542+
self.fixture_id_numeric = 0
15021543
self.populate_xml(element)
15031544

15041545
if self.sources:
@@ -1530,10 +1571,17 @@ def to_xml(self):
15301571
if self.multipatch:
15311572
attributes["multipatch"] = self.multipatch
15321573
element = ElementTree.Element(type(self).__name__, attributes)
1574+
if self.multipatch is None:
1575+
if self.fixture_id is None:
1576+
self.fixture_id = "0"
1577+
if self.fixture_id_numeric is None:
1578+
self.fixture_id_numeric = 0
15331579
self.populate_xml(element)
15341580

15351581
if self.projections:
15361582
self.projections.to_xml(element)
1583+
else:
1584+
raise ValueError(f"Projector '{self.name}' missing Projections")
15371585

15381586
return element
15391587

@@ -1749,7 +1797,7 @@ def __init__(
17491797
*args,
17501798
**kwargs,
17511799
):
1752-
self.rotation = rotation
1800+
self.rotation = 0.0 if rotation is None else rotation
17531801
self.filename = filename
17541802
super().__init__(xml_node, *args, **kwargs)
17551803

@@ -1814,9 +1862,10 @@ def _read_xml(self, xml_node: "Element"):
18141862
self.scale_handling = ScaleHandeling(xml_node=scale_handling_node)
18151863

18161864
def to_xml(self):
1865+
if self.source is None:
1866+
raise ValueError("Projection missing required Source")
18171867
element = ElementTree.Element(type(self).__name__)
1818-
if self.source:
1819-
element.append(self.source.to_xml())
1868+
element.append(self.source.to_xml())
18201869
if self.scale_handling:
18211870
self.scale_handling.to_xml(element)
18221871
return element
@@ -1840,6 +1889,8 @@ def _read_xml(self, xml_node: "Element"):
18401889

18411890
def to_xml(self, parent: Element):
18421891
element = ElementTree.SubElement(parent, type(self).__name__)
1892+
if len(self.projections) == 0:
1893+
raise ValueError("Projections missing Projection entries")
18431894
for projection in self.projections:
18441895
element.append(projection.to_xml())
18451896
return element
@@ -1895,6 +1946,8 @@ def _read_xml(self, xml_node: "Element"):
18951946

18961947
def to_xml(self, parent: Element):
18971948
element = ElementTree.SubElement(parent, type(self).__name__)
1949+
if len(self.sources) == 0:
1950+
raise ValueError("Sources missing Source entries")
18981951
for source in self.sources:
18991952
element.append(source.to_xml())
19001953
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)