feat(packaging): nest protocol/ + ocf/ under smartthings_local, add PyPI packaging

Nest the two library packages under a single import namespace so they
can ship as one distribution:

  protocol/ -> smartthings_local/protocol/
  ocf/      -> smartthings_local/ocf/   (git mv, history preserved)

- Rewrite all imports protocol.* -> smartthings_local.protocol.*,
  ocf.* -> smartthings_local.ocf.* across the ocf modules, mqtt_demo/
  (bridge, descriptor, samples), and tests.
- Add pyproject.toml: dist name `smartthings-local`, hatch-vcs versioning
  from v* tags, wheel ships only smartthings_local/.
- Add .github/workflows/publish.yml: build + PyPI Trusted Publishing on
  v* tags (OIDC, no stored token).
- Force-include protocol/ocf_root_ca.pem via [tool.hatch.build] artifacts:
  it is tracked but matches .gitignore's *.pem, so hatchling's VCS file
  selection would drop it — and dtls_session.py loads it at runtime.
- Update mqtt_demo Dockerfile COPY and deploy.sh tar allowlist to the
  single smartthings_local/ package.
- .gitignore: build artifacts (_version.py, dist/, *.egg-info/).

Validated: pytest tests/ (11 passed), python -m build produces sdist +
wheel with the pem bundled, fresh pip install resolves all nested imports
with the pem readable from site-packages.
This commit is contained in:
Jack Nagy
2026-07-06 20:29:19 +01:00
parent 9004bac729
commit 3fdc735141
23 changed files with 155 additions and 37 deletions
+50
View File
@@ -0,0 +1,50 @@
name: Publish to PyPI
# Publishes smartthings-local to PyPI on a version tag (v1.2.3).
# Uses PyPI Trusted Publishing (OIDC) — no API token is stored in the repo.
# One-time setup on PyPI: add a trusted publisher for this repo pointing at
# workflow `publish.yml` and environment `pypi`.
on:
push:
tags:
- "v*"
jobs:
build:
name: Build sdist + wheel
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # hatch-vcs needs full history + tags to derive the version
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: python -m pip install --upgrade build
- run: python -m build
- name: Verify the built version matches the tag
run: |
ls -l dist/
version="${GITHUB_REF_NAME#v}"
if ! ls dist/ | grep -q "smartthings_local-${version}"; then
echo "Built artifacts do not match tag version ${version}"; exit 1
fi
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
publish:
name: Publish to PyPI
needs: build
runs-on: ubuntu-latest
environment: pypi
permissions:
id-token: write # required for Trusted Publishing (OIDC)
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- uses: pypa/gh-action-pypi-publish@release/v1
+5
View File
@@ -17,6 +17,11 @@ __pycache__/
.venv/ .venv/
venv/ venv/
# Packaging / build artifacts
/smartthings_local/_version.py
/dist/
*.egg-info/
# Bridge runtime # Bridge runtime
bridge.log bridge.log
course_mapper.log course_mapper.log
+2 -3
View File
@@ -8,9 +8,8 @@ RUN pip install --no-cache-dir -r requirements.txt
# Application code — build context is the repo root (see # Application code — build context is the repo root (see
# docker-compose.yml's `context: ..`) since mqtt_demo/ depends on the # docker-compose.yml's `context: ..`) since mqtt_demo/ depends on the
# protocol/ and ocf/ library packages that live alongside it. # smartthings_local library package that lives alongside it.
COPY protocol/ ./protocol/ COPY smartthings_local/ ./smartthings_local/
COPY ocf/ ./ocf/
COPY mqtt_demo/ ./mqtt_demo/ COPY mqtt_demo/ ./mqtt_demo/
# /config holds the ab0b0ac4 client cert + key. Mount from the host so # /config holds the ab0b0ac4 client cert + key. Mount from the host so
+5 -5
View File
@@ -23,12 +23,12 @@ import time
import cbor2 import cbor2
from protocol.dtls_session import DtlsCoapSession, fmt_code from smartthings_local.protocol.dtls_session import DtlsCoapSession, fmt_code
from ocf.keepalive import KeepaliveTask from smartthings_local.ocf.keepalive import KeepaliveTask
from ocf.observe_refresh import ObserveRefreshTask from smartthings_local.ocf.observe_refresh import ObserveRefreshTask
from ocf.poll_scheduler import PollScheduler from smartthings_local.ocf.poll_scheduler import PollScheduler
from ocf.state_cache import StateCache from smartthings_local.ocf.state_cache import StateCache
from .descriptor import ApplianceDescriptor, bridge_diagnostic_discovery from .descriptor import ApplianceDescriptor, bridge_diagnostic_discovery
from .config import ApplianceConfig, SharedConfig from .config import ApplianceConfig, SharedConfig
+4 -5
View File
@@ -22,7 +22,7 @@ set -e
# This script lives in mqtt_demo/ but the build context is the repo # This script lives in mqtt_demo/ but the build context is the repo
# root (mqtt_demo/docker-compose.yml uses `context: ..`, since the # root (mqtt_demo/docker-compose.yml uses `context: ..`, since the
# image needs the protocol/ and ocf/ library packages alongside # image needs the smartthings_local library package alongside
# mqtt_demo/). Run everything from the repo root so the tar allowlist # mqtt_demo/). Run everything from the repo root so the tar allowlist
# and remote layout line up with that context. # and remote layout line up with that context.
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
@@ -51,12 +51,11 @@ ssh "${SSH_HOST}" mkdir -p "${REMOTE_DIR}" "${APPDATA_DIR}"
# Source code — explicit allowlist instead of an excludelist. Anything # Source code — explicit allowlist instead of an excludelist. Anything
# else in the repo (research files, certs, logs, the .git dir) stays # else in the repo (research files, certs, logs, the .git dir) stays
# local. protocol/ and ocf/ are the library packages mqtt_demo/ imports # local. smartthings_local/ is the library package mqtt_demo/ imports
# from; they need to land as REMOTE_DIR's siblings of mqtt_demo/ so the # from; it needs to land as REMOTE_DIR's sibling of mqtt_demo/ so the
# compose file's `context: ..` resolves the same way it does locally. # compose file's `context: ..` resolves the same way it does locally.
COPYFILE_DISABLE=1 tar cz \ COPYFILE_DISABLE=1 tar cz \
protocol/ \ smartthings_local/ \
ocf/ \
mqtt_demo/ \ mqtt_demo/ \
README.md \ README.md \
.gitignore \ .gitignore \
+1 -1
View File
@@ -27,7 +27,7 @@ from dataclasses import dataclass, field
from typing import Callable, Optional, TYPE_CHECKING from typing import Callable, Optional, TYPE_CHECKING
if TYPE_CHECKING: if TYPE_CHECKING:
from ocf.poll_scheduler import PollTier from smartthings_local.ocf.poll_scheduler import PollTier
@dataclass @dataclass
+1 -1
View File
@@ -13,7 +13,7 @@ from ..descriptor import (
device_block, device_block,
encode, encode,
) )
from ocf.poll_scheduler import PollTier from smartthings_local.ocf.poll_scheduler import PollTier
# --- OBSERVE paths ----------------------------------------------------- # --- OBSERVE paths -----------------------------------------------------
+1 -1
View File
@@ -30,7 +30,7 @@ from ..descriptor import (
device_block, device_block,
encode, encode,
) )
from ocf.poll_scheduler import PollTier from smartthings_local.ocf.poll_scheduler import PollTier
MODEL = 'ARTIK051_REF_17K' MODEL = 'ARTIK051_REF_17K'
+1 -1
View File
@@ -31,7 +31,7 @@ from ..descriptor import (
device_block, device_block,
encode, encode,
) )
from ocf.poll_scheduler import PollTier from smartthings_local.ocf.poll_scheduler import PollTier
# --------------------------------------------------------------------- # ---------------------------------------------------------------------
+64
View File
@@ -0,0 +1,64 @@
[build-system]
requires = ["hatchling", "hatch-vcs"]
build-backend = "hatchling.build"
[project]
name = "smartthings-local"
dynamic = ["version"]
description = "Local CoAP-over-DTLS (OCF) transport + polling layer for Samsung SmartThings appliances"
readme = "README.md"
requires-python = ">=3.11"
license = "MIT"
license-files = ["LICENSE"]
authors = [{ name = "Jack Nagy" }]
keywords = ["smartthings", "samsung", "ocf", "coap", "dtls", "home-assistant", "iot"]
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"Topic :: Home Automation",
"Topic :: System :: Networking",
"Programming Language :: Python :: 3",
]
dependencies = [
"cbor2>=5.6",
"pyOpenSSL>=23.0",
]
[project.urls]
Homepage = "https://github.com/QuiteYellow/SmartThings-Local"
Repository = "https://github.com/QuiteYellow/SmartThings-Local"
Issues = "https://github.com/QuiteYellow/SmartThings-Local/issues"
[project.optional-dependencies]
dev = ["pytest>=8.0"]
[tool.hatch.version]
source = "vcs"
# Derive the version from git tags of the form v1.2.3.
[tool.hatch.version.raw-options]
version_scheme = "no-guess-dev"
[tool.hatch.build.hooks.vcs]
version-file = "smartthings_local/_version.py"
# ocf_root_ca.pem is tracked, but the repo .gitignore's `*.pem` rule makes
# hatchling's VCS-based file selection drop it. It's loaded at runtime by
# protocol/dtls_session.py (load_verify_locations), so force it into every
# build target (wheel + sdist).
[tool.hatch.build]
artifacts = ["smartthings_local/protocol/ocf_root_ca.pem"]
[tool.hatch.build.targets.wheel]
# Ship only the library; the mqtt_demo/, tests/, and setup_cert.py stay
# in-repo and are not part of the distributable package.
packages = ["smartthings_local"]
[tool.hatch.build.targets.sdist]
include = [
"smartthings_local",
"tests",
"README.md",
"LICENSE",
"pyproject.toml",
]
+2
View File
@@ -0,0 +1,2 @@
"""smartthings-local: local CoAP-over-DTLS (OCF) transport + polling layer
for Samsung SmartThings appliances."""
@@ -20,7 +20,7 @@ from __future__ import annotations
import threading import threading
from typing import Callable, Optional from typing import Callable, Optional
from protocol.dtls_session import DtlsCoapSession from smartthings_local.protocol.dtls_session import DtlsCoapSession
class KeepaliveTask: class KeepaliveTask:
@@ -17,7 +17,7 @@ from __future__ import annotations
import threading import threading
from typing import Optional from typing import Optional
from protocol.dtls_session import DtlsCoapSession from smartthings_local.protocol.dtls_session import DtlsCoapSession
class ObserveRefreshTask: class ObserveRefreshTask:
@@ -36,7 +36,7 @@ from typing import Callable, Optional, TYPE_CHECKING
import cbor2 import cbor2
from protocol.dtls_session import DtlsCoapSession, fmt_code from smartthings_local.protocol.dtls_session import DtlsCoapSession, fmt_code
if TYPE_CHECKING: if TYPE_CHECKING:
from .state_cache import StateCache from .state_cache import StateCache
+1 -1
View File
@@ -1,4 +1,4 @@
from protocol.coap import ( from smartthings_local.protocol.coap import (
build_coap, parse_coap, encode_options, block_value, fmt_code, build_coap, parse_coap, encode_options, block_value, fmt_code,
TYPE_CON, METHOD_GET, URI_PATH, ACCEPT, CF_CBOR, BLOCK2, TYPE_CON, METHOD_GET, URI_PATH, ACCEPT, CF_CBOR, BLOCK2,
) )
+14 -15
View File
@@ -1,8 +1,8 @@
"""protocol/ and ocf/ must be vendorable on their own — no dependency """smartthings_local (protocol/ + ocf/) must be vendorable on its own — no
on mqtt_demo/. This copies just those two directories into an empty dependency on mqtt_demo/. This copies just the smartthings_local package into
temp dir and imports every module in them there, so a stray an empty temp dir and imports every module in it there, so a stray
`from mqtt_demo... import ...` fails loudly instead of silently `from mqtt_demo... import ...` fails loudly instead of silently passing
passing because mqtt_demo/ happens to also be on sys.path in-repo.""" because mqtt_demo/ happens to also be on sys.path in-repo."""
import os import os
import shutil import shutil
import subprocess import subprocess
@@ -12,17 +12,16 @@ from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent REPO_ROOT = Path(__file__).resolve().parent.parent
def test_protocol_and_ocf_import_without_mqtt_demo_present(tmp_path): def test_smartthings_local_imports_without_mqtt_demo_present(tmp_path):
for pkg in ('protocol', 'ocf'): shutil.copytree(REPO_ROOT / "smartthings_local", tmp_path / "smartthings_local")
shutil.copytree(REPO_ROOT / pkg, tmp_path / pkg)
import_lines = [ import_lines = [
"import protocol.coap", "import smartthings_local.protocol.coap",
"import protocol.dtls_session", "import smartthings_local.protocol.dtls_session",
"import ocf.state_cache", "import smartthings_local.ocf.state_cache",
"import ocf.poll_scheduler", "import smartthings_local.ocf.poll_scheduler",
"import ocf.keepalive", "import smartthings_local.ocf.keepalive",
"import ocf.observe_refresh", "import smartthings_local.ocf.observe_refresh",
] ]
script = "\n".join(import_lines) + "\nprint('OK')\n" script = "\n".join(import_lines) + "\nprint('OK')\n"
@@ -36,7 +35,7 @@ def test_protocol_and_ocf_import_without_mqtt_demo_present(tmp_path):
capture_output=True, text=True, capture_output=True, text=True,
) )
assert result.returncode == 0, ( assert result.returncode == 0, (
f"protocol/ocf failed to import without mqtt_demo/ present:\n" f"smartthings_local failed to import without mqtt_demo/ present:\n"
f"stdout: {result.stdout}\nstderr: {result.stderr}" f"stdout: {result.stdout}\nstderr: {result.stderr}"
) )
assert "OK" in result.stdout assert "OK" in result.stdout
+1 -1
View File
@@ -1,5 +1,5 @@
# tests/test_state_cache.py # tests/test_state_cache.py
from ocf.state_cache import StateCache from smartthings_local.ocf.state_cache import StateCache
class _FakeDescriptor: class _FakeDescriptor: