From e8ca8a011068f1b8231284be1a209c26aa782407 Mon Sep 17 00:00:00 2001 From: jamesbeedy Date: Sun, 6 Sep 2026 19:19:41 +0000 Subject: [PATCH] chore: fill in the PyPI project metadata and fix the sdist PyPI's project page for armasec-lite showed an empty Meta section and only a GitHub link, because the distribution carried the bare minimum of metadata. - `project.urls` gains Documentation, Issues and Changelog, and Homepage now points at the docs site rather than duplicating Repository. PyPI recognises these five names specifically and gives each its own icon. - `license` moves to the PEP 639 SPDX expression with `license-files`. Metadata 2.4 forbids carrying both a License-Expression and a `License ::` classifier, so the MIT classifier is removed with it; keeping both makes PyPI reject the upload. - Classifiers gain OS Independent, Python 3 Only, Framework :: Pytest (the package ships a `pytest11` entry point), two Topic entries and Typing :: Typed. - Keywords gain the provider names people actually search for. - `maintainers` added. `armasec_lite/py.typed` is new, which is what earns the Typing :: Typed classifier. Every signature is annotated and mypy runs in strict mode, but without the PEP 561 marker a consumer's type checker skips the installed package silently and treats everything it exports as `Any`. The sdist was also a release blocker. Hatchling's default is to ship everything the ROOT .gitignore does not exclude, and it does not read nested ignore files, so `docusaurus/node_modules` (ignored by docusaurus/.gitignore) went into the tarball: 574MB of input, a 115.7MB artifact, over PyPI's 100MB per-file limit. A tagged release would have failed at upload. The sdist target now names its contents. `include` alone was not enough: hatchling matches README* and LICEN[CS]E* recursively, which still pulled 1888 of them out of node_modules, so `exclude` names the two directories outright. Verified: `uv build` produces a 105KB sdist of 49 members rooted at armasec_lite, tests and examples only, and `twine check` PASSES on both the wheel and the sdist. The wheel contains py.typed and the LICENSE. Metadata renders as version 2.5 with License-Expression: MIT and no License :: classifier. Full suite 371 passed; ruff and mypy --strict clean. Three new tests in tests/unit/test_packaging.py cover the project-page fields, the py.typed marker and the sdist allow-list. All three were confirmed failing against the old configuration first. Co-Authored-By: Claude Opus 5 (1M context) --- armasec_lite/py.typed | 0 pyproject.toml | 63 ++++++++++++++++++++++++++-- tests/unit/test_packaging.py | 80 ++++++++++++++++++++++++++++++++++++ 3 files changed, 139 insertions(+), 4 deletions(-) create mode 100644 armasec_lite/py.typed diff --git a/armasec_lite/py.typed b/armasec_lite/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml index 10ebadf..c005836 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,19 +3,44 @@ name = "armasec-lite" version = "0.1.3" description = "Injectable FastAPI auth via OIDC, with three dependencies" authors = [{name = "Vantage Compute", email = "info@vantagecompute.ai"}] -license = {text = "MIT"} +maintainers = [{name = "Vantage Compute", email = "info@vantagecompute.ai"}] +# PEP 639 SPDX expression, not the older `{text = "MIT"}` table. Metadata 2.4 forbids a +# distribution from carrying both a License-Expression and a `License ::` classifier, so +# the "License :: OSI Approved :: MIT License" classifier was removed with this change. +# Keeping both makes PyPI reject the upload. +license = "MIT" +license-files = ["LICENSE"] readme = "README.md" requires-python = ">=3.12" -keywords = ["fastapi", "auth", "oidc", "oauth2", "security", "jwt"] +keywords = [ + "fastapi", + "auth", + "authentication", + "authorization", + "oidc", + "openid-connect", + "oauth2", + "keycloak", + "auth0", + "security", + "jwt", + "jwks", +] classifiers = [ "Development Status :: 3 - Alpha", "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", + "Intended Audience :: System Administrators", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Framework :: FastAPI", + "Framework :: Pytest", "Topic :: Internet :: WWW/HTTP", "Topic :: Security", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: System :: Systems Administration :: Authentication/Directory", + "Typing :: Typed", ] dependencies = [ "fastapi>=0.141.1,<1", @@ -23,9 +48,15 @@ dependencies = [ "pydantic>=2.13.5,<3", ] +# PyPI renders these as the project page's sidebar links, and it recognises these five +# names specifically, giving each its own icon. A sixth name under some other label would +# render as a plain bullet, so it is not worth adding one. [project.urls] -Homepage = "https://github.com/vantagecompute/armasec-lite" +Homepage = "https://docs.vantagecompute.ai/developer/armasec-lite/" +Documentation = "https://docs.vantagecompute.ai/developer/armasec-lite/" Repository = "https://github.com/vantagecompute/armasec-lite" +Issues = "https://github.com/vantagecompute/armasec-lite/issues" +Changelog = "https://github.com/vantagecompute/armasec-lite/releases" [project.optional-dependencies] test = ["pytest>=9.1.1,<10"] @@ -79,3 +110,27 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["armasec_lite"] + +# An explicit allow-list, because hatchling's default is to ship everything the ROOT +# .gitignore does not exclude, and it does not read nested ignore files. `node_modules` +# is ignored by docusaurus/.gitignore, so the default sdist swept in 574MB of it and +# compressed to a 115.7MB tarball: over PyPI's 100MB per-file limit, and therefore a +# release that fails at upload rather than at build. Naming the contents is also the +# only way the list stays reviewable as the repository grows a fourth or fifth +# top-level directory of benchmark harness and docs site. +[tool.hatch.build.targets.sdist] +include = [ + "armasec_lite", + "tests", + "examples", + "README.md", + "LICENSE", +] +# `include` alone is not sufficient. Hatchling matches `README*` and `LICEN[CS]E*` +# recursively for the sdist, so an include list naming only the two root files still +# pulled in 1888 of them from under docusaurus/node_modules. These two entries are what +# actually take the tarball from 115.7MB to 105KB. +exclude = [ + "docusaurus", + "legacy_comparison_compose", +] diff --git a/tests/unit/test_packaging.py b/tests/unit/test_packaging.py index 7e36bd2..d28385f 100644 --- a/tests/unit/test_packaging.py +++ b/tests/unit/test_packaging.py @@ -110,3 +110,83 @@ def test_importing_armasec_lite_does_not_load_pem_serialization(): [sys.executable, "-c", code], capture_output=True, text=True, check=True ) assert result.stdout.strip() == "False" + + +def test_distribution_metadata_carries_the_pypi_project_page_fields(): + """ + The published metadata must fill in every field PyPI renders on a project page. + + PyPI builds its sidebar from installed metadata, not from the repository, so a field + left out of `pyproject.toml` shows up as a missing link or an empty Meta section on + the release page rather than as a build failure. This test is what makes that + omission visible here instead of after an upload. + + `License-Expression` rather than a `License ::` classifier is deliberate: PEP 639 + metadata (2.4) rejects a distribution that carries both, so the classifier was + removed when the SPDX expression was added. + """ + metadata = importlib.metadata.metadata("armasec-lite") + + assert metadata["Summary"] + assert metadata.get("Author") or metadata.get("Author-email") + assert metadata["Requires-Python"] + assert metadata["Description-Content-Type"] == "text/markdown" + + urls = { + value.split(",", 1)[0].strip(): value.split(",", 1)[1].strip() + for value in metadata.get_all("Project-URL") or [] + } + assert {"Homepage", "Documentation", "Repository", "Issues", "Changelog"} <= urls.keys() + + classifiers = set(metadata.get_all("Classifier") or []) + assert "Typing :: Typed" in classifiers + assert "Framework :: FastAPI" in classifiers + assert "Framework :: Pytest" in classifiers + assert not [c for c in classifiers if c.startswith("License ::")] + + +def test_the_package_ships_a_py_typed_marker(): + """ + Without `py.typed`, PEP 561 tells a consumer's type checker to ignore the package. + + Every signature in `armasec_lite` is annotated and the project runs mypy in strict + mode, so the annotations are known good. The marker is what lets a downstream + project actually see them: mypy and pyright both skip an installed package that has + no marker, silently, and treat every symbol it exports as `Any`. + """ + import pathlib + + marker = pathlib.Path(armasec_lite.__file__).parent / "py.typed" + assert marker.is_file() + + +def test_the_sdist_ships_an_explicit_allow_list(): + """ + The source distribution must name what it contains rather than take hatchling's default. + + Hatchling's default sdist is "everything the root `.gitignore` does not exclude", and + it does not read nested ignore files. `docusaurus/node_modules` is ignored by + `docusaurus/.gitignore`, so the default swept in 574MB of it and produced a 115.7MB + tarball, over PyPI's 100MB per-file limit. The failure surfaces at upload, after a + tag has been pushed. + + This checks the declaration rather than building a tarball, so it stays a + millisecond-scale unit test. It cannot prove the build output is small; it catches + the regression that would make it large again, which is someone deleting the section + or adding a directory of harness data to it. + """ + import pathlib + import tomllib + + pyproject = pathlib.Path(__file__).parents[2] / "pyproject.toml" + config = tomllib.loads(pyproject.read_text()) + sdist = config["tool"]["hatch"]["build"]["targets"]["sdist"] + + assert sdist["include"], "an empty include list means hatchling falls back to its default" + assert "armasec_lite" in sdist["include"] + assert not [e for e in sdist["include"] if e.startswith(("docusaurus", "legacy_"))] + + # The include list alone does not do it: hatchling matches README* and LICEN[CS]E* + # recursively, which caught 1888 of them under docusaurus/node_modules even with + # only the two root files named above. + assert {"docusaurus", "legacy_comparison_compose"} <= set(sdist["exclude"])