Python httpx封装和使用

"""

========================================================

文件名:http_client.py

作用:封装了一个通用的 HTTP 请求客户端

什么是"封装"?

就是把 httpx 这个第三方库的复杂操作包装成更简单好用的工具。

就像把一台复杂的机器装进一个盒子,对外只留几个简单的按钮。

这个文件做了什么?

  1. 创建一个叫 HTTPClient 的类(可以理解为一个"请求工具箱")

  2. 发请求时自动带上 base_url、默认请求头、超时时间

  3. 自动把请求和响应内容打印到日志,方便排查问题

  4. 自动把请求和响应内容附加到 Allure 测试报告里

========================================================

"""

── 导入依赖 ──────────────────────────────────────────

import json # Python 内置库,用于处理 JSON 格式数据(序列化/反序列化)

from typing import Any, Optional

typing 是 Python 内置库,用于类型注解:

Any = 任意类型(什么类型都行)

Optional = 可选类型,即"这个参数可以传 None"

例如 Optionalstr 等价于 str 或 None

import allure # 第三方库,用于生成漂亮的测试报告,这里用来附加请求/响应内容

import httpx # 第三方库,现代化的 HTTP 请求库(类似 requests,但更强大)

从项目内部的公共模块导入三个函数:

from common.config import get_base_url, get_default_headers, get_timeout

get_base_url() → 返回接口的基础地址,如 "https://api.example.com"

get_default_headers() → 返回默认请求头,如 {"Content-Type": "application/json"}

get_timeout() → 返回默认超时时间,如 30.0(秒)

from common.logger import logger

项目内部封装好的日志工具,用于打印带颜色的请求/响应日志

── 类定义 ────────────────────────────────────────────

class HTTPClient:

"""

基于 httpx 的 HTTP 客户端封装。

什么是类(class)?

类就是一个"模板"或"图纸",根据它可以创建多个实例(对象)。

比如 HTTPClient 是图纸,client = HTTPClient() 就是按图纸造出来的工具。

这个类的三大功能:

  1. 自动注入 base_url、默认 headers、超时时间(不用每次手动写)

  2. 自动打印请求/响应日志(方便调试)

  3. 自动把请求与响应附加到 Allure 报告(方便查看测试结果)

"""

── 初始化方法 ────────────────────────────────────

def init(

self,

base_url: Optionalstr = None, # 基础URL,不传则从配置文件读取

headers: Optionaldict = None, # 额外请求头,不传则只用默认请求头

timeout: Optionalfloat = None, # 超时秒数,不传则从配置文件读取

) -> None:

"""

init 是类的初始化方法(构造函数)。

当你写 client = HTTPClient() 时,这个方法会自动执行。

作用:创建一个配置好的 httpx.Client 对象,存到 self._client 备用。

参数说明:

self → 指向当前实例本身(固定写法,不需要手动传)

base_url → 接口基础地址。如 "https://api.example.com"

不传则自动调用 get_base_url() 从配置读取

headers → 额外的请求头字典。如 {"Authorization": "Bearer token"}

不传则只用 get_default_headers() 的默认值

timeout → 请求超时秒数。如 60.0 表示60秒没响应就报错

不传则自动调用 get_timeout() 从配置读取

-> None → 初始化方法没有返回值

"""

self._client = httpx.Client(

base_url or get_base_url():

如果传了 base_url 就用传入的,

如果没传(None),就调用 get_base_url() 获取配置中的默认地址

base_url=base_url or get_base_url(),

{**get_default_headers(), **(headers or {})}:

** 是字典解包操作符,把字典展开合并成一个新字典

先铺默认请求头,再铺传入的请求头,同名 key 传入的会覆盖默认的

headers or {} 的作用:如果 headers 是 None,用空字典代替,避免报错

例如:

默认头 = {"Content-Type": "application/json", "User-Agent": "myapp"}

传入头 = {"Authorization": "Bearer token"}

合并后 = {"Content-Type": "application/json",

"User-Agent": "myapp",

"Authorization": "Bearer token"}

headers={**get_default_headers(), **(headers or {})},

timeout or get_timeout():同 base_url 的逻辑,传了用传入的,没传用默认的

timeout=timeout or get_timeout(),

follow_redirects=True:固定值

表示自动跟随 HTTP 重定向(如 301/302 跳转)

比如 http:// 自动跳转到 https:// 时会自动处理,不需要手动再发一次请求

follow_redirects=True,

)

