Skip to content

Commit c4aad54

Browse files
Merge pull request #52 from Botts-Innovative-Research/fix/error-handling-and-logging
Typed exceptions and logging hygiene (umbrella #46)
2 parents af14443 + e7bfd08 commit c4aad54

16 files changed

Lines changed: 755 additions & 146 deletions

README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,31 @@ and does not gate merges.
102102
Development happens on Python 3.14 (pinned in `.python-version`); 3.11 is the
103103
supported floor.
104104

105+
## Logging
106+
107+
OSHConnect logs to the `oshconnect` logger namespace — every module uses
108+
`logging.getLogger(__name__)`, so records arrive as `oshconnect.node`,
109+
`oshconnect.resources.system`, `oshconnect.csapi4py.mqtt`, and so on.
110+
111+
The library never configures logging on your behalf: the package logger
112+
carries a `NullHandler`, so nothing is emitted until you opt in. One call
113+
controls the whole library without touching the root logger or any other
114+
package:
115+
116+
```python
117+
import logging
118+
119+
logging.basicConfig(level=logging.INFO) # your app's choice
120+
logging.getLogger("oshconnect").setLevel(logging.DEBUG) # verbose OSHConnect
121+
logging.getLogger("oshconnect.csapi4py.mqtt").setLevel(logging.WARNING) # ...but quiet MQTT
122+
```
123+
124+
Note that discovery additionally raises `SchemaFetchWarning` through the
125+
`warnings` module when an individual datastream or control-stream schema
126+
fetch fails. That's deliberate and separate from logging — discovery
127+
doesn't raise on per-resource schema failures, so the warning is how you
128+
catch them programmatically (`warnings.catch_warnings`).
129+
105130
## Documentation Coverage
106131

107132
[`interrogate`](https://interrogate.readthedocs.io/) reports what fraction of

docs/source/api.rst

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,20 @@ Core Application
2121

2222
---
2323

24+
Exceptions
25+
----------
26+
Every error OSHConnect raises deliberately descends from ``OSHConnectError``,
27+
so callers can catch library failures without also swallowing their own bugs.
28+
``OSHConnectError`` subclasses the builtin ``Exception``, so existing
29+
``except Exception:`` handlers keep working.
30+
31+
.. automodule:: oshconnect.exceptions
32+
:members:
33+
:undoc-members:
34+
:show-inheritance:
35+
36+
---
37+
2438
Streamable Resources
2539
--------------------
2640
These are the primary objects for interacting with systems, datastreams, and control streams on an OSH node.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "oshconnect"
3-
version = "0.5.4a0"
3+
version = "0.5.5a1"
44
description = "Library for interfacing with OSH, helping guide visualization efforts, and providing a place to store configurations. Implements OGC CS API Part 3 (Pub/Sub) MQTT topic conventions including :data topics and resource event topics."
55
readme = "README.md"
66
authors = [

src/oshconnect/__init__.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,17 @@
55
# Contact Email: ian@botts-inc.com
66
# ==============================================================================
77

8+
# Exceptions — every deliberate failure descends from OSHConnectError,
9+
# which subclasses Exception so existing `except Exception:` still works.
10+
from .exceptions import (
11+
OSHConnectError,
12+
ConfigurationError,
13+
ResourceRequestError,
14+
ResourceInsertError,
15+
MissingLocationHeaderError,
16+
ResourceDiscoveryError,
17+
)
18+
819
# Core resources
920
from .oshconnectapi import OSHConnect
1021
from .streamableresource import Node, System, Datastream, ControlStream, StreamableModes, Status
@@ -73,6 +84,13 @@
7384
from .csapi4py.constants import ObservationFormat, APIResourceTypes, ContentTypes
7485

7586
__all__ = [
87+
# Exceptions
88+
"OSHConnectError",
89+
"ConfigurationError",
90+
"ResourceRequestError",
91+
"ResourceInsertError",
92+
"MissingLocationHeaderError",
93+
"ResourceDiscoveryError",
7694
# Core resources
7795
"OSHConnect",
7896
"Node",
@@ -142,3 +160,21 @@
142160
"DataStore",
143161
"SQLiteDataStore",
144162
]
163+
164+
# ---------------------------------------------------------------------------
165+
# Logging hygiene (kept last so it doesn't push the imports above out of
166+
# top-of-file position, which flake8 flags as E402).
167+
#
168+
# A library must not configure logging for the application embedding it.
169+
# Attaching a NullHandler to the package logger keeps OSHConnect from
170+
# implicitly installing a stderr handler on the ROOT logger the first time
171+
# it warns. Consumers opt in explicitly:
172+
#
173+
# logging.getLogger("oshconnect").setLevel(logging.DEBUG)
174+
#
175+
# Every module logs to `oshconnect.<module>` via logging.getLogger(__name__),
176+
# so that single call controls the whole library and nothing else.
177+
# ---------------------------------------------------------------------------
178+
import logging as _logging # noqa: E402
179+
180+
_logging.getLogger(__name__).addHandler(_logging.NullHandler())

src/oshconnect/events/handler.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
from .core import DefaultEventTypes, Event
1515
from .listeners import CallbackListener, IEventListener
1616

17+
logger = logging.getLogger(__name__)
18+
1719

1820
class EventHandler(object):
1921
"""
@@ -115,7 +117,7 @@ def publish(self, evt: Event):
115117
try:
116118
listener.handle_events(evt)
117119
except Exception as e:
118-
logging.error("Error in event listener %s: %s", listener, e)
120+
logger.error("Error in event listener %s: %s", listener, e)
119121
finally:
120122
self.publish_lock = False
121123
self.commit_changes()

src/oshconnect/exceptions.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
# =============================================================================
2+
# Copyright (c) 2026 Georobotix Innovative Research
3+
# Date: 2026/7/28
4+
# Author: Ian Patterson
5+
# Contact Email: ian.patterson@georobotix.us
6+
# =============================================================================
7+
8+
"""Typed exceptions raised by OSHConnect.
9+
10+
Every error the library raises deliberately descends from
11+
`OSHConnectError`, so callers can wrap an OSHConnect operation and catch
12+
*its* failures without also swallowing `KeyError`, `AttributeError`, and
13+
every other bug in their own code::
14+
15+
from oshconnect.exceptions import OSHConnectError, ResourceInsertError
16+
17+
try:
18+
system.insert_self()
19+
except ResourceInsertError as e:
20+
if e.status_code == 507: # server out of disk — worth retrying
21+
schedule_retry()
22+
else:
23+
raise
24+
25+
`OSHConnectError` subclasses the builtin `Exception`, so pre-existing
26+
``except Exception:`` handlers keep working unchanged.
27+
28+
The HTTP free-function layer in `oshconnect.api_helpers` deliberately does
29+
*not* raise these — it returns raw `requests.Response` objects and leaves
30+
status interpretation to the caller. These exceptions come from the
31+
wrapper layer (`System`, `Datastream`, `ControlStream`, `Node`,
32+
`OSHConnect`), which does interpret responses on the caller's behalf.
33+
"""
34+
from __future__ import annotations
35+
36+
37+
class OSHConnectError(Exception):
38+
"""Base class for every error OSHConnect raises deliberately.
39+
40+
Catch this to handle any OSHConnect-originated failure while letting
41+
genuine programming errors propagate.
42+
"""
43+
44+
45+
class ConfigurationError(OSHConnectError):
46+
"""The library was asked to act on an object it hasn't been given.
47+
48+
Raised for caller-side wiring mistakes that are detectable before any
49+
HTTP request is attempted — e.g. inserting a system into a `Node` that
50+
was never registered with the `OSHConnect` instance.
51+
"""
52+
53+
54+
class ResourceRequestError(OSHConnectError):
55+
"""Base for failures tied to a specific CS API HTTP exchange.
56+
57+
Carries whatever the response made available. Every field is optional
58+
because not every call site has all of them — check for ``None``
59+
rather than assuming.
60+
61+
:param message: Human-readable description; becomes ``str(exc)``.
62+
:param status_code: HTTP status from the response, when there was one.
63+
:param response_text: Raw response body, when there was one.
64+
:param resource_type: The CS API resource involved, e.g. ``'system'``.
65+
:param resource_label: Caller-facing name of the specific resource,
66+
e.g. the system's label or the datastream's name.
67+
"""
68+
69+
def __init__(self, message: str, *, status_code: int = None,
70+
response_text: str = None, resource_type: str = None,
71+
resource_label: str = None):
72+
super().__init__(message)
73+
self.status_code = status_code
74+
self.response_text = response_text
75+
self.resource_type = resource_type
76+
self.resource_label = resource_label
77+
78+
79+
class ResourceInsertError(ResourceRequestError):
80+
"""A create (POST) of a CS API resource did not succeed.
81+
82+
Raised when the server returns a non-OK response to a resource
83+
creation request — inserting a system, datastream, control stream, or
84+
observation.
85+
"""
86+
87+
88+
class MissingLocationHeaderError(ResourceInsertError):
89+
"""A create POST succeeded but the server omitted ``Location``.
90+
91+
The resource was very likely created; OSHConnect just cannot learn its
92+
server-assigned id, so the local wrapper cannot be linked to it. A
93+
distinct type because the remediation differs from an outright
94+
rejection: the caller may need to re-discover rather than re-POST, to
95+
avoid creating a duplicate. Subclasses `ResourceInsertError` so
96+
callers that don't care about the distinction can catch the broader
97+
type.
98+
"""
99+
100+
101+
class ResourceDiscoveryError(ResourceRequestError):
102+
"""A listing / discovery (GET) request did not succeed.
103+
104+
Distinguishes a genuine failure — server down, bad credentials, 5xx —
105+
from the legitimately-empty result that discovery otherwise returns.
106+
"""
107+
108+
109+
__all__ = [
110+
"OSHConnectError",
111+
"ConfigurationError",
112+
"ResourceRequestError",
113+
"ResourceInsertError",
114+
"MissingLocationHeaderError",
115+
"ResourceDiscoveryError",
116+
]

src/oshconnect/node.py

Lines changed: 42 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
from .csapi4py.default_api_helpers import APIHelper
3636
from .csapi4py.mqtt import MQTTCommClient
3737
from .csapi4py.nats import NatsCommClient
38+
from .exceptions import ResourceDiscoveryError
3839
from .resource_datamodels import SystemResource
3940

4041
if 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

Comments
 (0)