3535from .csapi4py .default_api_helpers import APIHelper
3636from .csapi4py .mqtt import MQTTCommClient
3737from .csapi4py .nats import NatsCommClient
38+ from .exceptions import ResourceDiscoveryError
3839from .resource_datamodels import SystemResource
3940
4041if TYPE_CHECKING :
@@ -196,6 +197,17 @@ def __init__(self, protocol: str, address: str, port: int, username: str = None,
196197 self .address = address
197198 self .server_root = server_root
198199 self .port = port
200+ # Bind every declared dataclass field up front, even when the
201+ # corresponding feature is off. The generated `__repr__` reads all
202+ # of them unconditionally, so a field left unset made `repr(node)`
203+ # raise `AttributeError: 'Node' object has no attribute
204+ # '_basic_auth'` (anonymous nodes) or `'_mqtt_client'` /
205+ # '_nats_client'` (transport-less nodes) — which then surfaced
206+ # anywhere a Node appeared in an error message or a pytest failure
207+ # report, burying the real problem under a repr traceback.
208+ self ._basic_auth = None
209+ self ._mqtt_client = None
210+ self ._nats_client = None
199211 self .is_secure = username is not None and password is not None
200212 if self .is_secure :
201213 self .add_basicauth (username , password )
@@ -307,8 +319,14 @@ def discover_systems(self) -> list[System] | None:
307319 The new systems are appended to this node's internal list and also
308320 returned for convenience.
309321
310- :return: List of newly-created `System` objects, or ``None`` if
311- the HTTP request failed.
322+ :return: List of newly-created `System` objects. An empty list
323+ means the server has no systems — a failure raises instead of
324+ returning a falsy value, so the two are distinguishable.
325+ :raises ResourceDiscoveryError: if the listing request fails.
326+ Previously this returned ``None``, which the common
327+ ``for s in node.discover_systems() or []:`` idiom silently
328+ turned into a zero-iteration loop — an auth failure and an
329+ empty server looked identical. See GitHub issue #49.
312330 """
313331 # Deferred runtime import: System -> StreamableResource -> Node would
314332 # otherwise close a cycle when this module is first loaded.
@@ -317,25 +335,28 @@ def discover_systems(self) -> list[System] | None:
317335 APIResourceTypes .SYSTEM ,
318336 params = {'f' : 'application/sml+json' },
319337 )
320- if result .ok :
321- new_systems = []
322- system_objs = result .json ()['items' ]
323- for system_json in system_objs :
324- system = SystemResource .model_validate (system_json , by_alias = True )
325- # Route through the canonical factory so the parsed
326- # `SystemResource` is bound to the wrapper via
327- # `set_system_resource(...)`. The previous manual
328- # `System(label=..., name=..., urn=..., resource_id=...)`
329- # call dropped the parsed resource on the floor —
330- # any caller reaching for `_underlying_resource`
331- # (deep-copy round-trip, cross-node sync, geometry,
332- # validTime, properties) saw only a thin shell.
333- sys_obj = System .from_resource (system , parent_node = self )
334- self ._systems .append (sys_obj )
335- new_systems .append (sys_obj )
336- return new_systems
337- else :
338- return None
338+ if not result .ok :
339+ raise ResourceDiscoveryError (
340+ f'Failed to list systems on { self ._api_helper .get_base_url ()} : '
341+ f'HTTP { result .status_code } — { result .text } ' ,
342+ status_code = result .status_code , response_text = result .text ,
343+ resource_type = 'system' ,
344+ )
345+ new_systems = []
346+ for system_json in result .json ()['items' ]:
347+ system = SystemResource .model_validate (system_json , by_alias = True )
348+ # Route through the canonical factory so the parsed
349+ # `SystemResource` is bound to the wrapper via
350+ # `set_system_resource(...)`. The previous manual
351+ # `System(label=..., name=..., urn=..., resource_id=...)`
352+ # call dropped the parsed resource on the floor —
353+ # any caller reaching for `_underlying_resource`
354+ # (deep-copy round-trip, cross-node sync, geometry,
355+ # validTime, properties) saw only a thin shell.
356+ sys_obj = System .from_resource (system , parent_node = self )
357+ self ._systems .append (sys_obj )
358+ new_systems .append (sys_obj )
359+ return new_systems
339360
340361 def get_api_helper (self ) -> APIHelper :
341362 """Return the `APIHelper` this node uses for HTTP calls."""
0 commit comments