── 类级别常量(所有实例共享)────────────────────────

_MAX_LOG_BODY = 2000

日志中请求体/响应体的最大显示字符数

超过 2000 个字符就截断,避免日志太长难以阅读

_METHOD_COLORS = {

"GET": "blue",

"POST": "green",

"PUT": "yellow",

"PATCH": "magenta",

"DELETE": "red",

}

不同 HTTP 方法对应不同的日志颜色,方便肉眼区分

GET=蓝色 POST=绿色 PUT=黄色 PATCH=紫色 DELETE=红色

日志分隔线,让请求和响应的日志更清晰易读

_SEP_REQ = "==================== HTTP REQUEST ===================="

_SEP_RESP = "==================== HTTP RESPONSE ===================="

_SEP_END = "========================================================"

── 核心方法:发送请求 ────────────────────────────────

def request(self, method: str, url: str, **kwargs: Any) -> httpx.Response:

"""

发送 HTTP 请求的核心方法,所有 get/post/put/patch/delete 最终都调用这里。

参数说明:

method → HTTP 方法,如 "GET"、"POST"(不区分大小写,内部会转大写)

url → 请求路径,如 "/api/user/info"

会自动拼接到 base_url 后面,最终变成完整地址

**kwargs → 可变关键字参数,可以传任意额外参数,如:

params={"page": 1} → URL 查询参数

json={"name": "张三"} → 请求体(JSON格式)

headers={"token": "xxx"} → 额外请求头

返回值:

httpx.Response 对象,包含:

response.status_code → 状态码,如 200、404、500

response.json() → 响应体解析为字典

response.text → 响应体原始字符串

response.headers → 响应头

response.elapsed → 请求耗时

"""

把 method 统一转成大写,保证后续颜色匹配等逻辑正常

例如 "get" → "GET"

method = method.upper()

try:

实际发送请求,调用 httpx.Client 的 request 方法

response = self._client.request(method, url, **kwargs)

except httpx.HTTPError as e:

如果请求过程中发生网络异常(如超时、连接拒绝等),进入这里

httpx.HTTPError 是所有 httpx 网络异常的基类

打印异常日志(只打印请求信息,因为没有响应)

logger.opt(colors=True).error(

"\n" + self._format_request_only(method, url, kwargs)

  • f"\n<red><bold>请求异常</bold></red> {self._esc(type(e).name)}: {self._esc(str(e))}"

type(e).name → 异常类型名,如 "ConnectTimeout"

str(e) → 异常详细信息

)

同时把异常信息附加到 Allure 报告

allure.attach(

f"{method} {url}\nERROR: {e}",

name="HTTP 异常",

attachment_type=allure.attachment_type.TEXT,

)

raise 重新抛出异常,让调用方知道请求失败了

raise

请求成功后,打印完整的请求 + 响应日志

logger.opt(colors=True).info(

"\n" + self._format_request_response(method, url, kwargs, response)

)

把请求和响应内容附加到 Allure 报告

self._attach_to_allure(method, url, kwargs, response)

返回响应对象供调用方使用

return response

── 日志格式化方法 ────────────────────────────────────

@classmethod

def _format_request_only(cls, method: str, url: str, kwargs: dict) -> str:

"""

格式化"只有请求"的日志文本(用于请求异常时,因为没有响应内容)。

@classmethod 说明:

这是一个类方法,第一个参数是 cls(类本身),而不是 self(实例)。

可以不创建实例直接调用:HTTPClient._format_request_only(...)

也可以通过实例调用:self._format_request_only(...)

参数:

cls → 类本身(固定写法)

method → HTTP 方法

url → 请求路径

kwargs → 请求的额外参数(headers/params/body等)

返回:格式化好的字符串,包含 ANSI 颜色标签(供 loguru 渲染颜色)

"""

提取请求头(如果 kwargs 里传了额外 headers 就取出来)

merged_headers = {**cls._safe_headers(kwargs.get("headers"))}

提取请求体:优先取 json,没有就取 data

