pigeonmq 1.1.0__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.
- pigeonmq-1.1.0/.gitignore +46 -0
- pigeonmq-1.1.0/PKG-INFO +60 -0
- pigeonmq-1.1.0/README.md +43 -0
- pigeonmq-1.1.0/pyproject.toml +27 -0
- pigeonmq-1.1.0/src/pigeonmq/__init__.py +3 -0
- pigeonmq-1.1.0/src/pigeonmq/client.py +143 -0
- pigeonmq-1.1.0/tests/test_integration.py +62 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Dependencies
|
|
2
|
+
node_modules/
|
|
3
|
+
|
|
4
|
+
# Next.js build output (web/, demo/)
|
|
5
|
+
.next/
|
|
6
|
+
next-env.d.ts
|
|
7
|
+
|
|
8
|
+
# Vercel deployment metadata (demo/)
|
|
9
|
+
.vercel
|
|
10
|
+
|
|
11
|
+
# Logs
|
|
12
|
+
*.log
|
|
13
|
+
npm-debug.log*
|
|
14
|
+
yarn-debug.log*
|
|
15
|
+
yarn-error.log*
|
|
16
|
+
|
|
17
|
+
# Internal scratch / generated notes (not for distribution)
|
|
18
|
+
work/
|
|
19
|
+
outputs/
|
|
20
|
+
|
|
21
|
+
# Agent / tooling scratch
|
|
22
|
+
.agents/
|
|
23
|
+
.codex/
|
|
24
|
+
|
|
25
|
+
# Local Claude Code settings (permissions etc.) - personal, not shared.
|
|
26
|
+
# The shared .claude/commands and .claude/agents ARE tracked.
|
|
27
|
+
.claude/settings.local.json
|
|
28
|
+
|
|
29
|
+
# Personal npm-token setup reminder (see release workflow) - not for distribution.
|
|
30
|
+
NPM_TOKEN_SETUP.md
|
|
31
|
+
|
|
32
|
+
# Environment
|
|
33
|
+
.env
|
|
34
|
+
.env.*
|
|
35
|
+
!.env.example
|
|
36
|
+
|
|
37
|
+
# Verification / screenshot artifacts (the banner ships as .svg)
|
|
38
|
+
*.png
|
|
39
|
+
.playwright-mcp/
|
|
40
|
+
|
|
41
|
+
# OS / editor cruft
|
|
42
|
+
.DS_Store
|
|
43
|
+
Thumbs.db
|
|
44
|
+
.idea/
|
|
45
|
+
.vscode/
|
|
46
|
+
*.swp
|
pigeonmq-1.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pigeonmq
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary: Python client for the Pigeon contract-native message broker
|
|
5
|
+
Project-URL: Homepage, https://pigeonmq.cc
|
|
6
|
+
Project-URL: Documentation, https://pigeonmq.cc
|
|
7
|
+
Project-URL: Repository, https://github.com/vishnu-77/pigeon
|
|
8
|
+
Project-URL: Issues, https://github.com/vishnu-77/pigeon/issues
|
|
9
|
+
Author: vishnu-77
|
|
10
|
+
License: Apache-2.0
|
|
11
|
+
Keywords: broker,communication-contracts,governance,messaging
|
|
12
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# Pigeon Python SDK
|
|
19
|
+
|
|
20
|
+
Official Python client for the Pigeon contract-native message broker.
|
|
21
|
+
|
|
22
|
+
> PyPI and TestPyPI Trusted Publishing are wired into `.github/workflows/release.yml`. Until the first registry release is cut, install from the repository subdirectory for development.
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install ./sdk/python
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
After the first PyPI release:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install pigeonmq
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from pigeonmq import PigeonClient, PigeonClientError
|
|
36
|
+
|
|
37
|
+
client = PigeonClient(token="checkout-token")
|
|
38
|
+
client.connect(["payments.authorize"])
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
result = client.request(
|
|
42
|
+
"payments.authorize",
|
|
43
|
+
{
|
|
44
|
+
"merchantId": "m",
|
|
45
|
+
"orderId": "order_42",
|
|
46
|
+
"amount": 42.0,
|
|
47
|
+
"currency": "GBP",
|
|
48
|
+
"paymentToken": "tok",
|
|
49
|
+
},
|
|
50
|
+
intent="authorize_payment",
|
|
51
|
+
idempotency_key="order_42:authorize",
|
|
52
|
+
classification="pci",
|
|
53
|
+
region="uk",
|
|
54
|
+
)
|
|
55
|
+
print(result["status"])
|
|
56
|
+
except PigeonClientError as error:
|
|
57
|
+
print(error.code, error.status)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The client exposes `connect`, `publish`, `request`, `receive`, `subjects`, `audit`, and `quarantine`. Errors surface as `PigeonClientError` with `code`, `status`, and `details`.
|
pigeonmq-1.1.0/README.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Pigeon Python SDK
|
|
2
|
+
|
|
3
|
+
Official Python client for the Pigeon contract-native message broker.
|
|
4
|
+
|
|
5
|
+
> PyPI and TestPyPI Trusted Publishing are wired into `.github/workflows/release.yml`. Until the first registry release is cut, install from the repository subdirectory for development.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install ./sdk/python
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
After the first PyPI release:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install pigeonmq
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
from pigeonmq import PigeonClient, PigeonClientError
|
|
19
|
+
|
|
20
|
+
client = PigeonClient(token="checkout-token")
|
|
21
|
+
client.connect(["payments.authorize"])
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
result = client.request(
|
|
25
|
+
"payments.authorize",
|
|
26
|
+
{
|
|
27
|
+
"merchantId": "m",
|
|
28
|
+
"orderId": "order_42",
|
|
29
|
+
"amount": 42.0,
|
|
30
|
+
"currency": "GBP",
|
|
31
|
+
"paymentToken": "tok",
|
|
32
|
+
},
|
|
33
|
+
intent="authorize_payment",
|
|
34
|
+
idempotency_key="order_42:authorize",
|
|
35
|
+
classification="pci",
|
|
36
|
+
region="uk",
|
|
37
|
+
)
|
|
38
|
+
print(result["status"])
|
|
39
|
+
except PigeonClientError as error:
|
|
40
|
+
print(error.code, error.status)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The client exposes `connect`, `publish`, `request`, `receive`, `subjects`, `audit`, and `quarantine`. Errors surface as `PigeonClientError` with `code`, `status`, and `details`.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pigeonmq"
|
|
7
|
+
version = "1.1.0"
|
|
8
|
+
description = "Python client for the Pigeon contract-native message broker"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "Apache-2.0" }
|
|
12
|
+
authors = [{ name = "vishnu-77" }]
|
|
13
|
+
keywords = ["messaging", "broker", "communication-contracts", "governance"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"License :: OSI Approved :: Apache Software License",
|
|
17
|
+
"Operating System :: OS Independent"
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
Homepage = "https://pigeonmq.cc"
|
|
22
|
+
Documentation = "https://pigeonmq.cc"
|
|
23
|
+
Repository = "https://github.com/vishnu-77/pigeon"
|
|
24
|
+
Issues = "https://github.com/vishnu-77/pigeon/issues"
|
|
25
|
+
|
|
26
|
+
[tool.hatch.build.targets.wheel]
|
|
27
|
+
packages = ["src/pigeonmq"]
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Small standard-library client for Pigeon Protocol v1."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
from urllib.error import HTTPError
|
|
8
|
+
from urllib.request import Request, urlopen
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PigeonClientError(RuntimeError):
|
|
12
|
+
def __init__(self, code: str, message: str, status: int, details: dict[str, Any] | None = None) -> None:
|
|
13
|
+
super().__init__(message)
|
|
14
|
+
self.code = code
|
|
15
|
+
self.status = status
|
|
16
|
+
self.details = details or {}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class PigeonClient:
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
url: str = "http://localhost:8787",
|
|
23
|
+
*,
|
|
24
|
+
token: str | None = None,
|
|
25
|
+
region: str = "uk",
|
|
26
|
+
timeout: float = 10.0,
|
|
27
|
+
) -> None:
|
|
28
|
+
self.url = url.rstrip("/")
|
|
29
|
+
self.token = token
|
|
30
|
+
self.region = region
|
|
31
|
+
self.timeout = timeout
|
|
32
|
+
self.contract: dict[str, Any] | None = None
|
|
33
|
+
self.contract_id: str | None = None
|
|
34
|
+
|
|
35
|
+
def connect(self, subjects: list[str], *, ttl_ms: int | None = None) -> dict[str, Any]:
|
|
36
|
+
body: dict[str, Any] = {"subjects": subjects}
|
|
37
|
+
if ttl_ms is not None:
|
|
38
|
+
body["ttlMs"] = ttl_ms
|
|
39
|
+
payload = self._post("/v1/contracts", body, with_contract=False)
|
|
40
|
+
self.contract = payload["contract"]
|
|
41
|
+
self.contract_id = self.contract["id"]
|
|
42
|
+
return self.contract
|
|
43
|
+
|
|
44
|
+
def publish(self, message: dict[str, Any]) -> dict[str, Any]:
|
|
45
|
+
self._require_contract()
|
|
46
|
+
return self._post("/v1/messages", message, with_contract=True)
|
|
47
|
+
|
|
48
|
+
def request(
|
|
49
|
+
self,
|
|
50
|
+
subject: str,
|
|
51
|
+
data: dict[str, Any],
|
|
52
|
+
*,
|
|
53
|
+
intent: str | None = None,
|
|
54
|
+
idempotency_key: str | None = None,
|
|
55
|
+
classification: str | None = None,
|
|
56
|
+
region: str | None = None,
|
|
57
|
+
message_type: str | None = None,
|
|
58
|
+
source: str = "sdk-python",
|
|
59
|
+
correlation_id: str | None = None,
|
|
60
|
+
) -> dict[str, Any]:
|
|
61
|
+
message = {
|
|
62
|
+
"subject": subject,
|
|
63
|
+
"type": message_type or f"{subject}.request",
|
|
64
|
+
"source": source,
|
|
65
|
+
"intent": intent,
|
|
66
|
+
"idempotencyKey": idempotency_key,
|
|
67
|
+
"classification": classification,
|
|
68
|
+
"region": region or self.region,
|
|
69
|
+
"correlationId": correlation_id,
|
|
70
|
+
"data": data,
|
|
71
|
+
}
|
|
72
|
+
return self.publish({key: value for key, value in message.items() if value is not None})
|
|
73
|
+
|
|
74
|
+
def receive(self, subject: str, *, max_messages: int = 1) -> list[dict[str, Any]]:
|
|
75
|
+
self._require_contract()
|
|
76
|
+
payload = self._post(
|
|
77
|
+
f"/v1/subjects/{subject}/receive",
|
|
78
|
+
{"max": max_messages},
|
|
79
|
+
with_contract=True,
|
|
80
|
+
)
|
|
81
|
+
return payload["messages"]
|
|
82
|
+
|
|
83
|
+
def subjects(self) -> list[dict[str, Any]]:
|
|
84
|
+
return self._get("/v1/subjects")["subjects"]
|
|
85
|
+
|
|
86
|
+
def audit(self) -> list[dict[str, Any]]:
|
|
87
|
+
return self._get("/v1/audit")["records"]
|
|
88
|
+
|
|
89
|
+
def quarantine(self) -> list[dict[str, Any]]:
|
|
90
|
+
return self._get("/v1/quarantine")["records"]
|
|
91
|
+
|
|
92
|
+
def _require_contract(self) -> None:
|
|
93
|
+
if not self.contract_id:
|
|
94
|
+
raise PigeonClientError(
|
|
95
|
+
"CONTRACT_REQUIRED",
|
|
96
|
+
"Call connect() to negotiate a contract first.",
|
|
97
|
+
0,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def _headers(self, with_contract: bool) -> dict[str, str]:
|
|
101
|
+
headers = {
|
|
102
|
+
"content-type": "application/json",
|
|
103
|
+
"x-pigeon-region": self.region,
|
|
104
|
+
}
|
|
105
|
+
if self.token:
|
|
106
|
+
headers["authorization"] = f"Bearer {self.token}"
|
|
107
|
+
if with_contract and self.contract_id:
|
|
108
|
+
headers["x-pigeon-contract"] = self.contract_id
|
|
109
|
+
return headers
|
|
110
|
+
|
|
111
|
+
def _post(self, path: str, body: dict[str, Any], *, with_contract: bool) -> dict[str, Any]:
|
|
112
|
+
request = Request(
|
|
113
|
+
f"{self.url}{path}",
|
|
114
|
+
data=json.dumps(body).encode("utf-8"),
|
|
115
|
+
headers=self._headers(with_contract),
|
|
116
|
+
method="POST",
|
|
117
|
+
)
|
|
118
|
+
return self._send(request)
|
|
119
|
+
|
|
120
|
+
def _get(self, path: str) -> dict[str, Any]:
|
|
121
|
+
request = Request(
|
|
122
|
+
f"{self.url}{path}",
|
|
123
|
+
headers=self._headers(False),
|
|
124
|
+
method="GET",
|
|
125
|
+
)
|
|
126
|
+
return self._send(request)
|
|
127
|
+
|
|
128
|
+
def _send(self, request: Request) -> dict[str, Any]:
|
|
129
|
+
try:
|
|
130
|
+
with urlopen(request, timeout=self.timeout) as response:
|
|
131
|
+
return json.loads(response.read().decode("utf-8"))
|
|
132
|
+
except HTTPError as error:
|
|
133
|
+
try:
|
|
134
|
+
payload = json.loads(error.read().decode("utf-8"))
|
|
135
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
136
|
+
payload = {}
|
|
137
|
+
details = payload.get("error", {})
|
|
138
|
+
raise PigeonClientError(
|
|
139
|
+
details.get("code", "REQUEST_FAILED"),
|
|
140
|
+
details.get("message", f"HTTP {error.code}"),
|
|
141
|
+
error.code,
|
|
142
|
+
details.get("details", {}),
|
|
143
|
+
) from error
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import unittest
|
|
3
|
+
|
|
4
|
+
from pigeonmq import PigeonClient, PigeonClientError
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
BASE = os.environ.get("PIGEON_URL", "http://localhost:8787")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PigeonIntegrationTests(unittest.TestCase):
|
|
11
|
+
def test_contract_and_publish(self):
|
|
12
|
+
client = PigeonClient(BASE, token="checkout-token")
|
|
13
|
+
contract = client.connect(["payments.authorize"])
|
|
14
|
+
self.assertTrue(contract["id"])
|
|
15
|
+
|
|
16
|
+
result = client.request(
|
|
17
|
+
"payments.authorize",
|
|
18
|
+
{
|
|
19
|
+
"merchantId": "m",
|
|
20
|
+
"orderId": "python_1",
|
|
21
|
+
"amount": 12.5,
|
|
22
|
+
"currency": "GBP",
|
|
23
|
+
"paymentToken": "tok",
|
|
24
|
+
},
|
|
25
|
+
intent="authorize_payment",
|
|
26
|
+
idempotency_key="python_1:authorize",
|
|
27
|
+
classification="pci",
|
|
28
|
+
region="uk",
|
|
29
|
+
)
|
|
30
|
+
self.assertEqual(result["status"], "accepted")
|
|
31
|
+
|
|
32
|
+
def test_unauthorized_principal_is_denied_at_negotiation(self):
|
|
33
|
+
client = PigeonClient(BASE, token="catalog-token")
|
|
34
|
+
with self.assertRaises(PigeonClientError) as raised:
|
|
35
|
+
client.connect(["payments.authorize"])
|
|
36
|
+
self.assertEqual(raised.exception.code, "NO_PERMITTED_SUBJECTS")
|
|
37
|
+
self.assertEqual(raised.exception.status, 403)
|
|
38
|
+
|
|
39
|
+
def test_sensitive_field_violation_is_denied(self):
|
|
40
|
+
client = PigeonClient(BASE, token="checkout-token")
|
|
41
|
+
client.connect(["payments.authorize"])
|
|
42
|
+
with self.assertRaises(PigeonClientError) as raised:
|
|
43
|
+
client.request(
|
|
44
|
+
"payments.authorize",
|
|
45
|
+
{
|
|
46
|
+
"merchantId": "m",
|
|
47
|
+
"orderId": "python_sensitive",
|
|
48
|
+
"amount": 12.5,
|
|
49
|
+
"currency": "GBP",
|
|
50
|
+
"paymentToken": "tok",
|
|
51
|
+
"card": {"pan": "TEST_ONLY"},
|
|
52
|
+
},
|
|
53
|
+
intent="authorize_payment",
|
|
54
|
+
idempotency_key="python_sensitive:authorize",
|
|
55
|
+
classification="pci",
|
|
56
|
+
region="uk",
|
|
57
|
+
)
|
|
58
|
+
self.assertIn(raised.exception.code, {"SENSITIVE_FIELD_DENIED", "RAW_PAN_DETECTED"})
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
if __name__ == "__main__":
|
|
62
|
+
unittest.main()
|