committed by
GitHub
23 changed files with 2123 additions and 1852 deletions
@ -1,7 +0,0 @@ |
|||
FROM python:3.9 |
|||
|
|||
RUN pip install httpx PyGithub "pydantic==1.5.1" "pyyaml>=5.3.1,<6.0.0" |
|||
|
|||
COPY ./app /app |
|||
|
|||
CMD ["python", "/app/main.py"] |
@ -1,10 +0,0 @@ |
|||
name: "Notify Translations" |
|||
description: "Notify in the issue for a translation when there's a new PR available" |
|||
author: "Sebastián Ramírez <[email protected]>" |
|||
inputs: |
|||
token: |
|||
description: 'Token, to read the GitHub API. Can be passed in using {{ secrets.GITHUB_TOKEN }}' |
|||
required: true |
|||
runs: |
|||
using: 'docker' |
|||
image: 'Dockerfile' |
@ -1,7 +0,0 @@ |
|||
FROM python:3.9 |
|||
|
|||
RUN pip install httpx PyGithub "pydantic==2.0.2" pydantic-settings "pyyaml>=5.3.1,<6.0.0" |
|||
|
|||
COPY ./app /app |
|||
|
|||
CMD ["python", "/app/main.py"] |
@ -1,10 +0,0 @@ |
|||
name: "Generate FastAPI People" |
|||
description: "Generate the data for the FastAPI People page" |
|||
author: "Sebastián Ramírez <[email protected]>" |
|||
inputs: |
|||
token: |
|||
description: 'User token, to read the GitHub API. Can be passed in using {{ secrets.FASTAPI_PEOPLE }}' |
|||
required: true |
|||
runs: |
|||
using: 'docker' |
|||
image: 'Dockerfile' |
@ -1,682 +0,0 @@ |
|||
import logging |
|||
import subprocess |
|||
import sys |
|||
from collections import Counter, defaultdict |
|||
from datetime import datetime, timedelta, timezone |
|||
from pathlib import Path |
|||
from typing import Any, Container, DefaultDict, Dict, List, Set, Union |
|||
|
|||
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" |
|||
questions_category_id = "MDE4OkRpc2N1c3Npb25DYXRlZ29yeTMyMDAxNDM0" |
|||
|
|||
discussions_query = """ |
|||
query Q($after: String, $category_id: ID) { |
|||
repository(name: "fastapi", owner: "fastapi") { |
|||
discussions(first: 100, after: $after, categoryId: $category_id) { |
|||
edges { |
|||
cursor |
|||
node { |
|||
number |
|||
author { |
|||
login |
|||
avatarUrl |
|||
url |
|||
} |
|||
title |
|||
createdAt |
|||
comments(first: 100) { |
|||
nodes { |
|||
createdAt |
|||
author { |
|||
login |
|||
avatarUrl |
|||
url |
|||
} |
|||
isAnswer |
|||
replies(first: 10) { |
|||
nodes { |
|||
createdAt |
|||
author { |
|||
login |
|||
avatarUrl |
|||
url |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
""" |
|||
|
|||
|
|||
prs_query = """ |
|||
query Q($after: String) { |
|||
repository(name: "fastapi", owner: "fastapi") { |
|||
pullRequests(first: 100, after: $after) { |
|||
edges { |
|||
cursor |
|||
node { |
|||
number |
|||
labels(first: 100) { |
|||
nodes { |
|||
name |
|||
} |
|||
} |
|||
author { |
|||
login |
|||
avatarUrl |
|||
url |
|||
} |
|||
title |
|||
createdAt |
|||
state |
|||
comments(first: 100) { |
|||
nodes { |
|||
createdAt |
|||
author { |
|||
login |
|||
avatarUrl |
|||
url |
|||
} |
|||
} |
|||
} |
|||
reviews(first:100) { |
|||
nodes { |
|||
author { |
|||
login |
|||
avatarUrl |
|||
url |
|||
} |
|||
state |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
""" |
|||
|
|||
sponsors_query = """ |
|||
query Q($after: String) { |
|||
user(login: "fastapi") { |
|||
sponsorshipsAsMaintainer(first: 100, after: $after) { |
|||
edges { |
|||
cursor |
|||
node { |
|||
sponsorEntity { |
|||
... on Organization { |
|||
login |
|||
avatarUrl |
|||
url |
|||
} |
|||
... on User { |
|||
login |
|||
avatarUrl |
|||
url |
|||
} |
|||
} |
|||
tier { |
|||
name |
|||
monthlyPriceInDollars |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
""" |
|||
|
|||
|
|||
class Author(BaseModel): |
|||
login: str |
|||
avatarUrl: str |
|||
url: str |
|||
|
|||
|
|||
# Discussions |
|||
|
|||
|
|||
class CommentsNode(BaseModel): |
|||
createdAt: datetime |
|||
author: Union[Author, None] = None |
|||
|
|||
|
|||
class Replies(BaseModel): |
|||
nodes: List[CommentsNode] |
|||
|
|||
|
|||
class DiscussionsCommentsNode(CommentsNode): |
|||
replies: Replies |
|||
|
|||
|
|||
class Comments(BaseModel): |
|||
nodes: List[CommentsNode] |
|||
|
|||
|
|||
class DiscussionsComments(BaseModel): |
|||
nodes: List[DiscussionsCommentsNode] |
|||
|
|||
|
|||
class DiscussionsNode(BaseModel): |
|||
number: int |
|||
author: Union[Author, None] = None |
|||
title: str |
|||
createdAt: datetime |
|||
comments: DiscussionsComments |
|||
|
|||
|
|||
class DiscussionsEdge(BaseModel): |
|||
cursor: str |
|||
node: DiscussionsNode |
|||
|
|||
|
|||
class Discussions(BaseModel): |
|||
edges: List[DiscussionsEdge] |
|||
|
|||
|
|||
class DiscussionsRepository(BaseModel): |
|||
discussions: Discussions |
|||
|
|||
|
|||
class DiscussionsResponseData(BaseModel): |
|||
repository: DiscussionsRepository |
|||
|
|||
|
|||
class DiscussionsResponse(BaseModel): |
|||
data: DiscussionsResponseData |
|||
|
|||
|
|||
# PRs |
|||
|
|||
|
|||
class LabelNode(BaseModel): |
|||
name: str |
|||
|
|||
|
|||
class Labels(BaseModel): |
|||
nodes: List[LabelNode] |
|||
|
|||
|
|||
class ReviewNode(BaseModel): |
|||
author: Union[Author, None] = None |
|||
state: str |
|||
|
|||
|
|||
class Reviews(BaseModel): |
|||
nodes: List[ReviewNode] |
|||
|
|||
|
|||
class PullRequestNode(BaseModel): |
|||
number: int |
|||
labels: Labels |
|||
author: Union[Author, None] = None |
|||
title: str |
|||
createdAt: datetime |
|||
state: str |
|||
comments: Comments |
|||
reviews: Reviews |
|||
|
|||
|
|||
class PullRequestEdge(BaseModel): |
|||
cursor: str |
|||
node: PullRequestNode |
|||
|
|||
|
|||
class PullRequests(BaseModel): |
|||
edges: List[PullRequestEdge] |
|||
|
|||
|
|||
class PRsRepository(BaseModel): |
|||
pullRequests: PullRequests |
|||
|
|||
|
|||
class PRsResponseData(BaseModel): |
|||
repository: PRsRepository |
|||
|
|||
|
|||
class PRsResponse(BaseModel): |
|||
data: PRsResponseData |
|||
|
|||
|
|||
# Sponsors |
|||
|
|||
|
|||
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): |
|||
input_token: SecretStr |
|||
github_repository: str |
|||
httpx_timeout: int = 30 |
|||
|
|||
|
|||
def get_graphql_response( |
|||
*, |
|||
settings: Settings, |
|||
query: str, |
|||
after: Union[str, None] = None, |
|||
category_id: Union[str, None] = None, |
|||
) -> Dict[str, Any]: |
|||
headers = {"Authorization": f"token {settings.input_token.get_secret_value()}"} |
|||
# category_id is only used by one query, but GraphQL allows unused variables, so |
|||
# keep it here for simplicity |
|||
variables = {"after": after, "category_id": category_id} |
|||
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}, category_id: {category_id}" |
|||
) |
|||
logging.error(response.text) |
|||
raise RuntimeError(response.text) |
|||
data = response.json() |
|||
if "errors" in data: |
|||
logging.error(f"Errors in response, after: {after}, category_id: {category_id}") |
|||
logging.error(data["errors"]) |
|||
logging.error(response.text) |
|||
raise RuntimeError(response.text) |
|||
return data |
|||
|
|||
|
|||
def get_graphql_question_discussion_edges( |
|||
*, |
|||
settings: Settings, |
|||
after: Union[str, None] = None, |
|||
): |
|||
data = get_graphql_response( |
|||
settings=settings, |
|||
query=discussions_query, |
|||
after=after, |
|||
category_id=questions_category_id, |
|||
) |
|||
graphql_response = DiscussionsResponse.model_validate(data) |
|||
return graphql_response.data.repository.discussions.edges |
|||
|
|||
|
|||
def get_graphql_pr_edges(*, settings: Settings, after: Union[str, None] = None): |
|||
data = get_graphql_response(settings=settings, query=prs_query, after=after) |
|||
graphql_response = PRsResponse.model_validate(data) |
|||
return graphql_response.data.repository.pullRequests.edges |
|||
|
|||
|
|||
def get_graphql_sponsor_edges(*, settings: Settings, after: Union[str, None] = None): |
|||
data = get_graphql_response(settings=settings, query=sponsors_query, after=after) |
|||
graphql_response = SponsorsResponse.model_validate(data) |
|||
return graphql_response.data.user.sponsorshipsAsMaintainer.edges |
|||
|
|||
|
|||
class DiscussionExpertsResults(BaseModel): |
|||
commenters: Counter |
|||
last_month_commenters: Counter |
|||
three_months_commenters: Counter |
|||
six_months_commenters: Counter |
|||
one_year_commenters: Counter |
|||
authors: Dict[str, Author] |
|||
|
|||
|
|||
def get_discussion_nodes(settings: Settings) -> List[DiscussionsNode]: |
|||
discussion_nodes: List[DiscussionsNode] = [] |
|||
discussion_edges = get_graphql_question_discussion_edges(settings=settings) |
|||
|
|||
while discussion_edges: |
|||
for discussion_edge in discussion_edges: |
|||
discussion_nodes.append(discussion_edge.node) |
|||
last_edge = discussion_edges[-1] |
|||
discussion_edges = get_graphql_question_discussion_edges( |
|||
settings=settings, after=last_edge.cursor |
|||
) |
|||
return discussion_nodes |
|||
|
|||
|
|||
def get_discussions_experts( |
|||
discussion_nodes: List[DiscussionsNode], |
|||
) -> DiscussionExpertsResults: |
|||
commenters = Counter() |
|||
last_month_commenters = Counter() |
|||
three_months_commenters = Counter() |
|||
six_months_commenters = Counter() |
|||
one_year_commenters = Counter() |
|||
authors: Dict[str, Author] = {} |
|||
|
|||
now = datetime.now(tz=timezone.utc) |
|||
one_month_ago = now - timedelta(days=30) |
|||
three_months_ago = now - timedelta(days=90) |
|||
six_months_ago = now - timedelta(days=180) |
|||
one_year_ago = now - timedelta(days=365) |
|||
|
|||
for discussion in discussion_nodes: |
|||
discussion_author_name = None |
|||
if discussion.author: |
|||
authors[discussion.author.login] = discussion.author |
|||
discussion_author_name = discussion.author.login |
|||
discussion_commentors: dict[str, datetime] = {} |
|||
for comment in discussion.comments.nodes: |
|||
if comment.author: |
|||
authors[comment.author.login] = comment.author |
|||
if comment.author.login != discussion_author_name: |
|||
author_time = discussion_commentors.get( |
|||
comment.author.login, comment.createdAt |
|||
) |
|||
discussion_commentors[comment.author.login] = max( |
|||
author_time, comment.createdAt |
|||
) |
|||
for reply in comment.replies.nodes: |
|||
if reply.author: |
|||
authors[reply.author.login] = reply.author |
|||
if reply.author.login != discussion_author_name: |
|||
author_time = discussion_commentors.get( |
|||
reply.author.login, reply.createdAt |
|||
) |
|||
discussion_commentors[reply.author.login] = max( |
|||
author_time, reply.createdAt |
|||
) |
|||
for author_name, author_time in discussion_commentors.items(): |
|||
commenters[author_name] += 1 |
|||
if author_time > one_month_ago: |
|||
last_month_commenters[author_name] += 1 |
|||
if author_time > three_months_ago: |
|||
three_months_commenters[author_name] += 1 |
|||
if author_time > six_months_ago: |
|||
six_months_commenters[author_name] += 1 |
|||
if author_time > one_year_ago: |
|||
one_year_commenters[author_name] += 1 |
|||
discussion_experts_results = DiscussionExpertsResults( |
|||
authors=authors, |
|||
commenters=commenters, |
|||
last_month_commenters=last_month_commenters, |
|||
three_months_commenters=three_months_commenters, |
|||
six_months_commenters=six_months_commenters, |
|||
one_year_commenters=one_year_commenters, |
|||
) |
|||
return discussion_experts_results |
|||
|
|||
|
|||
def get_pr_nodes(settings: Settings) -> List[PullRequestNode]: |
|||
pr_nodes: List[PullRequestNode] = [] |
|||
pr_edges = get_graphql_pr_edges(settings=settings) |
|||
|
|||
while pr_edges: |
|||
for edge in pr_edges: |
|||
pr_nodes.append(edge.node) |
|||
last_edge = pr_edges[-1] |
|||
pr_edges = get_graphql_pr_edges(settings=settings, after=last_edge.cursor) |
|||
return pr_nodes |
|||
|
|||
|
|||
class ContributorsResults(BaseModel): |
|||
contributors: Counter |
|||
commenters: Counter |
|||
reviewers: Counter |
|||
translation_reviewers: Counter |
|||
authors: Dict[str, Author] |
|||
|
|||
|
|||
def get_contributors(pr_nodes: List[PullRequestNode]) -> ContributorsResults: |
|||
contributors = Counter() |
|||
commenters = Counter() |
|||
reviewers = Counter() |
|||
translation_reviewers = Counter() |
|||
authors: Dict[str, Author] = {} |
|||
|
|||
for pr in pr_nodes: |
|||
author_name = None |
|||
if pr.author: |
|||
authors[pr.author.login] = pr.author |
|||
author_name = pr.author.login |
|||
pr_commentors: Set[str] = set() |
|||
pr_reviewers: Set[str] = set() |
|||
for comment in pr.comments.nodes: |
|||
if comment.author: |
|||
authors[comment.author.login] = comment.author |
|||
if comment.author.login == author_name: |
|||
continue |
|||
pr_commentors.add(comment.author.login) |
|||
for author_name in pr_commentors: |
|||
commenters[author_name] += 1 |
|||
for review in pr.reviews.nodes: |
|||
if review.author: |
|||
authors[review.author.login] = review.author |
|||
pr_reviewers.add(review.author.login) |
|||
for label in pr.labels.nodes: |
|||
if label.name == "lang-all": |
|||
translation_reviewers[review.author.login] += 1 |
|||
break |
|||
for reviewer in pr_reviewers: |
|||
reviewers[reviewer] += 1 |
|||
if pr.state == "MERGED" and pr.author: |
|||
contributors[pr.author.login] += 1 |
|||
return ContributorsResults( |
|||
contributors=contributors, |
|||
commenters=commenters, |
|||
reviewers=reviewers, |
|||
translation_reviewers=translation_reviewers, |
|||
authors=authors, |
|||
) |
|||
|
|||
|
|||
def get_individual_sponsors(settings: Settings): |
|||
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 get_top_users( |
|||
*, |
|||
counter: Counter, |
|||
authors: Dict[str, Author], |
|||
skip_users: Container[str], |
|||
min_count: int = 2, |
|||
): |
|||
users = [] |
|||
for commenter, count in counter.most_common(50): |
|||
if commenter in skip_users: |
|||
continue |
|||
if count >= min_count: |
|||
author = authors[commenter] |
|||
users.append( |
|||
{ |
|||
"login": commenter, |
|||
"count": count, |
|||
"avatarUrl": author.avatarUrl, |
|||
"url": author.url, |
|||
} |
|||
) |
|||
return users |
|||
|
|||
|
|||
if __name__ == "__main__": |
|||
logging.basicConfig(level=logging.INFO) |
|||
settings = Settings() |
|||
logging.info(f"Using config: {settings.model_dump_json()}") |
|||
g = Github(settings.input_token.get_secret_value()) |
|||
repo = g.get_repo(settings.github_repository) |
|||
discussion_nodes = get_discussion_nodes(settings=settings) |
|||
experts_results = get_discussions_experts(discussion_nodes=discussion_nodes) |
|||
pr_nodes = get_pr_nodes(settings=settings) |
|||
contributors_results = get_contributors(pr_nodes=pr_nodes) |
|||
authors = {**experts_results.authors, **contributors_results.authors} |
|||
maintainers_logins = {"tiangolo"} |
|||
bot_names = {"codecov", "github-actions", "pre-commit-ci", "dependabot"} |
|||
maintainers = [] |
|||
for login in maintainers_logins: |
|||
user = authors[login] |
|||
maintainers.append( |
|||
{ |
|||
"login": login, |
|||
"answers": experts_results.commenters[login], |
|||
"prs": contributors_results.contributors[login], |
|||
"avatarUrl": user.avatarUrl, |
|||
"url": user.url, |
|||
} |
|||
) |
|||
|
|||
skip_users = maintainers_logins | bot_names |
|||
experts = get_top_users( |
|||
counter=experts_results.commenters, |
|||
authors=authors, |
|||
skip_users=skip_users, |
|||
) |
|||
last_month_experts = get_top_users( |
|||
counter=experts_results.last_month_commenters, |
|||
authors=authors, |
|||
skip_users=skip_users, |
|||
) |
|||
three_months_experts = get_top_users( |
|||
counter=experts_results.three_months_commenters, |
|||
authors=authors, |
|||
skip_users=skip_users, |
|||
) |
|||
six_months_experts = get_top_users( |
|||
counter=experts_results.six_months_commenters, |
|||
authors=authors, |
|||
skip_users=skip_users, |
|||
) |
|||
one_year_experts = get_top_users( |
|||
counter=experts_results.one_year_commenters, |
|||
authors=authors, |
|||
skip_users=skip_users, |
|||
) |
|||
top_contributors = get_top_users( |
|||
counter=contributors_results.contributors, |
|||
authors=authors, |
|||
skip_users=skip_users, |
|||
) |
|||
top_reviewers = get_top_users( |
|||
counter=contributors_results.reviewers, |
|||
authors=authors, |
|||
skip_users=skip_users, |
|||
) |
|||
top_translations_reviewers = get_top_users( |
|||
counter=contributors_results.translation_reviewers, |
|||
authors=authors, |
|||
skip_users=skip_users, |
|||
) |
|||
|
|||
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) |
|||
|
|||
people = { |
|||
"maintainers": maintainers, |
|||
"experts": experts, |
|||
"last_month_experts": last_month_experts, |
|||
"three_months_experts": three_months_experts, |
|||
"six_months_experts": six_months_experts, |
|||
"one_year_experts": one_year_experts, |
|||
"top_contributors": top_contributors, |
|||
"top_reviewers": top_reviewers, |
|||
"top_translations_reviewers": top_translations_reviewers, |
|||
} |
|||
github_sponsors = { |
|||
"sponsors": sponsors, |
|||
} |
|||
# For local development |
|||
# people_path = Path("../../../../docs/en/data/people.yml") |
|||
people_path = Path("./docs/en/data/people.yml") |
|||
github_sponsors_path = Path("./docs/en/data/github_sponsors.yml") |
|||
people_old_content = people_path.read_text(encoding="utf-8") |
|||
github_sponsors_old_content = github_sponsors_path.read_text(encoding="utf-8") |
|||
new_people_content = yaml.dump( |
|||
people, sort_keys=False, width=200, allow_unicode=True |
|||
) |
|||
new_github_sponsors_content = yaml.dump( |
|||
github_sponsors, sort_keys=False, width=200, allow_unicode=True |
|||
) |
|||
if ( |
|||
people_old_content == new_people_content |
|||
and github_sponsors_old_content == new_github_sponsors_content |
|||
): |
|||
logging.info("The FastAPI People data hasn't changed, finishing.") |
|||
sys.exit(0) |
|||
people_path.write_text(new_people_content, encoding="utf-8") |
|||
github_sponsors_path.write_text(new_github_sponsors_content, encoding="utf-8") |
|||
logging.info("Setting up GitHub Actions git user") |
|||
subprocess.run(["git", "config", "user.name", "github-actions"], check=True) |
|||
subprocess.run( |
|||
["git", "config", "user.email", "[email protected]"], check=True |
|||
) |
|||
branch_name = "fastapi-people" |
|||
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(people_path), str(github_sponsors_path)], check=True |
|||
) |
|||
logging.info("Committing updated file") |
|||
message = "👥 Update FastAPI People" |
|||
result = 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") |
@ -35,7 +35,7 @@ jobs: |
|||
TIANGOLO_BUILD_PACKAGE: ${{ matrix.package }} |
|||
run: python -m build |
|||
- name: Publish |
|||
uses: pypa/[email protected].3 |
|||
uses: pypa/[email protected].4 |
|||
- name: Dump GitHub context |
|||
env: |
|||
GITHUB_CONTEXT: ${{ toJson(github) }} |
|||
|
File diff suppressed because it is too large
@ -0,0 +1,301 @@ |
|||
# 環境変数 |
|||
|
|||
/// tip |
|||
|
|||
もし、「環境変数」とは何か、それをどう使うかを既に知っている場合は、このセクションをスキップして構いません。 |
|||
|
|||
/// |
|||
|
|||
環境変数(**env var**とも呼ばれる)はPythonコードの**外側**、つまり**OS**に存在する変数で、Pythonから読み取ることができます。(他のプログラムでも同様に読み取れます。) |
|||
|
|||
環境変数は、アプリケーションの**設定**の管理や、Pythonの**インストール**などに役立ちます。 |
|||
|
|||
## 環境変数の作成と使用 |
|||
|
|||
環境変数は**シェル(ターミナル)**内で**作成**して使用でき、それらにPythonは不要です。 |
|||
|
|||
//// tab | Linux, macOS, Windows Bash |
|||
|
|||
<div class="termy"> |
|||
|
|||
```console |
|||
// You could create an env var MY_NAME with |
|||
$ export MY_NAME="Wade Wilson" |
|||
|
|||
// Then you could use it with other programs, like |
|||
$ echo "Hello $MY_NAME" |
|||
|
|||
Hello Wade Wilson |
|||
``` |
|||
|
|||
</div> |
|||
|
|||
//// |
|||
|
|||
//// tab | Windows PowerShell |
|||
|
|||
<div class="termy"> |
|||
|
|||
|
|||
```console |
|||
// Create an env var MY_NAME |
|||
$ $Env:MY_NAME = "Wade Wilson" |
|||
|
|||
// Use it with other programs, like |
|||
$ echo "Hello $Env:MY_NAME" |
|||
|
|||
Hello Wade Wilson |
|||
``` |
|||
|
|||
</div> |
|||
|
|||
//// |
|||
|
|||
## Pythonで環境変数を読み取る |
|||
|
|||
環境変数をPythonの**外側**、ターミナル(や他の方法)で作成し、**Python内で読み取る**こともできます。 |
|||
|
|||
例えば、以下のような`main.py`ファイルを用意します: |
|||
|
|||
```Python hl_lines="3" |
|||
import os |
|||
|
|||
name = os.getenv("MY_NAME", "World") |
|||
print(f"Hello {name} from Python") |
|||
``` |
|||
|
|||
/// tip |
|||
|
|||
<a href="https://docs.python.org/3.8/library/os.html#os.getenv" class="external-link" target="_blank">`os.getenv()`</a> の第2引数は、デフォルトで返される値を指定します。 |
|||
|
|||
この引数を省略するとデフォルト値として`None`が返されますが、ここではデフォルト値として`"World"`を指定しています。 |
|||
|
|||
/// |
|||
|
|||
次に、このPythonプログラムを呼び出します。 |
|||
|
|||
//// tab | Linux, macOS, Windows Bash |
|||
|
|||
<div class="termy"> |
|||
|
|||
```console |
|||
// Here we don't set the env var yet |
|||
$ python main.py |
|||
|
|||
// As we didn't set the env var, we get the default value |
|||
|
|||
Hello World from Python |
|||
|
|||
// But if we create an environment variable first |
|||
$ export MY_NAME="Wade Wilson" |
|||
|
|||
// And then call the program again |
|||
$ python main.py |
|||
|
|||
// Now it can read the environment variable |
|||
|
|||
Hello Wade Wilson from Python |
|||
``` |
|||
|
|||
</div> |
|||
|
|||
//// |
|||
|
|||
//// tab | Windows PowerShell |
|||
|
|||
<div class="termy"> |
|||
|
|||
```console |
|||
// Here we don't set the env var yet |
|||
$ python main.py |
|||
|
|||
// As we didn't set the env var, we get the default value |
|||
|
|||
Hello World from Python |
|||
|
|||
// But if we create an environment variable first |
|||
$ $Env:MY_NAME = "Wade Wilson" |
|||
|
|||
// And then call the program again |
|||
$ python main.py |
|||
|
|||
// Now it can read the environment variable |
|||
|
|||
Hello Wade Wilson from Python |
|||
``` |
|||
|
|||
</div> |
|||
|
|||
//// |
|||
|
|||
環境変数はコードの外側で設定し、内側から読み取ることができるので、他のファイルと一緒に(`git`に)保存する必要がありません。そのため、環境変数をコンフィグレーションや**設定**に使用することが一般的です。 |
|||
|
|||
また、**特定のプログラムの呼び出し**のための環境変数を、そのプログラムのみ、その実行中に限定して利用できるよう作成できます。 |
|||
|
|||
そのためには、プログラム起動コマンドと同じコマンドライン上の、起動コマンド直前で環境変数を作成してください。 |
|||
|
|||
<div class="termy"> |
|||
|
|||
```console |
|||
// Create an env var MY_NAME in line for this program call |
|||
$ MY_NAME="Wade Wilson" python main.py |
|||
|
|||
// Now it can read the environment variable |
|||
|
|||
Hello Wade Wilson from Python |
|||
|
|||
// The env var no longer exists afterwards |
|||
$ python main.py |
|||
|
|||
Hello World from Python |
|||
``` |
|||
|
|||
</div> |
|||
|
|||
/// tip |
|||
|
|||
詳しくは <a href="https://12factor.net/config" class="external-link" target="_blank">The Twelve-Factor App: Config</a> を参照してください。 |
|||
|
|||
/// |
|||
|
|||
## 型とバリデーション |
|||
|
|||
環境変数は**テキスト文字列**のみを扱うことができます。これは、環境変数がPython外部に存在し、他のプログラムやシステム全体(Linux、Windows、macOS間の互換性を含む)と連携する必要があるためです。 |
|||
|
|||
つまり、Pythonが環境変数から読み取る**あらゆる値**は **`str`型となり**、他の型への変換やバリデーションはコード内で行う必要があります。 |
|||
|
|||
環境変数を使用して**アプリケーション設定**を管理する方法については、[高度なユーザーガイド - Settings and Environment Variables](./advanced/settings.md){.internal-link target=_blank}で詳しく学べます。 |
|||
|
|||
## `PATH`環境変数 |
|||
|
|||
**`PATH`**という**特別な**環境変数があります。この環境変数は、OS(Linux、macOS、Windows)が実行するプログラムを発見するために使用されます。 |
|||
|
|||
`PATH`変数は、複数のディレクトリのパスから成る長い文字列です。このパスはLinuxやMacOSの場合は`:`で、Windowsの場合は`;`で区切られています。 |
|||
|
|||
例えば、`PATH`環境変数は次のような文字列かもしれません: |
|||
|
|||
//// tab | Linux, macOS |
|||
|
|||
```plaintext |
|||
/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin |
|||
``` |
|||
|
|||
これは、OSはプログラムを見つけるために以下のディレクトリを探す、ということを意味します: |
|||
|
|||
* `/usr/local/bin` |
|||
* `/usr/bin` |
|||
* `/bin` |
|||
* `/usr/sbin` |
|||
* `/sbin` |
|||
|
|||
//// |
|||
|
|||
//// tab | Windows |
|||
|
|||
```plaintext |
|||
C:\Program Files\Python312\Scripts;C:\Program Files\Python312;C:\Windows\System32 |
|||
``` |
|||
|
|||
これは、OSはプログラムを見つけるために以下のディレクトリを探す、ということを意味します: |
|||
|
|||
* `C:\Program Files\Python312\Scripts` |
|||
* `C:\Program Files\Python312` |
|||
* `C:\Windows\System32` |
|||
|
|||
//// |
|||
|
|||
ターミナル上で**コマンド**を入力すると、 OSはそのプログラムを見つけるために、`PATH`環境変数のリストに記載された**それぞれのディレクトリを探し**ます。 |
|||
|
|||
例えば、ターミナル上で`python`を入力すると、OSは`python`によって呼ばれるプログラムを見つけるために、そのリストの**先頭のディレクトリ**を最初に探します。 |
|||
|
|||
OSは、もしそのプログラムをそこで発見すれば**実行し**ますが、そうでなければリストの**他のディレクトリ**を探していきます。 |
|||
|
|||
### PythonのインストールとPATH環境変数の更新 |
|||
|
|||
Pythonのインストール時に`PATH`環境変数を更新したいか聞かれるかもしれません。 |
|||
|
|||
/// tab | Linux, macOS |
|||
|
|||
Pythonをインストールして、そのプログラムが`/opt/custompython/bin`というディレクトリに配置されたとします。 |
|||
|
|||
もし、`PATH`環境変数を更新するように答えると、`PATH`環境変数に`/opt/custompython/bin`が追加されます。 |
|||
|
|||
`PATH`環境変数は以下のように更新されるでしょう: |
|||
|
|||
``` plaintext |
|||
/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/opt/custompython/bin |
|||
``` |
|||
|
|||
このようにして、ターミナルで`python`と入力したときに、OSは`/opt/custompython/bin`(リストの末尾のディレクトリ)にあるPythonプログラムを見つけ、使用します。 |
|||
|
|||
/// |
|||
|
|||
/// tab | Windows |
|||
|
|||
Pythonをインストールして、そのプログラムが`C:\opt\custompython\bin`というディレクトリに配置されたとします。 |
|||
|
|||
もし、`PATH`環境変数を更新するように答えると、`PATH`環境変数に`C:\opt\custompython\bin`が追加されます。 |
|||
|
|||
`PATH`環境変数は以下のように更新されるでしょう: |
|||
|
|||
```plaintext |
|||
C:\Program Files\Python312\Scripts;C:\Program Files\Python312;C:\Windows\System32;C:\opt\custompython\bin |
|||
``` |
|||
|
|||
このようにして、ターミナルで`python`と入力したときに、OSは`C:\opt\custompython\bin\python`(リストの末尾のディレクトリ)にあるPythonプログラムを見つけ、使用します。 |
|||
|
|||
/// |
|||
|
|||
つまり、ターミナルで以下のコマンドを入力すると: |
|||
|
|||
<div class="termy"> |
|||
|
|||
``` console |
|||
$ python |
|||
``` |
|||
|
|||
</div> |
|||
|
|||
/// tab | Linux, macOS |
|||
|
|||
OSは`/opt/custompython/bin`にある`python`プログラムを**見つけ**て実行します。 |
|||
|
|||
これは、次のコマンドを入力した場合とほとんど同等です: |
|||
|
|||
<div class="termy"> |
|||
|
|||
```console |
|||
$ /opt/custompython/bin/python |
|||
``` |
|||
|
|||
</div> |
|||
|
|||
/// |
|||
|
|||
/// tab | Windows |
|||
|
|||
OSは`C:\opt\custompython\bin\python`にある`python`プログラムを**見つけ**て実行します。 |
|||
|
|||
これは、次のコマンドを入力した場合とほとんど同等です: |
|||
|
|||
<div class="termy"> |
|||
|
|||
```console |
|||
$ C:\opt\custompython\bin\python |
|||
``` |
|||
|
|||
</div> |
|||
|
|||
/// |
|||
|
|||
この情報は、[Virtual Environments](virtual-environments.md) について学ぶ際にも役立ちます。 |
|||
|
|||
## まとめ |
|||
|
|||
これで、**環境変数**とは何か、Pythonでどのように使用するかについて、基本的な理解が得られたはずです。 |
|||
|
|||
環境変数についての詳細は、<a href="https://en.wikipedia.org/wiki/Environment_variable" class="external-link" target="_blank">Wikipedia: Environment Variable</a> を参照してください。 |
|||
|
|||
環境変数の用途や適用方法が最初は直感的ではないかもしれませんが、開発中のさまざまなシナリオで繰り返し登場します。そのため、基本を知っておくことが重要です。 |
|||
|
|||
たとえば、この情報は次のセクションで扱う[Virtual Environments](virtual-environments.md)にも関連します。 |
@ -0,0 +1,99 @@ |
|||
# Асинхронное тестирование |
|||
|
|||
Вы уже видели как тестировать **FastAPI** приложение, используя имеющийся класс `TestClient`. К этому моменту вы видели только как писать тесты в синхронном стиле без использования `async` функций. |
|||
|
|||
Возможность использования асинхронных функций в ваших тестах может быть полезнa, когда, например, вы асинхронно обращаетесь к вашей базе данных. Представьте, что вы хотите отправить запросы в ваше FastAPI приложение, а затем при помощи асинхронной библиотеки для работы с базой данных удостовериться, что ваш бекэнд корректно записал данные в базу данных. |
|||
|
|||
Давайте рассмотрим, как мы можем это реализовать. |
|||
|
|||
## pytest.mark.anyio |
|||
|
|||
Если мы хотим вызывать асинхронные функции в наших тестах, то наши тестовые функции должны быть асинхронными. AnyIO предоставляет для этого отличный плагин, который позволяет нам указывать, какие тестовые функции должны вызываться асинхронно. |
|||
|
|||
## HTTPX |
|||
|
|||
Даже если **FastAPI** приложение использует обычные функции `def` вместо `async def`, это все равно `async` приложение 'под капотом'. |
|||
|
|||
Чтобы работать с асинхронным FastAPI приложением в ваших обычных тестовых функциях `def`, используя стандартный pytest, `TestClient` внутри себя делает некоторую магию. Но эта магия перестает работать, когда мы используем его внутри асинхронных функций. Запуская наши тесты асинхронно, мы больше не можем использовать `TestClient` внутри наших тестовых функций. |
|||
|
|||
`TestClient` основан на <a href="https://www.python-httpx.org" class="external-link" target="_blank">HTTPX</a>, и, к счастью, мы можем использовать его (`HTTPX`) напрямую для тестирования API. |
|||
|
|||
## Пример |
|||
|
|||
В качестве простого примера, давайте рассмотрим файловую структуру, схожую с описанной в [Большие приложения](../tutorial/bigger-applications.md){.internal-link target=_blank} и [Тестирование](../tutorial/testing.md){.internal-link target=_blank}: |
|||
|
|||
``` |
|||
. |
|||
├── app |
|||
│ ├── __init__.py |
|||
│ ├── main.py |
|||
│ └── test_main.py |
|||
``` |
|||
|
|||
Файл `main.py`: |
|||
|
|||
{* ../../docs_src/async_tests/main.py *} |
|||
|
|||
Файл `test_main.py` содержит тесты для `main.py`, теперь он может выглядеть так: |
|||
|
|||
{* ../../docs_src/async_tests/test_main.py *} |
|||
|
|||
## Запуск тестов |
|||
|
|||
Вы можете запустить свои тесты как обычно: |
|||
|
|||
<div class="termy"> |
|||
|
|||
```console |
|||
$ pytest |
|||
|
|||
---> 100% |
|||
``` |
|||
|
|||
</div> |
|||
|
|||
## Подробнее |
|||
|
|||
Маркер `@pytest.mark.anyio` говорит pytest, что тестовая функция должна быть вызвана асинхронно: |
|||
|
|||
{* ../../docs_src/async_tests/test_main.py hl[7] *} |
|||
|
|||
/// tip | Подсказка |
|||
|
|||
Обратите внимание, что тестовая функция теперь `async def` вместо простого `def`, как это было при использовании `TestClient`. |
|||
|
|||
/// |
|||
|
|||
Затем мы можем создать `AsyncClient` со ссылкой на приложение и посылать асинхронные запросы, используя `await`. |
|||
|
|||
{* ../../docs_src/async_tests/test_main.py hl[9:12] *} |
|||
|
|||
Это эквивалентно следующему: |
|||
|
|||
```Python |
|||
response = client.get('/') |
|||
``` |
|||
|
|||
...которое мы использовали для отправки наших запросов с `TestClient`. |
|||
|
|||
/// tip | Подсказка |
|||
|
|||
Обратите внимание, что мы используем async/await с `AsyncClient` - запрос асинхронный. |
|||
|
|||
/// |
|||
|
|||
/// warning | Внимание |
|||
|
|||
Если ваше приложение полагается на lifespan события, то `AsyncClient` не запустит эти события. Чтобы обеспечить их срабатывание используйте `LifespanManager` из <a href="https://github.com/florimondmanca/asgi-lifespan#usage" class="external-link" target="_blank">florimondmanca/asgi-lifespan</a>. |
|||
|
|||
/// |
|||
|
|||
## Вызов других асинхронных функций |
|||
|
|||
Теперь тестовая функция стала асинхронной, поэтому внутри нее вы можете вызывать также и другие `async` функции, не связанные с отправлением запросов в ваше FastAPI приложение. Как если бы вы вызывали их в любом другом месте вашего кода. |
|||
|
|||
/// tip | Подсказка |
|||
|
|||
Если вы столкнулись с `RuntimeError: Task attached to a different loop` при вызове асинхронных функций в ваших тестах (например, при использовании <a href="https://stackoverflow.com/questions/41584243/runtimeerror-task-attached-to-a-different-loop" class="external-link" target="_blank">MongoDB's MotorClient</a>), то не забывайте инициализировать объекты, которым нужен цикл событий (event loop), только внутри асинхронных функций, например, в `'@app.on_event("startup")` callback. |
|||
|
|||
/// |
@ -0,0 +1,556 @@ |
|||
# Большие приложения, в которых много файлов |
|||
|
|||
При построении приложения или веб-API нам редко удается поместить всё в один файл. |
|||
|
|||
**FastAPI** предоставляет удобный инструментарий, который позволяет нам структурировать приложение, сохраняя при этом всю необходимую гибкость. |
|||
|
|||
/// info | Примечание |
|||
|
|||
Если вы раньше использовали Flask, то это аналог шаблонов Flask (Flask's Blueprints). |
|||
|
|||
/// |
|||
|
|||
## Пример структуры приложения |
|||
|
|||
Давайте предположим, что наше приложение имеет следующую структуру: |
|||
|
|||
``` |
|||
. |
|||
├── app |
|||
│ ├── __init__.py |
|||
│ ├── main.py |
|||
│ ├── dependencies.py |
|||
│ └── routers |
|||
│ │ ├── __init__.py |
|||
│ │ ├── items.py |
|||
│ │ └── users.py |
|||
│ └── internal |
|||
│ ├── __init__.py |
|||
│ └── admin.py |
|||
``` |
|||
|
|||
/// tip | Подсказка |
|||
|
|||
Обратите внимание, что в каждом каталоге и подкаталоге имеется файл `__init__.py` |
|||
|
|||
Это как раз то, что позволяет импортировать код из одного файла в другой. |
|||
|
|||
Например, в файле `app/main.py` может быть следующая строка: |
|||
|
|||
``` |
|||
from app.routers import items |
|||
``` |
|||
|
|||
/// |
|||
|
|||
* Всё помещается в каталоге `app`. В нём также находится пустой файл `app/__init__.py`. Таким образом, `app` является "Python-пакетом" (коллекцией модулей Python). |
|||
* Он содержит файл `app/main.py`. Данный файл является частью пакета (т.е. находится внутри каталога, содержащего файл `__init__.py`), и, соответственно, он является модулем пакета: `app.main`. |
|||
* Он также содержит файл `app/dependencies.py`, который также, как и `app/main.py`, является модулем: `app.dependencies`. |
|||
* Здесь также находится подкаталог `app/routers/`, содержащий `__init__.py`. Он является суб-пакетом: `app.routers`. |
|||
* Файл `app/routers/items.py` находится внутри пакета `app/routers/`. Таким образом, он является суб-модулем: `app.routers.items`. |
|||
* Точно также `app/routers/users.py` является ещё одним суб-модулем: `app.routers.users`. |
|||
* Подкаталог `app/internal/`, содержащий файл `__init__.py`, является ещё одним суб-пакетом: `app.internal`. |
|||
* А файл `app/internal/admin.py` является ещё одним суб-модулем: `app.internal.admin`. |
|||
|
|||
<img src="/img/tutorial/bigger-applications/package.svg"> |
|||
|
|||
Та же самая файловая структура приложения, но с комментариями: |
|||
|
|||
``` |
|||
. |
|||
├── app # "app" пакет |
|||
│ ├── __init__.py # этот файл превращает "app" в "Python-пакет" |
|||
│ ├── main.py # модуль "main", напр.: import app.main |
|||
│ ├── dependencies.py # модуль "dependencies", напр.: import app.dependencies |
|||
│ └── routers # суб-пакет "routers" |
|||
│ │ ├── __init__.py # превращает "routers" в суб-пакет |
|||
│ │ ├── items.py # суб-модуль "items", напр.: import app.routers.items |
|||
│ │ └── users.py # суб-модуль "users", напр.: import app.routers.users |
|||
│ └── internal # суб-пакет "internal" |
|||
│ ├── __init__.py # превращает "internal" в суб-пакет |
|||
│ └── admin.py # суб-модуль "admin", напр.: import app.internal.admin |
|||
``` |
|||
|
|||
## `APIRouter` |
|||
|
|||
Давайте предположим, что для работы с пользователями используется отдельный файл (суб-модуль) `/app/routers/users.py`. |
|||
|
|||
Для лучшей организации приложения, вы хотите отделить операции пути, связанные с пользователями, от остального кода. |
|||
|
|||
Но так, чтобы эти операции по-прежнему оставались частью **FastAPI** приложения/веб-API (частью одного пакета) |
|||
|
|||
С помощью `APIRouter` вы можете создать *операции пути* (*эндпоинты*) для данного модуля. |
|||
|
|||
|
|||
### Импорт `APIRouter` |
|||
|
|||
Точно также, как и в случае с классом `FastAPI`, вам нужно импортировать и создать объект класса `APIRouter`. |
|||
|
|||
```Python hl_lines="1 3" title="app/routers/users.py" |
|||
{!../../docs_src/bigger_applications/app/routers/users.py!} |
|||
``` |
|||
|
|||
### Создание *эндпоинтов* с помощью `APIRouter` |
|||
|
|||
В дальнейшем используйте `APIRouter` для объявления *эндпоинтов*, точно также, как вы используете класс `FastAPI`: |
|||
|
|||
```Python hl_lines="6 11 16" title="app/routers/users.py" |
|||
{!../../docs_src/bigger_applications/app/routers/users.py!} |
|||
``` |
|||
|
|||
Вы можете думать об `APIRouter` как об "уменьшенной версии" класса FastAPI`. |
|||
|
|||
`APIRouter` поддерживает все те же самые опции. |
|||
|
|||
`APIRouter` поддерживает все те же самые параметры, такие как `parameters`, `responses`, `dependencies`, `tags`, и т. д. |
|||
|
|||
/// tip | Подсказка |
|||
|
|||
В данном примере, в качестве названия переменной используется `router`, но вы можете использовать любое другое имя. |
|||
|
|||
/// |
|||
|
|||
Мы собираемся подключить данный `APIRouter` к нашему основному приложению на `FastAPI`, но сначала давайте проверим зависимости и создадим ещё один модуль с `APIRouter`. |
|||
|
|||
## Зависимости |
|||
|
|||
Нам понадобятся некоторые зависимости, которые мы будем использовать в разных местах нашего приложения. |
|||
|
|||
Мы поместим их в отдельный модуль `dependencies` (`app/dependencies.py`). |
|||
|
|||
Теперь мы воспользуемся простой зависимостью, чтобы прочитать кастомизированный `X-Token` из заголовка: |
|||
|
|||
//// tab | Python 3.9+ |
|||
|
|||
```Python hl_lines="3 6-8" title="app/dependencies.py" |
|||
{!> ../../docs_src/bigger_applications/app_an_py39/dependencies.py!} |
|||
``` |
|||
|
|||
//// |
|||
|
|||
//// tab | Python 3.8+ |
|||
|
|||
```Python hl_lines="1 5-7" title="app/dependencies.py" |
|||
{!> ../../docs_src/bigger_applications/app_an/dependencies.py!} |
|||
``` |
|||
|
|||
//// |
|||
|
|||
//// tab | Python 3.8+ non-Annotated |
|||
|
|||
/// tip | Подсказка |
|||
|
|||
Мы рекомендуем использовать версию `Annotated`, когда это возможно. |
|||
|
|||
/// |
|||
|
|||
```Python hl_lines="1 4-6" title="app/dependencies.py" |
|||
{!> ../../docs_src/bigger_applications/app/dependencies.py!} |
|||
``` |
|||
|
|||
//// |
|||
|
|||
/// tip | Подсказка |
|||
|
|||
Для простоты мы воспользовались неким воображаемым заголовоком. |
|||
|
|||
В реальных случаях для получения наилучших результатов используйте интегрированные утилиты обеспечения безопасности [Security utilities](security/index.md){.internal-link target=_blank}. |
|||
|
|||
/// |
|||
|
|||
## Ещё один модуль с `APIRouter` |
|||
|
|||
Давайте также предположим, что у вас есть *эндпоинты*, отвечающие за обработку "items", и они находятся в модуле `app/routers/items.py`. |
|||
|
|||
У вас определены следующие *операции пути* (*эндпоинты*): |
|||
|
|||
* `/items/` |
|||
* `/items/{item_id}` |
|||
|
|||
Тут всё точно также, как и в ситуации с `app/routers/users.py`. |
|||
|
|||
Но теперь мы хотим поступить немного умнее и слегка упростить код. |
|||
|
|||
Мы знаем, что все *эндпоинты* данного модуля имеют некоторые общие свойства: |
|||
|
|||
* Префикс пути: `/items`. |
|||
* Теги: (один единственный тег: `items`). |
|||
* Дополнительные ответы (responses) |
|||
* Зависимости: использование созданной нами зависимости `X-token` |
|||
|
|||
Таким образом, вместо того чтобы добавлять все эти свойства в функцию каждого отдельного *эндпоинта*, |
|||
мы добавим их в `APIRouter`. |
|||
|
|||
```Python hl_lines="5-10 16 21" title="app/routers/items.py" |
|||
{!../../docs_src/bigger_applications/app/routers/items.py!} |
|||
``` |
|||
|
|||
Так как каждый *эндпоинт* начинается с символа `/`: |
|||
|
|||
```Python hl_lines="1" |
|||
@router.get("/{item_id}") |
|||
async def read_item(item_id: str): |
|||
... |
|||
``` |
|||
|
|||
...то префикс не должен заканчиваться символом `/`. |
|||
|
|||
В нашем случае префиксом является `/items`. |
|||
|
|||
Мы также можем добавить в наш маршрутизатор (router) список `тегов` (`tags`) и дополнительных `ответов` (`responses`), которые являются общими для каждого *эндпоинта*. |
|||
|
|||
И ещё мы можем добавить в наш маршрутизатор список `зависимостей`, которые должны вызываться при каждом обращении к *эндпоинтам*. |
|||
|
|||
/// tip | Подсказка |
|||
|
|||
Обратите внимание, что также, как и в случае с зависимостями в декораторах *эндпоинтов* ([dependencies in *path operation decorators*](dependencies/dependencies-in-path-operation-decorators.md){.internal-link target=_blank}), никакого значения в *функцию эндпоинта* передано не будет. |
|||
|
|||
/// |
|||
|
|||
В результате мы получим следующие эндпоинты: |
|||
|
|||
* `/items/` |
|||
* `/items/{item_id}` |
|||
|
|||
...как мы и планировали. |
|||
|
|||
* Они будут помечены тегами из заданного списка, в нашем случае это `"items"`. |
|||
* Эти теги особенно полезны для системы автоматической интерактивной документации (с использованием OpenAPI). |
|||
* Каждый из них будет включать предопределенные ответы `responses`. |
|||
* Каждый *эндпоинт* будет иметь список зависимостей (`dependencies`), исполняемых перед вызовом *эндпоинта*. |
|||
* Если вы определили зависимости в самой операции пути, **то она также будет выполнена**. |
|||
* Сначала выполняются зависимости маршрутизатора, затем вызываются зависимости, определенные в декораторе *эндпоинта* ([`dependencies` in the decorator](dependencies/dependencies-in-path-operation-decorators.md){.internal-link target=_blank}), и, наконец, обычные параметрические зависимости. |
|||
* Вы также можете добавить зависимости безопасности с областями видимости (`scopes`) [`Security` dependencies with `scopes`](../advanced/security/oauth2-scopes.md){.internal-link target=_blank}. |
|||
|
|||
/// tip | Подсказка |
|||
|
|||
Например, с помощью зависимостей в `APIRouter` мы можем потребовать аутентификации для доступа ко всей группе *эндпоинтов*, не указывая зависимости для каждой отдельной функции *эндпоинта*. |
|||
|
|||
/// |
|||
|
|||
/// check | Заметка |
|||
|
|||
Параметры `prefix`, `tags`, `responses` и `dependencies` относятся к функционалу **FastAPI**, помогающему избежать дублирования кода. |
|||
|
|||
/// |
|||
|
|||
### Импорт зависимостей |
|||
|
|||
Наш код находится в модуле `app.routers.items` (файл `app/routers/items.py`). |
|||
|
|||
И нам нужно вызвать функцию зависимости из модуля `app.dependencies` (файл `app/dependencies.py`). |
|||
|
|||
Мы используем операцию относительного импорта `..` для импорта зависимости: |
|||
|
|||
```Python hl_lines="3" title="app/routers/items.py" |
|||
{!../../docs_src/bigger_applications/app/routers/items.py!} |
|||
``` |
|||
|
|||
#### Как работает относительный импорт? |
|||
|
|||
/// tip | Подсказка |
|||
|
|||
Если вы прекрасно знаете, как работает импорт в Python, то переходите к следующему разделу. |
|||
|
|||
/// |
|||
|
|||
Одна точка `.`, как в данном примере: |
|||
|
|||
```Python |
|||
from .dependencies import get_token_header |
|||
``` |
|||
означает: |
|||
|
|||
* Начните с пакета, в котором находится данный модуль (файл `app/routers/items.py` расположен в каталоге `app/routers/`)... |
|||
* ... найдите модуль `dependencies` (файл `app/routers/dependencies.py`)... |
|||
* ... и импортируйте из него функцию `get_token_header`. |
|||
|
|||
К сожалению, такого файла не существует, и наши зависимости находятся в файле `app/dependencies.py`. |
|||
|
|||
Вспомните, как выглядит файловая структура нашего приложения: |
|||
|
|||
<img src="/img/tutorial/bigger-applications/package.svg"> |
|||
|
|||
--- |
|||
|
|||
Две точки `..`, как в данном примере: |
|||
|
|||
```Python |
|||
from ..dependencies import get_token_header |
|||
``` |
|||
|
|||
означают: |
|||
|
|||
* Начните с пакета, в котором находится данный модуль (файл `app/routers/items.py` находится в каталоге `app/routers/`)... |
|||
* ... перейдите в родительский пакет (каталог `app/`)... |
|||
* ... найдите в нём модуль `dependencies` (файл `app/dependencies.py`)... |
|||
* ... и импортируйте из него функцию `get_token_header`. |
|||
|
|||
Это работает верно! 🎉 |
|||
|
|||
--- |
|||
|
|||
Аналогично, если бы мы использовали три точки `...`, как здесь: |
|||
|
|||
```Python |
|||
from ...dependencies import get_token_header |
|||
``` |
|||
|
|||
то это бы означало: |
|||
|
|||
* Начните с пакета, в котором находится данный модуль (файл `app/routers/items.py` находится в каталоге `app/routers/`)... |
|||
* ... перейдите в родительский пакет (каталог `app/`)... |
|||
* ... затем перейдите в родительский пакет текущего пакета (такого пакета не существует, `app` находится на самом верхнем уровне 😱)... |
|||
* ... найдите в нём модуль `dependencies` (файл `app/dependencies.py`)... |
|||
* ... и импортируйте из него функцию `get_token_header`. |
|||
|
|||
Это будет относиться к некоторому пакету, находящемуся на один уровень выше чем `app/` и содержащему свой собственный файл `__init__.py`. Но ничего такого у нас нет. Поэтому это приведет к ошибке в нашем примере. 🚨 |
|||
|
|||
Теперь вы знаете, как работает импорт в Python, и сможете использовать относительное импортирование в своих собственных приложениях любого уровня сложности. 🤓 |
|||
|
|||
### Добавление пользовательских тегов (`tags`), ответов (`responses`) и зависимостей (`dependencies`) |
|||
|
|||
Мы не будем добавлять префикс `/items` и список тегов `tags=["items"]` для каждого *эндпоинта*, т.к. мы уже их добавили с помощью `APIRouter`. |
|||
|
|||
Но помимо этого мы можем добавить новые теги для каждого отдельного *эндпоинта*, а также некоторые дополнительные ответы (`responses`), характерные для данного *эндпоинта*: |
|||
|
|||
```Python hl_lines="30-31" title="app/routers/items.py" |
|||
{!../../docs_src/bigger_applications/app/routers/items.py!} |
|||
``` |
|||
|
|||
/// tip | Подсказка |
|||
|
|||
Последний *эндпоинт* будет иметь следующую комбинацию тегов: `["items", "custom"]`. |
|||
|
|||
А также в его документации будут содержаться оба ответа: один для `404` и другой для `403`. |
|||
|
|||
/// |
|||
|
|||
## Модуль main в `FastAPI` |
|||
|
|||
Теперь давайте посмотрим на модуль `app/main.py`. |
|||
|
|||
Именно сюда вы импортируете и именно здесь вы используете класс `FastAPI`. |
|||
|
|||
Это основной файл вашего приложения, который объединяет всё в одно целое. |
|||
|
|||
И теперь, когда большая часть логики приложения разделена на отдельные модули, основной файл `app/main.py` будет достаточно простым. |
|||
|
|||
### Импорт `FastAPI` |
|||
|
|||
Вы импортируете и создаете класс `FastAPI` как обычно. |
|||
|
|||
Мы даже можем объявить глобальные зависимости [global dependencies](dependencies/global-dependencies.md){.internal-link target=_blank}, которые будут объединены с зависимостями для каждого отдельного маршрутизатора: |
|||
|
|||
```Python hl_lines="1 3 7" title="app/main.py" |
|||
{!../../docs_src/bigger_applications/app/main.py!} |
|||
``` |
|||
|
|||
### Импорт `APIRouter` |
|||
|
|||
Теперь мы импортируем другие суб-модули, содержащие `APIRouter`: |
|||
|
|||
```Python hl_lines="4-5" title="app/main.py" |
|||
{!../../docs_src/bigger_applications/app/main.py!} |
|||
``` |
|||
|
|||
Так как файлы `app/routers/users.py` и `app/routers/items.py` являются суб-модулями одного и того же Python-пакета `app`, то мы сможем их импортировать, воспользовавшись операцией относительного импорта `.`. |
|||
|
|||
### Как работает импорт? |
|||
|
|||
Данная строка кода: |
|||
|
|||
```Python |
|||
from .routers import items, users |
|||
``` |
|||
|
|||
означает: |
|||
|
|||
* Начните с пакета, в котором содержится данный модуль (файл `app/main.py` содержится в каталоге `app/`)... |
|||
* ... найдите суб-пакет `routers` (каталог `app/routers/`)... |
|||
* ... и из него импортируйте суб-модули `items` (файл `app/routers/items.py`) и `users` (файл `app/routers/users.py`)... |
|||
|
|||
В модуле `items` содержится переменная `router` (`items.router`), та самая, которую мы создали в файле `app/routers/items.py`, она является объектом класса `APIRouter`. |
|||
|
|||
И затем мы сделаем то же самое для модуля `users`. |
|||
|
|||
Мы также могли бы импортировать и другим методом: |
|||
|
|||
```Python |
|||
from app.routers import items, users |
|||
``` |
|||
|
|||
/// info | Примечание |
|||
|
|||
Первая версия является примером относительного импорта: |
|||
|
|||
```Python |
|||
from .routers import items, users |
|||
``` |
|||
|
|||
Вторая версия является примером абсолютного импорта: |
|||
|
|||
```Python |
|||
from app.routers import items, users |
|||
``` |
|||
|
|||
Узнать больше о пакетах и модулях в Python вы можете из <a href="https://docs.python.org/3/tutorial/modules.html" class="external-link" target="_blank">официальной документации Python о модулях</a> |
|||
|
|||
/// |
|||
|
|||
### Избегайте конфликтов имен |
|||
|
|||
Вместо того чтобы импортировать только переменную `router`, мы импортируем непосредственно суб-модуль `items`. |
|||
|
|||
Мы делаем это потому, что у нас есть ещё одна переменная `router` в суб-модуле `users`. |
|||
|
|||
Если бы мы импортировали их одну за другой, как показано в примере: |
|||
|
|||
```Python |
|||
from .routers.items import router |
|||
from .routers.users import router |
|||
``` |
|||
|
|||
то переменная `router` из `users` переписал бы переменную `router` из `items`, и у нас не было бы возможности использовать их одновременно. |
|||
|
|||
Поэтому, для того чтобы использовать обе эти переменные в одном файле, мы импортировали соответствующие суб-модули: |
|||
|
|||
```Python hl_lines="5" title="app/main.py" |
|||
{!../../docs_src/bigger_applications/app/main.py!} |
|||
``` |
|||
|
|||
### Подключение маршрутизаторов (`APIRouter`) для `users` и для `items` |
|||
|
|||
Давайте подключим маршрутизаторы (`router`) из суб-модулей `users` и `items`: |
|||
|
|||
```Python hl_lines="10-11" title="app/main.py" |
|||
{!../../docs_src/bigger_applications/app/main.py!} |
|||
``` |
|||
|
|||
/// info | Примечание |
|||
|
|||
`users.router` содержит `APIRouter` из файла `app/routers/users.py`. |
|||
|
|||
А `items.router` содержит `APIRouter` из файла `app/routers/items.py`. |
|||
|
|||
/// |
|||
|
|||
С помощью `app.include_router()` мы можем добавить каждый из маршрутизаторов (`APIRouter`) в основное приложение `FastAPI`. |
|||
|
|||
Он подключит все маршруты заданного маршрутизатора к нашему приложению. |
|||
|
|||
/// note | Технические детали |
|||
|
|||
Фактически, внутри он создаст все *операции пути* для каждой операции пути объявленной в `APIRouter`. |
|||
|
|||
И под капотом всё будет работать так, как будто бы мы имеем дело с одним файлом приложения. |
|||
|
|||
/// |
|||
|
|||
/// check | Заметка |
|||
|
|||
При подключении маршрутизаторов не стоит беспокоиться о производительности. |
|||
|
|||
Операция подключения займёт микросекунды и понадобится только при запуске приложения. |
|||
|
|||
Таким образом, это не повлияет на производительность. ⚡ |
|||
|
|||
/// |
|||
|
|||
### Подключение `APIRouter` с пользовательскими префиксом (`prefix`), тегами (`tags`), ответами (`responses`), и зависимостями (`dependencies`) |
|||
|
|||
Теперь давайте представим, что ваша организация передала вам файл `app/internal/admin.py`. |
|||
|
|||
Он содержит `APIRouter` с некоторыми *эндпоитами* администрирования, которые ваша организация использует для нескольких проектов. |
|||
|
|||
В данном примере это сделать очень просто. Но давайте предположим, что поскольку файл используется для нескольких проектов, |
|||
то мы не можем модифицировать его, добавляя префиксы (`prefix`), зависимости (`dependencies`), теги (`tags`), и т.д. непосредственно в `APIRouter`: |
|||
|
|||
```Python hl_lines="3" title="app/internal/admin.py" |
|||
{!../../docs_src/bigger_applications/app/internal/admin.py!} |
|||
``` |
|||
|
|||
Но, несмотря на это, мы хотим использовать кастомный префикс (`prefix`) для подключенного маршрутизатора (`APIRouter`), в результате чего, каждая *операция пути* будет начинаться с `/admin`. Также мы хотим защитить наш маршрутизатор с помощью зависимостей, созданных для нашего проекта. И ещё мы хотим включить теги (`tags`) и ответы (`responses`). |
|||
|
|||
Мы можем применить все вышеперечисленные настройки, не изменяя начальный `APIRouter`. Нам всего лишь нужно передать нужные параметры в `app.include_router()`. |
|||
|
|||
```Python hl_lines="14-17" title="app/main.py" |
|||
{!../../docs_src/bigger_applications/app/main.py!} |
|||
``` |
|||
|
|||
Таким образом, оригинальный `APIRouter` не будет модифицирован, и мы сможем использовать файл `app/internal/admin.py` сразу в нескольких проектах организации. |
|||
|
|||
В результате, в нашем приложении каждый *эндпоинт* модуля `admin` будет иметь: |
|||
|
|||
* Префикс `/admin`. |
|||
* Тег `admin`. |
|||
* Зависимость `get_token_header`. |
|||
* Ответ `418`. 🍵 |
|||
|
|||
Это будет иметь место исключительно для `APIRouter` в нашем приложении, и не затронет любой другой код, использующий его. |
|||
|
|||
Например, другие проекты, могут использовать тот же самый `APIRouter` с другими методами аутентификации. |
|||
|
|||
### Подключение отдельного *эндпоинта* |
|||
|
|||
Мы также можем добавить *эндпоинт* непосредственно в основное приложение `FastAPI`. |
|||
|
|||
Здесь мы это делаем ... просто, чтобы показать, что это возможно 🤷: |
|||
|
|||
```Python hl_lines="21-23" title="app/main.py" |
|||
{!../../docs_src/bigger_applications/app/main.py!} |
|||
``` |
|||
|
|||
и это будет работать корректно вместе с другими *эндпоинтами*, добавленными с помощью `app.include_router()`. |
|||
|
|||
/// info | Сложные технические детали |
|||
|
|||
**Примечание**: это сложная техническая деталь, которую, скорее всего, **вы можете пропустить**. |
|||
|
|||
--- |
|||
|
|||
Маршрутизаторы (`APIRouter`) не "монтируются" по-отдельности и не изолируются от остального приложения. |
|||
|
|||
Это происходит потому, что нужно включить их *эндпоинты* в OpenAPI схему и в интерфейс пользователя. |
|||
|
|||
В силу того, что мы не можем их изолировать и "примонтировать" независимо от остальных, *эндпоинты* клонируются (пересоздаются) и не подключаются напрямую. |
|||
|
|||
/// |
|||
|
|||
## Проверка автоматической документации API |
|||
|
|||
Теперь запустите приложение: |
|||
|
|||
<div class="termy"> |
|||
|
|||
```console |
|||
$ fastapi dev app/main.py |
|||
|
|||
<span style="color: green;">INFO</span>: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) |
|||
``` |
|||
|
|||
</div> |
|||
|
|||
Откройте документацию по адресу <a href="http://127.0.0.1:8000/docs" class="external-link" target="_blank">http://127.0.0.1:8000/docs</a>. |
|||
|
|||
Вы увидите автоматическую API документацию. Она включает в себя маршруты из суб-модулей, используя верные маршруты, префиксы и теги: |
|||
|
|||
<img src="/img/tutorial/bigger-applications/image01.png"> |
|||
|
|||
## Подключение существующего маршрута через новый префикс (`prefix`) |
|||
|
|||
Вы можете использовать `.include_router()` несколько раз с одним и тем же маршрутом, применив различные префиксы. |
|||
|
|||
Это может быть полезным, если нужно предоставить доступ к одному и тому же API через различные префиксы, например, `/api/v1` и `/api/latest`. |
|||
|
|||
Это продвинутый способ, который вам может и не пригодится. Мы приводим его на случай, если вдруг вам это понадобится. |
|||
|
|||
## Включение одного маршрутизатора (`APIRouter`) в другой |
|||
|
|||
Точно так же, как вы включаете `APIRouter` в приложение `FastAPI`, вы можете включить `APIRouter` в другой `APIRouter`: |
|||
|
|||
```Python |
|||
router.include_router(other_router) |
|||
``` |
|||
|
|||
Удостоверьтесь, что вы сделали это до того, как подключить маршрутизатор (`router`) к вашему `FastAPI` приложению, и *эндпоинты* маршрутизатора `other_router` были также подключены. |
@ -0,0 +1,401 @@ |
|||
import logging |
|||
import secrets |
|||
import subprocess |
|||
import time |
|||
from collections import Counter |
|||
from datetime import datetime, timedelta, timezone |
|||
from pathlib import Path |
|||
from typing import Any, Container, Union |
|||
|
|||
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" |
|||
questions_category_id = "MDE4OkRpc2N1c3Npb25DYXRlZ29yeTMyMDAxNDM0" |
|||
|
|||
discussions_query = """ |
|||
query Q($after: String, $category_id: ID) { |
|||
repository(name: "fastapi", owner: "fastapi") { |
|||
discussions(first: 100, after: $after, categoryId: $category_id) { |
|||
edges { |
|||
cursor |
|||
node { |
|||
number |
|||
author { |
|||
login |
|||
avatarUrl |
|||
url |
|||
} |
|||
createdAt |
|||
comments(first: 50) { |
|||
totalCount |
|||
nodes { |
|||
createdAt |
|||
author { |
|||
login |
|||
avatarUrl |
|||
url |
|||
} |
|||
isAnswer |
|||
replies(first: 10) { |
|||
totalCount |
|||
nodes { |
|||
createdAt |
|||
author { |
|||
login |
|||
avatarUrl |
|||
url |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
""" |
|||
|
|||
|
|||
class Author(BaseModel): |
|||
login: str |
|||
avatarUrl: str | None = None |
|||
url: str | None = None |
|||
|
|||
|
|||
class CommentsNode(BaseModel): |
|||
createdAt: datetime |
|||
author: Union[Author, None] = None |
|||
|
|||
|
|||
class Replies(BaseModel): |
|||
totalCount: int |
|||
nodes: list[CommentsNode] |
|||
|
|||
|
|||
class DiscussionsCommentsNode(CommentsNode): |
|||
replies: Replies |
|||
|
|||
|
|||
class DiscussionsComments(BaseModel): |
|||
totalCount: int |
|||
nodes: list[DiscussionsCommentsNode] |
|||
|
|||
|
|||
class DiscussionsNode(BaseModel): |
|||
number: int |
|||
author: Union[Author, None] = None |
|||
title: str | None = None |
|||
createdAt: datetime |
|||
comments: DiscussionsComments |
|||
|
|||
|
|||
class DiscussionsEdge(BaseModel): |
|||
cursor: str |
|||
node: DiscussionsNode |
|||
|
|||
|
|||
class Discussions(BaseModel): |
|||
edges: list[DiscussionsEdge] |
|||
|
|||
|
|||
class DiscussionsRepository(BaseModel): |
|||
discussions: Discussions |
|||
|
|||
|
|||
class DiscussionsResponseData(BaseModel): |
|||
repository: DiscussionsRepository |
|||
|
|||
|
|||
class DiscussionsResponse(BaseModel): |
|||
data: DiscussionsResponseData |
|||
|
|||
|
|||
class Settings(BaseSettings): |
|||
github_token: SecretStr |
|||
github_repository: str |
|||
httpx_timeout: int = 30 |
|||
|
|||
|
|||
def get_graphql_response( |
|||
*, |
|||
settings: Settings, |
|||
query: str, |
|||
after: Union[str, None] = None, |
|||
category_id: Union[str, None] = None, |
|||
) -> dict[str, Any]: |
|||
headers = {"Authorization": f"token {settings.github_token.get_secret_value()}"} |
|||
variables = {"after": after, "category_id": category_id} |
|||
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}, category_id: {category_id}" |
|||
) |
|||
logging.error(response.text) |
|||
raise RuntimeError(response.text) |
|||
data = response.json() |
|||
if "errors" in data: |
|||
logging.error(f"Errors in response, after: {after}, category_id: {category_id}") |
|||
logging.error(data["errors"]) |
|||
logging.error(response.text) |
|||
raise RuntimeError(response.text) |
|||
return data |
|||
|
|||
|
|||
def get_graphql_question_discussion_edges( |
|||
*, |
|||
settings: Settings, |
|||
after: Union[str, None] = None, |
|||
) -> list[DiscussionsEdge]: |
|||
data = get_graphql_response( |
|||
settings=settings, |
|||
query=discussions_query, |
|||
after=after, |
|||
category_id=questions_category_id, |
|||
) |
|||
graphql_response = DiscussionsResponse.model_validate(data) |
|||
return graphql_response.data.repository.discussions.edges |
|||
|
|||
|
|||
class DiscussionExpertsResults(BaseModel): |
|||
commenters: Counter[str] |
|||
last_month_commenters: Counter[str] |
|||
three_months_commenters: Counter[str] |
|||
six_months_commenters: Counter[str] |
|||
one_year_commenters: Counter[str] |
|||
authors: dict[str, Author] |
|||
|
|||
|
|||
def get_discussion_nodes(settings: Settings) -> list[DiscussionsNode]: |
|||
discussion_nodes: list[DiscussionsNode] = [] |
|||
discussion_edges = get_graphql_question_discussion_edges(settings=settings) |
|||
|
|||
while discussion_edges: |
|||
for discussion_edge in discussion_edges: |
|||
discussion_nodes.append(discussion_edge.node) |
|||
last_edge = discussion_edges[-1] |
|||
# Handle GitHub secondary rate limits, requests per minute |
|||
time.sleep(5) |
|||
discussion_edges = get_graphql_question_discussion_edges( |
|||
settings=settings, after=last_edge.cursor |
|||
) |
|||
return discussion_nodes |
|||
|
|||
|
|||
def get_discussions_experts( |
|||
discussion_nodes: list[DiscussionsNode], |
|||
) -> DiscussionExpertsResults: |
|||
commenters = Counter[str]() |
|||
last_month_commenters = Counter[str]() |
|||
three_months_commenters = Counter[str]() |
|||
six_months_commenters = Counter[str]() |
|||
one_year_commenters = Counter[str]() |
|||
authors: dict[str, Author] = {} |
|||
|
|||
now = datetime.now(tz=timezone.utc) |
|||
one_month_ago = now - timedelta(days=30) |
|||
three_months_ago = now - timedelta(days=90) |
|||
six_months_ago = now - timedelta(days=180) |
|||
one_year_ago = now - timedelta(days=365) |
|||
|
|||
for discussion in discussion_nodes: |
|||
discussion_author_name = None |
|||
if discussion.author: |
|||
authors[discussion.author.login] = discussion.author |
|||
discussion_author_name = discussion.author.login |
|||
discussion_commentors: dict[str, datetime] = {} |
|||
for comment in discussion.comments.nodes: |
|||
if comment.author: |
|||
authors[comment.author.login] = comment.author |
|||
if comment.author.login != discussion_author_name: |
|||
author_time = discussion_commentors.get( |
|||
comment.author.login, comment.createdAt |
|||
) |
|||
discussion_commentors[comment.author.login] = max( |
|||
author_time, comment.createdAt |
|||
) |
|||
for reply in comment.replies.nodes: |
|||
if reply.author: |
|||
authors[reply.author.login] = reply.author |
|||
if reply.author.login != discussion_author_name: |
|||
author_time = discussion_commentors.get( |
|||
reply.author.login, reply.createdAt |
|||
) |
|||
discussion_commentors[reply.author.login] = max( |
|||
author_time, reply.createdAt |
|||
) |
|||
for author_name, author_time in discussion_commentors.items(): |
|||
commenters[author_name] += 1 |
|||
if author_time > one_month_ago: |
|||
last_month_commenters[author_name] += 1 |
|||
if author_time > three_months_ago: |
|||
three_months_commenters[author_name] += 1 |
|||
if author_time > six_months_ago: |
|||
six_months_commenters[author_name] += 1 |
|||
if author_time > one_year_ago: |
|||
one_year_commenters[author_name] += 1 |
|||
discussion_experts_results = DiscussionExpertsResults( |
|||
authors=authors, |
|||
commenters=commenters, |
|||
last_month_commenters=last_month_commenters, |
|||
three_months_commenters=three_months_commenters, |
|||
six_months_commenters=six_months_commenters, |
|||
one_year_commenters=one_year_commenters, |
|||
) |
|||
return discussion_experts_results |
|||
|
|||
|
|||
def get_top_users( |
|||
*, |
|||
counter: Counter[str], |
|||
authors: dict[str, Author], |
|||
skip_users: Container[str], |
|||
min_count: int = 2, |
|||
) -> list[dict[str, Any]]: |
|||
users: list[dict[str, Any]] = [] |
|||
for commenter, count in counter.most_common(50): |
|||
if commenter in skip_users: |
|||
continue |
|||
if count >= min_count: |
|||
author = authors[commenter] |
|||
users.append( |
|||
{ |
|||
"login": commenter, |
|||
"count": count, |
|||
"avatarUrl": author.avatarUrl, |
|||
"url": author.url, |
|||
} |
|||
) |
|||
return users |
|||
|
|||
|
|||
def get_users_to_write( |
|||
*, |
|||
counter: Counter[str], |
|||
authors: dict[str, Author], |
|||
min_count: int = 2, |
|||
) -> list[dict[str, Any]]: |
|||
users: dict[str, Any] = {} |
|||
users_list: list[dict[str, Any]] = [] |
|||
for user, count in counter.most_common(60): |
|||
if count >= min_count: |
|||
author = authors[user] |
|||
user_data = { |
|||
"login": user, |
|||
"count": count, |
|||
"avatarUrl": author.avatarUrl, |
|||
"url": author.url, |
|||
} |
|||
users[user] = user_data |
|||
users_list.append(user_data) |
|||
return users_list |
|||
|
|||
|
|||
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) |
|||
|
|||
discussion_nodes = get_discussion_nodes(settings=settings) |
|||
experts_results = get_discussions_experts(discussion_nodes=discussion_nodes) |
|||
|
|||
authors = experts_results.authors |
|||
maintainers_logins = {"tiangolo"} |
|||
maintainers = [] |
|||
for login in maintainers_logins: |
|||
user = authors[login] |
|||
maintainers.append( |
|||
{ |
|||
"login": login, |
|||
"answers": experts_results.commenters[login], |
|||
"avatarUrl": user.avatarUrl, |
|||
"url": user.url, |
|||
} |
|||
) |
|||
|
|||
experts = get_users_to_write( |
|||
counter=experts_results.commenters, |
|||
authors=authors, |
|||
) |
|||
last_month_experts = get_users_to_write( |
|||
counter=experts_results.last_month_commenters, |
|||
authors=authors, |
|||
) |
|||
three_months_experts = get_users_to_write( |
|||
counter=experts_results.three_months_commenters, |
|||
authors=authors, |
|||
) |
|||
six_months_experts = get_users_to_write( |
|||
counter=experts_results.six_months_commenters, |
|||
authors=authors, |
|||
) |
|||
one_year_experts = get_users_to_write( |
|||
counter=experts_results.one_year_commenters, |
|||
authors=authors, |
|||
) |
|||
|
|||
people = { |
|||
"maintainers": maintainers, |
|||
"experts": experts, |
|||
"last_month_experts": last_month_experts, |
|||
"three_months_experts": three_months_experts, |
|||
"six_months_experts": six_months_experts, |
|||
"one_year_experts": one_year_experts, |
|||
} |
|||
|
|||
# For local development |
|||
# people_path = Path("../docs/en/data/people.yml") |
|||
people_path = Path("./docs/en/data/people.yml") |
|||
|
|||
updated = update_content(content_path=people_path, new_content=people) |
|||
|
|||
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", "[email protected]"], check=True |
|||
) |
|||
branch_name = f"fastapi-people-experts-{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(people_path)], check=True) |
|||
logging.info("Committing updated file") |
|||
message = "👥 Update FastAPI People - Experts" |
|||
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() |
Loading…
Reference in new issue