kwargs.get("json") → 适用于 JSON 格式的请求体

kwargs.get("data") → 适用于表单格式的请求体

body = kwargs.get("json") if kwargs.get("json") is not None else kwargs.get("data")

用 "\n".join(...) 把多行内容拼接成一个字符串

<cyan>/<yellow>/<bold> 等是 loguru 的颜色标签,会渲染成彩色文字

return "\n".join([

f"<cyan>{cls._SEP_REQ}</cyan>",

f"<yellow><bold>URL </bold></yellow>: {cls._color_method(method)} <cyan>{cls._esc(url)}</cyan>",

f"<yellow><bold>Headers</bold></yellow>: {cls._fmt(merged_headers)}",

f"<yellow><bold>Params </bold></yellow>: {cls._fmt(kwargs.get('params'))}",

f"<yellow><bold>Body </bold></yellow>: {cls._fmt(body)}",

f"<cyan>{cls._SEP_END}</cyan>",

])

@classmethod

def _format_request_response(

cls, method: str, url: str, kwargs: dict, response: httpx.Response

) -> str:

"""

格式化"请求 + 响应"的完整日志文本(请求成功时使用)。

参数:

cls → 类本身

method → HTTP 方法

url → 请求路径

kwargs → 请求的额外参数

response → httpx 响应对象

返回:格式化好的完整请求+响应字符串

"""

从响应对象中取出"实际发出的"请求信息

这比用原始 url 更准确,因为 base_url 已经拼接进去了,params 也已经附加到 URL 上

final_url = str(response.request.url)

final_headers = dict(response.request.headers)

提取请求体

body = kwargs.get("json") if kwargs.get("json") is not None else kwargs.get("data")

尝试把响应体解析为 JSON 并格式化

try:

indent=2 表示格式化输出,每层缩进2个空格,方便阅读

ensure_ascii=False 表示中文不转义,保留原始中文字符

resp_body_str = json.dumps(response.json(), ensure_ascii=False, indent=2)

except (ValueError, json.JSONDecodeError):

如果响应体不是 JSON 格式(如返回 HTML),就直接用文本

resp_body_str = response.text

如果响应体超过最大长度限制,截断并提示

if len(resp_body_str) > cls._MAX_LOG_BODY:

resp_body_str = resp_body_str: cls._MAX_LOG_BODY + f"... truncated, total {len(resp_body_str)} chars"

truncated = 已截断;total xxx chars = 原始总字符数

return "\n".join([

f"<cyan>{cls._SEP_REQ}</cyan>",

f"<yellow><bold>URL </bold></yellow>: {cls._color_method(method)} <cyan>{cls._esc(final_url)}</cyan>",

f"<yellow><bold>Headers</bold></yellow>: {cls._fmt(final_headers)}",

f"<yellow><bold>Params </bold></yellow>: {cls._fmt(kwargs.get('params'))}",

f"<yellow><bold>Body </bold></yellow>: {cls._fmt(body)}",

f"<magenta>{cls._SEP_RESP}</magenta>",

response.elapsed.total_seconds() → 请求耗时(秒),:.3f 保留3位小数

f"<yellow><bold>Status </bold></yellow>: {cls._color_status(response.status_code)} "

f"<dim>({response.elapsed.total_seconds():.3f}s)</dim>",

f"<yellow><bold>Headers</bold></yellow>: {cls._fmt(dict(response.headers))}",

f"<yellow><bold>Response </bold></yellow>: {cls._esc(resp_body_str)}",

f"<magenta>{cls._SEP_END}</magenta>",

])

── 颜色辅助方法 ──────────────────────────────────────

@classmethod

def _color_method(cls, method: str) -> str:

"""

给 HTTP 方法加上对应的颜色标签。

例如:

"GET" → "<blue><bold>GET</bold></blue>"

"POST" → "<green><bold>POST</bold></green>"

"DELETE" → "<red><bold>DELETE</bold></red>"

"""

dict.get(key, default):取字典中 key 对应的值,没有则返回默认值 "white"

color = cls._METHOD_COLORS.get(method, "white")

return f"<{color}><bold>{method}</bold></{color}>"

@staticmethod

def _color_status(code: int) -> str:

