diff --git a/.gitignore b/.gitignore index dd9781c..ca26a6e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ # python .venv/ +__pycache__/ *.py[co] # website @@ -11,6 +12,10 @@ website/data/ # claude code .claude/skills/ -.superpowers/ .gstack/ +.playwright-cli/ +.superpowers/ skills-lock.json + +# codex +.agents/ diff --git a/CLAUDE.md b/CLAUDE.md index 81d1341..7210cc4 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. +An opinionated list of Python frameworks, libraries, tools, 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 +- [project-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. If not on PyPI, use the GitHub repo 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. diff --git a/Makefile b/Makefile index 21a4c5d..8a0905f 100644 --- a/Makefile +++ b/Makefile @@ -14,7 +14,6 @@ build: uv run python website/build.py preview: build - @echo "Check the website on http://localhost:8000" uv run watchmedo shell-command \ --patterns='*.md;*.html;*.css;*.js;*.py' \ --recursive \ diff --git a/README.md b/README.md index a782517..276f9f8 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,10 @@ # Awesome Python -An opinionated list of awesome Python frameworks, libraries, tools, software and resources. +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). +# **Sponsors** + +> The **#10 most-starred repo on GitHub**. Put your product in front of Python developers. [Become a sponsor](SPONSORSHIP.md). # Categories @@ -15,7 +17,7 @@ An opinionated list of awesome Python frameworks, libraries, tools, software and - [Computer Vision](#computer-vision) - [Recommender Systems](#recommender-systems) -**Web** +**Web Development** - [Web Frameworks](#web-frameworks) - [Web APIs](#web-apis) @@ -125,17 +127,23 @@ An opinionated list of awesome Python frameworks, libraries, tools, software and _Libraries for building AI applications, LLM integrations, and autonomous agents._ -- Frameworks +- 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-skills](https://github.com/trailofbits/skills) - Python-friendly security skills for auditing, testing, and safer backend development. +- Orchestration - [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. - [dspy](https://github.com/stanfordnlp/dspy) - A framework for programming, not prompting, language models. - - [instructor](https://github.com/567-labs/instructor) - A library for extracting structured data from LLMs, powered by Pydantic. - [langchain](https://github.com/langchain-ai/langchain) - Building applications with LLMs through composability. - - [llama_index](https://github.com/run-llama/llama_index) - A data framework for your LLM application. - [pydantic-ai](https://github.com/pydantic/pydantic-ai) - A Python agent framework for building generative AI applications with structured schemas. -- Pretrained Models and Inference - - [diffusers](https://github.com/huggingface/diffusers) - A library that provides pretrained diffusion models for generating and editing images, audio, and video. - - [transformers](https://github.com/huggingface/transformers) - A framework that lets you easily use pretrained transformer models for NLP, vision, and audio tasks. +- Data Layer + - [instructor](https://github.com/567-labs/instructor) - A library for extracting structured data from LLMs, powered by Pydantic. + - [llama-index](https://github.com/run-llama/llama_index) - A data framework for your LLM application. + - [mem0](https://github.com/mem0ai/mem0) - An intelligent memory layer for AI agents enabling personalized interactions. +- Pre-trained Models and Inference + - [diffusers](https://github.com/huggingface/diffusers) - A library that provides pre-trained diffusion models for generating and editing images, audio, and video. + - [transformers](https://github.com/huggingface/transformers) - A framework that lets you easily use pre-trained transformer models for NLP, vision, and audio tasks. - [vllm](https://github.com/vllm-project/vllm) - A high-throughput and memory-efficient inference and serving engine for LLMs. ## Deep Learning @@ -193,7 +201,7 @@ _Libraries for building recommender systems._ - [implicit](https://github.com/benfred/implicit) - A fast Python implementation of collaborative filtering for implicit datasets. - [scikit-surprise](https://github.com/NicolasHug/Surprise) - A scikit for building and analyzing recommender systems. -**Web** +**Web Development** ## Web Frameworks @@ -543,7 +551,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. @@ -573,14 +580,15 @@ _Tools of static analysis, linters and code quality checkers. Also see [awesome- - 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. -- Static Type Checkers, also see [awesome-python-typing](https://github.com/typeddjango/awesome-python-typing) + - [ruff](https://github.com/astral-sh/ruff) - An extremely fast Python linter and code formatter. +- Refactoring + - [rope](https://github.com/python-rope/rope) - Rope is a python refactoring library. +- Type Checkers - [awesome-python-typing](https://github.com/typeddjango/awesome-python-typing) - [mypy](https://github.com/python/mypy) - Check variable types during compile time. - [pyre-check](https://github.com/facebook/pyre-check) - Performant type checking. - [ty](https://github.com/astral-sh/ty) - An extremely fast Python type checker and language server. - [typeshed](https://github.com/python/typeshed) - Collection of library stubs for Python, with static types. -- Refactoring - - [rope](https://github.com/python-rope/rope) - Rope is a python refactoring library. -- Static Type Annotations Generators +- Type Annotations Generators - [monkeytype](https://github.com/Instagram/MonkeyType) - A system for Python that generates static type annotations by collecting runtime types. - [pytype](https://github.com/google/pytype) - Pytype checks and infers types for Python code - without requiring type annotations. @@ -588,7 +596,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. @@ -599,7 +607,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/). @@ -737,11 +745,11 @@ _Tools and libraries for Virtual Networking and SDN (Software Defined Networking **CLI & GUI** -## Command-line Interface Development +## CLI Development _Libraries for building command-line applications._ -- Command-line Application Development +- CLI Development - [argparse](https://docs.python.org/3/library/argparse.html) - (Python standard library) Command-line option and argument parsing. - [cement](https://github.com/datafolklabs/cement) - CLI Application Framework for Python. - [click](https://github.com/pallets/click/) - A package for creating beautiful command line interfaces in a composable way. @@ -756,7 +764,7 @@ _Libraries for building command-line applications._ - [textual](https://github.com/Textualize/textual) - A framework for building interactive user interfaces that run in the terminal and the browser. - [tqdm](https://github.com/tqdm/tqdm) - Fast, extensible progress bar for loops and CLI. -## Command-line Tools +## CLI Tools _Useful CLI-based tools for productivity._ @@ -834,11 +842,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. -- [cssutils](https://github.com/jaraco/cssutils) - A CSS library for 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 @@ -850,14 +858,14 @@ _Libraries for parsing and manipulating specific text formats._ - [kreuzberg](https://github.com/kreuzberg-dev/kreuzberg) - High-performance document extraction library with a Rust core, supporting 62+ formats including PDF, Office, images with OCR, HTML, email, and archives. - [pyelftools](https://github.com/eliben/pyelftools) - Parsing and analyzing ELF files and DWARF debugging information. - [tablib](https://github.com/jazzband/tablib) - A module for Tabular Datasets in XLS, CSV, JSON, YAML. -- Office +- MS Office - [docxtpl](https://github.com/elapouya/python-docx-template) - Editing a docx document by jinja2 template - [openpyxl](https://openpyxl.readthedocs.io/en/stable/) - A library for reading and writing Excel 2010 xlsx/xlsm/xltx/xltm files. - [pyexcel](https://github.com/pyexcel/pyexcel) - Providing one API for reading, manipulating and writing csv, ods, xls, xlsx and xlsm files. - [python-docx](https://github.com/python-openxml/python-docx) - Reads, queries and modifies Microsoft Word 2007/2008 docx files. - [python-pptx](https://github.com/scanny/python-pptx) - Python library for creating and updating PowerPoint (.pptx) files. - [xlsxwriter](https://github.com/jmcnamara/XlsxWriter) - A Python module for creating Excel .xlsx files. - - [xlwings](https://github.com/ZoomerAnalytics/xlwings) - A BSD-licensed library that makes it easy to call Python from Excel and vice versa. + - [xlwings](https://github.com/xlwings/xlwings) - A BSD-licensed library that makes it easy to call Python from Excel and vice versa. - PDF - [pdf_oxide](https://github.com/yfedoseev/pdf_oxide) - A fast PDF library for text extraction, image extraction, and markdown conversion, powered by Rust. - [pdfminer.six](https://github.com/pdfminer/pdfminer.six) - Pdfminer.six is a community maintained fork of the original PDFMiner. @@ -891,14 +899,14 @@ _Libraries for file manipulation._ _Libraries for manipulating images._ -- [pillow](https://github.com/python-pillow/Pillow) - Pillow is the friendly [PIL](http://www.pythonware.com/products/pil/) fork. +- [pillow](https://github.com/python-pillow/Pillow) - Pillow is the friendly [PIL](https://www.pythonware.com/products/pil/) fork. - [pymatting](https://github.com/pymatting/pymatting) - A library for alpha matting. - [python-barcode](https://github.com/WhyNotHugo/python-barcode) - Create barcodes in Python with no extra dependencies. - [python-qrcode](https://github.com/lincolnloop/python-qrcode) - A pure Python QR Code generator. - [pyvips](https://github.com/libvips/pyvips) - A fast image processing library with low memory needs. - [scikit-image](https://github.com/scikit-image/scikit-image) - A Python library for (scientific) image processing. - [thumbor](https://github.com/thumbor/thumbor) - A smart imaging service. It enables on-demand crop, re-sizing and flipping of images. -- [wand](https://github.com/emcconville/wand) - Python bindings for [MagickWand](http://www.imagemagick.org/script/magick-wand.php), C API for ImageMagick. +- [wand](https://github.com/emcconville/wand) - Python bindings for [MagickWand](https://www.imagemagick.org/script/magick-wand.php), C API for ImageMagick. ## Audio & Video Processing @@ -1090,7 +1098,6 @@ Where to discover learning resources or new Python libraries. - [Django Chat](https://djangochat.com/) - [PyPodcats](https://pypodcats.live) - [Python Bytes](https://pythonbytes.fm) -- [Python Test](https://podcast.pythontest.com/) - [Talk Python To Me](https://talkpython.fm/) - [The Real Python Podcast](https://realpython.com/podcasts/rpp/) @@ -1100,4 +1107,4 @@ Your contributions are always welcome! Please take a look at the [contribution g --- -If you have any question about this opinionated list, do not hesitate to contact [@VintaChen](https://twitter.com/VintaChen) on Twitter. +If you have any question about this opinionated list, do not hesitate to contact [@vinta](https://x.com/vinta) on X (Twitter). diff --git a/SPONSORSHIP.md b/SPONSORSHIP.md index 3f9e8a6..4bb3b89 100644 --- a/SPONSORSHIP.md +++ b/SPONSORSHIP.md @@ -2,11 +2,11 @@ **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. +awesome-python is where Python developers go to discover tools. It ranks on the first page of Google for "best Python libraries," is referenced by ChatGPT and other LLMs when recommending Python tools, and is the list developers check when evaluating frameworks. Your sponsorship puts your product in front of developers at the exact moment they're choosing what to use. -## By the Numbers +## Audience | Metric | Value | | ------------ | ---------------------------------------------------------------------------------------------------- | @@ -15,22 +15,35 @@ Your sponsorship puts your product in front of developers at the exact moment th | 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. +**Who visits:** Professional Python developers evaluating libraries and tools for production use. Not beginners browsing tutorials. People making adoption decisions. + +**Top referrers:** Google Search, GitHub, ChatGPT/LLMs, YouTube, Reddit, Hacker News. ## Sponsorship Tiers -### Logo Sponsor — $500/month (2 slots) +### Logo Sponsor - $500/month -Your logo and a one-line description at the top of the README, seen by every visitor. +Your logo and a one-line description pinned to the top of the README, above all project entries. Every visitor to the repo or awesome-python.com sees it first. -### Link Sponsor — $150/month (5 slots) +**What you get:** +- Logo + one-line description in the README header +- Logo on awesome-python.com sponsor section +- Permanent placement for the duration of your sponsorship -A text link with your product name at the top of the README, right below logo sponsors. +### Link Sponsor - $150/month + +A text link with your product name at the top of the README, directly below logo sponsors. + +**What you get:** +- Text link in the README header +- Link on awesome-python.com sponsor section ## Past Sponsors -- [Warp](https://www.warp.dev/) - https://github.com/vinta/awesome-python/pull/2766 +- [Warp](https://www.warp.dev/) - The terminal for modern developers. ## 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. +Email [vinta.chen@gmail.com](mailto:vinta.chen@gmail.com?subject=awesome-python%20Sponsorship) with your company name and preferred tier. + +Setup takes less than 24 hours. Month-to-month billing, cancel anytime. diff --git a/website/build.py b/website/build.py index 5ab9c9e..cf75928 100644 --- a/website/build.py +++ b/website/build.py @@ -4,30 +4,12 @@ import json import re import shutil +from datetime import datetime, timezone from pathlib import Path 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): @@ -120,6 +102,11 @@ 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: + scoped = f"{cat['name']} > {subcat}" + if not any(s["value"] == scoped for s in existing["subcategories"]): + existing["subcategories"].append({"name": subcat, "value": scoped}) else: merged = { "name": entry["name"], @@ -127,6 +114,7 @@ def extract_entries( "description": entry["description"], "categories": [cat["name"]], "groups": [group_name], + "subcategories": [{"name": entry["subcategory"], "value": f"{cat['name']} > {entry['subcategory']}"}] if entry["subcategory"] else [], "stars": None, "owner": None, "last_commit_at": None, @@ -138,6 +126,13 @@ def extract_entries( return entries +def format_stars_short(stars: int) -> str: + """Format star count as compact string like '230k'.""" + if stars >= 1000: + return f"{stars // 1000}k" + return str(stars) + + def build(repo_root: str) -> None: """Main build: parse README, render single-page HTML via Jinja2 templates.""" repo = Path(repo_root) @@ -151,14 +146,17 @@ 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, resources) - entries = extract_entries(categories, groups) + entries = extract_entries(categories, parsed_groups) stars_data = load_stars(website / "data" / "github_stars.json") + + repo_self = stars_data.get("vinta/awesome-python", {}) + repo_stars = format_stars_short(repo_self["stars"]) if "stars" in repo_self else None + for entry in entries: repo_key = extract_github_repo(entry["url"]) if not repo_key and entry.get("source_type") == "Built-in": @@ -185,12 +183,12 @@ def build(repo_root: str) -> None: (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), + repo_stars=repo_stars, + build_date=datetime.now(timezone.utc).strftime("%B %d, %Y"), ), encoding="utf-8", ) @@ -202,7 +200,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}") diff --git a/website/fetch_github_stars.py b/website/fetch_github_stars.py index d3b024e..ccff1b6 100644 --- a/website/fetch_github_stars.py +++ b/website/fetch_github_stars.py @@ -103,6 +103,7 @@ def main() -> None: readme_text = README_PATH.read_text(encoding="utf-8") current_repos = extract_github_repos(readme_text) + current_repos.add("vinta/awesome-python") print(f"Found {len(current_repos)} GitHub repos in README.md") cache = load_stars(CACHE_FILE) diff --git a/website/readme_parser.py b/website/readme_parser.py index c0ecfc6..4f36ed7 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): @@ -28,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): @@ -131,6 +130,7 @@ def _extract_description(nodes: list[SyntaxTreeNode]) -> str: # --- Entry extraction -------------------------------------------------------- _DESC_SEP_RE = re.compile(r"^\s*[-\u2013\u2014]\s*") +_SUBCAT_TRAILING_RE = re.compile(r"[\s,\-\u2013\u2014]+(also\s+see\s*)?$", re.IGNORECASE) def _find_child(node: SyntaxTreeNode, child_type: str) -> SyntaxTreeNode | None: @@ -178,7 +178,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: @@ -199,10 +203,16 @@ def _parse_list_entries(bullet_list: SyntaxTreeNode) -> list[ParsedEntry]: 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) — recurse into nested list + # Subcategory label: take text before the first link, strip trailing separators + pre_link = [] + for child in inline.children: + if child.type == "link": + break + pre_link.append(child) + label = _SUBCAT_TRAILING_RE.sub("", render_inline_text(pre_link)) if pre_link else 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 +241,7 @@ def _parse_list_entries(bullet_list: SyntaxTreeNode) -> list[ParsedEntry]: url=url, description=desc_html, also_see=also_see, + subcategory=subcategory, )) return entries @@ -245,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 ------------------------------------------------------- @@ -317,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. @@ -432,43 +350,30 @@ def _parse_grouped_sections( 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 [], [] + 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 groups, resources + cat_nodes = children[hr_idx + 1 : cat_end_idx or len(children)] + return _parse_grouped_sections(cat_nodes) diff --git a/website/static/main.js b/website/static/main.js index 1b9cc7d..7353ff2 100644 --- a/website/static/main.js +++ b/website/static/main.js @@ -1,75 +1,137 @@ -// 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'); +const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)"); -// 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'; +function getScrollBehavior() { + return reducedMotion.matches ? "auto" : "smooth"; } -// Format all commit date cells -document.querySelectorAll('.col-commit[data-commit]').forEach(function (td) { - var time = td.querySelector('time'); +let activeFilter = null; +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() { + const sections = document.querySelectorAll("[data-reveal]"); + if (!sections.length) return; + + if (!("IntersectionObserver" in window)) { + sections.forEach(function (section) { + section.classList.add("is-visible"); + }); + return; + } + + 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"; + observer.observe(section); + }); +} + +initRevealSections(); + +// Smooth scroll without hash in URL +document.querySelectorAll("[data-scroll-to]").forEach(function (link) { + link.addEventListener("click", function (e) { + const el = document.getElementById(link.dataset.scrollTo); + if (!el) return; + e.preventDefault(); + el.scrollIntoView({ behavior: getScrollBehavior() }); + }); +}); + +// Pause hero animations when scrolled out of view +(function () { + 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); +})(); + +function relativeTime(isoStr) { + 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"; +} + +document.querySelectorAll(".col-commit[data-commit]").forEach(function (td) { + const time = td.querySelector("time"); if (time) time.textContent = relativeTime(td.dataset.commit); }); -// Store original row order for sort reset +document + .querySelectorAll(".expand-commit time[datetime]") + .forEach(function (time) { + time.textContent = relativeTime(time.getAttribute("datetime")); + }); + rows.forEach(function (row, i) { row._origIndex = i; row._expandRow = row.nextElementSibling; }); function collapseAll() { - var openRows = document.querySelectorAll('.table tbody tr.row.open'); + if (!tbody) return; + const openRows = tbody.querySelectorAll("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 rowTags = row.dataset.tags; + show = rowTags ? rowTags.split("||").includes(activeFilter) : 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; } @@ -80,7 +142,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); } @@ -89,21 +151,16 @@ function applyFilters() { if (noResults) noResults.hidden = visibleCount > 0; - // 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); + tag.classList.toggle("active", activeFilter === tag.dataset.value); }); - // Filter bar if (filterBar) { if (activeFilter) { - filterBar.classList.add('visible'); - if (filterValue) filterValue.textContent = activeFilter.value; + filterBar.classList.add("visible"); + if (filterValue) filterValue.textContent = activeFilter; } else { - filterBar.classList.remove('visible'); + filterBar.classList.remove("visible"); } } @@ -111,40 +168,43 @@ 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("filter", activeFilter); } - 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) { @@ -152,22 +212,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); @@ -176,78 +236,86 @@ function sortRows() { applyFilters(); } +const sortHeaders = document.querySelectorAll("th[data-sort]"); + function updateSortIndicators() { - 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); + sortHeaders.forEach(function (th) { + th.classList.remove("sort-asc", "sort-desc"); + if (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"); } }); } // 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(); }); } -// 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; - - // Toggle: click same filter again to clear - if (activeFilter && activeFilter.type === type && activeFilter.value === value) { - activeFilter = null; - } else { - activeFilter = { type: type, value: value }; - } + const value = tag.dataset.value; + activeFilter = activeFilter === value ? null : value; applyFilters(); }); }); -// Clear filter if (filterClear) { - filterClear.addEventListener('click', function () { + filterClear.addEventListener("click", function () { 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'; - if (activeSort && activeSort.col === col) { - if (activeSort.order === defaultOrder) activeSort = { col: col, order: altOrder }; - else activeSort = { col: 'stars', order: 'desc' }; +const noResultsClear = document.querySelector(".no-results-clear"); +if (noResultsClear) { + noResultsClear.addEventListener("click", function () { + if (searchInput) searchInput.value = ""; + activeFilter = null; + applyFilters(); + }); +} + +sortHeaders.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.col === col) { + if (activeSort.order === defaultOrder) + activeSort = { col: col, order: altOrder }; + else activeSort = { col: "stars", order: "desc" }; } else { activeSort = { col: col, order: defaultOrder }; } @@ -256,22 +324,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(); @@ -279,39 +352,60 @@ if (searchInput) { }); } -// Back to top -var backToTop = document.querySelector('.back-to-top'); +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; + + const tableRect = tableWrap.getBoundingClientRect(); + const headRect = stickyHeaderCell.getBoundingClientRect(); + const hasPassedHeader = tableRect.top <= 0 && headRect.bottom > 0; + + 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 () { - backToTop.classList.toggle('visible', window.scrollY > 600); + updateBackToTopVisibility(); scrollTicking = false; }); scrollTicking = true; } }); - backToTop.addEventListener('click', function () { - window.scrollTo({ top: 0 }); + + window.addEventListener("resize", updateBackToTopVisibility); + + backToTop.addEventListener("click", function () { + const target = searchInput || resultsSection; + if (!target) return; + target.scrollIntoView({ behavior: getScrollBehavior(), block: "center" }); + if (searchInput) searchInput.focus(); }); + + updateBackToTopVisibility(); } -// 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 filter = params.get("filter"); + 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 (filter) activeFilter = filter; + if ( + (sort === "name" || sort === "stars" || sort === "commit-time") && + (order === "desc" || order === "asc") + ) { activeSort = { col: sort, order: order }; } - if (q || cat || group || sort) { + if (q || filter || sort) { sortRows(); } updateSortIndicators(); diff --git a/website/static/og-image.png b/website/static/og-image.png new file mode 100644 index 0000000..d41e780 Binary files /dev/null and b/website/static/og-image.png differ diff --git a/website/static/og-image.svg b/website/static/og-image.svg new file mode 100644 index 0000000..4aeda17 --- /dev/null +++ b/website/static/og-image.svg @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + THE FIELD GUIDE TO THE PYTHON ECOSYSTEM + + + Awesome Python + + + An opinionated list of awesome Python frameworks, + libraries, tools, and resources. + + + awesome-python.com + diff --git a/website/static/style.css b/website/static/style.css index 0a5571f..ee24ce9 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -1,190 +1,487 @@ -/* === 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); - --tag-text: oklch(45% 0.06 240); - --tag-hover-bg: oklch(93% 0.025 240); +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; } -html { font-size: 16px; } +:root { + color-scheme: light; + --font-display: "Cormorant Garamond", Georgia, serif; + --font-body: "Manrope", "Avenir Next", "Segoe UI", sans-serif; + + --shell-max: 84rem; + --shell-pad: clamp(1.25rem, 3vw, 2.5rem); + + --bg-page: oklch(96.8% 0.018 80); + --bg-page-top: oklch(95.2% 0.018 78); + --bg-page-end: oklch(98.4% 0.01 80); + --bg-paper: oklch(98.6% 0.01 80); + --bg-paper-strong: oklch(95.7% 0.016 76); + --ink: oklch(22% 0.02 55); + --ink-soft: oklch(38% 0.018 55); + --ink-muted: oklch(52% 0.02 55); + --line: oklch(83% 0.02 70); + --line-strong: oklch(64% 0.035 62); + --accent: oklch(58% 0.16 45); + --accent-deep: oklch(44% 0.15 42); + --accent-soft: oklch(92% 0.045 55); + --accent-underline: oklch(58% 0.16 45 / 0.4); + --highlight: oklch(87% 0.08 78); + + --hero-ink: oklch(15% 0.02 40); + --hero-text: oklch(97% 0.012 85); + --hero-muted: oklch(88% 0.02 82); + --hero-line: oklch(100% 0 0 / 0.16); + --hero-kicker: oklch(82% 0.04 72); + --hero-proof: oklch(75% 0.02 72); + --hero-bg-start: oklch(14% 0.03 32); + --hero-bg-mid: oklch(19% 0.035 35); + --hero-bg-end: oklch(28% 0.05 42); + --hero-btn-start: oklch(83% 0.08 72); + --hero-btn-end: oklch(73% 0.14 58); + + --row-hover: oklch(96.2% 0.02 76); + --row-focus: oklch(95.7% 0.026 68); + --row-open-start: oklch(96.2% 0.03 76); + --row-open-end: oklch(95.4% 0.026 74); + --thead-bg: oklch(98.2% 0.012 80 / 0.97); + + --search-inset: oklch(100% 0 0 / 0.75); + --search-shadow: oklch(18% 0.03 45 / 0.24); + --search-focus-ring: oklch(61% 0.14 48 / 0.45); + --search-focus-shadow: oklch(34% 0.08 42 / 0.28); + + --tag-hover-border: oklch(71% 0.09 62 / 0.45); + --tag-active-start: oklch(82% 0.08 75); + --tag-active-end: oklch(74% 0.11 58); + + --cta-bg: oklch(94% 0.025 72); + + --text-xs: 0.8rem; + --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-sep: oklch(55% 0.02 55); +} + +html { + font-size: 100%; + scroll-behavior: smooth; +} body { - font-family: var(--font-body); - background: var(--bg); - color: var(--text); - line-height: 1.55; min-height: 100vh; display: flex; flex-direction: column; + font-family: var(--font-body); + line-height: 1.6; + color: var(--ink); + background: + radial-gradient(circle at top left, oklch(100% 0 0 / 0.72), transparent 28rem), + linear-gradient(180deg, var(--bg-page-top), var(--bg-page) 24rem, var(--bg-page-end)); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } -a { color: var(--accent); text-decoration: none; text-underline-offset: 0.15em; } -a:hover { color: var(--accent-hover); text-decoration: underline; } +main { + display: flex; + flex-direction: column; +} + +a { + color: inherit; + text-decoration: none; +} + +button, +input { + font: inherit; +} + +img, +svg { + display: block; + max-width: 100%; +} + +kbd { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.8em; + padding: 0.08rem 0.38rem; + border: 1px solid var(--line); + border-bottom-width: 2px; + border-radius: 999px; + background: var(--bg-paper); + font-size: 0.85em; + line-height: 1.2; +} + +.section-shell { + width: min(100%, calc(var(--shell-max) + (var(--shell-pad) * 2))); + margin: 0 auto; + padding-inline: var(--shell-pad); +} -/* === 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); + z-index: 300; + padding: 0.75rem 1rem; + color: var(--hero-text); + background: var(--hero-ink); + font-size: var(--text-sm); font-weight: 700; - z-index: 200; } -.skip-link:focus { left: 0; } +.skip-link:focus { + left: 1rem; + top: 1rem; +} -/* === Hero === */ .hero { - max-width: 1400px; - margin: 0 auto; - padding: 3.5rem 2rem 1.5rem; + position: relative; + width: 100%; + min-height: 100svh; + overflow: clip; + background: + radial-gradient(circle at 18% 18%, oklch(55% 0.14 45 / 0.34), transparent 22rem), + radial-gradient(circle at 78% 32%, oklch(62% 0.17 70 / 0.17), transparent 24rem), + linear-gradient(140deg, var(--hero-bg-start) 0%, var(--hero-bg-mid) 52%, var(--hero-bg-end) 100%); + color: var(--hero-text); } -.hero-main { +.hero::before { + content: ""; + position: absolute; + inset: 0; + background: + linear-gradient(90deg, oklch(100% 0 0 / 0.03) 1px, transparent 1px), + linear-gradient(oklch(100% 0 0 / 0.03) 1px, transparent 1px); + background-size: 7rem 7rem; + mask-image: linear-gradient(180deg, oklch(0% 0 0 / 0.72), transparent 88%); + pointer-events: none; +} + +.hero-sheen, +.hero-noise { + position: absolute; + inset: 0; + pointer-events: none; +} + +.hero-sheen { + background: + linear-gradient(118deg, transparent 35%, oklch(100% 0 0 / 0.09) 49%, transparent 63%); + transform: translateX(-30%); + animation: sheen-drift 18s linear 3; +} + +.hero.offscreen .hero-sheen, +.hero.offscreen .hero-noise { + animation-play-state: paused; +} + +.hero-noise { + opacity: 0.1; + background-image: + radial-gradient(circle at 20% 20%, oklch(100% 0 0 / 0.65) 0.02rem, transparent 0.04rem), + radial-gradient(circle at 80% 30%, oklch(100% 0 0 / 0.55) 0.03rem, transparent 0.05rem); + background-size: 4rem 4rem, 5rem 5rem; +} + +.hero-shell { + position: relative; + z-index: 1; + width: min(100%, calc(var(--shell-max) + (var(--shell-pad) * 2))); + min-height: 100svh; + margin: 0 auto; + padding: 1.25rem var(--shell-pad) 2.4rem; display: flex; - flex-wrap: wrap; + flex-direction: column; + justify-content: space-between; + gap: 2rem; +} + +.hero-topbar { + display: flex; + align-items: center; justify-content: space-between; - align-items: flex-start; gap: 1rem; } -.hero-submit { - flex-shrink: 0; - padding: 0.4rem 1rem; - border: 1px solid var(--border-strong); - border-radius: 4px; +.hero-brand-mini { + font-size: var(--text-xs); + font-weight: 800; + letter-spacing: 0.04em; + color: var(--hero-muted); +} + +.hero-topbar-link:hover { + color: var(--hero-text); +} + +.hero-sub a:hover { + color: var(--hero-text); + text-decoration-color: oklch(100% 0 0 / 0.6); +} + +.hero-topbar-actions { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.hero-topbar-link { + padding: 0.45rem 0.8rem; + border: 1px solid var(--hero-line); + border-radius: 999px; + color: var(--hero-muted); + font-size: var(--text-xs); + font-weight: 700; + letter-spacing: 0.02em; + transition: + color 180ms ease, + border-color 180ms ease, + background-color 180ms ease, + transform 180ms ease; +} + +.hero-topbar-link-strong, +.hero-topbar-link:hover { + border-color: oklch(100% 0 0 / 0.28); + background: oklch(100% 0 0 / 0.06); +} + +.hero-topbar-link:active, +.hero-action:active, +.tag:active, +.filter-clear:active { + transform: translateY(1px); +} + +.hero-grid { + display: grid; + grid-template-columns: minmax(0, 1fr); + align-items: center; + gap: 0; + flex: 1; +} + +.hero-copy { + width: 100%; + max-width: none; + animation: hero-rise 700ms cubic-bezier(0.22, 1, 0.36, 1) both; +} + +.hero-kicker, +.section-label { + margin-bottom: 1.5rem; font-size: var(--text-sm); - color: var(--text); - text-decoration: none; - white-space: nowrap; - transition: border-color 0.2s, background 0.2s, color 0.2s; + font-weight: 800; + letter-spacing: 0.04em; } -.hero-submit:hover { - border-color: var(--accent); - background: var(--accent-light); - color: var(--accent); - text-decoration: none; +.hero-kicker { + color: var(--hero-kicker); } -.hero-submit:active { - transform: scale(0.97); -} - -.hero-submit:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; +.section-label { + color: var(--accent-deep); } .hero h1 { font-family: var(--font-display); - font-size: clamp(2rem, 5vw, 3rem); - font-weight: 400; - letter-spacing: -0.01em; - line-height: 1.1; + font-size: clamp(4.5rem, 11vw, 8.5rem); + line-height: 0.9; + font-weight: 600; + letter-spacing: -0.03em; text-wrap: balance; - color: var(--accent); - margin-bottom: 0.75rem; } .hero-sub { - font-size: var(--text-base); - color: var(--text-secondary); - line-height: 1.6; - margin-bottom: 0.5rem; + margin-top: 1.3rem; + color: var(--hero-muted); + font-size: clamp(1rem, 2vw, 1.18rem); text-wrap: pretty; } -.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-sub a { + color: var(--hero-text); + text-decoration: underline; + text-decoration-color: oklch(100% 0 0 / 0.25); + text-underline-offset: 0.2em; } -.hero-gh:hover { color: var(--accent); } +.hero-actions, +.final-cta-actions { + display: flex; + flex-wrap: wrap; + gap: 0.85rem; +} + +.hero-actions { + margin-top: 1.75rem; +} + +.hero-proof { + margin-top: 1.5rem; + color: var(--hero-proof); + font-size: var(--text-sm); + letter-spacing: 0.02em; +} + +.hero-action { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 3rem; + padding: 0.85rem 1.25rem; + border-radius: 999px; + border: 1px solid transparent; + font-size: var(--text-sm); + font-weight: 700; + letter-spacing: 0.01em; + transition: + transform 180ms ease, + color 180ms ease, + background-color 180ms ease, + border-color 180ms ease, + box-shadow 180ms ease; +} + +.hero-action-primary { + color: var(--hero-ink); + background: linear-gradient(135deg, var(--hero-btn-start), var(--hero-btn-end)); + box-shadow: 0 1.2rem 2.5rem -1.5rem oklch(0% 0 0 / 0.65); +} + +.hero-action-primary:hover { + box-shadow: 0 1.5rem 2.7rem -1.4rem oklch(0% 0 0 / 0.8); +} + +.hero-action-secondary { + color: var(--hero-text); + border-color: var(--hero-line); + background: oklch(100% 0 0 / 0.04); +} + +.hero-action-secondary:hover { + background: oklch(100% 0 0 / 0.08); + border-color: oklch(100% 0 0 / 0.28); +} + +.hero-action:focus-visible, +.hero-topbar-link:focus-visible, +.search:focus-visible, +.filter-clear:focus-visible, +.tag:focus-visible, +.back-to-top:focus-visible, +.no-results-clear:focus-visible, +.footer a:focus-visible, +.sort-btn:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 3px; +} + +.results-intro h2, +.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; +} + +.results-section { + padding-block: clamp(2.5rem, 6vw, 4.5rem) 0; + contain: layout style; +} + +.results-intro { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(18rem, 28rem); + gap: 1.25rem; + align-items: end; + padding-bottom: 1.75rem; +} + +.results-note { + color: var(--ink-soft); + font-size: var(--text-sm); + justify-self: end; + max-width: 28rem; +} -/* === Controls === */ .controls { - max-width: 1400px; - margin: 0 auto; - padding: 0 2rem 1rem; + display: grid; + gap: 0.85rem; + padding-bottom: 1.35rem; } .search-wrap { position: relative; - margin-bottom: 0.75rem; } .search-icon { position: absolute; - left: 1rem; + left: 1.15rem; top: 50%; transform: translateY(-50%); - color: var(--text-muted); + color: var(--ink-muted); pointer-events: none; } .search { width: 100%; - padding: 0.65rem 1rem 0.65rem 2.75rem; + min-height: 4.1rem; + padding: 1rem 1.15rem 1rem 3.2rem; border: 1px solid transparent; - border-radius: 4px; - background: var(--bg-input); - font-family: var(--font-body); - font-size: var(--text-sm); - color: var(--text); - transition: border-color 0.15s, background 0.15s; + border-radius: 999px; + background: linear-gradient(180deg, oklch(100% 0 0 / 0.82), var(--bg-paper)); + color: var(--ink); + font-size: clamp(1rem, 1.4vw, 1.06rem); + box-shadow: + inset 0 1px 0 var(--search-inset), + 0 1.4rem 2.6rem -2.1rem var(--search-shadow); + transition: + border-color 180ms ease, + box-shadow 180ms ease, + background-color 180ms ease; } -.search::placeholder { color: var(--text-muted); } +.search::placeholder { + color: var(--ink-muted); +} .search:focus { - outline: 2px solid var(--accent); - outline-offset: 2px; - border-color: var(--accent); - background: var(--bg); + border-color: var(--search-focus-ring); + box-shadow: + inset 0 1px 0 var(--search-inset), + 0 1.6rem 3rem -2rem var(--search-focus-shadow); } .filter-bar { display: flex; align-items: center; gap: 0.75rem; - padding: 0.5rem 0; + min-height: 2.3rem; font-size: var(--text-sm); - color: var(--text-secondary); + color: var(--ink-soft); opacity: 0; - transform: translateY(-4px); + transform: translateY(-0.4rem); pointer-events: none; - transition: opacity 0.15s ease, transform 0.15s cubic-bezier(0.25, 1, 0.5, 1); + transition: + opacity 180ms ease, + transform 180ms cubic-bezier(0.22, 1, 0.36, 1); } .filter-bar.visible { @@ -194,42 +491,37 @@ a:hover { color: var(--accent-hover); text-decoration: underline; } } .filter-bar strong { - color: var(--text); + color: var(--ink); } .filter-clear { - background: none; - border: 1px solid var(--border); - border-radius: 4px; - padding: 0.35rem 0.65rem; - font-family: inherit; - font-size: var(--text-xs); - color: var(--text-muted); + border: 1px solid var(--line); + border-radius: 999px; + background: var(--bg-paper); + color: var(--ink-soft); + padding: 0.42rem 0.82rem; cursor: pointer; - transition: border-color 0.15s, color 0.15s; -} - -.filter-clear:active { - transform: scale(0.97); + transition: + border-color 180ms ease, + color 180ms ease, + background-color 180ms ease, + transform 180ms ease; } .filter-clear:hover { - border-color: var(--text-muted); - color: var(--text); + color: var(--ink); + background: var(--accent-soft); + border-color: oklch(68% 0.08 58 / 0.5); } -.filter-clear:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; -} - -/* === Table === */ .table-wrap { width: 100%; - padding: 0; + border-top: 1px solid var(--line); + border-bottom: 1px solid var(--line); + scroll-margin-top: 1rem; } -.table-wrap:focus { +.table-wrap:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } @@ -241,162 +533,195 @@ a:hover { color: var(--accent-hover); text-decoration: underline; } 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; - box-shadow: 0 1px 0 var(--border); +.table thead th, +.table tbody td { + padding-inline: 0.9rem; } .table thead th:first-child, .table tbody td:first-child { - padding-left: max(2rem, calc(50vw - 700px + 2rem)); + padding-left: max(var(--shell-pad), calc(50vw - (var(--shell-max) / 2) + var(--shell-pad))); } .table thead th:last-child, .table tbody td:last-child { - padding-right: max(2rem, calc(50vw - 700px + 2rem)); + padding-right: max(var(--shell-pad), calc(50vw - (var(--shell-max) / 2) + var(--shell-pad))); +} + +.table thead th { + position: sticky; + top: 0; + z-index: 12; + padding-top: 1rem; + padding-bottom: 0.95rem; + text-align: left; + color: var(--ink); + font-size: var(--text-xs); + font-weight: 800; + letter-spacing: 0.03em; + white-space: nowrap; + border-bottom: 1px solid var(--line); + background: var(--thead-bg); } .table tbody td { - padding: 0.7rem 0.75rem; - border-bottom: 1px solid var(--border); - vertical-align: top; - transition: background 0.15s; + padding-top: 1rem; + padding-bottom: 1rem; + vertical-align: middle; + border-bottom: 1px solid var(--line); + transition: + background-color 180ms ease, + border-color 180ms ease; } -.table tbody tr.row:not(.open):hover td { - background: var(--bg-hover); +.table tbody tr[hidden] { + display: none; } -.table tbody tr[hidden] { display: none; } +.row { + cursor: pointer; +} + +.row:not(.open):hover td { + background: var(--row-hover); +} + +.row:focus-visible td { + background: var(--row-focus); + box-shadow: inset 3px 0 0 var(--accent); +} + +.row.open td { + background: linear-gradient(180deg, var(--row-open-start), var(--row-open-end)); + border-bottom-color: transparent; +} .col-num { - width: 3rem; - color: var(--text-muted); + width: 3.5rem; + color: var(--ink-muted); font-variant-numeric: tabular-nums; - text-align: left; } .col-name { - width: 30%; - overflow-wrap: anywhere; + white-space: nowrap; +} + +.mobile-cat { + display: none; } .col-name > a { - font-weight: 500; - color: var(--accent); - text-decoration: none; + 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 { text-decoration: underline; color: var(--accent-hover); } +.col-name > a:hover { + color: var(--accent-deep); + text-decoration: underline; + text-decoration-color: var(--accent-underline); + text-underline-offset: 0.2em; +} -/* === Sortable Headers === */ th[data-sort] { cursor: pointer; user-select: none; -} - -th[data-sort] { - transition: color 0.15s ease; + transition: color 180ms ease; } th[data-sort]:hover { - color: var(--accent); + color: var(--accent-deep); } -th[data-sort]:active { - color: var(--accent-hover); +.sort-btn { + background: none; + border: 0; + padding: 0; + color: inherit; + font: inherit; + cursor: inherit; } th[data-sort]::after { - content: " ▼"; + content: " \2193"; opacity: 0; - transition: opacity 0.15s; + transition: opacity 180ms ease; } th[data-sort="name"]::after { - content: " ▲"; + content: " \2191"; } -th[data-sort]:hover::after { +th[data-sort]:hover::after, +th[data-sort].sort-asc::after, +th[data-sort].sort-desc::after { opacity: 1; } th[data-sort].sort-desc::after { - content: " ▼"; - opacity: 1; + content: " \2193"; } th[data-sort].sort-asc::after { - content: " ▲"; - opacity: 1; + content: " \2191"; } -/* === Stars Column === */ .col-stars { - width: 5rem; - font-variant-numeric: tabular-nums; - white-space: nowrap; - color: var(--text-secondary); + width: 7rem; text-align: right; + white-space: nowrap; + font-variant-numeric: tabular-nums; + color: var(--ink-soft); } -/* === Source Badges === */ .source-badge { - display: inline-block; - font-size: 0.75rem; - font-weight: 600; - letter-spacing: 0.03em; - color: var(--text-muted); - background: var(--bg-input); - padding: 0.15rem 0.45rem; - border-radius: 3px; + display: inline-flex; + align-items: center; + min-height: 1.8rem; + padding: 0.18rem 0.6rem; + border-radius: 999px; + background: var(--bg-paper-strong); + color: var(--ink-soft); + font-size: var(--text-xs); + font-weight: 700; + letter-spacing: 0.02em; +} + +.col-commit { + width: 9rem; + white-space: nowrap; + color: var(--ink-muted); +} + +.col-cat { white-space: nowrap; } -.tag-source { - background: var(--bg-input); - color: var(--text-muted); - font-weight: 600; -} - -/* === Arrow Column === */ .col-arrow { - width: 2.5rem; + width: 3rem; text-align: center; } .arrow { display: inline-block; - font-size: 0.8rem; + color: var(--accent-deep); + font-size: 0.9rem; + transition: + transform 180ms ease, + color 180ms ease; +} + +.row:hover .arrow, +.row.open .arrow { color: var(--accent); - transition: transform 0.15s ease; } .row.open .arrow { transform: rotate(90deg); } -/* === Row Click === */ -.row { cursor: pointer; } -.row:active td { background: var(--bg-hover); } - -.row:focus-visible td { - outline: none; - background: var(--bg-hover); - box-shadow: inset 2px 0 0 var(--accent); -} - -/* === Expand Row === */ .expand-row { display: none; } @@ -405,157 +730,119 @@ th[data-sort].sort-asc::after { 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); -} - -@keyframes expand-in { - from { - opacity: 0; - transform: translateY(-4px); - } - to { - opacity: 1; - transform: translateY(0); - } + padding-top: 0.1rem; + padding-bottom: 1.15rem; + background: linear-gradient(180deg, var(--row-open-start), var(--row-open-end)); + border-bottom: 1px solid var(--line); } .expand-content { - font-size: var(--text-sm); - color: var(--text-secondary); - line-height: 1.6; + font-size: var(--text-base); + color: var(--ink-soft); + line-height: 1.7; text-wrap: pretty; - animation: expand-in 0.2s cubic-bezier(0.25, 1, 0.5, 1); + overflow-wrap: break-word; + word-break: break-word; + contain: layout style paint; + animation: expand-in 220ms cubic-bezier(0.22, 1, 0.36, 1); } -.expand-tags { - display: flex; - gap: 0.4rem; - margin-bottom: 0.4rem; +.expand-desc a, +.expand-also-see a, +.expand-meta a, +.footer a { + color: var(--accent-deep); } -.expand-tag { - font-size: var(--text-xs); - color: var(--tag-text); - background: var(--bg); - padding: 0.15rem 0.4rem; - border-radius: 3px; -} - -.expand-also-see { - margin-top: 0.25rem; - font-size: var(--text-xs); - color: var(--text-muted); -} - -.expand-also-see a { +.expand-desc a:hover, +.expand-also-see a:hover, +.expand-meta a:hover { color: var(--accent); - text-decoration: none; -} - -.expand-also-see a:hover { text-decoration: underline; + text-decoration-color: var(--accent-underline); + text-underline-offset: 0.2em; } +.expand-also-see, .expand-meta { - margin-top: 0.25rem; - font-size: var(--text-xs); - color: var(--text-muted); - font-weight: normal; + margin-top: 0.45rem; + font-size: var(--text-sm); + color: var(--ink-muted); } .expand-meta a { - color: var(--accent); - text-decoration: none; -} - -.expand-meta a:hover { - text-decoration: underline; + display: inline-block; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + vertical-align: bottom; } .expand-sep { - margin: 0 0.25rem; - color: var(--border); + margin-inline: 0.25rem; + color: var(--line-strong); } -.col-cat { - white-space: nowrap; +.expand-commit { + display: none; } -.col-cat .tag + .tag { - margin-left: 0.35rem; -} - -/* === Last Commit Column === */ -.col-commit { - width: 9rem; - white-space: nowrap; - color: var(--text-muted); -} - -/* === Tags === */ .tag { position: relative; - background: var(--accent-light); - border: none; - font-family: inherit; - font-size: var(--text-xs); - color: var(--tag-text); + border: 1px solid transparent; + border-radius: 999px; + background: var(--accent-soft); + color: var(--accent-deep); + padding: 0.14rem 0.48rem; + font-size: 0.6rem; + font-weight: 700; + letter-spacing: 0.02em; cursor: pointer; - padding: 0.25rem 0.5rem; - border-radius: 3px; - white-space: nowrap; - transition: background 0.15s, color 0.15s; + transition: + color 180ms ease, + background-color 180ms ease, + border-color 180ms ease, + transform 180ms ease; +} + +.tag + .tag { + margin-left: 0.2rem; } -/* Expand touch target to 44x44px minimum */ .tag::after { content: ""; position: absolute; inset: -0.5rem -0.25rem; -} - -.tag:active { - transform: scale(0.95); + min-height: 44px; + min-width: 44px; } .tag:hover { - background: var(--tag-hover-bg); - color: var(--accent); -} - -.tag:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 1px; + background: var(--highlight); + border-color: var(--tag-hover-border); + color: var(--ink); } .tag.active { - background: var(--highlight); - color: var(--highlight-text); - font-weight: 600; + background: linear-gradient(135deg, var(--tag-active-start), var(--tag-active-end)); + color: var(--hero-ink); } -/* === Back to Top === */ .back-to-top { + border: 0; background: none; - border: none; - padding: 0; - font-family: var(--font-body); - font-size: 0.8rem; - font-weight: 600; - color: var(--accent); + color: var(--accent-deep); + font-size: var(--text-xs); + font-weight: 800; + letter-spacing: 0.03em; cursor: pointer; opacity: 0; - transition: opacity 0.15s ease, color 0.15s; pointer-events: none; + transition: + opacity 180ms ease, + color 180ms ease; } .back-to-top.visible { @@ -564,92 +851,130 @@ th[data-sort].sort-asc::after { } .back-to-top:hover { - color: var(--accent-hover); + color: var(--accent); } -.back-to-top:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; -} - -/* === Noscript === */ -.noscript-msg { - text-align: center; - padding: 1rem; - color: var(--text-muted); -} - -/* === No Results === */ .no-results { - max-width: 1400px; - margin: 0 auto; - padding: 3rem 2rem; - font-size: var(--text-base); - color: var(--text-muted); + padding: 2.4rem var(--shell-pad); text-align: center; + color: var(--ink-muted); + 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: var(--accent-underline); + text-underline-offset: 0.2em; +} + +.no-results-clear:hover { + color: var(--accent); +} + +.final-cta { + padding-block: clamp(3rem, 7vw, 5.5rem); + background: var(--cta-bg); +} + +.final-cta > .section-shell { + display: grid; + gap: 1rem; +} + +.final-cta p { + color: var(--ink-soft); + font-size: clamp(1rem, 1.6vw, 1.08rem); +} + +.final-cta .hero-action-primary { + color: var(--hero-text); + background: linear-gradient(135deg, var(--accent), var(--accent-deep)); +} + +.final-cta .hero-action-secondary { + color: var(--ink); + background: transparent; + border-color: var(--line-strong); +} + +.final-cta .hero-action-secondary:hover { + background: var(--accent-soft); + border-color: var(--accent); } -/* === 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); + background: var(--footer-bg); + padding: 3rem var(--shell-pad); display: flex; align-items: center; - justify-content: flex-end; - gap: 0.5rem; + justify-content: space-between; + gap: 1rem; + font-size: var(--text-sm); + color: var(--footer-text); } -.footer a { color: var(--accent); text-decoration: none; } -.footer a:hover { color: var(--accent-hover); text-decoration: underline; } - -.footer-sep { color: var(--border-strong); } - -/* === Responsive === */ -@media (max-width: 900px) { - .col-commit { display: none; } - .tag-group { display: none; } - .col-name { width: 50%; } +.footer a { + color: var(--footer-link); } -@media (max-width: 640px) { - .table-wrap { overflow-x: auto; } - .table thead th { position: static; } - .hero { padding: 2rem 1.25rem 1rem; } - .controls { padding: 0 1.25rem 0.75rem; } - - .table { table-layout: auto; } - - .table thead th, - .table tbody td { - padding-left: 0.5rem; - padding-right: 0.5rem; - } - - .table thead th:first-child, - .table tbody td:first-child { padding-left: 0.25rem; } - - .table thead th:last-child, - .table tbody td:last-child { padding-right: 0.25rem; } - - .table thead th { font-size: var(--text-sm); } - - .col-num { width: 2rem; } - .col-stars { width: 4.75rem; } - .col-arrow { width: 1.25rem; } - .col-cat { display: none; } - .col-name { - width: auto; - white-space: normal; - } - .footer { padding: 1.25rem; justify-content: center; flex-wrap: wrap; } +.footer a:hover { + color: var(--footer-link-hover); + text-decoration: underline; + text-decoration-color: oklch(95% 0.01 80 / 0.4); + 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: var(--footer-link); +} + +.footer-links { + display: block; + text-align: right; +} + +.footer-sep { + color: var(--footer-sep); +} + +.noscript-msg { + padding: 1rem var(--shell-pad) 0; + text-align: center; + color: var(--ink-muted); +} + +[data-reveal].will-reveal { + opacity: 0; + transform: translateY(1.8rem); +} + +[data-reveal].will-reveal.is-visible { + opacity: 1; + transform: translateY(0); + transition: + opacity 600ms ease, + transform 600ms cubic-bezier(0.22, 1, 0.36, 1); } -/* === Screen Reader Only === */ .sr-only { position: absolute; width: 1px; @@ -662,8 +987,173 @@ th[data-sort].sort-asc::after { border: 0; } -/* === Reduced Motion === */ +@keyframes hero-rise { + from { + opacity: 0; + transform: translateY(1.3rem); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes expand-in { + from { + opacity: 0; + transform: translateY(-0.4rem); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes sheen-drift { + from { + transform: translateX(-30%); + } + to { + transform: translateX(35%); + } +} + +@media (max-width: 960px) { + .hero-shell { + min-height: auto; + padding-bottom: 2rem; + } + + .hero-grid, + .results-intro { + grid-template-columns: 1fr; + } + + .results-note { + justify-self: start; + } + + .col-commit { + display: none; + } + + .expand-commit { + display: inline; + } + + .tag-group { + display: none; + } + + .tag { + padding: 0.38rem 0.65rem; + font-size: 0.65rem; + } + + .table-wrap { + overflow-x: clip; + } +} + +@media (max-width: 680px) { + .hero { + min-height: auto; + } + + .hero-topbar { + gap: 0.75rem; + } + + .footer { + flex-direction: column; + align-items: center; + text-align: center; + } + + .hero-actions, + .final-cta-actions { + width: 100%; + } + + .hero-topbar-actions { + width: auto; + flex: 0 0 auto; + } + + .hero-topbar-link { + width: auto; + white-space: nowrap; + } + + .hero-action { + width: 100%; + } + + .hero h1 { + font-size: clamp(3.6rem, 18vw, 5.2rem); + } + + .search { + min-height: 3.5rem; + border-radius: 1.25rem; + } + + .table thead th, + .table tbody td { + padding-inline: 0.55rem; + } + + .table thead th:first-child, + .table tbody td:first-child { + padding-left: 0.8rem; + } + + .table thead th:last-child, + .table tbody td:last-child { + padding-right: 0.8rem; + } + + .col-num, + .col-cat { + display: none; + } + + .expand-row td:first-child, + .expand-row td:last-child { + display: none; + } + + .col-name { + white-space: normal; + } + + .mobile-cat { + display: block; + margin-top: 0.25rem; + font-size: var(--text-xs); + font-weight: 600; + color: var(--ink-muted); + } + + .expand-row td[colspan] { + padding-left: 0.8rem; + padding-right: 0.8rem; + } + + .col-stars { + width: 5.4rem; + } + + .col-arrow { + width: 1.8rem; + } +} + @media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } + *, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; diff --git a/website/templates/base.html b/website/templates/base.html index ea7c7e5..34546e7 100644 --- a/website/templates/base.html +++ b/website/templates/base.html @@ -6,18 +6,36 @@ {% block title %}Awesome Python{% endblock %} + + + + + + diff --git a/website/templates/index.html b/website/templates/index.html index 5018085..1003c1a 100644 --- a/website/templates/index.html +++ b/website/templates/index.html @@ -1,177 +1,274 @@ -{% extends "base.html" %} {% block content %} +{% extends "base.html" %} +{% block header %}
-
-
-

Awesome Python

-

- {{ subtitle }}
Maintained by - @vinta - and +

+ + +
+ + +
+
+

The field guide to the Python ecosystem

+

Awesome Python

+

+ {{ subtitle }}
Maintained by + @vinta + and + @JinyangWang27. +

+ + + + {% if repo_stars or build_date %} +

+ {% if repo_stars %}{{ repo_stars }}+ stars on GitHub{% endif %} {% if + repo_stars and build_date %}/{% endif %} {% if build_date %}Updated {{ + build_date }}{% endif %} +

+ {% endif %} +
- Submit a Project
- -

Search and filter

-
-
- - - - - -
-
- Showing - -
-
- -

Results

-
- - - - - - - - - - - - - {% for entry in entries %} - - - - - - - - - - - - - - {% endfor %} - -
#Project NameGitHub StarsLast CommitCategory - -
-
- {% 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 %} - -
-
-
- - +{% endblock %} +{% block content %} +
+
+
+

Search every project in one place

+
+

+ Press / to search. Tap a tag to filter. Click any row for + details. +

+
+ +
+

Search and filter

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

Results

+
+ + + + + + + + + + + + + {% for entry in entries %} + + + + + + + + + + + + + + {% endfor %} + +
Row number + + + + + + Tags + +
+
+ {% 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.last_commit_at %}/{% endif %} +
+
+
+
+ + +
+ +
+
+ +

