proxyhubb 0.2.0__tar.gz → 0.3.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.
- {proxyhubb-0.2.0 → proxyhubb-0.3.0}/PKG-INFO +2 -2
- {proxyhubb-0.2.0 → proxyhubb-0.3.0}/proxyhubb/__init__.py +1 -1
- {proxyhubb-0.2.0 → proxyhubb-0.3.0}/proxyhubb/client.py +209 -16
- {proxyhubb-0.2.0 → proxyhubb-0.3.0}/proxyhubb.egg-info/PKG-INFO +2 -2
- {proxyhubb-0.2.0 → proxyhubb-0.3.0}/pyproject.toml +2 -2
- {proxyhubb-0.2.0 → proxyhubb-0.3.0}/tests/test_client.py +43 -0
- {proxyhubb-0.2.0 → proxyhubb-0.3.0}/LICENSE +0 -0
- {proxyhubb-0.2.0 → proxyhubb-0.3.0}/README.md +0 -0
- {proxyhubb-0.2.0 → proxyhubb-0.3.0}/proxyhubb/proxy.py +0 -0
- {proxyhubb-0.2.0 → proxyhubb-0.3.0}/proxyhubb.egg-info/SOURCES.txt +0 -0
- {proxyhubb-0.2.0 → proxyhubb-0.3.0}/proxyhubb.egg-info/dependency_links.txt +0 -0
- {proxyhubb-0.2.0 → proxyhubb-0.3.0}/proxyhubb.egg-info/requires.txt +0 -0
- {proxyhubb-0.2.0 → proxyhubb-0.3.0}/proxyhubb.egg-info/top_level.txt +0 -0
- {proxyhubb-0.2.0 → proxyhubb-0.3.0}/setup.cfg +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: proxyhubb
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: Official Python SDK for ProxyHubb (
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Official Python SDK for ProxyHubb (buyer Connect/bulk + seller/provider APIs)
|
|
5
5
|
Author: ProxyHubb
|
|
6
6
|
License: MIT
|
|
7
7
|
Project-URL: Homepage, https://proxyhubb.com
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
"""ProxyHubb Python SDK — Control API client (
|
|
1
|
+
"""ProxyHubb Python SDK — Control API client (buyer + seller/provider)."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import os
|
|
6
6
|
from typing import Any, Mapping, Optional
|
|
7
|
-
from urllib.parse import quote
|
|
7
|
+
from urllib.parse import quote, urlencode
|
|
8
8
|
|
|
9
9
|
import requests
|
|
10
10
|
|
|
@@ -46,14 +46,18 @@ def as_requests_proxies(proxy: str) -> dict[str, str]:
|
|
|
46
46
|
|
|
47
47
|
|
|
48
48
|
class ProxyHubClient:
|
|
49
|
-
"""Client for ProxyHubb Control API (
|
|
49
|
+
"""Client for ProxyHubb Control API (buyer Connect/bulk + seller/provider).
|
|
50
50
|
|
|
51
51
|
Parameters
|
|
52
52
|
----------
|
|
53
53
|
api_key:
|
|
54
54
|
``phub_…`` / ``pk_…`` key for bulk pool endpoints (``X-API-Key``).
|
|
55
|
+
Buyer keys do **not** authorize provider/seller routes.
|
|
55
56
|
access_token:
|
|
56
|
-
Dashboard JWT for session intents
|
|
57
|
+
Dashboard JWT for session intents, order credentials, and seller ops
|
|
58
|
+
(``Authorization: Bearer``).
|
|
59
|
+
provider_token:
|
|
60
|
+
Provider CLI/dashboard token; alias of ``access_token`` (same Bearer header).
|
|
57
61
|
base_url:
|
|
58
62
|
Control API base URL. Defaults to production.
|
|
59
63
|
proxy_endpoint:
|
|
@@ -65,13 +69,15 @@ class ProxyHubClient:
|
|
|
65
69
|
api_key: Optional[str] = None,
|
|
66
70
|
*,
|
|
67
71
|
access_token: Optional[str] = None,
|
|
72
|
+
provider_token: Optional[str] = None,
|
|
68
73
|
base_url: str = DEFAULT_BASE_URL,
|
|
69
74
|
proxy_endpoint: Optional[str] = None,
|
|
70
75
|
) -> None:
|
|
71
|
-
|
|
72
|
-
|
|
76
|
+
bearer = access_token or provider_token or ""
|
|
77
|
+
if not api_key and not bearer:
|
|
78
|
+
raise ValueError("api_key, access_token, or provider_token is required")
|
|
73
79
|
self._api_key = api_key or ""
|
|
74
|
-
self._access_token =
|
|
80
|
+
self._access_token = bearer
|
|
75
81
|
self._base_url = base_url.rstrip("/")
|
|
76
82
|
self._proxy_endpoint = (
|
|
77
83
|
proxy_endpoint.strip()
|
|
@@ -91,29 +97,69 @@ class ProxyHubClient:
|
|
|
91
97
|
return self._proxy_endpoint
|
|
92
98
|
|
|
93
99
|
def set_access_token(self, token: str) -> None:
|
|
94
|
-
"""Update Bearer JWT
|
|
100
|
+
"""Update Bearer JWT / provider token used for authenticated calls."""
|
|
95
101
|
self._access_token = token
|
|
96
102
|
if token:
|
|
97
103
|
self._session.headers["Authorization"] = f"Bearer {token}"
|
|
98
104
|
else:
|
|
99
105
|
self._session.headers.pop("Authorization", None)
|
|
100
106
|
|
|
101
|
-
def
|
|
102
|
-
|
|
107
|
+
def set_provider_token(self, token: str) -> None:
|
|
108
|
+
"""Alias for :meth:`set_access_token` (same Bearer header)."""
|
|
109
|
+
self.set_access_token(token)
|
|
110
|
+
|
|
111
|
+
def _url(self, path: str, query: Optional[Mapping[str, Any]] = None) -> str:
|
|
112
|
+
url = f"{self._base_url}{path}"
|
|
113
|
+
if query:
|
|
114
|
+
filtered = {
|
|
115
|
+
k: v for k, v in query.items() if v is not None and v != ""
|
|
116
|
+
}
|
|
117
|
+
if filtered:
|
|
118
|
+
url = f"{url}?{urlencode(filtered)}"
|
|
119
|
+
return url
|
|
120
|
+
|
|
121
|
+
def _raise(self, resp: requests.Response) -> None:
|
|
122
|
+
if resp.ok:
|
|
123
|
+
return
|
|
124
|
+
try:
|
|
125
|
+
data = resp.json()
|
|
126
|
+
message = data.get("error") or resp.reason
|
|
127
|
+
except Exception:
|
|
128
|
+
message = resp.reason or resp.text
|
|
129
|
+
raise requests.HTTPError(
|
|
130
|
+
f"ProxyHubb API error {resp.status_code}: {message}",
|
|
131
|
+
response=resp,
|
|
132
|
+
)
|
|
103
133
|
|
|
104
|
-
def _get(self, path: str) -> dict[str, Any]:
|
|
105
|
-
resp = self._session.get(self._url(path))
|
|
106
|
-
|
|
134
|
+
def _get(self, path: str, *, query: Optional[Mapping[str, Any]] = None) -> dict[str, Any]:
|
|
135
|
+
resp = self._session.get(self._url(path, query))
|
|
136
|
+
self._raise(resp)
|
|
107
137
|
return resp.json()
|
|
108
138
|
|
|
109
139
|
def _post(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
110
140
|
resp = self._session.post(self._url(path), json=payload)
|
|
111
|
-
|
|
141
|
+
self._raise(resp)
|
|
142
|
+
return resp.json()
|
|
143
|
+
|
|
144
|
+
def _put(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
145
|
+
resp = self._session.put(self._url(path), json=payload)
|
|
146
|
+
self._raise(resp)
|
|
147
|
+
return resp.json()
|
|
148
|
+
|
|
149
|
+
def _patch(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
150
|
+
resp = self._session.patch(self._url(path), json=payload)
|
|
151
|
+
self._raise(resp)
|
|
112
152
|
return resp.json()
|
|
113
153
|
|
|
114
|
-
def _delete(self, path: str) -> None:
|
|
154
|
+
def _delete(self, path: str) -> dict[str, Any] | None:
|
|
115
155
|
resp = self._session.delete(self._url(path))
|
|
116
|
-
|
|
156
|
+
self._raise(resp)
|
|
157
|
+
if resp.status_code == 204 or not resp.content:
|
|
158
|
+
return None
|
|
159
|
+
try:
|
|
160
|
+
return resp.json()
|
|
161
|
+
except Exception:
|
|
162
|
+
return None
|
|
117
163
|
|
|
118
164
|
# ── Session intent (Connect) ──────────────────────────────────────────
|
|
119
165
|
|
|
@@ -258,3 +304,150 @@ class ProxyHubClient:
|
|
|
258
304
|
host=f"{ep['host']}:{ep['port']}",
|
|
259
305
|
)
|
|
260
306
|
)
|
|
307
|
+
|
|
308
|
+
# ── Provider tokens ───────────────────────────────────────────────────
|
|
309
|
+
|
|
310
|
+
def list_provider_tokens(self) -> dict[str, Any]:
|
|
311
|
+
"""List provider tokens (masked). Requires JWT or provider token Bearer."""
|
|
312
|
+
return self._get("/v1/provider/tokens")
|
|
313
|
+
|
|
314
|
+
def create_provider_token(
|
|
315
|
+
self,
|
|
316
|
+
*,
|
|
317
|
+
name: Optional[str] = None,
|
|
318
|
+
expires_in: Optional[int] = None,
|
|
319
|
+
node_id: Optional[str] = None,
|
|
320
|
+
) -> dict[str, Any]:
|
|
321
|
+
"""Create a provider token. Plaintext ``token`` is returned once."""
|
|
322
|
+
payload: dict[str, Any] = {}
|
|
323
|
+
if name is not None:
|
|
324
|
+
payload["name"] = name
|
|
325
|
+
if expires_in is not None:
|
|
326
|
+
payload["expires_in"] = expires_in
|
|
327
|
+
if node_id is not None:
|
|
328
|
+
payload["node_id"] = node_id
|
|
329
|
+
return self._post("/v1/provider/tokens", payload)
|
|
330
|
+
|
|
331
|
+
def rotate_provider_token(self, token_id: str) -> dict[str, Any]:
|
|
332
|
+
"""Rotate a provider token; returns new plaintext ``token`` once."""
|
|
333
|
+
if not token_id:
|
|
334
|
+
raise ValueError("token_id is required")
|
|
335
|
+
return self._post(f"/v1/provider/tokens/{token_id}/rotate", {})
|
|
336
|
+
|
|
337
|
+
def revoke_provider_token(self, token_id: str) -> dict[str, Any] | None:
|
|
338
|
+
"""Revoke a provider token."""
|
|
339
|
+
if not token_id:
|
|
340
|
+
raise ValueError("token_id is required")
|
|
341
|
+
return self._delete(f"/v1/provider/tokens/{token_id}")
|
|
342
|
+
|
|
343
|
+
# ── Provider nodes ────────────────────────────────────────────────────
|
|
344
|
+
|
|
345
|
+
def get_provider_readiness(self) -> dict[str, Any]:
|
|
346
|
+
return self._get("/v1/provider/readiness")
|
|
347
|
+
|
|
348
|
+
def list_provider_nodes(self) -> dict[str, Any]:
|
|
349
|
+
return self._get("/v1/provider/nodes")
|
|
350
|
+
|
|
351
|
+
def create_provider_node(
|
|
352
|
+
self,
|
|
353
|
+
name: str,
|
|
354
|
+
*,
|
|
355
|
+
ip_address: Optional[str] = None,
|
|
356
|
+
country: Optional[str] = None,
|
|
357
|
+
city: Optional[str] = None,
|
|
358
|
+
connection_type: Optional[str] = None,
|
|
359
|
+
) -> dict[str, Any]:
|
|
360
|
+
if not name:
|
|
361
|
+
raise ValueError("name is required")
|
|
362
|
+
payload: dict[str, Any] = {"name": name}
|
|
363
|
+
if ip_address is not None:
|
|
364
|
+
payload["ip_address"] = ip_address
|
|
365
|
+
if country is not None:
|
|
366
|
+
payload["country"] = country
|
|
367
|
+
if city is not None:
|
|
368
|
+
payload["city"] = city
|
|
369
|
+
if connection_type is not None:
|
|
370
|
+
payload["connection_type"] = connection_type
|
|
371
|
+
return self._post("/v1/provider/nodes", payload)
|
|
372
|
+
|
|
373
|
+
def update_provider_node(
|
|
374
|
+
self,
|
|
375
|
+
node_id: str,
|
|
376
|
+
*,
|
|
377
|
+
name: Optional[str] = None,
|
|
378
|
+
ip_address: Optional[str] = None,
|
|
379
|
+
country: Optional[str] = None,
|
|
380
|
+
city: Optional[str] = None,
|
|
381
|
+
connection_type: Optional[str] = None,
|
|
382
|
+
) -> dict[str, Any]:
|
|
383
|
+
if not node_id:
|
|
384
|
+
raise ValueError("node_id is required")
|
|
385
|
+
payload: dict[str, Any] = {}
|
|
386
|
+
if name is not None:
|
|
387
|
+
payload["name"] = name
|
|
388
|
+
if ip_address is not None:
|
|
389
|
+
payload["ip_address"] = ip_address
|
|
390
|
+
if country is not None:
|
|
391
|
+
payload["country"] = country
|
|
392
|
+
if city is not None:
|
|
393
|
+
payload["city"] = city
|
|
394
|
+
if connection_type is not None:
|
|
395
|
+
payload["connection_type"] = connection_type
|
|
396
|
+
return self._put(f"/v1/provider/nodes/{node_id}", payload)
|
|
397
|
+
|
|
398
|
+
def delete_provider_node(self, node_id: str) -> dict[str, Any] | None:
|
|
399
|
+
if not node_id:
|
|
400
|
+
raise ValueError("node_id is required")
|
|
401
|
+
return self._delete(f"/v1/provider/nodes/{node_id}")
|
|
402
|
+
|
|
403
|
+
def get_node_connect(self, node_id: str) -> dict[str, Any]:
|
|
404
|
+
"""Connect hints for provider-cli / provider-node join."""
|
|
405
|
+
if not node_id:
|
|
406
|
+
raise ValueError("node_id is required")
|
|
407
|
+
return self._get(f"/v1/provider/nodes/{node_id}/connect")
|
|
408
|
+
|
|
409
|
+
# ── Listings (seller) ─────────────────────────────────────────────────
|
|
410
|
+
|
|
411
|
+
def list_my_listings(
|
|
412
|
+
self, *, page: Optional[int] = None, limit: Optional[int] = None
|
|
413
|
+
) -> dict[str, Any]:
|
|
414
|
+
return self._get("/v1/listings/my", query={"page": page, "limit": limit})
|
|
415
|
+
|
|
416
|
+
def get_listing(self, listing_id: str) -> dict[str, Any]:
|
|
417
|
+
if not listing_id:
|
|
418
|
+
raise ValueError("listing_id is required")
|
|
419
|
+
return self._get(f"/v1/listings/{listing_id}")
|
|
420
|
+
|
|
421
|
+
def create_listing(self, **fields: Any) -> dict[str, Any]:
|
|
422
|
+
"""Create a marketplace listing as seller.
|
|
423
|
+
|
|
424
|
+
Required fields typically include ``title``, ``proxy_type``, ``protocol``,
|
|
425
|
+
``price_per_gb``, ``available_bandwidth_gb``.
|
|
426
|
+
"""
|
|
427
|
+
return self._post("/v1/listings", dict(fields))
|
|
428
|
+
|
|
429
|
+
def update_listing(self, listing_id: str, **fields: Any) -> dict[str, Any]:
|
|
430
|
+
"""Update a listing. Pause/activate with ``status="active"|"paused"``."""
|
|
431
|
+
if not listing_id:
|
|
432
|
+
raise ValueError("listing_id is required")
|
|
433
|
+
return self._patch(f"/v1/listings/{listing_id}", dict(fields))
|
|
434
|
+
|
|
435
|
+
def delete_listing(self, listing_id: str) -> dict[str, Any] | None:
|
|
436
|
+
if not listing_id:
|
|
437
|
+
raise ValueError("listing_id is required")
|
|
438
|
+
return self._delete(f"/v1/listings/{listing_id}")
|
|
439
|
+
|
|
440
|
+
# ── Earnings ──────────────────────────────────────────────────────────
|
|
441
|
+
|
|
442
|
+
def get_earnings_summary(self) -> dict[str, Any]:
|
|
443
|
+
return self._get("/v1/provider/earnings/summary")
|
|
444
|
+
|
|
445
|
+
def list_earnings(
|
|
446
|
+
self, *, page: Optional[int] = None, limit: Optional[int] = None
|
|
447
|
+
) -> dict[str, Any]:
|
|
448
|
+
return self._get(
|
|
449
|
+
"/v1/provider/earnings", query={"page": page, "limit": limit}
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
def list_earnings_history(self) -> dict[str, Any]:
|
|
453
|
+
return self._get("/v1/provider/earnings/history")
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: proxyhubb
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: Official Python SDK for ProxyHubb (
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Official Python SDK for ProxyHubb (buyer Connect/bulk + seller/provider APIs)
|
|
5
5
|
Author: ProxyHubb
|
|
6
6
|
License: MIT
|
|
7
7
|
Project-URL: Homepage, https://proxyhubb.com
|
|
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "proxyhubb"
|
|
7
|
-
version = "0.
|
|
8
|
-
description = "Official Python SDK for ProxyHubb (
|
|
7
|
+
version = "0.3.0"
|
|
8
|
+
description = "Official Python SDK for ProxyHubb (buyer Connect/bulk + seller/provider APIs)"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
11
11
|
requires-python = ">=3.9"
|
|
@@ -32,6 +32,13 @@ def test_access_token_only():
|
|
|
32
32
|
assert "X-API-Key" not in client._session.headers
|
|
33
33
|
|
|
34
34
|
|
|
35
|
+
def test_provider_token_alias():
|
|
36
|
+
client = ProxyHubClient(provider_token="ptok")
|
|
37
|
+
assert client._session.headers["Authorization"] == "Bearer ptok"
|
|
38
|
+
client.set_provider_token("ptok2")
|
|
39
|
+
assert client._session.headers["Authorization"] == "Bearer ptok2"
|
|
40
|
+
|
|
41
|
+
|
|
35
42
|
def test_proxy_url_helpers():
|
|
36
43
|
assert (
|
|
37
44
|
proxy_url("user", "pass", host="proxy.proxyhubb.com:8090")
|
|
@@ -108,3 +115,39 @@ def test_as_requests_proxy_from_intent():
|
|
|
108
115
|
client = ProxyHubClient(access_token="jwt")
|
|
109
116
|
proxies = client.as_requests_proxy_from_intent("tok")
|
|
110
117
|
assert "session-intent:tok@" in proxies["http"]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_create_provider_token():
|
|
121
|
+
client = ProxyHubClient(provider_token="ptok")
|
|
122
|
+
mock_resp = MagicMock()
|
|
123
|
+
mock_resp.ok = True
|
|
124
|
+
mock_resp.status_code = 201
|
|
125
|
+
mock_resp.json.return_value = {"id": "t1", "token": "raw-once"}
|
|
126
|
+
with patch.object(client._session, "post", return_value=mock_resp) as post:
|
|
127
|
+
out = client.create_provider_token(name="edge", node_id="n1")
|
|
128
|
+
assert out["token"] == "raw-once"
|
|
129
|
+
assert post.call_args[0][0].endswith("/v1/provider/tokens")
|
|
130
|
+
assert post.call_args[1]["json"]["node_id"] == "n1"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_seller_listing_and_earnings():
|
|
134
|
+
client = ProxyHubClient(access_token="jwt")
|
|
135
|
+
mock_resp = MagicMock()
|
|
136
|
+
mock_resp.ok = True
|
|
137
|
+
mock_resp.status_code = 200
|
|
138
|
+
mock_resp.content = b"{}"
|
|
139
|
+
mock_resp.json.return_value = {"ok": True}
|
|
140
|
+
|
|
141
|
+
with patch.object(client._session, "get", return_value=mock_resp) as get:
|
|
142
|
+
client.get_node_connect("n1")
|
|
143
|
+
client.get_earnings_summary()
|
|
144
|
+
client.list_my_listings(page=1, limit=5)
|
|
145
|
+
paths = [c.args[0] for c in get.call_args_list]
|
|
146
|
+
assert paths[0].endswith("/v1/provider/nodes/n1/connect")
|
|
147
|
+
assert paths[1].endswith("/v1/provider/earnings/summary")
|
|
148
|
+
assert "page=1" in paths[2] and "limit=5" in paths[2]
|
|
149
|
+
|
|
150
|
+
with patch.object(client._session, "patch", return_value=mock_resp) as patch_call:
|
|
151
|
+
client.update_listing("l1", status="paused")
|
|
152
|
+
assert patch_call.call_args[0][0].endswith("/v1/listings/l1")
|
|
153
|
+
assert patch_call.call_args[1]["json"]["status"] == "paused"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|