creno 0.1.0__py3-none-any.whl
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/__init__.py +32 -0
- creno/_version.py +1 -0
- creno/client.py +201 -0
- creno/exceptions.py +72 -0
- creno/models.py +114 -0
- creno/py.typed +0 -0
- creno-0.1.0.dist-info/METADATA +86 -0
- creno-0.1.0.dist-info/RECORD +10 -0
- creno-0.1.0.dist-info/WHEEL +4 -0
- creno-0.1.0.dist-info/licenses/LICENSE +21 -0
creno/__init__.py
ADDED
|
@@ -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
|
+
]
|
creno/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
creno/client.py
ADDED
|
@@ -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)
|
creno/exceptions.py
ADDED
|
@@ -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."""
|
creno/models.py
ADDED
|
@@ -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
|
+
)
|
creno/py.typed
ADDED
|
File without changes
|
|
@@ -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).
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
creno/__init__.py,sha256=IyUprdtsDTVMoway1FpcHUq031MdHpOaxxPVdeAa2oM,720
|
|
2
|
+
creno/_version.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
3
|
+
creno/client.py,sha256=QyXhcKob93mEJQhFUvvYYojSqqk4r32gNBmZPdl8fgg,7498
|
|
4
|
+
creno/exceptions.py,sha256=j8xREO7K0tjvXtjnpGrm6z1ZISEN_j4tMj7v222gpjk,2026
|
|
5
|
+
creno/models.py,sha256=QjxLlsUcia05SsIcbgH9dXYF2rOsebmv8vFXZye-qEg,3283
|
|
6
|
+
creno/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
creno-0.1.0.dist-info/METADATA,sha256=_wUymZ32i7YfWsN_zm-1A-cpW5pPM5eCttDGb9hlp5w,4640
|
|
8
|
+
creno-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
9
|
+
creno-0.1.0.dist-info/licenses/LICENSE,sha256=r5WLPHVmSujFz8jqRckdGBi6VvSvAzZ_xv-N-hxvd6g,1073
|
|
10
|
+
creno-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|