bookalimo 0.1.5__py3-none-any.whl → 1.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +289 -0
- bookalimo/integrations/google_places/client_sync.py +287 -0
- bookalimo/integrations/google_places/common.py +231 -0
- bookalimo/integrations/google_places/proto_adapter.py +224 -0
- bookalimo/integrations/google_places/resolve_airport.py +397 -0
- bookalimo/integrations/google_places/transports.py +98 -0
- bookalimo/{_logging.py → logging.py} +45 -42
- bookalimo/schemas/__init__.py +103 -0
- bookalimo/schemas/base.py +56 -0
- bookalimo/{models.py → schemas/booking.py} +88 -100
- bookalimo/schemas/places/__init__.py +62 -0
- bookalimo/schemas/places/common.py +351 -0
- bookalimo/schemas/places/field_mask.py +221 -0
- bookalimo/schemas/places/google.py +883 -0
- bookalimo/schemas/places/place.py +334 -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.1.dist-info/METADATA +370 -0
- bookalimo-1.0.1.dist-info/RECORD +38 -0
- bookalimo-1.0.1.dist-info/licenses/LICENSE +21 -0
- bookalimo/_client.py +0 -420
- bookalimo/wrapper.py +0 -444
- bookalimo-0.1.5.dist-info/METADATA +0 -392
- bookalimo-0.1.5.dist-info/RECORD +0 -12
- bookalimo-0.1.5.dist-info/licenses/LICENSE +0 -0
- {bookalimo-0.1.5.dist-info → bookalimo-1.0.1.dist-info}/WHEEL +0 -0
- {bookalimo-0.1.5.dist-info → bookalimo-1.0.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,351 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import re
|
4
|
+
from enum import IntEnum
|
5
|
+
from typing import Optional
|
6
|
+
|
7
|
+
from pydantic import (
|
8
|
+
AnyUrl,
|
9
|
+
BaseModel,
|
10
|
+
ConfigDict,
|
11
|
+
Field,
|
12
|
+
field_validator,
|
13
|
+
model_validator,
|
14
|
+
)
|
15
|
+
from typing_extensions import Self
|
16
|
+
|
17
|
+
BCP47 = re.compile(
|
18
|
+
r"^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$"
|
19
|
+
) # pragmatic, e.g., "en", "en-US", "zh-Hant"
|
20
|
+
|
21
|
+
CLDR_REGION_2 = re.compile(r"^[A-Z]{2}$") # e.g., "US", "GB"
|
22
|
+
|
23
|
+
PLACE_ID = re.compile(
|
24
|
+
r"^[A-Za-z0-9_-]{10,}$"
|
25
|
+
) # pragmatic: real IDs are long base64url-ish e.g., "ChIJN1t_tDeuEmsRUsoyG83frY4"
|
26
|
+
|
27
|
+
PLACE_RESOURCE = re.compile(
|
28
|
+
r"^places/[A-Za-z0-9_-]{10,}$"
|
29
|
+
) # e.g., "places/ChIJN1t_tDeuEmsRUsoyG83frY4"
|
30
|
+
|
31
|
+
PLACE_TYPE = re.compile(r"^[a-z0-9_]+$") # e.g., "gas_station", "restaurant"
|
32
|
+
|
33
|
+
HEX_COLOR = re.compile(r"^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$") # #abc, #a1b2c3
|
34
|
+
|
35
|
+
PLUS_GLOBAL = re.compile(
|
36
|
+
r"^[23456789CFGHJMPQRVWX]{4,8}\+[23456789CFGHJMPQRVWX]{2,}$"
|
37
|
+
) # Open Location Code (Plus Code), pragmatic
|
38
|
+
|
39
|
+
BASE64URL_36 = re.compile(
|
40
|
+
r"^[A-Za-z0-9_-]{1,36}$"
|
41
|
+
) # up to 36 chars, URL-safe base64-ish
|
42
|
+
|
43
|
+
|
44
|
+
# ---------- Fundamental types ----------
|
45
|
+
class LocalizedText(BaseModel):
|
46
|
+
"""Localized text with language code."""
|
47
|
+
|
48
|
+
model_config = ConfigDict(extra="allow")
|
49
|
+
|
50
|
+
text: str
|
51
|
+
language_code: str
|
52
|
+
|
53
|
+
@field_validator("language_code")
|
54
|
+
@classmethod
|
55
|
+
def _language_code(cls, v: str) -> str:
|
56
|
+
if not BCP47.match(v):
|
57
|
+
raise ValueError("language_code must be a valid BCP-47 language tag")
|
58
|
+
return v
|
59
|
+
|
60
|
+
|
61
|
+
# ---------- "External" Google message wrappers ----------
|
62
|
+
class ExternalModel(BaseModel):
|
63
|
+
"""Permissive wrapper for Google messages we don't model in detail."""
|
64
|
+
|
65
|
+
model_config = ConfigDict(extra="allow")
|
66
|
+
|
67
|
+
|
68
|
+
class OpeningHours(ExternalModel): ...
|
69
|
+
|
70
|
+
|
71
|
+
class PostalAddress(ExternalModel): ...
|
72
|
+
|
73
|
+
|
74
|
+
class TimeZone(ExternalModel): ...
|
75
|
+
|
76
|
+
|
77
|
+
class Timestamp(ExternalModel): ...
|
78
|
+
|
79
|
+
|
80
|
+
class Date(ExternalModel): ...
|
81
|
+
|
82
|
+
|
83
|
+
class ContentBlock(ExternalModel): ...
|
84
|
+
|
85
|
+
|
86
|
+
class Photo(ExternalModel): ...
|
87
|
+
|
88
|
+
|
89
|
+
class Review(ExternalModel): ...
|
90
|
+
|
91
|
+
|
92
|
+
class FuelOptions(ExternalModel): ...
|
93
|
+
|
94
|
+
|
95
|
+
class EVChargeOptions(ExternalModel): ...
|
96
|
+
|
97
|
+
|
98
|
+
class AddressDescriptor(BaseModel):
|
99
|
+
"""A relational description of a location with nearby landmarks and containing areas."""
|
100
|
+
|
101
|
+
model_config = ConfigDict(extra="forbid")
|
102
|
+
|
103
|
+
class Landmark(BaseModel):
|
104
|
+
"""Basic landmark information and relationship with target location."""
|
105
|
+
|
106
|
+
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
|
107
|
+
|
108
|
+
class SpatialRelationship(IntEnum):
|
109
|
+
"""Spatial relationship between target location and landmark."""
|
110
|
+
|
111
|
+
NEAR = 0
|
112
|
+
WITHIN = 1
|
113
|
+
BESIDE = 2
|
114
|
+
ACROSS_THE_ROAD = 3
|
115
|
+
DOWN_THE_ROAD = 4
|
116
|
+
AROUND_THE_CORNER = 5
|
117
|
+
BEHIND = 6
|
118
|
+
|
119
|
+
name: str = Field(..., description="Landmark's resource name")
|
120
|
+
place_id: str = Field(..., description="Landmark's place ID")
|
121
|
+
display_name: LocalizedText = Field(..., description="Landmark's display name")
|
122
|
+
types: list[str] = Field(
|
123
|
+
default_factory=list, description="Type tags for landmark"
|
124
|
+
)
|
125
|
+
spatial_relationship: SpatialRelationship = Field(
|
126
|
+
default=SpatialRelationship.NEAR,
|
127
|
+
description="Spatial relationship to target",
|
128
|
+
)
|
129
|
+
straight_line_distance_meters: float = Field(
|
130
|
+
..., ge=0.0, description="Straight line distance in meters"
|
131
|
+
)
|
132
|
+
travel_distance_meters: Optional[float] = Field(
|
133
|
+
default=None,
|
134
|
+
ge=0.0,
|
135
|
+
description="Travel distance in meters along road network",
|
136
|
+
)
|
137
|
+
|
138
|
+
@field_validator("name")
|
139
|
+
@classmethod
|
140
|
+
def _name(cls, v: str) -> str:
|
141
|
+
if not PLACE_RESOURCE.fullmatch(v):
|
142
|
+
raise ValueError("name must be in the form 'places/{place_id}'")
|
143
|
+
return v
|
144
|
+
|
145
|
+
@field_validator("place_id")
|
146
|
+
@classmethod
|
147
|
+
def _place_id(cls, v: str) -> str:
|
148
|
+
if not PLACE_ID.fullmatch(v):
|
149
|
+
raise ValueError("place_id must be a valid Place ID")
|
150
|
+
return v
|
151
|
+
|
152
|
+
@field_validator("types")
|
153
|
+
@classmethod
|
154
|
+
def _types(cls, v: list[str]) -> list[str]:
|
155
|
+
out, seen = [], set()
|
156
|
+
for raw in v:
|
157
|
+
t = raw.strip()
|
158
|
+
if not t:
|
159
|
+
raise ValueError("types cannot contain empty strings")
|
160
|
+
if not PLACE_TYPE.fullmatch(t):
|
161
|
+
raise ValueError(f"invalid place type '{t}'")
|
162
|
+
if t not in seen:
|
163
|
+
out.append(t)
|
164
|
+
seen.add(t)
|
165
|
+
return out
|
166
|
+
|
167
|
+
@model_validator(mode="after")
|
168
|
+
def _name_id_consistency(self) -> Self:
|
169
|
+
if self.name.split("/", 1)[1] != self.place_id:
|
170
|
+
raise ValueError("place_id must match the trailing component of name")
|
171
|
+
return self
|
172
|
+
|
173
|
+
class Area(BaseModel):
|
174
|
+
"""Area information and relationship with target location."""
|
175
|
+
|
176
|
+
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
|
177
|
+
|
178
|
+
class Containment(IntEnum):
|
179
|
+
"""Spatial relationship between target location and area."""
|
180
|
+
|
181
|
+
CONTAINMENT_UNSPECIFIED = 0
|
182
|
+
WITHIN = 1
|
183
|
+
OUTSKIRTS = 2
|
184
|
+
NEAR = 3
|
185
|
+
|
186
|
+
name: str = Field(..., description="Area's resource name")
|
187
|
+
place_id: str = Field(..., description="Area's place ID")
|
188
|
+
display_name: LocalizedText = Field(..., description="Area's display name")
|
189
|
+
containment: Containment = Field(
|
190
|
+
default=Containment.CONTAINMENT_UNSPECIFIED,
|
191
|
+
description="Spatial relationship to target",
|
192
|
+
)
|
193
|
+
|
194
|
+
@field_validator("name")
|
195
|
+
@classmethod
|
196
|
+
def _name(cls, v: str) -> str:
|
197
|
+
if not PLACE_RESOURCE.fullmatch(v):
|
198
|
+
raise ValueError("name must be in the form 'places/{place_id}'")
|
199
|
+
return v
|
200
|
+
|
201
|
+
@field_validator("place_id")
|
202
|
+
@classmethod
|
203
|
+
def _place_id(cls, v: str) -> str:
|
204
|
+
if not PLACE_ID.fullmatch(v):
|
205
|
+
raise ValueError("place_id must be a valid Place ID")
|
206
|
+
return v
|
207
|
+
|
208
|
+
@model_validator(mode="after")
|
209
|
+
def _name_id_consistency(self) -> Self:
|
210
|
+
if self.name.split("/", 1)[1] != self.place_id:
|
211
|
+
raise ValueError("place_id must match the trailing component of name")
|
212
|
+
return self
|
213
|
+
|
214
|
+
landmarks: list[Landmark] = Field(
|
215
|
+
default_factory=list, description="Ranked list of nearby landmarks"
|
216
|
+
)
|
217
|
+
areas: list[Area] = Field(
|
218
|
+
default_factory=list, description="Ranked list of containing or adjacent areas"
|
219
|
+
)
|
220
|
+
|
221
|
+
@field_validator("landmarks")
|
222
|
+
@classmethod
|
223
|
+
def _max_landmarks(cls, v: list[Landmark]) -> list[Landmark]:
|
224
|
+
if len(v) > 10: # Reasonable limit for API responses
|
225
|
+
raise ValueError("landmarks can contain at most 10 items")
|
226
|
+
return v
|
227
|
+
|
228
|
+
@field_validator("areas")
|
229
|
+
@classmethod
|
230
|
+
def _max_areas(cls, v: list[Area]) -> list[Area]:
|
231
|
+
if len(v) > 10: # Reasonable limit for API responses
|
232
|
+
raise ValueError("areas can contain at most 10 items")
|
233
|
+
return v
|
234
|
+
|
235
|
+
|
236
|
+
class PriceRange(ExternalModel): ...
|
237
|
+
|
238
|
+
|
239
|
+
# ---------- Geometry ----------
|
240
|
+
class LatLng(BaseModel):
|
241
|
+
model_config = ConfigDict(extra="forbid")
|
242
|
+
|
243
|
+
latitude: float = Field(..., description="[-90, 90]")
|
244
|
+
longitude: float = Field(..., description="[-180, 180]")
|
245
|
+
|
246
|
+
@model_validator(mode="after")
|
247
|
+
def _check(self) -> Self:
|
248
|
+
if not (-90 <= self.latitude <= 90):
|
249
|
+
raise ValueError("latitude must be between -90 and 90")
|
250
|
+
if not (-180 <= self.longitude <= 180):
|
251
|
+
raise ValueError("longitude must be between -180 and 180")
|
252
|
+
return self
|
253
|
+
|
254
|
+
|
255
|
+
class Viewport(BaseModel):
|
256
|
+
model_config = ConfigDict(extra="forbid")
|
257
|
+
|
258
|
+
high: LatLng
|
259
|
+
low: LatLng
|
260
|
+
|
261
|
+
@model_validator(mode="after")
|
262
|
+
def _lat_order(self) -> Self:
|
263
|
+
if self.high.latitude < self.low.latitude:
|
264
|
+
raise ValueError("high.latitude must be >= low.latitude")
|
265
|
+
return self
|
266
|
+
|
267
|
+
|
268
|
+
# ---------- Simple shared types ----------
|
269
|
+
class PlusCode(BaseModel):
|
270
|
+
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
|
271
|
+
|
272
|
+
global_code: Optional[str] = Field(default=None, description="e.g., '9FWM33GV+HQ'")
|
273
|
+
compound_code: Optional[str] = Field(default=None)
|
274
|
+
|
275
|
+
@field_validator("global_code")
|
276
|
+
@classmethod
|
277
|
+
def _check_global(cls, v: Optional[str]) -> Optional[str]:
|
278
|
+
if v is None:
|
279
|
+
return v
|
280
|
+
# Accept common formats; be permissive.
|
281
|
+
if not PLUS_GLOBAL.match(v):
|
282
|
+
# Let a variety of valid codes through without being too strict.
|
283
|
+
if "+" not in v or len(v) < 6:
|
284
|
+
raise ValueError(
|
285
|
+
"global_code must look like a valid plus code (e.g., '9FWM33GV+HQ')."
|
286
|
+
)
|
287
|
+
return v
|
288
|
+
|
289
|
+
|
290
|
+
class Attribution(BaseModel):
|
291
|
+
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
|
292
|
+
|
293
|
+
provider: str
|
294
|
+
provider_uri: Optional[AnyUrl] = None
|
295
|
+
|
296
|
+
|
297
|
+
class SubDestination(BaseModel):
|
298
|
+
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
|
299
|
+
|
300
|
+
name: str = Field(..., description="places/{place_id}")
|
301
|
+
id: str = Field(..., description="{place_id}")
|
302
|
+
|
303
|
+
@field_validator("name")
|
304
|
+
@classmethod
|
305
|
+
def _name(cls, v: str) -> str:
|
306
|
+
if not PLACE_RESOURCE.fullmatch(v):
|
307
|
+
raise ValueError("name must be in the form 'places/{place_id}'")
|
308
|
+
return v
|
309
|
+
|
310
|
+
@field_validator("id")
|
311
|
+
@classmethod
|
312
|
+
def _id(cls, v: str) -> str:
|
313
|
+
if not PLACE_ID.fullmatch(v):
|
314
|
+
raise ValueError("id must be a base64url-like token (>=10 chars)")
|
315
|
+
return v
|
316
|
+
|
317
|
+
@model_validator(mode="after")
|
318
|
+
def _match(self) -> Self:
|
319
|
+
if self.name.split("/", 1)[1] != self.id:
|
320
|
+
raise ValueError("id must match the trailing component of name")
|
321
|
+
return self
|
322
|
+
|
323
|
+
|
324
|
+
class AccessibilityOptions(BaseModel):
|
325
|
+
model_config = ConfigDict(extra="forbid")
|
326
|
+
|
327
|
+
wheelchair_accessible_parking: Optional[bool] = None
|
328
|
+
wheelchair_accessible_entrance: Optional[bool] = None
|
329
|
+
wheelchair_accessible_restroom: Optional[bool] = None
|
330
|
+
wheelchair_accessible_seating: Optional[bool] = None
|
331
|
+
|
332
|
+
|
333
|
+
class PaymentOptions(BaseModel):
|
334
|
+
model_config = ConfigDict(extra="forbid")
|
335
|
+
|
336
|
+
accepts_credit_cards: Optional[bool] = None
|
337
|
+
accepts_debit_cards: Optional[bool] = None
|
338
|
+
accepts_cash_only: Optional[bool] = None
|
339
|
+
accepts_nfc: Optional[bool] = None
|
340
|
+
|
341
|
+
|
342
|
+
class ParkingOptions(BaseModel):
|
343
|
+
model_config = ConfigDict(extra="forbid")
|
344
|
+
|
345
|
+
free_parking_lot: Optional[bool] = None
|
346
|
+
paid_parking_lot: Optional[bool] = None
|
347
|
+
free_street_parking: Optional[bool] = None
|
348
|
+
paid_street_parking: Optional[bool] = None
|
349
|
+
valet_parking: Optional[bool] = None
|
350
|
+
free_garage_parking: Optional[bool] = None
|
351
|
+
paid_garage_parking: Optional[bool] = None
|
@@ -0,0 +1,221 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import warnings
|
4
|
+
from typing import (
|
5
|
+
Any,
|
6
|
+
Callable,
|
7
|
+
Iterable,
|
8
|
+
NewType,
|
9
|
+
Optional,
|
10
|
+
Type,
|
11
|
+
Union,
|
12
|
+
get_args,
|
13
|
+
get_origin,
|
14
|
+
)
|
15
|
+
|
16
|
+
from pydantic import BaseModel
|
17
|
+
|
18
|
+
from .common import ExternalModel
|
19
|
+
from .place import GooglePlace
|
20
|
+
|
21
|
+
FieldMaskInput = Union[str, "FieldPath", Iterable[Union[str, "FieldPath"]]]
|
22
|
+
FieldTreeType = NewType("FieldTreeType", dict[str, Any])
|
23
|
+
|
24
|
+
# Sentinel meaning “anything under this node is allowed”
|
25
|
+
ANY = object()
|
26
|
+
|
27
|
+
|
28
|
+
def _unwrap(t: Any) -> Any:
|
29
|
+
"""Unwrap Optional[T], list[T], etc. Return (base_type, is_list)."""
|
30
|
+
origin = get_origin(t)
|
31
|
+
args = get_args(t)
|
32
|
+
# Optional[T] is Union[T, NoneType]
|
33
|
+
if origin is Union and args:
|
34
|
+
non_none = [a for a in args if a is not type(None)] # noqa: E721
|
35
|
+
if len(non_none) == 1:
|
36
|
+
return _unwrap(non_none[0])
|
37
|
+
if origin in (list, tuple, set):
|
38
|
+
inner = args[0] if args else Any
|
39
|
+
base, _ = _unwrap(inner)
|
40
|
+
return base, True
|
41
|
+
return t, False
|
42
|
+
|
43
|
+
|
44
|
+
def _is_model(t: Any) -> bool:
|
45
|
+
try:
|
46
|
+
return issubclass(t, BaseModel)
|
47
|
+
except Exception:
|
48
|
+
return False
|
49
|
+
|
50
|
+
|
51
|
+
def _is_external_model(t: Any) -> bool:
|
52
|
+
try:
|
53
|
+
return issubclass(t, ExternalModel)
|
54
|
+
except Exception:
|
55
|
+
return False
|
56
|
+
|
57
|
+
|
58
|
+
def build_field_tree(root: Type[BaseModel]) -> FieldTreeType:
|
59
|
+
return _build_field_tree(root)
|
60
|
+
|
61
|
+
|
62
|
+
def _build_field_tree(root: Type[BaseModel]) -> FieldTreeType:
|
63
|
+
"""
|
64
|
+
Introspect a Pydantic v2 model into a nested dict:
|
65
|
+
{ field_name: dict(...) | ANY | None }
|
66
|
+
- dict(...) => we know nested fields (another BaseModel with declared fields)
|
67
|
+
- ANY => ExternalModel (extra=allow) or otherwise permissive; allow any nested
|
68
|
+
- None => leaf / scalar
|
69
|
+
"""
|
70
|
+
tree = {}
|
71
|
+
|
72
|
+
# v2: model_fields holds FieldInfo by name
|
73
|
+
for name, fi in root.model_fields.items():
|
74
|
+
t, is_list = _unwrap(fi.annotation)
|
75
|
+
if _is_model(t):
|
76
|
+
if _is_external_model(t):
|
77
|
+
tree[name] = ANY
|
78
|
+
else:
|
79
|
+
# recurse into structured models
|
80
|
+
tree[name] = _build_field_tree(t)
|
81
|
+
else:
|
82
|
+
# Non-model (scalar or collection of scalars)
|
83
|
+
tree[name] = None
|
84
|
+
return FieldTreeType(tree)
|
85
|
+
|
86
|
+
|
87
|
+
# Cache once
|
88
|
+
_FIELD_TREE = build_field_tree(GooglePlace)
|
89
|
+
|
90
|
+
|
91
|
+
def _validate_path(path: str, tree: FieldTreeType) -> Optional[tuple[str, str, str]]:
|
92
|
+
"""
|
93
|
+
Validate a dotted path against the field tree.
|
94
|
+
Returns None if OK, else a human-friendly warning string.
|
95
|
+
"""
|
96
|
+
parts = path.split(".")
|
97
|
+
node: Any = tree
|
98
|
+
for i, seg in enumerate(parts):
|
99
|
+
if not isinstance(node, dict):
|
100
|
+
warn = f"'{'.'.join(parts[:i])}' is not an object, cannot select '{seg}'"
|
101
|
+
return warn, path, seg
|
102
|
+
if seg not in node:
|
103
|
+
warn = f"Unknown field '{seg}' at '{'.'.join(parts[:i]) or '<root>'}'"
|
104
|
+
return warn, path, seg
|
105
|
+
node = node[seg]
|
106
|
+
if node is ANY:
|
107
|
+
# Wildcard subtree: allow anything under it
|
108
|
+
return None
|
109
|
+
return None # fully validated
|
110
|
+
|
111
|
+
|
112
|
+
def compile_field_mask(
|
113
|
+
fields: FieldMaskInput,
|
114
|
+
*,
|
115
|
+
prefix: str = "",
|
116
|
+
on_warning: Optional[Callable[[str, str, str], None]] = None,
|
117
|
+
allow_star: bool = True,
|
118
|
+
extra_allowed_fields: Optional[list[str]] = None,
|
119
|
+
field_tree: Optional[FieldTreeType] = _FIELD_TREE,
|
120
|
+
) -> list[str]:
|
121
|
+
"""
|
122
|
+
Normalize + validate a field mask against a Pydantic model.
|
123
|
+
(mirroring the Google Places API `Place` model).
|
124
|
+
- Accepts: iterable of strings or FieldPath objects, or a single string (comma-separated ok).
|
125
|
+
- Dedupes, preserves order.
|
126
|
+
- Prefixes each with 'prefix' unless it already starts with that.
|
127
|
+
- Emits warnings via on_warning(...) but never raises on unknown nested under ExternalModel.
|
128
|
+
|
129
|
+
Args:
|
130
|
+
fields: FieldMaskInput
|
131
|
+
prefix: Prefix to add to each field
|
132
|
+
on_warning: Optional callable on_warning(warn, path, seg) -> None. If not provided, uses the default warning handler.
|
133
|
+
allow_star: Whether to allow the star wildcard
|
134
|
+
extra_allowed_fields: Optional list of fields to allow even if they are not in the field tree.
|
135
|
+
field_tree: Optional field tree to use instead of the default one.
|
136
|
+
Usage:
|
137
|
+
>>> compile_field_mask(F.display_name)
|
138
|
+
["display_name"]
|
139
|
+
>>> compile_field_mask(["reviews.text", "reviews.author_name"])
|
140
|
+
["reviews.text"]
|
141
|
+
>>> compile_field_mask("photos.author_attributions,photos.author_attributions.text")
|
142
|
+
["photos.author_attributions", "photos.author_attributions.text"]
|
143
|
+
"""
|
144
|
+
if extra_allowed_fields is None:
|
145
|
+
extra_allowed_fields = []
|
146
|
+
# Flatten to a list of strings
|
147
|
+
if isinstance(fields, str):
|
148
|
+
items: Iterable[str] = sum((s.split(",") for s in fields.split()), [])
|
149
|
+
items = [s.strip() for s in items if s.strip()]
|
150
|
+
elif isinstance(fields, FieldPath):
|
151
|
+
items = [str(fields)]
|
152
|
+
else:
|
153
|
+
items = [str(x).strip() for x in fields if str(x).strip()]
|
154
|
+
|
155
|
+
if not items:
|
156
|
+
return ["*"] if allow_star else []
|
157
|
+
|
158
|
+
seen = set()
|
159
|
+
ordered: list[str] = []
|
160
|
+
for raw in items:
|
161
|
+
if raw == "*":
|
162
|
+
# Keep '*' as-is, but usually only as the sole field
|
163
|
+
if allow_star and "*" not in seen:
|
164
|
+
seen.add("*")
|
165
|
+
ordered = ["*"] # star trumps everything else
|
166
|
+
continue
|
167
|
+
|
168
|
+
path = raw # snake_case already; don't transform
|
169
|
+
not_valid = None
|
170
|
+
if path not in extra_allowed_fields:
|
171
|
+
not_valid = _validate_path(path, field_tree or _FIELD_TREE)
|
172
|
+
if not_valid:
|
173
|
+
warn, path, seg = not_valid
|
174
|
+
if on_warning:
|
175
|
+
on_warning(warn, path, seg)
|
176
|
+
else:
|
177
|
+
warnings.warn(warn, UserWarning, stacklevel=2)
|
178
|
+
|
179
|
+
if prefix and not path.startswith(prefix):
|
180
|
+
path = f"{prefix}.{path}"
|
181
|
+
|
182
|
+
if path not in seen:
|
183
|
+
seen.add(path)
|
184
|
+
ordered.append(path)
|
185
|
+
|
186
|
+
# If '*' was included alongside others, Google prefers just '*'
|
187
|
+
if "*" in seen:
|
188
|
+
return ["*"]
|
189
|
+
|
190
|
+
return ordered
|
191
|
+
|
192
|
+
|
193
|
+
# ---- Tiny ergonomic builder for dotted paths ----
|
194
|
+
class FieldPath:
|
195
|
+
__slots__ = ("path",)
|
196
|
+
|
197
|
+
def __init__(self, path: str):
|
198
|
+
self.path = path
|
199
|
+
|
200
|
+
def __getattr__(self, name: str) -> FieldPath:
|
201
|
+
return FieldPath(f"{self.path}.{name}")
|
202
|
+
|
203
|
+
def __str__(self) -> str:
|
204
|
+
return self.path
|
205
|
+
|
206
|
+
|
207
|
+
class _Root:
|
208
|
+
def __getattr__(self, name: str) -> FieldPath:
|
209
|
+
return FieldPath(name)
|
210
|
+
|
211
|
+
|
212
|
+
F = _Root() # Usage: F.display_name, F.reviews.text, F.photos.author_attributions
|
213
|
+
|
214
|
+
|
215
|
+
if __name__ == "__main__":
|
216
|
+
print(
|
217
|
+
compile_field_mask(
|
218
|
+
"photos.author_attributions,photos.author_attributions.textt",
|
219
|
+
prefix="places",
|
220
|
+
)
|
221
|
+
)
|