Skip to content

Commit 0557fd0

Browse files
committed
feat(pypi): added getfreeproxy package
1 parent 0c8ffe7 commit 0557fd0

File tree

13 files changed

+527
-0
lines changed

13 files changed

+527
-0
lines changed

.github/workflows/ci.yml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
name: CI
2+
3+
on: [push, pull_request]
4+
5+
jobs:
6+
test:
7+
runs-on: ubuntu-latest
8+
steps:
9+
- uses: actions/checkout@v4
10+
- name: Set up Python
11+
uses: actions/setup-python@v4
12+
with:
13+
python-version: '3.11'
14+
- name: Install
15+
run: |
16+
python -m pip install --upgrade pip
17+
pip install -e .[dev]
18+
- name: Run tests
19+
run: pytest -q

.github/workflows/release.yml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
name: Release
2+
3+
on:
4+
release:
5+
types: [published]
6+
7+
jobs:
8+
build-and-publish:
9+
runs-on: ubuntu-latest
10+
steps:
11+
- uses: actions/checkout@v4
12+
- name: Set up Python
13+
uses: actions/setup-python@v4
14+
with:
15+
python-version: '3.11'
16+
- name: Upgrade pip and install build tools
17+
run: |
18+
python -m pip install --upgrade pip
19+
pip install build twine
20+
- name: Build distributions
21+
run: python -m build
22+
- name: Publish to PyPI
23+
uses: pypa/[email protected]
24+
with:
25+
user: __token__
26+
password: ${{ secrets.PYPI_API_TOKEN }}

README.md

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
GetFreeProxy Python client
2+
=================================
3+
4+
Install
5+
-------
6+
7+
```bash
8+
pip install .
9+
```
10+
11+
Usage (sync)
12+
------------
13+
14+
```py
15+
from getfreeproxy import FreeProxyClient
16+
17+
client = FreeProxyClient(api_key="YOUR_API_KEY")
18+
proxies = client.query(country="US", protocol="https")
19+
for p in proxies:
20+
print(p.proxy_url)
21+
client.close()
22+
```
23+
24+
Usage (async)
25+
-------------
26+
27+
```py
28+
import asyncio
29+
from getfreeproxy import AsyncFreeProxyClient
30+
31+
async def main():
32+
client = AsyncFreeProxyClient(api_key="YOUR_API_KEY")
33+
proxies = await client.query(country="US")
34+
for p in proxies:
35+
print(p.proxy_url)
36+
await client.aclose()
37+
38+
asyncio.run(main())
39+
```
40+
41+
Tests
42+
-----
43+
44+
Install dev dependencies and run tests:
45+
46+
```bash
47+
pip install -e .[dev]
48+
pytest -q
49+
```
50+
51+
Publishing
52+
----------
53+
54+
This repository uses a GitHub Actions workflow to publish to PyPI when a tag matching `vX.Y.Z` is pushed.
55+
56+
Preparation (one-time):
57+
- Create a PyPI API token at https://pypi.org/ (Account > API tokens).
58+
- Add the token to GitHub repository secrets as `PYPI_API_TOKEN` (Repository Settings > Secrets).
59+
60+
Release steps (recommended via CI):
61+
62+
1. Bump `version` in `pyproject.toml` to the new release version (for example `0.0.2`).
63+
2. Create a signed or annotated tag and push it:
64+
65+
```bash
66+
git tag -a v0.0.2 -m "Release v0.0.2"
67+
git push origin v0.0.2
68+
```
69+
70+
When the tag is pushed, GitHub Actions will run `.github/workflows/release.yml` and publish the built distributions to PyPI using the `PYPI_API_TOKEN` secret.
71+
72+
You can also build and upload locally (useful for dry-run checks):
73+
74+
```bash
75+
python -m pip install --upgrade build twine
76+
python -m build
77+
python -m twine check dist/*
78+
# then upload (will require PyPI credentials or token):
79+
python -m twine upload dist/*
80+
```
81+

getfreeproxy/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from ._version import __version__
2+
from .models import Proxy
3+
from .client import FreeProxyClient
4+
from .async_client import AsyncFreeProxyClient
5+
6+
__all__ = ["__version__", "Proxy", "FreeProxyClient", "AsyncFreeProxyClient"]