"""

根据 HTTP 状态码返回对应颜色的标签字符串。

@staticmethod 说明:

静态方法,既不需要 self 也不需要 cls,

跟普通函数一样,只是放在类里方便组织代码。

状态码颜色规则:

2xx(成功) → 绿色 如 200 OK

3xx(重定向)→ 青色 如 301 Moved

4xx(客户端错误)→ 黄色 如 404 Not Found

5xx(服务端错误)→ 红色 如 500 Internal Error

"""

if 200 <= code < 300:

return f"<green><bold>{code}</bold></green>"

if 300 <= code < 400:

return f"<cyan><bold>{code}</bold></cyan>"

if 400 <= code < 500:

return f"<yellow><bold>{code}</bold></yellow>"

return f"<red><bold>{code}</bold></red>" # 5xx 及其他

@classmethod

def _fmt(cls, value: Any) -> str:

"""

把任意类型的值格式化为适合日志输出的字符串。

处理逻辑:

None → 显示 "(none)" 提示为空

dict/list → 格式化为缩进的 JSON 字符串

其他类型 → 直接转字符串

超长内容 → 截断并提示

"""

if value is None:

return "<dim>(none)</dim>" # <dim> 是 loguru 的灰色标签

if isinstance(value, (dict, list)):

isinstance(value, (dict, list)) → 判断 value 是否是字典或列表

try:

格式化为 JSON 字符串,default=str 表示遇到无法序列化的类型就转字符串

text = json.dumps(value, ensure_ascii=False, indent=2, default=str)

except (TypeError, ValueError):

如果 JSON 序列化失败,直接转字符串

text = str(value)

else:

text = str(value)

超长截断

if len(text) > cls._MAX_LOG_BODY:

text = text: cls._MAX_LOG_BODY + f"... truncated, total {len(text)} chars"

对文本进行转义后返回

return cls._esc(text)

@staticmethod

def _esc(text: str) -> str:

"""

转义 loguru 颜色标签中的特殊字符 < 和 >。

为什么需要转义?

loguru 日志库用 <red>、<bold> 等标签来渲染颜色。

如果响应体里恰好包含 < > 字符(如 HTML、XML),

会被误认为颜色标签导致渲染出错。

转义后 < 变成 \\< ,loguru 就不会把它当标签处理了。

例如:

"<html>" → "\\<html\\>" (安全输出,不触发颜色渲染)

"""

return text.replace("\\", "\\\\").replace("<", r"\<").replace(">", r"\>")

@staticmethod

def _safe_headers(h: Any) -> dict:

"""

安全地把传入的 headers 转换为字典。

为什么需要"安全"转换?

headers 可能是 dict、httpx.Headers 对象、None 或其他类型,

直接 dict() 转换可能报错,这里做了保护处理。

处理逻辑:

None 或空值 → 返回空字典 {}

可转换的类型 → 转为 dict

转换失败 → 返回空字典 {}(不报错)

"""

if not h:

return {}

try:

return dict(h)

except Exception:

return {}

@staticmethod

def _attach_to_allure(

method: str, url: str, kwargs: dict, response: httpx.Response

) -> None:

"""

把请求和响应内容附加到 Allure 测试报告。

什么是 Allure?

Allure 是一个测试报告框架,可以生成图形化的 HTML 报告。

allure.attach() 可以在报告中附加额外信息(如请求详情、响应内容),

方便测试失败时查看具体的请求和响应数据。

附加内容:

  1. Request(请求信息)→ JSON 格式

  2. Response(响应信息)→ JSON 或 TEXT 格式

"""

整理请求信息为字典

req_payload = {

"method": method,

"url": str(response.request.url), # 最终实际请求的完整 URL

"headers": dict(response.request.headers),

"params": kwargs.get("params"),

"json": kwargs.get("json"),

"data": kwargs.get("data"),

}

把请求信息附加到 Allure 报告,格式为 JSON

allure.attach(

json.dumps(req_payload, ensure_ascii=False, indent=2, default=str),

name="Request", # 报告中显示的标题

attachment_type=allure.attachment_type.JSON, # 附件类型为 JSON

)

尝试把响应体解析为 JSON 附加

try:

body = response.json()

