mirror of
https://github.com/vinta/awesome-python.git
synced 2026-05-22 13:46:41 +08: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:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
Reference in New Issue
Block a user