getfreeproxy/_utils.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
from typing import Dict, Optional
2+
3+
4+
def build_headers(api_key: str, user_agent: Optional[str] = None) -> Dict[str, str]:
5+
headers = {"Authorization": f"Bearer {api_key}", "Accept": "application/json"}
6+
if user_agent:
7+
headers["User-Agent"] = user_agent
8+
return headers
9+
10+
11+
def extract_error_message(body_text: str, json_body: Optional[Dict] = None) -> str:
12+
if json_body:
13+
if isinstance(json_body, dict):
14+
if "error" in json_body:
15+
return str(json_body.get("error"))
16+
if "message" in json_body:
17+
return str(json_body.get("message"))
18+
# fallback to first string-like field
19+
for k in ("detail", "code"):
20+
if k in json_body:
21+
return str(json_body.get(k))
22+
return body_text or "Unknown error"

getfreeproxy/_version.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
__version__ = "0.0.1"

getfreeproxy/async_client.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
from typing import List, Optional, Dict
2+
import httpx
3+
4+
from .models import Proxy
5+
from .exceptions import APIError, InvalidAPIKey, NetworkError, TimeoutError, ParseError
6+
from ._utils import build_headers, extract_error_message
7+
8+
9+
class AsyncFreeProxyClient:
10+
def __init__(self, api_key: str, *, base_url: str = "https://api.getfreeproxy.com", timeout: float = 30.0, user_agent: Optional[str] = None, session: Optional[httpx.AsyncClient] = None):
11+
self.api_key = api_key
12+
self.base_url = base_url.rstrip("/")
13+
self.timeout = timeout
14+
self.user_agent = user_agent
15+
self._own_session = session is None
16+
self.session = session or httpx.AsyncClient(timeout=timeout)
17+
18+
async def _handle_error_response(self, response: httpx.Response):
19+
status = response.status_code
20+
text = response.text
21+
try:
22+
j = response.json()
23+
except Exception:
24+
j = None
25+
26+
msg = extract_error_message(text, j)
27+
if status == 401:
28+
raise InvalidAPIKey(status, msg, raw_body=text)
29+
raise APIError(status, msg, raw_body=text)
30+
31+
async def query(self, country: Optional[str] = None, protocol: Optional[str] = None, page: Optional[int] = None) -> List[Proxy]:
32+
url = f"{self.base_url}/v1/proxies"
33+
params: Dict[str, str] = {}
34+
if country:
35+
params["country"] = country
36+
if protocol:
37+
params["protocol"] = protocol
38+
if page is not None:
39+
params["page"] = str(page)
40+
41+
headers = build_headers(self.api_key, self.user_agent)
42+
try:
43+
resp = await self.session.get(url, headers=headers, params=params)
44+
except httpx.ReadTimeout:
45+
raise TimeoutError("request timed out")
46+
except httpx.RequestError as e:
47+
raise NetworkError(str(e))
48+
49+
if resp.status_code < 200 or resp.status_code >= 300:
50+
await self._handle_error_response(resp)
51+
52+
try:
53+
data = resp.json()
54+
except Exception as e:
55+
raise ParseError(f"failed to parse JSON response: {e}")
56+
57+
if not isinstance(data, list):
58+
raise ParseError("expected JSON array of proxies")
59+
60+
proxies = [Proxy.from_dict(item) for item in data]
61+
return proxies
62+
63+
async def query_country(self, country: str) -> List[Proxy]:
64+
return await self.query(country=country)
65+
66+
async def query_protocol(self, protocol: str) -> List[Proxy]:
67+
return await self.query(protocol=protocol)
68+
69+
async def query_page(self, page: int) -> List[Proxy]:
70+
return await self.query(page=page)
71+
72+
async def iter_pages(self, *, start: int = 1):
73+
page = start
74+
while True:
75+
items = await self.query(page=page)
76+
yield items
77+
if not items:
78+
break
79+
page += 1
80+
81+
async def raw_request(self, method: str, path: str, **kwargs) -> httpx.Response:
82+
url = f"{self.base_url.rstrip('/')}/{path.lstrip('/')}"
83+
headers = kwargs.pop("headers", None) or build_headers(self.api_key, self.user_agent)
84+
return await self.session.request(method, url, headers=headers, timeout=self.timeout, **kwargs)
85+
86+
async def aclose(self) -> None:
87+
if self._own_session:
88+
await self.session.aclose()

