proxyhat 0.2.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.
proxyhat/__init__.py ADDED
@@ -0,0 +1,39 @@
1
+ """ProxyHat Python SDK — official client for the ProxyHat residential proxy API."""
2
+
3
+ from proxyhat.async_client import AsyncProxyHat
4
+ from proxyhat.client import ProxyHat
5
+ from proxyhat.connection import (
6
+ PROXYHAT_GATEWAY,
7
+ PROXYHAT_PORT_HTTP,
8
+ PROXYHAT_PORT_SOCKS5,
9
+ build_connection_url,
10
+ build_proxy_username,
11
+ )
12
+ from proxyhat.errors import (
13
+ APIError,
14
+ AuthenticationError,
15
+ NotFoundError,
16
+ PermissionError,
17
+ ProxyHatError,
18
+ RateLimitError,
19
+ ValidationError,
20
+ )
21
+
22
+ __all__ = [
23
+ "PROXYHAT_GATEWAY",
24
+ "PROXYHAT_PORT_HTTP",
25
+ "PROXYHAT_PORT_SOCKS5",
26
+ "APIError",
27
+ "AsyncProxyHat",
28
+ "AuthenticationError",
29
+ "NotFoundError",
30
+ "PermissionError",
31
+ "ProxyHat",
32
+ "ProxyHatError",
33
+ "RateLimitError",
34
+ "ValidationError",
35
+ "build_connection_url",
36
+ "build_proxy_username",
37
+ ]
38
+
39
+ __version__ = "0.2.0"
@@ -0,0 +1,104 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from proxyhat.errors import (
6
+ APIError,
7
+ AuthenticationError,
8
+ NotFoundError,
9
+ PermissionError,
10
+ RateLimitError,
11
+ ValidationError,
12
+ )
13
+
14
+ DEFAULT_BASE_URL = "https://api.proxyhat.com/v1"
15
+ DEFAULT_TIMEOUT = 30.0
16
+ USER_AGENT = "proxyhat-python/0.1.0"
17
+
18
+
19
+ def _build_headers(api_key: str | None) -> dict[str, str]:
20
+ headers: dict[str, str] = {
21
+ "User-Agent": USER_AGENT,
22
+ "Accept": "application/json",
23
+ }
24
+ if api_key:
25
+ headers["Authorization"] = f"Bearer {api_key}"
26
+ return headers
27
+
28
+
29
+ def _raise_for_status(status_code: int, body: Any, headers: Any) -> None:
30
+ if 200 <= status_code < 300:
31
+ return
32
+
33
+ message = ""
34
+ errors = None
35
+
36
+ if isinstance(body, dict):
37
+ message = body.get("description", "") or body.get("message", "")
38
+ errors = body.get("errors")
39
+
40
+ if not message:
41
+ message = f"HTTP {status_code}"
42
+
43
+ if status_code == 401:
44
+ raise AuthenticationError(message=message, errors=errors)
45
+ elif status_code == 403:
46
+ raise PermissionError(message=message, errors=errors)
47
+ elif status_code == 404:
48
+ raise NotFoundError(message=message, errors=errors)
49
+ elif status_code == 422:
50
+ raise ValidationError(message=message, errors=errors)
51
+ elif status_code == 429:
52
+ retry_after = None
53
+ retry_header = headers.get("retry-after") if hasattr(headers, "get") else None
54
+ if retry_header:
55
+ try:
56
+ retry_after = int(retry_header)
57
+ except (ValueError, TypeError):
58
+ pass
59
+ raise RateLimitError(message=message, errors=errors, retry_after=retry_after)
60
+ else:
61
+ raise APIError(message=message, status_code=status_code, errors=errors)
62
+
63
+
64
+ def _parse_json_body(response: Any) -> Any:
65
+ """Attempt to parse response as JSON, return empty dict on failure."""
66
+ content_type = response.headers.get("content-type", "")
67
+ if "application/json" not in content_type:
68
+ return {}
69
+ try:
70
+ return response.json()
71
+ except Exception:
72
+ return {}
73
+
74
+
75
+ class BaseClientConfig:
76
+ """Shared configuration for sync and async clients."""
77
+
78
+ def __init__(
79
+ self,
80
+ api_key: str | None = None,
81
+ base_url: str = DEFAULT_BASE_URL,
82
+ timeout: float = DEFAULT_TIMEOUT,
83
+ ) -> None:
84
+ self.api_key = api_key
85
+ self.base_url = base_url.rstrip("/")
86
+ self.timeout = timeout
87
+
88
+ @property
89
+ def headers(self) -> dict[str, str]:
90
+ return _build_headers(self.api_key)
91
+
92
+ def build_url(self, path: str) -> str:
93
+ return f"{self.base_url}{path}"
94
+
95
+ @staticmethod
96
+ def handle_response(status_code: int, body: Any, headers: Any) -> Any:
97
+ _raise_for_status(status_code, body, headers)
98
+
99
+ if isinstance(body, dict):
100
+ if "payload" in body:
101
+ return body["payload"]
102
+ if "data" in body:
103
+ return body["data"]
104
+ return body
@@ -0,0 +1,124 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import httpx
6
+
7
+ from proxyhat._base_client import DEFAULT_BASE_URL, DEFAULT_TIMEOUT, BaseClientConfig, _parse_json_body
8
+ from proxyhat.connection import ProxyProtocol, build_connection_url
9
+ from proxyhat.resources.analytics import AsyncAnalyticsResource
10
+ from proxyhat.resources.auth import AsyncAuthResource
11
+ from proxyhat.resources.coupons import AsyncCouponsResource
12
+ from proxyhat.resources.email import AsyncEmailResource
13
+ from proxyhat.resources.locations import AsyncLocationsResource
14
+ from proxyhat.resources.payments import AsyncPaymentsResource
15
+ from proxyhat.resources.plans import AsyncPlansResource
16
+ from proxyhat.resources.profile import AsyncProfileResource
17
+ from proxyhat.resources.proxy_descriptors import AsyncProxyDescriptorsResource
18
+ from proxyhat.resources.proxy_presets import AsyncProxyPresetsResource
19
+ from proxyhat.resources.sub_user_groups import AsyncSubUserGroupsResource
20
+ from proxyhat.resources.sub_users import AsyncSubUsersResource
21
+ from proxyhat.resources.two_factor import AsyncTwoFactorResource
22
+
23
+
24
+ class AsyncProxyHat:
25
+ """Asynchronous client for the ProxyHat API."""
26
+
27
+ def __init__(
28
+ self,
29
+ api_key: str | None = None,
30
+ base_url: str = DEFAULT_BASE_URL,
31
+ timeout: float = DEFAULT_TIMEOUT,
32
+ ) -> None:
33
+ self._config = BaseClientConfig(api_key=api_key, base_url=base_url, timeout=timeout)
34
+ self._http = httpx.AsyncClient(headers=self._config.headers, timeout=self._config.timeout)
35
+
36
+ self.auth = AsyncAuthResource(self)
37
+ self.sub_users = AsyncSubUsersResource(self)
38
+ self.sub_user_groups = AsyncSubUserGroupsResource(self)
39
+ self.locations = AsyncLocationsResource(self)
40
+ self.analytics = AsyncAnalyticsResource(self)
41
+ self.proxy_presets = AsyncProxyPresetsResource(self)
42
+ self.proxy_descriptors = AsyncProxyDescriptorsResource(self)
43
+ self.profile = AsyncProfileResource(self)
44
+ self.two_factor = AsyncTwoFactorResource(self)
45
+ self.email = AsyncEmailResource(self)
46
+ self.coupons = AsyncCouponsResource(self)
47
+ self.plans = AsyncPlansResource(self)
48
+ self.payments = AsyncPaymentsResource(self)
49
+
50
+ async def connection_url(
51
+ self,
52
+ *,
53
+ sub_user: str | None = None,
54
+ country: str | None = None,
55
+ region: str | None = None,
56
+ city: str | None = None,
57
+ sticky: bool | str | None = None,
58
+ filter: str | None = None,
59
+ protocol: ProxyProtocol = "http",
60
+ ) -> str:
61
+ """Return a ready-to-use residential proxy URL (see ``ProxyHat.connection_url``)."""
62
+ users = await self.sub_users.list()
63
+ want = sub_user.strip() if sub_user else None
64
+ if want:
65
+ chosen = next((s for s in users if s.uuid == want or s.name == want), None)
66
+ else:
67
+ usable = [
68
+ s for s in users if not s.suspended_at and (s.traffic_limit == 0 or s.used_traffic < s.traffic_limit)
69
+ ]
70
+ chosen = usable[0] if usable else None
71
+ if chosen is None or not chosen.proxy_username or not chosen.proxy_password:
72
+ raise ValueError(
73
+ f'No sub-user matched "{want}" (or it has no proxy credentials).'
74
+ if want
75
+ else "No usable sub-user found (all suspended or out of traffic). "
76
+ "Create one, top up, or pass sub_user=."
77
+ )
78
+ return build_connection_url(
79
+ username=chosen.proxy_username,
80
+ password=chosen.proxy_password,
81
+ country=country,
82
+ region=region,
83
+ city=city,
84
+ sticky=sticky,
85
+ filter=filter,
86
+ protocol=protocol,
87
+ )
88
+
89
+ async def request(
90
+ self,
91
+ method: str,
92
+ path: str,
93
+ *,
94
+ json: dict[str, Any] | None = None,
95
+ params: dict[str, Any] | None = None,
96
+ ) -> Any:
97
+ url = self._config.build_url(path)
98
+ if params:
99
+ params = {k: v for k, v in params.items() if v is not None}
100
+ response = await self._http.request(method, url, json=json, params=params)
101
+ body = _parse_json_body(response)
102
+ return self._config.handle_response(response.status_code, body, response.headers)
103
+
104
+ async def request_raw(
105
+ self,
106
+ method: str,
107
+ path: str,
108
+ *,
109
+ params: dict[str, Any] | None = None,
110
+ ) -> httpx.Response:
111
+ """Make a request and return the raw httpx.Response (for binary downloads)."""
112
+ url = self._config.build_url(path)
113
+ if params:
114
+ params = {k: v for k, v in params.items() if v is not None}
115
+ return await self._http.request(method, url, params=params)
116
+
117
+ async def close(self) -> None:
118
+ await self._http.aclose()
119
+
120
+ async def __aenter__(self) -> AsyncProxyHat:
121
+ return self
122
+
123
+ async def __aexit__(self, *args: Any) -> None:
124
+ await self.close()
proxyhat/client.py ADDED
@@ -0,0 +1,129 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import httpx
6
+
7
+ from proxyhat._base_client import DEFAULT_BASE_URL, DEFAULT_TIMEOUT, BaseClientConfig, _parse_json_body
8
+ from proxyhat.connection import ProxyProtocol, build_connection_url
9
+ from proxyhat.resources.analytics import AnalyticsResource
10
+ from proxyhat.resources.auth import AuthResource
11
+ from proxyhat.resources.coupons import CouponsResource
12
+ from proxyhat.resources.email import EmailResource
13
+ from proxyhat.resources.locations import LocationsResource
14
+ from proxyhat.resources.payments import PaymentsResource
15
+ from proxyhat.resources.plans import PlansResource
16
+ from proxyhat.resources.profile import ProfileResource
17
+ from proxyhat.resources.proxy_descriptors import ProxyDescriptorsResource
18
+ from proxyhat.resources.proxy_presets import ProxyPresetsResource
19
+ from proxyhat.resources.sub_user_groups import SubUserGroupsResource
20
+ from proxyhat.resources.sub_users import SubUsersResource
21
+ from proxyhat.resources.two_factor import TwoFactorResource
22
+
23
+
24
+ class ProxyHat:
25
+ """Synchronous client for the ProxyHat API."""
26
+
27
+ def __init__(
28
+ self,
29
+ api_key: str | None = None,
30
+ base_url: str = DEFAULT_BASE_URL,
31
+ timeout: float = DEFAULT_TIMEOUT,
32
+ ) -> None:
33
+ self._config = BaseClientConfig(api_key=api_key, base_url=base_url, timeout=timeout)
34
+ self._http = httpx.Client(headers=self._config.headers, timeout=self._config.timeout)
35
+
36
+ self.auth = AuthResource(self)
37
+ self.sub_users = SubUsersResource(self)
38
+ self.sub_user_groups = SubUserGroupsResource(self)
39
+ self.locations = LocationsResource(self)
40
+ self.analytics = AnalyticsResource(self)
41
+ self.proxy_presets = ProxyPresetsResource(self)
42
+ self.proxy_descriptors = ProxyDescriptorsResource(self)
43
+ self.profile = ProfileResource(self)
44
+ self.two_factor = TwoFactorResource(self)
45
+ self.email = EmailResource(self)
46
+ self.coupons = CouponsResource(self)
47
+ self.plans = PlansResource(self)
48
+ self.payments = PaymentsResource(self)
49
+
50
+ def connection_url(
51
+ self,
52
+ *,
53
+ sub_user: str | None = None,
54
+ country: str | None = None,
55
+ region: str | None = None,
56
+ city: str | None = None,
57
+ sticky: bool | str | None = None,
58
+ filter: str | None = None,
59
+ protocol: ProxyProtocol = "http",
60
+ ) -> str:
61
+ """Return a ready-to-use residential proxy URL.
62
+
63
+ Looks up your sub-users, picks an active one with remaining traffic (or
64
+ the one named by ``sub_user``), and builds the gateway URL locally.
65
+ """
66
+ users = self.sub_users.list()
67
+ want = sub_user.strip() if sub_user else None
68
+ if want:
69
+ chosen = next((s for s in users if s.uuid == want or s.name == want), None)
70
+ else:
71
+ usable = [
72
+ s for s in users if not s.suspended_at and (s.traffic_limit == 0 or s.used_traffic < s.traffic_limit)
73
+ ]
74
+ chosen = usable[0] if usable else None
75
+ if chosen is None or not chosen.proxy_username or not chosen.proxy_password:
76
+ raise ValueError(
77
+ f'No sub-user matched "{want}" (or it has no proxy credentials).'
78
+ if want
79
+ else "No usable sub-user found (all suspended or out of traffic). "
80
+ "Create one, top up, or pass sub_user=."
81
+ )
82
+ return build_connection_url(
83
+ username=chosen.proxy_username,
84
+ password=chosen.proxy_password,
85
+ country=country,
86
+ region=region,
87
+ city=city,
88
+ sticky=sticky,
89
+ filter=filter,
90
+ protocol=protocol,
91
+ )
92
+
93
+ def request(
94
+ self,
95
+ method: str,
96
+ path: str,
97
+ *,
98
+ json: dict[str, Any] | None = None,
99
+ params: dict[str, Any] | None = None,
100
+ ) -> Any:
101
+ url = self._config.build_url(path)
102
+ # Filter out None params
103
+ if params:
104
+ params = {k: v for k, v in params.items() if v is not None}
105
+ response = self._http.request(method, url, json=json, params=params)
106
+ body = _parse_json_body(response)
107
+ return self._config.handle_response(response.status_code, body, response.headers)
108
+
109
+ def request_raw(
110
+ self,
111
+ method: str,
112
+ path: str,
113
+ *,
114
+ params: dict[str, Any] | None = None,
115
+ ) -> httpx.Response:
116
+ """Make a request and return the raw httpx.Response (for binary downloads)."""
117
+ url = self._config.build_url(path)
118
+ if params:
119
+ params = {k: v for k, v in params.items() if v is not None}
120
+ return self._http.request(method, url, params=params)
121
+
122
+ def close(self) -> None:
123
+ self._http.close()
124
+
125
+ def __enter__(self) -> ProxyHat:
126
+ return self
127
+
128
+ def __exit__(self, *args: Any) -> None:
129
+ self.close()
proxyhat/connection.py ADDED
@@ -0,0 +1,90 @@
1
+ """ProxyHat gateway connection helpers.
2
+
3
+ The API resources manage your account; these helpers turn a sub-user's
4
+ ``proxy_username`` / ``proxy_password`` into a ready-to-use proxy URL, building
5
+ the gateway username with ProxyHat's targeting grammar. Everything here is
6
+ offline (no network) — see https://docs.proxyhat.com/connecting.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import secrets
12
+ from typing import Literal
13
+ from urllib.parse import quote
14
+
15
+ PROXYHAT_GATEWAY = "gate.proxyhat.com"
16
+ PROXYHAT_PORT_HTTP = 8080
17
+ PROXYHAT_PORT_SOCKS5 = 1080
18
+
19
+ ProxyProtocol = Literal["http", "socks5"]
20
+
21
+
22
+ def _slug(value: str) -> str:
23
+ return "_".join(value.strip().lower().split())
24
+
25
+
26
+ def build_proxy_username(
27
+ base: str,
28
+ *,
29
+ country: str | None = None,
30
+ region: str | None = None,
31
+ city: str | None = None,
32
+ sticky: bool | str | None = None,
33
+ filter: str | None = None,
34
+ ) -> str:
35
+ """Build a gateway username from a base ``proxy_username`` plus targeting tokens.
36
+
37
+ Fixed order: ``<base>-country-<iso>[-region-<slug>][-city-<slug>]``
38
+ ``[-sid-<16hex>-ttl-<dur>]-filter-<tier>``.
39
+
40
+ ``sticky``: ``True`` → 30m default; a string sets the TTL ("30m"/"12h");
41
+ omit / ``False`` for a fresh rotating IP per request.
42
+ """
43
+ parts = [base.strip(), "country", (country or "any").strip().lower()]
44
+
45
+ if region and region.strip().lower() != "any":
46
+ parts += ["region", _slug(region)]
47
+ if city:
48
+ parts += ["city", _slug(city)]
49
+ if sticky is not None and sticky is not False:
50
+ if sticky is True:
51
+ ttl = "30m"
52
+ else:
53
+ raw = str(sticky).strip()
54
+ ttl = "30m" if raw.lower() in ("", "true", "1", "yes", "on") else raw
55
+ parts += ["sid", secrets.token_hex(8), "ttl", ttl]
56
+ if isinstance(filter, str):
57
+ tier = filter.strip().lower()
58
+ if tier.startswith("filter-"):
59
+ tier = tier[len("filter-") :]
60
+ if tier and tier != "none":
61
+ parts += ["filter", tier]
62
+
63
+ return "-".join(parts)
64
+
65
+
66
+ def build_connection_url(
67
+ *,
68
+ username: str,
69
+ password: str,
70
+ country: str | None = None,
71
+ region: str | None = None,
72
+ city: str | None = None,
73
+ sticky: bool | str | None = None,
74
+ filter: str | None = None,
75
+ protocol: ProxyProtocol = "http",
76
+ ) -> str:
77
+ """Build a full proxy connection URL for the ProxyHat gateway.
78
+
79
+ Example: ``http://<user>-country-us:<pass>@gate.proxyhat.com:8080``.
80
+ """
81
+ port = PROXYHAT_PORT_SOCKS5 if protocol == "socks5" else PROXYHAT_PORT_HTTP
82
+ user = build_proxy_username(
83
+ username,
84
+ country=country,
85
+ region=region,
86
+ city=city,
87
+ sticky=sticky,
88
+ filter=filter,
89
+ )
90
+ return f"{protocol}://{quote(user, safe='')}:{quote(password, safe='')}@{PROXYHAT_GATEWAY}:{port}"
proxyhat/errors.py ADDED
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ class ProxyHatError(Exception):
7
+ """Base exception for all ProxyHat SDK errors."""
8
+
9
+ message: str
10
+ status_code: int | None
11
+ errors: Any
12
+
13
+ def __init__(
14
+ self,
15
+ message: str,
16
+ status_code: int | None = None,
17
+ errors: Any = None,
18
+ ) -> None:
19
+ self.message = message
20
+ self.status_code = status_code
21
+ self.errors = errors
22
+ super().__init__(message)
23
+
24
+
25
+ class AuthenticationError(ProxyHatError):
26
+ """Raised on 401 Unauthorized responses."""
27
+
28
+ def __init__(self, message: str = "Unauthorized", errors: Any = None) -> None:
29
+ super().__init__(message, status_code=401, errors=errors)
30
+
31
+
32
+ class PermissionError(ProxyHatError):
33
+ """Raised on 403 Forbidden responses."""
34
+
35
+ def __init__(self, message: str = "Forbidden", errors: Any = None) -> None:
36
+ super().__init__(message, status_code=403, errors=errors)
37
+
38
+
39
+ class NotFoundError(ProxyHatError):
40
+ """Raised on 404 Not Found responses."""
41
+
42
+ def __init__(self, message: str = "Not Found", errors: Any = None) -> None:
43
+ super().__init__(message, status_code=404, errors=errors)
44
+
45
+
46
+ class ValidationError(ProxyHatError):
47
+ """Raised on 422 Unprocessable Entity responses. Includes field-level errors."""
48
+
49
+ def __init__(self, message: str = "Validation Error", errors: Any = None) -> None:
50
+ super().__init__(message, status_code=422, errors=errors)
51
+
52
+
53
+ class RateLimitError(ProxyHatError):
54
+ """Raised on 429 Too Many Requests responses."""
55
+
56
+ retry_after: int | None
57
+
58
+ def __init__(
59
+ self,
60
+ message: str = "Rate Limited",
61
+ errors: Any = None,
62
+ retry_after: int | None = None,
63
+ ) -> None:
64
+ super().__init__(message, status_code=429, errors=errors)
65
+ self.retry_after = retry_after
66
+
67
+
68
+ class APIError(ProxyHatError):
69
+ """Raised for all other HTTP errors (500, etc.)."""
70
+
71
+ def __init__(self, message: str = "API Error", status_code: int = 500, errors: Any = None) -> None:
72
+ super().__init__(message, status_code=status_code, errors=errors)
proxyhat/py.typed ADDED
File without changes
File without changes