Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
allow-prereleases: true
- name: Install dependencies
run: |
pipx install poetry
poetry install
- name: Unit tests
run: poetry run pytest test/unit -v --cov=awsiotclient --cov-branch --cov-report=term-missing
4 changes: 2 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
!test/certs/AmazonRootCA1.pem
test/certs/*
test/e2e/certs/*
!test/e2e/certs/AmazonRootCA1.pem

# Created by https://www.toptal.com/developers/gitignore/api/python
# Edit at https://www.toptal.com/developers/gitignore?templates=python
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,12 @@ job_client = jobs.client(
# <wait until the client receives job>
```

## Development

### Why `poetry.lock` is not committed

This is a library, not an application. Users resolve dependencies via `pyproject.toml` constraints, so a lock file provides no benefit to them. Additionally, we support Python 3.8–3.14, and a single lock file cannot cover all versions. CI runs `poetry lock` on each build to test against the latest compatible dependencies.
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This says CI runs poetry lock on each build, but the workflow only runs poetry install. Either add an explicit poetry lock step in CI (if that’s the intent) or adjust this documentation to match the actual CI behavior.

Suggested change
This is a library, not an application. Users resolve dependencies via `pyproject.toml` constraints, so a lock file provides no benefit to them. Additionally, we support Python 3.8–3.14, and a single lock file cannot cover all versions. CI runs `poetry lock` on each build to test against the latest compatible dependencies.
This is a library, not an application. Users resolve dependencies via `pyproject.toml` constraints, so a lock file provides no benefit to them. Additionally, we support Python 3.8–3.14, and a single lock file cannot cover all versions. In CI, dependencies are installed from `pyproject.toml` to test against the latest compatible versions.

Copilot uses AI. Check for mistakes.

Comment on lines +115 to +116
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This section says the project supports Python 3.8–3.14, but earlier in the README the minimum requirement is listed as Python 3.7.1+. These statements conflict; please align the documented supported Python range with the actual package requirement and CI matrix.

Copilot uses AI. Check for mistakes.
## License

This library is licensed under the Apache 2.0 License.
Expand Down
7 changes: 7 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ flake8 = {version = "^6.0.0", python = ">=3.8.1"}
flake8-pyproject = "^1.2.3"
isort = {version = "^5.12.0", python = ">=3.8"}
mypy = "^1.3.0"
pytest = "^7.0.0"
pytest-cov = {version = "^5.0.0", python = ">=3.8,<4.0.0"}
Comment on lines +21 to +22
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dev dependency pytest = "^7.0.0" won’t install on newer Python versions in the CI matrix (pytest 7.x doesn’t support Python 3.12+). Either bump pytest to a version that supports the full supported Python range, or reduce the CI matrix to only versions supported by the chosen pytest major.

Copilot uses AI. Check for mistakes.

[tool.pytest.ini_options]
testpaths = ["test"]
# Disable ROS2 (jazzy) pytest plugins that interfere when installed system-wide
addopts = "-p no:launch_testing -p no:launch_ros -p no:ament_copyright -p no:ament_xmllint -p no:ament_pep257 -p no:ament_mypy -p no:ament_lint -p no:ament_flake8"

[tool.isort]
profile = "black"
Expand Down
3 changes: 2 additions & 1 deletion src/awsiotclient/shadow.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ class ShadowClientCommon(ABC):
client: iotshadow.IotShadowClient
thing_name: str
property_name: Optional[str]
locked_data = ShadowData()
locked_data: ShadowData
qos: mqtt.QoS
publish_full_doc: bool

Expand All @@ -127,6 +127,7 @@ def __init__(
self.delta_func = delta_func
self.desired_func = desired_func
self.publish_full_doc = publish_full_doc
self.locked_data = ShadowData()

def __filter_property(self, v: ShadowDocument) -> ShadowDocument:
if self.property_name is None:
Expand Down
158 changes: 150 additions & 8 deletions test/README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,153 @@
## 準備
## ディレクトリ構成

```
test/
├── e2e/ # AWS IoT実環境へ接続するE2Eテスト
│ ├── common.py
│ ├── test_mqtt.py
│ ├── test_pubsub.py
│ ├── test_jobs.py
│ ├── test_classic_shadow.py
│ └── test_named_shadow.py
└── unit/ # mockを使ったユニットテスト
├── test_mqtt.py
├── test_pubsub.py
├── test_shadow.py
├── test_shadow_callbacks.py
├── test_classic_shadow.py
├── test_named_shadow.py
├── test_jobs.py
├── test_jobs_callbacks.py
└── test_dictdiff.py
```

## テスト一覧

### unit

| ファイル | テストクラス | テストケース |
| --- | --- | --- |
| test_dictdiff.py | DictdiffTestCase | test_dictdiff_no_difference |
| | | test_dictdiff_from_empty |
| | | test_dictdiff_to_empty |
| | | test_dictdiff_add_items |
| | | test_dictdiff_remove_items |
| | | test_dictdiff_update_items |
| | | test_dictdiff_update_dict |
| test_mqtt.py | TestConnectionParams | test_defaults |
| | | test_expands_home |
| | TestInit | test_mtls |
| | | test_websocket |
| | TestCallbacks | test_on_resubscribe_complete_raises_on_rejected |
| | | test_on_resubscribe_complete_succeeds |
| | TestConnectionCallbacks | test_on_connection_interrupted |
| | | test_on_connection_resumed_resubscribes_when_session_not_present |
| | | test_on_connection_resumed_skips_resubscribe_when_session_present |
| | TestWebsocketProxy | test_init_websocket_with_proxy_options |
| test_pubsub.py | TestSubscriber | test_subscribes_to_topic |
| | | test_callback_parses_json |
| | TestPublisher | test_publishes_payload |
| | | test_empty_payload_does_not_publish |
| test_shadow.py | TestDocumentTracker | test_get_set |
| | | test_update_same_value_returns_none |
| | | test_update_diff_only |
| | | test_update_full_doc |
| | TestShadowData | test_update_reported |
| | | test_update_desired |
| | | test_update_both |
| test_classic_shadow.py | TestClassicShadowClient | test_subscribes_on_init |
| | | test_publishes_get_shadow_on_init |
| | | test_update_shadow_request_publishes |
| | | test_update_noop_when_both_none |
| test_named_shadow.py | TestNamedShadowClient | test_subscribes_with_shadow_name |
| | | test_publishes_get_named_shadow_on_init |
| | | test_update_shadow_request_publishes |
| | | test_label_returns_shadow_name |
| | | test_update_noop_when_both_none |
| | | test_get_accepted_uses_full_document |
| test_jobs.py | TestJobsClient | test_subscribes_on_init |
| | | test_try_start_next_job_publishes |
| | | test_try_start_next_job_skips_when_already_working |
| | | test_job_thread_fn_calls_job_func_and_reports_succeeded |
| | | test_job_thread_fn_reports_failed_on_exception |
| test_shadow_callbacks.py | TestShadowCallbacks | test_delta_ignored_when_state_is_none |
| | | test_delta_deleted_property_resets_reported |
| | | test_delta_invalid_request_resets_desired |
| | | test_delta_unexpected_exception_is_reraised |
| | | test_get_shadow_accepted_uses_reported_state |
| | | test_get_shadow_accepted_ignores_when_delta_present |
| | | test_get_shadow_accepted_sets_default_when_property_missing |
| | | test_get_shadow_accepted_returns_early_when_reported_already_set |
| | | test_get_shadow_accepted_reraises_unexpected_errors |
| | | test_get_shadow_rejected_404_sets_default |
| | | test_get_shadow_rejected_non_404_raises |
| | | test_update_shadow_accepted_sets_desired_and_calls_callback |
| | | test_update_shadow_accepted_reraises_when_desired_callback_fails |
| | | test_update_shadow_rejected_raises |
| | | test_on_publish_update_shadow_raises_when_future_fails |
| | | test_change_both_values_publishes_update |
| | | test_change_desired_value_publishes_update |
| test_jobs_callbacks.py | TestJobsCallbacks | test_try_start_next_job_skips_when_disconnect_called |
| | | test_init_wraps_subscription_failure |
| | | test_done_working_on_job_retries_when_waiting |
| | | test_on_next_job_execution_changed_none |
| | | test_on_next_job_execution_changed_sets_wait_flag_while_working |
| | | test_on_next_job_execution_changed_starts_now_when_idle |
| | | test_on_next_job_execution_changed_wraps_unexpected_error |
| | | test_on_publish_start_next_pending_job_execution_raises |
| | | test_start_next_pending_job_accepted_spawns_thread |
| | | test_start_next_pending_job_accepted_wraps_thread_creation_error |
| | | test_start_next_pending_job_accepted_without_execution_marks_done |
| | | test_start_next_pending_job_rejected_raises |
| | | test_job_thread_user_defined_failure_sets_status_details |
| | | test_on_publish_update_job_execution_raises |
| | | test_on_update_job_execution_accepted_calls_done |
| | | test_on_update_job_execution_accepted_wraps_done_error |
| | | test_on_update_job_execution_rejected_raises |

### e2e

| ファイル | テストクラス | テストケース |
| --- | --- | --- |
| test_mqtt.py | TestMqtt | test_mqtt_setup |
| test_pubsub.py | TestPubSub | test_pubsub |
| test_jobs.py | TestJobs | test_jobs |
| test_classic_shadow.py | TestClassicShadow | test_classic_shadow_reported |
| | | test_classic_shadow_reported_value_delta |
| | | test_classic_shadow_desired_matches_with_reported |
| test_named_shadow.py | TestNamedShadow | test_named_shadow_reported |
| | | test_named_shadow_reported_value_delta |
| | | test_named_shadow_desired_matches_with_reported |

## テストの実行

```bash
# ユニットテストのみ
PYTHONPATH=src pytest test/unit -v

# E2Eテスト(要AWS認証情報)
source ./test/env.sh
PYTHONPATH=src pytest test/e2e -v

# 全テスト
source ./test/env.sh
PYTHONPATH=src pytest test -v

# ユニットテストのカバレッジ確認(推奨: pytest-cov)
PYTHONPATH=src pytest -q test/unit \
--cov=awsiotclient \
--cov-branch \
--cov-report=term-missing \
--cov-fail-under=95

# 補助: pytest-cov が利用できない環境では trace を使用
# (pytest-cov は Python 3.8+ で利用可能)
PYTHONPATH=src python -m trace --count --missing --summary --module pytest -q test/unit
```

`awsiotclient` がグローバル環境にもインストールされている場合、`PYTHONPATH=src` を付けないと `src/` ではなく `site-packages` の実装を参照する可能性があります。

## E2Eテストの準備

```bash
# モノの作成
Comment on lines +150 to 153
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the following E2E setup section, the example commands still write cert/key files under ./test/certs/..., but the new directory structure and test/e2e/common.py expect them under test/e2e/certs/. Update the paths in this section so the generated files match what the E2E tests load.

Copilot uses AI. Check for mistakes.
Expand All @@ -21,10 +170,3 @@ aws iot attach-policy \
--target "$(jq -r .certificateArn < ./test/certs/cert.json)" \
--policy-name <policy>
```

## テストの実行

```bash
source ./test/env.sh
python3 -m unittest
```
Empty file added test/e2e/__init__.py
Empty file.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Empty file added test/unit/__init__.py
Empty file.
88 changes: 88 additions & 0 deletions test/unit/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
from concurrent.futures import Future
from unittest.mock import MagicMock

import pytest


def _done_future(value=None):
future = Future()
future.set_result(value)
return future


@pytest.fixture
def make_done_future():
return _done_future


@pytest.fixture
def make_classic_shadow_client(make_done_future):
def factory():
mock_client = MagicMock()
for method_name in [
"subscribe_to_shadow_delta_updated_events",
"subscribe_to_update_shadow_accepted",
"subscribe_to_update_shadow_rejected",
"subscribe_to_get_shadow_accepted",
"subscribe_to_get_shadow_rejected",
]:
getattr(mock_client, method_name).return_value = (make_done_future(), 1)

mock_client.publish_get_shadow.return_value = make_done_future()
mock_client.publish_update_shadow.return_value = make_done_future()
return mock_client

return factory


@pytest.fixture
def make_named_shadow_client(make_done_future):
def factory():
mock_client = MagicMock()
for method_name in [
"subscribe_to_named_shadow_delta_updated_events",
"subscribe_to_update_named_shadow_accepted",
"subscribe_to_update_named_shadow_rejected",
"subscribe_to_get_named_shadow_accepted",
"subscribe_to_get_named_shadow_rejected",
]:
getattr(mock_client, method_name).return_value = (make_done_future(), 1)

mock_client.publish_get_named_shadow.return_value = make_done_future()
mock_client.publish_update_named_shadow.return_value = make_done_future()
return mock_client

return factory


@pytest.fixture
def make_jobs_client(make_done_future):
def factory():
mock_client = MagicMock()
for method_name in [
"subscribe_to_next_job_execution_changed_events",
"subscribe_to_start_next_pending_job_execution_accepted",
"subscribe_to_start_next_pending_job_execution_rejected",
"subscribe_to_update_job_execution_accepted",
"subscribe_to_update_job_execution_rejected",
]:
getattr(mock_client, method_name).return_value = (make_done_future(), 1)

mock_client.publish_start_next_pending_job_execution.return_value = (
make_done_future()
)
mock_client.publish_update_job_execution.return_value = make_done_future()
return mock_client

return factory


@pytest.fixture
def make_pubsub_connection(make_done_future):
def factory(qos):
connection = MagicMock()
connection.subscribe.return_value = (make_done_future({"qos": qos}), 1)
connection.publish.return_value = (make_done_future(None), 1)
return connection

return factory
59 changes: 59 additions & 0 deletions test/unit/test_classic_shadow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
from unittest.mock import MagicMock, patch

from awscrt import mqtt

import awsiotclient.classic_shadow as classic_shadow


class TestClassicShadowClient:
@patch("awsiotclient.classic_shadow.iotshadow.IotShadowClient")
def test_subscribes_on_init(self, MockShadowClient, make_classic_shadow_client):
mock_client = make_classic_shadow_client()
MockShadowClient.return_value = mock_client

conn = MagicMock(spec=mqtt.Connection)
classic_shadow.client(conn, "thing1", "prop1")

mock_client.subscribe_to_shadow_delta_updated_events.assert_called_once()
mock_client.subscribe_to_update_shadow_accepted.assert_called_once()
mock_client.subscribe_to_update_shadow_rejected.assert_called_once()
mock_client.subscribe_to_get_shadow_accepted.assert_called_once()
mock_client.subscribe_to_get_shadow_rejected.assert_called_once()

@patch("awsiotclient.classic_shadow.iotshadow.IotShadowClient")
def test_publishes_get_shadow_on_init(self, MockShadowClient, make_classic_shadow_client):
mock_client = make_classic_shadow_client()
MockShadowClient.return_value = mock_client

conn = MagicMock(spec=mqtt.Connection)
classic_shadow.client(conn, "thing1", "prop1")

mock_client.publish_get_shadow.assert_called_once()

@patch("awsiotclient.classic_shadow.iotshadow.IotShadowClient")
def test_update_shadow_request_publishes(
self, MockShadowClient, make_classic_shadow_client
):
mock_client = make_classic_shadow_client()
MockShadowClient.return_value = mock_client

conn = MagicMock(spec=mqtt.Connection)
c = classic_shadow.client(conn, "thing1", "prop1")

mock_client.publish_update_shadow.reset_mock()
c.change_reported_value({"temp": 25})

mock_client.publish_update_shadow.assert_called_once()

@patch("awsiotclient.classic_shadow.iotshadow.IotShadowClient")
def test_update_noop_when_both_none(self, MockShadowClient, make_classic_shadow_client):
mock_client = make_classic_shadow_client()
MockShadowClient.return_value = mock_client

conn = MagicMock(spec=mqtt.Connection)
c = classic_shadow.client(conn, "thing1", "prop1")

mock_client.publish_update_shadow.reset_mock()
c.update_shadow_request(desired=None, reported=None)

mock_client.publish_update_shadow.assert_not_called()
File renamed without changes.
Loading