bookalimo 0.1.5__py3-none-any.whl → 1.0.1__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.
Files changed (42) hide show
  1. bookalimo/__init__.py +17 -24
  2. bookalimo/_version.py +9 -0
  3. bookalimo/client.py +310 -0
  4. bookalimo/config.py +16 -0
  5. bookalimo/exceptions.py +115 -5
  6. bookalimo/integrations/__init__.py +1 -0
  7. bookalimo/integrations/google_places/__init__.py +31 -0
  8. bookalimo/integrations/google_places/client_async.py +289 -0
  9. bookalimo/integrations/google_places/client_sync.py +287 -0
  10. bookalimo/integrations/google_places/common.py +231 -0
  11. bookalimo/integrations/google_places/proto_adapter.py +224 -0
  12. bookalimo/integrations/google_places/resolve_airport.py +397 -0
  13. bookalimo/integrations/google_places/transports.py +98 -0
  14. bookalimo/{_logging.py → logging.py} +45 -42
  15. bookalimo/schemas/__init__.py +103 -0
  16. bookalimo/schemas/base.py +56 -0
  17. bookalimo/{models.py → schemas/booking.py} +88 -100
  18. bookalimo/schemas/places/__init__.py +62 -0
  19. bookalimo/schemas/places/common.py +351 -0
  20. bookalimo/schemas/places/field_mask.py +221 -0
  21. bookalimo/schemas/places/google.py +883 -0
  22. bookalimo/schemas/places/place.py +334 -0
  23. bookalimo/services/__init__.py +11 -0
  24. bookalimo/services/pricing.py +191 -0
  25. bookalimo/services/reservations.py +227 -0
  26. bookalimo/transport/__init__.py +7 -0
  27. bookalimo/transport/auth.py +41 -0
  28. bookalimo/transport/base.py +44 -0
  29. bookalimo/transport/httpx_async.py +230 -0
  30. bookalimo/transport/httpx_sync.py +230 -0
  31. bookalimo/transport/retry.py +102 -0
  32. bookalimo/transport/utils.py +59 -0
  33. bookalimo-1.0.1.dist-info/METADATA +370 -0
  34. bookalimo-1.0.1.dist-info/RECORD +38 -0
  35. bookalimo-1.0.1.dist-info/licenses/LICENSE +21 -0
  36. bookalimo/_client.py +0 -420
  37. bookalimo/wrapper.py +0 -444
  38. bookalimo-0.1.5.dist-info/METADATA +0 -392
  39. bookalimo-0.1.5.dist-info/RECORD +0 -12
  40. bookalimo-0.1.5.dist-info/licenses/LICENSE +0 -0
  41. {bookalimo-0.1.5.dist-info → bookalimo-1.0.1.dist-info}/WHEEL +0 -0
  42. {bookalimo-0.1.5.dist-info → bookalimo-1.0.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,351 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from enum import IntEnum
5
+ from typing import Optional
6
+
7
+ from pydantic import (
8
+ AnyUrl,
9
+ BaseModel,
10
+ ConfigDict,
11
+ Field,
12
+ field_validator,
13
+ model_validator,
14
+ )
15
+ from typing_extensions import Self
16
+
17
+ BCP47 = re.compile(
18
+ r"^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$"
19
+ ) # pragmatic, e.g., "en", "en-US", "zh-Hant"
20
+
21
+ CLDR_REGION_2 = re.compile(r"^[A-Z]{2}$") # e.g., "US", "GB"
22
+
23
+ PLACE_ID = re.compile(
24
+ r"^[A-Za-z0-9_-]{10,}$"
25
+ ) # pragmatic: real IDs are long base64url-ish e.g., "ChIJN1t_tDeuEmsRUsoyG83frY4"
26
+
27
+ PLACE_RESOURCE = re.compile(
28
+ r"^places/[A-Za-z0-9_-]{10,}$"
29
+ ) # e.g., "places/ChIJN1t_tDeuEmsRUsoyG83frY4"
30
+
31
+ PLACE_TYPE = re.compile(r"^[a-z0-9_]+$") # e.g., "gas_station", "restaurant"
32
+
33
+ HEX_COLOR = re.compile(r"^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$") # #abc, #a1b2c3
34
+
35
+ PLUS_GLOBAL = re.compile(
36
+ r"^[23456789CFGHJMPQRVWX]{4,8}\+[23456789CFGHJMPQRVWX]{2,}$"
37
+ ) # Open Location Code (Plus Code), pragmatic
38
+
39
+ BASE64URL_36 = re.compile(
40
+ r"^[A-Za-z0-9_-]{1,36}$"
41
+ ) # up to 36 chars, URL-safe base64-ish
42
+
43
+
44
+ # ---------- Fundamental types ----------
45
+ class LocalizedText(BaseModel):
46
+ """Localized text with language code."""
47
+
48
+ model_config = ConfigDict(extra="allow")
49
+
50
+ text: str
51
+ language_code: str
52
+
53
+ @field_validator("language_code")
54
+ @classmethod
55
+ def _language_code(cls, v: str) -> str:
56
+ if not BCP47.match(v):
57
+ raise ValueError("language_code must be a valid BCP-47 language tag")
58
+ return v
59
+
60
+
61
+ # ---------- "External" Google message wrappers ----------
62
+ class ExternalModel(BaseModel):
63
+ """Permissive wrapper for Google messages we don't model in detail."""
64
+
65
+ model_config = ConfigDict(extra="allow")
66
+
67
+
68
+ class OpeningHours(ExternalModel): ...
69
+
70
+
71
+ class PostalAddress(ExternalModel): ...
72
+
73
+
74
+ class TimeZone(ExternalModel): ...
75
+
76
+
77
+ class Timestamp(ExternalModel): ...
78
+
79
+
80
+ class Date(ExternalModel): ...
81
+
82
+
83
+ class ContentBlock(ExternalModel): ...
84
+
85
+
86
+ class Photo(ExternalModel): ...
87
+
88
+
89
+ class Review(ExternalModel): ...
90
+
91
+
92
+ class FuelOptions(ExternalModel): ...
93
+
94
+
95
+ class EVChargeOptions(ExternalModel): ...
96
+
97
+
98
+ class AddressDescriptor(BaseModel):
99
+ """A relational description of a location with nearby landmarks and containing areas."""
100
+
101
+ model_config = ConfigDict(extra="forbid")
102
+
103
+ class Landmark(BaseModel):
104
+ """Basic landmark information and relationship with target location."""
105
+
106
+ model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
107
+
108
+ class SpatialRelationship(IntEnum):
109
+ """Spatial relationship between target location and landmark."""
110
+
111
+ NEAR = 0
112
+ WITHIN = 1
113
+ BESIDE = 2
114
+ ACROSS_THE_ROAD = 3
115
+ DOWN_THE_ROAD = 4
116
+ AROUND_THE_CORNER = 5
117
+ BEHIND = 6
118
+
119
+ name: str = Field(..., description="Landmark's resource name")
120
+ place_id: str = Field(..., description="Landmark's place ID")
121
+ display_name: LocalizedText = Field(..., description="Landmark's display name")
122
+ types: list[str] = Field(
123
+ default_factory=list, description="Type tags for landmark"
124
+ )
125
+ spatial_relationship: SpatialRelationship = Field(
126
+ default=SpatialRelationship.NEAR,
127
+ description="Spatial relationship to target",
128
+ )
129
+ straight_line_distance_meters: float = Field(
130
+ ..., ge=0.0, description="Straight line distance in meters"
131
+ )
132
+ travel_distance_meters: Optional[float] = Field(
133
+ default=None,
134
+ ge=0.0,
135
+ description="Travel distance in meters along road network",
136
+ )
137
+
138
+ @field_validator("name")
139
+ @classmethod
140
+ def _name(cls, v: str) -> str:
141
+ if not PLACE_RESOURCE.fullmatch(v):
142
+ raise ValueError("name must be in the form 'places/{place_id}'")
143
+ return v
144
+
145
+ @field_validator("place_id")
146
+ @classmethod
147
+ def _place_id(cls, v: str) -> str:
148
+ if not PLACE_ID.fullmatch(v):
149
+ raise ValueError("place_id must be a valid Place ID")
150
+ return v
151
+
152
+ @field_validator("types")
153
+ @classmethod
154
+ def _types(cls, v: list[str]) -> list[str]:
155
+ out, seen = [], set()
156
+ for raw in v:
157
+ t = raw.strip()
158
+ if not t:
159
+ raise ValueError("types cannot contain empty strings")
160
+ if not PLACE_TYPE.fullmatch(t):
161
+ raise ValueError(f"invalid place type '{t}'")
162
+ if t not in seen:
163
+ out.append(t)
164
+ seen.add(t)
165
+ return out
166
+
167
+ @model_validator(mode="after")
168
+ def _name_id_consistency(self) -> Self:
169
+ if self.name.split("/", 1)[1] != self.place_id:
170
+ raise ValueError("place_id must match the trailing component of name")
171
+ return self
172
+
173
+ class Area(BaseModel):
174
+ """Area information and relationship with target location."""
175
+
176
+ model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
177
+
178
+ class Containment(IntEnum):
179
+ """Spatial relationship between target location and area."""
180
+
181
+ CONTAINMENT_UNSPECIFIED = 0
182
+ WITHIN = 1
183
+ OUTSKIRTS = 2
184
+ NEAR = 3
185
+
186
+ name: str = Field(..., description="Area's resource name")
187
+ place_id: str = Field(..., description="Area's place ID")
188
+ display_name: LocalizedText = Field(..., description="Area's display name")
189
+ containment: Containment = Field(
190
+ default=Containment.CONTAINMENT_UNSPECIFIED,
191
+ description="Spatial relationship to target",
192
+ )
193
+
194
+ @field_validator("name")
195
+ @classmethod
196
+ def _name(cls, v: str) -> str:
197
+ if not PLACE_RESOURCE.fullmatch(v):
198
+ raise ValueError("name must be in the form 'places/{place_id}'")
199
+ return v
200
+
201
+ @field_validator("place_id")
202
+ @classmethod
203
+ def _place_id(cls, v: str) -> str:
204
+ if not PLACE_ID.fullmatch(v):
205
+ raise ValueError("place_id must be a valid Place ID")
206
+ return v
207
+
208
+ @model_validator(mode="after")
209
+ def _name_id_consistency(self) -> Self:
210
+ if self.name.split("/", 1)[1] != self.place_id:
211
+ raise ValueError("place_id must match the trailing component of name")
212
+ return self
213
+
214
+ landmarks: list[Landmark] = Field(
215
+ default_factory=list, description="Ranked list of nearby landmarks"
216
+ )
217
+ areas: list[Area] = Field(
218
+ default_factory=list, description="Ranked list of containing or adjacent areas"
219
+ )
220
+
221
+ @field_validator("landmarks")
222
+ @classmethod
223
+ def _max_landmarks(cls, v: list[Landmark]) -> list[Landmark]:
224
+ if len(v) > 10: # Reasonable limit for API responses
225
+ raise ValueError("landmarks can contain at most 10 items")
226
+ return v
227
+
228
+ @field_validator("areas")
229
+ @classmethod
230
+ def _max_areas(cls, v: list[Area]) -> list[Area]:
231
+ if len(v) > 10: # Reasonable limit for API responses
232
+ raise ValueError("areas can contain at most 10 items")
233
+ return v
234
+
235
+
236
+ class PriceRange(ExternalModel): ...
237
+
238
+
239
+ # ---------- Geometry ----------
240
+ class LatLng(BaseModel):
241
+ model_config = ConfigDict(extra="forbid")
242
+
243
+ latitude: float = Field(..., description="[-90, 90]")
244
+ longitude: float = Field(..., description="[-180, 180]")
245
+
246
+ @model_validator(mode="after")
247
+ def _check(self) -> Self:
248
+ if not (-90 <= self.latitude <= 90):
249
+ raise ValueError("latitude must be between -90 and 90")
250
+ if not (-180 <= self.longitude <= 180):
251
+ raise ValueError("longitude must be between -180 and 180")
252
+ return self
253
+
254
+
255
+ class Viewport(BaseModel):
256
+ model_config = ConfigDict(extra="forbid")
257
+
258
+ high: LatLng
259
+ low: LatLng
260
+
261
+ @model_validator(mode="after")
262
+ def _lat_order(self) -> Self:
263
+ if self.high.latitude < self.low.latitude:
264
+ raise ValueError("high.latitude must be >= low.latitude")
265
+ return self
266
+
267
+
268
+ # ---------- Simple shared types ----------
269
+ class PlusCode(BaseModel):
270
+ model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
271
+
272
+ global_code: Optional[str] = Field(default=None, description="e.g., '9FWM33GV+HQ'")
273
+ compound_code: Optional[str] = Field(default=None)
274
+
275
+ @field_validator("global_code")
276
+ @classmethod
277
+ def _check_global(cls, v: Optional[str]) -> Optional[str]:
278
+ if v is None:
279
+ return v
280
+ # Accept common formats; be permissive.
281
+ if not PLUS_GLOBAL.match(v):
282
+ # Let a variety of valid codes through without being too strict.
283
+ if "+" not in v or len(v) < 6:
284
+ raise ValueError(
285
+ "global_code must look like a valid plus code (e.g., '9FWM33GV+HQ')."
286
+ )
287
+ return v
288
+
289
+
290
+ class Attribution(BaseModel):
291
+ model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
292
+
293
+ provider: str
294
+ provider_uri: Optional[AnyUrl] = None
295
+
296
+
297
+ class SubDestination(BaseModel):
298
+ model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
299
+
300
+ name: str = Field(..., description="places/{place_id}")
301
+ id: str = Field(..., description="{place_id}")
302
+
303
+ @field_validator("name")
304
+ @classmethod
305
+ def _name(cls, v: str) -> str:
306
+ if not PLACE_RESOURCE.fullmatch(v):
307
+ raise ValueError("name must be in the form 'places/{place_id}'")
308
+ return v
309
+
310
+ @field_validator("id")
311
+ @classmethod
312
+ def _id(cls, v: str) -> str:
313
+ if not PLACE_ID.fullmatch(v):
314
+ raise ValueError("id must be a base64url-like token (>=10 chars)")
315
+ return v
316
+
317
+ @model_validator(mode="after")
318
+ def _match(self) -> Self:
319
+ if self.name.split("/", 1)[1] != self.id:
320
+ raise ValueError("id must match the trailing component of name")
321
+ return self
322
+
323
+
324
+ class AccessibilityOptions(BaseModel):
325
+ model_config = ConfigDict(extra="forbid")
326
+
327
+ wheelchair_accessible_parking: Optional[bool] = None
328
+ wheelchair_accessible_entrance: Optional[bool] = None
329
+ wheelchair_accessible_restroom: Optional[bool] = None
330
+ wheelchair_accessible_seating: Optional[bool] = None
331
+
332
+
333
+ class PaymentOptions(BaseModel):
334
+ model_config = ConfigDict(extra="forbid")
335
+
336
+ accepts_credit_cards: Optional[bool] = None
337
+ accepts_debit_cards: Optional[bool] = None
338
+ accepts_cash_only: Optional[bool] = None
339
+ accepts_nfc: Optional[bool] = None
340
+
341
+
342
+ class ParkingOptions(BaseModel):
343
+ model_config = ConfigDict(extra="forbid")
344
+
345
+ free_parking_lot: Optional[bool] = None
346
+ paid_parking_lot: Optional[bool] = None
347
+ free_street_parking: Optional[bool] = None
348
+ paid_street_parking: Optional[bool] = None
349
+ valet_parking: Optional[bool] = None
350
+ free_garage_parking: Optional[bool] = None
351
+ paid_garage_parking: Optional[bool] = None
@@ -0,0 +1,221 @@
1
+ from __future__ import annotations
2
+
3
+ import warnings
4
+ from typing import (
5
+ Any,
6
+ Callable,
7
+ Iterable,
8
+ NewType,
9
+ Optional,
10
+ Type,
11
+ Union,
12
+ get_args,
13
+ get_origin,
14
+ )
15
+
16
+ from pydantic import BaseModel
17
+
18
+ from .common import ExternalModel
19
+ from .place import GooglePlace
20
+
21
+ FieldMaskInput = Union[str, "FieldPath", Iterable[Union[str, "FieldPath"]]]
22
+ FieldTreeType = NewType("FieldTreeType", dict[str, Any])
23
+
24
+ # Sentinel meaning “anything under this node is allowed”
25
+ ANY = object()
26
+
27
+
28
+ def _unwrap(t: Any) -> Any:
29
+ """Unwrap Optional[T], list[T], etc. Return (base_type, is_list)."""
30
+ origin = get_origin(t)
31
+ args = get_args(t)
32
+ # Optional[T] is Union[T, NoneType]
33
+ if origin is Union and args:
34
+ non_none = [a for a in args if a is not type(None)] # noqa: E721
35
+ if len(non_none) == 1:
36
+ return _unwrap(non_none[0])
37
+ if origin in (list, tuple, set):
38
+ inner = args[0] if args else Any
39
+ base, _ = _unwrap(inner)
40
+ return base, True
41
+ return t, False
42
+
43
+
44
+ def _is_model(t: Any) -> bool:
45
+ try:
46
+ return issubclass(t, BaseModel)
47
+ except Exception:
48
+ return False
49
+
50
+
51
+ def _is_external_model(t: Any) -> bool:
52
+ try:
53
+ return issubclass(t, ExternalModel)
54
+ except Exception:
55
+ return False
56
+
57
+
58
+ def build_field_tree(root: Type[BaseModel]) -> FieldTreeType:
59
+ return _build_field_tree(root)
60
+
61
+
62
+ def _build_field_tree(root: Type[BaseModel]) -> FieldTreeType:
63
+ """
64
+ Introspect a Pydantic v2 model into a nested dict:
65
+ { field_name: dict(...) | ANY | None }
66
+ - dict(...) => we know nested fields (another BaseModel with declared fields)
67
+ - ANY => ExternalModel (extra=allow) or otherwise permissive; allow any nested
68
+ - None => leaf / scalar
69
+ """
70
+ tree = {}
71
+
72
+ # v2: model_fields holds FieldInfo by name
73
+ for name, fi in root.model_fields.items():
74
+ t, is_list = _unwrap(fi.annotation)
75
+ if _is_model(t):
76
+ if _is_external_model(t):
77
+ tree[name] = ANY
78
+ else:
79
+ # recurse into structured models
80
+ tree[name] = _build_field_tree(t)
81
+ else:
82
+ # Non-model (scalar or collection of scalars)
83
+ tree[name] = None
84
+ return FieldTreeType(tree)
85
+
86
+
87
+ # Cache once
88
+ _FIELD_TREE = build_field_tree(GooglePlace)
89
+
90
+
91
+ def _validate_path(path: str, tree: FieldTreeType) -> Optional[tuple[str, str, str]]:
92
+ """
93
+ Validate a dotted path against the field tree.
94
+ Returns None if OK, else a human-friendly warning string.
95
+ """
96
+ parts = path.split(".")
97
+ node: Any = tree
98
+ for i, seg in enumerate(parts):
99
+ if not isinstance(node, dict):
100
+ warn = f"'{'.'.join(parts[:i])}' is not an object, cannot select '{seg}'"
101
+ return warn, path, seg
102
+ if seg not in node:
103
+ warn = f"Unknown field '{seg}' at '{'.'.join(parts[:i]) or '<root>'}'"
104
+ return warn, path, seg
105
+ node = node[seg]
106
+ if node is ANY:
107
+ # Wildcard subtree: allow anything under it
108
+ return None
109
+ return None # fully validated
110
+
111
+
112
+ def compile_field_mask(
113
+ fields: FieldMaskInput,
114
+ *,
115
+ prefix: str = "",
116
+ on_warning: Optional[Callable[[str, str, str], None]] = None,
117
+ allow_star: bool = True,
118
+ extra_allowed_fields: Optional[list[str]] = None,
119
+ field_tree: Optional[FieldTreeType] = _FIELD_TREE,
120
+ ) -> list[str]:
121
+ """
122
+ Normalize + validate a field mask against a Pydantic model.
123
+ (mirroring the Google Places API `Place` model).
124
+ - Accepts: iterable of strings or FieldPath objects, or a single string (comma-separated ok).
125
+ - Dedupes, preserves order.
126
+ - Prefixes each with 'prefix' unless it already starts with that.
127
+ - Emits warnings via on_warning(...) but never raises on unknown nested under ExternalModel.
128
+
129
+ Args:
130
+ fields: FieldMaskInput
131
+ prefix: Prefix to add to each field
132
+ on_warning: Optional callable on_warning(warn, path, seg) -> None. If not provided, uses the default warning handler.
133
+ allow_star: Whether to allow the star wildcard
134
+ extra_allowed_fields: Optional list of fields to allow even if they are not in the field tree.
135
+ field_tree: Optional field tree to use instead of the default one.
136
+ Usage:
137
+ >>> compile_field_mask(F.display_name)
138
+ ["display_name"]
139
+ >>> compile_field_mask(["reviews.text", "reviews.author_name"])
140
+ ["reviews.text"]
141
+ >>> compile_field_mask("photos.author_attributions,photos.author_attributions.text")
142
+ ["photos.author_attributions", "photos.author_attributions.text"]
143
+ """
144
+ if extra_allowed_fields is None:
145
+ extra_allowed_fields = []
146
+ # Flatten to a list of strings
147
+ if isinstance(fields, str):
148
+ items: Iterable[str] = sum((s.split(",") for s in fields.split()), [])
149
+ items = [s.strip() for s in items if s.strip()]
150
+ elif isinstance(fields, FieldPath):
151
+ items = [str(fields)]
152
+ else:
153
+ items = [str(x).strip() for x in fields if str(x).strip()]
154
+
155
+ if not items:
156
+ return ["*"] if allow_star else []
157
+
158
+ seen = set()
159
+ ordered: list[str] = []
160
+ for raw in items:
161
+ if raw == "*":
162
+ # Keep '*' as-is, but usually only as the sole field
163
+ if allow_star and "*" not in seen:
164
+ seen.add("*")
165
+ ordered = ["*"] # star trumps everything else
166
+ continue
167
+
168
+ path = raw # snake_case already; don't transform
169
+ not_valid = None
170
+ if path not in extra_allowed_fields:
171
+ not_valid = _validate_path(path, field_tree or _FIELD_TREE)
172
+ if not_valid:
173
+ warn, path, seg = not_valid
174
+ if on_warning:
175
+ on_warning(warn, path, seg)
176
+ else:
177
+ warnings.warn(warn, UserWarning, stacklevel=2)
178
+
179
+ if prefix and not path.startswith(prefix):
180
+ path = f"{prefix}.{path}"
181
+
182
+ if path not in seen:
183
+ seen.add(path)
184
+ ordered.append(path)
185
+
186
+ # If '*' was included alongside others, Google prefers just '*'
187
+ if "*" in seen:
188
+ return ["*"]
189
+
190
+ return ordered
191
+
192
+
193
+ # ---- Tiny ergonomic builder for dotted paths ----
194
+ class FieldPath:
195
+ __slots__ = ("path",)
196
+
197
+ def __init__(self, path: str):
198
+ self.path = path
199
+
200
+ def __getattr__(self, name: str) -> FieldPath:
201
+ return FieldPath(f"{self.path}.{name}")
202
+
203
+ def __str__(self) -> str:
204
+ return self.path
205
+
206
+
207
+ class _Root:
208
+ def __getattr__(self, name: str) -> FieldPath:
209
+ return FieldPath(name)
210
+
211
+
212
+ F = _Root() # Usage: F.display_name, F.reviews.text, F.photos.author_attributions
213
+
214
+
215
+ if __name__ == "__main__":
216
+ print(
217
+ compile_field_mask(
218
+ "photos.author_attributions,photos.author_attributions.textt",
219
+ prefix="places",
220
+ )
221
+ )