From b3d4c6546df0e83cceef0f0d484687e71e7fa36e Mon Sep 17 00:00:00 2001 From: sam0rr Date: Thu, 23 Jul 2026 12:40:27 -0400 Subject: [PATCH 1/2] Enforce private typed module boundary --- pyproject.toml | 2 +- .../{utils.py => _protocol.py} | 38 ++++++++++++--- .../{serial_manager.py => _serial_manager.py} | 9 +++- src/xkc_kl200_python/config.py | 4 +- src/xkc_kl200_python/constants.py | 41 +++-------------- src/xkc_kl200_python/errors.py | 6 +++ src/xkc_kl200_python/sensor.py | 26 ++++++----- tests/{test_utils.py => test_protocol.py} | 6 +-- tests/test_public_api.py | 46 +++++++++++++++++++ tests/test_sensor_init_modes.py | 2 +- tests/test_sensor_misc.py | 2 +- tests/test_sensor_reading.py | 2 +- tests/test_serial_manager.py | 19 ++++---- uv.lock | 2 +- 14 files changed, 133 insertions(+), 72 deletions(-) rename src/xkc_kl200_python/{utils.py => _protocol.py} (83%) rename src/xkc_kl200_python/{serial_manager.py => _serial_manager.py} (98%) rename tests/{test_utils.py => test_protocol.py} (94%) create mode 100644 tests/test_public_api.py diff --git a/pyproject.toml b/pyproject.toml index 04a3727..e8b1f98 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "uv_build" [project] name = "xkc-kl200-python" -version = "6.0.0" +version = "7.0.0" description = "A typed Python library for controlling the XKC-KL200 UART laser distance sensor." readme = "README.md" authors = [ diff --git a/src/xkc_kl200_python/utils.py b/src/xkc_kl200_python/_protocol.py similarity index 83% rename from src/xkc_kl200_python/utils.py rename to src/xkc_kl200_python/_protocol.py index ebef39e..06fd95a 100644 --- a/src/xkc_kl200_python/utils.py +++ b/src/xkc_kl200_python/_protocol.py @@ -1,15 +1,39 @@ -"""Protocol frame helpers shared by commands, parsing, and tests.""" +"""Wire constants and frame helpers for the XKC-KL200 UART protocol.""" from collections.abc import Sequence from dataclasses import dataclass from typing import Final -from .constants import ( - COMMAND_HEADER, - FRAME_LENGTH, - READ_DISTANCE_COMMAND, - SYSTEM_HEADER, -) +FRAME_LENGTH = 9 +COMMAND_HEADER = 0x62 +SYSTEM_HEADER = 0x61 +DEFAULT_SENSOR_ADDRESS = 0xFFFF + +READ_DISTANCE_COMMAND = 0x33 +CHANGE_BAUD_RATE_COMMAND = 0x30 +CHANGE_ADDRESS_COMMAND = 0x32 +SET_LED_MODE_COMMAND = 0x37 +SET_RELAY_MODE_COMMAND = 0x38 +RESET_COMMAND = 0x39 +SET_COMMUNICATION_MODE_COMMAND = 0x30 + +MIN_ADDRESS = 0x0000 +MAX_ADDRESS = 0xFFFE + +BAUD_RATE_TO_CODE = { + 2400: 0, + 4800: 1, + 9600: 2, + 14400: 3, + 19200: 4, + 38400: 5, + 56000: 6, + 57600: 7, + 115200: 8, + 128000: 9, +} + +CODE_TO_BAUD_RATE = {code: baudrate for baudrate, code in BAUD_RATE_TO_CODE.items()} @dataclass(frozen=True, kw_only=True, slots=True) diff --git a/src/xkc_kl200_python/serial_manager.py b/src/xkc_kl200_python/_serial_manager.py similarity index 98% rename from src/xkc_kl200_python/serial_manager.py rename to src/xkc_kl200_python/_serial_manager.py index d1b49e0..70838dc 100644 --- a/src/xkc_kl200_python/serial_manager.py +++ b/src/xkc_kl200_python/_serial_manager.py @@ -6,9 +6,14 @@ import serial +from ._protocol import ( + COMMAND_HEADER, + FRAME_LENGTH, + SYSTEM_HEADER, + calculate_checksum, +) from .config import SensorConfig -from .constants import COMMAND_HEADER, FRAME_LENGTH, SYSTEM_HEADER, XkcKl200Status -from .utils import calculate_checksum +from .constants import XkcKl200Status class SerialPort(Protocol): diff --git a/src/xkc_kl200_python/config.py b/src/xkc_kl200_python/config.py index de99aca..633d96f 100644 --- a/src/xkc_kl200_python/config.py +++ b/src/xkc_kl200_python/config.py @@ -2,13 +2,15 @@ from dataclasses import dataclass -from .constants import ( +from ._protocol import ( BAUD_RATE_TO_CODE, DEFAULT_SENSOR_ADDRESS, MAX_ADDRESS, MIN_ADDRESS, ) +__all__ = ["SensorConfig"] + @dataclass(frozen=True, kw_only=True, slots=True) class SensorConfig: diff --git a/src/xkc_kl200_python/constants.py b/src/xkc_kl200_python/constants.py index 69e38fb..bbc615b 100644 --- a/src/xkc_kl200_python/constants.py +++ b/src/xkc_kl200_python/constants.py @@ -1,40 +1,13 @@ -"""Protocol constants and small enums for the XKC-KL200 UART interface.""" +"""Public enums and status values for the XKC-KL200 UART interface.""" from enum import IntEnum -# Fixed frame layout values shared by command and measurement packets. -FRAME_LENGTH = 9 -COMMAND_HEADER = 0x62 -SYSTEM_HEADER = 0x61 -DEFAULT_SENSOR_ADDRESS = 0xFFFF - -# Command identifiers used by the device protocol. -READ_DISTANCE_COMMAND = 0x33 -CHANGE_BAUD_RATE_COMMAND = 0x30 -CHANGE_ADDRESS_COMMAND = 0x32 -SET_LED_MODE_COMMAND = 0x37 -SET_RELAY_MODE_COMMAND = 0x38 -RESET_COMMAND = 0x39 -SET_COMMUNICATION_MODE_COMMAND = 0x30 - -MIN_ADDRESS = 0x0000 -MAX_ADDRESS = 0xFFFE - -# Supported baud-rate mappings between user values and protocol codes. -BAUD_RATE_TO_CODE = { - 2400: 0, - 4800: 1, - 9600: 2, - 14400: 3, - 19200: 4, - 38400: 5, - 56000: 6, - 57600: 7, - 115200: 8, - 128000: 9, -} - -CODE_TO_BAUD_RATE = {code: baudrate for baudrate, code in BAUD_RATE_TO_CODE.items()} +__all__ = [ + "CommunicationMode", + "LedMode", + "RelayMode", + "XkcKl200Status", +] class XkcKl200Status(IntEnum): diff --git a/src/xkc_kl200_python/errors.py b/src/xkc_kl200_python/errors.py index 5b33c99..d635c5d 100644 --- a/src/xkc_kl200_python/errors.py +++ b/src/xkc_kl200_python/errors.py @@ -1,5 +1,11 @@ """Public exception types for fresh-read failures in the XKC-KL200 library.""" +__all__ = [ + "XkcKl200ReadError", + "XkcKl200ResponseError", + "XkcKl200TimeoutError", +] + class XkcKl200ReadError(Exception): """Base exception for failures while reading a fresh sensor measurement.""" diff --git a/src/xkc_kl200_python/sensor.py b/src/xkc_kl200_python/sensor.py index 4043c86..6c491ff 100644 --- a/src/xkc_kl200_python/sensor.py +++ b/src/xkc_kl200_python/sensor.py @@ -4,13 +4,13 @@ from dataclasses import replace from typing import Self, TypeVar -from .config import SensorConfig -from .constants import ( +from ._protocol import ( BAUD_RATE_TO_CODE, CHANGE_ADDRESS_COMMAND, CHANGE_BAUD_RATE_COMMAND, CODE_TO_BAUD_RATE, COMMAND_HEADER, + EMPTY_FRAME_PAYLOAD, MAX_ADDRESS, MIN_ADDRESS, READ_DISTANCE_COMMAND, @@ -19,22 +19,24 @@ SET_LED_MODE_COMMAND, SET_RELAY_MODE_COMMAND, SYSTEM_HEADER, + FramePayload, + build_command_frame, + parse_frame, + parse_measurement_frame, +) +from ._serial_manager import SerialFactory, SerialManager +from .config import SensorConfig +from .constants import ( CommunicationMode, LedMode, RelayMode, XkcKl200Status, ) from .errors import XkcKl200ResponseError, XkcKl200TimeoutError -from .serial_manager import SerialFactory, SerialManager -from .utils import ( - EMPTY_FRAME_PAYLOAD, - FramePayload, - build_command_frame, - parse_frame, - parse_measurement_frame, -) -EnumValue = TypeVar("EnumValue", bound=int) +__all__ = ["XkcKl200"] + +_EnumValue = TypeVar("_EnumValue", bound=int) class XkcKl200: @@ -263,7 +265,7 @@ def _resolve_baud_rate_code(baud_rate: int) -> int | None: @staticmethod def _coerce_enum_value( - value: int | EnumValue, enum_type: type[EnumValue] + value: int | _EnumValue, enum_type: type[_EnumValue] ) -> int | None: """Validate an integer-like value against an enum type.""" try: diff --git a/tests/test_utils.py b/tests/test_protocol.py similarity index 94% rename from tests/test_utils.py rename to tests/test_protocol.py index 89060ea..05f72c9 100644 --- a/tests/test_utils.py +++ b/tests/test_protocol.py @@ -1,9 +1,9 @@ -"""Protocol helper tests shared by runtime code and test fixtures.""" +"""Tests for private protocol helpers.""" import pytest -from xkc_kl200_python.constants import READ_DISTANCE_COMMAND -from xkc_kl200_python.utils import ( +from xkc_kl200_python._protocol import ( + READ_DISTANCE_COMMAND, FramePayload, build_command_frame, calculate_checksum, diff --git a/tests/test_public_api.py b/tests/test_public_api.py new file mode 100644 index 0000000..9adc77c --- /dev/null +++ b/tests/test_public_api.py @@ -0,0 +1,46 @@ +"""Tests for the supported typed package interface.""" + +import importlib.util + +import xkc_kl200_python +from xkc_kl200_python import config, constants, errors, sensor + + +def test_package_exports_are_exact_and_sorted() -> None: + """Expose exactly the documented high-level API.""" + assert xkc_kl200_python.__all__ == [ + "CommunicationMode", + "LedMode", + "RelayMode", + "SensorConfig", + "XkcKl200", + "XkcKl200ReadError", + "XkcKl200ResponseError", + "XkcKl200Status", + "XkcKl200TimeoutError", + ] + + +def test_public_modules_declare_exact_interfaces() -> None: + """Keep each importable public module limited to supported names.""" + assert config.__all__ == ["SensorConfig"] + assert constants.__all__ == [ + "CommunicationMode", + "LedMode", + "RelayMode", + "XkcKl200Status", + ] + assert errors.__all__ == [ + "XkcKl200ReadError", + "XkcKl200ResponseError", + "XkcKl200TimeoutError", + ] + assert sensor.__all__ == ["XkcKl200"] + + +def test_internal_module_paths_are_explicitly_private() -> None: + """Ship underscored internals without retaining the old module paths.""" + assert importlib.util.find_spec("xkc_kl200_python._protocol") is not None + assert importlib.util.find_spec("xkc_kl200_python._serial_manager") is not None + assert importlib.util.find_spec("xkc_kl200_python.serial_manager") is None + assert importlib.util.find_spec("xkc_kl200_python.utils") is None diff --git a/tests/test_sensor_init_modes.py b/tests/test_sensor_init_modes.py index 6fe6760..5ddbded 100644 --- a/tests/test_sensor_init_modes.py +++ b/tests/test_sensor_init_modes.py @@ -3,8 +3,8 @@ from conftest import FakeSerialFactory from xkc_kl200_python import CommunicationMode, LedMode, RelayMode, XkcKl200 +from xkc_kl200_python._protocol import build_command_frame from xkc_kl200_python.constants import XkcKl200Status -from xkc_kl200_python.utils import build_command_frame def test_invalid_led_mode_returns_invalid_parameter( diff --git a/tests/test_sensor_misc.py b/tests/test_sensor_misc.py index a4691b3..d9da63d 100644 --- a/tests/test_sensor_misc.py +++ b/tests/test_sensor_misc.py @@ -4,8 +4,8 @@ from conftest import FakeSerialFactory from xkc_kl200_python import CommunicationMode, SensorConfig, XkcKl200 +from xkc_kl200_python._protocol import FramePayload, build_command_frame from xkc_kl200_python.constants import XkcKl200Status -from xkc_kl200_python.utils import FramePayload, build_command_frame def make_sensor( diff --git a/tests/test_sensor_reading.py b/tests/test_sensor_reading.py index 641b35f..52d3383 100644 --- a/tests/test_sensor_reading.py +++ b/tests/test_sensor_reading.py @@ -8,8 +8,8 @@ XkcKl200ResponseError, XkcKl200TimeoutError, ) +from xkc_kl200_python._protocol import FramePayload, build_command_frame from xkc_kl200_python.constants import XkcKl200Status -from xkc_kl200_python.utils import FramePayload, build_command_frame def test_read_distance_updates_state(serial_factory: FakeSerialFactory) -> None: diff --git a/tests/test_serial_manager.py b/tests/test_serial_manager.py index b9b3e73..90e19b9 100644 --- a/tests/test_serial_manager.py +++ b/tests/test_serial_manager.py @@ -3,10 +3,13 @@ import pytest from conftest import FakeSerial, FakeSerialFactory +from xkc_kl200_python._protocol import FramePayload, build_command_frame +from xkc_kl200_python._serial_manager import ( + SerialManager, + default_serial_factory, +) from xkc_kl200_python.config import SensorConfig from xkc_kl200_python.constants import XkcKl200Status -from xkc_kl200_python.serial_manager import SerialManager, default_serial_factory -from xkc_kl200_python.utils import FramePayload, build_command_frame def test_write_frame(serial_factory: FakeSerialFactory) -> None: @@ -42,7 +45,7 @@ def fake_constructor( return fake_serial monkeypatch.setattr( - "xkc_kl200_python.serial_manager.serial.Serial", fake_constructor + "xkc_kl200_python._serial_manager.serial.Serial", fake_constructor ) result = default_serial_factory("/dev/ttyUSB0", 9600, 1.0) @@ -95,11 +98,11 @@ def test_read_frame_sleeps_before_timeout( monotonic_values = iter([0.0, 0.0, 0.002]) monkeypatch.setattr( - "xkc_kl200_python.serial_manager.time.monotonic", + "xkc_kl200_python._serial_manager.time.monotonic", lambda: next(monotonic_values), ) monkeypatch.setattr( - "xkc_kl200_python.serial_manager.time.sleep", + "xkc_kl200_python._serial_manager.time.sleep", sleep_calls.append, ) @@ -328,7 +331,7 @@ def fake_sleep(duration: float) -> None: if len(sleep_calls) == 1: serial_port.queue_read(valid_frame) - monkeypatch.setattr("xkc_kl200_python.serial_manager.time.sleep", fake_sleep) + monkeypatch.setattr("xkc_kl200_python._serial_manager.time.sleep", fake_sleep) result, status = manager.read_frame(expected_command=0x33, timeout=0.01) @@ -378,11 +381,11 @@ def test_read_frame_returns_deferred_checksum_error_at_timeout( bytes([0x62, 0x33, 0x09, 0xFF, 0xFF, 0x00, 0x64, 0x00, 0x00]) ) monkeypatch.setattr( - "xkc_kl200_python.serial_manager.time.monotonic", + "xkc_kl200_python._serial_manager.time.monotonic", lambda: next(monotonic_values), ) monkeypatch.setattr( - "xkc_kl200_python.serial_manager.time.sleep", + "xkc_kl200_python._serial_manager.time.sleep", sleep_calls.append, ) diff --git a/uv.lock b/uv.lock index 062935e..c362bf2 100644 --- a/uv.lock +++ b/uv.lock @@ -344,7 +344,7 @@ wheels = [ [[package]] name = "xkc-kl200-python" -version = "6.0.0" +version = "7.0.0" source = { editable = "." } dependencies = [ { name = "pyserial" }, From 7deebd7ce41022df0ff93d73157818ffebdca342 Mon Sep 17 00:00:00 2001 From: sam0rr Date: Thu, 23 Jul 2026 12:45:15 -0400 Subject: [PATCH 2/2] Correct documented development tools --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0477099..d61250f 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ A simplified, typed, and testable Python library for controlling the `XKC-KL200- - Typed API for sensor control and distance reads. - Straightforward request/response serial flow. - No hardware required for tests because the serial link is mocked. -- `uv`, `black`, `ruff`, `mypy`, and `pytest` ready from the start. +- `uv`, `ruff`, `mypy`, `pytest`, and `vulture` ready from the start. ---