goomzo 0.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.
@@ -0,0 +1,5 @@
1
+ .venv
2
+ __pycache__
3
+ .pytest_cache
4
+ .ruff_cache
5
+ dist
goomzo-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Goomzo
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.
goomzo-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,77 @@
1
+ Metadata-Version: 2.5
2
+ Name: goomzo
3
+ Version: 0.1.0
4
+ Summary: The Goomzo API for Python: messages, templates, customers and webhooks.
5
+ Project-URL: Homepage, https://goomzo.africa
6
+ Project-URL: Documentation, https://docs.goomzo.africa/docs/developers/python-sdk
7
+ Author-email: Goomzo <hello@goomzo.africa>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: api,customers,goomzo,sms,tanzania,whatsapp
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Typing :: Typed
14
+ Requires-Python: >=3.10
15
+ Requires-Dist: httpx<1,>=0.27
16
+ Requires-Dist: typing-extensions>=4.10
17
+ Description-Content-Type: text/markdown
18
+
19
+ # goomzo
20
+
21
+ The [Goomzo API](https://docs.goomzo.africa/docs/developers/python-sdk) for Python: send SMS and WhatsApp messages,
22
+ send saved templates, keep customers in step, and check webhooks. Sync and asyncio, fully typed.
23
+
24
+ ```bash
25
+ pip install goomzo
26
+ ```
27
+
28
+ ```python
29
+ from goomzo import Goomzo
30
+
31
+ goomzo = Goomzo() # reads GOOMZO_API_KEY
32
+
33
+ # One message.
34
+ goomzo.messages.send_one({'to': '+255712345678', 'channel': 'sms', 'content': {'text': 'Your order is ready.'}})
35
+
36
+ # A saved template. {{secret.code}} is delivered, but kept as ••••••.
37
+ goomzo.messages.send_one({'to': '+255712345678', 'template': 'auth_1', 'variables': {'code': '482913'}})
38
+
39
+ # Add or update a customer, with their segments and activity.
40
+ goomzo.customers.create(
41
+ [{'name': 'Amina Juma', 'phone': '+255712345678', 'segments': [{'name': 'Shoppers'}]}],
42
+ if_exists='update',
43
+ )
44
+
45
+ # Every customer in a segment, a page at a time behind the scenes.
46
+ for customer in goomzo.segments.customers.list('seg_4hq2v9n1c8x0t6pk'):
47
+ print(customer['name'])
48
+ ```
49
+
50
+ With asyncio:
51
+
52
+ ```python
53
+ from goomzo import AsyncGoomzo
54
+
55
+ goomzo = AsyncGoomzo()
56
+ await goomzo.messages.send_one({'to': '+255712345678', 'template': 'auth_1', 'variables': {'code': '482913'}})
57
+ ```
58
+
59
+ ## What it does for you
60
+
61
+ - **Typed:** every request and answer is a typed dict, with the API's own keys.
62
+ - **A fixed version:** it pins the API version it was written for.
63
+ - **Safe retries:** network errors, 429 and 5xx are tried again, twice by default. Every POST carries an
64
+ `Idempotency-Key`, the same on each try, so nothing is done twice.
65
+ - **Lists page themselves:** loop over a list for every item.
66
+ - **Errors you can act on:** a `GoomzoError` has the `status`, the `code`, the `fields` that were wrong and the
67
+ `request_id`.
68
+
69
+ ## Webhooks
70
+
71
+ ```python
72
+ from goomzo import verify_webhook
73
+
74
+ event = verify_webhook(request.body, request.headers, os.environ['GOOMZO_WEBHOOK_SECRET'])
75
+ if event['type'] == 'message.delivered':
76
+ ...
77
+ ```
goomzo-0.1.0/README.md ADDED
@@ -0,0 +1,59 @@
1
+ # goomzo
2
+
3
+ The [Goomzo API](https://docs.goomzo.africa/docs/developers/python-sdk) for Python: send SMS and WhatsApp messages,
4
+ send saved templates, keep customers in step, and check webhooks. Sync and asyncio, fully typed.
5
+
6
+ ```bash
7
+ pip install goomzo
8
+ ```
9
+
10
+ ```python
11
+ from goomzo import Goomzo
12
+
13
+ goomzo = Goomzo() # reads GOOMZO_API_KEY
14
+
15
+ # One message.
16
+ goomzo.messages.send_one({'to': '+255712345678', 'channel': 'sms', 'content': {'text': 'Your order is ready.'}})
17
+
18
+ # A saved template. {{secret.code}} is delivered, but kept as ••••••.
19
+ goomzo.messages.send_one({'to': '+255712345678', 'template': 'auth_1', 'variables': {'code': '482913'}})
20
+
21
+ # Add or update a customer, with their segments and activity.
22
+ goomzo.customers.create(
23
+ [{'name': 'Amina Juma', 'phone': '+255712345678', 'segments': [{'name': 'Shoppers'}]}],
24
+ if_exists='update',
25
+ )
26
+
27
+ # Every customer in a segment, a page at a time behind the scenes.
28
+ for customer in goomzo.segments.customers.list('seg_4hq2v9n1c8x0t6pk'):
29
+ print(customer['name'])
30
+ ```
31
+
32
+ With asyncio:
33
+
34
+ ```python
35
+ from goomzo import AsyncGoomzo
36
+
37
+ goomzo = AsyncGoomzo()
38
+ await goomzo.messages.send_one({'to': '+255712345678', 'template': 'auth_1', 'variables': {'code': '482913'}})
39
+ ```
40
+
41
+ ## What it does for you
42
+
43
+ - **Typed:** every request and answer is a typed dict, with the API's own keys.
44
+ - **A fixed version:** it pins the API version it was written for.
45
+ - **Safe retries:** network errors, 429 and 5xx are tried again, twice by default. Every POST carries an
46
+ `Idempotency-Key`, the same on each try, so nothing is done twice.
47
+ - **Lists page themselves:** loop over a list for every item.
48
+ - **Errors you can act on:** a `GoomzoError` has the `status`, the `code`, the `fields` that were wrong and the
49
+ `request_id`.
50
+
51
+ ## Webhooks
52
+
53
+ ```python
54
+ from goomzo import verify_webhook
55
+
56
+ event = verify_webhook(request.body, request.headers, os.environ['GOOMZO_WEBHOOK_SECRET'])
57
+ if event['type'] == 'message.delivered':
58
+ ...
59
+ ```
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "@goomzo/sdk-python",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "description": "The goomzo Python package, run by turbo through uv.",
6
+ "scripts": {
7
+ "lint": "uv run --frozen ruff check && uv run --frozen ruff format --check",
8
+ "check-types": "uv run --frozen pyright && uv run --frozen ty check src tests",
9
+ "test": "uv run --frozen pytest -q",
10
+ "build": "uv build"
11
+ }
12
+ }
@@ -0,0 +1,65 @@
1
+ [project]
2
+ name = "goomzo"
3
+ dynamic = ["version"]
4
+ description = "The Goomzo API for Python: messages, templates, customers and webhooks."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [{ name = "Goomzo", email = "hello@goomzo.africa" }]
9
+ requires-python = ">=3.10"
10
+ dependencies = ["httpx>=0.27,<1", "typing-extensions>=4.10"]
11
+ keywords = ["goomzo", "sms", "whatsapp", "customers", "tanzania", "api"]
12
+ classifiers = [
13
+ "Programming Language :: Python :: 3",
14
+ "Typing :: Typed",
15
+ "Intended Audience :: Developers",
16
+ ]
17
+
18
+ [project.urls]
19
+ Homepage = "https://goomzo.africa"
20
+ Documentation = "https://docs.goomzo.africa/docs/developers/python-sdk"
21
+
22
+ [dependency-groups]
23
+ dev = [
24
+ "pytest>=8",
25
+ "pytest-asyncio>=0.24",
26
+ "pyright>=1.1.390",
27
+ "ruff>=0.8",
28
+ "pyyaml>=6",
29
+ "ty>=0.0.101",
30
+ ]
31
+
32
+ [build-system]
33
+ requires = ["hatchling>=1.26"]
34
+ build-backend = "hatchling.build"
35
+
36
+ [tool.hatch.version]
37
+ path = "package.json"
38
+ pattern = '"version": "(?P<version>[^"]+)"'
39
+
40
+ [tool.hatch.build.targets.wheel]
41
+ packages = ["src/goomzo"]
42
+
43
+ [tool.hatch.build.targets.sdist]
44
+ include = ["src/goomzo", "README.md", "LICENSE", "package.json"]
45
+
46
+ [tool.ruff]
47
+ line-length = 120
48
+ target-version = "py310"
49
+
50
+ [tool.ruff.lint]
51
+ select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
52
+
53
+ [tool.ruff.format]
54
+ quote-style = "single"
55
+
56
+ [tool.pyright]
57
+ include = ["src", "tests"]
58
+ pythonVersion = "3.10"
59
+ typeCheckingMode = "strict"
60
+ executionEnvironments = [{ root = "tests", reportPrivateUsage = false }]
61
+
62
+ [tool.pytest.ini_options]
63
+ testpaths = ["tests"]
64
+ asyncio_mode = "auto"
65
+ asyncio_default_fixture_loop_scope = "function"
@@ -0,0 +1,26 @@
1
+ """The Goomzo API for Python: messages, templates, customers and webhooks."""
2
+
3
+ from ._client import AsyncGoomzo, Goomzo
4
+ from ._errors import FieldProblem, GoomzoConnectionError, GoomzoError, WebhookVerificationError
5
+ from ._pagination import AsyncPage, AsyncPager, Page
6
+ from ._transport import API_VERSION, Media
7
+ from ._version import VERSION
8
+ from ._webhooks import verify_webhook
9
+
10
+ __version__ = VERSION
11
+
12
+ __all__ = [
13
+ 'API_VERSION',
14
+ 'AsyncGoomzo',
15
+ 'AsyncPage',
16
+ 'AsyncPager',
17
+ 'FieldProblem',
18
+ 'Goomzo',
19
+ 'GoomzoConnectionError',
20
+ 'GoomzoError',
21
+ 'Media',
22
+ 'Page',
23
+ 'WebhookVerificationError',
24
+ '__version__',
25
+ 'verify_webhook',
26
+ ]
@@ -0,0 +1,119 @@
1
+ from __future__ import annotations
2
+
3
+ from types import TracebackType
4
+
5
+ import httpx
6
+ from typing_extensions import Self
7
+
8
+ from ._transport import AsyncTransport, SyncTransport, make_config
9
+ from .resources.broadcasts import AsyncBroadcasts, Broadcasts
10
+ from .resources.customers import ActivityRecorder, AsyncActivityRecorder, AsyncCustomers, Customers
11
+ from .resources.messages import AsyncMessages, Messages
12
+ from .resources.segments import AsyncSegments, Segments
13
+ from .resources.templates import AsyncTemplates, AsyncWhatsApp, Templates, WhatsApp
14
+ from .resources.webhook_endpoints import AsyncWebhookEndpoints, WebhookEndpoints
15
+
16
+
17
+ class Goomzo:
18
+ """
19
+ The Goomzo API client. Make one and share it across your app.
20
+
21
+ from goomzo import Goomzo
22
+
23
+ goomzo = Goomzo() # reads GOOMZO_API_KEY
24
+ goomzo.messages.send_one({'to': '+255712345678', 'channel': 'sms', 'content': {'text': 'Your order is ready.'}})
25
+
26
+ `api_key` is your API key's secret, `gzk_…`: keep it on your server. A failed request is tried again
27
+ `max_retries` times after a network error, a 429 or a 5xx, and each try waits up to `timeout` seconds.
28
+
29
+ Docs: https://docs.goomzo.africa/docs/developers/python-sdk
30
+ """
31
+
32
+ def __init__(
33
+ self,
34
+ *,
35
+ api_key: str | None = None,
36
+ base_url: str | None = None,
37
+ max_retries: int = 2,
38
+ timeout: float = 60.0,
39
+ http_client: httpx.Client | None = None,
40
+ ) -> None:
41
+ self._transport = SyncTransport(make_config(api_key, base_url, max_retries, timeout), http_client)
42
+ self.messages = Messages(self._transport)
43
+ """Send SMS and WhatsApp messages, each to one person."""
44
+ self.broadcasts = Broadcasts(self._transport)
45
+ """Send one message to many people."""
46
+ self.templates = Templates(self._transport)
47
+ """Saved templates, sent by name."""
48
+ self.whatsapp = WhatsApp(self._transport)
49
+ """The business's WhatsApp account and its templates."""
50
+ self.customers = Customers(self._transport)
51
+ """Add and update customers."""
52
+ self.activity = ActivityRecorder(self._transport)
53
+ """Record customer activity, by phone, email or id."""
54
+ self.segments = Segments(self._transport)
55
+ """Groups of customers."""
56
+ self.webhook_endpoints = WebhookEndpoints(self._transport)
57
+ """URLs that hear webhook events."""
58
+
59
+ def close(self) -> None:
60
+ """Closes the connections it opened. Not needed for an http_client you gave it."""
61
+ self._transport.close()
62
+
63
+ def __enter__(self) -> Self:
64
+ return self
65
+
66
+ def __exit__(self, kind: type[BaseException] | None, error: BaseException | None, tb: TracebackType | None) -> None:
67
+ self.close()
68
+
69
+
70
+ class AsyncGoomzo:
71
+ """
72
+ The Goomzo API client, for asyncio. The same as `Goomzo`, with every call awaited.
73
+
74
+ from goomzo import AsyncGoomzo
75
+
76
+ goomzo = AsyncGoomzo()
77
+ await goomzo.messages.send_one({'to': '+255712345678', 'template': 'auth_1', 'variables': {'code': '482913'}})
78
+
79
+ Docs: https://docs.goomzo.africa/docs/developers/python-sdk
80
+ """
81
+
82
+ def __init__(
83
+ self,
84
+ *,
85
+ api_key: str | None = None,
86
+ base_url: str | None = None,
87
+ max_retries: int = 2,
88
+ timeout: float = 60.0,
89
+ http_client: httpx.AsyncClient | None = None,
90
+ ) -> None:
91
+ self._transport = AsyncTransport(make_config(api_key, base_url, max_retries, timeout), http_client)
92
+ self.messages = AsyncMessages(self._transport)
93
+ """Send SMS and WhatsApp messages, each to one person."""
94
+ self.broadcasts = AsyncBroadcasts(self._transport)
95
+ """Send one message to many people."""
96
+ self.templates = AsyncTemplates(self._transport)
97
+ """Saved templates, sent by name."""
98
+ self.whatsapp = AsyncWhatsApp(self._transport)
99
+ """The business's WhatsApp account and its templates."""
100
+ self.customers = AsyncCustomers(self._transport)
101
+ """Add and update customers."""
102
+ self.activity = AsyncActivityRecorder(self._transport)
103
+ """Record customer activity, by phone, email or id."""
104
+ self.segments = AsyncSegments(self._transport)
105
+ """Groups of customers."""
106
+ self.webhook_endpoints = AsyncWebhookEndpoints(self._transport)
107
+ """URLs that hear webhook events."""
108
+
109
+ async def close(self) -> None:
110
+ """Closes the connections it opened. Not needed for an http_client you gave it."""
111
+ await self._transport.close()
112
+
113
+ async def __aenter__(self) -> Self:
114
+ return self
115
+
116
+ async def __aexit__(
117
+ self, kind: type[BaseException] | None, error: BaseException | None, tb: TracebackType | None
118
+ ) -> None:
119
+ await self.close()
@@ -0,0 +1,49 @@
1
+ from __future__ import annotations
2
+
3
+ from typing_extensions import TypedDict
4
+
5
+
6
+ class FieldProblem(TypedDict):
7
+ """A problem with one field of a request."""
8
+
9
+ code: str
10
+ message: str
11
+
12
+
13
+ class GoomzoError(Exception):
14
+ """
15
+ The API answered with an error.
16
+
17
+ `code` says what went wrong, such as `invalid`, `not_found` or `insufficient_sms`. `fields` names the fields that
18
+ were wrong, such as `customers[1].phone`. Include `request_id` when you contact Goomzo.
19
+ """
20
+
21
+ def __init__(
22
+ self,
23
+ status: int,
24
+ code: str,
25
+ message: str,
26
+ fields: dict[str, FieldProblem] | None = None,
27
+ request_id: str | None = None,
28
+ ) -> None:
29
+ super().__init__(message)
30
+ self.status = status
31
+ self.code = code
32
+ self.message = message
33
+ self.fields: dict[str, FieldProblem] = fields or {}
34
+ self.request_id = request_id
35
+
36
+ def __repr__(self) -> str:
37
+ return f'GoomzoError(status={self.status}, code={self.code!r}, message={self.message!r})'
38
+
39
+
40
+ class GoomzoConnectionError(Exception):
41
+ """No answer came: the network failed, or the request took longer than the timeout."""
42
+
43
+ def __init__(self, message: str, *, timed_out: bool) -> None:
44
+ super().__init__(message)
45
+ self.timed_out = timed_out
46
+
47
+
48
+ class WebhookVerificationError(Exception):
49
+ """A webhook's signature or timestamp didn't check out. Don't trust its body."""
@@ -0,0 +1,77 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import AsyncIterator, Awaitable, Callable, Generator, Iterator
4
+ from typing import Any, Generic, TypeVar
5
+
6
+ T = TypeVar('T')
7
+
8
+
9
+ class Page(Generic[T]):
10
+ """
11
+ One page of a list. Loop over it for every item: the next pages are asked for as you go.
12
+
13
+ page = goomzo.customers.list(limit=200)
14
+ page.items, page.next_cursor, page.total
15
+
16
+ for customer in goomzo.customers.list():
17
+ print(customer['name'])
18
+ """
19
+
20
+ def __init__(self, data: Any, fetch: Callable[[str], Page[T]]) -> None:
21
+ self.items: list[T] = data['items']
22
+ self.next_cursor: str | None = data.get('nextCursor')
23
+ self.total: int | None = data.get('total')
24
+ """How many match, on every page. Only customers, messages and broadcasts have it."""
25
+ self._fetch = fetch
26
+
27
+ def next_page(self) -> Page[T] | None:
28
+ """The next page, or None on the last."""
29
+ return self._fetch(self.next_cursor) if self.next_cursor else None
30
+
31
+ def __iter__(self) -> Iterator[T]:
32
+ page: Page[T] | None = self
33
+ while page is not None:
34
+ yield from page.items
35
+ page = page.next_page()
36
+
37
+
38
+ class AsyncPage(Generic[T]):
39
+ """One page of a list, for asyncio. `async for` over it for every item."""
40
+
41
+ def __init__(self, data: Any, fetch: Callable[[str], Awaitable[AsyncPage[T]]]) -> None:
42
+ self.items: list[T] = data['items']
43
+ self.next_cursor: str | None = data.get('nextCursor')
44
+ self.total: int | None = data.get('total')
45
+ self._fetch = fetch
46
+
47
+ async def next_page(self) -> AsyncPage[T] | None:
48
+ """The next page, or None on the last."""
49
+ return await self._fetch(self.next_cursor) if self.next_cursor else None
50
+
51
+ async def __aiter__(self) -> AsyncIterator[T]:
52
+ page: AsyncPage[T] | None = self
53
+ while page is not None:
54
+ for item in page.items:
55
+ yield item
56
+ page = await page.next_page()
57
+
58
+
59
+ class AsyncPager(Generic[T]):
60
+ """
61
+ A list for asyncio. Await it for the first page, or `async for` over it for every item.
62
+
63
+ page = await goomzo.customers.list(limit=200)
64
+
65
+ async for customer in goomzo.customers.list():
66
+ print(customer['name'])
67
+ """
68
+
69
+ def __init__(self, first: Callable[[], Awaitable[AsyncPage[T]]]) -> None:
70
+ self._first = first
71
+
72
+ def __await__(self) -> Generator[Any, None, AsyncPage[T]]:
73
+ return self._first().__await__()
74
+
75
+ async def __aiter__(self) -> AsyncIterator[T]:
76
+ async for item in await self._first():
77
+ yield item