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,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from ..config import MEILISEARCH_FACETS_TOURNOIS, MEILISEARCH_INDEX_TOURNOIS
|
|
4
|
+
from .multi_search_query import MultiSearchQuery
|
|
5
|
+
from .multi_search_result_tournois import TournoisMultiSearchResult
|
|
6
|
+
from .multi_search_results import MultiSearchResult
|
|
7
|
+
from .tournois_facet_distribution import TournoisFacetDistribution
|
|
8
|
+
from .tournois_facet_stats import TournoisFacetStats
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TournoisMultiSearchQuery(MultiSearchQuery):
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
q: str | None,
|
|
15
|
+
limit: int | None = 10,
|
|
16
|
+
offset: int | None = 0,
|
|
17
|
+
filter: list[str] | None = None,
|
|
18
|
+
sort: list[str] | None = None,
|
|
19
|
+
):
|
|
20
|
+
super().__init__(
|
|
21
|
+
index_uid=MEILISEARCH_INDEX_TOURNOIS,
|
|
22
|
+
q=q,
|
|
23
|
+
facets=MEILISEARCH_FACETS_TOURNOIS,
|
|
24
|
+
limit=limit,
|
|
25
|
+
offset=offset,
|
|
26
|
+
filter=filter,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
def is_valid_result(self, result: MultiSearchResult):
|
|
30
|
+
return result and (
|
|
31
|
+
isinstance(result, TournoisMultiSearchResult)
|
|
32
|
+
and (
|
|
33
|
+
result.facet_distribution is None
|
|
34
|
+
or isinstance(result.facet_distribution, TournoisFacetDistribution)
|
|
35
|
+
)
|
|
36
|
+
and (
|
|
37
|
+
result.facet_stats is None
|
|
38
|
+
or isinstance(result.facet_stats, TournoisFacetStats)
|
|
39
|
+
)
|
|
40
|
+
)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from ..utils.converter_utils import from_str
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class TypeAssociation:
|
|
11
|
+
libelle: str | None = None
|
|
12
|
+
|
|
13
|
+
@staticmethod
|
|
14
|
+
def from_dict(obj: Any) -> TypeAssociation:
|
|
15
|
+
assert isinstance(obj, dict)
|
|
16
|
+
libelle = from_str(obj, "libelle")
|
|
17
|
+
return TypeAssociation(libelle=libelle)
|
|
18
|
+
|
|
19
|
+
def to_dict(self) -> dict:
|
|
20
|
+
result: dict = {}
|
|
21
|
+
if self.libelle is not None:
|
|
22
|
+
result["libelle"] = self.libelle
|
|
23
|
+
return result
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from ..utils.converter_utils import from_int
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class TypeAssociationLibelle:
|
|
11
|
+
club: int | None = None
|
|
12
|
+
coopération_territoriale_club: int | None = None
|
|
13
|
+
|
|
14
|
+
@staticmethod
|
|
15
|
+
def from_dict(obj: Any) -> TypeAssociationLibelle:
|
|
16
|
+
assert isinstance(obj, dict)
|
|
17
|
+
club = from_int(obj, "Club")
|
|
18
|
+
coopération_territoriale_club = from_int(obj, "Coopération Territoriale Club")
|
|
19
|
+
return TypeAssociationLibelle(
|
|
20
|
+
club=club,
|
|
21
|
+
coopération_territoriale_club=coopération_territoriale_club,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
def to_dict(self) -> dict:
|
|
25
|
+
result: dict = {}
|
|
26
|
+
if self.club is not None:
|
|
27
|
+
result["Club"] = self.club
|
|
28
|
+
if self.coopération_territoriale_club is not None:
|
|
29
|
+
result["Coopération Territoriale Club"] = self.coopération_territoriale_club
|
|
30
|
+
return result
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from ..utils.converter_utils import from_int
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class TypeClass:
|
|
11
|
+
groupement: int | None = None
|
|
12
|
+
|
|
13
|
+
@staticmethod
|
|
14
|
+
def from_dict(obj: Any) -> TypeClass:
|
|
15
|
+
assert isinstance(obj, dict)
|
|
16
|
+
groupement = from_int(obj, "Groupement")
|
|
17
|
+
return TypeClass(groupement=groupement)
|
|
18
|
+
|
|
19
|
+
def to_dict(self) -> dict:
|
|
20
|
+
result: dict = {}
|
|
21
|
+
if self.groupement is not None:
|
|
22
|
+
result["Groupement"] = self.groupement
|
|
23
|
+
return result
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from ..utils.converter_utils import from_obj, from_str
|
|
7
|
+
from .logo import Logo
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class TypeCompetitionGenerique:
|
|
12
|
+
type_competition_generique_id: str | None = None
|
|
13
|
+
logo: Logo | None = None
|
|
14
|
+
|
|
15
|
+
@staticmethod
|
|
16
|
+
def from_dict(obj: Any) -> TypeCompetitionGenerique:
|
|
17
|
+
assert isinstance(obj, dict)
|
|
18
|
+
type_competition_generique_id = from_str(obj, "id")
|
|
19
|
+
logo = from_obj(Logo.from_dict, obj, "logo")
|
|
20
|
+
return TypeCompetitionGenerique(
|
|
21
|
+
type_competition_generique_id=type_competition_generique_id,
|
|
22
|
+
logo=logo,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
def to_dict(self) -> dict:
|
|
26
|
+
result: dict = {}
|
|
27
|
+
if self.type_competition_generique_id is not None:
|
|
28
|
+
result["id"] = self.type_competition_generique_id
|
|
29
|
+
if self.logo is not None:
|
|
30
|
+
result["logo"] = self.logo.to_dict()
|
|
31
|
+
return result
|
|
File without changes
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Utility modules for FFBB API client."""
|
|
2
|
+
|
|
3
|
+
from .converter_utils import (
|
|
4
|
+
from_bool,
|
|
5
|
+
from_datetime,
|
|
6
|
+
from_enum,
|
|
7
|
+
from_float,
|
|
8
|
+
from_int,
|
|
9
|
+
from_list,
|
|
10
|
+
from_obj,
|
|
11
|
+
from_officiels_list,
|
|
12
|
+
from_str,
|
|
13
|
+
from_uuid,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"from_bool",
|
|
18
|
+
"from_datetime",
|
|
19
|
+
"from_enum",
|
|
20
|
+
"from_float",
|
|
21
|
+
"from_int",
|
|
22
|
+
"from_list",
|
|
23
|
+
"from_obj",
|
|
24
|
+
"from_officiels_list",
|
|
25
|
+
"from_str",
|
|
26
|
+
"from_uuid",
|
|
27
|
+
]
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Advanced cache management for FFBB Data Client.
|
|
3
|
+
|
|
4
|
+
This module provides sophisticated caching strategies including:
|
|
5
|
+
- Multi-level caching (memory, disk, Redis)
|
|
6
|
+
- Configurable cache policies
|
|
7
|
+
- Cache performance metrics
|
|
8
|
+
- Intelligent cache invalidation
|
|
9
|
+
- Cache warming capabilities
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import threading
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Any, cast
|
|
18
|
+
|
|
19
|
+
import hishel
|
|
20
|
+
import hishel.httpx
|
|
21
|
+
import httpx
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class CacheMetrics:
|
|
26
|
+
"""Cache performance metrics."""
|
|
27
|
+
|
|
28
|
+
hits: int = 0
|
|
29
|
+
misses: int = 0
|
|
30
|
+
evictions: int = 0
|
|
31
|
+
sets: int = 0
|
|
32
|
+
errors: int = 0
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def hit_rate(self) -> float:
|
|
36
|
+
"""Calculate cache hit rate."""
|
|
37
|
+
total = self.hits + self.misses
|
|
38
|
+
return self.hits / total if total > 0 else 0.0
|
|
39
|
+
|
|
40
|
+
def reset(self) -> None:
|
|
41
|
+
"""Reset all metrics."""
|
|
42
|
+
self.hits = 0
|
|
43
|
+
self.misses = 0
|
|
44
|
+
self.evictions = 0
|
|
45
|
+
self.sets = 0
|
|
46
|
+
self.errors = 0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class CacheConfig:
|
|
50
|
+
"""
|
|
51
|
+
Configuration for cache behavior.
|
|
52
|
+
|
|
53
|
+
Attributes:
|
|
54
|
+
enabled: Whether caching is enabled.
|
|
55
|
+
backend: Cache backend ('memory', 'sqlite', 'redis').
|
|
56
|
+
expire_after: Default expiration time in seconds.
|
|
57
|
+
max_size: Maximum cache size (for memory backend).
|
|
58
|
+
redis_url: Redis URL for Redis backend.
|
|
59
|
+
key_prefix: Prefix for cache keys.
|
|
60
|
+
compression: Whether to compress cached data.
|
|
61
|
+
transport_retries: Low-level HTTP transport retries.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
enabled: bool = True,
|
|
67
|
+
backend: str = "sqlite",
|
|
68
|
+
expire_after: int = 1800, # 30 minutes
|
|
69
|
+
max_size: int = 1000,
|
|
70
|
+
redis_url: str | None = None,
|
|
71
|
+
key_prefix: str = "ffbb_api",
|
|
72
|
+
compression: bool = False,
|
|
73
|
+
transport_retries: int = 0,
|
|
74
|
+
) -> None:
|
|
75
|
+
"""
|
|
76
|
+
Initialize cache configuration.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
enabled: Whether caching is enabled.
|
|
80
|
+
backend: Cache backend type.
|
|
81
|
+
expire_after: Default expiration time.
|
|
82
|
+
max_size: Maximum cache size for memory backend.
|
|
83
|
+
redis_url: Redis connection URL.
|
|
84
|
+
key_prefix: Prefix for cache keys.
|
|
85
|
+
compression: Whether to compress cached data.
|
|
86
|
+
transport_retries: Low-level HTTP transport retries.
|
|
87
|
+
"""
|
|
88
|
+
self.enabled = enabled
|
|
89
|
+
self.backend = backend
|
|
90
|
+
self.expire_after = expire_after
|
|
91
|
+
self.max_size = max_size
|
|
92
|
+
self.redis_url = redis_url
|
|
93
|
+
self.key_prefix = key_prefix
|
|
94
|
+
self.compression = compression
|
|
95
|
+
self.transport_retries = transport_retries
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class CacheManager:
|
|
99
|
+
"""
|
|
100
|
+
Thread-safe singleton cache manager for API requests.
|
|
101
|
+
|
|
102
|
+
This class implements the singleton pattern with double-check locking
|
|
103
|
+
to ensure thread safety during instance creation.
|
|
104
|
+
|
|
105
|
+
Attributes:
|
|
106
|
+
config: Cache configuration settings.
|
|
107
|
+
metrics: Cache performance metrics.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
_instance: CacheManager | None = None
|
|
111
|
+
_lock: threading.Lock = threading.Lock()
|
|
112
|
+
_initialized: bool = False
|
|
113
|
+
|
|
114
|
+
def __new__(cls, config: CacheConfig | None = None) -> CacheManager:
|
|
115
|
+
"""
|
|
116
|
+
Create or return the singleton instance.
|
|
117
|
+
|
|
118
|
+
Uses double-check locking for thread safety.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
config: Optional configuration for first instantiation.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
The singleton CacheManager instance.
|
|
125
|
+
"""
|
|
126
|
+
if cls._instance is None:
|
|
127
|
+
with cls._lock:
|
|
128
|
+
if cls._instance is None:
|
|
129
|
+
instance = super().__new__(cls)
|
|
130
|
+
cls._instance = instance
|
|
131
|
+
return cls._instance
|
|
132
|
+
|
|
133
|
+
def __init__(self, config: CacheConfig | None = None) -> None:
|
|
134
|
+
"""
|
|
135
|
+
Initialize the cache manager instance.
|
|
136
|
+
|
|
137
|
+
Only initializes on first call to avoid re-initialization.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
config: Cache configuration settings.
|
|
141
|
+
"""
|
|
142
|
+
if CacheManager._initialized:
|
|
143
|
+
return
|
|
144
|
+
|
|
145
|
+
with CacheManager._lock:
|
|
146
|
+
if CacheManager._initialized:
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
self.config = config or CacheConfig()
|
|
150
|
+
self.metrics = CacheMetrics()
|
|
151
|
+
self._memory_cache: dict[str, dict[str, Any]] = {}
|
|
152
|
+
self._client: httpx.Client | None = None
|
|
153
|
+
self._async_client: httpx.AsyncClient | None = None
|
|
154
|
+
|
|
155
|
+
if self.config.enabled:
|
|
156
|
+
self._initialize_cache()
|
|
157
|
+
|
|
158
|
+
CacheManager._initialized = True
|
|
159
|
+
|
|
160
|
+
def _initialize_cache(self) -> None:
|
|
161
|
+
"""Initialize the cache backend."""
|
|
162
|
+
policy = hishel.FilterPolicy()
|
|
163
|
+
policy.use_body_key = True
|
|
164
|
+
# ── AJOUT : force le cache indépendamment des headers HTTP ──────────
|
|
165
|
+
# hishel respecte RFC 7234 par défaut → ne cache pas si le serveur
|
|
166
|
+
# ne renvoie pas de Cache-Control/ETag. FFBB n'en envoie pas.
|
|
167
|
+
policy.cacheable_methods = ["GET", "POST"]
|
|
168
|
+
policy.cacheable_status_codes = [200, 203, 204, 206, 300, 301, 308]
|
|
169
|
+
# ────────────────────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
if self.config.backend == "memory":
|
|
172
|
+
import sqlite3
|
|
173
|
+
|
|
174
|
+
# Use separate in-memory SQLite connections for sync and async:
|
|
175
|
+
# sharing one connection across threads (sync vs asyncio) is not safe.
|
|
176
|
+
sync_conn = sqlite3.connect(":memory:", check_same_thread=False)
|
|
177
|
+
async_conn = sqlite3.connect(":memory:", check_same_thread=False)
|
|
178
|
+
storage = hishel.SyncSqliteStorage(
|
|
179
|
+
connection=sync_conn, default_ttl=self.config.expire_after
|
|
180
|
+
)
|
|
181
|
+
self._client = hishel.httpx.SyncCacheClient(
|
|
182
|
+
storage=storage,
|
|
183
|
+
policy=policy,
|
|
184
|
+
transport=httpx.HTTPTransport(retries=self.config.transport_retries),
|
|
185
|
+
)
|
|
186
|
+
# Async version uses its own connection
|
|
187
|
+
async_storage = hishel.AsyncSqliteStorage(
|
|
188
|
+
connection=async_conn, default_ttl=self.config.expire_after
|
|
189
|
+
)
|
|
190
|
+
self._async_client = hishel.httpx.AsyncCacheClient(
|
|
191
|
+
storage=async_storage,
|
|
192
|
+
policy=policy,
|
|
193
|
+
transport=httpx.AsyncHTTPTransport(
|
|
194
|
+
retries=self.config.transport_retries
|
|
195
|
+
),
|
|
196
|
+
)
|
|
197
|
+
elif self.config.backend == "sqlite":
|
|
198
|
+
storage = hishel.SyncSqliteStorage(
|
|
199
|
+
database_path="http_cache.db", default_ttl=self.config.expire_after
|
|
200
|
+
)
|
|
201
|
+
self._client = hishel.httpx.SyncCacheClient(
|
|
202
|
+
storage=storage,
|
|
203
|
+
policy=policy,
|
|
204
|
+
transport=httpx.HTTPTransport(retries=self.config.transport_retries),
|
|
205
|
+
)
|
|
206
|
+
# Async version
|
|
207
|
+
async_storage = hishel.AsyncSqliteStorage(
|
|
208
|
+
database_path="http_cache.db", default_ttl=self.config.expire_after
|
|
209
|
+
)
|
|
210
|
+
self._async_client = hishel.httpx.AsyncCacheClient(
|
|
211
|
+
storage=async_storage,
|
|
212
|
+
policy=policy,
|
|
213
|
+
transport=httpx.AsyncHTTPTransport(
|
|
214
|
+
retries=self.config.transport_retries
|
|
215
|
+
),
|
|
216
|
+
)
|
|
217
|
+
else:
|
|
218
|
+
raise ValueError(f"Unsupported cache backend: {self.config.backend}")
|
|
219
|
+
|
|
220
|
+
def create_cache_key(self, request: httpx.Request, **_kwargs: Any) -> str:
|
|
221
|
+
"""
|
|
222
|
+
Create a cache key from the request.
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
request: httpx Request.
|
|
226
|
+
**_kwargs: Additional arguments (ignored).
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
Cache key string.
|
|
230
|
+
"""
|
|
231
|
+
key_parts = [
|
|
232
|
+
request.method or "GET",
|
|
233
|
+
str(request.url) or "",
|
|
234
|
+
]
|
|
235
|
+
|
|
236
|
+
if request.headers:
|
|
237
|
+
auth_header = request.headers.get("Authorization", "")
|
|
238
|
+
if auth_header:
|
|
239
|
+
key_parts.append("auth_masked")
|
|
240
|
+
|
|
241
|
+
if request.method == "POST" and request.content:
|
|
242
|
+
content_bytes = (
|
|
243
|
+
request.content
|
|
244
|
+
if isinstance(request.content, bytes)
|
|
245
|
+
else str(request.content).encode("utf-8")
|
|
246
|
+
)
|
|
247
|
+
body_hash = hashlib.md5(content_bytes).hexdigest()
|
|
248
|
+
key_parts.append(body_hash)
|
|
249
|
+
|
|
250
|
+
key_string = "|".join(key_parts)
|
|
251
|
+
return (
|
|
252
|
+
f"{self.config.key_prefix}:{hashlib.md5(key_string.encode()).hexdigest()}"
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
@property
|
|
256
|
+
def session(self) -> httpx.Client | None:
|
|
257
|
+
"""Get the cached session."""
|
|
258
|
+
return self._client if self.config.enabled else None
|
|
259
|
+
|
|
260
|
+
@property
|
|
261
|
+
def async_session(self) -> httpx.AsyncClient | None:
|
|
262
|
+
"""Get the cached async session."""
|
|
263
|
+
return self._async_client if self.config.enabled else None
|
|
264
|
+
|
|
265
|
+
def get_session(
|
|
266
|
+
self, async_mode: bool = False
|
|
267
|
+
) -> httpx.Client | httpx.AsyncClient | None:
|
|
268
|
+
"""
|
|
269
|
+
Get the cached session.
|
|
270
|
+
|
|
271
|
+
Args:
|
|
272
|
+
async_mode: Whether to return the async session.
|
|
273
|
+
|
|
274
|
+
Returns:
|
|
275
|
+
The cached session or None if caching is disabled.
|
|
276
|
+
"""
|
|
277
|
+
if async_mode:
|
|
278
|
+
return self.async_session
|
|
279
|
+
return self.session
|
|
280
|
+
|
|
281
|
+
def is_enabled(self) -> bool:
|
|
282
|
+
"""
|
|
283
|
+
Check if caching is enabled.
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
286
|
+
True if caching is enabled.
|
|
287
|
+
"""
|
|
288
|
+
return self.config.enabled and self._client is not None
|
|
289
|
+
|
|
290
|
+
def clear_cache(self) -> bool:
|
|
291
|
+
"""
|
|
292
|
+
Clear all cached data.
|
|
293
|
+
|
|
294
|
+
Returns:
|
|
295
|
+
True if cache was cleared successfully, False otherwise.
|
|
296
|
+
"""
|
|
297
|
+
if self._client is None:
|
|
298
|
+
return False
|
|
299
|
+
try:
|
|
300
|
+
storage = getattr(self._client, "_storage", None)
|
|
301
|
+
clear_fn = getattr(storage, "clear", None)
|
|
302
|
+
if callable(clear_fn):
|
|
303
|
+
clear_fn()
|
|
304
|
+
self.metrics.evictions = 0
|
|
305
|
+
return True
|
|
306
|
+
return False
|
|
307
|
+
except (OSError, RuntimeError, AttributeError):
|
|
308
|
+
self.metrics.errors += 1
|
|
309
|
+
return False
|
|
310
|
+
|
|
311
|
+
def get_cache_size(self) -> int:
|
|
312
|
+
"""
|
|
313
|
+
Get the current cache size.
|
|
314
|
+
|
|
315
|
+
Returns:
|
|
316
|
+
Number of cached items.
|
|
317
|
+
"""
|
|
318
|
+
if self._client is None:
|
|
319
|
+
return 0
|
|
320
|
+
try:
|
|
321
|
+
storage = getattr(self._client, "_storage", None)
|
|
322
|
+
count_method = getattr(storage, "count", None)
|
|
323
|
+
if count_method is not None:
|
|
324
|
+
return cast(int, count_method())
|
|
325
|
+
except (OSError, RuntimeError, AttributeError):
|
|
326
|
+
self.metrics.errors += 1
|
|
327
|
+
return 0
|
|
328
|
+
|
|
329
|
+
def get_metrics(self) -> CacheMetrics:
|
|
330
|
+
"""
|
|
331
|
+
Get cache performance metrics.
|
|
332
|
+
|
|
333
|
+
Returns:
|
|
334
|
+
Current cache metrics.
|
|
335
|
+
"""
|
|
336
|
+
return self.metrics
|
|
337
|
+
|
|
338
|
+
def warm_cache(self, urls: list[str], headers: dict[str, str] | None = None) -> int:
|
|
339
|
+
"""
|
|
340
|
+
Warm the cache by pre-fetching specified URLs.
|
|
341
|
+
|
|
342
|
+
Args:
|
|
343
|
+
urls: List of URLs to cache.
|
|
344
|
+
headers: Headers to use for requests.
|
|
345
|
+
|
|
346
|
+
Returns:
|
|
347
|
+
Number of URLs successfully cached.
|
|
348
|
+
"""
|
|
349
|
+
if not self.is_enabled() or self._client is None:
|
|
350
|
+
return 0
|
|
351
|
+
|
|
352
|
+
headers = headers or {}
|
|
353
|
+
count = 0
|
|
354
|
+
for url in urls:
|
|
355
|
+
try:
|
|
356
|
+
self._client.get(url, headers=headers, timeout=10)
|
|
357
|
+
count += 1
|
|
358
|
+
except (OSError, ConnectionError, TimeoutError, ValueError):
|
|
359
|
+
pass
|
|
360
|
+
return count
|
|
361
|
+
|
|
362
|
+
def invalidate_pattern(self, pattern: str) -> int:
|
|
363
|
+
"""
|
|
364
|
+
Invalidate cache entries matching a pattern.
|
|
365
|
+
|
|
366
|
+
Args:
|
|
367
|
+
pattern: Pattern to match for invalidation.
|
|
368
|
+
|
|
369
|
+
Returns:
|
|
370
|
+
Number of entries invalidated.
|
|
371
|
+
"""
|
|
372
|
+
if not self.is_enabled() or self._client is None:
|
|
373
|
+
return 0
|
|
374
|
+
|
|
375
|
+
try:
|
|
376
|
+
getattr(self._client, "_storage", None)
|
|
377
|
+
# hishel storage doesn't generally support pattern invalidation exposing cache_dict,
|
|
378
|
+
# so we'd need to iterate visually, but for now we skip or implement if needed
|
|
379
|
+
return 0
|
|
380
|
+
except (OSError, RuntimeError, AttributeError, KeyError):
|
|
381
|
+
self.metrics.errors += 1
|
|
382
|
+
return 0
|
|
383
|
+
|
|
384
|
+
@classmethod
|
|
385
|
+
def reset_instance(cls) -> None:
|
|
386
|
+
"""Reset the singleton instance (for testing purposes)."""
|
|
387
|
+
with cls._lock:
|
|
388
|
+
if cls._instance is not None:
|
|
389
|
+
client = getattr(cls._instance, "_client", None)
|
|
390
|
+
if client is not None:
|
|
391
|
+
client.close()
|
|
392
|
+
cls._instance = None
|
|
393
|
+
cls._initialized = False
|