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.
Files changed (68) hide show
  1. {edgesync-0.2.0 → edgesync-0.3.1}/.github/workflows/release.yml +51 -11
  2. {edgesync-0.2.0 → edgesync-0.3.1}/CHANGELOG.md +39 -1
  3. {edgesync-0.2.0 → edgesync-0.3.1}/CONTRIBUTING.md +19 -9
  4. {edgesync-0.2.0 → edgesync-0.3.1}/PKG-INFO +17 -12
  5. {edgesync-0.2.0 → edgesync-0.3.1}/README.md +11 -11
  6. edgesync-0.3.1/VERSION +1 -0
  7. {edgesync-0.2.0 → edgesync-0.3.1}/docs/getting-started.md +36 -0
  8. edgesync-0.3.1/edgesync/transports/mqtt.py +134 -0
  9. {edgesync-0.2.0 → edgesync-0.3.1}/pyproject.toml +6 -0
  10. edgesync-0.3.1/tests/integration/test_mqtt_transport.py +151 -0
  11. {edgesync-0.2.0 → edgesync-0.3.1}/uv.lock +29 -0
  12. edgesync-0.2.0/VERSION +0 -1
  13. {edgesync-0.2.0 → edgesync-0.3.1}/.github/workflows/ci.yml +0 -0
  14. {edgesync-0.2.0 → edgesync-0.3.1}/.gitignore +0 -0
  15. {edgesync-0.2.0 → edgesync-0.3.1}/LICENSE +0 -0
  16. {edgesync-0.2.0 → edgesync-0.3.1}/SECURITY.md +0 -0
  17. {edgesync-0.2.0 → edgesync-0.3.1}/docs/integrations.md +0 -0
  18. {edgesync-0.2.0 → edgesync-0.3.1}/docs/reliability.md +0 -0
  19. {edgesync-0.2.0 → edgesync-0.3.1}/docs/storage.md +0 -0
  20. {edgesync-0.2.0 → edgesync-0.3.1}/edgesync/__init__.py +0 -0
  21. {edgesync-0.2.0 → edgesync-0.3.1}/edgesync/client.py +0 -0
  22. {edgesync-0.2.0 → edgesync-0.3.1}/edgesync/config.py +0 -0
  23. {edgesync-0.2.0 → edgesync-0.3.1}/edgesync/exceptions.py +0 -0
  24. {edgesync-0.2.0 → edgesync-0.3.1}/edgesync/logging.py +0 -0
  25. {edgesync-0.2.0 → edgesync-0.3.1}/edgesync/models/__init__.py +0 -0
  26. {edgesync-0.2.0 → edgesync-0.3.1}/edgesync/models/delivery.py +0 -0
  27. {edgesync-0.2.0 → edgesync-0.3.1}/edgesync/models/message.py +0 -0
  28. {edgesync-0.2.0 → edgesync-0.3.1}/edgesync/models/receipt.py +0 -0
  29. {edgesync-0.2.0 → edgesync-0.3.1}/edgesync/models/stats.py +0 -0
  30. {edgesync-0.2.0 → edgesync-0.3.1}/edgesync/py.typed +0 -0
  31. {edgesync-0.2.0 → edgesync-0.3.1}/edgesync/queue/__init__.py +0 -0
  32. {edgesync-0.2.0 → edgesync-0.3.1}/edgesync/queue/manager.py +0 -0
  33. {edgesync-0.2.0 → edgesync-0.3.1}/edgesync/retry/__init__.py +0 -0
  34. {edgesync-0.2.0 → edgesync-0.3.1}/edgesync/retry/backoff.py +0 -0
  35. {edgesync-0.2.0 → edgesync-0.3.1}/edgesync/retry/policy.py +0 -0
  36. {edgesync-0.2.0 → edgesync-0.3.1}/edgesync/storage/__init__.py +0 -0
  37. {edgesync-0.2.0 → edgesync-0.3.1}/edgesync/storage/base.py +0 -0
  38. {edgesync-0.2.0 → edgesync-0.3.1}/edgesync/storage/migrations.py +0 -0
  39. {edgesync-0.2.0 → edgesync-0.3.1}/edgesync/storage/sqlite.py +0 -0
  40. {edgesync-0.2.0 → edgesync-0.3.1}/edgesync/transports/__init__.py +0 -0
  41. {edgesync-0.2.0 → edgesync-0.3.1}/edgesync/transports/base.py +0 -0
  42. {edgesync-0.2.0 → edgesync-0.3.1}/edgesync/transports/http.py +0 -0
  43. {edgesync-0.2.0 → edgesync-0.3.1}/edgesync/transports/registry.py +0 -0
  44. {edgesync-0.2.0 → edgesync-0.3.1}/edgesync/utils/__init__.py +0 -0
  45. {edgesync-0.2.0 → edgesync-0.3.1}/edgesync/utils/clock.py +0 -0
  46. {edgesync-0.2.0 → edgesync-0.3.1}/edgesync/utils/ids.py +0 -0
  47. {edgesync-0.2.0 → edgesync-0.3.1}/edgesync/worker/__init__.py +0 -0
  48. {edgesync-0.2.0 → edgesync-0.3.1}/edgesync/worker/lifecycle.py +0 -0
  49. {edgesync-0.2.0 → edgesync-0.3.1}/edgesync/worker/scheduler.py +0 -0
  50. {edgesync-0.2.0 → edgesync-0.3.1}/edgesync/worker/sync_worker.py +0 -0
  51. {edgesync-0.2.0 → edgesync-0.3.1}/tests/__init__.py +0 -0
  52. {edgesync-0.2.0 → edgesync-0.3.1}/tests/conftest.py +0 -0
  53. {edgesync-0.2.0 → edgesync-0.3.1}/tests/fixtures/__init__.py +0 -0
  54. {edgesync-0.2.0 → edgesync-0.3.1}/tests/fixtures/factories.py +0 -0
  55. {edgesync-0.2.0 → edgesync-0.3.1}/tests/fixtures/fake_transport.py +0 -0
  56. {edgesync-0.2.0 → edgesync-0.3.1}/tests/integration/__init__.py +0 -0
  57. {edgesync-0.2.0 → edgesync-0.3.1}/tests/integration/test_client.py +0 -0
  58. {edgesync-0.2.0 → edgesync-0.3.1}/tests/integration/test_http_transport.py +0 -0
  59. {edgesync-0.2.0 → edgesync-0.3.1}/tests/integration/test_sqlite_storage.py +0 -0
  60. {edgesync-0.2.0 → edgesync-0.3.1}/tests/integration/test_worker.py +0 -0
  61. {edgesync-0.2.0 → edgesync-0.3.1}/tests/reliability/__init__.py +0 -0
  62. {edgesync-0.2.0 → edgesync-0.3.1}/tests/reliability/test_reliability.py +0 -0
  63. {edgesync-0.2.0 → edgesync-0.3.1}/tests/unit/__init__.py +0 -0
  64. {edgesync-0.2.0 → edgesync-0.3.1}/tests/unit/test_backoff.py +0 -0
  65. {edgesync-0.2.0 → edgesync-0.3.1}/tests/unit/test_config.py +0 -0
  66. {edgesync-0.2.0 → edgesync-0.3.1}/tests/unit/test_models.py +0 -0
  67. {edgesync-0.2.0 → edgesync-0.3.1}/tests/unit/test_queue_manager.py +0 -0
  68. {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
- check-version:
46
- name: verify tag matches VERSION file
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: Compare tag to VERSION file
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
- FILE_VERSION="$(tr -d '[:space:]' < VERSION)"
56
- if [ "$TAG_VERSION" != "$FILE_VERSION" ]; then
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: check-version
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="$(tr -d '[:space:]' < 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
- name: EdgeSync v${{ steps.changelog.outputs.version }}
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.2.0...HEAD
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. To cut a release:
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
- 4. Tag the commit and push the tag: `git tag v0.2.0 && git push origin v0.2.0`.
72
-
73
- Pushing a `vX.Y.Z` tag triggers `.github/workflows/release.yml`, which runs the full test
74
- suite, verifies the tag matches `VERSION`, builds the sdist/wheel, and publishes to PyPI via
75
- Trusted Publishing. If that version is already on PyPI (e.g. the workflow is re-run), the
76
- publish step is skipped rather than failing.
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.2.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
  [![CI](https://github.com/adhuldas/EdgeSync/actions/workflows/ci.yml/badge.svg)](https://github.com/adhuldas/EdgeSync/actions/workflows/ci.yml)
32
37
  [![PyPI](https://img.shields.io/pypi/v/edgesync.svg)](https://pypi.org/project/edgesync/)
33
- [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
38
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/adhuldas/EdgeSync/blob/main/LICENSE)
34
39
  [![Python](https://img.shields.io/badge/python-3.10%2B-blue.svg)](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 an HTTP transport; implement `Transport` for anything
131
- else (MQTT, gRPC, a message broker, ...).
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
  [![CI](https://github.com/adhuldas/EdgeSync/actions/workflows/ci.yml/badge.svg)](https://github.com/adhuldas/EdgeSync/actions/workflows/ci.yml)
4
4
  [![PyPI](https://img.shields.io/pypi/v/edgesync.svg)](https://pypi.org/project/edgesync/)
5
- [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/adhuldas/EdgeSync/blob/main/LICENSE)
6
6
  [![Python](https://img.shields.io/badge/python-3.10%2B-blue.svg)](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 an HTTP transport; implement `Transport` for anything
103
- else (MQTT, gRPC, a message broker, ...).
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