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 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.