--- /dev/null
+import logging
+import secrets
+import subprocess
+from collections import defaultdict
+from pathlib import Path
+from typing import Any
+
+import httpx
+import yaml
+from github import Github
+from pydantic import BaseModel, SecretStr
+from pydantic_settings import BaseSettings
+
+github_graphql_url = "https://api.github.com/graphql"
+
+
+sponsors_query = """
+query Q($after: String) {
+ user(login: "tiangolo") {
+ sponsorshipsAsMaintainer(first: 100, after: $after) {
+ edges {
+ cursor
+ node {
+ sponsorEntity {
+ ... on Organization {
+ login
+ avatarUrl
+ url
+ }
+ ... on User {
+ login
+ avatarUrl
+ url
+ }
+ }
+ tier {
+ name
+ monthlyPriceInDollars
+ }
+ }
+ }
+ }
+ }
+}
+"""
+
+
+class SponsorEntity(BaseModel):
+ login: str
+ avatarUrl: str
+ url: str
+
+
+class Tier(BaseModel):
+ name: str
+ monthlyPriceInDollars: float
+
+
+class SponsorshipAsMaintainerNode(BaseModel):
+ sponsorEntity: SponsorEntity
+ tier: Tier
+
+
+class SponsorshipAsMaintainerEdge(BaseModel):
+ cursor: str
+ node: SponsorshipAsMaintainerNode
+
+
+class SponsorshipAsMaintainer(BaseModel):
+ edges: list[SponsorshipAsMaintainerEdge]
+
+
+class SponsorsUser(BaseModel):
+ sponsorshipsAsMaintainer: SponsorshipAsMaintainer
+
+
+class SponsorsResponseData(BaseModel):
+ user: SponsorsUser
+
+
+class SponsorsResponse(BaseModel):
+ data: SponsorsResponseData
+
+
+class Settings(BaseSettings):
+ github_token: SecretStr
+ github_repository: str
+ httpx_timeout: int = 30
+
+
+def get_graphql_response(
+ *,
+ settings: Settings,
+ query: str,
+ after: str | None = None,
+) -> dict[str, Any]:
+ headers = {"Authorization": f"token {settings.github_token.get_secret_value()}"}
+ variables = {"after": after}
+ response = httpx.post(
+ github_graphql_url,
+ headers=headers,
+ timeout=settings.httpx_timeout,
+ json={"query": query, "variables": variables, "operationName": "Q"},
+ )
+ if response.status_code != 200:
+ logging.error(f"Response was not 200, after: {after}")
+ logging.error(response.text)
+ raise RuntimeError(response.text)
+ data = response.json()
+ if "errors" in data:
+ logging.error(f"Errors in response, after: {after}")
+ logging.error(data["errors"])
+ logging.error(response.text)
+ raise RuntimeError(response.text)
+ return data
+
+
+def get_graphql_sponsor_edges(
+ *, settings: Settings, after: str | None = None
+) -> list[SponsorshipAsMaintainerEdge]:
+ data = get_graphql_response(settings=settings, query=sponsors_query, after=after)
+ graphql_response = SponsorsResponse.model_validate(data)
+ return graphql_response.data.user.sponsorshipsAsMaintainer.edges
+
+
+def get_individual_sponsors(
+ settings: Settings,
+) -> defaultdict[float, dict[str, SponsorEntity]]:
+ nodes: list[SponsorshipAsMaintainerNode] = []
+ edges = get_graphql_sponsor_edges(settings=settings)
+
+ while edges:
+ for edge in edges:
+ nodes.append(edge.node)
+ last_edge = edges[-1]
+ edges = get_graphql_sponsor_edges(settings=settings, after=last_edge.cursor)
+
+ tiers: defaultdict[float, dict[str, SponsorEntity]] = defaultdict(dict)
+ for node in nodes:
+ tiers[node.tier.monthlyPriceInDollars][node.sponsorEntity.login] = (
+ node.sponsorEntity
+ )
+ return tiers
+
+
+def update_content(*, content_path: Path, new_content: Any) -> bool:
+ old_content = content_path.read_text(encoding="utf-8")
+
+ new_content = yaml.dump(new_content, sort_keys=False, width=200, allow_unicode=True)
+ if old_content == new_content:
+ logging.info(f"The content hasn't changed for {content_path}")
+ return False
+ content_path.write_text(new_content, encoding="utf-8")
+ logging.info(f"Updated {content_path}")
+ return True
+
+
+def main() -> None:
+ logging.basicConfig(level=logging.INFO)
+ settings = Settings()
+ logging.info(f"Using config: {settings.model_dump_json()}")
+ g = Github(settings.github_token.get_secret_value())
+ repo = g.get_repo(settings.github_repository)
+
+ tiers = get_individual_sponsors(settings=settings)
+ keys = list(tiers.keys())
+ keys.sort(reverse=True)
+ sponsors = []
+ for key in keys:
+ sponsor_group = []
+ for login, sponsor in tiers[key].items():
+ sponsor_group.append(
+ {"login": login, "avatarUrl": sponsor.avatarUrl, "url": sponsor.url}
+ )
+ sponsors.append(sponsor_group)
+ github_sponsors = {
+ "sponsors": sponsors,
+ }
+
+ # For local development
+ # github_sponsors_path = Path("../docs/en/data/github_sponsors.yml")
+ github_sponsors_path = Path("./docs/en/data/github_sponsors.yml")
+ updated = update_content(
+ content_path=github_sponsors_path, new_content=github_sponsors
+ )
+
+ if not updated:
+ logging.info("The data hasn't changed, finishing.")
+ return
+
+ logging.info("Setting up GitHub Actions git user")
+ subprocess.run(["git", "config", "user.name", "github-actions"], check=True)
+ subprocess.run(
+ ["git", "config", "user.email", "github-actions@github.com"], check=True
+ )
+ branch_name = f"fastapi-people-sponsors-{secrets.token_hex(4)}"
+ logging.info(f"Creating a new branch {branch_name}")
+ subprocess.run(["git", "checkout", "-b", branch_name], check=True)
+ logging.info("Adding updated file")
+ subprocess.run(
+ [
+ "git",
+ "add",
+ str(github_sponsors_path),
+ ],
+ check=True,
+ )
+ logging.info("Committing updated file")
+ message = "👥 Update FastAPI People - Sponsors"
+ subprocess.run(["git", "commit", "-m", message], check=True)
+ logging.info("Pushing branch")
+ subprocess.run(["git", "push", "origin", branch_name], check=True)
+ logging.info("Creating PR")
+ pr = repo.create_pull(title=message, body=message, base="master", head=branch_name)
+ logging.info(f"Created PR: {pr.number}")
+ logging.info("Finished")
+
+
+if __name__ == "__main__":
+ main()