|
1 | 1 | from datetime import UTC, datetime, timedelta
|
2 |
| -from email.parser import HeaderParser |
3 |
| -from io import StringIO |
| 2 | +from typing import TypedDict |
4 | 3 |
|
5 | 4 | from discord import Colour, Embed
|
6 | 5 | from discord.ext.commands import Cog, Context, command
|
7 |
| -from pydis_core.utils.caching import AsyncCache |
8 | 6 |
|
9 | 7 | from bot.bot import Bot
|
10 |
| -from bot.constants import Keys |
11 | 8 | from bot.log import get_logger
|
12 | 9 |
|
13 | 10 | log = get_logger(__name__)
|
14 | 11 |
|
15 | 12 | ICON_URL = "https://www.python.org/static/opengraph-icon-200x200.png"
|
16 |
| -BASE_PEP_URL = "https://peps.python.org/pep-" |
17 |
| -PEPS_LISTING_API_URL = "https://api.github.com/repos/python/peps/contents/peps?ref=main" |
| 13 | +PEP_API_URL = "https://peps.python.org/api/peps.json" |
18 | 14 |
|
19 |
| -pep_cache = AsyncCache() |
| 15 | +class PEPInfo(TypedDict): |
| 16 | + """ |
| 17 | + Useful subset of the PEP API response. |
20 | 18 |
|
21 |
| -GITHUB_API_HEADERS = {} |
22 |
| -if Keys.github: |
23 |
| - GITHUB_API_HEADERS["Authorization"] = f"token {Keys.github}" |
| 19 | + Full structure documented at https://peps.python.org/api/ |
| 20 | + """ |
| 21 | + |
| 22 | + number: int |
| 23 | + title: str |
| 24 | + url: str |
| 25 | + status: str |
| 26 | + python_version: str | None |
| 27 | + created: str |
| 28 | + type: str |
24 | 29 |
|
25 | 30 |
|
26 | 31 | class PythonEnhancementProposals(Cog):
|
27 | 32 | """Cog for displaying information about PEPs."""
|
28 | 33 |
|
29 | 34 | def __init__(self, bot: Bot):
|
30 | 35 | self.bot = bot
|
31 |
| - self.peps: dict[int, str] = {} |
32 |
| - # Ensure peps are refreshed the first time this is checked |
33 |
| - self.last_refreshed_peps: datetime = datetime.min.replace(tzinfo=UTC) |
34 |
| - |
35 |
| - async def refresh_peps_urls(self) -> None: |
36 |
| - """Refresh PEP URLs listing in every 3 hours.""" |
37 |
| - # Wait until HTTP client is available |
38 |
| - await self.bot.wait_until_ready() |
39 |
| - log.trace("Started refreshing PEP URLs.") |
| 36 | + self.peps: dict[int, PEPInfo] = {} |
| 37 | + self.last_refreshed_peps: datetime | None = None |
| 38 | + |
| 39 | + async def refresh_pep_data(self) -> None: |
| 40 | + """Refresh PEP data.""" |
| 41 | + # Putting this first should prevent any race conditions |
40 | 42 | self.last_refreshed_peps = datetime.now(tz=UTC)
|
41 | 43 |
|
42 |
| - async with self.bot.http_session.get( |
43 |
| - PEPS_LISTING_API_URL, |
44 |
| - headers=GITHUB_API_HEADERS |
45 |
| - ) as resp: |
| 44 | + log.trace("Started refreshing PEP data.") |
| 45 | + async with self.bot.http_session.get(PEP_API_URL) as resp: |
46 | 46 | if resp.status != 200:
|
47 |
| - log.warning(f"Fetching PEP URLs from GitHub API failed with code {resp.status}") |
| 47 | + log.warning( |
| 48 | + f"Fetching PEP data from PEP API failed with code {resp.status}" |
| 49 | + ) |
48 | 50 | return
|
49 |
| - |
50 | 51 | listing = await resp.json()
|
51 | 52 |
|
52 |
| - log.trace("Got PEP URLs listing from GitHub API") |
53 |
| - |
54 |
| - for file in listing: |
55 |
| - name = file["name"] |
56 |
| - if name.startswith("pep-") and name.endswith((".rst", ".txt")): |
57 |
| - pep_number = name.replace("pep-", "").split(".")[0] |
58 |
| - self.peps[int(pep_number)] = file["download_url"] |
59 |
| - |
60 |
| - log.info("Successfully refreshed PEP URLs listing.") |
61 |
| - |
62 |
| - @staticmethod |
63 |
| - def get_pep_zero_embed() -> Embed: |
64 |
| - """Get information embed about PEP 0.""" |
65 |
| - pep_embed = Embed( |
66 |
| - title="**PEP 0 - Index of Python Enhancement Proposals (PEPs)**", |
67 |
| - url="https://peps.python.org/" |
68 |
| - ) |
69 |
| - pep_embed.set_thumbnail(url=ICON_URL) |
70 |
| - pep_embed.add_field(name="Status", value="Active") |
71 |
| - pep_embed.add_field(name="Created", value="13-Jul-2000") |
72 |
| - pep_embed.add_field(name="Type", value="Informational") |
| 53 | + for pep_num, pep_info in listing.items(): |
| 54 | + self.peps[int(pep_num)] = pep_info |
73 | 55 |
|
74 |
| - return pep_embed |
75 |
| - |
76 |
| - async def validate_pep_number(self, pep_nr: int) -> Embed | None: |
77 |
| - """Validate is PEP number valid. When it isn't, return error embed, otherwise None.""" |
78 |
| - if ( |
79 |
| - pep_nr not in self.peps |
80 |
| - and (self.last_refreshed_peps + timedelta(minutes=30)) <= datetime.now(tz=UTC) |
81 |
| - and len(str(pep_nr)) < 5 |
82 |
| - ): |
83 |
| - await self.refresh_peps_urls() |
84 |
| - |
85 |
| - if pep_nr not in self.peps: |
86 |
| - log.trace(f"PEP {pep_nr} was not found") |
87 |
| - return Embed( |
88 |
| - title="PEP not found", |
89 |
| - description=f"PEP {pep_nr} does not exist.", |
90 |
| - colour=Colour.red() |
91 |
| - ) |
| 56 | + log.info("Successfully refreshed PEP data.") |
92 | 57 |
|
93 |
| - return None |
94 |
| - |
95 |
| - def generate_pep_embed(self, pep_header: dict, pep_nr: int) -> Embed: |
96 |
| - """Generate PEP embed based on PEP headers data.""" |
97 |
| - # the parsed header can be wrapped to multiple lines, so we need to make sure that is removed |
98 |
| - # for an example of a pep with this issue, see pep 500 |
99 |
| - title = " ".join(pep_header["Title"].split()) |
100 |
| - # Assemble the embed |
101 |
| - pep_embed = Embed( |
102 |
| - title=f"**PEP {pep_nr} - {title}**", |
103 |
| - url=f"{BASE_PEP_URL}{pep_nr:04}", |
| 58 | + def generate_pep_embed(self, pep: PEPInfo) -> Embed: |
| 59 | + """Generate PEP embed.""" |
| 60 | + embed = Embed( |
| 61 | + title=f"**PEP {pep['number']} - {pep['title']}**", |
| 62 | + url=pep["url"], |
104 | 63 | )
|
| 64 | + embed.set_thumbnail(url=ICON_URL) |
105 | 65 |
|
106 |
| - pep_embed.set_thumbnail(url=ICON_URL) |
| 66 | + fields_to_check = ("status", "python_version", "created", "type") |
| 67 | + for field_name in fields_to_check: |
| 68 | + if field_value := pep.get(field_name): |
| 69 | + field_name = field_name.replace("_", " ").title() |
| 70 | + embed.add_field(name=field_name, value=field_value) |
107 | 71 |
|
108 |
| - # Add the interesting information |
109 |
| - fields_to_check = ("Status", "Python-Version", "Created", "Type") |
110 |
| - for field in fields_to_check: |
111 |
| - # Check for a PEP metadata field that is present but has an empty value |
112 |
| - # embed field values can't contain an empty string |
113 |
| - if pep_header.get(field, ""): |
114 |
| - pep_embed.add_field(name=field, value=pep_header[field]) |
115 |
| - |
116 |
| - return pep_embed |
117 |
| - |
118 |
| - @pep_cache(arg_offset=1) |
119 |
| - async def get_pep_embed(self, pep_nr: int) -> tuple[Embed, bool]: |
120 |
| - """Fetch, generate and return PEP embed. Second item of return tuple show does getting success.""" |
121 |
| - response = await self.bot.http_session.get(self.peps[pep_nr]) |
122 |
| - |
123 |
| - if response.status == 200: |
124 |
| - log.trace(f"PEP {pep_nr} found") |
125 |
| - pep_content = await response.text() |
126 |
| - |
127 |
| - # Taken from https://github.com/python/peps/blob/master/pep0/pep.py#L179 |
128 |
| - pep_header = HeaderParser().parse(StringIO(pep_content)) |
129 |
| - return self.generate_pep_embed(pep_header, pep_nr), True |
130 |
| - |
131 |
| - log.trace( |
132 |
| - f"The user requested PEP {pep_nr}, but the response had an unexpected status code: {response.status}." |
133 |
| - ) |
134 |
| - return Embed( |
135 |
| - title="Unexpected error", |
136 |
| - description="Unexpected HTTP error during PEP search. Please let us know.", |
137 |
| - colour=Colour.red() |
138 |
| - ), False |
| 72 | + return embed |
139 | 73 |
|
140 | 74 | @command(name="pep", aliases=("get_pep", "p"))
|
141 | 75 | async def pep_command(self, ctx: Context, pep_number: int) -> None:
|
142 | 76 | """Fetches information about a PEP and sends it to the channel."""
|
143 |
| - # Trigger typing in chat to show users that bot is responding |
144 |
| - await ctx.typing() |
| 77 | + # Refresh the PEP data up to every hour, as e.g. the PEP status might have changed. |
| 78 | + if ( |
| 79 | + self.last_refreshed_peps is None or ( |
| 80 | + (self.last_refreshed_peps + timedelta(hours=1)) <= datetime.now(tz=UTC) |
| 81 | + and len(str(pep_number)) < 5 |
| 82 | + ) |
| 83 | + ): |
| 84 | + await self.refresh_pep_data() |
145 | 85 |
|
146 |
| - # Handle PEP 0 directly because it's not in .rst or .txt so it can't be accessed like other PEPs. |
147 |
| - if pep_number == 0: |
148 |
| - pep_embed = self.get_pep_zero_embed() |
149 |
| - success = True |
150 |
| - else: |
151 |
| - success = False |
152 |
| - if not (pep_embed := await self.validate_pep_number(pep_number)): |
153 |
| - pep_embed, success = await self.get_pep_embed(pep_number) |
154 |
| - |
155 |
| - await ctx.send(embed=pep_embed) |
156 |
| - if success: |
157 |
| - log.trace(f"PEP {pep_number} getting and sending finished successfully. Increasing stat.") |
158 |
| - self.bot.stats.incr(f"pep_fetches.{pep_number}") |
| 86 | + if pep := self.peps.get(pep_number): |
| 87 | + embed = self.generate_pep_embed(pep) |
159 | 88 | else:
|
160 |
| - log.trace(f"Getting PEP {pep_number} failed. Error embed sent.") |
| 89 | + log.trace(f"PEP {pep_number} was not found") |
| 90 | + embed = Embed( |
| 91 | + title="PEP not found", |
| 92 | + description=f"PEP {pep_number} does not exist.", |
| 93 | + colour=Colour.red(), |
| 94 | + ) |
| 95 | + |
| 96 | + await ctx.send(embed=embed) |
161 | 97 |
|
162 | 98 |
|
163 | 99 | async def setup(bot: Bot) -> None:
|
|
0 commit comments