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.
Files changed (38) 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 +258 -0
  9. bookalimo/integrations/google_places/client_sync.py +257 -0
  10. bookalimo/integrations/google_places/common.py +245 -0
  11. bookalimo/integrations/google_places/proto_adapter.py +224 -0
  12. bookalimo/{_logging.py → logging.py} +59 -62
  13. bookalimo/schemas/__init__.py +97 -0
  14. bookalimo/schemas/base.py +56 -0
  15. bookalimo/{models.py → schemas/booking.py} +88 -100
  16. bookalimo/schemas/places/__init__.py +37 -0
  17. bookalimo/schemas/places/common.py +198 -0
  18. bookalimo/schemas/places/google.py +596 -0
  19. bookalimo/schemas/places/place.py +337 -0
  20. bookalimo/services/__init__.py +11 -0
  21. bookalimo/services/pricing.py +191 -0
  22. bookalimo/services/reservations.py +227 -0
  23. bookalimo/transport/__init__.py +7 -0
  24. bookalimo/transport/auth.py +41 -0
  25. bookalimo/transport/base.py +44 -0
  26. bookalimo/transport/httpx_async.py +230 -0
  27. bookalimo/transport/httpx_sync.py +230 -0
  28. bookalimo/transport/retry.py +102 -0
  29. bookalimo/transport/utils.py +59 -0
  30. bookalimo-1.0.0.dist-info/METADATA +307 -0
  31. bookalimo-1.0.0.dist-info/RECORD +35 -0
  32. bookalimo/_client.py +0 -420
  33. bookalimo/wrapper.py +0 -444
  34. bookalimo-0.1.4.dist-info/METADATA +0 -392
  35. bookalimo-0.1.4.dist-info/RECORD +0 -12
  36. {bookalimo-0.1.4.dist-info → bookalimo-1.0.0.dist-info}/WHEEL +0 -0
  37. {bookalimo-0.1.4.dist-info → bookalimo-1.0.0.dist-info}/licenses/LICENSE +0 -0
  38. {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 configuration for bookalimo package.
3
- Public SDK-style logging with built-in redaction helpers.
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
- logger.addHandler(logging.NullHandler())
17
- logger.setLevel(logging.WARNING)
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[..., Any]], Callable[..., Any]]:
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[..., Any]) -> Callable[..., Any]:
191
- async def _async_wrapper(self: Any, *args: Any, **kwargs: Any) -> Any:
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(self, *args, **kwargs)
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
- result_type,
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
  )