sds-library 0.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
sds/__init__.py ADDED
@@ -0,0 +1,159 @@
1
+ """
2
+ SDS (Synchronized Data Structures) Python Library
3
+
4
+ A Pythonic interface for IoT state synchronization over MQTT.
5
+ This is a wrapper around the SDS C library using CFFI.
6
+
7
+ Example usage:
8
+ >>> from sds import SdsNode, Role, Field, FieldType
9
+ >>> from dataclasses import dataclass
10
+ >>>
11
+ >>> @dataclass
12
+ ... class SensorState:
13
+ ... temperature: float = Field(float32=True)
14
+ ... humidity: float = Field(float32=True)
15
+ >>>
16
+ >>> with SdsNode("sensor_01", "localhost") as node:
17
+ ... table = node.register_table(
18
+ ... "SensorData", Role.DEVICE,
19
+ ... state_schema=SensorState,
20
+ ... )
21
+ ...
22
+ ... # C-like attribute access
23
+ ... table.state.temperature = 23.5
24
+ ... table.state.humidity = 65.0
25
+ ...
26
+ ... while True:
27
+ ... node.poll(timeout_ms=1000)
28
+ """
29
+
30
+ import logging as _logging_std
31
+
32
+ __version__ = "0.6.0"
33
+ __author__ = "SDS Team"
34
+
35
+ # Core classes
36
+ # Logging configuration
37
+ from sds._logging import configure_logging
38
+
39
+ # Enums
40
+ # Exceptions
41
+ from sds._types import (
42
+ ErrorCode,
43
+ LogLevel,
44
+ Role,
45
+ SdsAlreadyInitializedError,
46
+ SdsCapacityError,
47
+ SdsConfigError,
48
+ SdsError,
49
+ SdsMqttError,
50
+ SdsNotInitializedError,
51
+ SdsPlatformError,
52
+ SdsTableError,
53
+ SdsValidationError,
54
+ check_error,
55
+ )
56
+ from sds.node import SdsNode
57
+ from sds.table import DeviceView, SdsTable, SectionProxy
58
+
59
+ # Table helpers
60
+ from sds.tables import Field, FieldType
61
+
62
+ # Public API
63
+ __all__ = [
64
+ # Version
65
+ "__version__",
66
+
67
+ # Core classes
68
+ "SdsNode",
69
+ "SdsTable",
70
+ "SectionProxy",
71
+ "DeviceView",
72
+
73
+ # Enums
74
+ "Role",
75
+ "ErrorCode",
76
+ "LogLevel",
77
+
78
+ # Exceptions
79
+ "SdsError",
80
+ "SdsNotInitializedError",
81
+ "SdsAlreadyInitializedError",
82
+ "SdsConfigError",
83
+ "SdsMqttError",
84
+ "SdsTableError",
85
+ "SdsCapacityError",
86
+ "SdsPlatformError",
87
+ "SdsValidationError",
88
+ "check_error",
89
+
90
+ # Table helpers
91
+ "Field",
92
+ "FieldType",
93
+
94
+ # Utility functions
95
+ "get_version",
96
+ "get_c_library_version",
97
+ "set_log_level",
98
+ "get_log_level",
99
+ "configure_logging",
100
+ ]
101
+
102
+
103
+ def get_version() -> str:
104
+ """Get the SDS Python library version."""
105
+ return __version__
106
+
107
+
108
+ def get_c_library_version() -> str:
109
+ """
110
+ Get the SDS C library version.
111
+
112
+ The pure-Python backend does not link the C library; this reports the
113
+ optional CFFI extension when present.
114
+ """
115
+ try:
116
+ from sds._bindings import lib # noqa: F401
117
+ return "1.0.0"
118
+ except Exception:
119
+ return "unknown (extension not built)"
120
+
121
+
122
+ # Runtime log level for the pure-Python backend. Mapped onto the "sds" logger
123
+ # so it composes with standard logging configuration.
124
+ _LOGLEVEL_TO_PY = {
125
+ LogLevel.NONE: _logging_std.CRITICAL + 10,
126
+ LogLevel.ERROR: _logging_std.ERROR,
127
+ LogLevel.WARN: _logging_std.WARNING,
128
+ LogLevel.INFO: _logging_std.INFO,
129
+ LogLevel.DEBUG: _logging_std.DEBUG,
130
+ }
131
+ _current_log_level: LogLevel = LogLevel.WARN
132
+
133
+
134
+ def set_log_level(level: LogLevel) -> None:
135
+ """
136
+ Set the runtime log level.
137
+
138
+ Controls which SDS log messages are output. Can be called at any time,
139
+ even before creating an SdsNode.
140
+
141
+ Example:
142
+ >>> from sds import set_log_level, LogLevel
143
+ >>> set_log_level(LogLevel.DEBUG) # Enable all logs
144
+ >>> set_log_level(LogLevel.NONE) # Disable all logs
145
+ """
146
+ global _current_log_level
147
+ level = LogLevel(level)
148
+ _current_log_level = level
149
+ _logging_std.getLogger("sds").setLevel(_LOGLEVEL_TO_PY[level])
150
+
151
+
152
+ def get_log_level() -> LogLevel:
153
+ """
154
+ Get the current runtime log level.
155
+
156
+ Returns:
157
+ The current log level
158
+ """
159
+ return _current_log_level
sds/_bindings.py ADDED
@@ -0,0 +1,96 @@
1
+ """
2
+ Low-level CFFI bindings for the SDS library.
3
+
4
+ This module provides direct access to the C library functions.
5
+ Most users should use the higher-level sds.SdsNode class instead.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ # Try to import the compiled CFFI extension
10
+ try:
11
+ from sds._sds_cffi import ffi, lib
12
+ except ImportError as e:
13
+ # Provide helpful error message
14
+ raise ImportError(
15
+ "SDS CFFI extension not found. Please build the extension first:\n"
16
+ " cd python && pip install -e .\n"
17
+ "or:\n"
18
+ " cd python && python sds/_build_ffi.py\n"
19
+ f"Original error: {e}"
20
+ ) from e
21
+
22
+
23
+ # Re-export ffi and lib for direct access
24
+ __all__ = [
25
+ "ffi",
26
+ "lib",
27
+ "SdsErrorCode",
28
+ "SdsRoleCode",
29
+ "encode_string",
30
+ "decode_string",
31
+ ]
32
+
33
+
34
+ class SdsErrorCode:
35
+ """Error code constants from the C library."""
36
+
37
+ OK = lib.SDS_OK
38
+ NOT_INITIALIZED = lib.SDS_ERR_NOT_INITIALIZED
39
+ ALREADY_INITIALIZED = lib.SDS_ERR_ALREADY_INITIALIZED
40
+ INVALID_CONFIG = lib.SDS_ERR_INVALID_CONFIG
41
+ MQTT_CONNECT_FAILED = lib.SDS_ERR_MQTT_CONNECT_FAILED
42
+ MQTT_DISCONNECTED = lib.SDS_ERR_MQTT_DISCONNECTED
43
+ TABLE_NOT_FOUND = lib.SDS_ERR_TABLE_NOT_FOUND
44
+ TABLE_ALREADY_REGISTERED = lib.SDS_ERR_TABLE_ALREADY_REGISTERED
45
+ MAX_TABLES_REACHED = lib.SDS_ERR_MAX_TABLES_REACHED
46
+ INVALID_TABLE = lib.SDS_ERR_INVALID_TABLE
47
+ INVALID_ROLE = lib.SDS_ERR_INVALID_ROLE
48
+ OWNER_EXISTS = lib.SDS_ERR_OWNER_EXISTS
49
+ MAX_NODES_REACHED = lib.SDS_ERR_MAX_NODES_REACHED
50
+ BUFFER_FULL = lib.SDS_ERR_BUFFER_FULL
51
+ SECTION_TOO_LARGE = lib.SDS_ERR_SECTION_TOO_LARGE
52
+ PLATFORM_NOT_SET = lib.SDS_ERR_PLATFORM_NOT_SET
53
+ PLATFORM_ERROR = lib.SDS_ERR_PLATFORM_ERROR
54
+
55
+
56
+ class SdsRoleCode:
57
+ """Role code constants from the C library."""
58
+
59
+ OWNER = lib.SDS_ROLE_OWNER
60
+ DEVICE = lib.SDS_ROLE_DEVICE
61
+
62
+
63
+ def encode_string(s: str | None) -> bytes | ffi.CData:
64
+ """
65
+ Encode a Python string to a C string (char*).
66
+
67
+ Args:
68
+ s: Python string or None
69
+
70
+ Returns:
71
+ C string (ffi.new("char[]")) or ffi.NULL if input is None
72
+ """
73
+ if s is None:
74
+ return ffi.NULL
75
+ return s.encode("utf-8")
76
+
77
+
78
+ def decode_string(c_str: ffi.CData) -> str | None:
79
+ """
80
+ Decode a C string (char*) to a Python string.
81
+
82
+ Args:
83
+ c_str: C string pointer
84
+
85
+ Returns:
86
+ Python string or None if pointer is NULL
87
+ """
88
+ if c_str == ffi.NULL:
89
+ return None
90
+ return ffi.string(c_str).decode("utf-8")
91
+
92
+
93
+ def get_error_string(error_code: int) -> str:
94
+ """Get human-readable error message for an error code."""
95
+ c_str = lib.sds_error_string(error_code)
96
+ return decode_string(c_str) or f"Unknown error ({error_code})"
sds/_build_ffi.py ADDED
@@ -0,0 +1,144 @@
1
+ """
2
+ CFFI build script for SDS library bindings.
3
+
4
+ This script is used by setup.py to compile the CFFI extension.
5
+ It can also be run directly to build the extension in-place.
6
+ """
7
+ import os
8
+ import subprocess
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ from cffi import FFI
13
+
14
+ # Find the project root (parent of python/)
15
+ # Use resolve() to get absolute paths
16
+ PYTHON_DIR = Path(__file__).resolve().parent.parent
17
+ PROJECT_ROOT = PYTHON_DIR.parent
18
+
19
+ # Paths to C sources
20
+ INCLUDE_DIR = PROJECT_ROOT / "include"
21
+ SRC_DIR = PROJECT_ROOT / "src"
22
+ PLATFORM_DIR = PROJECT_ROOT / "platform"
23
+
24
+ print("Building CFFI extension with:")
25
+ print(f" PROJECT_ROOT: {PROJECT_ROOT}")
26
+ print(f" INCLUDE_DIR: {INCLUDE_DIR}")
27
+ print(f" SRC_DIR: {SRC_DIR}")
28
+
29
+ ffibuilder = FFI()
30
+
31
+ # Read the C definitions
32
+ cdefs_path = Path(__file__).parent / "_cdefs.h"
33
+ with open(cdefs_path, "r") as f:
34
+ cdefs = f.read()
35
+
36
+ ffibuilder.cdef(cdefs)
37
+
38
+ # Source code to compile - we'll compile the C library directly into the extension
39
+ # Include sds_types.h to get the auto-registered table metadata (SensorData, ActuatorData, etc.)
40
+ # Include sds_platform.h for log level types and functions
41
+ source_code = """
42
+ #include "sds.h"
43
+ #include "sds_json.h"
44
+ #include "sds_error.h"
45
+ #include "sds_platform.h" /* For SdsLogLevel, sds_set_log_level, sds_get_log_level */
46
+ #include "sds_types.h" /* Generated types - auto-registers via constructor */
47
+ """
48
+
49
+ # Get include directories and source files
50
+ include_dirs = [str(INCLUDE_DIR)]
51
+ library_dirs = []
52
+ sources = []
53
+
54
+ # For setuptools, we need relative paths from python/ directory
55
+ # But for the compiler, we need include paths to be absolute
56
+ def make_relative_source(path):
57
+ """Convert absolute path to relative from python/ directory."""
58
+ return os.path.relpath(str(path), str(PYTHON_DIR))
59
+
60
+ # Add all C source files
61
+ for src_file in SRC_DIR.glob("*.c"):
62
+ sources.append(make_relative_source(src_file))
63
+
64
+ # Add platform implementation (POSIX for macOS/Linux)
65
+ platform_posix_c = PLATFORM_DIR / "posix" / "sds_platform_posix.c"
66
+ if platform_posix_c.exists():
67
+ sources.append(make_relative_source(platform_posix_c))
68
+ print(f"Using POSIX platform: {platform_posix_c}")
69
+ else:
70
+ print(f"Warning: Platform implementation not found at {platform_posix_c}")
71
+
72
+ # Platform-specific library paths
73
+ # The POSIX platform uses Paho MQTT C library (paho-mqtt3c)
74
+ libraries = ["paho-mqtt3c"]
75
+
76
+ if sys.platform == "darwin":
77
+ # macOS: Use Homebrew paths for Paho MQTT
78
+ homebrew_prefix = None
79
+
80
+ # Try to get Homebrew prefix
81
+ try:
82
+ result = subprocess.run(
83
+ ["brew", "--prefix", "libpaho-mqtt"],
84
+ capture_output=True,
85
+ text=True
86
+ )
87
+ if result.returncode == 0:
88
+ homebrew_prefix = result.stdout.strip()
89
+ except FileNotFoundError:
90
+ pass
91
+
92
+ # Fallback to common locations
93
+ if not homebrew_prefix:
94
+ for prefix in ["/opt/homebrew/opt/libpaho-mqtt", "/usr/local/opt/libpaho-mqtt"]:
95
+ if Path(prefix).exists():
96
+ homebrew_prefix = prefix
97
+ break
98
+
99
+ if homebrew_prefix:
100
+ include_dirs.append(f"{homebrew_prefix}/include")
101
+ library_dirs.append(f"{homebrew_prefix}/lib")
102
+ print(f"Using Homebrew Paho MQTT at: {homebrew_prefix}")
103
+ else:
104
+ print("Warning: Could not find Homebrew Paho MQTT installation")
105
+ print("Install with: brew install libpaho-mqtt")
106
+
107
+ elif sys.platform == "linux":
108
+ # Linux: Check for pkg-config
109
+ try:
110
+ result = subprocess.run(
111
+ ["pkg-config", "--cflags", "--libs", "paho-mqtt3c"],
112
+ capture_output=True,
113
+ text=True
114
+ )
115
+ if result.returncode == 0:
116
+ # Parse pkg-config output
117
+ for flag in result.stdout.split():
118
+ if flag.startswith("-I"):
119
+ include_dirs.append(flag[2:])
120
+ elif flag.startswith("-L"):
121
+ library_dirs.append(flag[2:])
122
+ except FileNotFoundError:
123
+ pass
124
+
125
+ # Define the extension module
126
+ ffibuilder.set_source(
127
+ "sds._sds_cffi",
128
+ source_code,
129
+ sources=sources,
130
+ include_dirs=include_dirs,
131
+ library_dirs=library_dirs,
132
+ libraries=libraries,
133
+ # Extra compile args for safety
134
+ extra_compile_args=["-Wall", "-Wextra"],
135
+ )
136
+
137
+
138
+ def build_inplace():
139
+ """Build the extension in-place for development."""
140
+ ffibuilder.compile(verbose=True)
141
+
142
+
143
+ if __name__ == "__main__":
144
+ build_inplace()
sds/_logging.py ADDED
@@ -0,0 +1,78 @@
1
+ """
2
+ SDS Logging Configuration.
3
+
4
+ This module provides logging setup for the SDS library.
5
+ By default, the library uses a NullHandler to avoid unwanted output.
6
+ Applications can configure logging as needed.
7
+
8
+ Example:
9
+ # Enable SDS logging to console
10
+ import logging
11
+ from sds import configure_logging
12
+
13
+ configure_logging(level=logging.DEBUG)
14
+
15
+ # Or configure manually
16
+ logging.getLogger("sds").setLevel(logging.DEBUG)
17
+ logging.getLogger("sds").addHandler(logging.StreamHandler())
18
+ """
19
+ import logging
20
+ from typing import Optional
21
+
22
+ # Library logger - uses NullHandler by default (library best practice)
23
+ logger = logging.getLogger("sds")
24
+ logger.addHandler(logging.NullHandler())
25
+
26
+
27
+ def configure_logging(
28
+ level: int = logging.INFO,
29
+ format: Optional[str] = None,
30
+ handler: Optional[logging.Handler] = None,
31
+ ) -> None:
32
+ """
33
+ Configure logging for the SDS library.
34
+
35
+ This is a convenience function for setting up logging. For more
36
+ complex setups, configure the "sds" logger directly.
37
+
38
+ Args:
39
+ level: Logging level (default: INFO)
40
+ format: Log format string (default: "[%(levelname)s] sds: %(message)s")
41
+ handler: Custom handler (default: StreamHandler to stderr)
42
+
43
+ Example:
44
+ >>> from sds import configure_logging
45
+ >>> import logging
46
+ >>> configure_logging(level=logging.DEBUG)
47
+ """
48
+ sds_logger = logging.getLogger("sds")
49
+ sds_logger.setLevel(level)
50
+
51
+ # Remove existing handlers to avoid duplicates
52
+ for h in sds_logger.handlers[:]:
53
+ if not isinstance(h, logging.NullHandler):
54
+ sds_logger.removeHandler(h)
55
+
56
+ # Add the specified or default handler
57
+ if handler is None:
58
+ handler = logging.StreamHandler()
59
+ if format is None:
60
+ format = "[%(levelname)s] sds: %(message)s"
61
+ handler.setFormatter(logging.Formatter(format))
62
+
63
+ sds_logger.addHandler(handler)
64
+
65
+
66
+ def get_logger(name: str) -> logging.Logger:
67
+ """
68
+ Get a logger for an SDS submodule.
69
+
70
+ Args:
71
+ name: Module name (will be prefixed with "sds.")
72
+
73
+ Returns:
74
+ Logger instance
75
+ """
76
+ if name.startswith("sds."):
77
+ return logging.getLogger(name)
78
+ return logging.getLogger(f"sds.{name}")
sds/_mqtt.py ADDED
@@ -0,0 +1,129 @@
1
+ """
2
+ sds._mqtt — thin paho-mqtt transport for the pure-Python SDS engine.
3
+
4
+ A minimal wrapper that the protocol engine drives: connect (registering the
5
+ node's LWT will), subscribe/unsubscribe, publish (retain/QoS), and a
6
+ ``poll()``-compatible network pump. Incoming messages are delivered to a single
7
+ callback as ``(topic, payload_str)``.
8
+
9
+ This module is intentionally *not* imported by ``sds/__init__`` or the
10
+ conformance harness — the wire/protocol engine is transport-agnostic and is
11
+ validated against the golden vectors without a broker. ``paho-mqtt`` is only
12
+ required when this layer is actually used (live broker, Phase 6+).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Callable, Optional
18
+
19
+ try:
20
+ import paho.mqtt.client as mqtt
21
+ except ImportError as exc: # pragma: no cover - exercised only without paho
22
+ raise ImportError(
23
+ "sds._mqtt requires the 'paho-mqtt' package. Install it with "
24
+ "`pip install paho-mqtt` (it is declared as a runtime dependency of "
25
+ "sds-library)."
26
+ ) from exc
27
+
28
+ from ._protocol import is_reserved_topic
29
+
30
+ # Callback: (topic, payload) -> None
31
+ MessageCallback = Callable[[str, str], None]
32
+
33
+ _RESERVED_MSG = "topic '{}' is reserved (sds/ prefix); use the table API instead"
34
+
35
+
36
+ class MqttTransport:
37
+ """A small paho-mqtt client tuned for SDS usage."""
38
+
39
+ def __init__(
40
+ self,
41
+ node_id: str,
42
+ host: str = "localhost",
43
+ port: int = 1883,
44
+ *,
45
+ keepalive: int = 60,
46
+ on_message: Optional[MessageCallback] = None,
47
+ ) -> None:
48
+ self.node_id = node_id
49
+ self.host = host
50
+ self.port = port
51
+ self.keepalive = keepalive
52
+ self._on_message = on_message
53
+ self._connected = False
54
+
55
+ # client_id = node_id keeps broker-side session identity aligned with SDS.
56
+ self._client = mqtt.Client(client_id=node_id, clean_session=True)
57
+ self._client.on_connect = self._handle_connect
58
+ self._client.on_disconnect = self._handle_disconnect
59
+ self._client.on_message = self._handle_message
60
+
61
+ # ---- lifecycle --------------------------------------------------------
62
+
63
+ def connect(
64
+ self,
65
+ will_topic: Optional[str] = None,
66
+ will_payload: Optional[str] = None,
67
+ *,
68
+ will_retain: bool = True,
69
+ will_qos: int = 0,
70
+ ) -> None:
71
+ """Connect to the broker, registering the LWT will if provided."""
72
+ if will_topic is not None and will_payload is not None:
73
+ self._client.will_set(
74
+ will_topic, payload=will_payload, qos=will_qos, retain=will_retain
75
+ )
76
+ self._client.connect(self.host, self.port, self.keepalive)
77
+
78
+ def disconnect(self) -> None:
79
+ self._client.disconnect()
80
+
81
+ @property
82
+ def is_connected(self) -> bool:
83
+ return self._connected
84
+
85
+ # ---- pub/sub ----------------------------------------------------------
86
+
87
+ def subscribe(self, topic: str, qos: int = 0) -> None:
88
+ self._client.subscribe(topic, qos=qos)
89
+
90
+ def unsubscribe(self, topic: str) -> None:
91
+ self._client.unsubscribe(topic)
92
+
93
+ def publish(self, topic: str, payload: str, qos: int = 0, retain: bool = False) -> None:
94
+ self._client.publish(topic, payload=payload, qos=qos, retain=retain)
95
+
96
+ # ---- raw pub/sub (reserved-prefix guarded) ----------------------------
97
+
98
+ def raw_subscribe(self, topic: str, qos: int = 0) -> None:
99
+ if is_reserved_topic(topic):
100
+ raise ValueError(_RESERVED_MSG.format(topic))
101
+ self.subscribe(topic, qos)
102
+
103
+ def raw_publish(self, topic: str, payload: str, qos: int = 0, retain: bool = False) -> None:
104
+ if is_reserved_topic(topic):
105
+ raise ValueError(_RESERVED_MSG.format(topic))
106
+ self.publish(topic, payload, qos, retain)
107
+
108
+ # ---- network pump -----------------------------------------------------
109
+
110
+ def poll(self, timeout_ms: int = 0) -> None:
111
+ """Pump the network for up to ``timeout_ms`` (poll()-compatible)."""
112
+ self._client.loop(timeout=max(timeout_ms, 0) / 1000.0)
113
+
114
+ # ---- paho callbacks ---------------------------------------------------
115
+
116
+ def _handle_connect(self, client, userdata, flags, rc, *args) -> None:
117
+ self._connected = rc == 0
118
+
119
+ def _handle_disconnect(self, client, userdata, rc, *args) -> None:
120
+ self._connected = False
121
+
122
+ def _handle_message(self, client, userdata, msg) -> None:
123
+ if self._on_message is None:
124
+ return
125
+ try:
126
+ payload = msg.payload.decode("utf-8")
127
+ except (UnicodeDecodeError, AttributeError):
128
+ return
129
+ self._on_message(msg.topic, payload)