diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f73a7bb --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.{yml,yaml}] +indent_size = 2 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 91ef621..73813a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,33 +2,78 @@ name: CI on: push: - branches: [main, master] + branches: [main, master, 'codex/**'] pull_request: + workflow_call: + inputs: + ref: + type: string + required: false + default: '' + workflow_dispatch: permissions: contents: read +env: + PYTHONUTF8: '1' + jobs: test: - runs-on: ubuntu-24.04 - timeout-minutes: 10 + runs-on: ${{ matrix.os }} + timeout-minutes: 20 strategy: fail-fast: false matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + os: [ubuntu-24.04] + python-version: ['3.11', '3.12', '3.13', '3.14'] + include: + - os: windows-2025 + python-version: '3.13' + - os: macos-15 + python-version: '3.13' steps: - - uses: actions/checkout@v6 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ inputs.ref || github.sha }} + persist-credentials: false + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: ${{ matrix.python-version }} - - name: Install dependencies + - name: 安装开发工具和模板 + run: python -m pip install -e ".[dev]" + - run: ruff check . + - run: ruff format --check src tests scripts + - run: mypy src + - run: python -m pytest -q + - name: 改名后构建、重建、独立安装及真实HTTP与类型消费 + run: python scripts/verify_package.py --rename + - name: 未改名的模板也必须正确打包 + if: matrix.os == 'ubuntu-24.04' && matrix.python-version == '3.13' + run: python scripts/verify_package.py + - name: 最低直接依赖 + if: matrix.os == 'ubuntu-24.04' && matrix.python-version == '3.11' + run: python scripts/verify_package.py --rename --minimum + + audit: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ inputs.ref || github.sha }} + persist-credentials: false + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: '3.13' + - run: python -m pip install pip-audit==2.9.0 + - name: 仅解析和审计运行依赖,不将开发工具误算成SDK依赖 + shell: bash run: | - python -m pip install --upgrade pip - pip install -e ".[dev]" - - name: Lint with ruff - run: ruff check . - - name: Type-check with mypy - run: mypy src - - name: Run tests - run: pytest -q + python - <<'PYCODE' + import os, pathlib, tomllib + config = tomllib.loads(pathlib.Path("pyproject.toml").read_text(encoding="utf-8")) + path = pathlib.Path(os.environ["RUNNER_TEMP"]) / "sdk-runtime-requirements.txt" + path.write_text("\n".join(config["project"]["dependencies"]) + "\n", encoding="utf-8") + PYCODE + python -m pip_audit --strict -r "$RUNNER_TEMP/sdk-runtime-requirements.txt" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 68b294d..f631770 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,46 +1,92 @@ name: Publish -# Publishes the package to PyPI using Trusted Publishing (OIDC) — no API token -# is stored in the repository. -# -# IMPORTANT (template users): -# 1. This workflow is inert until you (a) rename the package to a real name -# via scripts/rename_template.py and (b) configure a "Trusted Publisher" -# for that package name on PyPI (and optionally TestPyPI) pointing at this -# repo + the `publish.yml` workflow + an `v*.*.*` tag scope. See docs/PUBLISHING.md. -# 2. The template ships with the placeholder name `example` and is NOT meant -# to be published as-is. -# 3. To validate against TestPyPI first, uncomment the `repository-url` line -# below and configure a TestPyPI Trusted Publisher for the same name. - +# 模板默认不可上传;复制后须显式启用仓库变量并配置受保护环境与PyPI信任。 on: - push: - tags: - - "v*.*.*" + workflow_dispatch: + inputs: + tag: + description: '已在main中验证的版本标签,例如v0.4.0' + required: true + type: string + target: + description: '目标索引' + required: true + type: choice + options: [testpypi, pypi] + default: testpypi permissions: contents: read - id-token: write jobs: - publish: + preflight: + if: github.ref == 'refs/heads/main' && github.repository != 'guyue55/py_example' && vars.ENABLE_PYPI_PUBLISH == 'true' runs-on: ubuntu-24.04 - timeout-minutes: 10 - environment: pypi + timeout-minutes: 5 + outputs: + sha: ${{ steps.ref.outputs.sha }} steps: - - uses: actions/checkout@v6 - - - name: Set up Python - uses: actions/setup-python@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: - python-version: "3.13" - - - name: Build distributions + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: '3.13' + - name: 标签格式、main归属与非占位元数据 + id: ref + env: + RELEASE_TAG: ${{ inputs.tag }} + shell: bash run: | - python -m pip install --upgrade build - python -m build - - - name: Publish to PyPI via Trusted Publishing - uses: pypa/gh-action-pypi-publish@release/v1 - # with: - # repository-url: https://test.pypi.org/legacy/ # uncomment for TestPyPI + set -euo pipefail + [[ "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] + test "$(git cat-file -t "refs/tags/$RELEASE_TAG")" = tag + release_sha=$(git rev-parse --verify "refs/tags/$RELEASE_TAG^{commit}") + git merge-base --is-ancestor "$release_sha" origin/main + git checkout --detach "$release_sha" + python scripts/check_release.py --tag "$RELEASE_TAG" + echo "sha=$release_sha" >> "$GITHUB_OUTPUT" + quality: + needs: preflight + uses: ./.github/workflows/ci.yml + with: + ref: ${{ needs.preflight.outputs.sha }} + build: + needs: [preflight, quality] + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ needs.preflight.outputs.sha }} + persist-credentials: false + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: '3.13' + - run: python -m pip install -e ".[dev]" + - run: python -m build + - run: python scripts/verify_package.py --artifacts dist + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: distributions-${{ needs.preflight.outputs.sha }} + path: | + dist/*.whl + dist/*.tar.gz + if-no-files-found: error + retention-days: 7 + publish: + needs: [preflight, build] + runs-on: ubuntu-24.04 + timeout-minutes: 5 + environment: ${{ inputs.target }} + permissions: + id-token: write + steps: + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: distributions-${{ needs.preflight.outputs.sha }} + path: dist/ + - uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # release/v1 + with: + repository-url: ${{ inputs.target == 'testpypi' && 'https://test.pypi.org/legacy/' || 'https://upload.pypi.org/legacy/' }} diff --git a/.gitignore b/.gitignore index 30b065d..d3406c8 100644 --- a/.gitignore +++ b/.gitignore @@ -59,4 +59,9 @@ venv.bak/ .idea/ .vscode/ *.swp -*.swo \ No newline at end of file +*.swo + +# 本机工作状态,不进入模板或发布包 +runtime/ +.workbuddy/ +.mypy_cache/ diff --git a/AGENTS.md b/AGENTS.md index 9df6c83..355a9a5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,19 +5,19 @@ ## 项目概述 `py_example` 是一个 **Python SDK / HTTP 客户端模板**:基于 `requests` 封装了 -认证(Bearer / Basic / ApiKey)、传输层重试(429 与 5xx 指数退避 + jitter)与 +认证(Bearer / Basic / ApiKey)、受控只读重试(429 与 5xx 指数退避 + jitter)与 类型化异常。它**不是成品库**,而是供他人 fork 后改名的脚手架。 - 占位包名:`example`(发布前必须改名)。 - 构建后端:setuptools(PEP 621 元数据),非 Poetry。 -- 目标 Python:`>=3.9`,CI 矩阵 3.9–3.13。 +- 目标 Python:`>=3.11`,CI 矩阵 3.11–3.14。 - 模板本身**不发布**到 PyPI;改名后才具备发布条件。 ## 目录结构 ``` src/example/ 包源码(client / auth / exceptions) -tests/ 测试(用真实本地 HTTP server,非外部依赖桩) +tests/ 单元Mock + 真实本地HTTP + 模板工具测试(证据分开) scripts/rename_template.py 一键改名 example → 真名 docs/PUBLISHING.md 发布到 PyPI(Trusted Publishing)指南 developer_guide.md 开发指南 @@ -33,16 +33,17 @@ ruff format . # 格式化 mypy src # 类型检查 python -m build # 构建 sdist + wheel python scripts/rename_template.py <新包名> # 改名(支持 --dry-run) -pre-commit run --all-files # 本地跑全部 pre-commit 钩子(CI 同款) +python scripts/verify_package.py --rename +python scripts/verify_package.py --rename --minimum ``` ## 代码风格 -- Lint/格式化:ruff(行宽 100,`target-version = py39`)。 +- Lint/格式化:ruff(行宽 100,`target-version = py311`)。 - 导入排序:isort(ruff 内置,`select = ["E","F","I","W"]`)。 -- **刻意不强制 UP(pyupgrade)规则**,保持对 3.9 的低门槛;当前 pre-commit **不引入** +- **刻意不强制 UP(pyupgrade)规则**,避免批量改写无关代码;当前 pre-commit **不引入** `pyupgrade` 钩子,避免 PEP 585(`dict[...]` 替代 `Dict[...]` 等)自动改写突破该边界。 -- 类型:mypy 约束 `<2`(兼容 3.9),未对全部函数强制 `disallow_untyped_defs`。 +- 类型:mypy 约束 `<2`,未对全部函数强制 `disallow_untyped_defs`。 - 库日志:每个模块用 `logging.getLogger(__name__)`,包在 `__init__` 挂 `NullHandler`,不要给库加 root handler。 @@ -61,17 +62,15 @@ pre-commit run --all-files # 本地跑全部 pre-commit 钩子(CI 同款) 例:`feat(client): 增加超时参数`、`docs(publish): 补充 Trusted Publisher 说明`。 - 分支:`main` / `master` 受 CI 保护;功能走分支 + PR。 -## 发布(Trusted Publishing,默认惰性) +## 发布(默认关闭) -- 发布流程见 `docs/PUBLISHING.md`。`.github/workflows/publish.yml` 在推送 - `v*.*.*` 标签时,通过 OIDC(无需 token)自动发布到 PyPI。 -- 发布前必须:① 用 `rename_template.py` 改名;② 在 PyPI 配置同名 - Trusted Publisher(environment 名 `pypi`);③ 在 PyPI 上确认包名未被占用。 -- **不可逆**:正式发布前务必先在 TestPyPI 跑通一次。 +唯一流程入口为 docs/PUBLISHING.md;原模板仓库不能上传占位包。复制并改名、替换元数据、显式启用仓库变量并配置受保护环境后,只能从main手动选择已验证Tag上传。构建与OIDC上传分离,不给测试或构建任务id-token权限。Tag推送不会触发上传。 + +准备发布不等于正式发布,不打Tag、不上传索引,除非用户明确授权。已发布Tag不移动、资产不覆盖。源码与wheel/源码包重建、模板改名、安装后的真实HTTP和类型消费都必须验证。 ## 边界(不要随意改动) - 不要切换 HTTP 库到 `httpx`(模板以 `requests` 为最小依赖示范)。 - 不要引入 Copier / cookiecutter 等额外脚手架(保持零模板引擎依赖)。 -- 不要改动 `requires-python` 下限,除非明确需要。 +- Python>=3.11是0.4维护基线;调整须说明支持状态和依赖安全依据,不为测试通过忽略漏洞。 - 不要删除 `NullHandler` 或 `__all__`,它们是库的基本契约。 diff --git a/CHANGELOG.md b/CHANGELOG.md index d199869..05e333f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ 格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/), 版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。 +## [0.4.0] - 待发布 + +### 修复与安全默认 +- 改名后User-Agent测试不再硬编码旧包名;拒绝Python关键字和路径式包名,AI入口及动态版本引用同步。 +- 默认关闭写操作状态码重试与自动重定向;读取错误不重放,写入重试须显式允许并由调用方保证幂等。 +- 真正启用jitter,修复单请求timeout冲突和调用方headers被修改;错误消息不默认回显远端正文。 +- 基于维护和安全状态将Python下限提升至3.11,Requests>=2.33、urllib3>=2.8;setuptools最低77.0.3匹配SPDX许可写法。 + +### 工程与验证 +- 添加类型标记、本地真实HTTP回归、模板工具和占位发布保护测试。 +- CI扩展解释器及Windows/macOS,增加改名、wheel消费、类型消费、最低依赖和无Git源码包重建验证。 +- 发布改为显式启用、手动选择Tag,质量门禁、构建与OIDC上传分离;修正授权文档。 + ## [0.3.0] - 2026-10-19 ### 新增 (Added) diff --git a/MANIFEST.in b/MANIFEST.in index f27fd06..fa6a168 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -2,4 +2,8 @@ include README.md include LICENSE recursive-include tests * recursive-exclude * __pycache__ -recursive-exclude * *.py[co] \ No newline at end of file +recursive-exclude * *.py[co] +recursive-include src py.typed +recursive-include scripts *.py +recursive-include docs *.md +include AGENTS.md developer_guide.md CHANGELOG.md diff --git a/README.md b/README.md index d68a191..5423bfd 100644 --- a/README.md +++ b/README.md @@ -1,92 +1,65 @@ -# Example +# Python SDK 模板 -> **Template notice:** This repository is a template. The package is named -> `example` as a neutral placeholder. Before using it as your own project, rename -> it with the provided helper: -> `python scripts/rename_template.py ` +> **Template notice:** 本仓库是轻量模板,占位包名为 `example`,不能原样上传 PyPI。 +> 使用前先运行 `python scripts/rename_template.py `, +> 再替换维护者、项目链接并完成验证。 -A minimal Python SDK utility template that provides common building blocks for -building Python SDKs: an HTTP client wrapper with auth, retry, and error -handling. +保留 requests/setuptools,提供 HTTP 客户端、认证与异常示例,不是完整业务框架。 +Python >=3.11;CI验证3.11–3.14,以及Linux、Windows、macOS。其他解释器未承诺通过。 -## Installation +## 安装与开发 -For development (editable install): - -```bash -pip install -e . +```sh +python -m pip install . # 仅运行依赖 +python -m pip install -e '.[dev]' # 开发、构建与检查工具 +python -m pytest -q +ruff check . +ruff format --check src tests scripts +mypy src ``` -To install the runtime dependencies only: - -```bash -pip install ".[dev]" # includes pytest and ruff for development -``` - -## Features - -- API client utilities (`APIClient`) with built-in retry and error handling -- Authentication helpers (`BearerAuth`, `BasicAuth`, `ApiKeyAuth`) -- Automatic retry with exponential backoff + jitter (429 and 5xx) -- Typed error handling (`APIError`, `RateLimitError`, `AuthenticationError`) -- Type-checked (`mypy`) and linted (`ruff`); tested across Python 3.9–3.13 +版本唯一来源是 `src/example/__init__.py` 的 `__version__`;发行物携带 `py.typed`。 +构建后端下限77.0.3以支持SPDX许可。依赖范围由库声明,具体应用自行锁定。 -## Using this template +## 从模板开始 -This repo is a starting point, not a final package. The package name `example` -is a placeholder. To make it your own, run the rename helper (it moves the -source directory, updates `pyproject.toml`, rewrites imports, and fixes the -docs in one step): - -```bash -python scripts/rename_template.py my_sdk # dry-run first with --dry-run -git add -A && git commit -m "chore: rename template to my_sdk" -pip install -e . -pytest +```sh +python scripts/rename_template.py my_sdk --dry-run +python scripts/rename_template.py my_sdk +python -m pip install -e '.[dev]' +python -m pytest -q ``` -After renaming, update `authors` and `project.urls` in `pyproject.toml` for -your project. +改名后必须修改 pyproject.toml 的 authors、project.urls、描述和适合自己项目的版本号。 +核对diff和暂存清单后提交,格式如 `chore(template): 完成模板改名`。 -## Publishing to PyPI +```sh +python scripts/verify_package.py --rename +python scripts/verify_package.py --rename --minimum +``` -The template is structured for easy publishing with **Trusted Publishing -(OIDC)** — no API token stored in the repo. See -[docs/PUBLISHING.md](docs/PUBLISHING.md) for the full workflow (TestPyPI -verification, `twine check`, release from a tag, and the GitHub Actions -`publish.yml`). +验证器在临时副本完成改名、构建、Twine检查、无Git的源码包最低后端重建,以及独立安装后的真实HTTP/类型消费测试;不修改调用者的包安装、全局设置或原项目。 -## Usage +## 客户端使用与行为边界 ```python -from example.client import APIClient -from example.auth import BearerAuth - -# Create an authenticated client -client = APIClient( - base_url="https://api.example.com", - auth=BearerAuth("your_token_here"), - timeout=30, -) - -# The client is also a context manager -with client: - # Make API requests with automatic retry and error handling - response = client.get("/endpoint", params={"key": "value"}) - - # Parse the response +from example import APIClient, BearerAuth + +with APIClient("https://api.example.com", auth=BearerAuth("YOUR_TOKEN"), timeout=30) as client: + response = client.get("/items", timeout=(3, 10)) data = response.json() - print(data) ``` -## Documentation - -For full documentation, visit [the project repository](https://github.com/guyue55/py_example). - -## Contributing +- 默认只对GET/HEAD/OPTIONS的429和常见5xx进行有限重试,指数退避包含jitter。连接建立前失败可重试;读取失败不重放,因为服务端可能已执行操作。 +- POST/PATCH/PUT/DELETE默认不进行状态码重试。服务端明确支持幂等键时,才使用如 `retry_methods={"GET", "POST"}` 显式开启;本库不生成或验证幂等键。 +- 默认不跟随重定向,防止自定义API-Key头意外转发;3xx原样返回。调用方可显式传 `allow_redirects=True`,并自行核实目的地和认证策略。 +- timeout遵循Requests的连接/读取语义,支持单请求覆盖,不是包含退避和Retry-After的总执行期限。 +- 默认异常消息不复制响应正文或底层URL;response和异常cause仍可包含敏感信息,不应直接记录完整对象或traceback到公开日志。 +- 正常关闭客户端会释放连接;流式响应由调用方负责关闭。客户端不提供异步、数据库、自动业务幂等或跨线程安全承诺。 -Contributions are welcome! Please feel free to submit a Pull Request. +## 发布 -## License +模板本身可发布GitHub源码版本,不能上传占位 `example` 包。 +复制并改名后的项目须显式设置 `ENABLE_PYPI_PUBLISH=true`、配置PyPI Trusted Publisher及受保护GitHub环境,再从main手动选择已经验证的Tag发布。推送Tag不会自动上传。 -This project is licensed under the MIT License - see the LICENSE file for details. +见 [发布流程](docs/PUBLISHING.md)、[开发与维护](developer_guide.md)、[AI协作说明](AGENTS.md)、[0.4迁移](docs/MIGRATION_0_4.md)。MIT许可见 [LICENSE](LICENSE)。 diff --git a/developer_guide.md b/developer_guide.md index ad78942..01d088e 100644 --- a/developer_guide.md +++ b/developer_guide.md @@ -1,87 +1,24 @@ -# 1. 开发指南 +# 开发与维护 -## 1.1 环境设置 +Python>=3.11,使用受支持解释器的最新补丁版本。保留requests/setuptools和轻量SDK边界,不引入业务、服务端或模板引擎。 -1. 克隆仓库: -```bash -git clone https://github.com/guyue55/py_example.git -cd py_example -``` - -2. 安装开发依赖(含测试与 lint 工具): -```bash -pip install -e ".[dev]" -``` - -## 2.2 运行测试 - -```bash -pytest -``` - -## 2.2.1 代码风格检查 - -```bash +```sh +python -m pip install -e '.[dev]' ruff check . +ruff format --check src tests scripts +mypy src +python -m pytest -q +python scripts/verify_package.py +python scripts/verify_package.py --rename +python scripts/verify_package.py --rename --minimum ``` -## 2.3 代码贡献指南 - -1. Fork 项目仓库 -2. 创建功能分支 -3. 提交变更 -4. 运行测试确保通过 -5. 提交 Pull Request - -# 3. 发布流程 - -## 3.1 版本管理 - -- 遵循语义化版本规范。 -- **唯一手写版本**位于 `src/example/__init__.py` 的 `__version__`。 -- `pyproject.toml` 通过 `dynamic = ["version"]` 从该值自动派生,**无需手动同步**, - 不要在 pyproject 里再写静态 `version`。 -- 每次发布在 `CHANGELOG.md` 记录变更(Keep a Changelog 格式)。 - -## 3.2 打包和发布 - -> 推荐方式:Trusted Publishing(OIDC),无需在仓库中保存 API token。 -> 完整流程见 [docs/PUBLISHING.md](docs/PUBLISHING.md),GitHub Actions 已提供 -> `.github/workflows/publish.yml`(推送 `v*.*.*` 标签即自动发布)。 - -本地自检步骤: - -先安装依赖 -```bash -python -m pip install --upgrade build twine -``` - -1. 构建分发包: -```bash -python -m build -``` +源码单元测试可用Mock定位错误;tests/test_http.py用独立本地端口验证真实重试次数、写入保护、超时与重定向。不要用静态适配器配置断言替代传输行为验证。 -2. 检查包是否符合PyPI的要求 -```bash -python -m twine check dist/* -``` - -3. 打版本标签并推送(触发自动发布): -```bash -git tag v0.2.0 -git push origin v0.2.0 -``` +verify_package.py的editable安装仅存在于临时副本的source-env;wheel消费者另建环境,且不复制src和pyproject,避免源码遮蔽发行包。原始与重建wheel均测试;源码包用最低后端77.0.3、无Git构建。最低直接依赖固定为Requests2.33.0/urllib3 2.8.0;传递依赖正常解析,不声称所有历史最低组合均测试。 +CI另解析并审计运行依赖,审计失败阻止合并/上传;安全通告检查不是完整安全认证。 -**注意事项** +只手动修改src/example/__init__.py中的__version__。改名器同步动态属性和导入路径;提交前审阅显式暂存清单,使用type(scope): 中文说明,不混入本机状态。main走PR,最终main CI与候选提交须一致。 -- **模板本身不发布**:本仓库包名为占位符 `example`,请先用 - `scripts/rename_template.py` 改名后再发布。 -- **API token(可选)**:若不使用 Trusted Publishing,上传到 **PyPI** 需要 - API token,没有的话先去网站注册账号再生成(https://pypi.org/)。 -- **避免每次都要输入 token**:设置 `$HOME/.pypirc` - ``` - [pypi] - username = __token__ - password = pypi-xxxxxx - ``` \ No newline at end of file +发布与回滚的唯一流程入口:[PUBLISHING.md](docs/PUBLISHING.md)。不在本指南复制另一套token或自动Tag发布方案。 diff --git a/docs/MIGRATION_0_4.md b/docs/MIGRATION_0_4.md new file mode 100644 index 0000000..af8d1b2 --- /dev/null +++ b/docs/MIGRATION_0_4.md @@ -0,0 +1,12 @@ +# 0.4迁移与范围 + +- 最低Python从3.9提升至3.11:3.9已在2025年、3.10已在2026年10月停止维护;新依赖基线Requests>=2.33和urllib3>=2.8包含旧版本安全修复。不忽略安全通告来维持旧解释器。 +- 默认写操作状态码重试关闭;仅在服务端支持幂等键等保证时显式配置retry_methods。连接建立前失败仍可有限重试;读取错误不重放。 +- 默认重定向关闭,返回3xx供应用判断。必要时显式allow_redirects=True并核实认证转发。 +- jitter从文案承诺变成真实参数retry_jitter。timeout支持单次请求覆盖,但不是总deadline。 +- 错误消息不再默认拼接远端正文;可在受控场景从exception.response访问详细内容,注意脱敏。 +- Publish改为默认禁用的手动流程,Tag推送不再上传;变更授权流程见发布文档。 + +保留库导入及现有认证类、HTTP方法和上下文管理器;本版是0.x次版本变更,不应当作无行为差异补丁升级。 + +[Python维护状态](https://devguide.python.org/versions/)、[Requests安全](https://github.com/psf/requests/security/advisories)、[urllib3安全](https://github.com/urllib3/urllib3/security/advisories)。 diff --git a/docs/PUBLISHING.md b/docs/PUBLISHING.md index 13c5390..5047dd0 100644 --- a/docs/PUBLISHING.md +++ b/docs/PUBLISHING.md @@ -1,64 +1,34 @@ -# 发布到 PyPI(Trusted Publishing) +# 发布与回滚 -本模板本身以占位包名 `example` 存在,**不应直接发布**。当你已用 -`scripts/rename_template.py` 改名成自己的包名后,即可按本文档发布到 PyPI。 +## 模板与实际库的区别 -推荐使用 **Trusted Publishing(OIDC)**,无需在仓库中存放任何 API token。 +`guyue55/py_example`是模板,保留占位包名example,只准备/发布GitHub源码版本。其Publish工作流显式拒绝在原仓库上传,也不会因Tag推送运行。 -## 流程概览 +使用模板的实际库先改名并替换authors/project.urls,再走下列步骤。不要原样上传example包或保留占位维护者。 -1. 改名并完善元数据(`authors`、`project.urls`、描述)。 -2. 本地构建并检查:`python -m build` + `python -m twine check dist/*`。 -3. 先在 **TestPyPI** 验证一次。 -4. 打版本标签 `vX.Y.Z`,推送标签触发 GitHub Actions 自动发布到正式 PyPI。 +## 本地准备 -## 1. 本地构建与自检 +1. 修改src下唯一__version__,更新CHANGELOG和发布说明;版本Tag必须与之完全一致。 +2. 安装开发依赖,执行开发指南中的格式/类型/源码测试。 +3. 执行 `python scripts/verify_package.py --rename` 和 `--minimum`,验证模板改名链路;实际库也可运行不带--rename的验证器。 +4. 在最终main提交上构建 `python -m build`;运行 `python -m twine check --strict dist/*`。Twine主要检查说明文档渲染,不是完整安全或功能认证。 +5. 检查许可证、元数据、源码、测试、来源SHA和摘要。不得把本机环境、WorkBuddy状态或凭证打包。 +6. main完整CI通过后,创建指向同一提交的附注Tag。已发布Tag或同名资产不得移动/覆盖,修复另发版本。 -```bash -python -m pip install --upgrade build twine -python -m build -python -m twine check dist/* -``` +## 复制后启用PyPI -`twine check` 会校验 `long_description`(README)、元数据与 wheel 结构。 +- PyPI/TestPyPI各自配置Trusted Publisher:owner、repository、workflow文件publish.yml,以及环境名pypi或testpypi。 +- GitHub仓库创建对应环境,设置审批人和允许main的分支规则;配置仓库变量ENABLE_PYPI_PUBLISH=true。 +- 在main的Publish工作流手动填写Tag和目标(默认testpypi)。Tag必须存在、属于main历史、版本与非占位元数据一致。 +- preflight固定提交 → 复用完整CI → 独立构建验包 → 制品传递 → 环境审批 → 上传。只有最后上传任务具备id-token:write;上传任务不检出源码、不执行构建。 +- 正式上传前核验目标、名称与授权。TestPyPI检查流程不等于正式索引依赖解析已通过;上传后匿名下载/安装复验。 -## 2. 配置 Trusted Publisher(一次性) +PyPI没有在上述GitHub Publisher配置中提供任意Tag通配输入;Tag限制由GitHub工作流和环境落实。不要笼统认为私有仓库必须使用长期token;按实际OIDC支持与权限核验。 -在 PyPI(以及可选的 TestPyPI)后台为你的包名添加 **Trusted Publisher**: +默认不使用长期token,也不建议将token写入.pypirc示例。可信发布仅验证身份,不证明代码或构建物安全。 -- **PyPI**:Account → Publishing(或某包的管理页)→ Add a new pending - publisher。 -- 填写: - - **PyPI project name**:你的真实包名(与 `pyproject.toml` 的 `name` 一致)。 - - **Owner / Repository**:你的 GitHub 仓库(如 `guyue55/py_example`)。 - - **Workflow name**:`publish.yml`。 - - **Environment name**:留空或填 `pypi`(与 `publish.yml` 中 `environment` 对应)。 - - **Tag / branch**:建议用 tag 模式,例如 `v*.*.*`。 +## 失败与回滚 -> 无需生成、无需在仓库里保存 PyPI token——OIDC 会在工作流运行时自动签发临时凭证。 +CI或验包失败停止发布;上传状态未知先查询索引,不盲目重复写入。已上传版本不可原地修复;必要时撤回可发现性并发布修正版,记录影响,不保证删除第三方下载副本。 -## 3. 发布(从 tag 触发) - -本仓库已提供 `.github/workflows/publish.yml`:当推送 `v*.*.*` 标签时,自动构建 -并通过 `pypa/gh-action-pypi-publish` 发布。 - -```bash -git tag v0.1.0 -git push origin v0.1.0 -``` - -- 发布正式 PyPI:默认行为。 -- 发布到 TestPyPI 验证:在 `publish.yml` 中取消注释 `repository-url` 那一行, - 并先在 TestPyPI 配置同名 Trusted Publisher,然后打一个测试标签即可。 - -## 4. 版本号与 CHANGELOG - -- 版本唯一手写源是 `src/<你的包>/__init__.py` 的 `__version__`;`pyproject.toml` - 通过 `dynamic = ["version"]` 自动派生,**只需改这一处**。 -- 在 `CHANGELOG.md` 记录本次变更(Keep a Changelog 格式)。 - -## 注意事项 - -- 包名在 PyPI 上全局唯一,改名时请先确认未被占用。 -- Trusted Publishing 要求仓库为**公开**或已正确授权;私有仓库请改用 token 方式。 -- 发布是不可逆操作;正式发布前务必先在 TestPyPI 跑通一次。 +参考:[PyPA打包](https://packaging.python.org/en/latest/guides/writing-pyproject-toml/)、[PyPI配置](https://docs.pypi.org/trusted-publishers/adding-a-publisher/)、[PyPI安全模型](https://docs.pypi.org/trusted-publishers/security-model/)。 diff --git a/pyproject.toml b/pyproject.toml index 523f36e..ed6596f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=61"] +requires = ["setuptools>=77.0.3"] build-backend = "setuptools.build_meta" [project] @@ -7,22 +7,23 @@ name = "example" dynamic = ["version"] description = "A minimal Python SDK utility template: HTTP client wrapper with auth, retry, and error handling." readme = "README.md" -requires-python = ">=3.9" +requires-python = ">=3.11" license = "MIT" +license-files = ["LICENSE"] authors = [{ name = "Your Name", email = "you@example.com" }] keywords = ["sdk", "http", "client", "template"] -dependencies = ["requests>=2.28"] +dependencies = ["requests>=2.33,<3", "urllib3>=2.8,<3"] classifiers = [ "Development Status :: 3 - Alpha", "Intended Audience :: Developers", "Topic :: Software Development :: Libraries :: Python Modules", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Typing :: Typed", ] [project.urls] @@ -30,21 +31,24 @@ Homepage = "https://github.com/guyue55/py_example" "Bug Reports" = "https://github.com/guyue55/py_example/issues" [project.optional-dependencies] -dev = ["pytest>=7", "ruff>=0.1", "mypy>=1.0,<2", "types-requests"] +dev = ["pytest>=7", "ruff>=0.1", "mypy>=1.0,<2", "types-requests", "build>=1,<2", "packaging>=24", "twine>=6,<8"] [tool.setuptools.packages.find] where = ["src"] +[tool.setuptools.package-data] +"*" = ["py.typed"] + [tool.setuptools.dynamic] version = {attr = "example.__version__"} [tool.pytest.ini_options] -pythonpath = ["src"] testpaths = ["tests"] +addopts = "-ra --import-mode=importlib" [tool.ruff] line-length = 100 -target-version = "py39" +target-version = "py311" [tool.ruff.lint] select = ["E", "F", "I", "W"] diff --git a/releases/v0.4.0.md b/releases/v0.4.0.md new file mode 100644 index 0000000..08cc621 --- /dev/null +++ b/releases/v0.4.0.md @@ -0,0 +1,9 @@ +# py_example v0.4.0(待发布) + +Python SDK模板维护版本,不是名为example的正式PyPI产品。本轮只准备GitHub源码版本;不得原样上传占位包。 + +重要迁移:Python最低3.11;默认写请求状态码重试及重定向关闭。详见docs/MIGRATION_0_4.md。 + +修复改名后测试失败、timeout覆盖与headers变异;补充真实jitter、安全依赖、发行包独立消费、最低构建后端、真实HTTP与类型测试。发布默认关闭,复制后需配置环境与信任、显式手动选择已验证Tag。 + +候选须来自最终main及对应CI,保留MIT许可、源码、测试与维护文档,排除本机状态。未正式发布前不创建Tag或Release。 diff --git a/scripts/check_release.py b/scripts/check_release.py new file mode 100644 index 0000000..cc96778 --- /dev/null +++ b/scripts/check_release.py @@ -0,0 +1,37 @@ +"""发布前明确拒绝占位模板、占位维护者和未匹配的版本标签;不上传任何内容。""" + +import argparse + +from project_metadata import metadata + + +def validate(config, version, tag): + project = config["project"] + if project["name"].lower().replace("_", "-").replace(".", "-") == "example": + raise ValueError("占位模板禁止上传PyPI,请先改名") + if not project.get("authors") or any( + not a.get("name", "").strip() + or a.get("name", "").strip().lower() == "your name" + or a.get("email", "").lower() == "you@example.com" + for a in project["authors"] + ): + raise ValueError("发布前必须替换维护者占位信息") + if not project.get("urls") or any( + "github.com/guyue55/py_example" in url.lower() for url in project["urls"].values() + ): + raise ValueError("发布前必须更新项目链接") + if tag != "v" + version: + raise ValueError("发布标签与源码版本不一致") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--tag", required=True) + args = parser.parse_args() + config, _, version = metadata() + validate(config, version, args.tag) + print("RELEASE_PREFLIGHT_OK", version) + + +if __name__ == "__main__": + main() diff --git a/scripts/project_metadata.py b/scripts/project_metadata.py new file mode 100644 index 0000000..e6c6c2f --- /dev/null +++ b/scripts/project_metadata.py @@ -0,0 +1,27 @@ +"""读取模板或改名后项目的静态元数据;不导入包或执行版本代码。""" + +import ast +import re +import tomllib +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def metadata(root=ROOT): + config = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8")) + attr = config["tool"]["setuptools"]["dynamic"]["version"]["attr"] + package, symbol = attr.rsplit(".", 1) + if not re.fullmatch(r"[a-z][a-z0-9_]*", package) or symbol != "__version__": + raise ValueError("动态版本路径必须指向顶层包的__version__") + module = ast.parse((root / "src" / package / "__init__.py").read_text(encoding="utf-8")) + versions = [ + node.value.value + for node in module.body + if isinstance(node, ast.Assign) + and isinstance(node.value, ast.Constant) + and any(isinstance(t, ast.Name) and t.id == symbol for t in node.targets) + ] + if len(versions) != 1 or not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", versions[0]): + raise ValueError("需要唯一且静态的稳定版本") + return config, package, versions[0] diff --git a/scripts/rename_template.py b/scripts/rename_template.py index 57389bf..567f657 100644 --- a/scripts/rename_template.py +++ b/scripts/rename_template.py @@ -29,6 +29,7 @@ from __future__ import annotations import argparse +import keyword import os import re import shutil @@ -48,7 +49,7 @@ def is_valid_package_name(name: str) -> bool: """Return True if ``name`` is a legal Python package identifier.""" - return bool(re.fullmatch(r"[a-z_][a-z0-9_]*", name)) + return bool(re.fullmatch(r"[a-z][a-z0-9_]*", name)) and not keyword.iskeyword(name) def rewrite_imports(text: str, new_name: str) -> str: @@ -99,7 +100,7 @@ def read_text(path: str) -> str: def write_text(path: str, text: str) -> None: - with open(path, "w", encoding="utf-8") as fh: + with open(path, "w", encoding="utf-8", newline="\n") as fh: fh.write(text) @@ -139,13 +140,15 @@ def collect_edits(new_name: str) -> list[tuple[str, str]]: if new != txt: edits.append((readme, new)) - # developer_guide.md (Chinese guide references src/example path) - guide = os.path.join(REPO_ROOT, "developer_guide.md") - if os.path.exists(guide): - txt = read_text(guide) - new = txt.replace("src/example", f"src/{new_name}") - if new != txt: - edits.append((guide, new)) + # AI入口和维护文档也跟随导入路径;保留模板工具本身的固定占位语义。 + for filename in ("developer_guide.md", "AGENTS.md", "docs/PUBLISHING.md"): + guide = os.path.join(REPO_ROOT, filename) + if os.path.exists(guide): + txt = read_text(guide) + new = rewrite_imports(txt, new_name).replace("src/example", f"src/{new_name}") + new = new.replace("example.__version__", f"{new_name}.__version__") + if new != txt: + edits.append((guide, new)) return edits @@ -173,6 +176,9 @@ def main() -> int: print(f"ERROR: target already exists: {target}") return 1 + if not os.path.isdir(SRC_EXAMPLE) or os.path.islink(SRC_EXAMPLE): + print("ERROR: src/example must be an existing regular template directory") + return 1 edits = collect_edits(new_name) if args.dry_run: @@ -189,7 +195,7 @@ def main() -> int: print(f"Renamed package 'example' -> '{new_name}'.") print("Next steps:") - print(" 1. git add -A && git commit -m 'chore: rename template to '") + print(" 1. 审核diff和暂存清单,再以 chore(template): 完成模板改名 提交") print(" 2. pip install -e .") print(" 3. pytest") print(" 4. Update authors/urls in pyproject.toml for your project.") diff --git a/scripts/verify_package.py b/scripts/verify_package.py new file mode 100644 index 0000000..9646df2 --- /dev/null +++ b/scripts/verify_package.py @@ -0,0 +1,178 @@ +"""临时副本中验证改名、源码包重建及安装后的功能与类型,不修改原项目。""" + +import argparse +import json +import os +import shutil +import subprocess +import sys +import tempfile +import venv +import zipfile +from email.parser import BytesParser +from pathlib import Path + +from packaging.requirements import Requirement +from project_metadata import ROOT, metadata + + +def clean_environment(): + env = os.environ.copy() + for name in ("PYTHONPATH", "PYTHONHOME", "PYTHONUSERBASE", "PYTEST_ADDOPTS", "PYTEST_PLUGINS"): + env.pop(name, None) + env["PYTHONNOUSERSITE"] = "1" + return env + + +def run(args, cwd): + subprocess.run([str(a) for a in args], cwd=cwd, env=clean_environment(), check=True) + + +def copy_project(target): + # 白名单复制可交付源码,排除本机WorkBuddy状态、环境与缓存;无需.git。 + for name in [ + "pyproject.toml", + "MANIFEST.in", + "README.md", + "LICENSE", + "AGENTS.md", + "developer_guide.md", + "CHANGELOG.md", + ]: + shutil.copy2(ROOT / name, target / name) + for name in ["src", "tests", "scripts", "docs", ".github"]: + if name == ".github" and not (ROOT / name).is_dir(): + continue + shutil.copytree( + ROOT / name, + target / name, + ignore=shutil.ignore_patterns("__pycache__", "*.pyc", "*.egg-info"), + ) + + +def inspect_wheel(wheel, config, package, version): + with zipfile.ZipFile(wheel) as archive: + names = archive.namelist() + if len(names) != len(set(names)): + raise ValueError("wheel包含重复成员") + info = next(n for n in names if n.endswith(".dist-info/METADATA")) + meta = BytesParser().parsebytes(archive.read(info)) + if ( + meta["Name"].replace("_", "-") != config["project"]["name"].replace("_", "-") + or meta["Version"] != version + ): + raise ValueError("发行物名称或版本不一致") + project = config["project"] + expected = {str(Requirement(d)) for d in project["dependencies"]} + expected.update( + str(Requirement(d + '; extra == "dev"')) + for d in project["optional-dependencies"]["dev"] + ) + if ( + meta["Requires-Python"] != project["requires-python"] + or meta["License-Expression"] != project["license"] + or {str(Requirement(d)) for d in meta.get_all("Requires-Dist", [])} != expected + ): + raise ValueError("发行物依赖、Python范围或许可不一致") + if f"{package}/py.typed" not in names or not any( + n.endswith("/licenses/LICENSE") for n in names + ): + raise ValueError("发行物缺少类型标记或许可") + + +def verify(root, minimum=False, artifacts=None): + config, package, version = metadata(root) + dist = artifacts if artifacts else root / "dist" + if not artifacts: + run([sys.executable, "-m", "build", "--outdir", dist], root) + wheels, sources = list(dist.glob("*.whl")), list(dist.glob("*.tar.gz")) + if len(wheels) != 1 or len(sources) != 1: + raise ValueError("验收目录必须恰好包含一个wheel和一个sdist") + wheel, sdist = wheels[0], sources[0] + inspect_wheel(wheel, config, package, version) + run([sys.executable, "-m", "twine", "check", "--strict", wheel, sdist], root) + rebuilt = root / "rebuilt" + # 从tar输入独立解包,使用最低setuptools,不依赖Git或editable安装。 + import tarfile + + extracted = root / "unpacked" + with tarfile.open(sdist) as archive: + archive.extractall(extracted, filter="data") + source = next(extracted.iterdir()) + constraint = root / "backend.txt" + constraint.write_text("setuptools==77.0.3\n", encoding="utf-8") + env = clean_environment() + env["PIP_CONSTRAINT"] = str(constraint) + subprocess.run( + [sys.executable, "-m", "build", "--wheel", "--outdir", str(rebuilt)], + cwd=source, + env=env, + check=True, + ) + rebuilt_wheel = next(rebuilt.glob("*.whl")) + inspect_wheel(rebuilt_wheel, config, package, version) + with zipfile.ZipFile(wheel) as a, zipfile.ZipFile(rebuilt_wheel) as b: + original = {n: a.read(n) for n in a.namelist() if n.startswith(package + "/")} + repeated = {n: b.read(n) for n in b.namelist() if n.startswith(package + "/")} + if original != repeated: + raise ValueError("源码包重建的包源码不一致") + for artifact in (wheel, rebuilt_wheel): + with tempfile.TemporaryDirectory(prefix="sdk-consumer-") as folder: + consumer = Path(folder) + venv.EnvBuilder(with_pip=True).create(consumer / "env") + python = consumer / "env" / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + requirements = ["pytest>=7", "mypy>=1,<2", "types-requests"] + if minimum: + requirements += ["requests==2.33.0", "urllib3==2.8.0"] + run([python, "-m", "pip", "install", artifact, *requirements], consumer) + code = f"""import importlib.metadata, importlib.resources, pathlib, sys +import {package} as sdk +assert sdk.__version__ == importlib.metadata.version({config["project"]["name"]!r}) == {version!r} +assert pathlib.Path(sdk.__file__).resolve().is_relative_to(pathlib.Path(sys.prefix).resolve()) +assert importlib.resources.files(sdk).joinpath("py.typed").is_file() +with sdk.APIClient("https://example.invalid") as client: + assert client.retry_methods == frozenset({{"GET", "HEAD", "OPTIONS"}}) +""" + run([python, "-I", "-c", code], consumer) + (consumer / "tests").mkdir() + for name in ["test_auth.py", "test_client.py", "test_http.py"]: + shutil.copy2(root / "tests" / name, consumer / "tests" / name) + run([python, "-m", "pytest", "--import-mode=importlib", "-q", "tests"], consumer) + (consumer / "consumer.py").write_text( + f"from {package} import APIClient\n" + "with APIClient('https://example.invalid') as c:\n" + " r = c.get('/x')\n reveal_type(r)\n", + encoding="utf-8", + ) + run([python, "-m", "mypy", "consumer.py", "--check-untyped-defs"], consumer) + run([python, "-m", "pip", "check"], consumer) + print("PACKAGE_VERIFIED", json.dumps({"name": package, "version": version, "minimum": minimum})) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--rename", action="store_true") + parser.add_argument("--minimum", action="store_true") + parser.add_argument("--artifacts", type=Path, help="验证实际待上传目录,不另行构建原wheel") + args = parser.parse_args() + if args.artifacts and args.rename: + parser.error("实际发行物验收不能同时改名") + artifacts = args.artifacts.resolve() if args.artifacts else None + with tempfile.TemporaryDirectory(prefix="sdk-template-") as folder: + root = Path(folder) + copy_project(root) + _, original_package, _ = metadata(root) + if args.rename and original_package == "example": + run([sys.executable, "scripts/rename_template.py", "my_sdk"], root) + elif args.rename: + print("RENAME_NOT_APPLICABLE", original_package, flush=True) + venv.EnvBuilder(with_pip=True).create(root / "source-env") + python = root / "source-env" / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + run([python, "-m", "pip", "install", "-e", ".[dev]"], root) + # editable只存在于副本的独立环境;不改调用者环境的包安装。 + run([python, "-m", "pytest", "-q"], root) + verify(root, args.minimum, artifacts) + + +if __name__ == "__main__": + main() diff --git a/src/example/__init__.py b/src/example/__init__.py index cca1e8b..10b3574 100644 --- a/src/example/__init__.py +++ b/src/example/__init__.py @@ -9,7 +9,7 @@ # Defined before the submodule imports so that ``from . import __version__`` # inside client.py resolves cleanly (no circular import during package init). -__version__ = "0.3.0" +__version__ = "0.4.0" from .auth import ApiKeyAuth, BasicAuth, BearerAuth from .client import APIClient diff --git a/src/example/client.py b/src/example/client.py index 8a56849..ecfc89a 100644 --- a/src/example/client.py +++ b/src/example/client.py @@ -4,11 +4,13 @@ """ import logging -from typing import Any, Dict, Optional +import math +from typing import Any, Dict, Iterable, Optional import requests from requests.adapters import HTTPAdapter from requests.auth import AuthBase +from requests.structures import CaseInsensitiveDict from urllib3.util.retry import Retry from . import __version__ @@ -25,9 +27,8 @@ # retried. DEFAULT_STATUS_FORCELIST = [429, 500, 502, 503, 504] -# Methods we allow to be retried. By default urllib3 only retries "safe" -# methods; we opt in to the write methods used by a typical SDK client. -RETRY_ALLOWED_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"] +# 只读方法默认可重试;写操作必须由调用方依据服务端幂等保证显式开启。 +RETRY_ALLOWED_METHODS = frozenset({"GET", "HEAD", "OPTIONS"}) class APIClient: @@ -36,7 +37,9 @@ class APIClient: Retries (including exponential backoff with jitter) are handled at the transport layer via :class:`urllib3.util.retry.Retry` mounted on the underlying :class:`requests.Session`. This covers 429 rate-limit responses, - 5xx server errors, connection errors, and timeouts. + 5xx server errors and pre-send connection errors. Read errors are not + retried because the remote side may already have performed the operation. + Request timeout is not an overall deadline including backoff/Retry-After. Attributes: base_url (str): The base URL for the API. @@ -52,7 +55,10 @@ def __init__( auth: Optional[AuthBase] = None, timeout: int = 60, max_retries: int = 3, - retry_delay: int = 1, + retry_delay: float = 1, + *, + retry_methods: Optional[Iterable[str]] = None, + retry_jitter: float = 0.2, ): """Initialize the API client. @@ -62,7 +68,23 @@ def __init__( timeout: Timeout for requests in seconds. max_retries: Maximum number of retry attempts for failed requests. retry_delay: Base backoff factor (seconds) for exponential backoff. + retry_methods: Explicit retry allowlist; defaults to GET/HEAD/OPTIONS. + Writes require a server-side idempotency guarantee. + retry_jitter: Maximum random jitter added to exponential backoff. """ + if isinstance(max_retries, bool) or not isinstance(max_retries, int) or max_retries < 0: + raise ValueError("max_retries必须是非负整数") + if any(not math.isfinite(v) or v < 0 for v in (retry_delay, retry_jitter)): + raise ValueError("退避与抖动必须是有限非负数") + if isinstance(retry_methods, str): + raise ValueError("retry_methods必须是方法名集合,不能是单个字符串") + methods = RETRY_ALLOWED_METHODS if retry_methods is None else frozenset(retry_methods) + if not methods or any( + not isinstance(m, str) or not m.isascii() or not m.isalpha() for m in methods + ): + raise ValueError("retry_methods必须包含合法HTTP方法名") + self.retry_methods = frozenset(m.upper() for m in methods) + self.retry_jitter = retry_jitter self.base_url = base_url.rstrip("/") self.auth = auth self.timeout = timeout @@ -85,9 +107,13 @@ def _mount_retry_adapter(self) -> None: """ retry = Retry( total=self.max_retries, + connect=self.max_retries, + read=0, + other=0, backoff_factor=self.retry_delay, + backoff_jitter=self.retry_jitter, status_forcelist=DEFAULT_STATUS_FORCELIST, - allowed_methods=RETRY_ALLOWED_METHODS, + allowed_methods=self.retry_methods, respect_retry_after_header=True, raise_on_status=False, ) @@ -126,15 +152,13 @@ def _handle_response(self, response: requests.Response) -> requests.Response: return response except requests.exceptions.HTTPError as e: if response.status_code == 429: - raise RateLimitError( - f"Rate limit exceeded: {response.text}", response=response - ) from e + raise RateLimitError("API rate limit exceeded (HTTP 429)", response=response) from e elif response.status_code in (401, 403): raise AuthenticationError( - f"Authentication failed: {response.text}", response=response + f"API authentication failed (HTTP {response.status_code})", response=response ) from e else: - raise APIError(f"API error: {response.text}", response=response) from e + raise APIError(f"API error (HTTP {response.status_code})", response=response) from e def request( self, @@ -166,11 +190,15 @@ def request( APIError: If the request fails after all retries. """ url = self._build_url(endpoint) - headers = headers or {} + # 不修改调用方字典;HTTP头大小写不敏感。 + request_headers = CaseInsensitiveDict(headers or {}) + timeout = kwargs.pop("timeout", self.timeout) + # 自定义API-Key头未必会在跨域跳转时被移除,默认不自动跟随重定向。 + kwargs.setdefault("allow_redirects", False) # Add default User-Agent (driven by the package version). - if "User-Agent" not in headers: - headers["User-Agent"] = f"{_PACKAGE_NAME}/{__version__}" + if "User-Agent" not in request_headers: + request_headers["User-Agent"] = f"{_PACKAGE_NAME}/{__version__}" try: response = self.session.request( @@ -179,14 +207,14 @@ def request( params=params, data=data, json=json, - headers=headers, + headers=request_headers, files=files, - timeout=self.timeout, + timeout=timeout, **kwargs, ) - except requests.exceptions.RetryError as e: - # Retries exhausted on a transport-level failure (connection/timeout). - raise APIError(f"Request failed after {self.max_retries} retries: {e}") from e + except requests.exceptions.RequestException as e: + # 默认消息不复制URL、响应正文或认证信息;保留cause供受控诊断。 + raise APIError("API transport failed; check the underlying cause") from e return self._handle_response(response) diff --git a/src/example/py.typed b/src/example/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_client.py b/tests/test_client.py index 7bf43e2..9a3bd30 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,8 +1,10 @@ import unittest from unittest.mock import Mock, patch +import pytest import requests +from example import __version__ from example.client import APIClient from example.exceptions import APIError, AuthenticationError, RateLimitError @@ -15,6 +17,9 @@ def setUp(self): self.base_url = "https://api.example.com" self.client = APIClient(self.base_url) + def tearDown(self): + self.client.close() + def test_build_url(self): """Test URL building functionality.""" # Test with leading slash @@ -37,7 +42,10 @@ def test_user_agent_is_versioned(self): self.client.get("/endpoint") _, kwargs = mock_request.call_args - self.assertTrue(kwargs["headers"]["User-Agent"].startswith("example/")) + self.assertEqual( + kwargs["headers"]["User-Agent"], + f"{APIClient.__module__.split('.')[0]}/{__version__}", + ) @patch("requests.Session.request") def test_request_success(self, mock_request): @@ -113,12 +121,15 @@ def test_transport_retry_exhausted_raises_api_error(self): self.client.get("/endpoint") def test_retry_adapter_is_configured(self): - """The mounted adapter must retry 429/5xx and the write methods.""" + """默认重试只允许读取方法,读取错误不重放。""" adapter = self.client.session.get_adapter("https://") retry = adapter.max_retries self.assertEqual(retry.total, 3) self.assertEqual(retry.status_forcelist, [429, 500, 502, 503, 504]) - self.assertTrue({"POST", "PUT", "PATCH", "DELETE"}.issubset(set(retry.allowed_methods))) + self.assertEqual(set(retry.allowed_methods), {"GET", "HEAD", "OPTIONS"}) + self.assertEqual(retry.read, 0) + self.assertEqual(retry.other, 0) + self.assertEqual(retry.backoff_jitter, 0.2) self.assertFalse(retry.raise_on_status) def test_context_manager_closes_session(self): @@ -132,3 +143,31 @@ def test_context_manager_closes_session(self): if __name__ == "__main__": unittest.main() + + +def test_request_options_and_headers_do_not_mutate_caller(): + client = APIClient("https://example.invalid") + headers = {"user-agent": "caller"} + with client, patch.object(client.session, "request") as send: + response = Mock(spec=requests.Response) + response.raise_for_status.return_value = None + send.return_value = response + client.get("/x", headers=headers, timeout=0.5) + assert send.call_args.kwargs["timeout"] == 0.5 + assert send.call_args.kwargs["headers"]["User-Agent"] == "caller" + assert send.call_args.kwargs["allow_redirects"] is False + assert headers == {"user-agent": "caller"} + + +@pytest.mark.parametrize( + "options", + [ + {"max_retries": -1}, + {"retry_jitter": float("inf")}, + {"retry_methods": "POST"}, + {"retry_methods": []}, + ], +) +def test_invalid_retry_configuration_is_rejected(options): + with pytest.raises(ValueError): + APIClient("https://example.invalid", **options) diff --git a/tests/test_http.py b/tests/test_http.py new file mode 100644 index 0000000..82c652e --- /dev/null +++ b/tests/test_http.py @@ -0,0 +1,120 @@ +"""本地真实HTTP验证重试次数、写入保护、超时和重定向,不调用外部服务。""" + +import threading +import time +from collections import Counter +from contextlib import contextmanager +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +import pytest + +from example import APIClient, APIError, AuthenticationError, RateLimitError + + +def api_client(*args, **kwargs): + client = APIClient(*args, **kwargs) + client.session.trust_env = False + return client + + +@contextmanager +def server(): + calls = Counter() + + class Handler(BaseHTTPRequestHandler): + def handle_request(self): + calls[self.command, self.path] += 1 + if self.path == "/slow": + time.sleep(0.2) + if self.path == "/rate": + status = 429 + elif self.path == "/auth": + status = 401 + elif self.path == "/redirect": + status = 302 + elif self.path == "/write": + status = 503 + elif self.path == "/retry" and calls[self.command, self.path] < 3: + status = 503 + else: + status = 200 + self.send_response(status) + if status == 429: + self.send_header("Retry-After", "0") + if status == 302: + self.send_header("Location", "/destination") + self.end_headers() + try: + self.wfile.write(b"SYNTHETIC_RESPONSE_BODY") + except (BrokenPipeError, ConnectionResetError): + pass + + do_GET = handle_request + do_POST = handle_request + do_PATCH = handle_request + do_PUT = handle_request + do_DELETE = handle_request + + def log_message(self, *args): + pass + + http = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=http.serve_forever, daemon=True) + thread.start() + try: + yield f"http://127.0.0.1:{http.server_port}", calls + finally: + http.shutdown() + http.server_close() + thread.join(timeout=5) + assert not thread.is_alive() + + +def test_get_retries_status_with_real_connection(): + with server() as (base, calls), api_client(base, retry_delay=0, retry_jitter=0) as client: + assert client.get("/retry").status_code == 200 + assert calls["GET", "/retry"] == 3 + + +@pytest.mark.parametrize("method", ["POST", "PATCH", "PUT", "DELETE"]) +def test_write_status_is_not_replayed_by_default(method): + with server() as (base, calls), api_client(base, retry_delay=0, retry_jitter=0) as client: + with pytest.raises(APIError): + client.request(method, "/write") + assert calls[method, "/write"] == 1 + + +def test_write_retry_requires_explicit_allowlist(): + with ( + server() as (base, calls), + api_client(base, retry_methods={"POST"}, retry_delay=0, retry_jitter=0) as client, + ): + assert client.post("/retry").status_code == 200 + assert calls["POST", "/retry"] == 3 + + +def test_read_timeout_is_not_replayed(): + with server() as (base, calls), api_client(base, retry_delay=0, retry_jitter=0) as client: + with pytest.raises(APIError): + client.get("/slow", timeout=0.03) + assert calls["GET", "/slow"] == 1 + + +def test_redirect_is_returned_without_following_and_errors_hide_body(): + with server() as (base, calls), api_client(base) as client: + assert client.get("/redirect").status_code == 302 + assert calls["GET", "/destination"] == 0 + with pytest.raises(AuthenticationError) as error: + client.get("/auth") + assert "SYNTHETIC_RESPONSE_BODY" not in str(error.value) + assert error.value.response.status_code == 401 + + +def test_rate_limit_retry_budget_is_bounded(): + with ( + server() as (base, calls), + api_client(base, max_retries=2, retry_delay=0, retry_jitter=0) as client, + ): + with pytest.raises(RateLimitError): + client.get("/rate") + assert calls["GET", "/rate"] == 3 diff --git a/tests/test_template.py b/tests/test_template.py new file mode 100644 index 0000000..e42b52e --- /dev/null +++ b/tests/test_template.py @@ -0,0 +1,58 @@ +"""模板工具的负向验证;实际改名后的构建和消费由verify_package.py完成。""" + +import importlib +import sys +from pathlib import Path + +import pytest + +SCRIPTS = Path(__file__).resolve().parents[1] / "scripts" +sys.path.insert(0, str(SCRIPTS)) +check_release = importlib.import_module("check_release") +rename_template = importlib.import_module("rename_template") +metadata = importlib.import_module("project_metadata").metadata + + +@pytest.mark.parametrize("name", ["class", "for", "123bad", "bad-name", "../escape", "_hidden", ""]) +def test_invalid_import_names(name): + assert not rename_template.is_valid_package_name(name) + + +def test_rename_updates_dynamic_version_and_imports(): + assert rename_template.is_valid_package_name("sample_sdk") + assert ( + rename_template.replace_pyproject_version_attr( + 'version = {attr = "example.__version__"}', "sample_sdk" + ) + == 'version = {attr = "sample_sdk.__version__"}' + ) + assert rename_template.rewrite_imports( + "from " + "example.client import APIClient", "sample_sdk" + ) == ("from sample_sdk.client import APIClient") + + +def test_placeholder_cannot_publish(): + with pytest.raises(ValueError, match="占位模板"): + check_release.validate({"project": {"name": "example"}}, "0.4.0", "v0.4.0") + + +def test_release_requires_real_metadata_and_exact_tag(): + config = { + "project": { + "name": "sample-sdk", + "authors": [{"name": "maintainer"}], + "urls": {"Homepage": "https://github.com/demo/sample-sdk"}, + } + } + check_release.validate(config, "0.4.0", "v0.4.0") + with pytest.raises(ValueError): + check_release.validate(config, "0.4.0", "v0.3.0") + config["project"]["authors"] = [{"name": "Your Name"}] + with pytest.raises(ValueError): + check_release.validate(config, "0.4.0", "v0.4.0") + + +def test_version_is_static_single_source(): + _, package, version = metadata() + assert version == importlib.import_module(package).__version__ + assert (SCRIPTS.parent / "src" / package / "py.typed").is_file()