catalejo 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.
@@ -0,0 +1,20 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ venv/
5
+ dist/
6
+ build/
7
+ *.egg-info/
8
+ .pytest_cache/
9
+ .mypy_cache/
10
+ .ruff_cache/
11
+ .coverage
12
+ htmlcov/
13
+ .DS_Store
14
+
15
+ # local HTTP cache for CNMV requests
16
+ .catalejo_cache/
17
+
18
+ # raw screen recordings (only the converted GIFs go in the repo)
19
+ docs/*.mov
20
+ docs/Screen*
@@ -0,0 +1,11 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (unreleased)
4
+
5
+ First public release.
6
+
7
+ - `catalejo.filings()`: price-sensitive (IP) and other regulatory (OIR) announcements from the CNMV register, with full history since 2020, date ranges and server-side filtering by NIF.
8
+ - `catalejo.securities()`: ISINs registered under an issuer (shares, bonds...).
9
+ - `catalejo.document_text()`: text of the PDF attached to a filing (`catalejo[pdf]`).
10
+ - MCP server (`catalejo-mcp`, `catalejo[mcp]`) with `latest_filings` and `issuer_filings` tools.
11
+ - Polite by default: identifiable User-Agent, 1 request per second, on-disk cache.
catalejo-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pol Fañanás
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,148 @@
1
+ Metadata-Version: 2.5
2
+ Name: catalejo
3
+ Version: 0.1.0
4
+ Summary: Clean, structured access to Spanish securities regulator (CNMV) public data, as a Python library and MCP server.
5
+ Project-URL: Homepage, https://github.com/polfananas/catalejo
6
+ Project-URL: Issues, https://github.com/polfananas/catalejo/issues
7
+ Project-URL: Changelog, https://github.com/polfananas/catalejo/blob/main/CHANGELOG.md
8
+ Author: Pol Fañanás
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: cnmv,corporate-disclosures,edgar,finance,financial-data,mcp,securities,spain
12
+ Classifier: Development Status :: 2 - Pre-Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: Financial and Insurance Industry
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Office/Business :: Financial :: Investment
17
+ Classifier: Typing :: Typed
18
+ Requires-Python: >=3.10
19
+ Requires-Dist: httpx>=0.27
20
+ Requires-Dist: selectolax>=1.0
21
+ Requires-Dist: tzdata; sys_platform == 'win32'
22
+ Provides-Extra: dev
23
+ Requires-Dist: mypy>=1.10; extra == 'dev'
24
+ Requires-Dist: pypdf>=5; extra == 'dev'
25
+ Requires-Dist: pytest>=8; extra == 'dev'
26
+ Requires-Dist: ruff>=0.6; extra == 'dev'
27
+ Provides-Extra: mcp
28
+ Requires-Dist: mcp<2,>=1.20; extra == 'mcp'
29
+ Provides-Extra: pdf
30
+ Requires-Dist: pypdf>=5; extra == 'pdf'
31
+ Description-Content-Type: text/markdown
32
+
33
+ # catalejo
34
+
35
+ [![tests](https://github.com/polfananas/catalejo/actions/workflows/tests.yml/badge.svg)](https://github.com/polfananas/catalejo/actions/workflows/tests.yml)
36
+
37
+ > *catalejo* (Spanish): spyglass.
38
+
39
+ **Spain's securities regulator (CNMV) publishes everything listed companies have to disclose, but only through web forms and PDFs.** catalejo turns that into clean Python objects, and plugs it into AI agents through an MCP server.
40
+
41
+ Think of it as a small **SEC EDGAR for Spain**.
42
+
43
+ > *Having had perfect understanding of all things from the very first, to write unto thee in order, that thou mightest know the certainty of those things.* (Luke 1:3-4)
44
+
45
+ ![catalejo in a terminal: the latest price-sensitive announcements, 176 BBVA filings since 2020, the whole BBVA–Sabadell takeover bid in order, and its outcome read from the CNMV's own PDF. All live data.](https://raw.githubusercontent.com/polfananas/catalejo/main/docs/demo.gif)
46
+
47
+ <!-- TODO: second GIF, Claude Desktop using the MCP server -->
48
+
49
+ ```python
50
+ >>> import catalejo
51
+ >>> for f in catalejo.filings(days=7, kind="IP"):
52
+ ... print(f.registered_at, f.issuer, "|", f.title)
53
+ 2026-11-10 16:20:00+01:00 CONSTRUCCIONES Y AUXILIAR DE FERROCARRILES, S.A | El Consejo de Administración de la Sociedad ha aprobado el Plan Estratégico 2027-2030.
54
+ 2026-11-09 12:14:00+01:00 ELECNOR, S.A. | Elecnor comunica la propuesta del Consejo de Administración de distribución de un dividendo ...
55
+ ```
56
+
57
+ ## Install
58
+
59
+ ```bash
60
+ pip install catalejo # library
61
+ pip install "catalejo[mcp]" # + MCP server for Claude and other agents
62
+ pip install "catalejo[pdf]" # + text extraction from filed PDFs
63
+ ```
64
+
65
+ Python 3.10+.
66
+
67
+ ## What you can get
68
+
69
+ **Price-sensitive announcements (`IP`) and other regulatory announcements (`OIR`)**, the two channels issuers use to disclose anything that can move the price: results, M&A, takeover bids, guidance, board changes, buybacks...
70
+
71
+ ```python
72
+ from datetime import date
73
+ import catalejo
74
+
75
+ catalejo.filings(days=3) # everything from the last 3 days, IP + OIR
76
+ catalejo.filings(days=30, kind="IP", issuer="cellnex")
77
+
78
+ # Full history for one company. Filtering by NIF (Spanish tax id) is done
79
+ # by the CNMV itself, so years of data come back in a few seconds.
80
+ catalejo.filings(since=date(2020, 1, 1), kind="IP", issuer="A64907306")
81
+ ```
82
+
83
+ Each `Filing` has `kind`, `registered_at` (timezone-aware, Madrid), `issuer`, `issuer_nif`, `category`, `title`, `registry_number` and `document_url`.
84
+
85
+ **The text of the filed document:**
86
+
87
+ ```python
88
+ f = catalejo.filings(days=10, kind="IP", issuer="A64907306")[0]
89
+ print(catalejo.document_text(f)) # needs catalejo[pdf]
90
+ ```
91
+
92
+ **The securities (ISINs) of an issuer**, shares and bonds:
93
+
94
+ ```python
95
+ >>> [s for s in catalejo.securities("A64907306") if s.is_share]
96
+ [Security(isin='ES0105066007', fisn='CLNX/AC 0.25', cfi='ESVUFB', ...)]
97
+ ```
98
+
99
+ ## Use it from Claude (MCP)
100
+
101
+ catalejo ships an MCP server with two tools, `latest_filings` and `issuer_filings`, so you can ask things like *"What price-sensitive news has Cellnex published since 2023? Group it by topic."*
102
+
103
+ **Claude Desktop:** add this to `claude_desktop_config.json` (Settings → Developer → Edit Config) and restart the app:
104
+
105
+ ```json
106
+ {
107
+ "mcpServers": {
108
+ "catalejo": { "command": "catalejo-mcp" }
109
+ }
110
+ }
111
+ ```
112
+
113
+ If Claude can't find the command, use the full path from `which catalejo-mcp`.
114
+
115
+ **Claude Code:** `claude mcp add catalejo -- catalejo-mcp`
116
+
117
+ ## Why not just ask an AI with web search?
118
+
119
+ Because the CNMV's search pages are forms that search engines barely index. An agent with web search ends up reading press articles: incomplete, late, and you can't tell what's missing. With catalejo it reads the official register directly: every filing, with its exact time, registry number and a link to the original document. And it can do what web search can't, like going through years of history or across every issuer.
120
+
121
+ ## Being a good citizen
122
+
123
+ catalejo talks only to `cnmv.es`, sends an identifiable User-Agent, makes **at most one request per second** and caches responses on disk (`~/.cache/catalejo`, or set `CATALEJO_CACHE_DIR`). Filed documents never change, so they are downloaded once. Please keep it that way if you fork it.
124
+
125
+ ## Good to know
126
+
127
+ - **Price-sensitive announcements (`IP`) have their own channel since 8 February 2020.** Before that, everything was published as *hechos relevantes*, which catalejo doesn't cover yet.
128
+ - Searching by **NIF** is fast (the CNMV filters). Searching by **name** works, but scans every filing in the window, so keep the window short.
129
+ - Times are the CNMV's registration time, to the minute.
130
+ - Filings come in the language the issuer used, which is almost always Spanish.
131
+ - Early days: the API may still change before 1.0.
132
+
133
+ ## Roadmap
134
+
135
+ - [x] Price-sensitive and other regulatory announcements, with full history
136
+ - [x] ISINs per issuer, text of filed PDFs
137
+ - [x] MCP server
138
+ - [ ] Directors' and managers' share dealings (art. 19 MAR)
139
+ - [ ] Net short positions
140
+ - [ ] Takeover bids (OPAs): status, price, spread
141
+ - [ ] Fund portfolios
142
+ - [ ] CLI
143
+
144
+ ## Data and license
145
+
146
+ The data comes from the [CNMV](https://www.cnmv.es) public registers and remains subject to the CNMV's [legal notice](https://www.cnmv.es/Portal/utilidades/NotaLegal): reproduce it faithfully, and if you ever sell it, tell your buyers it's free at cnmv.es. catalejo is not affiliated with or endorsed by the CNMV.
147
+
148
+ Code: MIT. See [LICENSE](https://github.com/polfananas/catalejo/blob/main/LICENSE).
@@ -0,0 +1,116 @@
1
+ # catalejo
2
+
3
+ [![tests](https://github.com/polfananas/catalejo/actions/workflows/tests.yml/badge.svg)](https://github.com/polfananas/catalejo/actions/workflows/tests.yml)
4
+
5
+ > *catalejo* (Spanish): spyglass.
6
+
7
+ **Spain's securities regulator (CNMV) publishes everything listed companies have to disclose, but only through web forms and PDFs.** catalejo turns that into clean Python objects, and plugs it into AI agents through an MCP server.
8
+
9
+ Think of it as a small **SEC EDGAR for Spain**.
10
+
11
+ > *Having had perfect understanding of all things from the very first, to write unto thee in order, that thou mightest know the certainty of those things.* (Luke 1:3-4)
12
+
13
+ ![catalejo in a terminal: the latest price-sensitive announcements, 176 BBVA filings since 2020, the whole BBVA–Sabadell takeover bid in order, and its outcome read from the CNMV's own PDF. All live data.](https://raw.githubusercontent.com/polfananas/catalejo/main/docs/demo.gif)
14
+
15
+ <!-- TODO: second GIF, Claude Desktop using the MCP server -->
16
+
17
+ ```python
18
+ >>> import catalejo
19
+ >>> for f in catalejo.filings(days=7, kind="IP"):
20
+ ... print(f.registered_at, f.issuer, "|", f.title)
21
+ 2026-11-10 16:20:00+01:00 CONSTRUCCIONES Y AUXILIAR DE FERROCARRILES, S.A | El Consejo de Administración de la Sociedad ha aprobado el Plan Estratégico 2027-2030.
22
+ 2026-11-09 12:14:00+01:00 ELECNOR, S.A. | Elecnor comunica la propuesta del Consejo de Administración de distribución de un dividendo ...
23
+ ```
24
+
25
+ ## Install
26
+
27
+ ```bash
28
+ pip install catalejo # library
29
+ pip install "catalejo[mcp]" # + MCP server for Claude and other agents
30
+ pip install "catalejo[pdf]" # + text extraction from filed PDFs
31
+ ```
32
+
33
+ Python 3.10+.
34
+
35
+ ## What you can get
36
+
37
+ **Price-sensitive announcements (`IP`) and other regulatory announcements (`OIR`)**, the two channels issuers use to disclose anything that can move the price: results, M&A, takeover bids, guidance, board changes, buybacks...
38
+
39
+ ```python
40
+ from datetime import date
41
+ import catalejo
42
+
43
+ catalejo.filings(days=3) # everything from the last 3 days, IP + OIR
44
+ catalejo.filings(days=30, kind="IP", issuer="cellnex")
45
+
46
+ # Full history for one company. Filtering by NIF (Spanish tax id) is done
47
+ # by the CNMV itself, so years of data come back in a few seconds.
48
+ catalejo.filings(since=date(2020, 1, 1), kind="IP", issuer="A64907306")
49
+ ```
50
+
51
+ Each `Filing` has `kind`, `registered_at` (timezone-aware, Madrid), `issuer`, `issuer_nif`, `category`, `title`, `registry_number` and `document_url`.
52
+
53
+ **The text of the filed document:**
54
+
55
+ ```python
56
+ f = catalejo.filings(days=10, kind="IP", issuer="A64907306")[0]
57
+ print(catalejo.document_text(f)) # needs catalejo[pdf]
58
+ ```
59
+
60
+ **The securities (ISINs) of an issuer**, shares and bonds:
61
+
62
+ ```python
63
+ >>> [s for s in catalejo.securities("A64907306") if s.is_share]
64
+ [Security(isin='ES0105066007', fisn='CLNX/AC 0.25', cfi='ESVUFB', ...)]
65
+ ```
66
+
67
+ ## Use it from Claude (MCP)
68
+
69
+ catalejo ships an MCP server with two tools, `latest_filings` and `issuer_filings`, so you can ask things like *"What price-sensitive news has Cellnex published since 2023? Group it by topic."*
70
+
71
+ **Claude Desktop:** add this to `claude_desktop_config.json` (Settings → Developer → Edit Config) and restart the app:
72
+
73
+ ```json
74
+ {
75
+ "mcpServers": {
76
+ "catalejo": { "command": "catalejo-mcp" }
77
+ }
78
+ }
79
+ ```
80
+
81
+ If Claude can't find the command, use the full path from `which catalejo-mcp`.
82
+
83
+ **Claude Code:** `claude mcp add catalejo -- catalejo-mcp`
84
+
85
+ ## Why not just ask an AI with web search?
86
+
87
+ Because the CNMV's search pages are forms that search engines barely index. An agent with web search ends up reading press articles: incomplete, late, and you can't tell what's missing. With catalejo it reads the official register directly: every filing, with its exact time, registry number and a link to the original document. And it can do what web search can't, like going through years of history or across every issuer.
88
+
89
+ ## Being a good citizen
90
+
91
+ catalejo talks only to `cnmv.es`, sends an identifiable User-Agent, makes **at most one request per second** and caches responses on disk (`~/.cache/catalejo`, or set `CATALEJO_CACHE_DIR`). Filed documents never change, so they are downloaded once. Please keep it that way if you fork it.
92
+
93
+ ## Good to know
94
+
95
+ - **Price-sensitive announcements (`IP`) have their own channel since 8 February 2020.** Before that, everything was published as *hechos relevantes*, which catalejo doesn't cover yet.
96
+ - Searching by **NIF** is fast (the CNMV filters). Searching by **name** works, but scans every filing in the window, so keep the window short.
97
+ - Times are the CNMV's registration time, to the minute.
98
+ - Filings come in the language the issuer used, which is almost always Spanish.
99
+ - Early days: the API may still change before 1.0.
100
+
101
+ ## Roadmap
102
+
103
+ - [x] Price-sensitive and other regulatory announcements, with full history
104
+ - [x] ISINs per issuer, text of filed PDFs
105
+ - [x] MCP server
106
+ - [ ] Directors' and managers' share dealings (art. 19 MAR)
107
+ - [ ] Net short positions
108
+ - [ ] Takeover bids (OPAs): status, price, spread
109
+ - [ ] Fund portfolios
110
+ - [ ] CLI
111
+
112
+ ## Data and license
113
+
114
+ The data comes from the [CNMV](https://www.cnmv.es) public registers and remains subject to the CNMV's [legal notice](https://www.cnmv.es/Portal/utilidades/NotaLegal): reproduce it faithfully, and if you ever sell it, tell your buyers it's free at cnmv.es. catalejo is not affiliated with or endorsed by the CNMV.
115
+
116
+ Code: MIT. See [LICENSE](https://github.com/polfananas/catalejo/blob/main/LICENSE).
@@ -0,0 +1,59 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.24"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "catalejo"
7
+ version = "0.1.0"
8
+ description = "Clean, structured access to Spanish securities regulator (CNMV) public data, as a Python library and MCP server."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.10"
13
+ authors = [{ name = "Pol Fañanás" }]
14
+ keywords = ["cnmv", "spain", "finance", "securities", "financial-data", "corporate-disclosures", "mcp", "edgar"]
15
+ classifiers = [
16
+ "Development Status :: 2 - Pre-Alpha",
17
+ "Intended Audience :: Developers",
18
+ "Intended Audience :: Financial and Insurance Industry",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: Office/Business :: Financial :: Investment",
21
+ "Typing :: Typed",
22
+ ]
23
+ dependencies = [
24
+ "httpx>=0.27",
25
+ "selectolax>=1.0",
26
+ "tzdata; sys_platform == 'win32'",
27
+ ]
28
+
29
+ [project.urls]
30
+ Homepage = "https://github.com/polfananas/catalejo"
31
+ Issues = "https://github.com/polfananas/catalejo/issues"
32
+ Changelog = "https://github.com/polfananas/catalejo/blob/main/CHANGELOG.md"
33
+
34
+ [project.optional-dependencies]
35
+ dev = ["pytest>=8", "ruff>=0.6", "mypy>=1.10", "pypdf>=5"]
36
+ pdf = ["pypdf>=5"]
37
+ mcp = ["mcp>=1.20,<2"]
38
+
39
+ [project.scripts]
40
+ catalejo-mcp = "catalejo.mcp_server:main"
41
+
42
+ [tool.hatch.build.targets.sdist]
43
+ exclude = ["docs", ".github"]
44
+
45
+ [tool.hatch.build.targets.wheel]
46
+ packages = ["src/catalejo"]
47
+
48
+ [tool.pytest.ini_options]
49
+ testpaths = ["tests"]
50
+
51
+ [tool.ruff]
52
+ line-length = 100
53
+ target-version = "py310"
54
+
55
+ [tool.ruff.lint]
56
+ select = ["E", "F", "I", "B", "UP"]
57
+
58
+ [tool.mypy]
59
+ strict = true
@@ -0,0 +1,10 @@
1
+ """catalejo: Spanish CNMV public data, made usable."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from catalejo.documents import document_text
6
+ from catalejo.filings import filings
7
+ from catalejo.models import Filing, Security
8
+ from catalejo.securities import securities
9
+
10
+ __all__ = ["Filing", "Security", "document_text", "filings", "securities"]
@@ -0,0 +1,203 @@
1
+ """Polite HTTP client for cnmv.es.
2
+
3
+ Everything catalejo downloads goes through here, so the rules for being a
4
+ good citizen live in one place:
5
+
6
+ * identifiable User-Agent,
7
+ * at most one request per ``min_interval`` seconds,
8
+ * on-disk cache so repeated calls don't hit the CNMV again,
9
+ * a few retries with backoff on 429/5xx and network errors,
10
+ * only *.cnmv.es hosts are allowed.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import json
17
+ import os
18
+ import time
19
+ from collections.abc import Callable, Mapping
20
+ from dataclasses import dataclass
21
+ from pathlib import Path
22
+ from urllib.parse import urlsplit
23
+
24
+ import httpx
25
+
26
+ from catalejo import __version__
27
+
28
+ USER_AGENT = f"catalejo/{__version__} (+https://github.com/polfananas/catalejo)"
29
+ ALLOWED_HOST_SUFFIX = "cnmv.es"
30
+ RETRY_STATUSES = frozenset({429, 500, 502, 503, 504})
31
+
32
+
33
+ class CNMVRequestError(RuntimeError):
34
+ """Raised when the CNMV can't be reached or keeps answering with errors."""
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class Response:
39
+ url: str
40
+ status_code: int
41
+ content: bytes
42
+ content_type: str
43
+ from_cache: bool = False
44
+
45
+ @property
46
+ def text(self) -> str:
47
+ return self.content.decode("utf-8", errors="replace")
48
+
49
+
50
+ def default_cache_dir() -> Path:
51
+ env = os.environ.get("CATALEJO_CACHE_DIR")
52
+ if env:
53
+ return Path(env)
54
+ base = os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache"
55
+ return Path(base) / "catalejo"
56
+
57
+
58
+ class HttpClient:
59
+ def __init__(
60
+ self,
61
+ *,
62
+ cache_dir: Path | None = None,
63
+ cache_ttl: float = 3600.0,
64
+ min_interval: float = 1.0,
65
+ max_retries: int = 3,
66
+ timeout: float = 30.0,
67
+ transport: httpx.BaseTransport | None = None,
68
+ sleep: Callable[[float], None] = time.sleep,
69
+ clock: Callable[[], float] = time.monotonic,
70
+ ) -> None:
71
+ self.cache_dir = cache_dir if cache_dir is not None else default_cache_dir()
72
+ self.cache_ttl = cache_ttl
73
+ self.min_interval = min_interval
74
+ self.max_retries = max_retries
75
+ self._sleep = sleep
76
+ self._clock = clock
77
+ self._last_request: float | None = None
78
+ self._client = httpx.Client(
79
+ headers={"User-Agent": USER_AGENT},
80
+ timeout=timeout,
81
+ follow_redirects=True,
82
+ transport=transport,
83
+ )
84
+
85
+ # -- public -----------------------------------------------------------
86
+
87
+ def get(
88
+ self,
89
+ url: str,
90
+ params: Mapping[str, str | int] | None = None,
91
+ *,
92
+ use_cache: bool = True,
93
+ ttl: float | None = None,
94
+ ) -> Response:
95
+ """GET a cnmv.es URL. `ttl` overrides the client's cache_ttl for this call
96
+ (use `math.inf` for things that never change, like filed documents)."""
97
+ _check_host(url)
98
+ key = _cache_key(url, params)
99
+ ttl = self.cache_ttl if ttl is None else ttl
100
+
101
+ if use_cache and ttl > 0:
102
+ cached = self._read_cache(key, ttl)
103
+ if cached is not None:
104
+ return cached
105
+
106
+ response = self._fetch(url, params)
107
+ if use_cache and ttl > 0:
108
+ self._write_cache(key, response)
109
+ return response
110
+
111
+ def close(self) -> None:
112
+ self._client.close()
113
+
114
+ def __enter__(self) -> HttpClient:
115
+ return self
116
+
117
+ def __exit__(self, *exc: object) -> None:
118
+ self.close()
119
+
120
+ # -- internals --------------------------------------------------------
121
+
122
+ def _throttle(self) -> None:
123
+ if self._last_request is not None:
124
+ wait = self.min_interval - (self._clock() - self._last_request)
125
+ if wait > 0:
126
+ self._sleep(wait)
127
+ self._last_request = self._clock()
128
+
129
+ def _fetch(self, url: str, params: Mapping[str, str | int] | None) -> Response:
130
+ last_error: str = ""
131
+ for attempt in range(self.max_retries + 1):
132
+ if attempt:
133
+ self._sleep(min(2**attempt, 30))
134
+ self._throttle()
135
+ try:
136
+ r = self._client.get(url, params=dict(params) if params else None)
137
+ except httpx.TransportError as e:
138
+ last_error = f"{type(e).__name__}: {e}"
139
+ continue
140
+
141
+ if r.status_code in RETRY_STATUSES:
142
+ last_error = f"HTTP {r.status_code}"
143
+ retry_after = r.headers.get("Retry-After", "")
144
+ if retry_after.isdigit():
145
+ self._sleep(min(int(retry_after), 60))
146
+ continue
147
+ if r.status_code >= 400:
148
+ raise CNMVRequestError(f"HTTP {r.status_code} for {r.url}")
149
+
150
+ return Response(
151
+ url=str(r.url),
152
+ status_code=r.status_code,
153
+ content=r.content,
154
+ content_type=r.headers.get("Content-Type", ""),
155
+ )
156
+
157
+ raise CNMVRequestError(
158
+ f"giving up on {url} after {self.max_retries + 1} attempts ({last_error})"
159
+ )
160
+
161
+ def _read_cache(self, key: str, ttl: float) -> Response | None:
162
+ meta_path = self.cache_dir / f"{key}.json"
163
+ body_path = self.cache_dir / f"{key}.bin"
164
+ try:
165
+ meta = json.loads(meta_path.read_text())
166
+ if time.time() - meta["fetched_at"] > ttl:
167
+ return None
168
+ content = body_path.read_bytes()
169
+ except (OSError, ValueError, KeyError):
170
+ return None
171
+ return Response(
172
+ url=meta["url"],
173
+ status_code=meta["status_code"],
174
+ content=content,
175
+ content_type=meta["content_type"],
176
+ from_cache=True,
177
+ )
178
+
179
+ def _write_cache(self, key: str, response: Response) -> None:
180
+ try:
181
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
182
+ (self.cache_dir / f"{key}.bin").write_bytes(response.content)
183
+ meta = {
184
+ "url": response.url,
185
+ "status_code": response.status_code,
186
+ "content_type": response.content_type,
187
+ "fetched_at": time.time(),
188
+ }
189
+ (self.cache_dir / f"{key}.json").write_text(json.dumps(meta))
190
+ except OSError:
191
+ pass # a broken cache shouldn't break the request
192
+
193
+
194
+ def _check_host(url: str) -> None:
195
+ host = (urlsplit(url).hostname or "").lower()
196
+ if host != ALLOWED_HOST_SUFFIX and not host.endswith("." + ALLOWED_HOST_SUFFIX):
197
+ raise ValueError(f"catalejo only talks to cnmv.es, got {host!r}")
198
+
199
+
200
+ def _cache_key(url: str, params: Mapping[str, str | int] | None) -> str:
201
+ items = sorted((k, str(v)) for k, v in (params or {}).items())
202
+ raw = url + "?" + "&".join(f"{k}={v}" for k, v in items)
203
+ return hashlib.sha256(raw.encode()).hexdigest()[:32]