Merge pull request #3106 from vinta/feature/better-seo-2

Static category pages and path-based filter URLs
This commit is contained in:
Vinta Chen
2026-05-03 12:38:12 +08:00
committed by GitHub
18 changed files with 1810 additions and 294 deletions

View File

@@ -10,6 +10,15 @@ fetch_github_stars:
test:
uv run pytest website/tests/ -v
lint:
uv run ruff check .
format:
uv run ruff format .
typecheck:
uv run ty check website
build:
uv run python website/build.py

View File

@@ -117,7 +117,7 @@ An opinionated guide to the best Python frameworks, libraries, tools, and resour
- [Penetration Testing](#penetration-testing)
- [Web Security](#web-security)
**Miscellaneous**
**Other**
- [Hardware](#hardware)
- [Microsoft Windows](#microsoft-windows)
@@ -1098,7 +1098,7 @@ _Libraries for application-layer web security._
- [secure](https://github.com/TypeError/secure) - HTTP security headers for Python web applications with ASGI and WSGI middleware.
**Miscellaneous**
**Other**
## Hardware

View File

@@ -37,7 +37,7 @@ Your sponsorship puts your product in front of developers at the exact moment th
## Get Started
Email [vinta.chen@gmail.com](mailto:vinta.chen@gmail.com?subject=awesome-python%20Sponsorship) with:
Email [vinta.chen@gmail.com](mailto:vinta.chen@gmail.com?subject=Awesome%20Python%20Sponsorship) with:
- **Tier:** Headline Sponsor ($500/mo) or Featured Sponsor ($150/mo)
- **Content:** Product name, URL, logo, and description (Headline tier) or `[Name](URL) - Description.` entry (Featured tier)

View File

@@ -14,7 +14,7 @@ Repository = "https://github.com/vinta/awesome-python"
[dependency-groups]
build = ["httpx==0.28.1", "jinja2==3.1.6", "markdown-it-py==4.0.0"]
lint = ["ruff==0.15.6"]
lint = ["ruff==0.15.6", "ty==0.0.33"]
test = ["pytest==9.0.3"]
dev = [
{ include-group = "build" },
@@ -23,16 +23,30 @@ dev = [
"watchdog==6.0.0",
]
[tool.pytest.ini_options]
testpaths = ["website/tests"]
pythonpath = ["website"]
[tool.ruff]
line-length = 200
[tool.uv]
exclude-newer = "3 days"
no-build = true
[tool.uv.pip]
only-binary = [":all:"]
[tool.ruff]
line-length = 200
[tool.ty.environment]
python-version = "3.13"
root = ["website"]
[tool.ty.terminal]
error-on-warning = true
[tool.ty.rules]
division-by-zero = "error"
possibly-missing-attribute = "error"
possibly-missing-import = "error"
possibly-unresolved-reference = "error"
unused-ignore-comment = "error"
[tool.pytest.ini_options]
testpaths = ["website/tests"]
pythonpath = ["website"]

34
uv.lock generated
View File

@@ -3,7 +3,7 @@ revision = 3
requires-python = ">=3.13"
[options]
exclude-newer = "2026-04-18T18:21:23.412234Z"
exclude-newer = "2026-04-30T04:22:17.540198Z"
exclude-newer-span = "P3D"
[[package]]
@@ -35,10 +35,12 @@ dev = [
{ name = "markdown-it-py" },
{ name = "pytest" },
{ name = "ruff" },
{ name = "ty" },
{ name = "watchdog" },
]
lint = [
{ name = "ruff" },
{ name = "ty" },
]
test = [
{ name = "pytest" },
@@ -58,9 +60,13 @@ dev = [
{ name = "markdown-it-py", specifier = "==4.0.0" },
{ name = "pytest", specifier = "==9.0.3" },
{ name = "ruff", specifier = "==0.15.6" },
{ name = "ty", specifier = "==0.0.33" },
{ name = "watchdog", specifier = "==6.0.0" },
]
lint = [{ name = "ruff", specifier = "==0.15.6" }]
lint = [
{ name = "ruff", specifier = "==0.15.6" },
{ name = "ty", specifier = "==0.0.33" },
]
test = [{ name = "pytest", specifier = "==9.0.3" }]
[[package]]
@@ -289,6 +295,30 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/d0/578c47dd68152ddddddf31cd7fc67dc30b7cdf639a86275fda821b0d9d98/ruff-0.15.6-py3-none-win_arm64.whl", hash = "sha256:c34de3dd0b0ba203be50ae70f5910b17188556630e2178fd7d79fc030eb0d837", size = 11060497, upload-time = "2026-03-12T23:05:25.968Z" },
]
[[package]]
name = "ty"
version = "0.0.33"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/84/44/9478c50c266826c1bf30d1692e589755bffa8f1c0a3eb7af8a346c255991/ty-0.0.33.tar.gz", hash = "sha256:46d63bda07403322cb6c28ccfdd5536be916e13df725c29f7ccd0a21f06bd9e8", size = 5559373, upload-time = "2026-04-28T10:45:13.18Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/24/e287388c63a19191be26b32ff4dbd06029834068150ebe2532939bc4c851/ty-0.0.33-py3-none-linux_armv6l.whl", hash = "sha256:94d0a9d2234261a8911396d59e506b5923fe0971dbda43b9dcea287936887fcc", size = 11021308, upload-time = "2026-04-28T10:45:43.34Z" },
{ url = "https://files.pythonhosted.org/packages/00/ca/ba1eed819895bd239fba8ee35dfcd5fcb266c203b0914a17a59579096bb5/ty-0.0.33-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e4a2b5ba078f90de342f56b5f7979bb77c9b9b1d8625a041352ffc6ee93c4073", size = 10777272, upload-time = "2026-04-28T10:45:32.905Z" },
{ url = "https://files.pythonhosted.org/packages/25/a8/c3131d37b44b3fea1d6654a1c929a0cd0873822f77a90482b8ec28f6fbbd/ty-0.0.33-py3-none-macosx_11_0_arm64.whl", hash = "sha256:84ff5707825e9af9668d2bcf66975f93e520a63b524ab494e3a8265735be2563", size = 10201078, upload-time = "2026-04-28T10:45:23.374Z" },
{ url = "https://files.pythonhosted.org/packages/7b/db/d8e37ff0045810cc65e1ff36aa0da0a2253c05659787ac987df8a16c7897/ty-0.0.33-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e375285736f57886868e7af0b11c7b0ec5b6543fa15e7ad2a714fed9f077d4e0", size = 10732347, upload-time = "2026-04-28T10:45:21.444Z" },
{ url = "https://files.pythonhosted.org/packages/e0/1a/20e83a412506a918e4684fc67b567cf7cc13b105470b3428cb23c3d5aa13/ty-0.0.33-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5680f6350c3b4e46b8bff6d7bb132366ea239463d6cad4892725d06046e65464", size = 10808238, upload-time = "2026-04-28T10:45:38.565Z" },
{ url = "https://files.pythonhosted.org/packages/5d/4b/d0a39f4464dc6cb4cc2c159473ce216bd1846bfb684c0323a3cb36dce5c6/ty-0.0.33-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c5535538bad8d0f7e62bcdff02197cdb30e41451d80b35d27e17d128f2e1dc5d", size = 11288348, upload-time = "2026-04-28T10:45:08.419Z" },
{ url = "https://files.pythonhosted.org/packages/35/7e/f1745e0f9583363d7a83d9a4990fc244f76ecc30840ddad83dc16a33c52d/ty-0.0.33-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:da196c42bbbc069e1e21e3e52107c061aa9660352dae57a41930690b56e2c02d", size = 11789907, upload-time = "2026-04-28T10:45:19.064Z" },
{ url = "https://files.pythonhosted.org/packages/a5/71/25f39f46a12d662859d45bc648555d0661044eb43db6b5648c9947487da9/ty-0.0.33-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9281672921ef6d4460e03146b5e6c18cb1a3e3a3b8a1a88f6f33226d05a469b7", size = 11500774, upload-time = "2026-04-28T10:45:48.012Z" },
{ url = "https://files.pythonhosted.org/packages/94/ec/136959ecbb7c71cb90537f5aea441c73f4ab24612868a6ecdc9d7444d32d/ty-0.0.33-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82c1b8f303f82da64e878108e764be3ecbcd7c9903ac0a7f7031614ed00b97ab", size = 11360314, upload-time = "2026-04-28T10:45:05.402Z" },
{ url = "https://files.pythonhosted.org/packages/cf/95/32809575c222f00beed498cb728e9290a0f5009f930025381bb7253b2206/ty-0.0.33-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:efe3af412c9ff67bce5fa37d0a2b0d8555c24072b145a5bac6c79637f1c83abe", size = 10707785, upload-time = "2026-04-28T10:45:10.836Z" },
{ url = "https://files.pythonhosted.org/packages/13/89/c8e9531f7aa4a093359e15fa32c8e1277fbbe90d16894d7c6032d29f4b34/ty-0.0.33-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:aeec29c91ea768601747da546c3efc20b72c2fb1bd52bcc786a5c6eeff51d27b", size = 10834987, upload-time = "2026-04-28T10:45:40.738Z" },
{ url = "https://files.pythonhosted.org/packages/31/16/9835fbcf5338af1a1917bd28fdb8a7193c210b83f243aa286fa9f79cb3ad/ty-0.0.33-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a535977c52bbb5f7e96b8b70a6ad375ad077f4a9ff2492508ea3816a2b403819", size = 10968968, upload-time = "2026-04-28T10:45:30.26Z" },
{ url = "https://files.pythonhosted.org/packages/36/69/64c76aabc1bc70c7f24b686cd93c3407f8ea430905e395f59bf9603ef571/ty-0.0.33-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1d732facf39fcb221ba279d469c5040d37883e964f123b1563888efd34818180", size = 11458077, upload-time = "2026-04-28T10:45:45.971Z" },
{ url = "https://files.pythonhosted.org/packages/91/84/fae27b0c4718776a298690d31ca4cc1995f2e3e1c63a7b59e84c41498e9a/ty-0.0.33-py3-none-win32.whl", hash = "sha256:d90960b574428dc252f85e8598ec5fcb7f619794196b2fc95a90da075ed4681c", size = 10345364, upload-time = "2026-04-28T10:45:16.836Z" },
{ url = "https://files.pythonhosted.org/packages/3c/a0/a2938b23ae3e1a09a2d7c189e2ac5f7113676bae4e0e23948b568e18e5f8/ty-0.0.33-py3-none-win_amd64.whl", hash = "sha256:c1c3aec62c44de610c6e95f0a4e97ac3dbc07934bfdbf1fd90d758c9ff72f48e", size = 11342470, upload-time = "2026-04-28T10:45:26.455Z" },
{ url = "https://files.pythonhosted.org/packages/ab/62/7fb948aace38d2f6329261bb33c035a8484549c74f1db28649c7a4c6fed9/ty-0.0.33-py3-none-win_arm64.whl", hash = "sha256:0d44f99ba1b441e55e2aa301b2ac0a21112784931b46a5f66f4ea9efe5620d97", size = 10742673, upload-time = "2026-04-28T10:45:35.555Z" },
]
[[package]]
name = "watchdog"
version = "6.0.0"

View File

@@ -5,13 +5,14 @@ import json
import re
import shutil
import xml.etree.ElementTree as ET
from collections import Counter
from collections.abc import Sequence
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from typing import TypedDict
from jinja2 import Environment, FileSystemLoader
from readme_parser import ParsedGroup, ParsedSection, parse_readme, parse_sponsors
from readme_parser import AlsoSee, ParsedGroup, ParsedSection, parse_readme, parse_sponsors, slugify
GITHUB_REPO_URL_RE = re.compile(r"^https?://github\.com/([^/]+/[^/]+?)(?:\.git)?/?$")
MARKDOWN_LINK_RE = re.compile(r"\[[^\]]+\]\(([^)\s]+)\)")
@@ -20,6 +21,14 @@ SITE_URL = "https://awesome-python.com/"
SITEMAP_URL = f"{SITE_URL}sitemap.xml"
SITEMAP_NS = "http://www.sitemaps.org/schemas/sitemap/0.9"
BUILTIN_FILTER = "Built-in"
BUILTIN_SLUG = "built-in"
BUILTIN_PATH = f"/categories/{BUILTIN_SLUG}/"
BUILTIN_PUBLIC_URL = f"{SITE_URL}categories/{BUILTIN_SLUG}/"
SPONSORSHIP_PATH = "/sponsorship/"
SPONSORSHIP_PUBLIC_URL = f"{SITE_URL}sponsorship/"
SOURCE_TYPE_DOMAINS = {
"docs.python.org": "Built-in",
"gitlab.com": "GitLab",
@@ -27,6 +36,37 @@ SOURCE_TYPE_DOMAINS = {
}
class TemplateSubcategory(TypedDict):
name: str
value: str
slug: str
url: str
class TemplateEntry(TypedDict):
name: str
url: str
description: str
categories: list[str]
groups: list[str]
subcategories: list[TemplateSubcategory]
stars: int | None
owner: str | None
last_commit_at: str | None
source_type: str | None
also_see: list[AlsoSee]
class SyntheticCategory(TypedDict):
name: str
slug: str
description: str
description_html: str
TemplateCategory = ParsedSection | SyntheticCategory
def detect_source_type(url: str) -> str | None:
"""Detect source type from URL domain. Returns None for GitHub URLs."""
if GITHUB_REPO_URL_RE.match(url):
@@ -55,13 +95,13 @@ def load_stars(path: Path) -> dict[str, dict]:
return {}
def sort_entries(entries: list[dict]) -> list[dict]:
def sort_entries(entries: Sequence[TemplateEntry]) -> list[TemplateEntry]:
"""Sort entries by stars descending, then name ascending.
Three tiers: starred entries first, stdlib second, other non-starred last.
"""
def sort_key(entry: dict) -> tuple[int, int, int, str]:
def sort_key(entry: TemplateEntry) -> tuple[int, int, int, str]:
stars = entry["stars"]
name = entry["name"].lower()
if stars is not None:
@@ -75,13 +115,35 @@ def sort_entries(entries: list[dict]) -> list[dict]:
def build_robots_txt() -> str:
return (
"User-agent: *\n"
"Content-Signal: search=yes, ai-input=yes, ai-train=yes\n"
"Allow: /\n"
"\n"
f"Sitemap: {SITEMAP_URL}\n"
)
return f"User-agent: *\nContent-Signal: search=yes, ai-input=yes, ai-train=yes\nAllow: /\n\nSitemap: {SITEMAP_URL}\n"
def category_path(category: ParsedSection) -> str:
return f"/categories/{category['slug']}/"
def category_public_url(category: ParsedSection) -> str:
return f"{SITE_URL}categories/{category['slug']}/"
def group_path(group_slug: str) -> str:
return f"/categories/{group_slug}/"
def group_public_url(group_slug: str) -> str:
return f"{SITE_URL}categories/{group_slug}/"
def subcategory_path(category_slug: str, subcategory_slug: str) -> str:
return f"/categories/{category_slug}/{subcategory_slug}/"
def subcategory_public_url(category_slug: str, subcategory_slug: str) -> str:
return f"{SITE_URL}categories/{category_slug}/{subcategory_slug}/"
def synthetic_category(name: str, slug: str) -> SyntheticCategory:
return {"name": name, "slug": slug, "description": "", "description_html": ""}
def write_sitemap_xml(path: Path, urls: Sequence[tuple[str, str]]) -> None:
@@ -165,7 +227,7 @@ def annotate_entries_with_stars(
if not entry or "stars" not in entry:
continue
stripped = line.rstrip("\n")
ending = line[len(stripped):]
ending = line[len(stripped) :]
annotated = f"{stripped} ({format_stars(entry['stars'])}){ending}"
break
out.append(annotated)
@@ -196,7 +258,7 @@ def remove_sponsors_section(markdown: str) -> str:
def extract_entries(
categories: list[ParsedSection],
groups: list[ParsedGroup],
) -> list[dict]:
) -> list[TemplateEntry]:
"""Flatten categories into individual library entries for table display.
Entries appearing in multiple categories are merged into a single entry
@@ -204,27 +266,27 @@ def extract_entries(
"""
cat_to_group = {cat["name"]: group["name"] for group in groups for cat in group["categories"]}
seen: dict[tuple[str, str], dict[str, Any]] = {} # (url, name) -> entry
entries: list[dict[str, Any]] = []
seen: dict[tuple[str, str], TemplateEntry] = {} # (url, name) -> entry
entries: list[TemplateEntry] = []
for cat in categories:
group_name = cat_to_group.get(cat["name"], "Other")
for entry in cat["entries"]:
key = (entry["url"], entry["name"])
existing: dict[str, Any] | None = seen.get(key)
existing = seen.get(key)
if existing is None:
existing = {
"name": entry["name"],
"url": entry["url"],
"description": entry["description"],
"categories": [],
"groups": [],
"subcategories": [],
"stars": None,
"owner": None,
"last_commit_at": None,
"source_type": detect_source_type(entry["url"]),
"also_see": entry["also_see"],
}
existing = TemplateEntry(
name=entry["name"],
url=entry["url"],
description=entry["description"],
categories=[],
groups=[],
subcategories=[],
stars=None,
owner=None,
last_commit_at=None,
source_type=detect_source_type(entry["url"]),
also_see=entry["also_see"],
)
seen[key] = existing
entries.append(existing)
if cat["name"] not in existing["categories"]:
@@ -235,7 +297,15 @@ def extract_entries(
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})
sub_slug = slugify(subcat)
existing["subcategories"].append(
TemplateSubcategory(
name=subcat,
value=scoped,
slug=sub_slug,
url=f"/categories/{cat['slug']}/{sub_slug}/",
)
)
return entries
@@ -255,6 +325,12 @@ def build(repo_root: Path) -> None:
sponsors = parse_sponsors(readme_text)
categories = [cat for g in parsed_groups for cat in g["categories"]]
cat_slugs = [cat["slug"] for cat in categories]
group_slugs = [g["slug"] for g in parsed_groups]
all_top_level_slugs = cat_slugs + group_slugs + [BUILTIN_SLUG]
duplicates = {s for s, n in Counter(all_top_level_slugs).items() if n > 1}
if duplicates:
raise ValueError(f"slug collision in /categories/ namespace: {sorted(duplicates)}. Rename a category or group so their slugs differ.")
total_entries = sum(c["entry_count"] for c in categories)
entries = extract_entries(categories, parsed_groups)
build_date = datetime.now(UTC)
@@ -278,6 +354,17 @@ def build(repo_root: Path) -> None:
entry["last_commit_at"] = sd.get("last_commit_at", "")
entries = sort_entries(entries)
category_urls = {cat["name"]: category_path(cat) for cat in categories}
filter_urls: dict[str, str] = dict(category_urls)
for group in parsed_groups:
filter_urls[group["name"]] = group_path(group["slug"])
for entry in entries:
for sub in entry.get("subcategories", []):
filter_urls[sub["value"]] = sub["url"]
builtin_entries = [e for e in entries if e.get("source_type") == BUILTIN_FILTER]
if builtin_entries:
filter_urls[BUILTIN_FILTER] = BUILTIN_PATH
env = Environment(
loader=FileSystemLoader(website / "templates"),
@@ -285,12 +372,13 @@ def build(repo_root: Path) -> None:
trim_blocks=True,
lstrip_blocks=True,
)
site_dir = website / "output"
if site_dir.exists():
shutil.rmtree(site_dir)
site_dir.mkdir(parents=True)
filter_urls_json = json.dumps(filter_urls, sort_keys=True, ensure_ascii=False).replace("</", "<\\/")
tpl_index = env.get_template("index.html")
(site_dir / "index.html").write_text(
tpl_index.render(
@@ -302,26 +390,130 @@ def build(repo_root: Path) -> None:
repo_stars=repo_stars,
build_date=build_date.strftime("%B %d, %Y"),
sponsors=sponsors,
category_urls=category_urls,
filter_urls=filter_urls,
filter_urls_json=filter_urls_json,
),
encoding="utf-8",
)
tpl_category = env.get_template("category.html")
categories_dir = site_dir / "categories"
def render_category(
category: TemplateCategory,
*,
category_url: str,
entries: Sequence[TemplateEntry],
current_path: str,
page_dir: Path,
parent_category: ParsedSection | None = None,
group_categories: Sequence[ParsedSection] | None = None,
) -> None:
page_dir.mkdir(parents=True, exist_ok=True)
(page_dir / "index.html").write_text(
tpl_category.render(
category=category,
category_url=category_url,
entries=entries,
total_categories=len(categories),
category_urls=category_urls,
current_path=current_path,
filter_urls=filter_urls,
filter_urls_json=filter_urls_json,
parent_category=parent_category,
group_categories=group_categories,
),
encoding="utf-8",
)
for category in categories:
render_category(
category,
category_url=category_public_url(category),
entries=[e for e in entries if category["name"] in e["categories"]],
current_path=category_path(category),
page_dir=categories_dir / category["slug"],
)
for group in parsed_groups:
render_category(
synthetic_category(group["name"], group["slug"]),
category_url=group_public_url(group["slug"]),
entries=[e for e in entries if group["name"] in e["groups"]],
current_path=group_path(group["slug"]),
page_dir=categories_dir / group["slug"],
group_categories=group["categories"],
)
if builtin_entries:
render_category(
synthetic_category(BUILTIN_FILTER, BUILTIN_SLUG),
category_url=BUILTIN_PUBLIC_URL,
entries=builtin_entries,
current_path=BUILTIN_PATH,
page_dir=categories_dir / BUILTIN_SLUG,
)
sponsorship_dir = site_dir / "sponsorship"
sponsorship_dir.mkdir(parents=True, exist_ok=True)
tpl_sponsorship = env.get_template("sponsorship.html")
hero_stats: list[str] = []
if repo_stars:
hero_stats.append(f"{repo_stars}+ stars on GitHub")
hero_stats.append(f"Updated {build_date.strftime('%B %d, %Y')}")
(sponsorship_dir / "index.html").write_text(
tpl_sponsorship.render(hero_stats=hero_stats),
encoding="utf-8",
)
subcat_to_entries: dict[str, list[TemplateEntry]] = {}
subcat_meta: dict[str, tuple[str, str, str]] = {} # value -> (cat_slug, sub_slug, sub_name)
cat_slug_by_url_prefix = {f"/categories/{c['slug']}/": c["slug"] for c in categories}
cat_by_slug = {c["slug"]: c for c in categories}
for entry in entries:
for sub in entry.get("subcategories", []):
value = sub["value"]
subcat_to_entries.setdefault(value, []).append(entry)
if value not in subcat_meta:
for prefix, cat_slug in cat_slug_by_url_prefix.items():
if sub["url"].startswith(prefix):
subcat_meta[value] = (cat_slug, sub["slug"], sub["name"])
break
for value, (cat_slug, sub_slug, sub_name) in subcat_meta.items():
render_category(
synthetic_category(sub_name, sub_slug),
category_url=subcategory_public_url(cat_slug, sub_slug),
entries=subcat_to_entries[value],
current_path=subcategory_path(cat_slug, sub_slug),
page_dir=categories_dir / cat_slug / sub_slug,
parent_category=cat_by_slug[cat_slug],
)
static_src = website / "static"
static_dst = site_dir / "static"
if static_src.exists():
shutil.copytree(static_src, static_dst, dirs_exist_ok=True)
markdown_index = annotate_entries_with_stars(
remove_sponsors_section(readme_text), stars_data
)
markdown_index = annotate_entries_with_stars(remove_sponsors_section(readme_text), stars_data)
llms_template = (website / "templates" / "llms.txt").read_text(encoding="utf-8")
llms_txt = build_llms_txt(llms_template, readme_text, stars_data)
(site_dir / "robots.txt").write_text(build_robots_txt(), encoding="utf-8")
write_sitemap_xml(site_dir / "sitemap.xml", [(SITE_URL, build_date.date().isoformat())])
sitemap_date = build_date.date().isoformat()
sitemap_urls = [(SITE_URL, sitemap_date)]
sitemap_urls.extend((category_public_url(c), sitemap_date) for c in categories)
sitemap_urls.extend((group_public_url(g["slug"]), sitemap_date) for g in parsed_groups)
if builtin_entries:
sitemap_urls.append((BUILTIN_PUBLIC_URL, sitemap_date))
for cat_slug, sub_slug, _ in sorted(subcat_meta.values()):
sitemap_urls.append((subcategory_public_url(cat_slug, sub_slug), sitemap_date))
sitemap_urls.append((SPONSORSHIP_PUBLIC_URL, sitemap_date))
write_sitemap_xml(site_dir / "sitemap.xml", sitemap_urls)
(site_dir / "index.md").write_text(markdown_index, encoding="utf-8")
(site_dir / "llms.txt").write_text(llms_txt, encoding="utf-8")
print(f"Built single page with {len(parsed_groups)} groups, {len(categories)} categories")
print(f"Built site with {len(parsed_groups)} groups, {len(categories)} categories")
print(f"Total entries: {total_entries}")
print(f"Output: {site_dir}")

View File

@@ -11,7 +11,6 @@ from itertools import batched
from pathlib import Path
import httpx
from build import extract_github_repo, load_stars
CACHE_MAX_AGE_HOURS = 12
@@ -53,10 +52,7 @@ def build_graphql_query(repos: Sequence[str]) -> str:
owner, name = repo.split("/", 1)
if not GITHUB_OWNER_RE.match(owner) or not GITHUB_NAME_RE.match(name):
continue
parts.append(
f'repo_{i}: repository(owner: "{owner}", name: "{name}") '
f"{{ stargazerCount owner {{ login }} defaultBranchRef {{ target {{ ... on Commit {{ committedDate }} }} }} }}"
)
parts.append(f'repo_{i}: repository(owner: "{owner}", name: "{name}") {{ stargazerCount owner {{ login }} defaultBranchRef {{ target {{ ... on Commit {{ committedDate }} }} }} }}')
if not parts:
return ""
return "query { " + " ".join(parts) + " }"

View File

@@ -27,6 +27,7 @@ class ParsedSection(TypedDict):
name: str
slug: str
description: str # plain text, links resolved to text
description_html: str # inline HTML, properly escaped
entries: list[ParsedEntry]
entry_count: int
@@ -113,22 +114,22 @@ def _heading_text(node: SyntaxTreeNode) -> str:
return ""
def _extract_description(nodes: list[SyntaxTreeNode]) -> str:
"""Extract description from the first paragraph if it's a single <em> block.
def _extract_description_children(nodes: list[SyntaxTreeNode]) -> list[SyntaxTreeNode]:
"""Extract description children from the first paragraph if it's a single <em> block.
Pattern: _Libraries for foo._ -> "Libraries for foo."
"""
if not nodes:
return ""
return []
first = nodes[0]
if first.type != "paragraph":
return ""
return []
for child in first.children:
if child.type == "inline" and len(child.children) == 1:
em = child.children[0]
if em.type == "em":
return render_inline_text(em.children)
return ""
return em.children
return []
# --- Entry extraction --------------------------------------------------------
@@ -228,18 +229,22 @@ def _parse_list_entries(
if sub_inline:
sub_link = _find_child(sub_inline, "link")
if sub_link:
also_see.append(AlsoSee(
name=render_inline_text(sub_link.children),
url=_href(sub_link),
))
also_see.append(
AlsoSee(
name=render_inline_text(sub_link.children),
url=_href(sub_link),
)
)
entries.append(ParsedEntry(
name=name,
url=url,
description=desc_html,
also_see=also_see,
subcategory=subcategory,
))
entries.append(
ParsedEntry(
name=name,
url=url,
description=desc_html,
also_see=also_see,
subcategory=subcategory,
)
)
return entries
@@ -258,20 +263,22 @@ def _parse_section_entries(content_nodes: list[SyntaxTreeNode]) -> list[ParsedEn
def _build_section(name: str, body: list[SyntaxTreeNode]) -> ParsedSection:
"""Build a ParsedSection from a heading name and its body nodes."""
desc = _extract_description(body)
content_nodes = body[1:] if desc else body
desc_children = _extract_description_children(body)
desc = render_inline_text(desc_children) if desc_children else ""
desc_html = render_inline_html(desc_children) if desc_children else ""
content_nodes = body[1:] if desc_children else body
entries = _parse_section_entries(content_nodes)
entry_count = len(entries) + sum(len(e["also_see"]) for e in entries)
return ParsedSection(
name=name,
slug=slugify(name),
description=desc,
description_html=desc_html,
entries=entries,
entry_count=entry_count,
)
def _is_bold_marker(node: SyntaxTreeNode) -> str | None:
"""Detect a bold-only paragraph used as a group marker.
@@ -317,11 +324,13 @@ def _parse_grouped_sections(
nonlocal current_group_name, current_group_cats
if current_group_cats:
name = current_group_name or "Other"
groups.append(ParsedGroup(
name=name,
slug=slugify(name),
categories=list(current_group_cats),
))
groups.append(
ParsedGroup(
name=name,
slug=slugify(name),
categories=list(current_group_cats),
)
)
current_group_name = None
current_group_cats = []

View File

@@ -59,6 +59,19 @@ document.querySelectorAll("[data-scroll-to]").forEach(function (link) {
});
});
// Land at #library-index without leaving the hash in the URL
if (window.location.hash === "#library-index") {
const target = document.getElementById("library-index");
if (target) {
target.scrollIntoView();
}
history.replaceState(
null,
"",
window.location.pathname + window.location.search,
);
}
// Pause hero animations when scrolled out of view
(function () {
const hero = document.querySelector(".hero");
@@ -167,19 +180,36 @@ function applyFilters() {
updateURL();
}
function updateURL() {
const filterUrlsScript = document.getElementById("filter-urls");
const filterToUrl = filterUrlsScript
? JSON.parse(filterUrlsScript.textContent)
: {};
const isIndexDocument =
location.pathname === "/" || location.pathname === "/index.html";
const urlToFilter = {};
Object.keys(filterToUrl).forEach(function (k) {
urlToFilter[filterToUrl[k]] = k;
});
function buildQueryString() {
const params = new URLSearchParams();
const query = searchInput ? searchInput.value.trim() : "";
if (query) params.set("q", query);
if (activeFilter) {
params.set("filter", activeFilter);
}
if (activeSort.col !== "stars" || activeSort.order !== "desc") {
params.set("sort", activeSort.col);
params.set("order", activeSort.order);
}
const qs = params.toString();
history.replaceState(null, "", qs ? "?" + qs : location.pathname);
return qs ? "?" + qs : "";
}
function updateURL() {
if (!isIndexDocument) return;
const path =
activeFilter && filterToUrl[activeFilter] ? filterToUrl[activeFilter] : "/";
history.replaceState(null, "", path + buildQueryString());
}
function getSortValue(row, col) {
@@ -202,6 +232,8 @@ function getSortValue(row, col) {
}
function sortRows() {
if (!tbody) return;
const arr = Array.prototype.slice.call(rows);
const col = activeSort.col;
const order = activeSort.order;
@@ -286,13 +318,27 @@ tags.forEach(function (tag) {
tag.addEventListener("click", function (e) {
e.preventDefault();
const value = tag.dataset.value;
activeFilter = activeFilter === value ? null : value;
applyFilters();
const url = tag.dataset.url;
if (isIndexDocument) {
activeFilter = activeFilter === value ? null : value;
if (activeFilter && url) {
history.pushState(null, "", url + buildQueryString());
} else {
history.pushState(null, "", "/" + buildQueryString());
}
applyFilters();
} else if (url) {
window.location.href = url;
}
});
});
if (filterClear) {
filterClear.addEventListener("click", function () {
if (!isIndexDocument) {
window.location.href = "/#library-index";
return;
}
activeFilter = null;
applyFilters();
});
@@ -301,6 +347,10 @@ if (filterClear) {
const noResultsClear = document.querySelector(".no-results-clear");
if (noResultsClear) {
noResultsClear.addEventListener("click", function () {
if (!isIndexDocument) {
window.location.href = "/";
return;
}
if (searchInput) searchInput.value = "";
activeFilter = null;
applyFilters();
@@ -394,19 +444,29 @@ if (backToTop) {
(function () {
const params = new URLSearchParams(location.search);
const q = params.get("q");
const filter = params.get("filter");
const sort = params.get("sort");
const order = params.get("order");
if (q && searchInput) searchInput.value = q;
if (filter) activeFilter = filter;
if (
(sort === "name" || sort === "stars" || sort === "commit-time") &&
(order === "desc" || order === "asc")
) {
activeSort = { col: sort, order: order };
}
if (q || filter || sort) {
const matched = urlToFilter[location.pathname];
if (matched) activeFilter = matched;
if (q || activeFilter || sort) {
sortRows();
}
if (activeFilter) {
applyFilters();
}
updateSortIndicators();
})();
window.addEventListener("popstate", function () {
if (!isIndexDocument) return;
const matched = urlToFilter[location.pathname];
activeFilter = matched || null;
applyFilters();
});

View File

@@ -335,6 +335,49 @@ kbd {
letter-spacing: 0.02em;
}
.hero-category-nav {
display: grid;
grid-template-columns: minmax(10rem, 14rem) minmax(0, 1fr);
gap: clamp(1.5rem, 4vw, 3rem);
align-items: start;
padding-top: 1.35rem;
border-top: 1px solid var(--hero-line);
animation: hero-rise 820ms cubic-bezier(0.22, 1, 0.36, 1) 110ms both;
}
.hero-category-meta h2 {
color: var(--hero-kicker);
font-size: var(--text-sm);
font-weight: 800;
letter-spacing: 0.04em;
}
.hero-category-links {
list-style: none;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
column-gap: clamp(1.25rem, 3vw, 2.5rem);
row-gap: 0.28rem;
}
.hero-category-link {
color: var(--hero-muted);
font-size: 0.72rem;
font-weight: 700;
line-height: 1.35;
text-decoration: underline;
text-decoration-color: transparent;
text-underline-offset: 0.18em;
transition:
color 180ms ease,
text-decoration-color 180ms ease;
}
.hero-category-link:hover {
color: var(--hero-text);
text-decoration-color: oklch(100% 0 0 / 0.42);
}
.hero-action {
display: inline-flex;
align-items: center;
@@ -376,18 +419,81 @@ kbd {
}
.hero-action:focus-visible,
.hero-brand-mini:focus-visible,
.hero-topbar-link:focus-visible,
.hero-category-link:focus-visible,
.search:focus-visible,
.filter-clear:focus-visible,
.tag:focus-visible,
.back-to-top:focus-visible,
.no-results-clear:focus-visible,
.table a:focus-visible,
.footer a:focus-visible,
.sort-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 3px;
}
.category-hero {
position: relative;
overflow: clip;
background: linear-gradient(140deg, var(--hero-bg-start) 0%, var(--hero-bg-mid) 58%, var(--hero-bg-end) 100%);
color: var(--hero-text);
}
.category-hero-shell {
position: relative;
z-index: 1;
width: min(100%, calc(var(--shell-max) + (var(--shell-pad) * 2)));
margin: 0 auto;
padding: 1.25rem var(--shell-pad) clamp(3.75rem, 8vw, 6.75rem);
display: grid;
gap: clamp(3rem, 8vw, 5.5rem);
}
.category-hero h1 {
font-family: var(--font-display);
font-size: clamp(3.6rem, 9vw, 7rem);
line-height: 0.9;
font-weight: 600;
text-wrap: balance;
}
.category-breadcrumb {
margin: 0 0 1rem;
color: var(--hero-muted);
font-size: clamp(1rem, 1.5vw, 1.1rem);
}
.category-breadcrumb a {
color: var(--hero-text);
text-decoration: underline;
text-decoration-color: oklch(100% 0 0 / 0.32);
text-underline-offset: 0.2em;
}
.category-breadcrumb a:hover {
text-decoration-color: oklch(100% 0 0 / 0.7);
}
.category-subtitle {
margin-top: 1.1rem;
color: var(--hero-muted);
font-size: clamp(1rem, 1.8vw, 1.18rem);
text-wrap: pretty;
}
.category-subtitle a {
color: var(--hero-text);
text-decoration: underline;
text-decoration-color: oklch(100% 0 0 / 0.32);
text-underline-offset: 0.2em;
}
.category-subtitle a:hover {
text-decoration-color: oklch(100% 0 0 / 0.7);
}
.sponsor-band {
padding-block: clamp(2.5rem, 5.5vw, 4rem);
background:
@@ -972,6 +1078,298 @@ th[data-sort].sort-asc::after {
color: var(--accent);
}
.sponsorship-hero .category-hero-shell {
padding-bottom: clamp(3.25rem, 6vw, 5rem);
gap: clamp(2rem, 5vw, 3.5rem);
}
.sponsorship-hero-copy h1 {
font-size: clamp(3.4rem, 8.5vw, 6.5rem);
}
.sponsorship-proof {
margin-top: 1.6rem;
}
.sponsorship-proof .proof-sep {
color: oklch(100% 0 0 / 0.32);
margin-inline: 0.15rem;
}
.sponsorship-hero .hero-actions {
margin-top: 1.9rem;
}
.sponsorship-section {
padding-block: clamp(2.75rem, 5.5vw, 4.25rem);
border-bottom: 1px solid var(--line);
}
.sponsorship-section:first-of-type {
padding-top: clamp(3.25rem, 6vw, 4.75rem);
}
.sponsorship-section:last-of-type {
border-bottom: 0;
padding-bottom: clamp(3.5rem, 7vw, 5.5rem);
}
.sponsorship-getstarted {
background: var(--cta-bg);
border-top: 1px solid var(--line);
}
.sponsorship-shell {
display: grid;
grid-template-columns: minmax(0, 16rem) minmax(0, 1fr);
gap: clamp(1.75rem, 5vw, 4rem);
align-items: start;
}
.sponsorship-meta {
display: flex;
flex-direction: column;
gap: 0.85rem;
position: sticky;
top: 1.5rem;
}
.sponsorship-meta .section-label {
margin-bottom: 0;
font-size: var(--text-lg);
}
.sponsorship-meta-note {
color: var(--ink-muted);
font-size: var(--text-sm);
line-height: 1.55;
}
.sponsorship-body {
display: flex;
flex-direction: column;
gap: 1.6rem;
font-size: var(--text-lg);
color: var(--ink-soft);
line-height: 1.7;
}
.sponsorship-body p {
text-wrap: pretty;
}
.sponsorship-body code {
font-family: ui-monospace, "SFMono-Regular", "Menlo", monospace;
font-size: 0.92em;
padding: 0.08rem 0.4rem;
border-radius: 0.4rem;
background: var(--bg-paper-strong);
color: var(--ink);
}
.sponsorship-body a:not(.hero-action):not(.tier-cta) {
color: var(--accent-deep);
text-decoration: underline;
text-decoration-color: var(--accent-underline);
text-underline-offset: 0.2em;
transition: color 180ms ease;
}
.sponsorship-body a:not(.hero-action):not(.tier-cta):hover {
color: var(--accent);
}
.sponsorship-lede {
font-family: var(--font-display);
font-size: clamp(1.55rem, 2.6vw, 2rem);
line-height: 1.25;
color: var(--ink);
letter-spacing: -0.01em;
text-wrap: pretty;
}
.sponsorship-facts {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 1.4rem;
border-top: 1px solid var(--line);
padding-top: 1.6rem;
}
.sponsorship-facts > div {
display: grid;
grid-template-columns: minmax(0, 12rem) minmax(0, 1fr);
gap: clamp(1rem, 3vw, 2rem);
align-items: baseline;
}
.sponsorship-facts dt {
font-size: var(--text-xs);
font-weight: 800;
letter-spacing: 0.05em;
color: var(--ink);
}
.sponsorship-facts dd {
color: var(--ink-soft);
font-size: var(--text-base);
line-height: 1.65;
}
.tier-list {
list-style: none;
padding: 0;
margin: 0;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: clamp(1.5rem, 3vw, 2.75rem);
}
.tier {
display: flex;
flex-direction: column;
gap: 1rem;
padding-block: 1.65rem;
border-top: 1px solid var(--line-strong);
}
.tier-eyebrow {
font-size: var(--text-xs);
font-weight: 800;
letter-spacing: 0.05em;
color: var(--ink);
}
.tier-price {
display: flex;
align-items: baseline;
gap: 0.55rem;
margin-bottom: 0.25rem;
}
.tier-amount {
font-family: var(--font-display);
font-size: clamp(3rem, 5.5vw, 4.5rem);
font-weight: 600;
line-height: 0.9;
letter-spacing: -0.025em;
color: var(--ink);
}
.tier-cadence {
color: var(--ink-muted);
font-size: var(--text-base);
font-weight: 600;
letter-spacing: 0.01em;
}
.tier-summary {
font-size: var(--text-lg);
color: var(--ink);
line-height: 1.5;
text-wrap: pretty;
}
.tier-includes {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.6rem;
border-top: 1px solid var(--line);
padding-top: 1.1rem;
}
.tier-includes li {
position: relative;
padding-left: 1.4rem;
color: var(--ink-soft);
font-size: var(--text-base);
line-height: 1.6;
}
.tier-includes li::before {
content: "";
position: absolute;
left: 0;
top: 0.65rem;
width: 0.55rem;
height: 1px;
background: var(--line-strong);
}
.tier-cta {
align-self: start;
margin-top: 0.75rem;
color: var(--accent-deep);
font-size: var(--text-sm);
font-weight: 700;
letter-spacing: 0.01em;
text-decoration: underline;
text-decoration-color: var(--accent-underline);
text-underline-offset: 0.22em;
transition: color 180ms ease, text-decoration-color 180ms ease;
}
.tier-cta:hover {
color: var(--accent);
text-decoration-color: var(--accent);
}
.past-sponsors {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.85rem;
}
.past-sponsors li {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.65rem;
padding-block: 0.4rem;
}
.past-sponsors a {
font-family: var(--font-display);
font-size: clamp(1.6rem, 2.8vw, 2.1rem);
font-weight: 600;
line-height: 1;
letter-spacing: -0.02em;
color: var(--ink);
transition: color 180ms ease;
}
.past-sponsors a:hover {
color: var(--accent-deep);
}
.past-sponsor-desc {
color: var(--ink-muted);
font-size: var(--text-base);
}
.sponsorship-cta-row {
display: flex;
flex-wrap: wrap;
gap: 0.85rem;
margin-top: 0.5rem;
}
.sponsorship-cta-row .hero-action-primary {
color: var(--hero-text);
background: linear-gradient(135deg, var(--accent), var(--accent-deep));
}
.sponsorship-fineprint {
font-size: var(--text-base);
color: var(--ink-muted);
}
.final-cta {
padding-block: clamp(3rem, 7vw, 5.5rem);
background: var(--cta-bg);
@@ -1117,10 +1515,34 @@ th[data-sort].sort-asc::after {
.hero-grid,
.results-intro,
.sponsor-shell {
.sponsor-shell,
.sponsorship-shell {
grid-template-columns: 1fr;
}
.sponsorship-meta {
position: static;
}
.tier-list {
grid-template-columns: 1fr;
gap: 0;
}
.sponsorship-facts > div {
grid-template-columns: 1fr;
gap: 0.35rem;
}
.hero-category-nav {
grid-template-columns: 1fr;
gap: 0.95rem;
}
.hero-category-links {
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
}
.results-note {
justify-self: start;
}
@@ -1185,6 +1607,15 @@ th[data-sort].sort-asc::after {
font-size: clamp(3.6rem, 18vw, 5.2rem);
}
.hero-category-links {
grid-template-columns: repeat(auto-fill, minmax(8rem, 1fr));
column-gap: 0.75rem;
}
.hero-category-link {
font-size: 0.68rem;
}
.search {
min-height: 3.5rem;
border-radius: 1.25rem;

View File

@@ -3,21 +3,24 @@
<head>
{% set default_meta_title = "Awesome Python" %}
{% set default_meta_description = "An opinionated guide to the best Python frameworks, libraries, and tools. Explore " ~ (entries | length) ~ " curated projects across " ~ total_categories ~ " categories, from AI and agents to data science and web development." %}
{% set canonical_url = "https://awesome-python.com/" %}
{% set default_canonical_url = "https://awesome-python.com/" %}
{% set social_image_url = "https://awesome-python.com/static/og-image.png" %}
{% set meta_title %}{% block title %}{{ default_meta_title }}{% endblock %}{% endset %}
{% set meta_description %}{% block description %}{{ default_meta_description }}{% endblock %}{% endset %}
{% set canonical_url %}{% block canonical_url %}{{ default_canonical_url }}{% endblock %}{% endset %}
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{ meta_title | trim }}</title>
<meta name="description" content="{{ meta_description | trim }}" />
<link rel="canonical" href="{{ canonical_url }}" />
<link rel="canonical" href="{{ canonical_url | trim }}" />
{% block alternate_links %}
<link rel="alternate" type="text/markdown" href="/index.md" />
{% endblock %}
<meta property="og:type" content="website" />
<meta property="og:title" content="{{ meta_title | trim }}" />
<meta property="og:description" content="{{ meta_description | trim }}" />
<meta property="og:image" content="{{ social_image_url }}" />
<meta property="og:url" content="{{ canonical_url }}" />
<meta property="og:url" content="{{ canonical_url | trim }}" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="{{ meta_title | trim }}" />
<meta name="twitter:description" content="{{ meta_description | trim }}" />

View File

@@ -0,0 +1,283 @@
{% extends "base.html" %}
{% block title %}{{ category.name }} Python Libraries | Awesome Python{% endblock %}
{% block description %}Explore {{ entries | length }} curated Python projects in {{ category.name }}. {% if category.description %}{{ category.description }}{% else %}Part of the Awesome Python catalog.{% endif %}{% endblock %}
{% block canonical_url %}{{ category_url }}{% endblock %}
{% block alternate_links %}{% endblock %}
{% block header %}
<header class="category-hero">
<div class="hero-sheen" aria-hidden="true"></div>
<div class="hero-noise" aria-hidden="true"></div>
<div class="category-hero-shell">
<nav class="hero-topbar category-topbar" aria-label="Site">
<a href="/" class="hero-brand-mini">Awesome Python</a>
<div class="hero-topbar-actions">
<a href="/#library-index" class="hero-topbar-link">All projects</a>
<a
href="https://github.com/vinta/awesome-python/blob/master/CONTRIBUTING.md"
class="hero-topbar-link hero-topbar-link-strong"
target="_blank"
rel="noopener"
>Submit a project</a
>
</div>
</nav>
<div class="category-hero-copy">
{% if parent_category %}
<p class="category-breadcrumb">
<a href="/categories/{{ parent_category.slug }}/">{{ parent_category.name }}</a>
</p>
{% endif %}
<h1>{{ category.name }}</h1>
{% if category.description_html %}
<p class="category-subtitle">{{ category.description_html | safe }}</p>
{% endif %}
</div>
{% if group_categories %}
<nav class="hero-category-nav" aria-labelledby="hero-category-heading">
<div class="hero-category-meta">
<h2 id="hero-category-heading">Browse by category</h2>
</div>
<ul class="hero-category-links">
{% for sub in group_categories %}
<li>
<a class="hero-category-link" href="{{ category_urls[sub.name] }}"
>{{ sub.name }}</a
>
</li>
{% endfor %}
</ul>
</nav>
{% endif %}
</div>
</header>
{% endblock %}
{% block content %}
<script type="application/json" id="filter-urls">{{ filter_urls_json | safe }}</script>
<section class="results-section" id="library-index">
<div class="results-intro section-shell" data-reveal>
<div>
<h2>Search every project in one place</h2>
</div>
<p class="results-note">
Press <kbd>/</kbd> to search. Tap a tag to filter. Click any row for
details.
</p>
</div>
<div class="controls section-shell" data-reveal>
<h2 class="sr-only">Search and filter</h2>
<div class="search-wrap">
<svg
class="search-icon"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<input
type="search"
class="search"
placeholder="Search {{ entries | length }} projects in {{ category.name }}..."
aria-label="Search projects"
/>
</div>
<div class="filter-bar" aria-live="polite">
<span>Filtering for <strong class="filter-value"></strong></span>
<button class="filter-clear" aria-label="Clear filter">
Clear filter
</button>
</div>
</div>
<h2 class="sr-only">Results</h2>
<div
class="table-wrap"
tabindex="0"
role="region"
aria-label="Libraries table"
>
<table class="table">
<thead>
<tr>
<th class="col-num"><span class="sr-only">Row number</span></th>
<th class="col-name" data-sort="name">
<button type="button" class="sort-btn">Project Name</button>
</th>
<th class="col-stars" data-sort="stars">
<button type="button" class="sort-btn">GitHub Stars</button>
</th>
<th class="col-commit" data-sort="commit-time">
<button type="button" class="sort-btn">Last Commit</button>
</th>
<th class="col-cat">Tags</th>
<th class="col-arrow">
<button class="back-to-top" aria-label="Back to top">
Top &uarr;
</button>
</th>
</tr>
</thead>
<tbody>
{% for entry in entries %}
<tr
class="row"
data-tags="{{ entry.categories | join('||') }}{% if entry.subcategories %}||{{ entry.subcategories | map(attribute='value') | join('||') }}{% endif %}||{{ entry.groups | join('||') }}{% if entry.source_type == 'Built-in' %}||Built-in{% endif %}"
tabindex="0"
aria-expanded="false"
aria-controls="expand-{{ loop.index }}"
>
<td class="col-num">{{ loop.index }}</td>
<td class="col-name">
<a href="{{ entry.url }}" target="_blank" rel="noopener"
>{{ entry.name }}</a
>
<span class="mobile-cat"
>{% if entry.subcategories %}{{ entry.subcategories[0].name }}{%
else %}{{ category.name }}{% endif %}</span
>
</td>
<td class="col-stars">
{% if entry.stars is not none %}{{ "{:,}".format(entry.stars) }}{%
elif entry.source_type %}<span class="source-badge"
>{{ entry.source_type }}</span
>{% else %}&mdash;{% endif %}
</td>
<td
class="col-commit"
{%
if
entry.last_commit_at
%}data-commit="{{ entry.last_commit_at }}"
{%
endif
%}
>
{% if entry.last_commit_at %}<time
datetime="{{ entry.last_commit_at }}"
>{{ entry.last_commit_at[:10] }}</time
>{% else %}&mdash;{% endif %}
</td>
<td class="col-cat">
{% for subcat in entry.subcategories %}
<button class="tag{% if subcat.url == current_path %} active{% endif %}" data-value="{{ subcat.value }}" data-url="{{ subcat.url }}">
{{ subcat.name }}
</button>
{% endfor %}
{% for cat in entry.categories %}
<a
class="tag{% if category_urls[cat] == current_path %} active{% endif %}"
href="{{ category_urls[cat] }}"
data-value="{{ cat }}"
data-url="{{ category_urls[cat] }}"
>{{ cat }}</a
>
{% endfor %}
{% if entry.groups %}
{% set group_url = filter_urls[entry.groups[0]] %}
<button
class="tag tag-group{% if group_url == current_path %} active{% endif %}"
data-value="{{ entry.groups[0] }}"
data-url="{{ group_url }}"
>
{{ entry.groups[0] }}
</button>
{% endif %}
{% if entry.source_type == 'Built-in' %}
<button
class="tag tag-source{% if '/categories/built-in/' == current_path %} active{% endif %}"
data-value="Built-in"
data-url="/categories/built-in/"
>
Built-in
</button>
{% endif %}
</td>
<td class="col-arrow"><span class="arrow">&rarr;</span></td>
</tr>
<tr class="expand-row" id="expand-{{ loop.index }}">
<td></td>
<td colspan="4">
<div class="expand-content">
{% if entry.description %}
<div class="expand-desc">{{ entry.description | safe }}</div>
{% endif %} {% if entry.also_see %}
<div class="expand-also-see">
Also see: {% for see in entry.also_see %}<a
href="{{ see.url }}"
target="_blank"
rel="noopener"
>{{ see.name }}</a
>{% if not loop.last %}, {% endif %}{% endfor %}
</div>
{% endif %}
<div class="expand-meta">
{% if entry.owner %}<a
href="https://github.com/{{ entry.owner }}"
target="_blank"
rel="noopener"
>{{ entry.owner }}</a
><span class="expand-sep">/</span>{% endif %}<a
href="{{ entry.url }}"
target="_blank"
rel="noopener"
>{{ entry.url | replace("https://", "") }}</a
>
{% if entry.last_commit_at %}<span class="expand-commit"
><span class="expand-sep">/</span
><time datetime="{{ entry.last_commit_at }}"
>{{ entry.last_commit_at[:10] }}</time
></span
>{% endif %}
</div>
</div>
</td>
<td></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="no-results" hidden>
<p>No projects match your search or filter.</p>
<p class="no-results-hint">
Try a broader term, or
<button class="no-results-clear">browse all projects</button>.
</p>
</div>
</section>
<section class="final-cta" data-reveal>
<div class="section-shell">
<p class="section-label">Contribute</p>
<h2>Know a project that belongs here?</h2>
<p>Tell us what it does and why it stands out.</p>
<div class="final-cta-actions">
<a
href="https://github.com/vinta/awesome-python/blob/master/CONTRIBUTING.md"
class="hero-action hero-action-primary"
target="_blank"
rel="noopener"
>Submit a project</a
>
<a
href="https://github.com/vinta/awesome-python"
class="hero-action hero-action-secondary"
target="_blank"
rel="noopener"
>Star the repository</a
>
</div>
</div>
</section>
{% endblock %}

View File

@@ -61,6 +61,21 @@
{% endif %}
</div>
</div>
<nav class="hero-category-nav" aria-labelledby="hero-category-heading">
<div class="hero-category-meta">
<h2 id="hero-category-heading">Browse by category</h2>
</div>
<ul class="hero-category-links">
{% for category in categories %}
<li>
<a class="hero-category-link" href="{{ category_urls[category.name] }}"
>{{ category.name }}</a
>
</li>
{% endfor %}
</ul>
</nav>
</div>
</header>
{% endblock %}
@@ -70,14 +85,7 @@
<div class="section-shell sponsor-shell">
<header class="sponsor-meta">
<p class="section-label" id="sponsor-heading">Sponsors</p>
<a
class="sponsor-become"
href="https://github.com/vinta/awesome-python/blob/master/SPONSORSHIP.md"
target="_blank"
rel="noopener"
>
Become a sponsor
</a>
<a class="sponsor-become" href="/sponsorship/"> Become a sponsor </a>
</header>
<ul class="sponsor-list">
{% for sponsor in sponsors %}
@@ -98,6 +106,7 @@
</section>
{% endif %}
<script type="application/json" id="filter-urls">{{ filter_urls_json | safe }}</script>
<section class="results-section" id="library-index">
<div class="results-intro section-shell" data-reveal>
<div>
@@ -211,17 +220,31 @@
</td>
<td class="col-cat">
{% for subcat in entry.subcategories %}
<button class="tag" data-value="{{ subcat.value }}">
<button class="tag" data-value="{{ subcat.value }}" data-url="{{ subcat.url }}">
{{ subcat.name }}
</button>
{% endfor %} {% for cat in entry.categories %}
<button class="tag" data-value="{{ cat }}">{{ cat }}</button>
<a
class="tag"
href="{{ category_urls[cat] }}"
data-value="{{ cat }}"
data-url="{{ category_urls[cat] }}"
>{{ cat }}</a
>
{% endfor %}
<button class="tag tag-group" data-value="{{ entry.groups[0] }}">
<button
class="tag tag-group"
data-value="{{ entry.groups[0] }}"
data-url="{{ filter_urls[entry.groups[0]] }}"
>
{{ entry.groups[0] }}
</button>
{% if entry.source_type == 'Built-in' %}
<button class="tag tag-source" data-value="Built-in">
<button
class="tag tag-source"
data-value="Built-in"
data-url="/categories/built-in/"
>
Built-in
</button>
{% endif %}

View File

@@ -0,0 +1,250 @@
{% extends "base.html" %}
{% block title %}Sponsor Awesome Python{% endblock %}
{% block description %}Sponsorship for awesome-python: tiers, audience, and how to get your product in front of professional Python developers evaluating tools for production use.{% endblock %}
{% block canonical_url %}https://awesome-python.com/sponsorship/{% endblock %}
{% block alternate_links %}{% endblock %}
{% block header %}
<header class="category-hero sponsorship-hero">
<div class="hero-sheen" aria-hidden="true"></div>
<div class="hero-noise" aria-hidden="true"></div>
<div class="category-hero-shell">
<nav class="hero-topbar category-topbar" aria-label="Site">
<a href="/" class="hero-brand-mini">Awesome Python</a>
<div class="hero-topbar-actions">
<a
href="https://github.com/vinta/awesome-python/blob/master/CONTRIBUTING.md"
class="hero-topbar-link hero-topbar-link-strong"
target="_blank"
rel="noopener"
>Submit a project</a
>
</div>
</nav>
<div class="category-hero-copy sponsorship-hero-copy">
<p class="hero-kicker">Sponsorship</p>
<h1>Sponsor Awesome Python</h1>
<p class="category-subtitle">
The #10 most-starred repository on GitHub, and the list Python
developers check when choosing what to use. Your sponsorship puts your
product in front of them at the moment of decision.
</p>
{% if hero_stats %}
<p class="hero-proof sponsorship-proof">
{% for stat in hero_stats %}{{ stat }}{% if not loop.last %}
<span class="proof-sep">/</span> {% endif %}{% endfor %}
</p>
{% endif %}
<div class="hero-actions">
<a
href="mailto:vinta.chen@gmail.com?subject=Awesome%20Python%20Sponsorship"
class="hero-action hero-action-primary"
>Email vinta.chen@gmail.com</a
>
<a
href="https://github.com/vinta/awesome-python"
class="hero-action hero-action-secondary"
target="_blank"
rel="noopener"
>View on GitHub</a
>
</div>
</div>
</div>
</header>
{% endblock %} {% block content %}
<section class="sponsorship-section sponsorship-audience" data-reveal>
<div class="section-shell sponsorship-shell">
<header class="sponsorship-meta">
<p class="section-label">Audience</p>
</header>
<div class="sponsorship-body">
<p class="sponsorship-lede">
Professional Python developers evaluating libraries and tools for
production use. Not beginners browsing tutorials. People making adoption
decisions.
</p>
<dl class="sponsorship-facts">
<div>
<dt>Who visits</dt>
<dd>
Mid to senior Python developers arriving with a specific question:
a maintained ORM, a fast HTTP client, a task queue worth running in
production.
</dd>
</div>
<div>
<dt>Where they come from</dt>
<dd>
Google Search, GitHub, Reddit, YouTube, ChatGPT and other LLMs,
Hacker News.
</dd>
</div>
<div>
<dt>Why it works</dt>
<dd>
Ranks on the first page of Google for "best Python libraries".
ChatGPT and other LLMs cite it when recommending Python tools.
Developers send it to each other.
</dd>
</div>
</dl>
</div>
</div>
</section>
<section class="sponsorship-section sponsorship-tiers" data-reveal>
<div class="section-shell sponsorship-shell">
<header class="sponsorship-meta">
<p class="section-label">Tiers</p>
<p class="sponsorship-meta-note">
One upfront payment per term. Setup takes less than 24 hours.
</p>
</header>
<div class="sponsorship-body">
<ol class="tier-list">
<li class="tier">
<p class="tier-eyebrow">Headline Sponsor</p>
<p class="tier-price">
<span class="tier-amount">$500</span>
<span class="tier-cadence">/ month</span>
</p>
<p class="tier-summary">
Logo pinned at the top of the README. Logo on the website.
</p>
<ul class="tier-includes">
<li>
Large logo and one-line description (max 120 characters) pinned at
the very top of the README, above all project entries.
</li>
<li>Logo link in the sponsor section of awesome-python.com.</li>
</ul>
<a
href="mailto:vinta.chen@gmail.com?subject=Awesome%20Python%20Sponsorship%20-%20Headline"
class="tier-cta"
>Email about Headline tier</a
>
</li>
<li class="tier">
<p class="tier-eyebrow">Featured Sponsor</p>
<p class="tier-price">
<span class="tier-amount">$150</span>
<span class="tier-cadence">/ month</span>
</p>
<p class="tier-summary">
Text link pinned at the top of the README. Text link on the website.
</p>
<ul class="tier-includes">
<li>
Text entry (<code>[Name](URL) - Description.</code>, max 120
characters) pinned at the top of the README, directly below
Headline sponsors.
</li>
<li>Text link in the sponsor section of awesome-python.com.</li>
</ul>
<a
href="mailto:vinta.chen@gmail.com?subject=Awesome%20Python%20Sponsorship%20-%20Featured"
class="tier-cta"
>Email about Featured tier</a
>
</li>
</ol>
</div>
</div>
</section>
<section class="sponsorship-section sponsorship-past" data-reveal>
<div class="section-shell sponsorship-shell">
<header class="sponsorship-meta">
<p class="section-label">Previously sponsored by</p>
</header>
<div class="sponsorship-body">
<ul class="past-sponsors">
<li>
<a href="https://www.warp.dev/" target="_blank" rel="noopener"
>Warp</a
>
<span class="past-sponsor-desc"
>The terminal for modern developers.</span
>
</li>
</ul>
</div>
</div>
</section>
<section class="sponsorship-section sponsorship-getstarted" data-reveal>
<div class="section-shell sponsorship-shell">
<header class="sponsorship-meta">
<p class="section-label">Get started</p>
</header>
<div class="sponsorship-body">
<p class="sponsorship-lede">
Email
<a
href="mailto:vinta.chen@gmail.com?subject=Awesome%20Python%20Sponsorship"
>vinta.chen@gmail.com</a
>
with the four items below.
</p>
<dl class="sponsorship-facts">
<div>
<dt>Tier</dt>
<dd>Headline Sponsor ($500/mo) or Featured Sponsor ($150/mo).</dd>
</div>
<div>
<dt>Content</dt>
<dd>
Product name, URL, logo, and description (Headline tier), or
<code>[Name](URL) - Description.</code> entry (Featured tier).
</dd>
</div>
<div>
<dt>Duration</dt>
<dd>1, 3, 6 months, or longer.</dd>
</div>
<div>
<dt>Payment method</dt>
<dd>US bank transfer (ACH/wire) or PayPal.</dd>
</div>
</dl>
<div class="sponsorship-cta-row">
<a
href="mailto:vinta.chen@gmail.com?subject=Awesome%20Python%20Sponsorship"
class="hero-action hero-action-primary"
>Email vinta.chen@gmail.com</a
>
</div>
</div>
</div>
</section>
<section class="sponsorship-section sponsorship-independence" data-reveal>
<div class="section-shell sponsorship-shell">
<header class="sponsorship-meta">
<p class="section-label">Editorial independence</p>
</header>
<div class="sponsorship-body">
<p>
Sponsorship is logo and link placement in the README header. It does not
influence which projects are listed. We curate listings on merit through
the normal
<a
href="https://github.com/vinta/awesome-python/blob/master/CONTRIBUTING.md"
target="_blank"
rel="noopener"
>contribution process</a
>.
</p>
<p class="sponsorship-fineprint">
We reserve the right to request changes to sponsor text, logos, or links
that are misleading, off-topic, or incompatible with the README
formatting.
</p>
</div>
</div>
</section>
{% endblock %}

View File

@@ -8,7 +8,9 @@ from datetime import UTC, date, datetime
from html.parser import HTMLParser
from pathlib import Path
import pytest
from build import (
TemplateEntry,
annotate_entries_with_stars,
build,
detect_source_type,
@@ -16,6 +18,7 @@ from build import (
extract_github_repo,
load_stars,
sort_entries,
subcategory_path,
)
from readme_parser import parse_readme, slugify
@@ -65,22 +68,18 @@ class TestSlugify:
def test_uppercase_acronym(self):
assert slugify("RESTful API") == "restful-api"
def test_all_caps(self):
assert slugify("CMS") == "cms"
def test_hyphenated_input(self):
assert slugify("Command-line Tools") == "command-line-tools"
def test_special_chars(self):
assert slugify("Editor Plugins and IDEs") == "editor-plugins-and-ides"
def test_single_word(self):
assert slugify("Audio") == "audio"
def test_extra_spaces(self):
assert slugify(" Date and Time ") == "date-and-time"
class TestSubcategoryPath:
def test_builds_path(self):
assert subcategory_path("web-frameworks", "synchronous") == "/categories/web-frameworks/synchronous/"
# ---------------------------------------------------------------------------
# build (integration)
# ---------------------------------------------------------------------------
@@ -109,14 +108,16 @@ class TestBuild:
"{% endblock %}",
encoding="utf-8",
)
(tpl_dir / "category.html").write_text(
'{% extends "base.html" %}{% block content %}<h1>{{ category.name }}</h1>{% for entry in entries %}<a href="{{ entry.url }}">{{ entry.name }}</a>{% endfor %}{% endblock %}',
encoding="utf-8",
)
(tpl_dir / "sponsorship.html").write_text(
'{% extends "base.html" %}{% block content %}<h1>Sponsor</h1>{% endblock %}',
encoding="utf-8",
)
(tpl_dir / "llms.txt").write_text(
"# Awesome Python\n"
"\n"
"Use this list to find Python tools.\n"
"\n"
"# Categories\n"
"\n"
"{{ categories_md }}\n",
"# Awesome Python\n\nUse this list to find Python tools.\n\n# Categories\n\n{{ categories_md }}\n",
encoding="utf-8",
)
@@ -125,7 +126,7 @@ class TestBuild:
tpl_dir = tmp_path / "website" / "templates"
shutil.copytree(real_tpl, tpl_dir)
def test_build_creates_single_page(self, tmp_path):
def test_build_creates_homepage_and_category_pages(self, tmp_path):
readme = textwrap.dedent("""\
# Awesome Python
@@ -137,7 +138,7 @@ class TestBuild:
## Widgets
_Widget libraries._
_Widget libraries. Also see [awesome-widgets](https://example.com/widgets)._
- [w1](https://example.com) - A widget.
@@ -164,8 +165,8 @@ class TestBuild:
site = tmp_path / "website" / "output"
assert (site / "index.html").exists()
# No category sub-pages
assert not (site / "categories").exists()
assert (site / "categories" / "widgets" / "index.html").exists()
assert (site / "categories" / "gadgets" / "index.html").exists()
def test_build_creates_root_discovery_files(self, tmp_path):
readme = textwrap.dedent("""\
@@ -175,9 +176,13 @@ class TestBuild:
---
**Tools**
## Widgets
- [w1](https://example.com) - A widget.
- Sync
- [w1](https://example.com) - A widget.
# Contributing
@@ -190,27 +195,93 @@ class TestBuild:
site = tmp_path / "website" / "output"
robots = (site / "robots.txt").read_text(encoding="utf-8")
assert robots == (
"User-agent: *\n"
"Content-Signal: search=yes, ai-input=yes, ai-train=yes\n"
"Allow: /\n"
"\n"
"Sitemap: https://awesome-python.com/sitemap.xml\n"
)
assert robots == ("User-agent: *\nContent-Signal: search=yes, ai-input=yes, ai-train=yes\nAllow: /\n\nSitemap: https://awesome-python.com/sitemap.xml\n")
sitemap = ET.parse(site / "sitemap.xml")
root = sitemap.getroot()
ns = {"sitemap": "http://www.sitemaps.org/schemas/sitemap/0.9"}
locs = [loc.text for loc in root.findall("sitemap:url/sitemap:loc", ns)]
lastmods = [lastmod.text for lastmod in root.findall("sitemap:url/sitemap:lastmod", ns)]
locs = [loc.text or "" for loc in root.findall("sitemap:url/sitemap:loc", ns)]
lastmods = [lastmod.text or "" for lastmod in root.findall("sitemap:url/sitemap:lastmod", ns)]
assert root.tag == "{http://www.sitemaps.org/schemas/sitemap/0.9}urlset"
assert locs == ["https://awesome-python.com/"]
assert len(lastmods) == 1
assert start_date <= date.fromisoformat(lastmods[0]) <= end_date
assert locs == [
"https://awesome-python.com/",
"https://awesome-python.com/categories/widgets/",
"https://awesome-python.com/categories/tools/",
"https://awesome-python.com/categories/widgets/sync/",
"https://awesome-python.com/sponsorship/",
]
assert len(lastmods) == len(locs)
assert all(start_date <= date.fromisoformat(lastmod) <= end_date for lastmod in lastmods)
assert all(loc.startswith("https://awesome-python.com/") for loc in locs)
assert all("?" not in loc for loc in locs)
def test_build_creates_category_pages_with_metadata_and_links(self, tmp_path):
readme = textwrap.dedent("""\
# Awesome Python
Intro.
---
**Tools**
## Widgets
_Widget libraries. Also see [awesome-widgets](https://example.com/widgets)._
- [w1](https://example.com/w1) - A widget.
- [w2](https://github.com/owner/w2) - A starred widget.
## Gadgets
_Gadget tools._
- [g1](https://example.com/g1) - A gadget.
# Contributing
Help!
""")
(tmp_path / "README.md").write_text(readme, encoding="utf-8")
self._copy_real_templates(tmp_path)
data_dir = tmp_path / "website" / "data"
data_dir.mkdir(parents=True)
stars = {
"owner/w2": {
"stars": 42,
"owner": "owner",
"last_commit_at": "2026-01-01T00:00:00+00:00",
"fetched_at": "2026-01-01T00:00:00+00:00",
},
}
(data_dir / "github_stars.json").write_text(json.dumps(stars), encoding="utf-8")
build(tmp_path)
site = tmp_path / "website" / "output"
index_html = (site / "index.html").read_text(encoding="utf-8")
category_html = (site / "categories" / "widgets" / "index.html").read_text(encoding="utf-8")
parser = HeadMetadataParser()
parser.feed(category_html)
assert 'href="/categories/widgets/"' in index_html
assert 'data-value="Widgets"' in index_html
assert parser.title.strip() == "Widgets Python Libraries | Awesome Python"
assert parser.meta_by_name["description"] == "Explore 2 curated Python projects in Widgets. Widget libraries. Also see awesome-widgets."
assert parser.links_by_rel["canonical"] == "https://awesome-python.com/categories/widgets/"
assert parser.meta_by_property["og:url"] == "https://awesome-python.com/categories/widgets/"
assert '<link rel="alternate" type="text/markdown" href="/index.md" />' not in category_html
assert "<h1>Widgets</h1>" in category_html
assert 'Widget libraries. Also see <a href="https://example.com/widgets" target="_blank" rel="noopener">awesome-widgets</a>.' in category_html
assert 'href="https://example.com/w1"' in category_html
assert "A widget." in category_html
assert 'href="https://github.com/owner/w2"' in category_html
assert '<table class="table">' in category_html
assert "42" in category_html
assert "2026-01-01T00:00:00+00:00" in category_html
def test_build_creates_markdown_alternate_without_sponsors(self, tmp_path):
readme = textwrap.dedent("""\
# Awesome Python
@@ -300,59 +371,6 @@ class TestBuild:
assert not (tmp_path / "website" / "output" / "categories" / "stale").exists()
def test_index_contains_category_names(self, tmp_path):
readme = textwrap.dedent("""\
# T
---
**Group A**
## Alpha
- [a](https://x.com) - A.
**Group B**
## Beta
- [b](https://x.com) - B.
# Contributing
Done.
""")
self._make_repo(tmp_path, readme)
build(tmp_path)
index_html = (tmp_path / "website" / "output" / "index.html").read_text()
assert "Alpha" in index_html
assert "Beta" in index_html
assert "Group A" in index_html
assert "Group B" in index_html
def test_index_contains_preview_text(self, tmp_path):
readme = textwrap.dedent("""\
# T
---
## Stuff
- [django](https://x.com) - A framework.
- [flask](https://x.com) - A micro.
# Contributing
Done.
""")
self._make_repo(tmp_path, readme)
build(tmp_path)
index_html = (tmp_path / "website" / "output" / "index.html").read_text()
assert "django" in index_html
assert "flask" in index_html
def test_build_with_stars_sorts_by_stars(self, tmp_path):
readme = textwrap.dedent("""\
# T
@@ -397,6 +415,26 @@ class TestBuild:
# Expand content present
assert "expand-content" in html
def test_build_fails_when_group_and_category_slug_collide(self, tmp_path):
readme = textwrap.dedent("""\
# T
---
**Widgets**
## Widgets
- [w1](https://example.com) - W.
# Contributing
Done.
""")
self._make_repo(tmp_path, readme)
with pytest.raises(ValueError, match="slug collision"):
build(tmp_path)
def test_index_contains_aligned_homepage_metadata(self, tmp_path):
readme = (Path(__file__).parents[2] / "README.md").read_text(encoding="utf-8")
(tmp_path / "README.md").write_text(readme, encoding="utf-8")
@@ -430,6 +468,215 @@ class TestBuild:
assert parser.meta_by_name["twitter:description"] == expected_description
assert parser.meta_by_name["twitter:image"] == expected_image
assert "<head>\n <meta charset" in html
assert 'id="hero-category-heading">Browse by category</h2>' in html
assert 'class="hero-category-link" href="/categories/ai-and-agents/"' in html
def test_build_creates_subcategory_pages(self, tmp_path):
readme = textwrap.dedent("""\
# T
---
**Web**
## Web Frameworks
- Synchronous
- [django](https://example.com/django) - Sync framework.
- Asynchronous
- [fastapi](https://example.com/fastapi) - Async framework.
# Contributing
Done.
""")
self._copy_real_templates(tmp_path)
(tmp_path / "README.md").write_text(readme, encoding="utf-8")
build(tmp_path)
site = tmp_path / "website" / "output"
sync = (site / "categories" / "web-frameworks" / "synchronous" / "index.html").read_text(encoding="utf-8")
async_ = (site / "categories" / "web-frameworks" / "asynchronous" / "index.html").read_text(encoding="utf-8")
assert "django" in sync
assert "fastapi" not in sync
assert "fastapi" in async_
assert "django" not in async_
def test_subcategory_page_shows_breadcrumb(self, tmp_path):
readme = textwrap.dedent("""\
# T
---
**Web**
## Web Frameworks
- Synchronous
- [django](https://example.com/django) - Sync.
# Contributing
Done.
""")
self._copy_real_templates(tmp_path)
(tmp_path / "README.md").write_text(readme, encoding="utf-8")
build(tmp_path)
site = tmp_path / "website" / "output"
sync = (site / "categories" / "web-frameworks" / "synchronous" / "index.html").read_text(encoding="utf-8")
assert 'href="/categories/web-frameworks/"' in sync
assert "Web Frameworks" in sync
assert "<h1>Synchronous</h1>" in sync
assert "category-breadcrumb" in sync
parent = (site / "categories" / "web-frameworks" / "index.html").read_text(encoding="utf-8")
assert "category-breadcrumb" not in parent
def test_index_embeds_filter_urls_json(self, tmp_path):
readme = textwrap.dedent("""\
# T
---
**AI & ML**
## Deep Learning
- [dl1](https://example.com/dl1) - DL.
## Machine Learning
- Classical
- [ml1](https://example.com/ml1) - ML.
# Contributing
Done.
""")
self._copy_real_templates(tmp_path)
(tmp_path / "README.md").write_text(readme, encoding="utf-8")
build(tmp_path)
site = tmp_path / "website" / "output"
index_html = (site / "index.html").read_text(encoding="utf-8")
marker = '<script type="application/json" id="filter-urls">'
assert marker in index_html
start = index_html.index(marker) + len(marker)
end = index_html.index("</script>", start)
data = json.loads(index_html[start:end])
assert data["Deep Learning"] == "/categories/deep-learning/"
assert data["Machine Learning"] == "/categories/machine-learning/"
assert data["AI & ML"] == "/categories/ai-ml/"
assert data["Machine Learning > Classical"] == "/categories/machine-learning/classical/"
def test_filter_urls_json_escapes_closing_script_tag(self, tmp_path):
readme = textwrap.dedent("""\
# T
---
## Sneaky </script><script>x=1</script>
- [a](https://example.com) - A.
# Contributing
Done.
""")
self._copy_real_templates(tmp_path)
(tmp_path / "README.md").write_text(readme, encoding="utf-8")
build(tmp_path)
site = tmp_path / "website" / "output"
index_html = (site / "index.html").read_text(encoding="utf-8")
marker = '<script type="application/json" id="filter-urls">'
start = index_html.index(marker) + len(marker)
end = index_html.index("</script>", start)
block = index_html[start:end]
assert "</script>" not in block
data = json.loads(block)
assert any("Sneaky" in key for key in data)
def test_build_creates_group_pages(self, tmp_path):
readme = textwrap.dedent("""\
# T
---
**AI & ML**
## Deep Learning
- [dl1](https://example.com/dl1) - DL.
## Machine Learning
- [ml1](https://example.com/ml1) - ML.
**Web Development**
## Web Frameworks
- [wf1](https://example.com/wf1) - WF.
# Contributing
Done.
""")
self._copy_real_templates(tmp_path)
(tmp_path / "README.md").write_text(readme, encoding="utf-8")
build(tmp_path)
site = tmp_path / "website" / "output"
ai_ml = (site / "categories" / "ai-ml" / "index.html").read_text(encoding="utf-8")
web_dev = (site / "categories" / "web-development" / "index.html").read_text(encoding="utf-8")
assert "dl1" in ai_ml
assert "ml1" in ai_ml
assert "wf1" not in ai_ml
assert "wf1" in web_dev
assert "dl1" not in web_dev
def test_tag_buttons_have_data_url(self, tmp_path):
readme = textwrap.dedent("""\
# T
---
**AI & ML**
## Deep Learning
- Vision
- [v1](https://example.com/v1) - Vision lib.
# Contributing
Done.
""")
self._copy_real_templates(tmp_path)
(tmp_path / "README.md").write_text(readme, encoding="utf-8")
build(tmp_path)
site = tmp_path / "website" / "output"
index_html = (site / "index.html").read_text(encoding="utf-8")
assert 'data-value="Deep Learning"' in index_html
assert 'data-url="/categories/deep-learning/"' in index_html
assert 'data-value="AI &amp; ML"' in index_html or 'data-value="AI & ML"' in index_html
assert 'data-url="/categories/ai-ml/"' in index_html
assert 'data-url="/categories/deep-learning/vision/"' in index_html
# ---------------------------------------------------------------------------
@@ -489,45 +736,61 @@ class TestLoadStars:
# ---------------------------------------------------------------------------
def _template_entry(name: str, stars: int | None, source_type: str | None = None) -> TemplateEntry:
return TemplateEntry(
name=name,
url="",
description="",
categories=[],
groups=[],
subcategories=[],
stars=stars,
owner=None,
last_commit_at=None,
source_type=source_type,
also_see=[],
)
class TestSortEntries:
def test_sorts_by_stars_descending(self):
entries = [
{"name": "a", "stars": 100, "url": ""},
{"name": "b", "stars": 500, "url": ""},
{"name": "c", "stars": 200, "url": ""},
_template_entry("a", 100),
_template_entry("b", 500),
_template_entry("c", 200),
]
result = sort_entries(entries)
assert [e["name"] for e in result] == ["b", "c", "a"]
def test_equal_stars_sorted_alphabetically(self):
entries = [
{"name": "beta", "stars": 100, "url": ""},
{"name": "alpha", "stars": 100, "url": ""},
_template_entry("beta", 100),
_template_entry("alpha", 100),
]
result = sort_entries(entries)
assert [e["name"] for e in result] == ["alpha", "beta"]
def test_no_stars_go_to_bottom(self):
entries = [
{"name": "no-stars", "stars": None, "url": ""},
{"name": "has-stars", "stars": 50, "url": ""},
_template_entry("no-stars", None),
_template_entry("has-stars", 50),
]
result = sort_entries(entries)
assert [e["name"] for e in result] == ["has-stars", "no-stars"]
def test_no_stars_sorted_alphabetically(self):
entries = [
{"name": "zebra", "stars": None, "url": ""},
{"name": "apple", "stars": None, "url": ""},
_template_entry("zebra", None),
_template_entry("apple", None),
]
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},
_template_entry("builtin", None, "Built-in"),
_template_entry("starred", 100),
_template_entry("unstarred", None),
]
result = sort_entries(entries)
assert [e["name"] for e in result] == ["starred", "builtin", "unstarred"]
@@ -634,6 +897,36 @@ class TestExtractEntries:
entries = extract_entries(categories, groups)
assert entries[0]["source_type"] == "Built-in"
def test_subcategory_includes_slug_and_url(self):
readme = textwrap.dedent("""\
# T
---
**Tools**
## Web Frameworks
- Synchronous
- [django](https://example.com/django) - A framework.
# 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]["subcategories"] == [
{
"name": "Synchronous",
"value": "Web Frameworks > Synchronous",
"slug": "synchronous",
"url": "/categories/web-frameworks/synchronous/",
},
]
# ---------------------------------------------------------------------------
# annotate_entries_with_stars
@@ -644,23 +937,15 @@ class TestAnnotateEntriesWithStars:
def test_appends_star_count_to_bullet(self):
markdown = "- [foo](https://github.com/owner/foo) - A foo.\n"
stars = {"owner/foo": {"stars": 123, "owner": "owner"}}
assert annotate_entries_with_stars(markdown, stars) == (
"- [foo](https://github.com/owner/foo) - A foo. (123 GitHub stars)\n"
)
assert annotate_entries_with_stars(markdown, stars) == ("- [foo](https://github.com/owner/foo) - A foo. (123 GitHub stars)\n")
def test_uses_first_github_link(self):
markdown = (
"- [foo](https://github.com/owner/foo) - A foo. "
"Also [bar](https://github.com/owner/bar).\n"
)
markdown = "- [foo](https://github.com/owner/foo) - A foo. Also [bar](https://github.com/owner/bar).\n"
stars = {
"owner/foo": {"stars": 10, "owner": "owner"},
"owner/bar": {"stars": 99, "owner": "owner"},
}
assert annotate_entries_with_stars(markdown, stars) == (
"- [foo](https://github.com/owner/foo) - A foo. "
"Also [bar](https://github.com/owner/bar). (10 GitHub stars)\n"
)
assert annotate_entries_with_stars(markdown, stars) == ("- [foo](https://github.com/owner/foo) - A foo. Also [bar](https://github.com/owner/bar). (10 GitHub stars)\n")
def test_skips_entries_without_star_data(self):
markdown = "- [foo](https://github.com/owner/foo) - A foo.\n"
@@ -679,13 +964,9 @@ class TestAnnotateEntriesWithStars:
def test_handles_indented_bullets(self):
markdown = " - [foo](https://github.com/owner/foo)\n"
stars = {"owner/foo": {"stars": 7, "owner": "owner"}}
assert annotate_entries_with_stars(markdown, stars) == (
" - [foo](https://github.com/owner/foo) (7 GitHub stars)\n"
)
assert annotate_entries_with_stars(markdown, stars) == (" - [foo](https://github.com/owner/foo) (7 GitHub stars)\n")
def test_preserves_lines_without_trailing_newline(self):
markdown = "- [foo](https://github.com/owner/foo) - A foo."
stars = {"owner/foo": {"stars": 5, "owner": "owner"}}
assert annotate_entries_with_stars(markdown, stars) == (
"- [foo](https://github.com/owner/foo) - A foo. (5 GitHub stars)"
)
assert annotate_entries_with_stars(markdown, stars) == ("- [foo](https://github.com/owner/foo) - A foo. (5 GitHub stars)")

View File

@@ -17,41 +17,12 @@ class TestExtractGithubRepos:
assert result == {"psf/requests"}
def test_multiple_repos(self):
readme = (
"* [requests](https://github.com/psf/requests) - HTTP.\n"
"* [flask](https://github.com/pallets/flask) - Micro."
)
readme = "* [requests](https://github.com/psf/requests) - HTTP.\n* [flask](https://github.com/pallets/flask) - Micro."
result = extract_github_repos(readme)
assert result == {"psf/requests", "pallets/flask"}
def test_ignores_non_github_urls(self):
readme = "* [pypy](https://foss.heptapod.net/pypy/pypy) - Fast Python."
result = extract_github_repos(readme)
assert result == set()
def test_ignores_github_io_urls(self):
readme = "* [docs](https://user.github.io/project) - Docs site."
result = extract_github_repos(readme)
assert result == set()
def test_ignores_github_wiki_and_blob_urls(self):
readme = (
"* [wiki](https://github.com/org/repo/wiki) - Wiki.\n"
"* [file](https://github.com/org/repo/blob/main/f.py) - File."
)
result = extract_github_repos(readme)
assert result == set()
def test_handles_trailing_slash(self):
readme = "* [lib](https://github.com/org/repo/) - Lib."
result = extract_github_repos(readme)
assert result == {"org/repo"}
def test_deduplicates(self):
readme = (
"* [a](https://github.com/org/repo) - A.\n"
"* [b](https://github.com/org/repo) - B."
)
readme = "* [a](https://github.com/org/repo) - A.\n* [b](https://github.com/org/repo) - B."
result = extract_github_repos(readme)
assert result == {"org/repo"}

View File

@@ -180,7 +180,9 @@ class TestParseReadmeSections:
groups = parse_readme(MINIMAL_README)
cats = groups[0]["categories"]
assert cats[0]["description"] == "Libraries for alpha stuff."
assert cats[0]["description_html"] == "Libraries for alpha stuff."
assert cats[1]["description"] == "Tools for beta."
assert cats[1]["description_html"] == "Tools for beta."
def test_contributing_skipped(self):
groups = parse_readme(MINIMAL_README)
@@ -216,6 +218,7 @@ class TestParseReadmeSections:
groups = parse_readme(readme)
cats = groups[0]["categories"]
assert cats[0]["description"] == ""
assert cats[0]["description_html"] == ""
assert cats[0]["entries"][0]["name"] == "item"
def test_description_with_link_stripped(self):
@@ -237,6 +240,7 @@ class TestParseReadmeSections:
groups = parse_readme(readme)
cats = groups[0]["categories"]
assert cats[0]["description"] == "Algorithms. Also see awesome-algos."
assert cats[0]["description_html"] == 'Algorithms. Also see <a href="https://example.com" target="_blank" rel="noopener">awesome-algos</a>.'
class TestParseGroupedReadme:
@@ -346,10 +350,7 @@ def _content_nodes(md_text: str) -> list[SyntaxTreeNode]:
class TestParseSectionEntries:
def test_flat_entries(self):
nodes = _content_nodes(
"- [django](https://example.com/d) - A web framework.\n"
"- [flask](https://example.com/f) - A micro framework.\n"
)
nodes = _content_nodes("- [django](https://example.com/d) - A web framework.\n- [flask](https://example.com/f) - A micro framework.\n")
entries = _parse_section_entries(nodes)
assert len(entries) == 2
assert entries[0]["name"] == "django"
@@ -366,13 +367,7 @@ class TestParseSectionEntries:
assert entries[0]["description"] == ""
def test_subcategorized_entries(self):
nodes = _content_nodes(
"- Algorithms\n"
" - [algos](https://x.com/a) - Algo lib.\n"
" - [sorts](https://x.com/s) - Sort lib.\n"
"- Design Patterns\n"
" - [patterns](https://x.com/p) - Pattern lib.\n"
)
nodes = _content_nodes("- Algorithms\n - [algos](https://x.com/a) - Algo lib.\n - [sorts](https://x.com/s) - Sort lib.\n- Design Patterns\n - [patterns](https://x.com/p) - Pattern lib.\n")
entries = _parse_section_entries(nodes)
assert len(entries) == 3
assert entries[0]["name"] == "algos"
@@ -428,7 +423,7 @@ class TestParseSectionEntries:
assert cats[0]["entry_count"] == 3
def test_description_html_escapes_xss(self):
nodes = _content_nodes('- [lib](https://x.com) - A <script>alert(1)</script> lib.\n')
nodes = _content_nodes("- [lib](https://x.com) - A <script>alert(1)</script> lib.\n")
entries = _parse_section_entries(nodes)
assert "<script>" not in entries[0]["description"]
assert "&lt;script&gt;" in entries[0]["description"]
@@ -445,9 +440,6 @@ class TestParseRealReadme:
def test_at_least_11_groups(self):
assert len(self.groups) >= 11
def test_first_group_is_ai_ml(self):
assert self.groups[0]["name"] == "AI & ML"
def test_at_least_69_categories(self):
assert len(self.cats) >= 69
@@ -455,38 +447,10 @@ class TestParseRealReadme:
all_names = [c["name"] for c in self.cats]
assert "Contributing" not in all_names
def test_first_category_is_ai_and_agents(self):
assert self.cats[0]["name"] == "AI and Agents"
assert self.cats[0]["slug"] == "ai-and-agents"
def test_web_apis_slug(self):
slugs = [c["slug"] for c in self.cats]
assert "web-apis" in slugs
def test_descriptions_extracted(self):
ai = next(c for c in self.cats if c["name"] == "AI and Agents")
assert "AI applications" in ai["description"]
def test_entry_counts_nonzero(self):
for cat in self.cats:
assert cat["entry_count"] > 0, f"{cat['name']} has 0 entries"
def test_async_has_also_see(self):
async_cat = next(c for c in self.cats if c["name"] == "Asynchronous Programming")
asyncio_entry = next(e for e in async_cat["entries"] if e["name"] == "asyncio")
assert len(asyncio_entry["also_see"]) >= 1
assert asyncio_entry["also_see"][0]["name"] == "awesome-asyncio"
def test_description_links_stripped_to_text(self):
algos = next(c for c in self.cats if c["name"] == "Algorithms and Design Patterns")
assert "awesome-algorithms" in algos["description"]
assert "https://" not in algos["description"]
def test_miscellaneous_in_own_group(self):
misc_group = next((g for g in self.groups if g["name"] == "Miscellaneous"), None)
assert misc_group is not None
assert any(c["name"] == "Miscellaneous" for c in misc_group["categories"])
def test_all_entries_have_nonempty_names(self):
bad = []
for cat in self.cats: