odos-py 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.
odos_py/__init__.py ADDED
@@ -0,0 +1,33 @@
1
+ """odos-py: a modern Python client for the Odos DEX Aggregator API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .async_client import AsyncOdosClient
6
+ from .client import OdosClient
7
+ from .exceptions import OdosAPIError, OdosError, OdosRateLimitError
8
+ from .models import (
9
+ AssembleRequest,
10
+ AssembleResponse,
11
+ InputToken,
12
+ OutputToken,
13
+ QuoteRequest,
14
+ QuoteResponse,
15
+ Transaction,
16
+ )
17
+
18
+ __version__ = "0.1.0"
19
+
20
+ __all__ = [
21
+ "OdosClient",
22
+ "AsyncOdosClient",
23
+ "OdosError",
24
+ "OdosAPIError",
25
+ "OdosRateLimitError",
26
+ "QuoteRequest",
27
+ "QuoteResponse",
28
+ "AssembleRequest",
29
+ "AssembleResponse",
30
+ "InputToken",
31
+ "OutputToken",
32
+ "Transaction",
33
+ ]
odos_py/_base.py ADDED
@@ -0,0 +1,110 @@
1
+ """Shared configuration and HTTP plumbing for the sync and async clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+ from email.utils import parsedate_to_datetime
7
+ from typing import Any, Mapping, Optional
8
+
9
+ import httpx
10
+
11
+ DEFAULT_BASE_URL = "https://api.odos.xyz"
12
+ DEFAULT_API_KEY_HEADER = "x-api-key"
13
+ DEFAULT_MAX_RETRIES = 3
14
+ DEFAULT_BACKOFF_BASE = 0.5
15
+ DEFAULT_TIMEOUT = 30.0
16
+
17
+
18
+ class OdosConfig:
19
+ """Holds connection settings shared by both client flavours."""
20
+
21
+ def __init__(
22
+ self,
23
+ *,
24
+ base_url: str = DEFAULT_BASE_URL,
25
+ api_key: Optional[str] = None,
26
+ api_key_header: str = DEFAULT_API_KEY_HEADER,
27
+ max_retries: int = DEFAULT_MAX_RETRIES,
28
+ backoff_base: float = DEFAULT_BACKOFF_BASE,
29
+ timeout: float = DEFAULT_TIMEOUT,
30
+ ) -> None:
31
+ self.base_url = base_url.rstrip("/")
32
+ self.api_key = api_key
33
+ self.api_key_header = api_key_header
34
+ self.max_retries = max_retries
35
+ self.backoff_base = backoff_base
36
+ self.timeout = timeout
37
+
38
+ def headers(self) -> dict[str, str]:
39
+ """Build default request headers, including the API key when set."""
40
+ headers = {
41
+ "Accept": "application/json",
42
+ "Content-Type": "application/json",
43
+ }
44
+ if self.api_key:
45
+ headers[self.api_key_header] = self.api_key
46
+ return headers
47
+
48
+ def url(self, path: str) -> str:
49
+ return f"{self.base_url}/{path.lstrip('/')}"
50
+
51
+
52
+ def parse_retry_after(
53
+ headers: Mapping[str, str], *, now: Optional[datetime] = None
54
+ ) -> Optional[float]:
55
+ """Extract the ``Retry-After`` delay in seconds, if present.
56
+
57
+ Per RFC 7231 the header may be either a number of seconds (delta) or an
58
+ HTTP-date. Both forms are supported; an HTTP-date is converted to a delay
59
+ relative to ``now`` (defaulting to the current UTC time) and clamped to a
60
+ non-negative value. Returns ``None`` when the header is absent or
61
+ unparseable, so callers fall back to exponential backoff.
62
+ """
63
+ value = headers.get("Retry-After") or headers.get("retry-after")
64
+ if value is None:
65
+ return None
66
+
67
+ text = str(value).strip()
68
+
69
+ # Form 1: a number of seconds.
70
+ try:
71
+ return float(text)
72
+ except ValueError:
73
+ pass
74
+
75
+ # Form 2: an HTTP-date.
76
+ try:
77
+ target = parsedate_to_datetime(text)
78
+ except (TypeError, ValueError):
79
+ return None
80
+ if target is None:
81
+ return None
82
+ if target.tzinfo is None:
83
+ target = target.replace(tzinfo=timezone.utc)
84
+
85
+ current = now or datetime.now(timezone.utc)
86
+ if current.tzinfo is None:
87
+ current = current.replace(tzinfo=timezone.utc)
88
+
89
+ delay = (target - current).total_seconds()
90
+ return max(0.0, delay)
91
+
92
+
93
+ def backoff_delay(base: float, attempt: int, retry_after: Optional[float]) -> float:
94
+ """Compute the wait before the next retry (exponential, server-hint aware).
95
+
96
+ ``attempt`` is zero-based. A server-provided ``Retry-After`` wins when it is
97
+ larger than the computed exponential backoff.
98
+ """
99
+ computed = base * float(2**attempt)
100
+ if retry_after is not None:
101
+ return max(computed, retry_after)
102
+ return computed
103
+
104
+
105
+ def safe_json(response: httpx.Response) -> Any:
106
+ """Return parsed JSON, or the raw text if the body is not valid JSON."""
107
+ try:
108
+ return response.json()
109
+ except ValueError:
110
+ return response.text
@@ -0,0 +1,163 @@
1
+ """Asynchronous client for the Odos DEX Aggregator API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from types import TracebackType
7
+ from typing import Any, Optional
8
+
9
+ import httpx
10
+
11
+ from ._base import (
12
+ DEFAULT_API_KEY_HEADER,
13
+ DEFAULT_BACKOFF_BASE,
14
+ DEFAULT_BASE_URL,
15
+ DEFAULT_MAX_RETRIES,
16
+ DEFAULT_TIMEOUT,
17
+ OdosConfig,
18
+ backoff_delay,
19
+ parse_retry_after,
20
+ safe_json,
21
+ )
22
+ from .exceptions import OdosAPIError, OdosRateLimitError
23
+ from .models import AssembleRequest, AssembleResponse, QuoteRequest, QuoteResponse
24
+
25
+
26
+ class AsyncOdosClient:
27
+ """Async counterpart of :class:`~odos_py.client.OdosClient`.
28
+
29
+ Same configuration and behaviour, backed by ``httpx.AsyncClient``.
30
+ """
31
+
32
+ def __init__(
33
+ self,
34
+ *,
35
+ base_url: str = DEFAULT_BASE_URL,
36
+ api_key: Optional[str] = None,
37
+ api_key_header: str = DEFAULT_API_KEY_HEADER,
38
+ max_retries: int = DEFAULT_MAX_RETRIES,
39
+ backoff_base: float = DEFAULT_BACKOFF_BASE,
40
+ timeout: float = DEFAULT_TIMEOUT,
41
+ transport: Optional[httpx.AsyncBaseTransport] = None,
42
+ ) -> None:
43
+ self._config = OdosConfig(
44
+ base_url=base_url,
45
+ api_key=api_key,
46
+ api_key_header=api_key_header,
47
+ max_retries=max_retries,
48
+ backoff_base=backoff_base,
49
+ timeout=timeout,
50
+ )
51
+ self._client = httpx.AsyncClient(
52
+ timeout=timeout,
53
+ headers=self._config.headers(),
54
+ transport=transport,
55
+ )
56
+
57
+ @property
58
+ def base_url(self) -> str:
59
+ return self._config.base_url
60
+
61
+ # -- lifecycle ---------------------------------------------------------
62
+
63
+ async def aclose(self) -> None:
64
+ await self._client.aclose()
65
+
66
+ async def __aenter__(self) -> AsyncOdosClient:
67
+ return self
68
+
69
+ async def __aexit__(
70
+ self,
71
+ exc_type: Optional[type[BaseException]],
72
+ exc: Optional[BaseException],
73
+ tb: Optional[TracebackType],
74
+ ) -> None:
75
+ await self.aclose()
76
+
77
+ # -- low-level request with retry/backoff ------------------------------
78
+
79
+ async def _request(self, method: str, path: str, *, json: Any = None) -> Any:
80
+ url = self._config.url(path)
81
+ attempt = 0
82
+ while True:
83
+ response = await self._client.request(method, url, json=json)
84
+ if response.status_code < 400:
85
+ return safe_json(response)
86
+
87
+ if response.status_code == 429 and attempt < self._config.max_retries:
88
+ retry_after = parse_retry_after(response.headers)
89
+ delay = backoff_delay(self._config.backoff_base, attempt, retry_after)
90
+ if delay > 0:
91
+ await asyncio.sleep(delay)
92
+ attempt += 1
93
+ continue
94
+
95
+ body = safe_json(response)
96
+ message = f"{method} {url} failed with status {response.status_code}"
97
+ if response.status_code == 429:
98
+ raise OdosRateLimitError(
99
+ message,
100
+ status_code=429,
101
+ body=body,
102
+ retry_after=parse_retry_after(response.headers),
103
+ )
104
+ raise OdosAPIError(message, status_code=response.status_code, body=body)
105
+
106
+ # -- SOR endpoints -----------------------------------------------------
107
+
108
+ async def quote(self, request: QuoteRequest) -> QuoteResponse:
109
+ """``POST /sor/quote/v2`` — get a swap path and its ``pathId``."""
110
+ data = await self._request("POST", "/sor/quote/v2", json=request.model_dump(by_alias=True))
111
+ return QuoteResponse.model_validate(data)
112
+
113
+ async def assemble(
114
+ self, *, user_addr: str, path_id: str, simulate: bool = False
115
+ ) -> AssembleResponse:
116
+ """``POST /sor/assemble`` — turn a ``pathId`` into signable calldata."""
117
+ req = AssembleRequest(user_addr=user_addr, path_id=path_id, simulate=simulate)
118
+ data = await self._request("POST", "/sor/assemble", json=req.model_dump(by_alias=True))
119
+ return AssembleResponse.model_validate(data)
120
+
121
+ async def execute(self, *, user_addr: str, path_id: str) -> Any:
122
+ """``POST /sor/execute`` — assisted execution (returns raw JSON)."""
123
+ return await self._request(
124
+ "POST",
125
+ "/sor/execute",
126
+ json={"userAddr": user_addr, "pathId": path_id},
127
+ )
128
+
129
+ async def swap(
130
+ self, request: QuoteRequest, *, simulate: bool = False
131
+ ) -> tuple[QuoteResponse, AssembleResponse]:
132
+ """Convenience helper: quote then assemble in one awaited call."""
133
+ quote = await self.quote(request)
134
+ if not quote.path_id:
135
+ raise OdosAPIError("Quote response did not include a pathId", body=quote.model_dump())
136
+ assembled = await self.assemble(
137
+ user_addr=request.user_addr, path_id=quote.path_id, simulate=simulate
138
+ )
139
+ return quote, assembled
140
+
141
+ quote_and_assemble = swap
142
+
143
+ # -- info / pricing endpoints -----------------------------------------
144
+
145
+ async def get_chains(self) -> Any:
146
+ """``GET /info/chains`` — supported chains."""
147
+ return await self._request("GET", "/info/chains")
148
+
149
+ async def get_tokens(self, chain_id: int) -> Any:
150
+ """``GET /info/tokens/{chainId}`` — token map for a chain."""
151
+ return await self._request("GET", f"/info/tokens/{chain_id}")
152
+
153
+ async def get_router(self, chain_id: int) -> Any:
154
+ """``GET /info/router/v2/{chainId}`` — router contract address."""
155
+ return await self._request("GET", f"/info/router/v2/{chain_id}")
156
+
157
+ async def get_contract_info(self, chain_id: int) -> Any:
158
+ """``GET /info/contract-info/v2/{chainId}`` — contract metadata."""
159
+ return await self._request("GET", f"/info/contract-info/v2/{chain_id}")
160
+
161
+ async def get_token_price(self, chain_id: int, token_address: str) -> Any:
162
+ """``GET /pricing/token/{chainId}/{tokenAddress}`` — token price."""
163
+ return await self._request("GET", f"/pricing/token/{chain_id}/{token_address}")
odos_py/client.py ADDED
@@ -0,0 +1,167 @@
1
+ """Synchronous client for the Odos DEX Aggregator API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from types import TracebackType
7
+ from typing import Any, Optional
8
+
9
+ import httpx
10
+
11
+ from ._base import (
12
+ DEFAULT_API_KEY_HEADER,
13
+ DEFAULT_BACKOFF_BASE,
14
+ DEFAULT_BASE_URL,
15
+ DEFAULT_MAX_RETRIES,
16
+ DEFAULT_TIMEOUT,
17
+ OdosConfig,
18
+ backoff_delay,
19
+ parse_retry_after,
20
+ safe_json,
21
+ )
22
+ from .exceptions import OdosAPIError, OdosRateLimitError
23
+ from .models import AssembleRequest, AssembleResponse, QuoteRequest, QuoteResponse
24
+
25
+
26
+ class OdosClient:
27
+ """A synchronous client for the Odos API.
28
+
29
+ The free keyless tier is heavily rate limited; pass ``api_key`` to raise
30
+ limits. ``api_key_header`` is configurable because the exact header name is
31
+ not publicly documented.
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ *,
37
+ base_url: str = DEFAULT_BASE_URL,
38
+ api_key: Optional[str] = None,
39
+ api_key_header: str = DEFAULT_API_KEY_HEADER,
40
+ max_retries: int = DEFAULT_MAX_RETRIES,
41
+ backoff_base: float = DEFAULT_BACKOFF_BASE,
42
+ timeout: float = DEFAULT_TIMEOUT,
43
+ transport: Optional[httpx.BaseTransport] = None,
44
+ ) -> None:
45
+ self._config = OdosConfig(
46
+ base_url=base_url,
47
+ api_key=api_key,
48
+ api_key_header=api_key_header,
49
+ max_retries=max_retries,
50
+ backoff_base=backoff_base,
51
+ timeout=timeout,
52
+ )
53
+ self._client = httpx.Client(
54
+ timeout=timeout,
55
+ headers=self._config.headers(),
56
+ transport=transport,
57
+ )
58
+
59
+ @property
60
+ def base_url(self) -> str:
61
+ return self._config.base_url
62
+
63
+ # -- lifecycle ---------------------------------------------------------
64
+
65
+ def close(self) -> None:
66
+ self._client.close()
67
+
68
+ def __enter__(self) -> OdosClient:
69
+ return self
70
+
71
+ def __exit__(
72
+ self,
73
+ exc_type: Optional[type[BaseException]],
74
+ exc: Optional[BaseException],
75
+ tb: Optional[TracebackType],
76
+ ) -> None:
77
+ self.close()
78
+
79
+ # -- low-level request with retry/backoff ------------------------------
80
+
81
+ def _request(self, method: str, path: str, *, json: Any = None) -> Any:
82
+ url = self._config.url(path)
83
+ attempt = 0
84
+ while True:
85
+ response = self._client.request(method, url, json=json)
86
+ if response.status_code < 400:
87
+ return safe_json(response)
88
+
89
+ if response.status_code == 429 and attempt < self._config.max_retries:
90
+ retry_after = parse_retry_after(response.headers)
91
+ delay = backoff_delay(self._config.backoff_base, attempt, retry_after)
92
+ if delay > 0:
93
+ time.sleep(delay)
94
+ attempt += 1
95
+ continue
96
+
97
+ body = safe_json(response)
98
+ message = f"{method} {url} failed with status {response.status_code}"
99
+ if response.status_code == 429:
100
+ raise OdosRateLimitError(
101
+ message,
102
+ status_code=429,
103
+ body=body,
104
+ retry_after=parse_retry_after(response.headers),
105
+ )
106
+ raise OdosAPIError(message, status_code=response.status_code, body=body)
107
+
108
+ # -- SOR endpoints -----------------------------------------------------
109
+
110
+ def quote(self, request: QuoteRequest) -> QuoteResponse:
111
+ """``POST /sor/quote/v2`` — get a swap path and its ``pathId``."""
112
+ data = self._request("POST", "/sor/quote/v2", json=request.model_dump(by_alias=True))
113
+ return QuoteResponse.model_validate(data)
114
+
115
+ def assemble(self, *, user_addr: str, path_id: str, simulate: bool = False) -> AssembleResponse:
116
+ """``POST /sor/assemble`` — turn a ``pathId`` into signable calldata."""
117
+ req = AssembleRequest(user_addr=user_addr, path_id=path_id, simulate=simulate)
118
+ data = self._request("POST", "/sor/assemble", json=req.model_dump(by_alias=True))
119
+ return AssembleResponse.model_validate(data)
120
+
121
+ def execute(self, *, user_addr: str, path_id: str) -> Any:
122
+ """``POST /sor/execute`` — assisted execution (returns raw JSON)."""
123
+ return self._request(
124
+ "POST",
125
+ "/sor/execute",
126
+ json={"userAddr": user_addr, "pathId": path_id},
127
+ )
128
+
129
+ def swap(
130
+ self, request: QuoteRequest, *, simulate: bool = False
131
+ ) -> tuple[QuoteResponse, AssembleResponse]:
132
+ """Convenience helper: quote then assemble in one call.
133
+
134
+ Returns the quote and the assembled transaction. Raises
135
+ :class:`OdosAPIError` if the quote did not return a ``pathId``.
136
+ """
137
+ quote = self.quote(request)
138
+ if not quote.path_id:
139
+ raise OdosAPIError("Quote response did not include a pathId", body=quote.model_dump())
140
+ assembled = self.assemble(
141
+ user_addr=request.user_addr, path_id=quote.path_id, simulate=simulate
142
+ )
143
+ return quote, assembled
144
+
145
+ quote_and_assemble = swap
146
+
147
+ # -- info / pricing endpoints -----------------------------------------
148
+
149
+ def get_chains(self) -> Any:
150
+ """``GET /info/chains`` — supported chains."""
151
+ return self._request("GET", "/info/chains")
152
+
153
+ def get_tokens(self, chain_id: int) -> Any:
154
+ """``GET /info/tokens/{chainId}`` — token map for a chain."""
155
+ return self._request("GET", f"/info/tokens/{chain_id}")
156
+
157
+ def get_router(self, chain_id: int) -> Any:
158
+ """``GET /info/router/v2/{chainId}`` — router contract address."""
159
+ return self._request("GET", f"/info/router/v2/{chain_id}")
160
+
161
+ def get_contract_info(self, chain_id: int) -> Any:
162
+ """``GET /info/contract-info/v2/{chainId}`` — contract metadata."""
163
+ return self._request("GET", f"/info/contract-info/v2/{chain_id}")
164
+
165
+ def get_token_price(self, chain_id: int, token_address: str) -> Any:
166
+ """``GET /pricing/token/{chainId}/{tokenAddress}`` — token price."""
167
+ return self._request("GET", f"/pricing/token/{chain_id}/{token_address}")
odos_py/exceptions.py ADDED
@@ -0,0 +1,42 @@
1
+ """Exception hierarchy for the Odos client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Optional
6
+
7
+
8
+ class OdosError(Exception):
9
+ """Base class for all errors raised by this library."""
10
+
11
+
12
+ class OdosAPIError(OdosError):
13
+ """Raised when the Odos API returns a non-success HTTP status."""
14
+
15
+ def __init__(
16
+ self,
17
+ message: str,
18
+ *,
19
+ status_code: Optional[int] = None,
20
+ body: Any = None,
21
+ ) -> None:
22
+ super().__init__(message)
23
+ self.status_code = status_code
24
+ self.body = body
25
+
26
+
27
+ class OdosRateLimitError(OdosAPIError):
28
+ """Raised on HTTP 429 after retries are exhausted.
29
+
30
+ ``retry_after`` is the server-advised wait in seconds, when available.
31
+ """
32
+
33
+ def __init__(
34
+ self,
35
+ message: str,
36
+ *,
37
+ status_code: Optional[int] = None,
38
+ body: Any = None,
39
+ retry_after: Optional[float] = None,
40
+ ) -> None:
41
+ super().__init__(message, status_code=status_code, body=body)
42
+ self.retry_after = retry_after
odos_py/models.py ADDED
@@ -0,0 +1,106 @@
1
+ """Pydantic models for Odos API requests and responses.
2
+
3
+ All models accept both the API's native ``camelCase`` field names and
4
+ ``snake_case`` aliases so the library is ergonomic from Python while staying
5
+ faithful to the wire format. Response models tolerate unknown fields because
6
+ the Odos API returns a large, evolving payload.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Optional
12
+
13
+ from pydantic import BaseModel, ConfigDict, Field
14
+ from pydantic.alias_generators import to_camel
15
+
16
+ _REQUEST_CONFIG = ConfigDict(
17
+ alias_generator=to_camel,
18
+ populate_by_name=True,
19
+ serialize_by_alias=True,
20
+ )
21
+ _RESPONSE_CONFIG = ConfigDict(
22
+ alias_generator=to_camel,
23
+ populate_by_name=True,
24
+ extra="allow",
25
+ )
26
+
27
+
28
+ class InputToken(BaseModel):
29
+ """A token being sold, with its amount expressed in wei (base units)."""
30
+
31
+ model_config = _REQUEST_CONFIG
32
+
33
+ token_address: str
34
+ amount: str
35
+
36
+
37
+ class OutputToken(BaseModel):
38
+ """A token being bought, with its desired proportion of the output."""
39
+
40
+ model_config = _REQUEST_CONFIG
41
+
42
+ token_address: str
43
+ proportion: float
44
+
45
+
46
+ class QuoteRequest(BaseModel):
47
+ """Body for ``POST /sor/quote/v2``."""
48
+
49
+ model_config = _REQUEST_CONFIG
50
+
51
+ chain_id: int
52
+ input_tokens: list[InputToken]
53
+ output_tokens: list[OutputToken]
54
+ user_addr: str
55
+ slippage_limit_percent: float
56
+
57
+
58
+ class QuoteResponse(BaseModel):
59
+ """Response from ``POST /sor/quote/v2``.
60
+
61
+ Only the fields commonly needed by callers are typed explicitly; any other
62
+ fields the API returns are preserved (``extra="allow"``).
63
+ """
64
+
65
+ model_config = _RESPONSE_CONFIG
66
+
67
+ path_id: Optional[str] = None
68
+ out_amounts: list[str] = Field(default_factory=list)
69
+ in_amounts: list[str] = Field(default_factory=list)
70
+ gas_estimate: Optional[float] = None
71
+ gas_estimate_value: Optional[float] = None
72
+ price_impact: Optional[float] = None
73
+ block_number: Optional[int] = None
74
+
75
+
76
+ class AssembleRequest(BaseModel):
77
+ """Body for ``POST /sor/assemble``."""
78
+
79
+ model_config = _REQUEST_CONFIG
80
+
81
+ user_addr: str
82
+ path_id: str
83
+ simulate: bool = False
84
+
85
+
86
+ class Transaction(BaseModel):
87
+ """The ready-to-sign transaction returned by ``assemble``."""
88
+
89
+ model_config = _RESPONSE_CONFIG
90
+
91
+ to: Optional[str] = None
92
+ data: Optional[str] = None
93
+ value: Optional[str] = None
94
+ gas: Optional[int] = None
95
+ gas_price: Optional[int] = None
96
+ nonce: Optional[int] = None
97
+ chain_id: Optional[int] = None
98
+ from_: Optional[str] = Field(default=None, alias="from")
99
+
100
+
101
+ class AssembleResponse(BaseModel):
102
+ """Response from ``POST /sor/assemble``."""
103
+
104
+ model_config = _RESPONSE_CONFIG
105
+
106
+ transaction: Optional[Transaction] = None
odos_py/py.typed ADDED
File without changes
@@ -0,0 +1,148 @@
1
+ Metadata-Version: 2.4
2
+ Name: odos-py
3
+ Version: 0.1.0
4
+ Summary: A modern Python client for the Odos DEX Aggregator API
5
+ Project-URL: Homepage, https://github.com/robertruben98/odos-py
6
+ Project-URL: Documentation, https://docs.odos.xyz/build/api-docs
7
+ Project-URL: Repository, https://github.com/robertruben98/odos-py
8
+ Project-URL: Issues, https://github.com/robertruben98/odos-py/issues
9
+ Author: robertdev
10
+ License: MIT
11
+ License-File: LICENSE
12
+ Keywords: aggregator,defi,dex,ethereum,odos,swap
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.9
23
+ Requires-Dist: httpx>=0.24
24
+ Requires-Dist: pydantic>=2.0
25
+ Provides-Extra: dev
26
+ Requires-Dist: mypy>=1.5; extra == 'dev'
27
+ Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
28
+ Requires-Dist: pytest>=7.0; extra == 'dev'
29
+ Requires-Dist: respx>=0.20; extra == 'dev'
30
+ Requires-Dist: ruff>=0.1; extra == 'dev'
31
+ Provides-Extra: exec
32
+ Requires-Dist: web3>=6.0; extra == 'exec'
33
+ Description-Content-Type: text/markdown
34
+
35
+ # odos-py
36
+
37
+ A modern, fully-typed Python client for the [Odos](https://docs.odos.xyz/build/api-docs) DEX Aggregator API.
38
+
39
+ - Sync (`OdosClient`) and async (`AsyncOdosClient`) clients built on `httpx`
40
+ - `pydantic` v2 models for all requests and responses
41
+ - Automatic HTTP 429 handling with exponential backoff
42
+ - Configurable base URL and API-key header
43
+ - `mypy --strict` clean, ships a `py.typed` marker
44
+
45
+ ## Install
46
+
47
+ ```bash
48
+ pip install odos-py
49
+ ```
50
+
51
+ Optional extra for signing/sending the assembled transaction with `web3.py`:
52
+
53
+ ```bash
54
+ pip install "odos-py[exec]"
55
+ ```
56
+
57
+ ## Quickstart
58
+
59
+ Odos works in two steps: `quote` returns a `pathId`, then `assemble` turns that
60
+ `pathId` into ready-to-sign transaction calldata. The `swap()` helper chains
61
+ both:
62
+
63
+ ```python
64
+ from odos_py import OdosClient, QuoteRequest, InputToken, OutputToken
65
+
66
+ client = OdosClient(api_key="YOUR_KEY") # api_key optional but recommended
67
+ quote, assembled = client.swap(QuoteRequest(
68
+ chain_id=1,
69
+ input_tokens=[InputToken(token_address="0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", # WETH
70
+ amount="1000000000000000000")], # 1 WETH (wei)
71
+ output_tokens=[OutputToken(token_address="0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", # USDC
72
+ proportion=1)],
73
+ user_addr="0x47E2D28169738039755586743E2dfCF3bd643f86",
74
+ slippage_limit_percent=0.3,
75
+ ))
76
+ print(quote.path_id, assembled.transaction.to)
77
+ ```
78
+
79
+ Async is identical with `await`:
80
+
81
+ ```python
82
+ import asyncio
83
+ from odos_py import AsyncOdosClient
84
+
85
+ async def main(request):
86
+ async with AsyncOdosClient() as client:
87
+ quote = await client.quote(request)
88
+ print(quote.path_id)
89
+ ```
90
+
91
+ ## Auth & rate limits
92
+
93
+ There is a free, **keyless** tier, but it is heavily rate limited — `POST
94
+ /sor/quote/v2` returns HTTP 429 quickly without a key. Pass an `api_key` to
95
+ raise limits. The exact API-key header name is not publicly documented, so it
96
+ is configurable:
97
+
98
+ ```python
99
+ OdosClient(api_key="YOUR_KEY", api_key_header="x-api-key") # default header name
100
+ ```
101
+
102
+ The client retries 429 responses with exponential backoff (honouring any
103
+ `Retry-After` header) and raises `OdosRateLimitError` once retries are
104
+ exhausted. Tune with `max_retries` and `backoff_base`.
105
+
106
+ ## Endpoints
107
+
108
+ | Method | Endpoint |
109
+ | --- | --- |
110
+ | `quote(request)` | `POST /sor/quote/v2` |
111
+ | `assemble(user_addr=, path_id=)` | `POST /sor/assemble` |
112
+ | `execute(user_addr=, path_id=)` | `POST /sor/execute` |
113
+ | `swap(request)` / `quote_and_assemble(request)` | quote then assemble |
114
+ | `get_chains()` | `GET /info/chains` |
115
+ | `get_tokens(chain_id)` | `GET /info/tokens/{chainId}` |
116
+ | `get_router(chain_id)` | `GET /info/router/v2/{chainId}` |
117
+ | `get_contract_info(chain_id)` | `GET /info/contract-info/v2/{chainId}` |
118
+ | `get_token_price(chain_id, token_address)` | `GET /pricing/token/{chainId}/{tokenAddress}` |
119
+
120
+ Multi-chain is handled per call via `chain_id` on `QuoteRequest` and the info
121
+ endpoints.
122
+
123
+ ## Configuration
124
+
125
+ ```python
126
+ OdosClient(
127
+ base_url="https://api.odos.xyz", # configurable / proxyable
128
+ api_key=None,
129
+ api_key_header="x-api-key",
130
+ max_retries=3,
131
+ backoff_base=0.5,
132
+ timeout=30.0,
133
+ )
134
+ ```
135
+
136
+ ## Development
137
+
138
+ ```bash
139
+ pip install -e ".[dev,exec]"
140
+ pytest # unit tests (HTTP mocked, no network)
141
+ pytest -m integration # live smoke test against /info/chains
142
+ ruff check .
143
+ mypy
144
+ ```
145
+
146
+ ## License
147
+
148
+ MIT
@@ -0,0 +1,11 @@
1
+ odos_py/__init__.py,sha256=6M2Eu4raTapGlVVbNItTwLEVlBLgJuzlOcBkgZgILDw,689
2
+ odos_py/_base.py,sha256=eVpW_URmVefVAChSegNcn1cUaYngFifpl2SAf78hwbc,3436
3
+ odos_py/async_client.py,sha256=ICyFDH_zm7tMIalMZ60NsrOvYEh66aiuAL45O8NyJtk,6078
4
+ odos_py/client.py,sha256=24MvIazeXvJXF5ERQxrYpV77XME5J-6wiu348cyqsvE,6104
5
+ odos_py/exceptions.py,sha256=MTce7jYyIqTAP14tEQyNDf0NI0J6eO8EUnSKcn_KP60,1043
6
+ odos_py/models.py,sha256=B5LMUbvbnAXMqGF1IVMFRTSg0d9PzdtC7gi_vJrgCQ4,2705
7
+ odos_py/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ odos_py-0.1.0.dist-info/METADATA,sha256=IAf2aclXr4-esBRI8vx5JSsag1xe1CloF1YxnEB9YJ0,4735
9
+ odos_py-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
10
+ odos_py-0.1.0.dist-info/licenses/LICENSE,sha256=-AcjerVulaZXw9Ub2qG825vAXbFN8KNwHs9VEcf2XDo,1069
11
+ odos_py-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Robert Ruben
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.