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,334 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import IntEnum
4
+ from typing import Optional
5
+
6
+ from pydantic import (
7
+ AnyUrl,
8
+ BaseModel,
9
+ ConfigDict,
10
+ Field,
11
+ field_validator,
12
+ model_validator,
13
+ )
14
+ from typing_extensions import Self
15
+
16
+ from .common import (
17
+ HEX_COLOR,
18
+ PLACE_ID,
19
+ PLACE_RESOURCE,
20
+ PLACE_TYPE,
21
+ AccessibilityOptions,
22
+ AddressDescriptor,
23
+ Attribution,
24
+ ContentBlock,
25
+ EVChargeOptions,
26
+ FuelOptions,
27
+ LatLng,
28
+ LocalizedText,
29
+ OpeningHours,
30
+ ParkingOptions,
31
+ PaymentOptions,
32
+ Photo,
33
+ PlusCode,
34
+ PostalAddress,
35
+ PriceRange,
36
+ Review,
37
+ SubDestination,
38
+ TimeZone,
39
+ Viewport,
40
+ )
41
+
42
+
43
+ class PriceLevel(IntEnum):
44
+ PRICE_LEVEL_UNSPECIFIED = 0
45
+ PRICE_LEVEL_FREE = 1
46
+ PRICE_LEVEL_INEXPENSIVE = 2
47
+ PRICE_LEVEL_MODERATE = 3
48
+ PRICE_LEVEL_EXPENSIVE = 4
49
+ PRICE_LEVEL_VERY_EXPENSIVE = 5
50
+
51
+
52
+ class BusinessStatus(IntEnum):
53
+ BUSINESS_STATUS_UNSPECIFIED = 0
54
+ OPERATIONAL = 1
55
+ CLOSED_TEMPORARILY = 2
56
+ CLOSED_PERMANENTLY = 3
57
+
58
+
59
+ class AddressComponent(BaseModel):
60
+ model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
61
+
62
+ long_text: str
63
+ short_text: Optional[str] = None
64
+ types: list[str] = Field(
65
+ default_factory=list
66
+ ) # limited to https://developers.google.com/maps/documentation/places/web-service/place-types
67
+ language_code: Optional[str] = None
68
+
69
+ @field_validator("types")
70
+ @classmethod
71
+ def _types(cls, v: list[str]) -> list[str]:
72
+ out, seen = [], set()
73
+ for raw in v:
74
+ t = raw.strip()
75
+ if not t:
76
+ raise ValueError("types cannot contain empty strings")
77
+ if not PLACE_TYPE.fullmatch(t):
78
+ raise ValueError(f"invalid place type '{t}'")
79
+ if t not in seen:
80
+ out.append(t)
81
+ seen.add(t)
82
+ return out
83
+
84
+
85
+ class GenerativeSummary(BaseModel):
86
+ model_config = ConfigDict(extra="forbid")
87
+
88
+ overview: Optional[LocalizedText] = None
89
+ overview_flag_content_uri: Optional[AnyUrl] = None
90
+ disclosure_text: Optional[LocalizedText] = None
91
+
92
+
93
+ class ReviewSummary(BaseModel):
94
+ model_config = ConfigDict(extra="forbid")
95
+
96
+ text: Optional[LocalizedText] = None
97
+ flag_content_uri: Optional[AnyUrl] = None
98
+ disclosure_text: Optional[LocalizedText] = None
99
+
100
+
101
+ class EvChargeAmenitySummary(BaseModel):
102
+ model_config = ConfigDict(extra="forbid")
103
+
104
+ overview: ContentBlock
105
+ coffee: Optional[ContentBlock] = None
106
+ restaurant: Optional[ContentBlock] = None
107
+ store: Optional[ContentBlock] = None
108
+ flag_content_uri: Optional[AnyUrl] = None
109
+ disclosure_text: Optional[LocalizedText] = None
110
+
111
+
112
+ class NeighborhoodSummary(BaseModel):
113
+ model_config = ConfigDict(extra="forbid")
114
+
115
+ overview: Optional[ContentBlock] = None
116
+ description: Optional[ContentBlock] = None
117
+ flag_content_uri: Optional[AnyUrl] = None
118
+ disclosure_text: Optional[LocalizedText] = None
119
+
120
+
121
+ class ContainingPlace(BaseModel):
122
+ model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
123
+
124
+ name: str
125
+ id: str
126
+
127
+ @field_validator("name")
128
+ @classmethod
129
+ def _name(cls, v: str) -> str:
130
+ if not PLACE_RESOURCE.fullmatch(v):
131
+ raise ValueError("name must be 'places/{place_id}'")
132
+ return v
133
+
134
+ @field_validator("id")
135
+ @classmethod
136
+ def _id(cls, v: str) -> str:
137
+ if not PLACE_ID.fullmatch(v):
138
+ raise ValueError("id must look like a Place ID")
139
+ return v
140
+
141
+ @model_validator(mode="after")
142
+ def _match(self) -> Self:
143
+ if self.name.split("/", 1)[1] != self.id:
144
+ raise ValueError("id must match the trailing component of name")
145
+ return self
146
+
147
+
148
+ class GooglePlace(BaseModel):
149
+ model_config = ConfigDict(extra="allow", str_strip_whitespace=True)
150
+
151
+ # Identity
152
+ name: Optional[str] = Field(default=None, description="places/{place_id}")
153
+ id: Optional[str] = Field(default=None, description="Place ID")
154
+
155
+ # Labels & typing
156
+ display_name: Optional[LocalizedText] = None
157
+ types: list[str] = Field(default_factory=list)
158
+ primary_type: Optional[str] = None
159
+ primary_type_display_name: Optional[LocalizedText] = None
160
+
161
+ # Phones & addresses
162
+ national_phone_number: Optional[str] = None
163
+ international_phone_number: Optional[str] = None
164
+ formatted_address: Optional[str] = None
165
+ address_descriptor: Optional[AddressDescriptor] = None
166
+ short_formatted_address: Optional[str] = None
167
+ postal_address: Optional[PostalAddress] = None
168
+ address_components: list[AddressComponent] = Field(default_factory=list)
169
+ plus_code: Optional[PlusCode] = None
170
+
171
+ # Location & map
172
+ location: Optional[LatLng] = None
173
+ viewport: Optional[Viewport] = None
174
+
175
+ # Scores, links, media
176
+ rating: Optional[float] = None
177
+ google_maps_uri: Optional[AnyUrl] = None
178
+ website_uri: Optional[AnyUrl] = None
179
+ reviews: list[Review] = Field(default_factory=list)
180
+ photos: list[Photo] = Field(default_factory=list)
181
+
182
+ # Hours
183
+ regular_opening_hours: Optional[OpeningHours] = None
184
+ current_opening_hours: Optional[OpeningHours] = None
185
+ current_secondary_opening_hours: list[OpeningHours] = Field(default_factory=list)
186
+ regular_secondary_opening_hours: list[OpeningHours] = Field(default_factory=list)
187
+ utc_offset_minutes: Optional[int] = None
188
+ time_zone: Optional[TimeZone] = None
189
+
190
+ # Misc attributes
191
+ adr_format_address: Optional[str] = None
192
+ business_status: Optional[BusinessStatus] = None
193
+ price_level: Optional[PriceLevel] = None
194
+ attributions: list[Attribution] = Field(default_factory=list)
195
+ user_rating_count: Optional[int] = None
196
+ icon_mask_base_uri: Optional[AnyUrl] = None
197
+ icon_background_color: Optional[str] = None
198
+
199
+ # Food/venue features (optionals in proto)
200
+ takeout: Optional[bool] = None
201
+ delivery: Optional[bool] = None
202
+ dine_in: Optional[bool] = None
203
+ curbside_pickup: Optional[bool] = None
204
+ reservable: Optional[bool] = None
205
+ editorial_summary: Optional[LocalizedText] = None
206
+ serves_breakfast: Optional[bool] = None
207
+ serves_lunch: Optional[bool] = None
208
+ serves_dinner: Optional[bool] = None
209
+ serves_beer: Optional[bool] = None
210
+ serves_wine: Optional[bool] = None
211
+ serves_brunch: Optional[bool] = None
212
+ serves_vegetarian_food: Optional[bool] = None
213
+ outdoor_seating: Optional[bool] = None
214
+ live_music: Optional[bool] = None
215
+ menu_for_children: Optional[bool] = None
216
+ serves_cocktails: Optional[bool] = None
217
+ serves_dessert: Optional[bool] = None
218
+ serves_coffee: Optional[bool] = None
219
+ good_for_children: Optional[bool] = None
220
+ allows_dogs: Optional[bool] = None
221
+ restroom: Optional[bool] = None
222
+ good_for_groups: Optional[bool] = None
223
+ good_for_watching_sports: Optional[bool] = None
224
+
225
+ # Options & related places
226
+ payment_options: Optional[PaymentOptions] = None
227
+ parking_options: Optional[ParkingOptions] = None
228
+ sub_destinations: list[SubDestination] = Field(default_factory=list)
229
+ accessibility_options: Optional[AccessibilityOptions] = None
230
+
231
+ # Fuel/EV & AI summaries
232
+ fuel_options: Optional[FuelOptions] = None
233
+ ev_charge_options: Optional[EVChargeOptions] = None
234
+ generative_summary: Optional[GenerativeSummary] = None
235
+ review_summary: Optional[ReviewSummary] = None
236
+ ev_charge_amenity_summary: Optional[EvChargeAmenitySummary] = None
237
+ neighborhood_summary: Optional[NeighborhoodSummary] = None
238
+
239
+ # Context
240
+ containing_places: list[ContainingPlace] = Field(default_factory=list)
241
+ pure_service_area_business: Optional[bool] = None
242
+ price_range: Optional[PriceRange] = None
243
+
244
+ # ---------- Validators ----------
245
+ @field_validator("name")
246
+ @classmethod
247
+ def _name(cls, v: Optional[str]) -> Optional[str]:
248
+ if v is None:
249
+ return v
250
+ if not PLACE_RESOURCE.fullmatch(v):
251
+ raise ValueError("name must be 'places/{place_id}'")
252
+ return v
253
+
254
+ @field_validator("id")
255
+ @classmethod
256
+ def _id(cls, v: Optional[str]) -> Optional[str]:
257
+ if v is None:
258
+ return v
259
+ if not PLACE_ID.fullmatch(v):
260
+ raise ValueError(
261
+ "id must be a plausible Place ID (base64url-like, >=10 chars)"
262
+ )
263
+ return v
264
+
265
+ @model_validator(mode="after")
266
+ def _id_consistency(self) -> Self:
267
+ if self.name and self.id and self.name.split("/", 1)[1] != self.id:
268
+ raise ValueError("id must match trailing component of name")
269
+ return self
270
+
271
+ @field_validator("types")
272
+ @classmethod
273
+ def _types(cls, v: list[str]) -> list[str]:
274
+ out, seen = [], set()
275
+ for raw in v:
276
+ t = raw.strip()
277
+ if not t:
278
+ raise ValueError("types cannot contain empty strings")
279
+ if not PLACE_TYPE.fullmatch(t):
280
+ raise ValueError(f"invalid place type '{t}'")
281
+ if t not in seen:
282
+ out.append(t)
283
+ seen.add(t)
284
+ return out
285
+
286
+ @model_validator(mode="after")
287
+ def _primary_type_is_in_types(self) -> Self:
288
+ if self.primary_type:
289
+ if not PLACE_TYPE.fullmatch(self.primary_type):
290
+ raise ValueError("primary_type must match place-type token pattern")
291
+ if self.types and self.primary_type not in self.types:
292
+ raise ValueError("primary_type must be included in types")
293
+ return self
294
+
295
+ @field_validator("rating")
296
+ @classmethod
297
+ def _rating(cls, v: Optional[float]) -> Optional[float]:
298
+ if v is None:
299
+ return v
300
+ if not (1.0 <= v <= 5.0):
301
+ raise ValueError("rating must be in [1.0, 5.0]")
302
+ return v
303
+
304
+ @field_validator("user_rating_count")
305
+ @classmethod
306
+ def _urc(cls, v: Optional[int]) -> Optional[int]:
307
+ if v is None:
308
+ return v
309
+ if v < 0:
310
+ raise ValueError("user_rating_count must be >= 0")
311
+ return v
312
+
313
+ @field_validator("icon_background_color")
314
+ @classmethod
315
+ def _hex(cls, v: Optional[str]) -> Optional[str]:
316
+ if v is None:
317
+ return v
318
+ if not HEX_COLOR.fullmatch(v):
319
+ raise ValueError("icon_background_color must be a hex color like #909CE1")
320
+ return v
321
+
322
+ @field_validator("reviews")
323
+ @classmethod
324
+ def _max_reviews(cls, v: list[Review]) -> list[Review]:
325
+ if len(v) > 5:
326
+ raise ValueError("reviews can contain at most 5 items")
327
+ return v
328
+
329
+ @field_validator("photos")
330
+ @classmethod
331
+ def _max_photos(cls, v: list[Photo]) -> list[Photo]:
332
+ if len(v) > 10:
333
+ raise ValueError("photos can contain at most 10 items")
334
+ return v
@@ -0,0 +1,11 @@
1
+ """Service layer for Bookalimo API operations."""
2
+
3
+ from .pricing import AsyncPricingService, PricingService
4
+ from .reservations import AsyncReservationsService, ReservationsService
5
+
6
+ __all__ = [
7
+ "ReservationsService",
8
+ "AsyncReservationsService",
9
+ "PricingService",
10
+ "AsyncPricingService",
11
+ ]
@@ -0,0 +1,191 @@
1
+ """Pricing service for getting quotes and updating trip details."""
2
+
3
+ from typing import Any
4
+
5
+ from ..schemas.booking import (
6
+ DetailsRequest,
7
+ DetailsResponse,
8
+ Location,
9
+ PriceRequest,
10
+ PriceResponse,
11
+ RateType,
12
+ )
13
+ from ..transport.base import AsyncBaseTransport, BaseTransport
14
+
15
+
16
+ class AsyncPricingService:
17
+ """Async pricing service."""
18
+
19
+ def __init__(self, transport: AsyncBaseTransport):
20
+ self._transport = transport
21
+
22
+ async def quote(
23
+ self,
24
+ rate_type: RateType,
25
+ date_time: str,
26
+ pickup: Location,
27
+ dropoff: Location,
28
+ passengers: int,
29
+ luggage: int,
30
+ **opts: Any,
31
+ ) -> PriceResponse:
32
+ """
33
+ Get pricing for a trip.
34
+
35
+ Args:
36
+ rate_type: Rate type (P2P, HOURLY, etc.)
37
+ date_time: Date and time in 'MM/dd/yyyy hh:mm tt' format
38
+ pickup: Pickup location
39
+ dropoff: Dropoff location
40
+ passengers: Number of passengers
41
+ luggage: Number of luggage pieces
42
+ **opts: Optional fields like hours, stops, account, car_class_code,
43
+ passenger, rewards, pets, car_seats, boosters, infants,
44
+ customer_comment
45
+
46
+ Returns:
47
+ PriceResponse with pricing information and session token
48
+ """
49
+ # Build request with optional fields
50
+ request_data: dict[str, Any] = {
51
+ "rate_type": rate_type,
52
+ "date_time": date_time,
53
+ "pickup": pickup,
54
+ "dropoff": dropoff,
55
+ "passengers": passengers,
56
+ "luggage": luggage,
57
+ }
58
+
59
+ # Add optional fields if provided
60
+ optional_fields = [
61
+ "hours",
62
+ "stops",
63
+ "account",
64
+ "passenger",
65
+ "rewards",
66
+ "car_class_code",
67
+ "pets",
68
+ "car_seats",
69
+ "boosters",
70
+ "infants",
71
+ "customer_comment",
72
+ ]
73
+
74
+ for field in optional_fields:
75
+ if field in opts and opts[field] is not None:
76
+ request_data[field] = opts[field]
77
+
78
+ request = PriceRequest(**request_data)
79
+ return await self._transport.post("/booking/price/", request, PriceResponse)
80
+
81
+ async def update_details(self, token: str, **details: Any) -> DetailsResponse:
82
+ """
83
+ Update reservation details and get updated pricing.
84
+
85
+ Args:
86
+ token: Session token from quote()
87
+ **details: Fields to update (car_class_code, pickup, dropoff,
88
+ stops, account, passenger, rewards, pets, car_seats,
89
+ boosters, infants, customer_comment, ta_fee)
90
+
91
+ Returns:
92
+ DetailsResponse with updated pricing
93
+ """
94
+ request_data: dict[str, Any] = {"token": token}
95
+
96
+ # Add provided details
97
+ for key, value in details.items():
98
+ if value is not None:
99
+ request_data[key] = value
100
+
101
+ request = DetailsRequest(**request_data)
102
+ return await self._transport.post("/booking/details/", request, DetailsResponse)
103
+
104
+
105
+ class PricingService:
106
+ """Sync pricing service."""
107
+
108
+ def __init__(self, transport: BaseTransport):
109
+ self._transport = transport
110
+
111
+ def quote(
112
+ self,
113
+ rate_type: RateType,
114
+ date_time: str,
115
+ pickup: Location,
116
+ dropoff: Location,
117
+ passengers: int,
118
+ luggage: int,
119
+ **opts: Any,
120
+ ) -> PriceResponse:
121
+ """
122
+ Get pricing for a trip.
123
+
124
+ Args:
125
+ rate_type: Rate type (P2P, HOURLY, etc.)
126
+ date_time: Date and time in 'MM/dd/yyyy hh:mm tt' format
127
+ pickup: Pickup location
128
+ dropoff: Location
129
+ passengers: Number of passengers
130
+ luggage: Number of luggage pieces
131
+ **opts: Optional fields like hours, stops, account, car_class_code,
132
+ passenger, rewards, pets, car_seats, boosters, infants,
133
+ customer_comment
134
+
135
+ Returns:
136
+ PriceResponse with pricing information and session token
137
+ """
138
+ # Build request with optional fields
139
+ request_data: dict[str, Any] = {
140
+ "rate_type": rate_type,
141
+ "date_time": date_time,
142
+ "pickup": pickup,
143
+ "dropoff": dropoff,
144
+ "passengers": passengers,
145
+ "luggage": luggage,
146
+ }
147
+
148
+ # Add optional fields if provided
149
+ optional_fields = [
150
+ "hours",
151
+ "stops",
152
+ "account",
153
+ "passenger",
154
+ "rewards",
155
+ "car_class_code",
156
+ "pets",
157
+ "car_seats",
158
+ "boosters",
159
+ "infants",
160
+ "customer_comment",
161
+ ]
162
+
163
+ for field in optional_fields:
164
+ if field in opts and opts[field] is not None:
165
+ request_data[field] = opts[field]
166
+
167
+ request = PriceRequest(**request_data)
168
+ return self._transport.post("/booking/price/", request, PriceResponse)
169
+
170
+ def update_details(self, token: str, **details: Any) -> DetailsResponse:
171
+ """
172
+ Update reservation details and get updated pricing.
173
+
174
+ Args:
175
+ token: Session token from quote()
176
+ **details: Fields to update (car_class_code, pickup, dropoff,
177
+ stops, account, passenger, rewards, pets, car_seats,
178
+ boosters, infants, customer_comment, ta_fee)
179
+
180
+ Returns:
181
+ DetailsResponse with updated pricing
182
+ """
183
+ request_data: dict[str, Any] = {"token": token}
184
+
185
+ # Add provided details
186
+ for key, value in details.items():
187
+ if value is not None:
188
+ request_data[key] = value
189
+
190
+ request = DetailsRequest(**request_data)
191
+ return self._transport.post("/booking/details/", request, DetailsResponse)