getfreeproxy/client.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
from typing import List, Optional, Dict
2+
import httpx
3+
4+
from .models import Proxy
5+
from .exceptions import APIError, InvalidAPIKey, NetworkError, TimeoutError, ParseError
6+
from ._utils import build_headers, extract_error_message
7+
8+
9+
class FreeProxyClient:
10+
def __init__(self, api_key: str, *, base_url: str = "https://api.getfreeproxy.com", timeout: float = 30.0, user_agent: Optional[str] = None, session: Optional[httpx.Client] = None):
11+
self.api_key = api_key
12+
self.base_url = base_url.rstrip("/")
13+
self.timeout = timeout
14+
self.user_agent = user_agent
15+
self._own_session = session is None
16+
self.session = session or httpx.Client(timeout=timeout)
17+
18+
def _handle_error_response(self, response: httpx.Response):
19+
status = response.status_code
20+
text = response.text
21+
try:
22+
j = response.json()
23+
except Exception:
24+
j = None
25+
26+
msg = extract_error_message(text, j)
27+
if status == 401:
28+
raise InvalidAPIKey(status, msg, raw_body=text)
29+
raise APIError(status, msg, raw_body=text)
30+
31+
def query(self, country: Optional[str] = None, protocol: Optional[str] = None, page: Optional[int] = None) -> List[Proxy]:
32+
url = f"{self.base_url}/v1/proxies"
33+
params: Dict[str, str] = {}
34+
if country:
35+
params["country"] = country
36+
if protocol:
37+
params["protocol"] = protocol
38+
if page is not None:
39+
params["page"] = str(page)
40+
41+
headers = build_headers(self.api_key, self.user_agent)
42+
try:
43+
resp = self.session.get(url, headers=headers, params=params)
44+
except httpx.ReadTimeout:
45+
raise TimeoutError("request timed out")
46+
except httpx.RequestError as e:
47+
raise NetworkError(str(e))
48+
49+
if resp.status_code < 200 or resp.status_code >= 300:
50+
self._handle_error_response(resp)
51+
52+
try:
53+
data = resp.json()
54+
except Exception as e:
55+
raise ParseError(f"failed to parse JSON response: {e}")
56+
57+
if not isinstance(data, list):
58+
raise ParseError("expected JSON array of proxies")
59+
60+
proxies = [Proxy.from_dict(item) for item in data]
61+
return proxies
62+
63+
def query_country(self, country: str) -> List[Proxy]:
64+
return self.query(country=country)
65+
66+
def query_protocol(self, protocol: str) -> List[Proxy]:
67+
return self.query(protocol=protocol)
68+
69+
def query_page(self, page: int) -> List[Proxy]:
70+
return self.query(page=page)
71+
72+
def iter_pages(self, *, start: int = 1):
73+
page = start
74+
while True:
75+
items = self.query(page=page)
76+
yield items
77+
if not items:
78+
break
79+
page += 1
80+
81+
def raw_request(self, method: str, path: str, **kwargs) -> httpx.Response:
82+
url = f"{self.base_url.rstrip('/')}/{path.lstrip('/')}"
83+
headers = kwargs.pop("headers", None) or build_headers(self.api_key, self.user_agent)
84+
return self.session.request(method, url, headers=headers, timeout=self.timeout, **kwargs)
85+
86+
def close(self) -> None:
87+
if self._own_session:
88+
self.session.close()

getfreeproxy/exceptions.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
from typing import Optional
2+
3+
4+
class APIError(Exception):
5+
def __init__(self, status_code: Optional[int], message: str, raw_body: Optional[str] = None):
6+
super().__init__(message)
7+
self.status_code = status_code
8+
self.message = message
9+
self.raw_body = raw_body
10+
11+
12+
class InvalidAPIKey(APIError):
13+
pass
14+
15+
16+
class NetworkError(Exception):
17+
pass
18+
19+
20+
class TimeoutError(Exception):
21+
pass
22+
23+
24+
class ParseError(Exception):
25+
pass

0 commit comments

Comments
 (0)