okama-macro 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.
Files changed (48) hide show
  1. okama_macro-0.1.0/PKG-INFO +151 -0
  2. okama_macro-0.1.0/README.md +129 -0
  3. okama_macro-0.1.0/okama_macro/__init__.py +11 -0
  4. okama_macro-0.1.0/okama_macro/_frame.py +14 -0
  5. okama_macro-0.1.0/okama_macro/_http.py +127 -0
  6. okama_macro-0.1.0/okama_macro/registry.py +282 -0
  7. okama_macro-0.1.0/okama_macro/sources/__init__.py +0 -0
  8. okama_macro-0.1.0/okama_macro/sources/bis.py +63 -0
  9. okama_macro-0.1.0/okama_macro/sources/boe/__init__.py +4 -0
  10. okama_macro-0.1.0/okama_macro/sources/boe/kr.py +15 -0
  11. okama_macro-0.1.0/okama_macro/sources/boe/request_data.py +51 -0
  12. okama_macro-0.1.0/okama_macro/sources/boi/__init__.py +6 -0
  13. okama_macro-0.1.0/okama_macro/sources/boi/gdp.py +18 -0
  14. okama_macro-0.1.0/okama_macro/sources/boi/infl.py +40 -0
  15. okama_macro-0.1.0/okama_macro/sources/boi/kr.py +17 -0
  16. okama_macro-0.1.0/okama_macro/sources/boi/request_data.py +47 -0
  17. okama_macro-0.1.0/okama_macro/sources/boi/settings.py +2 -0
  18. okama_macro-0.1.0/okama_macro/sources/censtatd.py +55 -0
  19. okama_macro-0.1.0/okama_macro/sources/cfets/__init__.py +4 -0
  20. okama_macro-0.1.0/okama_macro/sources/cfets/lpr.py +44 -0
  21. okama_macro-0.1.0/okama_macro/sources/cfets/request_data.py +33 -0
  22. okama_macro-0.1.0/okama_macro/sources/ecb/__init__.py +13 -0
  23. okama_macro-0.1.0/okama_macro/sources/ecb/gdp.py +19 -0
  24. okama_macro-0.1.0/okama_macro/sources/ecb/hicp.py +17 -0
  25. okama_macro-0.1.0/okama_macro/sources/ecb/kr.py +101 -0
  26. okama_macro-0.1.0/okama_macro/sources/ecb/request_data.py +50 -0
  27. okama_macro-0.1.0/okama_macro/sources/fred.py +68 -0
  28. okama_macro-0.1.0/okama_macro/sources/hkma.py +91 -0
  29. okama_macro-0.1.0/okama_macro/sources/mospi.py +189 -0
  30. okama_macro-0.1.0/okama_macro/sources/nbsc/__init__.py +162 -0
  31. okama_macro-0.1.0/okama_macro/sources/nbsc/auto_retail.py +29 -0
  32. okama_macro-0.1.0/okama_macro/sources/nbsc/codes.json +326 -0
  33. okama_macro-0.1.0/okama_macro/sources/nbsc/gdp.py +42 -0
  34. okama_macro-0.1.0/okama_macro/sources/nbsc/household.py +33 -0
  35. okama_macro-0.1.0/okama_macro/sources/nbsc/inflation.py +121 -0
  36. okama_macro-0.1.0/okama_macro/sources/nbsc/investment.py +225 -0
  37. okama_macro-0.1.0/okama_macro/sources/nbsc/land.py +32 -0
  38. okama_macro-0.1.0/okama_macro/sources/nbsc/money_supply.py +38 -0
  39. okama_macro-0.1.0/okama_macro/sources/nbsc/pmi.py +26 -0
  40. okama_macro-0.1.0/okama_macro/sources/nbsc/ppi.py +22 -0
  41. okama_macro-0.1.0/okama_macro/sources/nbsc/request_data.py +377 -0
  42. okama_macro-0.1.0/okama_macro/sources/nbsc/unemployment.py +14 -0
  43. okama_macro-0.1.0/okama_macro/sources/ons/__init__.py +5 -0
  44. okama_macro-0.1.0/okama_macro/sources/ons/gdp.py +26 -0
  45. okama_macro-0.1.0/okama_macro/sources/ons/infl.py +33 -0
  46. okama_macro-0.1.0/okama_macro/sources/ons/request_data.py +32 -0
  47. okama_macro-0.1.0/okama_macro/sources/rbi.py +49 -0
  48. okama_macro-0.1.0/pyproject.toml +37 -0
