ffbb-data-client 2.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 (207) hide show
  1. ffbb_api_client_v3/__init__.py +25 -0
  2. ffbb_data_client/__init__.py +175 -0
  3. ffbb_data_client/clients/__init__.py +13 -0
  4. ffbb_data_client/clients/api_ffbb_app_client.py +2475 -0
  5. ffbb_data_client/clients/ffbb_data_client.py +2789 -0
  6. ffbb_data_client/clients/meilisearch_client.py +218 -0
  7. ffbb_data_client/clients/meilisearch_ffbb_client.py +647 -0
  8. ffbb_data_client/config.py +153 -0
  9. ffbb_data_client/data/__init__.py +25 -0
  10. ffbb_data_client/data/collections.json +1364 -0
  11. ffbb_data_client/data/endpoint_discovery.json +1875 -0
  12. ffbb_data_client/data/indexes.json +501 -0
  13. ffbb_data_client/data/openapi.json +35713 -0
  14. ffbb_data_client/data/openapi_full.json +37622 -0
  15. ffbb_data_client/helpers/__init__.py +27 -0
  16. ffbb_data_client/helpers/http_requests_helper.py +73 -0
  17. ffbb_data_client/helpers/http_requests_utils.py +502 -0
  18. ffbb_data_client/helpers/meilisearch_client_extension.py +153 -0
  19. ffbb_data_client/helpers/multi_search_query_helper.py +35 -0
  20. ffbb_data_client/models/__init__.py +241 -0
  21. ffbb_data_client/models/affiche.py +45 -0
  22. ffbb_data_client/models/cartographie.py +82 -0
  23. ffbb_data_client/models/categorie.py +55 -0
  24. ffbb_data_client/models/categorie_type.py +42 -0
  25. ffbb_data_client/models/clock.py +38 -0
  26. ffbb_data_client/models/club_contacts.py +77 -0
  27. ffbb_data_client/models/code.py +7 -0
  28. ffbb_data_client/models/commune.py +66 -0
  29. ffbb_data_client/models/competition_fields.py +309 -0
  30. ffbb_data_client/models/competition_id.py +116 -0
  31. ffbb_data_client/models/competition_id_categorie.py +31 -0
  32. ffbb_data_client/models/competition_id_sexe.py +31 -0
  33. ffbb_data_client/models/competition_id_type_competition.py +27 -0
  34. ffbb_data_client/models/competition_id_type_competition_generique.py +24 -0
  35. ffbb_data_client/models/competition_origine.py +69 -0
  36. ffbb_data_client/models/competition_origine_categorie.py +23 -0
  37. ffbb_data_client/models/competition_origine_type_competition.py +14 -0
  38. ffbb_data_client/models/competition_origine_type_competition_generique.py +24 -0
  39. ffbb_data_client/models/competition_type.py +6 -0
  40. ffbb_data_client/models/competitions_facet_distribution.py +65 -0
  41. ffbb_data_client/models/competitions_facet_stats.py +14 -0
  42. ffbb_data_client/models/competitions_hit.py +232 -0
  43. ffbb_data_client/models/competitions_multi_search_query.py +40 -0
  44. ffbb_data_client/models/competitions_query.py +11 -0
  45. ffbb_data_client/models/configuration_models.py +5 -0
  46. ffbb_data_client/models/contact_info.py +18 -0
  47. ffbb_data_client/models/content_multi_search_query.py +93 -0
  48. ffbb_data_client/models/coordonnees.py +27 -0
  49. ffbb_data_client/models/coordonnees_type.py +5 -0
  50. ffbb_data_client/models/document_flyer.py +205 -0
  51. ffbb_data_client/models/document_flyer_type.py +6 -0
  52. ffbb_data_client/models/engagement_contacts.py +97 -0
  53. ffbb_data_client/models/engagements_facet_distribution.py +59 -0
  54. ffbb_data_client/models/engagements_facet_stats.py +14 -0
  55. ffbb_data_client/models/engagements_hit.py +192 -0
  56. ffbb_data_client/models/engagements_multi_search_query.py +41 -0
  57. ffbb_data_client/models/etat.py +6 -0
  58. ffbb_data_client/models/external_competition_id.py +42 -0
  59. ffbb_data_client/models/external_id.py +72 -0
  60. ffbb_data_client/models/facet_distribution.py +13 -0
  61. ffbb_data_client/models/facet_stats.py +13 -0
  62. ffbb_data_client/models/field_set.py +10 -0
  63. ffbb_data_client/models/folder.py +35 -0
  64. ffbb_data_client/models/formation_session.py +60 -0
  65. ffbb_data_client/models/formations_facet_distribution.py +61 -0
  66. ffbb_data_client/models/formations_facet_stats.py +14 -0
  67. ffbb_data_client/models/formations_hit.py +277 -0
  68. ffbb_data_client/models/formations_multi_search_query.py +41 -0
  69. ffbb_data_client/models/game_stats_model.py +57 -0
  70. ffbb_data_client/models/game_stats_models.py +5 -0
  71. ffbb_data_client/models/generic_search.py +92 -0
  72. ffbb_data_client/models/geo.py +27 -0
  73. ffbb_data_client/models/geo_sort_order.py +6 -0
  74. ffbb_data_client/models/get_commune_response.py +18 -0
  75. ffbb_data_client/models/get_competition_response.py +523 -0
  76. ffbb_data_client/models/get_configuration_response.py +45 -0
  77. ffbb_data_client/models/get_engagement_response.py +23 -0
  78. ffbb_data_client/models/get_entraineur_response.py +18 -0
  79. ffbb_data_client/models/get_formation_response.py +28 -0
  80. ffbb_data_client/models/get_officiel_response.py +18 -0
  81. ffbb_data_client/models/get_organisme_response.py +476 -0
  82. ffbb_data_client/models/get_poule_response.py +68 -0
  83. ffbb_data_client/models/get_pratique_response.py +18 -0
  84. ffbb_data_client/models/get_rencontre_response.py +93 -0
  85. ffbb_data_client/models/get_saisons_response.py +56 -0
  86. ffbb_data_client/models/get_salle_response.py +20 -0
  87. ffbb_data_client/models/get_terrain_response.py +16 -0
  88. ffbb_data_client/models/get_tournoi_response.py +16 -0
  89. ffbb_data_client/models/gradient_color.py +27 -0
  90. ffbb_data_client/models/hit.py +16 -0
  91. ffbb_data_client/models/id_engagement_equipe.py +32 -0
  92. ffbb_data_client/models/id_organisme_equipe.py +51 -0
  93. ffbb_data_client/models/id_organisme_equipe1_logo.py +28 -0
  94. ffbb_data_client/models/id_poule.py +27 -0
  95. ffbb_data_client/models/jour.py +11 -0
  96. ffbb_data_client/models/label.py +15 -0
  97. ffbb_data_client/models/labellisation.py +30 -0
  98. ffbb_data_client/models/live.py +192 -0
  99. ffbb_data_client/models/lives.py +6 -0
  100. ffbb_data_client/models/logo.py +28 -0
  101. ffbb_data_client/models/multi_search_queries.py +24 -0
  102. ffbb_data_client/models/multi_search_query.py +96 -0
  103. ffbb_data_client/models/multi_search_result_competitions.py +14 -0
  104. ffbb_data_client/models/multi_search_result_engagements.py +14 -0
  105. ffbb_data_client/models/multi_search_result_formations.py +12 -0
  106. ffbb_data_client/models/multi_search_result_organismes.py +12 -0
  107. ffbb_data_client/models/multi_search_result_pratiques.py +12 -0
  108. ffbb_data_client/models/multi_search_result_rencontres.py +12 -0
  109. ffbb_data_client/models/multi_search_result_salles.py +12 -0
  110. ffbb_data_client/models/multi_search_result_terrains.py +12 -0
  111. ffbb_data_client/models/multi_search_result_tournois.py +12 -0
  112. ffbb_data_client/models/multi_search_results.py +103 -0
  113. ffbb_data_client/models/multi_search_results_class.py +96 -0
  114. ffbb_data_client/models/nature_sol.py +57 -0
  115. ffbb_data_client/models/niveau.py +10 -0
  116. ffbb_data_client/models/niveau_class.py +27 -0
  117. ffbb_data_client/models/niveau_extractor.py +214 -0
  118. ffbb_data_client/models/niveau_info.py +64 -0
  119. ffbb_data_client/models/niveau_models.py +14 -0
  120. ffbb_data_client/models/niveau_type.py +10 -0
  121. ffbb_data_client/models/objectif.py +7 -0
  122. ffbb_data_client/models/organisateur.py +197 -0
  123. ffbb_data_client/models/organisateur_type.py +6 -0
  124. ffbb_data_client/models/organisme_fields.py +327 -0
  125. ffbb_data_client/models/organisme_id_pere.py +177 -0
  126. ffbb_data_client/models/organismes_facet_distribution.py +46 -0
  127. ffbb_data_client/models/organismes_facet_stats.py +14 -0
  128. ffbb_data_client/models/organismes_hit.py +196 -0
  129. ffbb_data_client/models/organismes_multi_search_query.py +41 -0
  130. ffbb_data_client/models/organismes_query.py +8 -0
  131. ffbb_data_client/models/phase_code.py +23 -0
  132. ffbb_data_client/models/poule.py +35 -0
  133. ffbb_data_client/models/poule_fields.py +261 -0
  134. ffbb_data_client/models/poule_rencontre_item_model.py +69 -0
  135. ffbb_data_client/models/poules_models.py +6 -0
  136. ffbb_data_client/models/poules_query.py +9 -0
  137. ffbb_data_client/models/pratique.py +7 -0
  138. ffbb_data_client/models/pratiques_facet_distribution.py +29 -0
  139. ffbb_data_client/models/pratiques_facet_stats.py +14 -0
  140. ffbb_data_client/models/pratiques_hit.py +310 -0
  141. ffbb_data_client/models/pratiques_hit_type.py +9 -0
  142. ffbb_data_client/models/pratiques_multi_search_query.py +41 -0
  143. ffbb_data_client/models/pratiques_type_class.py +45 -0
  144. ffbb_data_client/models/publication_internet.py +6 -0
  145. ffbb_data_client/models/purple_logo.py +24 -0
  146. ffbb_data_client/models/query_fields_manager.py +75 -0
  147. ffbb_data_client/models/ranking_engagement.py +41 -0
  148. ffbb_data_client/models/rankings_models.py +6 -0
  149. ffbb_data_client/models/rencontres_engagement.py +23 -0
  150. ffbb_data_client/models/rencontres_facet_distribution.py +65 -0
  151. ffbb_data_client/models/rencontres_facet_stats.py +14 -0
  152. ffbb_data_client/models/rencontres_hit.py +271 -0
  153. ffbb_data_client/models/rencontres_multi_search_query.py +41 -0
  154. ffbb_data_client/models/saison.py +23 -0
  155. ffbb_data_client/models/saison_fields.py +36 -0
  156. ffbb_data_client/models/saisons_models.py +6 -0
  157. ffbb_data_client/models/saisons_query.py +9 -0
  158. ffbb_data_client/models/salle.py +56 -0
  159. ffbb_data_client/models/salles_facet_distribution.py +14 -0
  160. ffbb_data_client/models/salles_facet_stats.py +14 -0
  161. ffbb_data_client/models/salles_hit.py +153 -0
  162. ffbb_data_client/models/salles_multi_search_query.py +40 -0
  163. ffbb_data_client/models/sexe.py +9 -0
  164. ffbb_data_client/models/sexe_class.py +31 -0
  165. ffbb_data_client/models/source.py +5 -0
  166. ffbb_data_client/models/status.py +5 -0
  167. ffbb_data_client/models/team_engagement.py +56 -0
  168. ffbb_data_client/models/team_ranking.py +108 -0
  169. ffbb_data_client/models/terrains_categorie_championnat_3x3_libelle.py +5 -0
  170. ffbb_data_client/models/terrains_facet_distribution.py +41 -0
  171. ffbb_data_client/models/terrains_facet_stats.py +14 -0
  172. ffbb_data_client/models/terrains_hit.py +223 -0
  173. ffbb_data_client/models/terrains_multi_search_query.py +42 -0
  174. ffbb_data_client/models/terrains_name.py +5 -0
  175. ffbb_data_client/models/terrains_sexe_enum.py +7 -0
  176. ffbb_data_client/models/terrains_storage.py +5 -0
  177. ffbb_data_client/models/tournoi_type_class.py +35 -0
  178. ffbb_data_client/models/tournoi_type_enum.py +7 -0
  179. ffbb_data_client/models/tournoi_types_3x3.py +43 -0
  180. ffbb_data_client/models/tournoi_types_3x3_libelle.py +60 -0
  181. ffbb_data_client/models/tournoi_types_3x3_libelle_enum.py +10 -0
  182. ffbb_data_client/models/tournois_facet_distribution.py +41 -0
  183. ffbb_data_client/models/tournois_facet_stats.py +14 -0
  184. ffbb_data_client/models/tournois_hit.py +132 -0
  185. ffbb_data_client/models/tournois_hit_type.py +5 -0
  186. ffbb_data_client/models/tournois_libelle.py +7 -0
  187. ffbb_data_client/models/tournois_multi_search_query.py +40 -0
  188. ffbb_data_client/models/type_association.py +23 -0
  189. ffbb_data_client/models/type_association_libelle.py +30 -0
  190. ffbb_data_client/models/type_class.py +23 -0
  191. ffbb_data_client/models/type_competition.py +8 -0
  192. ffbb_data_client/models/type_competition_generique.py +31 -0
  193. ffbb_data_client/models/type_enum.py +7 -0
  194. ffbb_data_client/models/type_league.py +6 -0
  195. ffbb_data_client/py.typed +0 -0
  196. ffbb_data_client/utils/__init__.py +27 -0
  197. ffbb_data_client/utils/cache_manager.py +393 -0
  198. ffbb_data_client/utils/converter_utils.py +329 -0
  199. ffbb_data_client/utils/input_validation.py +360 -0
  200. ffbb_data_client/utils/retry_utils.py +478 -0
  201. ffbb_data_client/utils/secure_logging.py +153 -0
  202. ffbb_data_client/utils/token_manager.py +115 -0
  203. ffbb_data_client-2.0.0.dist-info/METADATA +339 -0
  204. ffbb_data_client-2.0.0.dist-info/RECORD +207 -0
  205. ffbb_data_client-2.0.0.dist-info/WHEEL +5 -0
  206. ffbb_data_client-2.0.0.dist-info/licenses/LICENSE.txt +201 -0
  207. ffbb_data_client-2.0.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,329 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from collections.abc import Callable
