sadakio 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,4 @@
1
+ __pycache__/
2
+ *.egg-info/
3
+ dist/
4
+ .pytest_cache/
sadakio-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sadakio
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.
sadakio-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,139 @@
1
+ Metadata-Version: 2.5
2
+ Name: sadakio
3
+ Version: 0.1.0
4
+ Summary: Sadakio API client — read a business's own guests, visits and retention
5
+ Project-URL: Homepage, https://sadakio.com/gelistirici
6
+ Project-URL: Examples, https://github.com/lio-maker/sadakio-examples
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: api,cafe,loyalty,retention,sadakio,sdk
10
+ Requires-Python: >=3.10
11
+ Requires-Dist: httpx>=0.27
12
+ Description-Content-Type: text/markdown
13
+
14
+ # sadakio
15
+
16
+ Python client for the [Sadakio](https://sadakio.com) Public API. It reads
17
+ **one business's own** guests, visits and retention numbers.
18
+
19
+ Sadakio is the operating layer for small hospitality businesses in Türkiye:
20
+ guest base, loyalty programme, return visits, QR menu, Apple Wallet cards. If
21
+ you are building for cafés, salons or shops — your own product, a dashboard for
22
+ a client, or an integration with a POS you already sell — this is the read side
23
+ of that data without you having to build and run a loyalty engine.
24
+
25
+ Read-only today, because the Public API v1 is read-only.
26
+
27
+ ```bash
28
+ pip install sadakio
29
+ ```
30
+
31
+ The same name works in both ecosystems: `npm install sadakio` and
32
+ `pip install sadakio`.
33
+
34
+ ## Get a key
35
+
36
+ A business owner creates the key in the Sadakio panel under **Ayarlar → API**.
37
+ The raw key is shown once, at creation; only its fingerprint is stored.
38
+
39
+ Each key belongs to exactly one business and carries the `read` scope. It can
40
+ never name another business's row — a foreign id answers `404`, not `403`, so
41
+ the API cannot be used to discover what exists elsewhere.
42
+
43
+ ## Use it
44
+
45
+ ```python
46
+ import os
47
+ from sadakio import Sadakio
48
+
49
+ sadakio = Sadakio(api_key=os.environ["SADAKIO_API_KEY"])
50
+
51
+ # one page
52
+ page = sadakio.list_guests(limit=50)
53
+ print(page["data"], page["next_cursor"])
54
+
55
+ # or every guest, without writing the pagination yourself
56
+ for guest in sadakio.iter_guests(updated_since=last_sync):
57
+ print(guest["name"], guest["visits_count"], guest["masked_phone"])
58
+
59
+ # returned-guest numbers for the last month
60
+ summary = sadakio.retention(period="month")["data"]
61
+ ```
62
+
63
+ There is an async client with the same surface:
64
+
65
+ ```python
66
+ from sadakio import AsyncSadakio
67
+
68
+ async with AsyncSadakio(api_key=key) as sadakio:
69
+ async for guest in sadakio.iter_guests():
70
+ ...
71
+ ```
72
+
73
+ | Call | What it answers |
74
+ |---|---|
75
+ | `list_guests(**params)` / `iter_guests(**params)` | The guest base. `updated_since` matches guests who visited **or** were created since that moment, so an incremental sync never misses a brand-new guest. |
76
+ | `get_guest(id)` | One guest with their loyalty cards and balances. |
77
+ | `list_visits(**params)` / `iter_visits(**params)` | The earn-event feed: stamps, points, cashback, redeems. |
78
+ | `retention(**params)` | Returned-guest numbers over a window. |
79
+
80
+ ## Options
81
+
82
+ ```python
83
+ Sadakio(
84
+ api_key=..., # required
85
+ base_url="https://api.sadakio.com/api/v1", # default
86
+ timeout_s=30.0, # default
87
+ max_retries=2, # 429 and 5xx only
88
+ )
89
+ ```
90
+
91
+ Retries happen only where waiting can actually help — a rate limit or a server
92
+ error — and wait exactly as long as `Retry-After` says. A `404` is never
93
+ retried, because waiting cannot make it true.
94
+
95
+ ## Four things worth knowing before you trust a number
96
+
97
+ **Phones are always masked.** Last four digits only, and there is no unmasked
98
+ path. This is a KVKK decision, not a scope you can request your way past.
99
+
100
+ **Pagination is by cursor.** `iter_guests()` and `iter_visits()` walk it for you and stops on a null
101
+ cursor — not on an empty page, which is the usual way a hand-written sync
102
+ truncates itself.
103
+
104
+ **A reversed earn keeps its row**, with `reversed_at` set. History is never
105
+ deleted, so exclude reversed rows yourself when you count.
106
+
107
+ **No money figure is invented.** `retention()` returns
108
+ `estimated_returned_value` only when you supply a real `avg_ticket`.
109
+
110
+ ## Errors
111
+
112
+ Every failure is a `SadakioError` carrying `status`, `code` and a message that
113
+ includes the one step that fixes it. `err.is_retryable` tells you whether trying
114
+ again could plausibly work.
115
+
116
+ ```python
117
+ from sadakio import SadakioError
118
+
119
+ try:
120
+ sadakio.get_guest(guest_id)
121
+ except SadakioError as err:
122
+ if err.code == "not_found":
123
+ ...
124
+ ```
125
+
126
+ ## Contract
127
+
128
+ The machine-readable contract is served publicly, without a key, at
129
+ `https://api.sadakio.com/api/v1/openapi.yaml`. Response fields may be **added**
130
+ over time, never renamed or removed — so parse leniently and a release will not
131
+ break you.
132
+
133
+ Working with an AI assistant? There is an MCP server too: `uvx sadakio-mcp`.
134
+
135
+ Examples you can read and run: <https://github.com/lio-maker/sadakio-examples>
136
+
137
+ Docs: <https://sadakio.com/gelistirici> · Questions: biz@sadakio.com
138
+
139
+ Python 3.10 and up. MIT licensed. Built by the Sadakio team, and pull requests are welcome.
@@ -0,0 +1,126 @@
1
+ # sadakio
2
+
3
+ Python client for the [Sadakio](https://sadakio.com) Public API. It reads
4
+ **one business's own** guests, visits and retention numbers.
5
+
6
+ Sadakio is the operating layer for small hospitality businesses in Türkiye:
7
+ guest base, loyalty programme, return visits, QR menu, Apple Wallet cards. If
8
+ you are building for cafés, salons or shops — your own product, a dashboard for
9
+ a client, or an integration with a POS you already sell — this is the read side
10
+ of that data without you having to build and run a loyalty engine.
11
+
12
+ Read-only today, because the Public API v1 is read-only.
13
+
14
+ ```bash
15
+ pip install sadakio
16
+ ```
17
+
18
+ The same name works in both ecosystems: `npm install sadakio` and
19
+ `pip install sadakio`.
20
+
21
+ ## Get a key
22
+
23
+ A business owner creates the key in the Sadakio panel under **Ayarlar → API**.
24
+ The raw key is shown once, at creation; only its fingerprint is stored.
25
+
26
+ Each key belongs to exactly one business and carries the `read` scope. It can
27
+ never name another business's row — a foreign id answers `404`, not `403`, so
28
+ the API cannot be used to discover what exists elsewhere.
29
+
30
+ ## Use it
31
+
32
+ ```python
33
+ import os
34
+ from sadakio import Sadakio
35
+
36
+ sadakio = Sadakio(api_key=os.environ["SADAKIO_API_KEY"])
37
+
38
+ # one page
39
+ page = sadakio.list_guests(limit=50)
40
+ print(page["data"], page["next_cursor"])
41
+
42
+ # or every guest, without writing the pagination yourself
43
+ for guest in sadakio.iter_guests(updated_since=last_sync):
44
+ print(guest["name"], guest["visits_count"], guest["masked_phone"])
45
+
46
+ # returned-guest numbers for the last month
47
+ summary = sadakio.retention(period="month")["data"]
48
+ ```
49
+
50
+ There is an async client with the same surface:
51
+
52
+ ```python
53
+ from sadakio import AsyncSadakio
54
+
55
+ async with AsyncSadakio(api_key=key) as sadakio:
56
+ async for guest in sadakio.iter_guests():
57
+ ...
58
+ ```
59
+
60
+ | Call | What it answers |
61
+ |---|---|
62
+ | `list_guests(**params)` / `iter_guests(**params)` | The guest base. `updated_since` matches guests who visited **or** were created since that moment, so an incremental sync never misses a brand-new guest. |
63
+ | `get_guest(id)` | One guest with their loyalty cards and balances. |
64
+ | `list_visits(**params)` / `iter_visits(**params)` | The earn-event feed: stamps, points, cashback, redeems. |
65
+ | `retention(**params)` | Returned-guest numbers over a window. |
66
+
67
+ ## Options
68
+
69
+ ```python
70
+ Sadakio(
71
+ api_key=..., # required
72
+ base_url="https://api.sadakio.com/api/v1", # default
73
+ timeout_s=30.0, # default
74
+ max_retries=2, # 429 and 5xx only
75
+ )
76
+ ```
77
+
78
+ Retries happen only where waiting can actually help — a rate limit or a server
79
+ error — and wait exactly as long as `Retry-After` says. A `404` is never
80
+ retried, because waiting cannot make it true.
81
+
82
+ ## Four things worth knowing before you trust a number
83
+
84
+ **Phones are always masked.** Last four digits only, and there is no unmasked
85
+ path. This is a KVKK decision, not a scope you can request your way past.
86
+
87
+ **Pagination is by cursor.** `iter_guests()` and `iter_visits()` walk it for you and stops on a null
88
+ cursor — not on an empty page, which is the usual way a hand-written sync
89
+ truncates itself.
90
+
91
+ **A reversed earn keeps its row**, with `reversed_at` set. History is never
92
+ deleted, so exclude reversed rows yourself when you count.
93
+
94
+ **No money figure is invented.** `retention()` returns
95
+ `estimated_returned_value` only when you supply a real `avg_ticket`.
96
+
97
+ ## Errors
98
+
99
+ Every failure is a `SadakioError` carrying `status`, `code` and a message that
100
+ includes the one step that fixes it. `err.is_retryable` tells you whether trying
101
+ again could plausibly work.
102
+
103
+ ```python
104
+ from sadakio import SadakioError
105
+
106
+ try:
107
+ sadakio.get_guest(guest_id)
108
+ except SadakioError as err:
109
+ if err.code == "not_found":
110
+ ...
111
+ ```
112
+
113
+ ## Contract
114
+
115
+ The machine-readable contract is served publicly, without a key, at
116
+ `https://api.sadakio.com/api/v1/openapi.yaml`. Response fields may be **added**
117
+ over time, never renamed or removed — so parse leniently and a release will not
118
+ break you.
119
+
120
+ Working with an AI assistant? There is an MCP server too: `uvx sadakio-mcp`.
121
+
122
+ Examples you can read and run: <https://github.com/lio-maker/sadakio-examples>
123
+
124
+ Docs: <https://sadakio.com/gelistirici> · Questions: biz@sadakio.com
125
+
126
+ Python 3.10 and up. MIT licensed. Built by the Sadakio team, and pull requests are welcome.
@@ -0,0 +1,21 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "sadakio"
7
+ version = "0.1.0"
8
+ description = "Sadakio API client — read a business's own guests, visits and retention"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.10"
13
+ keywords = ["sadakio", "loyalty", "api", "cafe", "retention", "sdk"]
14
+ dependencies = ["httpx>=0.27"]
15
+
16
+ [project.urls]
17
+ Homepage = "https://sadakio.com/gelistirici"
18
+ Examples = "https://github.com/lio-maker/sadakio-examples"
19
+
20
+ [tool.hatch.build.targets.wheel]
21
+ packages = ["src/sadakio"]
@@ -0,0 +1,22 @@
1
+ """Sadakio API client.
2
+
3
+ from sadakio import Sadakio
4
+
5
+ sadakio = Sadakio(api_key=os.environ["SADAKIO_API_KEY"])
6
+ summary = sadakio.retention(period="month")
7
+ """
8
+
9
+ from ._client import AsyncSadakio, Sadakio
10
+ from ._constants import DEFAULT_BASE_URL, PACKAGE_NAME, VERSION
11
+ from ._errors import ERROR_CODES, SadakioError
12
+
13
+ __all__ = [
14
+ "Sadakio",
15
+ "AsyncSadakio",
16
+ "SadakioError",
17
+ "ERROR_CODES",
18
+ "VERSION",
19
+ "PACKAGE_NAME",
20
+ "DEFAULT_BASE_URL",
21
+ ]
22
+ __version__ = VERSION
@@ -0,0 +1,212 @@
1
+ """The sync and async clients for the Sadakio Public API v1.
2
+
3
+ Read-only, because v1 is read-only. Every call is scoped to the one business the
4
+ key belongs to.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import os
11
+ import time
12
+ from collections.abc import AsyncIterator, Iterator
13
+ from typing import Any, Literal
14
+
15
+ import httpx
16
+
17
+ from . import _core
18
+ from ._constants import DEFAULT_BASE_URL
19
+ from ._errors import SadakioError
20
+
21
+
22
+ class _Base:
23
+ def __init__(
24
+ self,
25
+ api_key: str | None = None,
26
+ base_url: str | None = None,
27
+ timeout_s: float = 30.0,
28
+ max_retries: int = 2,
29
+ ) -> None:
30
+ # Environment fallbacks are read HERE rather than as default arguments,
31
+ # which are bound once at import and would ignore anything set later.
32
+ # The same two variables the MCP server reads, so one product does not
33
+ # honour a setting in half its surfaces and silently ignore it in the
34
+ # other. An explicit argument always wins.
35
+ api_key = api_key or os.environ.get("SADAKIO_API_KEY")
36
+ base_url = base_url or os.environ.get("SADAKIO_BASE_URL") or DEFAULT_BASE_URL
37
+ if not api_key:
38
+ raise SadakioError(
39
+ 401, "unauthorized",
40
+ "No API key: pass api_key or set SADAKIO_API_KEY.",
41
+ )
42
+ self.api_key = api_key
43
+ self.base_url = base_url.rstrip("/")
44
+ self.timeout_s = timeout_s
45
+ self.max_retries = max_retries
46
+
47
+ def _url(self, path: str) -> str:
48
+ return f"{self.base_url}{path}"
49
+
50
+
51
+ class Sadakio(_Base):
52
+ """Synchronous client.
53
+
54
+ >>> sadakio = Sadakio(api_key=os.environ["SADAKIO_API_KEY"])
55
+ >>> for guest in sadakio.iter_guests(updated_since="2026-09-01T00:00:00Z"):
56
+ ... print(guest["name"], guest["visits_count"])
57
+ """
58
+
59
+ def __init__(self, *args: Any, transport: httpx.BaseTransport | None = None, **kw: Any) -> None:
60
+ super().__init__(*args, **kw)
61
+ self._http = httpx.Client(timeout=self.timeout_s, transport=transport)
62
+
63
+ def __enter__(self) -> Sadakio:
64
+ return self
65
+
66
+ def __exit__(self, *exc: object) -> None:
67
+ self.close()
68
+
69
+ def close(self) -> None:
70
+ self._http.close()
71
+
72
+ def request(self, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
73
+ url = self._url(path)
74
+ attempt = 0
75
+ while True:
76
+ try:
77
+ res = self._http.get(
78
+ url, params=_core.clean_params(params), headers=_core.headers(self.api_key)
79
+ )
80
+ return _core.interpret(res, url)
81
+ except SadakioError as err:
82
+ if not err.is_retryable or attempt >= self.max_retries:
83
+ raise
84
+ time.sleep(_core.backoff_seconds(err, attempt))
85
+ attempt += 1
86
+ except httpx.HTTPError as err:
87
+ wrapped = _core.wrap_transport_error(err, self.base_url, url, self.timeout_s)
88
+ if attempt >= self.max_retries:
89
+ raise wrapped from None
90
+ time.sleep(_core.backoff_seconds(wrapped, attempt))
91
+ attempt += 1
92
+
93
+ # --- the four shipped read endpoints -------------------------------------
94
+
95
+ def list_guests(self, **params: Any) -> dict[str, Any]:
96
+ return self.request("/guests", params)
97
+
98
+ def get_guest(self, guest_id: int) -> dict[str, Any]:
99
+ return self.request(f"/guests/{int(guest_id)}")
100
+
101
+ def list_visits(self, **params: Any) -> dict[str, Any]:
102
+ return self.request("/visits", params)
103
+
104
+ def retention(
105
+ self,
106
+ since: str | None = None,
107
+ until: str | None = None,
108
+ period: Literal["week", "month", "year"] | None = None,
109
+ avg_ticket: float | None = None,
110
+ ) -> dict[str, Any]:
111
+ return self.request(
112
+ "/stats/retention",
113
+ {"since": since, "until": until, "period": period, "avg_ticket": avg_ticket},
114
+ )
115
+
116
+ # --- pagination ----------------------------------------------------------
117
+
118
+ def _paginate(self, path: str, params: dict[str, Any]) -> Iterator[dict[str, Any]]:
119
+ """Walks every page and yields rows one at a time.
120
+
121
+ Pagination is the part every caller reimplements and half of them get
122
+ wrong — most often by stopping on an empty page instead of on a null
123
+ cursor, which silently truncates a sync.
124
+ """
125
+ cursor = params.get("cursor")
126
+ while True:
127
+ page = self.request(path, {**params, "cursor": cursor})
128
+ yield from page.get("data") or []
129
+ cursor = page.get("next_cursor")
130
+ if cursor is None:
131
+ return
132
+
133
+ def iter_guests(self, **params: Any) -> Iterator[dict[str, Any]]:
134
+ return self._paginate("/guests", params)
135
+
136
+ def iter_visits(self, **params: Any) -> Iterator[dict[str, Any]]:
137
+ return self._paginate("/visits", params)
138
+
139
+
140
+ class AsyncSadakio(_Base):
141
+ """Asynchronous client, same surface as :class:`Sadakio`."""
142
+
143
+ def __init__(self, *args: Any, transport: httpx.AsyncBaseTransport | None = None, **kw: Any) -> None:
144
+ super().__init__(*args, **kw)
145
+ self._http = httpx.AsyncClient(timeout=self.timeout_s, transport=transport)
146
+
147
+ async def __aenter__(self) -> AsyncSadakio:
148
+ return self
149
+
150
+ async def __aexit__(self, *exc: object) -> None:
151
+ await self.aclose()
152
+
153
+ async def aclose(self) -> None:
154
+ await self._http.aclose()
155
+
156
+ async def request(self, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
157
+ url = self._url(path)
158
+ attempt = 0
159
+ while True:
160
+ try:
161
+ res = await self._http.get(
162
+ url, params=_core.clean_params(params), headers=_core.headers(self.api_key)
163
+ )
164
+ return _core.interpret(res, url)
165
+ except SadakioError as err:
166
+ if not err.is_retryable or attempt >= self.max_retries:
167
+ raise
168
+ await asyncio.sleep(_core.backoff_seconds(err, attempt))
169
+ attempt += 1
170
+ except httpx.HTTPError as err:
171
+ wrapped = _core.wrap_transport_error(err, self.base_url, url, self.timeout_s)
172
+ if attempt >= self.max_retries:
173
+ raise wrapped from None
174
+ await asyncio.sleep(_core.backoff_seconds(wrapped, attempt))
175
+ attempt += 1
176
+
177
+ async def list_guests(self, **params: Any) -> dict[str, Any]:
178
+ return await self.request("/guests", params)
179
+
180
+ async def get_guest(self, guest_id: int) -> dict[str, Any]:
181
+ return await self.request(f"/guests/{int(guest_id)}")
182
+
183
+ async def list_visits(self, **params: Any) -> dict[str, Any]:
184
+ return await self.request("/visits", params)
185
+
186
+ async def retention(
187
+ self,
188
+ since: str | None = None,
189
+ until: str | None = None,
190
+ period: Literal["week", "month", "year"] | None = None,
191
+ avg_ticket: float | None = None,
192
+ ) -> dict[str, Any]:
193
+ return await self.request(
194
+ "/stats/retention",
195
+ {"since": since, "until": until, "period": period, "avg_ticket": avg_ticket},
196
+ )
197
+
198
+ async def _paginate(self, path: str, params: dict[str, Any]) -> AsyncIterator[dict[str, Any]]:
199
+ cursor = params.get("cursor")
200
+ while True:
201
+ page = await self.request(path, {**params, "cursor": cursor})
202
+ for row in page.get("data") or []:
203
+ yield row
204
+ cursor = page.get("next_cursor")
205
+ if cursor is None:
206
+ return
207
+
208
+ def iter_guests(self, **params: Any) -> AsyncIterator[dict[str, Any]]:
209
+ return self._paginate("/guests", params)
210
+
211
+ def iter_visits(self, **params: Any) -> AsyncIterator[dict[str, Any]]:
212
+ return self._paginate("/visits", params)
@@ -0,0 +1,10 @@
1
+ """The package's identity, in one place.
2
+
3
+ Renaming a published package touches the manifest, the readme, every example
4
+ and the user agent. Keeping it here means the next rename is one line.
5
+ """
6
+
7
+ PACKAGE_NAME = "sadakio"
8
+ VERSION = "0.1.0"
9
+ DEFAULT_BASE_URL = "https://api.sadakio.com/api/v1"
10
+ USER_AGENT = f"{PACKAGE_NAME}-py/{VERSION}"
@@ -0,0 +1,93 @@
1
+ """The parts the sync and async clients must agree on.
2
+
3
+ Request building and response interpretation live here exactly once, so the two
4
+ clients cannot drift into sending different parameters or explaining the same
5
+ failure two different ways.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ import httpx
13
+
14
+ from ._constants import USER_AGENT
15
+ from ._errors import SadakioError
16
+
17
+
18
+ def headers(api_key: str) -> dict[str, str]:
19
+ return {
20
+ "Authorization": f"Bearer {api_key}",
21
+ "Accept": "application/json",
22
+ "User-Agent": USER_AGENT,
23
+ }
24
+
25
+
26
+ def clean_params(params: dict[str, Any] | None) -> dict[str, Any]:
27
+ """Only parameters the caller actually set are sent.
28
+
29
+ An unset value must not travel as the string "None": a correct server
30
+ answers 422, and the caller gets a validation error for a parameter they
31
+ never set.
32
+ """
33
+ if not params:
34
+ return {}
35
+ return {k: v for k, v in params.items() if v is not None and v != ""}
36
+
37
+
38
+ def interpret(res: httpx.Response, url: str) -> dict[str, Any]:
39
+ """Turn a response into data, or into an error that says what to do."""
40
+ retry_after = res.headers.get("retry-after")
41
+ try:
42
+ body: Any = res.json()
43
+ except ValueError:
44
+ body = None
45
+
46
+ if res.status_code >= 400:
47
+ err = body.get("error") if isinstance(body, dict) else None
48
+ code = (err or {}).get("code") or f"http_{res.status_code}"
49
+ message = (err or {}).get("message") or (
50
+ f"Sadakio answered {res.status_code} with a body this client did not recognise."
51
+ if res.text
52
+ else f"Sadakio answered {res.status_code}."
53
+ )
54
+ raise SadakioError(
55
+ res.status_code,
56
+ code,
57
+ message,
58
+ retry_after=float(retry_after) if retry_after else None,
59
+ url=url,
60
+ )
61
+
62
+ if body is None:
63
+ raise SadakioError(
64
+ res.status_code, "invalid_response",
65
+ "Sadakio answered 200 with a body that is not JSON.", url=url,
66
+ )
67
+ if not isinstance(body, dict) or "data" not in body:
68
+ raise SadakioError(
69
+ res.status_code, "invalid_response",
70
+ 'Sadakio answered 200 but the body is not a v1 response: no "data" envelope.',
71
+ url=url,
72
+ )
73
+ return body
74
+
75
+
76
+ def backoff_seconds(err: SadakioError, attempt: int) -> float:
77
+ """Retry-After is the server saying exactly how long.
78
+
79
+ Guessing shorter is how a client turns one rate limit into several.
80
+ """
81
+ if err.retry_after is not None:
82
+ return err.retry_after
83
+ return 0.5 * (2**attempt)
84
+
85
+
86
+ def wrap_transport_error(err: Exception, base_url: str, url: str, timeout_s: float) -> SadakioError:
87
+ if isinstance(err, httpx.TimeoutException):
88
+ return SadakioError(
89
+ 0, "timeout", f"Sadakio did not answer within {timeout_s:g} seconds.", url=url
90
+ )
91
+ return SadakioError(
92
+ 0, "network_error", f"Could not reach Sadakio at {base_url}: {err}", url=url
93
+ )
@@ -0,0 +1,65 @@
1
+ """The error taxonomy, shared by the sync and async clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ #: Documented error codes, so a caller can branch without parsing prose.
6
+ ERROR_CODES = frozenset(
7
+ {
8
+ "unauthorized",
9
+ "forbidden",
10
+ "account_inactive",
11
+ "not_found",
12
+ "validation_failed",
13
+ "rate_limited",
14
+ }
15
+ )
16
+
17
+ _HINTS = {
18
+ "unauthorized": (
19
+ "Create a key in the Sadakio panel, Ayarlar → API. The raw key is shown "
20
+ "once, at creation."
21
+ ),
22
+ "forbidden": (
23
+ "This key does not carry the scope this call needs, or the business "
24
+ "account is not active."
25
+ ),
26
+ "account_inactive": (
27
+ "The business account behind this key is not active, so the API is "
28
+ "closed with the panel."
29
+ ),
30
+ "not_found": (
31
+ "No such row inside this key's own business. A key only ever sees its "
32
+ "own business, and a foreign id answers 404 rather than revealing that "
33
+ "it exists."
34
+ ),
35
+ "validation_failed": (
36
+ "Dates are ISO 8601, period is week, month or year, and limit is 1 to 200."
37
+ ),
38
+ "invalid_response": (
39
+ "Check base_url: it must end in /api/v1. A host that answers 200 with "
40
+ "something else is usually a base URL pointing one level too high, or a "
41
+ "proxy in front of the API."
42
+ ),
43
+ }
44
+
45
+
46
+ class SadakioError(Exception):
47
+ def __init__(
48
+ self,
49
+ status: int,
50
+ code: str,
51
+ message: str,
52
+ retry_after: float | None = None,
53
+ url: str | None = None,
54
+ ) -> None:
55
+ hint = _HINTS.get(code)
56
+ super().__init__(f"{message} {hint}" if hint else message)
57
+ self.status = status
58
+ self.code = code
59
+ self.retry_after = retry_after
60
+ self.url = url
61
+
62
+ @property
63
+ def is_retryable(self) -> bool:
64
+ """True when waiting and trying the same call again can plausibly work."""
65
+ return self.code == "rate_limited" or self.status >= 500 or self.status == 0
@@ -0,0 +1,31 @@
1
+ import httpx
2
+ import pytest
3
+
4
+
5
+ @pytest.fixture(scope="session")
6
+ def anyio_backend():
7
+ return "asyncio"
8
+
9
+
10
+ @pytest.fixture
11
+ def stub():
12
+ """A transport that answers from a route table and records every request."""
13
+
14
+ def make(routes, default=None):
15
+ calls: list[httpx.Request] = []
16
+
17
+ def handler(request: httpx.Request) -> httpx.Response:
18
+ calls.append(request)
19
+ entry = routes.get(request.url.path, default)
20
+ if entry is None:
21
+ raise AssertionError(f"no stub route for {request.url.path}")
22
+ if callable(entry):
23
+ entry = entry(request, len(calls))
24
+ status, body, headers = entry
25
+ if isinstance(body, str):
26
+ return httpx.Response(status, text=body, headers=headers)
27
+ return httpx.Response(status, json=body, headers=headers)
28
+
29
+ return handler, calls
30
+
31
+ return make
@@ -0,0 +1,166 @@
1
+ """The sync and async clients must behave identically."""
2
+
3
+ import httpx
4
+ import pytest
5
+
6
+ from sadakio import AsyncSadakio, Sadakio, SadakioError
7
+
8
+
9
+ def guest(i):
10
+ return {"id": i, "name": f"Guest {i}", "masked_phone": "+••••••••4579", "visits_count": i}
11
+
12
+
13
+ def sync_client(handler, **kw):
14
+ return Sadakio(api_key="sk_test", transport=httpx.MockTransport(handler), **kw)
15
+
16
+
17
+ def async_client(handler, **kw):
18
+ return AsyncSadakio(api_key="sk_test", transport=httpx.MockTransport(handler), **kw)
19
+
20
+
21
+ def test_a_client_without_a_key_fails_at_construction(stub):
22
+ with pytest.raises(SadakioError) as e:
23
+ Sadakio(api_key=None)
24
+ assert e.value.code == "unauthorized"
25
+
26
+
27
+ def test_the_key_travels_as_a_bearer_token(stub):
28
+ handler, calls = stub({"/api/v1/guests": (200, {"data": [], "next_cursor": None}, {})})
29
+ with sync_client(handler) as s:
30
+ s.list_guests()
31
+ assert calls[0].headers["authorization"] == "Bearer sk_test"
32
+ assert calls[0].headers["user-agent"].startswith("sadakio-py/")
33
+ assert str(calls[0].url.params) == "", "unset parameters must not be sent"
34
+
35
+
36
+ def test_iterate_walks_every_page_and_stops_on_a_null_cursor(stub):
37
+ def pages(request, _n):
38
+ cursor = request.url.params.get("cursor")
39
+ if not cursor:
40
+ return (200, {"data": [guest(1), guest(2)], "next_cursor": 2}, {})
41
+ if cursor == "2":
42
+ return (200, {"data": [guest(3)], "next_cursor": 3}, {})
43
+ # a real tail: the last page can be empty while still ending the walk
44
+ return (200, {"data": [], "next_cursor": None}, {})
45
+
46
+ handler, _ = stub({"/api/v1/guests": pages})
47
+ with sync_client(handler) as s:
48
+ assert [g["id"] for g in s.iter_guests()] == [1, 2, 3]
49
+
50
+
51
+ @pytest.mark.anyio
52
+ async def test_the_async_client_iterates_the_same_way(stub):
53
+ def pages(request, _n):
54
+ cursor = request.url.params.get("cursor")
55
+ if not cursor:
56
+ return (200, {"data": [guest(1)], "next_cursor": 1}, {})
57
+ return (200, {"data": [guest(2)], "next_cursor": None}, {})
58
+
59
+ handler, _ = stub({"/api/v1/guests": pages})
60
+ async with async_client(handler) as s:
61
+ ids = [g["id"] async for g in s.iter_guests()]
62
+ assert ids == [1, 2]
63
+
64
+
65
+ def test_iterate_honours_a_starting_cursor(stub):
66
+ def pages(request, _n):
67
+ if request.url.params.get("cursor") == "100":
68
+ return (200, {"data": [guest(101)], "next_cursor": None}, {})
69
+ return (200, {"data": [guest(1)], "next_cursor": None}, {})
70
+
71
+ handler, calls = stub({"/api/v1/guests": pages})
72
+ with sync_client(handler) as s:
73
+ assert [g["id"] for g in s.iter_guests(cursor=100)] == [101]
74
+ assert calls[0].url.params["cursor"] == "100"
75
+
76
+
77
+ def test_a_rate_limit_is_retried_waiting_as_long_as_the_server_said(stub):
78
+ def flaky(_request, n):
79
+ if n == 1:
80
+ return (429, {"error": {"code": "rate_limited", "message": "Too many."}}, {"Retry-After": "0"})
81
+ return (200, {"data": [{"id": 7}], "next_cursor": None}, {})
82
+
83
+ handler, calls = stub({"/api/v1/visits": flaky})
84
+ with sync_client(handler) as s:
85
+ assert s.list_visits()["data"][0]["id"] == 7
86
+ assert len(calls) == 2, "should have retried exactly once"
87
+
88
+
89
+ def test_retries_are_bounded_and_the_original_error_survives(stub):
90
+ handler, calls = stub({}, default=(429, {"error": {"code": "rate_limited", "message": "Too many."}}, {"Retry-After": "0"}))
91
+ with sync_client(handler, max_retries=2) as s, pytest.raises(SadakioError) as e:
92
+ s.list_guests()
93
+ assert e.value.code == "rate_limited"
94
+ assert len(calls) == 3, "one attempt plus two retries"
95
+
96
+
97
+ def test_a_404_is_never_retried(stub):
98
+ handler, calls = stub({}, default=(404, {"error": {"code": "not_found", "message": "Guest not found."}}, {}))
99
+ with sync_client(handler) as s, pytest.raises(SadakioError) as e:
100
+ s.get_guest(999)
101
+ assert e.value.code == "not_found"
102
+ assert "own business" in str(e.value)
103
+ assert len(calls) == 1
104
+
105
+
106
+ def test_a_200_that_is_not_a_v1_response_names_the_likely_cause(stub):
107
+ handler, _ = stub({}, default=(200, {"hello": "world"}, {}))
108
+ with sync_client(handler) as s, pytest.raises(SadakioError) as e:
109
+ s.list_guests()
110
+ assert e.value.code == "invalid_response"
111
+ assert "base_url" in str(e.value)
112
+
113
+
114
+ def test_an_added_response_field_passes_through(stub):
115
+ handler, _ = stub({}, default=(200, {"data": [{"id": 1, "brand_new": "x"}], "next_cursor": None}, {}))
116
+ with sync_client(handler) as s:
117
+ assert s.list_guests()["data"][0]["brand_new"] == "x"
118
+
119
+
120
+ def test_retention_forwards_the_window_and_invents_no_money(stub):
121
+ body = {"data": {"since": "a", "until": "b", "active_guests": 40, "returned_guests": 28}}
122
+ handler, calls = stub({"/api/v1/stats/retention": (200, body, {})})
123
+ with sync_client(handler) as s:
124
+ res = s.retention(period="month")
125
+ assert calls[0].url.params["period"] == "month"
126
+ assert "estimated_returned_value" not in res["data"]
127
+
128
+
129
+ def test_a_trailing_slash_makes_no_double_slash(stub):
130
+ handler, calls = stub({}, default=(200, {"data": [], "next_cursor": None}, {}))
131
+ with Sadakio(api_key="sk", base_url="https://example.test/api/v1/", transport=httpx.MockTransport(handler)) as s:
132
+ s.list_guests()
133
+ assert "v1//" not in str(calls[0].url)
134
+
135
+
136
+ def test_the_two_languages_agree_on_the_package_name():
137
+ """The npm and PyPI SDKs are one product under one name."""
138
+ import pathlib
139
+ import re
140
+
141
+ import sadakio
142
+
143
+ js = (pathlib.Path(__file__).parents[2] / "sdk-js" / "src" / "constants.js").read_text(encoding="utf-8")
144
+ js_name = re.search(r"PACKAGE_NAME = '([^']+)'", js).group(1)
145
+ js_base = re.search(r"DEFAULT_BASE_URL = '([^']+)'", js).group(1)
146
+ assert js_name == sadakio.PACKAGE_NAME
147
+ assert js_base == sadakio.DEFAULT_BASE_URL
148
+
149
+
150
+ def test_the_key_and_base_url_fall_back_to_the_environment(stub, monkeypatch):
151
+ handler, calls = stub({}, default=(200, {"data": [], "next_cursor": None}, {}))
152
+ monkeypatch.setenv("SADAKIO_API_KEY", "sk_from_env")
153
+ monkeypatch.setenv("SADAKIO_BASE_URL", "https://from-env.test/api/v1")
154
+ with Sadakio(transport=httpx.MockTransport(handler)) as s:
155
+ s.list_guests()
156
+ assert calls[0].headers["authorization"] == "Bearer sk_from_env"
157
+ assert str(calls[0].url).startswith("https://from-env.test/api/v1")
158
+
159
+
160
+ def test_an_explicit_argument_beats_the_environment(stub, monkeypatch):
161
+ handler, calls = stub({}, default=(200, {"data": [], "next_cursor": None}, {}))
162
+ monkeypatch.setenv("SADAKIO_BASE_URL", "https://from-env.test/api/v1")
163
+ with Sadakio(api_key="sk_explicit", base_url="https://explicit.test/api/v1",
164
+ transport=httpx.MockTransport(handler)) as s:
165
+ s.list_guests()
166
+ assert str(calls[0].url).startswith("https://explicit.test/api/v1")