bookalimo 1.0.0__py3-none-any.whl → 1.0.2__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/client.py +45 -22
- bookalimo/config.py +1 -1
- bookalimo/integrations/google_places/client_async.py +141 -156
- bookalimo/integrations/google_places/client_sync.py +142 -156
- bookalimo/integrations/google_places/common.py +464 -200
- bookalimo/integrations/google_places/resolve_airport.py +426 -0
- bookalimo/integrations/google_places/transports.py +105 -0
- bookalimo/logging.py +103 -0
- bookalimo/schemas/__init__.py +126 -34
- bookalimo/schemas/base.py +74 -14
- bookalimo/schemas/places/__init__.py +27 -0
- bookalimo/schemas/places/common.py +155 -2
- bookalimo/schemas/places/field_mask.py +212 -0
- bookalimo/schemas/places/google.py +458 -16
- bookalimo/schemas/places/place.py +25 -28
- bookalimo/schemas/requests.py +214 -0
- bookalimo/schemas/responses.py +196 -0
- bookalimo/schemas/{booking.py → shared.py} +55 -218
- bookalimo/services/pricing.py +9 -129
- bookalimo/services/reservations.py +10 -100
- bookalimo/transport/auth.py +2 -2
- bookalimo/transport/httpx_async.py +41 -125
- bookalimo/transport/httpx_sync.py +30 -109
- bookalimo/transport/utils.py +204 -3
- bookalimo-1.0.2.dist-info/METADATA +245 -0
- bookalimo-1.0.2.dist-info/RECORD +40 -0
- bookalimo-1.0.2.dist-info/licenses/LICENSE +21 -0
- bookalimo-1.0.0.dist-info/METADATA +0 -307
- bookalimo-1.0.0.dist-info/RECORD +0 -35
- bookalimo-1.0.0.dist-info/licenses/LICENSE +0 -0
- {bookalimo-1.0.0.dist-info → bookalimo-1.0.2.dist-info}/WHEEL +0 -0
- {bookalimo-1.0.0.dist-info → bookalimo-1.0.2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,426 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import csv
|
4
|
+
import math
|
5
|
+
import os
|
6
|
+
import re
|
7
|
+
import unicodedata
|
8
|
+
from functools import lru_cache
|
9
|
+
from importlib.resources import files
|
10
|
+
from types import MappingProxyType
|
11
|
+
from typing import Any, Dict, List, Optional, Tuple, cast
|
12
|
+
|
13
|
+
import numpy as np
|
14
|
+
from numpy.typing import NDArray
|
15
|
+
from rapidfuzz import fuzz, process
|
16
|
+
|
17
|
+
from bookalimo.schemas.places import GooglePlace, ResolvedAirport
|
18
|
+
|
19
|
+
# ---------- Config ----------
|
20
|
+
CSV_PATH = os.environ.get(
|
21
|
+
"AIRPORTS_CSV", str(files("airportsdata").joinpath("airports.csv"))
|
22
|
+
)
|
23
|
+
DEFAULT_MAX_RESULTS = 20 # number of airports to return
|
24
|
+
DIST_KM_SCALE = 200.0 # distance scale for proximity confidence
|
25
|
+
|
26
|
+
# Google types that clearly indicate “airport-ish” places
|
27
|
+
AIRPORTY_TYPES = {
|
28
|
+
"airport",
|
29
|
+
"international_airport",
|
30
|
+
"airstrip",
|
31
|
+
"heliport",
|
32
|
+
}
|
33
|
+
|
34
|
+
|
35
|
+
# ---------- Helpers ----------
|
36
|
+
def _norm(s: Optional[str]) -> str:
|
37
|
+
if not s:
|
38
|
+
return ""
|
39
|
+
s = unicodedata.normalize("NFKD", s)
|
40
|
+
s = "".join(ch for ch in s if not unicodedata.combining(ch))
|
41
|
+
s = s.lower()
|
42
|
+
s = re.sub(r"[^a-z0-9]+", " ", s).strip()
|
43
|
+
return s
|
44
|
+
|
45
|
+
|
46
|
+
def _haversine_km_scalar_to_many(
|
47
|
+
lat1_rad: float,
|
48
|
+
lon1_rad: float,
|
49
|
+
lat2_rad: NDArray[np.float64],
|
50
|
+
lon2_rad: NDArray[np.float64],
|
51
|
+
) -> NDArray[np.float64]:
|
52
|
+
dlat = lat2_rad - lat1_rad
|
53
|
+
dlon = lon2_rad - lon1_rad
|
54
|
+
a = (
|
55
|
+
np.sin(dlat / 2.0) ** 2
|
56
|
+
+ np.cos(lat1_rad) * np.cos(lat2_rad) * np.sin(dlon / 2.0) ** 2
|
57
|
+
)
|
58
|
+
c: NDArray[np.float64] = 2.0 * np.arcsin(np.sqrt(a))
|
59
|
+
return cast(NDArray[np.float64], 6371.0088 * c) # mean Earth radius (km)
|
60
|
+
|
61
|
+
|
62
|
+
def _place_points(places: list[GooglePlace]) -> list[tuple[float, float]]:
|
63
|
+
"""
|
64
|
+
Extract (lat, lon) from Places responses. Prefers 'location', then viewport center,
|
65
|
+
then Plus Code (if the openlocationcode lib is available).
|
66
|
+
"""
|
67
|
+
pts: list[tuple[float, float]] = []
|
68
|
+
for p in places or []:
|
69
|
+
# p.location (LatLng)
|
70
|
+
loc = getattr(p, "location", None)
|
71
|
+
if loc is not None and hasattr(loc, "latitude") and hasattr(loc, "longitude"):
|
72
|
+
pts.append((float(loc.latitude), float(loc.longitude)))
|
73
|
+
continue
|
74
|
+
|
75
|
+
# p.viewport (Viewport -> center)
|
76
|
+
vp = getattr(p, "viewport", None)
|
77
|
+
if vp is not None and hasattr(vp, "high") and hasattr(vp, "low"):
|
78
|
+
try:
|
79
|
+
lat = (float(vp.high.latitude) + float(vp.low.latitude)) / 2.0
|
80
|
+
lon = (float(vp.high.longitude) + float(vp.low.longitude)) / 2.0
|
81
|
+
pts.append((lat, lon))
|
82
|
+
continue
|
83
|
+
except Exception:
|
84
|
+
pass
|
85
|
+
return pts
|
86
|
+
|
87
|
+
|
88
|
+
def _place_hints(places: list[GooglePlace]) -> list[str]:
|
89
|
+
"""
|
90
|
+
Collect high-utility strings from Places to augment text matching.
|
91
|
+
Prioritizes places whose types include airport-ish categories.
|
92
|
+
"""
|
93
|
+
hints_prioritized: list[str] = []
|
94
|
+
hints_general: list[str] = []
|
95
|
+
|
96
|
+
for p in places or []:
|
97
|
+
types = set(getattr(p, "types", []) or [])
|
98
|
+
primary = getattr(p, "primary_type", None) or ""
|
99
|
+
airporty = bool(types & AIRPORTY_TYPES) or (primary in AIRPORTY_TYPES)
|
100
|
+
|
101
|
+
# Display name & address
|
102
|
+
disp = getattr(p, "display_name", None)
|
103
|
+
disp_txt = getattr(disp, "text", None) if disp is not None else None
|
104
|
+
addr = getattr(p, "formatted_address", None)
|
105
|
+
|
106
|
+
# A few relational names (areas/landmarks)
|
107
|
+
adesc = getattr(p, "address_descriptor", None)
|
108
|
+
area_names = []
|
109
|
+
lm_names = []
|
110
|
+
if adesc is not None:
|
111
|
+
for a in (getattr(adesc, "areas", []) or [])[:2]:
|
112
|
+
dn = getattr(a, "display_name", None)
|
113
|
+
if dn and getattr(dn, "text", None):
|
114
|
+
area_names.append(dn.text)
|
115
|
+
for lm in (getattr(adesc, "landmarks", []) or [])[:2]:
|
116
|
+
dn = getattr(lm, "display_name", None)
|
117
|
+
if dn and getattr(dn, "text", None):
|
118
|
+
lm_names.append(dn.text)
|
119
|
+
|
120
|
+
# Gather hint candidates
|
121
|
+
candidates = [disp_txt, addr, *area_names, *lm_names]
|
122
|
+
candidates = [c for c in candidates if c]
|
123
|
+
if not candidates:
|
124
|
+
continue
|
125
|
+
|
126
|
+
# Prioritize hints if the place is airport-ish
|
127
|
+
(hints_prioritized if airporty else hints_general).extend(candidates[:2])
|
128
|
+
|
129
|
+
# De-dup (by normalized form) and cap to keep RF calls small
|
130
|
+
seen = set()
|
131
|
+
|
132
|
+
def dedup_cap(items: list[str], cap: int) -> list[str]:
|
133
|
+
out = []
|
134
|
+
for s in items:
|
135
|
+
k = _norm(s)
|
136
|
+
if not k or k in seen:
|
137
|
+
continue
|
138
|
+
out.append(s)
|
139
|
+
seen.add(k)
|
140
|
+
if len(out) >= cap:
|
141
|
+
break
|
142
|
+
return out
|
143
|
+
|
144
|
+
return dedup_cap(hints_prioritized, cap=3) + dedup_cap(hints_general, cap=2)
|
145
|
+
|
146
|
+
|
147
|
+
def _parse_coord(s: Optional[str]) -> float:
|
148
|
+
"""Return float value or NaN for None/blank/invalid strings."""
|
149
|
+
if s is None:
|
150
|
+
return float("nan")
|
151
|
+
s = s.strip()
|
152
|
+
if s == "":
|
153
|
+
return float("nan")
|
154
|
+
try:
|
155
|
+
return float(s)
|
156
|
+
except ValueError:
|
157
|
+
return float("nan")
|
158
|
+
|
159
|
+
|
160
|
+
def _frozen_np_float(arr_like: List[float]) -> NDArray[np.float64]:
|
161
|
+
"""Create a float64 numpy array and set writeable=False."""
|
162
|
+
a = np.array(arr_like, dtype=np.float64)
|
163
|
+
a.setflags(write=False)
|
164
|
+
return a
|
165
|
+
|
166
|
+
|
167
|
+
def _frozen_np_bool(arr_like: List[bool]) -> NDArray[np.bool_]:
|
168
|
+
"""Create a bool numpy array and set writeable=False."""
|
169
|
+
a = np.array(arr_like, dtype=bool)
|
170
|
+
a.setflags(write=False)
|
171
|
+
return cast(NDArray[np.bool_], a)
|
172
|
+
|
173
|
+
|
174
|
+
# ---------- Data loading with immutable return + dual indexes ----------
|
175
|
+
@lru_cache(maxsize=1)
|
176
|
+
def _load_data() -> MappingProxyType[str, Any]:
|
177
|
+
"""
|
178
|
+
Loads and caches airport rows and vectorized fields.
|
179
|
+
Expects CSV columns: icao,iata,name,city,subd,country,elevation,lat,lon,tz,lid
|
180
|
+
|
181
|
+
Returns an immutable mapping with:
|
182
|
+
- rows: tuple[dict[str, Any]] (each row dict should be treated as read-only)
|
183
|
+
- lat_rad, lon_rad: np.ndarray (float64, write-protected)
|
184
|
+
- keys: tuple[str] (normalized text used for fuzzy matching)
|
185
|
+
- codes: tuple[tuple[str, str]] (iata, icao)
|
186
|
+
- has_coords: np.ndarray (bool, write-protected)
|
187
|
+
- idx_iata: Mapping[str, int] (UPPERCASE IATA -> row index)
|
188
|
+
- idx_icao: Mapping[str, int] (UPPERCASE ICAO -> row index)
|
189
|
+
"""
|
190
|
+
rows_mut: List[Dict[str, Any]] = []
|
191
|
+
lat_rad_mut: List[float] = []
|
192
|
+
lon_rad_mut: List[float] = []
|
193
|
+
keys_mut: List[str] = []
|
194
|
+
codes_mut: List[Tuple[str, str]] = []
|
195
|
+
has_coords_mut: List[bool] = []
|
196
|
+
idx_iata_mut: Dict[str, int] = {}
|
197
|
+
idx_icao_mut: Dict[str, int] = {}
|
198
|
+
|
199
|
+
with open(CSV_PATH, newline="", encoding="utf-8") as f:
|
200
|
+
reader = csv.DictReader(f)
|
201
|
+
for r in reader:
|
202
|
+
name = (r.get("name") or "").strip()
|
203
|
+
city = (r.get("city") or "").strip()
|
204
|
+
iata = (r.get("iata") or "").strip() or None
|
205
|
+
icao = (r.get("icao") or "").strip() or None
|
206
|
+
|
207
|
+
lat = _parse_coord(cast(Optional[str], r.get("lat")))
|
208
|
+
lon = _parse_coord(cast(Optional[str], r.get("lon")))
|
209
|
+
valid = not (math.isnan(lat) or math.isnan(lon))
|
210
|
+
|
211
|
+
idx = len(rows_mut)
|
212
|
+
rows_mut.append(
|
213
|
+
{
|
214
|
+
"name": name,
|
215
|
+
"city": city,
|
216
|
+
"iata": iata,
|
217
|
+
"icao": icao,
|
218
|
+
"lat": lat,
|
219
|
+
"lon": lon,
|
220
|
+
}
|
221
|
+
)
|
222
|
+
|
223
|
+
# radians() propagates NaN; no conditional needed
|
224
|
+
lat_rad_mut.append(math.radians(lat))
|
225
|
+
lon_rad_mut.append(math.radians(lon))
|
226
|
+
has_coords_mut.append(valid)
|
227
|
+
|
228
|
+
code_bits = (
|
229
|
+
" ".join([c for c in (iata, icao) if c]) if (iata or icao) else ""
|
230
|
+
)
|
231
|
+
keys_mut.append(_norm(f"{name} {city} {code_bits}"))
|
232
|
+
codes_mut.append((iata or "", icao or ""))
|
233
|
+
|
234
|
+
# Build dual indexes (first occurrence wins)
|
235
|
+
if iata:
|
236
|
+
iu = iata.upper()
|
237
|
+
if iu not in idx_iata_mut:
|
238
|
+
idx_iata_mut[iu] = idx
|
239
|
+
if icao:
|
240
|
+
iu = icao.upper()
|
241
|
+
if iu not in idx_icao_mut:
|
242
|
+
idx_icao_mut[iu] = idx
|
243
|
+
|
244
|
+
# Freeze everything
|
245
|
+
rows = tuple(rows_mut)
|
246
|
+
lat_rad = _frozen_np_float(lat_rad_mut)
|
247
|
+
lon_rad = _frozen_np_float(lon_rad_mut)
|
248
|
+
keys = tuple(keys_mut)
|
249
|
+
codes = tuple(codes_mut)
|
250
|
+
has_coords = _frozen_np_bool(has_coords_mut)
|
251
|
+
idx_iata = MappingProxyType(dict(idx_iata_mut)) # proxy ensures read-only
|
252
|
+
idx_icao = MappingProxyType(dict(idx_icao_mut))
|
253
|
+
|
254
|
+
# Return a read-only top-level mapping
|
255
|
+
return MappingProxyType(
|
256
|
+
{
|
257
|
+
"rows": rows,
|
258
|
+
"lat_rad": lat_rad,
|
259
|
+
"lon_rad": lon_rad,
|
260
|
+
"keys": keys,
|
261
|
+
"codes": codes,
|
262
|
+
"has_coords": has_coords,
|
263
|
+
"idx_iata": idx_iata,
|
264
|
+
"idx_icao": idx_icao,
|
265
|
+
}
|
266
|
+
)
|
267
|
+
|
268
|
+
|
269
|
+
# ---------- Convenience lookups (O(1) via dual indexes) ----------
|
270
|
+
def get_row_by_iata(code: str) -> Optional[dict[str, Any]]:
|
271
|
+
"""Return the airport row for an IATA code, or None if not found."""
|
272
|
+
if not code:
|
273
|
+
return None
|
274
|
+
data = _load_data()
|
275
|
+
idx = data["idx_iata"].get(code.upper())
|
276
|
+
return data["rows"][idx] if idx is not None else None
|
277
|
+
|
278
|
+
|
279
|
+
def get_row_by_icao(code: str) -> Optional[dict[str, Any]]:
|
280
|
+
"""Return the airport row for an ICAO code, or None if not found."""
|
281
|
+
if not code:
|
282
|
+
return None
|
283
|
+
data = _load_data()
|
284
|
+
idx = data["idx_icao"].get(code.upper())
|
285
|
+
return data["rows"][idx] if idx is not None else None
|
286
|
+
|
287
|
+
|
288
|
+
def _try_direct_code_lookup(query: str) -> Optional[ResolvedAirport]:
|
289
|
+
"""
|
290
|
+
Try to resolve the query as a direct IATA or ICAO code match.
|
291
|
+
Returns ResolvedAirport with high confidence if found, None otherwise.
|
292
|
+
"""
|
293
|
+
if not query:
|
294
|
+
return None
|
295
|
+
|
296
|
+
# Clean and normalize the query for code matching
|
297
|
+
code = query.strip().upper()
|
298
|
+
if not code:
|
299
|
+
return None
|
300
|
+
|
301
|
+
# Try IATA first (3 characters)
|
302
|
+
if len(code) == 3:
|
303
|
+
row = get_row_by_iata(code)
|
304
|
+
if row:
|
305
|
+
return ResolvedAirport(
|
306
|
+
name=row["name"],
|
307
|
+
city=row["city"],
|
308
|
+
iata_code=row["iata"],
|
309
|
+
icao_code=row["icao"],
|
310
|
+
confidence=0.95, # High confidence for exact code matches
|
311
|
+
)
|
312
|
+
|
313
|
+
# Try ICAO (4 characters)
|
314
|
+
elif len(code) == 4:
|
315
|
+
row = get_row_by_icao(code)
|
316
|
+
if row:
|
317
|
+
return ResolvedAirport(
|
318
|
+
name=row["name"],
|
319
|
+
city=row["city"],
|
320
|
+
iata_code=row["iata"],
|
321
|
+
icao_code=row["icao"],
|
322
|
+
confidence=0.95, # High confidence for exact code matches
|
323
|
+
)
|
324
|
+
|
325
|
+
return None
|
326
|
+
|
327
|
+
|
328
|
+
# ---------- Main ----------
|
329
|
+
def resolve_airport(
|
330
|
+
query: str,
|
331
|
+
places_response: list[GooglePlace],
|
332
|
+
max_distance_km: Optional[float] = 200,
|
333
|
+
max_results: Optional[int] = 5,
|
334
|
+
confidence_threshold: Optional[float] = 0.5,
|
335
|
+
text_weight: float = 0.5,
|
336
|
+
) -> list[ResolvedAirport]:
|
337
|
+
"""
|
338
|
+
Resolve airport candidates given a query and a list of Places responses.
|
339
|
+
Args:
|
340
|
+
query: The text query to resolve an airport from.
|
341
|
+
places_response: The list of Places responses to resolve an airport from.
|
342
|
+
max_distance_km: The maximum distance in kilometers to any of the places to consider for proximity.
|
343
|
+
max_results: The maximum number of results to return.
|
344
|
+
confidence_threshold: The confidence threshold to consider for the results. Default is 0.5.
|
345
|
+
text_weight: The weight for the text confidence.
|
346
|
+
Returns:
|
347
|
+
The list of resolved airports ordered by confidence.
|
348
|
+
"""
|
349
|
+
|
350
|
+
# First, try direct IATA/ICAO code lookup for exact matches
|
351
|
+
direct_match = _try_direct_code_lookup(query)
|
352
|
+
if direct_match is not None:
|
353
|
+
return [direct_match]
|
354
|
+
|
355
|
+
data = _load_data()
|
356
|
+
rows: list[dict[str, Any]] = data["rows"]
|
357
|
+
n = len(rows)
|
358
|
+
if n == 0:
|
359
|
+
return []
|
360
|
+
|
361
|
+
# ---- Proximity anchors from Places ----
|
362
|
+
anchors = _place_points(places_response)
|
363
|
+
min_dist = np.full(n, np.inf, dtype=float)
|
364
|
+
if anchors:
|
365
|
+
for lat, lon in anchors:
|
366
|
+
lat1 = math.radians(lat)
|
367
|
+
lon1 = math.radians(lon)
|
368
|
+
d = _haversine_km_scalar_to_many(
|
369
|
+
lat1, lon1, data["lat_rad"], data["lon_rad"]
|
370
|
+
)
|
371
|
+
np.nan_to_num(d, copy=False, nan=np.inf) # NaN coords -> ∞
|
372
|
+
np.minimum(min_dist, d, out=min_dist)
|
373
|
+
|
374
|
+
prox = np.zeros(n, dtype=float)
|
375
|
+
if anchors:
|
376
|
+
prox = 100.0 * np.exp(-min_dist / float(DIST_KM_SCALE))
|
377
|
+
|
378
|
+
# ---- Text score: best across augmented queries ----
|
379
|
+
hints = _place_hints(places_response)
|
380
|
+
q_variants = [_norm(query)] + [_norm(f"{query} {h}") for h in hints]
|
381
|
+
# Single cdist call over up to 1+5 variants keeps things fast
|
382
|
+
scores_matrix = process.cdist(q_variants, data["keys"], scorer=fuzz.token_set_ratio)
|
383
|
+
text_scores = np.array(scores_matrix.max(axis=0), dtype=float)
|
384
|
+
|
385
|
+
# Cap to 0..100
|
386
|
+
text_scores = np.clip(text_scores, 0.0, 100.0)
|
387
|
+
|
388
|
+
# ---- Blend + optional radius mask ----
|
389
|
+
final = text_weight * text_scores + (1.0 - text_weight) * prox
|
390
|
+
|
391
|
+
# Apply mask only if we have anchors and caller asked for one.
|
392
|
+
# IMPORTANT: rows without coords are still included (mask keeps them).
|
393
|
+
if anchors and max_distance_km is not None:
|
394
|
+
mask = (~data["has_coords"]) | (min_dist <= float(max_distance_km))
|
395
|
+
else:
|
396
|
+
mask = np.ones(n, dtype=bool)
|
397
|
+
|
398
|
+
final_masked = np.where(mask, final, -np.inf)
|
399
|
+
order = np.argsort(-final_masked)
|
400
|
+
top = order[: max_results or DEFAULT_MAX_RESULTS]
|
401
|
+
|
402
|
+
results: list[ResolvedAirport] = []
|
403
|
+
for idx in top:
|
404
|
+
if final_masked[idx] == -np.inf:
|
405
|
+
break
|
406
|
+
r = rows[idx]
|
407
|
+
text_confidence = float(text_scores[idx] / 100.0)
|
408
|
+
proximity_confidence = float(prox[idx] / 100.0)
|
409
|
+
if (
|
410
|
+
confidence_threshold is not None
|
411
|
+
and (text_confidence + proximity_confidence) / 2.0 < confidence_threshold
|
412
|
+
):
|
413
|
+
continue
|
414
|
+
results.append(
|
415
|
+
ResolvedAirport(
|
416
|
+
name=r["name"],
|
417
|
+
city=r["city"],
|
418
|
+
iata_code=r["iata"] or None,
|
419
|
+
icao_code=r["icao"] or None,
|
420
|
+
confidence=(text_confidence + proximity_confidence) / 2.0,
|
421
|
+
)
|
422
|
+
)
|
423
|
+
|
424
|
+
results.sort(key=lambda x: x.confidence, reverse=True)
|
425
|
+
|
426
|
+
return results
|
@@ -0,0 +1,105 @@
|
|
1
|
+
"""
|
2
|
+
Transport abstractions for Google Places clients.
|
3
|
+
"""
|
4
|
+
|
5
|
+
from __future__ import annotations
|
6
|
+
|
7
|
+
from typing import Any, Optional, Protocol, Type, TypeVar
|
8
|
+
|
9
|
+
from google.api_core.client_options import ClientOptions
|
10
|
+
from google.maps.places_v1 import PlacesAsyncClient, PlacesClient
|
11
|
+
|
12
|
+
T = TypeVar("T", PlacesClient, PlacesAsyncClient)
|
13
|
+
|
14
|
+
|
15
|
+
def _get_places_client(
|
16
|
+
api_key: str,
|
17
|
+
client: Optional[T],
|
18
|
+
client_type: Type[T],
|
19
|
+
) -> T:
|
20
|
+
"""Create client options with API key - shared logic."""
|
21
|
+
return client or client_type(client_options=ClientOptions(api_key=api_key))
|
22
|
+
|
23
|
+
|
24
|
+
class SyncPlacesTransport(Protocol):
|
25
|
+
"""Protocol for synchronous Places API transport."""
|
26
|
+
|
27
|
+
def autocomplete_places(self, *, request: dict[str, Any], **kwargs: Any) -> Any: ...
|
28
|
+
|
29
|
+
def search_text(
|
30
|
+
self, *, request: dict[str, Any], metadata: tuple[tuple[str, str], ...]
|
31
|
+
) -> Any: ...
|
32
|
+
|
33
|
+
def get_place(
|
34
|
+
self, *, request: dict[str, Any], metadata: tuple[tuple[str, str], ...]
|
35
|
+
) -> Any: ...
|
36
|
+
|
37
|
+
def close(self) -> None: ...
|
38
|
+
|
39
|
+
|
40
|
+
class AsyncPlacesTransport(Protocol):
|
41
|
+
"""Protocol for asynchronous Places API transport."""
|
42
|
+
|
43
|
+
async def autocomplete_places(
|
44
|
+
self, *, request: dict[str, Any], **kwargs: Any
|
45
|
+
) -> Any: ...
|
46
|
+
|
47
|
+
async def search_text(
|
48
|
+
self, *, request: dict[str, Any], metadata: tuple[tuple[str, str], ...]
|
49
|
+
) -> Any: ...
|
50
|
+
|
51
|
+
async def get_place(
|
52
|
+
self, *, request: dict[str, Any], metadata: tuple[tuple[str, str], ...]
|
53
|
+
) -> Any: ...
|
54
|
+
|
55
|
+
async def close(self) -> None: ...
|
56
|
+
|
57
|
+
|
58
|
+
class GoogleSyncTransport:
|
59
|
+
"""Synchronous transport implementation for Google Places API."""
|
60
|
+
|
61
|
+
def __init__(self, api_key: str, client: Optional[PlacesClient] = None) -> None:
|
62
|
+
self.client = _get_places_client(api_key, client, PlacesClient)
|
63
|
+
|
64
|
+
def autocomplete_places(self, *, request: dict[str, Any], **kwargs: Any) -> Any:
|
65
|
+
return self.client.autocomplete_places(request=request, **kwargs)
|
66
|
+
|
67
|
+
def search_text(
|
68
|
+
self, *, request: dict[str, Any], metadata: tuple[tuple[str, str], ...]
|
69
|
+
) -> Any:
|
70
|
+
return self.client.search_text(request=request, metadata=metadata)
|
71
|
+
|
72
|
+
def get_place(
|
73
|
+
self, *, request: dict[str, Any], metadata: tuple[tuple[str, str], ...]
|
74
|
+
) -> Any:
|
75
|
+
return self.client.get_place(request=request, metadata=metadata)
|
76
|
+
|
77
|
+
def close(self) -> None:
|
78
|
+
self.client.transport.close()
|
79
|
+
|
80
|
+
|
81
|
+
class GoogleAsyncTransport:
|
82
|
+
"""Asynchronous transport implementation for Google Places API."""
|
83
|
+
|
84
|
+
def __init__(
|
85
|
+
self, api_key: str, client: Optional[PlacesAsyncClient] = None
|
86
|
+
) -> None:
|
87
|
+
self.client = _get_places_client(api_key, client, PlacesAsyncClient)
|
88
|
+
|
89
|
+
async def autocomplete_places(
|
90
|
+
self, *, request: dict[str, Any], **kwargs: Any
|
91
|
+
) -> Any:
|
92
|
+
return await self.client.autocomplete_places(request=request, **kwargs)
|
93
|
+
|
94
|
+
async def search_text(
|
95
|
+
self, *, request: dict[str, Any], metadata: tuple[tuple[str, str], ...]
|
96
|
+
) -> Any:
|
97
|
+
return await self.client.search_text(request=request, metadata=metadata)
|
98
|
+
|
99
|
+
async def get_place(
|
100
|
+
self, *, request: dict[str, Any], metadata: tuple[tuple[str, str], ...]
|
101
|
+
) -> Any:
|
102
|
+
return await self.client.get_place(request=request, metadata=metadata)
|
103
|
+
|
104
|
+
async def close(self) -> None:
|
105
|
+
await self.client.transport.close()
|
bookalimo/logging.py
CHANGED
@@ -21,6 +21,7 @@ from collections.abc import Awaitable, Iterable, Mapping
|
|
21
21
|
from functools import wraps
|
22
22
|
from time import perf_counter
|
23
23
|
from typing import Any, Callable, TypeVar
|
24
|
+
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
24
25
|
|
25
26
|
from typing_extensions import ParamSpec
|
26
27
|
|
@@ -65,6 +66,87 @@ else:
|
|
65
66
|
|
66
67
|
REDACTED = "******"
|
67
68
|
|
69
|
+
# Sensitive query parameter names (case-insensitive)
|
70
|
+
SENSITIVE_QUERY_PARAMS = {
|
71
|
+
"token",
|
72
|
+
"access_token",
|
73
|
+
"refresh_token",
|
74
|
+
"api_key",
|
75
|
+
"apikey",
|
76
|
+
"key",
|
77
|
+
"password",
|
78
|
+
"pass",
|
79
|
+
"pwd",
|
80
|
+
"secret",
|
81
|
+
"auth",
|
82
|
+
"authorization",
|
83
|
+
"code",
|
84
|
+
"auth_code",
|
85
|
+
"verification_code",
|
86
|
+
"otp",
|
87
|
+
"session",
|
88
|
+
"session_id",
|
89
|
+
"sid",
|
90
|
+
"csrf_token",
|
91
|
+
"xsrf_token",
|
92
|
+
"signature",
|
93
|
+
"sig",
|
94
|
+
"hash",
|
95
|
+
"nonce",
|
96
|
+
"state",
|
97
|
+
}
|
98
|
+
|
99
|
+
|
100
|
+
def redact_url(
|
101
|
+
url: str, *, replacement: str = REDACTED, sensitive_params: set[str] | None = None
|
102
|
+
) -> str:
|
103
|
+
"""
|
104
|
+
Redact sensitive query parameters from a URL.
|
105
|
+
|
106
|
+
Args:
|
107
|
+
url: The URL to redact
|
108
|
+
replacement: The replacement string for sensitive values
|
109
|
+
sensitive_params: Set of parameter names to redact (case-insensitive)
|
110
|
+
Defaults to SENSITIVE_QUERY_PARAMS
|
111
|
+
|
112
|
+
Returns:
|
113
|
+
The URL with sensitive query parameters redacted
|
114
|
+
|
115
|
+
Example:
|
116
|
+
>>> redact_url("https://api.example.com/auth?token=secret123&user=john")
|
117
|
+
"https://api.example.com/auth?token=******&user=john"
|
118
|
+
"""
|
119
|
+
if not isinstance(url, str) or not url:
|
120
|
+
return _safe_str(url)
|
121
|
+
|
122
|
+
try:
|
123
|
+
parts = urlsplit(url)
|
124
|
+
if not parts.query:
|
125
|
+
return url
|
126
|
+
|
127
|
+
sensitive = sensitive_params or SENSITIVE_QUERY_PARAMS
|
128
|
+
sensitive_lower = {name.lower() for name in sensitive}
|
129
|
+
|
130
|
+
# Parse and redact query parameters
|
131
|
+
pairs = parse_qsl(parts.query, keep_blank_values=True)
|
132
|
+
redacted_pairs = []
|
133
|
+
|
134
|
+
for key, value in pairs:
|
135
|
+
if key.lower() in sensitive_lower:
|
136
|
+
redacted_pairs.append((key, replacement))
|
137
|
+
else:
|
138
|
+
redacted_pairs.append((key, value))
|
139
|
+
|
140
|
+
# Reconstruct URL with redacted query
|
141
|
+
redacted_query = urlencode(redacted_pairs, doseq=True)
|
142
|
+
return urlunsplit(
|
143
|
+
(parts.scheme, parts.netloc, parts.path, redacted_query, parts.fragment)
|
144
|
+
)
|
145
|
+
|
146
|
+
except Exception:
|
147
|
+
# If URL parsing fails, return a safe representation
|
148
|
+
return _safe_str(url)
|
149
|
+
|
68
150
|
|
69
151
|
def mask_token(s: Any, *, show_prefix: int = 6, show_suffix: int = 2) -> str:
|
70
152
|
if not isinstance(s, str) or not s:
|
@@ -179,6 +261,27 @@ def get_logger(name: str | None = None) -> logging.Logger:
|
|
179
261
|
return logger
|
180
262
|
|
181
263
|
|
264
|
+
def configure_httpx_logging() -> None:
|
265
|
+
"""
|
266
|
+
Configure httpx and httpcore loggers to prevent exposure of sensitive query parameters.
|
267
|
+
|
268
|
+
This is called automatically by the transport classes when debug logging is enabled.
|
269
|
+
It raises the log level of httpx/httpcore to WARNING to prevent their built-in
|
270
|
+
request/response logs from exposing URLs with sensitive query parameters.
|
271
|
+
"""
|
272
|
+
# Silence httpx's built-in request/response logs that might contain sensitive URLs
|
273
|
+
httpx_logger = logging.getLogger("httpx")
|
274
|
+
httpcore_logger = logging.getLogger("httpcore.http11")
|
275
|
+
|
276
|
+
# If our logger is at DEBUG level, silence httpx to prevent duplicate/unredacted logs
|
277
|
+
if logger.isEnabledFor(logging.DEBUG):
|
278
|
+
if httpx_logger.level < logging.WARNING:
|
279
|
+
httpx_logger.setLevel(logging.WARNING)
|
280
|
+
# Keep httpcore at INFO level for connection details (no URLs)
|
281
|
+
if httpcore_logger.level < logging.INFO:
|
282
|
+
httpcore_logger.setLevel(logging.INFO)
|
283
|
+
|
284
|
+
|
182
285
|
# ---- decorator for async methods --------------------------------------------
|
183
286
|
|
184
287
|
|