From c1680306227d16cc10ef9127fb15e551cc6eab59 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Thu, 16 Jul 2026 22:49:11 +0000 Subject: [PATCH 1/4] Require geoip2 5.3.0 for residential data The anonymizer.residential attribute requires the AnonymizerFeed support added in the geoip2 5.3.0 release, which is now published. Co-Authored-By: Claude Opus 4.8 --- pyproject.toml | 2 +- uv.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index bb2eb62..0bd345b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ authors = [ dependencies = [ "aiohttp>=3.14.0,<4.0.0", "email_validator>=2.0.0,<3.0.0", - "geoip2>=5.2.0,<6.0.0", + "geoip2>=5.3.0,<6.0.0", "requests>=2.24.0,<3.0.0", "typing-extensions>=4.13.2", "voluptuous", diff --git a/uv.lock b/uv.lock index c86f266..a5a35e8 100644 --- a/uv.lock +++ b/uv.lock @@ -345,7 +345,7 @@ name = "exceptiongroup" version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } wheels = [ @@ -457,16 +457,16 @@ wheels = [ [[package]] name = "geoip2" -version = "5.2.0" +version = "5.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, { name = "maxminddb" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/18/70/3d9e87289f79713aaf0fea9df4aa8e68776640fe59beb6299bb214610cfd/geoip2-5.2.0.tar.gz", hash = "sha256:6c9ded1953f8eb16043ed0a8ea20e6e9524ea7b65eb745724e12490aca44ef00", size = 176498, upload-time = "2025-11-20T18:21:08.874Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d2/8d/a62c6e56545049605777ad47de2274a72ed60e3991433ca3f1bcdd761ef5/geoip2-5.3.0.tar.gz", hash = "sha256:66df653e16c535f9bb45e3c7ce992fbe008911d9c7c349540574ea2fe8931db5", size = 177948, upload-time = "2026-07-16T22:34:01.497Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dd/d2/d55df737199a52b9d06e742ed2a608c525f0677e40375951372e65714fbd/geoip2-5.2.0-py3-none-any.whl", hash = "sha256:3d1546fd4eb7cad20445d027d2d9e81d3a71c074e019383f30db5d45e2c23320", size = 28991, upload-time = "2025-11-20T18:21:07.178Z" }, + { url = "https://files.pythonhosted.org/packages/71/bf/407ea894512ad989f6fbf9f8e50a02a69ce888bf6a2b799e53a99e4d26a7/geoip2-5.3.0-py3-none-any.whl", hash = "sha256:7f82c587f3f3691f30adc8634d0b93de9853d96d514ed48ff3ba38f188a5df31", size = 29186, upload-time = "2026-07-16T22:34:00.126Z" }, ] [[package]] @@ -744,7 +744,7 @@ lint = [ requires-dist = [ { name = "aiohttp", specifier = ">=3.14.0,<4.0.0" }, { name = "email-validator", specifier = ">=2.0.0,<3.0.0" }, - { name = "geoip2", specifier = ">=5.2.0,<6.0.0" }, + { name = "geoip2", specifier = ">=5.3.0,<6.0.0" }, { name = "requests", specifier = ">=2.24.0,<3.0.0" }, { name = "typing-extensions", specifier = ">=4.13.2" }, { name = "voluptuous" }, From 9bf97037b6c0d6aaf0b0aff24980f258372866ff Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Fri, 10 Jul 2026 21:00:45 +0000 Subject: [PATCH 2/4] Add residential sub-object to anonymizer for Insights The web service now nests a `residential` object inside `ip_address.anonymizer` containing residential proxy data for the network. It may be populated even when no other anonymizer attributes are set. The attribute is exposed automatically via the geoip2 dependency's new `Anonymizer.residential` attribute (a `geoip2.records.AnonymizerFeed`), so no model changes are needed here. Update the test fixtures and assertions to cover the new field, and note the change and the geoip2 5.3.0 minimum in HISTORY.rst. Co-Authored-By: Claude Fable 5 --- HISTORY.rst | 5 ++++ tests/data/factors-response.json | 7 +++++- tests/data/insights-response.json | 7 +++++- tests/test_models.py | 41 +++++++++++++++++++++++++++++++ tests/test_webservice.py | 8 ++++++ 5 files changed, 66 insertions(+), 2 deletions(-) diff --git a/HISTORY.rst b/HISTORY.rst index 99fd48b..9d488a3 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -21,6 +21,11 @@ History ``aiohttp.encode_basic_auth()`` instead of the ``aiohttp.BasicAuth`` / ``auth=`` parameter, which are deprecated as of aiohttp 3.14.0. As a result, the minimum required ``aiohttp`` version is now 3.14.0. +* The ``ip_address.anonymizer`` object may now contain a ``residential`` + attribute. This is a ``geoip2.records.AnonymizerFeed`` object containing + residential proxy data for the network. It has the following fields: + ``confidence``, ``network_last_seen``, and ``provider_name``. It may be + populated even when no other ``anonymizer`` attributes are set. 3.2.0 (2025-11-20) ++++++++++++++++++ diff --git a/tests/data/factors-response.json b/tests/data/factors-response.json index 1aee01f..9317084 100644 --- a/tests/data/factors-response.json +++ b/tests/data/factors-response.json @@ -28,7 +28,12 @@ "is_residential_proxy": true, "is_tor_exit_node": true, "network_last_seen": "2025-01-15", - "provider_name": "TestVPN" + "provider_name": "TestVPN", + "residential": { + "confidence": 82, + "network_last_seen": "2026-05-11", + "provider_name": "quickshift" + } }, "city": { "confidence": 42, diff --git a/tests/data/insights-response.json b/tests/data/insights-response.json index 14a4152..bcee439 100644 --- a/tests/data/insights-response.json +++ b/tests/data/insights-response.json @@ -28,7 +28,12 @@ "is_residential_proxy": true, "is_tor_exit_node": true, "network_last_seen": "2025-01-15", - "provider_name": "TestVPN" + "provider_name": "TestVPN", + "residential": { + "confidence": 82, + "network_last_seen": "2026-05-11", + "provider_name": "quickshift" + } }, "city": { "confidence": 42, diff --git a/tests/test_models.py b/tests/test_models.py index b7fa6c8..05cd2e9 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -197,6 +197,11 @@ def test_ip_address(self) -> None: "is_tor_exit_node": True, "network_last_seen": "2025-01-15", "provider_name": "TestVPN", + "residential": { + "confidence": 82, + "network_last_seen": "2026-05-11", + "provider_name": "quickshift", + }, }, traits={ "is_anonymous": True, @@ -238,6 +243,25 @@ def test_ip_address(self) -> None: ) self.assertEqual("TestVPN", address.anonymizer.provider_name) + # Test anonymizer.residential attribute + self.assertEqual(82, address.anonymizer.residential.confidence) + self.assertEqual( + datetime.date(2026, 5, 11), + address.anonymizer.residential.network_last_seen, + ) + self.assertEqual("quickshift", address.anonymizer.residential.provider_name) + + # Test to_dict() serialization of anonymizer.residential. The date + # is serialized in its ISO 8601 format. + self.assertEqual( + { + "confidence": 82, + "network_last_seen": "2026-05-11", + "provider_name": "quickshift", + }, + address.to_dict()["anonymizer"]["residential"], + ) + self.assertEqual("ANONYMOUS_IP", address.risk_reasons[0].code) self.assertEqual( "The IP address belongs to an anonymous network. " @@ -252,6 +276,23 @@ def test_ip_address(self) -> None: address.risk_reasons[1].reason, ) + def test_ip_address_anonymizer_without_residential(self) -> None: + address = IPAddress( + ["en"], + anonymizer={ + "is_anonymous": True, + "provider_name": "TestVPN", + }, + ) + + self.assertIsNone(address.anonymizer.residential.confidence) + self.assertIsNone(address.anonymizer.residential.network_last_seen) + self.assertIsNone(address.anonymizer.residential.provider_name) + + # Empty nested records are skipped during serialization, so there + # should be no residential key. + self.assertNotIn("residential", address.to_dict()["anonymizer"]) + def test_empty_address(self) -> None: address = IPAddress([]) self.assertEqual([], address.risk_reasons) diff --git a/tests/test_webservice.py b/tests/test_webservice.py index 2dfff37..d949642 100644 --- a/tests/test_webservice.py +++ b/tests/test_webservice.py @@ -236,6 +236,14 @@ def test_200(self) -> None: self.assertEqual("310", model.ip_address.traits.mobile_country_code) self.assertEqual("004", model.ip_address.traits.mobile_network_code) self.assertEqual("ANONYMOUS_IP", model.ip_address.risk_reasons[0].code) + self.assertEqual( + 82, + model.ip_address.anonymizer.residential.confidence, + ) + self.assertEqual( + "quickshift", + model.ip_address.anonymizer.residential.provider_name, + ) def test_authorization_header(self) -> None: # Credentials must be sent via the Authorization header rather than the From 47112583c511901afe3004305971192eb7eb8921 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Tue, 14 Jul 2026 16:08:34 +0000 Subject: [PATCH 3/4] Replace Any with object for unused kwargs Use object rather than Any for the discarded **_ keyword-argument parameters. object is the correct type for values that are accepted but never used, and it satisfies Ruff's ANN401 rule, so the per-file ANN401 ignores can be removed. The two constructors that forward **kwargs on to the geoip2 parent constructors keep Any (with an inline noqa), since object is not assignable to the parent's typed parameters. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 10 +++++----- pyproject.toml | 3 +-- src/minfraud/models.py | 36 ++++++++++++++++++------------------ 3 files changed, 24 insertions(+), 25 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6d04a7b..26d9927 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -105,13 +105,13 @@ def __init__( field_name: str | None = None, is_flag: bool | None = None, # ... other keyword-only parameters - **_: Any, # ignore unknown keys + **_: object, # ignore unknown keys ) -> None: ``` Key points: - Use `*` to enforce keyword-only arguments -- Accept `**_: Any` to ignore unknown keys from the API (forward compatibility) +- Accept `**_: object` to ignore unknown keys from the API (forward compatibility) - Use `| None = None` for optional parameters - Boolean fields can be `None` if not provided by API @@ -252,7 +252,7 @@ def test_email(self) -> None: *, new_field: str | None = None, # ... other params - **_: Any, + **_: object, ) -> None: ``` @@ -281,7 +281,7 @@ When creating a new model class: 2. **Follow the constructor pattern** from existing models 3. **Use type hints** for all attributes 4. **Use keyword-only arguments** with `*` separator -5. **Accept `**_: Any`** to ignore unknown API keys +5. **Accept `**_: object`** to ignore unknown API keys 6. **Provide comprehensive docstrings** for all attributes 7. **Add corresponding tests** with full coverage @@ -376,7 +376,7 @@ Adding required parameters breaks existing code. - Always add new parameters as optional with defaults - Use keyword-only arguments (after `*`) - Never add required positional parameters to existing constructors -- Use `**_: Any` to silently accept unknown parameters from API +- Use `**_: object` to silently accept unknown parameters from API ## Code Style Requirements diff --git a/pyproject.toml b/pyproject.toml index 0bd345b..195d0c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -92,8 +92,7 @@ ignorelist = ["id"] [tool.ruff.lint.per-file-ignores] "docs/*" = ["ALL"] -"src/minfraud/models.py" = ["ANN401", "PLR0913"] -"src/minfraud/webservice.py" = ["ANN401"] +"src/minfraud/models.py" = ["PLR0913"] "tests/*" = ["ANN201", "D"] [tool.tox] diff --git a/src/minfraud/models.py b/src/minfraud/models.py index d86e2ba..8cfc45d 100644 --- a/src/minfraud/models.py +++ b/src/minfraud/models.py @@ -100,7 +100,7 @@ class GeoIP2Location(geoip2.records.Location): `RFC 3339 `_. For instance, the local time in Boston might be returned as 2015-04-27T19:17:24-04:00.""" - def __init__(self, *args: Any, **kwargs: Any) -> None: + def __init__(self, *args: Any, **kwargs: Any) -> None: # noqa: ANN401 """Initialize a GeoIP2Location instance.""" self.local_time = kwargs.get("local_time") super().__init__(*args, **kwargs) @@ -135,7 +135,7 @@ def __init__( location: dict[str, Any] | None = None, risk: float | None = None, risk_reasons: list[dict[str, Any]] | None = None, - **kwargs: Any, + **kwargs: Any, # noqa: ANN401 ) -> None: """Initialize an IPAddress instance.""" # For raw attribute @@ -161,7 +161,7 @@ class ScoreIPAddress(_Serializable): """This field contains the risk associated with the IP address. The value ranges from 0.01 to 99. A higher score indicates a higher risk.""" - def __init__(self, *, risk: float | None = None, **_: Any) -> None: + def __init__(self, *, risk: float | None = None, **_: object) -> None: """Initialize a ScoreIPAddress instance.""" self.risk = risk @@ -197,7 +197,7 @@ def __init__( matches_provided_name: bool | None = None, phone_number: str | None = None, matches_provided_phone_number: bool | None = None, - **_: Any, + **_: object, ) -> None: """Initialize an Issuer instance.""" self.name = name @@ -239,7 +239,7 @@ def __init__( id: str | None = None, last_seen: str | None = None, local_time: str | None = None, - **_: Any, + **_: object, ) -> None: """Initialize a Device instance.""" self.confidence = confidence @@ -277,7 +277,7 @@ def __init__( action: str | None = None, reason: str | None = None, rule_label: str | None = None, - **_: Any, + **_: object, ) -> None: """Initialize a Disposition instance.""" self.action = action @@ -310,7 +310,7 @@ def __init__( status: str | None = None, last_visited_on: str | None = None, has_redirect: bool | None = None, - **_: Any, + **_: object, ) -> None: """Initialize an EmailDomainVisit instance.""" self.status = status @@ -354,7 +354,7 @@ def __init__( risk: float | None = None, volume: float | None = None, visit: dict[str, Any] | None = None, - **_: Any, + **_: object, ) -> None: """Initialize an EmailDomain instance.""" self.first_seen = first_seen @@ -498,7 +498,7 @@ def __init__( longitude: float | None = None, distance_to_ip_location: int | None = None, is_in_ip_country: bool | None = None, - **_: Any, + **_: object, ) -> None: """Initialize a BillingAddress instance.""" self.is_postal_in_city = is_postal_in_city @@ -553,7 +553,7 @@ def __init__( is_in_ip_country: bool | None = None, is_high_risk: bool | None = None, distance_to_billing_address: int | None = None, - **_: Any, + **_: object, ) -> None: """Initialize a ShippingAddress instance.""" self.is_postal_in_city = is_postal_in_city @@ -604,7 +604,7 @@ def __init__( matches_postal: bool | None = None, network_operator: str | None = None, number_type: str | None = None, - **_: Any, + **_: object, ) -> None: """Initialize a Phone instance.""" self.country = country @@ -639,7 +639,7 @@ def __init__( code: str | None = None, warning: str | None = None, input_pointer: str | None = None, - **_: Any, + **_: object, ) -> None: """Initialize a ServiceWarning instance.""" self.code = code @@ -783,7 +783,7 @@ def __init__( shipping_address: float | None = None, shipping_address_distance_to_ip_location: float | None = None, time_of_day: float | None = None, - **_: Any, + **_: object, ) -> None: """Initialize a Subscores instance.""" self.avs_result = avs_result @@ -897,7 +897,7 @@ def __init__( *, code: str | None = None, reason: str | None = None, - **_: Any, + **_: object, ) -> None: """Initialize a Reason instance.""" self.code = code @@ -922,7 +922,7 @@ def __init__( *, multiplier: float, reasons: list[dict[str, Any]] | None = None, - **_: Any, + **_: object, ) -> None: """Initialize a RiskScoreReason instance.""" self.multiplier = multiplier @@ -1030,7 +1030,7 @@ def __init__( subscores: dict[str, Any] | None = None, warnings: list[dict[str, Any]] | None = None, risk_score_reasons: list[dict[str, Any]] | None = None, - **_: Any, + **_: object, ) -> None: """Initialize a Factors instance.""" self.billing_address = BillingAddress(**(billing_address or {})) @@ -1136,7 +1136,7 @@ def __init__( shipping_address: dict[str, Any] | None = None, shipping_phone: dict[str, Any] | None = None, warnings: list[dict[str, Any]] | None = None, - **_: Any, + **_: object, ) -> None: """Initialize an Insights instance.""" self.billing_address = BillingAddress(**(billing_address or {})) @@ -1201,7 +1201,7 @@ def __init__( queries_remaining: int, risk_score: float, warnings: list[dict[str, Any]] | None = None, - **_: Any, + **_: object, ) -> None: """Initialize a Score instance.""" self.disposition = Disposition(**(disposition or {})) From 9088e4e7d96821a3204e91c9b572610a50eafd28 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Thu, 16 Jul 2026 22:53:40 +0000 Subject: [PATCH 4/4] Update mise tools Bump lychee 0.23.0 -> 0.24.2 via mise. Lychee 0.24 changed include_fragments from a boolean to a string enum; set it to "full" to match house convention (GeoIP2-java/ruby). Lychee 0.24 also tightened URL parsing and now errors on the ``http://username:password@host:port`` placeholder in the proxy docstrings instead of silently ignoring it. Since lychee applies its exclude patterns after URL parsing, an exclude regex can't suppress this error, so switch to the conventional angle-bracket placeholder form instead. Co-Authored-By: Claude Opus 4.8 --- lychee.toml | 2 +- mise.lock | 32 +++++++++++++++++++------------- src/minfraud/webservice.py | 4 ++-- 3 files changed, 22 insertions(+), 16 deletions(-) diff --git a/lychee.toml b/lychee.toml index efa1bfe..5d70281 100644 --- a/lychee.toml +++ b/lychee.toml @@ -5,7 +5,7 @@ # lychee './**/*.md' './**/*.rst' './src/minfraud/**/*.py' './pyproject.toml' # Include URL fragments in checks -include_fragments = true +include_fragments = "full" # Don't allow any redirects, so links that have moved are surfaced and can be # updated to their canonical destination. diff --git a/mise.lock b/mise.lock index 156e912..09ffa7e 100644 --- a/mise.lock +++ b/mise.lock @@ -1,29 +1,35 @@ # @generated - this file is auto-generated by `mise lock` https://mise.jdx.dev/dev-tools/mise-lock.html [[tools.lychee]] -version = "0.23.0" +version = "0.24.2" backend = "aqua:lycheeverse/lychee" [tools.lychee."platforms.linux-arm64"] -checksum = "sha256:97eb93b02a7d78a752fc33e5b0983439ccaadbf3db952b68a0a4401acd92e6e0" -url = "https://github.com/lycheeverse/lychee/releases/download/lychee-v0.23.0/lychee-aarch64-unknown-linux-gnu.tar.gz" +checksum = "sha256:5d0b0e3aeab240f41920c633a6eaf97599be6eedda034b36e858ede7dba5e535" +url = "https://github.com/lycheeverse/lychee/releases/download/lychee-v0.24.2/lychee-aarch64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/lycheeverse/lychee/releases/assets/409958602" [tools.lychee."platforms.linux-arm64-musl"] -checksum = "sha256:97eb93b02a7d78a752fc33e5b0983439ccaadbf3db952b68a0a4401acd92e6e0" -url = "https://github.com/lycheeverse/lychee/releases/download/lychee-v0.23.0/lychee-aarch64-unknown-linux-gnu.tar.gz" +checksum = "sha256:5d0b0e3aeab240f41920c633a6eaf97599be6eedda034b36e858ede7dba5e535" +url = "https://github.com/lycheeverse/lychee/releases/download/lychee-v0.24.2/lychee-aarch64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/lycheeverse/lychee/releases/assets/409958602" [tools.lychee."platforms.linux-x64"] -checksum = "sha256:5538440d2c69a45a0a09983271e5dee0c2fe7137d8035d25b2632e10a66a090a" -url = "https://github.com/lycheeverse/lychee/releases/download/lychee-v0.23.0/lychee-x86_64-unknown-linux-musl.tar.gz" +checksum = "sha256:73657a111819a30c47c08352896796f23d64e4eb2b3ed39b6d32149241566fc5" +url = "https://github.com/lycheeverse/lychee/releases/download/lychee-v0.24.2/lychee-x86_64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/lycheeverse/lychee/releases/assets/409959271" [tools.lychee."platforms.linux-x64-musl"] -checksum = "sha256:5538440d2c69a45a0a09983271e5dee0c2fe7137d8035d25b2632e10a66a090a" -url = "https://github.com/lycheeverse/lychee/releases/download/lychee-v0.23.0/lychee-x86_64-unknown-linux-musl.tar.gz" +checksum = "sha256:73657a111819a30c47c08352896796f23d64e4eb2b3ed39b6d32149241566fc5" +url = "https://github.com/lycheeverse/lychee/releases/download/lychee-v0.24.2/lychee-x86_64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/lycheeverse/lychee/releases/assets/409959271" [tools.lychee."platforms.macos-arm64"] -checksum = "sha256:4c8034900e11083b68ac6f6582c377ff1f704e268991999e09d717973e493e7f" -url = "https://github.com/lycheeverse/lychee/releases/download/lychee-v0.23.0/lychee-arm64-macos.dmg" +checksum = "sha256:c9d3740ea2d891854d37116c9fba840f37b6e7c89d330e7db84ac333631c4977" +url = "https://github.com/lycheeverse/lychee/releases/download/lychee-v0.24.2/lychee-aarch64-apple-darwin.tar.gz" +url_api = "https://api.github.com/repos/lycheeverse/lychee/releases/assets/409957951" [tools.lychee."platforms.windows-x64"] -checksum = "sha256:0fda7ff0a60c0250939fc25361c2d4e6e7853c31c996733fdd5a1dd760bcb824" -url = "https://github.com/lycheeverse/lychee/releases/download/lychee-v0.23.0/lychee-x86_64-windows.exe" +checksum = "sha256:32975d1493ee1a975d6bb41e4fb56fe419cb442ded628bb772ba2e614acfacad" +url = "https://github.com/lycheeverse/lychee/releases/download/lychee-v0.24.2/lychee-x86_64-pc-windows-msvc.zip" +url_api = "https://api.github.com/repos/lycheeverse/lychee/releases/assets/409959491" diff --git a/src/minfraud/webservice.py b/src/minfraud/webservice.py index 85abd02..0a8f02a 100644 --- a/src/minfraud/webservice.py +++ b/src/minfraud/webservice.py @@ -256,7 +256,7 @@ def __init__( # noqa: PLR0913 :type timeout: float :param proxy: The URL of an HTTP proxy to use. It may optionally include a basic auth username and password, e.g., - ``http://username:password@host:port``. + ``http://:@:``. :return: Client object :rtype: Client """ @@ -510,7 +510,7 @@ def __init__( # noqa: PLR0913 60. :param proxy: The URL of an HTTP proxy to use. It may optionally include a basic auth username and password, e.g., - ``http://username:password@host:port``. + ``http://:@:``. :type timeout: float :return: Client object :rtype: Client