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.
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proxyhubb
3
- Version: 0.2.0
4
- Summary: Official Python SDK for ProxyHubb (session intents, orders, bulk pools)
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
@@ -21,4 +21,4 @@ __all__ = [
21
21
  "proxy_url",
22
22
  "session_intent_proxy_url",
23
23
  ]
24
- __version__ = "0.2.0"
24
+ __version__ = "0.3.0"
@@ -1,10 +1,10 @@
1
- """ProxyHubb Python SDK — Control API client (Connect + bulk pools)."""
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 (session intents, orders, bulk pools).
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 and order credentials (``Authorization: Bearer``).
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
- if not api_key and not access_token:
72
- raise ValueError("api_key or access_token is required")
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 = access_token or ""
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 used for session intent / order credential calls."""
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 _url(self, path: str) -> str:
102
- return f"{self._base_url}{path}"
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
- resp.raise_for_status()
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
- resp.raise_for_status()
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
- resp.raise_for_status()
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.2.0
4
- Summary: Official Python SDK for ProxyHubb (session intents, orders, bulk pools)
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.2.0"
8
- description = "Official Python SDK for ProxyHubb (session intents, orders, bulk pools)"
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