brvm 0.1.0__tar.gz

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.
brvm-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,135 @@
1
+ Metadata-Version: 2.4
2
+ Name: brvm
3
+ Version: 0.1.0
4
+ Summary: Bibliothèque Python open-source pour scraper et analyser les données de la BRVM
5
+ Author-email: Naofal <votre.email@example.com>
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: httpx>=0.25.0
10
+ Requires-Dist: beautifulsoup4>=4.12.0
11
+ Requires-Dist: lxml>=5.0.0
12
+ Requires-Dist: html5lib>=1.1
13
+ Requires-Dist: pydantic>=2.4.0
14
+ Requires-Dist: pandas>=2.0.0
15
+ Requires-Dist: tenacity>=8.2.0
16
+ Requires-Dist: pdfplumber>=0.11.0
17
+ Provides-Extra: dev
18
+ Requires-Dist: pytest>=7.4.0; extra == "dev"
19
+ Requires-Dist: pytest-cov>=4.1.0; extra == "dev"
20
+ Requires-Dist: mypy>=1.8.0; extra == "dev"
21
+ Requires-Dist: ruff>=0.3.0; extra == "dev"
22
+
23
+ # BRVM Python Package 📈��
24
+
25
+ [![Python Versions](https://img.shields.io/badge/python-3.9%20%7C%203.10%20%7C%203.11-blue)](https://www.python.org/)
26
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT)
27
+
28
+ Une bibliothèque Python open-source robuste pour scraper et analyser les données de la **Bourse Régionale des Valeurs Mobilières (BRVM)**.
29
+ Conçue pour les analystes financiers, les développeurs et les data scientists, elle s'inspire du format `yfinance` en renvoyant directement des objets `pandas.DataFrame` propres et standardisés.
30
+
31
+ Les données sont extraites et croisées à partir de multiples sources fiables (Site officiel BRVM, Sika Finance, RichBourse, UMOA-Titres) avec un système de fallback automatique en cas d'indisponibilité d'une source.
32
+
33
+ ## 🔥 Fonctionnalités & Univers Couvert
34
+
35
+ - **Actions (Equities) & Indices** : Cotations en temps réel (EOD), historiques complets OHLCV, liste des tickers validés.
36
+ - **Obligations (Bonds)** : Prise en charge des obligations cotées à la BRVM.
37
+ - **Profils & Fondamentaux** : Informations d'entreprises, ratios financiers annuels (PER, ROE, Rendement de Dividende, Capitalisation).
38
+ - **Corporate Actions** : Historiques des dividendes, actualités des entreprises (Sika Finance), extraction du Bulletin Officiel de la Cote (BOC).
39
+ - **Marché Primaire (UMOA-Titres)** : Courbe des taux souverains, historique et calendrier des adjudications (Bons et Obligations du Trésor).
40
+ - **⚡ Cache SQLite Local** : Accélération des requêtes redondantes via une base de données locale respectueuse des API distantes.
41
+
42
+ ## 📦 Installation
43
+
44
+ Pour installer la version de développement directement depuis GitHub :
45
+
46
+ ```bash
47
+ git clone https://github.com/Naofal03/brvm.git
48
+ cd brvm
49
+ pip install -e "."
50
+ ```
51
+ *(Le package sera bientôt disponible sur PyPI via `pip install brvm`)*
52
+
53
+ ## 🚀 Utilisation Courante (API Complète)
54
+
55
+ Toutes les méthodes exposées renvoient soit des dictionnaires Python, soit des `pandas.DataFrame` prêts à l'analyse.
56
+
57
+ ### 1. Actions, Indices et Obligations
58
+ ```python
59
+ import brvm
60
+
61
+ # a. Lister tous les instruments (Actions, Indices, Obligations)
62
+ df_tickers = brvm.get_tickers()
63
+ print(df_tickers.head())
64
+
65
+ # b. Obtenir la dernière cotation officielle détaillée EOD d'une action
66
+ cours = brvm.get_quote("SNTS")
67
+ print(f"Sonatel - Clôture : {cours['close']} XOF, Volume : {cours['volume']}")
68
+
69
+ # c. Récupérer l'historique complet OHLCV (Open, High, Low, Close, Volume)
70
+ df_historique = brvm.get_history("SNTS", start="2023-01-01", end="2024-01-01")
71
+ print(df_historique)
72
+
73
+ # d. Récupérer l'historique d'un indice (ex: BRVM Composite)
74
+ df_index = brvm.get_index_history("BRVM-COMP")
75
+ ```
76
+
77
+ ### 2. Fondamentaux & Corporate Actions
78
+ ```python
79
+ # a. Profil de l'entreprise (Secteur, activité, capitalisation...)
80
+ profil = brvm.get_company_info("SNTS")
81
+
82
+ # b. Historique des ratios financiers (PER, ROE, Rendements)
83
+ df_fondamentaux = brvm.get_fundamentals_history("SNTS")
84
+
85
+ # c. Historique complet des dividendes
86
+ df_div = brvm.get_dividends("SNTS")
87
+
88
+ # d. Actualités et articles de presse (via Sika Finance)
89
+ news = brvm.get_news("SNTS")
90
+ print(news[0]['title'], "-", news[0]['url'])
91
+
92
+ # e. Récupérer le Bulletin Officiel de la Cote (BOC) du jour
93
+ df_boc = brvm.get_latest_boc()
94
+ ```
95
+
96
+ ### 3. Marché Primaire (UMOA-Titres)
97
+ ```python
98
+ # a. Récupérer la dernière Courbe des Taux (matrice des taux Zéro-Coupon)
99
+ courbe_taux = brvm.get_yield_curve()
100
+
101
+ # b. Consulter les résultats récents d'adjudications du marché primaire
102
+ historique_emissions = brvm.get_adjudications_history(limit=10)
103
+
104
+ # c. Consulter le calendrier des prochaines émissions de l'UEMOA
105
+ calendrier = brvm.get_adjudications_calendar(limit=5)
106
+ ```
107
+
108
+ ## 🛠️ Format des Données Temporelles (Standard OHLCV)
109
+ La méthode `get_history` obéit à la standardisation suivante :
110
+ - **Absence de Nulls intempestifs** : Les NaN pour Open/High/Low n'existent que pour les jours où le `trading_status` est "NC" (Non Coté).
111
+ - **Forward Fill** : Les jours sans transaction voient leur cours précédent reporté sur la valeur de clôture (`close`), le volume tombant à `0`.
112
+ - Colonnes fixes : `[date, open, high, low, close, adj_close, volume, value, trading_status]`.
113
+
114
+ ## ⚙️ Gestion du Cache Local
115
+ Par défaut, le package utilise une base de données cache `~/.brvm_cache.db` pour éviter d'inonder les serveurs de la BRVM.
116
+ - Les séries temporelles (EOD) expirent après **12 heures**.
117
+ - Les données structurelles (Tickers, Fondamentaux) expirent après **7 jours**.
118
+
119
+ Si vous souhaitez forcer un appel réseau frais :
120
+ ```python
121
+ brvm.disable_cache()
122
+ brvm.get_history("SNTS") # Requête réseau obligée
123
+ brvm.enable_cache() # Réactivation du système
124
+ ```
125
+
126
+ ## 🧪 Tests et Contribution
127
+ Le package demande une stricte couverture de tests (>80%). Pour lancer les tests en environnement de dev :
128
+
129
+ ```bash
130
+ pip install -e ".[dev]"
131
+ pytest --cov=brvm
132
+ ```
133
+
134
+ ## 📄 Licence
135
+ Ce projet est mis à disposition sous licence Open-Source MIT.
brvm-0.1.0/README.md ADDED
@@ -0,0 +1,113 @@
1
+ # BRVM Python Package 📈��
2
+
3
+ [![Python Versions](https://img.shields.io/badge/python-3.9%20%7C%203.10%20%7C%203.11-blue)](https://www.python.org/)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT)
5
+
6
+ Une bibliothèque Python open-source robuste pour scraper et analyser les données de la **Bourse Régionale des Valeurs Mobilières (BRVM)**.
7
+ Conçue pour les analystes financiers, les développeurs et les data scientists, elle s'inspire du format `yfinance` en renvoyant directement des objets `pandas.DataFrame` propres et standardisés.
8
+
9
+ Les données sont extraites et croisées à partir de multiples sources fiables (Site officiel BRVM, Sika Finance, RichBourse, UMOA-Titres) avec un système de fallback automatique en cas d'indisponibilité d'une source.
10
+
11
+ ## 🔥 Fonctionnalités & Univers Couvert
12
+
13
+ - **Actions (Equities) & Indices** : Cotations en temps réel (EOD), historiques complets OHLCV, liste des tickers validés.
14
+ - **Obligations (Bonds)** : Prise en charge des obligations cotées à la BRVM.
15
+ - **Profils & Fondamentaux** : Informations d'entreprises, ratios financiers annuels (PER, ROE, Rendement de Dividende, Capitalisation).
16
+ - **Corporate Actions** : Historiques des dividendes, actualités des entreprises (Sika Finance), extraction du Bulletin Officiel de la Cote (BOC).
17
+ - **Marché Primaire (UMOA-Titres)** : Courbe des taux souverains, historique et calendrier des adjudications (Bons et Obligations du Trésor).
18
+ - **⚡ Cache SQLite Local** : Accélération des requêtes redondantes via une base de données locale respectueuse des API distantes.
19
+
20
+ ## 📦 Installation
21
+
22
+ Pour installer la version de développement directement depuis GitHub :
23
+
24
+ ```bash
25
+ git clone https://github.com/Naofal03/brvm.git
26
+ cd brvm
27
+ pip install -e "."
28
+ ```
29
+ *(Le package sera bientôt disponible sur PyPI via `pip install brvm`)*
30
+
31
+ ## 🚀 Utilisation Courante (API Complète)
32
+
33
+ Toutes les méthodes exposées renvoient soit des dictionnaires Python, soit des `pandas.DataFrame` prêts à l'analyse.
34
+
35
+ ### 1. Actions, Indices et Obligations
36
+ ```python
37
+ import brvm
38
+
39
+ # a. Lister tous les instruments (Actions, Indices, Obligations)
40
+ df_tickers = brvm.get_tickers()
41
+ print(df_tickers.head())
42
+
43
+ # b. Obtenir la dernière cotation officielle détaillée EOD d'une action
44
+ cours = brvm.get_quote("SNTS")
45
+ print(f"Sonatel - Clôture : {cours['close']} XOF, Volume : {cours['volume']}")
46
+
47
+ # c. Récupérer l'historique complet OHLCV (Open, High, Low, Close, Volume)
48
+ df_historique = brvm.get_history("SNTS", start="2023-01-01", end="2024-01-01")
49
+ print(df_historique)
50
+
51
+ # d. Récupérer l'historique d'un indice (ex: BRVM Composite)
52
+ df_index = brvm.get_index_history("BRVM-COMP")
53
+ ```
54
+
55
+ ### 2. Fondamentaux & Corporate Actions
56
+ ```python
57
+ # a. Profil de l'entreprise (Secteur, activité, capitalisation...)
58
+ profil = brvm.get_company_info("SNTS")
59
+
60
+ # b. Historique des ratios financiers (PER, ROE, Rendements)
61
+ df_fondamentaux = brvm.get_fundamentals_history("SNTS")
62
+
63
+ # c. Historique complet des dividendes
64
+ df_div = brvm.get_dividends("SNTS")
65
+
66
+ # d. Actualités et articles de presse (via Sika Finance)
67
+ news = brvm.get_news("SNTS")
68
+ print(news[0]['title'], "-", news[0]['url'])
69
+
70
+ # e. Récupérer le Bulletin Officiel de la Cote (BOC) du jour
71
+ df_boc = brvm.get_latest_boc()
72
+ ```
73
+
74
+ ### 3. Marché Primaire (UMOA-Titres)
75
+ ```python
76
+ # a. Récupérer la dernière Courbe des Taux (matrice des taux Zéro-Coupon)
77
+ courbe_taux = brvm.get_yield_curve()
78
+
79
+ # b. Consulter les résultats récents d'adjudications du marché primaire
80
+ historique_emissions = brvm.get_adjudications_history(limit=10)
81
+
82
+ # c. Consulter le calendrier des prochaines émissions de l'UEMOA
83
+ calendrier = brvm.get_adjudications_calendar(limit=5)
84
+ ```
85
+
86
+ ## 🛠️ Format des Données Temporelles (Standard OHLCV)
87
+ La méthode `get_history` obéit à la standardisation suivante :
88
+ - **Absence de Nulls intempestifs** : Les NaN pour Open/High/Low n'existent que pour les jours où le `trading_status` est "NC" (Non Coté).
89
+ - **Forward Fill** : Les jours sans transaction voient leur cours précédent reporté sur la valeur de clôture (`close`), le volume tombant à `0`.
90
+ - Colonnes fixes : `[date, open, high, low, close, adj_close, volume, value, trading_status]`.
91
+
92
+ ## ⚙️ Gestion du Cache Local
93
+ Par défaut, le package utilise une base de données cache `~/.brvm_cache.db` pour éviter d'inonder les serveurs de la BRVM.
94
+ - Les séries temporelles (EOD) expirent après **12 heures**.
95
+ - Les données structurelles (Tickers, Fondamentaux) expirent après **7 jours**.
96
+
97
+ Si vous souhaitez forcer un appel réseau frais :
98
+ ```python
99
+ brvm.disable_cache()
100
+ brvm.get_history("SNTS") # Requête réseau obligée
101
+ brvm.enable_cache() # Réactivation du système
102
+ ```
103
+
104
+ ## 🧪 Tests et Contribution
105
+ Le package demande une stricte couverture de tests (>80%). Pour lancer les tests en environnement de dev :
106
+
107
+ ```bash
108
+ pip install -e ".[dev]"
109
+ pytest --cov=brvm
110
+ ```
111
+
112
+ ## 📄 Licence
113
+ Ce projet est mis à disposition sous licence Open-Source MIT.
@@ -0,0 +1,31 @@
1
+ """Public package exports for the BRVM Python library."""
2
+
3
+ from .core import (
4
+ get_company_info,
5
+ get_dividends,
6
+ get_fundamentals_history,
7
+ get_history,
8
+ get_index_history,
9
+ get_quote,
10
+ get_tickers,
11
+ )
12
+ from .exceptions import (
13
+ BRVMError,
14
+ DataValidationError,
15
+ InvalidSymbolError,
16
+ SourceUnavailableError,
17
+ )
18
+
19
+ __all__ = [
20
+ "BRVMError",
21
+ "DataValidationError",
22
+ "InvalidSymbolError",
23
+ "SourceUnavailableError",
24
+ "get_company_info",
25
+ "get_dividends",
26
+ "get_fundamentals_history",
27
+ "get_history",
28
+ "get_index_history",
29
+ "get_quote",
30
+ "get_tickers",
31
+ ]
@@ -0,0 +1,223 @@
1
+ import sqlite3
2
+ import logging
3
+ import os
4
+ import sqlite3
5
+ import pandas as pd
6
+ from datetime import datetime, timedelta
7
+ from pathlib import Path
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ # Options par défaut
12
+ DEFAULT_CACHE_DIR = Path.home() / ".brvm_cache.db"
13
+ TTL_EOD = timedelta(hours=12)
14
+ TTL_STATIC = timedelta(days=7)
15
+
16
+ _USE_CACHE = True
17
+
18
+ def disable_cache():
19
+ """Désactive le cache de manière globale."""
20
+ global _USE_CACHE
21
+ _USE_CACHE = False
22
+ logger.info("BRVM cache disabled.")
23
+
24
+ def enable_cache():
25
+ """Réactive le cache de manière globale."""
26
+ global _USE_CACHE
27
+ _USE_CACHE = True
28
+ logger.info("BRVM cache enabled.")
29
+
30
+ def is_cache_enabled():
31
+ """Vérifie si le cache est actif."""
32
+ return _USE_CACHE
33
+
34
+ def get_db_path() -> Path:
35
+ """Récupère le chemin du cache."""
36
+ # Override via var env
37
+ env_path = os.environ.get("BRVM_CACHE_DIR")
38
+ if env_path:
39
+ return Path(env_path)
40
+ return DEFAULT_CACHE_DIR
41
+
42
+ def _get_connection() -> sqlite3.Connection:
43
+ """Ouvre et gère la connexion locale."""
44
+ path = get_db_path()
45
+ path.parent.mkdir(parents=True, exist_ok=True)
46
+ conn = sqlite3.connect(path, check_same_thread=False)
47
+ # Active la vérification des foreign keys
48
+ conn.execute("PRAGMA foreign_keys = 1")
49
+ return conn
50
+
51
+ def init_db():
52
+ conn = _get_connection()
53
+ c = conn.cursor()
54
+ # Métadonnées cache pour gérer le TTL
55
+ c.execute('''
56
+ CREATE TABLE IF NOT EXISTS cache_meta (
57
+ key TEXT PRIMARY KEY,
58
+ updated_at TIMESTAMP NOT NULL
59
+ )
60
+ ''')
61
+ # Tickers
62
+ c.execute('''
63
+ CREATE TABLE IF NOT EXISTS tickers (
64
+ id TEXT PRIMARY KEY,
65
+ nom_entreprise TEXT,
66
+ secteur_activite TEXT,
67
+ type_instrument TEXT
68
+ )
69
+ ''')
70
+ # Historique de cotations
71
+ c.execute('''
72
+ CREATE TABLE IF NOT EXISTS daily_quotes (
73
+ date TIMESTAMP,
74
+ ticker_id TEXT,
75
+ open REAL,
76
+ high REAL,
77
+ low REAL,
78
+ close REAL,
79
+ adj_close REAL,
80
+ volume INTEGER CHECK(volume >= 0),
81
+ value REAL,
82
+ trading_status TEXT,
83
+ PRIMARY KEY (date, ticker_id),
84
+ FOREIGN KEY (ticker_id) REFERENCES tickers(id) ON DELETE CASCADE
85
+ )
86
+ ''')
87
+ # Fondamentaux
88
+ c.execute('''
89
+ CREATE TABLE IF NOT EXISTS fundamentals (
90
+ ticker_id TEXT,
91
+ annee_fiscale INTEGER,
92
+ per REAL,
93
+ rendement_dividende REAL,
94
+ capitalisation REAL,
95
+ roe REAL,
96
+ PRIMARY KEY (ticker_id, annee_fiscale),
97
+ FOREIGN KEY (ticker_id) REFERENCES tickers(id) ON DELETE CASCADE
98
+ )
99
+ ''')
100
+ # Dividendes
101
+ c.execute('''
102
+ CREATE TABLE IF NOT EXISTS dividends (
103
+ ticker_id TEXT,
104
+ date_detachement TIMESTAMP,
105
+ montant_net REAL,
106
+ PRIMARY KEY (ticker_id, date_detachement),
107
+ FOREIGN KEY (ticker_id) REFERENCES tickers(id) ON DELETE CASCADE
108
+ )
109
+ ''')
110
+ conn.commit()
111
+ conn.close()
112
+
113
+ def _set_updated(key: str):
114
+ conn = _get_connection()
115
+ c = conn.cursor()
116
+ c.execute(
117
+ "INSERT OR REPLACE INTO cache_meta (key, updated_at) VALUES (?, ?)",
118
+ (key, datetime.now())
119
+ )
120
+ conn.commit()
121
+ conn.close()
122
+
123
+ def _check_valid(key: str, ttl: timedelta) -> bool:
124
+ if not is_cache_enabled():
125
+ return False
126
+
127
+ conn = _get_connection()
128
+ c = conn.cursor()
129
+ c.execute("SELECT updated_at FROM cache_meta WHERE key = ?", (key,))
130
+ row = c.fetchone()
131
+ conn.close()
132
+
133
+ if not row:
134
+ return False
135
+
136
+ try:
137
+ updated_at = datetime.fromisoformat(row[0] if isinstance(row[0], str) else str(row[0]))
138
+ return datetime.now() - updated_at < ttl
139
+ except Exception:
140
+ return False
141
+
142
+ # ----- Operations sur le Tickers Cache -----
143
+ def cache_set_tickers(df: pd.DataFrame):
144
+ if not is_cache_enabled(): return
145
+ conn = _get_connection()
146
+ lock_path = str(get_db_path()) + ".lock"
147
+ # Simple to_sql save, ignoring FK constraints temporarily for initial load or just parsing properly
148
+ # A plus sûr: utiliser to_sql avec if_exists="replace" si c'est pour l'API. Or, la table est définie custom.
149
+ # Pour garder la structure du CC:
150
+ try:
151
+ df_copy = df.copy()
152
+ if 'symbol' in df_copy.columns:
153
+ df_copy = df_copy.rename(columns={'symbol': 'id'})
154
+
155
+ # On ne stocke que les colonnes prévues (simplification).
156
+ # Normalement on utilise REPLACE pour gérer les doublons SQLite
157
+ # pandas to_sql doesn't support REPLACE directly on all sqlite versions natively without extra dialect args.
158
+ # So we delete and insert.
159
+ c = conn.cursor()
160
+ c.execute("DELETE FROM tickers")
161
+ df_copy.to_sql("tickers", conn, if_exists="append", index=False)
162
+ _set_updated("tickers")
163
+ except Exception as e:
164
+ logger.warning(f"Failed to cache tickers: {e}")
165
+ finally:
166
+ conn.close()
167
+
168
+ def cache_get_tickers() -> pd.DataFrame:
169
+ if not _check_valid("tickers", TTL_STATIC):
170
+ return None
171
+ try:
172
+ conn = _get_connection()
173
+ df = pd.read_sql("SELECT * FROM tickers", conn)
174
+ conn.close()
175
+ # Remapper id -> symbol si convention interne
176
+ df = df.rename(columns={'id': 'symbol'})
177
+ return df if not df.empty else None
178
+ except Exception:
179
+ return None
180
+
181
+ # ----- Operations sur le Quotes Cache -----
182
+ def cache_set_history(symbol: str, df: pd.DataFrame):
183
+ if not is_cache_enabled() or df.empty: return
184
+ conn = _get_connection()
185
+ try:
186
+ c = conn.cursor()
187
+ c.execute("DELETE FROM daily_quotes WHERE ticker_id = ?", (symbol,))
188
+
189
+ df_copy = df.copy()
190
+ df_copy['ticker_id'] = symbol
191
+ # Ensure date format as string or datetime
192
+ df_copy['date'] = pd.to_datetime(df_copy['date'])
193
+
194
+ # Safe insert
195
+ df_copy.to_sql("daily_quotes", conn, if_exists="append", index=False)
196
+ _set_updated(f"history_{symbol}")
197
+ except Exception as e:
198
+ logger.warning(f"Failed to cache history for {symbol}: {e}")
199
+ finally:
200
+ conn.close()
201
+
202
+ def cache_get_history(symbol: str) -> pd.DataFrame:
203
+ if not _check_valid(f"history_{symbol}", TTL_EOD):
204
+ return None
205
+ try:
206
+ conn = _get_connection()
207
+ df = pd.read_sql("SELECT * FROM daily_quotes WHERE ticker_id = ? ORDER BY date ASC", conn, params=(symbol,))
208
+ conn.close()
209
+
210
+ if df.empty:
211
+ return None
212
+
213
+ df['date'] = pd.to_datetime(df['date']).dt.date
214
+ df = df.drop(columns=['ticker_id'])
215
+ return df
216
+ except Exception:
217
+ return None
218
+
219
+ # Initialisation
220
+ try:
221
+ init_db()
222
+ except Exception as e:
223
+ logger.warning(f"Cache DB init failed: {e}")
@@ -0,0 +1,134 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from collections.abc import Mapping
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ import certifi
8
+
9
+ try:
10
+ from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
11
+ except ModuleNotFoundError: # pragma: no cover - fallback for bare environments
12
+ def retry(*args: Any, **kwargs: Any): # type: ignore[override]
13
+ def decorator(func: Any) -> Any:
14
+ return func
15
+
16
+ return decorator
17
+
18
+ def retry_if_exception_type(*args: Any, **kwargs: Any) -> None:
19
+ return None
20
+
21
+ def stop_after_attempt(*args: Any, **kwargs: Any) -> None:
22
+ return None
23
+
24
+ def wait_exponential(*args: Any, **kwargs: Any) -> None:
25
+ return None
26
+
27
+ if TYPE_CHECKING:
28
+ import httpx
29
+
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ DEFAULT_HEADERS = {
34
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
35
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
36
+ "Accept-Language": "fr-FR,fr;q=0.9,en;q=0.8",
37
+ }
38
+
39
+ _client_singleton: "BRVMClient | None" = None
40
+
41
+
42
+ def _load_httpx() -> Any:
43
+ import httpx
44
+
45
+ return httpx
46
+
47
+
48
+ class BRVMClient:
49
+ """HTTP client shared by source adapters."""
50
+
51
+ def __init__(
52
+ self,
53
+ *,
54
+ timeout: float = 20.0,
55
+ headers: Mapping[str, str] | None = None,
56
+ ) -> None:
57
+ httpx = _load_httpx()
58
+ merged_headers = {**DEFAULT_HEADERS, **dict(headers or {})}
59
+ self._httpx = httpx
60
+ self._session = httpx.Client(
61
+ headers=merged_headers,
62
+ timeout=timeout,
63
+ verify=certifi.where(),
64
+ )
65
+ self._insecure_session = httpx.Client(
66
+ headers=merged_headers,
67
+ timeout=timeout,
68
+ verify=False,
69
+ )
70
+
71
+ @retry(
72
+ stop=stop_after_attempt(3),
73
+ wait=wait_exponential(multiplier=1, min=1, max=8),
74
+ retry=retry_if_exception_type(Exception),
75
+ reraise=True,
76
+ )
77
+ def get(
78
+ self, url: str, *, params: Mapping[str, Any] | None = None
79
+ ) -> "httpx.Response":
80
+ logger.debug("GET %s params=%s", url, params)
81
+ response = self._request_with_tls_fallback("GET", url, params=params)
82
+ response.raise_for_status()
83
+ return response
84
+
85
+ @retry(
86
+ stop=stop_after_attempt(3),
87
+ wait=wait_exponential(multiplier=1, min=1, max=8),
88
+ retry=retry_if_exception_type(Exception),
89
+ reraise=True,
90
+ )
91
+ def post(
92
+ self, url: str, *, data: Mapping[str, Any] | None = None
93
+ ) -> "httpx.Response":
94
+ logger.debug("POST %s", url)
95
+ response = self._request_with_tls_fallback("POST", url, data=data)
96
+ response.raise_for_status()
97
+ return response
98
+
99
+ def close(self) -> None:
100
+ self._session.close()
101
+ self._insecure_session.close()
102
+
103
+ def __enter__(self) -> "BRVMClient":
104
+ return self
105
+
106
+ def __exit__(self, exc_type: object, exc: object, tb: object) -> None:
107
+ self.close()
108
+
109
+ def _request_with_tls_fallback(
110
+ self,
111
+ method: str,
112
+ url: str,
113
+ *,
114
+ params: Mapping[str, Any] | None = None,
115
+ data: Mapping[str, Any] | None = None,
116
+ ) -> "httpx.Response":
117
+ try:
118
+ return self._session.request(method, url, params=params, data=data)
119
+ except Exception as exc:
120
+ if "CERTIFICATE_VERIFY_FAILED" not in str(exc):
121
+ raise
122
+
123
+ logger.warning(
124
+ "TLS verification failed for %s; retrying with certificate checks disabled.",
125
+ url,
126
+ )
127
+ return self._insecure_session.request(method, url, params=params, data=data)
128
+
129
+
130
+ def get_brvm_session() -> BRVMClient:
131
+ global _client_singleton
132
+ if _client_singleton is None:
133
+ _client_singleton = BRVMClient()
134
+ return _client_singleton