edgesync 0.2.0__tar.gz → 0.3.1__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.0 → edgesync-0.3.1}/.github/workflows/release.yml +51 -11
- {edgesync-0.2.0 → edgesync-0.3.1}/CHANGELOG.md +39 -1
- {edgesync-0.2.0 → edgesync-0.3.1}/CONTRIBUTING.md +19 -9
- {edgesync-0.2.0 → edgesync-0.3.1}/PKG-INFO +17 -12
- {edgesync-0.2.0 → edgesync-0.3.1}/README.md +11 -11
- edgesync-0.3.1/VERSION +1 -0
- {edgesync-0.2.0 → edgesync-0.3.1}/docs/getting-started.md +36 -0
- edgesync-0.3.1/edgesync/transports/mqtt.py +134 -0
- {edgesync-0.2.0 → edgesync-0.3.1}/pyproject.toml +6 -0
- edgesync-0.3.1/tests/integration/test_mqtt_transport.py +151 -0
- {edgesync-0.2.0 → edgesync-0.3.1}/uv.lock +29 -0
- edgesync-0.2.0/VERSION +0 -1
- {edgesync-0.2.0 → edgesync-0.3.1}/.github/workflows/ci.yml +0 -0
- {edgesync-0.2.0 → edgesync-0.3.1}/.gitignore +0 -0
- {edgesync-0.2.0 → edgesync-0.3.1}/LICENSE +0 -0
- {edgesync-0.2.0 → edgesync-0.3.1}/SECURITY.md +0 -0
- {edgesync-0.2.0 → edgesync-0.3.1}/docs/integrations.md +0 -0
- {edgesync-0.2.0 → edgesync-0.3.1}/docs/reliability.md +0 -0
- {edgesync-0.2.0 → edgesync-0.3.1}/docs/storage.md +0 -0
- {edgesync-0.2.0 → edgesync-0.3.1}/edgesync/__init__.py +0 -0
- {edgesync-0.2.0 → edgesync-0.3.1}/edgesync/client.py +0 -0
- {edgesync-0.2.0 → edgesync-0.3.1}/edgesync/config.py +0 -0
- {edgesync-0.2.0 → edgesync-0.3.1}/edgesync/exceptions.py +0 -0
- {edgesync-0.2.0 → edgesync-0.3.1}/edgesync/logging.py +0 -0
- {edgesync-0.2.0 → edgesync-0.3.1}/edgesync/models/__init__.py +0 -0
- {edgesync-0.2.0 → edgesync-0.3.1}/edgesync/models/delivery.py +0 -0
- {edgesync-0.2.0 → edgesync-0.3.1}/edgesync/models/message.py +0 -0
- {edgesync-0.2.0 → edgesync-0.3.1}/edgesync/models/receipt.py +0 -0
- {edgesync-0.2.0 → edgesync-0.3.1}/edgesync/models/stats.py +0 -0
- {edgesync-0.2.0 → edgesync-0.3.1}/edgesync/py.typed +0 -0
- {edgesync-0.2.0 → edgesync-0.3.1}/edgesync/queue/__init__.py +0 -0
- {edgesync-0.2.0 → edgesync-0.3.1}/edgesync/queue/manager.py +0 -0
- {edgesync-0.2.0 → edgesync-0.3.1}/edgesync/retry/__init__.py +0 -0
- {edgesync-0.2.0 → edgesync-0.3.1}/edgesync/retry/backoff.py +0 -0
- {edgesync-0.2.0 → edgesync-0.3.1}/edgesync/retry/policy.py +0 -0
- {edgesync-0.2.0 → edgesync-0.3.1}/edgesync/storage/__init__.py +0 -0
- {edgesync-0.2.0 → edgesync-0.3.1}/edgesync/storage/base.py +0 -0
- {edgesync-0.2.0 → edgesync-0.3.1}/edgesync/storage/migrations.py +0 -0
- {edgesync-0.2.0 → edgesync-0.3.1}/edgesync/storage/sqlite.py +0 -0
- {edgesync-0.2.0 → edgesync-0.3.1}/edgesync/transports/__init__.py +0 -0
- {edgesync-0.2.0 → edgesync-0.3.1}/edgesync/transports/base.py +0 -0
- {edgesync-0.2.0 → edgesync-0.3.1}/edgesync/transports/http.py +0 -0
- {edgesync-0.2.0 → edgesync-0.3.1}/edgesync/transports/registry.py +0 -0
- {edgesync-0.2.0 → edgesync-0.3.1}/edgesync/utils/__init__.py +0 -0
- {edgesync-0.2.0 → edgesync-0.3.1}/edgesync/utils/clock.py +0 -0
- {edgesync-0.2.0 → edgesync-0.3.1}/edgesync/utils/ids.py +0 -0
- {edgesync-0.2.0 → edgesync-0.3.1}/edgesync/worker/__init__.py +0 -0
- {edgesync-0.2.0 → edgesync-0.3.1}/edgesync/worker/lifecycle.py +0 -0
- {edgesync-0.2.0 → edgesync-0.3.1}/edgesync/worker/scheduler.py +0 -0
- {edgesync-0.2.0 → edgesync-0.3.1}/edgesync/worker/sync_worker.py +0 -0
- {edgesync-0.2.0 → edgesync-0.3.1}/tests/__init__.py +0 -0
- {edgesync-0.2.0 → edgesync-0.3.1}/tests/conftest.py +0 -0
- {edgesync-0.2.0 → edgesync-0.3.1}/tests/fixtures/__init__.py +0 -0
- {edgesync-0.2.0 → edgesync-0.3.1}/tests/fixtures/factories.py +0 -0
- {edgesync-0.2.0 → edgesync-0.3.1}/tests/fixtures/fake_transport.py +0 -0
- {edgesync-0.2.0 → edgesync-0.3.1}/tests/integration/__init__.py +0 -0
- {edgesync-0.2.0 → edgesync-0.3.1}/tests/integration/test_client.py +0 -0
- {edgesync-0.2.0 → edgesync-0.3.1}/tests/integration/test_http_transport.py +0 -0
- {edgesync-0.2.0 → edgesync-0.3.1}/tests/integration/test_sqlite_storage.py +0 -0
- {edgesync-0.2.0 → edgesync-0.3.1}/tests/integration/test_worker.py +0 -0
- {edgesync-0.2.0 → edgesync-0.3.1}/tests/reliability/__init__.py +0 -0
- {edgesync-0.2.0 → edgesync-0.3.1}/tests/reliability/test_reliability.py +0 -0
- {edgesync-0.2.0 → edgesync-0.3.1}/tests/unit/__init__.py +0 -0
- {edgesync-0.2.0 → edgesync-0.3.1}/tests/unit/test_backoff.py +0 -0
- {edgesync-0.2.0 → edgesync-0.3.1}/tests/unit/test_config.py +0 -0
- {edgesync-0.2.0 → edgesync-0.3.1}/tests/unit/test_models.py +0 -0
- {edgesync-0.2.0 → edgesync-0.3.1}/tests/unit/test_queue_manager.py +0 -0
- {edgesync-0.2.0 → edgesync-0.3.1}/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.1] - 2026-08-24
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- The release workflow's `should_release` job output was never actually set (it read from
|
|
15
|
+
the wrong step), so `build distributions`, `publish to PyPI`, and `create GitHub release`
|
|
16
|
+
were silently skipped on every run, including 0.3.0's. 0.3.0's tag and changes are
|
|
17
|
+
unaffected; this release exists solely to get the pipeline publishing again.
|
|
18
|
+
|
|
19
|
+
## [0.3.0] - 2026-08-24
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- `MQTTTransport` (`edgesync.transports.mqtt`), an optional MQTT destination backed by
|
|
24
|
+
`aiomqtt` (`pip install edgesync[mqtt]`). Only QoS 1/2 are accepted, since QoS 0 publishes
|
|
25
|
+
are never acknowledged by the broker and can't support at-least-once delivery. The
|
|
26
|
+
connection reconnects automatically after a dropped connection or failed publish, and
|
|
27
|
+
`start()` never raises if the broker is unreachable, consistent with EdgeSync's guarantee
|
|
28
|
+
that local queuing works independent of destination availability.
|
|
29
|
+
|
|
30
|
+
## [0.2.1] - 2026-08-24
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
|
|
34
|
+
- `Contributing` and `License` links in the package's `[project.urls]` metadata, so they
|
|
35
|
+
show up in PyPI's project sidebar alongside Homepage/Repository/Documentation/Changelog.
|
|
36
|
+
- `Issue Tracker` link in `[project.urls]`.
|
|
37
|
+
|
|
38
|
+
### Fixed
|
|
39
|
+
|
|
40
|
+
- `README.md` links to `docs/`, `CONTRIBUTING.md`, and `LICENSE` used repo-relative paths,
|
|
41
|
+
which render correctly on GitHub but break on PyPI (e.g. resolving to
|
|
42
|
+
`pypi.org/project/edgesync/CONTRIBUTING.md/`) since PyPI renders the README as a
|
|
43
|
+
standalone page. They're now absolute `github.com` URLs.
|
|
44
|
+
|
|
10
45
|
## [0.2.0] - 2026-08-23
|
|
11
46
|
|
|
12
47
|
### Added
|
|
@@ -47,6 +82,9 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
47
82
|
- Crash and restart recovery via expired-lease sweeps, safe across concurrent workers and
|
|
48
83
|
processes sharing the same database file.
|
|
49
84
|
|
|
50
|
-
[Unreleased]: https://github.com/adhuldas/EdgeSync/compare/v0.
|
|
85
|
+
[Unreleased]: https://github.com/adhuldas/EdgeSync/compare/v0.3.1...HEAD
|
|
86
|
+
[0.3.1]: https://github.com/adhuldas/EdgeSync/compare/v0.3.0...v0.3.1
|
|
87
|
+
[0.3.0]: https://github.com/adhuldas/EdgeSync/compare/v0.2.1...v0.3.0
|
|
88
|
+
[0.2.1]: https://github.com/adhuldas/EdgeSync/compare/v0.2.0...v0.2.1
|
|
51
89
|
[0.2.0]: https://github.com/adhuldas/EdgeSync/compare/v0.1.0...v0.2.0
|
|
52
90
|
[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,11 +1,14 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: edgesync
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
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
|
|
7
7
|
Project-URL: Documentation, https://github.com/adhuldas/EdgeSync/tree/main/docs
|
|
8
8
|
Project-URL: Changelog, https://github.com/adhuldas/EdgeSync/blob/main/CHANGELOG.md
|
|
9
|
+
Project-URL: Issue Tracker, https://github.com/adhuldas/EdgeSync/issues
|
|
10
|
+
Project-URL: Contributing, https://github.com/adhuldas/EdgeSync/blob/main/CONTRIBUTING.md
|
|
11
|
+
Project-URL: License, https://github.com/adhuldas/EdgeSync/blob/main/LICENSE
|
|
9
12
|
Author-email: Adhul Das M K <adhulamz@gmail.com>
|
|
10
13
|
License-Expression: MIT
|
|
11
14
|
License-File: LICENSE
|
|
@@ -24,13 +27,15 @@ Classifier: Typing :: Typed
|
|
|
24
27
|
Requires-Python: >=3.10
|
|
25
28
|
Requires-Dist: aiosqlite>=0.20
|
|
26
29
|
Requires-Dist: httpx>=0.27
|
|
30
|
+
Provides-Extra: mqtt
|
|
31
|
+
Requires-Dist: aiomqtt<3,>=2.5; extra == 'mqtt'
|
|
27
32
|
Description-Content-Type: text/markdown
|
|
28
33
|
|
|
29
34
|
# EdgeSync
|
|
30
35
|
|
|
31
36
|
[](https://github.com/adhuldas/EdgeSync/actions/workflows/ci.yml)
|
|
32
37
|
[](https://pypi.org/project/edgesync/)
|
|
33
|
-
[](LICENSE)
|
|
38
|
+
[](https://github.com/adhuldas/EdgeSync/blob/main/LICENSE)
|
|
34
39
|
[](pyproject.toml)
|
|
35
40
|
|
|
36
41
|
A lightweight Python library for reliable data delivery from edge applications to cloud
|
|
@@ -52,7 +57,7 @@ solves this with a durable store-and-forward architecture:
|
|
|
52
57
|
5. Pending data survives process crashes and device restarts.
|
|
53
58
|
|
|
54
59
|
EdgeSync provides **at-least-once** delivery, not exactly-once — see
|
|
55
|
-
[docs/reliability.md](docs/reliability.md) for the exact guarantee and how to build
|
|
60
|
+
[docs/reliability.md](https://github.com/adhuldas/EdgeSync/blob/main/docs/reliability.md) for the exact guarantee and how to build
|
|
56
61
|
idempotent consumers on top of it.
|
|
57
62
|
|
|
58
63
|
## Install
|
|
@@ -114,7 +119,7 @@ async def telemetry(payload: dict):
|
|
|
114
119
|
|
|
115
120
|
Flask is synchronous, so it needs a small bridge to run EdgeSync's event loop on a background
|
|
116
121
|
thread rather than spinning up a new loop per request. See
|
|
117
|
-
[docs/integrations.md](docs/integrations.md) for the full FastAPI, Flask, and plain-`asyncio`
|
|
122
|
+
[docs/integrations.md](https://github.com/adhuldas/EdgeSync/blob/main/docs/integrations.md) for the full FastAPI, Flask, and plain-`asyncio`
|
|
118
123
|
guide, including the ready-to-use Flask bridge.
|
|
119
124
|
|
|
120
125
|
## Features
|
|
@@ -127,17 +132,17 @@ guide, including the ready-to-use Flask bridge.
|
|
|
127
132
|
inspection instead of being silently dropped.
|
|
128
133
|
- **Multiple destinations** — route different message types to different endpoints or
|
|
129
134
|
transports.
|
|
130
|
-
- **Pluggable transports** — ships with
|
|
131
|
-
else (
|
|
135
|
+
- **Pluggable transports** — ships with HTTP and MQTT transports; implement `Transport` for
|
|
136
|
+
anything else (gRPC, a message broker, ...).
|
|
132
137
|
- **Bounded storage** — configurable queue capacity with a choice of overflow policies.
|
|
133
138
|
- **Async-native** — built on `asyncio` and `httpx`.
|
|
134
139
|
|
|
135
140
|
## Documentation
|
|
136
141
|
|
|
137
|
-
- [Getting started](docs/getting-started.md)
|
|
138
|
-
- [Using EdgeSync with FastAPI, Flask, and plain asyncio](docs/integrations.md)
|
|
139
|
-
- [Reliability & delivery guarantees](docs/reliability.md)
|
|
140
|
-
- [Storage design](docs/storage.md)
|
|
142
|
+
- [Getting started](https://github.com/adhuldas/EdgeSync/blob/main/docs/getting-started.md)
|
|
143
|
+
- [Using EdgeSync with FastAPI, Flask, and plain asyncio](https://github.com/adhuldas/EdgeSync/blob/main/docs/integrations.md)
|
|
144
|
+
- [Reliability & delivery guarantees](https://github.com/adhuldas/EdgeSync/blob/main/docs/reliability.md)
|
|
145
|
+
- [Storage design](https://github.com/adhuldas/EdgeSync/blob/main/docs/storage.md)
|
|
141
146
|
|
|
142
147
|
## Development
|
|
143
148
|
|
|
@@ -148,8 +153,8 @@ uv run ruff check .
|
|
|
148
153
|
uv run mypy
|
|
149
154
|
```
|
|
150
155
|
|
|
151
|
-
See [CONTRIBUTING.md](CONTRIBUTING.md) for the full workflow.
|
|
156
|
+
See [CONTRIBUTING.md](https://github.com/adhuldas/EdgeSync/blob/main/CONTRIBUTING.md) for the full workflow.
|
|
152
157
|
|
|
153
158
|
## License
|
|
154
159
|
|
|
155
|
-
MIT — see [LICENSE](LICENSE).
|
|
160
|
+
MIT — see [LICENSE](https://github.com/adhuldas/EdgeSync/blob/main/LICENSE).
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/adhuldas/EdgeSync/actions/workflows/ci.yml)
|
|
4
4
|
[](https://pypi.org/project/edgesync/)
|
|
5
|
-
[](LICENSE)
|
|
5
|
+
[](https://github.com/adhuldas/EdgeSync/blob/main/LICENSE)
|
|
6
6
|
[](pyproject.toml)
|
|
7
7
|
|
|
8
8
|
A lightweight Python library for reliable data delivery from edge applications to cloud
|
|
@@ -24,7 +24,7 @@ solves this with a durable store-and-forward architecture:
|
|
|
24
24
|
5. Pending data survives process crashes and device restarts.
|
|
25
25
|
|
|
26
26
|
EdgeSync provides **at-least-once** delivery, not exactly-once — see
|
|
27
|
-
[docs/reliability.md](docs/reliability.md) for the exact guarantee and how to build
|
|
27
|
+
[docs/reliability.md](https://github.com/adhuldas/EdgeSync/blob/main/docs/reliability.md) for the exact guarantee and how to build
|
|
28
28
|
idempotent consumers on top of it.
|
|
29
29
|
|
|
30
30
|
## Install
|
|
@@ -86,7 +86,7 @@ async def telemetry(payload: dict):
|
|
|
86
86
|
|
|
87
87
|
Flask is synchronous, so it needs a small bridge to run EdgeSync's event loop on a background
|
|
88
88
|
thread rather than spinning up a new loop per request. See
|
|
89
|
-
[docs/integrations.md](docs/integrations.md) for the full FastAPI, Flask, and plain-`asyncio`
|
|
89
|
+
[docs/integrations.md](https://github.com/adhuldas/EdgeSync/blob/main/docs/integrations.md) for the full FastAPI, Flask, and plain-`asyncio`
|
|
90
90
|
guide, including the ready-to-use Flask bridge.
|
|
91
91
|
|
|
92
92
|
## Features
|
|
@@ -99,17 +99,17 @@ guide, including the ready-to-use Flask bridge.
|
|
|
99
99
|
inspection instead of being silently dropped.
|
|
100
100
|
- **Multiple destinations** — route different message types to different endpoints or
|
|
101
101
|
transports.
|
|
102
|
-
- **Pluggable transports** — ships with
|
|
103
|
-
else (
|
|
102
|
+
- **Pluggable transports** — ships with HTTP and MQTT transports; implement `Transport` for
|
|
103
|
+
anything else (gRPC, a message broker, ...).
|
|
104
104
|
- **Bounded storage** — configurable queue capacity with a choice of overflow policies.
|
|
105
105
|
- **Async-native** — built on `asyncio` and `httpx`.
|
|
106
106
|
|
|
107
107
|
## Documentation
|
|
108
108
|
|
|
109
|
-
- [Getting started](docs/getting-started.md)
|
|
110
|
-
- [Using EdgeSync with FastAPI, Flask, and plain asyncio](docs/integrations.md)
|
|
111
|
-
- [Reliability & delivery guarantees](docs/reliability.md)
|
|
112
|
-
- [Storage design](docs/storage.md)
|
|
109
|
+
- [Getting started](https://github.com/adhuldas/EdgeSync/blob/main/docs/getting-started.md)
|
|
110
|
+
- [Using EdgeSync with FastAPI, Flask, and plain asyncio](https://github.com/adhuldas/EdgeSync/blob/main/docs/integrations.md)
|
|
111
|
+
- [Reliability & delivery guarantees](https://github.com/adhuldas/EdgeSync/blob/main/docs/reliability.md)
|
|
112
|
+
- [Storage design](https://github.com/adhuldas/EdgeSync/blob/main/docs/storage.md)
|
|
113
113
|
|
|
114
114
|
## Development
|
|
115
115
|
|
|
@@ -120,8 +120,8 @@ uv run ruff check .
|
|
|
120
120
|
uv run mypy
|
|
121
121
|
```
|
|
122
122
|
|
|
123
|
-
See [CONTRIBUTING.md](CONTRIBUTING.md) for the full workflow.
|
|
123
|
+
See [CONTRIBUTING.md](https://github.com/adhuldas/EdgeSync/blob/main/CONTRIBUTING.md) for the full workflow.
|
|
124
124
|
|
|
125
125
|
## License
|
|
126
126
|
|
|
127
|
-
MIT — see [LICENSE](LICENSE).
|
|
127
|
+
MIT — see [LICENSE](https://github.com/adhuldas/EdgeSync/blob/main/LICENSE).
|
edgesync-0.3.1/VERSION
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.3.1
|
|
@@ -124,6 +124,42 @@ 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
|
+
|
|
127
163
|
## Inspecting the queue
|
|
128
164
|
|
|
129
165
|
```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)
|
|
@@ -25,11 +25,17 @@ dependencies = [
|
|
|
25
25
|
"aiosqlite>=0.20",
|
|
26
26
|
]
|
|
27
27
|
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
mqtt = ["aiomqtt>=2.5,<3"]
|
|
30
|
+
|
|
28
31
|
[project.urls]
|
|
29
32
|
Homepage = "https://github.com/adhuldas/EdgeSync"
|
|
30
33
|
Repository = "https://github.com/adhuldas/EdgeSync"
|
|
31
34
|
Documentation = "https://github.com/adhuldas/EdgeSync/tree/main/docs"
|
|
32
35
|
Changelog = "https://github.com/adhuldas/EdgeSync/blob/main/CHANGELOG.md"
|
|
36
|
+
"Issue Tracker" = "https://github.com/adhuldas/EdgeSync/issues"
|
|
37
|
+
Contributing = "https://github.com/adhuldas/EdgeSync/blob/main/CONTRIBUTING.md"
|
|
38
|
+
License = "https://github.com/adhuldas/EdgeSync/blob/main/LICENSE"
|
|
33
39
|
|
|
34
40
|
[dependency-groups]
|
|
35
41
|
dev = [
|
|
@@ -0,0 +1,151 @@
|
|
|
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
|
|
@@ -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.0/VERSION
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.2.0
|
|
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
|