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.
@@ -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