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,231 @@
1
+ """
2
+ Common utilities and shared functionality for Google Places clients.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import re
8
+ from typing import Any, Optional
9
+
10
+ from ...logging import get_logger
11
+ from ...schemas.places import (
12
+ AddressComponent,
13
+ AddressDescriptor,
14
+ FieldMaskInput,
15
+ GooglePlace,
16
+ Place,
17
+ PlaceType,
18
+ compile_field_mask,
19
+ )
20
+ from ...schemas.places import (
21
+ google as models,
22
+ )
23
+
24
+ logger = get_logger("places")
25
+
26
+ # Default field mask for places queries
27
+ DEFAULT_PLACE_FIELDS = (
28
+ "display_name",
29
+ "formatted_address",
30
+ "location",
31
+ )
32
+
33
+ ADDRESS_TYPES = {
34
+ "street_address",
35
+ "route",
36
+ "intersection",
37
+ "premise",
38
+ "subpremise",
39
+ "plus_code",
40
+ "postal_code",
41
+ "locality",
42
+ "sublocality",
43
+ "neighborhood",
44
+ "administrative_area_level_1",
45
+ "administrative_area_level_2",
46
+ "country",
47
+ "floor",
48
+ "room",
49
+ }
50
+
51
+
52
+ def fmt_exc(e: BaseException) -> str:
53
+ """Format exception for logging without touching non-existent attributes."""
54
+ return f"{type(e).__name__}: {e}"
55
+
56
+
57
+ def mask_header(
58
+ fields: FieldMaskInput, prefix: str = ""
59
+ ) -> tuple[tuple[str, str], ...]:
60
+ """
61
+ Build the X-Goog-FieldMask header. Pass a comma-separated string or a sequence.
62
+ If None, no header is added (e.g., autocomplete, get_photo_media).
63
+ """
64
+ if fields is None:
65
+ return () # type: ignore[unreachable]
66
+ value = compile_field_mask(fields, prefix=prefix)
67
+ return (("x-goog-fieldmask", ",".join(value)),)
68
+
69
+
70
+ def strip_html(s: str) -> str:
71
+ # Simple fallback for adr_format_address (which is HTML)
72
+ return re.sub(r"<[^>]+>", "", s) if s else s
73
+
74
+
75
+ def infer_place_type(m: GooglePlace) -> PlaceType:
76
+ # 1) Airport wins outright
77
+ tset = set(m.types or [])
78
+ ptype = (m.primary_type or "").lower() if getattr(m, "primary_type", None) else ""
79
+ if "airport" in tset or ptype == "airport":
80
+ return PlaceType.AIRPORT
81
+ # 2) Anything that looks like a geocoded address
82
+ if tset & ADDRESS_TYPES:
83
+ return PlaceType.ADDRESS
84
+ # 3) Otherwise treat as a point of interest
85
+ return PlaceType.POI
86
+
87
+
88
+ def get_lat_lng(model: GooglePlace) -> tuple[float, float]:
89
+ if model.location:
90
+ lat = model.location.latitude
91
+ lng = model.location.longitude
92
+ elif model.viewport:
93
+ lat = model.viewport.low.latitude
94
+ lng = model.viewport.low.longitude
95
+ else:
96
+ lat = 0
97
+ lng = 0
98
+ return lat, lng
99
+
100
+
101
+ def name_from_place(p: Place) -> Optional[str]:
102
+ # Try common fields; support Google Places v1 structure where display_name may have `.text`
103
+ gp = getattr(p, "google_place", None)
104
+ candidates = [
105
+ getattr(gp, "display_name", None),
106
+ getattr(gp, "name", None),
107
+ getattr(p, "name", None),
108
+ getattr(p, "address_descriptor", None),
109
+ getattr(p, "address_components", None),
110
+ getattr(p, "formatted_address", None),
111
+ ]
112
+ for c in candidates:
113
+ if not c:
114
+ continue
115
+
116
+ maybe_text = getattr(c, "text", c)
117
+ if isinstance(maybe_text, str) and maybe_text.strip():
118
+ return maybe_text.strip()
119
+ elif isinstance(maybe_text, AddressDescriptor):
120
+ return maybe_text.landmarks[0].display_name.text
121
+ elif isinstance(maybe_text, list):
122
+ for item in maybe_text:
123
+ if isinstance(item, AddressComponent):
124
+ return item.long_text
125
+ else:
126
+ break
127
+ return None
128
+
129
+
130
+ def build_search_request_params(
131
+ query: Optional[str],
132
+ request: Optional[models.SearchTextRequest],
133
+ **kwargs: Any,
134
+ ) -> dict[str, Any]:
135
+ """Build request parameters for search_text API call."""
136
+ if query is None and request is None:
137
+ raise ValueError("Either 'query' or 'request' must be provided")
138
+ if query is not None and request is not None:
139
+ raise ValueError(
140
+ "Only one of 'query' or 'request' should be provided, not both"
141
+ )
142
+
143
+ if request is not None:
144
+ request_params = request.model_dump(exclude_none=True)
145
+ request_params.update(kwargs)
146
+ return request_params
147
+ else:
148
+ return {"text_query": query, **kwargs}
149
+
150
+
151
+ def normalize_place_from_proto(proto: Any) -> models.Place:
152
+ """Convert a proto Place to our normalized Place model."""
153
+ from .proto_adapter import validate_proto_to_model
154
+
155
+ model = validate_proto_to_model(proto, GooglePlace)
156
+ adr_format = getattr(model, "adr_format_address", None)
157
+ addr = (
158
+ getattr(model, "formatted_address", None)
159
+ or getattr(model, "short_formatted_address", None)
160
+ or strip_html(adr_format or "")
161
+ or ""
162
+ )
163
+ lat, lng = get_lat_lng(model)
164
+ return models.Place(
165
+ formatted_address=addr,
166
+ lat=lat,
167
+ lng=lng,
168
+ place_type=infer_place_type(model),
169
+ google_place=model,
170
+ )
171
+
172
+
173
+ def normalize_search_results(proto_response: Any) -> list[models.Place]:
174
+ """Convert search_text response to list of normalized Place models."""
175
+ return [normalize_place_from_proto(proto) for proto in proto_response.places]
176
+
177
+
178
+ def build_get_place_request(
179
+ place_id: Optional[str], request: Optional[models.GetPlaceRequest]
180
+ ) -> dict[str, Any]:
181
+ """Build request parameters for get_place API call."""
182
+ if place_id is None and request is None:
183
+ raise ValueError("Either 'place_id' or 'request' must be provided")
184
+
185
+ if request:
186
+ return request.model_dump(exclude_none=True)
187
+ else:
188
+ return {"name": f"places/{place_id}"}
189
+
190
+
191
+ def derive_effective_query(query: Optional[str], places: list[models.Place]) -> str:
192
+ """Derive an effective query string from query and/or places."""
193
+ effective_query = (query or "").strip()
194
+
195
+ if not effective_query:
196
+ if len(places) == 1:
197
+ derived = name_from_place(places[0])
198
+ if not derived:
199
+ raise ValueError("Could not derive a query from the provided place.")
200
+ effective_query = derived
201
+ else:
202
+ raise ValueError(
203
+ "Multiple places provided but no 'query' to disambiguate them."
204
+ )
205
+
206
+ return effective_query
207
+
208
+
209
+ def validate_resolve_airport_inputs(
210
+ place_id: Optional[str],
211
+ places: Optional[list[models.Place]],
212
+ max_distance_km: Optional[float],
213
+ ) -> None:
214
+ """Validate inputs for resolve_airport method."""
215
+ if place_id is not None and places is not None:
216
+ raise ValueError("Provide only one of place_id or places, not both.")
217
+ if max_distance_km is not None and max_distance_km <= 0:
218
+ raise ValueError("max_distance_km, if provided, must be > 0.")
219
+
220
+
221
+ def validate_autocomplete_inputs(
222
+ input: Optional[str],
223
+ request: Optional[models.AutocompletePlacesRequest],
224
+ ) -> models.AutocompletePlacesRequest:
225
+ """Validate inputs for autocomplete method."""
226
+ if request is None:
227
+ if input is None:
228
+ raise ValueError("Either input or request must be provided.")
229
+ else:
230
+ request = models.AutocompletePlacesRequest(input=input)
231
+ return request
@@ -0,0 +1,224 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ from functools import lru_cache
5
+ from typing import (
6
+ Any,
7
+ Dict,
8
+ List,
9
+ Optional,
10
+ Tuple,
11
+ Type,
12
+ TypeVar,
13
+ cast,
14
+ get_args,
15
+ get_origin,
16
+ )
17
+
18
+ from google.protobuf import json_format as jf
19
+
20
+ # Protobuf imports
21
+ from google.protobuf.message import Message as GPBMessage
22
+ from pydantic import BaseModel, TypeAdapter
23
+ from pydantic_core import ValidationError
24
+
25
+ # Logging
26
+ from ...logging import get_logger
27
+
28
+ TModel = TypeVar("TModel", bound=BaseModel)
29
+
30
+ log = get_logger("proto_adapter")
31
+
32
+ # ---------------- Proto extraction (proto-plus or vanilla GPB) ----------------
33
+
34
+
35
+ def _extract_gpb_message(msg: Any) -> GPBMessage:
36
+ if isinstance(msg, GPBMessage):
37
+ return msg
38
+ for attr in ("_pb", "pb"):
39
+ if hasattr(msg, attr):
40
+ cand = getattr(msg, attr)
41
+ if isinstance(cand, GPBMessage):
42
+ return cand
43
+ for meth in ("to_protobuf", "to_pb"):
44
+ if hasattr(msg, meth):
45
+ cand = getattr(msg, meth)()
46
+ if isinstance(cand, GPBMessage):
47
+ return cand
48
+ raise TypeError(
49
+ "Unsupported protobuf message type. Provide a google.protobuf Message "
50
+ "or a proto-plus message exposing ._pb/.pb or .to_protobuf()."
51
+ )
52
+
53
+
54
+ # ---------------- Version-proof MessageToDict wrapper ----------------
55
+
56
+
57
+ @lru_cache(maxsize=1)
58
+ def _mtodict_signature() -> inspect.Signature:
59
+ return inspect.signature(jf.MessageToDict)
60
+
61
+
62
+ def _message_to_dict(
63
+ msg: Any,
64
+ *,
65
+ use_integers_for_enums: bool = True,
66
+ including_default_value_fields: bool = False,
67
+ preserving_proto_field_name: bool = True,
68
+ ) -> dict[str, Any]:
69
+ """
70
+ Wraps google.protobuf.json_format.MessageToDict with runtime arg mapping
71
+ so it works across protobuf versions.
72
+ """
73
+ gpb = _extract_gpb_message(msg)
74
+ sig = _mtodict_signature()
75
+ params = sig.parameters
76
+
77
+ kwargs: dict[str, Any] = {
78
+ "preserving_proto_field_name": preserving_proto_field_name,
79
+ "use_integers_for_enums": use_integers_for_enums,
80
+ }
81
+
82
+ # protobuf>=5 uses "always_print_fields_with_no_presence"
83
+ if "including_default_value_fields" in params:
84
+ kwargs["including_default_value_fields"] = including_default_value_fields
85
+ elif "always_print_fields_with_no_presence" in params:
86
+ # Map our public arg to the new name
87
+ kwargs["always_print_fields_with_no_presence"] = including_default_value_fields
88
+ # else: neither supported (very old?) -> omit, default behavior
89
+
90
+ try:
91
+ return jf.MessageToDict(gpb, **kwargs)
92
+ except Exception as e:
93
+ log.error(f"MessageToDict conversion failed: {e}")
94
+ raise
95
+
96
+
97
+ # ---------------- Optional base64 -> bytes coercion guided by model schema ----------------
98
+
99
+
100
+ def _unwrap_optional(tp: Any) -> Any:
101
+ if get_origin(tp) is Optional:
102
+ return get_args(tp)[0]
103
+ return tp
104
+
105
+
106
+ def _coerce_by_annotation(data: Any, annotation: Any) -> Any:
107
+ from base64 import urlsafe_b64decode
108
+
109
+ from pydantic import BaseModel as PydBaseModel
110
+
111
+ if annotation is None:
112
+ return data
113
+
114
+ if get_origin(annotation) is Optional:
115
+ annotation = _unwrap_optional(annotation)
116
+
117
+ if annotation is bytes and isinstance(data, str):
118
+ # tolerate missing padding
119
+ try:
120
+ return urlsafe_b64decode(data + "===")
121
+ except Exception:
122
+ return data
123
+
124
+ if get_origin(annotation) in (list, tuple, List, Tuple):
125
+ (item_type,) = get_args(annotation) or (Any,)
126
+ if isinstance(data, list):
127
+ return [_coerce_by_annotation(x, item_type) for x in data]
128
+ return data
129
+
130
+ if get_origin(annotation) in (dict, Dict):
131
+ args = get_args(annotation)
132
+ if len(args) == 2 and isinstance(data, dict):
133
+ _, v_type = args
134
+ return {k: _coerce_by_annotation(v, v_type) for k, v in data.items()}
135
+ return data
136
+
137
+ if (
138
+ isinstance(annotation, type)
139
+ and issubclass(annotation, PydBaseModel)
140
+ and isinstance(data, dict)
141
+ ):
142
+ out = dict(data)
143
+ for fname, f in annotation.model_fields.items():
144
+ keys = [fname]
145
+ if f.alias and f.alias != fname:
146
+ keys.insert(0, f.alias)
147
+ present = next((k for k in keys if k in out), None)
148
+ if present is not None:
149
+ out[present] = _coerce_by_annotation(out[present], f.annotation)
150
+ return out
151
+
152
+ return data
153
+
154
+
155
+ # ---------------- TypeAdapter cache (generic-erased to appease Pyright) ----------------
156
+
157
+
158
+ @lru_cache(maxsize=256)
159
+ def _adapter_for_cached(model_cls: type) -> TypeAdapter[BaseModel]:
160
+ # erase generic in the cache to avoid TypeVar identity issues in type checkers
161
+ return TypeAdapter(model_cls)
162
+
163
+
164
+ # ---------------- Public API ----------------
165
+
166
+
167
+ def validate_proto_to_model(
168
+ msg: Any,
169
+ model_type: Type[TModel],
170
+ *,
171
+ decode_bytes_by_schema: bool = True,
172
+ use_integers_for_enums: bool = True,
173
+ including_default_value_fields: bool = False,
174
+ preserving_proto_field_name: bool = True,
175
+ ) -> TModel:
176
+ """
177
+ Validate a protobuf message (proto-plus or GPB) into a Pydantic v2 model instance.
178
+
179
+ - Works across protobuf versions (handles arg rename to always_print_fields_with_no_presence).
180
+ - Fully runtime: you pass the concrete Pydantic model class.
181
+ """
182
+ # Convert protobuf to dictionary
183
+ try:
184
+ raw = _message_to_dict(
185
+ msg,
186
+ use_integers_for_enums=use_integers_for_enums,
187
+ including_default_value_fields=including_default_value_fields,
188
+ preserving_proto_field_name=preserving_proto_field_name,
189
+ )
190
+ except Exception as e:
191
+ log.error(f"Failed to convert protobuf to dict: {e}")
192
+ raise
193
+
194
+ # Apply type coercion if enabled
195
+ try:
196
+ data = _coerce_by_annotation(raw, model_type) if decode_bytes_by_schema else raw
197
+ except Exception as e:
198
+ log.error(f"Failed during type coercion: {e}")
199
+ raise
200
+
201
+ # Validate with Pydantic
202
+ try:
203
+ adapter = _adapter_for_cached(model_type)
204
+ result = cast(TModel, adapter.validate_python(data))
205
+ return result
206
+
207
+ except ValidationError as e:
208
+ log.error(
209
+ f"Pydantic validation failed for {model_type.__name__} ({e.error_count()} errors)"
210
+ )
211
+
212
+ # Log key validation errors with context
213
+ for error in e.errors():
214
+ if "loc" in error and error["loc"]:
215
+ location = ".".join(str(loc) for loc in error["loc"])
216
+ log.error(f"Validation error at {location}: {error['msg']}")
217
+ else:
218
+ log.error(f"Validation error: {error['msg']}")
219
+
220
+ raise
221
+
222
+ except Exception as e:
223
+ log.error(f"Unexpected error during validation: {e}")
224
+ raise