bookalimo 0.1.4__py3-none-any.whl → 1.0.0__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/__init__.py +17 -24
- bookalimo/_version.py +9 -0
- bookalimo/client.py +310 -0
- bookalimo/config.py +16 -0
- bookalimo/exceptions.py +115 -5
- bookalimo/integrations/__init__.py +1 -0
- bookalimo/integrations/google_places/__init__.py +31 -0
- bookalimo/integrations/google_places/client_async.py +258 -0
- bookalimo/integrations/google_places/client_sync.py +257 -0
- bookalimo/integrations/google_places/common.py +245 -0
- bookalimo/integrations/google_places/proto_adapter.py +224 -0
- bookalimo/{_logging.py → logging.py} +59 -62
- bookalimo/schemas/__init__.py +97 -0
- bookalimo/schemas/base.py +56 -0
- bookalimo/{models.py → schemas/booking.py} +88 -100
- bookalimo/schemas/places/__init__.py +37 -0
- bookalimo/schemas/places/common.py +198 -0
- bookalimo/schemas/places/google.py +596 -0
- bookalimo/schemas/places/place.py +337 -0
- bookalimo/services/__init__.py +11 -0
- bookalimo/services/pricing.py +191 -0
- bookalimo/services/reservations.py +227 -0
- bookalimo/transport/__init__.py +7 -0
- bookalimo/transport/auth.py +41 -0
- bookalimo/transport/base.py +44 -0
- bookalimo/transport/httpx_async.py +230 -0
- bookalimo/transport/httpx_sync.py +230 -0
- bookalimo/transport/retry.py +102 -0
- bookalimo/transport/utils.py +59 -0
- bookalimo-1.0.0.dist-info/METADATA +307 -0
- bookalimo-1.0.0.dist-info/RECORD +35 -0
- bookalimo/_client.py +0 -420
- bookalimo/wrapper.py +0 -444
- bookalimo-0.1.4.dist-info/METADATA +0 -392
- bookalimo-0.1.4.dist-info/RECORD +0 -12
- {bookalimo-0.1.4.dist-info → bookalimo-1.0.0.dist-info}/WHEEL +0 -0
- {bookalimo-0.1.4.dist-info → bookalimo-1.0.0.dist-info}/licenses/LICENSE +0 -0
- {bookalimo-0.1.4.dist-info → bookalimo-1.0.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,245 @@
|
|
1
|
+
"""
|
2
|
+
Common utilities and shared functionality for Google Places clients.
|
3
|
+
"""
|
4
|
+
|
5
|
+
from __future__ import annotations
|
6
|
+
|
7
|
+
from typing import Literal, Sequence, Union
|
8
|
+
|
9
|
+
from ...logging import get_logger
|
10
|
+
|
11
|
+
logger = get_logger("places")
|
12
|
+
|
13
|
+
|
14
|
+
Fields = Union[
|
15
|
+
str,
|
16
|
+
Sequence[
|
17
|
+
Literal[
|
18
|
+
"*",
|
19
|
+
# Identity
|
20
|
+
"name",
|
21
|
+
# Labels & typing
|
22
|
+
"display_name",
|
23
|
+
"types",
|
24
|
+
"primary_type",
|
25
|
+
"primary_type_display_name",
|
26
|
+
# Phones & addresses
|
27
|
+
"national_phone_number",
|
28
|
+
"international_phone_number",
|
29
|
+
"formatted_address",
|
30
|
+
"short_formatted_address",
|
31
|
+
"postal_address",
|
32
|
+
"address_components",
|
33
|
+
"plus_code",
|
34
|
+
# Location & map
|
35
|
+
"location",
|
36
|
+
"viewport",
|
37
|
+
# Scores, links, media
|
38
|
+
"rating",
|
39
|
+
"google_maps_uri",
|
40
|
+
"website_uri",
|
41
|
+
"reviews",
|
42
|
+
"photos",
|
43
|
+
# Hours
|
44
|
+
"regular_opening_hours",
|
45
|
+
"current_opening_hours",
|
46
|
+
"current_secondary_opening_hours",
|
47
|
+
"regular_secondary_opening_hours",
|
48
|
+
"utc_offset_minutes",
|
49
|
+
"time_zone",
|
50
|
+
# Misc attributes
|
51
|
+
"adr_format_address",
|
52
|
+
"business_status",
|
53
|
+
"price_level",
|
54
|
+
"attributions",
|
55
|
+
"user_rating_count",
|
56
|
+
"icon_mask_base_uri",
|
57
|
+
"icon_background_color",
|
58
|
+
# Food/venue features
|
59
|
+
"takeout",
|
60
|
+
"delivery",
|
61
|
+
"dine_in",
|
62
|
+
"curbside_pickup",
|
63
|
+
"reservable",
|
64
|
+
"serves_breakfast",
|
65
|
+
"serves_lunch",
|
66
|
+
"serves_dinner",
|
67
|
+
"serves_beer",
|
68
|
+
"serves_wine",
|
69
|
+
"serves_brunch",
|
70
|
+
"serves_vegetarian_food",
|
71
|
+
"outdoor_seating",
|
72
|
+
"live_music",
|
73
|
+
"menu_for_children",
|
74
|
+
"serves_cocktails",
|
75
|
+
"serves_dessert",
|
76
|
+
"serves_coffee",
|
77
|
+
"good_for_children",
|
78
|
+
"allows_dogs",
|
79
|
+
"restroom",
|
80
|
+
"good_for_groups",
|
81
|
+
"good_for_watching_sports",
|
82
|
+
# Options & related places
|
83
|
+
"payment_options",
|
84
|
+
"parking_options",
|
85
|
+
"sub_destinations",
|
86
|
+
"accessibility_options",
|
87
|
+
# Fuel/EV & AI summaries
|
88
|
+
"fuel_options",
|
89
|
+
"ev_charge_options",
|
90
|
+
"generative_summary",
|
91
|
+
"review_summary",
|
92
|
+
"ev_charge_amenity_summary",
|
93
|
+
"neighborhood_summary",
|
94
|
+
# Context
|
95
|
+
"containing_places",
|
96
|
+
"pure_service_area_business",
|
97
|
+
"address_descriptor",
|
98
|
+
"price_range",
|
99
|
+
# Missing in your model but present in proto
|
100
|
+
"editorial_summary",
|
101
|
+
]
|
102
|
+
],
|
103
|
+
]
|
104
|
+
|
105
|
+
PlaceListFields = Union[
|
106
|
+
str,
|
107
|
+
Sequence[
|
108
|
+
Literal[
|
109
|
+
"*",
|
110
|
+
# Identity
|
111
|
+
"places.name",
|
112
|
+
# Labels & typing
|
113
|
+
"places.display_name",
|
114
|
+
"places.types",
|
115
|
+
"places.primary_type",
|
116
|
+
"places.primary_type_display_name",
|
117
|
+
# Phones & addresses
|
118
|
+
"places.national_phone_number",
|
119
|
+
"places.international_phone_number",
|
120
|
+
"places.formatted_address",
|
121
|
+
"places.short_formatted_address",
|
122
|
+
"places.postal_address",
|
123
|
+
"places.address_components",
|
124
|
+
"places.plus_code",
|
125
|
+
# Location & map
|
126
|
+
"places.location",
|
127
|
+
"places.viewport",
|
128
|
+
# Scores, links, media
|
129
|
+
"places.rating",
|
130
|
+
"places.google_maps_uri",
|
131
|
+
"places.website_uri",
|
132
|
+
"places.reviews",
|
133
|
+
"places.photos",
|
134
|
+
# Hours
|
135
|
+
"places.regular_opening_hours",
|
136
|
+
"places.current_opening_hours",
|
137
|
+
"places.current_secondary_opening_hours",
|
138
|
+
"places.regular_secondary_opening_hours",
|
139
|
+
"places.utc_offset_minutes",
|
140
|
+
"places.time_zone",
|
141
|
+
# Misc attributes
|
142
|
+
"places.adr_format_address",
|
143
|
+
"places.business_status",
|
144
|
+
"places.price_level",
|
145
|
+
"places.attributions",
|
146
|
+
"places.user_rating_count",
|
147
|
+
"places.icon_mask_base_uri",
|
148
|
+
"places.icon_background_color",
|
149
|
+
# Food/venue features
|
150
|
+
"places.takeout",
|
151
|
+
"places.delivery",
|
152
|
+
"places.dine_in",
|
153
|
+
"places.curbside_pickup",
|
154
|
+
"places.reservable",
|
155
|
+
"places.serves_breakfast",
|
156
|
+
"places.serves_lunch",
|
157
|
+
"places.serves_dinner",
|
158
|
+
"places.serves_beer",
|
159
|
+
"places.serves_wine",
|
160
|
+
"places.serves_brunch",
|
161
|
+
"places.serves_vegetarian_food",
|
162
|
+
"places.outdoor_seating",
|
163
|
+
"places.live_music",
|
164
|
+
"places.menu_for_children",
|
165
|
+
"places.serves_cocktails",
|
166
|
+
"places.serves_dessert",
|
167
|
+
"places.serves_coffee",
|
168
|
+
"places.good_for_children",
|
169
|
+
"places.allows_dogs",
|
170
|
+
"places.restroom",
|
171
|
+
"places.good_for_groups",
|
172
|
+
"places.good_for_watching_sports",
|
173
|
+
# Options & related places
|
174
|
+
"places.payment_options",
|
175
|
+
"places.parking_options",
|
176
|
+
"places.sub_destinations",
|
177
|
+
"places.accessibility_options",
|
178
|
+
# Fuel/EV & AI summaries
|
179
|
+
"places.fuel_options",
|
180
|
+
"places.ev_charge_options",
|
181
|
+
"places.generative_summary",
|
182
|
+
"places.review_summary",
|
183
|
+
"places.ev_charge_amenity_summary",
|
184
|
+
"places.neighborhood_summary",
|
185
|
+
# Context
|
186
|
+
"places.containing_places",
|
187
|
+
"places.pure_service_area_business",
|
188
|
+
"places.address_descriptor",
|
189
|
+
"places.price_range",
|
190
|
+
# Missing in your model but present in proto
|
191
|
+
"places.editorial_summary",
|
192
|
+
]
|
193
|
+
],
|
194
|
+
]
|
195
|
+
|
196
|
+
ADDRESS_TYPES = {
|
197
|
+
"street_address",
|
198
|
+
"route",
|
199
|
+
"intersection",
|
200
|
+
"premise",
|
201
|
+
"subpremise",
|
202
|
+
"plus_code",
|
203
|
+
"postal_code",
|
204
|
+
"locality",
|
205
|
+
"sublocality",
|
206
|
+
"neighborhood",
|
207
|
+
"administrative_area_level_1",
|
208
|
+
"administrative_area_level_2",
|
209
|
+
"country",
|
210
|
+
"floor",
|
211
|
+
"room",
|
212
|
+
}
|
213
|
+
|
214
|
+
|
215
|
+
def fmt_exc(e: BaseException) -> str:
|
216
|
+
"""Format exception for logging without touching non-existent attributes."""
|
217
|
+
return f"{type(e).__name__}: {e}"
|
218
|
+
|
219
|
+
|
220
|
+
def mask_header(fields: Sequence[str] | str | None) -> tuple[tuple[str, str], ...]:
|
221
|
+
"""
|
222
|
+
Build the X-Goog-FieldMask header. Pass a comma-separated string or a sequence.
|
223
|
+
If None, no header is added (e.g., autocomplete, get_photo_media).
|
224
|
+
"""
|
225
|
+
if fields is None:
|
226
|
+
return ()
|
227
|
+
if isinstance(fields, str):
|
228
|
+
value = fields
|
229
|
+
else:
|
230
|
+
value = ",".join(fields)
|
231
|
+
return (("x-goog-fieldmask", value),)
|
232
|
+
|
233
|
+
|
234
|
+
# Default field mask for places queries
|
235
|
+
DEFAULT_PLACE_FIELDS: Fields = (
|
236
|
+
"display_name",
|
237
|
+
"formatted_address",
|
238
|
+
"location",
|
239
|
+
)
|
240
|
+
|
241
|
+
DEFAULT_PLACE_LIST_FIELDS: PlaceListFields = (
|
242
|
+
"places.display_name",
|
243
|
+
"places.formatted_address",
|
244
|
+
"places.location",
|
245
|
+
)
|
@@ -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
|
@@ -1,6 +1,15 @@
|
|
1
1
|
"""
|
2
|
-
Logging
|
3
|
-
|
2
|
+
Logging utilities for the Bookalimo SDK.
|
3
|
+
|
4
|
+
The SDK uses Python's standard logging module. To enable debug logging,
|
5
|
+
configure the 'bookalimo' logger or set the BOOKALIMO_LOG_LEVEL environment variable.
|
6
|
+
|
7
|
+
Example:
|
8
|
+
import logging
|
9
|
+
logging.getLogger('bookalimo').setLevel(logging.DEBUG)
|
10
|
+
|
11
|
+
# Or via environment variable
|
12
|
+
export BOOKALIMO_LOG_LEVEL=DEBUG
|
4
13
|
"""
|
5
14
|
|
6
15
|
from __future__ import annotations
|
@@ -8,13 +17,51 @@ from __future__ import annotations
|
|
8
17
|
import logging
|
9
18
|
import os
|
10
19
|
import re
|
11
|
-
from collections.abc import Iterable, Mapping
|
20
|
+
from collections.abc import Awaitable, Iterable, Mapping
|
21
|
+
from functools import wraps
|
12
22
|
from time import perf_counter
|
13
|
-
from typing import Any, Callable
|
23
|
+
from typing import Any, Callable, TypeVar
|
24
|
+
|
25
|
+
from typing_extensions import ParamSpec
|
26
|
+
|
27
|
+
P = ParamSpec("P")
|
28
|
+
R = TypeVar("R")
|
29
|
+
|
30
|
+
|
31
|
+
def _level_from_env() -> int | None:
|
32
|
+
"""Get log level from BOOKALIMO_LOG_LEVEL environment variable."""
|
33
|
+
lvl = os.getenv("BOOKALIMO_LOG_LEVEL")
|
34
|
+
if not lvl:
|
35
|
+
return None
|
36
|
+
try:
|
37
|
+
return int(lvl)
|
38
|
+
except ValueError:
|
39
|
+
try:
|
40
|
+
return logging._nameToLevel.get(lvl.upper(), None)
|
41
|
+
except Exception:
|
42
|
+
return None
|
43
|
+
|
14
44
|
|
15
45
|
logger = logging.getLogger("bookalimo")
|
16
|
-
|
17
|
-
|
46
|
+
|
47
|
+
# Apply environment variable level if set, otherwise use WARNING as default
|
48
|
+
env_level = _level_from_env()
|
49
|
+
logger.setLevel(env_level if env_level is not None else logging.WARNING)
|
50
|
+
|
51
|
+
# If user set BOOKALIMO_LOG_LEVEL, they expect to see logs - add console handler
|
52
|
+
if env_level is not None:
|
53
|
+
# Only add console handler if one doesn't already exist
|
54
|
+
if not any(isinstance(h, logging.StreamHandler) for h in logger.handlers):
|
55
|
+
console_handler = logging.StreamHandler()
|
56
|
+
console_handler.setLevel(env_level)
|
57
|
+
formatter = logging.Formatter(
|
58
|
+
"%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
59
|
+
)
|
60
|
+
console_handler.setFormatter(formatter)
|
61
|
+
logger.addHandler(console_handler)
|
62
|
+
else:
|
63
|
+
# If no env var is set, use NullHandler (library default behavior)
|
64
|
+
logger.addHandler(logging.NullHandler())
|
18
65
|
|
19
66
|
REDACTED = "******"
|
20
67
|
|
@@ -132,44 +179,6 @@ def get_logger(name: str | None = None) -> logging.Logger:
|
|
132
179
|
return logger
|
133
180
|
|
134
181
|
|
135
|
-
def enable_debug_logging(level: int | None = None) -> None:
|
136
|
-
level = level or _level_from_env() or logging.DEBUG
|
137
|
-
logger.setLevel(level)
|
138
|
-
|
139
|
-
has_real_handler = any(
|
140
|
-
not isinstance(h, logging.NullHandler) for h in logger.handlers
|
141
|
-
)
|
142
|
-
if not has_real_handler:
|
143
|
-
handler = logging.StreamHandler()
|
144
|
-
formatter = logging.Formatter(
|
145
|
-
"%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
146
|
-
)
|
147
|
-
handler.setFormatter(formatter)
|
148
|
-
logger.addHandler(handler)
|
149
|
-
|
150
|
-
logger.info("bookalimo logging enabled at %s", logging.getLevelName(logger.level))
|
151
|
-
|
152
|
-
|
153
|
-
def disable_debug_logging() -> None:
|
154
|
-
logger.setLevel(logging.WARNING)
|
155
|
-
for handler in logger.handlers[:]:
|
156
|
-
if not isinstance(handler, logging.NullHandler):
|
157
|
-
logger.removeHandler(handler)
|
158
|
-
|
159
|
-
|
160
|
-
def _level_from_env() -> int | None:
|
161
|
-
lvl = os.getenv("BOOKALIMO_LOG_LEVEL")
|
162
|
-
if not lvl:
|
163
|
-
return None
|
164
|
-
try:
|
165
|
-
return int(lvl)
|
166
|
-
except ValueError:
|
167
|
-
try:
|
168
|
-
return logging._nameToLevel.get(lvl.upper(), None)
|
169
|
-
except Exception:
|
170
|
-
return None
|
171
|
-
|
172
|
-
|
173
182
|
# ---- decorator for async methods --------------------------------------------
|
174
183
|
|
175
184
|
|
@@ -178,27 +187,19 @@ def log_call(
|
|
178
187
|
include_params: Iterable[str] | None = None,
|
179
188
|
transforms: Mapping[str, Callable[[Any], Any]] | None = None,
|
180
189
|
operation: str | None = None,
|
181
|
-
) -> Callable[[Callable[
|
182
|
-
"""
|
183
|
-
Decorator for async SDK methods.
|
184
|
-
- DEBUG: logs start/end with sanitized params + duration
|
185
|
-
- WARNING: logs errors (sanitized). No overhead when DEBUG is off.
|
186
|
-
"""
|
190
|
+
) -> Callable[[Callable[P, Awaitable[R]]], Callable[P, Awaitable[R]]]:
|
187
191
|
include = set(include_params or [])
|
188
192
|
transforms = transforms or {}
|
189
193
|
|
190
|
-
def _decorate(fn: Callable[
|
191
|
-
|
194
|
+
def _decorate(fn: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]:
|
195
|
+
@wraps(fn)
|
196
|
+
async def _async_wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
192
197
|
log = get_logger("wrapper")
|
193
198
|
op = operation or fn.__name__
|
194
199
|
|
195
|
-
# Fast path: if debug disabled, skip param binding/redaction entirely
|
196
200
|
debug_on = log.isEnabledFor(logging.DEBUG)
|
197
201
|
if debug_on:
|
198
|
-
# Build a minimal, sanitized args snapshot
|
199
202
|
snapshot: dict[str, Any] = {}
|
200
|
-
# Map positional args to param names without inspect overhead by relying on kwargs only:
|
201
|
-
# we assume call sites are using kwargs in the wrapper (they do).
|
202
203
|
for k in include:
|
203
204
|
val = kwargs.get(k, None)
|
204
205
|
if k in transforms:
|
@@ -209,7 +210,6 @@ def log_call(
|
|
209
210
|
else:
|
210
211
|
val = redact_param(k, val)
|
211
212
|
snapshot[k] = val
|
212
|
-
|
213
213
|
start = perf_counter()
|
214
214
|
log.debug(
|
215
215
|
"→ %s(%s)",
|
@@ -219,21 +219,18 @@ def log_call(
|
|
219
219
|
)
|
220
220
|
|
221
221
|
try:
|
222
|
-
result = await fn(
|
222
|
+
result = await fn(*args, **kwargs)
|
223
223
|
if debug_on:
|
224
224
|
dur_ms = (perf_counter() - start) * 1000.0
|
225
|
-
# Keep result logging ultra-light
|
226
|
-
result_type = type(result).__name__
|
227
225
|
log.debug(
|
228
226
|
"← %s ok in %.1f ms (%s)",
|
229
227
|
op,
|
230
228
|
dur_ms,
|
231
|
-
|
229
|
+
type(result).__name__,
|
232
230
|
extra={"operation": op},
|
233
231
|
)
|
234
232
|
return result
|
235
233
|
except Exception as e:
|
236
|
-
# WARNING with sanitized error; no param dump on failures
|
237
234
|
log.warning(
|
238
235
|
"%s failed: %s", op, e.__class__.__name__, extra={"operation": op}
|
239
236
|
)
|