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.
- ffbb_api_client_v3/__init__.py +25 -0
- ffbb_data_client/__init__.py +175 -0
- ffbb_data_client/clients/__init__.py +13 -0
- ffbb_data_client/clients/api_ffbb_app_client.py +2475 -0
- ffbb_data_client/clients/ffbb_data_client.py +2789 -0
- ffbb_data_client/clients/meilisearch_client.py +218 -0
- ffbb_data_client/clients/meilisearch_ffbb_client.py +647 -0
- ffbb_data_client/config.py +153 -0
- ffbb_data_client/data/__init__.py +25 -0
- ffbb_data_client/data/collections.json +1364 -0
- ffbb_data_client/data/endpoint_discovery.json +1875 -0
- ffbb_data_client/data/indexes.json +501 -0
- ffbb_data_client/data/openapi.json +35713 -0
- ffbb_data_client/data/openapi_full.json +37622 -0
- ffbb_data_client/helpers/__init__.py +27 -0
- ffbb_data_client/helpers/http_requests_helper.py +73 -0
- ffbb_data_client/helpers/http_requests_utils.py +502 -0
- ffbb_data_client/helpers/meilisearch_client_extension.py +153 -0
- ffbb_data_client/helpers/multi_search_query_helper.py +35 -0
- ffbb_data_client/models/__init__.py +241 -0
- ffbb_data_client/models/affiche.py +45 -0
- ffbb_data_client/models/cartographie.py +82 -0
- ffbb_data_client/models/categorie.py +55 -0
- ffbb_data_client/models/categorie_type.py +42 -0
- ffbb_data_client/models/clock.py +38 -0
- ffbb_data_client/models/club_contacts.py +77 -0
- ffbb_data_client/models/code.py +7 -0
- ffbb_data_client/models/commune.py +66 -0
- ffbb_data_client/models/competition_fields.py +309 -0
- ffbb_data_client/models/competition_id.py +116 -0
- ffbb_data_client/models/competition_id_categorie.py +31 -0
- ffbb_data_client/models/competition_id_sexe.py +31 -0
- ffbb_data_client/models/competition_id_type_competition.py +27 -0
- ffbb_data_client/models/competition_id_type_competition_generique.py +24 -0
- ffbb_data_client/models/competition_origine.py +69 -0
- ffbb_data_client/models/competition_origine_categorie.py +23 -0
- ffbb_data_client/models/competition_origine_type_competition.py +14 -0
- ffbb_data_client/models/competition_origine_type_competition_generique.py +24 -0
- ffbb_data_client/models/competition_type.py +6 -0
- ffbb_data_client/models/competitions_facet_distribution.py +65 -0
- ffbb_data_client/models/competitions_facet_stats.py +14 -0
- ffbb_data_client/models/competitions_hit.py +232 -0
- ffbb_data_client/models/competitions_multi_search_query.py +40 -0
- ffbb_data_client/models/competitions_query.py +11 -0
- ffbb_data_client/models/configuration_models.py +5 -0
- ffbb_data_client/models/contact_info.py +18 -0
- ffbb_data_client/models/content_multi_search_query.py +93 -0
- ffbb_data_client/models/coordonnees.py +27 -0
- ffbb_data_client/models/coordonnees_type.py +5 -0
- ffbb_data_client/models/document_flyer.py +205 -0
- ffbb_data_client/models/document_flyer_type.py +6 -0
- ffbb_data_client/models/engagement_contacts.py +97 -0
- ffbb_data_client/models/engagements_facet_distribution.py +59 -0
- ffbb_data_client/models/engagements_facet_stats.py +14 -0
- ffbb_data_client/models/engagements_hit.py +192 -0
- ffbb_data_client/models/engagements_multi_search_query.py +41 -0
- ffbb_data_client/models/etat.py +6 -0
- ffbb_data_client/models/external_competition_id.py +42 -0
- ffbb_data_client/models/external_id.py +72 -0
- ffbb_data_client/models/facet_distribution.py +13 -0
- ffbb_data_client/models/facet_stats.py +13 -0
- ffbb_data_client/models/field_set.py +10 -0
- ffbb_data_client/models/folder.py +35 -0
- ffbb_data_client/models/formation_session.py +60 -0
- ffbb_data_client/models/formations_facet_distribution.py +61 -0
- ffbb_data_client/models/formations_facet_stats.py +14 -0
- ffbb_data_client/models/formations_hit.py +277 -0
- ffbb_data_client/models/formations_multi_search_query.py +41 -0
- ffbb_data_client/models/game_stats_model.py +57 -0
- ffbb_data_client/models/game_stats_models.py +5 -0
- ffbb_data_client/models/generic_search.py +92 -0
- ffbb_data_client/models/geo.py +27 -0
- ffbb_data_client/models/geo_sort_order.py +6 -0
- ffbb_data_client/models/get_commune_response.py +18 -0
- ffbb_data_client/models/get_competition_response.py +523 -0
- ffbb_data_client/models/get_configuration_response.py +45 -0
- ffbb_data_client/models/get_engagement_response.py +23 -0
- ffbb_data_client/models/get_entraineur_response.py +18 -0
- ffbb_data_client/models/get_formation_response.py +28 -0
- ffbb_data_client/models/get_officiel_response.py +18 -0
- ffbb_data_client/models/get_organisme_response.py +476 -0
- ffbb_data_client/models/get_poule_response.py +68 -0
- ffbb_data_client/models/get_pratique_response.py +18 -0
- ffbb_data_client/models/get_rencontre_response.py +93 -0
- ffbb_data_client/models/get_saisons_response.py +56 -0
- ffbb_data_client/models/get_salle_response.py +20 -0
- ffbb_data_client/models/get_terrain_response.py +16 -0
- ffbb_data_client/models/get_tournoi_response.py +16 -0
- ffbb_data_client/models/gradient_color.py +27 -0
- ffbb_data_client/models/hit.py +16 -0
- ffbb_data_client/models/id_engagement_equipe.py +32 -0
- ffbb_data_client/models/id_organisme_equipe.py +51 -0
- ffbb_data_client/models/id_organisme_equipe1_logo.py +28 -0
- ffbb_data_client/models/id_poule.py +27 -0
- ffbb_data_client/models/jour.py +11 -0
- ffbb_data_client/models/label.py +15 -0
- ffbb_data_client/models/labellisation.py +30 -0
- ffbb_data_client/models/live.py +192 -0
- ffbb_data_client/models/lives.py +6 -0
- ffbb_data_client/models/logo.py +28 -0
- ffbb_data_client/models/multi_search_queries.py +24 -0
- ffbb_data_client/models/multi_search_query.py +96 -0
- ffbb_data_client/models/multi_search_result_competitions.py +14 -0
- ffbb_data_client/models/multi_search_result_engagements.py +14 -0
- ffbb_data_client/models/multi_search_result_formations.py +12 -0
- ffbb_data_client/models/multi_search_result_organismes.py +12 -0
- ffbb_data_client/models/multi_search_result_pratiques.py +12 -0
- ffbb_data_client/models/multi_search_result_rencontres.py +12 -0
- ffbb_data_client/models/multi_search_result_salles.py +12 -0
- ffbb_data_client/models/multi_search_result_terrains.py +12 -0
- ffbb_data_client/models/multi_search_result_tournois.py +12 -0
- ffbb_data_client/models/multi_search_results.py +103 -0
- ffbb_data_client/models/multi_search_results_class.py +96 -0
- ffbb_data_client/models/nature_sol.py +57 -0
- ffbb_data_client/models/niveau.py +10 -0
- ffbb_data_client/models/niveau_class.py +27 -0
- ffbb_data_client/models/niveau_extractor.py +214 -0
- ffbb_data_client/models/niveau_info.py +64 -0
- ffbb_data_client/models/niveau_models.py +14 -0
- ffbb_data_client/models/niveau_type.py +10 -0
- ffbb_data_client/models/objectif.py +7 -0
- ffbb_data_client/models/organisateur.py +197 -0
- ffbb_data_client/models/organisateur_type.py +6 -0
- ffbb_data_client/models/organisme_fields.py +327 -0
- ffbb_data_client/models/organisme_id_pere.py +177 -0
- ffbb_data_client/models/organismes_facet_distribution.py +46 -0
- ffbb_data_client/models/organismes_facet_stats.py +14 -0
- ffbb_data_client/models/organismes_hit.py +196 -0
- ffbb_data_client/models/organismes_multi_search_query.py +41 -0
- ffbb_data_client/models/organismes_query.py +8 -0
- ffbb_data_client/models/phase_code.py +23 -0
- ffbb_data_client/models/poule.py +35 -0
- ffbb_data_client/models/poule_fields.py +261 -0
- ffbb_data_client/models/poule_rencontre_item_model.py +69 -0
- ffbb_data_client/models/poules_models.py +6 -0
- ffbb_data_client/models/poules_query.py +9 -0
- ffbb_data_client/models/pratique.py +7 -0
- ffbb_data_client/models/pratiques_facet_distribution.py +29 -0
- ffbb_data_client/models/pratiques_facet_stats.py +14 -0
- ffbb_data_client/models/pratiques_hit.py +310 -0
- ffbb_data_client/models/pratiques_hit_type.py +9 -0
- ffbb_data_client/models/pratiques_multi_search_query.py +41 -0
- ffbb_data_client/models/pratiques_type_class.py +45 -0
- ffbb_data_client/models/publication_internet.py +6 -0
- ffbb_data_client/models/purple_logo.py +24 -0
- ffbb_data_client/models/query_fields_manager.py +75 -0
- ffbb_data_client/models/ranking_engagement.py +41 -0
- ffbb_data_client/models/rankings_models.py +6 -0
- ffbb_data_client/models/rencontres_engagement.py +23 -0
- ffbb_data_client/models/rencontres_facet_distribution.py +65 -0
- ffbb_data_client/models/rencontres_facet_stats.py +14 -0
- ffbb_data_client/models/rencontres_hit.py +271 -0
- ffbb_data_client/models/rencontres_multi_search_query.py +41 -0
- ffbb_data_client/models/saison.py +23 -0
- ffbb_data_client/models/saison_fields.py +36 -0
- ffbb_data_client/models/saisons_models.py +6 -0
- ffbb_data_client/models/saisons_query.py +9 -0
- ffbb_data_client/models/salle.py +56 -0
- ffbb_data_client/models/salles_facet_distribution.py +14 -0
- ffbb_data_client/models/salles_facet_stats.py +14 -0
- ffbb_data_client/models/salles_hit.py +153 -0
- ffbb_data_client/models/salles_multi_search_query.py +40 -0
- ffbb_data_client/models/sexe.py +9 -0
- ffbb_data_client/models/sexe_class.py +31 -0
- ffbb_data_client/models/source.py +5 -0
- ffbb_data_client/models/status.py +5 -0
- ffbb_data_client/models/team_engagement.py +56 -0
- ffbb_data_client/models/team_ranking.py +108 -0
- ffbb_data_client/models/terrains_categorie_championnat_3x3_libelle.py +5 -0
- ffbb_data_client/models/terrains_facet_distribution.py +41 -0
- ffbb_data_client/models/terrains_facet_stats.py +14 -0
- ffbb_data_client/models/terrains_hit.py +223 -0
- ffbb_data_client/models/terrains_multi_search_query.py +42 -0
- ffbb_data_client/models/terrains_name.py +5 -0
- ffbb_data_client/models/terrains_sexe_enum.py +7 -0
- ffbb_data_client/models/terrains_storage.py +5 -0
- ffbb_data_client/models/tournoi_type_class.py +35 -0
- ffbb_data_client/models/tournoi_type_enum.py +7 -0
- ffbb_data_client/models/tournoi_types_3x3.py +43 -0
- ffbb_data_client/models/tournoi_types_3x3_libelle.py +60 -0
- ffbb_data_client/models/tournoi_types_3x3_libelle_enum.py +10 -0
- ffbb_data_client/models/tournois_facet_distribution.py +41 -0
- ffbb_data_client/models/tournois_facet_stats.py +14 -0
- ffbb_data_client/models/tournois_hit.py +132 -0
- ffbb_data_client/models/tournois_hit_type.py +5 -0
- ffbb_data_client/models/tournois_libelle.py +7 -0
- ffbb_data_client/models/tournois_multi_search_query.py +40 -0
- ffbb_data_client/models/type_association.py +23 -0
- ffbb_data_client/models/type_association_libelle.py +30 -0
- ffbb_data_client/models/type_class.py +23 -0
- ffbb_data_client/models/type_competition.py +8 -0
- ffbb_data_client/models/type_competition_generique.py +31 -0
- ffbb_data_client/models/type_enum.py +7 -0
- ffbb_data_client/models/type_league.py +6 -0
- ffbb_data_client/py.typed +0 -0
- ffbb_data_client/utils/__init__.py +27 -0
- ffbb_data_client/utils/cache_manager.py +393 -0
- ffbb_data_client/utils/converter_utils.py +329 -0
- ffbb_data_client/utils/input_validation.py +360 -0
- ffbb_data_client/utils/retry_utils.py +478 -0
- ffbb_data_client/utils/secure_logging.py +153 -0
- ffbb_data_client/utils/token_manager.py +115 -0
- ffbb_data_client-2.0.0.dist-info/METADATA +339 -0
- ffbb_data_client-2.0.0.dist-info/RECORD +207 -0
- ffbb_data_client-2.0.0.dist-info/WHEEL +5 -0
- ffbb_data_client-2.0.0.dist-info/licenses/LICENSE.txt +201 -0
- 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
|