creno 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.
- creno-0.1.0/.gitignore +22 -0
- creno-0.1.0/CHANGELOG.md +22 -0
- creno-0.1.0/LICENSE +21 -0
- creno-0.1.0/PKG-INFO +86 -0
- creno-0.1.0/README.md +59 -0
- creno-0.1.0/pyproject.toml +48 -0
- creno-0.1.0/scripts/verify_live.py +67 -0
- creno-0.1.0/src/creno/__init__.py +32 -0
- creno-0.1.0/src/creno/_version.py +1 -0
- creno-0.1.0/src/creno/client.py +201 -0
- creno-0.1.0/src/creno/exceptions.py +72 -0
- creno-0.1.0/src/creno/models.py +114 -0
- creno-0.1.0/src/creno/py.typed +0 -0
- creno-0.1.0/tests/conftest.py +17 -0
- creno-0.1.0/tests/test_client_integration.py +66 -0
- creno-0.1.0/tests/test_client_unit.py +239 -0
creno-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
node_modules/
|
|
2
|
+
dist/
|
|
3
|
+
.env
|
|
4
|
+
.env.*
|
|
5
|
+
!.env.example
|
|
6
|
+
!.env.production
|
|
7
|
+
*.log
|
|
8
|
+
.DS_Store
|
|
9
|
+
.turbo/
|
|
10
|
+
|
|
11
|
+
__pycache__/
|
|
12
|
+
*.egg-info/
|
|
13
|
+
.pytest_cache/
|
|
14
|
+
.mypy_cache/
|
|
15
|
+
.ruff_cache/
|
|
16
|
+
build/
|
|
17
|
+
.venv/
|
|
18
|
+
packages/vue/demo/public/
|
|
19
|
+
test-results/
|
|
20
|
+
playwright-report/
|
|
21
|
+
blob-report/
|
|
22
|
+
playwright/.cache/
|
creno-0.1.0/CHANGELOG.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to the `creno` Python package are documented here.
|
|
4
|
+
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
5
|
+
versioning follows [SemVer](https://semver.org/).
|
|
6
|
+
|
|
7
|
+
## [0.1.0] - 2026-07-28
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- `CrenoClient`: typed, synchronous client (`httpx`-based) covering
|
|
11
|
+
`fetch_service_types`, `fetch_availability`, and `create_booking`.
|
|
12
|
+
- A typed exception per API error code, `CrenoConflictError` (409),
|
|
13
|
+
`CrenoPlanLimitError` (402), `CrenoRateLimitError` (429),
|
|
14
|
+
`CrenoValidationError`, `CrenoNotFoundError`, `CrenoForbiddenError`,
|
|
15
|
+
`CrenoAuthenticationError`, and a `CrenoAPIError` base for anything else, so callers can catch the specific failure they care about instead of
|
|
16
|
+
parsing status codes by hand.
|
|
17
|
+
- Full type hints (`py.typed`), Python 3.9-3.13.
|
|
18
|
+
- Every request identifies itself via `X-Client-Library: python` and a
|
|
19
|
+
`creno-python/{version}` User-Agent.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
- Relicensed to **MIT** (was file-based custom license metadata).
|
creno-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Solution Lancée
|
|
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.
|
creno-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: creno
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python client for Creno's scheduling and booking API (https://crenoapp.com).
|
|
5
|
+
Project-URL: Homepage, https://crenoapp.com
|
|
6
|
+
Project-URL: Documentation, https://crenoapp.com/docs
|
|
7
|
+
Project-URL: Repository, https://github.com/whythoughts/platform
|
|
8
|
+
Author-email: Solution Lancee <contact@crenoapp.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: api-client,booking,creno,scheduling
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Requires-Dist: httpx<1,>=0.27
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: mypy>=1.11; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
25
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# creno
|
|
29
|
+
|
|
30
|
+
Official Python client for [Creno](https://crenoapp.com)'s scheduling and booking API.
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from creno import CrenoClient
|
|
34
|
+
|
|
35
|
+
with CrenoClient(api_key="pk_live_...") as client:
|
|
36
|
+
service_types = client.list_service_types()
|
|
37
|
+
availability = client.get_availability(from_="2026-08-01", to="2026-08-07")
|
|
38
|
+
booking = client.create_booking(
|
|
39
|
+
start_at=availability.slots[0].start_at,
|
|
40
|
+
customer_name="Jane Doe",
|
|
41
|
+
customer_email="jane@example.com",
|
|
42
|
+
)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
pip install creno
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Requires Python 3.9+. The only runtime dependency is [httpx](https://www.python-httpx.org/).
|
|
52
|
+
|
|
53
|
+
## Security model
|
|
54
|
+
|
|
55
|
+
The API key you pass to `CrenoClient` is the same "publishable" key used by Creno's browser widgets (`@creno/react`, `<creno-widget>`). In the browser, safety comes from Creno's per-tenant origin allowlist, not from keeping the key secret. This client makes server-to-server calls with no `Origin` header, so that allowlist check never applies here. Treat the key as sensitive in your own backend even though it's called "publishable" elsewhere.
|
|
56
|
+
|
|
57
|
+
## Errors
|
|
58
|
+
|
|
59
|
+
Every non-2xx response raises a subclass of `CrenoError` (`.message`, `.status_code`, `.response_body`):
|
|
60
|
+
|
|
61
|
+
| Exception | Status | Meaning |
|
|
62
|
+
|---|---|---|
|
|
63
|
+
| `CrenoAuthenticationError` | 401 | Missing or invalid API key |
|
|
64
|
+
| `CrenoForbiddenError` | 403 | Origin not on the tenant's allowlist (shouldn't occur for server-to-server calls) |
|
|
65
|
+
| `CrenoNotFoundError` | 404 | No matching resource, or no scheduling resource configured for this tenant |
|
|
66
|
+
| `CrenoValidationError` | 400 | Request failed validation (for example, a Turnstile challenge) |
|
|
67
|
+
| `CrenoConflictError` | 409 | The requested time slot is no longer available |
|
|
68
|
+
| `CrenoPlanLimitError` | 402 | The tenant's plan limit was reached, carries `.limit_type` and `.plan` |
|
|
69
|
+
| `CrenoRateLimitError` | 429 | Too many requests, rate-limited per API key rather than per IP |
|
|
70
|
+
| `CrenoAPIError` | any | Fallback for network errors or unexpected responses |
|
|
71
|
+
|
|
72
|
+
`list_service_types` and `get_availability` retry automatically (up to 3 attempts with backoff) on network errors or 5xx responses, since they're read-only. `create_booking` never retries automatically: a lost response after a booking was actually created server-side, followed by a client-side retry, could still submit a second real booking attempt for a real customer, even though Creno's own database can never double-book the same slot. If `create_booking` raises `CrenoAPIError` with an unclear outcome, check the tenant's Creno dashboard before retrying by hand.
|
|
73
|
+
|
|
74
|
+
## Dates and times
|
|
75
|
+
|
|
76
|
+
`Slot` and `Booking` fields are real timezone-aware `datetime.datetime` objects, already parsed from the API's ISO-8601 strings. `create_booking`'s `start_at` accepts either a timezone-aware `datetime` or an ISO-8601 string; a naive `datetime` (no `tzinfo`) raises `ValueError` rather than silently guessing a timezone. `get_availability`'s `from_`/`to` accept either a `datetime.date` or a `"YYYY-MM-DD"` string.
|
|
77
|
+
|
|
78
|
+
Optional fields you don't have, for example `resource_id` or `service_type_id`, should simply be omitted (their default is `None`). This client drops `None` values from the request body before sending, since Creno's API treats a field as "not provided" only when it's absent from the JSON body entirely, not when it's sent as `null`.
|
|
79
|
+
|
|
80
|
+
## License
|
|
81
|
+
|
|
82
|
+
[MIT](./LICENSE). See [CHANGELOG.md](./CHANGELOG.md) for release history and the repo root's [SECURITY.md](../../SECURITY.md) to report a vulnerability.
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
Made by [Solution Lancée](https://solutionlancee.com).
|
creno-0.1.0/README.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# creno
|
|
2
|
+
|
|
3
|
+
Official Python client for [Creno](https://crenoapp.com)'s scheduling and booking API.
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
from creno import CrenoClient
|
|
7
|
+
|
|
8
|
+
with CrenoClient(api_key="pk_live_...") as client:
|
|
9
|
+
service_types = client.list_service_types()
|
|
10
|
+
availability = client.get_availability(from_="2026-08-01", to="2026-08-07")
|
|
11
|
+
booking = client.create_booking(
|
|
12
|
+
start_at=availability.slots[0].start_at,
|
|
13
|
+
customer_name="Jane Doe",
|
|
14
|
+
customer_email="jane@example.com",
|
|
15
|
+
)
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
pip install creno
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Requires Python 3.9+. The only runtime dependency is [httpx](https://www.python-httpx.org/).
|
|
25
|
+
|
|
26
|
+
## Security model
|
|
27
|
+
|
|
28
|
+
The API key you pass to `CrenoClient` is the same "publishable" key used by Creno's browser widgets (`@creno/react`, `<creno-widget>`). In the browser, safety comes from Creno's per-tenant origin allowlist, not from keeping the key secret. This client makes server-to-server calls with no `Origin` header, so that allowlist check never applies here. Treat the key as sensitive in your own backend even though it's called "publishable" elsewhere.
|
|
29
|
+
|
|
30
|
+
## Errors
|
|
31
|
+
|
|
32
|
+
Every non-2xx response raises a subclass of `CrenoError` (`.message`, `.status_code`, `.response_body`):
|
|
33
|
+
|
|
34
|
+
| Exception | Status | Meaning |
|
|
35
|
+
|---|---|---|
|
|
36
|
+
| `CrenoAuthenticationError` | 401 | Missing or invalid API key |
|
|
37
|
+
| `CrenoForbiddenError` | 403 | Origin not on the tenant's allowlist (shouldn't occur for server-to-server calls) |
|
|
38
|
+
| `CrenoNotFoundError` | 404 | No matching resource, or no scheduling resource configured for this tenant |
|
|
39
|
+
| `CrenoValidationError` | 400 | Request failed validation (for example, a Turnstile challenge) |
|
|
40
|
+
| `CrenoConflictError` | 409 | The requested time slot is no longer available |
|
|
41
|
+
| `CrenoPlanLimitError` | 402 | The tenant's plan limit was reached, carries `.limit_type` and `.plan` |
|
|
42
|
+
| `CrenoRateLimitError` | 429 | Too many requests, rate-limited per API key rather than per IP |
|
|
43
|
+
| `CrenoAPIError` | any | Fallback for network errors or unexpected responses |
|
|
44
|
+
|
|
45
|
+
`list_service_types` and `get_availability` retry automatically (up to 3 attempts with backoff) on network errors or 5xx responses, since they're read-only. `create_booking` never retries automatically: a lost response after a booking was actually created server-side, followed by a client-side retry, could still submit a second real booking attempt for a real customer, even though Creno's own database can never double-book the same slot. If `create_booking` raises `CrenoAPIError` with an unclear outcome, check the tenant's Creno dashboard before retrying by hand.
|
|
46
|
+
|
|
47
|
+
## Dates and times
|
|
48
|
+
|
|
49
|
+
`Slot` and `Booking` fields are real timezone-aware `datetime.datetime` objects, already parsed from the API's ISO-8601 strings. `create_booking`'s `start_at` accepts either a timezone-aware `datetime` or an ISO-8601 string; a naive `datetime` (no `tzinfo`) raises `ValueError` rather than silently guessing a timezone. `get_availability`'s `from_`/`to` accept either a `datetime.date` or a `"YYYY-MM-DD"` string.
|
|
50
|
+
|
|
51
|
+
Optional fields you don't have, for example `resource_id` or `service_type_id`, should simply be omitted (their default is `None`). This client drops `None` values from the request body before sending, since Creno's API treats a field as "not provided" only when it's absent from the JSON body entirely, not when it's sent as `null`.
|
|
52
|
+
|
|
53
|
+
## License
|
|
54
|
+
|
|
55
|
+
[MIT](./LICENSE). See [CHANGELOG.md](./CHANGELOG.md) for release history and the repo root's [SECURITY.md](../../SECURITY.md) to report a vulnerability.
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
Made by [Solution Lancée](https://solutionlancee.com).
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "creno"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official Python client for Creno's scheduling and booking API (https://crenoapp.com)."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.9"
|
|
13
|
+
authors = [{ name = "Solution Lancee", email = "contact@crenoapp.com" }]
|
|
14
|
+
keywords = ["creno", "scheduling", "booking", "api-client"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.9",
|
|
18
|
+
"Programming Language :: Python :: 3.10",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Operating System :: OS Independent",
|
|
23
|
+
"Typing :: Typed",
|
|
24
|
+
]
|
|
25
|
+
dependencies = ["httpx>=0.27,<1"]
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
dev = ["pytest>=8", "mypy>=1.11", "ruff>=0.6"]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://crenoapp.com"
|
|
32
|
+
Documentation = "https://crenoapp.com/docs"
|
|
33
|
+
Repository = "https://github.com/whythoughts/platform"
|
|
34
|
+
|
|
35
|
+
[tool.hatch.build.targets.wheel]
|
|
36
|
+
packages = ["src/creno"]
|
|
37
|
+
|
|
38
|
+
[tool.ruff]
|
|
39
|
+
line-length = 110
|
|
40
|
+
target-version = "py39"
|
|
41
|
+
|
|
42
|
+
[tool.mypy]
|
|
43
|
+
python_version = "3.10"
|
|
44
|
+
strict = true
|
|
45
|
+
|
|
46
|
+
[tool.pytest.ini_options]
|
|
47
|
+
markers = ["integration: real HTTP tests against a running Creno API instance"]
|
|
48
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Runs real requests against a live Creno API instance and prints the results.
|
|
3
|
+
|
|
4
|
+
This is the standalone, human-runnable proof that the SDK actually works end
|
|
5
|
+
to end, separate from the mocked/gated test suites in tests/.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
pnpm dev:api # from the platform repo root, in one terminal
|
|
9
|
+
|
|
10
|
+
CRENO_API_URL=http://localhost:3000 \\
|
|
11
|
+
CRENO_API_KEY=<a real seeded tenant's publishable key> \\
|
|
12
|
+
python scripts/verify_live.py
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
import uuid
|
|
18
|
+
from datetime import date, timedelta
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
|
|
22
|
+
|
|
23
|
+
from creno import CrenoClient, CrenoError # noqa: E402
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def main() -> int:
|
|
27
|
+
api_url = os.environ.get("CRENO_API_URL", "http://localhost:3000")
|
|
28
|
+
api_key = os.environ.get("CRENO_API_KEY")
|
|
29
|
+
if not api_key:
|
|
30
|
+
print("Set CRENO_API_KEY to a real tenant's publishable key before running this script.")
|
|
31
|
+
return 1
|
|
32
|
+
|
|
33
|
+
with CrenoClient(api_key=api_key, base_url=api_url) as client:
|
|
34
|
+
print(f"Connecting to {api_url} ...")
|
|
35
|
+
|
|
36
|
+
service_types = client.list_service_types()
|
|
37
|
+
print(f"Service types: {len(service_types)} found")
|
|
38
|
+
for service_type in service_types:
|
|
39
|
+
print(f" - {service_type.name} (active={service_type.active})")
|
|
40
|
+
|
|
41
|
+
today = date.today()
|
|
42
|
+
availability = client.get_availability(from_=today, to=today + timedelta(days=14))
|
|
43
|
+
print(f"Availability ({availability.timezone}): {len(availability.slots)} open slots in the next 14 days")
|
|
44
|
+
|
|
45
|
+
if not availability.slots:
|
|
46
|
+
print("No open slots in the next 14 days, cannot verify booking creation. Seed more availability and re-run.")
|
|
47
|
+
return 0
|
|
48
|
+
|
|
49
|
+
slot = availability.slots[0]
|
|
50
|
+
print(f"Booking the first open slot: {slot.start_at.isoformat()}")
|
|
51
|
+
try:
|
|
52
|
+
booking = client.create_booking(
|
|
53
|
+
start_at=slot.start_at,
|
|
54
|
+
customer_name="Creno SDK Verification",
|
|
55
|
+
customer_email=f"sdk-verify-{uuid.uuid4().hex[:8]}@example.com",
|
|
56
|
+
notes="Created by scripts/verify_live.py, safe to delete.",
|
|
57
|
+
)
|
|
58
|
+
except CrenoError as exc:
|
|
59
|
+
print(f"Booking failed: {exc.message} (status {exc.status_code})")
|
|
60
|
+
return 1
|
|
61
|
+
|
|
62
|
+
print(f"Booking created: id={booking.id} status={booking.status}")
|
|
63
|
+
return 0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
if __name__ == "__main__":
|
|
67
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from ._version import __version__
|
|
2
|
+
from .client import CrenoClient
|
|
3
|
+
from .exceptions import (
|
|
4
|
+
CrenoAPIError,
|
|
5
|
+
CrenoAuthenticationError,
|
|
6
|
+
CrenoConflictError,
|
|
7
|
+
CrenoError,
|
|
8
|
+
CrenoForbiddenError,
|
|
9
|
+
CrenoNotFoundError,
|
|
10
|
+
CrenoPlanLimitError,
|
|
11
|
+
CrenoRateLimitError,
|
|
12
|
+
CrenoValidationError,
|
|
13
|
+
)
|
|
14
|
+
from .models import Availability, Booking, ServiceType, Slot
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"__version__",
|
|
18
|
+
"CrenoClient",
|
|
19
|
+
"CrenoError",
|
|
20
|
+
"CrenoAuthenticationError",
|
|
21
|
+
"CrenoForbiddenError",
|
|
22
|
+
"CrenoNotFoundError",
|
|
23
|
+
"CrenoValidationError",
|
|
24
|
+
"CrenoConflictError",
|
|
25
|
+
"CrenoPlanLimitError",
|
|
26
|
+
"CrenoRateLimitError",
|
|
27
|
+
"CrenoAPIError",
|
|
28
|
+
"ServiceType",
|
|
29
|
+
"Slot",
|
|
30
|
+
"Availability",
|
|
31
|
+
"Booking",
|
|
32
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import Any, Dict, List, Optional
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from ._version import __version__
|
|
9
|
+
from .exceptions import (
|
|
10
|
+
CrenoAPIError,
|
|
11
|
+
CrenoAuthenticationError,
|
|
12
|
+
CrenoConflictError,
|
|
13
|
+
CrenoError,
|
|
14
|
+
CrenoForbiddenError,
|
|
15
|
+
CrenoNotFoundError,
|
|
16
|
+
CrenoPlanLimitError,
|
|
17
|
+
CrenoRateLimitError,
|
|
18
|
+
CrenoValidationError,
|
|
19
|
+
)
|
|
20
|
+
from .models import Availability, Booking, DateLike, DatetimeLike, ServiceType, to_date_str, to_iso
|
|
21
|
+
|
|
22
|
+
DEFAULT_BASE_URL = "https://api.crenoapp.com"
|
|
23
|
+
DEFAULT_TIMEOUT = 10.0
|
|
24
|
+
|
|
25
|
+
# Only applied to the two read-only GET endpoints, never to create_booking
|
|
26
|
+
# (see its docstring for why).
|
|
27
|
+
_MAX_RETRY_ATTEMPTS = 3
|
|
28
|
+
_RETRY_BASE_DELAY_SECONDS = 0.2
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _drop_none(values: Dict[str, Any]) -> Dict[str, Any]:
|
|
32
|
+
return {key: value for key, value in values.items() if value is not None}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CrenoClient:
|
|
36
|
+
"""Synchronous client for Creno's public scheduling and booking API.
|
|
37
|
+
|
|
38
|
+
The API key you pass here is the same "publishable" key used by Creno's
|
|
39
|
+
browser widgets. In the browser, safety comes from Creno's origin
|
|
40
|
+
allowlist, not from keeping the key secret. This client makes
|
|
41
|
+
server-to-server calls with no Origin header, so that allowlist check
|
|
42
|
+
never applies here, treat the key as sensitive in your own backend.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
api_key: str,
|
|
48
|
+
*,
|
|
49
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
50
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
51
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
52
|
+
) -> None:
|
|
53
|
+
if not api_key:
|
|
54
|
+
raise ValueError("api_key is required")
|
|
55
|
+
self._client = httpx.Client(
|
|
56
|
+
base_url=base_url.rstrip("/"),
|
|
57
|
+
timeout=timeout,
|
|
58
|
+
headers={
|
|
59
|
+
"X-API-Key": api_key,
|
|
60
|
+
"User-Agent": f"creno-python/{__version__}",
|
|
61
|
+
"X-Client-Library": "python",
|
|
62
|
+
"Content-Type": "application/json",
|
|
63
|
+
},
|
|
64
|
+
transport=transport,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def __enter__(self) -> "CrenoClient":
|
|
68
|
+
return self
|
|
69
|
+
|
|
70
|
+
def __exit__(self, *exc_info: Any) -> None:
|
|
71
|
+
self.close()
|
|
72
|
+
|
|
73
|
+
def close(self) -> None:
|
|
74
|
+
self._client.close()
|
|
75
|
+
|
|
76
|
+
# -- endpoints -----------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
def list_service_types(self, *, resource_id: Optional[str] = None) -> List[ServiceType]:
|
|
79
|
+
"""GET /v1/public/service-types. Retries on network errors or 5xx."""
|
|
80
|
+
params = _drop_none({"resourceId": resource_id})
|
|
81
|
+
rows = self._request_idempotent("GET", "/v1/public/service-types", params=params)
|
|
82
|
+
return [ServiceType._from_api(row) for row in rows]
|
|
83
|
+
|
|
84
|
+
def get_availability(
|
|
85
|
+
self,
|
|
86
|
+
*,
|
|
87
|
+
from_: DateLike,
|
|
88
|
+
to: DateLike,
|
|
89
|
+
resource_id: Optional[str] = None,
|
|
90
|
+
service_type_id: Optional[str] = None,
|
|
91
|
+
) -> Availability:
|
|
92
|
+
"""GET /v1/public/availability. Retries on network errors or 5xx."""
|
|
93
|
+
params = _drop_none(
|
|
94
|
+
{
|
|
95
|
+
"from": to_date_str(from_),
|
|
96
|
+
"to": to_date_str(to),
|
|
97
|
+
"resourceId": resource_id,
|
|
98
|
+
"serviceTypeId": service_type_id,
|
|
99
|
+
}
|
|
100
|
+
)
|
|
101
|
+
data = self._request_idempotent("GET", "/v1/public/availability", params=params)
|
|
102
|
+
return Availability._from_api(data)
|
|
103
|
+
|
|
104
|
+
def create_booking(
|
|
105
|
+
self,
|
|
106
|
+
*,
|
|
107
|
+
start_at: DatetimeLike,
|
|
108
|
+
customer_name: str,
|
|
109
|
+
customer_email: str,
|
|
110
|
+
resource_id: Optional[str] = None,
|
|
111
|
+
service_type_id: Optional[str] = None,
|
|
112
|
+
customer_phone: Optional[str] = None,
|
|
113
|
+
notes: Optional[str] = None,
|
|
114
|
+
lang: Optional[str] = None,
|
|
115
|
+
turnstile_token: Optional[str] = None,
|
|
116
|
+
) -> Booking:
|
|
117
|
+
"""POST /v1/public/bookings.
|
|
118
|
+
|
|
119
|
+
Never retried automatically: a lost response after the booking was
|
|
120
|
+
actually created server-side, followed by a client-side retry, could
|
|
121
|
+
still submit a second real booking attempt to a real customer, even
|
|
122
|
+
though Creno's own database can never double-book the same slot.
|
|
123
|
+
"""
|
|
124
|
+
body = _drop_none(
|
|
125
|
+
{
|
|
126
|
+
"resourceId": resource_id,
|
|
127
|
+
"serviceTypeId": service_type_id,
|
|
128
|
+
"startAt": to_iso(start_at),
|
|
129
|
+
"customerName": customer_name,
|
|
130
|
+
"customerEmail": customer_email,
|
|
131
|
+
"customerPhone": customer_phone,
|
|
132
|
+
"notes": notes,
|
|
133
|
+
"lang": lang,
|
|
134
|
+
"turnstileToken": turnstile_token,
|
|
135
|
+
}
|
|
136
|
+
)
|
|
137
|
+
data = self._request("POST", "/v1/public/bookings", json=body)
|
|
138
|
+
return Booking._from_api(data)
|
|
139
|
+
|
|
140
|
+
# -- internals -------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
def _request_idempotent(self, method: str, path: str, *, params: Dict[str, Any]) -> Any:
|
|
143
|
+
last_error: Optional[CrenoError] = None
|
|
144
|
+
for attempt in range(_MAX_RETRY_ATTEMPTS):
|
|
145
|
+
try:
|
|
146
|
+
return self._request(method, path, params=params)
|
|
147
|
+
except CrenoError as exc:
|
|
148
|
+
retryable = exc.status_code is None or exc.status_code >= 500
|
|
149
|
+
if not retryable or attempt == _MAX_RETRY_ATTEMPTS - 1:
|
|
150
|
+
raise
|
|
151
|
+
last_error = exc
|
|
152
|
+
time.sleep(_RETRY_BASE_DELAY_SECONDS * (2**attempt))
|
|
153
|
+
assert last_error is not None # pragma: no cover - loop always raises or returns
|
|
154
|
+
raise last_error
|
|
155
|
+
|
|
156
|
+
def _request(
|
|
157
|
+
self,
|
|
158
|
+
method: str,
|
|
159
|
+
path: str,
|
|
160
|
+
*,
|
|
161
|
+
params: Optional[Dict[str, Any]] = None,
|
|
162
|
+
json: Optional[Dict[str, Any]] = None,
|
|
163
|
+
) -> Any:
|
|
164
|
+
try:
|
|
165
|
+
response = self._client.request(method, path, params=params, json=json)
|
|
166
|
+
except httpx.TransportError as exc:
|
|
167
|
+
raise CrenoAPIError(f"Network error calling the Creno API: {exc}") from exc
|
|
168
|
+
|
|
169
|
+
if response.status_code // 100 == 2:
|
|
170
|
+
if response.status_code == 204 or not response.content:
|
|
171
|
+
return None
|
|
172
|
+
return response.json()
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
body: Any = response.json()
|
|
176
|
+
except ValueError:
|
|
177
|
+
body = None
|
|
178
|
+
message = (body or {}).get("error") if isinstance(body, dict) else None
|
|
179
|
+
message = message or response.text or response.reason_phrase
|
|
180
|
+
|
|
181
|
+
if response.status_code == 401:
|
|
182
|
+
raise CrenoAuthenticationError(message, status_code=401, response_body=body)
|
|
183
|
+
if response.status_code == 403:
|
|
184
|
+
raise CrenoForbiddenError(message, status_code=403, response_body=body)
|
|
185
|
+
if response.status_code == 404:
|
|
186
|
+
raise CrenoNotFoundError(message, status_code=404, response_body=body)
|
|
187
|
+
if response.status_code == 400:
|
|
188
|
+
raise CrenoValidationError(message, status_code=400, response_body=body)
|
|
189
|
+
if response.status_code == 409:
|
|
190
|
+
raise CrenoConflictError(message, status_code=409, response_body=body)
|
|
191
|
+
if response.status_code == 402:
|
|
192
|
+
raise CrenoPlanLimitError(
|
|
193
|
+
message,
|
|
194
|
+
status_code=402,
|
|
195
|
+
response_body=body,
|
|
196
|
+
limit_type=(body or {}).get("limitType") if isinstance(body, dict) else None,
|
|
197
|
+
plan=(body or {}).get("plan") if isinstance(body, dict) else None,
|
|
198
|
+
)
|
|
199
|
+
if response.status_code == 429:
|
|
200
|
+
raise CrenoRateLimitError(message, status_code=429, response_body=body)
|
|
201
|
+
raise CrenoAPIError(message, status_code=response.status_code, response_body=body)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class CrenoError(Exception):
|
|
7
|
+
"""Base class for every error this client raises.
|
|
8
|
+
|
|
9
|
+
`status_code` is None for errors that never got an HTTP response at all
|
|
10
|
+
(a network failure) rather than a non-2xx one.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
message: str,
|
|
16
|
+
*,
|
|
17
|
+
status_code: Optional[int] = None,
|
|
18
|
+
response_body: Any = None,
|
|
19
|
+
) -> None:
|
|
20
|
+
super().__init__(message)
|
|
21
|
+
self.message = message
|
|
22
|
+
self.status_code = status_code
|
|
23
|
+
self.response_body = response_body
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CrenoAuthenticationError(CrenoError):
|
|
27
|
+
"""401 - the X-API-Key header was missing or didn't match a real key."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CrenoForbiddenError(CrenoError):
|
|
31
|
+
"""403 - the request's Origin header isn't on the tenant's allowlist.
|
|
32
|
+
|
|
33
|
+
This client never sends an Origin header, so this shouldn't occur during
|
|
34
|
+
normal server-to-server use of this SDK.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class CrenoNotFoundError(CrenoError):
|
|
39
|
+
"""404 - no matching resource, or no scheduling resource configured for this tenant."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class CrenoValidationError(CrenoError):
|
|
43
|
+
"""400 - the request body failed validation (for example, a Turnstile challenge)."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class CrenoConflictError(CrenoError):
|
|
47
|
+
"""409 - the requested time slot is no longer available."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class CrenoPlanLimitError(CrenoError):
|
|
51
|
+
"""402 - the tenant's plan limit has been reached."""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
message: str,
|
|
56
|
+
*,
|
|
57
|
+
status_code: int,
|
|
58
|
+
response_body: Any,
|
|
59
|
+
limit_type: Optional[str],
|
|
60
|
+
plan: Optional[str],
|
|
61
|
+
) -> None:
|
|
62
|
+
super().__init__(message, status_code=status_code, response_body=response_body)
|
|
63
|
+
self.limit_type = limit_type
|
|
64
|
+
self.plan = plan
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class CrenoRateLimitError(CrenoError):
|
|
68
|
+
"""429 - too many requests. Rate limits are keyed per API key, not per IP."""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class CrenoAPIError(CrenoError):
|
|
72
|
+
"""Fallback for network errors, 5xx responses, or any other unexpected response."""
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import date, datetime, timezone
|
|
5
|
+
from typing import Any, Dict, List, Optional, Union
|
|
6
|
+
|
|
7
|
+
DateLike = Union[str, date]
|
|
8
|
+
DatetimeLike = Union[str, datetime]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _parse_datetime(value: str) -> datetime:
|
|
12
|
+
# datetime.fromisoformat only accepts "+00:00", not the "Z" suffix Creno's
|
|
13
|
+
# API actually sends, on Python versions before 3.11.
|
|
14
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _parse_optional_datetime(value: Optional[str]) -> Optional[datetime]:
|
|
18
|
+
return _parse_datetime(value) if value else None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def to_iso(value: DatetimeLike) -> str:
|
|
22
|
+
if isinstance(value, datetime):
|
|
23
|
+
if value.tzinfo is None:
|
|
24
|
+
raise ValueError(
|
|
25
|
+
"datetime values passed to the Creno client must be timezone-aware "
|
|
26
|
+
"(attach a tzinfo, e.g. datetime.timezone.utc)"
|
|
27
|
+
)
|
|
28
|
+
return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
29
|
+
return value
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def to_date_str(value: DateLike) -> str:
|
|
33
|
+
if isinstance(value, date):
|
|
34
|
+
return value.isoformat()
|
|
35
|
+
return value
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class ServiceType:
|
|
40
|
+
id: str
|
|
41
|
+
resource_id: str
|
|
42
|
+
name: str
|
|
43
|
+
active: bool
|
|
44
|
+
sort_order: int
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def _from_api(cls, data: Dict[str, Any]) -> "ServiceType":
|
|
48
|
+
return cls(
|
|
49
|
+
id=data["id"],
|
|
50
|
+
resource_id=data["resourceId"],
|
|
51
|
+
name=data["name"],
|
|
52
|
+
active=data["active"],
|
|
53
|
+
sort_order=data["sortOrder"],
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class Slot:
|
|
59
|
+
start_at: datetime
|
|
60
|
+
end_at: datetime
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
def _from_api(cls, data: Dict[str, Any]) -> "Slot":
|
|
64
|
+
return cls(start_at=_parse_datetime(data["startAt"]), end_at=_parse_datetime(data["endAt"]))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class Availability:
|
|
69
|
+
resource_id: str
|
|
70
|
+
timezone: str
|
|
71
|
+
slots: List[Slot]
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def _from_api(cls, data: Dict[str, Any]) -> "Availability":
|
|
75
|
+
return cls(
|
|
76
|
+
resource_id=data["resourceId"],
|
|
77
|
+
timezone=data["timezone"],
|
|
78
|
+
slots=[Slot._from_api(row) for row in data["slots"]],
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class Booking:
|
|
84
|
+
id: str
|
|
85
|
+
tenant_id: str
|
|
86
|
+
resource_id: str
|
|
87
|
+
service_type_id: Optional[str]
|
|
88
|
+
customer_name: str
|
|
89
|
+
customer_email: str
|
|
90
|
+
customer_phone: Optional[str]
|
|
91
|
+
start_at: datetime
|
|
92
|
+
end_at: datetime
|
|
93
|
+
status: str
|
|
94
|
+
hold_expires_at: Optional[datetime]
|
|
95
|
+
notes: Optional[str]
|
|
96
|
+
created_at: datetime
|
|
97
|
+
|
|
98
|
+
@classmethod
|
|
99
|
+
def _from_api(cls, data: Dict[str, Any]) -> "Booking":
|
|
100
|
+
return cls(
|
|
101
|
+
id=data["id"],
|
|
102
|
+
tenant_id=data["tenantId"],
|
|
103
|
+
resource_id=data["resourceId"],
|
|
104
|
+
service_type_id=data.get("serviceTypeId"),
|
|
105
|
+
customer_name=data["customerName"],
|
|
106
|
+
customer_email=data["customerEmail"],
|
|
107
|
+
customer_phone=data.get("customerPhone"),
|
|
108
|
+
start_at=_parse_datetime(data["startAt"]),
|
|
109
|
+
end_at=_parse_datetime(data["endAt"]),
|
|
110
|
+
status=data["status"],
|
|
111
|
+
hold_expires_at=_parse_optional_datetime(data.get("holdExpiresAt")),
|
|
112
|
+
notes=data.get("notes"),
|
|
113
|
+
created_at=_parse_datetime(data["createdAt"]),
|
|
114
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
import pytest
|
|
3
|
+
|
|
4
|
+
from creno import CrenoClient
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@pytest.fixture
|
|
8
|
+
def client_with():
|
|
9
|
+
"""Builds a CrenoClient whose transport is a caller-supplied handler function.
|
|
10
|
+
|
|
11
|
+
Usage: client_with(lambda request: httpx.Response(200, json=[]))
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def make(handler) -> CrenoClient:
|
|
15
|
+
return CrenoClient(api_key="pk_test_123", transport=httpx.MockTransport(handler))
|
|
16
|
+
|
|
17
|
+
return make
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Real HTTP integration tests against a running Creno API instance.
|
|
2
|
+
|
|
3
|
+
Skipped unless CRENO_TEST_API_URL and CRENO_TEST_API_KEY are set, so a bare
|
|
4
|
+
`pytest` run never silently "passes" without ever touching the network.
|
|
5
|
+
|
|
6
|
+
pnpm dev:api # from the platform repo root, in one terminal
|
|
7
|
+
CRENO_TEST_API_URL=http://localhost:3000 \\
|
|
8
|
+
CRENO_TEST_API_KEY=<a real seeded tenant's publishable key> \\
|
|
9
|
+
pytest tests/test_client_integration.py -m integration
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import uuid
|
|
14
|
+
from datetime import date, timedelta
|
|
15
|
+
|
|
16
|
+
import pytest
|
|
17
|
+
|
|
18
|
+
from creno import CrenoClient
|
|
19
|
+
|
|
20
|
+
API_URL = os.environ.get("CRENO_TEST_API_URL")
|
|
21
|
+
API_KEY = os.environ.get("CRENO_TEST_API_KEY")
|
|
22
|
+
|
|
23
|
+
pytestmark = [
|
|
24
|
+
pytest.mark.integration,
|
|
25
|
+
pytest.mark.skipif(
|
|
26
|
+
not API_URL or not API_KEY,
|
|
27
|
+
reason=(
|
|
28
|
+
"Set CRENO_TEST_API_URL and CRENO_TEST_API_KEY to run these tests against "
|
|
29
|
+
"a real running Creno API instance."
|
|
30
|
+
),
|
|
31
|
+
),
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@pytest.fixture
|
|
36
|
+
def client():
|
|
37
|
+
with CrenoClient(api_key=API_KEY, base_url=API_URL) as c:
|
|
38
|
+
yield c
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_list_service_types_real(client: CrenoClient) -> None:
|
|
42
|
+
result = client.list_service_types()
|
|
43
|
+
assert isinstance(result, list)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_get_availability_real(client: CrenoClient) -> None:
|
|
47
|
+
today = date.today()
|
|
48
|
+
availability = client.get_availability(from_=today, to=today + timedelta(days=7))
|
|
49
|
+
assert availability.timezone
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_create_booking_real(client: CrenoClient) -> None:
|
|
53
|
+
today = date.today()
|
|
54
|
+
availability = client.get_availability(from_=today, to=today + timedelta(days=14))
|
|
55
|
+
if not availability.slots:
|
|
56
|
+
pytest.skip("No available slots in the next 14 days for the test tenant; seed more availability.")
|
|
57
|
+
|
|
58
|
+
slot = availability.slots[0]
|
|
59
|
+
booking = client.create_booking(
|
|
60
|
+
start_at=slot.start_at,
|
|
61
|
+
customer_name="Creno SDK Integration Test",
|
|
62
|
+
customer_email=f"sdk-integration-test-{uuid.uuid4().hex[:8]}@example.com",
|
|
63
|
+
notes="Created by the creno Python SDK's integration test suite.",
|
|
64
|
+
)
|
|
65
|
+
assert booking.status in ("pending", "confirmed")
|
|
66
|
+
assert booking.start_at == slot.start_at
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from creno import (
|
|
7
|
+
CrenoAuthenticationError,
|
|
8
|
+
CrenoConflictError,
|
|
9
|
+
CrenoForbiddenError,
|
|
10
|
+
CrenoNotFoundError,
|
|
11
|
+
CrenoPlanLimitError,
|
|
12
|
+
CrenoRateLimitError,
|
|
13
|
+
CrenoValidationError,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_sends_api_key_header(client_with):
|
|
18
|
+
seen = {}
|
|
19
|
+
|
|
20
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
21
|
+
seen["api_key"] = request.headers["x-api-key"]
|
|
22
|
+
return httpx.Response(200, json=[])
|
|
23
|
+
|
|
24
|
+
client_with(handler).list_service_types()
|
|
25
|
+
assert seen["api_key"] == "pk_test_123"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_list_service_types_parses_rows(client_with):
|
|
29
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
30
|
+
return httpx.Response(
|
|
31
|
+
200,
|
|
32
|
+
json=[{"id": "st1", "resourceId": "r1", "name": "Haircut", "active": True, "sortOrder": 0}],
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
result = client_with(handler).list_service_types()
|
|
36
|
+
assert len(result) == 1
|
|
37
|
+
assert result[0].name == "Haircut"
|
|
38
|
+
assert result[0].sort_order == 0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_get_availability_parses_slots(client_with):
|
|
42
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
43
|
+
return httpx.Response(
|
|
44
|
+
200,
|
|
45
|
+
json={
|
|
46
|
+
"resourceId": "r1",
|
|
47
|
+
"timezone": "America/Toronto",
|
|
48
|
+
"slots": [{"startAt": "2026-08-01T13:00:00Z", "endAt": "2026-08-01T13:30:00Z"}],
|
|
49
|
+
},
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
availability = client_with(handler).get_availability(from_="2026-08-01", to="2026-08-07")
|
|
53
|
+
assert availability.timezone == "America/Toronto"
|
|
54
|
+
assert availability.slots[0].start_at.hour == 13
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_get_availability_accepts_date_objects(client_with):
|
|
58
|
+
from datetime import date
|
|
59
|
+
|
|
60
|
+
seen = {}
|
|
61
|
+
|
|
62
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
63
|
+
seen["query"] = dict(request.url.params)
|
|
64
|
+
return httpx.Response(200, json={"resourceId": "r1", "timezone": "UTC", "slots": []})
|
|
65
|
+
|
|
66
|
+
client_with(handler).get_availability(from_=date(2026, 8, 1), to=date(2026, 8, 7))
|
|
67
|
+
assert seen["query"]["from"] == "2026-08-01"
|
|
68
|
+
assert seen["query"]["to"] == "2026-08-07"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_create_booking_omits_none_fields_from_body(client_with):
|
|
72
|
+
seen = {}
|
|
73
|
+
|
|
74
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
75
|
+
seen["body"] = json.loads(request.read())
|
|
76
|
+
return httpx.Response(
|
|
77
|
+
201,
|
|
78
|
+
json={
|
|
79
|
+
"id": "b1",
|
|
80
|
+
"tenantId": "t1",
|
|
81
|
+
"resourceId": "r1",
|
|
82
|
+
"serviceTypeId": None,
|
|
83
|
+
"customerName": "Jane Doe",
|
|
84
|
+
"customerEmail": "jane@example.com",
|
|
85
|
+
"customerPhone": None,
|
|
86
|
+
"startAt": "2026-08-01T13:00:00Z",
|
|
87
|
+
"endAt": "2026-08-01T13:30:00Z",
|
|
88
|
+
"status": "confirmed",
|
|
89
|
+
"holdExpiresAt": None,
|
|
90
|
+
"notes": None,
|
|
91
|
+
"createdAt": "2026-08-01T12:00:00Z",
|
|
92
|
+
},
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
booking = client_with(handler).create_booking(
|
|
96
|
+
start_at="2026-08-01T13:00:00Z",
|
|
97
|
+
customer_name="Jane Doe",
|
|
98
|
+
customer_email="jane@example.com",
|
|
99
|
+
)
|
|
100
|
+
assert "resourceId" not in seen["body"]
|
|
101
|
+
assert "serviceTypeId" not in seen["body"]
|
|
102
|
+
assert "customerPhone" not in seen["body"]
|
|
103
|
+
assert booking.status == "confirmed"
|
|
104
|
+
assert booking.service_type_id is None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_create_booking_normalizes_aware_datetime_to_utc_iso(client_with):
|
|
108
|
+
from datetime import datetime, timedelta, timezone
|
|
109
|
+
|
|
110
|
+
seen = {}
|
|
111
|
+
eastern = timezone(timedelta(hours=-4))
|
|
112
|
+
|
|
113
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
114
|
+
seen["body"] = json.loads(request.read())
|
|
115
|
+
return httpx.Response(
|
|
116
|
+
201,
|
|
117
|
+
json={
|
|
118
|
+
"id": "b1",
|
|
119
|
+
"tenantId": "t1",
|
|
120
|
+
"resourceId": "r1",
|
|
121
|
+
"serviceTypeId": None,
|
|
122
|
+
"customerName": "Jane Doe",
|
|
123
|
+
"customerEmail": "jane@example.com",
|
|
124
|
+
"customerPhone": None,
|
|
125
|
+
"startAt": "2026-08-01T17:00:00Z",
|
|
126
|
+
"endAt": "2026-08-01T17:30:00Z",
|
|
127
|
+
"status": "confirmed",
|
|
128
|
+
"holdExpiresAt": None,
|
|
129
|
+
"notes": None,
|
|
130
|
+
"createdAt": "2026-08-01T12:00:00Z",
|
|
131
|
+
},
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
client_with(handler).create_booking(
|
|
135
|
+
start_at=datetime(2026, 8, 1, 13, 0, 0, tzinfo=eastern),
|
|
136
|
+
customer_name="Jane Doe",
|
|
137
|
+
customer_email="jane@example.com",
|
|
138
|
+
)
|
|
139
|
+
assert seen["body"]["startAt"] == "2026-08-01T17:00:00Z"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_create_booking_rejects_naive_datetime(client_with):
|
|
143
|
+
from datetime import datetime
|
|
144
|
+
|
|
145
|
+
def handler(request: httpx.Request) -> httpx.Response: # pragma: no cover - should never be called
|
|
146
|
+
return httpx.Response(201, json={})
|
|
147
|
+
|
|
148
|
+
with pytest.raises(ValueError):
|
|
149
|
+
client_with(handler).create_booking(
|
|
150
|
+
start_at=datetime(2026, 8, 1, 13, 0, 0),
|
|
151
|
+
customer_name="Jane Doe",
|
|
152
|
+
customer_email="jane@example.com",
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@pytest.mark.parametrize(
|
|
157
|
+
"status_code,exc_class",
|
|
158
|
+
[
|
|
159
|
+
(401, CrenoAuthenticationError),
|
|
160
|
+
(403, CrenoForbiddenError),
|
|
161
|
+
(404, CrenoNotFoundError),
|
|
162
|
+
(400, CrenoValidationError),
|
|
163
|
+
(409, CrenoConflictError),
|
|
164
|
+
(429, CrenoRateLimitError),
|
|
165
|
+
],
|
|
166
|
+
)
|
|
167
|
+
def test_error_status_codes_map_to_exceptions(client_with, status_code, exc_class):
|
|
168
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
169
|
+
return httpx.Response(status_code, json={"error": "boom"})
|
|
170
|
+
|
|
171
|
+
with pytest.raises(exc_class) as exc_info:
|
|
172
|
+
client_with(handler).list_service_types()
|
|
173
|
+
assert exc_info.value.status_code == status_code
|
|
174
|
+
assert exc_info.value.message == "boom"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_plan_limit_error_carries_limit_type_and_plan(client_with):
|
|
178
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
179
|
+
return httpx.Response(
|
|
180
|
+
402,
|
|
181
|
+
json={"error": "Plan limit reached", "limitType": "bookingsPerMonth", "plan": "starter"},
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
with pytest.raises(CrenoPlanLimitError) as exc_info:
|
|
185
|
+
client_with(handler).create_booking(
|
|
186
|
+
start_at="2026-08-01T13:00:00Z", customer_name="Jane", customer_email="jane@example.com"
|
|
187
|
+
)
|
|
188
|
+
assert exc_info.value.limit_type == "bookingsPerMonth"
|
|
189
|
+
assert exc_info.value.plan == "starter"
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def test_get_availability_retries_on_500_then_succeeds(client_with):
|
|
193
|
+
calls = {"count": 0}
|
|
194
|
+
|
|
195
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
196
|
+
calls["count"] += 1
|
|
197
|
+
if calls["count"] < 2:
|
|
198
|
+
return httpx.Response(500, json={"error": "boom"})
|
|
199
|
+
return httpx.Response(200, json={"resourceId": "r1", "timezone": "UTC", "slots": []})
|
|
200
|
+
|
|
201
|
+
result = client_with(handler).get_availability(from_="2026-08-01", to="2026-08-07")
|
|
202
|
+
assert calls["count"] == 2
|
|
203
|
+
assert result.timezone == "UTC"
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def test_get_availability_does_not_retry_on_4xx(client_with):
|
|
207
|
+
calls = {"count": 0}
|
|
208
|
+
|
|
209
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
210
|
+
calls["count"] += 1
|
|
211
|
+
return httpx.Response(404, json={"error": "Resource not found"})
|
|
212
|
+
|
|
213
|
+
with pytest.raises(CrenoNotFoundError):
|
|
214
|
+
client_with(handler).get_availability(from_="2026-08-01", to="2026-08-07")
|
|
215
|
+
assert calls["count"] == 1
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def test_create_booking_does_not_retry_on_500(client_with):
|
|
219
|
+
calls = {"count": 0}
|
|
220
|
+
|
|
221
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
222
|
+
calls["count"] += 1
|
|
223
|
+
return httpx.Response(500, json={"error": "boom"})
|
|
224
|
+
|
|
225
|
+
with pytest.raises(Exception):
|
|
226
|
+
client_with(handler).create_booking(
|
|
227
|
+
start_at="2026-08-01T13:00:00Z", customer_name="Jane", customer_email="jane@example.com"
|
|
228
|
+
)
|
|
229
|
+
assert calls["count"] == 1
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def test_context_manager_closes_client(client_with):
|
|
233
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
234
|
+
return httpx.Response(200, json=[])
|
|
235
|
+
|
|
236
|
+
client = client_with(handler)
|
|
237
|
+
with client:
|
|
238
|
+
client.list_service_types()
|
|
239
|
+
assert client._client.is_closed
|