bookalimo 0.1.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.
bookalimo/models.py ADDED
@@ -0,0 +1,532 @@
1
+ """
2
+ Pydantic models for Book-A-Limo API data structures.
3
+ """
4
+
5
+ import hashlib
6
+ import warnings
7
+ from enum import Enum
8
+ from typing import Any, Optional
9
+
10
+ import airportsdata
11
+ import pycountry
12
+ import us
13
+ from pydantic import BaseModel, Field, model_validator
14
+ from typing_extensions import Self
15
+
16
+ icao_data = airportsdata.load("ICAO")
17
+ iata_data = airportsdata.load("IATA")
18
+
19
+
20
+ class RateType(Enum):
21
+ """Rate types for reservations."""
22
+
23
+ P2P = 0 # Point-to-Point (best guess from context)
24
+ HOURLY = 1 # Hourly (best guess from context)
25
+ DAILY = 2 # Daily
26
+ TOUR = 3 # Tour
27
+ ROUND_TRIP = 4 # Round Trip
28
+ RT_HALF = 5 # RT Half
29
+
30
+
31
+ class LocationType(Enum):
32
+ """Location types."""
33
+
34
+ ADDRESS = 0
35
+ AIRPORT = 1
36
+ TRAIN_STATION = 2
37
+ CRUISE = 3
38
+
39
+
40
+ class MeetGreetType(Enum):
41
+ """Meet & Greet options."""
42
+
43
+ OTHER = 0
44
+ FBO = 1
45
+ BAGGAGE_CLAIM = 2
46
+ CURB_SIDE = 3
47
+ GATE = 4
48
+ INTERNATIONAL = 5
49
+ GREETER_SERVICE = 6
50
+
51
+
52
+ class RewardType(Enum):
53
+ """Reward account types."""
54
+
55
+ UNITED_MILEAGEPLUS = 0
56
+
57
+
58
+ class ReservationStatus(Enum):
59
+ """Reservation status."""
60
+
61
+ ACTIVE = None
62
+ NO_SHOW = 0
63
+ CANCELED = 1
64
+ LATE_CANCELED = 2
65
+
66
+
67
+ class CardHolderType(Enum):
68
+ """
69
+ Credit card holder types (API Documentation Unclear).
70
+ TODO: Update when API documentation is clarified by Ivan.
71
+ Best guess based on typical credit card processing:
72
+ """
73
+
74
+ PERSONAL = 0 # Personal/Individual account (best guess)
75
+ BUSINESS = 1 # Business/Corporate account (best guess)
76
+ # Note: Add UNKNOWN = 3 if you see this value in responses
77
+ UNKNOWN = 3 # From example in API doc
78
+
79
+
80
+ class Credentials(BaseModel):
81
+ """User credentials for API authentication."""
82
+
83
+ id: str
84
+ is_customer: bool = False
85
+ password_hash: str
86
+
87
+ @classmethod
88
+ def create_hash(cls, password: str, user_id: str) -> str:
89
+ """Create password hash as required by API: Sha256(Sha256(Password) + LowerCase(Id))"""
90
+ inner_hash = hashlib.sha256(password.encode()).hexdigest()
91
+ full_string = inner_hash + user_id.lower()
92
+ return hashlib.sha256(full_string.encode()).hexdigest()
93
+
94
+
95
+ class City(BaseModel):
96
+ """City information."""
97
+
98
+ city_name: str
99
+ country_code: str = Field(..., description="ISO 3166-1 alpha-2 country code")
100
+ state_code: Optional[str] = Field(None, description="US state code")
101
+ state_name: Optional[str] = Field(None, description="US state name")
102
+
103
+ @model_validator(mode="after")
104
+ def validate_country_code(self) -> Self:
105
+ """Validate that country_code is a valid ISO 3166-1 alpha-2 country code."""
106
+ if not pycountry.countries.get(alpha_2=self.country_code):
107
+ raise ValueError(f"Invalid country code: {self.country_code}")
108
+
109
+ return self
110
+
111
+ @model_validator(mode="after")
112
+ def validate_us(self) -> Self:
113
+ """Validate that state_code is a valid US state code and state_name is a valid US state name."""
114
+ code_match = us.states.lookup(str(self.state_code))
115
+ name_match = us.states.lookup(str(self.state_name))
116
+ if not code_match and not name_match:
117
+ raise ValueError(
118
+ f"Invalid state code or name: {self.state_code} or {self.state_name}"
119
+ )
120
+ if code_match and name_match and code_match != name_match:
121
+ raise ValueError(
122
+ f"State code and name do not match: {self.state_code} and {self.state_name}"
123
+ )
124
+ match = code_match or name_match
125
+ if match:
126
+ self.state_code = match.abbr
127
+ self.state_name = match.name
128
+ return self
129
+ raise ValueError(
130
+ f"Invalid state code or name: {self.state_code} or {self.state_name}"
131
+ )
132
+
133
+
134
+ class Address(BaseModel):
135
+ """
136
+ Address information.
137
+
138
+ Note: API documentation inconsistency - mentions 'googlePlaceId' in text
139
+ but examples use 'googleGeocode'. Following examples and using google_geocode.
140
+ TODO: Confirm with API author if this should be googlePlaceId instead.
141
+ """
142
+
143
+ google_geocode: Optional[dict[str, Any]] = Field(
144
+ None, description="Raw Google Geocoding API response (recommended)"
145
+ )
146
+ city: Optional[City] = Field(
147
+ None, description="Use only if google_geocode not available"
148
+ )
149
+ district: Optional[str] = Field(None, description="e.g., Manhattan")
150
+ neighbourhood: Optional[str] = Field(None, description="e.g., Lower Manhattan")
151
+ place_name: Optional[str] = Field(None, description="e.g., Empire State Building")
152
+ street_name: Optional[str] = Field(None, description="e.g., East 34th St")
153
+ building: Optional[str] = Field(None, description="e.g., 53")
154
+ suite: Optional[str] = Field(None, description="e.g., 5P")
155
+ zip: Optional[str] = Field(None, description="e.g., 10016")
156
+
157
+ @model_validator(mode="after")
158
+ def validate_address(self) -> Self:
159
+ """Validate that either place_name or street_name is provided."""
160
+ if not self.place_name and not self.street_name:
161
+ raise ValueError("Either place_name or street_name must be provided")
162
+
163
+ return self
164
+
165
+ @model_validator(mode="after")
166
+ def validate_city_or_google_geocode(self) -> Self:
167
+ """Validate that exactly one of city or google_geocode is provided, with preference for google_geocode."""
168
+ if not self.city and not self.google_geocode:
169
+ raise ValueError("Either city or google_geocode must be provided")
170
+
171
+ if self.city and self.google_geocode:
172
+ raise ValueError("Only one of city or google_geocode must be provided")
173
+
174
+ if self.city:
175
+ warnings.warn(
176
+ "Not recommended. Use only if you can't provide googleGeocode.",
177
+ stacklevel=3,
178
+ )
179
+
180
+ return self
181
+
182
+
183
+ class Airport(BaseModel):
184
+ """Airport information."""
185
+
186
+ iata_code: str = Field(..., description="3-letter IATA code, e.g., JFK")
187
+ country_code: Optional[str] = Field(None, description="ISO 3166-1 alpha-2")
188
+ state_code: Optional[str] = Field(None, description="US state code, e.g., NY")
189
+ airline_iata_code: Optional[str] = Field(
190
+ None, description="2-letter IATA airline code"
191
+ )
192
+ airline_icao_code: Optional[str] = Field(
193
+ None, description="3-letter ICAO airline code"
194
+ )
195
+ flight_number: Optional[str] = Field(None, description="e.g., UA1234")
196
+ terminal: Optional[str] = Field(None, description="e.g., 7")
197
+ arriving_from_city: Optional[City] = None
198
+ meet_greet: Optional[int] = Field(
199
+ None,
200
+ description="Meet & greet option ID. Leave empty on price request to see options.",
201
+ )
202
+
203
+ @model_validator(mode="after")
204
+ def validate_country_code(self) -> Self:
205
+ """Validate that country_code is a valid ISO 3166-1 alpha-2 country code."""
206
+ if not pycountry.countries.get(alpha_2=self.country_code):
207
+ raise ValueError(f"Invalid country code: {self.country_code}")
208
+
209
+ return self
210
+
211
+ @model_validator(mode="after")
212
+ def validate_state_code(self) -> Self:
213
+ """Validate that state_code is a valid US state code."""
214
+ if self.state_code and not us.states.lookup(str(self.state_code)):
215
+ raise ValueError(f"Invalid state code: {self.state_code}")
216
+
217
+ return self
218
+
219
+ @model_validator(mode="after")
220
+ def validate_airport(self) -> Self:
221
+ """Validate that iata_code or airline_icao_code is a valid IATA or ICAO code."""
222
+ if self.iata_code and self.iata_code not in iata_data:
223
+ raise ValueError(f"Invalid IATA code: {self.iata_code}")
224
+ if self.airline_icao_code and self.airline_icao_code not in icao_data:
225
+ raise ValueError(f"Invalid ICAO code: {self.airline_icao_code}")
226
+
227
+ return self
228
+
229
+
230
+ class Location(BaseModel):
231
+ """Location (address or airport)."""
232
+
233
+ type: LocationType
234
+ address: Optional[Address] = None
235
+ airport: Optional[Airport] = None
236
+
237
+ @model_validator(mode="after")
238
+ def validate_location(self) -> Self:
239
+ """Validate that the correct location type is provided."""
240
+ if self.type == LocationType.ADDRESS and not self.address:
241
+ raise ValueError("Address is required when type is ADDRESS")
242
+ if self.type == LocationType.AIRPORT and not self.airport:
243
+ raise ValueError("Airport is required when type is AIRPORT")
244
+
245
+ return self
246
+
247
+
248
+ class Stop(BaseModel):
249
+ """Stop information."""
250
+
251
+ description: str = Field(..., description="Address, place name, or comment")
252
+ is_en_route: bool = Field(..., description="True if stop is en-route")
253
+
254
+
255
+ class Account(BaseModel):
256
+ """Travel agency or corporate account info."""
257
+
258
+ id: str = Field(..., description="TA or corporate account number")
259
+ department: Optional[str] = None
260
+ booker_first_name: Optional[str] = None
261
+ booker_last_name: Optional[str] = None
262
+ booker_email: Optional[str] = None
263
+ booker_phone: Optional[str] = Field(None, description="E164 format")
264
+
265
+
266
+ class Passenger(BaseModel):
267
+ """Passenger information."""
268
+
269
+ first_name: str
270
+ last_name: str
271
+ email: Optional[str] = None
272
+ phone: str = Field(..., description="E164 format recommended")
273
+
274
+
275
+ class Reward(BaseModel):
276
+ """Reward account information."""
277
+
278
+ type: RewardType
279
+ value: str = Field(..., description="Reward account number")
280
+
281
+
282
+ class CreditCard(BaseModel):
283
+ """Credit card information."""
284
+
285
+ number: str
286
+ expiration: str = Field(..., description="MM/YY format")
287
+ cvv: str
288
+ card_holder: str
289
+ zip: Optional[str] = None
290
+ holder_type: Optional[CardHolderType] = Field(
291
+ None,
292
+ description="Card holder type - API documentation unclear, using best guess",
293
+ )
294
+
295
+
296
+ class BreakdownItem(BaseModel):
297
+ """Price breakdown item."""
298
+
299
+ name: str
300
+ value: float
301
+ is_grand: bool = Field(
302
+ ..., description="True if item should be highlighted (totals)"
303
+ )
304
+
305
+
306
+ class MeetGreetAdditional(BaseModel):
307
+ """Additional meet & greet charges."""
308
+
309
+ name: str
310
+ price: float
311
+
312
+
313
+ class MeetGreet(BaseModel):
314
+ """Meet & greet option."""
315
+
316
+ id: int
317
+ name: str
318
+ base_price: float
319
+ instructions: str
320
+ additional: list[MeetGreetAdditional]
321
+ total_price: float
322
+ fees: float
323
+ reservation_price: float
324
+
325
+
326
+ class Price(BaseModel):
327
+ """Car class pricing information."""
328
+
329
+ car_class: str
330
+ car_description: str
331
+ max_passengers: int
332
+ max_luggage: int
333
+ price: float = Field(..., description="Price WITHOUT Meet&Greet")
334
+ price_default: float = Field(..., description="Price WITH default Meet&Greet")
335
+ image_128: str = Field(alias="image128")
336
+ image_256: str = Field(alias="image256")
337
+ image_512: str = Field(alias="image512")
338
+ default_meet_greet: Optional[int] = None
339
+ meet_greets: list[MeetGreet] = Field(default_factory=list)
340
+
341
+
342
+ class Reservation(BaseModel):
343
+ """Basic reservation information."""
344
+
345
+ confirmation_number: str
346
+ is_archive: bool
347
+ local_date_time: str
348
+ eastern_date_time: Optional[str] = None
349
+ rate_type: RateType
350
+ passenger_name: Optional[str] = None
351
+ pickup_type: LocationType
352
+ pickup: str
353
+ dropoff_type: LocationType
354
+ dropoff: str
355
+ car_class: str
356
+ status: Optional[ReservationStatus] = None
357
+
358
+
359
+ class EditableReservationRequest(BaseModel):
360
+ """
361
+ Editable reservation for modifications.
362
+
363
+ Note: API documentation inconsistency - credit_card marked as required in model
364
+ but omitted in edit examples. Making it optional as edit requests may not need it.
365
+ TODO: Clarify with API author when credit_card is actually required.
366
+ """
367
+
368
+ confirmation: str
369
+ is_cancel_request: bool = False
370
+ rate_type: Optional[RateType] = None
371
+ pickup_date: Optional[str] = Field(None, description="MM/dd/yyyy format")
372
+ pickup_time: Optional[str] = Field(None, description="hh:mm tt format")
373
+ stops: Optional[list[Stop]] = None
374
+ credit_card: Optional[CreditCard] = Field(
375
+ None, description="Conditionally required - unclear from API docs when exactly"
376
+ )
377
+ passengers: Optional[int] = None
378
+ luggage: Optional[int] = None
379
+ pets: Optional[int] = None
380
+ car_seats: Optional[int] = None
381
+ boosters: Optional[int] = None
382
+ infants: Optional[int] = None
383
+ other: Optional[str] = Field(None, description="Other changes not listed")
384
+
385
+
386
+ class EditableReservationRequestAuthenticated(EditableReservationRequest):
387
+ """Request for editing reservation with authenticated credentials."""
388
+
389
+ credentials: Credentials
390
+
391
+
392
+ # Request/Response Models
393
+
394
+
395
+ class PriceRequest(BaseModel):
396
+ """Request for getting prices."""
397
+
398
+ rate_type: RateType
399
+ date_time: str = Field(..., description="MM/dd/yyyy hh:mm tt format")
400
+ pickup: Location
401
+ dropoff: Location
402
+ hours: Optional[int] = Field(None, description="For hourly rate_type only")
403
+ passengers: int
404
+ luggage: int
405
+ stops: Optional[list[Stop]] = None
406
+ account: Optional[Account] = Field(
407
+ None, description="TAs must provide for commission"
408
+ )
409
+ passenger: Optional[Passenger] = None
410
+ rewards: Optional[list[Reward]] = None
411
+ car_class_code: Optional[str] = Field(
412
+ None, description="e.g., 'SD' for specific car class"
413
+ )
414
+ pets: Optional[int] = None
415
+ car_seats: Optional[int] = None
416
+ boosters: Optional[int] = None
417
+ infants: Optional[int] = None
418
+ customer_comment: Optional[str] = None
419
+
420
+
421
+ class PriceRequestAuthenticated(PriceRequest):
422
+ """Request for getting prices with authenticated credentials."""
423
+
424
+ credentials: Credentials
425
+
426
+
427
+ class PriceResponse(BaseModel):
428
+ """Response from get prices."""
429
+
430
+ token: str
431
+ prices: list[Price]
432
+
433
+
434
+ class DetailsRequest(BaseModel):
435
+ """Request for setting reservation details."""
436
+
437
+ token: str
438
+ car_class_code: Optional[str] = None
439
+ pickup: Optional[Location] = None
440
+ dropoff: Optional[Location] = None
441
+ stops: Optional[list[Stop]] = None
442
+ account: Optional[Account] = None
443
+ passenger: Optional[Passenger] = None
444
+ rewards: Optional[list[Reward]] = None
445
+ pets: Optional[int] = None
446
+ car_seats: Optional[int] = None
447
+ boosters: Optional[int] = None
448
+ infants: Optional[int] = None
449
+ customer_comment: Optional[str] = None
450
+ ta_fee: Optional[float] = Field(
451
+ None, description="For Travel Agencies - additional fee in USD"
452
+ )
453
+
454
+
455
+ class DetailsResponse(BaseModel):
456
+ """Response from set details."""
457
+
458
+ price: float
459
+ breakdown: list[BreakdownItem]
460
+
461
+
462
+ class BookRequest(BaseModel):
463
+ """Request for booking reservation."""
464
+
465
+ token: str
466
+ promo: Optional[str] = None
467
+ method: Optional[str] = Field(None, description="'charge' for charge accounts")
468
+ credit_card: Optional[CreditCard] = None
469
+
470
+ @model_validator(mode="after")
471
+ def validate_book_request(self) -> Self:
472
+ """Validate that either method or credit_card is provided."""
473
+ if not self.method and not self.credit_card:
474
+ raise ValueError("Either method='charge' or credit_card must be provided")
475
+
476
+ return self
477
+
478
+
479
+ class BookResponse(BaseModel):
480
+ """Response from book reservation."""
481
+
482
+ reservation_id: str
483
+
484
+
485
+ class ListReservationsRequest(BaseModel):
486
+ """Request for listing reservations."""
487
+
488
+ credentials: Credentials
489
+ is_archive: bool = False
490
+
491
+
492
+ class ListReservationsResponse(BaseModel):
493
+ """Response from list reservations."""
494
+
495
+ success: bool
496
+ reservations: list[Reservation] = Field(default_factory=list)
497
+ error: Optional[str] = None
498
+
499
+
500
+ class GetReservationRequest(BaseModel):
501
+ """Request for getting reservation details."""
502
+
503
+ credentials: Credentials
504
+ confirmation: str
505
+
506
+
507
+ class GetReservationResponse(BaseModel):
508
+ """Response from get reservation."""
509
+
510
+ reservation: EditableReservationRequest
511
+ is_editable: bool
512
+ status: Optional[ReservationStatus] = None
513
+ is_cancellation_pending: bool
514
+ car_description: Optional[str] = None
515
+ cancellation_policy: Optional[str] = None
516
+ pickup_type: LocationType
517
+ pickup_description: str
518
+ dropoff_type: LocationType
519
+ dropoff_description: str
520
+ additional_services: Optional[str] = None
521
+ payment_method: Optional[str] = None
522
+ breakdown: list[BreakdownItem] = Field(default_factory=list)
523
+ passenger_name: Optional[str] = None
524
+ evoucher_url: Optional[str] = None
525
+ receipt_url: Optional[str] = None
526
+ pending_changes: list[list[str]] = Field(default_factory=list)
527
+
528
+
529
+ class EditReservationResponse(BaseModel):
530
+ """Response from edit reservation."""
531
+
532
+ success: bool
bookalimo/py.typed ADDED
@@ -0,0 +1 @@
1
+ # Marker file for PEP 561