From ba2274031f4fc58cd1a8cb0f6642ab4ac9566ce4 Mon Sep 17 00:00:00 2001 From: Andrey Lebedev Date: Tue, 7 Oct 2025 19:40:01 +0100 Subject: [PATCH 1/4] Add docstrings to IP validation helper functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add documentation to is_ipv4_hostname and is_ipv6_hostname functions to clarify their purpose and CIDR notation support. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- httpx/_utils.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/httpx/_utils.py b/httpx/_utils.py index 7fe827da4d..1093872dce 100644 --- a/httpx/_utils.py +++ b/httpx/_utils.py @@ -227,6 +227,10 @@ def __eq__(self, other: typing.Any) -> bool: def is_ipv4_hostname(hostname: str) -> bool: + """ + Check if the given hostname is a valid IPv4 address. + Supports CIDR notation by checking only the address part. + """ try: ipaddress.IPv4Address(hostname.split("/")[0]) except Exception: @@ -235,6 +239,10 @@ def is_ipv4_hostname(hostname: str) -> bool: def is_ipv6_hostname(hostname: str) -> bool: + """ + Check if the given hostname is a valid IPv6 address. + Supports CIDR notation by checking only the address part. + """ try: ipaddress.IPv6Address(hostname.split("/")[0]) except Exception: From 5bf0ece7d438087b14504661f1fef63a7607ca88 Mon Sep 17 00:00:00 2001 From: Andrey Lebedev Date: Tue, 7 Oct 2025 23:05:27 +0100 Subject: [PATCH 2/4] Add is_ip_address function to validate IP addresses Introduced a new helper function, is_ip_address, to check if a given hostname is a valid IP address (either IPv4 or IPv6), including support for CIDR notation. This enhances the utility of the IP validation module. --- httpx/_utils.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/httpx/_utils.py b/httpx/_utils.py index 1093872dce..20563a5ec1 100644 --- a/httpx/_utils.py +++ b/httpx/_utils.py @@ -248,3 +248,11 @@ def is_ipv6_hostname(hostname: str) -> bool: except Exception: return False return True + + +def is_ip_address(hostname: str) -> bool: + """ + Check if the given hostname is a valid IP address (either IPv4 or IPv6). + Supports CIDR notation by checking only the address part. + """ + return is_ipv4_hostname(hostname) or is_ipv6_hostname(hostname) From 2855333afe0876c729979c50dade1427e212dedc Mon Sep 17 00:00:00 2001 From: Andrey Lebedev Date: Wed, 8 Oct 2025 00:10:28 +0100 Subject: [PATCH 3/4] Add normalize_header_key function for consistent HTTP header key handling Introduced a new utility function, normalize_header_key, to standardize HTTP header keys for comparison and storage. This function normalizes keys to lowercase by default, with an option to preserve the original case for compatibility with HTTP/1.1. Updated __all__ to include the new function. --- httpx/__init__.py | 2 ++ httpx/_utils.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/httpx/__init__.py b/httpx/__init__.py index e9addde071..bf03d9fae0 100644 --- a/httpx/__init__.py +++ b/httpx/__init__.py @@ -10,6 +10,7 @@ from ._transports import * from ._types import * from ._urls import * +from ._utils import normalize_header_key try: from ._main import main @@ -63,6 +64,7 @@ def main() -> None: # type: ignore "MockTransport", "NetRCAuth", "NetworkError", + "normalize_header_key", "options", "patch", "PoolTimeout", diff --git a/httpx/_utils.py b/httpx/_utils.py index 20563a5ec1..0ea77218b0 100644 --- a/httpx/_utils.py +++ b/httpx/_utils.py @@ -256,3 +256,36 @@ def is_ip_address(hostname: str) -> bool: Supports CIDR notation by checking only the address part. """ return is_ipv4_hostname(hostname) or is_ipv6_hostname(hostname) + + +def normalize_header_key(key: str, *, preserve_case: bool = False) -> str: + """ + Normalize HTTP header keys for consistent comparison and storage. + + By default, converts header keys to lowercase following HTTP/2 conventions. + Can optionally preserve the original case for HTTP/1.1 compatibility. + + Args: + key: The header key to normalize + preserve_case: If True, preserve the original case. If False (default), + convert to lowercase. + + Returns: + The normalized header key as a string + + Examples: + >>> normalize_header_key("Content-Type") + 'content-type' + >>> normalize_header_key("Content-Type", preserve_case=True) + 'Content-Type' + >>> normalize_header_key("X-Custom-Header") + 'x-custom-header' + + Note: + This function is useful when working with HTTP headers across different + protocol versions. HTTP/2 requires lowercase header names, while HTTP/1.1 + traditionally uses title-case headers (though comparison is case-insensitive). + """ + if preserve_case: + return key.strip() + return key.strip().lower() From 74725ae218f543a1d5a51eae8a7ea2443f889ad0 Mon Sep 17 00:00:00 2001 From: Andrey Lebedev Date: Wed, 8 Oct 2025 12:46:38 +0100 Subject: [PATCH 4/4] Add json_or_text and save_to_file methods to Response class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit adds two new convenience methods to the Response class: 1. json_or_text(): Attempts to parse the response as JSON, automatically falling back to text content if JSON parsing fails. This provides a convenient way to handle responses that may be in either format without explicit exception handling. 2. save_to_file(path, mode='auto'): Saves response content to a file with intelligent handling of different content types. Supports multiple modes: - 'auto': Automatically detects format based on Content-Type header - 'binary': Saves raw bytes (images, PDFs, etc.) - 'text': Saves as text with encoding support - 'json': Parses and saves formatted JSON with indentation These methods enhance the Response API by providing commonly needed functionality that previously required manual implementation. The save_to_file method is particularly useful for downloading and storing various types of content from web APIs and servers. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- httpx/_models.py | 121 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) diff --git a/httpx/_models.py b/httpx/_models.py index 2cc86321a4..bee3be5db8 100644 --- a/httpx/_models.py +++ b/httpx/_models.py @@ -831,6 +831,127 @@ def raise_for_status(self) -> Response: def json(self, **kwargs: typing.Any) -> typing.Any: return jsonlib.loads(self.content, **kwargs) + def json_or_text(self, **kwargs: typing.Any) -> typing.Any: + """ + Attempt to parse response as JSON, falling back to text if parsing fails. + + This is a convenience method that combines the behavior of `json()` and `text`. + It first attempts to parse the response body as JSON. If that fails due to + invalid JSON or an empty response, it returns the text content instead. + + Args: + **kwargs: Additional arguments passed to json.loads() when parsing JSON. + + Returns: + The parsed JSON data if successful, otherwise the text content of the response. + + Example: + >>> response = httpx.get("https://api.example.com/data") + >>> data = response.json_or_text() # Returns JSON dict or text string + >>> # Useful for APIs that may return either JSON or plain text + >>> response = httpx.get("https://example.com/endpoint") + >>> content = response.json_or_text() # Handles both content types gracefully + + Note: + This method is particularly useful when: + - Working with APIs that may return different content types + - You want automatic fallback behavior without exception handling + - Processing responses where the content type is uncertain + """ + try: + return jsonlib.loads(self.content, **kwargs) + except (jsonlib.JSONDecodeError, UnicodeDecodeError, ValueError): + return self.text + + def save_to_file( + self, + path: str, + mode: str = "auto", + *, + encoding: str | None = None, + indent: int | None = None, + ) -> None: + """ + Save the response content to a file. + + This method provides a convenient way to save response content to disk with + automatic handling of different content types based on the mode parameter. + + Args: + path: The file path where the content should be saved. + mode: The save mode, one of: + - 'auto': Automatically detect based on Content-Type header + (saves as JSON if content-type is application/json, binary otherwise) + - 'binary': Save raw bytes (use for images, PDFs, etc.) + - 'text': Save as text using the response's encoding + - 'json': Parse as JSON and save with formatting + encoding: Text encoding to use when mode is 'text'. Defaults to the + response's detected encoding. Only applicable for 'text' mode. + indent: Number of spaces for JSON indentation when mode is 'json'. + Defaults to 2. Only applicable for 'json' mode. + + Raises: + ValueError: If an invalid mode is specified. + OSError: If there are issues writing to the file. + JSONDecodeError: If mode is 'json' but content is not valid JSON. + + Example: + >>> # Save binary content (image, PDF, etc.) + >>> response = httpx.get("https://example.com/image.png") + >>> response.save_to_file("image.png", mode="binary") + >>> + >>> # Save JSON with formatting + >>> response = httpx.get("https://api.example.com/data") + >>> response.save_to_file("data.json", mode="json", indent=4) + >>> + >>> # Auto-detect based on content type + >>> response = httpx.get("https://example.com/file") + >>> response.save_to_file("output.txt", mode="auto") + >>> + >>> # Save as text with specific encoding + >>> response = httpx.get("https://example.com/page") + >>> response.save_to_file("page.html", mode="text", encoding="utf-8") + + Note: + - The 'auto' mode checks the Content-Type header to determine format + - For 'binary' and 'json' modes, the file is written in binary mode + - For 'text' mode, the file is written in text mode with specified encoding + - Parent directories are not created automatically; they must exist + """ + import pathlib + + file_path = pathlib.Path(path) + + if mode not in ("auto", "binary", "text", "json"): + raise ValueError( + f"Invalid mode '{mode}'. Must be one of: 'auto', 'binary', 'text', 'json'" + ) + + # Determine actual mode if auto + if mode == "auto": + content_type = self.headers.get("content-type", "").lower() + if "application/json" in content_type: + mode = "json" + elif any( + t in content_type + for t in ["text/", "application/xml", "application/javascript"] + ): + mode = "text" + else: + mode = "binary" + + # Save based on determined mode + if mode == "binary": + file_path.write_bytes(self.content) + elif mode == "text": + text_encoding = encoding or self.encoding or "utf-8" + file_path.write_text(self.text, encoding=text_encoding) + elif mode == "json": + json_data = self.json() + json_indent = 2 if indent is None else indent + formatted_json = jsonlib.dumps(json_data, indent=json_indent, ensure_ascii=False) + file_path.write_text(formatted_json, encoding="utf-8") + @property def cookies(self) -> Cookies: if not hasattr(self, "_cookies"):