Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
34fe90b
chore: migrate from Poetry to uv (closes #105)
bluet May 11, 2026
00a055f
chore: requirements.txt for Snyk (works around uv.lock unsupported)
bluet May 11, 2026
1558779
chore(snyk): generate requirements.txt without hashes
bluet May 11, 2026
51ed1f3
fix(ci): pair setup-python with setup-uv to get latest Python patch
bluet May 11, 2026
7aa59b5
chore: --quiet on uv export so requirements.txt is clean
bluet May 11, 2026
28405e6
fix(security): bump dep lower-bounds to clear known vulns at constrai…
bluet May 11, 2026
283154b
chore(snyk): add [tool.poetry.dependencies] shim with bumped versions
bluet May 11, 2026
b65b730
fix: address PR #230 review feedback
bluet May 12, 2026
8657f72
fix(snyk): remove [tool.poetry.dependencies] shim — it was causing th…
bluet May 12, 2026
e68eb39
fix(security): bump vulnerable deps + add uv install/use compatibilit…
bluet May 20, 2026
a690cf7
chore: trigger PR sync to re-run WIP check
bluet May 20, 2026
7e22c9f
fix: add ruff to dev-deps so documented poetry workflow works (codex …
bluet May 20, 2026
27e562a
docs(readme): bump stale v2.0.0b1 references to current v2.0.0b3
bluet May 20, 2026
2f02e9c
docs(readme): make evergreen — drop stale version/count/dep references
bluet May 20, 2026
cb1a338
Initial plan
Copilot Jun 9, 2026
a4b26f3
Merge origin/master and update dependencies
Copilot Jun 10, 2026
61155cd
style: apply ruff formatting to test files
Copilot Jun 10, 2026
6b75fa3
Stabilize IPv4-mapped IPv6 test expectations
Copilot Jun 10, 2026
d4494ce
docs: remove unsupported uv sync setup step
Copilot Jun 10, 2026
ad8df60
chore(deps): lock aiohttp 3.14.5, multidict 6.9.1, yarl 1.25.1 (advis…
bluet Oct 24, 2026
c230136
fix(resolver): read aiodns 4 DNSResult answers (for #230) (#238)
bluet Oct 24, 2026
1f34c17
docs: make the uv runner command use Poetry's environment; align .git…
bluet Oct 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/python-test-versions.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ jobs:
run: poetry install --no-interaction
- name: Lint with ruff (modern replacement for flake8)
run: |
# Install ruff for fast, comprehensive linting
poetry run pip install ruff
# ruff is in the Poetry dev dependency group so it's already
# installed by `poetry install` above — no ad-hoc pip install needed
# stop the build if there are Python syntax errors or undefined names
poetry run ruff check . --select=E9,F63,F7,F82 --output-format=github
# comprehensive linting with GitHub integration
Expand Down
6 changes: 4 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,8 @@ venv_*/
*-report.json
*-stderr.txt

# uv-managed venv lockfile. Project uses poetry.lock as the canonical
# lockfile; committing both creates ambiguity for contributors.
# uv lockfile. Poetry is canonical (poetry.lock is what CI tests against).
# `uv sync` is not supported with this Poetry-only metadata (no [project]
# table); uv is only a command runner after `poetry install` (see README).
# Ignore any stray uv.lock so it never drifts from poetry.lock.
uv.lock
246 changes: 106 additions & 140 deletions README.md

Large diffs are not rendered by default.

2,198 changes: 1,277 additions & 921 deletions poetry.lock

Large diffs are not rendered by default.

27 changes: 24 additions & 3 deletions proxybroker/resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
from .errors import ResolveError
from .utils import DATA_DIR, canonicalize_ip, log

# One address from a DNS answer, in the legacy aiodns shape (``.host``/``.ttl``).
_AddressRecord = namedtuple("_AddressRecord", ["host", "ttl"])

GeoData = namedtuple(
"GeoData", ["code", "name", "region_code", "region_name", "city_name"]
)
Expand Down Expand Up @@ -547,11 +550,29 @@ async def _resolve(self, host, qtype):
if self._resolver is None:
# Deferred construction - we are now inside a running loop.
self._resolver = aiodns.DNSResolver()
query = getattr(self._resolver, "query_dns", self._resolver.query)
Comment thread
bluet marked this conversation as resolved.
Resolved
try:
resp = await asyncio.wait_for(
self._resolver.query(host, qtype), timeout=self._timeout
)
resp = await asyncio.wait_for(query(host, qtype), timeout=self._timeout)
except (aiodns.error.DNSError, asyncio.TimeoutError) as e:
raise ResolveError from e
else:
return self._address_records(resp)

@staticmethod
def _address_records(resp):
"""Normalise a DNS answer to objects exposing ``.host``.

aiodns 4 ``query_dns()`` returns a pycares 5 ``DNSResult`` whose
address records sit in ``.answer`` as ``DNSRecord`` objects with the
address at ``.data.addr``. The legacy ``query()`` path (aiodns < 4)
returns a list of objects that already expose ``.host``. Non-address
records in the answer section (e.g. CNAME) are skipped.
"""
answer = getattr(resp, "answer", None)
if answer is None:
return resp
return [
_AddressRecord(record.data.addr, getattr(record, "ttl", 0))
for record in answer
if getattr(getattr(record, "data", None), "addr", None)
]
41 changes: 27 additions & 14 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@

[tool.poetry]
name = "proxybroker2"
version = "2.0.0b3"
Expand Down Expand Up @@ -30,23 +29,37 @@ classifiers = [

]

# Lower bounds bumped from prior version to clear known vulnerabilities at
# the constraint floor (snyk scans the lower bound when no specific version
# is locked). Notable:
# - click ^8.3.3 fixes SNYK-PYTHON-CLICK-16347201 (HIGH, command injection
# in click.edit() filename param)
# - aiohttp ^3.13.5 fixes multiple HIGH vulns (SSRF on Windows static
# handler, memory exhaustion in multipart/Request.post/ZLibDecompressor,
# request smuggling)
# Other deps bumped to match poetry.lock current resolution for consistency.
[tool.poetry.dependencies]
python = "^3.10"
aiohttp = "^3.12.0"
aiodns = "^3.4.0"
attrs = ">=25.3.0"
maxminddb = "^2.7.0"
cachetools = "^5.5.2"
click = "^8.2.1"
pyyaml = "^6.0.2"
aiohttp = "^3.14.1"
aiodns = "^4.0.4"
attrs = ">=26.1.0,<27.0.0"
maxminddb = "^3.1.1"
cachetools = "^7.1.4"
click = "^8.4.1"
pyyaml = "^6.0.3"

[tool.poetry.dev-dependencies]
pytest = "^8.3.5"
pytest-asyncio = "^0.26.0"
pytest-runner = "^6.0.1"
[tool.poetry.group.dev.dependencies]
pytest = "^9.0.3"
pytest-asyncio = "^1.4.0"
pytest-mock = "^3.15.1"
pytest-cov = "^7.1.0"
alabaster = "^1.0.0"
pytest-mock = "^3.14.0"
pytest-cov = "^6.1.1"
# ruff is also pinned in .pre-commit-config.yaml; the README's contributor
# workflow uses `poetry run ruff check / format`, so it must be installable
# via `poetry install`. Without this, the CI workflow falls back to
# `poetry run pip install ruff` ad-hoc, but contributors get
# "command not found" on a clean checkout.
ruff = "^0.15.16"

[build-system]
requires = ["poetry-core>=2.1.3"]
Expand Down
25 changes: 11 additions & 14 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,21 +33,19 @@ def get_version():
INFO["long_description"] = f.read()

REQUIRES = [
"aiohttp>=3.12.0",
"aiodns>=3.4.0",
"attrs>=25.3.0",
"maxminddb>=2.7.0",
"cachetools>=5.5.2",
"click>=8.2.1",
"pyyaml>=6.0.2",
"aiohttp>=3.14.1",
"aiodns>=4.0.4",
"attrs>=26.1.0,<27.0.0",
"maxminddb>=3.1.1",
"cachetools>=7.1.4",
"click>=8.4.1",
"pyyaml>=6.0.3",
]
SETUP_REQUIRES = ["pytest-runner>=6.0.1"]
TEST_REQUIRES = [
"pytest>=8.3.5",
"pytest-asyncio>=0.26.0",
"pytest-runner>=6.0.1",
"pytest-mock>=3.14.0",
"pytest-cov>=6.1.1",
"pytest>=9.0.3",
"pytest-asyncio>=1.4.0",
"pytest-mock>=3.15.1",
"pytest-cov>=7.1.0",
]
PACKAGES = ["proxybroker", "proxybroker.data"]
PACKAGE_DATA = {"": ["LICENSE"], INFO["package"]: ["data/*.mmdb"]}
Expand All @@ -62,7 +60,6 @@ def get_version():
license=INFO["license"],
url=INFO["url"],
install_requires=REQUIRES,
setup_requires=SETUP_REQUIRES,
tests_require=TEST_REQUIRES,
packages=PACKAGES,
package_data=PACKAGE_DATA,
Expand Down
5 changes: 4 additions & 1 deletion tests/test_providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import pytest

from proxybroker.providers import Provider
from proxybroker.utils import canonicalize_ip


class TestProviderConstruction:
Expand Down Expand Up @@ -109,7 +110,9 @@ def test_ipv6_mapped_does_not_spawn_phantom_ipv4(self):
p = Provider(url="http://example.com")
page = "real proxy: [::ffff:192.0.2.1]:8080 trailer"
results = p.find_proxies(page)
assert ("::ffff:192.0.2.1", "8080") in results
expected_mapped = canonicalize_ip("::ffff:192.0.2.1")
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
assert expected_mapped is not None
assert (expected_mapped, "8080") in results
assert not any(host == "192.0.2.1" for host, _port in results)

def test_ipv6_bracketed_alongside_v4_pairs(self):
Expand Down
5 changes: 4 additions & 1 deletion tests/test_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,10 @@ def query_side_effect(host, qtype):
return a_future
raise aiodns.error.DNSError(1, "no AAAA record (test)")

mocker.patch("aiodns.DNSResolver.query", side_effect=query_side_effect)
resolver_method = (
"query_dns" if hasattr(aiodns.DNSResolver, "query_dns") else "query"
)
mocker.patch(f"aiodns.DNSResolver.{resolver_method}", side_effect=query_side_effect)
proxy = await Proxy.create("testhost.com", "80")
assert proxy.host == "127.0.0.1"

Expand Down
56 changes: 52 additions & 4 deletions tests/test_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,10 @@ def query_side_effect(host, qtype):
return a_future
raise aiodns.error.DNSError(1, "no AAAA record (test)")

mocker.patch("aiodns.DNSResolver.query", side_effect=query_side_effect)
resolver_method = (
"query_dns" if hasattr(aiodns.DNSResolver, "query_dns") else "query"
)
mocker.patch(f"aiodns.DNSResolver.{resolver_method}", side_effect=query_side_effect)
assert await resolver.resolve("test.com") == "127.0.0.1"


Expand Down Expand Up @@ -253,7 +256,10 @@ def query_side_effect(host, qtype):
return a_future
raise aiodns.error.DNSError(1, "no AAAA record (test)")

mocker.patch("aiodns.DNSResolver.query", side_effect=query_side_effect)
resolver_method = (
"query_dns" if hasattr(aiodns.DNSResolver, "query_dns") else "query"
)
mocker.patch(f"aiodns.DNSResolver.{resolver_method}", side_effect=query_side_effect)
resp = [
{
"hostname": "test2.com",
Expand Down Expand Up @@ -297,7 +303,10 @@ def query_side_effect(host, qtype):
return f
raise aiodns.error.DNSError(1, f"no {qtype} record for {host} (test)")

mocker.patch("aiodns.DNSResolver.query", side_effect=query_side_effect)
resolver_method = (
"query_dns" if hasattr(aiodns.DNSResolver, "query_dns") else "query"
)
mocker.patch(f"aiodns.DNSResolver.{resolver_method}", side_effect=query_side_effect)
await resolver.resolve("test.com")
await resolver.resolve("test2.com", port=80, family=socket.AF_INET)
# Resolve dispatch:
Expand All @@ -314,7 +323,7 @@ def query_side_effect(host, qtype):

# Mock an exception for test3.com
mocker.patch(
"aiodns.DNSResolver.query",
f"aiodns.DNSResolver.{resolver_method}",
side_effect=asyncio.TimeoutError("DNS resolution failed"),
)
with pytest.raises(ResolveError):
Expand Down Expand Up @@ -827,3 +836,42 @@ async def fake_probe(family):
await asyncio.sleep(0.05)
assert probe_was_cancelled["v4"], "v4 probe was leaked, never cancelled"
assert probe_was_cancelled["v6"], "v6 probe was leaked, never cancelled"


@pytest.mark.asyncio
async def test_resolve_reads_aiodns4_dnsresult(mocker, resolver):
"""aiodns 4 query_dns() returns a pycares 5 DNSResult, not a list of
objects with .host; resolve() must read the address records from
DNSResult.answer[*].data.addr and skip non-address records."""
pycares = pytest.importorskip("pycares")
if not hasattr(pycares, "DNSResult"):
pytest.skip("pycares < 5: legacy result shape")
import aiodns

if not hasattr(aiodns.DNSResolver, "query_dns"):
pytest.skip("aiodns < 4: legacy query() path")

def record(data, rtype):
return pycares.DNSRecord(
name="test.com", type=rtype, record_class=1, ttl=60, data=data
)

a_result = pycares.DNSResult(
answer=[
record(pycares.CNAMERecordData(cname="alias.test.com"), 5),
record(pycares.ARecordData(addr="192.0.2.7"), 1),
],
authority=[],
additional=[],
)
a_future = asyncio.Future()
a_future.set_result(a_result)

def query_side_effect(host, qtype):
if qtype == "A":
return a_future
raise aiodns.error.DNSError(1, "no AAAA record (test)")

resolver._cached_hosts.clear()
mocker.patch("aiodns.DNSResolver.query_dns", side_effect=query_side_effect)
assert await resolver.resolve("test.com") == "192.0.2.7"
5 changes: 3 additions & 2 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,9 @@ def test_get_all_ip_ipv6_documentation_range():

def test_get_all_ip_ipv6_ipv4_mapped():
found = get_all_ip("transparent: ::ffff:192.0.2.1 leak")
# IPv4-mapped form preserved as-is by stdlib canonicalisation.
assert "::ffff:192.0.2.1" in found
expected_mapped = canonicalize_ip("::ffff:192.0.2.1")
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
assert expected_mapped is not None
assert expected_mapped in found
# The embedded v4 part should also be picked up by the v4 path.
assert "192.0.2.1" in found

Expand Down
Loading