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,212 @@
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