pharox-sdk 0.2.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.
- pharox_sdk-0.2.0/LICENSE +21 -0
- pharox_sdk-0.2.0/PKG-INFO +57 -0
- pharox_sdk-0.2.0/README.md +37 -0
- pharox_sdk-0.2.0/pyproject.toml +47 -0
- pharox_sdk-0.2.0/src/pharox_sdk/__init__.py +12 -0
- pharox_sdk-0.2.0/src/pharox_sdk/client.py +232 -0
- pharox_sdk-0.2.0/src/pharox_sdk/exceptions.py +11 -0
- pharox_sdk-0.2.0/src/pharox_sdk/sdk.py +241 -0
pharox_sdk-0.2.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Zacarias
|
|
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,57 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pharox-sdk
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Typed Python SDK for pharox — remote mode (HTTP) and local mode (direct toolkit)
|
|
5
|
+
License: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Author: fzaca
|
|
8
|
+
Requires-Python: >=3.10,<4.0
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Requires-Dist: httpx (>=0.28,<0.29)
|
|
17
|
+
Requires-Dist: pharox (>=0.8.1,<0.9.0)
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# pharox-sdk
|
|
21
|
+
|
|
22
|
+
Python SDK for the [Pharox](https://github.com/fzaca/pharox) proxy lifecycle management ecosystem.
|
|
23
|
+
|
|
24
|
+
Works in two interchangeable modes:
|
|
25
|
+
|
|
26
|
+
- **Remote mode** — HTTP client for a running `pharox-service`
|
|
27
|
+
- **Local mode** — direct access to `pharox-toolkit` via `IAsyncStorage` (no service required)
|
|
28
|
+
|
|
29
|
+
## Installation
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install pharox-sdk
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Quick start
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from pharox_sdk import PharoxSDK
|
|
39
|
+
|
|
40
|
+
# Remote mode
|
|
41
|
+
sdk = PharoxSDK.remote("http://localhost:8000", api_key="my-key")
|
|
42
|
+
|
|
43
|
+
# Local mode (no service needed)
|
|
44
|
+
from pharox import AsyncInMemoryStorage
|
|
45
|
+
sdk = PharoxSDK.local(AsyncInMemoryStorage())
|
|
46
|
+
|
|
47
|
+
# Same interface in both modes
|
|
48
|
+
async with sdk.with_lease("my-pool") as lease:
|
|
49
|
+
if lease:
|
|
50
|
+
print(lease.proxy_id)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Links
|
|
54
|
+
|
|
55
|
+
- [pharox-toolkit](https://github.com/fzaca/pharox) — core library
|
|
56
|
+
- [pharox-service](https://github.com/fzaca/pharox-service) — FastAPI service
|
|
57
|
+
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# pharox-sdk
|
|
2
|
+
|
|
3
|
+
Python SDK for the [Pharox](https://github.com/fzaca/pharox) proxy lifecycle management ecosystem.
|
|
4
|
+
|
|
5
|
+
Works in two interchangeable modes:
|
|
6
|
+
|
|
7
|
+
- **Remote mode** — HTTP client for a running `pharox-service`
|
|
8
|
+
- **Local mode** — direct access to `pharox-toolkit` via `IAsyncStorage` (no service required)
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install pharox-sdk
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Quick start
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
from pharox_sdk import PharoxSDK
|
|
20
|
+
|
|
21
|
+
# Remote mode
|
|
22
|
+
sdk = PharoxSDK.remote("http://localhost:8000", api_key="my-key")
|
|
23
|
+
|
|
24
|
+
# Local mode (no service needed)
|
|
25
|
+
from pharox import AsyncInMemoryStorage
|
|
26
|
+
sdk = PharoxSDK.local(AsyncInMemoryStorage())
|
|
27
|
+
|
|
28
|
+
# Same interface in both modes
|
|
29
|
+
async with sdk.with_lease("my-pool") as lease:
|
|
30
|
+
if lease:
|
|
31
|
+
print(lease.proxy_id)
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Links
|
|
35
|
+
|
|
36
|
+
- [pharox-toolkit](https://github.com/fzaca/pharox) — core library
|
|
37
|
+
- [pharox-service](https://github.com/fzaca/pharox-service) — FastAPI service
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "pharox-sdk"
|
|
3
|
+
version = "0.2.0"
|
|
4
|
+
description = "Typed Python SDK for pharox — remote mode (HTTP) and local mode (direct toolkit)"
|
|
5
|
+
authors = ["fzaca"]
|
|
6
|
+
license = "MIT"
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
packages = [{ include = "pharox_sdk", from = "src" }]
|
|
9
|
+
|
|
10
|
+
[tool.poetry.dependencies]
|
|
11
|
+
python = "^3.10"
|
|
12
|
+
pharox = "^0.8.1"
|
|
13
|
+
httpx = "^0.28"
|
|
14
|
+
|
|
15
|
+
[tool.poetry.group.dev.dependencies]
|
|
16
|
+
pytest = "^8.0"
|
|
17
|
+
pytest-asyncio = "^0.23"
|
|
18
|
+
respx = "^0.21"
|
|
19
|
+
mypy = "^1.10"
|
|
20
|
+
ruff = "^0.4"
|
|
21
|
+
commitizen = "^3.27"
|
|
22
|
+
|
|
23
|
+
[tool.commitizen]
|
|
24
|
+
name = "cz_conventional_commits"
|
|
25
|
+
tag_format = "v$version"
|
|
26
|
+
version_provider = "poetry"
|
|
27
|
+
update_changelog_on_bump = true
|
|
28
|
+
|
|
29
|
+
[tool.pytest.ini_options]
|
|
30
|
+
asyncio_mode = "auto"
|
|
31
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
32
|
+
testpaths = ["tests"]
|
|
33
|
+
|
|
34
|
+
[tool.mypy]
|
|
35
|
+
python_version = "3.10"
|
|
36
|
+
strict = true
|
|
37
|
+
ignore_missing_imports = true
|
|
38
|
+
|
|
39
|
+
[tool.ruff]
|
|
40
|
+
line-length = 88
|
|
41
|
+
|
|
42
|
+
[tool.ruff.lint]
|
|
43
|
+
select = ["E", "F", "I"]
|
|
44
|
+
|
|
45
|
+
[build-system]
|
|
46
|
+
requires = ["poetry-core"]
|
|
47
|
+
build-backend = "poetry.core.masonry.api"
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""pharox-sdk — typed Python SDK for the Pharox proxy ecosystem."""
|
|
2
|
+
|
|
3
|
+
from .client import PharoxClient
|
|
4
|
+
from .exceptions import RemoteError, SDKError
|
|
5
|
+
from .sdk import PharoxSDK
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"PharoxClient",
|
|
9
|
+
"PharoxSDK",
|
|
10
|
+
"RemoteError",
|
|
11
|
+
"SDKError",
|
|
12
|
+
]
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Async HTTP client for pharox-service (remote mode low-level API)."""
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
from uuid import UUID
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
from pharox import (
|
|
8
|
+
Lease,
|
|
9
|
+
LeaseStatus,
|
|
10
|
+
ProxyFilters,
|
|
11
|
+
ProxyPool,
|
|
12
|
+
ProxyProtocol,
|
|
13
|
+
ProxyStatus,
|
|
14
|
+
SelectorStrategy,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
from .exceptions import RemoteError
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _parse_pool(data: dict[str, Any]) -> ProxyPool:
|
|
21
|
+
return ProxyPool(
|
|
22
|
+
id=UUID(data["id"]),
|
|
23
|
+
name=data["name"],
|
|
24
|
+
description=data.get("description", ""),
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _parse_lease(data: dict[str, Any]) -> Lease:
|
|
29
|
+
pool_id_str = data.get("pool_id") or ""
|
|
30
|
+
return Lease(
|
|
31
|
+
id=UUID(data["id"]),
|
|
32
|
+
proxy_id=UUID(data["proxy_id"]),
|
|
33
|
+
consumer_id=UUID(data["consumer_id"]),
|
|
34
|
+
pool_id=UUID(pool_id_str) if pool_id_str else None,
|
|
35
|
+
status=LeaseStatus(data["status"]),
|
|
36
|
+
acquired_at=datetime.fromisoformat(data["acquired_at"]),
|
|
37
|
+
expires_at=datetime.fromisoformat(data["expires_at"]),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class PharoxClient:
|
|
42
|
+
"""
|
|
43
|
+
Low-level async HTTP client for pharox-service.
|
|
44
|
+
|
|
45
|
+
Use as an async context manager or call ``aclose()`` when done.
|
|
46
|
+
|
|
47
|
+
Parameters
|
|
48
|
+
----------
|
|
49
|
+
base_url:
|
|
50
|
+
Base URL of pharox-service (e.g. ``"http://localhost:8000"``).
|
|
51
|
+
api_key:
|
|
52
|
+
API key for authentication.
|
|
53
|
+
timeout:
|
|
54
|
+
Request timeout in seconds. Defaults to 30.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self, base_url: str, api_key: str, timeout: float = 30.0
|
|
59
|
+
) -> None:
|
|
60
|
+
self._http = httpx.AsyncClient(
|
|
61
|
+
base_url=base_url.rstrip("/"),
|
|
62
|
+
headers={"X-API-Key": api_key},
|
|
63
|
+
timeout=timeout,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
async def aclose(self) -> None:
|
|
67
|
+
await self._http.aclose()
|
|
68
|
+
|
|
69
|
+
async def __aenter__(self) -> "PharoxClient":
|
|
70
|
+
return self
|
|
71
|
+
|
|
72
|
+
async def __aexit__(self, *_: Any) -> None:
|
|
73
|
+
await self.aclose()
|
|
74
|
+
|
|
75
|
+
def _raise_for_status(self, response: httpx.Response) -> None:
|
|
76
|
+
if response.is_error:
|
|
77
|
+
try:
|
|
78
|
+
detail = response.json().get("detail", response.text)
|
|
79
|
+
except Exception:
|
|
80
|
+
detail = response.text
|
|
81
|
+
raise RemoteError(response.status_code, str(detail))
|
|
82
|
+
|
|
83
|
+
async def _request(
|
|
84
|
+
self,
|
|
85
|
+
method: str,
|
|
86
|
+
url: str,
|
|
87
|
+
**kwargs: Any,
|
|
88
|
+
) -> httpx.Response:
|
|
89
|
+
"""Execute an HTTP request, wrapping network errors as RemoteError."""
|
|
90
|
+
try:
|
|
91
|
+
return await self._http.request(method, url, **kwargs)
|
|
92
|
+
except httpx.TimeoutException as exc:
|
|
93
|
+
raise RemoteError(0, f"Request timed out: {exc}") from exc
|
|
94
|
+
except httpx.NetworkError as exc:
|
|
95
|
+
raise RemoteError(0, f"Network error: {exc}") from exc
|
|
96
|
+
|
|
97
|
+
# ------------------------------------------------------------------
|
|
98
|
+
# Pools
|
|
99
|
+
# ------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
async def create_pool(
|
|
102
|
+
self, name: str, description: str = ""
|
|
103
|
+
) -> ProxyPool:
|
|
104
|
+
r = await self._request(
|
|
105
|
+
"POST", "/v1/pools/", json={"name": name, "description": description}
|
|
106
|
+
)
|
|
107
|
+
self._raise_for_status(r)
|
|
108
|
+
return _parse_pool(r.json())
|
|
109
|
+
|
|
110
|
+
async def list_pools(self) -> list[ProxyPool]:
|
|
111
|
+
r = await self._request("GET", "/v1/pools/")
|
|
112
|
+
self._raise_for_status(r)
|
|
113
|
+
return [_parse_pool(p) for p in r.json()]
|
|
114
|
+
|
|
115
|
+
async def get_pool(self, pool_id: str) -> ProxyPool:
|
|
116
|
+
r = await self._request("GET", f"/v1/pools/{pool_id}")
|
|
117
|
+
self._raise_for_status(r)
|
|
118
|
+
return _parse_pool(r.json())
|
|
119
|
+
|
|
120
|
+
async def delete_pool(self, pool_id: str) -> None:
|
|
121
|
+
r = await self._request("DELETE", f"/v1/pools/{pool_id}")
|
|
122
|
+
self._raise_for_status(r)
|
|
123
|
+
|
|
124
|
+
# ------------------------------------------------------------------
|
|
125
|
+
# Proxies
|
|
126
|
+
# ------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
async def add_proxy(
|
|
129
|
+
self,
|
|
130
|
+
pool_id: str,
|
|
131
|
+
host: str,
|
|
132
|
+
port: int,
|
|
133
|
+
protocol: ProxyProtocol = ProxyProtocol.HTTP,
|
|
134
|
+
username: Optional[str] = None,
|
|
135
|
+
password: Optional[str] = None,
|
|
136
|
+
country: Optional[str] = None,
|
|
137
|
+
city: Optional[str] = None,
|
|
138
|
+
latitude: Optional[float] = None,
|
|
139
|
+
longitude: Optional[float] = None,
|
|
140
|
+
) -> dict[str, Any]:
|
|
141
|
+
payload: dict[str, Any] = {
|
|
142
|
+
"host": host,
|
|
143
|
+
"port": port,
|
|
144
|
+
"protocol": protocol.value,
|
|
145
|
+
}
|
|
146
|
+
if username:
|
|
147
|
+
payload["username"] = username
|
|
148
|
+
if password:
|
|
149
|
+
payload["password"] = password
|
|
150
|
+
if country:
|
|
151
|
+
payload["country"] = country
|
|
152
|
+
if city:
|
|
153
|
+
payload["city"] = city
|
|
154
|
+
if latitude is not None:
|
|
155
|
+
payload["latitude"] = latitude
|
|
156
|
+
if longitude is not None:
|
|
157
|
+
payload["longitude"] = longitude
|
|
158
|
+
|
|
159
|
+
r = await self._request(
|
|
160
|
+
"POST", f"/v1/pools/{pool_id}/proxies/", json=payload
|
|
161
|
+
)
|
|
162
|
+
self._raise_for_status(r)
|
|
163
|
+
return r.json() # type: ignore[no-any-return]
|
|
164
|
+
|
|
165
|
+
async def list_proxies(self, pool_id: str) -> list[dict[str, Any]]:
|
|
166
|
+
r = await self._request("GET", f"/v1/pools/{pool_id}/proxies/")
|
|
167
|
+
self._raise_for_status(r)
|
|
168
|
+
return r.json() # type: ignore[no-any-return]
|
|
169
|
+
|
|
170
|
+
async def update_proxy_status(
|
|
171
|
+
self, pool_id: str, proxy_id: str, status: ProxyStatus
|
|
172
|
+
) -> dict[str, Any]:
|
|
173
|
+
r = await self._request(
|
|
174
|
+
"PATCH",
|
|
175
|
+
f"/v1/pools/{pool_id}/proxies/{proxy_id}",
|
|
176
|
+
json={"status": status.value},
|
|
177
|
+
)
|
|
178
|
+
self._raise_for_status(r)
|
|
179
|
+
return r.json() # type: ignore[no-any-return]
|
|
180
|
+
|
|
181
|
+
async def delete_proxy(self, pool_id: str, proxy_id: str) -> None:
|
|
182
|
+
r = await self._request(
|
|
183
|
+
"DELETE", f"/v1/pools/{pool_id}/proxies/{proxy_id}"
|
|
184
|
+
)
|
|
185
|
+
self._raise_for_status(r)
|
|
186
|
+
|
|
187
|
+
# ------------------------------------------------------------------
|
|
188
|
+
# Leases
|
|
189
|
+
# ------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
async def acquire_lease(
|
|
192
|
+
self,
|
|
193
|
+
pool_id: str,
|
|
194
|
+
consumer_id: str,
|
|
195
|
+
ttl_seconds: int = 300,
|
|
196
|
+
filters: Optional[ProxyFilters] = None,
|
|
197
|
+
selector: Optional[SelectorStrategy] = None,
|
|
198
|
+
) -> Optional[Lease]:
|
|
199
|
+
"""
|
|
200
|
+
Returns the acquired Lease or None if no proxy is available (HTTP 409).
|
|
201
|
+
Raises RemoteError for any other non-2xx response.
|
|
202
|
+
"""
|
|
203
|
+
payload: dict[str, Any] = {
|
|
204
|
+
"pool_id": pool_id,
|
|
205
|
+
"consumer_id": consumer_id,
|
|
206
|
+
"ttl_seconds": ttl_seconds,
|
|
207
|
+
"strategy": (
|
|
208
|
+
selector.value
|
|
209
|
+
if selector
|
|
210
|
+
else SelectorStrategy.FIRST_AVAILABLE.value
|
|
211
|
+
),
|
|
212
|
+
}
|
|
213
|
+
if filters:
|
|
214
|
+
payload["filters"] = filters.model_dump(exclude_none=True)
|
|
215
|
+
|
|
216
|
+
r = await self._request("POST", "/v1/leases/", json=payload)
|
|
217
|
+
if r.status_code == 409:
|
|
218
|
+
return None
|
|
219
|
+
self._raise_for_status(r)
|
|
220
|
+
return _parse_lease(r.json())
|
|
221
|
+
|
|
222
|
+
async def get_lease(self, lease_id: str) -> Optional[Lease]:
|
|
223
|
+
r = await self._request("GET", f"/v1/leases/{lease_id}")
|
|
224
|
+
if r.status_code == 404:
|
|
225
|
+
return None
|
|
226
|
+
self._raise_for_status(r)
|
|
227
|
+
return _parse_lease(r.json())
|
|
228
|
+
|
|
229
|
+
async def release_lease(self, lease_id: str) -> Lease:
|
|
230
|
+
r = await self._request("POST", f"/v1/leases/{lease_id}/release", json={})
|
|
231
|
+
self._raise_for_status(r)
|
|
232
|
+
return _parse_lease(r.json())
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
class SDKError(Exception):
|
|
2
|
+
"""Base error for pharox-sdk."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class RemoteError(SDKError):
|
|
6
|
+
"""Raised when the pharox-service returns an unexpected HTTP error."""
|
|
7
|
+
|
|
8
|
+
def __init__(self, status_code: int, detail: str) -> None:
|
|
9
|
+
self.status_code = status_code
|
|
10
|
+
self.detail = detail
|
|
11
|
+
super().__init__(f"HTTP {status_code}: {detail}")
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Unified dual-mode PharoxSDK — remote (HTTP) or local (direct toolkit)."""
|
|
2
|
+
from contextlib import asynccontextmanager
|
|
3
|
+
from typing import AsyncIterator, Optional
|
|
4
|
+
|
|
5
|
+
from pharox import (
|
|
6
|
+
IAsyncStorage,
|
|
7
|
+
Lease,
|
|
8
|
+
ProxyFilters,
|
|
9
|
+
ProxyPool,
|
|
10
|
+
SelectorStrategy,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
from .client import PharoxClient
|
|
14
|
+
from .exceptions import RemoteError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class PharoxSDK:
|
|
18
|
+
"""
|
|
19
|
+
Dual-mode SDK that mirrors the async ProxyManager semantics.
|
|
20
|
+
|
|
21
|
+
Use the class methods to create an instance:
|
|
22
|
+
|
|
23
|
+
.. code-block:: python
|
|
24
|
+
|
|
25
|
+
# Remote mode — talks to a running pharox-service
|
|
26
|
+
sdk = PharoxSDK.remote("http://localhost:8000", api_key="secret")
|
|
27
|
+
|
|
28
|
+
# Local mode — uses an IAsyncStorage directly (no HTTP overhead)
|
|
29
|
+
sdk = PharoxSDK.local(storage)
|
|
30
|
+
|
|
31
|
+
Both modes expose the same interface:
|
|
32
|
+
``acquire_proxy``, ``release_proxy``, ``with_lease``.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
*,
|
|
38
|
+
client: Optional[PharoxClient] = None,
|
|
39
|
+
storage: Optional[IAsyncStorage] = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
if (client is None) == (storage is None):
|
|
42
|
+
raise ValueError(
|
|
43
|
+
"Provide exactly one of client= (remote) or storage= (local)."
|
|
44
|
+
)
|
|
45
|
+
self._client = client
|
|
46
|
+
self._storage = storage
|
|
47
|
+
# name → id cache used only in remote mode
|
|
48
|
+
self._pool_cache: dict[str, str] = {}
|
|
49
|
+
|
|
50
|
+
# ------------------------------------------------------------------
|
|
51
|
+
# Constructors
|
|
52
|
+
# ------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def remote(
|
|
56
|
+
cls, base_url: str, api_key: str, timeout: float = 30.0
|
|
57
|
+
) -> "PharoxSDK":
|
|
58
|
+
"""Create an SDK instance that calls a remote pharox-service."""
|
|
59
|
+
return cls(client=PharoxClient(base_url, api_key, timeout=timeout))
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def local(cls, storage: IAsyncStorage) -> "PharoxSDK":
|
|
63
|
+
"""Create an SDK instance that uses a local storage backend directly."""
|
|
64
|
+
return cls(storage=storage)
|
|
65
|
+
|
|
66
|
+
# ------------------------------------------------------------------
|
|
67
|
+
# Lifecycle
|
|
68
|
+
# ------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
async def aclose(self) -> None:
|
|
71
|
+
if self._client:
|
|
72
|
+
await self._client.aclose()
|
|
73
|
+
|
|
74
|
+
async def __aenter__(self) -> "PharoxSDK":
|
|
75
|
+
return self
|
|
76
|
+
|
|
77
|
+
async def __aexit__(self, *_: object) -> None:
|
|
78
|
+
await self.aclose()
|
|
79
|
+
|
|
80
|
+
# ------------------------------------------------------------------
|
|
81
|
+
# Pool helpers (remote mode)
|
|
82
|
+
# ------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
async def _resolve_pool_id(self, pool_name: str) -> Optional[str]:
|
|
85
|
+
"""Look up pool_id by name, using a local name cache."""
|
|
86
|
+
if pool_name in self._pool_cache:
|
|
87
|
+
return self._pool_cache[pool_name]
|
|
88
|
+
if self._client is None: # pragma: no cover
|
|
89
|
+
raise RuntimeError("_resolve_pool_id called in local mode")
|
|
90
|
+
pools = await self._client.list_pools()
|
|
91
|
+
self._pool_cache = {p.name: str(p.id) for p in pools}
|
|
92
|
+
return self._pool_cache.get(pool_name)
|
|
93
|
+
|
|
94
|
+
# ------------------------------------------------------------------
|
|
95
|
+
# Public API — mirrors ProxyManager async semantics
|
|
96
|
+
# ------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
async def acquire_proxy(
|
|
99
|
+
self,
|
|
100
|
+
pool_name: str,
|
|
101
|
+
consumer_id: str = "default",
|
|
102
|
+
ttl_seconds: int = 300,
|
|
103
|
+
filters: Optional[ProxyFilters] = None,
|
|
104
|
+
selector: Optional[SelectorStrategy] = None,
|
|
105
|
+
) -> Optional[Lease]:
|
|
106
|
+
"""
|
|
107
|
+
Acquire a proxy lease from the named pool.
|
|
108
|
+
|
|
109
|
+
Returns the Lease on success, or None if no proxy is available.
|
|
110
|
+
"""
|
|
111
|
+
if self._client is not None:
|
|
112
|
+
return await self._acquire_remote(
|
|
113
|
+
pool_name, consumer_id, ttl_seconds, filters, selector
|
|
114
|
+
)
|
|
115
|
+
return await self._acquire_local(
|
|
116
|
+
pool_name, consumer_id, ttl_seconds, filters, selector
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
async def release_proxy(self, lease: Lease) -> None:
|
|
120
|
+
"""Release a previously acquired lease."""
|
|
121
|
+
if self._client is not None:
|
|
122
|
+
await self._client.release_lease(str(lease.id))
|
|
123
|
+
elif self._storage is not None:
|
|
124
|
+
await self._storage.release_lease(lease)
|
|
125
|
+
else: # pragma: no cover
|
|
126
|
+
raise RuntimeError("SDK has neither client nor storage")
|
|
127
|
+
|
|
128
|
+
@asynccontextmanager
|
|
129
|
+
async def with_lease(
|
|
130
|
+
self,
|
|
131
|
+
pool_name: str,
|
|
132
|
+
consumer_id: str = "default",
|
|
133
|
+
ttl_seconds: int = 300,
|
|
134
|
+
filters: Optional[ProxyFilters] = None,
|
|
135
|
+
selector: Optional[SelectorStrategy] = None,
|
|
136
|
+
) -> AsyncIterator[Optional[Lease]]:
|
|
137
|
+
"""
|
|
138
|
+
Async context manager that acquires a lease and releases it on exit.
|
|
139
|
+
|
|
140
|
+
.. code-block:: python
|
|
141
|
+
|
|
142
|
+
async with sdk.with_lease("my-pool") as lease:
|
|
143
|
+
if lease:
|
|
144
|
+
print(lease.proxy_id)
|
|
145
|
+
"""
|
|
146
|
+
lease = await self.acquire_proxy(
|
|
147
|
+
pool_name,
|
|
148
|
+
consumer_id=consumer_id,
|
|
149
|
+
ttl_seconds=ttl_seconds,
|
|
150
|
+
filters=filters,
|
|
151
|
+
selector=selector,
|
|
152
|
+
)
|
|
153
|
+
try:
|
|
154
|
+
yield lease
|
|
155
|
+
finally:
|
|
156
|
+
if lease is not None:
|
|
157
|
+
await self.release_proxy(lease)
|
|
158
|
+
|
|
159
|
+
# ------------------------------------------------------------------
|
|
160
|
+
# Admin helpers (remote mode only)
|
|
161
|
+
# ------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
async def create_pool(
|
|
164
|
+
self, name: str, description: str = ""
|
|
165
|
+
) -> ProxyPool:
|
|
166
|
+
"""Create a pool in the remote service."""
|
|
167
|
+
self._require_remote()
|
|
168
|
+
assert self._client is not None
|
|
169
|
+
pool = await self._client.create_pool(name, description)
|
|
170
|
+
self._pool_cache[pool.name] = str(pool.id)
|
|
171
|
+
return pool
|
|
172
|
+
|
|
173
|
+
async def list_pools(self) -> list[ProxyPool]:
|
|
174
|
+
"""List all pools from the remote service."""
|
|
175
|
+
self._require_remote()
|
|
176
|
+
assert self._client is not None
|
|
177
|
+
pools = await self._client.list_pools()
|
|
178
|
+
self._pool_cache = {p.name: str(p.id) for p in pools}
|
|
179
|
+
return pools
|
|
180
|
+
|
|
181
|
+
# ------------------------------------------------------------------
|
|
182
|
+
# Private helpers
|
|
183
|
+
# ------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
def _require_remote(self) -> None:
|
|
186
|
+
if self._client is None:
|
|
187
|
+
raise RuntimeError(
|
|
188
|
+
"This method is only available in remote mode."
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
async def _acquire_remote(
|
|
192
|
+
self,
|
|
193
|
+
pool_name: str,
|
|
194
|
+
consumer_id: str,
|
|
195
|
+
ttl_seconds: int,
|
|
196
|
+
filters: Optional[ProxyFilters],
|
|
197
|
+
selector: Optional[SelectorStrategy],
|
|
198
|
+
) -> Optional[Lease]:
|
|
199
|
+
if self._client is None: # pragma: no cover
|
|
200
|
+
raise RuntimeError("_acquire_remote called in local mode")
|
|
201
|
+
pool_id = await self._resolve_pool_id(pool_name)
|
|
202
|
+
if pool_id is None:
|
|
203
|
+
from pharox import PoolNotFoundError
|
|
204
|
+
raise PoolNotFoundError(pool_name)
|
|
205
|
+
try:
|
|
206
|
+
return await self._client.acquire_lease(
|
|
207
|
+
pool_id=pool_id,
|
|
208
|
+
consumer_id=consumer_id,
|
|
209
|
+
ttl_seconds=ttl_seconds,
|
|
210
|
+
filters=filters,
|
|
211
|
+
selector=selector,
|
|
212
|
+
)
|
|
213
|
+
except RemoteError as exc:
|
|
214
|
+
if exc.status_code == 404:
|
|
215
|
+
# Pool was in cache but no longer exists — evict and raise
|
|
216
|
+
self._pool_cache.pop(pool_name, None)
|
|
217
|
+
from pharox import PoolNotFoundError
|
|
218
|
+
raise PoolNotFoundError(pool_name) from exc
|
|
219
|
+
raise
|
|
220
|
+
|
|
221
|
+
async def _acquire_local(
|
|
222
|
+
self,
|
|
223
|
+
pool_name: str,
|
|
224
|
+
consumer_id: str,
|
|
225
|
+
ttl_seconds: int,
|
|
226
|
+
filters: Optional[ProxyFilters],
|
|
227
|
+
selector: Optional[SelectorStrategy],
|
|
228
|
+
) -> Optional[Lease]:
|
|
229
|
+
if self._storage is None: # pragma: no cover
|
|
230
|
+
raise RuntimeError("_acquire_local called in remote mode")
|
|
231
|
+
await self._storage.ensure_consumer(consumer_id)
|
|
232
|
+
await self._storage.cleanup_expired_leases()
|
|
233
|
+
strategy = selector or SelectorStrategy.FIRST_AVAILABLE
|
|
234
|
+
proxy = await self._storage.find_available_proxy(
|
|
235
|
+
pool_name, filters, strategy
|
|
236
|
+
)
|
|
237
|
+
if proxy is None:
|
|
238
|
+
return None
|
|
239
|
+
return await self._storage.create_lease(
|
|
240
|
+
proxy, consumer_id, ttl_seconds
|
|
241
|
+
)
|