@@ -0,0 +1,151 @@
1
+ Metadata-Version: 2.4
2
+ Name: okama-macro
3
+ Version: 0.1.0
4
+ Summary: Macro-economic data-source clients (CPI, central-bank rates) for the okama project
5
+ License: MIT
6
+ Author: mbk-dev
7
+ Requires-Python: >=3.11,<4.0
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.11
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Programming Language :: Python :: 3.14
14
+ Requires-Dist: lxml
15
+ Requires-Dist: pandas
16
+ Requires-Dist: requests
17
+ Requires-Dist: urllib3
18
+ Project-URL: Homepage, https://github.com/mbk-dev/okama-macro
19
+ Project-URL: Repository, https://github.com/mbk-dev/okama-macro
20
+ Description-Content-Type: text/markdown
21
+
22
+ # okama-macro
23
+
24
+ Macro-economic data-source clients — CPI inflation and central-bank / policy
25
+ rates — for the [okama](https://github.com/mbk-dev/okama) project.
26
+
27
+ One package, one HTTP/DataFrame layer, one dependency. It consolidates the macro
28
+ source clients that used to live as separate per-source micro-repos and as
29
+ in-repo modules of okama-API, behind a single unified series API. Rationale and
30
+ migration history: [mbk-dev/okama-API#41](https://github.com/mbk-dev/okama-API/issues/41)
31
+ and its ADR 0001.
32
+
33
+ ## Install
34
+
35
+ ```bash
36
+ pip install okama-macro
37
+ ```
38
+
39
+ Requires **Python ≥ 3.11**. Runs on both **pandas 2.x and 3.x**.
40
+
41
+ ## Quick start
42
+
43
+ ```python
44
+ import okama_macro
45
+
46
+ okama_macro.list_series()
47
+ # ['CHN_LPR1.RATE', 'CHN_LPR5.RATE', 'CNY.INFL', 'EU_DFR.RATE',
48
+ # 'EU_MLR.RATE', 'EU_MRO.RATE', 'GBP.INFL', 'HKD.INFL', 'HK_BR.RATE',
49
+ # 'ILS.INFL', 'IND_RBI.RATE', 'INR.INFL', 'ISR_IR.RATE', 'UK_BR.RATE',
50
+ # 'USD.INFL', 'US_EFFR.RATE']
51
+
52
+ # A rate as a decimal fraction (3.62% -> 0.0362), observations only:
53
+ okama_macro.get('US_EFFR.RATE', first_date='2020-01-01')
54
+
55
+ # Monthly m/m inflation as a decimal fraction:
56
+ okama_macro.get('USD.INFL')
57
+ ```
58
+
59
+ ## The contract
60
+
61
+ Every series returned by `get()` obeys the same contract, so callers never
62
+ special-case a source:
63
+
64
+ - **Decimal fractions** — m/m inflation `0.0042`, a rate `0.0525` (never percent,
65
+ never an index level).
66
+ - **CPI series** are monthly, stamped on the **first of the month**, derived from
67
+ the source's index via `pct_change()` (base-invariant).
68
+ - **Rate series** normally carry **observations only — no padding**. Forward-fill
69
+ to a daily grid on the consumer side if you need one. `UK_BR.RATE` is the
70
+ documented exception: the Bank of England source publishes change dates, and
71
+ the client safely forward-fills them into a daily series.
72
+ - **Ascending `DatetimeIndex`**, `float` dtype, and `Series.name == key`.
73
+
74
+ `get()` raises `ValueError` for an unknown key (listing the known ones);
75
+ `list_series()` returns the available keys, sorted.
76
+
77
+ ## Available series
78
+
79
+ | Key | Series | Source module |
80
+ |-----|--------|---------------|
81
+ | `USD.INFL` | US CPI, m/m | `fred` (FRED `CPIAUCNS`) |
82
+ | `HKD.INFL` | Hong Kong Composite CPI, m/m | `censtatd` (HK C&SD) |
83
+ | `INR.INFL` | India General CPI, m/m | `mospi` (MOSPI) |
84
+ | `CNY.INFL` | China CPI, m/m | `nbsc` (NBS China) |
85
+ | `GBP.INFL` | UK CPIH, m/m | `ons` (UK ONS) |
86
+ | `ILS.INFL` | Israel CPI, m/m | `boi` (Bank of Israel) |
87
+ | `US_EFFR.RATE` | US Federal Funds rate | `fred` (FRED `DFF`) |
88
+ | `HK_BR.RATE` | HKMA Discount Window Base Rate | `hkma` |
89
+ | `IND_RBI.RATE` | RBI policy repo rate | `bis` (history) + `rbi` (same-day tail) |
90
+ | `CHN_LPR1.RATE` | China one-year Loan Prime Rate | `cfets` |
91
+ | `CHN_LPR5.RATE` | China five-year Loan Prime Rate | `cfets` |
92
+ | `ISR_IR.RATE` | Bank of Israel policy rate | `boi` |
93
+ | `EU_MRO.RATE` | ECB main refinancing operations rate | `ecb` |
94
+ | `EU_MLR.RATE` | ECB marginal lending facility rate | `ecb` |
95
+ | `EU_DFR.RATE` | ECB deposit facility rate | `ecb` |
96
+ | `UK_BR.RATE` | Bank of England Bank Rate | `boe` |
97
+
98
+ ## Raw per-source clients
99
+
100
+ Each source also exposes its raw client under `okama_macro.sources.*`, returning
101
+ data **as the agency publishes it** (CPI index levels, rates in percent) — use
102
+ these only if you need the unnormalised series; prefer `get()` otherwise.
103
+
104
+ ```python
105
+ from okama_macro.sources import (
106
+ bis, boe, boi, censtatd, cfets, ecb, fred, hkma, mospi, nbsc, ons, rbi
107
+ )
108
+
109
+ hkma.get_base_rate() # percent, daily
110
+ censtatd.get_composite_cpi() # CPI index level, monthly
111
+ ```
112
+
113
+ ## Configuration (environment)
114
+
115
+ | Variable | Needed for | Notes |
116
+ |----------|-----------|-------|
117
+ | `FRED_API_KEY` | `USD.INFL`, `US_EFFR.RATE` | Free key from FRED; kept out of logs. |
118
+ | `PROXY_HOST`, `PROXY_PORT` | `bis`, `mospi`, `rbi` | Optional outbound HTTP proxy. |
119
+ | `PROXY_USER`, `PROXY_PASS` | — | Optional proxy credentials. |
120
+
121
+ ## Architecture
122
+
123
+ ```
124
+ okama_macro/
125
+ ├── __init__.py # public API: get(), list_series()
126
+ ├── registry.py # key -> normalised Series (the contract)
127
+ ├── _http.py # shared Session: retry/back-off, proxy, browser UA,
128
+ │ # legacy-TLS, secret redaction
129
+ ├── _frame.py # DataFrame/Series shaping helpers
130
+ └── sources/ # one client per source (bis, boe, boi, censtatd, cfets,
131
+ # ecb, fred, hkma, mospi, nbsc, ons, rbi)
132
+ ```
133
+
134
+ Two layers: thin **sources** (data as published, on the shared `_http`) and a
135
+ **registry** that normalises each key to the contract above.
136
+
137
+ ## Development
138
+
139
+ ```bash
140
+ poetry install
141
+ poetry run pytest # test suite
142
+ poetry run ruff check . # lint (rules C, E, F, W, B)
143
+ ```
144
+
145
+ CI runs the suite and lint on Python 3.11, 3.12, 3.13 and 3.14. `poetry.lock` is
146
+ intentionally untracked.
147
+
148
+ ## License
149
+
150
+ MIT.
151
+
@@ -0,0 +1,129 @@
1
+ # okama-macro
2
+
3
+ Macro-economic data-source clients — CPI inflation and central-bank / policy
4
+ rates — for the [okama](https://github.com/mbk-dev/okama) project.
5
+
6
+ One package, one HTTP/DataFrame layer, one dependency. It consolidates the macro
7
+ source clients that used to live as separate per-source micro-repos and as
8
+ in-repo modules of okama-API, behind a single unified series API. Rationale and
9
+ migration history: [mbk-dev/okama-API#41](https://github.com/mbk-dev/okama-API/issues/41)
10
+ and its ADR 0001.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ pip install okama-macro
16
+ ```
17
+
18
+ Requires **Python ≥ 3.11**. Runs on both **pandas 2.x and 3.x**.
19
+
20
+ ## Quick start
21
+
22
+ ```python
23
+ import okama_macro
24
+
25
+ okama_macro.list_series()
26
+ # ['CHN_LPR1.RATE', 'CHN_LPR5.RATE', 'CNY.INFL', 'EU_DFR.RATE',
27
+ # 'EU_MLR.RATE', 'EU_MRO.RATE', 'GBP.INFL', 'HKD.INFL', 'HK_BR.RATE',
28
+ # 'ILS.INFL', 'IND_RBI.RATE', 'INR.INFL', 'ISR_IR.RATE', 'UK_BR.RATE',
29
+ # 'USD.INFL', 'US_EFFR.RATE']
30
+
31
+ # A rate as a decimal fraction (3.62% -> 0.0362), observations only:
32
+ okama_macro.get('US_EFFR.RATE', first_date='2020-01-01')
33
+
34
+ # Monthly m/m inflation as a decimal fraction:
35
+ okama_macro.get('USD.INFL')
36
+ ```
37
+
38
+ ## The contract
39
+
40
+ Every series returned by `get()` obeys the same contract, so callers never
41
+ special-case a source:
42
+
43
+ - **Decimal fractions** — m/m inflation `0.0042`, a rate `0.0525` (never percent,
44
+ never an index level).
45
+ - **CPI series** are monthly, stamped on the **first of the month**, derived from
46
+ the source's index via `pct_change()` (base-invariant).
47
+ - **Rate series** normally carry **observations only — no padding**. Forward-fill
48
+ to a daily grid on the consumer side if you need one. `UK_BR.RATE` is the
49
+ documented exception: the Bank of England source publishes change dates, and
50
+ the client safely forward-fills them into a daily series.
51
+ - **Ascending `DatetimeIndex`**, `float` dtype, and `Series.name == key`.
52
+
53
+ `get()` raises `ValueError` for an unknown key (listing the known ones);
54
+ `list_series()` returns the available keys, sorted.
55
+
56
+ ## Available series
57
+
58
+ | Key | Series | Source module |
59
+ |-----|--------|---------------|
60
+ | `USD.INFL` | US CPI, m/m | `fred` (FRED `CPIAUCNS`) |
61
+ | `HKD.INFL` | Hong Kong Composite CPI, m/m | `censtatd` (HK C&SD) |
62
+ | `INR.INFL` | India General CPI, m/m | `mospi` (MOSPI) |
63
+ | `CNY.INFL` | China CPI, m/m | `nbsc` (NBS China) |
64
+ | `GBP.INFL` | UK CPIH, m/m | `ons` (UK ONS) |
65
+ | `ILS.INFL` | Israel CPI, m/m | `boi` (Bank of Israel) |
66
+ | `US_EFFR.RATE` | US Federal Funds rate | `fred` (FRED `DFF`) |
67
+ | `HK_BR.RATE` | HKMA Discount Window Base Rate | `hkma` |
68
+ | `IND_RBI.RATE` | RBI policy repo rate | `bis` (history) + `rbi` (same-day tail) |
69
+ | `CHN_LPR1.RATE` | China one-year Loan Prime Rate | `cfets` |
70
+ | `CHN_LPR5.RATE` | China five-year Loan Prime Rate | `cfets` |
71
+ | `ISR_IR.RATE` | Bank of Israel policy rate | `boi` |
72
+ | `EU_MRO.RATE` | ECB main refinancing operations rate | `ecb` |
73
+ | `EU_MLR.RATE` | ECB marginal lending facility rate | `ecb` |
74
+ | `EU_DFR.RATE` | ECB deposit facility rate | `ecb` |
75
+ | `UK_BR.RATE` | Bank of England Bank Rate | `boe` |
76
+
77
+ ## Raw per-source clients
78
+
79
+ Each source also exposes its raw client under `okama_macro.sources.*`, returning
80
+ data **as the agency publishes it** (CPI index levels, rates in percent) — use
81
+ these only if you need the unnormalised series; prefer `get()` otherwise.
82
+
83
+ ```python
84
+ from okama_macro.sources import (
85
+ bis, boe, boi, censtatd, cfets, ecb, fred, hkma, mospi, nbsc, ons, rbi
86
+ )
87
+
88
+ hkma.get_base_rate() # percent, daily
89
+ censtatd.get_composite_cpi() # CPI index level, monthly
90
+ ```
91
+
92
+ ## Configuration (environment)
93
+
94
+ | Variable | Needed for | Notes |
95
+ |----------|-----------|-------|
96
+ | `FRED_API_KEY` | `USD.INFL`, `US_EFFR.RATE` | Free key from FRED; kept out of logs. |
97
+ | `PROXY_HOST`, `PROXY_PORT` | `bis`, `mospi`, `rbi` | Optional outbound HTTP proxy. |
98
+ | `PROXY_USER`, `PROXY_PASS` | — | Optional proxy credentials. |
99
+
100
+ ## Architecture
101
+
102
+ ```
103
+ okama_macro/
104
+ ├── __init__.py # public API: get(), list_series()
105
+ ├── registry.py # key -> normalised Series (the contract)
106
+ ├── _http.py # shared Session: retry/back-off, proxy, browser UA,
107
+ │ # legacy-TLS, secret redaction
108
+ ├── _frame.py # DataFrame/Series shaping helpers
109
+ └── sources/ # one client per source (bis, boe, boi, censtatd, cfets,
110
+ # ecb, fred, hkma, mospi, nbsc, ons, rbi)
111
+ ```
112
+
113
+ Two layers: thin **sources** (data as published, on the shared `_http`) and a
114
+ **registry** that normalises each key to the contract above.
115
+
116
+ ## Development
117
+
118
+ ```bash
119
+ poetry install
120
+ poetry run pytest # test suite
121
+ poetry run ruff check . # lint (rules C, E, F, W, B)
122
+ ```
123
+
124
+ CI runs the suite and lint on Python 3.11, 3.12, 3.13 and 3.14. `poetry.lock` is
125
+ intentionally untracked.
126
+
127
+ ## License
128
+
129
+ MIT.
@@ -0,0 +1,11 @@
1
+ """okama-macro: consolidated macro-economic data-source clients for okama.
2
+
3
+ Public API: ``get(key, first_date=None, last_date=None)`` returns a normalised
4
+ ``pd.Series`` (decimal fractions, ascending DatetimeIndex, observations only);
5
+ ``list_series()`` lists the available keys. Per-source raw clients live in
6
+ ``okama_macro.sources``.
7
+ """
8
+
9
+ from okama_macro.registry import get, list_series
10
+
11
+ __all__ = ['get', 'list_series']
@@ -0,0 +1,14 @@
1
+ """Shared DataFrame/Series shaping helpers."""
2
+
3
+ import pandas as pd
4
+
5
+
6
+ def clip_window(obj: pd.Series | pd.DataFrame,
7
+ first_date: object = None,
8
+ last_date: object = None) -> pd.Series | pd.DataFrame:
9
+ """Clip a DatetimeIndex-ed object to the inclusive [first_date, last_date]."""
10
+ if first_date is not None:
11
+ obj = obj[obj.index >= pd.Timestamp(first_date)]
12
+ if last_date is not None:
13
+ obj = obj[obj.index <= pd.Timestamp(last_date)]
14
+ return obj
@@ -0,0 +1,127 @@
1
+ """Shared HTTP plumbing for all okama-macro source clients.
2
+
3
+ ``get()`` is a plain GET with a browser-like User-Agent (several statistical
4
+ agencies reject the default python-requests UA), an optional outbound proxy
5
+ taken from the ``PROXY_*`` environment variables, and linear-back-off retries
6
+ on transient upstream 5xx responses. 4xx responses and hard connection errors
7
+ fail fast. On failure it raises ``RuntimeError`` with the ``redact`` strings
8
+ masked so API keys never leak into logs. ``legacy_tls_session()`` (Task 3)
9
+ serves endpoints that need OpenSSL legacy server renegotiation.
10
+ """
11
+
12
+ import logging
13
+ import os
14
+ import ssl
15
+ import time
16
+
17
+ import requests
18
+ import urllib3
19
+ from requests.adapters import HTTPAdapter
20
+ from urllib3 import PoolManager
21
+
22
+ info_logger = logging.getLogger('okama_macro.http')
23
+
24
+ DEFAULT_TIMEOUT = 60 # seconds
25
+ USER_AGENT = 'Mozilla/5.0 (okama-data pipeline)'
26
+
27
+
28
+ def proxies_from_env() -> dict[str, str] | None:
29
+ """Build the requests proxies dict from PROXY_* env vars, or None if unset.
30
+
31
+ Foreign sources go through the local HAProxy on the production server;
32
+ without the env vars (e.g. in tests) requests go direct.
33
+ """
34
+ host, port = os.getenv('PROXY_HOST'), os.getenv('PROXY_PORT')
35
+ if not (host and port):
36
+ return None
37
+ user, password = os.getenv('PROXY_USER'), os.getenv('PROXY_PASS')
38
+ auth = f'{user}:{password}@' if user and password else ''
39
+ url = f'http://{auth}{host}:{port}'
40
+ return {'http': url, 'https': url}
41
+
42
+
43
+ class _LegacyRenegotiationAdapter(HTTPAdapter):
44
+ """HTTPAdapter enabling OpenSSL legacy server connect (api.mospi.gov.in)."""
45
+
46
+ def __init__(self, ssl_context: ssl.SSLContext, **kwargs):
47
+ self._ssl_context = ssl_context
48
+ super().__init__(**kwargs)
49
+
50
+ def init_poolmanager(self, connections, maxsize, block=False, **pool_kwargs):
51
+ pool_kwargs['ssl_context'] = self._ssl_context
52
+ self.poolmanager = PoolManager(
53
+ num_pools=connections, maxsize=maxsize, block=block, **pool_kwargs,
54
+ )
55
+
56
+ def proxy_manager_for(self, proxy, **proxy_kwargs):
57
+ # Proxied HTTPS uses a separate ProxyManager that does NOT inherit the
58
+ # context from init_poolmanager, so inject it here too. On the
59
+ # production server MOSPI is fetched through the local HAProxy; without
60
+ # this the tunneled TLS handshake loses OP_LEGACY_SERVER_CONNECT and
61
+ # fails with UNSAFE_LEGACY_RENEGOTIATION_DISABLED.
62
+ proxy_kwargs['ssl_context'] = self._ssl_context
63
+ return super().proxy_manager_for(proxy, **proxy_kwargs)
64
+
65
+
66
+ def get(url: str,
67
+ *,
68
+ params: dict | None = None,
69
+ headers: dict | None = None,
70
+ timeout: int = DEFAULT_TIMEOUT,
71
+ max_attempts: int = 3,
72
+ backoff: float = 1.0,
73
+ use_proxy: bool = False,
74
+ redact: tuple[str, ...] = (),
75
+ label: str = 'request') -> requests.Response:
76
+ """GET ``url`` with UA/proxy defaults and linear-back-off retries on 5xx.
77
+
78
+ Caller-supplied ``headers`` are merged over the defaults, so an explicit
79
+ ``User-Agent`` from the caller wins.
80
+ """
81
+ merged_headers = {'User-Agent': USER_AGENT} | (headers or {})
82
+ proxies = proxies_from_env() if use_proxy else None
83
+ for attempt in range(max_attempts):
84
+ try:
85
+ response = requests.get(url, params=params, headers=merged_headers,
86
+ timeout=timeout, proxies=proxies)
87
+ response.raise_for_status()
88
+ return response
89
+ except requests.RequestException as error:
90
+ resp = getattr(error, 'response', None)
91
+ transient = resp is not None and resp.status_code >= 500
92
+ if transient and attempt < max_attempts - 1:
93
+ info_logger.warning(
94
+ f'{label}: HTTP {resp.status_code}; '
95
+ f'retry {attempt + 1}/{max_attempts - 1}'
96
+ )
97
+ time.sleep(backoff * (attempt + 1))
98
+ continue
99
+ message = str(error)
100
+ for secret in redact:
101
+ message = message.replace(secret, '***')
102
+ raise RuntimeError(f'{label} failed: {message}') from None
103
+ msg = f'{label} failed after {max_attempts} attempts'
104
+ raise RuntimeError(msg) # pragma: no cover
105
+
106
+
107
+ def legacy_tls_session() -> requests.Session:
108
+ """A Session for endpoints needing legacy TLS renegotiation (MOSPI).
109
+
110
+ Verification is disabled (the endpoint presents a chain OpenSSL rejects) —
111
+ the same workaround as MOSPI's own reference client. UA and env proxies
112
+ are preset.
113
+ """
114
+ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
115
+ ssl_context = ssl.create_default_context()
116
+ ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2
117
+ ssl_context.options |= getattr(ssl, 'OP_LEGACY_SERVER_CONNECT', 0x4)
118
+ ssl_context.check_hostname = False
119
+ ssl_context.verify_mode = ssl.CERT_NONE
120
+ session = requests.Session()
121
+ session.headers.update({'User-Agent': USER_AGENT})
122
+ session.verify = False
123
+ session.mount('https://', _LegacyRenegotiationAdapter(ssl_context))
124
+ proxies = proxies_from_env()
125
+ if proxies:
126
+ session.proxies = proxies
127
+ return session