eltoque-python 0.1.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.
eltoque/__init__.py ADDED
@@ -0,0 +1,44 @@
1
+ """Unofficial, typed Python client for the official elTOQUE exchange-rate API.
2
+
3
+ Exchange rates are indicative, not official. Source: elTOQUE.
4
+ Access requires registration and an application-specific API token.
5
+ """
6
+
7
+ from eltoque._async_client import AsyncElToqueClient
8
+ from eltoque._client import ElToqueClient
9
+ from eltoque._version import __version__
10
+ from eltoque.exceptions import (
11
+ ElToqueAPIError,
12
+ ElToqueAuthenticationError,
13
+ ElToqueBadRequestError,
14
+ ElToqueConfigurationError,
15
+ ElToqueDecodeError,
16
+ ElToqueError,
17
+ ElToqueNetworkError,
18
+ ElToqueRateLimitError,
19
+ ElToqueServerError,
20
+ ElToqueTimeoutError,
21
+ ElToqueUnprocessableError,
22
+ ElToqueValidationError,
23
+ )
24
+ from eltoque.models import RateLimitInfo, RatesResponse
25
+
26
+ __all__ = [
27
+ "AsyncElToqueClient",
28
+ "ElToqueAPIError",
29
+ "ElToqueAuthenticationError",
30
+ "ElToqueBadRequestError",
31
+ "ElToqueClient",
32
+ "ElToqueConfigurationError",
33
+ "ElToqueDecodeError",
34
+ "ElToqueError",
35
+ "ElToqueNetworkError",
36
+ "ElToqueRateLimitError",
37
+ "ElToqueServerError",
38
+ "ElToqueTimeoutError",
39
+ "ElToqueUnprocessableError",
40
+ "ElToqueValidationError",
41
+ "RateLimitInfo",
42
+ "RatesResponse",
43
+ "__version__",
44
+ ]
@@ -0,0 +1,131 @@
1
+ """Asynchronous client. Mirrors :class:`eltoque.ElToqueClient`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from typing import TYPE_CHECKING, Self
7
+
8
+ import httpx
9
+
10
+ from eltoque._config import DEFAULT_BASE_URL, DEFAULT_TIMEOUT, ClientConfig
11
+ from eltoque._dates import day_range, validate_range
12
+ from eltoque._retry import RetryPolicy
13
+ from eltoque._transport import (
14
+ FOLLOW_REDIRECTS,
15
+ aread_limited,
16
+ build_request_parts,
17
+ parse_response,
18
+ translate_exception,
19
+ )
20
+ from eltoque.exceptions import ElToqueConfigurationError, ElToqueError
21
+
22
+ if TYPE_CHECKING:
23
+ from datetime import date, datetime
24
+ from types import TracebackType
25
+
26
+ from eltoque.models import RatesResponse
27
+
28
+
29
+ class AsyncElToqueClient:
30
+ """Async client for the elTOQUE ``GET /v1/trmi`` endpoint.
31
+
32
+ Exchange rates are indicative, not official. Source: elTOQUE. Every request is made
33
+ explicitly by the caller; nothing polls in the background.
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ api_token: str,
39
+ *,
40
+ base_url: str = DEFAULT_BASE_URL,
41
+ timeout: float | httpx.Timeout = DEFAULT_TIMEOUT,
42
+ max_retries: int = 0,
43
+ transport: httpx.AsyncBaseTransport | None = None,
44
+ http_client: httpx.AsyncClient | None = None,
45
+ ) -> None:
46
+ """Create a client.
47
+
48
+ ``max_retries`` (default 0, max 5) opts in to bounded retries for 429, 502/503/504 and
49
+ transport failures; every retry consumes API quota. ``Retry-After`` is honored and
50
+ waits use ``asyncio.sleep`` so cancellation propagates immediately.
51
+
52
+ ``transport`` is a test seam (e.g. ``httpx.MockTransport``). ``http_client`` lets you
53
+ share your own ``httpx.AsyncClient``: it is never closed by this SDK, its own timeout
54
+ applies (``timeout`` is ignored) and redirects are still not followed for our requests.
55
+ """
56
+ self._config = ClientConfig(
57
+ api_token=api_token, base_url=base_url, timeout=timeout, max_retries=max_retries
58
+ )
59
+ if http_client is not None and transport is not None:
60
+ raise ElToqueConfigurationError("pass either http_client or transport, not both")
61
+ self._owns_http = http_client is None
62
+ self._http = http_client or httpx.AsyncClient(
63
+ timeout=self._config.timeout, follow_redirects=FOLLOW_REDIRECTS, transport=transport
64
+ )
65
+ self._closed = False
66
+
67
+ async def __aenter__(self) -> Self:
68
+ self._ensure_open()
69
+ return self
70
+
71
+ async def __aexit__(
72
+ self,
73
+ exc_type: type[BaseException] | None,
74
+ exc_value: BaseException | None,
75
+ traceback: TracebackType | None,
76
+ ) -> None:
77
+ await self.aclose()
78
+
79
+ def __repr__(self) -> str:
80
+ return f"AsyncElToqueClient(base_url={self._config.base_url!r})"
81
+
82
+ async def get_latest_rates(self) -> RatesResponse:
83
+ """Rates for the rolling last 24 hours, computed by the provider at request time."""
84
+ return await self._get(None)
85
+
86
+ async def get_rates(self, date_from: datetime, date_to: datetime) -> RatesResponse:
87
+ """Rates for an explicit window shorter than 24 hours (naive datetimes only)."""
88
+ return await self._get(validate_range(date_from, date_to))
89
+
90
+ async def get_rates_for_day(self, day: date) -> RatesResponse:
91
+ """Convenience for ``get_rates(day 00:00:00, day 23:59:59)``; one request, no loops."""
92
+ return await self.get_rates(*day_range(day))
93
+
94
+ async def aclose(self) -> None:
95
+ """Release the connection pool (only if this client created it). Idempotent."""
96
+ if not self._closed:
97
+ self._closed = True
98
+ if self._owns_http:
99
+ await self._http.aclose()
100
+
101
+ def _ensure_open(self) -> None:
102
+ if self._closed:
103
+ raise ElToqueError("this client is closed; create a new AsyncElToqueClient")
104
+
105
+ async def _get(self, date_range: tuple[str, str] | None) -> RatesResponse:
106
+ self._ensure_open()
107
+ policy = RetryPolicy.from_config(self._config)
108
+ while True:
109
+ try:
110
+ return await self._get_once(date_range)
111
+ except ElToqueError as error:
112
+ delay = policy.next_delay(error)
113
+ if delay is None:
114
+ raise
115
+ await asyncio.sleep(delay)
116
+
117
+ async def _get_once(self, date_range: tuple[str, str] | None) -> RatesResponse:
118
+ url, params, headers = build_request_parts(self._config, date_range)
119
+ request = self._http.build_request("GET", url, params=params or None, headers=headers)
120
+ try:
121
+ response = await self._http.send(
122
+ request, stream=True, follow_redirects=FOLLOW_REDIRECTS
123
+ )
124
+ try:
125
+ ok = 200 <= response.status_code < 300
126
+ body = await aread_limited(response, self._config.max_response_bytes, strict=ok)
127
+ finally:
128
+ await response.aclose()
129
+ except httpx.HTTPError as error:
130
+ raise translate_exception(error) from None
131
+ return parse_response(self._config, response.status_code, response.headers, body)
eltoque/_client.py ADDED
@@ -0,0 +1,136 @@
1
+ """Synchronous client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import TYPE_CHECKING, Self
7
+
8
+ import httpx
9
+
10
+ from eltoque._config import DEFAULT_BASE_URL, DEFAULT_TIMEOUT, ClientConfig
11
+ from eltoque._dates import day_range, validate_range
12
+ from eltoque._retry import RetryPolicy
13
+ from eltoque._transport import (
14
+ FOLLOW_REDIRECTS,
15
+ build_request_parts,
16
+ parse_response,
17
+ read_limited,
18
+ translate_exception,
19
+ )
20
+ from eltoque.exceptions import ElToqueConfigurationError, ElToqueError
21
+
22
+ if TYPE_CHECKING:
23
+ from datetime import date, datetime
24
+ from types import TracebackType
25
+
26
+ from eltoque.models import RatesResponse
27
+
28
+
29
+ class ElToqueClient:
30
+ """Client for the elTOQUE ``GET /v1/trmi`` endpoint.
31
+
32
+ Exchange rates are indicative, not official. Source: elTOQUE. Every request is made
33
+ explicitly by the caller; nothing polls in the background.
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ api_token: str,
39
+ *,
40
+ base_url: str = DEFAULT_BASE_URL,
41
+ timeout: float | httpx.Timeout = DEFAULT_TIMEOUT,
42
+ max_retries: int = 0,
43
+ transport: httpx.BaseTransport | None = None,
44
+ http_client: httpx.Client | None = None,
45
+ ) -> None:
46
+ """Create a client.
47
+
48
+ ``max_retries`` (default 0, max 5) opts in to bounded retries for 429, 502/503/504 and
49
+ transport failures; every retry consumes API quota. ``Retry-After`` is honored.
50
+
51
+ ``transport`` is a test seam (e.g. ``httpx.MockTransport``). ``http_client`` lets you
52
+ share your own ``httpx.Client``: it is never closed by this SDK, its own timeout
53
+ applies (``timeout`` is ignored) and redirects are still not followed for our requests.
54
+ """
55
+ self._config = ClientConfig(
56
+ api_token=api_token, base_url=base_url, timeout=timeout, max_retries=max_retries
57
+ )
58
+ if http_client is not None and transport is not None:
59
+ raise ElToqueConfigurationError("pass either http_client or transport, not both")
60
+ self._owns_http = http_client is None
61
+ self._http = http_client or httpx.Client(
62
+ timeout=self._config.timeout, follow_redirects=FOLLOW_REDIRECTS, transport=transport
63
+ )
64
+ self._closed = False
65
+
66
+ def __enter__(self) -> Self:
67
+ self._ensure_open()
68
+ return self
69
+
70
+ def __exit__(
71
+ self,
72
+ exc_type: type[BaseException] | None,
73
+ exc_value: BaseException | None,
74
+ traceback: TracebackType | None,
75
+ ) -> None:
76
+ self.close()
77
+
78
+ def __repr__(self) -> str:
79
+ return f"ElToqueClient(base_url={self._config.base_url!r})"
80
+
81
+ def get_latest_rates(self) -> RatesResponse:
82
+ """Rates for the rolling last 24 hours, computed by the provider at request time."""
83
+ return self._get(None)
84
+
85
+ def get_rates(self, date_from: datetime, date_to: datetime) -> RatesResponse:
86
+ """Rates for an explicit window shorter than 24 hours.
87
+
88
+ Both bounds must be naive datetimes, sent as ``YYYY-MM-DD HH:MM:SS``. The provider's
89
+ time zone is undocumented, so no conversion is applied.
90
+ """
91
+ return self._get(validate_range(date_from, date_to))
92
+
93
+ def get_rates_for_day(self, day: date) -> RatesResponse:
94
+ """Convenience for ``get_rates(day 00:00:00, day 23:59:59)``.
95
+
96
+ The provider time zone is undocumented, so this is not a guaranteed complete local
97
+ calendar day. One call makes exactly one request; no multi-day assembly is offered.
98
+ """
99
+ return self.get_rates(*day_range(day))
100
+
101
+ def close(self) -> None:
102
+ """Release the connection pool (only if this client created it). Idempotent."""
103
+ if not self._closed:
104
+ self._closed = True
105
+ if self._owns_http:
106
+ self._http.close()
107
+
108
+ def _ensure_open(self) -> None:
109
+ if self._closed:
110
+ raise ElToqueError("this client is closed; create a new ElToqueClient")
111
+
112
+ def _get(self, date_range: tuple[str, str] | None) -> RatesResponse:
113
+ self._ensure_open()
114
+ policy = RetryPolicy.from_config(self._config)
115
+ while True:
116
+ try:
117
+ return self._get_once(date_range)
118
+ except ElToqueError as error:
119
+ delay = policy.next_delay(error)
120
+ if delay is None:
121
+ raise
122
+ time.sleep(delay)
123
+
124
+ def _get_once(self, date_range: tuple[str, str] | None) -> RatesResponse:
125
+ url, params, headers = build_request_parts(self._config, date_range)
126
+ request = self._http.build_request("GET", url, params=params or None, headers=headers)
127
+ try:
128
+ response = self._http.send(request, stream=True, follow_redirects=FOLLOW_REDIRECTS)
129
+ try:
130
+ ok = 200 <= response.status_code < 300
131
+ body = read_limited(response, self._config.max_response_bytes, strict=ok)
132
+ finally:
133
+ response.close()
134
+ except httpx.HTTPError as error:
135
+ raise translate_exception(error) from None
136
+ return parse_response(self._config, response.status_code, response.headers, body)
eltoque/_config.py ADDED
@@ -0,0 +1,106 @@
1
+ """Validated, immutable client configuration (internal)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from dataclasses import dataclass, field
7
+ from urllib.parse import urlsplit
8
+
9
+ import httpx
10
+
11
+ from eltoque.exceptions import ElToqueConfigurationError
12
+
13
+ DEFAULT_BASE_URL = "https://tasas.eltoque.com"
14
+ LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
15
+ DEFAULT_TIMEOUT = httpx.Timeout(10.0, connect=5.0)
16
+ MAX_RETRIES_LIMIT = 5
17
+ DEFAULT_MAX_RETRY_DELAY = 30.0
18
+ DEFAULT_MAX_TOTAL_RETRY_DELAY = 60.0
19
+ DEFAULT_MAX_RESPONSE_BYTES = 5 * 1024 * 1024
20
+
21
+
22
+ def _validate_token(token: object) -> str:
23
+ if not isinstance(token, str) or not token:
24
+ raise ElToqueConfigurationError("api_token must be a non-empty string")
25
+ # Printable ASCII without whitespace: blocks header injection and "Bearer " prefixes.
26
+ if not all("!" <= char <= "~" for char in token):
27
+ raise ElToqueConfigurationError(
28
+ "api_token contains whitespace, control or non-ASCII characters; "
29
+ "pass the bare token without a 'Bearer ' prefix"
30
+ )
31
+ return token
32
+
33
+
34
+ def _validate_base_url(base_url: object) -> str:
35
+ if not isinstance(base_url, str) or not base_url:
36
+ raise ElToqueConfigurationError("base_url must be a non-empty string")
37
+ try:
38
+ parts = urlsplit(base_url)
39
+ host = parts.hostname
40
+ parts.port # noqa: B018 (raises ValueError on an invalid port)
41
+ except ValueError as error:
42
+ raise ElToqueConfigurationError("base_url is not a valid URL") from error
43
+ if not host:
44
+ raise ElToqueConfigurationError("base_url must include a host")
45
+ if parts.username is not None or parts.password is not None:
46
+ raise ElToqueConfigurationError("base_url must not contain credentials")
47
+ if parts.query or parts.fragment:
48
+ raise ElToqueConfigurationError("base_url must not contain a query or fragment")
49
+ if parts.scheme == "http":
50
+ if host not in LOOPBACK_HOSTS:
51
+ raise ElToqueConfigurationError(
52
+ "plain http is only allowed for loopback hosts (local tests); use https"
53
+ )
54
+ elif parts.scheme != "https":
55
+ raise ElToqueConfigurationError("base_url must use https")
56
+ return base_url.rstrip("/")
57
+
58
+
59
+ def _validate_timeout(timeout: object) -> httpx.Timeout:
60
+ if isinstance(timeout, httpx.Timeout):
61
+ return timeout
62
+ if isinstance(timeout, bool) or not isinstance(timeout, int | float):
63
+ raise ElToqueConfigurationError("timeout must be a positive number or httpx.Timeout")
64
+ if not math.isfinite(timeout) or timeout <= 0:
65
+ raise ElToqueConfigurationError("timeout must be a positive, finite number of seconds")
66
+ return httpx.Timeout(float(timeout))
67
+
68
+
69
+ def _validate_non_negative(name: str, value: object, *, integer: bool = False) -> None:
70
+ kinds = int if integer else (int, float)
71
+ if isinstance(value, bool) or not isinstance(value, kinds):
72
+ raise ElToqueConfigurationError(f"{name} must be a number")
73
+ if not math.isfinite(value) or value < 0:
74
+ raise ElToqueConfigurationError(f"{name} must be a finite number >= 0")
75
+
76
+
77
+ @dataclass(frozen=True, slots=True, repr=False)
78
+ class ClientConfig:
79
+ """Settings shared by the sync and async clients. The token is never shown in ``repr``."""
80
+
81
+ api_token: str
82
+ base_url: str = DEFAULT_BASE_URL
83
+ timeout: httpx.Timeout | float = field(default_factory=lambda: DEFAULT_TIMEOUT)
84
+ max_retries: int = 0
85
+ max_retry_delay: float = DEFAULT_MAX_RETRY_DELAY
86
+ max_total_retry_delay: float = DEFAULT_MAX_TOTAL_RETRY_DELAY
87
+ max_response_bytes: int = DEFAULT_MAX_RESPONSE_BYTES
88
+
89
+ def __post_init__(self) -> None:
90
+ object.__setattr__(self, "api_token", _validate_token(self.api_token))
91
+ object.__setattr__(self, "base_url", _validate_base_url(self.base_url))
92
+ object.__setattr__(self, "timeout", _validate_timeout(self.timeout))
93
+ _validate_non_negative("max_retries", self.max_retries, integer=True)
94
+ if self.max_retries > MAX_RETRIES_LIMIT:
95
+ raise ElToqueConfigurationError(f"max_retries must be <= {MAX_RETRIES_LIMIT}")
96
+ _validate_non_negative("max_retry_delay", self.max_retry_delay)
97
+ _validate_non_negative("max_total_retry_delay", self.max_total_retry_delay)
98
+ _validate_non_negative("max_response_bytes", self.max_response_bytes, integer=True)
99
+ if self.max_response_bytes == 0:
100
+ raise ElToqueConfigurationError("max_response_bytes must be > 0")
101
+
102
+ def __repr__(self) -> str:
103
+ return (
104
+ f"ClientConfig(api_token='***', base_url={self.base_url!r}, timeout={self.timeout!r}, "
105
+ f"max_retries={self.max_retries})"
106
+ )
eltoque/_dates.py ADDED
@@ -0,0 +1,53 @@
1
+ """Date-range validation and formatting (internal).
2
+
3
+ The upstream time zone is **not documented**, so values are never converted: only naive
4
+ ``datetime`` objects are accepted and are sent exactly as written, truncated to whole seconds.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from datetime import date, datetime, time, timedelta
10
+
11
+ from eltoque.exceptions import ElToqueValidationError
12
+
13
+ DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
14
+ MAX_WINDOW = timedelta(hours=24)
15
+
16
+
17
+ def _check_naive(name: str, value: object) -> datetime:
18
+ if not isinstance(value, datetime):
19
+ raise ElToqueValidationError(f"{name} must be a datetime.datetime")
20
+ if value.tzinfo is not None and value.utcoffset() is not None:
21
+ raise ElToqueValidationError(
22
+ f"{name} must be a naive datetime: the elTOQUE time zone is not documented, "
23
+ "so no conversion is attempted. Drop tzinfo after normalizing it yourself."
24
+ )
25
+ return value.replace(microsecond=0, tzinfo=None)
26
+
27
+
28
+ def validate_range(date_from: datetime, date_to: datetime) -> tuple[str, str]:
29
+ """Validate an explicit window and return the ``(date_from, date_to)`` query strings.
30
+
31
+ Rules: both bounds naive, ``date_from < date_to`` and a window shorter than 24 hours
32
+ (checked after truncating to whole seconds, i.e. on exactly what is sent).
33
+ """
34
+ start = _check_naive("date_from", date_from)
35
+ end = _check_naive("date_to", date_to)
36
+ if start >= end:
37
+ raise ElToqueValidationError("date_from must be earlier than date_to")
38
+ if end - start >= MAX_WINDOW:
39
+ raise ElToqueValidationError(
40
+ "the interval must be shorter than 24 hours (the API answers HTTP 400 otherwise)"
41
+ )
42
+ return start.strftime(DATE_FORMAT), end.strftime(DATE_FORMAT)
43
+
44
+
45
+ def day_range(day: date) -> tuple[datetime, datetime]:
46
+ """Return ``00:00:00`` and ``23:59:59`` of ``day`` as naive datetimes.
47
+
48
+ The provider's time zone is undocumented, so this is *not* a guaranteed complete
49
+ local calendar day; it is simply the widest whole-second window allowed in one date.
50
+ """
51
+ if isinstance(day, datetime) or not isinstance(day, date):
52
+ raise ElToqueValidationError("day must be a datetime.date (not a datetime)")
53
+ return datetime.combine(day, time(0, 0, 0)), datetime.combine(day, time(23, 59, 59))
eltoque/_retry.py ADDED
@@ -0,0 +1,59 @@
1
+ """Pure retry policy shared by the sync and async clients (internal). It never sleeps."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING
7
+
8
+ from eltoque.exceptions import (
9
+ ElToqueAPIError,
10
+ ElToqueNetworkError,
11
+ ElToqueRateLimitError,
12
+ ElToqueTimeoutError,
13
+ )
14
+
15
+ if TYPE_CHECKING:
16
+ from eltoque._config import ClientConfig
17
+ from eltoque.exceptions import ElToqueError
18
+
19
+ TRANSIENT_STATUSES = frozenset({502, 503, 504})
20
+ BACKOFF_BASE = 0.5
21
+
22
+
23
+ @dataclass(slots=True)
24
+ class RetryPolicy:
25
+ """Decides whether and how long to wait before the next attempt.
26
+
27
+ Retries are opt-in (``max_retries=0`` by default) and consume API quota. A request is
28
+ retried only for 429, transient gateway statuses (502/503/504) and transport failures.
29
+ 400/401/422 are never retried. If the server asks for a wait longer than
30
+ ``max_retry_delay`` (or the total budget), the error is raised instead.
31
+ """
32
+
33
+ max_retries: int
34
+ max_delay: float
35
+ max_total_delay: float
36
+ retries_done: int = 0
37
+ total_delay: float = 0.0
38
+
39
+ @classmethod
40
+ def from_config(cls, config: ClientConfig) -> RetryPolicy:
41
+ return cls(config.max_retries, config.max_retry_delay, config.max_total_retry_delay)
42
+
43
+ @staticmethod
44
+ def is_retryable(error: ElToqueError) -> bool:
45
+ if isinstance(error, ElToqueRateLimitError | ElToqueNetworkError | ElToqueTimeoutError):
46
+ return True
47
+ return isinstance(error, ElToqueAPIError) and error.status_code in TRANSIENT_STATUSES
48
+
49
+ def next_delay(self, error: ElToqueError) -> float | None:
50
+ """Return seconds to wait before retrying, or ``None`` to give up and raise."""
51
+ if self.retries_done >= self.max_retries or not self.is_retryable(error):
52
+ return None
53
+ retry_after = error.retry_after if isinstance(error, ElToqueAPIError) else None
54
+ delay = retry_after if retry_after is not None else BACKOFF_BASE * 2**self.retries_done
55
+ if delay > self.max_delay or self.total_delay + delay > self.max_total_delay:
56
+ return None
57
+ self.retries_done += 1
58
+ self.total_delay += delay
59
+ return delay
eltoque/_transport.py ADDED
@@ -0,0 +1,165 @@
1
+ """Sync/async-neutral request building and response handling (internal)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ import httpx
10
+
11
+ from eltoque._version import __version__
12
+ from eltoque.exceptions import (
13
+ ElToqueAPIError,
14
+ ElToqueAuthenticationError,
15
+ ElToqueBadRequestError,
16
+ ElToqueDecodeError,
17
+ ElToqueError,
18
+ ElToqueNetworkError,
19
+ ElToqueRateLimitError,
20
+ ElToqueServerError,
21
+ ElToqueTimeoutError,
22
+ ElToqueUnprocessableError,
23
+ )
24
+ from eltoque.models import RateLimitInfo, RatesResponse
25
+
26
+ if TYPE_CHECKING:
27
+ from collections.abc import Mapping
28
+
29
+ from eltoque._config import ClientConfig
30
+
31
+ TRMI_PATH = "/v1/trmi"
32
+ USER_AGENT = f"eltoque-python/{__version__}"
33
+ EXCERPT_LIMIT = 200
34
+ # Never follow redirects: a cross-host 3xx would forward the bearer token.
35
+ FOLLOW_REDIRECTS = False
36
+
37
+ _CONTROL_CHARS = re.compile(r"[\x00-\x1f\x7f]+")
38
+ _ERROR_TYPES: dict[int, tuple[type[ElToqueAPIError], str]] = {
39
+ 400: (ElToqueBadRequestError, "bad request (the interval must be shorter than 24 hours)"),
40
+ 401: (ElToqueAuthenticationError, "unauthorized (the token is wrong or expired)"),
41
+ 422: (ElToqueUnprocessableError, "unprocessable entity (check the token)"),
42
+ 429: (ElToqueRateLimitError, "rate limit exceeded"),
43
+ }
44
+
45
+
46
+ def build_request_parts(
47
+ config: ClientConfig, date_range: tuple[str, str] | None = None
48
+ ) -> tuple[str, dict[str, str], dict[str, str]]:
49
+ """Return ``(url, query_params, headers)`` for ``GET /v1/trmi``.
50
+
51
+ Without a date range no query parameters are sent (rolling last 24 hours).
52
+ """
53
+ params: dict[str, str] = {}
54
+ if date_range is not None:
55
+ params = {"date_from": date_range[0], "date_to": date_range[1]}
56
+ headers = {
57
+ "Authorization": f"Bearer {config.api_token}",
58
+ "Accept": "application/json",
59
+ "User-Agent": USER_AGENT,
60
+ }
61
+ return f"{config.base_url}{TRMI_PATH}", params, headers
62
+
63
+
64
+ def make_excerpt(body: bytes, token: str) -> str | None:
65
+ """Return a short, single-line, token-free excerpt of a response body for diagnostics."""
66
+ text = body[: EXCERPT_LIMIT * 4].decode("utf-8", errors="replace")
67
+ text = _CONTROL_CHARS.sub(" ", text).replace(token, "***").strip()
68
+ if not text:
69
+ return None
70
+ return text if len(text) <= EXCERPT_LIMIT else text[:EXCERPT_LIMIT] + "..."
71
+
72
+
73
+ def error_for_response(
74
+ status_code: int, headers: Mapping[str, str], body: bytes, token: str
75
+ ) -> ElToqueAPIError | None:
76
+ """Map an HTTP response to an SDK exception, or ``None`` for a 2xx status."""
77
+ if 200 <= status_code < 300:
78
+ return None
79
+ error_type, reason = _ERROR_TYPES.get(status_code, (ElToqueAPIError, "unexpected status"))
80
+ if status_code >= 500:
81
+ error_type, reason = ElToqueServerError, "server error"
82
+ return error_type(
83
+ f"HTTP {status_code}: {reason}",
84
+ status_code=status_code,
85
+ body_excerpt=make_excerpt(body, token),
86
+ rate_limit=RateLimitInfo.from_headers(headers),
87
+ )
88
+
89
+
90
+ def _too_large(limit: int) -> ElToqueDecodeError:
91
+ return ElToqueDecodeError(f"response body exceeds the {limit}-byte limit")
92
+
93
+
94
+ def check_declared_length(headers: Mapping[str, str], limit: int, *, strict: bool) -> None:
95
+ """Reject early when ``Content-Length`` already exceeds ``limit`` (strict mode only)."""
96
+ declared = headers.get("content-length", "")
97
+ if strict and declared.isascii() and declared.isdigit() and int(declared) > limit:
98
+ raise _too_large(limit)
99
+
100
+
101
+ def read_limited(response: httpx.Response, limit: int, *, strict: bool = True) -> bytes:
102
+ """Read a streamed body. Over ``limit``: raise (strict) or truncate (non-strict)."""
103
+ check_declared_length(response.headers, limit, strict=strict)
104
+ chunks: list[bytes] = []
105
+ size = 0
106
+ for chunk in response.iter_bytes():
107
+ size += len(chunk)
108
+ if size > limit:
109
+ if strict:
110
+ raise _too_large(limit)
111
+ chunks.append(chunk[: len(chunk) - (size - limit)])
112
+ break
113
+ chunks.append(chunk)
114
+ return b"".join(chunks)
115
+
116
+
117
+ async def aread_limited(response: httpx.Response, limit: int, *, strict: bool = True) -> bytes:
118
+ """Async twin of :func:`read_limited`."""
119
+ check_declared_length(response.headers, limit, strict=strict)
120
+ chunks: list[bytes] = []
121
+ size = 0
122
+ async for chunk in response.aiter_bytes():
123
+ size += len(chunk)
124
+ if size > limit:
125
+ if strict:
126
+ raise _too_large(limit)
127
+ chunks.append(chunk[: len(chunk) - (size - limit)])
128
+ break
129
+ chunks.append(chunk)
130
+ return b"".join(chunks)
131
+
132
+
133
+ def decode_json(body: bytes) -> Any:
134
+ """Decode a JSON body exactly as sent; never substitute ``{}`` for bad input."""
135
+ if not body.strip():
136
+ raise ElToqueDecodeError("the response body is empty; expected JSON")
137
+ try:
138
+ return json.loads(body)
139
+ except (UnicodeDecodeError, json.JSONDecodeError) as error:
140
+ raise ElToqueDecodeError("the response body is not valid JSON") from error
141
+
142
+
143
+ def translate_exception(error: httpx.HTTPError) -> ElToqueError:
144
+ """Convert an httpx failure into an SDK exception without echoing URLs or headers.
145
+
146
+ Callers raise the result ``from None``: the original message is dropped on purpose, since a
147
+ custom transport could put arbitrary text (even credentials) in it. Only the type name stays.
148
+ """
149
+ if isinstance(error, httpx.TimeoutException):
150
+ return ElToqueTimeoutError(f"the request timed out ({type(error).__name__})")
151
+ return ElToqueNetworkError(f"the request failed ({type(error).__name__})")
152
+
153
+
154
+ def parse_response(
155
+ config: ClientConfig, status_code: int, headers: Mapping[str, str], body: bytes
156
+ ) -> RatesResponse:
157
+ """Turn a fully read HTTP response into a :class:`RatesResponse` or raise an SDK error."""
158
+ error = error_for_response(status_code, headers, body, config.api_token)
159
+ if error is not None:
160
+ raise error
161
+ return RatesResponse(
162
+ data=decode_json(body),
163
+ status_code=status_code,
164
+ rate_limit=RateLimitInfo.from_headers(headers),
165
+ )
eltoque/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
eltoque/exceptions.py ADDED
@@ -0,0 +1,77 @@
1
+ """Public exception hierarchy.
2
+
3
+ Messages never contain the API token. Response bodies are not included in messages;
4
+ ``body_excerpt`` holds a short, sanitized excerpt for diagnostics only.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import TYPE_CHECKING
10
+
11
+ if TYPE_CHECKING:
12
+ from eltoque.models import RateLimitInfo
13
+
14
+
15
+ class ElToqueError(Exception):
16
+ """Base class for every error raised by this SDK."""
17
+
18
+
19
+ class ElToqueConfigurationError(ElToqueError):
20
+ """Invalid client configuration (token, base URL, timeout, retries)."""
21
+
22
+
23
+ class ElToqueValidationError(ElToqueError):
24
+ """Invalid call arguments, detected before any HTTP request is made."""
25
+
26
+
27
+ class ElToqueNetworkError(ElToqueError):
28
+ """The request could not be completed (connection, DNS, TLS, protocol)."""
29
+
30
+
31
+ class ElToqueTimeoutError(ElToqueError):
32
+ """The request timed out."""
33
+
34
+
35
+ class ElToqueDecodeError(ElToqueError):
36
+ """A successful response did not contain valid JSON, or exceeded the size limit."""
37
+
38
+
39
+ class ElToqueAPIError(ElToqueError):
40
+ """The API answered with an unexpected or error HTTP status."""
41
+
42
+ def __init__(
43
+ self,
44
+ message: str,
45
+ *,
46
+ status_code: int,
47
+ body_excerpt: str | None = None,
48
+ rate_limit: RateLimitInfo | None = None,
49
+ retry_after: float | None = None,
50
+ ) -> None:
51
+ super().__init__(message)
52
+ self.status_code = status_code
53
+ self.body_excerpt = body_excerpt
54
+ self.rate_limit = rate_limit
55
+ if retry_after is None and rate_limit is not None:
56
+ retry_after = rate_limit.retry_after
57
+ self.retry_after = retry_after
58
+
59
+
60
+ class ElToqueBadRequestError(ElToqueAPIError):
61
+ """HTTP 400, e.g. a date range of 24 hours or more."""
62
+
63
+
64
+ class ElToqueAuthenticationError(ElToqueAPIError):
65
+ """HTTP 401: the token is wrong or expired."""
66
+
67
+
68
+ class ElToqueUnprocessableError(ElToqueAPIError):
69
+ """HTTP 422: unprocessable entity; check the token."""
70
+
71
+
72
+ class ElToqueRateLimitError(ElToqueAPIError):
73
+ """HTTP 429: the API key exceeded its rate limit. See ``retry_after``."""
74
+
75
+
76
+ class ElToqueServerError(ElToqueAPIError):
77
+ """HTTP 5xx."""
eltoque/models.py ADDED
@@ -0,0 +1,78 @@
1
+ """Response models owned by the SDK.
2
+
3
+ Nothing in this module describes provider fields: the published OpenAPI document does not
4
+ define the HTTP 200 body, so the payload is exposed exactly as decoded.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import math
10
+ from dataclasses import dataclass, field
11
+ from datetime import UTC, datetime
12
+ from typing import TYPE_CHECKING, Any
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Mapping
16
+
17
+
18
+ def _parse_int(value: str | None) -> int | None:
19
+ if value is None:
20
+ return None
21
+ text = value.strip()
22
+ if not text.isascii() or not text.isdigit():
23
+ return None
24
+ return int(text)
25
+
26
+
27
+ def _parse_seconds(value: str | None) -> float | None:
28
+ if value is None:
29
+ return None
30
+ try:
31
+ seconds = float(value.strip())
32
+ except ValueError:
33
+ return None
34
+ if not math.isfinite(seconds) or seconds < 0:
35
+ return None
36
+ return seconds
37
+
38
+
39
+ @dataclass(frozen=True, slots=True)
40
+ class RateLimitInfo:
41
+ """Rate-limit metadata parsed from response headers.
42
+
43
+ Every field is optional: missing or malformed headers yield ``None`` and never raise.
44
+ ``reset`` is kept as the raw integer sent by the provider; its unit is not documented.
45
+ """
46
+
47
+ limit: int | None = None
48
+ remaining: int | None = None
49
+ reset: int | None = None
50
+ retry_after: float | None = None
51
+
52
+ @classmethod
53
+ def from_headers(cls, headers: Mapping[str, str]) -> RateLimitInfo:
54
+ """Build an instance from response headers (names are matched case-insensitively)."""
55
+ lowered = {key.lower(): value for key, value in headers.items()}
56
+ return cls(
57
+ limit=_parse_int(lowered.get("x-ratelimit-limit")),
58
+ remaining=_parse_int(lowered.get("x-ratelimit-remaining")),
59
+ reset=_parse_int(lowered.get("x-ratelimit-reset")),
60
+ retry_after=_parse_seconds(lowered.get("retry-after")),
61
+ )
62
+
63
+
64
+ @dataclass(frozen=True, slots=True)
65
+ class RatesResponse:
66
+ """Envelope around a successful ``GET /v1/trmi`` response.
67
+
68
+ ``data`` is the decoded JSON body, untouched: the provider has not published a schema for
69
+ HTTP 200, so no field names are guaranteed. ``status_code``, ``rate_limit`` and
70
+ ``received_at`` are SDK metadata, not provider fields.
71
+
72
+ Exchange rates are indicative, not official. Source: elTOQUE.
73
+ """
74
+
75
+ data: Any
76
+ status_code: int = 200
77
+ rate_limit: RateLimitInfo = field(default_factory=RateLimitInfo)
78
+ received_at: datetime = field(default_factory=lambda: datetime.now(UTC))
eltoque/py.typed ADDED
File without changes
@@ -0,0 +1,286 @@
1
+ Metadata-Version: 2.5
2
+ Name: eltoque-python
3
+ Version: 0.1.0
4
+ Summary: Unofficial, typed Python client for the official elTOQUE exchange-rate API.
5
+ Project-URL: Homepage, https://github.com/Alejo2075/eltoque-python
6
+ Project-URL: Source, https://github.com/Alejo2075/eltoque-python
7
+ Project-URL: Issues, https://github.com/Alejo2075/eltoque-python/issues
8
+ Project-URL: Changelog, https://github.com/Alejo2075/eltoque-python/blob/main/CHANGELOG.md
9
+ Project-URL: elTOQUE API docs, https://tasas.eltoque.com/docs/
10
+ Author: eltoque-python contributors
11
+ License-Expression: MIT
12
+ License-File: LICENSE
13
+ Keywords: api-client,async,cuba,eltoque,exchange-rate,httpx
14
+ Classifier: Development Status :: 3 - Alpha
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3 :: Only
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Programming Language :: Python :: 3.14
24
+ Classifier: Typing :: Typed
25
+ Requires-Python: >=3.11
26
+ Requires-Dist: httpx<1,>=0.28
27
+ Description-Content-Type: text/markdown
28
+
29
+ # eltoque-python
30
+
31
+ **Unofficial Python SDK for elTOQUE exchange-rate data.**
32
+ A modern, typed client for the official elTOQUE exchange-rate API, with sync and async interfaces.
33
+
34
+ [![PyPI](https://img.shields.io/pypi/v/eltoque-python)](https://pypi.org/project/eltoque-python/)
35
+ [![Python](https://img.shields.io/pypi/pyversions/eltoque-python)](https://pypi.org/project/eltoque-python/)
36
+ [![CI](https://github.com/Alejo2075/eltoque-python/actions/workflows/ci.yml/badge.svg)](https://github.com/Alejo2075/eltoque-python/actions/workflows/ci.yml)
37
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
38
+
39
+ > **Independent project.** `eltoque-python` is community-maintained. It is not developed,
40
+ > sponsored, endorsed or supported by elTOQUE.
41
+ >
42
+ > **Exchange rates are indicative, not official. Source: elTOQUE.
43
+ > Access requires registration and an application-specific API token.**
44
+
45
+ ```python
46
+ import os
47
+
48
+ from eltoque import ElToqueClient
49
+
50
+ with ElToqueClient(api_token=os.environ["ELTOQUE_API_TOKEN"]) as client:
51
+ response = client.get_latest_rates()
52
+ print(response.data) # the provider's JSON, untouched
53
+ ```
54
+
55
+ Leer en español: [docs/quickstart.es.md](docs/quickstart.es.md)
56
+
57
+ ## Why use it?
58
+
59
+ - **Sync and async** clients with the same methods and behavior.
60
+ - **Typed** public API (`py.typed`, checked with `mypy --strict`).
61
+ - **Safe by default:** HTTPS only, explicit timeouts, no redirects followed (your token is never
62
+ forwarded to another host), bounded response size, and the token never appears in `repr`,
63
+ error messages or logs.
64
+ - **Predictable errors:** one exception family, one subclass per documented HTTP status.
65
+ - **Rate-limit aware:** parses `X-RateLimit-*` and `Retry-After`; retries are opt-in and bounded.
66
+ - **Pure Python**, one runtime dependency ([`httpx`](https://www.python-httpx.org/)), works in
67
+ plain scripts, notebooks, Django, Flask, FastAPI or AWS Lambda.
68
+ - **Tested offline** with mocked HTTP: no token needed to develop or run the test suite.
69
+
70
+ ## Installation
71
+
72
+ ```bash
73
+ pip install eltoque-python
74
+ ```
75
+
76
+ Requires Python 3.11 or newer. To work on the SDK itself, install from a clone with `pip install -e .`.
77
+
78
+ ## Get your API token
79
+
80
+ The SDK does not grant API access. Each application that integrates the API needs **its own
81
+ token**, requested from elTOQUE:
82
+
83
+ 1. Fill in the official form: <https://tasas-token.eltoque.com/>
84
+ 2. Read the [terms of use](https://eltoque.com/api-tasas-de-eltoque-terminos-y-condiciones-de-uso).
85
+ 3. Keep the token secret and read it from the environment:
86
+
87
+ ```bash
88
+ export ELTOQUE_API_TOKEN="your-application-token"
89
+ ```
90
+
91
+ Never commit a token, share it with third parties, or ship it in a browser bundle.
92
+
93
+ ## Quickstart
94
+
95
+ ### Latest rates (rolling last 24 hours)
96
+
97
+ ```python
98
+ import os
99
+
100
+ from eltoque import ElToqueClient
101
+
102
+ with ElToqueClient(api_token=os.environ["ELTOQUE_API_TOKEN"]) as client:
103
+ response = client.get_latest_rates()
104
+ print(response.data)
105
+ ```
106
+
107
+ ### A specific window (shorter than 24 hours)
108
+
109
+ ```python
110
+ import os
111
+ from datetime import datetime
112
+
113
+ from eltoque import ElToqueClient
114
+
115
+ with ElToqueClient(api_token=os.environ["ELTOQUE_API_TOKEN"]) as client:
116
+ response = client.get_rates(
117
+ datetime(2022, 10, 27, 0, 0, 0),
118
+ datetime(2022, 10, 27, 23, 59, 59),
119
+ )
120
+ print(response.data)
121
+ ```
122
+
123
+ ### Async
124
+
125
+ ```python
126
+ import asyncio
127
+ import os
128
+
129
+ from eltoque import AsyncElToqueClient
130
+
131
+
132
+ async def main() -> None:
133
+ async with AsyncElToqueClient(api_token=os.environ["ELTOQUE_API_TOKEN"]) as client:
134
+ response = await client.get_latest_rates()
135
+ print(response.data)
136
+
137
+
138
+ asyncio.run(main())
139
+ ```
140
+
141
+ ### Handling errors
142
+
143
+ ```python
144
+ import os
145
+
146
+ from eltoque import ElToqueClient, ElToqueError, ElToqueRateLimitError
147
+
148
+ with ElToqueClient(api_token=os.environ["ELTOQUE_API_TOKEN"], max_retries=2) as client:
149
+ try:
150
+ response = client.get_latest_rates()
151
+ except ElToqueRateLimitError as error:
152
+ print(f"Rate limited, retry in {error.retry_after} s")
153
+ except ElToqueError as error:
154
+ print(f"Request failed: {error}")
155
+ else:
156
+ print(response.rate_limit.remaining, "requests left in this window")
157
+ ```
158
+
159
+ ## API reference
160
+
161
+ ### Clients
162
+
163
+ `ElToqueClient` (sync) and `AsyncElToqueClient` (async, `await` every method) share this
164
+ constructor:
165
+
166
+ | Parameter | Default | Description |
167
+ | --- | --- | --- |
168
+ | `api_token` | required | Your application token. Printable ASCII, no spaces, no `Bearer ` prefix. |
169
+ | `base_url` | `https://tasas.eltoque.com` | HTTPS only. Plain `http://` is accepted for `localhost`/loopback (tests). |
170
+ | `timeout` | `10 s` (`5 s` connect) | Seconds, or an `httpx.Timeout`. |
171
+ | `max_retries` | `0` | Opt-in retries (max 5). See [Rate limits](#rate-limits-and-retries). |
172
+ | `transport` | `None` | Test seam, e.g. `httpx.MockTransport`. |
173
+ | `http_client` | `None` | Your own `httpx.Client` / `httpx.AsyncClient`. It is never closed by the SDK, and its own timeout applies. |
174
+
175
+ | Method | Description |
176
+ | --- | --- |
177
+ | `get_latest_rates()` | Rolling last 24 hours, computed by the provider at request time. |
178
+ | `get_rates(date_from, date_to)` | Explicit window, strictly shorter than 24 hours. |
179
+ | `get_rates_for_day(day)` | `day 00:00:00` to `day 23:59:59`. One request. |
180
+ | `close()` / `aclose()` | Release connections. Idempotent. Also called by `with` / `async with`. |
181
+
182
+ Using a client after it was closed raises `ElToqueError`.
183
+
184
+ ### `RatesResponse`
185
+
186
+ | Attribute | Description |
187
+ | --- | --- |
188
+ | `data` | The decoded JSON body, **exactly as the provider sent it** (object, list, scalar or `null`). |
189
+ | `status_code` | HTTP status (SDK metadata). |
190
+ | `rate_limit` | `RateLimitInfo` with optional `limit`, `remaining`, `reset`, `retry_after` (SDK metadata). |
191
+ | `received_at` | UTC timestamp of when the SDK received the response (SDK metadata). |
192
+
193
+ ### Exceptions
194
+
195
+ ```text
196
+ ElToqueError
197
+ ├── ElToqueConfigurationError invalid token, base_url, timeout, retries
198
+ ├── ElToqueValidationError invalid arguments, detected before any request
199
+ ├── ElToqueNetworkError connection, DNS, TLS, protocol failures
200
+ ├── ElToqueTimeoutError
201
+ ├── ElToqueDecodeError 200 response that is not valid JSON, or too large
202
+ └── ElToqueAPIError has .status_code, .body_excerpt, .rate_limit, .retry_after
203
+ ├── ElToqueBadRequestError 400
204
+ ├── ElToqueAuthenticationError 401
205
+ ├── ElToqueUnprocessableError 422
206
+ ├── ElToqueRateLimitError 429
207
+ └── ElToqueServerError 5xx
208
+ ```
209
+
210
+ Other 3xx/4xx statuses raise the base `ElToqueAPIError`. See [docs/errors.md](docs/errors.md).
211
+
212
+ ## Date and time rules
213
+
214
+ - `date_from` and `date_to` must be **naive** `datetime` objects. Timezone-aware values are
215
+ rejected, because the API does not document its time zone and the SDK will not guess one.
216
+ - They are sent as `YYYY-MM-DD HH:MM:SS` (microseconds are dropped).
217
+ - `date_from` must be earlier than `date_to`, and the window must be **shorter than 24 hours**,
218
+ otherwise the API answers HTTP 400. The SDK rejects invalid windows before any request.
219
+ - `get_rates_for_day` is a convenience; it is not guaranteed to be a complete local calendar day.
220
+
221
+ ## Rate limits and retries
222
+
223
+ By default each API key may make **60 requests/minute and up to 10 requests/second**; your key
224
+ may have different limits. Over the limit the API answers `429` with a `Retry-After` header.
225
+
226
+ - Retries are **off** (`max_retries=0`). Every retry consumes quota.
227
+ - With `max_retries > 0` the SDK retries only `429`, `502`, `503`, `504` and transport failures,
228
+ never `400`, `401` or `422`.
229
+ - `Retry-After` is honored. If the server asks for longer than 30 s (or the 60 s total budget),
230
+ the error is raised instead of waiting.
231
+ - There is no background polling, caching or bulk collection. You decide when to call.
232
+
233
+ ## Known limitations
234
+
235
+ - Only one operation is documented by the provider: `GET /v1/trmi`. That is all this SDK calls.
236
+ - The provider **does not publish the schema of a successful response**, so `RatesResponse.data`
237
+ is untyped and no field (currency names, timestamps, ...) is guaranteed. Typed rate models will
238
+ come only after the schema is validated with authorized access.
239
+ - The time zone of the API is undocumented (see above).
240
+ - Installing this SDK does not give you access to the API; you need your own approved token.
241
+
242
+ ## Supported Python versions
243
+
244
+ Python 3.11, 3.12, 3.13 and 3.14. No framework is required.
245
+
246
+ ## Development
247
+
248
+ ```bash
249
+ uv sync # or: pip install -e . && pip install pytest pytest-asyncio pytest-cov ruff mypy build twine
250
+ uv run ruff check .
251
+ uv run ruff format --check .
252
+ uv run mypy src
253
+ uv run pytest # offline, no token needed
254
+ uv run python -m build
255
+ uv run twine check dist/*
256
+ ```
257
+
258
+ Optional live check, only with your own authorized token (never run in CI):
259
+
260
+ ```bash
261
+ ELTOQUE_API_TOKEN=... ELTOQUE_ALLOW_LIVE_TESTS=1 uv run pytest -m integration --no-cov
262
+ ```
263
+
264
+ ## Security and legal
265
+
266
+ - Treat your token like a password. See [docs/security.md](docs/security.md).
267
+ - This project is not affiliated with elTOQUE. When you display rates, name **elTOQUE as the
268
+ source** and describe them as **reference estimates**, as the
269
+ [terms of use](https://eltoque.com/api-tasas-de-eltoque-terminos-y-condiciones-de-uso) require.
270
+ Do not modify the data, share your key, resell the data or mass-extract it.
271
+ - The [MIT license](LICENSE) covers this SDK's source code only. It grants no rights over
272
+ elTOQUE's API, data, name or trademarks.
273
+
274
+ ## Official resources
275
+
276
+ - API landing page: <https://tasas.eltoque.com/>
277
+ - Interactive docs: <https://tasas.eltoque.com/docs/>
278
+ - OpenAPI document: <https://tasas.eltoque.com/static/swagger.json>
279
+ - What is and is not verified: [docs/api-contract.md](docs/api-contract.md)
280
+
281
+ ## Community
282
+
283
+ - Contributing guide: [CONTRIBUTING.md](CONTRIBUTING.md)
284
+ - Code of conduct: [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)
285
+ - Security policy: [SECURITY.md](SECURITY.md)
286
+ - Release notes: [CHANGELOG.md](CHANGELOG.md)
@@ -0,0 +1,15 @@
1
+ eltoque/__init__.py,sha256=n8tNGDozKLqvQEBMBFzF_ZEKrpaPhUmveJvi_tk93js,1217
2
+ eltoque/_async_client.py,sha256=w3dlRBLTSTorvlKDknQLFJGUIu_TU-yQRbGGF7XEouk,5129
3
+ eltoque/_client.py,sha256=_1kWhS4t8QgHYovoDH11U8BFFtL_Axx_SuLuotHg8r4,5130
4
+ eltoque/_config.py,sha256=WhomK447d-krKNPEunySQYJGshAkOL8LvlEewE16MEo,4680
5
+ eltoque/_dates.py,sha256=e24dVq4Piy_Up7Our_qkC2H15WCVLBs-WisdF_RnPcs,2307
6
+ eltoque/_retry.py,sha256=lxHigz0SkZx-GJTRVei5-nHh5IPoGRgUhSxJ8o25UrI,2186
7
+ eltoque/_transport.py,sha256=4gzJisbvBnyICy4LPueVE7E0bvnBUNmogpQIPeHhK-I,6122
8
+ eltoque/_version.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
9
+ eltoque/exceptions.py,sha256=Wksj29sdxW11_fFemyvNktnUyS3cG51UUDoFTin9964,2149
10
+ eltoque/models.py,sha256=TrN_64EMGI5_rtUXxR0pEvTiZi1U5NxXXqMrHNtUVCY,2526
11
+ eltoque/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ eltoque_python-0.1.0.dist-info/METADATA,sha256=WWLWly5NSNdBeM_AacqkcXBeLrh0Cmjm-W_taGliNOE,11269
13
+ eltoque_python-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
14
+ eltoque_python-0.1.0.dist-info/licenses/LICENSE,sha256=FG7YS9H2810eIXz1GzwcjlUisz2-3qcMO3h9vF57gZI,1226
15
+ eltoque_python-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,24 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 eltoque-python contributors
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.
22
+
23
+ Scope note: this license covers the source code of this repository only. It
24
+ grants no rights over the elTOQUE API, data, name or trademarks.