"""
========================================================
文件名:http_client.py
作用:封装了一个通用的 HTTP 请求客户端
什么是"封装"?
就是把 httpx 这个第三方库的复杂操作包装成更简单好用的工具。
就像把一台复杂的机器装进一个盒子,对外只留几个简单的按钮。
这个文件做了什么?
-
创建一个叫 HTTPClient 的类(可以理解为一个"请求工具箱")
-
发请求时自动带上 base_url、默认请求头、超时时间
-
自动把请求和响应内容打印到日志,方便排查问题
-
自动把请求和响应内容附加到 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() 就是按图纸造出来的工具。
这个类的三大功能:
-
自动注入 base_url、默认 headers、超时时间(不用每次手动写)
-
自动打印请求/响应日志(方便调试)
-
自动把请求与响应附加到 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() 可以在报告中附加额外信息(如请求详情、响应内容),
方便测试失败时查看具体的请求和响应数据。
附加内容:
-
Request(请求信息)→ JSON 格式
-
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()