Merge pull request #2969 from vinta/feature/relaunch-website

Relaunch website with custom build system
This commit is contained in:
Vinta Chen 2026-03-18 13:59:01 +08:00 committed by GitHub
commit a732751923
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 5297 additions and 56 deletions

48
.github/workflows/deploy-website.yml vendored Normal file
View File

@ -0,0 +1,48 @@
name: Deploy Website
on:
push:
branches:
- master
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
- name: Install dependencies
run: uv sync --no-dev
- name: Build site
run: uv run python website/build.py
- name: Upload artifact
uses: actions/upload-pages-artifact@v4
with:
path: website/output/
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

14
.gitignore vendored
View File

@ -1,9 +1,15 @@
# macOS
.DS_Store
# python
.venv/
*.py[co]
docs/index.md
site/
# website
website/output/
# PyCharm IDE
.idea
# claude code
.claude/skills/
.superpowers/
.gstack/
skills-lock.json

View File

@ -1,14 +1,17 @@
-include .env
export
site_install:
pip install -r requirements.txt
uv sync --no-dev
site_link:
ln -sf $(CURDIR)/README.md $(CURDIR)/docs/index.md
site_fetch_stats:
uv run python website/fetch_github_stars.py
site_preview: site_link
mkdocs serve
site_build:
uv run python website/build.py
site_build: site_link
mkdocs build
site_preview: site_build
python -m http.server -d website/output/ 8000
site_deploy: site_link
mkdocs gh-deploy --clean
site_deploy: site_build
@echo "Deploy via GitHub Actions (push to master)"

View File

