Skip to content

Commit 8148648

Browse files
committed
Add PyPI publish job and complex smoke test
1 parent fae436c commit 8148648

2 files changed

Lines changed: 252 additions & 0 deletions

File tree

.github/workflows/publish.yml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ on:
99
jobs:
1010
publish-testpypi:
1111
name: Publish to TestPyPI
12+
if: github.event_name == 'push'
1213
runs-on: ubuntu-latest
1314
environment: testpypi
1415
permissions:
@@ -23,3 +24,18 @@ jobs:
2324
- uses: pypa/gh-action-pypi-publish@release/v1
2425
with:
2526
repository-url: https://test.pypi.org/legacy/
27+
28+
publish-pypi:
29+
name: Publish to PyPI
30+
runs-on: ubuntu-latest
31+
environment: pypi
32+
permissions:
33+
id-token: write
34+
steps:
35+
- uses: actions/checkout@v4
36+
- uses: actions/setup-python@v5
37+
with:
38+
python-version: "3.11"
39+
- run: pip install build
40+
- run: python -m build
41+
- uses: pypa/gh-action-pypi-publish@release/v1

examples/smoke_test_complex.py

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Smoke test for queuebridge — complex models + Celery + Dramatiq (no Redis required).
4+
5+
Run after installing from TestPyPI or locally:
6+
py -3 examples/smoke_test_complex.py
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import sys
12+
from datetime import datetime, timezone
13+
from decimal import Decimal
14+
from enum import Enum
15+
from typing import Optional
16+
from uuid import UUID, uuid4
17+
18+
from pydantic import BaseModel, Field, validate_call
19+
20+
from queuebridge import decode, encode
21+
from queuebridge.codec import decode_wire
22+
23+
24+
# ---------------------------------------------------------------------------
25+
# Complex domain models (the kind people actually pass to task queues)
26+
# ---------------------------------------------------------------------------
27+
28+
29+
class Priority(str, Enum):
30+
LOW = "low"
31+
HIGH = "high"
32+
CRITICAL = "critical"
33+
34+
35+
class LineItem(BaseModel):
36+
sku: str
37+
qty: int = Field(ge=1)
38+
unit_price: Decimal
39+
40+
41+
class ShippingAddress(BaseModel):
42+
street: str
43+
city: str
44+
country: str = "US"
45+
geo_id: UUID
46+
47+
48+
class ShipmentRequest(BaseModel):
49+
"""Nested model with UUID, datetime, Decimal, Enum, and list of models."""
50+
51+
request_id: UUID
52+
customer_ref: str
53+
priority: Priority
54+
created_at: datetime
55+
items: list[LineItem]
56+
ship_to: ShippingAddress
57+
notes: Optional[str] = None
58+
tags: set[str] = Field(default_factory=set)
59+
60+
61+
class ShipmentResult(BaseModel):
62+
request_id: UUID
63+
tracking_code: str
64+
total: Decimal
65+
shipped_at: datetime
66+
item_count: int
67+
68+
69+
def make_sample_request() -> ShipmentRequest:
70+
return ShipmentRequest(
71+
request_id=uuid4(),
72+
customer_ref="CUST-8842",
73+
priority=Priority.HIGH,
74+
created_at=datetime.now(timezone.utc),
75+
items=[
76+
LineItem(sku="WIDGET-01", qty=3, unit_price=Decimal("19.99")),
77+
LineItem(sku="GADGET-42", qty=1, unit_price=Decimal("149.50")),
78+
],
79+
ship_to=ShippingAddress(
80+
street="42 Queue Lane",
81+
city="Brooklyn",
82+
geo_id=uuid4(),
83+
),
84+
notes="Fragile — handle with care",
85+
tags={"express", "insured"},
86+
)
87+
88+
89+
# ---------------------------------------------------------------------------
90+
# 1. Core codec roundtrip
91+
# ---------------------------------------------------------------------------
92+
93+
94+
def test_codec_roundtrip() -> None:
95+
original = make_sample_request()
96+
wire = encode(original)
97+
assert isinstance(wire, dict)
98+
restored = decode(wire, ShipmentRequest)
99+
assert restored == original
100+
assert isinstance(restored.ship_to.geo_id, UUID)
101+
assert isinstance(restored.items[0].unit_price, Decimal)
102+
assert restored.priority is Priority.HIGH
103+
print(" [OK] encode/decode roundtrip (nested models, UUID, Decimal, Enum, set)")
104+
105+
106+
def test_codec_list_of_models() -> None:
107+
batch = [make_sample_request(), make_sample_request()]
108+
wire = encode(batch)
109+
restored = decode(wire, list[ShipmentRequest])
110+
assert len(restored) == 2
111+
assert all(isinstance(r, ShipmentRequest) for r in restored)
112+
print(" [OK] list[ShipmentRequest] roundtrip")
113+
114+
115+
# ---------------------------------------------------------------------------
116+
# 2. Celery (eager mode — no broker needed)
117+
# ---------------------------------------------------------------------------
118+
119+
120+
def test_celery_eager() -> None:
121+
from celery import Celery
122+
123+
from queuebridge.celery import register_queuebridge, typed_result
124+
125+
app = Celery("smoke", broker="memory://", backend="cache+memory://")
126+
app.conf.update(task_always_eager=True, task_store_eager_result=True)
127+
register_queuebridge(app)
128+
129+
@app.task(pydantic=True)
130+
def fulfill_shipment(req: ShipmentRequest) -> ShipmentResult:
131+
assert isinstance(req, ShipmentRequest)
132+
assert isinstance(req.items[0].unit_price, Decimal)
133+
total = sum(i.unit_price * i.qty for i in req.items)
134+
return ShipmentResult(
135+
request_id=req.request_id,
136+
tracking_code=f"TRK-{req.customer_ref}",
137+
total=total,
138+
shipped_at=datetime.now(timezone.utc),
139+
item_count=len(req.items),
140+
)
141+
142+
request = make_sample_request()
143+
async_result = fulfill_shipment.delay(request)
144+
assert async_result.successful(), async_result.traceback
145+
146+
raw = async_result.get()
147+
assert isinstance(raw, dict), "Celery .get() still returns dict without typed_result"
148+
149+
result = typed_result(async_result, ShipmentResult).get()
150+
assert isinstance(result, ShipmentResult)
151+
assert result.tracking_code == f"TRK-{request.customer_ref}"
152+
assert result.total == Decimal("19.99") * 3 + Decimal("149.50")
153+
print(" [OK] Celery delay(model) + typed_result().get() -> ShipmentResult")
154+
155+
156+
# ---------------------------------------------------------------------------
157+
# 3. Dramatiq (StubBroker + in-process worker)
158+
# ---------------------------------------------------------------------------
159+
160+
161+
def test_dramatiq_stub() -> None:
162+
import dramatiq
163+
from dramatiq import Worker
164+
from dramatiq.brokers.stub import StubBroker
165+
166+
from queuebridge.dramatiq import register_queuebridge
167+
168+
received: list[ShipmentRequest] = []
169+
170+
broker = StubBroker()
171+
dramatiq.set_broker(broker)
172+
register_queuebridge(broker)
173+
174+
@dramatiq.actor
175+
@validate_call
176+
def receive_shipment(req: ShipmentRequest) -> None:
177+
received.append(req)
178+
179+
broker.declare_queue("default")
180+
worker = Worker(broker, worker_timeout=100)
181+
worker.start()
182+
try:
183+
receive_shipment.send(make_sample_request())
184+
broker.join("default", timeout=5000)
185+
finally:
186+
worker.stop()
187+
188+
assert len(received) == 1
189+
assert isinstance(received[0], ShipmentRequest)
190+
assert received[0].priority in Priority
191+
print(" [OK] Dramatiq send(model) -> validate_call receives ShipmentRequest")
192+
193+
194+
# ---------------------------------------------------------------------------
195+
# 4. Wire format sanity (what actually hits the broker)
196+
# ---------------------------------------------------------------------------
197+
198+
199+
def test_wire_has_tags() -> None:
200+
req = make_sample_request()
201+
wire = encode(req)
202+
assert "__qb__" in wire
203+
assert wire["__qb__"]["t"].endswith("ShipmentRequest")
204+
unwrapped = decode_wire(wire)
205+
assert isinstance(unwrapped, ShipmentRequest)
206+
print(" [OK] wire format uses __qb__ tags; decode_wire unwraps to model")
207+
208+
209+
def main() -> int:
210+
print("queuebridge smoke test - complex models\n")
211+
tests = [
212+
("Core codec", test_codec_roundtrip),
213+
("List of models", test_codec_list_of_models),
214+
("Wire format", test_wire_has_tags),
215+
("Celery eager", test_celery_eager),
216+
("Dramatiq stub", test_dramatiq_stub),
217+
]
218+
failed = 0
219+
for name, fn in tests:
220+
print(f"\n{name}:")
221+
try:
222+
fn()
223+
except Exception as exc:
224+
print(f" [FAIL] {exc}")
225+
failed += 1
226+
227+
print()
228+
if failed:
229+
print(f"FAILED — {failed} check(s)")
230+
return 1
231+
print("ALL CHECKS PASSED — queuebridge is working")
232+
return 0
233+
234+
235+
if __name__ == "__main__":
236+
sys.exit(main())

0 commit comments

Comments
 (0)