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.
- sadakio-0.1.0/.gitignore +4 -0
- sadakio-0.1.0/LICENSE +21 -0
- sadakio-0.1.0/PKG-INFO +139 -0
- sadakio-0.1.0/README.md +126 -0
- sadakio-0.1.0/pyproject.toml +21 -0
- sadakio-0.1.0/src/sadakio/__init__.py +22 -0
- sadakio-0.1.0/src/sadakio/_client.py +212 -0
- sadakio-0.1.0/src/sadakio/_constants.py +10 -0
- sadakio-0.1.0/src/sadakio/_core.py +93 -0
- sadakio-0.1.0/src/sadakio/_errors.py +65 -0
- sadakio-0.1.0/tests/conftest.py +31 -0
- sadakio-0.1.0/tests/test_sdk.py +166 -0
sadakio-0.1.0/.gitignore
ADDED
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.
|
sadakio-0.1.0/README.md
ADDED
|
@@ -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")
|