5
+ from datetime import datetime, time, timedelta, timezone
6
+ from enum import Enum
7
+ from typing import Any, TypeVar
8
+ from uuid import UUID
9
+
10
+ import dateutil.parser
11
+
12
+ T = TypeVar("T")
13
+ EnumT = TypeVar("EnumT", bound=Enum)
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ def from_officiels_list(x: Any) -> list | None:
19
+ """
20
+ Handle officiels field which can be either:
21
+ - A comma-separated string (old format)
22
+ - A list of dicts (new format)
23
+ - None
24
+ """
25
+ if x is None:
26
+ return None
27
+ if isinstance(x, list):
28
+ return x # Return as-is if already a list
29
+ if isinstance(x, str):
30
+ return [s.strip() for s in x.split(",")] if x else None
31
+ return None
32
+
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # from_TYPE helpers — direct dict-key extraction with type coercion
36
+ # ---------------------------------------------------------------------------
37
+
38
+
39
+ def from_str(obj: dict, key: str) -> str | None:
40
+ x = obj.get(key)
41
+ if x is None:
42
+ return None
43
+ if isinstance(x, str):
44
+ return x
45
+ try:
46
+ return str(x)
47
+ except (TypeError, ValueError):
48
+ logger.warning(
49
+ "from_str(%r): cannot convert %s to str (value: %.100r)",
50
+ key,
51
+ type(x).__name__,
52
+ x,
53
+ )
54
+ return None
55
+
56
+
57
+ def from_int(obj: dict, key: str) -> int | None:
58
+ x = obj.get(key)
59
+ if x is None:
60
+ return None
61
+ if isinstance(x, int) and not isinstance(x, bool):
62
+ return x
63
+ if isinstance(x, str):
64
+ if not x.strip():
65
+ return None
66
+ try:
67
+ return int(x)
68
+ except ValueError:
69
+ logger.warning("from_int(%r): cannot parse %r as int", key, x)
70
+ return None
71
+ if isinstance(x, float):
72
+ return int(x)
73
+ logger.warning(
74
+ "from_int(%r): unexpected type %s (value: %.100r)",
75
+ key,
76
+ type(x).__name__,
77
+ x,
78
+ )
79
+ return None
80
+
81
+
82
+ def from_float(obj: dict, key: str) -> float | None:
83
+ x = obj.get(key)
84
+ if x is None:
85
+ return None
86
+ if isinstance(x, (float, int)) and not isinstance(x, bool):
87
+ return float(x)
88
+ if isinstance(x, str):
89
+ try:
90
+ return float(x)
91
+ except ValueError:
92
+ logger.warning("from_float(%r): cannot parse %r as float", key, x)
93
+ return None
94
+ logger.warning(
95
+ "from_float(%r): unexpected type %s (value: %.100r)",
96
+ key,
97
+ type(x).__name__,
98
+ x,
99
+ )
100
+ return None
101
+
102
+
103
+ def from_bool(obj: dict, key: str) -> bool | None:
104
+ x = obj.get(key)
105
+ if x is None:
106
+ return None
107
+ if isinstance(x, bool):
108
+ return x
109
+ if isinstance(x, str):
110
+ if x.lower() == "true":
111
+ return True
112
+ if x.lower() == "false":
113
+ return False
114
+ logger.warning("from_bool(%r): cannot parse %r as bool", key, x)
115
+ return None
116
+ logger.warning(
117
+ "from_bool(%r): unexpected type %s (value: %.100r)",
118
+ key,
119
+ type(x).__name__,
120
+ x,
121
+ )
122
+ return None
123
+
124
+
125
+ def from_datetime(obj: dict, key: str) -> datetime | None:
126
+ x = obj.get(key)
127
+ if not x:
128
+ return None
129
+ if isinstance(x, str):
130
+ try:
131
+ # ⚡ Bolt optimization: datetime.fromisoformat is ~10x faster than dateutil.parser.parse
132
+ # We try the fast native ISO parsing first, then fallback to dateutil for complex formats
133
+ clean_str = x.replace("Z", "+00:00") if x.endswith("Z") else x
134
+ return datetime.fromisoformat(clean_str)
135
+ except ValueError:
136
+ try:
137
+ result: datetime = dateutil.parser.parse(x)
138
+ return result
139
+ except (ValueError, dateutil.parser.ParserError):
140
+ logger.warning("from_datetime(%r): cannot parse %r as datetime", key, x)
141
+ return None
142
+ logger.warning(
143
+ "from_datetime(%r): unexpected type %s (value: %.100r)",
144
+ key,
145
+ type(x).__name__,
146
+ x,
147
+ )
148
+ return None
149
+
150
+
151
+ def from_time(obj: dict, key: str) -> time | None:
152
+ x = obj.get(key)
153
+ if not x:
154
+ return None
155
+ if isinstance(x, str):
156
+ # Format HH:MM:SS (Meilisearch)
157
+ if ":" in x:
158
+ parts = x.split(":")
159
+ try:
160
+ return time(
161
+ int(parts[0]), int(parts[1]), int(parts[2]) if len(parts) > 2 else 0
162
+ )
163
+ except (ValueError, IndexError):
164
+ logger.warning("from_time(%r): cannot parse %r as time", key, x)
165
+ return None
166
+ # Format HHMM (REST API, 4-digit)
167
+ if x.isdigit() and len(x) == 4:
168
+ try:
169
+ return time(int(x[:2]), int(x[2:]))
170
+ except ValueError:
171
+ logger.warning("from_time(%r): cannot parse %r as time", key, x)
172
+ return None
173
+ logger.warning("from_time(%r): cannot parse %r as time", key, x)
174
+ return None
175
+ if isinstance(x, time):
176
+ return x
177
+ logger.warning(
178
+ "from_time(%r): unexpected type %s (value: %.100r)",
179
+ key,
180
+ type(x).__name__,
181
+ x,
182
+ )
183
+ return None
184
+
185
+
186
+ def from_enum(enum_class: type[EnumT], obj: dict, key: str) -> EnumT | None:
187
+ x = obj.get(key)
188
+ if x is None:
189
+ return None
190
+ try:
191
+ return enum_class(x)
192
+ except ValueError:
193
+ logger.warning(
194
+ "from_enum(%s, %r): unknown value %r",
195
+ enum_class.__name__,
196
+ key,
197
+ x,
198
+ )
199
+ return None
200
+
201
+
202
+ def from_obj(from_dict_fn: Callable[[Any], T], obj: dict, key: str) -> T | None:
203
+ x = obj.get(key)
204
+ if x is None:
205
+ return None
206
+ if isinstance(x, dict):
207
+ return from_dict_fn(x)
208
+ # Directus returns FK (str/int) when field depth is shallow (*),
209
+ # and the full object when depth is deep (*.*).
210
+ # Return None gracefully instead of warning for scalar FK values.
211
+ logger.debug(
212
+ "from_obj(%r): expected dict or None, got %s (scalar FK?)",
213
+ key,
214
+ type(x).__name__,
215
+ )
216
+ return None
217
+
218
+
219
+ def from_list(item_fn: Callable[[Any], T], obj: dict, key: str) -> list[T] | None:
220
+ x = obj.get(key)
221
+ if x is None:
222
+ return None
223
+ if isinstance(x, list):
224
+ return [item_fn(item) for item in x]
225
+ logger.warning(
226
+ "from_list(%r): expected list or None, got %s",
227
+ key,
228
+ type(x).__name__,
229
+ )
230
+ return None
231
+
232
+
233
+ def from_uuid(obj: dict, key: str) -> UUID | None:
234
+ x = obj.get(key)
235
+ if not x:
236
+ return None
237
+ try:
238
+ return UUID(x) if isinstance(x, str) else None
239
+ except ValueError:
240
+ logger.warning("from_uuid(%r): invalid UUID %r", key, x)
241
+ return None
242
+
243
+
244
+ def from_duration(obj: dict, key: str) -> timedelta | None:
245
+ """Parse a duration string like '37h00' or '6h55' into a timedelta.
246
+
247
+ Also handles numeric values (int/float) interpreted as hours,
248
+ and plain numeric strings.
249
+ """
250
+ x = obj.get(key)
251
+ if x is None:
252
+ return None
253
+ if isinstance(x, timedelta):
254
+ return x
255
+ if isinstance(x, (int, float)):
256
+ return timedelta(hours=int(x), minutes=int((x % 1) * 60))
257
+ if isinstance(x, str):
258
+ x = x.strip()
259
+ if not x:
260
+ return None
261
+ # Format "37h00", "6h55", "10h50"
262
+ if "h" in x.lower():
263
+ parts = x.lower().split("h", 1)
264
+ try:
265
+ hours = int(parts[0])
266
+ minutes = int(parts[1]) if parts[1] else 0
267
+ return timedelta(hours=hours, minutes=minutes)
268
+ except ValueError:
269
+ logger.warning("from_duration(%r): cannot parse %r", key, x)
270
+ return None
271
+ # Plain numeric string → interpret as hours
272
+ try:
273
+ val = float(x)
274
+ return timedelta(hours=int(val), minutes=int((val % 1) * 60))
275
+ except ValueError:
276
+ logger.warning("from_duration(%r): cannot parse %r", key, x)
277
+ return None
278
+ logger.warning(
279
+ "from_duration(%r): unexpected type %s (value: %.100r)",
280
+ key,
281
+ type(x).__name__,
282
+ x,
283
+ )
284
+ return None
285
+
286
+
287
+ def from_timestamp(obj: dict, key: str) -> datetime | None:
288
+ """Parse a Unix timestamp (int or numeric string) into a datetime (UTC)."""
289
+ x = obj.get(key)
290
+ if x is None:
291
+ return None
292
+ if isinstance(x, (int, float)) and not isinstance(x, bool):
293
+ return datetime.fromtimestamp(x, tz=timezone.utc)
294
+ if isinstance(x, str):
295
+ x = x.strip()
296
+ if not x:
297
+ return None
298
+ try:
299
+ return datetime.fromtimestamp(int(x), tz=timezone.utc)
300
+ except (ValueError, OverflowError, OSError):
301
+ logger.warning("from_timestamp(%r): cannot parse %r as timestamp", key, x)
302
+ return None
303
+ logger.warning(
304
+ "from_timestamp(%r): unexpected type %s (value: %.100r)",
305
+ key,
306
+ type(x).__name__,
307
+ x,
308
+ )
309
+ return None
310
+
311
+
312
+ def from_phone(obj: dict, key: str) -> str | None:
313
+ """Parse a phone number string (normalized format)."""
314
+ x = obj.get(key)
315
+ if x is None:
316
+ return None
317
+ if isinstance(x, str):
318
+ if not x.strip():
319
+ return None
320
+ return x
321
+ if isinstance(x, (int, float)) and not isinstance(x, bool):
322
+ return str(int(x))
323
+ logger.warning(
324
+ "from_phone(%r): unexpected type %s (value: %.100r)",
325
+ key,
326
+ type(x).__name__,
327
+ x,
328
+ )
329
+ return None
@@ -0,0 +1,360 @@
1
+ """
2
+ Input validation utilities for FFBB Data Client.
3
+
4
+ This module provides comprehensive validation functions for all input parameters
5
+ to ensure data integrity and prevent common errors.
6
+ """
7
+
8
+ import re
9
+ from typing import Any
10
+ from urllib.parse import urlparse
11
+
12
+ # Pre-compile regex patterns for performance (avoid redundant compilation in loops)
13
+ _TOKEN_INVALID_CHARS_RE = re.compile(r'[<>"\';&]')
14
+ _QUERY_INVALID_CHARS_RE = re.compile(r"[<>]")
15
+
16
+
17
+ class ValidationError(ValueError):
18
+ """Exception raised when input validation fails."""
19
+
20
+
21
+ def validate_token(token: str, field_name: str = "token") -> str:
22
+ """
23
+ Validate a bearer token.
24
+
25
+ Args:
26
+ token (str): The token to validate
27
+ field_name (str): Name of the field for error messages
28
+
29
+ Returns:
30
+ str: The validated token
31
+
32
+ Raises:
33
+ ValidationError: If token is invalid
34
+ """
35
+ if token is None:
36
+ raise ValidationError(f"{field_name} cannot be None")
37
+
38
+ if not isinstance(token, str):
39
+ raise ValidationError(
40
+ f"{field_name} must be a string, got {type(token).__name__}"
41
+ )
42
+
43
+ token_stripped = token.strip()
44
+ if not token_stripped:
45
+ raise ValidationError(f"{field_name} cannot be empty or whitespace-only")
46
+
47
+ if len(token_stripped) < 10:
48
+ raise ValidationError(f"{field_name} must be at least 10 characters long")
49
+
50
+ if len(token_stripped) > 1000:
51
+ raise ValidationError(f"{field_name} cannot be longer than 1000 characters")
52
+
53
+ # Check for potentially dangerous characters
54
+ # ⚡ Bolt optimization: using pre-compiled regex avoids recompilation overhead
55
+ if _TOKEN_INVALID_CHARS_RE.search(token_stripped):
56
+ raise ValidationError(f"{field_name} contains invalid characters")
57
+
58
+ return token_stripped
59
+
60
+
61
+ def validate_url(url: str, field_name: str = "url") -> str:
62
+ """
63
+ Validate a URL.
64
+
65
+ Args:
66
+ url (str): The URL to validate
67
+ field_name (str): Name of the field for error messages
68
+
69
+ Returns:
70
+ str: The validated URL
71
+
72
+ Raises:
73
+ ValidationError: If URL is invalid
74
+ """
75
+ if url is None:
76
+ raise ValidationError(f"{field_name} cannot be None")
77
+
78
+ if not isinstance(url, str):
79
+ raise ValidationError(
80
+ f"{field_name} must be a string, got {type(url).__name__}"
81
+ )
82
+
83
+ url_stripped = url.strip()
84
+ if not url_stripped:
85
+ raise ValidationError(f"{field_name} cannot be empty")
86
+
87
+ # Parse URL
88
+ try:
89
+ parsed = urlparse(url_stripped)
90
+ if not parsed.scheme or not parsed.netloc:
91
+ raise ValidationError(f"{field_name} must be a valid URL")
92
+ except Exception as e:
93
+ raise ValidationError(f"{field_name} is not a valid URL: {e}") from e
94
+
95
+ # Check scheme
96
+ if parsed.scheme not in ["http", "https"]:
97
+ raise ValidationError(f"{field_name} must use HTTP or HTTPS protocol")
98
+
99
+ return url_stripped
100
+
101
+
102
+ def validate_positive_integer(value: int | str, field_name: str = "value") -> int:
103
+ """
104
+ Validate a positive integer.
105
+
106
+ Args:
107
+ value: The value to validate (int or string representation)
108
+ field_name (str): Name of the field for error messages
109
+
110
+ Returns:
111
+ int: The validated integer
112
+
113
+ Raises:
114
+ ValidationError: If value is invalid
115
+ """
116
+ if value is None:
117
+ raise ValidationError(f"{field_name} cannot be None")
118
+
119
+ try:
120
+ if isinstance(value, str):
121
+ int_value = int(value.strip())
122
+ else:
123
+ int_value = int(value)
124
+ except (ValueError, AttributeError) as e:
125
+ raise ValidationError(f"{field_name} must be a valid integer: {e}") from e
126
+
127
+ if int_value <= 0:
128
+ raise ValidationError(
129
+ f"{field_name} must be a positive integer, got {int_value}"
130
+ )
131
+
132
+ if int_value > 2**31 - 1: # Max 32-bit signed integer
133
+ raise ValidationError(f"{field_name} is too large (max: {2**31 - 1})")
134
+
135
+ return int_value
136
+
137
+
138
+ def validate_string_list(
139
+ values: list[str] | None, field_name: str = "values"
140
+ ) -> list[str] | None:
141
+ """
142
+ Validate a list of strings.
143
+
144
+ Args:
145
+ values: The list to validate
146
+ field_name (str): Name of the field for error messages
147
+
148
+ Returns:
149
+ Optional[List[str]]: The validated list or None
150
+
151
+ Raises:
152
+ ValidationError: If list is invalid
153
+ """
154
+ if values is None:
155
+ return None
156
+
157
+ if not isinstance(values, list):
158
+ raise ValidationError(
159
+ f"{field_name} must be a list, got {type(values).__name__}"
160
+ )
161
+
162
+ if len(values) == 0:
163
+ return values
164
+
165
+ validated_values = []
166
+ for i, value in enumerate(values):
167
+ if value is None:
168
+ raise ValidationError(f"{field_name}[{i}] cannot be None")
169
+
170
+ if not isinstance(value, str):
171
+ raise ValidationError(
172
+ f"{field_name}[{i}] must be a string, got {type(value).__name__}"
173
+ )
174
+
175
+ value_stripped = value.strip()
176
+ if not value_stripped:
177
+ raise ValidationError(f"{field_name}[{i}] cannot be empty")
178
+
179
+ if len(value_stripped) > 100:
180
+ raise ValidationError(f"{field_name}[{i}] is too long (max 100 characters)")
181
+
182
+ validated_values.append(value_stripped)
183
+
184
+ return validated_values
185
+
186
+
187
+ def validate_boolean(value: Any, field_name: str = "value") -> bool:
188
+ """
189
+ Validate a boolean value.
190
+
191
+ Args:
192
+ value: The value to validate
193
+ field_name (str): Name of the field for error messages
194
+
195
+ Returns:
196
+ bool: The validated boolean
197
+
198
+ Raises:
199
+ ValidationError: If value is invalid
200
+ """
201
+ if isinstance(value, bool):
202
+ return value
203
+
204
+ if isinstance(value, str):
205
+ lower_value = value.lower().strip()
206
+ if lower_value in ["true", "1", "yes", "on"]:
207
+ return True
208
+ elif lower_value in ["false", "0", "no", "off"]:
209
+ return False
210
+
211
+ if isinstance(value, int):
212
+ if value == 0:
213
+ return False
214
+ elif value == 1:
215
+ return True
216
+
217
+ raise ValidationError(
218
+ f"{field_name} must be a boolean (True/False), got {value} ({type(value).__name__})"
219
+ )
220
+
221
+
222
+ def validate_deep_limit(
223
+ deep_limit: str | int | None, field_name: str = "deep_limit"
224
+ ) -> str | None:
225
+ """
226
+ Validate a deep limit parameter.
227
+
228
+ Args:
229
+ deep_limit: The deep limit to validate
230
+ field_name (str): Name of the field for error messages
231
+
232
+ Returns:
233
+ Optional[str]: The validated deep limit or None
234
+
235
+ Raises:
236
+ ValidationError: If deep limit is invalid
237
+ """
238
+ if deep_limit is None:
239
+ return None
240
+
241
+ try:
242
+ if isinstance(deep_limit, str):
243
+ int_value = int(deep_limit.strip())
244
+ else:
245
+ int_value = int(deep_limit)
246
+ except (ValueError, AttributeError) as e:
247
+ raise ValidationError(f"{field_name} must be a valid integer: {e}") from e
248
+
249
+ if int_value < 1:
250
+ raise ValidationError(f"{field_name} must be at least 1, got {int_value}")
251
+
252
+ if int_value > 10000:
253
+ raise ValidationError(
254
+ f"{field_name} cannot be greater than 10000, got {int_value}"
255
+ )
256
+
257
+ return str(int_value)
258
+
259
+
260
+ def validate_offset(value: int | str | None, field_name: str = "offset") -> int | None:
261
+ """
262
+ Validate an offset parameter (can be used for pagination).
263
+
264
+ Returns the integer offset or None.
265
+ """
266
+ if value is None:
267
+ return None
268
+
269
+ try:
270
+ if isinstance(value, str):
271
+ int_value = int(value.strip())
272
+ else:
273
+ int_value = int(value)
274
+ except (ValueError, AttributeError) as e:
275
+ raise ValidationError(f"{field_name} must be a valid integer: {e}") from e
276
+
277
+ if int_value < 0:
278
+ raise ValidationError(f"{field_name} must be >= 0, got {int_value}")
279
+
280
+ if int_value > 2**31 - 1:
281
+ raise ValidationError(f"{field_name} is too large (max: {2**31 - 1})")
282
+
283
+ return int_value
284
+
285
+
286
+ def validate_filter_criteria(
287
+ filter_criteria: str | None, field_name: str = "filter_criteria"
288
+ ) -> str | None:
289
+ """
290
+ Validate filter criteria (JSON string).
291
+
292
+ Args:
293
+ filter_criteria: The filter criteria to validate
294
+ field_name (str): Name of the field for error messages
295
+
296
+ Returns:
297
+ Optional[str]: The validated filter criteria or None
298
+
299
+ Raises:
300
+ ValidationError: If filter criteria is invalid
301
+ """
302
+ if filter_criteria is None:
303
+ return None
304
+
305
+ if not isinstance(filter_criteria, str):
306
+ raise ValidationError(
307
+ f"{field_name} must be a string, got {type(filter_criteria).__name__}"
308
+ )
309
+
310
+ filter_stripped = filter_criteria.strip()
311
+ if not filter_stripped:
312
+ return None
313
+
314
+ if len(filter_stripped) > 1000:
315
+ raise ValidationError(f"{field_name} is too long (max 1000 characters)")
316
+
317
+ # Basic JSON structure validation
318
+ if not (filter_stripped.startswith("{") and filter_stripped.endswith("}")):
319
+ raise ValidationError(
320
+ f"{field_name} must be a valid JSON object (start with {{ and end with }})"
321
+ )
322
+
323
+ return filter_stripped
324
+
325
+
326
+ def validate_search_query(query: str | None, field_name: str = "query") -> str | None:
327
+ """
328
+ Validate a search query.
329
+
330
+ Args:
331
+ query: The search query to validate
332
+ field_name (str): Name of the field for error messages
333
+
334
+ Returns:
335
+ Optional[str]: The validated query or None
336
+
337
+ Raises:
338
+ ValidationError: If query is invalid
339
+ """
340
+ if query is None:
341
+ return None
342
+
343
+ if not isinstance(query, str):
344
+ raise ValidationError(
345
+ f"{field_name} must be a string, got {type(query).__name__}"
346
+ )
347
+
348
+ query_stripped = query.strip()
349
+ if not query_stripped:
350
+ return None
351
+
352
+ if len(query_stripped) > 200:
353
+ raise ValidationError(f"{field_name} is too long (max 200 characters)")
354
+
355
+ # Check for potentially dangerous characters in search queries
356
+ # ⚡ Bolt optimization: using pre-compiled regex avoids recompilation overhead
357
+ if _QUERY_INVALID_CHARS_RE.search(query_stripped):
358
+ raise ValidationError(f"{field_name} contains invalid characters")
359
+
360
+ return query_stripped