edgesync 0.2.1__tar.gz → 0.3.2__tar.gz
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.
- {edgesync-0.2.1 → edgesync-0.3.2}/.github/workflows/release.yml +51 -11
- {edgesync-0.2.1 → edgesync-0.3.2}/CHANGELOG.md +39 -1
- {edgesync-0.2.1 → edgesync-0.3.2}/CONTRIBUTING.md +19 -9
- {edgesync-0.2.1 → edgesync-0.3.2}/PKG-INFO +67 -3
- {edgesync-0.2.1 → edgesync-0.3.2}/README.md +64 -2
- edgesync-0.3.2/VERSION +1 -0
- {edgesync-0.2.1 → edgesync-0.3.2}/docs/getting-started.md +61 -0
- edgesync-0.3.2/edgesync/transports/mqtt.py +134 -0
- {edgesync-0.2.1 → edgesync-0.3.2}/pyproject.toml +3 -0
- edgesync-0.3.2/tests/integration/test_mqtt_transport.py +195 -0
- {edgesync-0.2.1 → edgesync-0.3.2}/uv.lock +29 -0
- edgesync-0.2.1/VERSION +0 -1
- {edgesync-0.2.1 → edgesync-0.3.2}/.github/workflows/ci.yml +0 -0
- {edgesync-0.2.1 → edgesync-0.3.2}/.gitignore +0 -0
- {edgesync-0.2.1 → edgesync-0.3.2}/LICENSE +0 -0
- {edgesync-0.2.1 → edgesync-0.3.2}/SECURITY.md +0 -0
- {edgesync-0.2.1 → edgesync-0.3.2}/docs/integrations.md +0 -0
- {edgesync-0.2.1 → edgesync-0.3.2}/docs/reliability.md +0 -0
- {edgesync-0.2.1 → edgesync-0.3.2}/docs/storage.md +0 -0
- {edgesync-0.2.1 → edgesync-0.3.2}/edgesync/__init__.py +0 -0
- {edgesync-0.2.1 → edgesync-0.3.2}/edgesync/client.py +0 -0
- {edgesync-0.2.1 → edgesync-0.3.2}/edgesync/config.py +0 -0
- {edgesync-0.2.1 → edgesync-0.3.2}/edgesync/exceptions.py +0 -0
- {edgesync-0.2.1 → edgesync-0.3.2}/edgesync/logging.py +0 -0
- {edgesync-0.2.1 → edgesync-0.3.2}/edgesync/models/__init__.py +0 -0
- {edgesync-0.2.1 → edgesync-0.3.2}/edgesync/models/delivery.py +0 -0
- {edgesync-0.2.1 → edgesync-0.3.2}/edgesync/models/message.py +0 -0
- {edgesync-0.2.1 → edgesync-0.3.2}/edgesync/models/receipt.py +0 -0
- {edgesync-0.2.1 → edgesync-0.3.2}/edgesync/models/stats.py +0 -0
- {edgesync-0.2.1 → edgesync-0.3.2}/edgesync/py.typed +0 -0
- {edgesync-0.2.1 → edgesync-0.3.2}/edgesync/queue/__init__.py +0 -0
- {edgesync-0.2.1 → edgesync-0.3.2}/edgesync/queue/manager.py +0 -0
- {edgesync-0.2.1 → edgesync-0.3.2}/edgesync/retry/__init__.py +0 -0
- {edgesync-0.2.1 → edgesync-0.3.2}/edgesync/retry/backoff.py +0 -0
- {edgesync-0.2.1 → edgesync-0.3.2}/edgesync/retry/policy.py +0 -0
- {edgesync-0.2.1 → edgesync-0.3.2}/edgesync/storage/__init__.py +0 -0
- {edgesync-0.2.1 → edgesync-0.3.2}/edgesync/storage/base.py +0 -0
- {edgesync-0.2.1 → edgesync-0.3.2}/edgesync/storage/migrations.py +0 -0
- {edgesync-0.2.1 → edgesync-0.3.2}/edgesync/storage/sqlite.py +0 -0
- {edgesync-0.2.1 → edgesync-0.3.2}/edgesync/transports/__init__.py +0 -0
- {edgesync-0.2.1 → edgesync-0.3.2}/edgesync/transports/base.py +0 -0
- {edgesync-0.2.1 → edgesync-0.3.2}/edgesync/transports/http.py +0 -0
- {edgesync-0.2.1 → edgesync-0.3.2}/edgesync/transports/registry.py +0 -0
- {edgesync-0.2.1 → edgesync-0.3.2}/edgesync/utils/__init__.py +0 -0
- {edgesync-0.2.1 → edgesync-0.3.2}/edgesync/utils/clock.py +0 -0
- {edgesync-0.2.1 → edgesync-0.3.2}/edgesync/utils/ids.py +0 -0
- {edgesync-0.2.1 → edgesync-0.3.2}/edgesync/worker/__init__.py +0 -0
- {edgesync-0.2.1 → edgesync-0.3.2}/edgesync/worker/lifecycle.py +0 -0
- {edgesync-0.2.1 → edgesync-0.3.2}/edgesync/worker/scheduler.py +0 -0
- {edgesync-0.2.1 → edgesync-0.3.2}/edgesync/worker/sync_worker.py +0 -0
- {edgesync-0.2.1 → edgesync-0.3.2}/tests/__init__.py +0 -0
- {edgesync-0.2.1 → edgesync-0.3.2}/tests/conftest.py +0 -0
- {edgesync-0.2.1 → edgesync-0.3.2}/tests/fixtures/__init__.py +0 -0
- {edgesync-0.2.1 → edgesync-0.3.2}/tests/fixtures/factories.py +0 -0
- {edgesync-0.2.1 → edgesync-0.3.2}/tests/fixtures/fake_transport.py +0 -0
- {edgesync-0.2.1 → edgesync-0.3.2}/tests/integration/__init__.py +0 -0
- {edgesync-0.2.1 → edgesync-0.3.2}/tests/integration/test_client.py +0 -0
- {edgesync-0.2.1 → edgesync-0.3.2}/tests/integration/test_http_transport.py +0 -0
- {edgesync-0.2.1 → edgesync-0.3.2}/tests/integration/test_sqlite_storage.py +0 -0
- {edgesync-0.2.1 → edgesync-0.3.2}/tests/integration/test_worker.py +0 -0
- {edgesync-0.2.1 → edgesync-0.3.2}/tests/reliability/__init__.py +0 -0
- {edgesync-0.2.1 → edgesync-0.3.2}/tests/reliability/test_reliability.py +0 -0
- {edgesync-0.2.1 → edgesync-0.3.2}/tests/unit/__init__.py +0 -0
- {edgesync-0.2.1 → edgesync-0.3.2}/tests/unit/test_backoff.py +0 -0
- {edgesync-0.2.1 → edgesync-0.3.2}/tests/unit/test_config.py +0 -0
- {edgesync-0.2.1 → edgesync-0.3.2}/tests/unit/test_models.py +0 -0
- {edgesync-0.2.1 → edgesync-0.3.2}/tests/unit/test_queue_manager.py +0 -0
- {edgesync-0.2.1 → edgesync-0.3.2}/tests/unit/test_retry_policy.py +0 -0
|
@@ -2,6 +2,8 @@ name: Release
|
|
|
2
2
|
|
|
3
3
|
on:
|
|
4
4
|
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
paths: ["VERSION"]
|
|
5
7
|
tags:
|
|
6
8
|
- "v*.*.*"
|
|
7
9
|
|
|
@@ -42,26 +44,64 @@ jobs:
|
|
|
42
44
|
if: matrix.python-version == '3.13'
|
|
43
45
|
run: uv run mypy
|
|
44
46
|
|
|
45
|
-
|
|
46
|
-
name: verify
|
|
47
|
+
tag:
|
|
48
|
+
name: verify / create release tag
|
|
47
49
|
runs-on: ubuntu-latest
|
|
48
50
|
needs: test
|
|
51
|
+
permissions:
|
|
52
|
+
contents: write # required to push the tag when triggered by a VERSION bump
|
|
53
|
+
outputs:
|
|
54
|
+
version: ${{ steps.version.outputs.version }}
|
|
55
|
+
should_release: ${{ steps.verify_tag.outputs.should_release || steps.create_tag.outputs.should_release }}
|
|
49
56
|
steps:
|
|
50
57
|
- uses: actions/checkout@v4
|
|
58
|
+
with:
|
|
59
|
+
fetch-depth: 0
|
|
60
|
+
|
|
61
|
+
- name: Determine version and existing-tag status
|
|
62
|
+
id: version
|
|
63
|
+
run: |
|
|
64
|
+
VERSION="$(tr -d '[:space:]' < VERSION)"
|
|
65
|
+
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
|
66
|
+
if git ls-remote --exit-code --tags origin "refs/tags/v$VERSION" >/dev/null 2>&1; then
|
|
67
|
+
echo "exists=true" >> "$GITHUB_OUTPUT"
|
|
68
|
+
else
|
|
69
|
+
echo "exists=false" >> "$GITHUB_OUTPUT"
|
|
70
|
+
fi
|
|
51
71
|
|
|
52
|
-
- name:
|
|
72
|
+
- name: Verify tag matches VERSION file
|
|
73
|
+
id: verify_tag
|
|
74
|
+
if: startsWith(github.ref, 'refs/tags/')
|
|
53
75
|
run: |
|
|
54
76
|
TAG_VERSION="${GITHUB_REF_NAME#v}"
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
echo "::error::tag v$TAG_VERSION does not match VERSION file ($FILE_VERSION)"
|
|
77
|
+
if [ "$TAG_VERSION" != "${{ steps.version.outputs.version }}" ]; then
|
|
78
|
+
echo "::error::tag $GITHUB_REF_NAME does not match VERSION file (${{ steps.version.outputs.version }})"
|
|
58
79
|
exit 1
|
|
59
80
|
fi
|
|
81
|
+
echo "should_release=true" >> "$GITHUB_OUTPUT"
|
|
82
|
+
|
|
83
|
+
- name: Create and push release tag
|
|
84
|
+
id: create_tag
|
|
85
|
+
if: github.ref == 'refs/heads/main' && steps.version.outputs.exists == 'false'
|
|
86
|
+
run: |
|
|
87
|
+
VERSION="${{ steps.version.outputs.version }}"
|
|
88
|
+
git config user.name "github-actions[bot]"
|
|
89
|
+
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
90
|
+
git tag "v$VERSION"
|
|
91
|
+
git push origin "v$VERSION"
|
|
92
|
+
echo "should_release=true" >> "$GITHUB_OUTPUT"
|
|
93
|
+
|
|
94
|
+
- name: Skip (VERSION unchanged / tag already released)
|
|
95
|
+
if: github.ref == 'refs/heads/main' && steps.version.outputs.exists == 'true'
|
|
96
|
+
run: |
|
|
97
|
+
echo "::notice::v${{ steps.version.outputs.version }} already tagged/released; skipping publish."
|
|
98
|
+
echo "should_release=false" >> "$GITHUB_OUTPUT"
|
|
60
99
|
|
|
61
100
|
build:
|
|
62
101
|
name: build distributions
|
|
63
102
|
runs-on: ubuntu-latest
|
|
64
|
-
needs:
|
|
103
|
+
needs: tag
|
|
104
|
+
if: needs.tag.outputs.should_release == 'true'
|
|
65
105
|
steps:
|
|
66
106
|
- uses: actions/checkout@v4
|
|
67
107
|
|
|
@@ -107,7 +147,7 @@ jobs:
|
|
|
107
147
|
github-release:
|
|
108
148
|
name: create GitHub release
|
|
109
149
|
runs-on: ubuntu-latest
|
|
110
|
-
needs: publish-pypi
|
|
150
|
+
needs: [tag, publish-pypi]
|
|
111
151
|
permissions:
|
|
112
152
|
contents: write # required to create the GitHub release
|
|
113
153
|
steps:
|
|
@@ -122,7 +162,7 @@ jobs:
|
|
|
122
162
|
- name: Extract CHANGELOG section for this version
|
|
123
163
|
id: changelog
|
|
124
164
|
run: |
|
|
125
|
-
VERSION="$
|
|
165
|
+
VERSION="${{ needs.tag.outputs.version }}"
|
|
126
166
|
NOTES_FILE="$(mktemp)"
|
|
127
167
|
awk -v ver="$VERSION" '
|
|
128
168
|
$0 ~ "^## \\[" ver "\\]" { found=1; next }
|
|
@@ -134,11 +174,11 @@ jobs:
|
|
|
134
174
|
exit 1
|
|
135
175
|
fi
|
|
136
176
|
echo "notes_file=$NOTES_FILE" >> "$GITHUB_OUTPUT"
|
|
137
|
-
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
|
138
177
|
|
|
139
178
|
- name: Create GitHub release
|
|
140
179
|
uses: softprops/action-gh-release@v2
|
|
141
180
|
with:
|
|
142
|
-
|
|
181
|
+
tag_name: v${{ needs.tag.outputs.version }}
|
|
182
|
+
name: EdgeSync v${{ needs.tag.outputs.version }}
|
|
143
183
|
body_path: ${{ steps.changelog.outputs.notes_file }}
|
|
144
184
|
files: dist/*
|
|
@@ -7,6 +7,41 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.3.2] - 2026-08-24
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- MQTT TLS documentation in `README.md` and `docs/getting-started.md`, covering
|
|
15
|
+
`tls_params`/`port` usage for TLS connections and client-certificate (mutual TLS) auth, plus
|
|
16
|
+
test coverage for the `aiomqtt.Client` construction path (both plaintext and TLS), which
|
|
17
|
+
wasn't previously exercised since existing tests injected a fake client directly.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- `README.md`'s Quickstart now includes a full MQTT usage section (previously it only linked
|
|
22
|
+
out to the getting-started guide), so the MQTT example renders directly on PyPI's project
|
|
23
|
+
page instead of requiring a click-through to GitHub.
|
|
24
|
+
|
|
25
|
+
## [0.3.1] - 2026-08-24
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- The release workflow's `should_release` job output was never actually set (it read from
|
|
30
|
+
the wrong step), so `build distributions`, `publish to PyPI`, and `create GitHub release`
|
|
31
|
+
were silently skipped on every run, including 0.3.0's. 0.3.0's tag and changes are
|
|
32
|
+
unaffected; this release exists solely to get the pipeline publishing again.
|
|
33
|
+
|
|
34
|
+
## [0.3.0] - 2026-08-24
|
|
35
|
+
|
|
36
|
+
### Added
|
|
37
|
+
|
|
38
|
+
- `MQTTTransport` (`edgesync.transports.mqtt`), an optional MQTT destination backed by
|
|
39
|
+
`aiomqtt` (`pip install edgesync[mqtt]`). Only QoS 1/2 are accepted, since QoS 0 publishes
|
|
40
|
+
are never acknowledged by the broker and can't support at-least-once delivery. The
|
|
41
|
+
connection reconnects automatically after a dropped connection or failed publish, and
|
|
42
|
+
`start()` never raises if the broker is unreachable, consistent with EdgeSync's guarantee
|
|
43
|
+
that local queuing works independent of destination availability.
|
|
44
|
+
|
|
10
45
|
## [0.2.1] - 2026-08-24
|
|
11
46
|
|
|
12
47
|
### Added
|
|
@@ -62,7 +97,10 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
62
97
|
- Crash and restart recovery via expired-lease sweeps, safe across concurrent workers and
|
|
63
98
|
processes sharing the same database file.
|
|
64
99
|
|
|
65
|
-
[Unreleased]: https://github.com/adhuldas/EdgeSync/compare/v0.2
|
|
100
|
+
[Unreleased]: https://github.com/adhuldas/EdgeSync/compare/v0.3.2...HEAD
|
|
101
|
+
[0.3.2]: https://github.com/adhuldas/EdgeSync/compare/v0.3.1...v0.3.2
|
|
102
|
+
[0.3.1]: https://github.com/adhuldas/EdgeSync/compare/v0.3.0...v0.3.1
|
|
103
|
+
[0.3.0]: https://github.com/adhuldas/EdgeSync/compare/v0.2.1...v0.3.0
|
|
66
104
|
[0.2.1]: https://github.com/adhuldas/EdgeSync/compare/v0.2.0...v0.2.1
|
|
67
105
|
[0.2.0]: https://github.com/adhuldas/EdgeSync/compare/v0.1.0...v0.2.0
|
|
68
106
|
[0.1.0]: https://github.com/adhuldas/EdgeSync/releases/tag/v0.1.0
|
|
@@ -62,18 +62,28 @@ New behavior should come with a test in the appropriate tier. Reliability-sensit
|
|
|
62
62
|
## Releasing
|
|
63
63
|
|
|
64
64
|
`VERSION` (repo root) is the single source of truth for the package version — it's read by
|
|
65
|
-
`pyproject.toml` (via Hatchling) and by `edgesync.__version__` at runtime.
|
|
65
|
+
`pyproject.toml` (via Hatchling) and by `edgesync.__version__` at runtime. Releasing is
|
|
66
|
+
**automatic**: to cut a release, just
|
|
66
67
|
|
|
67
68
|
1. Bump the version in `VERSION` (no `v` prefix, e.g. `0.2.0`).
|
|
68
69
|
2. Move the `[Unreleased]` entries in `CHANGELOG.md` under a new `## [0.2.0] - YYYY-MM-DD`
|
|
69
|
-
heading.
|
|
70
|
-
3. Commit those two changes
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
70
|
+
heading — this section becomes the GitHub release notes, so make sure it's accurate.
|
|
71
|
+
3. Commit those two changes and push to `main`.
|
|
72
|
+
|
|
73
|
+
Pushing a commit that changes `VERSION` on `main` triggers `.github/workflows/release.yml`,
|
|
74
|
+
which runs the full test suite, tags the commit `vX.Y.Z`, builds the sdist/wheel, publishes
|
|
75
|
+
to PyPI via Trusted Publishing, and creates a GitHub release with notes pulled from the
|
|
76
|
+
matching `CHANGELOG.md` section. If a tag for that version already exists (e.g. the workflow
|
|
77
|
+
re-runs on an unrelated push, or the `VERSION` file is reverted), the whole pipeline is
|
|
78
|
+
skipped rather than re-publishing.
|
|
79
|
+
|
|
80
|
+
**Because of this, any commit that changes `VERSION` on `main` publishes to PyPI** — there's
|
|
81
|
+
no separate confirmation step. Double-check `VERSION` and the `CHANGELOG.md` entry before
|
|
82
|
+
pushing.
|
|
83
|
+
|
|
84
|
+
You can still trigger a release manually the old way (`git tag vX.Y.Z && git push origin
|
|
85
|
+
vX.Y.Z`) — useful if `VERSION` was already bumped but the automatic run failed for an
|
|
86
|
+
unrelated reason (e.g. a transient PyPI outage).
|
|
77
87
|
|
|
78
88
|
## Reporting bugs
|
|
79
89
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: edgesync
|
|
3
|
-
Version: 0.2
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Summary: Reliable data delivery for unreliable networks.
|
|
5
5
|
Project-URL: Homepage, https://github.com/adhuldas/EdgeSync
|
|
6
6
|
Project-URL: Repository, https://github.com/adhuldas/EdgeSync
|
|
@@ -27,6 +27,8 @@ Classifier: Typing :: Typed
|
|
|
27
27
|
Requires-Python: >=3.10
|
|
28
28
|
Requires-Dist: aiosqlite>=0.20
|
|
29
29
|
Requires-Dist: httpx>=0.27
|
|
30
|
+
Provides-Extra: mqtt
|
|
31
|
+
Requires-Dist: aiomqtt<3,>=2.5; extra == 'mqtt'
|
|
30
32
|
Description-Content-Type: text/markdown
|
|
31
33
|
|
|
32
34
|
# EdgeSync
|
|
@@ -92,6 +94,68 @@ That's it: `publish()` durably persists the message and returns as soon as it's
|
|
|
92
94
|
background worker delivers it, retrying with exponential backoff on failure, without the
|
|
93
95
|
caller needing to stay connected or wait for the network.
|
|
94
96
|
|
|
97
|
+
## MQTT
|
|
98
|
+
|
|
99
|
+
Install the optional extra first:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
pip install edgesync[mqtt]
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Publishing to MQTT instead of HTTP is the same pattern with a different transport:
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
from edgesync import EdgeSync
|
|
109
|
+
from edgesync.transports.mqtt import MQTTTransport
|
|
110
|
+
|
|
111
|
+
sync = EdgeSync(
|
|
112
|
+
database="edgesync.db",
|
|
113
|
+
destinations={
|
|
114
|
+
"telemetry": MQTTTransport(
|
|
115
|
+
"broker.example.com",
|
|
116
|
+
"devices/device-001/telemetry",
|
|
117
|
+
qos=1,
|
|
118
|
+
username="device-001",
|
|
119
|
+
password="...",
|
|
120
|
+
),
|
|
121
|
+
},
|
|
122
|
+
default_destination="telemetry",
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
await sync.publish({"temperature": 28.5})
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
`MQTTTransport` only accepts QoS 1 or 2 — QoS 0 publishes are never acknowledged by the
|
|
129
|
+
broker, so there'd be no way to tell a successful delivery from a lost one, which would break
|
|
130
|
+
EdgeSync's at-least-once guarantee. Unlike `HTTPTransport`, MQTT holds a persistent broker
|
|
131
|
+
connection: `EdgeSync.start()` still won't raise if the broker is unreachable (data keeps
|
|
132
|
+
queuing locally either way), and the transport automatically reconnects on the next delivery
|
|
133
|
+
attempt after a dropped connection or failed publish.
|
|
134
|
+
|
|
135
|
+
**TLS**: by default `MQTTTransport` connects in plaintext on port `1883`. To connect over
|
|
136
|
+
TLS, pass `tls_params` (an `aiomqtt.TLSParameters`) and switch to the broker's TLS port,
|
|
137
|
+
conventionally `8883`:
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
import aiomqtt
|
|
141
|
+
from edgesync.transports.mqtt import MQTTTransport
|
|
142
|
+
|
|
143
|
+
MQTTTransport(
|
|
144
|
+
"broker.example.com",
|
|
145
|
+
"devices/device-001/telemetry",
|
|
146
|
+
port=8883,
|
|
147
|
+
tls_params=aiomqtt.TLSParameters(
|
|
148
|
+
ca_certs="/etc/ssl/certs/ca-certificates.crt",
|
|
149
|
+
),
|
|
150
|
+
)
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
`TLSParameters` also accepts `certfile` / `keyfile` for client-certificate (mutual TLS)
|
|
154
|
+
authentication. Omit `tls_params` entirely for an unencrypted connection, e.g. a broker on a
|
|
155
|
+
trusted local network or a local development/test setup. See the
|
|
156
|
+
[MQTT section of the getting-started guide](https://github.com/adhuldas/EdgeSync/blob/main/docs/getting-started.md#mqtt)
|
|
157
|
+
for further detail.
|
|
158
|
+
|
|
95
159
|
## Usage with FastAPI, Flask, and plain asyncio
|
|
96
160
|
|
|
97
161
|
EdgeSync is async-native, so it's most at home in an `asyncio` app or an async framework like
|
|
@@ -130,8 +194,8 @@ guide, including the ready-to-use Flask bridge.
|
|
|
130
194
|
inspection instead of being silently dropped.
|
|
131
195
|
- **Multiple destinations** — route different message types to different endpoints or
|
|
132
196
|
transports.
|
|
133
|
-
- **Pluggable transports** — ships with
|
|
134
|
-
else (
|
|
197
|
+
- **Pluggable transports** — ships with HTTP and MQTT transports; implement `Transport` for
|
|
198
|
+
anything else (gRPC, a message broker, ...).
|
|
135
199
|
- **Bounded storage** — configurable queue capacity with a choice of overflow policies.
|
|
136
200
|
- **Async-native** — built on `asyncio` and `httpx`.
|
|
137
201
|
|
|
@@ -61,6 +61,68 @@ That's it: `publish()` durably persists the message and returns as soon as it's
|
|
|
61
61
|
background worker delivers it, retrying with exponential backoff on failure, without the
|
|
62
62
|
caller needing to stay connected or wait for the network.
|
|
63
63
|
|
|
64
|
+
## MQTT
|
|
65
|
+
|
|
66
|
+
Install the optional extra first:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
pip install edgesync[mqtt]
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Publishing to MQTT instead of HTTP is the same pattern with a different transport:
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from edgesync import EdgeSync
|
|
76
|
+
from edgesync.transports.mqtt import MQTTTransport
|
|
77
|
+
|
|
78
|
+
sync = EdgeSync(
|
|
79
|
+
database="edgesync.db",
|
|
80
|
+
destinations={
|
|
81
|
+
"telemetry": MQTTTransport(
|
|
82
|
+
"broker.example.com",
|
|
83
|
+
"devices/device-001/telemetry",
|
|
84
|
+
qos=1,
|
|
85
|
+
username="device-001",
|
|
86
|
+
password="...",
|
|
87
|
+
),
|
|
88
|
+
},
|
|
89
|
+
default_destination="telemetry",
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
await sync.publish({"temperature": 28.5})
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`MQTTTransport` only accepts QoS 1 or 2 — QoS 0 publishes are never acknowledged by the
|
|
96
|
+
broker, so there'd be no way to tell a successful delivery from a lost one, which would break
|
|
97
|
+
EdgeSync's at-least-once guarantee. Unlike `HTTPTransport`, MQTT holds a persistent broker
|
|
98
|
+
connection: `EdgeSync.start()` still won't raise if the broker is unreachable (data keeps
|
|
99
|
+
queuing locally either way), and the transport automatically reconnects on the next delivery
|
|
100
|
+
attempt after a dropped connection or failed publish.
|
|
101
|
+
|
|
102
|
+
**TLS**: by default `MQTTTransport` connects in plaintext on port `1883`. To connect over
|
|
103
|
+
TLS, pass `tls_params` (an `aiomqtt.TLSParameters`) and switch to the broker's TLS port,
|
|
104
|
+
conventionally `8883`:
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
import aiomqtt
|
|
108
|
+
from edgesync.transports.mqtt import MQTTTransport
|
|
109
|
+
|
|
110
|
+
MQTTTransport(
|
|
111
|
+
"broker.example.com",
|
|
112
|
+
"devices/device-001/telemetry",
|
|
113
|
+
port=8883,
|
|
114
|
+
tls_params=aiomqtt.TLSParameters(
|
|
115
|
+
ca_certs="/etc/ssl/certs/ca-certificates.crt",
|
|
116
|
+
),
|
|
117
|
+
)
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
`TLSParameters` also accepts `certfile` / `keyfile` for client-certificate (mutual TLS)
|
|
121
|
+
authentication. Omit `tls_params` entirely for an unencrypted connection, e.g. a broker on a
|
|
122
|
+
trusted local network or a local development/test setup. See the
|
|
123
|
+
[MQTT section of the getting-started guide](https://github.com/adhuldas/EdgeSync/blob/main/docs/getting-started.md#mqtt)
|
|
124
|
+
for further detail.
|
|
125
|
+
|
|
64
126
|
## Usage with FastAPI, Flask, and plain asyncio
|
|
65
127
|
|
|
66
128
|
EdgeSync is async-native, so it's most at home in an `asyncio` app or an async framework like
|
|
@@ -99,8 +161,8 @@ guide, including the ready-to-use Flask bridge.
|
|
|
99
161
|
inspection instead of being silently dropped.
|
|
100
162
|
- **Multiple destinations** — route different message types to different endpoints or
|
|
101
163
|
transports.
|
|
102
|
-
- **Pluggable transports** — ships with
|
|
103
|
-
else (
|
|
164
|
+
- **Pluggable transports** — ships with HTTP and MQTT transports; implement `Transport` for
|
|
165
|
+
anything else (gRPC, a message broker, ...).
|
|
104
166
|
- **Bounded storage** — configurable queue capacity with a choice of overflow policies.
|
|
105
167
|
- **Async-native** — built on `asyncio` and `httpx`.
|
|
106
168
|
|
edgesync-0.3.2/VERSION
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.3.2
|
|
@@ -124,6 +124,67 @@ await sync.publish({"level": "critical"}, destination="alerts")
|
|
|
124
124
|
Implement the `Transport` ABC (`start`, `deliver`, `close`) to deliver over something other
|
|
125
125
|
than HTTP.
|
|
126
126
|
|
|
127
|
+
## MQTT
|
|
128
|
+
|
|
129
|
+
Install the optional extra first:
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
pip install edgesync[mqtt]
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
from edgesync import EdgeSync
|
|
137
|
+
from edgesync.transports.mqtt import MQTTTransport
|
|
138
|
+
|
|
139
|
+
sync = EdgeSync(
|
|
140
|
+
database="edgesync.db",
|
|
141
|
+
destinations={
|
|
142
|
+
"telemetry": MQTTTransport(
|
|
143
|
+
"broker.example.com",
|
|
144
|
+
"devices/device-001/telemetry",
|
|
145
|
+
qos=1,
|
|
146
|
+
username="device-001",
|
|
147
|
+
password="...",
|
|
148
|
+
),
|
|
149
|
+
},
|
|
150
|
+
default_destination="telemetry",
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
await sync.publish({"temperature": 28.5})
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
`MQTTTransport` only accepts QoS 1 or 2 — QoS 0 publishes are never acknowledged by the
|
|
157
|
+
broker, so there'd be no way to tell a successful delivery from a lost one, which would break
|
|
158
|
+
EdgeSync's at-least-once guarantee. Unlike `HTTPTransport`, MQTT holds a persistent broker
|
|
159
|
+
connection: `EdgeSync.start()` still won't raise if the broker is unreachable (data keeps
|
|
160
|
+
queuing locally either way), and the transport automatically reconnects on the next delivery
|
|
161
|
+
attempt after a dropped connection or failed publish.
|
|
162
|
+
|
|
163
|
+
### TLS
|
|
164
|
+
|
|
165
|
+
By default `MQTTTransport` connects in plaintext on port `1883`. To connect over TLS, pass
|
|
166
|
+
`tls_params` (an `aiomqtt.TLSParameters`, re-exported from the underlying `paho-mqtt` client)
|
|
167
|
+
and switch to the broker's TLS port, conventionally `8883`:
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
import aiomqtt
|
|
171
|
+
from edgesync.transports.mqtt import MQTTTransport
|
|
172
|
+
|
|
173
|
+
MQTTTransport(
|
|
174
|
+
"broker.example.com",
|
|
175
|
+
"devices/device-001/telemetry",
|
|
176
|
+
port=8883,
|
|
177
|
+
tls_params=aiomqtt.TLSParameters(
|
|
178
|
+
ca_certs="/etc/ssl/certs/ca-certificates.crt",
|
|
179
|
+
),
|
|
180
|
+
)
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
`TLSParameters` also accepts `certfile` / `keyfile` for client-certificate (mutual TLS)
|
|
184
|
+
authentication — see [aiomqtt's documentation](https://aiomqtt.bo3hm.com) for the full set of
|
|
185
|
+
options. Omit `tls_params` entirely for an unencrypted connection, e.g. when talking to a
|
|
186
|
+
broker on a trusted local network or in a local development/test setup.
|
|
187
|
+
|
|
127
188
|
## Inspecting the queue
|
|
128
189
|
|
|
129
190
|
```python
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""MQTT transport backed by ``aiomqtt``.
|
|
2
|
+
|
|
3
|
+
Requires the optional ``aiomqtt`` dependency: ``pip install edgesync[mqtt]``.
|
|
4
|
+
|
|
5
|
+
Unlike :class:`~edgesync.transports.http.HTTPTransport`, MQTT is a stateful,
|
|
6
|
+
persistent connection rather than a per-request protocol, which shapes two
|
|
7
|
+
design decisions here:
|
|
8
|
+
|
|
9
|
+
- ``start()`` never raises if the broker is unreachable. Failing hard would
|
|
10
|
+
contradict EdgeSync's whole premise -- an edge app must still boot and
|
|
11
|
+
accept ``publish()`` calls into the durable queue even if the destination
|
|
12
|
+
is down at startup. The first ``deliver()`` call (re)connects on demand,
|
|
13
|
+
and any failure is reported as a retryable :class:`DeliveryResult` so the
|
|
14
|
+
worker retries with backoff like any other transient failure.
|
|
15
|
+
- A publish failure tears the connection down (rather than leaving it half
|
|
16
|
+
broken) so the *next* delivery attempt starts from a clean reconnect
|
|
17
|
+
instead of repeatedly hitting the same stale, disconnected client.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import contextlib
|
|
23
|
+
import json
|
|
24
|
+
|
|
25
|
+
from edgesync.logging import logger
|
|
26
|
+
from edgesync.models.delivery import DeliveryResult
|
|
27
|
+
from edgesync.models.message import Message
|
|
28
|
+
from edgesync.transports.base import Transport
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
import aiomqtt
|
|
32
|
+
except ImportError as exc: # pragma: no cover - exercised only without the extra installed
|
|
33
|
+
raise ImportError(
|
|
34
|
+
"MQTTTransport requires the 'aiomqtt' package. Install it with: pip install edgesync[mqtt]"
|
|
35
|
+
) from exc
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class MQTTTransport(Transport):
|
|
39
|
+
"""Publish messages as JSON payloads to a fixed MQTT topic.
|
|
40
|
+
|
|
41
|
+
Only QoS 1 and 2 are accepted: QoS 0 ("at most once") is never
|
|
42
|
+
acknowledged by the broker, so there'd be no way to distinguish success
|
|
43
|
+
from failure and EdgeSync's at-least-once guarantee couldn't be upheld.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
hostname: str,
|
|
49
|
+
topic: str,
|
|
50
|
+
*,
|
|
51
|
+
port: int = 1883,
|
|
52
|
+
qos: int = 1,
|
|
53
|
+
username: str | None = None,
|
|
54
|
+
password: str | None = None,
|
|
55
|
+
client_id: str | None = None,
|
|
56
|
+
tls_params: aiomqtt.TLSParameters | None = None,
|
|
57
|
+
timeout: float = 30.0,
|
|
58
|
+
client: aiomqtt.Client | None = None,
|
|
59
|
+
) -> None:
|
|
60
|
+
if qos not in (1, 2):
|
|
61
|
+
raise ValueError(
|
|
62
|
+
f"qos must be 1 or 2 for at-least-once delivery (got {qos}); "
|
|
63
|
+
"QoS 0 publishes are never acknowledged by the broker"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
self._topic = topic
|
|
67
|
+
self._qos = qos
|
|
68
|
+
self._timeout = timeout
|
|
69
|
+
self._external_client = client
|
|
70
|
+
self._client: aiomqtt.Client | None = None
|
|
71
|
+
self._connected = False
|
|
72
|
+
self._hostname = hostname
|
|
73
|
+
self._port = port
|
|
74
|
+
self._username = username
|
|
75
|
+
self._password = password
|
|
76
|
+
self._client_id = client_id
|
|
77
|
+
self._tls_params = tls_params
|
|
78
|
+
|
|
79
|
+
async def start(self) -> None:
|
|
80
|
+
if self._client is not None:
|
|
81
|
+
return
|
|
82
|
+
self._client = self._external_client or aiomqtt.Client(
|
|
83
|
+
self._hostname,
|
|
84
|
+
self._port,
|
|
85
|
+
username=self._username,
|
|
86
|
+
password=self._password,
|
|
87
|
+
identifier=self._client_id,
|
|
88
|
+
tls_params=self._tls_params,
|
|
89
|
+
)
|
|
90
|
+
await self._try_connect()
|
|
91
|
+
|
|
92
|
+
async def close(self) -> None:
|
|
93
|
+
if self._client is not None and self._connected:
|
|
94
|
+
with contextlib.suppress(aiomqtt.MqttError):
|
|
95
|
+
await self._client.__aexit__(None, None, None)
|
|
96
|
+
self._client = None
|
|
97
|
+
self._connected = False
|
|
98
|
+
|
|
99
|
+
async def deliver(self, message: Message) -> DeliveryResult:
|
|
100
|
+
if self._client is None:
|
|
101
|
+
raise RuntimeError("MQTTTransport.start() must be called before deliver()")
|
|
102
|
+
|
|
103
|
+
if not self._connected and not await self._try_connect():
|
|
104
|
+
return DeliveryResult.retryable_failure("mqtt broker unreachable")
|
|
105
|
+
|
|
106
|
+
payload = json.dumps(message.payload).encode("utf-8")
|
|
107
|
+
try:
|
|
108
|
+
await self._client.publish(
|
|
109
|
+
self._topic,
|
|
110
|
+
payload=payload,
|
|
111
|
+
qos=self._qos,
|
|
112
|
+
timeout=self._timeout,
|
|
113
|
+
)
|
|
114
|
+
except aiomqtt.MqttError as exc:
|
|
115
|
+
await self._disconnect_after_error()
|
|
116
|
+
return DeliveryResult.retryable_failure(f"mqtt error: {exc}")
|
|
117
|
+
|
|
118
|
+
return DeliveryResult.success_result()
|
|
119
|
+
|
|
120
|
+
async def _try_connect(self) -> bool:
|
|
121
|
+
assert self._client is not None
|
|
122
|
+
try:
|
|
123
|
+
await self._client.__aenter__()
|
|
124
|
+
except aiomqtt.MqttError as exc:
|
|
125
|
+
logger.warning("MQTTTransport: broker connection failed: %s", exc)
|
|
126
|
+
return False
|
|
127
|
+
self._connected = True
|
|
128
|
+
return True
|
|
129
|
+
|
|
130
|
+
async def _disconnect_after_error(self) -> None:
|
|
131
|
+
self._connected = False
|
|
132
|
+
assert self._client is not None
|
|
133
|
+
with contextlib.suppress(aiomqtt.MqttError):
|
|
134
|
+
await self._client.__aexit__(None, None, None)
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import aiomqtt
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from edgesync.models.delivery import DeliveryOutcome
|
|
9
|
+
from edgesync.transports.mqtt import MQTTTransport
|
|
10
|
+
from tests.fixtures.factories import make_message
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FakeMQTTClient:
|
|
14
|
+
"""A minimal double for ``aiomqtt.Client``'s connection/publish lifecycle."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, *, fail_connect: bool = False) -> None:
|
|
17
|
+
self.fail_connect = fail_connect
|
|
18
|
+
self.connect_calls = 0
|
|
19
|
+
self.disconnect_calls = 0
|
|
20
|
+
self.published: list[tuple[str, bytes, int, float | None]] = []
|
|
21
|
+
self._publish_side_effect: Exception | None = None
|
|
22
|
+
|
|
23
|
+
def fail_next_publish(self, exc: Exception) -> None:
|
|
24
|
+
self._publish_side_effect = exc
|
|
25
|
+
|
|
26
|
+
async def __aenter__(self) -> FakeMQTTClient:
|
|
27
|
+
self.connect_calls += 1
|
|
28
|
+
if self.fail_connect:
|
|
29
|
+
raise aiomqtt.MqttError("connection refused")
|
|
30
|
+
return self
|
|
31
|
+
|
|
32
|
+
async def __aexit__(self, *args: object) -> None:
|
|
33
|
+
self.disconnect_calls += 1
|
|
34
|
+
|
|
35
|
+
async def publish(
|
|
36
|
+
self, topic: str, payload: bytes, qos: int, *, timeout: float | None = None
|
|
37
|
+
) -> None:
|
|
38
|
+
if self._publish_side_effect is not None:
|
|
39
|
+
exc, self._publish_side_effect = self._publish_side_effect, None
|
|
40
|
+
raise exc
|
|
41
|
+
self.published.append((topic, payload, qos, timeout))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@pytest.mark.parametrize("qos", [0, 3, -1])
|
|
45
|
+
async def test_rejects_qos_that_is_not_1_or_2(qos: int) -> None:
|
|
46
|
+
with pytest.raises(ValueError, match="qos must be 1 or 2"):
|
|
47
|
+
MQTTTransport("broker.invalid", "telemetry", qos=qos, client=FakeMQTTClient())
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def test_deliver_before_start_raises() -> None:
|
|
51
|
+
transport = MQTTTransport("broker.invalid", "telemetry", client=FakeMQTTClient())
|
|
52
|
+
with pytest.raises(RuntimeError):
|
|
53
|
+
await transport.deliver(make_message())
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def test_close_without_start_is_safe() -> None:
|
|
57
|
+
transport = MQTTTransport("broker.invalid", "telemetry", client=FakeMQTTClient())
|
|
58
|
+
await transport.close() # must not raise
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def test_start_connects_client() -> None:
|
|
62
|
+
fake = FakeMQTTClient()
|
|
63
|
+
transport = MQTTTransport("broker.invalid", "telemetry", client=fake)
|
|
64
|
+
await transport.start()
|
|
65
|
+
assert fake.connect_calls == 1
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def test_start_does_not_raise_when_broker_unreachable() -> None:
|
|
69
|
+
fake = FakeMQTTClient(fail_connect=True)
|
|
70
|
+
transport = MQTTTransport("broker.invalid", "telemetry", client=fake)
|
|
71
|
+
await transport.start() # must not raise
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def test_deliver_retries_connection_when_broker_was_unreachable_at_start() -> None:
|
|
75
|
+
fake = FakeMQTTClient(fail_connect=True)
|
|
76
|
+
transport = MQTTTransport("broker.invalid", "telemetry", client=fake)
|
|
77
|
+
await transport.start()
|
|
78
|
+
assert fake.connect_calls == 1
|
|
79
|
+
|
|
80
|
+
result = await transport.deliver(make_message())
|
|
81
|
+
assert result.retryable
|
|
82
|
+
assert fake.connect_calls == 2
|
|
83
|
+
assert fake.published == []
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
async def test_deliver_success() -> None:
|
|
87
|
+
fake = FakeMQTTClient()
|
|
88
|
+
transport = MQTTTransport("broker.invalid", "telemetry", client=fake)
|
|
89
|
+
await transport.start()
|
|
90
|
+
result = await transport.deliver(make_message(payload={"temperature": 28.5}))
|
|
91
|
+
assert result.success
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
async def test_deliver_publishes_json_payload_to_configured_topic() -> None:
|
|
95
|
+
fake = FakeMQTTClient()
|
|
96
|
+
transport = MQTTTransport("broker.invalid", "sensors/device-001", client=fake)
|
|
97
|
+
await transport.start()
|
|
98
|
+
await transport.deliver(make_message(payload={"temperature": 28.5}))
|
|
99
|
+
topic, payload, _qos, _timeout = fake.published[0]
|
|
100
|
+
assert topic == "sensors/device-001"
|
|
101
|
+
assert json.loads(payload) == {"temperature": 28.5}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
async def test_deliver_uses_configured_qos() -> None:
|
|
105
|
+
fake = FakeMQTTClient()
|
|
106
|
+
transport = MQTTTransport("broker.invalid", "telemetry", qos=2, client=fake)
|
|
107
|
+
await transport.start()
|
|
108
|
+
await transport.deliver(make_message())
|
|
109
|
+
_topic, _payload, qos, _timeout = fake.published[0]
|
|
110
|
+
assert qos == 2
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
async def test_deliver_mqtt_error_is_retryable() -> None:
|
|
114
|
+
fake = FakeMQTTClient()
|
|
115
|
+
transport = MQTTTransport("broker.invalid", "telemetry", client=fake)
|
|
116
|
+
await transport.start()
|
|
117
|
+
fake.fail_next_publish(aiomqtt.MqttError("broker went away"))
|
|
118
|
+
|
|
119
|
+
result = await transport.deliver(make_message())
|
|
120
|
+
assert result.retryable
|
|
121
|
+
assert result.outcome is DeliveryOutcome.RETRYABLE_FAILURE
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
async def test_deliver_reconnects_after_publish_error() -> None:
|
|
125
|
+
fake = FakeMQTTClient()
|
|
126
|
+
transport = MQTTTransport("broker.invalid", "telemetry", client=fake)
|
|
127
|
+
await transport.start()
|
|
128
|
+
fake.fail_next_publish(aiomqtt.MqttError("broker went away"))
|
|
129
|
+
|
|
130
|
+
await transport.deliver(make_message())
|
|
131
|
+
assert fake.disconnect_calls == 1
|
|
132
|
+
|
|
133
|
+
result = await transport.deliver(make_message())
|
|
134
|
+
assert result.success
|
|
135
|
+
assert fake.connect_calls == 2
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
async def test_close_disconnects_started_client() -> None:
|
|
139
|
+
fake = FakeMQTTClient()
|
|
140
|
+
transport = MQTTTransport("broker.invalid", "telemetry", client=fake)
|
|
141
|
+
await transport.start()
|
|
142
|
+
await transport.close()
|
|
143
|
+
assert fake.disconnect_calls == 1
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
async def test_close_after_failed_start_does_not_disconnect() -> None:
|
|
147
|
+
fake = FakeMQTTClient(fail_connect=True)
|
|
148
|
+
transport = MQTTTransport("broker.invalid", "telemetry", client=fake)
|
|
149
|
+
await transport.start()
|
|
150
|
+
await transport.close()
|
|
151
|
+
assert fake.disconnect_calls == 0
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
async def test_start_without_tls_params_constructs_plaintext_client(
|
|
155
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
156
|
+
) -> None:
|
|
157
|
+
captured: dict[str, object] = {}
|
|
158
|
+
|
|
159
|
+
class RecordingClient(FakeMQTTClient):
|
|
160
|
+
def __init__(self, hostname: str, port: int, **kwargs: object) -> None:
|
|
161
|
+
super().__init__()
|
|
162
|
+
captured["hostname"] = hostname
|
|
163
|
+
captured["port"] = port
|
|
164
|
+
captured.update(kwargs)
|
|
165
|
+
|
|
166
|
+
monkeypatch.setattr(aiomqtt, "Client", RecordingClient)
|
|
167
|
+
|
|
168
|
+
transport = MQTTTransport("broker.invalid", "telemetry")
|
|
169
|
+
await transport.start()
|
|
170
|
+
|
|
171
|
+
assert captured["hostname"] == "broker.invalid"
|
|
172
|
+
assert captured["port"] == 1883
|
|
173
|
+
assert captured["tls_params"] is None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
async def test_start_with_tls_params_constructs_tls_client(
|
|
177
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
178
|
+
) -> None:
|
|
179
|
+
captured: dict[str, object] = {}
|
|
180
|
+
|
|
181
|
+
class RecordingClient(FakeMQTTClient):
|
|
182
|
+
def __init__(self, hostname: str, port: int, **kwargs: object) -> None:
|
|
183
|
+
super().__init__()
|
|
184
|
+
captured["hostname"] = hostname
|
|
185
|
+
captured["port"] = port
|
|
186
|
+
captured.update(kwargs)
|
|
187
|
+
|
|
188
|
+
monkeypatch.setattr(aiomqtt, "Client", RecordingClient)
|
|
189
|
+
|
|
190
|
+
tls_params = aiomqtt.TLSParameters(ca_certs="/etc/ssl/certs/ca-certificates.crt")
|
|
191
|
+
transport = MQTTTransport("broker.invalid", "telemetry", port=8883, tls_params=tls_params)
|
|
192
|
+
await transport.start()
|
|
193
|
+
|
|
194
|
+
assert captured["port"] == 8883
|
|
195
|
+
assert captured["tls_params"] is tls_params
|
|
@@ -6,6 +6,19 @@ resolution-markers = [
|
|
|
6
6
|
"python_full_version < '3.15'",
|
|
7
7
|
]
|
|
8
8
|
|
|
9
|
+
[[package]]
|
|
10
|
+
name = "aiomqtt"
|
|
11
|
+
version = "2.5.1"
|
|
12
|
+
source = { registry = "https://pypi.org/simple" }
|
|
13
|
+
dependencies = [
|
|
14
|
+
{ name = "paho-mqtt" },
|
|
15
|
+
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
|
|
16
|
+
]
|
|
17
|
+
sdist = { url = "https://files.pythonhosted.org/packages/70/44/cfc58272783a11729462dc6df5adbfeabd084f840f609054ac772ae98c19/aiomqtt-2.5.1.tar.gz", hash = "sha256:25a0a47d157e8f158d2da1110ea4786c0615518751e94f7b04976c977a8ff20d", size = 86641, upload-time = "2026-03-05T18:28:56.421Z" }
|
|
18
|
+
wheels = [
|
|
19
|
+
{ url = "https://files.pythonhosted.org/packages/01/9e/5089fa596220bf0dc73deeb23db27904e4b3504986caf08571f6f5cb84a8/aiomqtt-2.5.1-py3-none-any.whl", hash = "sha256:fd58c3593160e4d475d90ce911cdfc4239cd64de96b0ba22edf6c86bd7afa278", size = 16051, upload-time = "2026-03-05T18:28:55.14Z" },
|
|
20
|
+
]
|
|
21
|
+
|
|
9
22
|
[[package]]
|
|
10
23
|
name = "aiosqlite"
|
|
11
24
|
version = "0.22.1"
|
|
@@ -261,6 +274,11 @@ dependencies = [
|
|
|
261
274
|
{ name = "httpx" },
|
|
262
275
|
]
|
|
263
276
|
|
|
277
|
+
[package.optional-dependencies]
|
|
278
|
+
mqtt = [
|
|
279
|
+
{ name = "aiomqtt" },
|
|
280
|
+
]
|
|
281
|
+
|
|
264
282
|
[package.dev-dependencies]
|
|
265
283
|
dev = [
|
|
266
284
|
{ name = "mypy" },
|
|
@@ -273,9 +291,11 @@ dev = [
|
|
|
273
291
|
|
|
274
292
|
[package.metadata]
|
|
275
293
|
requires-dist = [
|
|
294
|
+
{ name = "aiomqtt", marker = "extra == 'mqtt'", specifier = ">=2.5,<3" },
|
|
276
295
|
{ name = "aiosqlite", specifier = ">=0.20" },
|
|
277
296
|
{ name = "httpx", specifier = ">=0.27" },
|
|
278
297
|
]
|
|
298
|
+
provides-extras = ["mqtt"]
|
|
279
299
|
|
|
280
300
|
[package.metadata.requires-dev]
|
|
281
301
|
dev = [
|
|
@@ -569,6 +589,15 @@ wheels = [
|
|
|
569
589
|
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
|
|
570
590
|
]
|
|
571
591
|
|
|
592
|
+
[[package]]
|
|
593
|
+
name = "paho-mqtt"
|
|
594
|
+
version = "2.1.0"
|
|
595
|
+
source = { registry = "https://pypi.org/simple" }
|
|
596
|
+
sdist = { url = "https://files.pythonhosted.org/packages/39/15/0a6214e76d4d32e7f663b109cf71fb22561c2be0f701d67f93950cd40542/paho_mqtt-2.1.0.tar.gz", hash = "sha256:12d6e7511d4137555a3f6ea167ae846af2c7357b10bc6fa4f7c3968fc1723834", size = 148848, upload-time = "2024-04-29T19:52:55.591Z" }
|
|
597
|
+
wheels = [
|
|
598
|
+
{ url = "https://files.pythonhosted.org/packages/c4/cb/00451c3cf31790287768bb12c6bec834f5d292eaf3022afc88e14b8afc94/paho_mqtt-2.1.0-py3-none-any.whl", hash = "sha256:6db9ba9b34ed5bc6b6e3812718c7e06e2fd7444540df2455d2c51bd58808feee", size = 67219, upload-time = "2024-04-29T19:52:48.345Z" },
|
|
599
|
+
]
|
|
600
|
+
|
|
572
601
|
[[package]]
|
|
573
602
|
name = "pathspec"
|
|
574
603
|
version = "1.1.1"
|
edgesync-0.2.1/VERSION
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.2.1
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|