chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
"""
|
||||
Authentication handlers.
|
||||
Auth objects are callables that modify a request before it is sent.
|
||||
DigestAuth is the most interesting: it participates in a full request/response cycle,
|
||||
reading the 401 response to build the challenge before re-sending.
|
||||
"""
|
||||
import hashlib
|
||||
import time
|
||||
from models import Request, Response
|
||||
|
||||
|
||||
class Auth:
|
||||
"""Base class for all authentication handlers."""
|
||||
|
||||
def auth_flow(self, request: Request):
|
||||
"""Modify the request. May yield to inspect the response."""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class BasicAuth(Auth):
|
||||
"""HTTP Basic Authentication."""
|
||||
|
||||
def __init__(self, username: str, password: str):
|
||||
self.username = username
|
||||
self.password = password
|
||||
|
||||
def auth_flow(self, request: Request):
|
||||
import base64
|
||||
credentials = f"{self.username}:{self.password}".encode()
|
||||
encoded = base64.b64encode(credentials).decode()
|
||||
request.headers["Authorization"] = f"Basic {encoded}"
|
||||
yield request
|
||||
|
||||
|
||||
class BearerAuth(Auth):
|
||||
"""Bearer token authentication."""
|
||||
|
||||
def __init__(self, token: str):
|
||||
self.token = token
|
||||
|
||||
def auth_flow(self, request: Request):
|
||||
request.headers["Authorization"] = f"Bearer {self.token}"
|
||||
yield request
|
||||
|
||||
|
||||
class DigestAuth(Auth):
|
||||
"""
|
||||
HTTP Digest Authentication.
|
||||
Requires a full request/response cycle: sends the initial request,
|
||||
reads the 401 WWW-Authenticate header, then re-sends with credentials.
|
||||
This is the only auth handler that reads from Response.
|
||||
"""
|
||||
|
||||
def __init__(self, username: str, password: str):
|
||||
self.username = username
|
||||
self.password = password
|
||||
self._nonce_count = 0
|
||||
|
||||
def auth_flow(self, request: Request):
|
||||
yield request # first attempt, no credentials
|
||||
|
||||
# This handler must inspect the Response to continue
|
||||
response = yield
|
||||
|
||||
if response.status_code == 401:
|
||||
challenge = self._parse_challenge(response)
|
||||
credentials = self._build_credentials(request, challenge)
|
||||
request.headers["Authorization"] = credentials
|
||||
yield request
|
||||
|
||||
def _parse_challenge(self, response: Response) -> dict:
|
||||
"""Extract digest parameters from the WWW-Authenticate header."""
|
||||
header = response.headers.get("www-authenticate", "")
|
||||
params = {}
|
||||
for part in header.replace("Digest ", "").split(","):
|
||||
if "=" in part:
|
||||
key, _, value = part.strip().partition("=")
|
||||
params[key.strip()] = value.strip().strip('"')
|
||||
return params
|
||||
|
||||
def _build_credentials(self, request: Request, challenge: dict) -> str:
|
||||
"""Compute the Authorization header value for a digest challenge."""
|
||||
self._nonce_count += 1
|
||||
nc = f"{self._nonce_count:08x}"
|
||||
cnonce = hashlib.md5(str(time.time()).encode()).hexdigest()[:8]
|
||||
realm = challenge.get("realm", "")
|
||||
nonce = challenge.get("nonce", "")
|
||||
|
||||
ha1 = hashlib.md5(f"{self.username}:{realm}:{self.password}".encode()).hexdigest()
|
||||
ha2 = hashlib.md5(f"{request.method}:{request.url.path}".encode()).hexdigest()
|
||||
response_hash = hashlib.md5(f"{ha1}:{nonce}:{nc}:{cnonce}:auth:{ha2}".encode()).hexdigest()
|
||||
|
||||
return (
|
||||
f'Digest username="{self.username}", realm="{realm}", '
|
||||
f'nonce="{nonce}", uri="{request.url.path}", '
|
||||
f'nc={nc}, cnonce="{cnonce}", response="{response_hash}"'
|
||||
)
|
||||
|
||||
|
||||
class NetRCAuth(Auth):
|
||||
"""Load credentials from ~/.netrc based on the request host."""
|
||||
|
||||
def auth_flow(self, request: Request):
|
||||
import netrc
|
||||
try:
|
||||
credentials = netrc.netrc().authenticators(request.url.host)
|
||||
if credentials:
|
||||
username, _, password = credentials
|
||||
basic = BasicAuth(username, password)
|
||||
yield from basic.auth_flow(request)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
yield request
|
||||
@@ -0,0 +1,161 @@
|
||||
"""
|
||||
The main Client and AsyncClient classes.
|
||||
BaseClient holds all shared logic. Client and AsyncClient extend it for sync/async.
|
||||
This is the integration hub of the library - it imports from every other module.
|
||||
"""
|
||||
from models import Request, Response, URL, Headers, Cookies
|
||||
from auth import Auth, BasicAuth
|
||||
from transport import BaseTransport, HTTPTransport, AsyncHTTPTransport
|
||||
from exceptions import TooManyRedirects, InvalidURL
|
||||
from utils import build_url_with_params, obfuscate_sensitive_headers
|
||||
|
||||
|
||||
DEFAULT_MAX_REDIRECTS = 20
|
||||
|
||||
|
||||
class Timeout:
|
||||
def __init__(self, timeout=5.0, *, connect=None, read=None, write=None, pool=None):
|
||||
self.connect = connect or timeout
|
||||
self.read = read or timeout
|
||||
self.write = write or timeout
|
||||
self.pool = pool or timeout
|
||||
|
||||
|
||||
class Limits:
|
||||
def __init__(self, max_connections=100, max_keepalive_connections=20, keepalive_expiry=5.0):
|
||||
self.max_connections = max_connections
|
||||
self.max_keepalive_connections = max_keepalive_connections
|
||||
self.keepalive_expiry = keepalive_expiry
|
||||
|
||||
|
||||
class BaseClient:
|
||||
"""
|
||||
Shared implementation for Client and AsyncClient.
|
||||
Handles auth, redirects, cookies, and header defaults.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
auth=None,
|
||||
headers=None,
|
||||
cookies=None,
|
||||
timeout=Timeout(),
|
||||
max_redirects=DEFAULT_MAX_REDIRECTS,
|
||||
base_url="",
|
||||
):
|
||||
self._auth = auth
|
||||
self._headers = Headers(headers or {})
|
||||
self._cookies = Cookies(cookies or {})
|
||||
self._timeout = timeout
|
||||
self._max_redirects = max_redirects
|
||||
self._base_url = URL(base_url) if base_url else None
|
||||
|
||||
def _build_request(self, method: str, url: str, **kwargs) -> Request:
|
||||
if self._base_url:
|
||||
url = self._base_url.raw.rstrip("/") + "/" + url.lstrip("/")
|
||||
if kwargs.get("params"):
|
||||
url = build_url_with_params(url, kwargs.pop("params"))
|
||||
headers = Headers(kwargs.get("headers", {}))
|
||||
for k, v in self._headers.items():
|
||||
if k not in headers:
|
||||
headers[k] = v
|
||||
return Request(method, url, headers=headers, content=kwargs.get("content"), cookies=self._cookies)
|
||||
|
||||
def _merge_cookies(self, response: Response) -> None:
|
||||
for name, value in response.cookies.items():
|
||||
self._cookies.set(name, value)
|
||||
|
||||
|
||||
class Client(BaseClient):
|
||||
"""Synchronous HTTP client."""
|
||||
|
||||
def __init__(self, *, transport: BaseTransport = None, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._transport = transport or HTTPTransport()
|
||||
|
||||
def request(self, method: str, url: str, **kwargs) -> Response:
|
||||
request = self._build_request(method, url, **kwargs)
|
||||
auth = kwargs.get("auth") or self._auth
|
||||
if auth:
|
||||
flow = auth.auth_flow(request)
|
||||
request = next(flow)
|
||||
response = self._transport.handle_request(request)
|
||||
self._merge_cookies(response)
|
||||
if auth:
|
||||
try:
|
||||
flow.send(response)
|
||||
except StopIteration:
|
||||
pass
|
||||
return response
|
||||
|
||||
def get(self, url: str, **kwargs) -> Response:
|
||||
return self.request("GET", url, **kwargs)
|
||||
|
||||
def post(self, url: str, **kwargs) -> Response:
|
||||
return self.request("POST", url, **kwargs)
|
||||
|
||||
def put(self, url: str, **kwargs) -> Response:
|
||||
return self.request("PUT", url, **kwargs)
|
||||
|
||||
def patch(self, url: str, **kwargs) -> Response:
|
||||
return self.request("PATCH", url, **kwargs)
|
||||
|
||||
def delete(self, url: str, **kwargs) -> Response:
|
||||
return self.request("DELETE", url, **kwargs)
|
||||
|
||||
def head(self, url: str, **kwargs) -> Response:
|
||||
return self.request("HEAD", url, **kwargs)
|
||||
|
||||
def send(self, request: Request) -> Response:
|
||||
return self._transport.handle_request(request)
|
||||
|
||||
def close(self) -> None:
|
||||
self._transport.close()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
self.close()
|
||||
|
||||
|
||||
class AsyncClient(BaseClient):
|
||||
"""Asynchronous HTTP client."""
|
||||
|
||||
def __init__(self, *, transport=None, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._transport = transport or AsyncHTTPTransport()
|
||||
|
||||
async def request(self, method: str, url: str, **kwargs) -> Response:
|
||||
request = self._build_request(method, url, **kwargs)
|
||||
response = await self._transport.handle_async_request(request)
|
||||
self._merge_cookies(response)
|
||||
return response
|
||||
|
||||
async def get(self, url: str, **kwargs) -> Response:
|
||||
return await self.request("GET", url, **kwargs)
|
||||
|
||||
async def post(self, url: str, **kwargs) -> Response:
|
||||
return await self.request("POST", url, **kwargs)
|
||||
|
||||
async def put(self, url: str, **kwargs) -> Response:
|
||||
return await self.request("PUT", url, **kwargs)
|
||||
|
||||
async def patch(self, url: str, **kwargs) -> Response:
|
||||
return await self.request("PATCH", url, **kwargs)
|
||||
|
||||
async def delete(self, url: str, **kwargs) -> Response:
|
||||
return await self.request("DELETE", url, **kwargs)
|
||||
|
||||
async def send(self, request: Request) -> Response:
|
||||
return await self._transport.handle_async_request(request)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
await self._transport.aclose()
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args):
|
||||
await self.aclose()
|
||||
@@ -0,0 +1,90 @@
|
||||
"""
|
||||
httpx-like exception hierarchy.
|
||||
All exceptions inherit from HTTPError at the top.
|
||||
"""
|
||||
|
||||
|
||||
class HTTPError(Exception):
|
||||
"""Base class for all httpx exceptions."""
|
||||
def __init__(self, message, *, request=None):
|
||||
self.request = request
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class RequestError(HTTPError):
|
||||
"""An error occurred while issuing a request."""
|
||||
|
||||
|
||||
class TransportError(RequestError):
|
||||
"""An error occurred at the transport layer."""
|
||||
|
||||
|
||||
class TimeoutException(TransportError):
|
||||
"""A timeout occurred."""
|
||||
|
||||
|
||||
class ConnectTimeout(TimeoutException):
|
||||
"""Timed out while connecting to the host."""
|
||||
|
||||
|
||||
class ReadTimeout(TimeoutException):
|
||||
"""Timed out while receiving data from the host."""
|
||||
|
||||
|
||||
class WriteTimeout(TimeoutException):
|
||||
"""Timed out while sending data to the host."""
|
||||
|
||||
|
||||
class PoolTimeout(TimeoutException):
|
||||
"""Timed out waiting to acquire a connection from the pool."""
|
||||
|
||||
|
||||
class NetworkError(TransportError):
|
||||
"""A network error occurred."""
|
||||
|
||||
|
||||
class ConnectError(NetworkError):
|
||||
"""Failed to establish a connection."""
|
||||
|
||||
|
||||
class ReadError(NetworkError):
|
||||
"""Failed to receive data from the network."""
|
||||
|
||||
|
||||
class WriteError(NetworkError):
|
||||
"""Failed to send data through the network."""
|
||||
|
||||
|
||||
class CloseError(NetworkError):
|
||||
"""Failed to close a connection."""
|
||||
|
||||
|
||||
class ProxyError(TransportError):
|
||||
"""An error occurred while establishing a proxy connection."""
|
||||
|
||||
|
||||
class ProtocolError(TransportError):
|
||||
"""A protocol was violated."""
|
||||
|
||||
|
||||
class DecodingError(RequestError):
|
||||
"""Decoding of the response failed."""
|
||||
|
||||
|
||||
class TooManyRedirects(RequestError):
|
||||
"""Too many redirects."""
|
||||
|
||||
|
||||
class HTTPStatusError(HTTPError):
|
||||
"""A 4xx or 5xx response was received."""
|
||||
def __init__(self, message, *, request, response):
|
||||
self.response = response
|
||||
super().__init__(message, request=request)
|
||||
|
||||
|
||||
class InvalidURL(Exception):
|
||||
"""URL is improperly formed or cannot be parsed."""
|
||||
|
||||
|
||||
class CookieConflict(Exception):
|
||||
"""Attempted to look up a cookie by name but multiple cookies exist."""
|
||||
@@ -0,0 +1,120 @@
|
||||
"""
|
||||
Core data models: URL, Headers, Cookies, Request, Response.
|
||||
These are the central data types that everything else in the library references.
|
||||
"""
|
||||
import json as _json
|
||||
from exceptions import HTTPStatusError
|
||||
|
||||
|
||||
class URL:
|
||||
def __init__(self, url: str):
|
||||
self.raw = url
|
||||
self.scheme, _, rest = url.partition("://")
|
||||
self.host, _, self.path = rest.partition("/")
|
||||
self.path = "/" + self.path
|
||||
|
||||
def copy_with(self, **kwargs) -> "URL":
|
||||
return URL(kwargs.get("url", self.raw))
|
||||
|
||||
def __str__(self):
|
||||
return self.raw
|
||||
|
||||
def __repr__(self):
|
||||
return f"URL({self.raw!r})"
|
||||
|
||||
|
||||
class Headers:
|
||||
def __init__(self, headers=None):
|
||||
self._store = {}
|
||||
for k, v in (headers or {}).items():
|
||||
self._store[k.lower()] = v
|
||||
|
||||
def get(self, key: str, default=None):
|
||||
return self._store.get(key.lower(), default)
|
||||
|
||||
def items(self):
|
||||
return self._store.items()
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
self._store[key.lower()] = value
|
||||
|
||||
def __getitem__(self, key):
|
||||
return self._store[key.lower()]
|
||||
|
||||
def __contains__(self, key):
|
||||
return key.lower() in self._store
|
||||
|
||||
|
||||
class Cookies:
|
||||
def __init__(self, cookies=None):
|
||||
self._jar = dict(cookies or {})
|
||||
|
||||
def set(self, name: str, value: str, domain: str = "") -> None:
|
||||
self._jar[name] = value
|
||||
|
||||
def get(self, name: str, default=None):
|
||||
return self._jar.get(name, default)
|
||||
|
||||
def delete(self, name: str) -> None:
|
||||
self._jar.pop(name, None)
|
||||
|
||||
def clear(self) -> None:
|
||||
self._jar.clear()
|
||||
|
||||
def items(self):
|
||||
return self._jar.items()
|
||||
|
||||
|
||||
class Request:
|
||||
def __init__(self, method: str, url, *, headers=None, content=None, cookies=None):
|
||||
self.method = method.upper()
|
||||
self.url = URL(url) if isinstance(url, str) else url
|
||||
self.headers = Headers(headers)
|
||||
self.content = content or b""
|
||||
self.cookies = Cookies(cookies)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Request [{self.method}]>"
|
||||
|
||||
|
||||
class Response:
|
||||
def __init__(self, status_code: int, *, headers=None, content=None, request=None):
|
||||
self.status_code = status_code
|
||||
self.headers = Headers(headers)
|
||||
self.content = content or b""
|
||||
self.request = request
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
return self.content.decode("utf-8", errors="replace")
|
||||
|
||||
def json(self):
|
||||
return _json.loads(self.content)
|
||||
|
||||
def read(self) -> bytes:
|
||||
return self.content
|
||||
|
||||
@property
|
||||
def is_success(self) -> bool:
|
||||
return 200 <= self.status_code < 300
|
||||
|
||||
@property
|
||||
def is_error(self) -> bool:
|
||||
return self.status_code >= 400
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
if self.is_error:
|
||||
message = f"{self.status_code} Error"
|
||||
raise HTTPStatusError(message, request=self.request, response=self)
|
||||
|
||||
@property
|
||||
def cookies(self) -> Cookies:
|
||||
jar = Cookies()
|
||||
for header in self.headers.get("set-cookie", "").split(","):
|
||||
if "=" in header:
|
||||
name, _, value = header.strip().partition("=")
|
||||
jar.set(name.strip(), value.split(";")[0].strip())
|
||||
return jar
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Response [{self.status_code}]>"
|
||||
@@ -0,0 +1,135 @@
|
||||
"""
|
||||
Transport layer: connection management and low-level HTTP sending.
|
||||
HTTPTransport wraps a connection pool. ProxyTransport sits in front of it.
|
||||
MockTransport is used in tests.
|
||||
"""
|
||||
from models import Request, Response
|
||||
from exceptions import TransportError, ConnectError, TimeoutException
|
||||
|
||||
|
||||
class BaseTransport:
|
||||
"""Sync transport interface."""
|
||||
|
||||
def handle_request(self, request: Request) -> Response:
|
||||
raise NotImplementedError
|
||||
|
||||
def close(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class AsyncBaseTransport:
|
||||
"""Async transport interface."""
|
||||
|
||||
async def handle_async_request(self, request: Request) -> Response:
|
||||
raise NotImplementedError
|
||||
|
||||
async def aclose(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class ConnectionPool:
|
||||
"""
|
||||
Manages a pool of persistent HTTP connections.
|
||||
Keys connections by (scheme, host, port).
|
||||
"""
|
||||
|
||||
def __init__(self, max_connections=100, max_keepalive_connections=20):
|
||||
self.max_connections = max_connections
|
||||
self.max_keepalive_connections = max_keepalive_connections
|
||||
self._pool = {}
|
||||
|
||||
def _get_connection_key(self, request: Request) -> tuple:
|
||||
url = request.url
|
||||
port = 443 if url.scheme == "https" else 80
|
||||
return (url.scheme, url.host, port)
|
||||
|
||||
def get_connection(self, request: Request):
|
||||
key = self._get_connection_key(request)
|
||||
return self._pool.get(key)
|
||||
|
||||
def return_connection(self, request: Request, conn) -> None:
|
||||
key = self._get_connection_key(request)
|
||||
if len(self._pool) < self.max_keepalive_connections:
|
||||
self._pool[key] = conn
|
||||
|
||||
def close(self) -> None:
|
||||
self._pool.clear()
|
||||
|
||||
|
||||
class HTTPTransport(BaseTransport):
|
||||
"""
|
||||
The main sync HTTP transport.
|
||||
Uses a ConnectionPool for connection reuse.
|
||||
"""
|
||||
|
||||
def __init__(self, verify=True, cert=None, limits=None):
|
||||
self.verify = verify
|
||||
self.cert = cert
|
||||
self._pool = ConnectionPool()
|
||||
|
||||
def handle_request(self, request: Request) -> Response:
|
||||
conn = self._pool.get_connection(request)
|
||||
try:
|
||||
response = self._send(request, conn)
|
||||
self._pool.return_connection(request, conn)
|
||||
return response
|
||||
except TimeoutException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise ConnectError(str(exc)) from exc
|
||||
|
||||
def _send(self, request: Request, conn) -> Response:
|
||||
# Simplified: in real httpx this does the actual socket I/O
|
||||
return Response(200, headers={}, content=b"", request=request)
|
||||
|
||||
def close(self) -> None:
|
||||
self._pool.close()
|
||||
|
||||
|
||||
class AsyncHTTPTransport(AsyncBaseTransport):
|
||||
"""The async variant of HTTPTransport."""
|
||||
|
||||
def __init__(self, verify=True, cert=None):
|
||||
self.verify = verify
|
||||
self.cert = cert
|
||||
|
||||
async def handle_async_request(self, request: Request) -> Response:
|
||||
return Response(200, headers={}, content=b"", request=request)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class MockTransport(BaseTransport):
|
||||
"""
|
||||
A transport for testing that returns predefined responses.
|
||||
Pass a handler function that receives a Request and returns a Response.
|
||||
"""
|
||||
|
||||
def __init__(self, handler):
|
||||
self.handler = handler
|
||||
|
||||
def handle_request(self, request: Request) -> Response:
|
||||
return self.handler(request)
|
||||
|
||||
|
||||
class ProxyTransport(BaseTransport):
|
||||
"""
|
||||
Routes requests through an HTTP/HTTPS proxy.
|
||||
Wraps an inner transport and prepends proxy connection handling.
|
||||
"""
|
||||
|
||||
def __init__(self, proxy_url: str, *, inner: BaseTransport = None):
|
||||
self.proxy_url = proxy_url
|
||||
self._inner = inner or HTTPTransport()
|
||||
|
||||
def handle_request(self, request: Request) -> Response:
|
||||
try:
|
||||
return self._inner.handle_request(request)
|
||||
except TransportError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise TransportError(f"Proxy error: {exc}") from exc
|
||||
|
||||
def close(self) -> None:
|
||||
self._inner.close()
|
||||
@@ -0,0 +1,85 @@
|
||||
"""
|
||||
Utility functions shared across the library.
|
||||
Small helpers that don't belong in any one module.
|
||||
"""
|
||||
import re
|
||||
from models import Cookies
|
||||
|
||||
|
||||
SENSITIVE_HEADERS = {"authorization", "cookie", "set-cookie", "proxy-authorization"}
|
||||
|
||||
|
||||
def primitive_value_to_str(value) -> str:
|
||||
"""Convert a primitive value to its string representation."""
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
return str(value)
|
||||
|
||||
|
||||
def normalize_header_key(key: str) -> str:
|
||||
"""Convert a header key to its canonical Title-Case form."""
|
||||
return "-".join(word.capitalize() for word in key.split("-"))
|
||||
|
||||
|
||||
def flatten_queryparams(params: dict) -> list:
|
||||
"""
|
||||
Expand a params dict into a flat list of (key, value) pairs.
|
||||
List values become multiple pairs with the same key.
|
||||
"""
|
||||
result = []
|
||||
for key, value in params.items():
|
||||
if isinstance(value, list):
|
||||
for item in value:
|
||||
result.append((key, primitive_value_to_str(item)))
|
||||
else:
|
||||
result.append((key, primitive_value_to_str(value)))
|
||||
return result
|
||||
|
||||
|
||||
def parse_content_type(content_type: str) -> tuple:
|
||||
"""
|
||||
Parse a Content-Type header value.
|
||||
Returns (media_type, params_dict).
|
||||
Example: 'application/json; charset=utf-8' -> ('application/json', {'charset': 'utf-8'})
|
||||
"""
|
||||
parts = [p.strip() for p in content_type.split(";")]
|
||||
media_type = parts[0]
|
||||
params = {}
|
||||
for part in parts[1:]:
|
||||
if "=" in part:
|
||||
key, _, value = part.partition("=")
|
||||
params[key.strip()] = value.strip()
|
||||
return media_type, params
|
||||
|
||||
|
||||
def obfuscate_sensitive_headers(headers: dict) -> dict:
|
||||
"""Return a copy of headers with sensitive values replaced by [obfuscated]."""
|
||||
return {
|
||||
k: "[obfuscated]" if k.lower() in SENSITIVE_HEADERS else v
|
||||
for k, v in headers.items()
|
||||
}
|
||||
|
||||
|
||||
def unset_all_cookies(cookies: Cookies) -> None:
|
||||
"""Clear all cookies from a cookie jar in place."""
|
||||
cookies.clear()
|
||||
|
||||
|
||||
def is_known_encoding(encoding: str) -> bool:
|
||||
"""Check if a character encoding label is recognized by Python's codec system."""
|
||||
import codecs
|
||||
try:
|
||||
codecs.lookup(encoding)
|
||||
return True
|
||||
except LookupError:
|
||||
return False
|
||||
|
||||
|
||||
def build_url_with_params(base_url: str, params: dict) -> str:
|
||||
"""Append query parameters to a URL string."""
|
||||
if not params:
|
||||
return base_url
|
||||
pairs = flatten_queryparams(params)
|
||||
query = "&".join(f"{k}={v}" for k, v in pairs)
|
||||
separator = "&" if "?" in base_url else "?"
|
||||
return f"{base_url}{separator}{query}"
|
||||
Reference in New Issue
Block a user