Know a project that belongs here?

+

Tell us what it does and why it stands out.

+ +
+
{% endblock %} diff --git a/website/tests/test_build.py b/website/tests/test_build.py index 6302c3d..3b40660 100644 --- a/website/tests/test_build.py +++ b/website/tests/test_build.py @@ -7,12 +7,14 @@ from pathlib import Path from build import ( build, + detect_source_type, + extract_entries, extract_github_repo, - group_categories, + format_stars_short, load_stars, sort_entries, ) -from readme_parser import slugify +from readme_parser import parse_readme, slugify # --------------------------------------------------------------------------- # slugify @@ -42,40 +44,6 @@ class TestSlugify: 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) # --------------------------------------------------------------------------- @@ -94,19 +62,13 @@ class TestBuild: ) (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", ) @@ -365,3 +327,133 @@ class TestSortEntries: ] result = sort_entries(entries) assert [e["name"] for e in result] == ["apple", "zebra"] + + def test_builtin_between_starred_and_unstarred(self): + entries = [ + {"name": "builtin", "stars": None, "source_type": "Built-in"}, + {"name": "starred", "stars": 100, "source_type": None}, + {"name": "unstarred", "stars": None, "source_type": None}, + ] + result = sort_entries(entries) + assert [e["name"] for e in result] == ["starred", "builtin", "unstarred"] + + +# --------------------------------------------------------------------------- +# detect_source_type +# --------------------------------------------------------------------------- + + +class TestDetectSourceType: + def test_github_repo_returns_none(self): + assert detect_source_type("https://github.com/psf/requests") is None + + def test_stdlib_url(self): + assert detect_source_type("https://docs.python.org/3/library/asyncio.html") == "Built-in" + + def test_gitlab_url(self): + assert detect_source_type("https://gitlab.com/org/repo") == "GitLab" + + def test_bitbucket_url(self): + assert detect_source_type("https://bitbucket.org/org/repo") == "Bitbucket" + + def test_non_github_external(self): + assert detect_source_type("https://example.com/tool") == "External" + + def test_github_non_repo_returns_none(self): + assert detect_source_type("https://github.com/org/repo/wiki") is None + + +# --------------------------------------------------------------------------- +# format_stars_short +# --------------------------------------------------------------------------- + + +class TestFormatStarsShort: + def test_under_1000(self): + assert format_stars_short(500) == "500" + + def test_exactly_1000(self): + assert format_stars_short(1000) == "1k" + + def test_large_number(self): + assert format_stars_short(52000) == "52k" + + def test_zero(self): + assert format_stars_short(0) == "0" + + +# --------------------------------------------------------------------------- +# extract_entries +# --------------------------------------------------------------------------- + + +class TestExtractEntries: + def test_basic_extraction(self): + readme = textwrap.dedent("""\ + # T + + --- + + **Tools** + + ## Widgets + + - [widget](https://example.com) - A widget. + + # Contributing + + Done. + """) + groups = parse_readme(readme) + categories = [c for g in groups for c in g["categories"]] + entries = extract_entries(categories, groups) + assert len(entries) == 1 + assert entries[0]["name"] == "widget" + assert entries[0]["categories"] == ["Widgets"] + assert entries[0]["groups"] == ["Tools"] + + def test_duplicate_entry_merged(self): + readme = textwrap.dedent("""\ + # T + + --- + + **Tools** + + ## Alpha + + - [shared](https://example.com/shared) - Shared lib. + + ## Beta + + - [shared](https://example.com/shared) - Shared lib. + + # Contributing + + Done. + """) + groups = parse_readme(readme) + categories = [c for g in groups for c in g["categories"]] + entries = extract_entries(categories, groups) + shared = [e for e in entries if e["name"] == "shared"] + assert len(shared) == 1 + assert sorted(shared[0]["categories"]) == ["Alpha", "Beta"] + + def test_source_type_detected(self): + readme = textwrap.dedent("""\ + # T + + --- + + ## Stdlib + + - [asyncio](https://docs.python.org/3/library/asyncio.html) - Async I/O. + + # Contributing + + Done. + """) + groups = parse_readme(readme) + categories = [c for g in groups for c in g["categories"]] + entries = extract_entries(categories, groups) + assert entries[0]["source_type"] == "Built-in" diff --git a/website/tests/test_fetch_github_stars.py b/website/tests/test_fetch_github_stars.py index 6c9eb38..10d6478 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, @@ -138,6 +135,24 @@ class TestParseGraphqlResponse: assert result["a/x"]["stars"] == 100 assert result["b/y"]["stars"] == 200 + def test_extracts_last_commit_at(self): + data = { + "repo_0": { + "stargazerCount": 100, + "owner": {"login": "org"}, + "defaultBranchRef": {"target": {"committedDate": "2025-06-01T00:00:00Z"}}, + } + } + repos = ["org/repo"] + result = parse_graphql_response(data, repos) + assert result["org/repo"]["last_commit_at"] == "2025-06-01T00:00:00Z" + + def test_missing_default_branch_ref(self): + data = {"repo_0": {"stargazerCount": 50, "owner": {"login": "org"}}} + repos = ["org/repo"] + result = parse_graphql_response(data, repos) + assert result["org/repo"]["last_commit_at"] == "" + class TestMainSkipsFreshCache: """Verify that main() skips fetching when all cache entries are fresh.""" @@ -163,7 +178,13 @@ class TestMainSkipsFreshCache: "owner": "psf", "last_commit_at": "2025-01-01T00:00:00+00:00", "fetched_at": (now - timedelta(hours=1)).isoformat(), - } + }, + "vinta/awesome-python": { + "stars": 230000, + "owner": "vinta", + "last_commit_at": "2025-01-01T00:00:00+00:00", + "fetched_at": (now - timedelta(hours=1)).isoformat(), + }, } cache_file.write_text(json.dumps(fresh_cache), encoding="utf-8") monkeypatch.setattr("fetch_github_stars.CACHE_FILE", cache_file) @@ -198,7 +219,13 @@ class TestMainSkipsFreshCache: "owner": "psf", "last_commit_at": "2025-01-01T00:00:00+00:00", "fetched_at": (now - timedelta(hours=24)).isoformat(), - } + }, + "vinta/awesome-python": { + "stars": 230000, + "owner": "vinta", + "last_commit_at": "2025-01-01T00:00:00+00:00", + "fetched_at": (now - timedelta(hours=24)).isoformat(), + }, } cache_file.write_text(json.dumps(stale_cache), encoding="utf-8") monkeypatch.setattr("fetch_github_stars.CACHE_FILE", cache_file) @@ -213,7 +240,12 @@ class TestMainSkipsFreshCache: "stargazerCount": 53000, "owner": {"login": "psf"}, "defaultBranchRef": {"target": {"committedDate": "2025-06-01T00:00:00Z"}}, - } + }, + "repo_1": { + "stargazerCount": 231000, + "owner": {"login": "vinta"}, + "defaultBranchRef": {"target": {"committedDate": "2025-06-01T00:00:00Z"}}, + }, } } mock_response.raise_for_status = MagicMock() @@ -226,6 +258,6 @@ class TestMainSkipsFreshCache: main() output = capsys.readouterr().out - assert "1 repos to fetch" in output - assert "Done. Fetched 1 repos" in output + assert "2 repos to fetch" in output + assert "Done. Fetched 2 repos" in output mock_client.post.assert_called_once() diff --git a/website/tests/test_readme_parser.py b/website/tests/test_readme_parser.py index d365c45..cea5cbb 100644 --- a/website/tests/test_readme_parser.py +++ b/website/tests/test_readme_parser.py @@ -7,7 +7,6 @@ import pytest from readme_parser import ( _parse_section_entries, - _render_section_html, parse_readme, render_inline_html, render_inline_text, @@ -159,50 +158,39 @@ GROUPED_README = textwrap.dedent("""\ 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 @@ class TestParseReadmeSections: 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 @@ class TestParseReadmeSections: 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 @@ class TestParseGroupedReadme: Done. """) - groups, _ = parse_readme(readme) + groups = parse_readme(readme) assert len(groups) == 1 assert groups[0]["name"] == "HasCats" @@ -319,7 +302,7 @@ class TestParseGroupedReadme: 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 @@ class TestParseGroupedReadme: 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 @@ class TestParseSectionEntries: 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 @@ class TestParseSectionEntries: 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 "