diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 0000000..70a1638 --- /dev/null +++ b/.github/workflows/checks.yml @@ -0,0 +1,66 @@ +name: Checks + +on: + pull_request: + push: + branches: [master] + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + python: ["3.10", "3.11", "3.12", "3.13", "3.14", "3.15"] + include: + - python: "3.10" + minimum: true + env: + IPGEO_RUN_LIVE_TESTS: "false" + IPGEO_RUN_LIVE_HARDENING: "false" + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: ${{ matrix.python }} + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: "24.22.1" + - run: npm install --global postman-cli@1.73.0 + - name: Create test environment + run: | + python -m venv .venv + echo "$GITHUB_WORKSPACE/.venv/bin" >> "$GITHUB_PATH" + - run: python -m pip install --upgrade pip + - name: Install dependencies + if: ${{ !matrix.minimum }} + run: python -m pip install -e '.[dev]' + - name: Install minimum dependencies + if: matrix.minimum + run: python -m pip install -c tests/constraints-minimum.txt -e '.[dev]' + - run: python -m pip check + - run: ruff check src tests + - run: mypy src + - run: pyright --pythonpath "$(command -v python)" + - run: pytest --cov=src/ipgeolocation --cov-report=term-missing + - run: python -m build + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: "3.11" + - name: Create audit environment + run: | + python -m venv .venv-audit + .venv-audit/bin/python -m pip install --upgrade pip + .venv-audit/bin/python -m pip install -e '.[audit]' + - name: Audit scanner dependencies + run: .venv-audit/bin/pip-audit --skip-editable + - name: Audit SDK dependencies + run: >- + .venv-audit/bin/pip-audit --skip-editable + --path ".venv/lib/python${{ matrix.python }}/site-packages" diff --git a/.gitignore b/.gitignore index 1578a04..19e8efb 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ build/ dist/ *.egg-info/ .venv/ +.venv-audit/ venv/ .claude/ CLAUDE-REVIEW.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 4832125..dd55af4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 3.0.0 + +- Requires Python 3.10 or newer. +- Updated HTTP, certificate, test and build dependency minimums. +- Added dependency auditing and checks across supported Python versions. + ## 2.0.0 - Added the Python SDK for the IPGeolocation API unified endpoints. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7cdfcc1..eedf8c5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,8 +2,9 @@ ## Requirements -- Python 3.8+ +- Python 3.10+ - `pip` +- Node.js 24 and Postman CLI 1.73.0 for local HTTP integration tests ## Setup @@ -11,6 +12,7 @@ python3 -m venv .venv source .venv/bin/activate pip install -e .[dev] +npm install --global postman-cli@1.73.0 ``` ## Run Tests @@ -19,6 +21,8 @@ pip install -e .[dev] pytest ``` +HTTP integration tests run both clients against a local Postman simulation. + ## Coverage Check ```bash @@ -33,6 +37,21 @@ mypy src pyright ``` +## Dependency and Build Checks + +```bash +python -m build +``` + +Dependency audits use Python 3.11+ for the scanner and inspect the SDK's test environment: + +```bash +python3.11 -m venv .venv-audit +.venv-audit/bin/python -m pip install -e '.[audit]' +.venv-audit/bin/pip-audit --skip-editable \ + --path "$(.venv/bin/python -c 'import sysconfig; print(sysconfig.get_paths()["purelib"])')" +``` + ## Live Tests Live tests are disabled by default and consume API credits. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..6be68a2 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,3 @@ +include CONTRIBUTING.md +include tests/constraints-minimum.txt +recursive-include postman *.yaml *.js diff --git a/README.md b/README.md index 7f8c524..7f468db 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Typed Python SDK for the [IPGeolocation.io](https://ipgeolocation.io/) IP Geoloc - Single lookup (`/v3/ipgeo`) and bulk lookup (`/v3/ipgeo-bulk`) - Sync client built on `requests` and async client built on `httpx` - Typed response models plus raw JSON/XML methods -- Python 3.8+ +- Python 3.10+ ## Table of Contents diff --git a/postman/mocks/ipgeolocation/config.yaml b/postman/mocks/ipgeolocation/config.yaml new file mode 100644 index 0000000..2965be1 --- /dev/null +++ b/postman/mocks/ipgeolocation/config.yaml @@ -0,0 +1,7 @@ +version: 1 +name: ipgeolocation +origin: https://api.ipgeolocation.io +interceptRequests: true +scenarios: + - name: default + path: default.js diff --git a/postman/mocks/ipgeolocation/default.js b/postman/mocks/ipgeolocation/default.js new file mode 100644 index 0000000..c52337d --- /dev/null +++ b/postman/mocks/ipgeolocation/default.js @@ -0,0 +1,51 @@ +const http = require('node:http'); + +http.createServer((req, res) => { + const url = new URL(req.url, 'http://localhost'); + const reply = (status, body) => { + res.writeHead(status, { + 'Content-Type': 'application/json', + 'X-Credits-Charged': '1', + 'X-Successful-Records': '1', + }); + res.end(JSON.stringify(body)); + }; + if (!['/v3/ipgeo', '/v3/ipgeo-bulk'].includes(url.pathname)) { + return reply(501, { message: 'Endpoint not modeled' }); + } + const bulk = url.pathname === '/v3/ipgeo-bulk'; + if (req.method !== (bulk ? 'POST' : 'GET')) { + return reply(400, { message: 'Unexpected HTTP method' }); + } + const key = url.searchParams.get('apiKey'); + const statuses = { BAD_KEY: 401, FORBIDDEN: 403, RATE_LIMIT: 429, SERVER_ERROR: 503 }; + if (statuses[key]) return reply(statuses[key], { message: `Test failure for ${key}` }); + if (key === 'BAD_JSON') { res.writeHead(200); return res.end('{'); } + if (key === 'BAD_ENCODING') { + res.writeHead(200, { 'Content-Type': 'application/json; charset=unknown-encoding' }); + return res.end(JSON.stringify({ ip: '8.8.8.8', location: { city: '東京' } })); + } + if (key === 'LARGE_RESPONSE') { + return reply(200, { ip: '8.8.8.8', location: { city: '東京'.repeat(2000) } }); + } + if (key === 'SLOW_REQUEST') { + return setTimeout(() => reply(200, { ip: '8.8.8.8' }), 300); + } + if (key !== 'TEST_KEY &+?=#%' && !(key === null && req.headers.origin === 'https://app.example.test')) { + return reply(401, { message: 'Invalid test key' }); + } + if (bulk && (req.headers['content-type'] !== 'application/json' || !Array.isArray(req.body?.ips))) { + return reply(400, { message: 'Expected JSON ips array' }); + } + const result = { + ip: bulk ? req.body.ips[0] : url.searchParams.get('ip'), + location: { city: 'Test City' }, + _request: { + method: req.method, + query: Object.fromEntries(url.searchParams), + headers: req.headers, + body: bulk ? req.body : null, + }, + }; + reply(200, bulk ? [result, { message: 'IP address not found' }] : result); +}).listen(process.env.PORT || 4500); diff --git a/postman/simulations/python-sdk/python-sdk.context.yaml b/postman/simulations/python-sdk/python-sdk.context.yaml new file mode 100644 index 0000000..6d2f5d7 --- /dev/null +++ b/postman/simulations/python-sdk/python-sdk.context.yaml @@ -0,0 +1,23 @@ +purpose: + stands_in_for: The IPGeolocation single and bulk endpoints used by the Python SDK. + unblocks: HTTP transport, encoding, authentication, parsing, timeout and response-size regression tests. +assumptions: + - id: transport-compatibility + status: supported + claim: Both clients preserve request and response behavior with the updated dependencies. + reason: Both clients passed the same real HTTP cases using current and minimum dependency versions; the fixture echoes requests for comparison. + verification: On 2026-10-29, 26 HTTP cases passed with both dependency sets. They cover single and bulk lookups, raw responses, query encoding, headers, metadata, errors, malformed JSON, unknown encoding, timeouts and streamed response limits. Simulation request events confirm that the real transports reached the stand-in. + source: tests/test_http_integration.py; src/ipgeolocation/_transport.py + updated_at: '2026-10-29' + - id: live-provider-behavior + status: unknown + claim: The simulation establishes live API behavior and account permissions. + reason: The official API Hub workspace listed no published code mock, and Orbit discovery returned HTTP 403. The fixture models the existing SDK tests and client request builders. + verification: No live provider request or API credential is used. + source: src/ipgeolocation/client.py; src/ipgeolocation/async_client.py; postman/mocks/ipgeolocation/default.js + updated_at: '2026-10-29' +fingerprints: + - id: python-sdk-security-update + date: '2026-10-29' + commit: 65d06151e88d23f1018b6a509cb24cc4302b8342 plus dependency and integration-test changes. + cli: Postman CLI 1.73.0. diff --git a/postman/simulations/python-sdk/python-sdk.sim.yaml b/postman/simulations/python-sdk/python-sdk.sim.yaml new file mode 100644 index 0000000..e8763b7 --- /dev/null +++ b/postman/simulations/python-sdk/python-sdk.sim.yaml @@ -0,0 +1,8 @@ +simulation: python-sdk +routing: path +mocks: + - name: ipgeolocation + routeKey: ipgeolocation + origin: + - api.ipgeolocation.io + path: ../../mocks/ipgeolocation/config.yaml diff --git a/pyproject.toml b/pyproject.toml index 9d8da0d..be8e274 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,15 +1,15 @@ [build-system] -requires = ["setuptools>=69", "wheel"] +requires = ["setuptools>=84.1.0", "wheel>=0.48.0"] build-backend = "setuptools.build_meta" [project] name = "ipgeolocationio" -version = "2.0.0" +version = "3.0.0" description = "Typed Python SDK for the IPGeolocation.io IP location API, with sync and async clients." readme = "README.md" license = "MIT" license-files = ["LICENSE"] -requires-python = ">=3.8" +requires-python = ">=3.10" authors = [ { name = "IPGeolocation Team", email = "support@ipgeolocation.io" } ] @@ -27,29 +27,39 @@ classifiers = [ "Intended Audience :: Developers", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.15", "Typing :: Typed", "Topic :: Internet", "Topic :: Software Development :: Libraries :: Python Modules", ] dependencies = [ - "httpx>=0.28,<1", - "requests>=2.32.4,<3", + "httpx>=0.28.1,<1", + "requests>=2.33.0,<3", + "urllib3>=2.8.0,<3", + "anyio>=4.14.2,<5", + "certifi>=2026.10.23", ] [project.optional-dependencies] dev = [ - "pytest>=8.0.0,<9", + "pytest>=9.0.3,<10", "pytest-cov>=7.0.0,<8", "pyright>=1.1.408,<2", "ruff>=0.15.6,<0.16", "mypy>=1.19.1,<2", "types-requests>=2.32.4,<3", + "build>=1.6.1,<2", + "setuptools>=84.1.0", +] +audit = [ + "pip-audit>=2.10.1,<3", + "filelock>=4.2.1", + "setuptools>=84.1.0", ] [project.urls] @@ -82,16 +92,14 @@ show_missing = true skip_covered = false [tool.ruff] -target-version = "py38" +target-version = "py310" line-length = 100 [tool.ruff.lint] select = ["E", "F", "I"] [tool.mypy] -# mypy 1.19+ no longer supports targeting Python 3.8 directly. -# Pyright covers the real Python 3.8 compatibility check for this SDK. -python_version = "3.9" +python_version = "3.10" files = ["src"] check_untyped_defs = true warn_unused_ignores = true @@ -101,7 +109,5 @@ no_implicit_optional = true [tool.pyright] include = ["src"] -pythonVersion = "3.8" +pythonVersion = "3.10" typeCheckingMode = "standard" -venvPath = "." -venv = ".venv" diff --git a/tests/constraints-minimum.txt b/tests/constraints-minimum.txt new file mode 100644 index 0000000..851311c --- /dev/null +++ b/tests/constraints-minimum.txt @@ -0,0 +1,7 @@ +requests==2.33.0 +httpx==0.28.1 +urllib3==2.8.0 +anyio==4.14.2 +certifi==2026.10.23 +pytest==9.0.3 +setuptools==84.1.0 diff --git a/tests/test_http_integration.py b/tests/test_http_integration.py new file mode 100644 index 0000000..abd6d4f --- /dev/null +++ b/tests/test_http_integration.py @@ -0,0 +1,263 @@ +"""Exercise both SDK transports against a local Postman simulation.""" + +from __future__ import annotations + +import asyncio +import json +import os +import shutil +import signal +import subprocess +import time +from pathlib import Path + +import pytest + +from ipgeolocation import ( + ApiException, + AsyncIpGeolocationClient, + BulkLookupError, + BulkLookupIpGeolocationRequest, + BulkLookupSuccess, + IpGeolocationClient, + IpGeolocationClientConfig, + LookupIpGeolocationRequest, + RateLimitException, + RequestTimeoutException, + SerializationException, + ServerErrorException, + TransportException, + UnauthorizedException, +) +from ipgeolocation._transport import HttpRequestData, HttpxAsyncTransport, RequestsHttpTransport + +TEST_KEY = "TEST_KEY &+?=#%" + + +@pytest.fixture(scope="module") +def api_url(tmp_path_factory): + postman = shutil.which("postman") + if postman is None or os.name != "posix": + pytest.skip("HTTP integration tests require Postman CLI on Linux or macOS") + log_path = tmp_path_factory.mktemp("simulation") / "requests.ndjson" + with log_path.open("w") as output, pytest.MonkeyPatch.context() as env: + env.setenv("NO_PROXY", "localhost,127.0.0.1") + process = subprocess.Popen( + [ + postman, + "simulation", + "run", + "postman/simulations/python-sdk/python-sdk.sim.yaml", + "--port", + "auto", + "--no-history", + "--output", + "ndjson", + ], + cwd=Path(__file__).resolve().parents[1], + stdout=output, + stderr=subprocess.STDOUT, + start_new_session=True, + ) + try: + deadline = time.monotonic() + 20 + while time.monotonic() < deadline: + for line in log_path.read_text().splitlines(): + try: + event = json.loads(line) + except json.JSONDecodeError: + continue + if event.get("event") == "listening": + yield event["mocks"][0]["address"] + assert '"event":"request"' in log_path.read_text(errors="replace") + return + assert process.poll() is None, log_path.read_text() + time.sleep(0.1) + pytest.fail("Postman simulation did not start: " + log_path.read_text()) + finally: + try: + os.killpg(process.pid, signal.SIGINT) + except ProcessLookupError: + pass + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + + +@pytest.fixture(params=[IpGeolocationClient, AsyncIpGeolocationClient], ids=["requests", "httpx"]) +def client_type(request): + return request.param + + +def _call(client_type, api_url, method, request, *, api_key=TEST_KEY, origin=None, timeout=1): + config = IpGeolocationClientConfig( + api_key=api_key, + request_origin=origin, + base_url=api_url, + connect_timeout=min(0.1, timeout), + read_timeout=timeout, + ) + if client_type is AsyncIpGeolocationClient: + + async def run(): + async with client_type(config) as client: + return await getattr(client, method)(request) + + return asyncio.run(run()) + with client_type(config) as client: + return getattr(client, method)(request) + + +def test_http_lookup(client_type, api_url): + result = _call( + client_type, + api_url, + "lookup_ip_geolocation", + LookupIpGeolocationRequest(ip="2001:db8::1", include=["security"]), + ) + assert result.data.ip == "2001:db8::1" + assert result.data.location.city == "Test City" + assert result.metadata.status_code == 200 + assert result.metadata.credits_charged == 1 + assert result.metadata.header_values("x-credits-charged") == ("1",) + assert result.metadata.first_header_value("X-CREDITS-CHARGED") == "1" + + +def test_http_raw_lookup_preserves_query_and_headers(client_type, api_url): + result = _call( + client_type, + api_url, + "lookup_ip_geolocation_raw", + LookupIpGeolocationRequest( + ip="8.8.8.8", fields=["location.city"], headers={"X-Request-Test": "kept"} + ), + ) + request = json.loads(result.data)["_request"] + assert request["query"]["apiKey"] == TEST_KEY + assert request["query"]["fields"] == "location.city" + assert request["headers"]["x-request-test"] == "kept" + assert request["headers"]["user-agent"] == client_type.default_user_agent() + + +def test_http_bulk_lookup(client_type, api_url): + result = _call( + client_type, + api_url, + "bulk_lookup_ip_geolocation", + BulkLookupIpGeolocationRequest(ips=["8.8.8.8", "invalid-ip"]), + ) + assert isinstance(result.data[0], BulkLookupSuccess) + assert result.data[0].data.ip == "8.8.8.8" + assert isinstance(result.data[1], BulkLookupError) + assert result.data[1].error.message == "IP address not found" + + +def test_http_raw_bulk_lookup(client_type, api_url): + result = _call( + client_type, + api_url, + "bulk_lookup_ip_geolocation_raw", + BulkLookupIpGeolocationRequest(ips=["8.8.8.8", "invalid-ip"]), + ) + request = json.loads(result.data)[0]["_request"] + assert request["method"] == "POST" + assert request["body"] == {"ips": ["8.8.8.8", "invalid-ip"]} + + +@pytest.mark.parametrize( + "key,exception,status", + [ + ("BAD_KEY", UnauthorizedException, 401), + ("FORBIDDEN", ApiException, 403), + ("RATE_LIMIT", RateLimitException, 429), + ("SERVER_ERROR", ServerErrorException, 503), + ], +) +def test_http_errors(client_type, api_url, key, exception, status): + with pytest.raises(exception) as error: + _call( + client_type, + api_url, + "lookup_ip_geolocation", + LookupIpGeolocationRequest(ip="8.8.8.8"), + api_key=key, + ) + assert error.value.status_code == status + assert error.value.api_message == f"Test failure for {key}" + + +def test_http_invalid_json(client_type, api_url): + with pytest.raises(SerializationException): + _call( + client_type, + api_url, + "lookup_ip_geolocation", + LookupIpGeolocationRequest(ip="8.8.8.8"), + api_key="BAD_JSON", + ) + + +def test_http_unknown_encoding_falls_back_to_utf8(client_type, api_url): + result = _call( + client_type, + api_url, + "lookup_ip_geolocation", + LookupIpGeolocationRequest(ip="8.8.8.8"), + api_key="BAD_ENCODING", + ) + assert result.data.location.city == "東京" + + +def test_http_timeout(client_type, api_url): + with pytest.raises(RequestTimeoutException): + _call( + client_type, + api_url, + "lookup_ip_geolocation", + LookupIpGeolocationRequest(ip="8.8.8.8"), + api_key="SLOW_REQUEST", + timeout=0.05, + ) + + +def test_http_origin_authentication(client_type, api_url): + result = _call( + client_type, + api_url, + "lookup_ip_geolocation", + LookupIpGeolocationRequest(ip="8.8.8.8"), + api_key=None, + origin="https://app.example.test", + ) + assert result.data.ip == "8.8.8.8" + + +def test_http_streaming_size_limit(client_type, api_url): + request = HttpRequestData( + url=f"{api_url}/v3/ipgeo?apiKey=LARGE_RESPONSE", + method="GET", + headers={}, + body=None, + connect_timeout=0.1, + read_timeout=1, + ) + if client_type is AsyncIpGeolocationClient: + + async def run(): + transport = HttpxAsyncTransport(max_response_body_chars=128) + try: + with pytest.raises(TransportException, match="Response body exceeded max size"): + await transport.send(request) + finally: + await transport.close() + + asyncio.run(run()) + else: + transport = RequestsHttpTransport(max_response_body_chars=128) + try: + with pytest.raises(TransportException, match="Response body exceeded max size"): + transport.send(request) + finally: + transport.close()