From bd73b1faf099628acdd6b7b436df9ea205220935 Mon Sep 17 00:00:00 2001 From: Jinyang Date: Wed, 18 Mar 2026 07:57:37 +0400 Subject: [PATCH 01/96] remove zipline No longer maintained and out-dated. --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 340e28e76..9f34e2250 100644 --- a/README.md +++ b/README.md @@ -934,7 +934,6 @@ _Libraries for scientific computing. Also see [Python-for-Scientists](https://gi - [SimPy](https://gitlab.com/team-simpy/simpy) - A process-based discrete-event simulation framework. - [statsmodels](https://github.com/statsmodels/statsmodels) - Statistical modeling and econometrics in Python. - [SymPy](https://github.com/sympy/sympy) - A Python library for symbolic mathematics. -- [Zipline](https://github.com/quantopian/zipline) - A Pythonic algorithmic trading library. ## Search From 4af84dac8e2fc4a53071588909444ae8c9089f0f Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Wed, 18 Mar 2026 13:48:45 +0800 Subject: [PATCH 02/96] remove mkdocs site infrastructure Replace the MkDocs-based build (mkdocs.yml, requirements.txt, docs/CNAME, docs/css/extra.css) with a custom website builder as part of the site relaunch. Co-Authored-By: Claude --- docs/CNAME | 1 - docs/css/extra.css | 9 --------- mkdocs.yml | 26 -------------------------- requirements.txt | 2 -- 4 files changed, 38 deletions(-) delete mode 100644 docs/CNAME delete mode 100644 docs/css/extra.css delete mode 100644 mkdocs.yml delete mode 100644 requirements.txt diff --git a/docs/CNAME b/docs/CNAME deleted file mode 100644 index 0f6ced663..000000000 --- a/docs/CNAME +++ /dev/null @@ -1 +0,0 @@ -awesome-python.com \ No newline at end of file diff --git a/docs/css/extra.css b/docs/css/extra.css deleted file mode 100644 index 1230d796a..000000000 --- a/docs/css/extra.css +++ /dev/null @@ -1,9 +0,0 @@ -@media (min-width: 960px) { - html { - scroll-behavior: smooth; - } - - .md-content__inner > ul:nth-child(5) { - display: none; - } -} diff --git a/mkdocs.yml b/mkdocs.yml deleted file mode 100644 index 67a0373a4..000000000 --- a/mkdocs.yml +++ /dev/null @@ -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" diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 89d64c303..000000000 --- a/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -mkdocs==1.0.4 -mkdocs-material==4.0.2 From 177183d9bde4b4f0a51f0d97e9f95588204a619d Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Wed, 18 Mar 2026 13:48:49 +0800 Subject: [PATCH 03/96] add custom website build system Replaces MkDocs with a bespoke Python site generator using Jinja2 templates and Markdown. Adds uv for dependency management, GitHub Actions workflow for deployment, and Makefile targets for local development (fetch_stars, build, preview, deploy). Co-Authored-By: Claude --- .github/workflows/deploy-website.yml | 48 + .gitignore | 14 +- Makefile | 18 +- pyproject.toml | 23 + uv.lock | 258 +++ website/build.py | 502 +++++ website/data/github_stars.json | 2627 ++++++++++++++++++++++ website/fetch_github_stars.py | 192 ++ website/static/main.js | 154 ++ website/static/style.css | 459 ++++ website/templates/base.html | 67 + website/templates/index.html | 146 ++ website/tests/test_build.py | 642 ++++++ website/tests/test_fetch_github_stars.py | 161 ++ 14 files changed, 5298 insertions(+), 13 deletions(-) create mode 100644 .github/workflows/deploy-website.yml create mode 100644 pyproject.toml create mode 100644 uv.lock create mode 100644 website/build.py create mode 100644 website/data/github_stars.json create mode 100644 website/fetch_github_stars.py create mode 100644 website/static/main.js create mode 100644 website/static/style.css create mode 100644 website/templates/base.html create mode 100644 website/templates/index.html create mode 100644 website/tests/test_build.py create mode 100644 website/tests/test_fetch_github_stars.py diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml new file mode 100644 index 000000000..28e254eac --- /dev/null +++ b/.github/workflows/deploy-website.yml @@ -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@v4 + + - 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@v3 + 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 diff --git a/.gitignore b/.gitignore index 096c327d6..083917b2f 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/Makefile b/Makefile index eda7a8ff3..5d6d75818 100644 --- a/Makefile +++ b/Makefile @@ -1,14 +1,14 @@ site_install: - pip install -r requirements.txt + uv sync --no-dev -site_link: - ln -sf $(CURDIR)/README.md $(CURDIR)/docs/index.md +fetch_stars: + 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)" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000..d564cde9f --- /dev/null +++ b/pyproject.toml @@ -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 diff --git a/uv.lock b/uv.lock new file mode 100644 index 000000000..1f7b17c7c --- /dev/null +++ b/uv.lock @@ -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" }, +] diff --git a/website/build.py b/website/build.py new file mode 100644 index 000000000..b8340eb5d --- /dev/null +++ b/website/build.py @@ -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'
{label}
') + 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'
' + f'{name}' + f"
" + ) + else: + out.append( + f'
' + f'{name}' + f'{desc}' + f"
" + ) + 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'
' + f'{name}' + f"
" + ) + else: + out.append( + f'
' + f'{name}' + f"
" + ) + 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

...

since this is inline content + html = re.sub(r"^

(.*)

$", r"\1", html.strip()) + # Add target/rel to links for external navigation + html = html.replace(" 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)) diff --git a/website/data/github_stars.json b/website/data/github_stars.json new file mode 100644 index 000000000..1476651fc --- /dev/null +++ b/website/data/github_stars.json @@ -0,0 +1,2627 @@ +{ + "0rpc/zerorpc-python": { + "stars": 3237, + "owner": "0rpc", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "567-labs/instructor": { + "stars": 12554, + "owner": "567-labs", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "Alir3z4/html2text": { + "stars": 2135, + "owner": "Alir3z4", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "AnswerDotAI/fasthtml": { + "stars": 6883, + "owner": "AnswerDotAI", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "AtsushiSakai/PythonRobotics": { + "stars": 28887, + "owner": "AtsushiSakai", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "BeanieODM/beanie": { + "stars": 2661, + "owner": "BeanieODM", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "Bogdanp/dramatiq": { + "stars": 5172, + "owner": "Bogdanp", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "ChristosChristofidis/awesome-deep-learning": { + "stars": 27712, + "owner": "ChristosChristofidis", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "CleanCut/green": { + "stars": 806, + "owner": "CleanCut", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "Cornices/cornice": { + "stars": 390, + "owner": "Cornices", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "DLR-RM/stable-baselines3": { + "stars": 12908, + "owner": "DLR-RM", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "Delgan/loguru": { + "stars": 23690, + "owner": "Delgan", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "DiffSK/configobj": { + "stars": 337, + "owner": "DiffSK", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "DmytroLitvinov/awesome-flake8-extensions": { + "stars": 1276, + "owner": "DmytroLitvinov", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "EmilStenstrom/justhtml": { + "stars": 1116, + "owner": "EmilStenstrom", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "FactoryBoy/factory_boy": { + "stars": 3781, + "owner": "FactoryBoy", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "HBNetwork/python-decouple": { + "stars": 3017, + "owner": "HBNetwork", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "HypothesisWorks/hypothesis": { + "stars": 8498, + "owner": "HypothesisWorks", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "Instagram/MonkeyType": { + "stars": 4996, + "owner": "Instagram", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "IronLanguages/ironpython3": { + "stars": 2735, + "owner": "IronLanguages", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "JaidedAI/EasyOCR": { + "stars": 29096, + "owner": "JaidedAI", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "Kozea/pygal": { + "stars": 2752, + "owner": "Kozea", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "Lightning-AI/pytorch-lightning": { + "stars": 30934, + "owner": "Lightning-AI", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "LuminosoInsight/python-ftfy": { + "stars": 4015, + "owner": "rspeer", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "MagicStack/uvloop": { + "stars": 11687, + "owner": "MagicStack", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "ManimCommunity/manim": { + "stars": 37253, + "owner": "ManimCommunity", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "Manisso/fsociety": { + "stars": 11925, + "owner": "Manisso", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "Maratyszcza/PeachPy": { + "stars": 2048, + "owner": "Maratyszcza", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "MasoniteFramework/masonite": { + "stars": 2365, + "owner": "MasoniteFramework", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "MechanicalSoup/MechanicalSoup": { + "stars": 4850, + "owner": "MechanicalSoup", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "MervinPraison/PraisonAI": { + "stars": 5677, + "owner": "MervinPraison", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "Microsoft/PTVS": { + "stars": 2567, + "owner": "microsoft", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "MongoEngine/mongoengine": { + "stars": 4349, + "owner": "MongoEngine", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "NicolasHug/Surprise": { + "stars": 6772, + "owner": "NicolasHug", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "Nuitka/Nuitka": { + "stars": 14642, + "owner": "Nuitka", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "OpenBB-finance/OpenBB": { + "stars": 63238, + "owner": "OpenBB-finance", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "Parisson/TimeSide": { + "stars": 394, + "owner": "Parisson", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "Parsely/streamparse": { + "stars": 1504, + "owner": "pystorm", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "PennyLaneAI/pennylane": { + "stars": 3111, + "owner": "PennyLaneAI", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "PrefectHQ/prefect": { + "stars": 21889, + "owner": "PrefectHQ", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "PyCQA/flake8": { + "stars": 3770, + "owner": "PyCQA", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "PyCQA/prospector": { + "stars": 2074, + "owner": "prospector-dev", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "PyMySQL/PyMySQL": { + "stars": 7838, + "owner": "PyMySQL", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "PyMySQL/mysqlclient": { + "stars": 2525, + "owner": "PyMySQL", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "Pylons/colander": { + "stars": 464, + "owner": "Pylons", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "Pylons/waitress": { + "stars": 1572, + "owner": "Pylons", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "Qiskit/qiskit": { + "stars": 7137, + "owner": "Qiskit", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "RaRe-Technologies/gensim": { + "stars": 16375, + "owner": "piskvorky", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "RasaHQ/rasa": { + "stars": 21086, + "owner": "RasaHQ", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "RaylockLLC/DearPyGui": { + "stars": 15279, + "owner": "hoffstadt", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "SCons/scons": { + "stars": 2357, + "owner": "SCons", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "SciTools/cartopy": { + "stars": 1589, + "owner": "SciTools", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "ScrapeGraphAI/toonify": { + "stars": 323, + "owner": "ScrapeGraphAI", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "SmileyChris/django-countries": { + "stars": 1521, + "owner": "SmileyChris", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "Suor/django-cacheops": { + "stars": 2263, + "owner": "Suor", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "Suor/funcy": { + "stars": 3501, + "owner": "Suor", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "Supervisor/supervisor": { + "stars": 9007, + "owner": "Supervisor", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "Tencent/rapidjson": { + "stars": 15007, + "owner": "Tencent", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "Textualize/rich": { + "stars": 55801, + "owner": "Textualize", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "Textualize/textual": { + "stars": 34878, + "owner": "Textualize", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "TheAlgorithms/Python": { + "stars": 218785, + "owner": "TheAlgorithms", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "TkTech/pysimdjson": { + "stars": 761, + "owner": "TkTech", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "TomNicholas/Python-for-Scientists": { + "stars": 357, + "owner": "TomNicholas", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "Valloric/YouCompleteMe": { + "stars": 26276, + "owner": "ycm-core", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "WhyNotHugo/python-barcode": { + "stars": 649, + "owner": "WhyNotHugo", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "ZoomerAnalytics/xlwings": { + "stars": 6, + "owner": "ZoomerAnalytics", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "aaugustin/websockets": { + "stars": 5643, + "owner": "python-websockets", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "abhiTronix/vidgear": { + "stars": 3684, + "owner": "abhiTronix", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "aboSamoor/polyglot": { + "stars": 2368, + "owner": "aboSamoor", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "agno-agi/agno": { + "stars": 38754, + "owner": "agno-agi", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "ahupp/python-magic": { + "stars": 2896, + "owner": "ahupp", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "aizvorski/scikit-video": { + "stars": 152, + "owner": "aizvorski", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "ajenti/ajenti": { + "stars": 7908, + "owner": "ajenti", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "alecthomas/voluptuous": { + "stars": 1847, + "owner": "alecthomas", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "altair-viz/altair": { + "stars": 10301, + "owner": "vega", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "amitt001/delegator.py": { + "stars": 1746, + "owner": "amitt001", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "amoffat/sh": { + "stars": 7241, + "owner": "amoffat", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "amosgyamfi/awesome-fasthtml": { + "stars": 79, + "owner": "amosgyamfi", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "andialbrecht/sqlparse": { + "stars": 3999, + "owner": "andialbrecht", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "ansible/ansible": { + "stars": 68310, + "owner": "ansible", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "apache/spark": { + "stars": 42992, + "owner": "apache", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "arrow-py/arrow": { + "stars": 9035, + "owner": "arrow-py", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "art049/odmantic": { + "stars": 1168, + "owner": "art049", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "astral-sh/ruff": { + "stars": 46329, + "owner": "astral-sh", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "astral-sh/ty": { + "stars": 17739, + "owner": "astral-sh", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "astral-sh/uv": { + "stars": 81192, + "owner": "astral-sh", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "asweigart/pyautogui": { + "stars": 12363, + "owner": "asweigart", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "aws/aws-sdk-pandas": { + "stars": 4106, + "owner": "aws", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "bbangert/beaker": { + "stars": 545, + "owner": "bbangert", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "beetbox/audioread": { + "stars": 536, + "owner": "beetbox", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "beetbox/beets": { + "stars": 14856, + "owner": "beetbox", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "benedekrozemberczki/karateclub": { + "stars": 2276, + "owner": "benedekrozemberczki", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "benfred/implicit": { + "stars": 3773, + "owner": "benfred", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "benfred/py-spy": { + "stars": 15031, + "owner": "benfred", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "benhamner/Metrics": { + "stars": 1654, + "owner": "benhamner", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "benoitc/gunicorn": { + "stars": 10481, + "owner": "benoitc", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "bfly123/claude_code_bridge": { + "stars": 1642, + "owner": "bfly123", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "bloomberg/bqplot": { + "stars": 3684, + "owner": "bqplot", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "bokeh/bokeh": { + "stars": 20365, + "owner": "bokeh", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "boppreh/mouse": { + "stars": 961, + "owner": "boppreh", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "borgbackup/borg": { + "stars": 13081, + "owner": "borgbackup", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "boto/boto3": { + "stars": 9736, + "owner": "boto", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "bpython/bpython": { + "stars": 2771, + "owner": "bpython", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "browser-use/browser-use": { + "stars": 81099, + "owner": "browser-use", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "bterwijn/memory_graph": { + "stars": 771, + "owner": "bterwijn", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "buildout/buildout": { + "stars": 613, + "owner": "buildout", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "buriy/python-readability": { + "stars": 2894, + "owner": "buriy", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "canonical/cloud-init": { + "stars": 3627, + "owner": "canonical", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "carlosescri/DottedDict": { + "stars": 222, + "owner": "carlosescri", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "cdgriffith/Box": { + "stars": 2822, + "owner": "cdgriffith", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "chaostoolkit/chaostoolkit": { + "stars": 2001, + "owner": "chaostoolkit", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "chapmanb/bcbb": { + "stars": 645, + "owner": "chapmanb", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "chapmanb/bcbio-nextgen": { + "stars": 1027, + "owner": "bcbio", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "chardet/chardet": { + "stars": 2486, + "owner": "chardet", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "chriskiehl/Gooey": { + "stars": 22025, + "owner": "chriskiehl", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "clips/pattern": { + "stars": 8856, + "owner": "clips", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "cobrateam/splinter": { + "stars": 2767, + "owner": "cobrateam", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "codeinthehole/purl": { + "stars": 303, + "owner": "codeinthehole", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "codelucas/newspaper": { + "stars": 15009, + "owner": "codelucas", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "coleifer/huey": { + "stars": 5940, + "owner": "coleifer", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "coleifer/micawber": { + "stars": 674, + "owner": "coleifer", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "coleifer/peewee": { + "stars": 11952, + "owner": "coleifer", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "conda/conda": { + "stars": 7342, + "owner": "conda", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "cookiecutter/cookiecutter": { + "stars": 24744, + "owner": "cookiecutter", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "copier-org/copier": { + "stars": 3214, + "owner": "copier-org", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "crossbario/autobahn-python": { + "stars": 2534, + "owner": "crossbario", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "cython/cython": { + "stars": 10654, + "owner": "cython", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "dahlia/awesome-sqlalchemy": { + "stars": 3031, + "owner": "dahlia", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "dashingsoft/pyarmor": { + "stars": 4989, + "owner": "dashingsoft", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "dask/dask": { + "stars": 13768, + "owner": "dask", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "datafolklabs/cement": { + "stars": 1341, + "owner": "datafolklabs", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "datastax/python-driver": { + "stars": 1426, + "owner": "apache", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "dateutil/dateutil": { + "stars": 2604, + "owner": "dateutil", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "davidaurelio/hashids-python": { + "stars": 1423, + "owner": "davidaurelio", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "daviddrysdale/python-phonenumbers": { + "stars": 3720, + "owner": "daviddrysdale", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "davidhalter/jedi": { + "stars": 6123, + "owner": "davidhalter", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "davidhalter/jedi-vim": { + "stars": 5319, + "owner": "davidhalter", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "dbader/schedule": { + "stars": 12246, + "owner": "dbader", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "dbcli/litecli": { + "stars": 3214, + "owner": "dbcli", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "dbcli/mycli": { + "stars": 11886, + "owner": "dbcli", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "dbcli/pgcli": { + "stars": 13073, + "owner": "dbcli", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "deanmalmgren/textract": { + "stars": 4482, + "owner": "deanmalmgren", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "derek73/python-nameparser": { + "stars": 702, + "owner": "derek73", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "desbordante/desbordante-core": { + "stars": 469, + "owner": "Desbordante", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "devpi/devpi": { + "stars": 1146, + "owner": "devpi", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "devsnd/tinytag": { + "stars": 805, + "owner": "tinytag", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "dfunckt/django-rules": { + "stars": 1970, + "owner": "dfunckt", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "dgunning/edgartools": { + "stars": 1859, + "owner": "dgunning", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "dhamaniasad/awesome-postgres": { + "stars": 11767, + "owner": "dhamaniasad", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "dimka665/awesome-slugify": { + "stars": 491, + "owner": "voronind", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "django-cache-machine/django-cache-machine": { + "stars": 885, + "owner": "django-cache-machine", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "django-compressor/django-compressor": { + "stars": 2871, + "owner": "django-compressor", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "django-guardian/django-guardian": { + "stars": 3893, + "owner": "django-guardian", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "django-haystack/django-haystack": { + "stars": 3800, + "owner": "django-haystack", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "django-haystack/pysolr": { + "stars": 697, + "owner": "django-haystack", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "django-tastypie/django-tastypie": { + "stars": 3955, + "owner": "django-tastypie", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "django/channels": { + "stars": 6336, + "owner": "django", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "django/daphne": { + "stars": 2651, + "owner": "django", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "django/django": { + "stars": 87081, + "owner": "django", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "dmlc/xgboost": { + "stars": 28138, + "owner": "dmlc", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "docling-project/docling": { + "stars": 55968, + "owner": "docling-project", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "dpkp/kafka-python": { + "stars": 5887, + "owner": "dpkp", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "dry-python/returns": { + "stars": 4238, + "owner": "dry-python", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "dynaconf/dynaconf": { + "stars": 4272, + "owner": "dynaconf", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "elapouya/python-docx-template": { + "stars": 2588, + "owner": "elapouya", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "elastic/elasticsearch-dsl-py": { + "stars": 3883, + "owner": "elastic", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "eliben/pyelftools": { + "stars": 2217, + "owner": "eliben", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "emcconville/wand": { + "stars": 1479, + "owner": "emcconville", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "emmett-framework/granian": { + "stars": 5173, + "owner": "emmett-framework", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "encode/django-rest-framework": { + "stars": 29928, + "owner": "encode", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "encode/httpx": { + "stars": 15163, + "owner": "encode", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "encode/uvicorn": { + "stars": 10496, + "owner": "Kludex", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "erikrose/more-itertools": { + "stars": 4043, + "owner": "more-itertools", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "esnme/ultrajson": { + "stars": 4474, + "owner": "ultrajson", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "evhub/coconut": { + "stars": 4313, + "owner": "evhub", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "fabric/fabric": { + "stars": 15406, + "owner": "fabric", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "facebook/PathPicker": { + "stars": 5232, + "owner": "facebook", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "facebook/pyre-check": { + "stars": 7153, + "owner": "facebook", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "facebookresearch/hydra": { + "stars": 10258, + "owner": "facebookresearch", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "faif/python-patterns": { + "stars": 42795, + "owner": "faif", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "falconry/falcon": { + "stars": 9805, + "owner": "falconry", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "feature-engine/feature_engine": { + "stars": 2214, + "owner": "feature-engine", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "feincms/feincms": { + "stars": 1077, + "owner": "feincms", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "fengsp/plan": { + "stars": 1182, + "owner": "fengsp", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "fighting41love/funNLP": { + "stars": 79457, + "owner": "fighting41love", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "flask-admin/flask-admin": { + "stars": 6057, + "owner": "pallets-eco", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "flask-api/flask-api": { + "stars": 1468, + "owner": "flask-api", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "flask-restful/flask-restful": { + "stars": 6924, + "owner": "flask-restful", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "fogleman/Quads": { + "stars": 1223, + "owner": "fogleman", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "fxsjy/jieba": { + "stars": 34802, + "owner": "fxsjy", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "gabrielfalcao/HTTPretty": { + "stars": 2209, + "owner": "gabrielfalcao", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "gaojiuli/toapi": { + "stars": 3555, + "owner": "elliotgao2", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "gawel/pyquery": { + "stars": 2379, + "owner": "gawel", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "geopandas/geopandas": { + "stars": 5067, + "owner": "geopandas", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "geopy/geopy": { + "stars": 4783, + "owner": "geopy", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "getnikola/nikola": { + "stars": 2722, + "owner": "getnikola", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "getpelican/pelican": { + "stars": 13249, + "owner": "getpelican", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "getsentry/responses": { + "stars": 4330, + "owner": "getsentry", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "getsentry/sentry-python": { + "stars": 2156, + "owner": "getsentry", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "gevent/gevent": { + "stars": 6443, + "owner": "gevent", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "giampaolo/psutil": { + "stars": 11108, + "owner": "giampaolo", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "glamp/bashplotlib": { + "stars": 1917, + "owner": "glamp", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "gleitz/howdoi": { + "stars": 10831, + "owner": "gleitz", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "google/jax": { + "stars": 35125, + "owner": "jax-ml", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "google/python-fire": { + "stars": 28155, + "owner": "google", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "google/pytype": { + "stars": 5029, + "owner": "google", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "google/yapf": { + "stars": 13991, + "owner": "google", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "gorakhargosh/watchdog": { + "stars": 7283, + "owner": "gorakhargosh", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "gotcha/ipdb": { + "stars": 1968, + "owner": "gotcha", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "grantjenks/python-diskcache": { + "stars": 2846, + "owner": "grantjenks", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "grantjenks/python-sortedcontainers": { + "stars": 3935, + "owner": "grantjenks", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "graphql-python/graphene": { + "stars": 8252, + "owner": "graphql-python", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "gruns/furl": { + "stars": 2797, + "owner": "gruns", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "gruns/icecream": { + "stars": 10032, + "owner": "gruns", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "h2oai/h2o-3": { + "stars": 7511, + "owner": "h2oai", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "has2k1/plotnine": { + "stars": 4521, + "owner": "has2k1", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "hbldh/bleak": { + "stars": 2351, + "owner": "hbldh", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "hi-primus/optimus": { + "stars": 1539, + "owner": "hi-primus", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "html5lib/html5lib-python": { + "stars": 1218, + "owner": "html5lib", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "httpie/cli": { + "stars": 37725, + "owner": "httpie", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "hugapi/hug": { + "stars": 6905, + "owner": "hugapi", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "huggingface/diffusers": { + "stars": 33076, + "owner": "huggingface", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "huggingface/transformers": { + "stars": 157984, + "owner": "huggingface", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "humiaozuzu/awesome-flask": { + "stars": 12696, + "owner": "humiaozuzu", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "ibayer/fastFM": { + "stars": 1090, + "owner": "ibayer", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "ijl/orjson": { + "stars": 7961, + "owner": "ijl", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "indico/indico": { + "stars": 2031, + "owner": "indico", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "inducer/pudb": { + "stars": 3218, + "owner": "inducer", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "infiniflow/ragflow": { + "stars": 75265, + "owner": "infiniflow", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "ionelmc/python-hunter": { + "stars": 866, + "owner": "ionelmc", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "ionelmc/python-manhole": { + "stars": 400, + "owner": "ionelmc", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "isnowfy/snownlp": { + "stars": 6614, + "owner": "isnowfy", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "jab/bidict": { + "stars": 1578, + "owner": "jab", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "jaraco/path.py": { + "stars": 1124, + "owner": "jaraco", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "jazzband/django-debug-toolbar": { + "stars": 8351, + "owner": "django-commons", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "jazzband/django-oauth-toolkit": { + "stars": 3310, + "owner": "django-oauth", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "jazzband/django-pipeline": { + "stars": 1543, + "owner": "jazzband", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "jazzband/geojson": { + "stars": 984, + "owner": "jazzband", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "jazzband/pip-tools": { + "stars": 7993, + "owner": "jazzband", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "jazzband/tablib": { + "stars": 4751, + "owner": "jazzband", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "jeffknupp/sandman2": { + "stars": 2044, + "owner": "jeffknupp", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "jek/blinker": { + "stars": 2034, + "owner": "pallets-eco", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "jendrikseipp/vulture": { + "stars": 4379, + "owner": "jendrikseipp", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "jet-admin/jet-bridge": { + "stars": 1794, + "owner": "jet-admin", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "jfkirk/tensorrec": { + "stars": 1302, + "owner": "jfkirk", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "jiaaro/pydub": { + "stars": 9743, + "owner": "jiaaro", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "jindaxiang/akshare": { + "stars": 17394, + "owner": "akfamily", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "jmcnamara/XlsxWriter": { + "stars": 3920, + "owner": "jmcnamara", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "joke2k/faker": { + "stars": 19220, + "owner": "joke2k", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "jonathanslenders/ptpython": { + "stars": 5410, + "owner": "prompt-toolkit", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "jonathanslenders/python-prompt-toolkit": { + "stars": 10329, + "owner": "prompt-toolkit", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "jorgenschaefer/elpy": { + "stars": 1940, + "owner": "jorgenschaefer", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "jpadilla/pyjwt": { + "stars": 5622, + "owner": "jpadilla", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "jschneier/django-storages": { + "stars": 2939, + "owner": "jschneier", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "keleshev/schema": { + "stars": 2944, + "owner": "keleshev", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "keon/algorithms": { + "stars": 25390, + "owner": "keon", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "keras-team/keras": { + "stars": 63928, + "owner": "keras-team", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "keunwoochoi/kapre": { + "stars": 946, + "owner": "keunwoochoi", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "kevin1024/vcrpy": { + "stars": 2951, + "owner": "kevin1024", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "kiwicom/schemathesis": { + "stars": 3114, + "owner": "schemathesis", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "klen/mixer": { + "stars": 954, + "owner": "klen", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "knipknap/SpiffWorkflow": { + "stars": 1864, + "owner": "sartography", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "kootenpv/yagmail": { + "stars": 2725, + "owner": "kootenpv", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "kornia/kornia": { + "stars": 11119, + "owner": "kornia", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "kreuzberg-dev/kreuzberg": { + "stars": 6736, + "owner": "kreuzberg-dev", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "kurtmckee/feedparser": { + "stars": 2327, + "owner": "kurtmckee", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "laixintao/iredis": { + "stars": 2728, + "owner": "laixintao", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "lancopku/pkuseg-python": { + "stars": 6702, + "owner": "lancopku", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "langchain-ai/langchain": { + "stars": 129943, + "owner": "langchain-ai", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "lektor/lektor": { + "stars": 3926, + "owner": "lektor", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "lemire/simdjson": { + "stars": 23448, + "owner": "simdjson", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "lepture/authlib": { + "stars": 5244, + "owner": "authlib", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "lepture/mistune": { + "stars": 2998, + "owner": "lepture", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "lericson/pylibmc": { + "stars": 493, + "owner": "lericson", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "libAudioFlux/audioFlux": { + "stars": 3281, + "owner": "libAudioFlux", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "librosa/librosa": { + "stars": 8263, + "owner": "librosa", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "libvips/pyvips": { + "stars": 789, + "owner": "libvips", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "lincolnloop/python-qrcode": { + "stars": 4864, + "owner": "lincolnloop", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "linkedin/shiv": { + "stars": 1918, + "owner": "linkedin", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "litestar-org/litestar": { + "stars": 8099, + "owner": "litestar-org", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "litestar-org/polyfactory": { + "stars": 1428, + "owner": "litestar-org", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "lk-geimfari/mimesis": { + "stars": 4799, + "owner": "lk-geimfari", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "locustio/locust": { + "stars": 27608, + "owner": "locustio", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "lorien/grab": { + "stars": 2458, + "owner": "lorien", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "lyst/lightfm": { + "stars": 5066, + "owner": "lyst", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "maciejkula/spotlight": { + "stars": 3042, + "owner": "maciejkula", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "madmaze/pytesseract": { + "stars": 6321, + "owner": "madmaze", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "mahmoud/boltons": { + "stars": 6856, + "owner": "mahmoud", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "mailgun/flanker": { + "stars": 1650, + "owner": "mailgun", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "marcelotduarte/cx_Freeze": { + "stars": 1532, + "owner": "marcelotduarte", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "marimo-team/marimo": { + "stars": 19725, + "owner": "marimo-team", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "markusschanta/awesome-jupyter": { + "stars": 4569, + "owner": "markusschanta", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "marph91/jimmy": { + "stars": 400, + "owner": "marph91", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "marrow/mailer": { + "stars": 293, + "owner": "marrow", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "marshmallow-code/marshmallow": { + "stars": 7228, + "owner": "marshmallow-code", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "marshmallow-code/webargs": { + "stars": 1405, + "owner": "marshmallow-code", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "martinblech/xmltodict": { + "stars": 5726, + "owner": "martinblech", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "martinrusev/imbox": { + "stars": 1211, + "owner": "martinrusev", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "matplotlib/matplotlib": { + "stars": 22585, + "owner": "matplotlib", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "metawilm/cl-python": { + "stars": 394, + "owner": "metawilm", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "mhammond/pywin32": { + "stars": 5531, + "owner": "mhammond", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "mher/flower": { + "stars": 7130, + "owner": "mher", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "michaelhelmick/lassie": { + "stars": 630, + "owner": "michaelhelmick", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "micropython/micropython": { + "stars": 21553, + "owner": "micropython", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "microsoft/markitdown": { + "stars": 90875, + "owner": "microsoft", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "miguelgrinberg/microdot": { + "stars": 2093, + "owner": "miguelgrinberg", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "mindflayer/python-mocket": { + "stars": 309, + "owner": "mindflayer", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "mindsdb/mindsdb": { + "stars": 38775, + "owner": "mindsdb", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "mingrammer/diagrams": { + "stars": 42078, + "owner": "mingrammer", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "mininet/mininet": { + "stars": 5788, + "owner": "mininet", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "miracle2k/flask-assets": { + "stars": 459, + "owner": "miracle2k", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "miracle2k/webassets": { + "stars": 935, + "owner": "miracle2k", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "miso-belica/sumy": { + "stars": 3664, + "owner": "miso-belica", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "mitmproxy/pdoc": { + "stars": 2474, + "owner": "mitmproxy", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "mitsuhiko/pluginbase": { + "stars": 1141, + "owner": "mitsuhiko", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "mitsuhiko/unp": { + "stars": 455, + "owner": "mitsuhiko", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "mkdocs/mkdocs": { + "stars": 21860, + "owner": "mkdocs", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "modoboa/modoboa": { + "stars": 3468, + "owner": "modoboa", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "mongodb/django-mongodb-backend": { + "stars": 218, + "owner": "mongodb", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "mongodb/mongo-python-driver": { + "stars": 4338, + "owner": "mongodb", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "moses-palmer/pynput": { + "stars": 2125, + "owner": "moses-palmer", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "mozilla/unicode-slugify": { + "stars": 328, + "owner": "mozilla", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "mozillazg/python-pinyin": { + "stars": 5271, + "owner": "mozillazg", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "mpdavis/python-jose": { + "stars": 1743, + "owner": "mpdavis", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "mpi4py/mpi4py": { + "stars": 902, + "owner": "mpi4py", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "mre/awesome-static-analysis": { + "stars": 14439, + "owner": "analysis-tools-dev", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "msiemens/tinydb": { + "stars": 7487, + "owner": "msiemens", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "mstamy2/PyPDF2": { + "stars": 9878, + "owner": "py-pdf", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "mwaskom/seaborn": { + "stars": 13770, + "owner": "mwaskom", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "mymarilyn/clickhouse-driver": { + "stars": 1293, + "owner": "mymarilyn", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "napalm-automation/napalm": { + "stars": 2438, + "owner": "napalm-automation", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "nficano/python-lambda": { + "stars": 1521, + "owner": "nficano", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "nicfit/eyeD3": { + "stars": 631, + "owner": "nicfit", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "nose-devs/nose2": { + "stars": 822, + "owner": "nose-devs", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "noxrepo/pox": { + "stars": 652, + "owner": "noxrepo", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "nucleic/enaml": { + "stars": 1574, + "owner": "nucleic", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "numba/numba": { + "stars": 10935, + "owner": "numba", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "nvbn/thefuck": { + "stars": 95714, + "owner": "nvbn", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "nvdv/vprof": { + "stars": 3982, + "owner": "nvdv", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "oauthlib/oauthlib": { + "stars": 2958, + "owner": "oauthlib", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "offerrall/FuncToWeb": { + "stars": 389, + "owner": "offerrall", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "openai/gym": { + "stars": 37100, + "owner": "openai", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "openembedded/bitbake": { + "stars": 509, + "owner": "openembedded", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "openstack/cliff": { + "stars": 260, + "owner": "openstack", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "orsinium/textdistance": { + "stars": 3524, + "owner": "life4", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "pallets-eco/flask-debugtoolbar": { + "stars": 980, + "owner": "pallets-eco", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "pallets/click": { + "stars": 17367, + "owner": "pallets", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "pallets/flask": { + "stars": 71376, + "owner": "pallets", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "pallets/itsdangerous": { + "stars": 3102, + "owner": "pallets", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "pallets/jinja": { + "stars": 11513, + "owner": "pallets", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "pallets/markupsafe": { + "stars": 685, + "owner": "pallets", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "pallets/werkzeug": { + "stars": 6849, + "owner": "pallets", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "paramiko/paramiko": { + "stars": 9712, + "owner": "paramiko", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "pathsim/pathsim": { + "stars": 334, + "owner": "pathsim", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "pathwaycom/pathway": { + "stars": 60281, + "owner": "pathwaycom", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "patrys/httmock": { + "stars": 472, + "owner": "patrys", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "patx/pickledb": { + "stars": 1069, + "owner": "patx", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "pdfminer/pdfminer.six": { + "stars": 6933, + "owner": "pdfminer", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "pennersr/django-allauth": { + "stars": 10307, + "owner": "pennersr", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "peterbrittain/asciimatics": { + "stars": 4271, + "owner": "peterbrittain", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "pgjones/hypercorn": { + "stars": 1536, + "owner": "pgjones", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "pgmpy/pgmpy": { + "stars": 3213, + "owner": "pgmpy", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "pikepdf/pikepdf": { + "stars": 2667, + "owner": "pikepdf", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "planetopendata/awesome-sqlite": { + "stars": 388, + "owner": "planetopendata", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "platformio/platformio-core": { + "stars": 8931, + "owner": "platformio", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "plotly/plotly.py": { + "stars": 18354, + "owner": "plotly", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "pndurette/gTTS": { + "stars": 2595, + "owner": "pndurette", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "pola-rs/polars": { + "stars": 37775, + "owner": "pola-rs", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "ponyorm/pony": { + "stars": 3826, + "owner": "ponyorm", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "prabhupant/python-ds": { + "stars": 3074, + "owner": "prabhupant", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "pricingassistant/mrq": { + "stars": 896, + "owner": "pricingassistant", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "prompt-toolkit/python-prompt-toolkit": { + "stars": 10329, + "owner": "prompt-toolkit", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "psf/black": { + "stars": 41430, + "owner": "psf", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "psf/requests": { + "stars": 53881, + "owner": "psf", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "psf/requests-html": { + "stars": 13869, + "owner": "psf", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "psycopg/psycopg": { + "stars": 2322, + "owner": "psycopg", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "pudo/dataset": { + "stars": 4853, + "owner": "pudo", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "pwaller/pyfiglet": { + "stars": 1545, + "owner": "pwaller", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "py2exe/py2exe": { + "stars": 995, + "owner": "py2exe", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "pybee/toga": { + "stars": 5323, + "owner": "beeware", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "pybuilder/pybuilder": { + "stars": 1956, + "owner": "pybuilder", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "pyca/cryptography": { + "stars": 7515, + "owner": "pyca", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "pyca/pynacl": { + "stars": 1185, + "owner": "pyca", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "pydantic/pydantic": { + "stars": 27237, + "owner": "pydantic", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "pydantic/pydantic-ai": { + "stars": 15531, + "owner": "pydantic", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "pyenv-win/pyenv-win": { + "stars": 7064, + "owner": "pyenv-win", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "pyenv/pyenv": { + "stars": 44440, + "owner": "pyenv", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "pyeve/cerberus": { + "stars": 3270, + "owner": "pyeve", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "pyeve/eve": { + "stars": 6746, + "owner": "pyeve", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "pyexcel/pyexcel": { + "stars": 1284, + "owner": "pyexcel", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "pyglet/pyglet": { + "stars": 2172, + "owner": "pyglet", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "pygraphviz/pygraphviz": { + "stars": 834, + "owner": "pygraphviz", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "pyinfra-dev/pyinfra": { + "stars": 4869, + "owner": "pyinfra-dev", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "pyinstaller/pyinstaller": { + "stars": 12924, + "owner": "pyinstaller", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "pyinvoke/invoke": { + "stars": 4722, + "owner": "pyinvoke", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "pylint-dev/pylint": { + "stars": 5661, + "owner": "pylint-dev", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "pymatting/pymatting": { + "stars": 1891, + "owner": "pymatting", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "pymc-devs/pymc3": { + "stars": 9527, + "owner": "pymc-devs", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "pymssql/pymssql": { + "stars": 880, + "owner": "pymssql", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "pynamodb/PynamoDB": { + "stars": 2647, + "owner": "pynamodb", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "pypa/bandersnatch": { + "stars": 528, + "owner": "pypa", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "pypa/hatch": { + "stars": 7145, + "owner": "pypa", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "pypa/virtualenv": { + "stars": 5017, + "owner": "pypa", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "pypa/warehouse": { + "stars": 3978, + "owner": "pypi", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "pyparsing/pyparsing": { + "stars": 2465, + "owner": "pyparsing", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "pyqtgraph/pyqtgraph": { + "stars": 4311, + "owner": "pyqtgraph", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "pyston/pyston": { + "stars": 2507, + "owner": "pyston", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "python-attrs/attrs": { + "stars": 5746, + "owner": "python-attrs", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "python-greenlet/greenlet": { + "stars": 1814, + "owner": "python-greenlet", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "python-jsonschema/jsonschema": { + "stars": 4935, + "owner": "python-jsonschema", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "python-mode/python-mode": { + "stars": 5478, + "owner": "python-mode", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "python-openxml/python-docx": { + "stars": 5486, + "owner": "python-openxml", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "python-pillow/Pillow": { + "stars": 13437, + "owner": "python-pillow", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "python-rapidjson/python-rapidjson": { + "stars": 532, + "owner": "python-rapidjson", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "python-rope/rope": { + "stars": 2183, + "owner": "python-rope", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "python-trio/trio": { + "stars": 7204, + "owner": "python-trio", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "python/cpython": { + "stars": 72015, + "owner": "python", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "python/mypy": { + "stars": 20302, + "owner": "python", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "python/typeshed": { + "stars": 5021, + "owner": "python", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "pythonnet/pythonnet": { + "stars": 5418, + "owner": "pythonnet", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "pytoolz/cytoolz": { + "stars": 1103, + "owner": "pytoolz", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "pytoolz/toolz": { + "stars": 5125, + "owner": "pytoolz", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "pytorch/pytorch": { + "stars": 98355, + "owner": "pytorch", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "pytransitions/transitions": { + "stars": 6459, + "owner": "pytransitions", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "quantopian/zipline": { + "stars": 19514, + "owner": "quantopian", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "quantumlib/Cirq": { + "stars": 4890, + "owner": "quantumlib", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "quodlibet/mutagen": { + "stars": 1867, + "owner": "quodlibet", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "r0x0r/pywebview": { + "stars": 5803, + "owner": "r0x0r", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "ranaroussi/yfinance": { + "stars": 22172, + "owner": "ranaroussi", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "ray-project/ray": { + "stars": 41786, + "owner": "ray-project", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "redis/redis-py": { + "stars": 13505, + "owner": "redis", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "reflex-dev/reflex": { + "stars": 28234, + "owner": "reflex-dev", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "robotframework/robotframework": { + "stars": 11478, + "owner": "robotframework", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "ronaldoussoren/py2app": { + "stars": 421, + "owner": "ronaldoussoren", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "rq/rq": { + "stars": 10605, + "owner": "rq", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "rsalmei/alive-progress": { + "stars": 6256, + "owner": "rsalmei", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "run-llama/llama_index": { + "stars": 47740, + "owner": "run-llama", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "s3tools/s3cmd": { + "stars": 4869, + "owner": "s3tools", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "saffsd/langid.py": { + "stars": 2455, + "owner": "saffsd", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "saltstack/salt": { + "stars": 15281, + "owner": "saltstack", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "samuelcolvin/watchfiles": { + "stars": 2444, + "owner": "samuelcolvin", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "sanic-org/sanic": { + "stars": 18639, + "owner": "sanic-org", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "scanny/python-pptx": { + "stars": 3233, + "owner": "scanny", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "schematics/schematics": { + "stars": 2591, + "owner": "schematics", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "scottrogowski/code2flow": { + "stars": 4545, + "owner": "scottrogowski", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "scrapy/scrapy": { + "stars": 60855, + "owner": "scrapy", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "sdispater/pendulum": { + "stars": 6628, + "owner": "python-pendulum", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "sdispater/poetry": { + "stars": 34316, + "owner": "python-poetry", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "sebastien/cuisine": { + "stars": 1270, + "owner": "sebastien", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "secdev/scapy": { + "stars": 12108, + "owner": "secdev", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "sehmaschine/django-grappelli": { + "stars": 3927, + "owner": "sehmaschine", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "selwin/python-user-agents": { + "stars": 1520, + "owner": "selwin", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "sergree/matchering": { + "stars": 2446, + "owner": "sergree", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "shahraizali/awesome-django": { + "stars": 1901, + "owner": "shahraizali", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "shapely/shapely": { + "stars": 4395, + "owner": "shapely", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "sherlock-project/sherlock": { + "stars": 73803, + "owner": "sherlock-project", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "simonw/datasette": { + "stars": 10835, + "owner": "simonw", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "simonw/sqlite-utils": { + "stars": 2019, + "owner": "simonw", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "sirfz/tesserocr": { + "stars": 2160, + "owner": "sirfz", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "skorokithakis/shortuuid": { + "stars": 2178, + "owner": "skorokithakis", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "sloria/doitlive": { + "stars": 3566, + "owner": "sloria", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "sphinx-doc/sphinx": { + "stars": 7721, + "owner": "sphinx-doc", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "spotify/annoy": { + "stars": 14181, + "owner": "spotify", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "spotify/luigi": { + "stars": 18694, + "owner": "spotify", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "spulec/freezegun": { + "stars": 4498, + "owner": "spulec", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "spyder-ide/spyder": { + "stars": 9163, + "owner": "spyder-ide", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "sqlalchemy/dogpile.cache": { + "stars": 291, + "owner": "sqlalchemy", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "sqlmapproject/sqlmap": { + "stars": 36853, + "owner": "sqlmapproject", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "stanfordnlp/stanza": { + "stars": 7739, + "owner": "stanfordnlp", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "statsmodels/statsmodels": { + "stars": 11298, + "owner": "statsmodels", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "stchris/untangle": { + "stars": 631, + "owner": "stchris", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "strawberry-graphql/strawberry-django": { + "stars": 488, + "owner": "strawberry-graphql", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "streamlit/streamlit": { + "stars": 43924, + "owner": "streamlit", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "sunainapai/makesite": { + "stars": 1872, + "owner": "sunainapai", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "sympy/sympy": { + "stars": 14495, + "owner": "sympy", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "tartley/colorama": { + "stars": 3770, + "owner": "tartley", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "tayllan/awesome-algorithms": { + "stars": 24835, + "owner": "tayllan", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "tensorflow/tensorflow": { + "stars": 194201, + "owner": "tensorflow", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "thauber/django-schedule": { + "stars": 850, + "owner": "thauber", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "thumbor/thumbor": { + "stars": 10465, + "owner": "thumbor", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "tiangolo/fastapi": { + "stars": 96302, + "owner": "fastapi", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "tiangolo/typer": { + "stars": 19041, + "owner": "fastapi", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "timofurrer/awesome-asyncio": { + "stars": 5030, + "owner": "timofurrer", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "timofurrer/try": { + "stars": 751, + "owner": "timofurrer", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "timothycrosley/isort": { + "stars": 6916, + "owner": "PyCQA", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "tmux-python/tmuxp": { + "stars": 4451, + "owner": "tmux-python", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "tmux/tmux": { + "stars": 43165, + "owner": "tmux", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "tomerfiliba/rpyc": { + "stars": 1693, + "owner": "tomerfiliba-org", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "tomschimansky/customtkinter": { + "stars": 13223, + "owner": "TomSchimansky", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "tornadoweb/tornado": { + "stars": 22406, + "owner": "tornadoweb", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "tqdm/tqdm": { + "stars": 31040, + "owner": "tqdm", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "trustedsec/social-engineer-toolkit": { + "stars": 14671, + "owner": "trustedsec", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "twisted/treq": { + "stars": 606, + "owner": "twisted", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "twisted/twisted": { + "stars": 5951, + "owner": "twisted", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "tyiannak/pyAudioAnalysis": { + "stars": 6234, + "owner": "tyiannak", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "typeddjango/awesome-python-typing": { + "stars": 1950, + "owner": "typeddjango", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "ultraplot/UltraPlot": { + "stars": 279, + "owner": "Ultraplot", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "un33k/python-slugify": { + "stars": 1599, + "owner": "un33k", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "unclecode/crawl4ai": { + "stars": 62122, + "owner": "unclecode", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "unfoldadmin/django-unfold": { + "stars": 3369, + "owner": "unfoldadmin", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "uralbash/awesome-pyramid": { + "stars": 570, + "owner": "uralbash", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "urllib3/urllib3": { + "stars": 4012, + "owner": "urllib3", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "vadikko2/python-cqrs": { + "stars": 44, + "owner": "pypatterns", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "vinta/pangu.py": { + "stars": 276, + "owner": "vinta", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "vispy/vispy": { + "stars": 3558, + "owner": "vispy", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "vitali87/code-graph-rag": { + "stars": 2131, + "owner": "vitali87", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "vllm-project/vllm": { + "stars": 73457, + "owner": "vllm-project", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "wagtail/wagtail": { + "stars": 20240, + "owner": "wagtail", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "waylan/Python-Markdown": { + "stars": 4186, + "owner": "Python-Markdown", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "web2py/pydal": { + "stars": 531, + "owner": "web2py", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "wireservice/csvkit": { + "stars": 6360, + "owner": "wireservice", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "wooey/wooey": { + "stars": 2218, + "owner": "wooey", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "worldveil/dejavu": { + "stars": 6736, + "owner": "worldveil", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "xonsh/xonsh": { + "stars": 9252, + "owner": "xonsh", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "yfedoseev/pdf_oxide": { + "stars": 431, + "owner": "yfedoseev", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "yoloseem/awesome-sphinxdoc": { + "stars": 973, + "owner": "ygzgxyz", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "ytdl-org/youtube-dl": { + "stars": 139894, + "owner": "ytdl-org", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "zappa/Zappa": { + "stars": 3676, + "owner": "zappa", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "zauberzeug/nicegui": { + "stars": 15518, + "owner": "zauberzeug", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "zoofIO/flexx": { + "stars": 3343, + "owner": "flexxui", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "zopefoundation/ZODB": { + "stars": 752, + "owner": "zopefoundation", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "ztane/python-Levenshtein": { + "stars": 1278, + "owner": "ztane", + "fetched_at": "2026-03-17T20:08:08.973003+00:00" + }, + "josephmisiti/awesome-machine-learning": { + "stars": 72017, + "owner": "josephmisiti", + "fetched_at": "2026-03-17T20:12:03.066921+00:00" + }, + "sorrycc/awesome-javascript": { + "stars": 34931, + "owner": "sorrycc", + "fetched_at": "2026-03-17T20:12:03.066921+00:00" + }, + "vinta/awesome-python": { + "stars": 287640, + "owner": "vinta", + "fetched_at": "2026-03-17T20:12:03.066921+00:00" + } +} diff --git a/website/fetch_github_stars.py b/website/fetch_github_stars.py new file mode 100644 index 000000000..4f9b50a39 --- /dev/null +++ b/website/fetch_github_stars.py @@ -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() diff --git a/website/static/main.js b/website/static/main.js new file mode 100644 index 000000000..16e183117 --- /dev/null +++ b/website/static/main.js @@ -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(); + } + }); +} diff --git a/website/static/style.css b/website/static/style.css new file mode 100644 index 000000000..c31b68753 --- /dev/null +++ b/website/static/style.css @@ -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; + } +} diff --git a/website/templates/base.html b/website/templates/base.html new file mode 100644 index 000000000..4a3bc249c --- /dev/null +++ b/website/templates/base.html @@ -0,0 +1,67 @@ + + + + + + {% block title %}Awesome Python{% endblock %} + + + + + + + + + + + + + + + +
{% block content %}{% endblock %}
+ + + + + + + diff --git a/website/templates/index.html b/website/templates/index.html new file mode 100644 index 000000000..ea2c48214 --- /dev/null +++ b/website/templates/index.html @@ -0,0 +1,146 @@ +{% extends "base.html" %} +{% block content %} +
+
+
+

Awesome Python

+

+ {{ subtitle }}
Curated by + @vinta + since 2014. +

+ awesome-python on GitHub → +
+ Submit a Project +
+
+ +
+
+ + + + + +
+ +
+ +
+ + + + + + + + + + + + + {% for entry in entries %} + + + + + + + + + + + + + {% endfor %} + +
#Project NameGitHub StarsCategoryGroup
+
+ {% if entry.description %} +
{{ entry.description | safe }}
+ {% endif %} {% if entry.also_see %} +
+ Also see: {% for see in entry.also_see %}{{ see.name }}{% if not loop.last %}, {% endif %}{% endfor %} +
+ {% endif %} +
+ {% if entry.owner %}{{ entry.owner }}/{% endif %}{{ entry.url | replace("https://", "") }}{% if entry.pushed_at %}·Last pushed {{ entry.pushed_at[:10] }}{% endif %} +
+
+
+
+ + +{% endblock %} diff --git a/website/tests/test_build.py b/website/tests/test_build.py new file mode 100644 index 000000000..e551f954b --- /dev/null +++ b/website/tests/test_build.py @@ -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 "
  • " in html + assert 'lib' in html + + def test_renders_plain_text(self): + from build import render_markdown + + html = render_markdown("Hello world") + assert "

    Hello world

    " 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( + "{% block title %}{% endblock %}" + "" + "{% block content %}{% endblock %}", + encoding="utf-8", + ) + (tpl_dir / "index.html").write_text( + '{% extends "base.html" %}{% block content %}' + "{% for group in groups %}" + '
    ' + "

    {{ group.name }}

    " + "{% for cat in group.categories %}" + '
    ' + "{{ cat.name }}" + "{{ cat.preview }}" + "{{ cat.entry_count }}" + '' + "
    " + "{% endfor %}" + "
    " + "{% 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"] diff --git a/website/tests/test_fetch_github_stars.py b/website/tests/test_fetch_github_stars.py new file mode 100644 index 000000000..2465899ff --- /dev/null +++ b/website/tests/test_fetch_github_stars.py @@ -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 From cd7b8f6bb0692c46377b8c88d68975b186cff999 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Wed, 18 Mar 2026 13:48:53 +0800 Subject: [PATCH 04/96] update README description and remove Resources section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 'tools' to the description tagline - Remove the Resources TOC entry (Newsletters, Podcasts) and corresponding section — content is no longer relevant to the relaunched site - Remove uv from the Environment Management section (it's now a dev dependency managed by pyproject.toml, not a curated list entry) Co-Authored-By: Claude --- README.md | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/README.md b/README.md index 9f34e2250..d31fdfee4 100644 --- a/README.md +++ b/README.md @@ -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 From 2fe0f5c2bd36652734425673e9d698e2b925cab4 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Wed, 18 Mar 2026 13:57:26 +0800 Subject: [PATCH 05/96] ci: bump actions/checkout to v6 and upload-pages-artifact to v4 Co-Authored-By: Claude --- .github/workflows/deploy-website.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml index 28e254eac..66e7021e6 100644 --- a/.github/workflows/deploy-website.yml +++ b/.github/workflows/deploy-website.yml @@ -18,7 +18,7 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install uv uses: astral-sh/setup-uv@v7 @@ -32,7 +32,7 @@ jobs: run: uv run python website/build.py - name: Upload artifact - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@v4 with: path: website/output/ From 87a16f47ead7ed523afc166eb3e73199b07c03a3 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Wed, 18 Mar 2026 13:57:32 +0800 Subject: [PATCH 06/96] build: load .env in Makefile and rename fetch_stars to site_fetch_stats Adds -include .env with export so environment variables (e.g. GitHub token) are available to uv commands without manual export. Renames the target to match the site_ prefix convention used by the other targets. Co-Authored-By: Claude --- Makefile | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 5d6d75818..8cb7005ec 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,10 @@ +-include .env +export + site_install: uv sync --no-dev -fetch_stars: +site_fetch_stats: uv run python website/fetch_github_stars.py site_build: From 7eb9b11a67c523bbfb1227d0f50a2d3744a752d5 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Wed, 18 Mar 2026 13:57:36 +0800 Subject: [PATCH 07/96] data: remove zipline entry from github_stars.json Follows the removal of zipline from README.md (see bd73b1f). Co-Authored-By: Claude --- website/data/github_stars.json | 5 ----- 1 file changed, 5 deletions(-) diff --git a/website/data/github_stars.json b/website/data/github_stars.json index 1476651fc..c71287fd4 100644 --- a/website/data/github_stars.json +++ b/website/data/github_stars.json @@ -2119,11 +2119,6 @@ "owner": "pytransitions", "fetched_at": "2026-03-17T20:08:08.973003+00:00" }, - "quantopian/zipline": { - "stars": 19514, - "owner": "quantopian", - "fetched_at": "2026-03-17T20:08:08.973003+00:00" - }, "quantumlib/Cirq": { "stars": 4890, "owner": "quantumlib", From c5caa5a5e15e95652a70431c8eb7c2da19287a21 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Wed, 18 Mar 2026 14:03:50 +0800 Subject: [PATCH 08/96] ci: hardcode deployment URL to https://awesome-python.com The deploy-pages action outputs http:// despite HTTPS being enforced. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/deploy-website.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml index 66e7021e6..e9fda8f6b 100644 --- a/.github/workflows/deploy-website.yml +++ b/.github/workflows/deploy-website.yml @@ -41,7 +41,7 @@ jobs: runs-on: ubuntu-latest environment: name: github-pages - url: ${{ steps.deployment.outputs.page_url }} + url: https://awesome-python.com/ steps: - name: Deploy to GitHub Pages id: deployment From 5fa7c7d1a670387dc04b6408a06e7b1a6dbbbf42 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Wed, 18 Mar 2026 17:20:23 +0800 Subject: [PATCH 09/96] feat(website): add markdown-it-py README parser and inline renderer tests Introduce readme_parser.py which parses README.md into structured section data using the markdown-it-py AST. Includes TypedDicts for ParsedEntry/ParsedSection, slugify(), render_inline_html(), and render_inline_text(). Add test_readme_parser.py covering HTML escaping, link rendering, emphasis, strong, and code_inline for both renderers. Co-Authored-By: Claude --- website/readme_parser.py | 93 +++++++++++++++++++++++++++++ website/tests/test_readme_parser.py | 69 +++++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 website/readme_parser.py create mode 100644 website/tests/test_readme_parser.py diff --git a/website/readme_parser.py b/website/readme_parser.py new file mode 100644 index 000000000..a98e0e0c6 --- /dev/null +++ b/website/readme_parser.py @@ -0,0 +1,93 @@ +"""Parse README.md into structured section data using markdown-it-py AST.""" + +from __future__ import annotations + +import re +from typing import TypedDict + +from markdown_it.tree import SyntaxTreeNode +from markupsafe import escape + + +class AlsoSee(TypedDict): + name: str + url: str + + +class ParsedEntry(TypedDict): + name: str + url: str + description: str # inline HTML, properly escaped + also_see: list[AlsoSee] + + +class ParsedSection(TypedDict): + name: str + slug: str + description: str # plain text, links resolved to text + content: str # raw markdown (backward compat) + entries: list[ParsedEntry] + entry_count: int + preview: str + content_html: str # rendered HTML, properly escaped + + +# --- Slugify ---------------------------------------------------------------- + +_SLUG_NON_ALNUM_RE = re.compile(r"[^a-z0-9\s-]") +_SLUG_WHITESPACE_RE = re.compile(r"[\s]+") +_SLUG_MULTI_DASH_RE = re.compile(r"-+") + + +def slugify(name: str) -> str: + """Convert a category name to a URL-friendly slug.""" + slug = name.lower() + slug = _SLUG_NON_ALNUM_RE.sub("", slug) + slug = _SLUG_WHITESPACE_RE.sub("-", slug.strip()) + slug = _SLUG_MULTI_DASH_RE.sub("-", slug) + return slug + + +# --- Inline renderers ------------------------------------------------------- + + +def render_inline_html(children: list[SyntaxTreeNode]) -> str: + """Render inline AST nodes to HTML with proper escaping.""" + parts: list[str] = [] + for child in children: + match child.type: + case "text": + parts.append(str(escape(child.content))) + case "softbreak": + parts.append(" ") + case "link": + href = str(escape(child.attrGet("href") or "")) + inner = render_inline_html(child.children) + parts.append( + f'{inner}' + ) + case "em": + parts.append(f"{render_inline_html(child.children)}") + case "strong": + parts.append(f"{render_inline_html(child.children)}") + case "code_inline": + parts.append(f"{escape(child.content)}") + case "html_inline": + parts.append(str(escape(child.content))) + return "".join(parts) + + +def render_inline_text(children: list[SyntaxTreeNode]) -> str: + """Render inline AST nodes to plain text (links become their text).""" + parts: list[str] = [] + for child in children: + match child.type: + case "text": + parts.append(child.content) + case "softbreak": + parts.append(" ") + case "code_inline": + parts.append(child.content) + case "em" | "strong" | "link": + parts.append(render_inline_text(child.children)) + return "".join(parts) diff --git a/website/tests/test_readme_parser.py b/website/tests/test_readme_parser.py new file mode 100644 index 000000000..974143e51 --- /dev/null +++ b/website/tests/test_readme_parser.py @@ -0,0 +1,69 @@ +"""Tests for the readme_parser module.""" + +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +from readme_parser import render_inline_html, render_inline_text + +from markdown_it import MarkdownIt +from markdown_it.tree import SyntaxTreeNode + + +def _parse_inline(md_text: str) -> list[SyntaxTreeNode]: + """Helper: parse a single paragraph and return its inline children.""" + md = MarkdownIt("commonmark") + root = SyntaxTreeNode(md.parse(md_text)) + # root > paragraph > inline > children + return root.children[0].children[0].children + + +class TestRenderInlineHtml: + def test_plain_text_escapes_html(self): + children = _parse_inline("Hello & friends") + assert render_inline_html(children) == "Hello <world> & friends" + + def test_link_with_target(self): + children = _parse_inline("[name](https://example.com)") + html = render_inline_html(children) + assert 'href="https://example.com"' in html + assert 'target="_blank"' in html + assert 'rel="noopener"' in html + assert ">name" in html + + def test_emphasis(self): + children = _parse_inline("*italic* text") + assert "italic" in render_inline_html(children) + + def test_strong(self): + children = _parse_inline("**bold** text") + assert "bold" in render_inline_html(children) + + def test_code_inline(self): + children = _parse_inline("`some code`") + assert "some code" in render_inline_html(children) + + def test_mixed_link_and_text(self): + children = _parse_inline("See [foo](https://x.com) for details.") + html = render_inline_html(children) + assert "See " in html + assert ">foo" in html + assert " for details." in html + + +class TestRenderInlineText: + def test_plain_text(self): + children = _parse_inline("Hello world") + assert render_inline_text(children) == "Hello world" + + def test_link_becomes_text(self): + children = _parse_inline("See [awesome-algos](https://github.com/x/y).") + assert render_inline_text(children) == "See awesome-algos." + + def test_emphasis_stripped(self): + children = _parse_inline("*italic* text") + assert render_inline_text(children) == "italic text" + + def test_code_inline_kept(self): + children = _parse_inline("`code` here") + assert render_inline_text(children) == "code here" From 1c67c9f0e68718cd23f885d11a9168d8d0d53980 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Wed, 18 Mar 2026 17:21:49 +0800 Subject: [PATCH 10/96] feat: replace regex README parser with markdown-it-py AST parser Introduce parse_readme() which uses MarkdownIt to build a full AST instead of line-by-line regex matching. The function splits the document at the thematic break, groups nodes by h2 heading, extracts category descriptions from leading italic paragraphs, and separates the Categories, Resources, and Contributing sections cleanly. Add markdown-it-py==4.0.0 (+ mdurl) as a runtime dependency to support the new parser. Tests cover section counts, names, slugs, descriptions, content presence, boundary conditions (no separator, no description), and mixed description markup. Co-Authored-By: Claude --- pyproject.toml | 1 + uv.lock | 23 ++++ website/readme_parser.py | 167 ++++++++++++++++++++++++++++ website/tests/test_readme_parser.py | 135 +++++++++++++++++++++- 4 files changed, 325 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index d564cde9f..3f03420a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,7 @@ dependencies = [ "httpx==0.28.1", "jinja2==3.1.6", "markdown==3.10.2", + "markdown-it-py==4.0.0", ] [dependency-groups] diff --git a/uv.lock b/uv.lock index 1f7b17c7c..51bd68225 100644 --- a/uv.lock +++ b/uv.lock @@ -22,6 +22,7 @@ dependencies = [ { name = "httpx" }, { name = "jinja2" }, { name = "markdown" }, + { name = "markdown-it-py" }, ] [package.dev-dependencies] @@ -35,6 +36,7 @@ requires-dist = [ { name = "httpx", specifier = "==0.28.1" }, { name = "jinja2", specifier = "==3.1.6" }, { name = "markdown", specifier = "==3.10.2" }, + { name = "markdown-it-py", specifier = "==4.0.0" }, ] [package.metadata.requires-dev] @@ -137,6 +139,18 @@ 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 = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + [[package]] name = "markupsafe" version = "3.0.3" @@ -189,6 +203,15 @@ wheels = [ { 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 = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + [[package]] name = "packaging" version = "26.0" diff --git a/website/readme_parser.py b/website/readme_parser.py index a98e0e0c6..62afd94c7 100644 --- a/website/readme_parser.py +++ b/website/readme_parser.py @@ -5,6 +5,7 @@ import re from typing import TypedDict +from markdown_it import MarkdownIt from markdown_it.tree import SyntaxTreeNode from markupsafe import escape @@ -91,3 +92,169 @@ def render_inline_text(children: list[SyntaxTreeNode]) -> str: case "em" | "strong" | "link": parts.append(render_inline_text(child.children)) return "".join(parts) + + +# --- AST helpers ------------------------------------------------------------- + + +def _heading_text(node: SyntaxTreeNode) -> str: + """Extract plain text from a heading node.""" + for child in node.children: + if child.type == "inline": + return render_inline_text(child.children) + return "" + + +def _extract_description(nodes: list[SyntaxTreeNode]) -> str: + """Extract description from the first paragraph if it's a single block. + + Pattern: _Libraries for foo._ -> "Libraries for foo." + """ + if not nodes: + return "" + first = nodes[0] + if first.type != "paragraph": + return "" + for child in first.children: + if child.type == "inline" and len(child.children) == 1: + em = child.children[0] + if em.type == "em": + return render_inline_text(em.children) + return "" + + +def _has_description(nodes: list[SyntaxTreeNode]) -> bool: + """Check if the first node is a description paragraph (_italic text_).""" + if not nodes: + return False + first = nodes[0] + if first.type != "paragraph": + return False + for child in first.children: + if child.type == "inline" and len(child.children) == 1: + if child.children[0].type == "em": + return True + return False + + +def _nodes_to_raw_markdown(nodes: list[SyntaxTreeNode], source_lines: list[str]) -> str: + """Extract raw markdown text for AST nodes using source line mappings.""" + if not nodes: + return "" + start_line = None + end_line = None + for node in nodes: + node_map = node.map + if node_map is not None: + if start_line is None or node_map[0] < start_line: + start_line = node_map[0] + if end_line is None or node_map[1] > end_line: + end_line = node_map[1] + if start_line is None: + return "" + return "\n".join(source_lines[start_line:end_line]).strip() + + +# --- Stubs for Tasks 3 & 4 (replace in later tasks) ------------------------- + + +def _parse_section_entries(content_nodes: list[SyntaxTreeNode]) -> list[ParsedEntry]: + return [] + + +def _render_section_html(content_nodes: list[SyntaxTreeNode]) -> str: + return "" + + +# --- Section splitting ------------------------------------------------------- + + +def _group_by_h2( + nodes: list[SyntaxTreeNode], + source_lines: list[str], +) -> list[ParsedSection]: + """Group AST nodes into sections by h2 headings.""" + sections: list[ParsedSection] = [] + current_name: str | None = None + current_body: list[SyntaxTreeNode] = [] + + def flush() -> None: + nonlocal current_name + if current_name is None: + return + desc = _extract_description(current_body) + content_nodes = current_body[1:] if _has_description(current_body) else current_body + content = _nodes_to_raw_markdown(content_nodes, source_lines) + entries = _parse_section_entries(content_nodes) + entry_count = len(entries) + sum(len(e["also_see"]) for e in entries) + preview = ", ".join(e["name"] for e in entries[:4]) + content_html = _render_section_html(content_nodes) + + sections.append(ParsedSection( + name=current_name, + slug=slugify(current_name), + description=desc, + content=content, + entries=entries, + entry_count=entry_count, + preview=preview, + content_html=content_html, + )) + current_name = None + + for node in nodes: + if node.type == "heading" and node.tag == "h2": + flush() + current_name = _heading_text(node) + current_body = [] + elif current_name is not None: + current_body.append(node) + + flush() + return sections + + +def parse_readme(text: str) -> tuple[list[ParsedSection], list[ParsedSection]]: + """Parse README.md text into categories and resources. + + Returns (categories, resources) where each is a list of ParsedSection dicts. + """ + md = MarkdownIt("commonmark") + tokens = md.parse(text) + root = SyntaxTreeNode(tokens) + source_lines = text.split("\n") + children = root.children + + # Find thematic break (---) + hr_idx = None + for i, node in enumerate(children): + if node.type == "hr": + hr_idx = i + break + if hr_idx is None: + return [], [] + + # Find # Resources and # Contributing boundaries + resources_idx = None + contributing_idx = None + for i, node in enumerate(children): + if node.type == "heading" and node.tag == "h1": + text_content = _heading_text(node) + if text_content == "Resources": + resources_idx = i + elif text_content == "Contributing": + contributing_idx = i + + # Slice into category and resource ranges + cat_end = resources_idx or contributing_idx or len(children) + cat_nodes = children[hr_idx + 1 : cat_end] + + res_nodes: list[SyntaxTreeNode] = [] + if resources_idx is not None: + res_end = contributing_idx or len(children) + res_nodes = children[resources_idx + 1 : res_end] + + categories = _group_by_h2(cat_nodes, source_lines) + resources = _group_by_h2(res_nodes, source_lines) + + return categories, resources diff --git a/website/tests/test_readme_parser.py b/website/tests/test_readme_parser.py index 974143e51..3f32e8448 100644 --- a/website/tests/test_readme_parser.py +++ b/website/tests/test_readme_parser.py @@ -2,9 +2,10 @@ import os import sys +import textwrap sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) -from readme_parser import render_inline_html, render_inline_text +from readme_parser import parse_readme, render_inline_html, render_inline_text from markdown_it import MarkdownIt from markdown_it.tree import SyntaxTreeNode @@ -67,3 +68,135 @@ def test_emphasis_stripped(self): def test_code_inline_kept(self): children = _parse_inline("`code` here") assert render_inline_text(children) == "code here" + + +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 TestParseReadmeSections: + 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_resource_names(self): + _, resources = parse_readme(MINIMAL_README) + assert resources[0]["name"] == "Newsletters" + assert resources[1]["name"] == "Podcasts" + + def test_resource_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"] + + def test_description_with_link_stripped(self): + readme = textwrap.dedent("""\ + # T + + --- + + ## Algos + + _Algorithms. Also see [awesome-algos](https://example.com)._ + + - [lib](https://x.com) - Lib. + + # Contributing + + Done. + """) + cats, _ = parse_readme(readme) + assert cats[0]["description"] == "Algorithms. Also see awesome-algos." From 3d015bc63026635087701358237d9ddb60fb67d7 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Wed, 18 Mar 2026 17:23:11 +0800 Subject: [PATCH 11/96] feat(parser): implement entry extraction from bullet list AST nodes Replace _parse_section_entries stub with full implementation that walks bullet_list AST nodes to extract ParsedEntry records, including support for subcategory labels (text-only list items) and also_see nested links. Add _parse_list_entries, helper finders (_find_inline, _find_first_link, _find_child), and _extract_description_html with separator stripping. Extend test suite with TestParseSectionEntries covering flat entries, link-only entries, subcategorized entries, also_see, entry_count, preview first-four, and XSS escaping in description HTML. Co-Authored-By: Claude --- website/readme_parser.py | 114 +++++++++++++++++++++++++++- website/tests/test_readme_parser.py | 105 ++++++++++++++++++++++++- 2 files changed, 216 insertions(+), 3 deletions(-) diff --git a/website/readme_parser.py b/website/readme_parser.py index 62afd94c7..71a36742b 100644 --- a/website/readme_parser.py +++ b/website/readme_parser.py @@ -155,11 +155,121 @@ def _nodes_to_raw_markdown(nodes: list[SyntaxTreeNode], source_lines: list[str]) return "\n".join(source_lines[start_line:end_line]).strip() -# --- Stubs for Tasks 3 & 4 (replace in later tasks) ------------------------- +# --- Entry extraction -------------------------------------------------------- + +_DESC_SEP_RE = re.compile(r"^\s*[-\u2013\u2014]\s*") + + +def _find_inline(node: SyntaxTreeNode) -> SyntaxTreeNode | None: + """Find the inline node in a list_item's paragraph.""" + for child in node.children: + if child.type == "paragraph": + for sub in child.children: + if sub.type == "inline": + return sub + return None + + +def _find_first_link(inline: SyntaxTreeNode) -> SyntaxTreeNode | None: + """Find the first link node among inline children.""" + for child in inline.children: + if child.type == "link": + return child + return None + + +def _find_child(node: SyntaxTreeNode, child_type: str) -> SyntaxTreeNode | None: + """Find first direct child of a given type.""" + for child in node.children: + if child.type == child_type: + return child + return None + + +def _extract_description_html(inline: SyntaxTreeNode, first_link: SyntaxTreeNode) -> str: + """Extract description HTML from inline content after the first link. + + AST: [link("name"), text(" - Description.")] -> "Description." + The separator (- / en-dash / em-dash) is stripped. + """ + link_idx = next((i for i, c in enumerate(inline.children) if c is first_link), None) + if link_idx is None: + return "" + desc_children = inline.children[link_idx + 1 :] + if not desc_children: + return "" + html = render_inline_html(desc_children) + return _DESC_SEP_RE.sub("", html) + + +def _parse_list_entries(bullet_list: SyntaxTreeNode) -> list[ParsedEntry]: + """Extract entries from a bullet_list AST node. + + Handles three patterns: + - Text-only list_item -> subcategory label -> recurse into nested list + - Link list_item with nested link-only items -> entry with also_see + - Link list_item without nesting -> simple entry + """ + entries: list[ParsedEntry] = [] + + for list_item in bullet_list.children: + if list_item.type != "list_item": + continue + + inline = _find_inline(list_item) + if inline is None: + continue + + first_link = _find_first_link(inline) + + if first_link is None: + # Subcategory label — recurse into nested bullet_list + nested = _find_child(list_item, "bullet_list") + if nested: + entries.extend(_parse_list_entries(nested)) + continue + + # Entry with a link + name = render_inline_text(first_link.children) + url = first_link.attrGet("href") or "" + desc_html = _extract_description_html(inline, first_link) + + # Collect also_see from nested bullet_list + also_see: list[AlsoSee] = [] + nested = _find_child(list_item, "bullet_list") + if nested: + for sub_item in nested.children: + if sub_item.type != "list_item": + continue + sub_inline = _find_inline(sub_item) + if sub_inline: + sub_link = _find_first_link(sub_inline) + if sub_link: + also_see.append(AlsoSee( + name=render_inline_text(sub_link.children), + url=sub_link.attrGet("href") or "", + )) + + entries.append(ParsedEntry( + name=name, + url=url, + description=desc_html, + also_see=also_see, + )) + + return entries def _parse_section_entries(content_nodes: list[SyntaxTreeNode]) -> list[ParsedEntry]: - return [] + """Extract all entries from a section's content nodes.""" + entries: list[ParsedEntry] = [] + for node in content_nodes: + if node.type == "bullet_list": + entries.extend(_parse_list_entries(node)) + return entries + + +# --- Content HTML rendering (stub for Task 4) -------------------------------- def _render_section_html(content_nodes: list[SyntaxTreeNode]) -> str: diff --git a/website/tests/test_readme_parser.py b/website/tests/test_readme_parser.py index 3f32e8448..f0f53e92f 100644 --- a/website/tests/test_readme_parser.py +++ b/website/tests/test_readme_parser.py @@ -5,7 +5,7 @@ import textwrap sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) -from readme_parser import parse_readme, render_inline_html, render_inline_text +from readme_parser import _parse_section_entries, parse_readme, render_inline_html, render_inline_text from markdown_it import MarkdownIt from markdown_it.tree import SyntaxTreeNode @@ -200,3 +200,106 @@ def test_description_with_link_stripped(self): """) cats, _ = parse_readme(readme) assert cats[0]["description"] == "Algorithms. Also see awesome-algos." + + +def _content_nodes(md_text: str) -> list[SyntaxTreeNode]: + """Helper: parse markdown and return all block nodes.""" + md = MarkdownIt("commonmark") + root = SyntaxTreeNode(md.parse(md_text)) + return root.children + + +class TestParseSectionEntries: + def test_flat_entries(self): + nodes = _content_nodes( + "- [django](https://example.com/d) - A web framework.\n" + "- [flask](https://example.com/f) - A micro framework.\n" + ) + entries = _parse_section_entries(nodes) + assert len(entries) == 2 + assert entries[0]["name"] == "django" + assert entries[0]["url"] == "https://example.com/d" + assert "web framework" in entries[0]["description"] + assert entries[0]["also_see"] == [] + assert entries[1]["name"] == "flask" + + def test_link_only_entry(self): + nodes = _content_nodes("- [tool](https://x.com)\n") + entries = _parse_section_entries(nodes) + assert len(entries) == 1 + assert entries[0]["name"] == "tool" + assert entries[0]["description"] == "" + + def test_subcategorized_entries(self): + nodes = _content_nodes( + "- Algorithms\n" + " - [algos](https://x.com/a) - Algo lib.\n" + " - [sorts](https://x.com/s) - Sort lib.\n" + "- Design Patterns\n" + " - [patterns](https://x.com/p) - Pattern lib.\n" + ) + entries = _parse_section_entries(nodes) + assert len(entries) == 3 + assert entries[0]["name"] == "algos" + assert entries[2]["name"] == "patterns" + + def test_also_see_sub_entries(self): + nodes = _content_nodes( + "- [asyncio](https://docs.python.org/3/library/asyncio.html) - Async I/O.\n" + " - [awesome-asyncio](https://github.com/timofurrer/awesome-asyncio)\n" + "- [trio](https://github.com/python-trio/trio) - Friendly async.\n" + ) + entries = _parse_section_entries(nodes) + assert len(entries) == 2 + assert entries[0]["name"] == "asyncio" + assert len(entries[0]["also_see"]) == 1 + assert entries[0]["also_see"][0]["name"] == "awesome-asyncio" + assert entries[1]["name"] == "trio" + assert entries[1]["also_see"] == [] + + def test_entry_count_includes_also_see(self): + readme = textwrap.dedent("""\ + # T + + --- + + ## Async + + - [asyncio](https://x.com) - Async I/O. + - [awesome-asyncio](https://y.com) + - [trio](https://z.com) - Friendly async. + + # Contributing + + Done. + """) + cats, _ = parse_readme(readme) + # 2 main entries + 1 also_see = 3 + assert cats[0]["entry_count"] == 3 + + def test_preview_first_four_names(self): + readme = textwrap.dedent("""\ + # T + + --- + + ## Libs + + - [alpha](https://x.com) - A. + - [beta](https://x.com) - B. + - [gamma](https://x.com) - C. + - [delta](https://x.com) - D. + - [epsilon](https://x.com) - E. + + # Contributing + + Done. + """) + cats, _ = parse_readme(readme) + assert cats[0]["preview"] == "alpha, beta, gamma, delta" + + def test_description_html_escapes_xss(self): + nodes = _content_nodes('- [lib](https://x.com) - A lib.\n') + entries = _parse_section_entries(nodes) + assert "\n") + html = _render_section_html(nodes) + assert "\n") html = _render_section_html(nodes) assert "