allure.attach(

json.dumps(body, ensure_ascii=False, indent=2),

name=f"Response ({response.status_code})", # 标题带上状态码

attachment_type=allure.attachment_type.JSON,

)

except (ValueError, json.JSONDecodeError):

响应体不是 JSON(如 HTML),以纯文本方式附加

allure.attach(

response.text,

name=f"Response ({response.status_code})",

attachment_type=allure.attachment_type.TEXT,

)

── 快捷请求方法(对 request 的封装)──────────────────

def get(self, url: str, **kwargs: Any) -> httpx.Response:

"""

发送 GET 请求。

GET 用于获取数据,参数通过 params 传递到 URL 上。

使用示例:

client.get("/api/user/info", params={"user_id": 123})

→ 实际请求:GET https://api.example.com/api/user/info?user_id=123

"""

return self.request("GET", url, **kwargs)

def post(self, url: str, **kwargs: Any) -> httpx.Response:

"""

发送 POST 请求。

POST 通常用于创建数据,参数通过 json 或 data 放在请求体里。

使用示例:

client.post("/api/user/create", json={"name": "张三", "age": 18})

"""

return self.request("POST", url, **kwargs)

def put(self, url: str, **kwargs: Any) -> httpx.Response:

"""

发送 PUT 请求。

PUT 通常用于完整更新一条数据(替换整个资源)。

使用示例:

client.put("/api/user/1", json={"name": "李四", "age": 20})

"""

return self.request("PUT", url, **kwargs)

def patch(self, url: str, **kwargs: Any) -> httpx.Response:

"""

发送 PATCH 请求。

PATCH 通常用于部分更新数据(只改某几个字段)。

使用示例:

client.patch("/api/user/1", json={"age": 21}) # 只改年龄

"""

return self.request("PATCH", url, **kwargs)

def delete(self, url: str, **kwargs: Any) -> httpx.Response:

"""

发送 DELETE 请求。

DELETE 用于删除数据。

使用示例:

client.delete("/api/user/1")

"""

return self.request("DELETE", url, **kwargs)

── 资源管理方法 ──────────────────────────────────────

def close(self) -> None:

"""

关闭 HTTP 连接,释放资源。

为什么需要关闭?

httpx.Client 内部维护了连接池(复用 TCP 连接提升性能),

使用完毕后需要显式关闭,否则可能导致连接泄漏。

"""

self._client.close()

def enter(self) -> "HTTPClient":

"""

支持 with 语句的进入方法。

什么是 with 语句(上下文管理器)?

with HTTPClient() as client:

client.get("/api/xxx")

with 块结束后,自动调用 exit,自动关闭连接

返回 self(即当前实例),赋值给 as 后面的变量。

"HTTPClient" 加引号是"前向引用",表示返回类型是 HTTPClient 自身。

"""

return self

def exit(self, exc_type, exc_val, exc_tb) -> None:

"""

支持 with 语句的退出方法,with 块结束时自动执行(无论是否报错)。

参数说明(固定写法):

exc_type → 异常类型(没有异常则为 None)

exc_val → 异常值(没有异常则为 None)

exc_tb → 异常堆栈信息(没有异常则为 None)

这里不管有没有异常,都执行 close() 关闭连接。

"""

self.close()

══════════════════════════════════════════════════════

使用示例(新手速查)

══════════════════════════════════════════════════════

方式一:手动管理(需要手动 close)

client = HTTPClient()

response = client.get("/api/user/info", params={"id": 1})

print(response.status_code) # 200

print(response.json()) # {"code": 0, "data": {...}}

client.close() # 用完要手动关闭

方式二:with 语句(推荐,自动关闭)

with HTTPClient() as client:

response = client.post(

"/api/user/create",

json={"name": "张三"},

headers={"Authorization": "Bearer mytoken"}

)

print(response.status_code)

# with 块结束,自动关闭连接

方式三:自定义配置

client = HTTPClient(

base_url="https://test-api.example.com", # 指定测试环境地址

headers={"Authorization": "Bearer token"}, # 带上鉴权头

timeout=60.0 # 60秒超时

)

══════════════════════════════════════════════════════

python 复制代码
import json
from typing import Any, Optional

import allure
import httpx

from common.config import get_base_url, get_default_headers, get_timeout
from common.logger import logger


class HTTPClient:

    def __init__(
            self,
            base_url: Optional[str] = None,
            headers: Optional[dict] = None,
            timeout: Optional[float] = None,
    ) -> None:
        self._client = httpx.Client(
            base_url=base_url or get_base_url(),
            headers={**get_default_headers(), **(headers or {})},
            timeout=timeout or get_timeout(),
            follow_redirects=True,
        )

    _MAX_LOG_BODY = 2000

    _METHOD_COLORS = {
        "GET": "blue",
        "POST": "green",
        "PUT": "yellow",
        "PATCH": "magenta",
        "DELETE": "red",
    }

    _SEP_REQ = "==================== HTTP REQUEST  ===================="
    _SEP_RESP = "==================== HTTP RESPONSE ===================="
    _SEP_END = "========================================================"

    def request(self, method: str, url: str, **kwargs: Any) -> httpx.Response:
        method = method.upper()

        try:
            response = self._client.request(method, url, **kwargs)
        except httpx.HTTPError as e:
            logger.opt(colors=True).error(
                "\n" + self._format_request_only(method, url, kwargs)
                + f"\n<red><bold>[请求异常]</bold></red> {self._esc(type(e).__name__)}: {self._esc(str(e))}"
            )
            allure.attach(
                f"{method} {url}\nERROR: {e}",
                name="HTTP 异常",
                attachment_type=allure.attachment_type.TEXT,
            )
            raise

        logger.opt(colors=True).info(
            "\n" + self._format_request_response(method, url, kwargs, response)
        )

        self._attach_to_allure(method, url, kwargs, response)

        return response

    @classmethod
    def _format_request_only(cls, method: str, url: str, kwargs: dict) -> str:
        merged_headers = {**cls._safe_headers(kwargs.get("headers"))}
        body = kwargs.get("json") if kwargs.get("json") is not None else kwargs.get("data")

        return "\n".join([
            f"<cyan>{cls._SEP_REQ}</cyan>",
            f"<yellow><bold>URL    </bold></yellow>: {cls._color_method(method)} <cyan>{cls._esc(url)}</cyan>",
            f"<yellow><bold>Headers</bold></yellow>: {cls._fmt(merged_headers)}",
            f"<yellow><bold>Params </bold></yellow>: {cls._fmt(kwargs.get('params'))}",
            f"<yellow><bold>Body   </bold></yellow>: {cls._fmt(body)}",
            f"<cyan>{cls._SEP_END}</cyan>",
        ])

    @classmethod
    def _format_request_response(
            cls, method: str, url: str, kwargs: dict, response: httpx.Response
    ) -> str:
        final_url = str(response.request.url)
        final_headers = dict(response.request.headers)
        body = kwargs.get("json") if kwargs.get("json") is not None else kwargs.get("data")

        try:
            resp_body_str = json.dumps(response.json(), ensure_ascii=False, indent=2)
        except (ValueError, json.JSONDecodeError):
            resp_body_str = response.text

        if len(resp_body_str) > cls._MAX_LOG_BODY:
            resp_body_str = resp_body_str[: cls._MAX_LOG_BODY] + f"... [truncated, total {len(resp_body_str)} chars]"

        return "\n".join([
            f"<cyan>{cls._SEP_REQ}</cyan>",
            f"<yellow><bold>URL    </bold></yellow>: {cls._color_method(method)} <cyan>{cls._esc(final_url)}</cyan>",
            f"<yellow><bold>Headers</bold></yellow>: {cls._fmt(final_headers)}",
            f"<yellow><bold>Params </bold></yellow>: {cls._fmt(kwargs.get('params'))}",
            f"<yellow><bold>Body   </bold></yellow>: {cls._fmt(body)}",
            f"<magenta>{cls._SEP_RESP}</magenta>",
            f"<yellow><bold>Status </bold></yellow>: {cls._color_status(response.status_code)} "
            f"<dim>({response.elapsed.total_seconds():.3f}s)</dim>",
            f"<yellow><bold>Headers</bold></yellow>: {cls._fmt(dict(response.headers))}",
            f"<yellow><bold>Response   </bold></yellow>: {cls._esc(resp_body_str)}",
            f"<magenta>{cls._SEP_END}</magenta>",
        ])

    @classmethod
    def _color_method(cls, method: str) -> str:
        color = cls._METHOD_COLORS.get(method, "white")
        return f"<{color}><bold>{method}</bold></{color}>"

    @staticmethod
    def _color_status(code: int) -> str:
        if 200 <= code < 300:
            return f"<green><bold>{code}</bold></green>"
        if 300 <= code < 400:
            return f"<cyan><bold>{code}</bold></cyan>"
        if 400 <= code < 500:
            return f"<yellow><bold>{code}</bold></yellow>"
        return f"<red><bold>{code}</bold></red>"

    @classmethod
    def _fmt(cls, value: Any) -> str:
        if value is None:
            return "<dim>(none)</dim>"

        if isinstance(value, (dict, list)):
            try:
                text = json.dumps(value, ensure_ascii=False, indent=2, default=str)
            except (TypeError, ValueError):
                text = str(value)
        else:
            text = str(value)

        if len(text) > cls._MAX_LOG_BODY:
            text = text[: cls._MAX_LOG_BODY] + f"... [truncated, total {len(text)} chars]"

        return cls._esc(text)

    @staticmethod
    def _esc(text: str) -> str:
        return text.replace("\\", "\\\\").replace("<", r"\<").replace(">", r"\>")

    @staticmethod
    def _safe_headers(h: Any) -> dict:
        if not h:
            return {}
        try:
            return dict(h)
        except Exception:
            return {}

    @staticmethod
    def _attach_to_allure(
            method: str, url: str, kwargs: dict, response: httpx.Response
    ) -> None:
        req_payload = {
            "method": method,
            "url": str(response.request.url),
            "headers": dict(response.request.headers),
            "params": kwargs.get("params"),
            "json": kwargs.get("json"),
            "data": kwargs.get("data"),
        }

        allure.attach(
            json.dumps(req_payload, ensure_ascii=False, indent=2, default=str),
            name="Request",
            attachment_type=allure.attachment_type.JSON,
        )

        try:
            body = response.json()
            allure.attach(
                json.dumps(body, ensure_ascii=False, indent=2),
                name=f"Response ({response.status_code})",
                attachment_type=allure.attachment_type.JSON,
            )
        except (ValueError, json.JSONDecodeError):
            allure.attach(
                response.text,
                name=f"Response ({response.status_code})",
                attachment_type=allure.attachment_type.TEXT,
            )

    def get(self, url: str, **kwargs: Any) -> httpx.Response:
        return self.request("GET", url, **kwargs)

    def post(self, url: str, **kwargs: Any) -> httpx.Response:
        return self.request("POST", url, **kwargs)

    def put(self, url: str, **kwargs: Any) -> httpx.Response:
        return self.request("PUT", url, **kwargs)

    def patch(self, url: str, **kwargs: Any) -> httpx.Response:
        return self.request("PATCH", url, **kwargs)

    def delete(self, url: str, **kwargs: Any) -> httpx.Response:
        return self.request("DELETE", url, **kwargs)

    def close(self) -> None:
        self._client.close()

    def __enter__(self) -> "HTTPClient":
        return self

    def __exit__(self, exc_type, exc_val, exc_tb) -> None:
        self.close()
相关推荐
Asize2 小时前
重生之我在 Vibe Coding 时代当程序员:第十二课,Prompt 不是咒语,是可以沉淀的业务接口
前端·人工智能·python
abigale032 小时前
字典 与 Python 对象 的总结
python·dict·object
星河漫步Lu2 小时前
Pycharm中部署Anaconda环境
ide·python·pycharm
qq_283720053 小时前
2026 最新 Python+AI 零基础入门全教程 :从零搭建人工智能完整项目
开发语言·人工智能·python
时尚IT男3 小时前
Python发票识别实战:从PDF中精准提取发票号与(小写)¥金额
开发语言·python·pdf
许彰午3 小时前
12_ArrayList与LinkedList深度对比
java·前端·python
CTA终结者3 小时前
期货量化环境装不上怎么办:天勤 TqSdk 安装与 Python 版本排查
开发语言·python
SilentSamsara3 小时前
Python 与 Docker:多阶段构建、最小镜像与健康检查
运维·开发语言·python·docker·中间件·容器