zazu-sdk 0.2.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.
@@ -0,0 +1,18 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *$py.class
4
+ *.egg-info/
5
+ .pytest_cache/
6
+ .mypy_cache/
7
+ .ruff_cache/
8
+ .venv/
9
+ venv/
10
+ dist/
11
+ build/
12
+ .coverage
13
+ htmlcov/
14
+ .env
15
+ tests/fixtures/cassettes/
16
+ cassettes-v*.tar.gz
17
+ .tox/
18
+ .python-version.local
@@ -0,0 +1,24 @@
1
+ # Changelog
2
+
3
+ All notable changes to `zazu-sdk` are documented here.
4
+
5
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
+ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.2.1]
11
+
12
+ Version alignment: the whole SDK family now releases in lockstep with zazu-ruby. No functional changes since [0.1.0].
13
+
14
+ ## [0.1.0]
15
+
16
+ Initial release.
17
+
18
+ ### Added
19
+
20
+ - Sync `Zazu` client built on `httpx`
21
+ - Resource modules: `accounts`, `beneficiaries`, `checkout_sessions`, `customers`, `entity`, `invoices`, `payment_links`, `transfer_drafts`, `webhook_endpoints`
22
+ - Cursor-based `Page` with `auto_paging_iter()`
23
+ - Nine-class error hierarchy mirroring `zazu-ruby`
24
+ - Cassette-replay test harness driven by the Ruby SDK's release tarball
zazu_sdk-0.2.1/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Zazu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,120 @@
1
+ Metadata-Version: 2.4
2
+ Name: zazu-sdk
3
+ Version: 0.2.1
4
+ Summary: Python SDK for the Zazu API.
5
+ Project-URL: Homepage, https://github.com/getzazu/zazu-python
6
+ Project-URL: Documentation, https://github.com/getzazu/zazu-python#readme
7
+ Project-URL: Repository, https://github.com/getzazu/zazu-python
8
+ Project-URL: Issues, https://github.com/getzazu/zazu-python/issues
9
+ Project-URL: Changelog, https://github.com/getzazu/zazu-python/blob/main/CHANGELOG.md
10
+ Author-email: Zazu <engineering@zazu.ma>
11
+ License-Expression: MIT
12
+ License-File: LICENSE
13
+ Keywords: api,fintech,morocco,sdk,zazu
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.11
25
+ Requires-Dist: httpx>=0.27
26
+ Provides-Extra: dev
27
+ Requires-Dist: mypy>=1.11; extra == 'dev'
28
+ Requires-Dist: pytest-vcr>=1.0.2; extra == 'dev'
29
+ Requires-Dist: pytest>=8; extra == 'dev'
30
+ Requires-Dist: ruff>=0.7; extra == 'dev'
31
+ Requires-Dist: vcrpy>=6; extra == 'dev'
32
+ Description-Content-Type: text/markdown
33
+
34
+ # zazu-sdk
35
+
36
+ Python SDK for the [Zazu API](https://zazu.ma).
37
+
38
+ ## Install
39
+
40
+ ```bash
41
+ pip install zazu-sdk
42
+ ```
43
+
44
+ Requires Python 3.11+.
45
+
46
+ ## Quick start
47
+
48
+ ```python
49
+ from zazu_sdk import Zazu
50
+
51
+ client = Zazu(api_key="sk_live_...")
52
+
53
+ client.entity.get()
54
+ client.accounts.list(currency_code="MAD")
55
+ client.customers.list(q="Acme")
56
+ client.customers.create(
57
+ customer_type="business",
58
+ company_name="Acme Corp",
59
+ email="billing@acme.com",
60
+ )
61
+
62
+ client.invoices.list()
63
+ client.payment_links.cancel(payment_link_id)
64
+ client.webhook_endpoints.list()
65
+
66
+ client.checkout_sessions.create(
67
+ account_id=account_id,
68
+ amount="100.00",
69
+ success_url="https://example.com/ok",
70
+ cancel_url="https://example.com/cancel",
71
+ )
72
+ client.checkout_sessions.get(session_id)
73
+ ```
74
+
75
+ The client picks up `ZAZU_API_KEY`, `ZAZU_BASE_URL`, `ZAZU_API_VERSION`, and
76
+ `ZAZU_TIMEOUT` from the environment if you don't pass them.
77
+
78
+ ## Pagination
79
+
80
+ List endpoints return a `Page`. Iterate one page or chase cursors:
81
+
82
+ ```python
83
+ page = client.invoices.list(limit=25)
84
+ for invoice in page:
85
+ ...
86
+
87
+ # All items, automatic cursor chasing:
88
+ for invoice in client.invoices.list().auto_paging_iter():
89
+ ...
90
+ ```
91
+
92
+ ## Errors
93
+
94
+ Nine concrete subclasses; discriminate with `isinstance`:
95
+
96
+ ```python
97
+ from zazu_sdk import (
98
+ ZazuValidationError,
99
+ ZazuRateLimitError,
100
+ ZazuNotFoundError,
101
+ )
102
+
103
+ try:
104
+ client.invoices.get("nope")
105
+ except ZazuNotFoundError as err:
106
+ print(err.status, err.request_id, err.body)
107
+ except ZazuRateLimitError as err:
108
+ print("retry after", err.retry_after, "seconds")
109
+ ```
110
+
111
+ ## Cross-SDK contract
112
+
113
+ `zazu-sdk` is one of several Zazu SDKs that all replay cassettes recorded by
114
+ the canonical [`zazu-ruby`](https://github.com/getzazu/zazu-ruby) SDK against
115
+ staging. The wire format is snake_case JSON; request and response shapes match
116
+ across Ruby, TypeScript, Python, Go, and Rust.
117
+
118
+ ## License
119
+
120
+ MIT.
@@ -0,0 +1,87 @@
1
+ # zazu-sdk
2
+
3
+ Python SDK for the [Zazu API](https://zazu.ma).
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install zazu-sdk
9
+ ```
10
+
11
+ Requires Python 3.11+.
12
+
13
+ ## Quick start
14
+
15
+ ```python
16
+ from zazu_sdk import Zazu
17
+
18
+ client = Zazu(api_key="sk_live_...")
19
+
20
+ client.entity.get()
21
+ client.accounts.list(currency_code="MAD")
22
+ client.customers.list(q="Acme")
23
+ client.customers.create(
24
+ customer_type="business",
25
+ company_name="Acme Corp",
26
+ email="billing@acme.com",
27
+ )
28
+
29
+ client.invoices.list()
30
+ client.payment_links.cancel(payment_link_id)
31
+ client.webhook_endpoints.list()
32
+
33
+ client.checkout_sessions.create(
34
+ account_id=account_id,
35
+ amount="100.00",
36
+ success_url="https://example.com/ok",
37
+ cancel_url="https://example.com/cancel",
38
+ )
39
+ client.checkout_sessions.get(session_id)
40
+ ```
41
+
42
+ The client picks up `ZAZU_API_KEY`, `ZAZU_BASE_URL`, `ZAZU_API_VERSION`, and
43
+ `ZAZU_TIMEOUT` from the environment if you don't pass them.
44
+
45
+ ## Pagination
46
+
47
+ List endpoints return a `Page`. Iterate one page or chase cursors:
48
+
49
+ ```python
50
+ page = client.invoices.list(limit=25)
51
+ for invoice in page:
52
+ ...
53
+
54
+ # All items, automatic cursor chasing:
55
+ for invoice in client.invoices.list().auto_paging_iter():
56
+ ...
57
+ ```
58
+
59
+ ## Errors
60
+
61
+ Nine concrete subclasses; discriminate with `isinstance`:
62
+
63
+ ```python
64
+ from zazu_sdk import (
65
+ ZazuValidationError,
66
+ ZazuRateLimitError,
67
+ ZazuNotFoundError,
68
+ )
69
+
70
+ try:
71
+ client.invoices.get("nope")
72
+ except ZazuNotFoundError as err:
73
+ print(err.status, err.request_id, err.body)
74
+ except ZazuRateLimitError as err:
75
+ print("retry after", err.retry_after, "seconds")
76
+ ```
77
+
78
+ ## Cross-SDK contract
79
+
80
+ `zazu-sdk` is one of several Zazu SDKs that all replay cassettes recorded by
81
+ the canonical [`zazu-ruby`](https://github.com/getzazu/zazu-ruby) SDK against
82
+ staging. The wire format is snake_case JSON; request and response shapes match
83
+ across Ruby, TypeScript, Python, Go, and Rust.
84
+
85
+ ## License
86
+
87
+ MIT.
@@ -0,0 +1,91 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "zazu-sdk"
7
+ description = "Python SDK for the Zazu API."
8
+ readme = "README.md"
9
+ license = "MIT"
10
+ license-files = ["LICENSE"]
11
+ requires-python = ">=3.11"
12
+ authors = [{ name = "Zazu", email = "engineering@zazu.ma" }]
13
+ keywords = ["zazu", "sdk", "api", "fintech", "morocco"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Topic :: Software Development :: Libraries :: Python Modules",
24
+ "Typing :: Typed",
25
+ ]
26
+ dynamic = ["version"]
27
+ dependencies = ["httpx>=0.27"]
28
+
29
+ [project.optional-dependencies]
30
+ dev = [
31
+ "pytest>=8",
32
+ "pytest-vcr>=1.0.2",
33
+ "vcrpy>=6",
34
+ "ruff>=0.7",
35
+ "mypy>=1.11",
36
+ ]
37
+
38
+ [project.urls]
39
+ Homepage = "https://github.com/getzazu/zazu-python"
40
+ Documentation = "https://github.com/getzazu/zazu-python#readme"
41
+ Repository = "https://github.com/getzazu/zazu-python"
42
+ Issues = "https://github.com/getzazu/zazu-python/issues"
43
+ Changelog = "https://github.com/getzazu/zazu-python/blob/main/CHANGELOG.md"
44
+
45
+ [tool.hatch.version]
46
+ path = "src/zazu_sdk/_version.py"
47
+
48
+ [tool.hatch.build.targets.wheel]
49
+ packages = ["src/zazu_sdk"]
50
+
51
+ [tool.hatch.build.targets.sdist]
52
+ include = ["src/zazu_sdk", "README.md", "CHANGELOG.md", "LICENSE"]
53
+
54
+ [tool.pytest.ini_options]
55
+ testpaths = ["tests"]
56
+ addopts = "-ra"
57
+ filterwarnings = ["error"]
58
+
59
+ [tool.ruff]
60
+ line-length = 100
61
+ target-version = "py311"
62
+ src = ["src", "tests"]
63
+
64
+ [tool.ruff.lint]
65
+ select = [
66
+ "E", "F", "W", # pycodestyle / pyflakes
67
+ "I", # isort
68
+ "B", # bugbear
69
+ "UP", # pyupgrade
70
+ "PL", # pylint subset
71
+ "RUF", # ruff-specific
72
+ "SIM", # simplify
73
+ "TCH", # type-checking imports
74
+ ]
75
+ ignore = [
76
+ "PLR0913", # too many args — SDK methods accept many kwargs by design
77
+ "PLR0911", # too many returns — error-status dispatch is naturally a switch
78
+ "PLR2004", # magic-value comparison — fine for HTTP status codes
79
+ "TC001", # move application imports under TYPE_CHECKING — many are used at runtime
80
+ "TC002", # move third-party imports under TYPE_CHECKING — same reason
81
+ "TC003", # move stdlib imports under TYPE_CHECKING — same reason
82
+ "UP037", # remove quotes from annotations — needed for `Self`-style forward refs
83
+ ]
84
+
85
+ [tool.ruff.lint.per-file-ignores]
86
+ "tests/**" = ["PLR2004"]
87
+
88
+ [tool.mypy]
89
+ python_version = "3.11"
90
+ strict = true
91
+ files = ["src/zazu_sdk"]
@@ -0,0 +1,57 @@
1
+ """Zazu Python SDK. Mirrors lib/zazu.rb."""
2
+
3
+ from ._version import __version__
4
+ from .client import Zazu
5
+ from .errors import (
6
+ ZazuArgumentError,
7
+ ZazuAuthenticationError,
8
+ ZazuConfigurationError,
9
+ ZazuConnectionError,
10
+ ZazuError,
11
+ ZazuForbiddenError,
12
+ ZazuNotFoundError,
13
+ ZazuRateLimitError,
14
+ ZazuServerError,
15
+ ZazuValidationError,
16
+ )
17
+ from .page import MAX_PER_PAGE, Page
18
+ from .resources.accounts import Accounts
19
+ from .resources.beneficiaries import Beneficiaries
20
+ from .resources.checkout_sessions import CheckoutSessions
21
+ from .resources.customers import Customers
22
+ from .resources.entity import Entity
23
+ from .resources.invoices import Invoices
24
+ from .resources.payment_links import PaymentLinks
25
+ from .resources.transfer_drafts import TransferDrafts
26
+ from .resources.webhook_endpoints import WebhookEndpoints
27
+ from .response import ZazuResponse
28
+
29
+ VERSION = __version__
30
+
31
+ __all__ = [
32
+ "MAX_PER_PAGE",
33
+ "VERSION",
34
+ "Accounts",
35
+ "Beneficiaries",
36
+ "CheckoutSessions",
37
+ "Customers",
38
+ "Entity",
39
+ "Invoices",
40
+ "Page",
41
+ "PaymentLinks",
42
+ "TransferDrafts",
43
+ "WebhookEndpoints",
44
+ "Zazu",
45
+ "ZazuArgumentError",
46
+ "ZazuAuthenticationError",
47
+ "ZazuConfigurationError",
48
+ "ZazuConnectionError",
49
+ "ZazuError",
50
+ "ZazuForbiddenError",
51
+ "ZazuNotFoundError",
52
+ "ZazuRateLimitError",
53
+ "ZazuResponse",
54
+ "ZazuServerError",
55
+ "ZazuValidationError",
56
+ "__version__",
57
+ ]
@@ -0,0 +1 @@
1
+ __version__ = "0.2.1"
@@ -0,0 +1,200 @@
1
+ """Mirrors lib/zazu/client.rb. Sync HTTP entry point built on httpx."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from typing import Any
8
+ from urllib.parse import urlencode
9
+
10
+ import httpx
11
+
12
+ from ._version import __version__
13
+ from .errors import (
14
+ ZazuAuthenticationError,
15
+ ZazuConfigurationError,
16
+ ZazuConnectionError,
17
+ ZazuError,
18
+ ZazuForbiddenError,
19
+ ZazuNotFoundError,
20
+ ZazuRateLimitError,
21
+ ZazuServerError,
22
+ ZazuValidationError,
23
+ )
24
+ from .page import MAX_PER_PAGE, Page
25
+ from .resources.accounts import Accounts
26
+ from .resources.beneficiaries import Beneficiaries
27
+ from .resources.checkout_sessions import CheckoutSessions
28
+ from .resources.customers import Customers
29
+ from .resources.entity import Entity
30
+ from .resources.invoices import Invoices
31
+ from .resources.payment_links import PaymentLinks
32
+ from .resources.transfer_drafts import TransferDrafts
33
+ from .resources.webhook_endpoints import WebhookEndpoints
34
+ from .response import ZazuResponse
35
+
36
+ DEFAULT_BASE_URL = "https://zazu.ma"
37
+ DEFAULT_TIMEOUT = 30.0
38
+ USER_AGENT = f"zazu-sdk/{__version__}"
39
+
40
+
41
+ class Zazu:
42
+ """Top-level Zazu client. Resources hang off it as attributes."""
43
+
44
+ def __init__(
45
+ self,
46
+ api_key: str | None = None,
47
+ *,
48
+ base_url: str | None = None,
49
+ api_version: str | None = None,
50
+ timeout: float | None = None,
51
+ http_client: httpx.Client | None = None,
52
+ ) -> None:
53
+ api_key = api_key or os.getenv("ZAZU_API_KEY")
54
+ if not api_key:
55
+ raise ZazuConfigurationError(
56
+ "Missing api_key. Pass api_key= or set ZAZU_API_KEY."
57
+ )
58
+ self.api_key = api_key
59
+ self.base_url = (base_url or os.getenv("ZAZU_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
60
+ self.api_version = api_version or os.getenv("ZAZU_API_VERSION")
61
+ self.timeout = (
62
+ timeout if timeout is not None else _env_float("ZAZU_TIMEOUT", DEFAULT_TIMEOUT)
63
+ )
64
+ self._owns_client = http_client is None
65
+ self._http = http_client or httpx.Client(timeout=self.timeout)
66
+
67
+ self.accounts = Accounts(self)
68
+ self.beneficiaries = Beneficiaries(self)
69
+ self.checkout_sessions = CheckoutSessions(self)
70
+ self.customers = Customers(self)
71
+ self.entity = Entity(self)
72
+ self.invoices = Invoices(self)
73
+ self.payment_links = PaymentLinks(self)
74
+ self.transfer_drafts = TransferDrafts(self)
75
+ self.webhook_endpoints = WebhookEndpoints(self)
76
+
77
+ def __enter__(self) -> Zazu:
78
+ return self
79
+
80
+ def __exit__(self, *exc: Any) -> None:
81
+ self.close()
82
+
83
+ def close(self) -> None:
84
+ if self._owns_client:
85
+ self._http.close()
86
+
87
+ def request(
88
+ self,
89
+ method: str,
90
+ path: str,
91
+ *,
92
+ params: dict[str, Any] | None = None,
93
+ body: Any = None,
94
+ headers: dict[str, str] | None = None,
95
+ ) -> ZazuResponse:
96
+ url = self._build_url(path, params)
97
+ request_headers = {
98
+ "Authorization": f"Bearer {self.api_key}",
99
+ "User-Agent": USER_AGENT,
100
+ "Accept": "application/json",
101
+ }
102
+ if self.api_version:
103
+ request_headers["Zazu-Version"] = self.api_version
104
+ if headers:
105
+ request_headers.update(headers)
106
+
107
+ request_kwargs: dict[str, Any] = {"headers": request_headers}
108
+ if body is not None:
109
+ request_headers["Content-Type"] = "application/json"
110
+ request_kwargs["content"] = json.dumps(body)
111
+
112
+ try:
113
+ raw = self._http.request(method.upper(), url, **request_kwargs)
114
+ except httpx.TimeoutException as err:
115
+ raise ZazuConnectionError(f"Request timed out after {self.timeout}s") from err
116
+ except httpx.HTTPError as err:
117
+ raise ZazuConnectionError(f"Connection failed: {err}") from err
118
+
119
+ parsed = _parse_body(raw)
120
+ response = ZazuResponse(raw, parsed)
121
+ if response.success:
122
+ return response
123
+ raise _build_error(response)
124
+
125
+ def _build_url(self, path: str, params: dict[str, Any] | None) -> str:
126
+ trimmed = path.lstrip("/")
127
+ url = f"{self.base_url}/{trimmed}"
128
+ if params:
129
+ cleaned = {k: v for k, v in params.items() if v is not None}
130
+ if cleaned:
131
+ url = f"{url}?{urlencode(cleaned, doseq=True)}"
132
+ return url
133
+
134
+
135
+ def _env_float(name: str, default: float) -> float:
136
+ raw = os.getenv(name)
137
+ if not raw:
138
+ return default
139
+ try:
140
+ return float(raw)
141
+ except ValueError:
142
+ return default
143
+
144
+
145
+ def _parse_body(raw: httpx.Response) -> Any:
146
+ content_type = raw.headers.get("content-type", "")
147
+ if "json" not in content_type:
148
+ return None
149
+ text = raw.text
150
+ if not text:
151
+ return None
152
+ try:
153
+ return json.loads(text)
154
+ except json.JSONDecodeError:
155
+ return text
156
+
157
+
158
+ def _error_payload(body: Any) -> dict[str, Any]:
159
+ if not isinstance(body, dict):
160
+ return {}
161
+ err = body.get("error")
162
+ return err if isinstance(err, dict) else {}
163
+
164
+
165
+ def _build_error(response: ZazuResponse) -> ZazuError:
166
+ payload = _error_payload(response.body)
167
+ message = payload.get("message")
168
+ opts: dict[str, Any] = {
169
+ "status": response.status,
170
+ "request_id": response.request_id,
171
+ "type": payload.get("type"),
172
+ "param": payload.get("param"),
173
+ "body": response.body,
174
+ "headers": response.headers,
175
+ }
176
+
177
+ status = response.status
178
+ if status == 401:
179
+ return ZazuAuthenticationError(message or "Authentication failed", **opts)
180
+ if status == 403:
181
+ return ZazuForbiddenError(message or "Forbidden", **opts)
182
+ if status == 404:
183
+ return ZazuNotFoundError(message or "Not found", **opts)
184
+ if status == 422:
185
+ return ZazuValidationError(message or "Validation failed", **opts)
186
+ if status == 429:
187
+ retry_after = response.headers.get("retry-after")
188
+ try:
189
+ retry_after_int = int(retry_after) if retry_after else None
190
+ except ValueError:
191
+ retry_after_int = None
192
+ return ZazuRateLimitError(
193
+ message or "Rate limited", retry_after=retry_after_int, **opts
194
+ )
195
+ if 500 <= status < 600:
196
+ return ZazuServerError(message or f"Server error ({status})", **opts)
197
+ return ZazuError(message or f"Unexpected status {status}", **opts)
198
+
199
+
200
+ __all__ = ["MAX_PER_PAGE", "Page", "Zazu"]
@@ -0,0 +1,69 @@
1
+ """Mirrors lib/zazu/errors.rb. Nine-class hierarchy shared across SDKs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ class ZazuError(Exception):
9
+ """Base class for every error raised by the SDK."""
10
+
11
+ def __init__(
12
+ self,
13
+ message: str,
14
+ *,
15
+ status: int | None = None,
16
+ request_id: str | None = None,
17
+ type: str | None = None,
18
+ param: str | None = None,
19
+ body: Any = None,
20
+ headers: Any = None,
21
+ ) -> None:
22
+ super().__init__(message)
23
+ self.message = message
24
+ self.status = status
25
+ self.request_id = request_id
26
+ self.type = type
27
+ self.param = param
28
+ self.body = body
29
+ self.headers = headers
30
+
31
+
32
+ class ZazuArgumentError(ZazuError):
33
+ """Caller passed bad arguments before any HTTP request was made."""
34
+
35
+
36
+ class ZazuConfigurationError(ZazuError):
37
+ """Missing or invalid client configuration (e.g. no API key)."""
38
+
39
+
40
+ class ZazuConnectionError(ZazuError):
41
+ """The HTTP request failed before getting a response (timeout, DNS, refused)."""
42
+
43
+
44
+ class ZazuAuthenticationError(ZazuError):
45
+ """HTTP 401."""
46
+
47
+
48
+ class ZazuForbiddenError(ZazuError):
49
+ """HTTP 403."""
50
+
51
+
52
+ class ZazuNotFoundError(ZazuError):
53
+ """HTTP 404."""
54
+
55
+
56
+ class ZazuValidationError(ZazuError):
57
+ """HTTP 422."""
58
+
59
+
60
+ class ZazuRateLimitError(ZazuError):
61
+ """HTTP 429. `retry_after` is the value of the Retry-After header in seconds, if present."""
62
+
63
+ def __init__(self, message: str, *, retry_after: int | None = None, **kwargs: Any) -> None:
64
+ super().__init__(message, **kwargs)
65
+ self.retry_after = retry_after
66
+
67
+
68
+ class ZazuServerError(ZazuError):
69
+ """HTTP 5xx."""
@@ -0,0 +1,54 @@
1
+ """Mirrors lib/zazu/page.rb. Cursor-based pagination, hard cap of 100 items per page."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Iterator
6
+ from typing import Any, Generic, TypeVar
7
+
8
+ from .response import ZazuResponse
9
+
10
+ MAX_PER_PAGE = 100
11
+
12
+ T = TypeVar("T")
13
+
14
+
15
+ class Page(Generic[T]):
16
+ """A single page of results plus a fetcher to load the next page on demand."""
17
+
18
+ def __init__(
19
+ self,
20
+ response: ZazuResponse,
21
+ fetcher: Callable[[str | None], "Page[T]"],
22
+ ) -> None:
23
+ self.response = response
24
+ self._fetcher = fetcher
25
+ body = response.body if isinstance(response.body, dict) else {}
26
+ self.data: list[T] = list(body.get("data") or [])
27
+ self.has_more: bool = bool(body.get("has_more"))
28
+ self.next_cursor: str | None = body.get("next_cursor")
29
+
30
+ def next(self) -> Page[T] | None:
31
+ """Fetch the next page, or None if there are no more results."""
32
+ if not self.has_more or not self.next_cursor:
33
+ return None
34
+ return self._fetcher(self.next_cursor)
35
+
36
+ def auto_paging_iter(self) -> Iterator[T]:
37
+ """Yield every item across all pages by chasing cursors."""
38
+ page: Page[T] | None = self
39
+ while page is not None:
40
+ yield from page.data
41
+ page = page.next()
42
+
43
+ def __iter__(self) -> Iterator[T]:
44
+ return iter(self.data)
45
+
46
+ def __len__(self) -> int:
47
+ return len(self.data)
48
+
49
+ def __repr__(self) -> str:
50
+ return f"Page(items={len(self.data)}, has_more={self.has_more})"
51
+
52
+
53
+ PageBody = dict[str, Any]
54
+ PageFetcher = Callable[[str | None], Page[Any]]
File without changes
File without changes
@@ -0,0 +1,64 @@
1
+ """Mirrors lib/zazu/resources/accounts.rb."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from typing import Any
7
+
8
+ from ..page import MAX_PER_PAGE, Page
9
+ from ..response import ZazuResponse
10
+ from .base import ResourceBase
11
+
12
+
13
+ class Accounts(ResourceBase):
14
+ def list(
15
+ self,
16
+ *,
17
+ status: str | None = None,
18
+ currency_code: str | None = None,
19
+ limit: int = MAX_PER_PAGE,
20
+ cursor: str | None = None,
21
+ ) -> Page[Any]:
22
+ return self.list_page(
23
+ "api/accounts",
24
+ {"status": status, "currency_code": currency_code},
25
+ limit=limit,
26
+ cursor=cursor,
27
+ )
28
+
29
+ def get(self, id: str) -> ZazuResponse:
30
+ return self.http_get(self.encode_path("api/accounts", id))
31
+
32
+ def list_transactions(
33
+ self,
34
+ account_id: str,
35
+ *,
36
+ operation: str | None = None,
37
+ posted_after: str | datetime | None = None,
38
+ posted_before: str | datetime | None = None,
39
+ limit: int = MAX_PER_PAGE,
40
+ cursor: str | None = None,
41
+ ) -> Page[Any]:
42
+ return self.list_page(
43
+ self.encode_path("api/accounts", account_id, "transactions"),
44
+ {
45
+ "operation": operation,
46
+ "posted_after": _serialize_time(posted_after),
47
+ "posted_before": _serialize_time(posted_before),
48
+ },
49
+ limit=limit,
50
+ cursor=cursor,
51
+ )
52
+
53
+ def get_transaction(self, account_id: str, transaction_id: str) -> ZazuResponse:
54
+ return self.http_get(
55
+ self.encode_path("api/accounts", account_id, "transactions", transaction_id)
56
+ )
57
+
58
+
59
+ def _serialize_time(value: str | datetime | None) -> str | None:
60
+ if value is None:
61
+ return None
62
+ if isinstance(value, str):
63
+ return value
64
+ return value.isoformat()
@@ -0,0 +1,89 @@
1
+ """Mirrors lib/zazu/resources/base.rb."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+ from urllib.parse import quote
7
+
8
+ from ..errors import ZazuArgumentError
9
+ from ..page import MAX_PER_PAGE, Page
10
+ from ..response import ZazuResponse
11
+
12
+ if TYPE_CHECKING:
13
+ from ..client import Zazu
14
+
15
+
16
+ class ResourceBase:
17
+ def __init__(self, client: Zazu) -> None:
18
+ self._client = client
19
+
20
+ @staticmethod
21
+ def encode_path(base: str, *segments: str) -> str:
22
+ """Build a request path by joining a literal base path with dynamic
23
+ segments. The base is appended verbatim; each dynamic segment is
24
+ percent-encoded so an ID containing `/` or other special characters
25
+ cannot escape the intended path.
26
+
27
+ encode_path("api/accounts", "acc_xyz")
28
+ # => "api/accounts/acc_xyz"
29
+ encode_path("api/accounts", "acc 1", "transactions", "tx 1")
30
+ # => "api/accounts/acc%201/transactions/tx%201"
31
+ """
32
+ encoded = []
33
+ for seg in segments:
34
+ text = str(seg)
35
+ # An empty segment would silently turn `/things/:id` into
36
+ # `/things/`, which on most APIs redispatches to the list
37
+ # endpoint. Surface it loudly.
38
+ if not text:
39
+ raise ZazuArgumentError("path segment cannot be blank")
40
+ encoded.append(quote(text, safe=""))
41
+ return "/".join([base, *encoded])
42
+
43
+ def http_get(
44
+ self,
45
+ path: str,
46
+ *,
47
+ params: dict[str, Any] | None = None,
48
+ ) -> ZazuResponse:
49
+ return self._client.request("GET", path, params=params)
50
+
51
+ def http_post(
52
+ self,
53
+ path: str,
54
+ *,
55
+ body: Any = None,
56
+ params: dict[str, Any] | None = None,
57
+ ) -> ZazuResponse:
58
+ return self._client.request("POST", path, body=body, params=params)
59
+
60
+ def http_patch(
61
+ self,
62
+ path: str,
63
+ *,
64
+ body: Any = None,
65
+ ) -> ZazuResponse:
66
+ return self._client.request("PATCH", path, body=body)
67
+
68
+ def http_delete(self, path: str) -> ZazuResponse:
69
+ return self._client.request("DELETE", path)
70
+
71
+ def list_page(
72
+ self,
73
+ path: str,
74
+ params: dict[str, Any] | None = None,
75
+ *,
76
+ limit: int | None = None,
77
+ cursor: str | None = None,
78
+ ) -> Page[Any]:
79
+ merged: dict[str, Any] = dict(params or {})
80
+ if limit is not None:
81
+ merged["limit"] = min(limit, MAX_PER_PAGE)
82
+ if cursor is not None:
83
+ merged["cursor"] = cursor
84
+
85
+ def fetch(next_cursor: str | None) -> Page[Any]:
86
+ return self.list_page(path, params, limit=limit, cursor=next_cursor)
87
+
88
+ response = self.http_get(path, params=merged)
89
+ return Page(response, fetch)
@@ -0,0 +1,23 @@
1
+ """Mirrors lib/zazu/resources/beneficiaries.rb.
2
+
3
+ Read-only directory of saved transfer recipients. Each beneficiary
4
+ embeds its bank accounts; the one flagged ``default`` is used when a
5
+ transfer names only the beneficiary_id. Beneficiaries are created and
6
+ managed in the Zazu dashboard.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ from ..page import MAX_PER_PAGE, Page
14
+ from ..response import ZazuResponse
15
+ from .base import ResourceBase
16
+
17
+
18
+ class Beneficiaries(ResourceBase):
19
+ def list(self, *, limit: int = MAX_PER_PAGE, cursor: str | None = None) -> Page[Any]:
20
+ return self.list_page("api/beneficiaries", {}, limit=limit, cursor=cursor)
21
+
22
+ def get(self, id: str) -> ZazuResponse:
23
+ return self.http_get(self.encode_path("api/beneficiaries", id))
@@ -0,0 +1,16 @@
1
+ """Mirrors lib/zazu/resources/checkout_sessions.rb."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from ..response import ZazuResponse
8
+ from .base import ResourceBase
9
+
10
+
11
+ class CheckoutSessions(ResourceBase):
12
+ def get(self, id: str) -> ZazuResponse:
13
+ return self.http_get(self.encode_path("api/checkout_sessions", id))
14
+
15
+ def create(self, **attributes: Any) -> ZazuResponse:
16
+ return self.http_post("api/checkout_sessions", body=attributes)
@@ -0,0 +1,32 @@
1
+ """Mirrors lib/zazu/resources/customers.rb."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from ..page import MAX_PER_PAGE, Page
8
+ from ..response import ZazuResponse
9
+ from .base import ResourceBase
10
+
11
+
12
+ class Customers(ResourceBase):
13
+ def list(
14
+ self,
15
+ *,
16
+ q: str | None = None,
17
+ limit: int = MAX_PER_PAGE,
18
+ cursor: str | None = None,
19
+ ) -> Page[Any]:
20
+ return self.list_page("api/customers", {"q": q}, limit=limit, cursor=cursor)
21
+
22
+ def get(self, id: str) -> ZazuResponse:
23
+ return self.http_get(self.encode_path("api/customers", id))
24
+
25
+ def create(self, **attributes: Any) -> ZazuResponse:
26
+ return self.http_post("api/customers", body=attributes)
27
+
28
+ def update(self, id: str, **attributes: Any) -> ZazuResponse:
29
+ return self.http_patch(self.encode_path("api/customers", id), body=attributes)
30
+
31
+ def delete(self, id: str) -> ZazuResponse:
32
+ return self.http_delete(self.encode_path("api/customers", id))
@@ -0,0 +1,11 @@
1
+ """Mirrors lib/zazu/resources/entity.rb."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..response import ZazuResponse
6
+ from .base import ResourceBase
7
+
8
+
9
+ class Entity(ResourceBase):
10
+ def get(self) -> ZazuResponse:
11
+ return self.http_get("api/entity")
@@ -0,0 +1,56 @@
1
+ """Mirrors lib/zazu/resources/invoices.rb."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from ..page import MAX_PER_PAGE, Page
8
+ from ..response import ZazuResponse
9
+ from .base import ResourceBase
10
+
11
+
12
+ class Invoices(ResourceBase):
13
+ def list(
14
+ self,
15
+ *,
16
+ status: str | None = None,
17
+ customer_id: str | None = None,
18
+ limit: int = MAX_PER_PAGE,
19
+ cursor: str | None = None,
20
+ ) -> Page[Any]:
21
+ return self.list_page(
22
+ "api/invoices",
23
+ {"status": status, "customer_id": customer_id},
24
+ limit=limit,
25
+ cursor=cursor,
26
+ )
27
+
28
+ def get(self, id: str) -> ZazuResponse:
29
+ return self.http_get(self.encode_path("api/invoices", id))
30
+
31
+ def create(self, **attributes: Any) -> ZazuResponse:
32
+ return self.http_post("api/invoices", body=attributes)
33
+
34
+ def update(self, id: str, **attributes: Any) -> ZazuResponse:
35
+ return self.http_patch(self.encode_path("api/invoices", id), body=attributes)
36
+
37
+ def send_invoice(self, id: str) -> ZazuResponse:
38
+ return self.http_post(self.encode_path("api/invoices", id, "send"))
39
+
40
+ def mark_as_paid(self, id: str) -> ZazuResponse:
41
+ return self.http_post(self.encode_path("api/invoices", id, "mark_as_paid"))
42
+
43
+ def cancel(self, id: str) -> ZazuResponse:
44
+ return self.http_post(self.encode_path("api/invoices", id, "cancel"))
45
+
46
+ def credit_note(self, id: str) -> ZazuResponse:
47
+ return self.http_post(self.encode_path("api/invoices", id, "credit_note"))
48
+
49
+ def delete(self, id: str) -> ZazuResponse:
50
+ return self.http_delete(self.encode_path("api/invoices", id))
51
+
52
+ def create_payment_link(self, invoice_id: str, *, account_id: str) -> ZazuResponse:
53
+ return self.http_post(
54
+ self.encode_path("api/invoices", invoice_id, "payment_link"),
55
+ body={"account_id": account_id},
56
+ )
@@ -0,0 +1,35 @@
1
+ """Mirrors lib/zazu/resources/payment_links.rb."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from ..page import MAX_PER_PAGE, Page
8
+ from ..response import ZazuResponse
9
+ from .base import ResourceBase
10
+
11
+
12
+ class PaymentLinks(ResourceBase):
13
+ def list(
14
+ self,
15
+ *,
16
+ status: str | None = None,
17
+ link_type: str | None = None,
18
+ limit: int = MAX_PER_PAGE,
19
+ cursor: str | None = None,
20
+ ) -> Page[Any]:
21
+ return self.list_page(
22
+ "api/payment_links",
23
+ {"status": status, "link_type": link_type},
24
+ limit=limit,
25
+ cursor=cursor,
26
+ )
27
+
28
+ def get(self, id: str) -> ZazuResponse:
29
+ return self.http_get(self.encode_path("api/payment_links", id))
30
+
31
+ def create(self, **attributes: Any) -> ZazuResponse:
32
+ return self.http_post("api/payment_links", body=attributes)
33
+
34
+ def cancel(self, id: str) -> ZazuResponse:
35
+ return self.http_post(self.encode_path("api/payment_links", id, "cancel"))
@@ -0,0 +1,22 @@
1
+ """Mirrors lib/zazu/resources/transfer_drafts.rb.
2
+
3
+ API-initiated transfers. Creating a draft routes it into the workspace's
4
+ in-app approval flow — the API never executes a transfer itself. Poll
5
+ ``get`` (status: requested → processing → completed / failed) or
6
+ subscribe to the ``transfer.executed`` webhook.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ from ..response import ZazuResponse
14
+ from .base import ResourceBase
15
+
16
+
17
+ class TransferDrafts(ResourceBase):
18
+ def create(self, **attributes: Any) -> ZazuResponse:
19
+ return self.http_post("api/transfer_drafts", body=attributes)
20
+
21
+ def get(self, id: str) -> ZazuResponse:
22
+ return self.http_get(self.encode_path("api/transfer_drafts", id))
@@ -0,0 +1,53 @@
1
+ """Mirrors lib/zazu/resources/webhook_endpoints.rb."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from typing import Any
7
+
8
+ from ..page import MAX_PER_PAGE, Page
9
+ from ..response import ZazuResponse
10
+ from .base import ResourceBase
11
+
12
+
13
+ class WebhookEndpoints(ResourceBase):
14
+ def list(
15
+ self,
16
+ *,
17
+ limit: int = MAX_PER_PAGE,
18
+ cursor: str | None = None,
19
+ ) -> Page[Any]:
20
+ return self.list_page("api/webhook_endpoints", {}, limit=limit, cursor=cursor)
21
+
22
+ def get(self, id: str) -> ZazuResponse:
23
+ return self.http_get(self.encode_path("api/webhook_endpoints", id))
24
+
25
+ def create(
26
+ self,
27
+ *,
28
+ url: str,
29
+ events: Sequence[str],
30
+ description: str | None = None,
31
+ ) -> ZazuResponse:
32
+ body: dict[str, Any] = {"url": url, "events": list(events)}
33
+ if description is not None:
34
+ body["description"] = description
35
+ return self.http_post("api/webhook_endpoints", body=body)
36
+
37
+ def update(self, id: str, **attributes: Any) -> ZazuResponse:
38
+ return self.http_patch(self.encode_path("api/webhook_endpoints", id), body=attributes)
39
+
40
+ def delete(self, id: str) -> ZazuResponse:
41
+ return self.http_delete(self.encode_path("api/webhook_endpoints", id))
42
+
43
+ def test_endpoint(self, id: str) -> ZazuResponse:
44
+ return self.http_post(self.encode_path("api/webhook_endpoints", id, "test"))
45
+
46
+ def regenerate_secret(self, id: str) -> ZazuResponse:
47
+ return self.http_post(self.encode_path("api/webhook_endpoints", id, "regenerate_secret"))
48
+
49
+ def enable(self, id: str) -> ZazuResponse:
50
+ return self.http_post(self.encode_path("api/webhook_endpoints", id, "enable"))
51
+
52
+ def disable(self, id: str) -> ZazuResponse:
53
+ return self.http_post(self.encode_path("api/webhook_endpoints", id, "disable"))
@@ -0,0 +1,26 @@
1
+ """Mirrors lib/zazu/response.rb."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+
10
+ class ZazuResponse:
11
+ """Thin wrapper over httpx.Response — exposes status, headers, and parsed body."""
12
+
13
+ def __init__(self, raw: httpx.Response, body: Any) -> None:
14
+ self._raw = raw
15
+ self.status: int = raw.status_code
16
+ self.headers: httpx.Headers = raw.headers
17
+ self.body: Any = body
18
+
19
+ @property
20
+ def request_id(self) -> str | None:
21
+ value = self.headers.get("X-Request-Id")
22
+ return value if isinstance(value, str) else None
23
+
24
+ @property
25
+ def success(self) -> bool:
26
+ return 200 <= self.status < 300