mirror of
https://github.com/vinta/awesome-python.git
synced 2026-10-21 03:02:40 +00:00
add custom website build system
Replaces MkDocs with a bespoke Python site generator using Jinja2 templates and Markdown. Adds uv for dependency management, GitHub Actions workflow for deployment, and Makefile targets for local development (fetch_stars, build, preview, deploy). Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
4af84dac8e
commit
177183d9bd
14 changed files with 5298 additions and 13 deletions
502
website/build.py
Normal file
502
website/build.py
Normal file
|
|
@ -0,0 +1,502 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Build a single-page HTML site from README.md for the awesome-python website."""
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import TypedDict
|
||||
|
||||
import markdown
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
# Thematic grouping of categories. Each category name must match exactly
|
||||
# as it appears in README.md (the ## heading text).
|
||||
SECTION_GROUPS: list[tuple[str, list[str]]] = [
|
||||
("Web & API", [
|
||||
"Web Frameworks", "RESTful API", "GraphQL", "WebSocket",
|
||||
"ASGI Servers", "WSGI Servers", "HTTP Clients", "Template Engine",
|
||||
"Web Asset Management", "Web Content Extracting", "Web Crawling",
|
||||
]),
|
||||
("Data & ML", [
|
||||
"Data Analysis", "Data Validation", "Data Visualization",
|
||||
"Machine Learning", "Deep Learning", "Computer Vision",
|
||||
"Natural Language Processing", "Recommender Systems", "Science",
|
||||
"Quantum Computing",
|
||||
]),
|
||||
("DevOps & Infrastructure", [
|
||||
"DevOps Tools", "Distributed Computing", "Task Queues",
|
||||
"Job Scheduler", "Serverless Frameworks", "Logging", "Processes",
|
||||
"Shell", "Network Virtualization", "RPC Servers",
|
||||
]),
|
||||
("Database & Storage", [
|
||||
"Database", "Database Drivers", "ORM", "Caching", "Search",
|
||||
"Serialization",
|
||||
]),
|
||||
("Development Tools", [
|
||||
"Testing", "Debugging Tools", "Code Analysis", "Build Tools",
|
||||
"Refactoring", "Documentation", "Editor Plugins and IDEs",
|
||||
"Interactive Interpreter",
|
||||
]),
|
||||
("CLI & GUI", [
|
||||
"Command-line Interface Development", "Command-line Tools",
|
||||
"GUI Development",
|
||||
]),
|
||||
("Content & Media", [
|
||||
"Audio", "Video", "Image Processing", "HTML Manipulation",
|
||||
"Text Processing", "Specific Formats Processing",
|
||||
"File Manipulation", "Downloader",
|
||||
]),
|
||||
("System & Runtime", [
|
||||
"Asynchronous Programming", "Environment Management",
|
||||
"Package Management", "Package Repositories", "Distribution",
|
||||
"Implementations", "Built-in Classes Enhancement",
|
||||
"Functional Programming", "Configuration Files",
|
||||
]),
|
||||
("Security & Auth", [
|
||||
"Authentication", "Cryptography", "Penetration Testing",
|
||||
"Permissions",
|
||||
]),
|
||||
("Specialized", [
|
||||
"CMS", "Admin Panels", "Email", "Game Development", "Geolocation",
|
||||
"Hardware", "Internationalization", "Date and Time",
|
||||
"URL Manipulation", "Robotics", "Microsoft Windows", "Miscellaneous",
|
||||
"Algorithms and Design Patterns", "Static Site Generator",
|
||||
]),
|
||||
("Resources", []), # Filled dynamically from parsed resources
|
||||
]
|
||||
|
||||
|
||||
def slugify(name: str) -> str:
|
||||
"""Convert a category name to a URL-friendly slug."""
|
||||
slug = name.lower()
|
||||
slug = re.sub(r"[^a-z0-9\s-]", "", slug)
|
||||
slug = re.sub(r"[\s]+", "-", slug.strip())
|
||||
slug = re.sub(r"-+", "-", slug)
|
||||
return slug
|
||||
|
||||
|
||||
def count_entries(content: str) -> int:
|
||||
"""Count library entries (lines starting with * [ or - [) in a content block."""
|
||||
return sum(1 for line in content.split("\n") if re.match(r"\s*[-*]\s+\[", line))
|
||||
|
||||
|
||||
def extract_preview(content: str, *, max_names: int = 4) -> str:
|
||||
"""Extract first N main library names from markdown content for preview text.
|
||||
|
||||
Only includes top-level or single-indent entries (indent <= 3 spaces),
|
||||
skipping subcategory labels (items without links) and deep sub-entries.
|
||||
"""
|
||||
names = []
|
||||
for m in re.finditer(r"^(\s*)[-*]\s+\[([^\]]+)\]", content, re.MULTILINE):
|
||||
indent_len = len(m.group(1))
|
||||
if indent_len > 3:
|
||||
continue
|
||||
names.append(m.group(2))
|
||||
if len(names) >= max_names:
|
||||
break
|
||||
return ", ".join(names)
|
||||
|
||||
|
||||
def render_content_html(content: str) -> str:
|
||||
"""Render category markdown content to HTML with subcategory detection.
|
||||
|
||||
Lines that are list items without links (e.g., "- Synchronous") are
|
||||
treated as subcategory headers and rendered as bold dividers.
|
||||
|
||||
Indent levels in the README:
|
||||
- 0 spaces: top-level entry or subcategory label
|
||||
- 2 spaces: entry under a subcategory (still a main entry)
|
||||
- 4+ spaces: sub-entry (e.g., awesome-django under django)
|
||||
"""
|
||||
lines = content.split("\n")
|
||||
out: list[str] = []
|
||||
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
indent_len = len(line) - len(line.lstrip())
|
||||
|
||||
# Detect subcategory labels: list items without links
|
||||
m = re.match(r"^[-*]\s+(.+)$", stripped)
|
||||
if m and "[" not in stripped:
|
||||
label = m.group(1)
|
||||
out.append(f'<div class="subcat">{label}</div>')
|
||||
continue
|
||||
|
||||
# Entry with link and description: * [name](url) - Description.
|
||||
m = re.match(
|
||||
r"^\s*[-*]\s+\[([^\]]+)\]\(([^)]+)\)\s*[-\u2013\u2014]\s*(.+)$",
|
||||
line,
|
||||
)
|
||||
if m:
|
||||
name, url, desc = m.groups()
|
||||
if indent_len > 3:
|
||||
out.append(
|
||||
f'<div class="entry-sub">'
|
||||
f'<a href="{url}">{name}</a>'
|
||||
f"</div>"
|
||||
)
|
||||
else:
|
||||
out.append(
|
||||
f'<div class="entry">'
|
||||
f'<a href="{url}">{name}</a>'
|
||||
f'<span class="sep">—</span>{desc}'
|
||||
f"</div>"
|
||||
)
|
||||
continue
|
||||
|
||||
# Link-only entry (no description): * [name](url)
|
||||
m = re.match(r"^\s*[-*]\s+\[([^\]]+)\]\(([^)]+)\)\s*$", line)
|
||||
if m:
|
||||
name, url = m.groups()
|
||||
if indent_len > 3:
|
||||
out.append(
|
||||
f'<div class="entry-sub">'
|
||||
f'<a href="{url}">{name}</a>'
|
||||
f"</div>"
|
||||
)
|
||||
else:
|
||||
out.append(
|
||||
f'<div class="entry">'
|
||||
f'<a href="{url}">{name}</a>'
|
||||
f"</div>"
|
||||
)
|
||||
continue
|
||||
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def parse_readme(text: str) -> tuple[list[dict], list[dict]]:
|
||||
"""Parse README.md text into categories and resources.
|
||||
|
||||
Returns:
|
||||
(categories, resources) where each is a list of dicts with keys:
|
||||
name, slug, description, content
|
||||
"""
|
||||
lines = text.split("\n")
|
||||
|
||||
separator_idx = None
|
||||
for i, line in enumerate(lines):
|
||||
if line.strip() == "---" and i > 0:
|
||||
separator_idx = i
|
||||
break
|
||||
|
||||
if separator_idx is None:
|
||||
return [], []
|
||||
|
||||
resources_idx = None
|
||||
contributing_idx = None
|
||||
for i, line in enumerate(lines):
|
||||
if line.strip() == "# Resources":
|
||||
resources_idx = i
|
||||
elif line.strip() == "# Contributing":
|
||||
contributing_idx = i
|
||||
|
||||
cat_end = resources_idx if resources_idx is not None else len(lines)
|
||||
category_lines = lines[separator_idx + 1 : cat_end]
|
||||
|
||||
resource_lines = []
|
||||
if resources_idx is not None:
|
||||
res_end = contributing_idx if contributing_idx is not None else len(lines)
|
||||
resource_lines = lines[resources_idx:res_end]
|
||||
|
||||
categories = _extract_sections(category_lines, level=2)
|
||||
resources = _extract_sections(resource_lines, level=2)
|
||||
|
||||
return categories, resources
|
||||
|
||||
|
||||
def _extract_sections(lines: list[str], *, level: int) -> list[dict]:
|
||||
"""Extract ## sections from a block of lines."""
|
||||
prefix = "#" * level + " "
|
||||
sections = []
|
||||
current_name = None
|
||||
current_lines: list[str] = []
|
||||
|
||||
for line in lines:
|
||||
if line.startswith(prefix) and not line.startswith(prefix + "#"):
|
||||
if current_name is not None:
|
||||
sections.append(_build_section(current_name, current_lines))
|
||||
current_name = line[len(prefix) :].strip()
|
||||
current_lines = []
|
||||
elif current_name is not None:
|
||||
current_lines.append(line)
|
||||
|
||||
if current_name is not None:
|
||||
sections.append(_build_section(current_name, current_lines))
|
||||
|
||||
return sections
|
||||
|
||||
|
||||
def _build_section(name: str, lines: list[str]) -> dict:
|
||||
"""Build a section dict from a name and its content lines."""
|
||||
while lines and not lines[0].strip():
|
||||
lines = lines[1:]
|
||||
while lines and not lines[-1].strip():
|
||||
lines = lines[:-1]
|
||||
|
||||
description = ""
|
||||
content_lines = lines
|
||||
if lines:
|
||||
m = re.match(r"^_(.+)_$", lines[0].strip())
|
||||
if m:
|
||||
description = m.group(1)
|
||||
content_lines = lines[1:]
|
||||
while content_lines and not content_lines[0].strip():
|
||||
content_lines = content_lines[1:]
|
||||
|
||||
content = "\n".join(content_lines).strip()
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"slug": slugify(name),
|
||||
"description": description,
|
||||
"content": content,
|
||||
}
|
||||
|
||||
|
||||
def render_markdown(text: str) -> str:
|
||||
"""Render markdown text to HTML."""
|
||||
md = markdown.Markdown(extensions=["extra"])
|
||||
return md.convert(text)
|
||||
|
||||
|
||||
def strip_markdown_links(text: str) -> str:
|
||||
"""Replace [text](url) with just text for plain-text contexts."""
|
||||
return re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text)
|
||||
|
||||
|
||||
def render_inline_markdown(text: str) -> str:
|
||||
"""Render inline markdown (links, bold, italic) to HTML."""
|
||||
from markupsafe import Markup
|
||||
|
||||
html = markdown.markdown(text)
|
||||
# Strip wrapping <p>...</p> since this is inline content
|
||||
html = re.sub(r"^<p>(.*)</p>$", r"\1", html.strip())
|
||||
# Add target/rel to links for external navigation
|
||||
html = html.replace("<a ", '<a target="_blank" rel="noopener" ')
|
||||
return Markup(html)
|
||||
|
||||
|
||||
def group_categories(
|
||||
categories: list[dict],
|
||||
resources: list[dict],
|
||||
) -> list[dict]:
|
||||
"""Organize categories and resources into thematic section groups."""
|
||||
cat_by_name = {c["name"]: c for c in categories}
|
||||
groups = []
|
||||
|
||||
for group_name, cat_names in SECTION_GROUPS:
|
||||
if group_name == "Resources":
|
||||
# Resources group uses parsed resources directly
|
||||
group_cats = list(resources)
|
||||
else:
|
||||
group_cats = [cat_by_name[n] for n in cat_names if n in cat_by_name]
|
||||
|
||||
if group_cats:
|
||||
groups.append({
|
||||
"name": group_name,
|
||||
"slug": slugify(group_name),
|
||||
"categories": group_cats,
|
||||
})
|
||||
|
||||
# Any categories not in a group go into "Other"
|
||||
grouped_names = set()
|
||||
for _, cat_names in SECTION_GROUPS:
|
||||
grouped_names.update(cat_names)
|
||||
ungrouped = [c for c in categories if c["name"] not in grouped_names]
|
||||
if ungrouped:
|
||||
groups.append({
|
||||
"name": "Other",
|
||||
"slug": "other",
|
||||
"categories": ungrouped,
|
||||
})
|
||||
|
||||
return groups
|
||||
|
||||
|
||||
class Entry(TypedDict):
|
||||
name: str
|
||||
url: str
|
||||
description: str
|
||||
category: str
|
||||
group: str
|
||||
stars: int | None
|
||||
owner: str | None
|
||||
pushed_at: str | None
|
||||
|
||||
|
||||
class StarData(TypedDict):
|
||||
stars: int
|
||||
owner: str
|
||||
pushed_at: str
|
||||
fetched_at: str
|
||||
|
||||
|
||||
GITHUB_REPO_URL_RE = re.compile(
|
||||
r"^https?://github\.com/([^/]+/[^/]+?)(?:\.git)?/?$"
|
||||
)
|
||||
|
||||
|
||||
def extract_github_repo(url: str) -> str | None:
|
||||
"""Extract owner/repo from a GitHub repo URL. Returns None for non-GitHub URLs."""
|
||||
m = GITHUB_REPO_URL_RE.match(url)
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def load_stars(path: Path) -> dict[str, StarData]:
|
||||
"""Load star data from JSON. Returns empty dict if file doesn't exist or is corrupt."""
|
||||
if path.exists():
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
return {}
|
||||
|
||||
|
||||
def sort_entries(entries: list[dict]) -> list[dict]:
|
||||
"""Sort entries by stars descending, then name ascending. No-star entries go last."""
|
||||
def sort_key(entry: dict) -> tuple[int, int, str]:
|
||||
stars = entry["stars"]
|
||||
name = entry["name"].lower()
|
||||
if stars is None:
|
||||
return (1, 0, name)
|
||||
return (0, -stars, name)
|
||||
return sorted(entries, key=sort_key)
|
||||
|
||||
|
||||
def extract_entries(
|
||||
categories: list[dict],
|
||||
resources: list[dict],
|
||||
groups: list[dict],
|
||||
) -> list[dict]:
|
||||
"""Flatten categories into individual library entries for table display."""
|
||||
cat_to_group: dict[str, str] = {}
|
||||
for group in groups:
|
||||
for cat in group["categories"]:
|
||||
cat_to_group[cat["name"]] = group["name"]
|
||||
|
||||
entries: list[dict] = []
|
||||
for cat in categories:
|
||||
group_name = cat_to_group.get(cat["name"], "Other")
|
||||
last_entry_indent = -1
|
||||
for line in cat["content"].split("\n"):
|
||||
indent_len = len(line) - len(line.lstrip())
|
||||
|
||||
# Link-only sub-item deeper than parent → "also see"
|
||||
m_sub = re.match(r"\s*[-*]\s+\[([^\]]+)\]\(([^)]+)\)\s*$", line)
|
||||
if m_sub and indent_len > last_entry_indent >= 0 and entries:
|
||||
entries[-1]["also_see"].append({
|
||||
"name": m_sub.group(1),
|
||||
"url": m_sub.group(2),
|
||||
})
|
||||
continue
|
||||
|
||||
if indent_len > 3:
|
||||
continue
|
||||
m = re.match(
|
||||
r"\s*[-*]\s+\[([^\]]+)\]\(([^)]+)\)\s*(?:[-\u2013\u2014]\s*(.+))?$",
|
||||
line,
|
||||
)
|
||||
if m:
|
||||
last_entry_indent = indent_len
|
||||
entries.append({
|
||||
"name": m.group(1),
|
||||
"url": m.group(2),
|
||||
"description": render_inline_markdown(m.group(3)) if m.group(3) else "",
|
||||
"category": cat["name"],
|
||||
"group": group_name,
|
||||
"stars": None,
|
||||
"owner": None,
|
||||
"pushed_at": None,
|
||||
"also_see": [],
|
||||
})
|
||||
return entries
|
||||
|
||||
|
||||
def build(repo_root: str) -> None:
|
||||
"""Main build: parse README, render single-page HTML via Jinja2 templates."""
|
||||
repo = Path(repo_root)
|
||||
website = repo / "website"
|
||||
readme_text = (repo / "README.md").read_text(encoding="utf-8")
|
||||
|
||||
# Extract subtitle from the first non-empty, non-heading line
|
||||
subtitle = ""
|
||||
for line in readme_text.split("\n"):
|
||||
stripped = line.strip()
|
||||
if stripped and not stripped.startswith("#"):
|
||||
subtitle = stripped
|
||||
break
|
||||
|
||||
categories, resources = parse_readme(readme_text)
|
||||
|
||||
# Enrich with entry counts, rendered HTML, previews, and clean descriptions
|
||||
for cat in categories + resources:
|
||||
cat["entry_count"] = count_entries(cat["content"])
|
||||
cat["content_html"] = render_content_html(cat["content"])
|
||||
cat["preview"] = extract_preview(cat["content"])
|
||||
cat["description"] = strip_markdown_links(cat["description"])
|
||||
|
||||
total_entries = sum(c["entry_count"] for c in categories)
|
||||
|
||||
# Organize into groups
|
||||
groups = group_categories(categories, resources)
|
||||
|
||||
# Flatten entries for table view
|
||||
entries = extract_entries(categories, resources, groups)
|
||||
|
||||
# Load and merge GitHub star data
|
||||
stars_data = load_stars(website / "data" / "github_stars.json")
|
||||
for entry in entries:
|
||||
repo_key = extract_github_repo(entry["url"])
|
||||
if repo_key and repo_key in stars_data:
|
||||
entry["stars"] = stars_data[repo_key]["stars"]
|
||||
entry["owner"] = stars_data[repo_key]["owner"]
|
||||
entry["pushed_at"] = stars_data[repo_key].get("pushed_at", "")
|
||||
|
||||
# Sort by stars descending
|
||||
entries = sort_entries(entries)
|
||||
|
||||
# Set up Jinja2
|
||||
env = Environment(
|
||||
loader=FileSystemLoader(website / "templates"),
|
||||
autoescape=True,
|
||||
)
|
||||
|
||||
# Output directory
|
||||
site_dir = website / "output"
|
||||
if site_dir.exists():
|
||||
shutil.rmtree(site_dir)
|
||||
site_dir.mkdir(parents=True)
|
||||
|
||||
# Generate single index.html
|
||||
tpl_index = env.get_template("index.html")
|
||||
(site_dir / "index.html").write_text(
|
||||
tpl_index.render(
|
||||
categories=categories,
|
||||
resources=resources,
|
||||
groups=groups,
|
||||
subtitle=subtitle,
|
||||
entries=entries,
|
||||
total_entries=total_entries,
|
||||
total_categories=len(categories),
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Copy static assets
|
||||
static_src = website / "static"
|
||||
static_dst = site_dir / "static"
|
||||
if static_src.exists():
|
||||
shutil.copytree(static_src, static_dst)
|
||||
|
||||
# Write CNAME
|
||||
(site_dir / "CNAME").write_text("awesome-python.com\n", encoding="utf-8")
|
||||
|
||||
print(f"Built single page with {len(categories)} categories + {len(resources)} resources")
|
||||
print(f"Total entries: {total_entries}")
|
||||
print(f"Output: {site_dir}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
build(str(Path(__file__).parent.parent))
|
||||
2627
website/data/github_stars.json
Normal file
2627
website/data/github_stars.json
Normal file
File diff suppressed because it is too large
Load diff
192
website/fetch_github_stars.py
Normal file
192
website/fetch_github_stars.py
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Fetch GitHub star counts and owner info for all GitHub repos in README.md."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from build import extract_github_repo
|
||||
|
||||
CACHE_MAX_AGE_DAYS = 7
|
||||
DATA_DIR = Path(__file__).parent / "data"
|
||||
CACHE_FILE = DATA_DIR / "github_stars.json"
|
||||
README_PATH = Path(__file__).parent.parent / "README.md"
|
||||
GRAPHQL_URL = "https://api.github.com/graphql"
|
||||
BATCH_SIZE = 100
|
||||
|
||||
|
||||
def extract_github_repos(text: str) -> set[str]:
|
||||
"""Extract unique owner/repo pairs from GitHub URLs in markdown text."""
|
||||
repos = set()
|
||||
for url in re.findall(r"https?://github\.com/[^\s)\]]+", text):
|
||||
repo = extract_github_repo(url.split("#")[0].rstrip("/"))
|
||||
if repo:
|
||||
repos.add(repo)
|
||||
return repos
|
||||
|
||||
|
||||
def load_cache() -> dict:
|
||||
"""Load the star cache from disk. Returns empty dict if missing or corrupt."""
|
||||
if CACHE_FILE.exists():
|
||||
try:
|
||||
return json.loads(CACHE_FILE.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
print(f"Warning: corrupt cache at {CACHE_FILE}, starting fresh.", file=sys.stderr)
|
||||
return {}
|
||||
return {}
|
||||
|
||||
|
||||
def save_cache(cache: dict) -> None:
|
||||
"""Write the star cache to disk, creating data/ dir if needed."""
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
CACHE_FILE.write_text(
|
||||
json.dumps(cache, indent=2, ensure_ascii=False) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def build_graphql_query(repos: list[str]) -> str:
|
||||
"""Build a GraphQL query with aliases for up to 100 repos."""
|
||||
if not repos:
|
||||
return ""
|
||||
parts = []
|
||||
for i, repo in enumerate(repos):
|
||||
owner, name = repo.split("/", 1)
|
||||
if '"' in owner or '"' in name:
|
||||
continue
|
||||
parts.append(
|
||||
f'repo_{i}: repository(owner: "{owner}", name: "{name}") '
|
||||
f"{{ stargazerCount pushedAt owner {{ login }} }}"
|
||||
)
|
||||
if not parts:
|
||||
return ""
|
||||
return "query { " + " ".join(parts) + " }"
|
||||
|
||||
|
||||
def parse_graphql_response(
|
||||
data: dict,
|
||||
repos: list[str],
|
||||
) -> dict[str, dict]:
|
||||
"""Parse GraphQL response into {owner/repo: {stars, owner}} dict."""
|
||||
result = {}
|
||||
for i, repo in enumerate(repos):
|
||||
node = data.get(f"repo_{i}")
|
||||
if node is None:
|
||||
continue
|
||||
result[repo] = {
|
||||
"stars": node.get("stargazerCount", 0),
|
||||
"owner": node.get("owner", {}).get("login", ""),
|
||||
"pushed_at": node.get("pushedAt", ""),
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def fetch_batch(
|
||||
repos: list[str], *, client: httpx.Client,
|
||||
) -> dict[str, dict]:
|
||||
"""Fetch star data for a batch of repos via GitHub GraphQL API."""
|
||||
query = build_graphql_query(repos)
|
||||
if not query:
|
||||
return {}
|
||||
resp = client.post(GRAPHQL_URL, json={"query": query})
|
||||
resp.raise_for_status()
|
||||
result = resp.json()
|
||||
if "errors" in result:
|
||||
for err in result["errors"]:
|
||||
print(f" Warning: {err.get('message', err)}", file=sys.stderr)
|
||||
data = result.get("data", {})
|
||||
return parse_graphql_response(data, repos)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Fetch GitHub stars for all repos in README.md, updating the JSON cache."""
|
||||
token = os.environ.get("GITHUB_TOKEN", "")
|
||||
if not token:
|
||||
print("Error: GITHUB_TOKEN environment variable is required.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
readme_text = README_PATH.read_text(encoding="utf-8")
|
||||
current_repos = extract_github_repos(readme_text)
|
||||
print(f"Found {len(current_repos)} GitHub repos in README.md")
|
||||
|
||||
cache = load_cache()
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Prune entries not in current README
|
||||
pruned = {k: v for k, v in cache.items() if k in current_repos}
|
||||
if len(pruned) < len(cache):
|
||||
print(f"Pruned {len(cache) - len(pruned)} stale cache entries")
|
||||
cache = pruned
|
||||
|
||||
# Determine which repos need fetching (missing or stale)
|
||||
to_fetch = []
|
||||
for repo in sorted(current_repos):
|
||||
entry = cache.get(repo)
|
||||
if entry and "fetched_at" in entry:
|
||||
fetched = datetime.fromisoformat(entry["fetched_at"])
|
||||
age_days = (now - fetched).days
|
||||
if age_days < CACHE_MAX_AGE_DAYS:
|
||||
continue
|
||||
to_fetch.append(repo)
|
||||
|
||||
print(f"{len(to_fetch)} repos to fetch ({len(current_repos) - len(to_fetch)} cached)")
|
||||
|
||||
if not to_fetch:
|
||||
save_cache(cache)
|
||||
print("Cache is up to date.")
|
||||
return
|
||||
|
||||
# Fetch in batches
|
||||
fetched_count = 0
|
||||
skipped_repos: list[str] = []
|
||||
|
||||
with httpx.Client(
|
||||
headers={"Authorization": f"bearer {token}", "Content-Type": "application/json"},
|
||||
transport=httpx.HTTPTransport(retries=2),
|
||||
timeout=30,
|
||||
) as client:
|
||||
for i in range(0, len(to_fetch), BATCH_SIZE):
|
||||
batch = to_fetch[i : i + BATCH_SIZE]
|
||||
batch_num = i // BATCH_SIZE + 1
|
||||
total_batches = (len(to_fetch) + BATCH_SIZE - 1) // BATCH_SIZE
|
||||
print(f"Fetching batch {batch_num}/{total_batches} ({len(batch)} repos)...")
|
||||
|
||||
try:
|
||||
results = fetch_batch(batch, client=client)
|
||||
except httpx.HTTPStatusError as e:
|
||||
print(f"HTTP error {e.response.status_code}", file=sys.stderr)
|
||||
if e.response.status_code == 401:
|
||||
print("Error: Invalid GITHUB_TOKEN.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print("Saving partial cache and exiting.", file=sys.stderr)
|
||||
save_cache(cache)
|
||||
sys.exit(1)
|
||||
|
||||
now_iso = now.isoformat()
|
||||
for repo in batch:
|
||||
if repo in results:
|
||||
cache[repo] = {
|
||||
"stars": results[repo]["stars"],
|
||||
"owner": results[repo]["owner"],
|
||||
"pushed_at": results[repo]["pushed_at"],
|
||||
"fetched_at": now_iso,
|
||||
}
|
||||
fetched_count += 1
|
||||
else:
|
||||
skipped_repos.append(repo)
|
||||
|
||||
# Save after each batch in case of interruption
|
||||
save_cache(cache)
|
||||
|
||||
if skipped_repos:
|
||||
print(f"Skipped {len(skipped_repos)} repos (deleted/private/renamed)")
|
||||
print(f"Done. Fetched {fetched_count} repos, {len(cache)} total cached.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
154
website/static/main.js
Normal file
154
website/static/main.js
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
// State
|
||||
var activeFilter = null; // { type: "cat"|"group", value: "..." }
|
||||
var searchInput = document.querySelector('.search');
|
||||
var filterBar = document.querySelector('.filter-bar');
|
||||
var filterValue = document.querySelector('.filter-value');
|
||||
var filterClear = document.querySelector('.filter-clear');
|
||||
var noResults = document.querySelector('.no-results');
|
||||
var countEl = document.querySelector('.count');
|
||||
var rows = document.querySelectorAll('.table tbody tr.row');
|
||||
var tags = document.querySelectorAll('.tag');
|
||||
var tbody = document.querySelector('.table tbody');
|
||||
|
||||
function collapseAll() {
|
||||
var openRows = document.querySelectorAll('.table tbody tr.row.open');
|
||||
openRows.forEach(function (row) {
|
||||
row.classList.remove('open');
|
||||
row.setAttribute('aria-expanded', 'false');
|
||||
});
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
var query = searchInput ? searchInput.value.toLowerCase().trim() : '';
|
||||
var visibleCount = 0;
|
||||
|
||||
// Collapse all expanded rows on filter/search change
|
||||
collapseAll();
|
||||
|
||||
rows.forEach(function (row) {
|
||||
var show = true;
|
||||
|
||||
// Category/group filter
|
||||
if (activeFilter) {
|
||||
show = row.dataset[activeFilter.type] === activeFilter.value;
|
||||
}
|
||||
|
||||
// Text search
|
||||
if (show && query) {
|
||||
if (!row._searchText) {
|
||||
var text = row.textContent.toLowerCase();
|
||||
var next = row.nextElementSibling;
|
||||
if (next && next.classList.contains('expand-row')) {
|
||||
text += ' ' + next.textContent.toLowerCase();
|
||||
}
|
||||
row._searchText = text;
|
||||
}
|
||||
show = row._searchText.includes(query);
|
||||
}
|
||||
|
||||
row.hidden = !show;
|
||||
|
||||
if (show) {
|
||||
visibleCount++;
|
||||
row.querySelector('.col-num').textContent = String(visibleCount);
|
||||
}
|
||||
});
|
||||
|
||||
if (noResults) noResults.hidden = visibleCount > 0;
|
||||
if (countEl) countEl.textContent = visibleCount;
|
||||
|
||||
// Update tag highlights
|
||||
tags.forEach(function (tag) {
|
||||
var isActive = activeFilter
|
||||
&& tag.dataset.type === activeFilter.type
|
||||
&& tag.dataset.value === activeFilter.value;
|
||||
tag.classList.toggle('active', isActive);
|
||||
});
|
||||
|
||||
// Filter bar
|
||||
if (filterBar) {
|
||||
if (activeFilter) {
|
||||
filterBar.hidden = false;
|
||||
if (filterValue) filterValue.textContent = activeFilter.value;
|
||||
} else {
|
||||
filterBar.hidden = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Expand/collapse: event delegation on tbody
|
||||
if (tbody) {
|
||||
tbody.addEventListener('click', function (e) {
|
||||
// Don't toggle if clicking a link or tag button
|
||||
if (e.target.closest('a') || e.target.closest('.tag')) return;
|
||||
|
||||
var row = e.target.closest('tr.row');
|
||||
if (!row) return;
|
||||
|
||||
var isOpen = row.classList.contains('open');
|
||||
if (isOpen) {
|
||||
row.classList.remove('open');
|
||||
row.setAttribute('aria-expanded', 'false');
|
||||
} else {
|
||||
row.classList.add('open');
|
||||
row.setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
});
|
||||
|
||||
// Keyboard: Enter or Space on focused .row toggles expand
|
||||
tbody.addEventListener('keydown', function (e) {
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||||
var row = e.target.closest('tr.row');
|
||||
if (!row) return;
|
||||
e.preventDefault();
|
||||
row.click();
|
||||
});
|
||||
}
|
||||
|
||||
// Tag click: filter by category or group
|
||||
tags.forEach(function (tag) {
|
||||
tag.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
var type = tag.dataset.type;
|
||||
var value = tag.dataset.value;
|
||||
|
||||
// Toggle: click same filter again to clear
|
||||
if (activeFilter && activeFilter.type === type && activeFilter.value === value) {
|
||||
activeFilter = null;
|
||||
} else {
|
||||
activeFilter = { type: type, value: value };
|
||||
}
|
||||
applyFilters();
|
||||
});
|
||||
});
|
||||
|
||||
// Clear filter
|
||||
if (filterClear) {
|
||||
filterClear.addEventListener('click', function () {
|
||||
activeFilter = null;
|
||||
applyFilters();
|
||||
});
|
||||
}
|
||||
|
||||
// Search input
|
||||
if (searchInput) {
|
||||
var searchTimer;
|
||||
searchInput.addEventListener('input', function () {
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(applyFilters, 150);
|
||||
});
|
||||
|
||||
// Keyboard shortcuts
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === '/' && !['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement.tagName) && !e.ctrlKey && !e.metaKey) {
|
||||
e.preventDefault();
|
||||
searchInput.focus();
|
||||
}
|
||||
if (e.key === 'Escape' && document.activeElement === searchInput) {
|
||||
searchInput.value = '';
|
||||
activeFilter = null;
|
||||
applyFilters();
|
||||
searchInput.blur();
|
||||
}
|
||||
});
|
||||
}
|
||||
459
website/static/style.css
Normal file
459
website/static/style.css
Normal file
|
|
@ -0,0 +1,459 @@
|
|||
/* === Reset & Base === */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--font-display: Georgia, "Noto Serif", "Times New Roman", serif;
|
||||
--font-body: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
||||
|
||||
--text-xs: 0.9375rem;
|
||||
--text-sm: 1rem;
|
||||
--text-base: 1.125rem;
|
||||
|
||||
--bg: oklch(99.5% 0.003 240);
|
||||
--bg-hover: oklch(97% 0.008 240);
|
||||
--text: oklch(15% 0.005 240);
|
||||
--text-secondary: oklch(35% 0.005 240);
|
||||
--text-muted: oklch(50% 0.005 240);
|
||||
--border: oklch(90% 0.005 240);
|
||||
--border-strong: oklch(75% 0.008 240);
|
||||
--border-heavy: oklch(25% 0.01 240);
|
||||
--bg-input: oklch(94.5% 0.035 240);
|
||||
--accent: oklch(42% 0.14 240);
|
||||
--accent-hover: oklch(32% 0.16 240);
|
||||
--accent-light: oklch(97% 0.015 240);
|
||||
--highlight: oklch(93% 0.10 90);
|
||||
--highlight-text: oklch(35% 0.10 90);
|
||||
}
|
||||
|
||||
html { font-size: 16px; }
|
||||
|
||||
body {
|
||||
font-family: var(--font-body);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.55;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { color: var(--accent-hover); text-decoration: underline; }
|
||||
|
||||
/* === Skip Link === */
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
top: 0;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--text);
|
||||
color: var(--bg);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
.skip-link:focus { left: 0; }
|
||||
|
||||
/* === Hero === */
|
||||
.hero {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 3.5rem 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.hero-main {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.hero-submit {
|
||||
flex-shrink: 0;
|
||||
padding: 0.4rem 1rem;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 4px;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hero-submit:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(2rem, 5vw, 3rem);
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.1;
|
||||
color: var(--accent);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.hero-sub {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.hero-sub a { color: var(--text-secondary); font-weight: 600; }
|
||||
.hero-sub a:hover { color: var(--accent); }
|
||||
|
||||
.hero-gh {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.hero-gh:hover { color: var(--accent); }
|
||||
|
||||
/* === Controls === */
|
||||
.controls {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 0 2rem 1rem;
|
||||
}
|
||||
|
||||
.search-wrap {
|
||||
position: relative;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 1rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--text-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.search {
|
||||
width: 100%;
|
||||
padding: 0.65rem 1rem 0.65rem 2.75rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-input);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.search::placeholder { color: var(--text-muted); }
|
||||
|
||||
.search:focus {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-color: var(--accent);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.filter-bar[hidden] { display: none; }
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 0;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.filter-bar strong {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.filter-clear {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 0.15rem 0.5rem;
|
||||
font-family: inherit;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.filter-clear:hover {
|
||||
border-color: var(--text-muted);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.stats {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.stats strong { color: var(--text-secondary); }
|
||||
|
||||
/* === Table === */
|
||||
.table-wrap {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.table thead th {
|
||||
text-align: left;
|
||||
font-weight: 700;
|
||||
font-size: var(--text-base);
|
||||
color: var(--text);
|
||||
padding: 0.65rem 0.75rem;
|
||||
border-bottom: 2px solid var(--border-heavy);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--bg);
|
||||
z-index: 10;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.table thead th:first-child,
|
||||
.table tbody td:first-child {
|
||||
padding-left: max(2rem, calc(50vw - 700px + 2rem));
|
||||
}
|
||||
|
||||
.table thead th:last-child,
|
||||
.table tbody td:last-child {
|
||||
padding-right: max(2rem, calc(50vw - 700px + 2rem));
|
||||
}
|
||||
|
||||
.table tbody td {
|
||||
padding: 0.7rem 0.75rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.table tbody tr.row:not(.open):hover td {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.table tbody tr[hidden] { display: none; }
|
||||
|
||||
.col-num {
|
||||
width: 3rem;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.col-name {
|
||||
width: 35%;
|
||||
overflow-wrap: break-word;
|
||||
word-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.col-name > a {
|
||||
font-weight: 500;
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.col-name > a:hover { text-decoration: underline; color: var(--accent-hover); }
|
||||
|
||||
/* === Stars Column === */
|
||||
.col-stars {
|
||||
width: 5rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* === Arrow Column === */
|
||||
.col-arrow {
|
||||
width: 2.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
display: inline-block;
|
||||
font-size: 0.8rem;
|
||||
color: var(--accent);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.row.open .arrow {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
/* === Row Click === */
|
||||
.row { cursor: pointer; }
|
||||
|
||||
/* === Expand Row === */
|
||||
.expand-row {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.row.open + .expand-row {
|
||||
display: table-row;
|
||||
}
|
||||
|
||||
.row.open td {
|
||||
background: var(--accent-light);
|
||||
border-bottom-color: transparent;
|
||||
padding-bottom: 0.1rem;
|
||||
}
|
||||
|
||||
.expand-row td {
|
||||
padding: 0.15rem 0.75rem 0.75rem;
|
||||
background: var(--accent-light);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.expand-content {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.expand-also-see {
|
||||
margin-top: 0.25rem;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.expand-also-see a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.expand-also-see a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.expand-meta {
|
||||
margin-top: 0.25rem;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.expand-meta a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.expand-meta a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.expand-sep {
|
||||
margin: 0 0.25rem;
|
||||
color: var(--border);
|
||||
}
|
||||
|
||||
.col-cat, .col-group {
|
||||
width: 13%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* === Tags === */
|
||||
.tag {
|
||||
background: var(--accent-light);
|
||||
border: none;
|
||||
font-family: inherit;
|
||||
font-size: var(--text-xs);
|
||||
color: oklch(45% 0.06 240);
|
||||
cursor: pointer;
|
||||
padding: 0.15rem 0.35rem;
|
||||
border-radius: 3px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tag:hover {
|
||||
background: var(--accent-light);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.tag.active {
|
||||
background: var(--highlight);
|
||||
color: var(--highlight-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* === No Results === */
|
||||
.no-results {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 3rem 2rem;
|
||||
font-size: var(--text-base);
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* === Footer === */
|
||||
.footer {
|
||||
margin-top: auto;
|
||||
border-top: none;
|
||||
width: 100%;
|
||||
padding: 1.25rem 2rem;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-input);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.footer a { color: var(--text-muted); text-decoration: none; }
|
||||
.footer a:hover { color: var(--accent); }
|
||||
|
||||
.footer-links {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* === Responsive === */
|
||||
@media (max-width: 900px) {
|
||||
.col-group { display: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.hero { padding: 2rem 1.25rem 1rem; }
|
||||
.controls { padding: 0 1.25rem 0.75rem; }
|
||||
|
||||
.table thead th:first-child,
|
||||
.table tbody td:first-child { padding-left: 1.25rem; }
|
||||
|
||||
.table thead th:last-child,
|
||||
.table tbody td:last-child { padding-right: 1.25rem; }
|
||||
|
||||
.col-cat { display: none; }
|
||||
.col-name { white-space: normal; }
|
||||
.footer { padding: 1.25rem; flex-direction: column; gap: 0.5rem; }
|
||||
}
|
||||
|
||||
/* === Screen Reader Only === */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* === Reduced Motion === */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
67
website/templates/base.html
Normal file
67
website/templates/base.html
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{% block title %}Awesome Python{% endblock %}</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="{% block description %}An opinionated list of awesome Python frameworks, libraries, software and resources. {{ total_entries }} libraries across {{ categories | length }} categories.{% endblock %}"
|
||||
/>
|
||||
<link rel="canonical" href="https://awesome-python.com/" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="Awesome Python" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="An opinionated list of awesome Python frameworks, libraries, software and resources."
|
||||
/>
|
||||
<meta property="og:url" content="https://awesome-python.com/" />
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<link
|
||||
rel="icon"
|
||||
href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🐍</text></svg>"
|
||||
/>
|
||||
<link rel="stylesheet" href="/static/style.css" />
|
||||
<script
|
||||
async
|
||||
src="https://www.googletagmanager.com/gtag/js?id=G-0LMLYE0HER"
|
||||
></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag() {
|
||||
dataLayer.push(arguments);
|
||||
}
|
||||
gtag("js", new Date());
|
||||
gtag("config", "G-0LMLYE0HER");
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<a href="#content" class="skip-link">Skip to content</a>
|
||||
|
||||
<main id="content">{% block content %}{% endblock %}</main>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="footer-links">
|
||||
<a href="https://github.com/vinta" target="_blank" rel="noopener"
|
||||
>GitHub</a
|
||||
>
|
||||
<a href="https://twitter.com/vinta" target="_blank" rel="noopener"
|
||||
>Twitter</a
|
||||
>
|
||||
</div>
|
||||
<span
|
||||
>Curated by
|
||||
<a href="https://github.com/vinta" target="_blank" rel="noopener"
|
||||
>Vinta</a
|
||||
></span
|
||||
>
|
||||
</footer>
|
||||
|
||||
<noscript
|
||||
><p style="text-align: center; padding: 1rem; color: #666">
|
||||
JavaScript is needed for search and filtering.
|
||||
</p></noscript
|
||||
>
|
||||
<script src="/static/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
146
website/templates/index.html
Normal file
146
website/templates/index.html
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<header class="hero">
|
||||
<div class="hero-main">
|
||||
<div>
|
||||
<h1>Awesome Python</h1>
|
||||
<p class="hero-sub">
|
||||
{{ subtitle }}<br />Curated by
|
||||
<a href="https://github.com/vinta" target="_blank" rel="noopener"
|
||||
>@vinta</a
|
||||
>
|
||||
since 2014.
|
||||
</p>
|
||||
<a
|
||||
href="https://github.com/vinta/awesome-python"
|
||||
class="hero-gh"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>awesome-python on GitHub →</a
|
||||
>
|
||||
</div>
|
||||
<a
|
||||
href="https://github.com/vinta/awesome-python/blob/master/CONTRIBUTING.md"
|
||||
class="hero-submit"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>Submit a Project</a
|
||||
>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="controls">
|
||||
<div class="search-wrap">
|
||||
<svg
|
||||
class="search-icon"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
class="search"
|
||||
placeholder="Search {{ entries | length }} libraries across {{ total_categories }} categories..."
|
||||
aria-label="Search libraries"
|
||||
/>
|
||||
</div>
|
||||
<div class="filter-bar" hidden>
|
||||
<span>Showing <strong class="filter-value"></strong></span>
|
||||
<button class="filter-clear" aria-label="Clear filter">
|
||||
× Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-num"><span class="sr-only">#</span></th>
|
||||
<th class="col-name">Project Name</th>
|
||||
<th class="col-stars">GitHub Stars</th>
|
||||
<th class="col-cat">Category</th>
|
||||
<th class="col-group">Group</th>
|
||||
<th class="col-arrow"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in entries %}
|
||||
<tr
|
||||
class="row"
|
||||
data-cat="{{ entry.category }}"
|
||||
data-group="{{ entry.group }}"
|
||||
tabindex="0"
|
||||
aria-expanded="false"
|
||||
aria-controls="expand-{{ loop.index }}"
|
||||
>
|
||||
<td class="col-num">{{ loop.index }}</td>
|
||||
<td class="col-name">
|
||||
<a href="{{ entry.url }}" target="_blank" rel="noopener"
|
||||
>{{ entry.name }}</a
|
||||
>
|
||||
</td>
|
||||
<td class="col-stars">
|
||||
{% if entry.stars is not none %}{{ "{:,}".format(entry.stars) }}{%
|
||||
else %}—{% endif %}
|
||||
</td>
|
||||
<td class="col-cat">
|
||||
<button class="tag" data-type="cat" data-value="{{ entry.category }}">
|
||||
{{ entry.category }}
|
||||
</button>
|
||||
</td>
|
||||
<td class="col-group">
|
||||
<button class="tag" data-type="group" data-value="{{ entry.group }}">
|
||||
{{ entry.group }}
|
||||
</button>
|
||||
</td>
|
||||
<td class="col-arrow"><span class="arrow">→</span></td>
|
||||
</tr>
|
||||
<tr class="expand-row" id="expand-{{ loop.index }}">
|
||||
<td></td>
|
||||
<td colspan="5">
|
||||
<div class="expand-content">
|
||||
{% if entry.description %}
|
||||
<div class="expand-desc">{{ entry.description | safe }}</div>
|
||||
{% endif %} {% if entry.also_see %}
|
||||
<div class="expand-also-see">
|
||||
Also see: {% for see in entry.also_see %}<a
|
||||
href="{{ see.url }}"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>{{ see.name }}</a
|
||||
>{% if not loop.last %}, {% endif %}{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="expand-meta">
|
||||
{% if entry.owner %}<a
|
||||
href="https://github.com/{{ entry.owner }}"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>{{ entry.owner }}</a
|
||||
><span class="expand-sep">/</span>{% endif %}<a
|
||||
href="{{ entry.url }}"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>{{ entry.url | replace("https://", "") }}</a
|
||||
>{% if entry.pushed_at %}<span class="expand-sep">·</span
|
||||
>Last pushed {{ entry.pushed_at[:10] }}{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="no-results" hidden>No libraries match your search.</div>
|
||||
{% endblock %}
|
||||
642
website/tests/test_build.py
Normal file
642
website/tests/test_build.py
Normal file
|
|
@ -0,0 +1,642 @@
|
|||
"""Tests for the build module."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from build import (
|
||||
build,
|
||||
count_entries,
|
||||
extract_github_repo,
|
||||
extract_preview,
|
||||
group_categories,
|
||||
load_stars,
|
||||
parse_readme,
|
||||
render_content_html,
|
||||
slugify,
|
||||
sort_entries,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# slugify
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSlugify:
|
||||
def test_simple(self):
|
||||
assert slugify("Admin Panels") == "admin-panels"
|
||||
|
||||
def test_uppercase_acronym(self):
|
||||
assert slugify("RESTful API") == "restful-api"
|
||||
|
||||
def test_all_caps(self):
|
||||
assert slugify("CMS") == "cms"
|
||||
|
||||
def test_hyphenated_input(self):
|
||||
assert slugify("Command-line Tools") == "command-line-tools"
|
||||
|
||||
def test_special_chars(self):
|
||||
assert slugify("Editor Plugins and IDEs") == "editor-plugins-and-ides"
|
||||
|
||||
def test_single_word(self):
|
||||
assert slugify("Audio") == "audio"
|
||||
|
||||
def test_extra_spaces(self):
|
||||
assert slugify(" Date and Time ") == "date-and-time"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# count_entries
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCountEntries:
|
||||
def test_counts_dash_entries(self):
|
||||
assert count_entries("- [a](url) - Desc.\n- [b](url) - Desc.") == 2
|
||||
|
||||
def test_counts_star_entries(self):
|
||||
assert count_entries("* [a](url) - Desc.") == 1
|
||||
|
||||
def test_ignores_non_entries(self):
|
||||
assert count_entries("Some text\n- [a](url) - Desc.\nMore text") == 1
|
||||
|
||||
def test_counts_indented_entries(self):
|
||||
assert count_entries(" - [a](url) - Desc.") == 1
|
||||
|
||||
def test_empty_content(self):
|
||||
assert count_entries("") == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# extract_preview
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestExtractPreview:
|
||||
def test_basic(self):
|
||||
content = "* [alpha](url) - A.\n* [beta](url) - B.\n* [gamma](url) - C."
|
||||
assert extract_preview(content) == "alpha, beta, gamma"
|
||||
|
||||
def test_max_four(self):
|
||||
content = "\n".join(f"* [lib{i}](url) - Desc." for i in range(10))
|
||||
assert extract_preview(content) == "lib0, lib1, lib2, lib3"
|
||||
|
||||
def test_empty(self):
|
||||
assert extract_preview("") == ""
|
||||
|
||||
def test_skips_subcategory_labels(self):
|
||||
content = "* Synchronous\n* [django](url) - Framework.\n* [flask](url) - Micro."
|
||||
assert extract_preview(content) == "django, flask"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# render_content_html
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRenderContentHtml:
|
||||
def test_basic_entry(self):
|
||||
content = "* [django](https://example.com) - A web framework."
|
||||
html = render_content_html(content)
|
||||
assert 'href="https://example.com"' in html
|
||||
assert "django" in html
|
||||
assert "A web framework." in html
|
||||
assert 'class="entry"' in html
|
||||
|
||||
def test_subcategory_label(self):
|
||||
content = "* Synchronous\n* [django](https://x.com) - Framework."
|
||||
html = render_content_html(content)
|
||||
assert 'class="subcat"' in html
|
||||
assert "Synchronous" in html
|
||||
|
||||
def test_sub_entry(self):
|
||||
content = "* [django](https://x.com) - Framework.\n * [awesome-django](https://y.com)"
|
||||
html = render_content_html(content)
|
||||
assert 'class="entry-sub"' in html
|
||||
assert "awesome-django" in html
|
||||
|
||||
def test_link_only_entry(self):
|
||||
content = "* [tool](https://x.com)"
|
||||
html = render_content_html(content)
|
||||
assert 'href="https://x.com"' in html
|
||||
assert "tool" in html
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parse_readme
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
MINIMAL_README = textwrap.dedent("""\
|
||||
# Awesome Python
|
||||
|
||||
Some intro text.
|
||||
|
||||
---
|
||||
|
||||
## Alpha
|
||||
|
||||
_Libraries for alpha stuff._
|
||||
|
||||
- [lib-a](https://example.com/a) - Does A.
|
||||
- [lib-b](https://example.com/b) - Does B.
|
||||
|
||||
## Beta
|
||||
|
||||
_Tools for beta._
|
||||
|
||||
- [lib-c](https://example.com/c) - Does C.
|
||||
|
||||
# Resources
|
||||
|
||||
Where to discover resources.
|
||||
|
||||
## Newsletters
|
||||
|
||||
- [News One](https://example.com/n1)
|
||||
- [News Two](https://example.com/n2)
|
||||
|
||||
## Podcasts
|
||||
|
||||
- [Pod One](https://example.com/p1)
|
||||
|
||||
# Contributing
|
||||
|
||||
Please contribute!
|
||||
""")
|
||||
|
||||
|
||||
class TestParseReadme:
|
||||
def test_category_count(self):
|
||||
cats, resources = parse_readme(MINIMAL_README)
|
||||
assert len(cats) == 2
|
||||
|
||||
def test_resource_count(self):
|
||||
cats, resources = parse_readme(MINIMAL_README)
|
||||
assert len(resources) == 2
|
||||
|
||||
def test_category_names(self):
|
||||
cats, _ = parse_readme(MINIMAL_README)
|
||||
assert cats[0]["name"] == "Alpha"
|
||||
assert cats[1]["name"] == "Beta"
|
||||
|
||||
def test_category_slugs(self):
|
||||
cats, _ = parse_readme(MINIMAL_README)
|
||||
assert cats[0]["slug"] == "alpha"
|
||||
assert cats[1]["slug"] == "beta"
|
||||
|
||||
def test_category_description(self):
|
||||
cats, _ = parse_readme(MINIMAL_README)
|
||||
assert cats[0]["description"] == "Libraries for alpha stuff."
|
||||
assert cats[1]["description"] == "Tools for beta."
|
||||
|
||||
def test_category_content_has_entries(self):
|
||||
cats, _ = parse_readme(MINIMAL_README)
|
||||
assert "lib-a" in cats[0]["content"]
|
||||
assert "lib-b" in cats[0]["content"]
|
||||
|
||||
def test_resources_names(self):
|
||||
_, resources = parse_readme(MINIMAL_README)
|
||||
assert resources[0]["name"] == "Newsletters"
|
||||
assert resources[1]["name"] == "Podcasts"
|
||||
|
||||
def test_resources_content(self):
|
||||
_, resources = parse_readme(MINIMAL_README)
|
||||
assert "News One" in resources[0]["content"]
|
||||
assert "Pod One" in resources[1]["content"]
|
||||
|
||||
def test_contributing_skipped(self):
|
||||
cats, resources = parse_readme(MINIMAL_README)
|
||||
all_names = [c["name"] for c in cats] + [r["name"] for r in resources]
|
||||
assert "Contributing" not in all_names
|
||||
|
||||
def test_no_separator(self):
|
||||
cats, resources = parse_readme("# Just a heading\n\nSome text.\n")
|
||||
assert cats == []
|
||||
assert resources == []
|
||||
|
||||
def test_no_description(self):
|
||||
readme = textwrap.dedent("""\
|
||||
# Title
|
||||
|
||||
---
|
||||
|
||||
## NullDesc
|
||||
|
||||
- [item](https://x.com) - Thing.
|
||||
|
||||
# Resources
|
||||
|
||||
## Tips
|
||||
|
||||
- [tip](https://x.com)
|
||||
|
||||
# Contributing
|
||||
|
||||
Done.
|
||||
""")
|
||||
cats, resources = parse_readme(readme)
|
||||
assert cats[0]["description"] == ""
|
||||
assert "item" in cats[0]["content"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parse_readme on real README
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestParseRealReadme:
|
||||
@pytest.fixture(autouse=True)
|
||||
def load_readme(self):
|
||||
readme_path = os.path.join(os.path.dirname(__file__), "..", "..", "README.md")
|
||||
with open(readme_path, encoding="utf-8") as f:
|
||||
self.readme_text = f.read()
|
||||
self.cats, self.resources = parse_readme(self.readme_text)
|
||||
|
||||
def test_at_least_83_categories(self):
|
||||
assert len(self.cats) >= 83
|
||||
|
||||
def test_resources_has_newsletters_and_podcasts(self):
|
||||
names = [r["name"] for r in self.resources]
|
||||
assert "Newsletters" in names
|
||||
assert "Podcasts" in names
|
||||
|
||||
def test_contributing_not_in_results(self):
|
||||
all_names = [c["name"] for c in self.cats] + [
|
||||
r["name"] for r in self.resources
|
||||
]
|
||||
assert "Contributing" not in all_names
|
||||
|
||||
def test_first_category_is_admin_panels(self):
|
||||
assert self.cats[0]["name"] == "Admin Panels"
|
||||
assert self.cats[0]["slug"] == "admin-panels"
|
||||
|
||||
def test_last_category_is_wsgi_servers(self):
|
||||
assert self.cats[-1]["name"] == "WSGI Servers"
|
||||
assert self.cats[-1]["slug"] == "wsgi-servers"
|
||||
|
||||
def test_restful_api_slug(self):
|
||||
slugs = [c["slug"] for c in self.cats]
|
||||
assert "restful-api" in slugs
|
||||
|
||||
def test_descriptions_extracted(self):
|
||||
admin = self.cats[0]
|
||||
assert admin["description"] == "Libraries for administrative interfaces."
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# group_categories
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGroupCategories:
|
||||
def test_groups_known_categories(self):
|
||||
cats = [
|
||||
{"name": "Web Frameworks", "slug": "web-frameworks"},
|
||||
{"name": "Testing", "slug": "testing"},
|
||||
]
|
||||
groups = group_categories(cats, [])
|
||||
group_names = [g["name"] for g in groups]
|
||||
assert "Web & API" in group_names
|
||||
assert "Development Tools" in group_names
|
||||
|
||||
def test_ungrouped_go_to_other(self):
|
||||
cats = [{"name": "Unknown Category", "slug": "unknown-category"}]
|
||||
groups = group_categories(cats, [])
|
||||
group_names = [g["name"] for g in groups]
|
||||
assert "Other" in group_names
|
||||
|
||||
def test_resources_grouped(self):
|
||||
resources = [{"name": "Newsletters", "slug": "newsletters"}]
|
||||
groups = group_categories([], resources)
|
||||
group_names = [g["name"] for g in groups]
|
||||
assert "Resources" in group_names
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# render_markdown (kept for compatibility)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRenderMarkdown:
|
||||
def test_renders_link_list(self):
|
||||
from build import render_markdown
|
||||
|
||||
html = render_markdown("- [lib](https://example.com) - Does stuff.")
|
||||
assert "<li>" in html
|
||||
assert '<a href="https://example.com">lib</a>' in html
|
||||
|
||||
def test_renders_plain_text(self):
|
||||
from build import render_markdown
|
||||
|
||||
html = render_markdown("Hello world")
|
||||
assert "<p>Hello world</p>" in html
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# build (integration)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBuild:
|
||||
def _make_repo(self, tmp_path, readme):
|
||||
(tmp_path / "README.md").write_text(readme, encoding="utf-8")
|
||||
tpl_dir = tmp_path / "website" / "templates"
|
||||
tpl_dir.mkdir(parents=True)
|
||||
(tpl_dir / "base.html").write_text(
|
||||
"<!DOCTYPE html><html lang='en'><head><title>{% block title %}{% endblock %}</title>"
|
||||
"<meta name='description' content='{% block description %}{% endblock %}'>"
|
||||
"</head><body>{% block content %}{% endblock %}</body></html>",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(tpl_dir / "index.html").write_text(
|
||||
'{% extends "base.html" %}{% block content %}'
|
||||
"{% for group in groups %}"
|
||||
'<section class="group">'
|
||||
"<h2>{{ group.name }}</h2>"
|
||||
"{% for cat in group.categories %}"
|
||||
'<div class="row" id="{{ cat.slug }}">'
|
||||
"<span>{{ cat.name }}</span>"
|
||||
"<span>{{ cat.preview }}</span>"
|
||||
"<span>{{ cat.entry_count }}</span>"
|
||||
'<div class="row-content" hidden>{{ cat.content_html | safe }}</div>'
|
||||
"</div>"
|
||||
"{% endfor %}"
|
||||
"</section>"
|
||||
"{% endfor %}"
|
||||
"{% endblock %}",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def test_build_creates_single_page(self, tmp_path):
|
||||
readme = textwrap.dedent("""\
|
||||
# Awesome Python
|
||||
|
||||
Intro.
|
||||
|
||||
---
|
||||
|
||||
## Widgets
|
||||
|
||||
_Widget libraries._
|
||||
|
||||
- [w1](https://example.com) - A widget.
|
||||
|
||||
## Gadgets
|
||||
|
||||
_Gadget tools._
|
||||
|
||||
- [g1](https://example.com) - A gadget.
|
||||
|
||||
# Resources
|
||||
|
||||
Info.
|
||||
|
||||
## Newsletters
|
||||
|
||||
- [NL](https://example.com)
|
||||
|
||||
# Contributing
|
||||
|
||||
Help!
|
||||
""")
|
||||
self._make_repo(tmp_path, readme)
|
||||
build(str(tmp_path))
|
||||
|
||||
site = tmp_path / "website" / "output"
|
||||
assert (site / "index.html").exists()
|
||||
# No category sub-pages
|
||||
assert not (site / "categories").exists()
|
||||
|
||||
def test_build_creates_cname(self, tmp_path):
|
||||
readme = textwrap.dedent("""\
|
||||
# T
|
||||
|
||||
---
|
||||
|
||||
## Only
|
||||
|
||||
- [x](https://x.com) - X.
|
||||
|
||||
# Contributing
|
||||
|
||||
Done.
|
||||
""")
|
||||
self._make_repo(tmp_path, readme)
|
||||
build(str(tmp_path))
|
||||
|
||||
cname = tmp_path / "website" / "output" / "CNAME"
|
||||
assert cname.exists()
|
||||
assert "awesome-python.com" in cname.read_text()
|
||||
|
||||
def test_build_cleans_stale_output(self, tmp_path):
|
||||
readme = textwrap.dedent("""\
|
||||
# T
|
||||
|
||||
---
|
||||
|
||||
## Only
|
||||
|
||||
- [x](https://x.com) - X.
|
||||
|
||||
# Contributing
|
||||
|
||||
Done.
|
||||
""")
|
||||
self._make_repo(tmp_path, readme)
|
||||
|
||||
stale = tmp_path / "website" / "output" / "categories" / "stale"
|
||||
stale.mkdir(parents=True)
|
||||
(stale / "index.html").write_text("old", encoding="utf-8")
|
||||
|
||||
build(str(tmp_path))
|
||||
|
||||
assert not (tmp_path / "website" / "output" / "categories" / "stale").exists()
|
||||
|
||||
def test_index_contains_category_names(self, tmp_path):
|
||||
readme = textwrap.dedent("""\
|
||||
# T
|
||||
|
||||
---
|
||||
|
||||
## Alpha
|
||||
|
||||
- [a](https://x.com) - A.
|
||||
|
||||
## Beta
|
||||
|
||||
- [b](https://x.com) - B.
|
||||
|
||||
# Contributing
|
||||
|
||||
Done.
|
||||
""")
|
||||
self._make_repo(tmp_path, readme)
|
||||
build(str(tmp_path))
|
||||
|
||||
index_html = (tmp_path / "website" / "output" / "index.html").read_text()
|
||||
assert "Alpha" in index_html
|
||||
assert "Beta" in index_html
|
||||
|
||||
def test_index_contains_preview_text(self, tmp_path):
|
||||
readme = textwrap.dedent("""\
|
||||
# T
|
||||
|
||||
---
|
||||
|
||||
## Stuff
|
||||
|
||||
- [django](https://x.com) - A framework.
|
||||
- [flask](https://x.com) - A micro.
|
||||
|
||||
# Contributing
|
||||
|
||||
Done.
|
||||
""")
|
||||
self._make_repo(tmp_path, readme)
|
||||
build(str(tmp_path))
|
||||
|
||||
index_html = (tmp_path / "website" / "output" / "index.html").read_text()
|
||||
assert "django" in index_html
|
||||
assert "flask" in index_html
|
||||
|
||||
def test_build_with_stars_sorts_by_stars(self, tmp_path):
|
||||
readme = textwrap.dedent("""\
|
||||
# T
|
||||
|
||||
---
|
||||
|
||||
## Stuff
|
||||
|
||||
- [low-stars](https://github.com/org/low) - Low.
|
||||
- [high-stars](https://github.com/org/high) - High.
|
||||
- [no-stars](https://example.com/none) - None.
|
||||
|
||||
# Contributing
|
||||
|
||||
Done.
|
||||
""")
|
||||
(tmp_path / "README.md").write_text(readme, encoding="utf-8")
|
||||
|
||||
# Copy real templates
|
||||
real_tpl = Path(__file__).parent / ".." / "templates"
|
||||
tpl_dir = tmp_path / "website" / "templates"
|
||||
shutil.copytree(real_tpl, tpl_dir)
|
||||
|
||||
# Create mock star data
|
||||
data_dir = tmp_path / "website" / "data"
|
||||
data_dir.mkdir(parents=True)
|
||||
stars = {
|
||||
"org/high": {"stars": 5000, "owner": "org", "fetched_at": "2026-01-01T00:00:00+00:00"},
|
||||
"org/low": {"stars": 100, "owner": "org", "fetched_at": "2026-01-01T00:00:00+00:00"},
|
||||
}
|
||||
(data_dir / "github_stars.json").write_text(json.dumps(stars), encoding="utf-8")
|
||||
|
||||
build(str(tmp_path))
|
||||
|
||||
html = (tmp_path / "website" / "output" / "index.html").read_text(encoding="utf-8")
|
||||
# Star-sorted: high-stars (5000) before low-stars (100) before no-stars (None)
|
||||
assert html.index("high-stars") < html.index("low-stars")
|
||||
assert html.index("low-stars") < html.index("no-stars")
|
||||
# Formatted star counts
|
||||
assert "5,000" in html
|
||||
assert "100" in html
|
||||
# Expand content present
|
||||
assert "expand-content" in html
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# extract_github_repo
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestExtractGithubRepo:
|
||||
def test_github_url(self):
|
||||
assert extract_github_repo("https://github.com/psf/requests") == "psf/requests"
|
||||
|
||||
def test_non_github_url(self):
|
||||
assert extract_github_repo("https://foss.heptapod.net/pypy/pypy") is None
|
||||
|
||||
def test_github_io_url(self):
|
||||
assert extract_github_repo("https://user.github.io/proj") is None
|
||||
|
||||
def test_trailing_slash(self):
|
||||
assert extract_github_repo("https://github.com/org/repo/") == "org/repo"
|
||||
|
||||
def test_deep_path(self):
|
||||
assert extract_github_repo("https://github.com/org/repo/tree/main") is None
|
||||
|
||||
def test_dot_git_suffix(self):
|
||||
assert extract_github_repo("https://github.com/org/repo.git") == "org/repo"
|
||||
|
||||
def test_org_only(self):
|
||||
assert extract_github_repo("https://github.com/org") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# load_stars
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLoadStars:
|
||||
def test_returns_empty_when_missing(self, tmp_path):
|
||||
result = load_stars(tmp_path / "nonexistent.json")
|
||||
assert result == {}
|
||||
|
||||
def test_loads_valid_json(self, tmp_path):
|
||||
data = {"psf/requests": {"stars": 52467, "owner": "psf", "fetched_at": "2026-01-01T00:00:00+00:00"}}
|
||||
f = tmp_path / "stars.json"
|
||||
f.write_text(json.dumps(data), encoding="utf-8")
|
||||
result = load_stars(f)
|
||||
assert result["psf/requests"]["stars"] == 52467
|
||||
|
||||
def test_returns_empty_on_corrupt_json(self, tmp_path):
|
||||
f = tmp_path / "stars.json"
|
||||
f.write_text("not json", encoding="utf-8")
|
||||
result = load_stars(f)
|
||||
assert result == {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# sort_entries
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSortEntries:
|
||||
def test_sorts_by_stars_descending(self):
|
||||
entries = [
|
||||
{"name": "a", "stars": 100, "url": ""},
|
||||
{"name": "b", "stars": 500, "url": ""},
|
||||
{"name": "c", "stars": 200, "url": ""},
|
||||
]
|
||||
result = sort_entries(entries)
|
||||
assert [e["name"] for e in result] == ["b", "c", "a"]
|
||||
|
||||
def test_equal_stars_sorted_alphabetically(self):
|
||||
entries = [
|
||||
{"name": "beta", "stars": 100, "url": ""},
|
||||
{"name": "alpha", "stars": 100, "url": ""},
|
||||
]
|
||||
result = sort_entries(entries)
|
||||
assert [e["name"] for e in result] == ["alpha", "beta"]
|
||||
|
||||
def test_no_stars_go_to_bottom(self):
|
||||
entries = [
|
||||
{"name": "no-stars", "stars": None, "url": ""},
|
||||
{"name": "has-stars", "stars": 50, "url": ""},
|
||||
]
|
||||
result = sort_entries(entries)
|
||||
assert [e["name"] for e in result] == ["has-stars", "no-stars"]
|
||||
|
||||
def test_no_stars_sorted_alphabetically(self):
|
||||
entries = [
|
||||
{"name": "zebra", "stars": None, "url": ""},
|
||||
{"name": "apple", "stars": None, "url": ""},
|
||||
]
|
||||
result = sort_entries(entries)
|
||||
assert [e["name"] for e in result] == ["apple", "zebra"]
|
||||
161
website/tests/test_fetch_github_stars.py
Normal file
161
website/tests/test_fetch_github_stars.py
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
"""Tests for fetch_github_stars module."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from fetch_github_stars import (
|
||||
build_graphql_query,
|
||||
extract_github_repos,
|
||||
load_cache,
|
||||
parse_graphql_response,
|
||||
save_cache,
|
||||
)
|
||||
|
||||
|
||||
class TestExtractGithubRepos:
|
||||
def test_extracts_owner_repo_from_github_url(self):
|
||||
readme = "* [requests](https://github.com/psf/requests) - HTTP lib."
|
||||
result = extract_github_repos(readme)
|
||||
assert result == {"psf/requests"}
|
||||
|
||||
def test_multiple_repos(self):
|
||||
readme = (
|
||||
"* [requests](https://github.com/psf/requests) - HTTP.\n"
|
||||
"* [flask](https://github.com/pallets/flask) - Micro."
|
||||
)
|
||||
result = extract_github_repos(readme)
|
||||
assert result == {"psf/requests", "pallets/flask"}
|
||||
|
||||
def test_ignores_non_github_urls(self):
|
||||
readme = "* [pypy](https://foss.heptapod.net/pypy/pypy) - Fast Python."
|
||||
result = extract_github_repos(readme)
|
||||
assert result == set()
|
||||
|
||||
def test_ignores_github_io_urls(self):
|
||||
readme = "* [docs](https://user.github.io/project) - Docs site."
|
||||
result = extract_github_repos(readme)
|
||||
assert result == set()
|
||||
|
||||
def test_ignores_github_wiki_and_blob_urls(self):
|
||||
readme = (
|
||||
"* [wiki](https://github.com/org/repo/wiki) - Wiki.\n"
|
||||
"* [file](https://github.com/org/repo/blob/main/f.py) - File."
|
||||
)
|
||||
result = extract_github_repos(readme)
|
||||
assert result == set()
|
||||
|
||||
def test_handles_trailing_slash(self):
|
||||
readme = "* [lib](https://github.com/org/repo/) - Lib."
|
||||
result = extract_github_repos(readme)
|
||||
assert result == {"org/repo"}
|
||||
|
||||
def test_deduplicates(self):
|
||||
readme = (
|
||||
"* [a](https://github.com/org/repo) - A.\n"
|
||||
"* [b](https://github.com/org/repo) - B."
|
||||
)
|
||||
result = extract_github_repos(readme)
|
||||
assert result == {"org/repo"}
|
||||
|
||||
def test_strips_fragment(self):
|
||||
readme = "* [lib](https://github.com/org/repo#section) - Lib."
|
||||
result = extract_github_repos(readme)
|
||||
assert result == {"org/repo"}
|
||||
|
||||
|
||||
class TestLoadCache:
|
||||
def test_returns_empty_when_missing(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("fetch_github_stars.CACHE_FILE", tmp_path / "nonexistent.json")
|
||||
result = load_cache()
|
||||
assert result == {}
|
||||
|
||||
def test_loads_valid_cache(self, tmp_path, monkeypatch):
|
||||
cache_file = tmp_path / "stars.json"
|
||||
cache_file.write_text('{"a/b": {"stars": 1}}', encoding="utf-8")
|
||||
monkeypatch.setattr("fetch_github_stars.CACHE_FILE", cache_file)
|
||||
result = load_cache()
|
||||
assert result == {"a/b": {"stars": 1}}
|
||||
|
||||
def test_returns_empty_on_corrupt_json(self, tmp_path, monkeypatch):
|
||||
cache_file = tmp_path / "stars.json"
|
||||
cache_file.write_text("not json", encoding="utf-8")
|
||||
monkeypatch.setattr("fetch_github_stars.CACHE_FILE", cache_file)
|
||||
result = load_cache()
|
||||
assert result == {}
|
||||
|
||||
|
||||
class TestSaveCache:
|
||||
def test_creates_directory_and_writes_json(self, tmp_path, monkeypatch):
|
||||
data_dir = tmp_path / "data"
|
||||
cache_file = data_dir / "stars.json"
|
||||
monkeypatch.setattr("fetch_github_stars.DATA_DIR", data_dir)
|
||||
monkeypatch.setattr("fetch_github_stars.CACHE_FILE", cache_file)
|
||||
save_cache({"a/b": {"stars": 1}})
|
||||
assert cache_file.exists()
|
||||
assert json.loads(cache_file.read_text(encoding="utf-8")) == {"a/b": {"stars": 1}}
|
||||
|
||||
|
||||
class TestBuildGraphqlQuery:
|
||||
def test_single_repo(self):
|
||||
query = build_graphql_query(["psf/requests"])
|
||||
assert "repository" in query
|
||||
assert 'owner: "psf"' in query
|
||||
assert 'name: "requests"' in query
|
||||
assert "stargazerCount" in query
|
||||
|
||||
def test_multiple_repos_use_aliases(self):
|
||||
query = build_graphql_query(["psf/requests", "pallets/flask"])
|
||||
assert "repo_0:" in query
|
||||
assert "repo_1:" in query
|
||||
|
||||
def test_empty_list(self):
|
||||
query = build_graphql_query([])
|
||||
assert query == ""
|
||||
|
||||
def test_skips_repos_with_quotes_in_name(self):
|
||||
query = build_graphql_query(['org/"bad"'])
|
||||
assert query == ""
|
||||
|
||||
def test_skips_only_bad_repos(self):
|
||||
query = build_graphql_query(["good/repo", 'bad/"repo"'])
|
||||
assert "good" in query
|
||||
assert "bad" not in query
|
||||
|
||||
|
||||
class TestParseGraphqlResponse:
|
||||
def test_parses_star_count_and_owner(self):
|
||||
data = {
|
||||
"repo_0": {
|
||||
"stargazerCount": 52467,
|
||||
"owner": {"login": "psf"},
|
||||
}
|
||||
}
|
||||
repos = ["psf/requests"]
|
||||
result = parse_graphql_response(data, repos)
|
||||
assert result["psf/requests"]["stars"] == 52467
|
||||
assert result["psf/requests"]["owner"] == "psf"
|
||||
|
||||
def test_skips_null_repos(self):
|
||||
data = {"repo_0": None}
|
||||
repos = ["deleted/repo"]
|
||||
result = parse_graphql_response(data, repos)
|
||||
assert result == {}
|
||||
|
||||
def test_handles_missing_owner(self):
|
||||
data = {"repo_0": {"stargazerCount": 100}}
|
||||
repos = ["org/repo"]
|
||||
result = parse_graphql_response(data, repos)
|
||||
assert result["org/repo"]["owner"] == ""
|
||||
|
||||
def test_multiple_repos(self):
|
||||
data = {
|
||||
"repo_0": {"stargazerCount": 100, "owner": {"login": "a"}},
|
||||
"repo_1": {"stargazerCount": 200, "owner": {"login": "b"}},
|
||||
}
|
||||
repos = ["a/x", "b/y"]
|
||||
result = parse_graphql_response(data, repos)
|
||||
assert len(result) == 2
|
||||
assert result["a/x"]["stars"] == 100
|
||||
assert result["b/y"]["stars"] == 200
|
||||
Loading…
Add table
Add a link
Reference in a new issue