From b64178e9ce5bb43b7b49fd2589520977224ea160 Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Mon, 17 Aug 2026 20:07:01 +0100 Subject: [PATCH] Modernize packaging + CI so a release can actually reach PyPI (#14) The user-visible point of this change: the README's "use `ho` instead" pointer has never reached PyPI. 0.1.31 was uploaded at 11:46:58Z on 2026-03-06; the commit adding that note landed 7 seconds later, and every publish since has failed, so `pip install http2py` still hands people a package that never mentions its successor. Getting a green, publishable CI is what ships the deprecation notice. Why Publish was red: the job ran `twine upload && epythet make . github` as a single step. The upload succeeded (hence 0.1.31 on PyPI) and the docs build failed, because an earlier commit had deleted docs/ and docsrc/. The failure also skipped the push-back and tag steps, which is why the tree still said 0.1.30. Packaging - pyproject.toml (hatchling) carries the metadata ported from setup.cfg: deps, keywords, the api-pkg-maker console script, SPDX `license = "Apache-2.0"` with no License:: classifier. setup.py and setup.cfg deleted. - Version 0.1.32: 0.1.31 is burned on PyPI. Verified the CI bump lands on 0.1.33 from here, safely past it. CI - .github/workflows/ci.yml replaced with the current uv-based standard: checkout@v6, setup-uv@v7, the wads setup-python-uv / install-deps-uv / run-tests-uv composites. Drops checkout@v2, setup-python@v2, and the dead SCRIPTS_REPOSITORY_URL env pointing at a host that no longer resolves. - No docs builder: `[tool.wads.ci.docs].enabled = false` gates the pages job off. docs/ and docsrc/ are gone; re-enabling it re-creates the exact failure above. - Tested on 3.10 and 3.12. Tests (there were none that ran) - New tests/ with a real smoke surface: the package imports, its public names are exported, mk_request_function fills a url_template from path args (via the injectable dispatch seam, so no network), and HttpClient binds the routes an OpenAPI spec declares. - tests/test_ci_collection_contract.py guards the thing that was broken: under --doctest-modules an unimportable module is not one red test, it aborts the whole session. api_pkg_maker stays, excluded from collection - It imports `setuptools.sandbox`, removed from modern setuptools. Rewriting it or deleting it is still an open question, so it is left in place and excluded from collection instead - the reversible option. Excluded in two places that a test keeps in agreement: [tool.wads.ci.testing].exclude_paths and the repo-root conftest. Two traps found while verifying, both of which would have kept CI red - http2py/tests/conftest.py imported py2http at module level. pytest eagerly imports the conftest of any test* subdirectory *before* --ignore is applied, so excluding the directory was not enough: a machine without py2http (CI included) aborted with "ImportError while loading conftest" before any test ran. The import is now inside the fixture that needs it. - A cold `pip install http2py` fails on `import ju`: ju/oas.py imports dill at module level, never uses it, and does not declare it. Filed upstream as i2mint/ju#6. Carrying `dill` here as a temporary dependency, with a test that goes red as soon as upstream is fixed so the workaround does not become permanent. Also added the module docstrings that were missing (they are extracted for generated docs, and D100 is enabled). https://claude.ai/code/session_01GPpn5ixPgqGqk7uJH7cC6o --- .github/workflows/ci.yml | 254 +++++++++++++++++++++------ conftest.py | 21 +++ http2py/api_pkg_maker.py | 11 ++ http2py/authentication.py | 7 + http2py/cli_maker.py | 6 + http2py/client.py | 6 + http2py/constants.py | 2 + http2py/decorators.py | 7 + http2py/default_configs.py | 2 + http2py/example_cli.py | 2 + http2py/global_state.py | 2 + http2py/py2request.py | 12 +- http2py/testing_utils.py | 6 + http2py/tests/__init__.py | 1 + http2py/tests/conftest.py | 18 +- http2py/util.py | 6 + pyproject.toml | 190 ++++++++++++++++++++ setup.cfg | 39 ---- setup.py | 3 - tests/test_ci_collection_contract.py | 139 +++++++++++++++ tests/test_dependency_workarounds.py | 85 +++++++++ tests/test_smoke.py | 84 +++++++++ 22 files changed, 799 insertions(+), 104 deletions(-) create mode 100644 conftest.py create mode 100644 pyproject.toml delete mode 100644 setup.cfg delete mode 100644 setup.py create mode 100644 tests/test_ci_collection_contract.py create mode 100644 tests/test_dependency_workarounds.py create mode 100644 tests/test_smoke.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a000197..1eea631 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,90 +1,236 @@ -name: Continuous Integration +name: Continuous Integration (uv) +# NOTE: publishing uses `uv publish`, which reads UV_PUBLISH_TOKEN -- mapped here +# from secrets.PYPI_PASSWORD. This repo has no repository-level secrets; it +# inherits the organization-level PYPI_PASSWORD / SSH_PRIVATE_KEY (visibility: +# all), which is the same pair other migrated packages in this org publish with. +# +# The GitHub Pages job below is gated on [tool.wads.ci.docs].enabled, which is +# false here: docs/ and docsrc/ were removed from this repo, and the legacy CI's +# `epythet make . github` call against the now-absent docs is what broke Publish. on: [push, pull_request] -env: - PROJECT_NAME: http2py - SCRIPTS_REPOSITORY_URL: http://${{ secrets.SCRIPTS_USERNAME }}:${{ secrets.SCRIPTS_TOKEN }}@git.otosense.ai/vferon/ci-scripts.git + +# Note: Environment variables (PROJECT_NAME and vars from [tool.wads.ci.env]) +# are set by the read-ci-config action in the setup job and made available +# to all subsequent jobs via GITHUB_ENV + jobs: + # First job: Read configuration from pyproject.toml + setup: + name: Read Configuration + runs-on: ubuntu-latest + outputs: + project-name: ${{ steps.config.outputs.project-name }} + python-versions: ${{ steps.config.outputs.python-versions }} + pytest-args: ${{ steps.config.outputs.pytest-args }} + coverage-enabled: ${{ steps.config.outputs.coverage-enabled }} + exclude-paths: ${{ steps.config.outputs.exclude-paths }} + test-on-windows: ${{ steps.config.outputs.test-on-windows }} + build-sdist: ${{ steps.config.outputs.build-sdist }} + build-wheel: ${{ steps.config.outputs.build-wheel }} + metrics-enabled: ${{ steps.config.outputs.metrics-enabled }} + metrics-config-path: ${{ steps.config.outputs.metrics-config-path }} + metrics-storage-branch: ${{ steps.config.outputs.metrics-storage-branch }} + metrics-python-version: ${{ steps.config.outputs.metrics-python-version }} + metrics-force-run: ${{ steps.config.outputs.metrics-force-run }} + ruff-enabled: ${{ steps.config.outputs.ruff-enabled }} + black-enabled: ${{ steps.config.outputs.black-enabled }} + mypy-enabled: ${{ steps.config.outputs.mypy-enabled }} + docs-enabled: ${{ steps.config.outputs.docs-enabled }} + + steps: + - uses: actions/checkout@v6 + + - name: Set up uv + uses: astral-sh/setup-uv@v7 + + - name: Set up Python + run: uv python install 3.11 + + - name: Read CI Config + id: config + uses: i2mint/wads/actions/read-ci-config@master + with: + pyproject-path: . + + # Second job: Validation using the config validation: name: Validation if: "!contains(github.event.head_commit.message, '[skip ci]')" + needs: setup runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.10"] + python-version: ${{ fromJson(needs.setup.outputs.python-versions) }} + steps: - # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - - uses: actions/checkout@v2 + - uses: actions/checkout@v6 + + - name: Set up uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: i2mint/wads/actions/setup-python-uv@master with: python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip -q install axblack pytest pylint isee - isee install-requires - pip install -r $PROJECT_NAME/tests/test_requirements.txt + - name: Install System Dependencies + uses: i2mint/wads/actions/install-system-deps@master + with: + pyproject-path: . + + - name: Install Dependencies + uses: i2mint/wads/actions/install-deps-uv@master + + - name: Format Source Code + if: needs.setup.outputs.ruff-enabled != 'false' + run: uvx ruff format . + + - name: Format Source Code (black) + if: needs.setup.outputs.black-enabled == 'true' + run: uvx black . - - name: Format source code - run: black --line-length=88 . + - name: Lint Validation + if: needs.setup.outputs.ruff-enabled != 'false' + run: uvx ruff check --output-format=github ${{ needs.setup.outputs.project-name }} - # - name: Validate docstrings - # run: pylint ./$PROJECT_NAME --disable=all --enable=C0114,C0115,C0116 + - name: Type Check (mypy) + if: needs.setup.outputs.mypy-enabled == 'true' + run: uvx mypy ${{ needs.setup.outputs.project-name }} + + - name: Run Tests + uses: i2mint/wads/actions/run-tests-uv@master + with: + root-dir: ${{ needs.setup.outputs.project-name }} + pytest-args: ${{ needs.setup.outputs.pytest-args }} + exclude-paths: ${{ needs.setup.outputs.exclude-paths }} + coverage: ${{ needs.setup.outputs.coverage-enabled }} + + - name: Track Code Metrics + if: needs.setup.outputs.metrics-enabled == 'true' + uses: i2mint/umpyre/actions/track-metrics@master + continue-on-error: true + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + config-path: ${{ needs.setup.outputs.metrics-config-path }} + storage-branch: ${{ needs.setup.outputs.metrics-storage-branch }} + python-version: ${{ needs.setup.outputs.metrics-python-version }} + force-run: ${{ needs.setup.outputs.metrics-force-run }} + + # Optional Windows testing (if enabled in config) + windows-validation: + name: Windows Tests + if: "!contains(github.event.head_commit.message, '[skip ci]') && needs.setup.outputs.test-on-windows == 'true'" + needs: setup + runs-on: windows-latest + continue-on-error: true + + steps: + - uses: actions/checkout@v6 - - name: Test - run: pytest --doctest-modules -vs $PROJECT_NAME + - name: Set up uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + + - name: Set up Python + uses: i2mint/wads/actions/setup-python-uv@master + with: + python-version: ${{ fromJson(needs.setup.outputs.python-versions)[0] }} + + - name: Install System Dependencies + uses: i2mint/wads/actions/install-system-deps@master + with: + pyproject-path: . + + - name: Install Dependencies + uses: i2mint/wads/actions/install-deps-uv@master + + - name: Run Tests + uses: i2mint/wads/actions/run-tests-uv@master + with: + root-dir: ${{ needs.setup.outputs.project-name }} + pytest-args: ${{ needs.setup.outputs.pytest-args }} + exclude-paths: ${{ needs.setup.outputs.exclude-paths }} + + # Publishing job publish: name: Publish - if: "!contains(github.event.head_commit.message, '[skip ci]') && github.ref == 'refs/heads/master'" - needs: validation + permissions: + contents: write + if: "!contains(github.event.head_commit.message, '[skip ci]') && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main')" + needs: [setup, validation] runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.10"] + steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v6 with: fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} - - name: Configure Git - run: | - git config --global user.email "vferon@pentalog.com" - git config --global user.name "GitHub CI Runner" + - name: Set up uv + uses: astral-sh/setup-uv@v7 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + - name: Set up Python + uses: i2mint/wads/actions/setup-python-uv@master with: - python-version: ${{ matrix.python-version }} + python-version: ${{ fromJson(needs.setup.outputs.python-versions)[0] }} + create-venv: "false" - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip -q install semver axblack twine epythet wads isee - isee install-requires + - name: Format Source Code + if: needs.setup.outputs.ruff-enabled != 'false' + run: uvx ruff format . - - name: Format source code - run: black --line-length=88 . + - name: Format Source Code (black) + if: needs.setup.outputs.black-enabled == 'true' + run: uvx black . - - name: Update version number - run: | - export VERSION=$(isee gen-semver) - echo "VERSION=$VERSION" >> $GITHUB_ENV - isee update-setup-cfg + - name: Update Version Number + id: version + uses: i2mint/isee/actions/bump-version-number@master + - name: Build Distribution + uses: i2mint/wads/actions/build-dist-uv@master + with: + sdist: ${{ needs.setup.outputs.build-sdist }} + wheel: ${{ needs.setup.outputs.build-wheel }} - - name: Package - run: python setup.py sdist + - name: Publish to PyPI + uses: i2mint/wads/actions/pypi-publish-uv@master + with: + pypi-token: ${{ secrets.PYPI_PASSWORD }} - - name: Publish - run: | - twine upload dist/$PROJECT_NAME-$VERSION.tar.gz -u ${{ secrets.PYPI_USERNAME }} -p ${{ secrets.PYPI_PASSWORD }} --non-interactive --skip-existing --disable-progress-bar - epythet make . github + - name: Force SSH for git remote + run: git remote set-url origin git@github.com:${{ github.repository }}.git - - name: Push Changes - run: pack check-in "**CI** Formatted code + Updated version number and documentation. [skip ci]" --auto-choose-default-action --bypass-docstring-validation --bypass-tests --bypass-code-formatting --verbose + - name: Commit Changes + uses: i2mint/wads/actions/git-commit@master + with: + commit-message: "**CI** Formatted code + Updated version to ${{ env.VERSION }} [skip ci]" + ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} + push: true - name: Tag Repository - run: isee tag-repo $VERSION + uses: i2mint/wads/actions/git-tag@master + with: + tag: ${{ env.VERSION }} + message: "Release version ${{ env.VERSION }}" + push: true + + # Optional GitHub Pages (skipped when [tool.wads.ci.docs].enabled = false) + github-pages: + name: Publish GitHub Pages + permissions: + contents: write + pages: write + id-token: write + if: "!contains(github.event.head_commit.message, '[skip ci]') && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && needs.setup.outputs.docs-enabled != 'false'" + needs: [setup, publish] + runs-on: ubuntu-latest + steps: + - uses: i2mint/epythet/actions/publish-github-pages@master + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + ignore: "tests/,scrap/,examples/" diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..cf73e19 --- /dev/null +++ b/conftest.py @@ -0,0 +1,21 @@ +"""Pytest configuration for the http2py repository. + +``http2py.api_pkg_maker`` imports ``setuptools.sandbox``, which modern +setuptools no longer ships, so merely *importing* the module raises +``ImportError``. Under ``--doctest-modules`` that is not a single failing test: +it aborts collection for the whole session. The module is deliberately left in +place (whether to rewrite it or remove it is still open on issue #14), so it is +excluded from collection instead. + +CI excludes the same two paths via ``[tool.wads.ci.testing].exclude_paths`` in +pyproject.toml, which the wads ``run-tests-uv`` action turns into ``--ignore`` +flags. Repeating them here is what makes a bare ``pytest`` (no flags, e.g. a +local run or an editor's test runner) behave the same way as CI. +``tests/test_ci_collection_contract.py`` asserts the two lists stay in +agreement, so they cannot drift apart silently. +""" + +collect_ignore = [ + "http2py/api_pkg_maker.py", + "http2py/tests", +] diff --git a/http2py/api_pkg_maker.py b/http2py/api_pkg_maker.py index 7b52a9c..d68e899 100644 --- a/http2py/api_pkg_maker.py +++ b/http2py/api_pkg_maker.py @@ -1,3 +1,14 @@ +"""Generate an installable python package from an OpenAPI specification. + +Given a spec (or the URL of one), :func:`mk_api_pkg` writes a small source +distribution whose functions are bound to the service's routes. + +Note: this module imports ``setuptools.sandbox``, which modern setuptools no +longer provides, so importing it raises ``ImportError``. It is kept in place +pending a decision (rewrite or remove) and is excluded from test collection; +see the repo-root ``conftest.py``. +""" + import argh import os import shutil diff --git a/http2py/authentication.py b/http2py/authentication.py index 04bc51f..4dc3ccd 100644 --- a/http2py/authentication.py +++ b/http2py/authentication.py @@ -1,3 +1,10 @@ +"""Resolve credentials and build the auth callables that requests will use. + +:func:`mk_auth` turns an auth specification -- inline values, environment +variables, or a JSON credentials file -- into something the request layer can +attach to outgoing calls. +""" + import json import os from pathlib import Path diff --git a/http2py/cli_maker.py b/http2py/cli_maker.py index a3a8010..de4c81a 100644 --- a/http2py/cli_maker.py +++ b/http2py/cli_maker.py @@ -1,3 +1,9 @@ +"""Turn an http-bound python object into a command line interface. + +Signatures are first made argparse-friendly (:func:`mk_argparse_friendly`), +then dispatched with ``argh`` by :func:`mk_cli` / :func:`dispatch_cli`. +""" + import argh from functools import wraps from glom import glom diff --git a/http2py/client.py b/http2py/client.py index 4d30682..66ba285 100644 --- a/http2py/client.py +++ b/http2py/client.py @@ -1,3 +1,9 @@ +"""The main entry point: a python object facading an http service. + +:class:`HttpClient` takes an OpenAPI specification (a dict, or the URL of one) +and exposes each declared route as a normal python method. +""" + from glom import glom from requests import request, get, Session from i2.errors import AuthorizationError diff --git a/http2py/constants.py b/http2py/constants.py index 799c9ee..18d473f 100644 --- a/http2py/constants.py +++ b/http2py/constants.py @@ -1,3 +1,5 @@ +"""Content-type strings shared across the request and response machinery.""" + JSON_CONTENT_TYPE = "application/json" BINARY_CONTENT_TYPE = "application/octet-stream" FORM_CONTENT_TYPE = "multipart/form-data" diff --git a/http2py/decorators.py b/http2py/decorators.py index 413af7b..8919cb2 100644 --- a/http2py/decorators.py +++ b/http2py/decorators.py @@ -1,3 +1,10 @@ +"""Response handling: map http status codes to exceptions, decode the payload. + +``handle_json_resp`` / ``handle_binary_resp`` / ``handle_raw_resp`` wrap an +output transformation so it only ever sees a successful response of the +expected content type. +""" + from functools import partial from i2.errors import ( AuthorizationError, diff --git a/http2py/default_configs.py b/http2py/default_configs.py index 0c85085..e3467c0 100644 --- a/http2py/default_configs.py +++ b/http2py/default_configs.py @@ -1,3 +1,5 @@ +"""Default output transformations used when a method spec does not supply one.""" + from http2py.decorators import ( handle_raw_resp, handle_json_resp, diff --git a/http2py/example_cli.py b/http2py/example_cli.py index 65d69e4..50a1403 100644 --- a/http2py/example_cli.py +++ b/http2py/example_cli.py @@ -1,3 +1,5 @@ +"""A tiny worked example of the CLI-making tools, used by the docs and by hand.""" + import argh from collections.abc import Iterable diff --git a/http2py/global_state.py b/http2py/global_state.py index 6db9d64..de5e563 100644 --- a/http2py/global_state.py +++ b/http2py/global_state.py @@ -1,3 +1,5 @@ +"""Process-wide state (notably the shared ``requests`` session) for the clients.""" + from requests import request, Session _global_state = {} diff --git a/http2py/py2request.py b/http2py/py2request.py index 3d7380e..b32c1b9 100644 --- a/http2py/py2request.py +++ b/http2py/py2request.py @@ -305,9 +305,9 @@ def request_func(self, *args, **kwargs): if docstring: request_func.__doc__ = docstring - assert callable( - output_trans - ), f"output_trans {output_trans} is not callable, try again" + assert callable(output_trans), ( + f"output_trans {output_trans} is not callable, try again" + ) return request_func @@ -452,9 +452,9 @@ def _mk_method_func_and_wrap(method_spec, method_func_from_method_spec): def _mk_signature_from_names(arg_names, pk_names): - assert set(pk_names) <= set( - arg_names - ), "The query_arg_names must be a subset of the names in the url_template" + assert set(pk_names) <= set(arg_names), ( + "The query_arg_names must be a subset of the names in the url_template" + ) ko_names = _difference_conserving_order(arg_names, pk_names) if ko_names: ko_names_str = "*, " + ", ".join(ko_names) diff --git a/http2py/testing_utils.py b/http2py/testing_utils.py index 9940c99..edcc61d 100644 --- a/http2py/testing_utils.py +++ b/http2py/testing_utils.py @@ -1,3 +1,9 @@ +"""Helpers for testing code that talks to an http service. + +:class:`MockHttpClient` answers without touching the network, and +:func:`mk_unit_tests` generates test stubs from a client's bound methods. +""" + from http2py.client import HttpClient TEST_NUMBER = 100 diff --git a/http2py/tests/__init__.py b/http2py/tests/__init__.py index e69de29..cd8bba3 100644 --- a/http2py/tests/__init__.py +++ b/http2py/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for http2py that require a live py2http service to be running.""" diff --git a/http2py/tests/conftest.py b/http2py/tests/conftest.py index 8b941d0..b825b8a 100644 --- a/http2py/tests/conftest.py +++ b/http2py/tests/conftest.py @@ -1,6 +1,18 @@ +"""Fixtures for the live-service tests in this directory. + +These tests stand up a real ``py2http`` web service and talk to it, so +``py2http`` is a test-only requirement (see ``test_requirements.txt``) and is +*not* one of http2py's declared dependencies. + +It is imported inside the fixture rather than at module level on purpose: +pytest eagerly imports the ``conftest.py`` of any ``test*`` sub-directory of a +collection root, and it does so *before* ``--ignore`` is applied. A module-level +``from py2http import run_app`` therefore aborted the entire session with +"ImportError while loading conftest" on any machine (CI included) that has no +py2http installed -- even though this directory is excluded from collection. +""" + import pytest -from py2http import run_app -from py2http.util import run_process def foo(a: int = 0, b: int = 0, c=0): @@ -19,6 +31,8 @@ def confuser(a: int = 0, x: float = 3.14): @pytest.fixture(scope="session", autouse=True) def ws_app(): + from py2http import run_app + from py2http.util import run_process with run_process( func=run_app, diff --git a/http2py/util.py b/http2py/util.py index 055c23b..752901d 100644 --- a/http2py/util.py +++ b/http2py/util.py @@ -1,3 +1,9 @@ +"""Small general-purpose helpers: data files, dict defaults, JSON-ability checks. + +Also holds the friendly ``ModuleNotFoundError`` messages used to point a user at +the right install when an optional dependency is missing. +""" + import json try: diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e44736d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,190 @@ +[build-system] +requires = [ + "hatchling", +] +build-backend = "hatchling.build" + +[project] +name = "http2py" +version = "0.1.32" +description = "Tools to create python binders to http web services." +readme = "README.md" +requires-python = ">=3.10" +license = "Apache-2.0" +keywords = [ + "webservice", + "http", + "requests", + "API", +] +authors = [ + { name = "Otosense" }, +] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.12", + "Topic :: Internet :: WWW/HTTP", + "Topic :: Software Development :: Libraries :: Python Modules", +] +dependencies = [ + "glom", + "i2", + "ju", + "requests", + "argh", + "PyYAML", + "importlib_resources", + # TEMPORARY, not imported by http2py: ju/oas.py has an unconditional (and + # unused) `import dill` while not declaring dill, so a cold `pip install + # http2py` dies on `import ju`. Tracked upstream as i2mint/ju#6. Remove this + # line once a fixed ju is released -- tests/test_dependency_workarounds.py + # fails as soon as that happens, so it will not be forgotten. + "dill", +] + +[project.urls] +Homepage = "https://github.com/i2mint/http2py" +Repository = "https://github.com/i2mint/http2py" + +[project.scripts] +api-pkg-maker = "http2py.api_pkg_maker:main" + +[project.optional-dependencies] +dev = [ + "pytest>=7.0", + "pytest-cov>=4.0", + "ruff>=0.1.0", +] + +[tool.hatch.build.targets.wheel] +packages = [ + "http2py", +] + +[tool.ruff] +line-length = 88 +target-version = "py310" +exclude = [ + "**/*.ipynb", + ".git", + ".venv", + "build", + "dist", + "tests", + "examples", + "scrap", +] + +[tool.ruff.lint] +select = [ + "D100", +] +ignore = [ + "D203", + "E501", + "B905", +] + +[tool.ruff.lint.pydocstyle] +convention = "google" + +[tool.ruff.lint.per-file-ignores] +"**/tests/*" = [ + "D", +] +"**/examples/*" = [ + "D", +] +"**/scrap/*" = [ + "D", +] + +[tool.pytest.ini_options] +minversion = "6.0" +testpaths = [ + "http2py", + "tests", +] +norecursedirs = [ + "build", + "dist", + ".git", + ".venv", + "*.egg-info", +] +doctest_optionflags = [ + "NORMALIZE_WHITESPACE", + "ELLIPSIS", +] + +[tool.wads.ci] +project_name = "http2py" + +[tool.wads.ci.commands] +pre_test = [] +test = [] +post_test = [] +lint = [] +format = [] + +[tool.wads.ci.env] +required_envvars = [] +test_envvars = [] +extra_envvars = [] + +[tool.wads.ci.env.defaults] + +[tool.wads.ci.quality.ruff] +enabled = true + +[tool.wads.ci.quality.black] +enabled = false + +[tool.wads.ci.quality.mypy] +enabled = false + +[tool.wads.ci.testing] +python_versions = [ + "3.10", + "3.12", +] +pytest_args = [ + "-v", + "--tb=short", +] +coverage_enabled = true +coverage_threshold = 0 +coverage_report_format = [ + "term", +] +# `http2py.api_pkg_maker` imports `setuptools.sandbox`, which modern setuptools +# no longer ships, so the module raises ImportError on import and would abort +# the whole collection. Whether to rewrite it or drop it is still open on +# issue #14; excluding it from collection is the reversible middle course. +# The same two paths are repeated in the repo-root conftest.py so that a bare +# `pytest` behaves like CI -- tests/test_ci_collection_contract.py keeps the +# two lists in agreement. +exclude_paths = [ + "http2py/api_pkg_maker.py", + "http2py/tests", +] +test_on_windows = false + +[tool.wads.ci.metrics] +enabled = false + +[tool.wads.ci.build] +sdist = true +wheel = true + +[tool.wads.ci.publish] +enabled = true + +# No docs builder: commit 43aef24 removed docs/ and docsrc/ from this repo, and +# the legacy CI's `epythet make . github` call is what turned the Publish job +# red. Nothing here to build. +[tool.wads.ci.docs] +enabled = false diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 2bc4067..0000000 --- a/setup.cfg +++ /dev/null @@ -1,39 +0,0 @@ -[metadata] -name = http2py -version = 0.1.30 -url = https://github.com/i2mint/http2py -root_url = https://github.com/i2mint -platforms = any -description = Tools to create python binders to http web services. -description_file = README.md -long_description = file: README.md -long_description_content_type = text/markdown -license = Apache -description-file = README.md -keywords = - webservice - http - requests - API -copyright = - 2020 - Otosense -display_name = Http2py - -[options] -packages = find: -include_package_data = True -zip_safe = False -install_requires = - glom - i2 - ju - requests - argh - PyYAML - importlib_resources - -[options.entry_points] -console_scripts = - api-pkg-maker = http2py.api_pkg_maker:main - diff --git a/setup.py b/setup.py deleted file mode 100644 index 201cd4c..0000000 --- a/setup.py +++ /dev/null @@ -1,3 +0,0 @@ -from setuptools import setup - -setup() # Note: Everything should be in the local setup.cfg diff --git a/tests/test_ci_collection_contract.py b/tests/test_ci_collection_contract.py new file mode 100644 index 0000000..105fd47 --- /dev/null +++ b/tests/test_ci_collection_contract.py @@ -0,0 +1,139 @@ +"""Guards on what CI is allowed to collect -- the thing that was actually broken. + +Under ``pytest --doctest-modules`` (what the CI test action runs) an unimportable +module is not one red test, it is a collection abort that takes the whole session +down. Two modules in this repo are in that state: + +* ``http2py/api_pkg_maker.py`` imports ``setuptools.sandbox``, removed from + modern setuptools; +* ``http2py/tests/api_pkg_maker_test.py`` imports that module. + +Both are left in place on purpose (rewriting vs. deleting ``api_pkg_maker`` is +still open on issue #14) and excluded from collection in two places that must +agree: ``[tool.wads.ci.testing].exclude_paths`` in pyproject.toml, and +``collect_ignore`` in the repo-root conftest.py. + +There is a third, subtler trap that these tests pin down: pytest eagerly imports +``conftest.py`` from any ``test*`` sub-directory of a collection root *before* +``--ignore`` is consulted. ``http2py/tests/conftest.py`` therefore gets imported +even though the directory is excluded -- so it must not need anything outside +this package's declared dependencies at import time. +""" + +import importlib +import importlib.util +import pkgutil +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parent.parent + +# Modules that are known-unimportable and therefore excluded from collection. +# Keep this in step with pyproject's exclude_paths and conftest's collect_ignore. +KNOWN_UNIMPORTABLE = {"http2py.api_pkg_maker"} + +EXPECTED_EXCLUDED_PATHS = {"http2py/api_pkg_maker.py", "http2py/tests"} + + +def _package_modules(): + """Dotted names of every module under the http2py package.""" + import http2py + + return sorted( + name + for _, name, _ in pkgutil.walk_packages( + http2py.__path__, prefix="http2py." + ) + ) + + +def test_every_package_module_imports_except_the_known_broken_one(): + """A new unimportable module would abort CI collection -- fail here instead.""" + failures = {} + for name in _package_modules(): + if name in KNOWN_UNIMPORTABLE or name.startswith("http2py.tests"): + continue + try: + importlib.import_module(name) + except Exception as exc: # noqa: BLE001 -- reporting, not handling + failures[name] = f"{type(exc).__name__}: {exc}" + assert not failures, f"modules that would abort collection: {failures}" + + +def test_known_broken_module_is_still_broken(): + """Tripwire for issue #14. + + If ``api_pkg_maker`` starts importing again (someone rewrote it, or removed + it), the exclusions below are dead weight and the #14 (a)/(b) question is + answered -- so this deliberately fails to force that cleanup rather than + letting a stale exclusion sit there forever. + """ + for name in KNOWN_UNIMPORTABLE: + with pytest.raises(ImportError): + importlib.import_module(name) + + +@pytest.mark.skipif(sys.version_info < (3, 11), reason="tomllib needs Python 3.11+") +def test_pyproject_and_conftest_exclusions_agree(): + """The two exclusion lists are separate mechanisms; they must not drift.""" + import tomllib + + pyproject = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text()) + ci_excluded = set(pyproject["tool"]["wads"]["ci"]["testing"]["exclude_paths"]) + + spec = importlib.util.spec_from_file_location( + "_http2py_root_conftest", REPO_ROOT / "conftest.py" + ) + root_conftest = importlib.util.module_from_spec(spec) + spec.loader.exec_module(root_conftest) + conftest_excluded = set(root_conftest.collect_ignore) + + assert ci_excluded == EXPECTED_EXCLUDED_PATHS + assert conftest_excluded == EXPECTED_EXCLUDED_PATHS + + +@pytest.mark.skipif(sys.version_info < (3, 11), reason="tomllib needs Python 3.11+") +def test_docs_builder_is_disabled(): + """docs/ and docsrc/ are gone from this repo; re-enabling docs would re-break + the Publish job the same way the legacy `epythet make . github` call did.""" + import tomllib + + pyproject = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text()) + assert pyproject["tool"]["wads"]["ci"]["docs"]["enabled"] is False + + +def test_excluded_test_dir_conftest_imports_without_undeclared_deps(): + """``http2py/tests/conftest.py`` is imported by pytest before --ignore applies. + + It must therefore not import anything outside http2py's declared + dependencies at module level. ``py2http`` is a test-only dependency that CI + does not install, so importing it eagerly aborted the session with + "ImportError while loading conftest" before any test ran. + """ + conftest_path = REPO_ROOT / "http2py" / "tests" / "conftest.py" + + class _BlockPy2http: + def find_spec(self, fullname, path=None, target=None): + if fullname == "py2http" or fullname.startswith("py2http."): + raise ImportError(f"{fullname} is blocked for this test") + return None + + blocker = _BlockPy2http() + saved = {k: v for k, v in sys.modules.items() if k.split(".")[0] == "py2http"} + for key in saved: + del sys.modules[key] + sys.meta_path.insert(0, blocker) + try: + spec = importlib.util.spec_from_file_location( + "_http2py_tests_conftest", conftest_path + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + finally: + sys.meta_path.remove(blocker) + sys.modules.update(saved) + sys.modules.pop("_http2py_tests_conftest", None) + + assert hasattr(module, "ws_app") diff --git a/tests/test_dependency_workarounds.py b/tests/test_dependency_workarounds.py new file mode 100644 index 0000000..b7dfa22 --- /dev/null +++ b/tests/test_dependency_workarounds.py @@ -0,0 +1,85 @@ +"""Tripwires for dependencies http2py declares but does not itself use. + +Right now there is exactly one: ``dill``. ``ju/oas.py`` imports it at module +level while ``ju`` does not declare it, and ``ju/__init__.py`` imports ``ju.oas`` +eagerly -- so a cold ``pip install http2py`` dies on ``import ju`` with +``ModuleNotFoundError: No module named 'dill'``. Declaring ``dill`` here is a +workaround, tracked upstream as i2mint/ju#6. + +A workaround with no expiry becomes permanent, so the test below fails the +moment upstream is fixed (either the import goes away or ``dill`` becomes a +declared requirement of ``ju``). When it fails, drop ``dill`` from +``[project].dependencies`` and delete this module. + +The check is deliberately static -- it reads ``ju``'s source and metadata rather +than re-importing ``ju`` with ``dill`` hidden, so it cannot leave a half-imported +package behind for the rest of the session. +""" + +import ast +import re +import sys +from importlib.metadata import PackageNotFoundError, requires +from pathlib import Path + +import pytest + + +def _ju_oas_source(): + import ju.oas + + return Path(ju.oas.__file__).read_text() + + +def _module_level_imports(source): + """Top-level (non-nested) imported top-level module names.""" + names = set() + for node in ast.parse(source).body: + if isinstance(node, ast.Import): + names.update(alias.name.split(".")[0] for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module: + names.add(node.module.split(".")[0]) + return names + + +def _declared_requirement_names(distribution): + try: + reqs = requires(distribution) or [] + except PackageNotFoundError: # pragma: no cover - ju is a hard dependency + pytest.skip(f"{distribution} is not installed as a distribution") + return {re.split(r"[\s\[<>=!;(]", req, maxsplit=1)[0].lower() for req in reqs} + + +def test_dill_workaround_is_still_needed(): + """Fails once i2mint/ju#6 is released -- then remove the `dill` dependency.""" + imports_dill = "dill" in _module_level_imports(_ju_oas_source()) + ju_declares_dill = "dill" in _declared_requirement_names("ju") + + assert imports_dill and not ju_declares_dill, ( + "ju no longer needs http2py to carry `dill` for it " + f"(module-level import: {imports_dill}, declared by ju: {ju_declares_dill}). " + "Remove `dill` from [project].dependencies in pyproject.toml and delete " + "this module. See i2mint/ju#6." + ) + + +@pytest.mark.skipif(sys.version_info < (3, 11), reason="tomllib needs Python 3.11+") +def test_dill_is_declared_while_the_workaround_stands(): + """The workaround only works if the dependency is actually declared. + + Read from pyproject.toml rather than installed metadata: an editable install + freezes its .dist-info at install time, so the metadata of a dev checkout + lags the file that CI actually builds from. + """ + import tomllib + + repo_root = Path(__file__).resolve().parent.parent + pyproject = tomllib.loads((repo_root / "pyproject.toml").read_text()) + declared = { + re.split(r"[\s\[<>=!;(]", dep, maxsplit=1)[0].lower() + for dep in pyproject["project"]["dependencies"] + } + assert "dill" in declared, ( + "http2py must declare `dill` while i2mint/ju#6 is open, otherwise a cold " + "`pip install http2py` fails at `import ju`." + ) diff --git a/tests/test_smoke.py b/tests/test_smoke.py new file mode 100644 index 0000000..f5871e2 --- /dev/null +++ b/tests/test_smoke.py @@ -0,0 +1,84 @@ +"""Smoke tests: the package imports and its advertised public surface works. + +http2py is in maintenance mode (its README points users at ``ho``), so these +tests deliberately stay at the level of "a `pip install http2py` is usable": +the top-level import succeeds, the names re-exported from ``__init__`` exist, +and the central factory still builds a callable with the right signature. No +network is touched. +""" + +import pytest + + +def test_import(): + import http2py # noqa: F401 + + +@pytest.mark.parametrize( + "name", ["HttpClient", "mk_cli", "dispatch_cli", "mk_request_function"] +) +def test_public_names_are_exported(name): + import http2py + + assert hasattr(http2py, name), f"http2py.{name} is missing from the public API" + + +def test_mk_request_function_formats_the_url_template_from_path_args(): + """The core factory: spec in, callable out, url_template filled from args. + + ``dispatch`` is injected so nothing leaves the machine -- that seam is the + reason this can be a real behavioural test rather than a mock-heavy one. + """ + from http2py import mk_request_function + + calls = [] + + class _Response: + status_code = 200 + text = "pong" + + def fake_dispatch(method, url, **request_kwargs): + calls.append((method, url)) + return _Response() + + func = mk_request_function( + { + "method_name": "ping", + "url_template": "https://example.com/ping/{who}", + "path_arg_names": ["who"], + "method": "GET", + }, + function_kind="function", + dispatch=fake_dispatch, + ) + + assert callable(func) + assert func.func_args == ["who"] + assert func(who="bob") == "pong" + assert calls == [("GET", "https://example.com/ping/bob")] + + +def test_http_client_from_openapi_spec_binds_declared_methods(): + from http2py import HttpClient + + spec = { + "openapi": "3.0.2", + "info": {"title": "example", "version": "0.1"}, + "servers": [{"url": "https://example.com"}], + "paths": { + "/ping": { + "get": { + "x-method_name": "ping", + "description": "Answers with a pong.", + "responses": { + "200": { + "description": "", + "content": {"application/json": {"schema": {}}}, + } + }, + } + } + }, + } + client = HttpClient(openapi_spec=spec) + assert callable(getattr(client, "ping", None))