From 61d69eeff890abd770cbfb533877ef62531050ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=AE=B6=E5=90=8D?= Date: Sun, 8 Mar 2026 21:12:05 +0800 Subject: [PATCH 001/429] Add Agno to Machine Learning section --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 00b4d2ef79..f93941d0e2 100644 --- a/README.md +++ b/README.md @@ -724,6 +724,7 @@ _Libraries for Machine Learning. Also see [awesome-machine-learning](https://git - [diffusers](https://github.com/huggingface/diffusers) - A library that provides pretrained diffusion models for generating and editing images, audio, and video. - [gym](https://github.com/openai/gym) - A toolkit for developing and comparing reinforcement learning algorithms. +- [Agno](https://github.com/agno-agi/agno) - Open-source Python library for building AI agents and agentic systems. - [Feature-engine](https://github.com/feature-engine/feature_engine) - sklearn compatible API with the widest toolset for feature engineering and selection. - [H2O](https://github.com/h2oai/h2o-3) - Open Source Fast Scalable Machine Learning Platform. - [Instructor](https://github.com/567-labs/instructor) - A library for extracting structured data from LLMs, powered by Pydantic. From c07522717f5c7362f4534a31623a1fec5236e115 Mon Sep 17 00:00:00 2001 From: Muhammad Zain ul abidin Date: Fri, 13 Mar 2026 11:00:23 +0500 Subject: [PATCH 002/429] Add crawl4ai to web scraping libraries list Crawl4AI correct url --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 0d87610120..1316cb997f 100644 --- a/README.md +++ b/README.md @@ -1144,6 +1144,7 @@ _Libraries to automate web scraping._ - [browser-use](https://github.com/browser-use/browser-use) - Make websites accessible for AI agents with easy browser automation. - [feedparser](https://github.com/kurtmckee/feedparser) - Universal feed parser. +- [crawl4ai](https://github.com/unclecode/crawl4ai) - An open-source, LLM-friendly web crawler that provides lightning-fast, structured data extraction specifically designed for AI agents. - [grab](https://github.com/lorien/grab) - Site scraping framework. - [mechanicalsoup](https://github.com/MechanicalSoup/MechanicalSoup) - A Python library for automating interaction with websites. - [scrapy](https://github.com/scrapy/scrapy) - A fast high-level screen scraping and web crawling framework. From bdeb5b90e841e9c6fce2e68c69594a92a84fe54a Mon Sep 17 00:00:00 2001 From: Muhammad Zain ul abidin Date: Fri, 13 Mar 2026 11:03:59 +0500 Subject: [PATCH 003/429] Fix duplicate entry for feedparser in README Fixed Alphabetic Order --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1316cb997f..77eda81920 100644 --- a/README.md +++ b/README.md @@ -1143,8 +1143,8 @@ _Libraries for extracting web contents._ _Libraries to automate web scraping._ - [browser-use](https://github.com/browser-use/browser-use) - Make websites accessible for AI agents with easy browser automation. -- [feedparser](https://github.com/kurtmckee/feedparser) - Universal feed parser. - [crawl4ai](https://github.com/unclecode/crawl4ai) - An open-source, LLM-friendly web crawler that provides lightning-fast, structured data extraction specifically designed for AI agents. +- [feedparser](https://github.com/kurtmckee/feedparser) - Universal feed parser. - [grab](https://github.com/lorien/grab) - Site scraping framework. - [mechanicalsoup](https://github.com/MechanicalSoup/MechanicalSoup) - A Python library for automating interaction with websites. - [scrapy](https://github.com/scrapy/scrapy) - A fast high-level screen scraping and web crawling framework. From 9eb21b268e8c4558dd134964f124c1d0529e1e14 Mon Sep 17 00:00:00 2001 From: AshithaT Date: Sun, 15 Mar 2026 22:58:19 +0530 Subject: [PATCH 004/429] Add pgmpy library to Machine Learning section --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 77eda81920..36086db4b0 100644 --- a/README.md +++ b/README.md @@ -731,6 +731,7 @@ _Libraries for Machine Learning. Also see [awesome-machine-learning](https://git - [LlamaIndex](https://github.com/run-llama/llama_index) - A data framework for your LLM application. - [Metrics](https://github.com/benhamner/Metrics) - Machine learning evaluation metrics. - [MindsDB](https://github.com/mindsdb/mindsdb) - MindsDB is an open source AI layer for existing databases that allows you to effortlessly develop, train and deploy state-of-the-art machine learning models using standard queries. +- [pgmpy](https://github.com/pgmpy/pgmpy) - A Python library for probabilistic graphical models and Bayesian networks. - [PraisonAI](https://github.com/MervinPraison/PraisonAI) - Production-ready Multi-AI Agents framework with self-reflection, 100+ LLM support, MCP integration, and agentic workflows. - [pydantic-ai](https://github.com/pydantic/pydantic-ai) - A Python agent framework for building generative AI applications with structured schemas. - [RAGFlow](https://github.com/infiniflow/ragflow) - An open-source RAG engine for document understanding and question answering with LLMs. From 742c99aa3c5517079366f67f38c1f79ecbe1c659 Mon Sep 17 00:00:00 2001 From: Jinyang Date: Tue, 17 Mar 2026 07:37:53 +0400 Subject: [PATCH 005/429] fix entry order --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f93941d0e2..d5f9fc5433 100644 --- a/README.md +++ b/README.md @@ -722,9 +722,9 @@ _Libraries for generating and working with logs._ _Libraries for Machine Learning. Also see [awesome-machine-learning](https://github.com/josephmisiti/awesome-machine-learning#python)._ +- [Agno](https://github.com/agno-agi/agno) - Open-source Python library for building AI agents and agentic systems. - [diffusers](https://github.com/huggingface/diffusers) - A library that provides pretrained diffusion models for generating and editing images, audio, and video. - [gym](https://github.com/openai/gym) - A toolkit for developing and comparing reinforcement learning algorithms. -- [Agno](https://github.com/agno-agi/agno) - Open-source Python library for building AI agents and agentic systems. - [Feature-engine](https://github.com/feature-engine/feature_engine) - sklearn compatible API with the widest toolset for feature engineering and selection. - [H2O](https://github.com/h2oai/h2o-3) - Open Source Fast Scalable Machine Learning Platform. - [Instructor](https://github.com/567-labs/instructor) - A library for extracting structured data from LLMs, powered by Pydantic. From a8eac91658ad902de4ecc51e767a2c9e62c04c2c Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Tue, 17 Mar 2026 19:20:36 +0800 Subject: [PATCH 006/429] add sponsorship --- README.md | 170 ++++++++++++++++++++++++------------------------- SPONSORSHIP.md | 36 +++++++++++ 2 files changed, 121 insertions(+), 85 deletions(-) create mode 100644 SPONSORSHIP.md diff --git a/README.md b/README.md index 4170d11079..340e28e763 100644 --- a/README.md +++ b/README.md @@ -2,94 +2,94 @@ An opinionated list of awesome Python frameworks, libraries, software and resources. -Inspired by [awesome-php](https://github.com/ziadoz/awesome-php). - -- [Awesome Python](#awesome-python) - - [Admin Panels](#admin-panels) - - [Algorithms and Design Patterns](#algorithms-and-design-patterns) - - [ASGI Servers](#asgi-servers) - - [Asynchronous Programming](#asynchronous-programming) - - [Audio](#audio) - - [Authentication](#authentication) - - [Build Tools](#build-tools) - - [Built-in Classes Enhancement](#built-in-classes-enhancement) - - [Caching](#caching) - - [CMS](#cms) - - [Code Analysis](#code-analysis) - - [Command-line Interface Development](#command-line-interface-development) - - [Command-line Tools](#command-line-tools) - - [Computer Vision](#computer-vision) - - [Configuration Files](#configuration-files) - - [Cryptography](#cryptography) - - [Data Analysis](#data-analysis) - - [Data Validation](#data-validation) - - [Data Visualization](#data-visualization) - - [Database Drivers](#database-drivers) - - [Database](#database) - - [Date and Time](#date-and-time) - - [Debugging Tools](#debugging-tools) - - [Deep Learning](#deep-learning) - - [DevOps Tools](#devops-tools) - - [Distributed Computing](#distributed-computing) - - [Distribution](#distribution) - - [Documentation](#documentation) - - [Downloader](#downloader) - - [Editor Plugins and IDEs](#editor-plugins-and-ides) - - [Email](#email) - - [Environment Management](#environment-management) - - [File Manipulation](#file-manipulation) - - [Functional Programming](#functional-programming) - - [Game Development](#game-development) - - [Geolocation](#geolocation) - - [GUI Development](#gui-development) - - [Hardware](#hardware) - - [HTML Manipulation](#html-manipulation) - - [HTTP Clients](#http-clients) - - [Image Processing](#image-processing) - - [Implementations](#implementations) - - [Interactive Interpreter](#interactive-interpreter) - - [Internationalization](#internationalization) - - [Job Scheduler](#job-scheduler) - - [Logging](#logging) - - [Machine Learning](#machine-learning) - - [Miscellaneous](#miscellaneous) - - [Natural Language Processing](#natural-language-processing) - - [Network Virtualization](#network-virtualization) - - [ORM](#orm) - - [Package Management](#package-management) - - [Package Repositories](#package-repositories) - - [Penetration testing](#penetration-testing) - - [Permissions](#permissions) - - [Processes](#processes) - - [Quantum Computing](#quantum-computing) - - [Recommender Systems](#recommender-systems) - - [Refactoring](#refactoring) - - [RESTful API](#restful-api) - - [Robotics](#robotics) - - [RPC Servers](#rpc-servers) - - [Science](#science) - - [Search](#search) - - [Serialization](#serialization) - - [Serverless Frameworks](#serverless-frameworks) - - [Shell](#shell) - - [Specific Formats Processing](#specific-formats-processing) - - [Static Site Generator](#static-site-generator) - - [Task Queues](#task-queues) - - [Template Engine](#template-engine) - - [Testing](#testing) - - [Text Processing](#text-processing) - - [URL Manipulation](#url-manipulation) - - [Video](#video) - - [Web Asset Management](#web-asset-management) - - [Web Content Extracting](#web-content-extracting) - - [Web Crawling](#web-crawling) - - [Web Frameworks](#web-frameworks) - - [WebSocket](#websocket) - - [WSGI Servers](#wsgi-servers) +> The **#10 most-starred repo on GitHub**. Put your product where Python developers discover tools. [Become a sponsor](SPONSORSHIP.md). + +# Categories + +- [Admin Panels](#admin-panels) +- [Algorithms and Design Patterns](#algorithms-and-design-patterns) +- [ASGI Servers](#asgi-servers) +- [Asynchronous Programming](#asynchronous-programming) +- [Audio](#audio) +- [Authentication](#authentication) +- [Build Tools](#build-tools) +- [Built-in Classes Enhancement](#built-in-classes-enhancement) +- [Caching](#caching) +- [CMS](#cms) +- [Code Analysis](#code-analysis) +- [Command-line Interface Development](#command-line-interface-development) +- [Command-line Tools](#command-line-tools) +- [Computer Vision](#computer-vision) +- [Configuration Files](#configuration-files) +- [Cryptography](#cryptography) +- [Data Analysis](#data-analysis) +- [Data Validation](#data-validation) +- [Data Visualization](#data-visualization) +- [Database Drivers](#database-drivers) +- [Database](#database) +- [Date and Time](#date-and-time) +- [Debugging Tools](#debugging-tools) +- [Deep Learning](#deep-learning) +- [DevOps Tools](#devops-tools) +- [Distributed Computing](#distributed-computing) +- [Distribution](#distribution) +- [Documentation](#documentation) +- [Downloader](#downloader) +- [Editor Plugins and IDEs](#editor-plugins-and-ides) +- [Email](#email) +- [Environment Management](#environment-management) +- [File Manipulation](#file-manipulation) +- [Functional Programming](#functional-programming) +- [Game Development](#game-development) +- [Geolocation](#geolocation) +- [GUI Development](#gui-development) +- [Hardware](#hardware) +- [HTML Manipulation](#html-manipulation) +- [HTTP Clients](#http-clients) +- [Image Processing](#image-processing) +- [Implementations](#implementations) +- [Interactive Interpreter](#interactive-interpreter) +- [Internationalization](#internationalization) +- [Job Scheduler](#job-scheduler) +- [Logging](#logging) +- [Machine Learning](#machine-learning) +- [Miscellaneous](#miscellaneous) +- [Natural Language Processing](#natural-language-processing) +- [Network Virtualization](#network-virtualization) +- [ORM](#orm) +- [Package Management](#package-management) +- [Package Repositories](#package-repositories) +- [Penetration testing](#penetration-testing) +- [Permissions](#permissions) +- [Processes](#processes) +- [Quantum Computing](#quantum-computing) +- [Recommender Systems](#recommender-systems) +- [Refactoring](#refactoring) +- [RESTful API](#restful-api) +- [Robotics](#robotics) +- [RPC Servers](#rpc-servers) +- [Science](#science) +- [Search](#search) +- [Serialization](#serialization) +- [Serverless Frameworks](#serverless-frameworks) +- [Shell](#shell) +- [Specific Formats Processing](#specific-formats-processing) +- [Static Site Generator](#static-site-generator) +- [Task Queues](#task-queues) +- [Template Engine](#template-engine) +- [Testing](#testing) +- [Text Processing](#text-processing) +- [URL Manipulation](#url-manipulation) +- [Video](#video) +- [Web Asset Management](#web-asset-management) +- [Web Content Extracting](#web-content-extracting) +- [Web Crawling](#web-crawling) +- [Web Frameworks](#web-frameworks) +- [WebSocket](#websocket) +- [WSGI Servers](#wsgi-servers) - [Resources](#resources) - [Newsletters](#newsletters) - [Podcasts](#podcasts) -- [Contributing](#contributing) --- diff --git a/SPONSORSHIP.md b/SPONSORSHIP.md new file mode 100644 index 0000000000..3f9e8a68ef --- /dev/null +++ b/SPONSORSHIP.md @@ -0,0 +1,36 @@ +# Sponsor awesome-python + +**The #10 most-starred repository on all of GitHub.** + +awesome-python is where Python developers go to discover tools. When someone searches Google for "best Python libraries," they land here. When ChatGPT recommends Python tools, it references this list. When developers evaluate frameworks, this is the list they check. + +Your sponsorship puts your product in front of developers at the exact moment they're choosing what to use. + +## By the Numbers + +| Metric | Value | +| ------------ | ---------------------------------------------------------------------------------------------------- | +| Stars | ![Stars](https://img.shields.io/github/stars/vinta/awesome-python?style=for-the-badge) | +| Forks | ![Forks](https://img.shields.io/github/forks/vinta/awesome-python?style=for-the-badge) | +| Watchers | ![Watchers](https://img.shields.io/github/watchers/vinta/awesome-python?style=for-the-badge) | +| Contributors | ![Contributors](https://img.shields.io/github/contributors/vinta/awesome-python?style=for-the-badge) | + +Top referrers: GitHub, Google Search, YouTube, Reddit, ChatGPT — developers actively searching for and evaluating Python tools. + +## Sponsorship Tiers + +### Logo Sponsor — $500/month (2 slots) + +Your logo and a one-line description at the top of the README, seen by every visitor. + +### Link Sponsor — $150/month (5 slots) + +A text link with your product name at the top of the README, right below logo sponsors. + +## Past Sponsors + +- [Warp](https://www.warp.dev/) - https://github.com/vinta/awesome-python/pull/2766 + +## Get Started + +Email [vinta.chen@gmail.com](mailto:vinta.chen@gmail.com?subject=awesome-python%20Sponsorship) with your company name and preferred tier. Most sponsors are set up within 24 hours. From bd73b1faf099628acdd6b7b436df9ea205220935 Mon Sep 17 00:00:00 2001 From: Jinyang Date: Wed, 18 Mar 2026 07:57:37 +0400 Subject: [PATCH 007/429] 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 340e28e763..9f34e2250d 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 008/429] 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 0f6ced6634..0000000000 --- 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 1230d796a6..0000000000 --- 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 67a0373a4b..0000000000 --- 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 89d64c3034..0000000000 --- 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 009/429] 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 0000000000..28e254eacd --- /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 096c327d69..083917b2f8 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 eda7a8ff3a..5d6d758184 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 0000000000..d564cde9fe --- /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 0000000000..1f7b17c7c4 --- /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 0000000000..b8340eb5d1 --- /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 0000000000..1476651fc4 --- /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 0000000000..4f9b50a397 --- /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 0000000000..16e1831170 --- /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 0000000000..c31b687535 --- /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 0000000000..4a3bc249cb --- /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 0000000000..ea2c482149 --- /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 0000000000..e551f954b3 --- /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 0000000000..2465899ff3 --- /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 010/429] 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 9f34e2250d..d31fdfee4a 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 011/429] 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 28e254eacd..66e7021e66 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 012/429] 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 5d6d758184..8cb7005ece 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 013/429] 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 1476651fc4..c71287fd40 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 014/429] 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 66e7021e66..e9fda8f6bc 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 015/429] 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 0000000000..a98e0e0c69 --- /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 0000000000..974143e51f --- /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 016/429] 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 d564cde9fe..3f03420a40 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 1f7b17c7c4..51bd682258 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 a98e0e0c69..62afd94c71 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 974143e51f..3f32e84482 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 017/429] 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 62afd94c71..71a36742b0 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 3f32e84482..f0f53e92f0 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 " diff --git a/website/templates/index.html b/website/templates/index.html index 88a5337a9d..3231c1a4fd 100644 --- a/website/templates/index.html +++ b/website/templates/index.html @@ -51,21 +51,21 @@

    Awesome Python

    {{ "{:,}".format(entries | length) }}
    -
    unique projects indexed
    +
    projects
    {{ total_categories }}
    -
    categories to search
    +
    categories
    {{ groups | length }}
    -
    editorial groupings
    +
    topic groups
    - Scroll into the index + Jump to the list @@ -73,11 +73,11 @@

    Awesome Python

    -

    One searchable surface for the ecosystem

    +

    Search every project in one place

    - Use / to focus search, tap a tag to filter, and open a row for - descriptions, related projects, and source details. + Press / to search. Tap a tag to filter. Click any row for + details.

    @@ -127,7 +127,7 @@

    Results

    Project Name GitHub Stars Last Commit - Category + Tags - {% endfor %} {% if entry.source_type == 'Built-in' %} + {% endfor %} + + {% if entry.source_type == 'Built-in' %} {% endif %} - From 5a8c565a882eda8f956df09ad35a3f54ddef374b Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 15:24:24 +0800 Subject: [PATCH 148/429] style(css): add background color to final-cta section Co-Authored-By: Claude --- website/static/style.css | 1 + 1 file changed, 1 insertion(+) diff --git a/website/static/style.css b/website/static/style.css index 6f80e1a047..1db22f55f1 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -835,6 +835,7 @@ th[data-sort].sort-asc::after { .final-cta { padding-block: clamp(3rem, 7vw, 5.5rem); + background: oklch(94% 0.025 72); display: grid; gap: 1rem; } From 0308fd1b3ca71ac1ed35fc593a9c68ba662e369e Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 15:26:36 +0800 Subject: [PATCH 149/429] feat: show category label on mobile in project name column On narrow screens the category column is hidden. This adds a .mobile-cat span inside the name cell that renders the first category below the project name, giving mobile users the context they were missing. Co-Authored-By: Claude --- website/static/style.css | 16 ++++++++++++++++ website/templates/index.html | 1 + 2 files changed, 17 insertions(+) diff --git a/website/static/style.css b/website/static/style.css index 1db22f55f1..16a16c1dae 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -611,6 +611,10 @@ kbd { white-space: nowrap; } +.mobile-cat { + display: none; +} + .col-name > a { color: var(--ink); font-size: clamp(1rem, 1.5vw, 1.08rem); @@ -1066,6 +1070,18 @@ th[data-sort].sort-asc::after { display: none; } + .col-name { + white-space: normal; + } + + .mobile-cat { + display: block; + margin-top: 0.25rem; + font-size: var(--text-tag); + font-weight: 600; + color: var(--ink-muted); + } + .col-stars { width: 5.4rem; } diff --git a/website/templates/index.html b/website/templates/index.html index 8181079d1d..889601351f 100644 --- a/website/templates/index.html +++ b/website/templates/index.html @@ -151,6 +151,7 @@

    Results

    {{ entry.name }} + {{ entry.categories[0] }} {% if entry.stars is not none %}{{ "{:,}".format(entry.stars) }}{% From 97f18d295f1832fdb265b53e848650319e48c718 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 15:28:25 +0800 Subject: [PATCH 150/429] feat: add clear action to no-results message When a search or filter yields no results, the message now includes an inline button that resets both the search input and the active filter. Improves discoverability and reduces dead-end frustration. Co-Authored-By: Claude --- website/static/main.js | 10 ++++++++++ website/static/style.css | 20 ++++++++++++++++++++ website/templates/index.html | 5 ++++- 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/website/static/main.js b/website/static/main.js index 97b56e2f29..223100eac1 100644 --- a/website/static/main.js +++ b/website/static/main.js @@ -270,6 +270,16 @@ if (filterClear) { }); } +// No-results clear +var noResultsClear = document.querySelector('.no-results-clear'); +if (noResultsClear) { + noResultsClear.addEventListener('click', function () { + if (searchInput) searchInput.value = ''; + activeFilter = null; + applyFilters(); + }); +} + // Column sorting document.querySelectorAll('th[data-sort]').forEach(function (th) { th.addEventListener('click', function () { diff --git a/website/static/style.css b/website/static/style.css index 16a16c1dae..da52515192 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -837,6 +837,26 @@ th[data-sort].sort-asc::after { font-size: var(--text-lg); } +.no-results-hint { + margin-top: 0.5rem; + font-size: var(--text-sm); +} + +.no-results-clear { + background: none; + border: none; + color: var(--accent-deep); + font: inherit; + cursor: pointer; + text-decoration: underline; + text-decoration-color: oklch(58% 0.16 45 / 0.4); + text-underline-offset: 0.2em; +} + +.no-results-clear:hover { + color: var(--accent); +} + .final-cta { padding-block: clamp(3rem, 7vw, 5.5rem); background: oklch(94% 0.025 72); diff --git a/website/templates/index.html b/website/templates/index.html index 889601351f..f3183f308e 100644 --- a/website/templates/index.html +++ b/website/templates/index.html @@ -237,7 +237,10 @@

    Results

    - +
    From d3070b735e0d507c43f0cd79fd3d58b2492aa887 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 15:30:04 +0800 Subject: [PATCH 151/429] feat: add build date to footer Displays the UTC date the site was last built in the footer so visitors can see how fresh the data is. Co-Authored-By: Claude --- website/build.py | 2 ++ website/static/style.css | 11 +++++++++++ website/templates/base.html | 5 ++++- 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/website/build.py b/website/build.py index 5ab9c9efa1..af10ebb2fb 100644 --- a/website/build.py +++ b/website/build.py @@ -4,6 +4,7 @@ import json import re import shutil +from datetime import datetime, timezone from pathlib import Path from typing import TypedDict @@ -191,6 +192,7 @@ def build(repo_root: str) -> None: entries=entries, total_entries=total_entries, total_categories=len(categories), + build_date=datetime.now(timezone.utc).strftime("%B %d, %Y"), ), encoding="utf-8", ) diff --git a/website/static/style.css b/website/static/style.css index da52515192..5133dab72c 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -908,12 +908,23 @@ th[data-sort].sort-asc::after { text-underline-offset: 0.2em; } +.footer-left { + display: flex; + flex-direction: column; + gap: 0.3rem; +} + .footer-brand { font-weight: 700; letter-spacing: 0.03em; color: oklch(82% 0.02 75); } +.footer-date { + font-size: 0.7rem; + color: oklch(50% 0.02 55); +} + .footer-links { display: block; text-align: right; diff --git a/website/templates/base.html b/website/templates/base.html index 9a17e0cc65..cf48cfd5c3 100644 --- a/website/templates/base.html +++ b/website/templates/base.html @@ -44,7 +44,10 @@
    {% block content %}{% endblock %}
    - Awesome Python +
    From 38412182e7da1fb2e36e651e1b2c022e92ce2081 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 15:32:35 +0800 Subject: [PATCH 154/429] style: increase footer vertical padding from 2rem to 3rem Co-Authored-By: Claude --- website/static/style.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/static/style.css b/website/static/style.css index 5133dab72c..78a2c73d12 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -888,7 +888,7 @@ th[data-sort].sort-asc::after { .footer { margin-top: auto; background: oklch(16% 0.025 35); - padding: 2rem var(--shell-pad); + padding: 3rem var(--shell-pad); display: flex; align-items: center; justify-content: space-between; From 53d280ddcf4e8e646f1f1f421c5ee396ddede351 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 15:37:59 +0800 Subject: [PATCH 155/429] fix(css): scope final-cta grid to inner section-shell wrapper Move display:grid and gap from .final-cta to .final-cta > .section-shell so the grid context is applied to the correct container element, not the outer section. Wrap final-cta content in index.html with a section-shell div accordingly. Also fix .no-results bottom padding that was missing. Co-Authored-By: Claude --- website/static/style.css | 5 ++++- website/templates/index.html | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/website/static/style.css b/website/static/style.css index 78a2c73d12..98fa665be1 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -831,7 +831,7 @@ th[data-sort].sort-asc::after { } .no-results { - padding: 2.4rem var(--shell-pad) 0; + padding: 2.4rem var(--shell-pad); text-align: center; color: var(--ink-muted); font-size: var(--text-lg); @@ -860,6 +860,9 @@ th[data-sort].sort-asc::after { .final-cta { padding-block: clamp(3rem, 7vw, 5.5rem); background: oklch(94% 0.025 72); +} + +.final-cta > .section-shell { display: grid; gap: 1rem; } diff --git a/website/templates/index.html b/website/templates/index.html index d365de9547..6abd906aa2 100644 --- a/website/templates/index.html +++ b/website/templates/index.html @@ -243,7 +243,8 @@

    Results

    -
    +
    +

    Know a project that belongs here?

    Tell us what it does and why it stands out.

    @@ -263,5 +264,6 @@

    Know a project that belongs here?

    >Star the repository
    +
    {% endblock %} From b12d80f67e7a43707ff77227c1a0f7f56f689158 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 15:42:35 +0800 Subject: [PATCH 156/429] fix(readme): correct playwright entry name to playwright-python The project name in the entry was 'playwright' but the GitHub repository is 'playwright-python', which is also how the Python package is referred to. Updated the display name to match. Co-Authored-By: Claude --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ed5b19c3f1..d49b1a7147 100644 --- a/README.md +++ b/README.md @@ -599,7 +599,7 @@ _Libraries for testing codebases and generating test data._ - [tox](https://github.com/tox-dev/tox) - Auto builds and tests distributions in multiple Python versions - GUI / Web Testing - [locust](https://github.com/locustio/locust) - Scalable user load testing tool written in Python. - - [playwright](https://github.com/microsoft/playwright-python) - Python version of the Playwright testing and automation library. + - [playwright-python](https://github.com/microsoft/playwright-python) - Python version of the Playwright testing and automation library. - [pyautogui](https://github.com/asweigart/pyautogui) - PyAutoGUI is a cross-platform GUI automation Python module for human beings. - [schemathesis](https://github.com/schemathesis/schemathesis) - A tool for automatic property-based testing of web applications built with Open API / Swagger specifications. - [selenium](https://github.com/SeleniumHQ/selenium) - Python bindings for [Selenium](https://selenium.dev/) [WebDriver](https://selenium.dev/documentation/webdriver/). From 5fc022d59589b6dd0fc9e37d833af6fb8538aa25 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 15:45:18 +0800 Subject: [PATCH 157/429] refactor(build): remove resources from build pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resources are no longer passed through parse_readme, group_categories, or the index template — they are replaced with empty lists and the unused variable is prefixed with an underscore. Co-Authored-By: Claude --- website/build.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/website/build.py b/website/build.py index af10ebb2fb..29cbfc092a 100644 --- a/website/build.py +++ b/website/build.py @@ -152,11 +152,11 @@ def build(repo_root: str) -> None: subtitle = stripped break - parsed_groups, resources = parse_readme(readme_text) + parsed_groups, _resources = parse_readme(readme_text) categories = [cat for g in parsed_groups for cat in g["categories"]] total_entries = sum(c["entry_count"] for c in categories) - groups = group_categories(parsed_groups, resources) + groups = group_categories(parsed_groups, []) entries = extract_entries(categories, groups) stars_data = load_stars(website / "data" / "github_stars.json") @@ -186,7 +186,7 @@ def build(repo_root: str) -> None: (site_dir / "index.html").write_text( tpl_index.render( categories=categories, - resources=resources, + resources=[], groups=groups, subtitle=subtitle, entries=entries, @@ -204,7 +204,7 @@ def build(repo_root: str) -> None: (site_dir / "llms.txt").write_text(readme_text, encoding="utf-8") - print(f"Built single page with {len(parsed_groups)} groups, {len(categories)} categories + {len(resources)} resources") + print(f"Built single page with {len(parsed_groups)} groups, {len(categories)} categories") print(f"Total entries: {total_entries}") print(f"Output: {site_dir}") From df2191fc053b3d746365208563cd3cbe9ade21b5 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 15:58:42 +0800 Subject: [PATCH 158/429] refactor(build): remove unused group_categories wrapper group_categories only ever appended a Resources group when the resources list was non-empty. All call sites passed an empty list, making it a no-op indirection. Inline parsed_groups directly and remove the dead code along with its tests. Co-Authored-By: Claude --- website/build.py | 29 ++++------------------------- website/tests/test_build.py | 35 ----------------------------------- 2 files changed, 4 insertions(+), 60 deletions(-) diff --git a/website/build.py b/website/build.py index 29cbfc092a..2a6116a88d 100644 --- a/website/build.py +++ b/website/build.py @@ -9,26 +9,7 @@ from typing import TypedDict from jinja2 import Environment, FileSystemLoader -from readme_parser import parse_readme, slugify - - -def group_categories( - parsed_groups: list[dict], - resources: list[dict], -) -> list[dict]: - """Combine parsed groups with resources for template rendering.""" - groups = list(parsed_groups) - - if resources: - groups.append( - { - "name": "Resources", - "slug": slugify("Resources"), - "categories": list(resources), - } - ) - - return groups +from readme_parser import parse_readme class StarData(TypedDict): @@ -152,12 +133,11 @@ def build(repo_root: str) -> None: subtitle = stripped break - parsed_groups, _resources = parse_readme(readme_text) + parsed_groups, _ = parse_readme(readme_text) categories = [cat for g in parsed_groups for cat in g["categories"]] total_entries = sum(c["entry_count"] for c in categories) - groups = group_categories(parsed_groups, []) - entries = extract_entries(categories, groups) + entries = extract_entries(categories, parsed_groups) stars_data = load_stars(website / "data" / "github_stars.json") for entry in entries: @@ -186,8 +166,7 @@ def build(repo_root: str) -> None: (site_dir / "index.html").write_text( tpl_index.render( categories=categories, - resources=[], - groups=groups, + groups=parsed_groups, subtitle=subtitle, entries=entries, total_entries=total_entries, diff --git a/website/tests/test_build.py b/website/tests/test_build.py index 6302c3d3e3..0e7eb48762 100644 --- a/website/tests/test_build.py +++ b/website/tests/test_build.py @@ -8,7 +8,6 @@ from build import ( build, extract_github_repo, - group_categories, load_stars, sort_entries, ) @@ -42,40 +41,6 @@ def test_extra_spaces(self): assert slugify(" Date and Time ") == "date-and-time" -# --------------------------------------------------------------------------- -# group_categories -# --------------------------------------------------------------------------- - - -class TestGroupCategories: - def test_appends_resources(self): - parsed_groups = [ - {"name": "G1", "slug": "g1", "categories": [{"name": "Cat1"}]}, - ] - resources = [{"name": "Newsletters", "slug": "newsletters"}] - groups = group_categories(parsed_groups, resources) - group_names = [g["name"] for g in groups] - assert "G1" in group_names - assert "Resources" in group_names - - def test_no_resources_no_extra_group(self): - parsed_groups = [ - {"name": "G1", "slug": "g1", "categories": [{"name": "Cat1"}]}, - ] - groups = group_categories(parsed_groups, []) - assert len(groups) == 1 - assert groups[0]["name"] == "G1" - - def test_preserves_group_order(self): - parsed_groups = [ - {"name": "Second", "slug": "second", "categories": [{"name": "C2"}]}, - {"name": "First", "slug": "first", "categories": [{"name": "C1"}]}, - ] - groups = group_categories(parsed_groups, []) - assert groups[0]["name"] == "Second" - assert groups[1]["name"] == "First" - - # --------------------------------------------------------------------------- # build (integration) # --------------------------------------------------------------------------- From cd3c8ad0768575e03ae39f2652c97a2d26402ee6 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 16:03:47 +0800 Subject: [PATCH 159/429] refactor(css): consolidate --text-label and --text-tag into --text-xs Both variables mapped to near-identical small-text sizes already covered by --text-xs. Remove the redundant variables and migrate all call sites, including two remaining hardcoded rem values, to the shared token. Co-Authored-By: Claude --- website/static/style.css | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/website/static/style.css b/website/static/style.css index 98fa665be1..fbccd2d200 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -35,8 +35,6 @@ --text-sm: 0.95rem; --text-base: 1rem; --text-lg: 1.125rem; - --text-label: 0.78rem; - --text-tag: 0.76rem; } html { @@ -183,7 +181,7 @@ kbd { } .hero-brand-mini { - font-size: var(--text-label); + font-size: var(--text-xs); font-weight: 800; letter-spacing: 0.04em; color: var(--hero-muted); @@ -250,7 +248,7 @@ kbd { .hero-kicker, .section-label { margin-bottom: 0.9rem; - font-size: var(--text-label); + font-size: var(--text-xs); font-weight: 800; letter-spacing: 0.04em; } @@ -678,7 +676,7 @@ th[data-sort].sort-asc::after { border-radius: 999px; background: var(--bg-paper-strong); color: var(--ink-soft); - font-size: 0.72rem; + font-size: var(--text-xs); font-weight: 700; letter-spacing: 0.02em; } @@ -774,7 +772,7 @@ th[data-sort].sort-asc::after { background: var(--accent-soft); color: var(--accent-deep); padding: 0.34rem 0.68rem; - font-size: var(--text-tag); + font-size: var(--text-xs); font-weight: 700; letter-spacing: 0.02em; cursor: pointer; @@ -810,7 +808,7 @@ th[data-sort].sort-asc::after { border: 0; background: none; color: var(--accent-deep); - font-size: 0.72rem; + font-size: var(--text-xs); font-weight: 800; letter-spacing: 0.03em; cursor: pointer; @@ -924,7 +922,7 @@ th[data-sort].sort-asc::after { } .footer-date { - font-size: 0.7rem; + font-size: var(--text-xs); color: oklch(50% 0.02 55); } @@ -1111,7 +1109,7 @@ th[data-sort].sort-asc::after { .mobile-cat { display: block; margin-top: 0.25rem; - font-size: var(--text-tag); + font-size: var(--text-xs); font-weight: 600; color: var(--ink-muted); } From 4bb9c1240b64a15b5a96769f57553491809d4cc6 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 16:10:14 +0800 Subject: [PATCH 160/429] fix(website): accessibility and defensive layout improvements - Add aria-sort attributes to table header on sort state changes - Replace .table-wrap:focus outline:none with focus-visible outline - Move noscript message above the fold into main, before content - Upgrade hero-topbar div to nav with aria-label for landmark semantics - Remove role=button from tr elements (invalid ARIA on native elements) - Fix back-to-top button label and text (was labelled 'Back to search') - Switch font-size from 16px to 100% to respect user browser preferences - Add overflow-wrap and word-break to .col-name and description cells Co-Authored-By: Claude --- website/static/main.js | 3 +++ website/static/style.css | 11 ++++++++--- website/templates/base.html | 15 +++++++++------ website/templates/index.html | 13 ++++++------- 4 files changed, 26 insertions(+), 16 deletions(-) diff --git a/website/static/main.js b/website/static/main.js index c1d8d01901..83d5be048c 100644 --- a/website/static/main.js +++ b/website/static/main.js @@ -212,6 +212,9 @@ function updateSortIndicators() { th.classList.remove('sort-asc', 'sort-desc'); if (activeSort && th.dataset.sort === activeSort.col) { th.classList.add('sort-' + activeSort.order); + th.setAttribute('aria-sort', activeSort.order === 'asc' ? 'ascending' : 'descending'); + } else { + th.removeAttribute('aria-sort'); } }); } diff --git a/website/static/style.css b/website/static/style.css index fbccd2d200..26349eb3d3 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -38,7 +38,7 @@ } html { - font-size: 16px; + font-size: 100%; scroll-behavior: smooth; } @@ -523,8 +523,9 @@ kbd { scroll-margin-top: 1rem; } -.table-wrap:focus { - outline: none; +.table-wrap:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; } .table { @@ -617,6 +618,8 @@ kbd { color: var(--ink); font-size: clamp(1rem, 1.5vw, 1.08rem); font-weight: 700; + overflow-wrap: break-word; + word-break: break-word; } .col-name > a:hover { @@ -734,6 +737,8 @@ th[data-sort].sort-asc::after { color: var(--ink-soft); line-height: 1.7; text-wrap: pretty; + overflow-wrap: break-word; + word-break: break-word; animation: expand-in 220ms cubic-bezier(0.22, 1, 0.36, 1); } diff --git a/website/templates/base.html b/website/templates/base.html index cf48cfd5c3..317594d01b 100644 --- a/website/templates/base.html +++ b/website/templates/base.html @@ -41,7 +41,15 @@ -
    {% block content %}{% endblock %}
    + {% block header %}{% endblock %} +
    + + {% block content %}{% endblock %} +
    - diff --git a/website/templates/index.html b/website/templates/index.html index 6abd906aa2..7168ba88bc 100644 --- a/website/templates/index.html +++ b/website/templates/index.html @@ -1,10 +1,10 @@ -{% extends "base.html" %} {% block content %} +{% extends "base.html" %} {% block header %}
    -
    +
    +
    @@ -68,7 +68,7 @@

    Awesome Python

    Jump to the list
    - +{% endblock %} {% block content %}
    @@ -129,8 +129,8 @@

    Results

    Last Commit Tags - @@ -139,7 +139,6 @@

    Results

    {% for entry in entries %} Date: Sun, 22 Mar 2026 16:12:10 +0800 Subject: [PATCH 161/429] perf(fonts): trim Google Fonts to weights in use Narrowed the Cormorant Garamond request to 600 only and Manrope to 400/600/700/800, removing the unused 500 and 700 variants. Added font-weight: 600 to .final-cta h2 so the heading explicitly uses the retained weight. Co-Authored-By: Claude --- website/static/style.css | 1 + website/templates/base.html | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/website/static/style.css b/website/static/style.css index 26349eb3d3..efc2afb25a 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -400,6 +400,7 @@ kbd { .final-cta h2 { font-family: var(--font-display); font-size: clamp(2.2rem, 4vw, 3.3rem); + font-weight: 600; line-height: 0.94; letter-spacing: -0.03em; } diff --git a/website/templates/base.html b/website/templates/base.html index 317594d01b..5bca928da7 100644 --- a/website/templates/base.html +++ b/website/templates/base.html @@ -21,7 +21,7 @@ From 80a5596b1169d946ba76290df5e8afa63a66d077 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 16:13:08 +0800 Subject: [PATCH 162/429] perf(hero): pause animations when hero scrolls out of view Uses IntersectionObserver to toggle an .offscreen class on the hero element, which sets animation-play-state: paused on the sheen, noise, and scroll-cue animations. Avoids unnecessary GPU work while the hero is not visible. Co-Authored-By: Claude --- website/static/main.js | 10 ++++++++++ website/static/style.css | 6 ++++++ 2 files changed, 16 insertions(+) diff --git a/website/static/main.js b/website/static/main.js index 83d5be048c..da58fe2278 100644 --- a/website/static/main.js +++ b/website/static/main.js @@ -41,6 +41,16 @@ function initRevealSections() { initRevealSections(); +// Pause hero animations when scrolled out of view +(function () { + var hero = document.querySelector('.hero'); + if (!hero || !('IntersectionObserver' in window)) return; + var observer = new IntersectionObserver(function (entries) { + hero.classList.toggle('offscreen', !entries[0].isIntersecting); + }); + observer.observe(hero); +})(); + // Relative time formatting function relativeTime(isoStr) { var date = new Date(isoStr); diff --git a/website/static/style.css b/website/static/style.css index efc2afb25a..17cd5fc65e 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -152,6 +152,12 @@ kbd { animation: sheen-drift 18s linear infinite; } +.hero.offscreen .hero-sheen, +.hero.offscreen .hero-noise, +.hero.offscreen .hero-scrollcue::after { + animation-play-state: paused; +} + .hero-noise { opacity: 0.1; background-image: From 50e27b992fd05d4994f2661b956437d6c60ca424 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 16:14:11 +0800 Subject: [PATCH 163/429] perf(css): add CSS containment to results section and detail panel Apply contain: layout style to .results-section and contain: layout style paint to the detail panel element to reduce browser layout recalculation scope during search interactions. Co-Authored-By: Claude --- website/static/style.css | 2 ++ 1 file changed, 2 insertions(+) diff --git a/website/static/style.css b/website/static/style.css index 17cd5fc65e..0e477515b6 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -413,6 +413,7 @@ kbd { .results-section { padding-block: clamp(2.5rem, 6vw, 4.5rem) 0; + contain: layout style; } .results-intro { @@ -746,6 +747,7 @@ th[data-sort].sort-asc::after { text-wrap: pretty; overflow-wrap: break-word; word-break: break-word; + contain: layout style paint; animation: expand-in 220ms cubic-bezier(0.22, 1, 0.36, 1); } From 302ae14c2d8d58749c6d3c91091757f6840c0003 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 16:14:56 +0800 Subject: [PATCH 164/429] refactor(css): remove backdrop-filter blur from table header Drops the blur(14px) backdrop-filter on the sticky table header and raises the background opacity from 0.92 to 0.97 so the header remains clearly readable without the compositing overhead. Co-Authored-By: Claude --- website/static/style.css | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/website/static/style.css b/website/static/style.css index 0e477515b6..078d334405 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -571,8 +571,7 @@ kbd { letter-spacing: 0.03em; white-space: nowrap; border-bottom: 1px solid var(--line); - background: oklch(98.2% 0.012 80 / 0.92); - backdrop-filter: blur(14px); + background: oklch(98.2% 0.012 80 / 0.97); } .table tbody td { From 80a50511958318272d5728dfdbb0ae6c1cbad4a6 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 16:16:36 +0800 Subject: [PATCH 165/429] fix(css): increase expand meta/also-see font size to --text-sm --text-xs was too small for secondary metadata rows; bump to --text-sm for better readability. Co-Authored-By: Claude --- website/static/style.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/static/style.css b/website/static/style.css index 078d334405..d9f5e463f6 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -769,7 +769,7 @@ th[data-sort].sort-asc::after { .expand-also-see, .expand-meta { margin-top: 0.45rem; - font-size: var(--text-xs); + font-size: var(--text-sm); color: var(--ink-muted); } From 6648961d7beff7038d90a1802474c764f5445886 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 16:19:38 +0800 Subject: [PATCH 166/429] fix(css): hide col-num and expand-row first-child at col-cat breakpoint Co-Authored-By: Claude --- website/static/style.css | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/website/static/style.css b/website/static/style.css index d9f5e463f6..ba5d011ed4 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -1111,7 +1111,9 @@ th[data-sort].sort-asc::after { padding-right: 0.8rem; } - .col-cat { + .col-num, + .col-cat, + .expand-row td:first-child { display: none; } From 86aa6232609626ac98906ce0308768e4fd2e8197 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 16:20:14 +0800 Subject: [PATCH 167/429] fix(css): increase tag padding on mobile breakpoint Co-Authored-By: Claude --- website/static/style.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/website/static/style.css b/website/static/style.css index ba5d011ed4..afe6bd1d22 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -1043,6 +1043,10 @@ th[data-sort].sort-asc::after { .tag-group { display: none; } + + .tag { + padding: 0.5rem 0.85rem; + } } @media (max-width: 680px) { From f2b635da19b90f0ca51650b891d62f2179b5ff81 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 16:20:52 +0800 Subject: [PATCH 168/429] fix(css): truncate long links in expand-meta and add mobile padding to expand-row .expand-meta links can overflow their container on narrow viewports. Apply ellipsis truncation to keep the row tidy. .expand-row td[colspan] gains symmetric inline padding on mobile to match the surrounding table spacing. Co-Authored-By: Claude --- website/static/style.css | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/website/static/style.css b/website/static/style.css index afe6bd1d22..206cc03693 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -773,6 +773,15 @@ th[data-sort].sort-asc::after { color: var(--ink-muted); } +.expand-meta a { + display: inline-block; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + vertical-align: bottom; +} + .expand-sep { margin-inline: 0.25rem; color: var(--line-strong); @@ -1133,6 +1142,11 @@ th[data-sort].sort-asc::after { color: var(--ink-muted); } + .expand-row td[colspan] { + padding-left: 0.8rem; + padding-right: 0.8rem; + } + .col-stars { width: 5.4rem; } From 58c0fd9e452a16e83536889bc1483f73a7a1955d Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 16:23:41 +0800 Subject: [PATCH 169/429] fix(css): extend focus-visible outline to no-results-clear and footer links Co-Authored-By: Claude --- website/static/style.css | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/website/static/style.css b/website/static/style.css index 206cc03693..a1d8b38d2f 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -352,7 +352,9 @@ kbd { .search:focus-visible, .filter-clear:focus-visible, .tag:focus-visible, -.back-to-top:focus-visible { +.back-to-top:focus-visible, +.no-results-clear:focus-visible, +.footer a:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; } From 895da326f65b8f1415cbf4c22e0136479176852b Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 16:24:54 +0800 Subject: [PATCH 170/429] refactor(css): remove unused --bg-hover and --hero-shadow tokens Neither variable was referenced anywhere in the stylesheet. Removing dead tokens keeps the design token surface minimal. Co-Authored-By: Claude --- website/static/style.css | 2 -- 1 file changed, 2 deletions(-) diff --git a/website/static/style.css b/website/static/style.css index a1d8b38d2f..275e181c77 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -15,7 +15,6 @@ --bg-page: oklch(96.8% 0.018 80); --bg-paper: oklch(98.6% 0.01 80); --bg-paper-strong: oklch(95.7% 0.016 76); - --bg-hover: oklch(93.8% 0.026 72); --ink: oklch(22% 0.02 55); --ink-soft: oklch(38% 0.018 55); --ink-muted: oklch(52% 0.02 55); @@ -26,7 +25,6 @@ --accent-soft: oklch(92% 0.045 55); --highlight: oklch(87% 0.08 78); --hero-ink: oklch(15% 0.02 40); - --hero-shadow: oklch(8% 0.02 35 / 0.5); --hero-text: oklch(97% 0.012 85); --hero-muted: oklch(88% 0.02 82); --hero-line: oklch(100% 0 0 / 0.16); From 7fa0a425dc59aa3683ee37d30e852a652efa095e Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 16:25:04 +0800 Subject: [PATCH 171/429] fix(css): remove outline:none suppression from .row:focus-visible td MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Suppressing the outline on a focus-visible rule is self-defeating — it opts into the intent-based focus ring selector but then hides it. The row already receives a visible inset box-shadow on focus, so the outline:none was redundant and harmful to keyboard accessibility. Co-Authored-By: Claude --- website/static/style.css | 1 - 1 file changed, 1 deletion(-) diff --git a/website/static/style.css b/website/static/style.css index 275e181c77..c28e3a7b48 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -597,7 +597,6 @@ kbd { } .row:focus-visible td { - outline: none; background: oklch(95.7% 0.026 68); box-shadow: inset 3px 0 0 var(--accent); } From 3954a3e7423b8685e09feaf8151b0eb3478e9f71 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 16:26:27 +0800 Subject: [PATCH 172/429] refactor(css): extract footer color values into CSS custom properties Hardcoded oklch() values in footer rules are replaced with named tokens (--footer-bg, --footer-text, --footer-link, --footer-link-hover, --footer-muted, --footer-sep) declared in :root. No visual change. Co-Authored-By: Claude --- website/static/style.css | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/website/static/style.css b/website/static/style.css index c28e3a7b48..7ecd572448 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -33,6 +33,13 @@ --text-sm: 0.95rem; --text-base: 1rem; --text-lg: 1.125rem; + + --footer-bg: oklch(16% 0.025 35); + --footer-text: oklch(72% 0.02 75); + --footer-link: oklch(82% 0.02 75); + --footer-link-hover: oklch(95% 0.01 80); + --footer-muted: oklch(50% 0.02 55); + --footer-sep: oklch(55% 0.02 55); } html { @@ -909,22 +916,22 @@ th[data-sort].sort-asc::after { .footer { margin-top: auto; - background: oklch(16% 0.025 35); + background: var(--footer-bg); padding: 3rem var(--shell-pad); display: flex; align-items: center; justify-content: space-between; gap: 1rem; font-size: var(--text-xs); - color: oklch(72% 0.02 75); + color: var(--footer-text); } .footer a { - color: oklch(82% 0.02 75); + color: var(--footer-link); } .footer a:hover { - color: oklch(95% 0.01 80); + color: var(--footer-link-hover); text-decoration: underline; text-decoration-color: oklch(95% 0.01 80 / 0.4); text-underline-offset: 0.2em; @@ -939,12 +946,12 @@ th[data-sort].sort-asc::after { .footer-brand { font-weight: 700; letter-spacing: 0.03em; - color: oklch(82% 0.02 75); + color: var(--footer-link); } .footer-date { font-size: var(--text-xs); - color: oklch(50% 0.02 55); + color: var(--footer-muted); } .footer-links { @@ -953,7 +960,7 @@ th[data-sort].sort-asc::after { } .footer-sep { - color: oklch(55% 0.02 55); + color: var(--footer-sep); } .noscript-msg { From 944787071535624b41c7d657ca3cbca1f11c1d1f Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 16:26:33 +0800 Subject: [PATCH 173/429] feat(html): add theme-color meta tag for browser UI chrome Sets the mobile browser toolbar color to match the dark footer/page background (#1c1410), preventing a jarring white chrome flash on load. Co-Authored-By: Claude --- website/templates/base.html | 1 + 1 file changed, 1 insertion(+) diff --git a/website/templates/base.html b/website/templates/base.html index 5bca928da7..5e7cea95dc 100644 --- a/website/templates/base.html +++ b/website/templates/base.html @@ -17,6 +17,7 @@ /> + From 321df7b78c07bdde862ebb24cd0dde2d31c7d333 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 16:28:44 +0800 Subject: [PATCH 174/429] refactor(hero): remove metrics block from hero section The projects/categories/topic groups stats added visual clutter to the hero without contributing to the core purpose of the section. Co-Authored-By: Claude --- website/static/style.css | 40 ------------------------------------ website/templates/index.html | 15 -------------- 2 files changed, 55 deletions(-) diff --git a/website/static/style.css b/website/static/style.css index 7ecd572448..c77aa9e7e1 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -306,11 +306,6 @@ kbd { margin-top: 1.75rem; } -.hero-actions, -.hero-metrics { - width: 100%; -} - .hero-action { display: inline-flex; align-items: center; @@ -364,33 +359,6 @@ kbd { outline-offset: 3px; } -.hero-metrics { - margin-top: clamp(1.8rem, 4vw, 2.8rem); - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 1.5rem; - max-width: none; -} - -.hero-metrics div { - padding-top: 0.9rem; - border-top: 1px solid var(--hero-line); -} - -.hero-metrics dt { - font-size: clamp(1.6rem, 3.2vw, 2.4rem); - font-weight: 800; - line-height: 1; - color: var(--hero-text); -} - -.hero-metrics dd { - margin-top: 0.3rem; - color: var(--hero-muted); - font-size: var(--text-xs); - letter-spacing: 0.02em; -} - .hero-scrollcue { align-self: flex-start; display: inline-flex; @@ -1094,14 +1062,6 @@ th[data-sort].sort-asc::after { font-size: clamp(3.6rem, 18vw, 5.2rem); } - .hero-metrics { - gap: 1rem; - } - - .hero-metrics div { - min-width: 0; - } - .search { min-height: 3.5rem; border-radius: 1.25rem; diff --git a/website/templates/index.html b/website/templates/index.html index 7168ba88bc..226c789d7f 100644 --- a/website/templates/index.html +++ b/website/templates/index.html @@ -47,21 +47,6 @@

    Awesome Python

    >View on GitHub
    - -
    -
    -
    {{ "{:,}".format(entries | length) }}
    -
    projects
    -
    -
    -
    {{ total_categories }}
    -
    categories
    -
    -
    -
    {{ groups | length }}
    -
    topic groups
    -
    -
    From 014ba9e39407d1104d14ffe29a645fef8defe02d Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 16:29:47 +0800 Subject: [PATCH 175/429] refactor(hero): remove redundant scroll cue The "Jump to the list" anchor duplicated the "Browse the List" button. Removes the element, its CSS rules, the scroll-line keyframe animation, and cleans up the offscreen pause and focus-visible selector lists. Co-Authored-By: Claude Opus 4.6 (1M context) --- website/static/style.css | 36 ++---------------------------------- website/templates/index.html | 2 -- 2 files changed, 2 insertions(+), 36 deletions(-) diff --git a/website/static/style.css b/website/static/style.css index c77aa9e7e1..73d147c413 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -158,8 +158,7 @@ kbd { } .hero.offscreen .hero-sheen, -.hero.offscreen .hero-noise, -.hero.offscreen .hero-scrollcue::after { +.hero.offscreen .hero-noise { animation-play-state: paused; } @@ -198,8 +197,7 @@ kbd { color: var(--hero-muted); } -.hero-topbar-link:hover, -.hero-scrollcue:hover { +.hero-topbar-link:hover { color: var(--hero-text); } @@ -348,7 +346,6 @@ kbd { .hero-action:focus-visible, .hero-topbar-link:focus-visible, -.hero-scrollcue:focus-visible, .search:focus-visible, .filter-clear:focus-visible, .tag:focus-visible, @@ -359,24 +356,6 @@ kbd { outline-offset: 3px; } -.hero-scrollcue { - align-self: flex-start; - display: inline-flex; - align-items: center; - gap: 0.65rem; - color: var(--hero-muted); - font-size: var(--text-xs); - letter-spacing: 0.04em; -} - -.hero-scrollcue::after { - content: ""; - width: 3rem; - height: 1px; - background: var(--hero-line); - animation: scroll-line 1.6s ease-in-out infinite; -} - .results-intro h2, .final-cta h2 { font-family: var(--font-display); @@ -984,17 +963,6 @@ th[data-sort].sort-asc::after { } } -@keyframes scroll-line { - 0%, 100% { - transform: scaleX(0.4); - transform-origin: left; - } - 50% { - transform: scaleX(1); - transform-origin: left; - } -} - @keyframes sheen-drift { from { transform: translateX(-30%); diff --git a/website/templates/index.html b/website/templates/index.html index 226c789d7f..cb8c276fe2 100644 --- a/website/templates/index.html +++ b/website/templates/index.html @@ -49,8 +49,6 @@

    Awesome Python

    - - Jump to the list {% endblock %} {% block content %} From a12fef4e54eb9f2c01a60f71e690eadd9fde9814 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 16:30:14 +0800 Subject: [PATCH 176/429] refactor(results): remove redundant "Library index" section label The heading "Search every project in one place" already communicates the section's purpose. The label above it was visual noise. Co-Authored-By: Claude Opus 4.6 (1M context) --- website/templates/index.html | 1 - 1 file changed, 1 deletion(-) diff --git a/website/templates/index.html b/website/templates/index.html index cb8c276fe2..602df0bd73 100644 --- a/website/templates/index.html +++ b/website/templates/index.html @@ -55,7 +55,6 @@

    Awesome Python

    -

    Search every project in one place

    From f11468b2629f3e398b1e04aab2477dcdb0773cc1 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 16:36:55 +0800 Subject: [PATCH 177/429] remove(readme): remove python-cqrs from Design Patterns Co-Authored-By: Claude --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index d49b1a7147..061b899e81 100644 --- a/README.md +++ b/README.md @@ -543,7 +543,6 @@ _Python implementation of data structures, algorithms and design patterns. Also - [sortedcontainers](https://github.com/grantjenks/python-sortedcontainers) - Fast and pure-Python implementation of sorted collections. - [thealgorithms](https://github.com/TheAlgorithms/Python) - All Algorithms implemented in Python. - Design Patterns - - [python-cqrs](https://github.com/pypatterns/python-cqrs) - Event-Driven Architecture Framework with CQRS/CQS, Transaction Outbox, Saga orchestration. - [python-patterns](https://github.com/faif/python-patterns) - A collection of design patterns in Python. - [transitions](https://github.com/pytransitions/transitions) - A lightweight, object-oriented finite state machine implementation. From ef51d9a7aad0e19a7fee39aa1a07c5d047f55e4d Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 16:38:48 +0800 Subject: [PATCH 178/429] refactor(html-xml): replace cssutils with tinycss2 cssutils is unmaintained; tinycss2 is the actively maintained low-level CSS parser and generator for Python. Co-Authored-By: Claude --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 061b899e81..c4e24b5004 100644 --- a/README.md +++ b/README.md @@ -832,7 +832,7 @@ _Libraries for parsing and manipulating plain texts._ _Libraries for working with HTML and XML._ - [beautifulsoup](https://www.crummy.com/software/BeautifulSoup/bs4/doc/) - Providing Pythonic idioms for iterating, searching, and modifying HTML or XML. -- [cssutils](https://github.com/jaraco/cssutils) - A CSS library for Python. +- [tinycss2](https://github.com/Kozea/tinycss2) - A low-level CSS parser and generator written in Python. - [justhtml](https://github.com/EmilStenstrom/justhtml/) - A pure Python HTML5 parser that just works. - [lxml](https://github.com/lxml/lxml) - A very fast, easy-to-use and versatile library for handling HTML and XML. - [markupsafe](https://github.com/pallets/markupsafe) - Implements a XML/HTML/XHTML Markup safe string for Python. From 8fbe0e03947d8be53ee857aea669864de302312b Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 16:41:36 +0800 Subject: [PATCH 179/429] fix(readme): sort tinycss2 alphabetically in HTML/XML section Co-Authored-By: Claude --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c4e24b5004..231f65de14 100644 --- a/README.md +++ b/README.md @@ -832,11 +832,11 @@ _Libraries for parsing and manipulating plain texts._ _Libraries for working with HTML and XML._ - [beautifulsoup](https://www.crummy.com/software/BeautifulSoup/bs4/doc/) - Providing Pythonic idioms for iterating, searching, and modifying HTML or XML. -- [tinycss2](https://github.com/Kozea/tinycss2) - A low-level CSS parser and generator written in Python. - [justhtml](https://github.com/EmilStenstrom/justhtml/) - A pure Python HTML5 parser that just works. - [lxml](https://github.com/lxml/lxml) - A very fast, easy-to-use and versatile library for handling HTML and XML. - [markupsafe](https://github.com/pallets/markupsafe) - Implements a XML/HTML/XHTML Markup safe string for Python. - [pyquery](https://github.com/gawel/pyquery) - A jQuery-like library for parsing HTML. +- [tinycss2](https://github.com/Kozea/tinycss2) - A low-level CSS parser and generator written in Python. - [xmltodict](https://github.com/martinblech/xmltodict) - Working with XML feel like you are working with JSON. ## File Format Processing From 1b5a0c2a3c151748e741095fcd1ba885f4110333 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 16:41:42 +0800 Subject: [PATCH 180/429] docs(claude): expand project structure and entry format docs Documents the website/ build pipeline, Makefile targets, pyproject.toml tooling, and tightens the entry format example and key rules. Co-Authored-By: Claude --- CLAUDE.md | 41 +++++++++++++++++++++++------------------ 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 81d13417c6..15b939a8dc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,31 +2,36 @@ ## Repository Overview -This is the awesome-python repository - a curated list of Python frameworks, libraries, software and resources. The repository serves as a comprehensive directory about Python ecosystem. +A curated list of awesome Python frameworks, libraries, software and resources. Published at [awesome-python.com](https://awesome-python.com/). ## PR Review Guidelines -**For all PR review tasks, refer to [CONTRIBUTING.md](CONTRIBUTING.md)** which contains: +**Refer to [CONTRIBUTING.md](CONTRIBUTING.md)** for acceptance criteria, quality requirements, rejection rules, and entry format. -- Acceptance criteria (Industry Standard, Rising Star, Hidden Gem) -- Quality requirements -- Automatic rejection criteria -- Entry format reference -- PR description template +## Structure -## Architecture & Structure +- **README.md**: Source of truth. Hierarchical categories with alphabetically ordered entries. +- **CONTRIBUTING.md**: Submission guidelines and review criteria. +- **website/**: Static site generator that builds awesome-python.com from README.md. + - `build.py`: Parses README.md and renders HTML via Jinja2 templates. + - `fetch_github_stars.py`: Fetches star counts into `website/data/`. + - `readme_parser.py`: Markdown-to-structured-data parser. + - `templates/`, `static/`: Jinja2 templates and CSS/JS assets. + - `tests/`: Pytest tests for the build pipeline. +- **Makefile**: `make install`, `make build`, `make preview`, `make test`, `make fetch_github_stars`. +- **pyproject.toml**: Uses `uv` for dependency management. Python >=3.13. -The repository follows a single-file architecture: +## Entry Format -- **README.md**: All content in hierarchical structure (categories, subcategories, entries) -- **CONTRIBUTING.md**: Submission guidelines and review criteria -- **sort.py**: Script to enforce alphabetical ordering +```markdown +- [pypi-name](https://github.com/owner/repo) - Description ending with period. +``` -Entry format: `* [project-name](url) - Concise description ending with period.` +Use PyPI package name as display name. Use GitHub URLs when available. -## Key Considerations +## Key Rules -- This is a curated list, not a code project -- Quality over quantity - only "awesome" projects -- Alphabetical ordering within categories is mandatory -- README.md is the source of truth for all content +- Alphabetical ordering within categories is mandatory. +- Quality over quantity. Only "awesome" projects. +- One project per PR. +- README.md is the single source of content truth. From 7d1007d3739cd6268451f309ace51b6f045313c8 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 16:44:18 +0800 Subject: [PATCH 181/429] docs(claude): clarify repo description and entry naming rule Reword the overview from 'curated list' to 'opinionated list' to better reflect editorial intent. Rename 'pypi-name' placeholder to 'project-name' and add a rule for projects not published on PyPI. Co-Authored-By: Claude --- CLAUDE.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 15b939a8dc..7210cc4182 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ ## Repository Overview -A curated list of awesome Python frameworks, libraries, software and resources. Published at [awesome-python.com](https://awesome-python.com/). +An opinionated list of Python frameworks, libraries, tools, and resources. Published at [awesome-python.com](https://awesome-python.com/). ## PR Review Guidelines @@ -24,10 +24,10 @@ A curated list of awesome Python frameworks, libraries, software and resources. ## Entry Format ```markdown -- [pypi-name](https://github.com/owner/repo) - Description ending with period. +- [project-name](https://github.com/owner/repo) - Description ending with period. ``` -Use PyPI package name as display name. Use GitHub URLs when available. +Use PyPI package name as display name. If not on PyPI, use the GitHub repo name. Use GitHub URLs when available. ## Key Rules From a92b1a6e867fd4088b521086c2cff1851c8cae6f Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 23:42:18 +0800 Subject: [PATCH 182/429] fix(css): fix hero topbar layout on small screens Split hero-topbar and footer selectors to apply distinct responsive styles. The topbar now uses a horizontal row layout with nowrap so the nav link does not stack vertically, while hero-topbar-actions and hero-topbar-link get auto widths to avoid stretching full-width. Co-Authored-By: Claude --- website/static/style.css | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/website/static/style.css b/website/static/style.css index 73d147c413..7e0f8473e2 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -1005,19 +1005,34 @@ th[data-sort].sort-asc::after { min-height: auto; } - .hero-topbar, + .hero-topbar { + align-items: center; + flex-direction: row; + flex-wrap: nowrap; + justify-content: space-between; + gap: 0.75rem; + } + .footer { align-items: flex-start; flex-direction: column; } - .hero-topbar-actions, .hero-actions, .final-cta-actions { width: 100%; } - .hero-topbar-link, + .hero-topbar-actions { + width: auto; + flex: 0 0 auto; + } + + .hero-topbar-link { + width: auto; + white-space: nowrap; + } + .hero-action { width: 100%; } From dbff2522c879813a202d87ba31cd08cdff88bcc9 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 23:52:31 +0800 Subject: [PATCH 183/429] fix(css): hide last column in expand-row on mobile Co-Authored-By: Claude --- website/static/style.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/website/static/style.css b/website/static/style.css index 7e0f8473e2..38eeab297f 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -1075,7 +1075,8 @@ th[data-sort].sort-asc::after { .col-num, .col-cat, - .expand-row td:first-child { + .expand-row td:first-child, + .expand-row td:last-child { display: none; } From 88031d78a5997b896e321feac08d5766b816a052 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 22 Mar 2026 23:58:54 +0800 Subject: [PATCH 184/429] fix(css): center footer on mobile Consolidate two conflicting mobile footer rules into one, setting flex-direction to column, align-items to center, and text-align to center so the footer is symmetrically centered on narrow viewports. Co-Authored-By: Claude --- website/static/style.css | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/website/static/style.css b/website/static/style.css index 38eeab297f..724c133ae8 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -1014,8 +1014,9 @@ th[data-sort].sort-asc::after { } .footer { - align-items: flex-start; flex-direction: column; + align-items: center; + text-align: center; } .hero-actions, @@ -1037,10 +1038,6 @@ th[data-sort].sort-asc::after { width: 100%; } - .footer { - align-items: flex-end; - } - .hero h1 { font-size: clamp(3.6rem, 18vw, 5.2rem); } From 3395b2e4284609c0c0ded72839fe3dd3f30add13 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Mon, 23 Mar 2026 00:09:06 +0800 Subject: [PATCH 185/429] fix(css): enable table horizontal scroll at 768px breakpoint Moves .table-wrap overflow-x rule from the 680px breakpoint to 768px so the table becomes scrollable before it gets too narrow to read. Co-Authored-By: Claude --- website/static/style.css | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/website/static/style.css b/website/static/style.css index 724c133ae8..5c3d796f81 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -998,6 +998,10 @@ th[data-sort].sort-asc::after { .tag { padding: 0.5rem 0.85rem; } + + .table-wrap { + overflow-x: auto; + } } @media (max-width: 680px) { @@ -1047,10 +1051,6 @@ th[data-sort].sort-asc::after { border-radius: 1.25rem; } - .table-wrap { - overflow-x: auto; - } - .table thead th { position: static; } From 25d3f307cc3ddbe61f5660a194e94ba5a801d509 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Mon, 23 Mar 2026 00:25:35 +0800 Subject: [PATCH 186/429] docs(readme): reword sponsorship tagline Replace 'where Python developers discover tools' with 'in front of Python developers' for cleaner, more direct phrasing. Co-Authored-By: Claude --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 231f65de14..e396e42d1f 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ An opinionated list of Python frameworks, libraries, tools, and resources. -> The **#10 most-starred repo on GitHub**. Put your product where Python developers discover tools. [Become a sponsor](SPONSORSHIP.md). +> The **#10 most-starred repo on GitHub**. Put your product in front of Python developers. [Become a sponsor](SPONSORSHIP.md). # Categories From 394803d2be09af2cd89897e9d012cfac83d4f66b Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Mon, 23 Mar 2026 00:58:31 +0800 Subject: [PATCH 187/429] docs(readme): add agent skills entries Add Python-focused descriptions for Trail of Bits, Sentry Skills, and Django AI Plugins in the AI and Agents section. --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index e396e42d1f..b9e622a65b 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,10 @@ An opinionated list of Python frameworks, libraries, tools, and resources. _Libraries for building AI applications, LLM integrations, and autonomous agents._ +- Agent Skills + - [django-ai-plugins](https://github.com/vintasoftware/django-ai-plugins) - Django backend agent skills for Django, DRF, Celery, and Django-specific code review. + - [sentry-skills](https://github.com/getsentry/skills) - Python-focused engineering skills for code review, debugging, and backend workflows. + - [trailofbits](https://github.com/trailofbits/skills) - Python-friendly security skills for auditing, testing, and safer backend development. - Frameworks - [autogen](https://github.com/microsoft/autogen) - A programming framework for building agentic AI applications. - [crewai](https://github.com/crewAIInc/crewAI) - A framework for orchestrating role-playing autonomous AI agents for collaborative task solving. From 1c249d4b5fe59bd563120bbdc41606ca3426d2a2 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Mon, 23 Mar 2026 01:04:13 +0800 Subject: [PATCH 188/429] docs(readme): rename Testing Frameworks subcategory to Frameworks Shorter label that reads more naturally in the context of the Testing section, which already scopes it to testing. Co-Authored-By: Claude --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b9e622a65b..356099a31b 100644 --- a/README.md +++ b/README.md @@ -591,7 +591,7 @@ _Tools of static analysis, linters and code quality checkers. Also see [awesome- _Libraries for testing codebases and generating test data._ -- Testing Frameworks +- Frameworks - [hypothesis](https://github.com/HypothesisWorks/hypothesis) - Hypothesis is an advanced Quickcheck style property based testing library. - [pytest](https://github.com/pytest-dev/pytest) - A mature full-featured Python testing tool. - [robotframework](https://github.com/robotframework/robotframework) - A generic test automation framework. From f2b4a7bc83ef4a61a9e8bbd574dd302ac4926356 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Mon, 23 Mar 2026 01:04:20 +0800 Subject: [PATCH 189/429] feat(website): surface subcategory labels as filterable tags Entries nested under a plain-text subcategory heading (e.g. "Frameworks" inside Testing) now carry a subcategory field populated by the parser. The build pipeline collects these into a subcategories list on each merged entry, and the template renders them as tag-subcat buttons that plug into the existing data-cats filter mechanism. A dedicated .tag-subcat style distinguishes them visually from category tags, and both are hidden on mobile alongside .tag-group. Co-Authored-By: Claude --- website/build.py | 4 ++++ website/readme_parser.py | 11 +++++++++-- website/static/style.css | 9 ++++++++- website/templates/index.html | 10 +++++++++- 4 files changed, 30 insertions(+), 4 deletions(-) diff --git a/website/build.py b/website/build.py index 2a6116a88d..821c681423 100644 --- a/website/build.py +++ b/website/build.py @@ -102,6 +102,9 @@ def extract_entries( existing["categories"].append(cat["name"]) if group_name not in existing["groups"]: existing["groups"].append(group_name) + subcat = entry["subcategory"] + if subcat and subcat not in existing["subcategories"]: + existing["subcategories"].append(subcat) else: merged = { "name": entry["name"], @@ -109,6 +112,7 @@ def extract_entries( "description": entry["description"], "categories": [cat["name"]], "groups": [group_name], + "subcategories": [entry["subcategory"]] if entry["subcategory"] else [], "stars": None, "owner": None, "last_commit_at": None, diff --git a/website/readme_parser.py b/website/readme_parser.py index c0ecfc60c5..1068a3393f 100644 --- a/website/readme_parser.py +++ b/website/readme_parser.py @@ -20,6 +20,7 @@ class ParsedEntry(TypedDict): url: str description: str # inline HTML, properly escaped also_see: list[AlsoSee] + subcategory: str # sub-category label, empty if none class ParsedSection(TypedDict): @@ -178,7 +179,11 @@ def _extract_description_html(inline: SyntaxTreeNode, first_link: SyntaxTreeNode return _DESC_SEP_RE.sub("", html) -def _parse_list_entries(bullet_list: SyntaxTreeNode) -> list[ParsedEntry]: +def _parse_list_entries( + bullet_list: SyntaxTreeNode, + *, + subcategory: str = "", +) -> list[ParsedEntry]: """Extract entries from a bullet_list AST node. Handles three patterns: @@ -200,9 +205,10 @@ def _parse_list_entries(bullet_list: SyntaxTreeNode) -> list[ParsedEntry]: if first_link is None or not _is_leading_link(inline, first_link): # Subcategory label (plain text or text-before-link) — recurse into nested list + label = render_inline_text(inline.children) nested = _find_child(list_item, "bullet_list") if nested: - entries.extend(_parse_list_entries(nested)) + entries.extend(_parse_list_entries(nested, subcategory=label)) continue # Entry with a link @@ -231,6 +237,7 @@ def _parse_list_entries(bullet_list: SyntaxTreeNode) -> list[ParsedEntry]: url=url, description=desc_html, also_see=also_see, + subcategory=subcategory, )) return entries diff --git a/website/static/style.css b/website/static/style.css index 5c3d796f81..97d6b86485 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -779,6 +779,12 @@ th[data-sort].sort-asc::after { color: var(--hero-ink); } +.tag-subcat { + background: var(--bg-paper-strong); + color: var(--ink-soft); + font-weight: 600; +} + .back-to-top { border: 0; background: none; @@ -991,7 +997,8 @@ th[data-sort].sort-asc::after { display: none; } - .tag-group { + .tag-group, + .tag-subcat { display: none; } diff --git a/website/templates/index.html b/website/templates/index.html index 602df0bd73..0689d260f0 100644 --- a/website/templates/index.html +++ b/website/templates/index.html @@ -121,7 +121,7 @@

    Results

    {% for entry in entries %} Results + {% endfor %} {% for subcat in entry.subcategories %} + {% endfor %} - {% endfor %} {% for subcat in entry.subcategories %} + {% for subcat in entry.subcategories %} + {% endfor %} {% for cat in entry.categories %} + {% endfor %} {% endfor %} {% for cat in entry.categories %} From 31fa9a4c3844f2fc0f816939d99c91ee40ca6ae6 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Mon, 23 Mar 2026 01:32:55 +0800 Subject: [PATCH 198/429] fix(css): reduce tag badge size and spacing --- website/static/style.css | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/website/static/style.css b/website/static/style.css index 5c3d796f81..daf018f6d2 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -746,8 +746,8 @@ th[data-sort].sort-asc::after { border-radius: 999px; background: var(--accent-soft); color: var(--accent-deep); - padding: 0.34rem 0.68rem; - font-size: var(--text-xs); + padding: 0.14rem 0.48rem; + font-size: 0.6rem; font-weight: 700; letter-spacing: 0.02em; cursor: pointer; @@ -759,13 +759,13 @@ th[data-sort].sort-asc::after { } .tag + .tag { - margin-left: 0.4rem; + margin-left: 0.2rem; } .tag::after { content: ""; position: absolute; - inset: -0.45rem -0.2rem; + inset: -0.35rem -0.15rem; } .tag:hover { @@ -996,7 +996,7 @@ th[data-sort].sort-asc::after { } .tag { - padding: 0.5rem 0.85rem; + padding: 0.32rem 0.6rem; } .table-wrap { From 028c642a8e87ab3421caa7be1d0165d002729034 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Mon, 23 Mar 2026 01:34:33 +0800 Subject: [PATCH 199/429] refactor(js): replace var with let/const and use double quotes Modernize variable declarations and string literals in main.js for consistency and to signal immutability intent. Pure style change with no behavioral differences. Co-Authored-By: Claude --- website/static/main.js | 331 ++++++++++++++++++++++------------------- 1 file changed, 181 insertions(+), 150 deletions(-) diff --git a/website/static/main.js b/website/static/main.js index da58fe2278..f1150b0c62 100644 --- a/website/static/main.js +++ b/website/static/main.js @@ -1,40 +1,43 @@ // State -var activeFilter = null; // { type: "cat"|"group", value: "..." } -var activeSort = { col: 'stars', order: 'desc' }; -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 rows = document.querySelectorAll('.table tbody tr.row'); -var tags = document.querySelectorAll('.tag'); -var tbody = document.querySelector('.table tbody'); +let activeFilter = null; // { type: "cat"|"group", value: "..." } +let activeSort = { col: "stars", order: "desc" }; +const searchInput = document.querySelector(".search"); +const filterBar = document.querySelector(".filter-bar"); +const filterValue = document.querySelector(".filter-value"); +const filterClear = document.querySelector(".filter-clear"); +const noResults = document.querySelector(".no-results"); +const rows = document.querySelectorAll(".table tbody tr.row"); +const tags = document.querySelectorAll(".tag"); +const tbody = document.querySelector(".table tbody"); function initRevealSections() { - var sections = document.querySelectorAll('[data-reveal]'); + const sections = document.querySelectorAll("[data-reveal]"); if (!sections.length) return; - if (!('IntersectionObserver' in window)) { + if (!("IntersectionObserver" in window)) { sections.forEach(function (section) { - section.classList.add('is-visible'); + section.classList.add("is-visible"); }); return; } - var observer = new IntersectionObserver(function (entries) { - entries.forEach(function (entry) { - if (!entry.isIntersecting) return; - entry.target.classList.add('is-visible'); - observer.unobserve(entry.target); - }); - }, { - threshold: 0.12, - rootMargin: '0px 0px -8% 0px', - }); + const observer = new IntersectionObserver( + function (entries) { + entries.forEach(function (entry) { + if (!entry.isIntersecting) return; + entry.target.classList.add("is-visible"); + observer.unobserve(entry.target); + }); + }, + { + threshold: 0.12, + rootMargin: "0px 0px -8% 0px", + }, + ); sections.forEach(function (section, index) { - section.classList.add('will-reveal'); - section.style.transitionDelay = Math.min(index * 70, 180) + 'ms'; + section.classList.add("will-reveal"); + section.style.transitionDelay = Math.min(index * 70, 180) + "ms"; observer.observe(section); }); } @@ -43,34 +46,36 @@ initRevealSections(); // Pause hero animations when scrolled out of view (function () { - var hero = document.querySelector('.hero'); - if (!hero || !('IntersectionObserver' in window)) return; - var observer = new IntersectionObserver(function (entries) { - hero.classList.toggle('offscreen', !entries[0].isIntersecting); + const hero = document.querySelector(".hero"); + if (!hero || !("IntersectionObserver" in window)) return; + const observer = new IntersectionObserver(function (entries) { + hero.classList.toggle("offscreen", !entries[0].isIntersecting); }); observer.observe(hero); })(); // Relative time formatting function relativeTime(isoStr) { - var date = new Date(isoStr); - var now = new Date(); - var diffMs = now - date; - var diffHours = Math.floor(diffMs / 3600000); - var diffDays = Math.floor(diffMs / 86400000); - if (diffHours < 1) return 'just now'; - if (diffHours < 24) return diffHours === 1 ? '1 hour ago' : diffHours + ' hours ago'; - if (diffDays === 1) return 'yesterday'; - if (diffDays < 30) return diffDays + ' days ago'; - var diffMonths = Math.floor(diffDays / 30); - if (diffMonths < 12) return diffMonths === 1 ? '1 month ago' : diffMonths + ' months ago'; - var diffYears = Math.floor(diffDays / 365); - return diffYears === 1 ? '1 year ago' : diffYears + ' years ago'; + const date = new Date(isoStr); + const now = new Date(); + const diffMs = now - date; + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + if (diffHours < 1) return "just now"; + if (diffHours < 24) + return diffHours === 1 ? "1 hour ago" : diffHours + " hours ago"; + if (diffDays === 1) return "yesterday"; + if (diffDays < 30) return diffDays + " days ago"; + const diffMonths = Math.floor(diffDays / 30); + if (diffMonths < 12) + return diffMonths === 1 ? "1 month ago" : diffMonths + " months ago"; + const diffYears = Math.floor(diffDays / 365); + return diffYears === 1 ? "1 year ago" : diffYears + " years ago"; } // Format all commit date cells -document.querySelectorAll('.col-commit[data-commit]').forEach(function (td) { - var time = td.querySelector('time'); +document.querySelectorAll(".col-commit[data-commit]").forEach(function (td) { + const time = td.querySelector("time"); if (time) time.textContent = relativeTime(td.dataset.commit); }); @@ -81,36 +86,37 @@ rows.forEach(function (row, i) { }); function collapseAll() { - var openRows = document.querySelectorAll('.table tbody tr.row.open'); + const openRows = document.querySelectorAll(".table tbody tr.row.open"); openRows.forEach(function (row) { - row.classList.remove('open'); - row.setAttribute('aria-expanded', 'false'); + row.classList.remove("open"); + row.setAttribute("aria-expanded", "false"); }); } function applyFilters() { - var query = searchInput ? searchInput.value.toLowerCase().trim() : ''; - var visibleCount = 0; + const query = searchInput ? searchInput.value.toLowerCase().trim() : ""; + let visibleCount = 0; // Collapse all expanded rows on filter/search change collapseAll(); rows.forEach(function (row) { - var show = true; + let show = true; // Category/group filter if (activeFilter) { - var attr = activeFilter.type === 'cat' ? row.dataset.cats : row.dataset.groups; - show = attr ? attr.split('||').indexOf(activeFilter.value) !== -1 : false; + const attr = + activeFilter.type === "cat" ? row.dataset.cats : row.dataset.groups; + show = attr ? attr.split("||").indexOf(activeFilter.value) !== -1 : false; } // 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(); + let text = row.textContent.toLowerCase(); + const next = row.nextElementSibling; + if (next && next.classList.contains("expand-row")) { + text += " " + next.textContent.toLowerCase(); } row._searchText = text; } @@ -121,7 +127,7 @@ function applyFilters() { if (show) { visibleCount++; - var numCell = row.cells[0]; + const numCell = row.cells[0]; if (numCell.textContent !== String(visibleCount)) { numCell.textContent = String(visibleCount); } @@ -132,19 +138,20 @@ function applyFilters() { // 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); + const isActive = + activeFilter && + tag.dataset.type === activeFilter.type && + tag.dataset.value === activeFilter.value; + tag.classList.toggle("active", isActive); }); // Filter bar if (filterBar) { if (activeFilter) { - filterBar.classList.add('visible'); + filterBar.classList.add("visible"); if (filterValue) filterValue.textContent = activeFilter.value; } else { - filterBar.classList.remove('visible'); + filterBar.classList.remove("visible"); } } @@ -152,40 +159,46 @@ function applyFilters() { } function updateURL() { - var params = new URLSearchParams(); - var query = searchInput ? searchInput.value.trim() : ''; - if (query) params.set('q', query); + const params = new URLSearchParams(); + const query = searchInput ? searchInput.value.trim() : ""; + if (query) params.set("q", query); if (activeFilter) { - params.set(activeFilter.type === 'cat' ? 'category' : 'group', activeFilter.value); + params.set( + activeFilter.type === "cat" ? "category" : "group", + activeFilter.value, + ); } - if (activeSort.col !== 'stars' || activeSort.order !== 'desc') { - params.set('sort', activeSort.col); - params.set('order', activeSort.order); + if (activeSort.col !== "stars" || activeSort.order !== "desc") { + params.set("sort", activeSort.col); + params.set("order", activeSort.order); } - var qs = params.toString(); - history.replaceState(null, '', qs ? '?' + qs : location.pathname); + const qs = params.toString(); + history.replaceState(null, "", qs ? "?" + qs : location.pathname); } function getSortValue(row, col) { - if (col === 'name') { - return row.querySelector('.col-name a').textContent.trim().toLowerCase(); + if (col === "name") { + return row.querySelector(".col-name a").textContent.trim().toLowerCase(); } - if (col === 'stars') { - var text = row.querySelector('.col-stars').textContent.trim().replace(/,/g, ''); - var num = parseInt(text, 10); + if (col === "stars") { + const text = row + .querySelector(".col-stars") + .textContent.trim() + .replace(/,/g, ""); + const num = parseInt(text, 10); return isNaN(num) ? -1 : num; } - if (col === 'commit-time') { - var attr = row.querySelector('.col-commit').getAttribute('data-commit'); + if (col === "commit-time") { + const attr = row.querySelector(".col-commit").getAttribute("data-commit"); return attr ? new Date(attr).getTime() : 0; } return 0; } function sortRows() { - var arr = Array.prototype.slice.call(rows); - var col = activeSort.col; - var order = activeSort.order; + const arr = Array.prototype.slice.call(rows); + const col = activeSort.col; + const order = activeSort.order; // Cache sort values once to avoid DOM queries per comparison arr.forEach(function (row) { @@ -193,22 +206,22 @@ function sortRows() { }); arr.sort(function (a, b) { - var aVal = a._sortVal; - var bVal = b._sortVal; - if (col === 'name') { - var cmp = aVal < bVal ? -1 : aVal > bVal ? 1 : 0; + const aVal = a._sortVal; + const bVal = b._sortVal; + if (col === "name") { + const cmp = aVal < bVal ? -1 : aVal > bVal ? 1 : 0; if (cmp === 0) return a._origIndex - b._origIndex; - return order === 'desc' ? -cmp : cmp; + return order === "desc" ? -cmp : cmp; } if (aVal <= 0 && bVal <= 0) return a._origIndex - b._origIndex; if (aVal <= 0) return 1; if (bVal <= 0) return -1; - var cmp = aVal - bVal; + const cmp = aVal - bVal; if (cmp === 0) return a._origIndex - b._origIndex; - return order === 'desc' ? -cmp : cmp; + return order === "desc" ? -cmp : cmp; }); - var frag = document.createDocumentFragment(); + const frag = document.createDocumentFragment(); arr.forEach(function (row) { frag.appendChild(row); frag.appendChild(row._expandRow); @@ -218,40 +231,43 @@ function sortRows() { } function updateSortIndicators() { - document.querySelectorAll('th[data-sort]').forEach(function (th) { - th.classList.remove('sort-asc', 'sort-desc'); + document.querySelectorAll("th[data-sort]").forEach(function (th) { + th.classList.remove("sort-asc", "sort-desc"); if (activeSort && th.dataset.sort === activeSort.col) { - th.classList.add('sort-' + activeSort.order); - th.setAttribute('aria-sort', activeSort.order === 'asc' ? 'ascending' : 'descending'); + th.classList.add("sort-" + activeSort.order); + th.setAttribute( + "aria-sort", + activeSort.order === "asc" ? "ascending" : "descending", + ); } else { - th.removeAttribute('aria-sort'); + th.removeAttribute("aria-sort"); } }); } // Expand/collapse: event delegation on tbody if (tbody) { - tbody.addEventListener('click', function (e) { + 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; + if (e.target.closest("a") || e.target.closest(".tag")) return; - var row = e.target.closest('tr.row'); + const row = e.target.closest("tr.row"); if (!row) return; - var isOpen = row.classList.contains('open'); + const isOpen = row.classList.contains("open"); if (isOpen) { - row.classList.remove('open'); - row.setAttribute('aria-expanded', 'false'); + row.classList.remove("open"); + row.setAttribute("aria-expanded", "false"); } else { - row.classList.add('open'); - row.setAttribute('aria-expanded', 'true'); + 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'); + tbody.addEventListener("keydown", function (e) { + if (e.key !== "Enter" && e.key !== " ") return; + const row = e.target.closest("tr.row"); if (!row) return; e.preventDefault(); row.click(); @@ -260,13 +276,17 @@ if (tbody) { // Tag click: filter by category or group tags.forEach(function (tag) { - tag.addEventListener('click', function (e) { + tag.addEventListener("click", function (e) { e.preventDefault(); - var type = tag.dataset.type; - var value = tag.dataset.value; + const type = tag.dataset.type; + const value = tag.dataset.value; // Toggle: click same filter again to clear - if (activeFilter && activeFilter.type === type && activeFilter.value === value) { + if ( + activeFilter && + activeFilter.type === type && + activeFilter.value === value + ) { activeFilter = null; } else { activeFilter = { type: type, value: value }; @@ -277,31 +297,32 @@ tags.forEach(function (tag) { // Clear filter if (filterClear) { - filterClear.addEventListener('click', function () { + filterClear.addEventListener("click", function () { activeFilter = null; applyFilters(); }); } // No-results clear -var noResultsClear = document.querySelector('.no-results-clear'); +const noResultsClear = document.querySelector(".no-results-clear"); if (noResultsClear) { - noResultsClear.addEventListener('click', function () { - if (searchInput) searchInput.value = ''; + noResultsClear.addEventListener("click", function () { + if (searchInput) searchInput.value = ""; activeFilter = null; applyFilters(); }); } // Column sorting -document.querySelectorAll('th[data-sort]').forEach(function (th) { - th.addEventListener('click', function () { - var col = th.dataset.sort; - var defaultOrder = col === 'name' ? 'asc' : 'desc'; - var altOrder = defaultOrder === 'asc' ? 'desc' : 'asc'; +document.querySelectorAll("th[data-sort]").forEach(function (th) { + th.addEventListener("click", function () { + const col = th.dataset.sort; + const defaultOrder = col === "name" ? "asc" : "desc"; + const altOrder = defaultOrder === "asc" ? "desc" : "asc"; if (activeSort && activeSort.col === col) { - if (activeSort.order === defaultOrder) activeSort = { col: col, order: altOrder }; - else activeSort = { col: 'stars', order: 'desc' }; + if (activeSort.order === defaultOrder) + activeSort = { col: col, order: altOrder }; + else activeSort = { col: "stars", order: "desc" }; } else { activeSort = { col: col, order: defaultOrder }; } @@ -312,20 +333,27 @@ document.querySelectorAll('th[data-sort]').forEach(function (th) { // Search input if (searchInput) { - var searchTimer; - searchInput.addEventListener('input', function () { + let 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) { + 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 = ''; + if (e.key === "Escape" && document.activeElement === searchInput) { + searchInput.value = ""; activeFilter = null; applyFilters(); searchInput.blur(); @@ -334,24 +362,24 @@ if (searchInput) { } // Back to top -var backToTop = document.querySelector('.back-to-top'); -var resultsSection = document.querySelector('#library-index'); -var tableWrap = document.querySelector('.table-wrap'); -var stickyHeaderCell = backToTop ? backToTop.closest('th') : null; +const backToTop = document.querySelector(".back-to-top"); +const resultsSection = document.querySelector("#library-index"); +const tableWrap = document.querySelector(".table-wrap"); +const stickyHeaderCell = backToTop ? backToTop.closest("th") : null; function updateBackToTopVisibility() { if (!backToTop || !tableWrap || !stickyHeaderCell) return; - var tableRect = tableWrap.getBoundingClientRect(); - var headRect = stickyHeaderCell.getBoundingClientRect(); - var hasPassedHeader = tableRect.top <= 0 && headRect.bottom > 0; + const tableRect = tableWrap.getBoundingClientRect(); + const headRect = stickyHeaderCell.getBoundingClientRect(); + const hasPassedHeader = tableRect.top <= 0 && headRect.bottom > 0; - backToTop.classList.toggle('visible', hasPassedHeader); + backToTop.classList.toggle("visible", hasPassedHeader); } if (backToTop) { - var scrollTicking = false; - window.addEventListener('scroll', function () { + let scrollTicking = false; + window.addEventListener("scroll", function () { if (!scrollTicking) { requestAnimationFrame(function () { updateBackToTopVisibility(); @@ -361,12 +389,12 @@ if (backToTop) { } }); - window.addEventListener('resize', updateBackToTopVisibility); + window.addEventListener("resize", updateBackToTopVisibility); - backToTop.addEventListener('click', function () { - var target = searchInput || resultsSection; + backToTop.addEventListener("click", function () { + const target = searchInput || resultsSection; if (!target) return; - target.scrollIntoView({ behavior: 'smooth', block: 'center' }); + target.scrollIntoView({ behavior: "smooth", block: "center" }); if (searchInput) searchInput.focus(); }); @@ -375,16 +403,19 @@ if (backToTop) { // Restore state from URL (function () { - var params = new URLSearchParams(location.search); - var q = params.get('q'); - var cat = params.get('category'); - var group = params.get('group'); - var sort = params.get('sort'); - var order = params.get('order'); + const params = new URLSearchParams(location.search); + const q = params.get("q"); + const cat = params.get("category"); + const group = params.get("group"); + const sort = params.get("sort"); + const order = params.get("order"); if (q && searchInput) searchInput.value = q; - if (cat) activeFilter = { type: 'cat', value: cat }; - else if (group) activeFilter = { type: 'group', value: group }; - if ((sort === 'name' || sort === 'stars' || sort === 'commit-time') && (order === 'desc' || order === 'asc')) { + if (cat) activeFilter = { type: "cat", value: cat }; + else if (group) activeFilter = { type: "group", value: group }; + if ( + (sort === "name" || sort === "stars" || sort === "commit-time") && + (order === "desc" || order === "asc") + ) { activeSort = { col: sort, order: order }; } if (q || cat || group || sort) { From c5dd3060efddabb17770f8f4c09ce48a002416d9 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Mon, 23 Mar 2026 01:43:12 +0800 Subject: [PATCH 200/429] chore: add __pycache__ to .gitignore and remove sys.path hack in tests Co-Authored-By: Claude --- .gitignore | 1 + website/tests/test_fetch_github_stars.py | 3 --- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 8c0ca9e852..ca26a6e8fc 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ # python .venv/ +__pycache__/ *.py[co] # website diff --git a/website/tests/test_fetch_github_stars.py b/website/tests/test_fetch_github_stars.py index 6c9eb383d6..ecdccf732f 100644 --- a/website/tests/test_fetch_github_stars.py +++ b/website/tests/test_fetch_github_stars.py @@ -1,10 +1,7 @@ """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, From 25a3f4d9037a796b8106e137286f6ba88b8d3cd6 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Mon, 23 Mar 2026 01:43:19 +0800 Subject: [PATCH 201/429] refactor(parser): remove resources parsing, preview, and content_html fields parse_readme now returns list[ParsedGroup] instead of a tuple. The resources section (Newsletters, Podcasts), preview string, and content_html rendering are no longer produced by the parser or consumed by the build. Removes _render_section_html, _group_by_h2, and the associated dead code and tests. Co-Authored-By: Claude --- website/build.py | 3 +- website/readme_parser.py | 132 +++----------------------- website/tests/test_build.py | 16 +--- website/tests/test_readme_parser.py | 139 ++++------------------------ 4 files changed, 37 insertions(+), 253 deletions(-) diff --git a/website/build.py b/website/build.py index 6ff46df02c..f205747292 100644 --- a/website/build.py +++ b/website/build.py @@ -139,7 +139,7 @@ def build(repo_root: str) -> None: subtitle = stripped break - parsed_groups, _ = parse_readme(readme_text) + parsed_groups = parse_readme(readme_text) categories = [cat for g in parsed_groups for cat in g["categories"]] total_entries = sum(c["entry_count"] for c in categories) @@ -172,7 +172,6 @@ def build(repo_root: str) -> None: (site_dir / "index.html").write_text( tpl_index.render( categories=categories, - groups=parsed_groups, subtitle=subtitle, entries=entries, total_entries=total_entries, diff --git a/website/readme_parser.py b/website/readme_parser.py index 91b0faf2a7..4f36ed77e4 100644 --- a/website/readme_parser.py +++ b/website/readme_parser.py @@ -29,8 +29,6 @@ class ParsedSection(TypedDict): description: str # plain text, links resolved to text entries: list[ParsedEntry] entry_count: int - preview: str - content_html: str # rendered HTML, properly escaped class ParsedGroup(TypedDict): @@ -258,69 +256,6 @@ def _parse_section_entries(content_nodes: list[SyntaxTreeNode]) -> list[ParsedEn return entries -# --- Content HTML rendering -------------------------------------------------- - - -def _render_bullet_list_html( - bullet_list: SyntaxTreeNode, - *, - is_sub: bool = False, -) -> str: - """Render a bullet_list node to HTML with entry/entry-sub/subcat classes.""" - out: list[str] = [] - - 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 or not _is_leading_link(inline, first_link): - # Subcategory label (plain text or text-before-link) - label = str(escape(render_inline_text(inline.children))) - out.append(f'
    {label}
    ') - nested = _find_child(list_item, "bullet_list") - if nested: - out.append(_render_bullet_list_html(nested, is_sub=False)) - continue - - # Entry with a link - name = str(escape(render_inline_text(first_link.children))) - url = str(escape(first_link.attrGet("href") or "")) - - if is_sub: - out.append(f'
    {name}
    ') - else: - desc = _extract_description_html(inline, first_link) - if desc: - out.append( - f'
    {name}' - f'{desc}
    ' - ) - else: - out.append(f'
    {name}
    ') - - # Nested items under an entry with a link are sub-entries - nested = _find_child(list_item, "bullet_list") - if nested: - out.append(_render_bullet_list_html(nested, is_sub=True)) - - return "\n".join(out) - - -def _render_section_html(content_nodes: list[SyntaxTreeNode]) -> str: - """Render a section's content nodes to HTML.""" - parts: list[str] = [] - for node in content_nodes: - if node.type == "bullet_list": - parts.append(_render_bullet_list_html(node)) - return "\n".join(parts) - - # --- Section splitting ------------------------------------------------------- @@ -330,45 +265,15 @@ def _build_section(name: str, body: list[SyntaxTreeNode]) -> ParsedSection: content_nodes = body[1:] if desc else body 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) return ParsedSection( name=name, slug=slugify(name), description=desc, entries=entries, entry_count=entry_count, - preview=preview, - content_html=content_html, ) -def _group_by_h2( - nodes: list[SyntaxTreeNode], -) -> 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 - sections.append(_build_section(current_name, current_body)) - 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 _is_bold_marker(node: SyntaxTreeNode) -> str | None: """Detect a bold-only paragraph used as a group marker. @@ -445,43 +350,30 @@ def flush_group() -> None: return groups -def parse_readme(text: str) -> tuple[list[ParsedGroup], list[ParsedSection]]: - """Parse README.md text into grouped categories and resources. +def parse_readme(text: str) -> list[ParsedGroup]: + """Parse README.md text into grouped categories. - Returns (groups, resources) where groups is a list of ParsedGroup dicts - containing nested categories, and resources is a flat list of ParsedSection. + Returns a list of ParsedGroup dicts containing nested categories. + Content between the thematic break (---) and # Resources or # Contributing + is parsed as categories grouped by bold markers (**Group Name**). """ md = MarkdownIt("commonmark") tokens = md.parse(text) root = SyntaxTreeNode(tokens) children = root.children - # Find thematic break (---), # Resources, and # Contributing in one pass + # Find thematic break (---) and section boundaries in one pass hr_idx = None - resources_idx = None - contributing_idx = None + cat_end_idx = None for i, node in enumerate(children): if hr_idx is None and node.type == "hr": hr_idx = i elif 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 + if cat_end_idx is None and text_content in ("Resources", "Contributing"): + cat_end_idx = i if hr_idx is None: - return [], [] - - # 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] - - groups = _parse_grouped_sections(cat_nodes) - resources = _group_by_h2(res_nodes) + return [] - return groups, resources + cat_nodes = children[hr_idx + 1 : cat_end_idx or len(children)] + return _parse_grouped_sections(cat_nodes) diff --git a/website/tests/test_build.py b/website/tests/test_build.py index 0e7eb48762..c9d29f4551 100644 --- a/website/tests/test_build.py +++ b/website/tests/test_build.py @@ -59,19 +59,13 @@ def _make_repo(self, tmp_path, readme): ) (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 }}" - '' + "{% for entry in entries %}" + '
    ' + "{{ entry.name }}" + "{{ entry.categories | join(', ') }}" + "{{ entry.groups | join(', ') }}" "
    " "{% endfor %}" - "
    " - "{% endfor %}" "{% endblock %}", encoding="utf-8", ) diff --git a/website/tests/test_readme_parser.py b/website/tests/test_readme_parser.py index d365c45c7c..cea5cbbf82 100644 --- a/website/tests/test_readme_parser.py +++ b/website/tests/test_readme_parser.py @@ -7,7 +7,6 @@ from readme_parser import ( _parse_section_entries, - _render_section_html, parse_readme, render_inline_html, render_inline_text, @@ -159,50 +158,39 @@ def test_code_inline_kept(self): class TestParseReadmeSections: def test_ungrouped_categories_go_to_other(self): - groups, resources = parse_readme(MINIMAL_README) + groups = parse_readme(MINIMAL_README) assert len(groups) == 1 assert groups[0]["name"] == "Other" assert len(groups[0]["categories"]) == 2 def test_ungrouped_category_names(self): - groups, _ = parse_readme(MINIMAL_README) + groups = parse_readme(MINIMAL_README) cats = groups[0]["categories"] assert cats[0]["name"] == "Alpha" assert cats[1]["name"] == "Beta" - def test_resource_count(self): - _, resources = parse_readme(MINIMAL_README) - assert len(resources) == 2 - def test_category_slugs(self): - groups, _ = parse_readme(MINIMAL_README) + groups = parse_readme(MINIMAL_README) cats = groups[0]["categories"] assert cats[0]["slug"] == "alpha" assert cats[1]["slug"] == "beta" def test_category_description(self): - groups, _ = parse_readme(MINIMAL_README) + groups = parse_readme(MINIMAL_README) cats = groups[0]["categories"] assert cats[0]["description"] == "Libraries for alpha stuff." assert cats[1]["description"] == "Tools for beta." - def test_resource_names(self): - _, resources = parse_readme(MINIMAL_README) - assert resources[0]["name"] == "Newsletters" - assert resources[1]["name"] == "Podcasts" - def test_contributing_skipped(self): - groups, resources = parse_readme(MINIMAL_README) + groups = parse_readme(MINIMAL_README) all_names = [] for g in groups: all_names.extend(c["name"] for c in g["categories"]) - all_names.extend(r["name"] for r in resources) assert "Contributing" not in all_names def test_no_separator(self): - groups, resources = parse_readme("# Just a heading\n\nSome text.\n") + groups = parse_readme("# Just a heading\n\nSome text.\n") assert groups == [] - assert resources == [] def test_no_description(self): readme = textwrap.dedent("""\ @@ -224,7 +212,7 @@ def test_no_description(self): Done. """) - groups, resources = parse_readme(readme) + groups = parse_readme(readme) cats = groups[0]["categories"] assert cats[0]["description"] == "" assert cats[0]["entries"][0]["name"] == "item" @@ -245,42 +233,37 @@ def test_description_with_link_stripped(self): Done. """) - groups, _ = parse_readme(readme) + groups = parse_readme(readme) cats = groups[0]["categories"] assert cats[0]["description"] == "Algorithms. Also see awesome-algos." class TestParseGroupedReadme: def test_group_count(self): - groups, _ = parse_readme(GROUPED_README) + groups = parse_readme(GROUPED_README) assert len(groups) == 2 def test_group_names(self): - groups, _ = parse_readme(GROUPED_README) + groups = parse_readme(GROUPED_README) assert groups[0]["name"] == "Group One" assert groups[1]["name"] == "Group Two" def test_group_slugs(self): - groups, _ = parse_readme(GROUPED_README) + groups = parse_readme(GROUPED_README) assert groups[0]["slug"] == "group-one" assert groups[1]["slug"] == "group-two" def test_group_one_has_one_category(self): - groups, _ = parse_readme(GROUPED_README) + groups = parse_readme(GROUPED_README) assert len(groups[0]["categories"]) == 1 assert groups[0]["categories"][0]["name"] == "Alpha" def test_group_two_has_two_categories(self): - groups, _ = parse_readme(GROUPED_README) + groups = parse_readme(GROUPED_README) assert len(groups[1]["categories"]) == 2 assert groups[1]["categories"][0]["name"] == "Beta" assert groups[1]["categories"][1]["name"] == "Gamma" - def test_resources_still_parsed(self): - _, resources = parse_readme(GROUPED_README) - assert len(resources) == 1 - assert resources[0]["name"] == "Newsletters" - def test_empty_group_skipped(self): readme = textwrap.dedent("""\ # T @@ -299,7 +282,7 @@ def test_empty_group_skipped(self): Done. """) - groups, _ = parse_readme(readme) + groups = parse_readme(readme) assert len(groups) == 1 assert groups[0]["name"] == "HasCats" @@ -319,7 +302,7 @@ def test_bold_with_extra_text_not_group_marker(self): Done. """) - groups, _ = parse_readme(readme) + groups = parse_readme(readme) # "Note:" has text after the strong node, so it's not a group marker # Category goes into "Other" assert len(groups) == 1 @@ -345,7 +328,7 @@ def test_categories_before_any_group_marker(self): Done. """) - groups, _ = parse_readme(readme) + groups = parse_readme(readme) assert len(groups) == 2 assert groups[0]["name"] == "Other" assert groups[0]["categories"][0]["name"] == "Orphan" @@ -438,33 +421,11 @@ def test_entry_count_includes_also_see(self): Done. """) - groups, _ = parse_readme(readme) + groups = parse_readme(readme) cats = groups[0]["categories"] # 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. - """) - groups, _ = parse_readme(readme) - cats = groups[0]["categories"] - 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) @@ -472,58 +433,13 @@ def test_description_html_escapes_xss(self): assert "<script>" in entries[0]["description"] -class TestRenderSectionHtml: - def test_basic_entry(self): - nodes = _content_nodes("- [django](https://example.com) - A web framework.\n") - html = _render_section_html(nodes) - assert 'class="entry"' in html - assert 'href="https://example.com"' in html - assert "django" in html - assert "A web framework." in html - - def test_subcategory_label(self): - nodes = _content_nodes( - "- Synchronous\n - [django](https://x.com) - Framework.\n" - ) - html = _render_section_html(nodes) - assert 'class="subcat"' in html - assert "Synchronous" in html - assert 'class="entry"' in html - - def test_sub_entry(self): - nodes = _content_nodes( - "- [django](https://x.com) - Framework.\n" - " - [awesome-django](https://y.com)\n" - ) - html = _render_section_html(nodes) - assert 'class="entry-sub"' in html - assert "awesome-django" in html - - def test_link_only_entry(self): - nodes = _content_nodes("- [tool](https://x.com)\n") - html = _render_section_html(nodes) - assert 'class="entry"' in html - assert 'href="https://x.com"' in html - assert "tool" in html - - def test_xss_escaped_in_name(self): - nodes = _content_nodes('- [](https://x.com) - Bad.\n') - html = _render_section_html(nodes) - assert "onerror" not in html or "&" in html - - def test_xss_escaped_in_subcat(self): - nodes = _content_nodes("- \n") - html = _render_section_html(nodes) - assert "
    diff --git a/website/tests/test_build.py b/website/tests/test_build.py index 0ebeb5852a..9ea2b6175c 100644 --- a/website/tests/test_build.py +++ b/website/tests/test_build.py @@ -615,6 +615,46 @@ def test_subcategory_page_shows_breadcrumb(self, tmp_path): parent = (site / "categories" / "web-frameworks" / "index.html").read_text(encoding="utf-8") assert "category-breadcrumb" not in parent + def test_index_embeds_filter_urls_json(self, tmp_path): + readme = textwrap.dedent("""\ + # T + + --- + + **AI & ML** + + ## Deep Learning + + - [dl1](https://example.com/dl1) - DL. + + ## Machine Learning + + - Classical + + - [ml1](https://example.com/ml1) - ML. + + # Contributing + + Done. + """) + self._copy_real_templates(tmp_path) + (tmp_path / "README.md").write_text(readme, encoding="utf-8") + build(tmp_path) + + site = tmp_path / "website" / "output" + index_html = (site / "index.html").read_text(encoding="utf-8") + + marker = '", start) + data = json.loads(index_html[start:end]) + + assert data["Deep Learning"] == "/categories/deep-learning/" + assert data["Machine Learning"] == "/categories/machine-learning/" + assert data["AI & ML"] == "/categories/ai-ml/" + assert data["Machine Learning > Classical"] == "/categories/machine-learning/classical/" + def test_build_creates_group_pages(self, tmp_path): readme = textwrap.dedent("""\ # T From 704332271b3ea4f6744cd29adce0b31367128aef Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 3 May 2026 00:40:52 +0800 Subject: [PATCH 369/429] fix(website): escape in embedded filter URLs JSON `| safe` bypasses Jinja autoescape. If a category name ever contained "", the literal substring would close the script block early, leaking JSON content into the DOM and creating an XSS vector. Replace " --- website/build.py | 12 +++++++----- website/tests/test_build.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/website/build.py b/website/build.py index f8d79d4a47..3e94a2b28e 100644 --- a/website/build.py +++ b/website/build.py @@ -92,6 +92,10 @@ def category_public_url(category: ParsedSection) -> str: return f"{SITE_URL}categories/{category['slug']}/" +def group_path(group_slug: str) -> str: + return f"/categories/{group_slug}/" + + def group_public_url(group_slug: str) -> str: return f"{SITE_URL}categories/{group_slug}/" @@ -315,11 +319,9 @@ def build(repo_root: Path) -> None: entries = sort_entries(entries) category_urls = {cat["name"]: category_path(cat) for cat in categories} - filter_urls: dict[str, str] = {} - for cat in categories: - filter_urls[cat["name"]] = category_path(cat) + filter_urls: dict[str, str] = dict(category_urls) for group in parsed_groups: - filter_urls[group["name"]] = f"/categories/{group['slug']}/" + filter_urls[group["name"]] = group_path(group["slug"]) for entry in entries: for sub in entry.get("subcategories", []): filter_urls[sub["value"]] = sub["url"] @@ -348,7 +350,7 @@ def build(repo_root: Path) -> None: build_date=build_date.strftime("%B %d, %Y"), sponsors=sponsors, category_urls=category_urls, - filter_urls_json=json.dumps(filter_urls, sort_keys=True), + filter_urls_json=json.dumps(filter_urls, sort_keys=True, ensure_ascii=False).replace(" Classical"] == "/categories/machine-learning/classical/" + def test_filter_urls_json_escapes_closing_script_tag(self, tmp_path): + readme = textwrap.dedent("""\ + # T + + --- + + ## Sneaky + + - [a](https://example.com) - A. + + # Contributing + + Done. + """) + self._copy_real_templates(tmp_path) + (tmp_path / "README.md").write_text(readme, encoding="utf-8") + build(tmp_path) + + site = tmp_path / "website" / "output" + index_html = (site / "index.html").read_text(encoding="utf-8") + + marker = '", start) + block = index_html[start:end] + assert "" not in block + data = json.loads(block) + assert any("Sneaky" in key for key in data) + def test_build_creates_group_pages(self, tmp_path): readme = textwrap.dedent("""\ # T From 04a04a136b4143b4c7753e9edd8422433c8c2267 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 3 May 2026 00:44:43 +0800 Subject: [PATCH 370/429] feat(website): add data-url to tag buttons for client-side routing Co-Authored-By: Claude Opus 4.7 (1M context) --- website/build.py | 1 + website/templates/category.html | 10 +++++++--- website/templates/index.html | 9 +++++++-- website/tests/test_build.py | 31 +++++++++++++++++++++++++++++++ 4 files changed, 46 insertions(+), 5 deletions(-) diff --git a/website/build.py b/website/build.py index 3e94a2b28e..7c87f9864e 100644 --- a/website/build.py +++ b/website/build.py @@ -332,6 +332,7 @@ def build(repo_root: Path) -> None: trim_blocks=True, lstrip_blocks=True, ) + env.filters["slugify"] = slugify site_dir = website / "output" if site_dir.exists(): diff --git a/website/templates/category.html b/website/templates/category.html index 8983c77e5c..c17cebc578 100644 --- a/website/templates/category.html +++ b/website/templates/category.html @@ -117,15 +117,19 @@

    {{ category.name }} results

    {% for subcat in entry.subcategories %} - {% endfor %} - {% if entry.groups %} - {% endif %} diff --git a/website/templates/index.html b/website/templates/index.html index b3f9c6e23e..2f90964486 100644 --- a/website/templates/index.html +++ b/website/templates/index.html @@ -227,7 +227,7 @@

    Results

    {% for subcat in entry.subcategories %} - {% endfor %} {% for cat in entry.categories %} @@ -235,10 +235,15 @@

    Results

    class="tag" href="{{ category_urls[cat] }}" data-value="{{ cat }}" + data-url="{{ category_urls[cat] }}" >{{ cat }} {% endfor %} - {% if entry.source_type == 'Built-in' %} diff --git a/website/tests/test_build.py b/website/tests/test_build.py index 85c7ea2192..b482c1a640 100644 --- a/website/tests/test_build.py +++ b/website/tests/test_build.py @@ -724,6 +724,37 @@ def test_build_creates_group_pages(self, tmp_path): assert "wf1" in web_dev assert "dl1" not in web_dev + def test_tag_buttons_have_data_url(self, tmp_path): + readme = textwrap.dedent("""\ + # T + + --- + + **AI & ML** + + ## Deep Learning + + - Vision + + - [v1](https://example.com/v1) - Vision lib. + + # Contributing + + Done. + """) + self._copy_real_templates(tmp_path) + (tmp_path / "README.md").write_text(readme, encoding="utf-8") + build(tmp_path) + + site = tmp_path / "website" / "output" + index_html = (site / "index.html").read_text(encoding="utf-8") + + assert 'data-value="Deep Learning"' in index_html + assert 'data-url="/categories/deep-learning/"' in index_html + assert 'data-value="AI & ML"' in index_html or 'data-value="AI & ML"' in index_html + assert 'data-url="/categories/ai-ml/"' in index_html + assert 'data-url="/categories/deep-learning/vision/"' in index_html + # --------------------------------------------------------------------------- # extract_github_repo From b0136ac2669f2872aa3d83aef9e649b478cef9fa Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 3 May 2026 00:49:41 +0800 Subject: [PATCH 371/429] feat(website): switch index filter URLs from querystring to path Tag clicks on / pushState a category/group/subcategory path; on static pages they fully navigate. Search and sort stay in querystring. Built-in source tag has no data-url and stays as an in-page filter. The isIndexDocument flag is captured at load time so toggling on the index keeps working after pushState changes location.pathname. Co-Authored-By: Claude Opus 4.7 (1M context) --- website/static/main.js | 62 +++++++++++++++++++++++++++++++++++------- 1 file changed, 52 insertions(+), 10 deletions(-) diff --git a/website/static/main.js b/website/static/main.js index f875f8b19f..b8d353373c 100644 --- a/website/static/main.js +++ b/website/static/main.js @@ -167,19 +167,39 @@ function applyFilters() { updateURL(); } -function updateURL() { +const filterUrlsScript = document.getElementById("filter-urls"); +const filterToUrl = filterUrlsScript + ? JSON.parse(filterUrlsScript.textContent) + : {}; +const urlToFilter = {}; +Object.keys(filterToUrl).forEach(function (k) { + urlToFilter[filterToUrl[k]] = k; +}); + +const isIndexDocument = + location.pathname === "/" || location.pathname === "/index.html"; + +function isIndexPage() { + return isIndexDocument; +} + +function buildQueryString() { const params = new URLSearchParams(); const query = searchInput ? searchInput.value.trim() : ""; if (query) params.set("q", query); - if (activeFilter) { - params.set("filter", activeFilter); - } if (activeSort.col !== "stars" || activeSort.order !== "desc") { params.set("sort", activeSort.col); params.set("order", activeSort.order); } const qs = params.toString(); - history.replaceState(null, "", qs ? "?" + qs : location.pathname); + return qs ? "?" + qs : ""; +} + +function updateURL() { + if (!isIndexPage()) return; + const path = + activeFilter && filterToUrl[activeFilter] ? filterToUrl[activeFilter] : "/"; + history.replaceState(null, "", path + buildQueryString()); } function getSortValue(row, col) { @@ -288,8 +308,21 @@ tags.forEach(function (tag) { tag.addEventListener("click", function (e) { e.preventDefault(); const value = tag.dataset.value; - activeFilter = activeFilter === value ? null : value; - applyFilters(); + const url = tag.dataset.url; + if (isIndexPage()) { + activeFilter = activeFilter === value ? null : value; + if (activeFilter && url) { + history.pushState(null, "", url + buildQueryString()); + } else { + history.pushState(null, "", "/" + buildQueryString()); + } + applyFilters(); + } else if (url) { + window.location.href = url; + } else { + activeFilter = activeFilter === value ? null : value; + applyFilters(); + } }); }); @@ -396,19 +429,28 @@ if (backToTop) { (function () { const params = new URLSearchParams(location.search); const q = params.get("q"); - const filter = params.get("filter"); const sort = params.get("sort"); const order = params.get("order"); if (q && searchInput) searchInput.value = q; - if (filter) activeFilter = filter; if ( (sort === "name" || sort === "stars" || sort === "commit-time") && (order === "desc" || order === "asc") ) { activeSort = { col: sort, order: order }; } - if (q || filter || sort) { + if (isIndexPage()) { + const matched = urlToFilter[location.pathname]; + if (matched) activeFilter = matched; + } + if (q || activeFilter || sort) { sortRows(); } updateSortIndicators(); })(); + +window.addEventListener("popstate", function () { + if (!isIndexPage()) return; + const matched = urlToFilter[location.pathname]; + activeFilter = matched || null; + applyFilters(); +}); From 8e00055c8cf8efe118af1f05154377a0f4a6a677 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 3 May 2026 07:51:39 +0800 Subject: [PATCH 372/429] fix(website): remove duplicate tag on group/category pages The category template rendered a tag for `category.name` plus a tag for `entry.groups[0]`, which duplicated the group name on group pages where those values are identical (e.g. /categories/python-language/ showing "Python Language" twice). It also never rendered `entry.categories`, so group pages omitted each project's actual category. Mirror the index template's tag rendering on category, group, and subcategory pages, and mark whichever tag matches the current page URL as active. Pass `category_urls` and `current_path` to each render call so the template can match by URL. --- website/build.py | 6 ++++++ website/templates/category.html | 19 +++++++++++++------ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/website/build.py b/website/build.py index 7c87f9864e..4b65d2bc84 100644 --- a/website/build.py +++ b/website/build.py @@ -369,6 +369,8 @@ def build(repo_root: Path) -> None: entries=category_entries, total_categories=len(categories), page_kind="category", + category_urls=category_urls, + current_path=category_path(category), ), encoding="utf-8", ) @@ -390,6 +392,8 @@ def build(repo_root: Path) -> None: entries=group_entries, total_categories=len(categories), page_kind="group", + category_urls=category_urls, + current_path=group_path(group["slug"]), ), encoding="utf-8", ) @@ -425,6 +429,8 @@ def build(repo_root: Path) -> None: total_categories=len(categories), page_kind="subcategory", parent_category=category, + category_urls=category_urls, + current_path=subcategory_path(category["slug"], sub["slug"]), ), encoding="utf-8", ) diff --git a/website/templates/category.html b/website/templates/category.html index c17cebc578..b3af6507b4 100644 --- a/website/templates/category.html +++ b/website/templates/category.html @@ -117,18 +117,25 @@

    {{ category.name }} results

    {% for subcat in entry.subcategories %} - {% endfor %} - + {% for cat in entry.categories %} + {{ cat }} + {% endfor %} {% if entry.groups %} + {% set group_url = "/categories/" ~ (entry.groups[0] | slugify) ~ "/" %} From 8e72e8af8f74189d8e53ad690a180f1859470a5f Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 3 May 2026 07:56:54 +0800 Subject: [PATCH 373/429] feat(website): strip #library-index from URL after All projects click The "All projects" link in the category-page topbar pointed to /#library-index so the browser would scroll to the library section on arrival. The hash stayed in the URL, which looked like an internal anchor state rather than a clean homepage URL. On homepage load, if the hash is #library-index, scroll to the section explicitly and use history.replaceState to drop the hash from the URL. The scrollIntoView call covers the case where the script runs before the browser's native anchor scroll, since replaceState removes the hash the browser would have used. --- website/static/main.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/website/static/main.js b/website/static/main.js index b8d353373c..0cb0e1f612 100644 --- a/website/static/main.js +++ b/website/static/main.js @@ -59,6 +59,19 @@ document.querySelectorAll("[data-scroll-to]").forEach(function (link) { }); }); +// Land at #library-index without leaving the hash in the URL +if (window.location.hash === "#library-index") { + const target = document.getElementById("library-index"); + if (target) { + target.scrollIntoView(); + } + history.replaceState( + null, + "", + window.location.pathname + window.location.search, + ); +} + // Pause hero animations when scrolled out of view (function () { const hero = document.querySelector(".hero"); From 1139402838fb1949bf960de9dddd0db3141f0b94 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 3 May 2026 08:01:37 +0800 Subject: [PATCH 374/429] fix(website): stop category-page heading from wrapping The results-intro grid (1fr + 28rem note column) squeezed the heading on category pages with long names, e.g. "Python Projects in Environment Management" wrapped onto two lines. Scope a single-column override to .category-results so the heading takes the full row and the note drops below right-aligned. Index page layout is untouched since its heading is short. --- website/static/style.css | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/website/static/style.css b/website/static/style.css index f0a1bc8f64..27b4d34ec9 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -502,6 +502,15 @@ kbd { padding-top: clamp(2.5rem, 5vw, 3.75rem); } +.category-results .results-intro { + grid-template-columns: 1fr; + gap: 0.6rem; +} + +.category-results .results-intro .results-note { + justify-self: end; +} + .category-table .col-name { width: min(42rem, 48vw); white-space: normal; From 03db91bcd04279f899540f5f34aa3829d164412e Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 3 May 2026 08:02:39 +0800 Subject: [PATCH 375/429] style(website): left-align results note on category pages Switch justify-self from end to start so the "Sorted by GitHub stars..." note sits flush left under the heading instead of right-aligned. --- website/static/style.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/static/style.css b/website/static/style.css index 27b4d34ec9..852b01a4ea 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -508,7 +508,7 @@ kbd { } .category-results .results-intro .results-note { - justify-self: end; + justify-self: start; } .category-table .col-name { From 033694204c1c812209a5d9c5d36c871cd6c4ed84 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 3 May 2026 08:17:17 +0800 Subject: [PATCH 376/429] feat(website): move descriptions into expand row on category pages Removes inline .category-row-desc from the name cell and renders entry.description inside .expand-content instead, matching the index page pattern. Drops the now-unused CSS rules for .category-row-desc and the overridden .category-table .expand-content padding. Co-Authored-By: Claude --- website/static/style.css | 35 --------------------------------- website/templates/category.html | 7 +++---- 2 files changed, 3 insertions(+), 39 deletions(-) diff --git a/website/static/style.css b/website/static/style.css index 852b01a4ea..6876dc2bac 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -511,41 +511,6 @@ kbd { justify-self: start; } -.category-table .col-name { - width: min(42rem, 48vw); - white-space: normal; -} - -.category-table .col-name > a { - display: inline-block; -} - -.category-row-desc { - display: block; - max-width: 68ch; - margin-top: 0.32rem; - color: var(--ink-soft); - font-size: var(--text-sm); - font-weight: 500; - line-height: 1.55; - text-wrap: pretty; -} - -.category-row-desc a { - color: var(--accent-deep); - text-decoration: underline; - text-decoration-color: var(--accent-underline); - text-underline-offset: 0.18em; -} - -.category-row-desc a:hover { - color: var(--accent); -} - -.category-table .expand-content { - padding-block: 0.25rem 0.15rem; -} - .sponsor-band { padding-block: clamp(2.5rem, 5.5vw, 4rem); background: diff --git a/website/templates/category.html b/website/templates/category.html index b3af6507b4..a49dde4088 100644 --- a/website/templates/category.html +++ b/website/templates/category.html @@ -86,9 +86,6 @@

    {{ category.name }} results

    {{ entry.name }} - {% if entry.description %} - {{ entry.description | safe }} - {% endif %} {% if entry.subcategories %}{{ entry.subcategories[0].name }}{% else %}{{ category.name }}{% endif %}{{ category.name }} results
    - {% if entry.also_see %} + {% if entry.description %} +
    {{ entry.description | safe }}
    + {% endif %} {% if entry.also_see %}
    Also see: {% for see in entry.also_see %} Date: Sun, 3 May 2026 08:26:37 +0800 Subject: [PATCH 377/429] feat(website): mirror index layout on category pages Add search input, filter chips, no-results block, and back-to-top button to category/group/subcategory pages. Pass filter_urls_json to all page types so tag-chip navigation works site-wide. Fix JS so filter-clear and no-results-clear redirect to / on non-index pages instead of trying to filter a non-existent local table. Remove the now-redundant .category-results CSS overrides. Co-Authored-By: Claude --- website/build.py | 7 +++- website/static/main.js | 14 +++++-- website/static/style.css | 15 +------ website/templates/category.html | 72 ++++++++++++++++++++++++++++----- website/tests/test_build.py | 2 +- 5 files changed, 80 insertions(+), 30 deletions(-) diff --git a/website/build.py b/website/build.py index 4b65d2bc84..8fed7860e9 100644 --- a/website/build.py +++ b/website/build.py @@ -339,6 +339,8 @@ def build(repo_root: Path) -> None: shutil.rmtree(site_dir) site_dir.mkdir(parents=True) + filter_urls_json = json.dumps(filter_urls, sort_keys=True, ensure_ascii=False).replace(" None: build_date=build_date.strftime("%B %d, %Y"), sponsors=sponsors, category_urls=category_urls, - filter_urls_json=json.dumps(filter_urls, sort_keys=True, ensure_ascii=False).replace(" None: page_kind="category", category_urls=category_urls, current_path=category_path(category), + filter_urls_json=filter_urls_json, ), encoding="utf-8", ) @@ -394,6 +397,7 @@ def build(repo_root: Path) -> None: page_kind="group", category_urls=category_urls, current_path=group_path(group["slug"]), + filter_urls_json=filter_urls_json, ), encoding="utf-8", ) @@ -431,6 +435,7 @@ def build(repo_root: Path) -> None: parent_category=category, category_urls=category_urls, current_path=subcategory_path(category["slug"], sub["slug"]), + filter_urls_json=filter_urls_json, ), encoding="utf-8", ) diff --git a/website/static/main.js b/website/static/main.js index 0cb0e1f612..4da6f6692c 100644 --- a/website/static/main.js +++ b/website/static/main.js @@ -341,6 +341,10 @@ tags.forEach(function (tag) { if (filterClear) { filterClear.addEventListener("click", function () { + if (!isIndexPage()) { + window.location.href = "/"; + return; + } activeFilter = null; applyFilters(); }); @@ -349,6 +353,10 @@ if (filterClear) { const noResultsClear = document.querySelector(".no-results-clear"); if (noResultsClear) { noResultsClear.addEventListener("click", function () { + if (!isIndexPage()) { + window.location.href = "/"; + return; + } if (searchInput) searchInput.value = ""; activeFilter = null; applyFilters(); @@ -451,10 +459,8 @@ if (backToTop) { ) { activeSort = { col: sort, order: order }; } - if (isIndexPage()) { - const matched = urlToFilter[location.pathname]; - if (matched) activeFilter = matched; - } + const matched = urlToFilter[location.pathname]; + if (matched) activeFilter = matched; if (q || activeFilter || sort) { sortRows(); } diff --git a/website/static/style.css b/website/static/style.css index 6876dc2bac..77b584421e 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -430,7 +430,7 @@ kbd { .tag:focus-visible, .back-to-top:focus-visible, .no-results-clear:focus-visible, -.category-table a:focus-visible, +.table a:focus-visible, .footer a:focus-visible, .sort-btn:focus-visible { outline: 2px solid var(--accent); @@ -498,19 +498,6 @@ kbd { text-decoration-color: oklch(100% 0 0 / 0.7); } -.category-results { - padding-top: clamp(2.5rem, 5vw, 3.75rem); -} - -.category-results .results-intro { - grid-template-columns: 1fr; - gap: 0.6rem; -} - -.category-results .results-intro .results-note { - justify-self: start; -} - .sponsor-band { padding-block: clamp(2.5rem, 5.5vw, 4rem); background: diff --git a/website/templates/category.html b/website/templates/category.html index a49dde4088..96ced7f5d9 100644 --- a/website/templates/category.html +++ b/website/templates/category.html @@ -38,24 +38,58 @@

    {{ category.name }}

    {% endblock %} {% block content %} -
    + +
    -

    Python Projects in {{ category.name }}

    +

    Search every project in one place

    - Sorted by GitHub stars when available. Click any row for details. + Press / to search. Tap a tag to filter. Click any row for + details.

    -

    {{ category.name }} results

    +
    +

    Search and filter

    +
    + + + + + +
    +
    + Filtering for + +
    +
    + +

    Results

    - +
    @@ -69,7 +103,11 @@

    {{ category.name }} results

    - + @@ -79,7 +117,7 @@

    {{ category.name }} results

    data-tags="{{ entry.categories | join('||') }}{% if entry.subcategories %}||{{ entry.subcategories | map(attribute='value') | join('||') }}{% endif %}||{{ entry.groups | join('||') }}{% if entry.source_type == 'Built-in' %}||Built-in{% endif %}" tabindex="0" aria-expanded="false" - aria-controls="category-expand-{{ loop.index }}" + aria-controls="expand-{{ loop.index }}" > - +
    Row number TagsDetails + +
    {{ loop.index }} @@ -145,7 +183,7 @@

    {{ category.name }} results

    @@ -188,6 +226,14 @@

    {{ category.name }} results

    + +
    @@ -203,7 +249,13 @@

    Know a project that belongs here?

    rel="noopener" >Submit a project
    - Browse all + Star the repository
    diff --git a/website/tests/test_build.py b/website/tests/test_build.py index b482c1a640..57657a6661 100644 --- a/website/tests/test_build.py +++ b/website/tests/test_build.py @@ -302,7 +302,7 @@ def test_build_creates_category_pages_with_metadata_and_links(self, tmp_path): assert 'href="https://example.com/w1"' in category_html assert "A widget." in category_html assert 'href="https://github.com/owner/w2"' in category_html - assert '' in category_html + assert '
    ' in category_html assert "42" in category_html assert "2026-01-01T00:00:00+00:00" in category_html From 70a8255289eb5ffc03621dc2e9b32769463dd624 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 3 May 2026 08:35:55 +0800 Subject: [PATCH 378/429] feat(website): add /categories/built-in/ page for Built-in tag filter Register Built-in as a navigable filter path alongside regular category and group slugs, emit the page during build, add it to the sitemap, and wire the Built-in tag buttons in index.html and category.html to navigate there via data-url. Co-Authored-By: Claude --- website/build.py | 35 ++++++++++++++++++++++++++++++++- website/templates/category.html | 6 +++++- website/templates/index.html | 6 +++++- 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/website/build.py b/website/build.py index 8fed7860e9..5a753a657b 100644 --- a/website/build.py +++ b/website/build.py @@ -20,6 +20,11 @@ SITEMAP_URL = f"{SITE_URL}sitemap.xml" SITEMAP_NS = "http://www.sitemaps.org/schemas/sitemap/0.9" +BUILTIN_FILTER = "Built-in" +BUILTIN_SLUG = "built-in" +BUILTIN_PATH = f"/categories/{BUILTIN_SLUG}/" +BUILTIN_PUBLIC_URL = f"{SITE_URL}categories/{BUILTIN_SLUG}/" + SOURCE_TYPE_DOMAINS = { "docs.python.org": "Built-in", "gitlab.com": "GitLab", @@ -287,7 +292,7 @@ def build(repo_root: Path) -> None: categories = [cat for g in parsed_groups for cat in g["categories"]] cat_slugs = [cat["slug"] for cat in categories] group_slugs = [g["slug"] for g in parsed_groups] - all_top_level_slugs = cat_slugs + group_slugs + all_top_level_slugs = cat_slugs + group_slugs + [BUILTIN_SLUG] duplicates = {s for s in all_top_level_slugs if all_top_level_slugs.count(s) > 1} if duplicates: raise ValueError( @@ -325,6 +330,9 @@ def build(repo_root: Path) -> None: for entry in entries: for sub in entry.get("subcategories", []): filter_urls[sub["value"]] = sub["url"] + builtin_entries = [e for e in entries if e.get("source_type") == BUILTIN_FILTER] + if builtin_entries: + filter_urls[BUILTIN_FILTER] = BUILTIN_PATH env = Environment( loader=FileSystemLoader(website / "templates"), @@ -402,6 +410,29 @@ def build(repo_root: Path) -> None: encoding="utf-8", ) + if builtin_entries: + page_dir = categories_dir / BUILTIN_SLUG + page_dir.mkdir(parents=True, exist_ok=True) + synthetic = { + "name": BUILTIN_FILTER, + "slug": BUILTIN_SLUG, + "description": "", + "description_html": "", + } + (page_dir / "index.html").write_text( + tpl_category.render( + category=synthetic, + category_url=BUILTIN_PUBLIC_URL, + entries=builtin_entries, + total_categories=len(categories), + page_kind="built-in", + category_urls=category_urls, + current_path=BUILTIN_PATH, + filter_urls_json=filter_urls_json, + ), + encoding="utf-8", + ) + seen_subcats: set[tuple[str, str]] = set() for category in categories: cat_url_prefix = f"/categories/{category['slug']}/" @@ -455,6 +486,8 @@ def build(repo_root: Path) -> None: sitemap_urls = [(SITE_URL, sitemap_date)] sitemap_urls.extend((category_public_url(c), sitemap_date) for c in categories) sitemap_urls.extend((group_public_url(g["slug"]), sitemap_date) for g in parsed_groups) + if builtin_entries: + sitemap_urls.append((BUILTIN_PUBLIC_URL, sitemap_date)) for cat_slug, sub_slug in sorted(seen_subcats): sitemap_urls.append((subcategory_public_url(cat_slug, sub_slug), sitemap_date)) write_sitemap_xml(site_dir / "sitemap.xml", sitemap_urls) diff --git a/website/templates/category.html b/website/templates/category.html index 96ced7f5d9..bb97ae6149 100644 --- a/website/templates/category.html +++ b/website/templates/category.html @@ -176,7 +176,11 @@

    Results

    {% endif %} {% if entry.source_type == 'Built-in' %} - {% endif %} diff --git a/website/templates/index.html b/website/templates/index.html index 2f90964486..7e79bdbac5 100644 --- a/website/templates/index.html +++ b/website/templates/index.html @@ -247,7 +247,11 @@

    Results

    {{ entry.groups[0] }} {% if entry.source_type == 'Built-in' %} - {% endif %} From b82a254a09668ade6a62644af9a42eaf44503ceb Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 3 May 2026 09:03:25 +0800 Subject: [PATCH 379/429] fix(website): clear filter lands at /#library-index on category pages Co-Authored-By: Claude --- website/static/main.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/static/main.js b/website/static/main.js index 4da6f6692c..e485454367 100644 --- a/website/static/main.js +++ b/website/static/main.js @@ -342,7 +342,7 @@ tags.forEach(function (tag) { if (filterClear) { filterClear.addEventListener("click", function () { if (!isIndexPage()) { - window.location.href = "/"; + window.location.href = "/#library-index"; return; } activeFilter = null; From 64781112d8ce6b1ff634b0acbbf44b72db59dfef Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 3 May 2026 09:08:37 +0800 Subject: [PATCH 380/429] feat(website): add Browse by category nav to group page hero Co-Authored-By: Claude --- website/build.py | 1 + website/templates/category.html | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/website/build.py b/website/build.py index 5a753a657b..a00bc103c3 100644 --- a/website/build.py +++ b/website/build.py @@ -406,6 +406,7 @@ def build(repo_root: Path) -> None: category_urls=category_urls, current_path=group_path(group["slug"]), filter_urls_json=filter_urls_json, + group_categories=group["categories"], ), encoding="utf-8", ) diff --git a/website/templates/category.html b/website/templates/category.html index bb97ae6149..ad147fdbdb 100644 --- a/website/templates/category.html +++ b/website/templates/category.html @@ -34,6 +34,23 @@

    {{ category.name }}

    {{ category.description_html | safe }}

    {% endif %} + + {% if group_categories %} + + {% endif %} {% endblock %} From c68b985d7c0bc110c0eade90fc9bd346ac3fd96a Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 3 May 2026 09:35:39 +0800 Subject: [PATCH 381/429] feat(website): add /sponsorship/ landing page Adds a dedicated sponsorship page at /sponsorship/ built from the Jinja2 template, with hero stats, tier cards, and CSS. Updates the index.html sponsor sidebar link to point to /sponsorship/ instead of the GitHub SPONSORSHIP.md. Adds the URL to the sitemap and test fixtures. Also renames .impeccable.md to DESIGN.md. Co-Authored-By: Claude --- .impeccable.md => DESIGN.md | 0 SPONSORSHIP.md | 2 +- website/build.py | 21 ++ website/static/style.css | 309 ++++++++++++++++++++++++++++- website/templates/index.html | 9 +- website/templates/sponsorship.html | 252 +++++++++++++++++++++++ website/tests/test_build.py | 7 + 7 files changed, 590 insertions(+), 10 deletions(-) rename .impeccable.md => DESIGN.md (100%) create mode 100644 website/templates/sponsorship.html diff --git a/.impeccable.md b/DESIGN.md similarity index 100% rename from .impeccable.md rename to DESIGN.md diff --git a/SPONSORSHIP.md b/SPONSORSHIP.md index b833748a25..debd6f9159 100644 --- a/SPONSORSHIP.md +++ b/SPONSORSHIP.md @@ -37,7 +37,7 @@ Your sponsorship puts your product in front of developers at the exact moment th ## Get Started -Email [vinta.chen@gmail.com](mailto:vinta.chen@gmail.com?subject=awesome-python%20Sponsorship) with: +Email [vinta.chen@gmail.com](mailto:vinta.chen@gmail.com?subject=Awesome%20Python%20Sponsorship) with: - **Tier:** Headline Sponsor ($500/mo) or Featured Sponsor ($150/mo) - **Content:** Product name, URL, logo, and description (Headline tier) or `[Name](URL) - Description.` entry (Featured tier) diff --git a/website/build.py b/website/build.py index a00bc103c3..35fdaa4e8c 100644 --- a/website/build.py +++ b/website/build.py @@ -25,6 +25,9 @@ BUILTIN_PATH = f"/categories/{BUILTIN_SLUG}/" BUILTIN_PUBLIC_URL = f"{SITE_URL}categories/{BUILTIN_SLUG}/" +SPONSORSHIP_PATH = "/sponsorship/" +SPONSORSHIP_PUBLIC_URL = f"{SITE_URL}sponsorship/" + SOURCE_TYPE_DOMAINS = { "docs.python.org": "Built-in", "gitlab.com": "GitLab", @@ -434,6 +437,23 @@ def build(repo_root: Path) -> None: encoding="utf-8", ) + sponsorship_dir = site_dir / "sponsorship" + sponsorship_dir.mkdir(parents=True, exist_ok=True) + tpl_sponsorship = env.get_template("sponsorship.html") + hero_stats: list[str] = [] + if repo_stars: + hero_stats.append(f"{repo_stars}+ stars on GitHub") + hero_stats.append(f"{total_entries}+ curated projects") + hero_stats.append(f"Updated {build_date.strftime('%B %d, %Y')}") + (sponsorship_dir / "index.html").write_text( + tpl_sponsorship.render( + total_entries=total_entries, + total_categories=len(categories), + hero_stats=hero_stats, + ), + encoding="utf-8", + ) + seen_subcats: set[tuple[str, str]] = set() for category in categories: cat_url_prefix = f"/categories/{category['slug']}/" @@ -491,6 +511,7 @@ def build(repo_root: Path) -> None: sitemap_urls.append((BUILTIN_PUBLIC_URL, sitemap_date)) for cat_slug, sub_slug in sorted(seen_subcats): sitemap_urls.append((subcategory_public_url(cat_slug, sub_slug), sitemap_date)) + sitemap_urls.append((SPONSORSHIP_PUBLIC_URL, sitemap_date)) write_sitemap_xml(site_dir / "sitemap.xml", sitemap_urls) (site_dir / "index.md").write_text(markdown_index, encoding="utf-8") (site_dir / "llms.txt").write_text(llms_txt, encoding="utf-8") diff --git a/website/static/style.css b/website/static/style.css index 77b584421e..4d02f72c5f 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -1082,6 +1082,298 @@ th[data-sort].sort-asc::after { color: var(--accent); } +.sponsorship-hero .category-hero-shell { + padding-bottom: clamp(3.25rem, 6vw, 5rem); + gap: clamp(2rem, 5vw, 3.5rem); +} + +.sponsorship-hero-copy h1 { + font-size: clamp(3.4rem, 8.5vw, 6.5rem); +} + +.sponsorship-proof { + margin-top: 1.6rem; +} + +.sponsorship-proof .proof-sep { + color: oklch(100% 0 0 / 0.32); + margin-inline: 0.15rem; +} + +.sponsorship-hero .hero-actions { + margin-top: 1.9rem; +} + +.sponsorship-section { + padding-block: clamp(2.75rem, 5.5vw, 4.25rem); + border-bottom: 1px solid var(--line); +} + +.sponsorship-section:first-of-type { + padding-top: clamp(3.25rem, 6vw, 4.75rem); +} + +.sponsorship-section:last-of-type { + border-bottom: 0; + padding-bottom: clamp(3.5rem, 7vw, 5.5rem); +} + +.sponsorship-getstarted { + background: var(--cta-bg); + border-top: 1px solid var(--line); +} + +.sponsorship-shell { + display: grid; + grid-template-columns: minmax(0, 16rem) minmax(0, 1fr); + gap: clamp(1.75rem, 5vw, 4rem); + align-items: start; +} + +.sponsorship-meta { + display: flex; + flex-direction: column; + gap: 0.85rem; + position: sticky; + top: 1.5rem; +} + +.sponsorship-meta .section-label { + margin-bottom: 0; + font-size: var(--text-lg); +} + +.sponsorship-meta-note { + color: var(--ink-muted); + font-size: var(--text-sm); + line-height: 1.55; +} + +.sponsorship-body { + display: flex; + flex-direction: column; + gap: 1.6rem; + font-size: var(--text-lg); + color: var(--ink-soft); + line-height: 1.7; +} + +.sponsorship-body p { + text-wrap: pretty; +} + +.sponsorship-body code { + font-family: ui-monospace, "SFMono-Regular", "Menlo", monospace; + font-size: 0.92em; + padding: 0.08rem 0.4rem; + border-radius: 0.4rem; + background: var(--bg-paper-strong); + color: var(--ink); +} + +.sponsorship-body a:not(.hero-action):not(.tier-cta) { + color: var(--accent-deep); + text-decoration: underline; + text-decoration-color: var(--accent-underline); + text-underline-offset: 0.2em; + transition: color 180ms ease; +} + +.sponsorship-body a:not(.hero-action):not(.tier-cta):hover { + color: var(--accent); +} + +.sponsorship-lede { + font-family: var(--font-display); + font-size: clamp(1.55rem, 2.6vw, 2rem); + line-height: 1.25; + color: var(--ink); + letter-spacing: -0.01em; + text-wrap: pretty; +} + +.sponsorship-facts { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 1.4rem; + border-top: 1px solid var(--line); + padding-top: 1.6rem; +} + +.sponsorship-facts > div { + display: grid; + grid-template-columns: minmax(0, 12rem) minmax(0, 1fr); + gap: clamp(1rem, 3vw, 2rem); + align-items: baseline; +} + +.sponsorship-facts dt { + font-size: var(--text-xs); + font-weight: 800; + letter-spacing: 0.05em; + color: var(--ink); +} + +.sponsorship-facts dd { + color: var(--ink-soft); + font-size: var(--text-base); + line-height: 1.65; +} + +.tier-list { + list-style: none; + padding: 0; + margin: 0; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: clamp(1.5rem, 3vw, 2.75rem); +} + +.tier { + display: flex; + flex-direction: column; + gap: 1rem; + padding-block: 1.65rem; + border-top: 1px solid var(--line-strong); +} + +.tier-eyebrow { + font-size: var(--text-xs); + font-weight: 800; + letter-spacing: 0.05em; + color: var(--ink); +} + +.tier-price { + display: flex; + align-items: baseline; + gap: 0.55rem; + margin-bottom: 0.25rem; +} + +.tier-amount { + font-family: var(--font-display); + font-size: clamp(3rem, 5.5vw, 4.5rem); + font-weight: 600; + line-height: 0.9; + letter-spacing: -0.025em; + color: var(--ink); +} + +.tier-cadence { + color: var(--ink-muted); + font-size: var(--text-base); + font-weight: 600; + letter-spacing: 0.01em; +} + +.tier-summary { + font-size: var(--text-lg); + color: var(--ink); + line-height: 1.5; + text-wrap: pretty; +} + +.tier-includes { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 0.6rem; + border-top: 1px solid var(--line); + padding-top: 1.1rem; +} + +.tier-includes li { + position: relative; + padding-left: 1.4rem; + color: var(--ink-soft); + font-size: var(--text-base); + line-height: 1.6; +} + +.tier-includes li::before { + content: ""; + position: absolute; + left: 0; + top: 0.65rem; + width: 0.55rem; + height: 1px; + background: var(--line-strong); +} + +.tier-cta { + align-self: start; + margin-top: 0.75rem; + color: var(--accent-deep); + font-size: var(--text-sm); + font-weight: 700; + letter-spacing: 0.01em; + text-decoration: underline; + text-decoration-color: var(--accent-underline); + text-underline-offset: 0.22em; + transition: color 180ms ease, text-decoration-color 180ms ease; +} + +.tier-cta:hover { + color: var(--accent); + text-decoration-color: var(--accent); +} + +.past-sponsors { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 0.85rem; +} + +.past-sponsors li { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 0.65rem; + padding-block: 0.4rem; +} + +.past-sponsors a { + font-family: var(--font-display); + font-size: clamp(1.6rem, 2.8vw, 2.1rem); + font-weight: 600; + line-height: 1; + letter-spacing: -0.02em; + color: var(--ink); + transition: color 180ms ease; +} + +.past-sponsors a:hover { + color: var(--accent-deep); +} + +.past-sponsor-desc { + color: var(--ink-muted); + font-size: var(--text-base); +} + +.sponsorship-cta-row { + display: flex; + flex-wrap: wrap; + gap: 0.85rem; + margin-top: 0.5rem; +} + +.sponsorship-cta-row .hero-action-primary { + color: var(--hero-text); + background: linear-gradient(135deg, var(--accent), var(--accent-deep)); +} + +.sponsorship-fineprint { + font-size: var(--text-base); + color: var(--ink-muted); +} + .final-cta { padding-block: clamp(3rem, 7vw, 5.5rem); background: var(--cta-bg); @@ -1227,8 +1519,23 @@ th[data-sort].sort-asc::after { .hero-grid, .results-intro, - .sponsor-shell { + .sponsor-shell, + .sponsorship-shell { + grid-template-columns: 1fr; + } + + .sponsorship-meta { + position: static; + } + + .tier-list { + grid-template-columns: 1fr; + gap: 0; + } + + .sponsorship-facts > div { grid-template-columns: 1fr; + gap: 0.35rem; } .hero-category-nav { diff --git a/website/templates/index.html b/website/templates/index.html index 7e79bdbac5..36408415d6 100644 --- a/website/templates/index.html +++ b/website/templates/index.html @@ -85,14 +85,7 @@

    Browse by category

    From 3167cff71f173d7f53d502956e6740cdc950c582 Mon Sep 17 00:00:00 2001 From: chernistry Date: Sat, 9 May 2026 23:14:45 +0300 Subject: [PATCH 420/429] Add bernstein to AI and Agents > Orchestration --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index bd17363315..6519d26383 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,7 @@ _Libraries for building AI applications, LLM integrations, and autonomous agents - Orchestration - [ag2](https://github.com/ag2ai/ag2) - An open-source AgentOS for multi-agent orchestration and building agentic AI systems. - [autogen](https://github.com/microsoft/autogen) - A programming framework for building agentic AI applications. + - [bernstein](https://github.com/sipyourdrink-ltd/bernstein) - A deterministic Python orchestrator for CLI coding agents (Claude Code, Codex, Gemini CLI, and 40+ more) with parallel git worktrees and an HMAC-signed audit chain. - [bub](https://github.com/bubbuild/bub) - A lightweight, hook-first Python framework for channel-native agents that live alongside people. - [crewai](https://github.com/crewAIInc/crewAI) - A framework for orchestrating role-playing autonomous AI agents for collaborative task solving. - [dspy](https://github.com/stanfordnlp/dspy) - A framework for programming, not prompting, language models. From b9df44ecc444cc15e5c431ae6417241c1e7aab9f Mon Sep 17 00:00:00 2001 From: "Leon.C" <160379708+zichen0116@users.noreply.github.com> Date: Mon, 11 May 2026 11:12:02 +0800 Subject: [PATCH 421/429] docs: add CODE_OF_CONDUCT.md Add Contributor Covenant Code of Conduct to establish community guidelines and ensure a welcoming environment for all contributors. --- CODE_OF_CONDUCT.md | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 CODE_OF_CONDUCT.md diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000000..f13d736d08 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,40 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity and +orientation. + +## Our Standards + +Examples of behavior that contributes to a positive environment: + +- Using welcoming and inclusive language +- Being respectful of differing viewpoints and experiences +- Gracefully accepting constructive criticism +- Focusing on what is best for the community +- Showing empathy towards other community members + +Examples of unacceptable behavior: + +- The use of sexualized language or imagery +- Trolling, insulting or derogatory comments +- Public or private harassment +- Publishing others' private information without permission +- Other conduct which could reasonably be considered inappropriate + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the project maintainers. All complaints will be reviewed and +investigated and will result in a response that is deemed necessary and +appropriate to the circumstances. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), +version 2.0. From 04abb895d7142558fc3c12f807af12ec843ea75d Mon Sep 17 00:00:00 2001 From: Aman Gupta <98139823+dat-a-man@users.noreply.github.com> Date: Mon, 11 May 2026 07:57:29 +0000 Subject: [PATCH 422/429] Updated --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index 6519d26383..4df4f54192 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ An opinionated guide to the best Python frameworks, libraries, tools, and resour **Data & Science** - [Data Analysis](#data-analysis) +- [Data Ingestion / ETL](#data-ingestion--etl) - [Data Validation](#data-validation) - [Data Visualization](#data-visualization) - [Geolocation](#geolocation) @@ -484,6 +485,12 @@ _Libraries for data analysis._ - [lumibot](https://github.com/Lumiwealth/lumibot) - Algorithmic trading framework for backtesting and live deployment across stocks, options, crypto, futures, and forex. - [openbb](https://github.com/OpenBB-finance/OpenBB) - A financial data platform for analysts, quants and AI agents. - [yfinance](https://github.com/ranaroussi/yfinance) - Easy Pythonic way to download market and financial data from Yahoo Finance. + +### Data Ingestion / ETL + +_Libraries for extracting data from external sources and loading it into databases, warehouses, and lakehouses._ + +- [dlt](https://github.com/dlt-hub/dlt) - A Python library for building data pipelines with automatic schema inference, incremental loading, and support for multiple sources and destinations. ### Data Validation From c506774ac7beb562e2a8ea1dddf88d752730bb10 Mon Sep 17 00:00:00 2001 From: Jinyang Date: Mon, 11 May 2026 13:40:03 +0400 Subject: [PATCH 423/429] Refactor data libraries section in README.md for clarity and organization --- README.md | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 4df4f54192..2f637ec8be 100644 --- a/README.md +++ b/README.md @@ -469,28 +469,29 @@ _Libraries for serializing complex data types._ _Libraries for data analysis._ +- [aws-sdk-pandas](https://github.com/aws/aws-sdk-pandas) - Pandas on AWS. +- [datasette](https://github.com/simonw/datasette) - An open source multi-tool for exploring and publishing data. +- [data-profiling](https://github.com/Data-Centric-AI-Community/data-profiling) - Generate detailed data profiling reports for pandas DataFrames. +- [desbordante](https://github.com/desbordante/desbordante-core/) - An open source data profiler for complex pattern discovery. +- [ibis](https://github.com/ibis-project/ibis) - A portable Python dataframe library with a single API for 20+ backends. +- [modin](https://github.com/modin-project/modin) - A drop-in pandas replacement that scales workflows by changing a single line of code. +- [pandas](https://github.com/pandas-dev/pandas) - A library providing high-performance, easy-to-use data structures and data analysis tools. +- [pathway](https://github.com/pathwaycom/pathway) - Real-time data processing framework for Python with reactive dataflows. +- [polars](https://github.com/pola-rs/polars) - A fast DataFrame library implemented in Rust with a Python API. + + +### Data Ingestion / ETL + +_Libraries for data extraction, transformation, and loading pipelines across multiple sources and destinations._ + - General - - [aws-sdk-pandas](https://github.com/aws/aws-sdk-pandas) - Pandas on AWS. - - [datasette](https://github.com/simonw/datasette) - An open source multi-tool for exploring and publishing data. - - [data-profiling](https://github.com/Data-Centric-AI-Community/data-profiling) - Generate detailed data profiling reports for pandas DataFrames. - - [desbordante](https://github.com/desbordante/desbordante-core/) - An open source data profiler for complex pattern discovery. - - [ibis](https://github.com/ibis-project/ibis) - A portable Python dataframe library with a single API for 20+ backends. - - [modin](https://github.com/modin-project/modin) - A drop-in pandas replacement that scales workflows by changing a single line of code. - - [pandas](https://github.com/pandas-dev/pandas) - A library providing high-performance, easy-to-use data structures and data analysis tools. - - [pathway](https://github.com/pathwaycom/pathway) - Real-time data processing framework for Python with reactive dataflows. - - [polars](https://github.com/pola-rs/polars) - A fast DataFrame library implemented in Rust with a Python API. + - [dlt](https://github.com/dlt-hub/dlt) - A Python library for building data pipelines with automatic schema inference, incremental loading, and support for multiple sources and destinations. - Financial Data - [akshare](https://github.com/akfamily/akshare) - A financial data interface library, built for human beings! - [edgartools](https://github.com/dgunning/edgartools) - Library for downloading structured data from SEC EDGAR filings and XBRL financial statements. - [lumibot](https://github.com/Lumiwealth/lumibot) - Algorithmic trading framework for backtesting and live deployment across stocks, options, crypto, futures, and forex. - [openbb](https://github.com/OpenBB-finance/OpenBB) - A financial data platform for analysts, quants and AI agents. - [yfinance](https://github.com/ranaroussi/yfinance) - Easy Pythonic way to download market and financial data from Yahoo Finance. - -### Data Ingestion / ETL - -_Libraries for extracting data from external sources and loading it into databases, warehouses, and lakehouses._ - -- [dlt](https://github.com/dlt-hub/dlt) - A Python library for building data pipelines with automatic schema inference, incremental loading, and support for multiple sources and destinations. ### Data Validation From e503d16eb26a1253ad677c9a8f09c6842beb40d3 Mon Sep 17 00:00:00 2001 From: Ankita Ghosh Date: Mon, 11 May 2026 15:30:27 +0530 Subject: [PATCH 424/429] fix: remove duplicate ruff entry from Code Linters section (#3119) --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 4df4f54192..1d019d0292 100644 --- a/README.md +++ b/README.md @@ -612,7 +612,6 @@ _Tools of static analysis, linters and code quality checkers. Also see [awesome- - [flake8](https://github.com/PyCQA/flake8) - A wrapper around `pycodestyle`, `pyflakes` and McCabe. - [awesome-flake8-extensions](https://github.com/DmytroLitvinov/awesome-flake8-extensions) - [pylint](https://github.com/pylint-dev/pylint) - A fully customizable source code analyzer. - - [ruff](https://github.com/astral-sh/ruff) - An extremely fast Python linter and code formatter. - Code Formatters - [black](https://github.com/psf/black) - The uncompromising Python code formatter. - [isort](https://github.com/PyCQA/isort) - A Python utility / library to sort imports. From 5885075bf7ef21b0c2bb3fe677e050b2f30a84b6 Mon Sep 17 00:00:00 2001 From: Mohd Aasim Ansari Date: Sun, 17 May 2026 07:31:18 +0000 Subject: [PATCH 425/429] Add ultralytics to Computer Vision section --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 81eff0edb4..f65b3ab145 100644 --- a/README.md +++ b/README.md @@ -214,6 +214,7 @@ _Libraries for Computer Vision._ - [kornia](https://github.com/kornia/kornia/) - Open Source Differentiable Computer Vision Library for PyTorch. - [opencv](https://github.com/opencv/opencv-python) - Open Source Computer Vision Library. - [pytesseract](https://github.com/madmaze/pytesseract) - A wrapper for [Google Tesseract OCR](https://github.com/tesseract-ocr). +- [ultralytics](https://github.com/ultralytics/ultralytics) - Ultralytics YOLO for object detection, segmentation, pose estimation, and classification with state-of-the-art accuracy and speed. ### Recommender Systems From 420619b114cbb3ebd7cc958020455d61bb6e3d5c Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 17 May 2026 17:13:32 +0800 Subject: [PATCH 426/429] add links to the website --- README.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 81eff0edb4..c9708a59ce 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# Awesome Python +# [Awesome Python](https://awesome-python.com/) -An opinionated guide to the best Python frameworks, libraries, tools, and resources. +An opinionated guide to the best Python frameworks, libraries, tools, and resources. Use the [website](https://awesome-python.com/) to search and filter projects more easily. ## **Sponsors** @@ -479,7 +479,6 @@ _Libraries for data analysis._ - [pathway](https://github.com/pathwaycom/pathway) - Real-time data processing framework for Python with reactive dataflows. - [polars](https://github.com/pola-rs/polars) - A fast DataFrame library implemented in Rust with a Python API. - ### Data Ingestion / ETL _Libraries for data extraction, transformation, and loading pipelines across multiple sources and destinations._ From 3e66360e400e5baae2bc3d3017a28e1de2f85fd4 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sun, 17 May 2026 17:15:31 +0800 Subject: [PATCH 427/429] refine wording --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c9708a59ce..52d0c73eec 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # [Awesome Python](https://awesome-python.com/) -An opinionated guide to the best Python frameworks, libraries, tools, and resources. Use the [website](https://awesome-python.com/) to search and filter projects more easily. +An opinionated guide to the best Python frameworks, libraries, tools, and resources. + +**Visit the [website](https://awesome-python.com/) to search and filter projects more easily.** ## **Sponsors** From a9b13e4c4a64546a730f40b093a74854d60ebc77 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Mon, 18 May 2026 13:53:45 +0800 Subject: [PATCH 428/429] update .gitignore --- .gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 0d9f410bb5..f081c83359 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# secrets +.env* + # macOS .DS_Store @@ -10,7 +13,7 @@ __pycache__/ website/output/ website/data/ -# planning docs +# docs docs/ # agents From a089ae28cc12418e9044539fe77abab848179ef0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Raahul=20Dutta=20-=20=E0=A6=B0=E0=A6=BE=E0=A6=B9=E0=A7=81?= =?UTF-8?q?=E0=A6=B2=20=F0=9F=96=96?= Date: Tue, 19 May 2026 06:31:32 +0200 Subject: [PATCH 429/429] Merge pull request #3137 from raahulrahl/add-bindu Add bindu to AI and Agents > Orchestration --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 3920b7ec9e..c7b25af22b 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,7 @@ _Libraries for building AI applications, LLM integrations, and autonomous agents - [ag2](https://github.com/ag2ai/ag2) - An open-source AgentOS for multi-agent orchestration and building agentic AI systems. - [autogen](https://github.com/microsoft/autogen) - A programming framework for building agentic AI applications. - [bernstein](https://github.com/sipyourdrink-ltd/bernstein) - A deterministic Python orchestrator for CLI coding agents (Claude Code, Codex, Gemini CLI, and 40+ more) with parallel git worktrees and an HMAC-signed audit chain. + - [bindu](https://github.com/getbindu/Bindu) - A framework that wraps any agent handler with DID-based cryptographic identity, A2A JSON-RPC over HTTP, OAuth2 auth, x402 (USDC) payments, and a built-in operator inbox. - [bub](https://github.com/bubbuild/bub) - A lightweight, hook-first Python framework for channel-native agents that live alongside people. - [crewai](https://github.com/crewAIInc/crewAI) - A framework for orchestrating role-playing autonomous AI agents for collaborative task solving. - [dspy](https://github.com/stanfordnlp/dspy) - A framework for programming, not prompting, language models.