@ -1,6 +1,6 @@
# Awesome Python
An opinionated list of awesome Python frameworks, libraries, software and resources.
An opinionated list of awesome Python frameworks, libraries, tools, software and resources.
> The **#10 most-starred repo on GitHub**. Put your product where Python developers discover tools. [Become a sponsor](SPONSORSHIP.md).
@ -87,9 +87,6 @@ An opinionated list of awesome Python frameworks, libraries, software and resour
- [Web Frameworks](#web-frameworks)
- [WebSocket](#websocket)
- [WSGI Servers](#wsgi-servers)
- [Resources](#resources)
- [Newsletters](#newsletters)
- [Podcasts](#podcasts)
---
@ -534,7 +531,6 @@ _Libraries for Python version and virtual environment management._
- [pyenv](https://github.com/pyenv/pyenv) - Simple Python version management.
- [pyenv-win](https://github.com/pyenv-win/pyenv-win) - Pyenv for Windows, Simple Python version management.
- [uv](https://github.com/astral-sh/uv) - An extremely fast Python package and project manager, written in Rust.
- [virtualenv](https://github.com/pypa/virtualenv) - A tool to create isolated Python environments.
## File Manipulation

View File

@ -1 +0,0 @@
awesome-python.com

View File

@ -1,9 +0,0 @@
@media (min-width: 960px) {
html {
scroll-behavior: smooth;
}
.md-content__inner > ul:nth-child(5) {
display: none;
}
}

View File

@ -1,26 +0,0 @@
site_name: Awesome Python
site_url: https://awesome-python.com
site_description: A curated list of awesome Python frameworks, libraries and software
site_author: Vinta Chen
repo_name: vinta/awesome-python
repo_url: https://github.com/vinta/awesome-python
theme:
name: material
palette:
primary: red
accent: pink
extra:
social:
- type: github
link: https://github.com/vinta
- type: twitter
link: https://twitter.com/vinta
- type: linkedin
link: https://www.linkedin.com/in/vinta
google_analytics:
- UA-510626-7
- auto
extra_css:
- css/extra.css
nav:
- "Life is short, you need Python.": "index.md"

23
pyproject.toml Normal file
View File

@ -0,0 +1,23 @@
[project]
name = "awesome-python"
version = "0.1.0"
description = "An opinionated list of awesome Python frameworks, libraries, software and resources."
requires-python = ">=3.13"
dependencies = [
"httpx==0.28.1",
"jinja2==3.1.6",
"markdown==3.10.2",
]
[dependency-groups]
dev = [
"pytest==9.0.2",
"ruff==0.15.6",
]
[tool.pytest.ini_options]
testpaths = ["website/tests"]
[tool.ruff]
target-version = "py313"
line-length = 100

View File

@ -1,2 +0,0 @@
mkdocs==1.0.4
mkdocs-material==4.0.2

258
uv.lock generated Normal file
View File

@ -0,0 +1,258 @@
version = 1
revision = 3
requires-python = ">=3.13"
[[package]]
name = "anyio"
version = "4.12.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
]
[[package]]
name = "awesome-python"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "httpx" },
{ name = "jinja2" },
{ name = "markdown" },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "httpx", specifier = "==0.28.1" },
{ name = "jinja2", specifier = "==3.1.6" },
{ name = "markdown", specifier = "==3.10.2" },
]
[package.metadata.requires-dev]
dev = [
{ name = "pytest", specifier = "==9.0.2" },
{ name = "ruff", specifier = "==0.15.6" },
]
[[package]]
name = "certifi"
version = "2026.2.25"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "jinja2"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
[[package]]
name = "markdown"
version = "3.10.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
{ url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
{ url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
{ url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
{ url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
{ url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
{ url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
{ url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
{ url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
{ url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
{ url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
{ url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
{ url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
{ url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
{ url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
{ url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
{ url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
{ url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
{ url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
{ url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
{ url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
{ url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
{ url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
{ url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
{ url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
{ url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
]
[[package]]
name = "packaging"
version = "26.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "pytest"
version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]]
name = "ruff"
version = "0.15.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/51/df/f8629c19c5318601d3121e230f74cbee7a3732339c52b21daa2b82ef9c7d/ruff-0.15.6.tar.gz", hash = "sha256:8394c7bb153a4e3811a4ecdacd4a8e6a4fa8097028119160dffecdcdf9b56ae4", size = 4597916, upload-time = "2026-03-12T23:05:47.51Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9e/2f/4e03a7e5ce99b517e98d3b4951f411de2b0fa8348d39cf446671adcce9a2/ruff-0.15.6-py3-none-linux_armv6l.whl", hash = "sha256:7c98c3b16407b2cf3d0f2b80c80187384bc92c6774d85fefa913ecd941256fff", size = 10508953, upload-time = "2026-03-12T23:05:17.246Z" },
{ url = "https://files.pythonhosted.org/packages/70/60/55bcdc3e9f80bcf39edf0cd272da6fa511a3d94d5a0dd9e0adf76ceebdb4/ruff-0.15.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ee7dcfaad8b282a284df4aa6ddc2741b3f4a18b0555d626805555a820ea181c3", size = 10942257, upload-time = "2026-03-12T23:05:23.076Z" },
{ url = "https://files.pythonhosted.org/packages/e7/f9/005c29bd1726c0f492bfa215e95154cf480574140cb5f867c797c18c790b/ruff-0.15.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3bd9967851a25f038fc8b9ae88a7fbd1b609f30349231dffaa37b6804923c4bb", size = 10322683, upload-time = "2026-03-12T23:05:33.738Z" },
{ url = "https://files.pythonhosted.org/packages/5f/74/2f861f5fd7cbb2146bddb5501450300ce41562da36d21868c69b7a828169/ruff-0.15.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13f4594b04e42cd24a41da653886b04d2ff87adbf57497ed4f728b0e8a4866f8", size = 10660986, upload-time = "2026-03-12T23:05:53.245Z" },
{ url = "https://files.pythonhosted.org/packages/c1/a1/309f2364a424eccb763cdafc49df843c282609f47fe53aa83f38272389e0/ruff-0.15.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e2ed8aea2f3fe57886d3f00ea5b8aae5bf68d5e195f487f037a955ff9fbaac9e", size = 10332177, upload-time = "2026-03-12T23:05:56.145Z" },
{ url = "https://files.pythonhosted.org/packages/30/41/7ebf1d32658b4bab20f8ac80972fb19cd4e2c6b78552be263a680edc55ac/ruff-0.15.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70789d3e7830b848b548aae96766431c0dc01a6c78c13381f423bf7076c66d15", size = 11170783, upload-time = "2026-03-12T23:06:01.742Z" },
{ url = "https://files.pythonhosted.org/packages/76/be/6d488f6adca047df82cd62c304638bcb00821c36bd4881cfca221561fdfc/ruff-0.15.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:542aaf1de3154cea088ced5a819ce872611256ffe2498e750bbae5247a8114e9", size = 12044201, upload-time = "2026-03-12T23:05:28.697Z" },
{ url = "https://files.pythonhosted.org/packages/71/68/e6f125df4af7e6d0b498f8d373274794bc5156b324e8ab4bf5c1b4fc0ec7/ruff-0.15.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c22e6f02c16cfac3888aa636e9eba857254d15bbacc9906c9689fdecb1953ab", size = 11421561, upload-time = "2026-03-12T23:05:31.236Z" },
{ url = "https://files.pythonhosted.org/packages/f1/9f/f85ef5fd01a52e0b472b26dc1b4bd228b8f6f0435975442ffa4741278703/ruff-0.15.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98893c4c0aadc8e448cfa315bd0cc343a5323d740fe5f28ef8a3f9e21b381f7e", size = 11310928, upload-time = "2026-03-12T23:05:45.288Z" },
{ url = "https://files.pythonhosted.org/packages/8c/26/b75f8c421f5654304b89471ed384ae8c7f42b4dff58fa6ce1626d7f2b59a/ruff-0.15.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:70d263770d234912374493e8cc1e7385c5d49376e41dfa51c5c3453169dc581c", size = 11235186, upload-time = "2026-03-12T23:05:50.677Z" },
{ url = "https://files.pythonhosted.org/packages/fc/d4/d5a6d065962ff7a68a86c9b4f5500f7d101a0792078de636526c0edd40da/ruff-0.15.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:55a1ad63c5a6e54b1f21b7514dfadc0c7fb40093fa22e95143cf3f64ebdcd512", size = 10635231, upload-time = "2026-03-12T23:05:37.044Z" },
{ url = "https://files.pythonhosted.org/packages/d6/56/7c3acf3d50910375349016cf33de24be021532042afbed87942858992491/ruff-0.15.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8dc473ba093c5ec238bb1e7429ee676dca24643c471e11fbaa8a857925b061c0", size = 10340357, upload-time = "2026-03-12T23:06:04.748Z" },
{ url = "https://files.pythonhosted.org/packages/06/54/6faa39e9c1033ff6a3b6e76b5df536931cd30caf64988e112bbf91ef5ce5/ruff-0.15.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:85b042377c2a5561131767974617006f99f7e13c63c111b998f29fc1e58a4cfb", size = 10860583, upload-time = "2026-03-12T23:05:58.978Z" },
{ url = "https://files.pythonhosted.org/packages/cb/1e/509a201b843b4dfb0b32acdedf68d951d3377988cae43949ba4c4133a96a/ruff-0.15.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cef49e30bc5a86a6a92098a7fbf6e467a234d90b63305d6f3ec01225a9d092e0", size = 11410976, upload-time = "2026-03-12T23:05:39.955Z" },
{ url = "https://files.pythonhosted.org/packages/6c/25/3fc9114abf979a41673ce877c08016f8e660ad6cf508c3957f537d2e9fa9/ruff-0.15.6-py3-none-win32.whl", hash = "sha256:bbf67d39832404812a2d23020dda68fee7f18ce15654e96fb1d3ad21a5fe436c", size = 10616872, upload-time = "2026-03-12T23:05:42.451Z" },
{ url = "https://files.pythonhosted.org/packages/89/7a/09ece68445ceac348df06e08bf75db72d0e8427765b96c9c0ffabc1be1d9/ruff-0.15.6-py3-none-win_amd64.whl", hash = "sha256:aee25bc84c2f1007ecb5037dff75cef00414fdf17c23f07dc13e577883dca406", size = 11787271, upload-time = "2026-03-12T23:05:20.168Z" },
{ url = "https://files.pythonhosted.org/packages/7f/d0/578c47dd68152ddddddf31cd7fc67dc30b7cdf639a86275fda821b0d9d98/ruff-0.15.6-py3-none-win_arm64.whl", hash = "sha256:c34de3dd0b0ba203be50ae70f5910b17188556630e2178fd7d79fc030eb0d837", size = 11060497, upload-time = "2026-03-12T23:05:25.968Z" },
]

502
website/build.py Normal file
View File

@ -0,0 +1,502 @@
#!/usr/bin/env python3
"""Build a single-page HTML site from README.md for the awesome-python website."""
import json
import re
import shutil
from pathlib import Path
from typing import TypedDict
import markdown
from jinja2 import Environment, FileSystemLoader
# Thematic grouping of categories. Each category name must match exactly
# as it appears in README.md (the ## heading text).
SECTION_GROUPS: list[tuple[str, list[str]]] = [
("Web & API", [
"Web Frameworks", "RESTful API", "GraphQL", "WebSocket",
"ASGI Servers", "WSGI Servers", "HTTP Clients", "Template Engine",
"Web Asset Management", "Web Content Extracting", "Web Crawling",
]),
("Data & ML", [
"Data Analysis", "Data Validation", "Data Visualization",
"Machine Learning", "Deep Learning", "Computer Vision",
"Natural Language Processing", "Recommender Systems", "Science",
"Quantum Computing",
]),
("DevOps & Infrastructure", [
"DevOps Tools", "Distributed Computing", "Task Queues",
"Job Scheduler", "Serverless Frameworks", "Logging", "Processes",
"Shell", "Network Virtualization", "RPC Servers",
]),
("Database & Storage", [
"Database", "Database Drivers", "ORM", "Caching", "Search",
"Serialization",
]),
("Development Tools", [
"Testing", "Debugging Tools", "Code Analysis", "Build Tools",
"Refactoring", "Documentation", "Editor Plugins and IDEs",
"Interactive Interpreter",
]),
("CLI & GUI", [
"Command-line Interface Development", "Command-line Tools",
"GUI Development",
]),
("Content & Media", [
"Audio", "Video", "Image Processing", "HTML Manipulation",
"Text Processing", "Specific Formats Processing",
"File Manipulation", "Downloader",
]),
("System & Runtime", [
"Asynchronous Programming", "Environment Management",
"Package Management", "Package Repositories", "Distribution",
"Implementations", "Built-in Classes Enhancement",
"Functional Programming", "Configuration Files",
]),
("Security & Auth", [
"Authentication", "Cryptography", "Penetration Testing",
"Permissions",
]),
("Specialized", [
"CMS", "Admin Panels", "Email", "Game Development", "Geolocation",
"Hardware", "Internationalization", "Date and Time",
"URL Manipulation", "Robotics", "Microsoft Windows", "Miscellaneous",
"Algorithms and Design Patterns", "Static Site Generator",
]),
("Resources", []), # Filled dynamically from parsed resources
]
def slugify(name: str) -> str:
"""Convert a category name to a URL-friendly slug."""
slug = name.lower()
slug = re.sub(r"[^a-z0-9\s-]", "", slug)
slug = re.sub(r"[\s]+", "-", slug.strip())
slug = re.sub(r"-+", "-", slug)
return slug
def count_entries(content: str) -> int:
"""Count library entries (lines starting with * [ or - [) in a content block."""
return sum(1 for line in content.split("\n") if re.match(r"\s*[-*]\s+\[", line))
def extract_preview(content: str, *, max_names: int = 4) -> str:
"""Extract first N main library names from markdown content for preview text.
Only includes top-level or single-indent entries (indent <= 3 spaces),
skipping subcategory labels (items without links) and deep sub-entries.
"""
names = []
for m in re.finditer(r"^(\s*)[-*]\s+\[([^\]]+)\]", content, re.MULTILINE):
indent_len = len(m.group(1))
if indent_len > 3:
continue
names.append(m.group(2))
if len(names) >= max_names:
break
return ", ".join(names)
def render_content_html(content: str) -> str:
"""Render category markdown content to HTML with subcategory detection.
Lines that are list items without links (e.g., "- Synchronous") are
treated as subcategory headers and rendered as bold dividers.
Indent levels in the README:
- 0 spaces: top-level entry or subcategory label
- 2 spaces: entry under a subcategory (still a main entry)
- 4+ spaces: sub-entry (e.g., awesome-django under django)
"""
lines = content.split("\n")
out: list[str] = []
for line in lines:
stripped = line.strip()
indent_len = len(line) - len(line.lstrip())
# Detect subcategory labels: list items without links
m = re.match(r"^[-*]\s+(.+)$", stripped)
if m and "[" not in stripped:
label = m.group(1)
out.append(f'<div class="subcat">{label}</div>')
continue
# Entry with link and description: * [name](url) - Description.
m = re.match(
r"^\s*[-*]\s+\[([^\]]+)\]\(([^)]+)\)\s*[-\u2013\u2014]\s*(.+)$",
line,
)
if m:
name, url, desc = m.groups()
if indent_len > 3:
out.append(
f'<div class="entry-sub">'
f'<a href="{url}">{name}</a>'
f"</div>"
)
else:
out.append(
f'<div class="entry">'
f'<a href="{url}">{name}</a>'
f'<span class="sep">&mdash;</span>{desc}'
f"</div>"
)
continue
# Link-only entry (no description): * [name](url)
m = re.match(r"^\s*[-*]\s+\[([^\]]+)\]\(([^)]+)\)\s*$", line)
if m:
name, url = m.groups()
if indent_len > 3:
out.append(
f'<div class="entry-sub">'
f'<a href="{url}">{name}</a>'
f"</div>"
)
else:
out.append(
f'<div class="entry">'
f'<a href="{url}">{name}</a>'
f"</div>"
)
continue
return "\n".join(out)
def parse_readme(text: str) -> tuple[list[dict], list[dict]]:
"""Parse README.md text into categories and resources.
Returns:
(categories, resources) where each is a list of dicts with keys:
name, slug, description, content
"""
lines = text.split("\n")
separator_idx = None
for i, line in enumerate(lines):
if line.strip() == "---" and i > 0:
separator_idx = i
break
if separator_idx is None:
return [], []
resources_idx = None
contributing_idx = None
for i, line in enumerate(lines):
if line.strip() == "# Resources":
resources_idx = i
elif line.strip() == "# Contributing":
contributing_idx = i
cat_end = resources_idx if resources_idx is not None else len(lines)
category_lines = lines[separator_idx + 1 : cat_end]
resource_lines = []
if resources_idx is not None:
res_end = contributing_idx if contributing_idx is not None else len(lines)
resource_lines = lines[resources_idx:res_end]
categories = _extract_sections(category_lines, level=2)
resources = _extract_sections(resource_lines, level=2)
return categories, resources
def _extract_sections(lines: list[str], *, level: int) -> list[dict]:
"""Extract ## sections from a block of lines."""
prefix = "#" * level + " "
sections = []
current_name = None
current_lines: list[str] = []
for line in lines:
if line.startswith(prefix) and not line.startswith(prefix + "#"):
if current_name is not None:
sections.append(_build_section(current_name, current_lines))
current_name = line[len(prefix) :].strip()
current_lines = []
elif current_name is not None:
current_lines.append(line)
if current_name is not None:
sections.append(_build_section(current_name, current_lines))
return sections
def _build_section(name: str, lines: list[str]) -> dict:
"""Build a section dict from a name and its content lines."""
while lines and not lines[0].strip():
lines = lines[1:]
while lines and not lines[-1].strip():
lines = lines[:-1]
description = ""
content_lines = lines
if lines:
m = re.match(r"^_(.+)_$", lines[0].strip())
if m:
description = m.group(1)
content_lines = lines[1:]
while content_lines and not content_lines[0].strip():
content_lines = content_lines[1:]
content = "\n".join(content_lines).strip()
return {
"name": name,
"slug": slugify(name),
"description": description,
"content": content,
}
def render_markdown(text: str) -> str:
"""Render markdown text to HTML."""
md = markdown.Markdown(extensions=["extra"])
return md.convert(text)
def strip_markdown_links(text: str) -> str:
"""Replace [text](url) with just text for plain-text contexts."""
return re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text)
def render_inline_markdown(text: str) -> str:
"""Render inline markdown (links, bold, italic) to HTML."""
from markupsafe import Markup
html = markdown.markdown(text)
# Strip wrapping <p>...</p> since this is inline content
html = re.sub(r"^<p>(.*)</p>$", r"\1", html.strip())
# Add target/rel to links for external navigation
html = html.replace("<a ", '<a target="_blank" rel="noopener" ')
return Markup(html)
def group_categories(
categories: list[dict],
resources: list[dict],
) -> list[dict]:
"""Organize categories and resources into thematic section groups."""
cat_by_name = {c["name"]: c for c in categories}
groups = []
for group_name, cat_names in SECTION_GROUPS:
if group_name == "Resources":
# Resources group uses parsed resources directly
group_cats = list(resources)
else:
group_cats = [cat_by_name[n] for n in cat_names if n in cat_by_name]
if group_cats:
groups.append({
"name": group_name,
"slug": slugify(group_name),
"categories": group_cats,
})
# Any categories not in a group go into "Other"
grouped_names = set()
for _, cat_names in SECTION_GROUPS:
grouped_names.update(cat_names)
ungrouped = [c for c in categories if c["name"] not in grouped_names]
if ungrouped:
groups.append({
"name": "Other",
"slug": "other",
"categories": ungrouped,
})
return groups
class Entry(TypedDict):
name: str
url: str
description: str
category: str
group: str
stars: int | None
owner: str | None
pushed_at: str | None
class StarData(TypedDict):
stars: int
owner: str
pushed_at: str
fetched_at: str
GITHUB_REPO_URL_RE = re.compile(
r"^https?://github\.com/([^/]+/[^/]+?)(?:\.git)?/?$"
)
def extract_github_repo(url: str) -> str | None:
"""Extract owner/repo from a GitHub repo URL. Returns None for non-GitHub URLs."""
m = GITHUB_REPO_URL_RE.match(url)
return m.group(1) if m else None
def load_stars(path: Path) -> dict[str, StarData]:
"""Load star data from JSON. Returns empty dict if file doesn't exist or is corrupt."""
if path.exists():
try:
return json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return {}
return {}
def sort_entries(entries: list[dict]) -> list[dict]:
"""Sort entries by stars descending, then name ascending. No-star entries go last."""
def sort_key(entry: dict) -> tuple[int, int, str]:
stars = entry["stars"]
name = entry["name"].lower()
if stars is None:
return (1, 0, name)
return (0, -stars, name)
return sorted(entries, key=sort_key)
def extract_entries(
categories: list[dict],
resources: list[dict],
groups: list[dict],
) -> list[dict]:
"""Flatten categories into individual library entries for table display."""
cat_to_group: dict[str, str] = {}
for group in groups:
for cat in group["categories"]:
cat_to_group[cat["name"]] = group["name"]
entries: list[dict] = []
for cat in categories:
group_name = cat_to_group.get(cat["name"], "Other")
last_entry_indent = -1
for line in cat["content"].split("\n"):
indent_len = len(line) - len(line.lstrip())
# Link-only sub-item deeper than parent → "also see"
m_sub = re.match(r"\s*[-*]\s+\[([^\]]+)\]\(([^)]+)\)\s*$", line)
if m_sub and indent_len > last_entry_indent >= 0 and entries:
entries[-1]["also_see"].append({
"name": m_sub.group(1),
"url": m_sub.group(2),
})
continue
if indent_len > 3:
continue
m = re.match(
r"\s*[-*]\s+\[([^\]]+)\]\(([^)]+)\)\s*(?:[-\u2013\u2014]\s*(.+))?$",
line,
)
if m:
last_entry_indent = indent_len
entries.append({
"name": m.group(1),
"url": m.group(2),
"description": render_inline_markdown(m.group(3)) if m.group(3) else "",
"category": cat["name"],
"group": group_name,
"stars": None,
"owner": None,
"pushed_at": None,
"also_see": [],
})
return entries
def build(repo_root: str) -> None:
"""Main build: parse README, render single-page HTML via Jinja2 templates."""
repo = Path(repo_root)
website = repo / "website"
readme_text = (repo / "README.md").read_text(encoding="utf-8")
# Extract subtitle from the first non-empty, non-heading line
subtitle = ""
for line in readme_text.split("\n"):
stripped = line.strip()
if stripped and not stripped.startswith("#"):
subtitle = stripped
break
categories, resources = parse_readme(readme_text)
# Enrich with entry counts, rendered HTML, previews, and clean descriptions
for cat in categories + resources:
cat["entry_count"] = count_entries(cat["content"])
cat["content_html"] = render_content_html(cat["content"])
cat["preview"] = extract_preview(cat["content"])
cat["description"] = strip_markdown_links(cat["description"])
total_entries = sum(c["entry_count"] for c in categories)
# Organize into groups
groups = group_categories(categories, resources)
# Flatten entries for table view
entries = extract_entries(categories, resources, groups)
# Load and merge GitHub star data
stars_data = load_stars(website / "data" / "github_stars.json")
for entry in entries:
repo_key = extract_github_repo(entry["url"])
if repo_key and repo_key in stars_data:
entry["stars"] = stars_data[repo_key]["stars"]
entry["owner"] = stars_data[repo_key]["owner"]
entry["pushed_at"] = stars_data[repo_key].get("pushed_at", "")
# Sort by stars descending
entries = sort_entries(entries)
# Set up Jinja2
env = Environment(
loader=FileSystemLoader(website / "templates"),
autoescape=True,
)
# Output directory
site_dir = website / "output"
if site_dir.exists():
shutil.rmtree(site_dir)
site_dir.mkdir(parents=True)
# Generate single index.html
tpl_index = env.get_template("index.html")
(site_dir / "index.html").write_text(
tpl_index.render(
categories=categories,
resources=resources,
groups=groups,
subtitle=subtitle,
entries=entries,
total_entries=total_entries,
total_categories=len(categories),
),
encoding="utf-8",
)
# Copy static assets
static_src = website / "static"
static_dst = site_dir / "static"
if static_src.exists():
shutil.copytree(static_src, static_dst)
# Write CNAME
(site_dir / "CNAME").write_text("awesome-python.com\n", encoding="utf-8")
print(f"Built single page with {len(categories)} categories + {len(resources)} resources")
print(f"Total entries: {total_entries}")
print(f"Output: {site_dir}")
if __name__ == "__main__":
build(str(Path(__file__).parent.parent))

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,192 @@
#!/usr/bin/env python3
"""Fetch GitHub star counts and owner info for all GitHub repos in README.md."""
import json
import os
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
import httpx
from build import extract_github_repo
CACHE_MAX_AGE_DAYS = 7
DATA_DIR = Path(__file__).parent / "data"
CACHE_FILE = DATA_DIR / "github_stars.json"
README_PATH = Path(__file__).parent.parent / "README.md"
GRAPHQL_URL = "https://api.github.com/graphql"
BATCH_SIZE = 100
def extract_github_repos(text: str) -> set[str]:
"""Extract unique owner/repo pairs from GitHub URLs in markdown text."""
repos = set()
for url in re.findall(r"https?://github\.com/[^\s)\]]+", text):
repo = extract_github_repo(url.split("#")[0].rstrip("/"))
if repo:
repos.add(repo)
return repos
def load_cache() -> dict:
"""Load the star cache from disk. Returns empty dict if missing or corrupt."""
if CACHE_FILE.exists():
try:
return json.loads(CACHE_FILE.read_text(encoding="utf-8"))
except json.JSONDecodeError:
print(f"Warning: corrupt cache at {CACHE_FILE}, starting fresh.", file=sys.stderr)
return {}
return {}
def save_cache(cache: dict) -> None:
"""Write the star cache to disk, creating data/ dir if needed."""
DATA_DIR.mkdir(parents=True, exist_ok=True)
CACHE_FILE.write_text(
json.dumps(cache, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
def build_graphql_query(repos: list[str]) -> str:
"""Build a GraphQL query with aliases for up to 100 repos."""
if not repos:
return ""
parts = []
for i, repo in enumerate(repos):
owner, name = repo.split("/", 1)
if '"' in owner or '"' in name:
continue
parts.append(
f'repo_{i}: repository(owner: "{owner}", name: "{name}") '
f"{{ stargazerCount pushedAt owner {{ login }} }}"
)
if not parts:
return ""
return "query { " + " ".join(parts) + " }"
def parse_graphql_response(
data: dict,
repos: list[str],
) -> dict[str, dict]:
"""Parse GraphQL response into {owner/repo: {stars, owner}} dict."""
result = {}
for i, repo in enumerate(repos):
node = data.get(f"repo_{i}")
if node is None:
continue
result[repo] = {
"stars": node.get("stargazerCount", 0),
"owner": node.get("owner", {}).get("login", ""),
"pushed_at": node.get("pushedAt", ""),
}
return result
def fetch_batch(
repos: list[str], *, client: httpx.Client,
) -> dict[str, dict]:
"""Fetch star data for a batch of repos via GitHub GraphQL API."""
query = build_graphql_query(repos)
if not query:
return {}
resp = client.post(GRAPHQL_URL, json={"query": query})
resp.raise_for_status()
result = resp.json()
if "errors" in result:
for err in result["errors"]:
print(f" Warning: {err.get('message', err)}", file=sys.stderr)
data = result.get("data", {})
return parse_graphql_response(data, repos)
def main() -> None:
"""Fetch GitHub stars for all repos in README.md, updating the JSON cache."""
token = os.environ.get("GITHUB_TOKEN", "")
if not token:
print("Error: GITHUB_TOKEN environment variable is required.", file=sys.stderr)
sys.exit(1)
readme_text = README_PATH.read_text(encoding="utf-8")
current_repos = extract_github_repos(readme_text)
print(f"Found {len(current_repos)} GitHub repos in README.md")
cache = load_cache()
now = datetime.now(timezone.utc)
# Prune entries not in current README
pruned = {k: v for k, v in cache.items() if k in current_repos}
if len(pruned) < len(cache):
print(f"Pruned {len(cache) - len(pruned)} stale cache entries")
cache = pruned
# Determine which repos need fetching (missing or stale)
to_fetch = []
for repo in sorted(current_repos):
entry = cache.get(repo)
if entry and "fetched_at" in entry:
fetched = datetime.fromisoformat(entry["fetched_at"])
age_days = (now - fetched).days
if age_days < CACHE_MAX_AGE_DAYS:
continue
to_fetch.append(repo)
print(f"{len(to_fetch)} repos to fetch ({len(current_repos) - len(to_fetch)} cached)")
if not to_fetch:
save_cache(cache)
print("Cache is up to date.")
return
# Fetch in batches
fetched_count = 0
skipped_repos: list[str] = []
with httpx.Client(
headers={"Authorization": f"bearer {token}", "Content-Type": "application/json"},
transport=httpx.HTTPTransport(retries=2),
timeout=30,
) as client:
for i in range(0, len(to_fetch), BATCH_SIZE):
batch = to_fetch[i : i + BATCH_SIZE]
batch_num = i // BATCH_SIZE + 1
total_batches = (len(to_fetch) + BATCH_SIZE - 1) // BATCH_SIZE
print(f"Fetching batch {batch_num}/{total_batches} ({len(batch)} repos)...")
try:
results = fetch_batch(batch, client=client)
except httpx.HTTPStatusError as e:
print(f"HTTP error {e.response.status_code}", file=sys.stderr)
if e.response.status_code == 401:
print("Error: Invalid GITHUB_TOKEN.", file=sys.stderr)
sys.exit(1)
print("Saving partial cache and exiting.", file=sys.stderr)
save_cache(cache)
sys.exit(1)
now_iso = now.isoformat()
for repo in batch:
if repo in results:
cache[repo] = {
"stars": results[repo]["stars"],
"owner": results[repo]["owner"],
"pushed_at": results[repo]["pushed_at"],
"fetched_at": now_iso,
}
fetched_count += 1
else:
skipped_repos.append(repo)
# Save after each batch in case of interruption
save_cache(cache)
if skipped_repos:
print(f"Skipped {len(skipped_repos)} repos (deleted/private/renamed)")
print(f"Done. Fetched {fetched_count} repos, {len(cache)} total cached.")
if __name__ == "__main__":
main()

154
website/static/main.js Normal file
View File

@ -0,0 +1,154 @@
// State
var activeFilter = null; // { type: "cat"|"group", value: "..." }
var searchInput = document.querySelector('.search');
var filterBar = document.querySelector('.filter-bar');
var filterValue = document.querySelector('.filter-value');
var filterClear = document.querySelector('.filter-clear');
var noResults = document.querySelector('.no-results');
var countEl = document.querySelector('.count');
var rows = document.querySelectorAll('.table tbody tr.row');
var tags = document.querySelectorAll('.tag');
var tbody = document.querySelector('.table tbody');
function collapseAll() {
var openRows = document.querySelectorAll('.table tbody tr.row.open');
openRows.forEach(function (row) {
row.classList.remove('open');
row.setAttribute('aria-expanded', 'false');
});
}
function applyFilters() {
var query = searchInput ? searchInput.value.toLowerCase().trim() : '';
var visibleCount = 0;
// Collapse all expanded rows on filter/search change
collapseAll();
rows.forEach(function (row) {
var show = true;
// Category/group filter
if (activeFilter) {
show = row.dataset[activeFilter.type] === activeFilter.value;
}
// Text search
if (show && query) {
if (!row._searchText) {
var text = row.textContent.toLowerCase();
var next = row.nextElementSibling;
if (next && next.classList.contains('expand-row')) {
text += ' ' + next.textContent.toLowerCase();
}
row._searchText = text;
}
show = row._searchText.includes(query);
}
row.hidden = !show;
if (show) {
visibleCount++;
row.querySelector('.col-num').textContent = String(visibleCount);
}
});
if (noResults) noResults.hidden = visibleCount > 0;
if (countEl) countEl.textContent = visibleCount;
// Update tag highlights
tags.forEach(function (tag) {
var isActive = activeFilter
&& tag.dataset.type === activeFilter.type
&& tag.dataset.value === activeFilter.value;
tag.classList.toggle('active', isActive);
});
// Filter bar
if (filterBar) {
if (activeFilter) {
filterBar.hidden = false;
if (filterValue) filterValue.textContent = activeFilter.value;
} else {
filterBar.hidden = true;
}
}
}
// Expand/collapse: event delegation on tbody
if (tbody) {
tbody.addEventListener('click', function (e) {
// Don't toggle if clicking a link or tag button
if (e.target.closest('a') || e.target.closest('.tag')) return;
var row = e.target.closest('tr.row');
if (!row) return;
var isOpen = row.classList.contains('open');
if (isOpen) {
row.classList.remove('open');
row.setAttribute('aria-expanded', 'false');
} else {
row.classList.add('open');
row.setAttribute('aria-expanded', 'true');
}
});
// Keyboard: Enter or Space on focused .row toggles expand
tbody.addEventListener('keydown', function (e) {
if (e.key !== 'Enter' && e.key !== ' ') return;
var row = e.target.closest('tr.row');
if (!row) return;
e.preventDefault();
row.click();
});
}
// Tag click: filter by category or group
tags.forEach(function (tag) {
tag.addEventListener('click', function (e) {
e.preventDefault();
var type = tag.dataset.type;
var value = tag.dataset.value;
// Toggle: click same filter again to clear
if (activeFilter && activeFilter.type === type && activeFilter.value === value) {
activeFilter = null;
} else {
activeFilter = { type: type, value: value };
}
applyFilters();
});
});
// Clear filter
if (filterClear) {
filterClear.addEventListener('click', function () {
activeFilter = null;
applyFilters();
});
}
// Search input
if (searchInput) {
var searchTimer;
searchInput.addEventListener('input', function () {
clearTimeout(searchTimer);
searchTimer = setTimeout(applyFilters, 150);
});
// Keyboard shortcuts
document.addEventListener('keydown', function (e) {
if (e.key === '/' && !['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement.tagName) && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
searchInput.focus();
}
if (e.key === 'Escape' && document.activeElement === searchInput) {
searchInput.value = '';
activeFilter = null;
applyFilters();
searchInput.blur();
}
});
}

459
website/static/style.css Normal file
View File

@ -0,0 +1,459 @@
/* === Reset & Base === */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--font-display: Georgia, "Noto Serif", "Times New Roman", serif;
--font-body: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
--text-xs: 0.9375rem;
--text-sm: 1rem;
--text-base: 1.125rem;
--bg: oklch(99.5% 0.003 240);
--bg-hover: oklch(97% 0.008 240);
--text: oklch(15% 0.005 240);
--text-secondary: oklch(35% 0.005 240);
--text-muted: oklch(50% 0.005 240);
--border: oklch(90% 0.005 240);
--border-strong: oklch(75% 0.008 240);
--border-heavy: oklch(25% 0.01 240);
--bg-input: oklch(94.5% 0.035 240);
--accent: oklch(42% 0.14 240);
--accent-hover: oklch(32% 0.16 240);
--accent-light: oklch(97% 0.015 240);
--highlight: oklch(93% 0.10 90);
--highlight-text: oklch(35% 0.10 90);
}
html { font-size: 16px; }
body {
font-family: var(--font-body);
background: var(--bg);
color: var(--text);
line-height: 1.55;
min-height: 100vh;
display: flex;
flex-direction: column;
-webkit-font-smoothing: antialiased;
}
a { color: var(--accent); text-decoration: none; }
a:hover { color: var(--accent-hover); text-decoration: underline; }
/* === Skip Link === */
.skip-link {
position: absolute;
left: -9999px;
top: 0;
padding: 0.5rem 1rem;
background: var(--text);
color: var(--bg);
font-size: var(--text-xs);
font-weight: 700;
z-index: 200;
}
.skip-link:focus { left: 0; }
/* === Hero === */
.hero {
max-width: 1400px;
margin: 0 auto;
padding: 3.5rem 2rem 1.5rem;
}
.hero-main {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.hero-submit {
flex-shrink: 0;
padding: 0.4rem 1rem;
border: 1px solid var(--border-strong);
border-radius: 4px;
font-size: var(--text-sm);
color: var(--text);
text-decoration: none;
white-space: nowrap;
}
.hero-submit:hover {
border-color: var(--accent);
color: var(--accent);
text-decoration: none;
}
.hero h1 {
font-family: var(--font-display);
font-size: clamp(2rem, 5vw, 3rem);
font-weight: 400;
letter-spacing: -0.01em;
line-height: 1.1;
color: var(--accent);
margin-bottom: 0.75rem;
}
.hero-sub {
font-size: var(--text-sm);
color: var(--text-secondary);
line-height: 1.6;
margin-bottom: 0.5rem;
}
.hero-sub a { color: var(--text-secondary); font-weight: 600; }
.hero-sub a:hover { color: var(--accent); }
.hero-gh {
font-size: var(--text-sm);
color: var(--text-muted);
font-weight: 500;
}
.hero-gh:hover { color: var(--accent); }
/* === Controls === */
.controls {
max-width: 1400px;
margin: 0 auto;
padding: 0 2rem 1rem;
}
.search-wrap {
position: relative;
margin-bottom: 0.75rem;
}
.search-icon {
position: absolute;
left: 1rem;
top: 50%;
transform: translateY(-50%);
color: var(--text-muted);
pointer-events: none;
}
.search {
width: 100%;
padding: 0.65rem 1rem 0.65rem 2.75rem;
border: 1px solid transparent;
border-radius: 4px;
background: var(--bg-input);
font-family: var(--font-body);
font-size: var(--text-sm);
color: var(--text);
}
.search::placeholder { color: var(--text-muted); }
.search:focus {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-color: var(--accent);
background: var(--bg);
}
.filter-bar[hidden] { display: none; }
.filter-bar {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0;
font-size: var(--text-sm);
color: var(--text-secondary);
}
.filter-bar strong {
color: var(--text);
}
.filter-clear {
background: none;
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.15rem 0.5rem;
font-family: inherit;
font-size: var(--text-xs);
color: var(--text-muted);
cursor: pointer;
}
.filter-clear:hover {
border-color: var(--text-muted);
color: var(--text);
}
.stats {
font-size: var(--text-sm);
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.stats strong { color: var(--text-secondary); }
/* === Table === */
.table-wrap {
width: 100%;
padding: 0;
overflow-x: auto;
}
.table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
font-size: var(--text-sm);
}
.table thead th {
text-align: left;
font-weight: 700;
font-size: var(--text-base);
color: var(--text);
padding: 0.65rem 0.75rem;
border-bottom: 2px solid var(--border-heavy);
position: sticky;
top: 0;
background: var(--bg);
z-index: 10;
white-space: nowrap;
}
.table thead th:first-child,
.table tbody td:first-child {
padding-left: max(2rem, calc(50vw - 700px + 2rem));
}
.table thead th:last-child,
.table tbody td:last-child {
padding-right: max(2rem, calc(50vw - 700px + 2rem));
}
.table tbody td {
padding: 0.7rem 0.75rem;
border-bottom: 1px solid var(--border);
vertical-align: top;
}
.table tbody tr.row:not(.open):hover td {
background: var(--bg-hover);
}
.table tbody tr[hidden] { display: none; }
.col-num {
width: 3rem;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
text-align: right;
}
.col-name {
width: 35%;
overflow-wrap: break-word;
word-wrap: break-word;
word-break: break-word;
}
.col-name > a {
font-weight: 500;
color: var(--accent);
text-decoration: none;
}
.col-name > a:hover { text-decoration: underline; color: var(--accent-hover); }
/* === Stars Column === */
.col-stars {
width: 5rem;
font-variant-numeric: tabular-nums;
white-space: nowrap;
color: var(--text-secondary);
}
/* === Arrow Column === */
.col-arrow {
width: 2.5rem;
text-align: center;
}
.arrow {
display: inline-block;
font-size: 0.8rem;
color: var(--accent);
transition: transform 0.15s ease;
}
.row.open .arrow {
transform: rotate(90deg);
}
/* === Row Click === */
.row { cursor: pointer; }
/* === Expand Row === */
.expand-row {
display: none;
}
.row.open + .expand-row {
display: table-row;
}
.row.open td {
background: var(--accent-light);
border-bottom-color: transparent;
padding-bottom: 0.1rem;
}
.expand-row td {
padding: 0.15rem 0.75rem 0.75rem;
background: var(--accent-light);
border-bottom: 1px solid var(--border);
}
.expand-content {
font-size: var(--text-sm);
color: var(--text-secondary);
line-height: 1.6;
}
.expand-also-see {
margin-top: 0.25rem;
font-size: var(--text-xs);
color: var(--text-muted);
}
.expand-also-see a {
color: var(--accent);
text-decoration: none;
}
.expand-also-see a:hover {
text-decoration: underline;
}
.expand-meta {
margin-top: 0.25rem;
font-size: var(--text-xs);
color: var(--text-muted);
font-weight: normal;
}
.expand-meta a {
color: var(--accent);
text-decoration: none;
}
.expand-meta a:hover {
text-decoration: underline;
}
.expand-sep {
margin: 0 0.25rem;
color: var(--border);
}
.col-cat, .col-group {
width: 13%;
white-space: nowrap;
}
/* === Tags === */
.tag {
background: var(--accent-light);
border: none;
font-family: inherit;
font-size: var(--text-xs);
color: oklch(45% 0.06 240);
cursor: pointer;
padding: 0.15rem 0.35rem;
border-radius: 3px;
white-space: nowrap;
}
.tag:hover {
background: var(--accent-light);
color: var(--accent);
}
.tag.active {
background: var(--highlight);
color: var(--highlight-text);
font-weight: 600;
}
/* === No Results === */
.no-results {
max-width: 1400px;
margin: 0 auto;
padding: 3rem 2rem;
font-size: var(--text-base);
color: var(--text-muted);
text-align: center;
}
/* === Footer === */
.footer {
margin-top: auto;
border-top: none;
width: 100%;
padding: 1.25rem 2rem;
font-size: var(--text-xs);
color: var(--text-muted);
background: var(--bg-input);
display: flex;
align-items: center;
justify-content: space-between;
}
.footer a { color: var(--text-muted); text-decoration: none; }
.footer a:hover { color: var(--accent); }
.footer-links {
display: flex;
gap: 1rem;
}
/* === Responsive === */
@media (max-width: 900px) {
.col-group { display: none; }
}
@media (max-width: 640px) {
.hero { padding: 2rem 1.25rem 1rem; }
.controls { padding: 0 1.25rem 0.75rem; }
.table thead th:first-child,
.table tbody td:first-child { padding-left: 1.25rem; }
.table thead th:last-child,
.table tbody td:last-child { padding-right: 1.25rem; }
.col-cat { display: none; }
.col-name { white-space: normal; }
.footer { padding: 1.25rem; flex-direction: column; gap: 0.5rem; }
}
/* === Screen Reader Only === */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* === Reduced Motion === */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
transition-duration: 0.01ms !important;
}
}

View File

@ -0,0 +1,67 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{% block title %}Awesome Python{% endblock %}</title>
<meta
name="description"
content="{% block description %}An opinionated list of awesome Python frameworks, libraries, software and resources. {{ total_entries }} libraries across {{ categories | length }} categories.{% endblock %}"
/>
<link rel="canonical" href="https://awesome-python.com/" />
<meta property="og:type" content="website" />
<meta property="og:title" content="Awesome Python" />
<meta
property="og:description"
content="An opinionated list of awesome Python frameworks, libraries, software and resources."
/>
<meta property="og:url" content="https://awesome-python.com/" />
<meta name="twitter:card" content="summary" />
<link
rel="icon"
href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🐍</text></svg>"
/>
<link rel="stylesheet" href="/static/style.css" />
<script
async
src="https://www.googletagmanager.com/gtag/js?id=G-0LMLYE0HER"
></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag() {
dataLayer.push(arguments);
}
gtag("js", new Date());
gtag("config", "G-0LMLYE0HER");
</script>
</head>
<body>
<a href="#content" class="skip-link">Skip to content</a>
<main id="content">{% block content %}{% endblock %}</main>
<footer class="footer">
<div class="footer-links">
<a href="https://github.com/vinta" target="_blank" rel="noopener"
>GitHub</a
>
<a href="https://twitter.com/vinta" target="_blank" rel="noopener"
>Twitter</a
>
</div>
<span
>Curated by
<a href="https://github.com/vinta" target="_blank" rel="noopener"
>Vinta</a
></span
>
</footer>
<noscript
><p style="text-align: center; padding: 1rem; color: #666">
JavaScript is needed for search and filtering.
</p></noscript
>
<script src="/static/main.js"></script>
</body>
</html>

View File

@ -0,0 +1,146 @@
{% extends "base.html" %}
{% block content %}
<header class="hero">
<div class="hero-main">
<div>
<h1>Awesome Python</h1>
<p class="hero-sub">
{{ subtitle }}<br />Curated by
<a href="https://github.com/vinta" target="_blank" rel="noopener"
>@vinta</a
>
since 2014.
</p>
<a
href="https://github.com/vinta/awesome-python"
class="hero-gh"
target="_blank"
rel="noopener"
>awesome-python on GitHub &rarr;</a
>
</div>
<a
href="https://github.com/vinta/awesome-python/blob/master/CONTRIBUTING.md"
class="hero-submit"
target="_blank"
rel="noopener"
>Submit a Project</a
>
</div>
</header>
<div class="controls">
<div class="search-wrap">
<svg
class="search-icon"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<input
type="search"
class="search"
placeholder="Search {{ entries | length }} libraries across {{ total_categories }} categories..."
aria-label="Search libraries"
/>
</div>
<div class="filter-bar" hidden>
<span>Showing <strong class="filter-value"></strong></span>
<button class="filter-clear" aria-label="Clear filter">
&times; Clear
</button>
</div>
</div>
<div class="table-wrap">
<table class="table">
<thead>
<tr>
<th class="col-num"><span class="sr-only">#</span></th>
<th class="col-name">Project Name</th>
<th class="col-stars">GitHub Stars</th>
<th class="col-cat">Category</th>
<th class="col-group">Group</th>
<th class="col-arrow"></th>
</tr>
</thead>
<tbody>
{% for entry in entries %}
<tr
class="row"
data-cat="{{ entry.category }}"
data-group="{{ entry.group }}"
tabindex="0"
aria-expanded="false"
aria-controls="expand-{{ loop.index }}"
>
<td class="col-num">{{ loop.index }}</td>
<td class="col-name">
<a href="{{ entry.url }}" target="_blank" rel="noopener"
>{{ entry.name }}</a
>
</td>
<td class="col-stars">
{% if entry.stars is not none %}{{ "{:,}".format(entry.stars) }}{%
else %}&mdash;{% endif %}
</td>
<td class="col-cat">
<button class="tag" data-type="cat" data-value="{{ entry.category }}">
{{ entry.category }}
</button>
</td>
<td class="col-group">
<button class="tag" data-type="group" data-value="{{ entry.group }}">
{{ entry.group }}
</button>
</td>
<td class="col-arrow"><span class="arrow">&rarr;</span></td>
</tr>
<tr class="expand-row" id="expand-{{ loop.index }}">
<td></td>
<td colspan="5">
<div class="expand-content">
{% if entry.description %}
<div class="expand-desc">{{ entry.description | safe }}</div>
{% endif %} {% if entry.also_see %}
<div class="expand-also-see">
Also see: {% for see in entry.also_see %}<a
href="{{ see.url }}"
target="_blank"
rel="noopener"
>{{ see.name }}</a
>{% if not loop.last %}, {% endif %}{% endfor %}
</div>
{% endif %}
<div class="expand-meta">
{% if entry.owner %}<a
href="https://github.com/{{ entry.owner }}"
target="_blank"
rel="noopener"
>{{ entry.owner }}</a
><span class="expand-sep">/</span>{% endif %}<a
href="{{ entry.url }}"
target="_blank"
rel="noopener"
>{{ entry.url | replace("https://", "") }}</a
>{% if entry.pushed_at %}<span class="expand-sep">&middot;</span
>Last pushed {{ entry.pushed_at[:10] }}{% endif %}
</div>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="no-results" hidden>No libraries match your search.</div>
{% endblock %}

642
website/tests/test_build.py Normal file
View File

@ -0,0 +1,642 @@
"""Tests for the build module."""
import json
import os
import shutil
import sys
import textwrap
from pathlib import Path
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from build import (
build,
count_entries,
extract_github_repo,
extract_preview,
group_categories,
load_stars,
parse_readme,
render_content_html,
slugify,
sort_entries,
)
# ---------------------------------------------------------------------------
# slugify
# ---------------------------------------------------------------------------
class TestSlugify:
def test_simple(self):
assert slugify("Admin Panels") == "admin-panels"
def test_uppercase_acronym(self):
assert slugify("RESTful API") == "restful-api"
def test_all_caps(self):
assert slugify("CMS") == "cms"
def test_hyphenated_input(self):
assert slugify("Command-line Tools") == "command-line-tools"
def test_special_chars(self):
assert slugify("Editor Plugins and IDEs") == "editor-plugins-and-ides"
def test_single_word(self):
assert slugify("Audio") == "audio"
def test_extra_spaces(self):
assert slugify(" Date and Time ") == "date-and-time"
# ---------------------------------------------------------------------------
# count_entries
# ---------------------------------------------------------------------------
class TestCountEntries:
def test_counts_dash_entries(self):
assert count_entries("- [a](url) - Desc.\n- [b](url) - Desc.") == 2
def test_counts_star_entries(self):
assert count_entries("* [a](url) - Desc.") == 1
def test_ignores_non_entries(self):
assert count_entries("Some text\n- [a](url) - Desc.\nMore text") == 1
def test_counts_indented_entries(self):
assert count_entries(" - [a](url) - Desc.") == 1
def test_empty_content(self):
assert count_entries("") == 0
# ---------------------------------------------------------------------------
# extract_preview
# ---------------------------------------------------------------------------
class TestExtractPreview:
def test_basic(self):
content = "* [alpha](url) - A.\n* [beta](url) - B.\n* [gamma](url) - C."
assert extract_preview(content) == "alpha, beta, gamma"
def test_max_four(self):
content = "\n".join(f"* [lib{i}](url) - Desc." for i in range(10))
assert extract_preview(content) == "lib0, lib1, lib2, lib3"
def test_empty(self):
assert extract_preview("") == ""
def test_skips_subcategory_labels(self):
content = "* Synchronous\n* [django](url) - Framework.\n* [flask](url) - Micro."
assert extract_preview(content) == "django, flask"
# ---------------------------------------------------------------------------
# render_content_html
# ---------------------------------------------------------------------------
class TestRenderContentHtml:
def test_basic_entry(self):
content = "* [django](https://example.com) - A web framework."
html = render_content_html(content)
assert 'href="https://example.com"' in html
assert "django" in html
assert "A web framework." in html
assert 'class="entry"' in html
def test_subcategory_label(self):
content = "* Synchronous\n* [django](https://x.com) - Framework."
html = render_content_html(content)
assert 'class="subcat"' in html
assert "Synchronous" in html
def test_sub_entry(self):
content = "* [django](https://x.com) - Framework.\n * [awesome-django](https://y.com)"
html = render_content_html(content)
assert 'class="entry-sub"' in html
assert "awesome-django" in html
def test_link_only_entry(self):
content = "* [tool](https://x.com)"
html = render_content_html(content)
assert 'href="https://x.com"' in html
assert "tool" in html
# ---------------------------------------------------------------------------
# parse_readme
# ---------------------------------------------------------------------------
MINIMAL_README = textwrap.dedent("""\
# Awesome Python
Some intro text.
---
## Alpha
_Libraries for alpha stuff._
- [lib-a](https://example.com/a) - Does A.
- [lib-b](https://example.com/b) - Does B.
## Beta
_Tools for beta._
- [lib-c](https://example.com/c) - Does C.
# Resources
Where to discover resources.
## Newsletters
- [News One](https://example.com/n1)
- [News Two](https://example.com/n2)
## Podcasts
- [Pod One](https://example.com/p1)
# Contributing
Please contribute!
""")
class TestParseReadme:
def test_category_count(self):
cats, resources = parse_readme(MINIMAL_README)
assert len(cats) == 2
def test_resource_count(self):
cats, resources = parse_readme(MINIMAL_README)
assert len(resources) == 2
def test_category_names(self):
cats, _ = parse_readme(MINIMAL_README)
assert cats[0]["name"] == "Alpha"
assert cats[1]["name"] == "Beta"
def test_category_slugs(self):
cats, _ = parse_readme(MINIMAL_README)
assert cats[0]["slug"] == "alpha"
assert cats[1]["slug"] == "beta"
def test_category_description(self):
cats, _ = parse_readme(MINIMAL_README)
assert cats[0]["description"] == "Libraries for alpha stuff."
assert cats[1]["description"] == "Tools for beta."
def test_category_content_has_entries(self):
cats, _ = parse_readme(MINIMAL_README)
assert "lib-a" in cats[0]["content"]
assert "lib-b" in cats[0]["content"]
def test_resources_names(self):
_, resources = parse_readme(MINIMAL_README)
assert resources[0]["name"] == "Newsletters"
assert resources[1]["name"] == "Podcasts"
def test_resources_content(self):
_, resources = parse_readme(MINIMAL_README)
assert "News One" in resources[0]["content"]
assert "Pod One" in resources[1]["content"]
def test_contributing_skipped(self):
cats, resources = parse_readme(MINIMAL_README)
all_names = [c["name"] for c in cats] + [r["name"] for r in resources]
assert "Contributing" not in all_names
def test_no_separator(self):
cats, resources = parse_readme("# Just a heading\n\nSome text.\n")
assert cats == []
assert resources == []
def test_no_description(self):
readme = textwrap.dedent("""\
# Title
---
## NullDesc
- [item](https://x.com) - Thing.
# Resources
## Tips
- [tip](https://x.com)
# Contributing
Done.
""")
cats, resources = parse_readme(readme)
assert cats[0]["description"] == ""
assert "item" in cats[0]["content"]
# ---------------------------------------------------------------------------
# parse_readme on real README
# ---------------------------------------------------------------------------
class TestParseRealReadme:
@pytest.fixture(autouse=True)
def load_readme(self):
readme_path = os.path.join(os.path.dirname(__file__), "..", "..", "README.md")
with open(readme_path, encoding="utf-8") as f:
self.readme_text = f.read()
self.cats, self.resources = parse_readme(self.readme_text)
def test_at_least_83_categories(self):
assert len(self.cats) >= 83
def test_resources_has_newsletters_and_podcasts(self):
names = [r["name"] for r in self.resources]
assert "Newsletters" in names
assert "Podcasts" in names
def test_contributing_not_in_results(self):
all_names = [c["name"] for c in self.cats] + [
r["name"] for r in self.resources
]
assert "Contributing" not in all_names
def test_first_category_is_admin_panels(self):
assert self.cats[0]["name"] == "Admin Panels"
assert self.cats[0]["slug"] == "admin-panels"
def test_last_category_is_wsgi_servers(self):
assert self.cats[-1]["name"] == "WSGI Servers"
assert self.cats[-1]["slug"] == "wsgi-servers"
def test_restful_api_slug(self):
slugs = [c["slug"] for c in self.cats]
assert "restful-api" in slugs
def test_descriptions_extracted(self):
admin = self.cats[0]
assert admin["description"] == "Libraries for administrative interfaces."
# ---------------------------------------------------------------------------
# group_categories
# ---------------------------------------------------------------------------
class TestGroupCategories:
def test_groups_known_categories(self):
cats = [
{"name": "Web Frameworks", "slug": "web-frameworks"},
{"name": "Testing", "slug": "testing"},
]
groups = group_categories(cats, [])
group_names = [g["name"] for g in groups]
assert "Web & API" in group_names
assert "Development Tools" in group_names
def test_ungrouped_go_to_other(self):
cats = [{"name": "Unknown Category", "slug": "unknown-category"}]
groups = group_categories(cats, [])
group_names = [g["name"] for g in groups]
assert "Other" in group_names
def test_resources_grouped(self):
resources = [{"name": "Newsletters", "slug": "newsletters"}]
groups = group_categories([], resources)
group_names = [g["name"] for g in groups]
assert "Resources" in group_names
# ---------------------------------------------------------------------------
# render_markdown (kept for compatibility)
# ---------------------------------------------------------------------------
class TestRenderMarkdown:
def test_renders_link_list(self):
from build import render_markdown
html = render_markdown("- [lib](https://example.com) - Does stuff.")
assert "<li>" in html
assert '<a href="https://example.com">lib</a>' in html
def test_renders_plain_text(self):
from build import render_markdown
html = render_markdown("Hello world")
assert "<p>Hello world</p>" in html
# ---------------------------------------------------------------------------
# build (integration)
# ---------------------------------------------------------------------------
class TestBuild:
def _make_repo(self, tmp_path, readme):
(tmp_path / "README.md").write_text(readme, encoding="utf-8")
tpl_dir = tmp_path / "website" / "templates"
tpl_dir.mkdir(parents=True)
(tpl_dir / "base.html").write_text(
"<!DOCTYPE html><html lang='en'><head><title>{% block title %}{% endblock %}</title>"
"<meta name='description' content='{% block description %}{% endblock %}'>"
"</head><body>{% block content %}{% endblock %}</body></html>",
encoding="utf-8",
)
(tpl_dir / "index.html").write_text(
'{% extends "base.html" %}{% block content %}'
"{% for group in groups %}"
'<section class="group">'
"<h2>{{ group.name }}</h2>"
"{% for cat in group.categories %}"
'<div class="row" id="{{ cat.slug }}">'
"<span>{{ cat.name }}</span>"
"<span>{{ cat.preview }}</span>"
"<span>{{ cat.entry_count }}</span>"
'<div class="row-content" hidden>{{ cat.content_html | safe }}</div>'
"</div>"
"{% endfor %}"
"</section>"
"{% endfor %}"
"{% endblock %}",
encoding="utf-8",
)
def test_build_creates_single_page(self, tmp_path):
readme = textwrap.dedent("""\
# Awesome Python
Intro.
---
## Widgets
_Widget libraries._
- [w1](https://example.com) - A widget.
## Gadgets
_Gadget tools._
- [g1](https://example.com) - A gadget.
# Resources
Info.
## Newsletters
- [NL](https://example.com)
# Contributing
Help!
""")
self._make_repo(tmp_path, readme)
build(str(tmp_path))
site = tmp_path / "website" / "output"
assert (site / "index.html").exists()
# No category sub-pages
assert not (site / "categories").exists()
def test_build_creates_cname(self, tmp_path):
readme = textwrap.dedent("""\
# T
---
## Only
- [x](https://x.com) - X.
# Contributing
Done.
""")
self._make_repo(tmp_path, readme)
build(str(tmp_path))
cname = tmp_path / "website" / "output" / "CNAME"
assert cname.exists()
assert "awesome-python.com" in cname.read_text()
def test_build_cleans_stale_output(self, tmp_path):
readme = textwrap.dedent("""\
# T
---
## Only
- [x](https://x.com) - X.
# Contributing
Done.
""")
self._make_repo(tmp_path, readme)
stale = tmp_path / "website" / "output" / "categories" / "stale"
stale.mkdir(parents=True)
(stale / "index.html").write_text("old", encoding="utf-8")
build(str(tmp_path))
assert not (tmp_path / "website" / "output" / "categories" / "stale").exists()
def test_index_contains_category_names(self, tmp_path):
readme = textwrap.dedent("""\
# T
---
## Alpha
- [a](https://x.com) - A.
## Beta
- [b](https://x.com) - B.
# Contributing
Done.
""")
self._make_repo(tmp_path, readme)
build(str(tmp_path))
index_html = (tmp_path / "website" / "output" / "index.html").read_text()
assert "Alpha" in index_html
assert "Beta" in index_html
def test_index_contains_preview_text(self, tmp_path):
readme = textwrap.dedent("""\
# T
---
## Stuff
- [django](https://x.com) - A framework.
- [flask](https://x.com) - A micro.
# Contributing
Done.
""")
self._make_repo(tmp_path, readme)
build(str(tmp_path))
index_html = (tmp_path / "website" / "output" / "index.html").read_text()
assert "django" in index_html
assert "flask" in index_html
def test_build_with_stars_sorts_by_stars(self, tmp_path):
readme = textwrap.dedent("""\
# T
---
## Stuff
- [low-stars](https://github.com/org/low) - Low.
- [high-stars](https://github.com/org/high) - High.
- [no-stars](https://example.com/none) - None.
# Contributing
Done.
""")
(tmp_path / "README.md").write_text(readme, encoding="utf-8")
# Copy real templates
real_tpl = Path(__file__).parent / ".." / "templates"
tpl_dir = tmp_path / "website" / "templates"
shutil.copytree(real_tpl, tpl_dir)
# Create mock star data
data_dir = tmp_path / "website" / "data"
data_dir.mkdir(parents=True)
stars = {
"org/high": {"stars": 5000, "owner": "org", "fetched_at": "2026-01-01T00:00:00+00:00"},
"org/low": {"stars": 100, "owner": "org", "fetched_at": "2026-01-01T00:00:00+00:00"},
}
(data_dir / "github_stars.json").write_text(json.dumps(stars), encoding="utf-8")
build(str(tmp_path))
html = (tmp_path / "website" / "output" / "index.html").read_text(encoding="utf-8")
# Star-sorted: high-stars (5000) before low-stars (100) before no-stars (None)
assert html.index("high-stars") < html.index("low-stars")
assert html.index("low-stars") < html.index("no-stars")
# Formatted star counts
assert "5,000" in html
assert "100" in html
# Expand content present
assert "expand-content" in html
# ---------------------------------------------------------------------------
# extract_github_repo
# ---------------------------------------------------------------------------
class TestExtractGithubRepo:
def test_github_url(self):
assert extract_github_repo("https://github.com/psf/requests") == "psf/requests"
def test_non_github_url(self):
assert extract_github_repo("https://foss.heptapod.net/pypy/pypy") is None
def test_github_io_url(self):
assert extract_github_repo("https://user.github.io/proj") is None
def test_trailing_slash(self):
assert extract_github_repo("https://github.com/org/repo/") == "org/repo"
def test_deep_path(self):
assert extract_github_repo("https://github.com/org/repo/tree/main") is None
def test_dot_git_suffix(self):
assert extract_github_repo("https://github.com/org/repo.git") == "org/repo"
def test_org_only(self):
assert extract_github_repo("https://github.com/org") is None
# ---------------------------------------------------------------------------
# load_stars
# ---------------------------------------------------------------------------
class TestLoadStars:
def test_returns_empty_when_missing(self, tmp_path):
result = load_stars(tmp_path / "nonexistent.json")
assert result == {}
def test_loads_valid_json(self, tmp_path):
data = {"psf/requests": {"stars": 52467, "owner": "psf", "fetched_at": "2026-01-01T00:00:00+00:00"}}
f = tmp_path / "stars.json"
f.write_text(json.dumps(data), encoding="utf-8")
result = load_stars(f)
assert result["psf/requests"]["stars"] == 52467
def test_returns_empty_on_corrupt_json(self, tmp_path):
f = tmp_path / "stars.json"
f.write_text("not json", encoding="utf-8")
result = load_stars(f)
assert result == {}
# ---------------------------------------------------------------------------
# sort_entries
# ---------------------------------------------------------------------------
class TestSortEntries:
def test_sorts_by_stars_descending(self):
entries = [
{"name": "a", "stars": 100, "url": ""},
{"name": "b", "stars": 500, "url": ""},
{"name": "c", "stars": 200, "url": ""},
]
result = sort_entries(entries)
assert [e["name"] for e in result] == ["b", "c", "a"]
def test_equal_stars_sorted_alphabetically(self):
entries = [
{"name": "beta", "stars": 100, "url": ""},
{"name": "alpha", "stars": 100, "url": ""},
]
result = sort_entries(entries)
assert [e["name"] for e in result] == ["alpha", "beta"]
def test_no_stars_go_to_bottom(self):
entries = [
{"name": "no-stars", "stars": None, "url": ""},
{"name": "has-stars", "stars": 50, "url": ""},
]
result = sort_entries(entries)
assert [e["name"] for e in result] == ["has-stars", "no-stars"]
def test_no_stars_sorted_alphabetically(self):
entries = [
{"name": "zebra", "stars": None, "url": ""},
{"name": "apple", "stars": None, "url": ""},
]
result = sort_entries(entries)
assert [e["name"] for e in result] == ["apple", "zebra"]

View File

@ -0,0 +1,161 @@
"""Tests for fetch_github_stars module."""
import json
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from fetch_github_stars import (
build_graphql_query,
extract_github_repos,
load_cache,
parse_graphql_response,
save_cache,
)
class TestExtractGithubRepos:
def test_extracts_owner_repo_from_github_url(self):
readme = "* [requests](https://github.com/psf/requests) - HTTP lib."
result = extract_github_repos(readme)
assert result == {"psf/requests"}
def test_multiple_repos(self):
readme = (
"* [requests](https://github.com/psf/requests) - HTTP.\n"
"* [flask](https://github.com/pallets/flask) - Micro."
)
result = extract_github_repos(readme)
assert result == {"psf/requests", "pallets/flask"}
def test_ignores_non_github_urls(self):
readme = "* [pypy](https://foss.heptapod.net/pypy/pypy) - Fast Python."
result = extract_github_repos(readme)
assert result == set()
def test_ignores_github_io_urls(self):
readme = "* [docs](https://user.github.io/project) - Docs site."
result = extract_github_repos(readme)
assert result == set()
def test_ignores_github_wiki_and_blob_urls(self):
readme = (
"* [wiki](https://github.com/org/repo/wiki) - Wiki.\n"
"* [file](https://github.com/org/repo/blob/main/f.py) - File."
)
result = extract_github_repos(readme)
assert result == set()
def test_handles_trailing_slash(self):
readme = "* [lib](https://github.com/org/repo/) - Lib."
result = extract_github_repos(readme)
assert result == {"org/repo"}
def test_deduplicates(self):
readme = (
"* [a](https://github.com/org/repo) - A.\n"
"* [b](https://github.com/org/repo) - B."
)
result = extract_github_repos(readme)
assert result == {"org/repo"}
def test_strips_fragment(self):
readme = "* [lib](https://github.com/org/repo#section) - Lib."
result = extract_github_repos(readme)
assert result == {"org/repo"}
class TestLoadCache:
def test_returns_empty_when_missing(self, tmp_path, monkeypatch):
monkeypatch.setattr("fetch_github_stars.CACHE_FILE", tmp_path / "nonexistent.json")
result = load_cache()
assert result == {}
def test_loads_valid_cache(self, tmp_path, monkeypatch):
cache_file = tmp_path / "stars.json"
cache_file.write_text('{"a/b": {"stars": 1}}', encoding="utf-8")
monkeypatch.setattr("fetch_github_stars.CACHE_FILE", cache_file)
result = load_cache()
assert result == {"a/b": {"stars": 1}}
def test_returns_empty_on_corrupt_json(self, tmp_path, monkeypatch):
cache_file = tmp_path / "stars.json"
cache_file.write_text("not json", encoding="utf-8")
monkeypatch.setattr("fetch_github_stars.CACHE_FILE", cache_file)
result = load_cache()
assert result == {}
class TestSaveCache:
def test_creates_directory_and_writes_json(self, tmp_path, monkeypatch):
data_dir = tmp_path / "data"
cache_file = data_dir / "stars.json"
monkeypatch.setattr("fetch_github_stars.DATA_DIR", data_dir)
monkeypatch.setattr("fetch_github_stars.CACHE_FILE", cache_file)
save_cache({"a/b": {"stars": 1}})
assert cache_file.exists()
assert json.loads(cache_file.read_text(encoding="utf-8")) == {"a/b": {"stars": 1}}
class TestBuildGraphqlQuery:
def test_single_repo(self):
query = build_graphql_query(["psf/requests"])
assert "repository" in query
assert 'owner: "psf"' in query
assert 'name: "requests"' in query
assert "stargazerCount" in query
def test_multiple_repos_use_aliases(self):
query = build_graphql_query(["psf/requests", "pallets/flask"])
assert "repo_0:" in query
assert "repo_1:" in query
def test_empty_list(self):
query = build_graphql_query([])
assert query == ""
def test_skips_repos_with_quotes_in_name(self):
query = build_graphql_query(['org/"bad"'])
assert query == ""
def test_skips_only_bad_repos(self):
query = build_graphql_query(["good/repo", 'bad/"repo"'])
assert "good" in query
assert "bad" not in query
class TestParseGraphqlResponse:
def test_parses_star_count_and_owner(self):
data = {
"repo_0": {
"stargazerCount": 52467,
"owner": {"login": "psf"},
}
}
repos = ["psf/requests"]
result = parse_graphql_response(data, repos)
assert result["psf/requests"]["stars"] == 52467
assert result["psf/requests"]["owner"] == "psf"
def test_skips_null_repos(self):
data = {"repo_0": None}
repos = ["deleted/repo"]
result = parse_graphql_response(data, repos)
assert result == {}
def test_handles_missing_owner(self):
data = {"repo_0": {"stargazerCount": 100}}
repos = ["org/repo"]
result = parse_graphql_response(data, repos)
assert result["org/repo"]["owner"] == ""
def test_multiple_repos(self):
data = {
"repo_0": {"stargazerCount": 100, "owner": {"login": "a"}},
"repo_1": {"stargazerCount": 200, "owner": {"login": "b"}},
}
repos = ["a/x", "b/y"]
result = parse_graphql_response(data, repos)
assert len(result) == 2
assert result["a/x"]["stars"] == 100
assert result["b/y"]["stars"] == 200