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:
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 \
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 -----------------------------------------------------
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------
|
# ---------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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,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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,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:
|
||||||
|
|||||||
Reference in New Issue
Block a user