diff --git a/CHANGELOG.md b/CHANGELOG.md index ba62375..4a3fa89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ The format follows the spirit of Keep a Changelog, and this project intends to u - Added repository workflow defaults for future GitHub workflow sessions. - Updated post-release documentation now that `0.3.0` is published. +- Hardened model factory handling for missing or null optional nested PrintNode + response fields. ## 0.3.0 - 2026-04-26 diff --git a/printnode_community/model.py b/printnode_community/model.py index 2e1fe09..78ab161 100644 --- a/printnode_community/model.py +++ b/printnode_community/model.py @@ -62,7 +62,7 @@ def create_computers(self, computers_dict): def create_computer(self, computer_dict): fields = self._underscore_keys(computer_dict) - del fields['jre'] + fields.pop('jre', None) self._rename_field(fields, 'inet_6', 'inet6') return safe_tuple_populate(Computer, fields) @@ -71,10 +71,9 @@ def create_printers(self, printers_dict): def create_printer(self, printer_dict): fields = self._underscore_keys(printer_dict) - fields['computer'] = self.create_computer(fields['computer']) - if fields['capabilities']: - fields['capabilities'] = self.create_capabilities( - fields['capabilities']) + self._create_nested_model(fields, 'computer', self.create_computer) + self._create_nested_model( + fields, 'capabilities', self.create_capabilities) return safe_tuple_populate(Printer, fields) def create_capabilities(self, capabilities_dict): @@ -86,7 +85,7 @@ def create_printjobs(self, printjobs_dict): def create_printjob(self, printjob_dict): fields = self._underscore_keys(printjob_dict) - fields['printer'] = self.create_printer(fields['printer']) + self._create_nested_model(fields, 'printer', self.create_printer) fields.setdefault('content', None) fields.setdefault('pages', None) fields.setdefault('qty', 1) @@ -109,10 +108,16 @@ def _underscore_keys(self, input_dict): for k, v in input_dict.items()} def _rename_field(self, obj_dict, old_name, new_name): + if old_name not in obj_dict: + return assert new_name not in obj_dict obj_dict[new_name] = obj_dict[old_name] del obj_dict[old_name] + def _create_nested_model(self, fields, field_name, factory): + if field_name in fields and fields[field_name] is not None: + fields[field_name] = factory(fields[field_name]) + def _map(self, f, iter): return list(map(f, iter)) diff --git a/tests/test_model.py b/tests/test_model.py index 66687ac..83bba54 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -130,6 +130,17 @@ def test_create_computer_maps_camel_case_and_ignores_jre(): assert not hasattr(computer, 'jre') +def test_create_computer_defaults_missing_optional_network_fields(): + payload = computer_payload() + del payload['jre'] + del payload['inet6'] + + computer = ModelFactory().create_computer(payload) + + assert isinstance(computer, Computer) + assert computer.inet6 is None + + def test_create_printer_builds_nested_computer_and_capabilities(): printer = ModelFactory().create_printer(printer_payload()) @@ -138,6 +149,27 @@ def test_create_printer_builds_nested_computer_and_capabilities(): assert printer.capabilities.supports_custom_paper_size is True +def test_create_printer_defaults_missing_optional_nested_fields(): + payload = printer_payload() + del payload['computer'] + del payload['capabilities'] + + printer = ModelFactory().create_printer(payload) + + assert printer.computer is None + assert printer.capabilities is None + + +def test_create_printer_preserves_null_nested_fields(): + printer = ModelFactory().create_printer(printer_payload( + computer=None, + capabilities=None, + )) + + assert printer.computer is None + assert printer.capabilities is None + + def test_create_printjob_defaults_optional_fields(): printjob = ModelFactory().create_printjob(printjob_payload()) @@ -148,6 +180,15 @@ def test_create_printjob_defaults_optional_fields(): assert printjob.options == {} +def test_create_printjob_defaults_missing_printer(): + payload = printjob_payload() + del payload['printer'] + + printjob = ModelFactory().create_printjob(payload) + + assert printjob.printer is None + + def test_create_printjob_preserves_server_supplied_optional_fields(): printjob = ModelFactory().create_printjob(printjob_payload( content='https://example.com/file.pdf',