import datetime import pathlib import re from textwrap import dedent from textwrap import indent import packaging.version import platformdirs import tabulate import wcwidth from requests_cache import CachedResponse from requests_cache import CachedSession from requests_cache import OriginalResponse from requests_cache import SQLiteCache from tqdm import tqdm FILE_HEAD = r""" .. Note this file is autogenerated by scripts/update-plugin-list.py - usually weekly via github action .. _plugin-list: Plugin List =========== PyPI projects that match "pytest-\*" are considered plugins and are listed automatically together with a manually-maintained list in `the source code `_. Packages classified as inactive are excluded. .. The following conditional uses a different format for this list when creating a PDF, because otherwise the table gets far too wide for the page. """ DEVELOPMENT_STATUS_CLASSIFIERS = ( "Development Status :: 1 - Planning", "Development Status :: 2 - Pre-Alpha", "Development Status :: 3 - Alpha", "Development Status :: 4 - Beta", "Development Status :: 5 - Production/Stable", "Development Status :: 6 - Mature", "Development Status :: 7 - Inactive", ) ADDITIONAL_PROJECTS = { # set of additional projects to consider as plugins "logassert", "nuts", } def escape_rst(text: str) -> str: """Rudimentary attempt to escape special RST characters to appear as plain text.""" text = ( text.replace("*", "\\*") .replace("<", "\\<") .replace(">", "\\>") .replace("`", "\\`") ) text = re.sub(r"_\b", "", text) return text def project_response_with_refresh( session: CachedSession, name: str, last_serial: int ) -> OriginalResponse | CachedResponse: """Get a http cached pypi project force refresh in case of last serial mismatch """ response = session.get(f"https://pypi.org/pypi/{name}/json") if int(response.headers.get("X-PyPI-Last-Serial", -1)) != last_serial: response = session.get(f"https://pypi.org/pypi/{name}/json", refresh=True) return response def get_session() -> CachedSession: """Configures the requests-cache session""" cache_path = platformdirs.user_cache_path("pytest-plugin-list") cache_path.mkdir(exist_ok=True, parents=True) cache_file = cache_path.joinpath("http_cache.sqlite3") return CachedSession(backend=SQLiteCache(cache_file)) def pytest_plugin_projects_from_pypi(session: CachedSession) -> dict[str, int]: response = session.get( "https://pypi.org/simple", headers={"Accept": "application/vnd.pypi.simple.v1+json"}, refresh=True, ) return { name: p["_last-serial"] for p in response.json()["projects"] if (name := p["name"]).startswith("pytest-") or name in ADDITIONAL_PROJECTS } def iter_plugins(): session = get_session() name_2_serial = pytest_plugin_projects_from_pypi(session) for name, last_serial in tqdm(name_2_serial.items(), smoothing=0): response = project_response_with_refresh(session, name, last_serial) if response.status_code == 404: # Some packages, like pytest-azurepipelines42, are included in https://pypi.org/simple # but return 404 on the JSON API. Skip. continue response.raise_for_status() info = response.json()["info"] if "Development Status :: 7 - Inactive" in info["classifiers"]: continue for classifier in DEVELOPMENT_STATUS_CLASSIFIERS: if classifier in info["classifiers"]: status = classifier[22:] break else: status = "N/A" requires = "N/A" if info["requires_dist"]: for requirement in info["requires_dist"]: if re.match(r"pytest(?![-.\w])", requirement): requires = requirement break def version_sort_key(version_string): """ Return the sort key for the given version string returned by the API. """ try: return packaging.version.parse(version_string) except packaging.version.InvalidVersion: # Use a hard-coded pre-release version. return packaging.version.Version("0.0.0alpha") releases = response.json()["releases"] for release in sorted(releases, key=version_sort_key, reverse=True): if releases[release]: release_date = datetime.date.fromisoformat( releases[release][-1]["upload_time_iso_8601"].split("T")[0] ) last_release = release_date.strftime("%b %d, %Y") break name = f':pypi:`{info["name"]}`' summary = "" if info["summary"]: summary = escape_rst(info["summary"].replace("\n", "")) yield { "name": name, "summary": summary.strip(), "last release": last_release, "status": status, "requires": requires, } def plugin_definitions(plugins): """Return RST for the plugin list that fits better on a vertical page.""" for plugin in plugins: yield dedent( f""" {plugin['name']} *last release*: {plugin["last release"]}, *status*: {plugin["status"]}, *requires*: {plugin["requires"]} {plugin["summary"]} """ ) def main(): plugins = [*iter_plugins()] reference_dir = pathlib.Path("doc", "en", "reference") plugin_list = reference_dir / "plugin_list.rst" with plugin_list.open("w", encoding="UTF-8") as f: f.write(FILE_HEAD) f.write(f"This list contains {len(plugins)} plugins.\n\n") f.write(".. only:: not latex\n\n") wcwidth # reference library that must exist for tabulate to work plugin_table = tabulate.tabulate(plugins, headers="keys", tablefmt="rst") f.write(indent(plugin_table, " ")) f.write("\n\n") f.write(".. only:: latex\n\n") f.write(indent("".join(plugin_definitions(plugins)), " ")) if __name__ == "__main__": main()