mirror of
https://github.com/vinta/awesome-python.git
synced 2026-04-12 02:31:43 +08:00
Merge pull request #2969 from vinta/feature/relaunch-website
Relaunch website with custom build system
This commit is contained in:
commit
a732751923
48
.github/workflows/deploy-website.yml
vendored
Normal file
48
.github/workflows/deploy-website.yml
vendored
Normal 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
14
.gitignore
vendored
@ -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
|
||||
|
||||
21
Makefile
21
Makefile
@ -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)"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -1 +0,0 @@
|
||||
awesome-python.com
|
||||
@ -1,9 +0,0 @@
|
||||
@media (min-width: 960px) {
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
.md-content__inner > ul:nth-child(5) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
26
mkdocs.yml
26
mkdocs.yml
@ -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
23
pyproject.toml
Normal 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
|
||||
@ -1,2 +0,0 @@
|
||||
mkdocs==1.0.4
|
||||
mkdocs-material==4.0.2
|
||||
258
uv.lock
generated
Normal file
258
uv.lock
generated
Normal 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
502
website/build.py
Normal 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">—</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))
|
||||
2622
website/data/github_stars.json
Normal file
2622
website/data/github_stars.json
Normal file
File diff suppressed because it is too large
Load Diff
192
website/fetch_github_stars.py
Normal file
192
website/fetch_github_stars.py
Normal 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
154
website/static/main.js
Normal 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
459
website/static/style.css
Normal 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;
|
||||
}
|
||||
}
|
||||
67
website/templates/base.html
Normal file
67
website/templates/base.html
Normal 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>
|
||||
146
website/templates/index.html
Normal file
146
website/templates/index.html
Normal 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 →</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">
|
||||
× 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 %}—{% 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">→</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">·</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
642
website/tests/test_build.py
Normal 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"]
|
||||
161
website/tests/test_fetch_github_stars.py
Normal file
161
website/tests/test_fetch_github_stars.py
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user