diff --git a/ChangeLog.md b/ChangeLog.md index be635c1..2f31e92 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -1,5 +1,12 @@ # Peplum ChangeLog +## Unreleased + +**Released: WiP** + +- Added the ability to view the source of a PEP. + ([#17](https://github.com/davep/peplum/pull/17)) + ## v0.2.0 **Released: 2025-01-27** diff --git a/README.md b/README.md index 8d391fc..54761a1 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ Expanding for the common locations, the files normally created are: - `~/.config/peplum/configuration.json` -- The configuration file. - `~/.local/share/peplum/*.json` -- The locally-held PEP data. +- `~/.local/share/peplum/cache/*.rst` -- The locally-cached PEP source files. ## Getting help diff --git a/src/peplum/app/commands/__init__.py b/src/peplum/app/commands/__init__.py index 92073b8..d964eea 100644 --- a/src/peplum/app/commands/__init__.py +++ b/src/peplum/app/commands/__init__.py @@ -25,6 +25,7 @@ Quit, RedownloadPEPs, TogglePEPDetails, + ViewPEP, ) from .navigation_sorting import ( ToggleAuthorsSortOrder, @@ -59,6 +60,7 @@ "TogglePythonVersionsSortOrder", "ToggleStatusesSortOrder", "ToggleTypesSortOrder", + "ViewPEP", ] ### __init__.py ends here diff --git a/src/peplum/app/commands/main.py b/src/peplum/app/commands/main.py index 5d121db..cdf007f 100644 --- a/src/peplum/app/commands/main.py +++ b/src/peplum/app/commands/main.py @@ -61,4 +61,12 @@ class TogglePEPDetails(Command): BINDING_KEY = "f3" +############################################################################## +class ViewPEP(Command): + """View the source of the currently-highlighted PEP""" + + FOOTER_TEXT = "View" + BINDING_KEY = "f4" + + ### main.py ends here diff --git a/src/peplum/app/data/__init__.py b/src/peplum/app/data/__init__.py index 333c1b9..e65a2c7 100644 --- a/src/peplum/app/data/__init__.py +++ b/src/peplum/app/data/__init__.py @@ -8,6 +8,7 @@ save_configuration, update_configuration, ) +from .locations import cache_dir from .notes import Notes from .pep import PEP, PEPStatus, PEPType, PostHistory from .peps import ( @@ -28,17 +29,18 @@ ############################################################################## # Exports. __all__ = [ - "pep_data", "AuthorCount", + "cache_dir", "Configuration", "Containing", "load_configuration", "Notes", "PEP", - "PEPStatus", - "PEPType", + "pep_data", "PEPCount", "PEPs", + "PEPStatus", + "PEPType", "PostHistory", "PythonVersionCount", "save_configuration", diff --git a/src/peplum/app/data/locations.py b/src/peplum/app/data/locations.py index ae4c6cb..7d6deed 100644 --- a/src/peplum/app/data/locations.py +++ b/src/peplum/app/data/locations.py @@ -41,6 +41,21 @@ def data_dir() -> Path: return _app_dir(xdg_data_home()) +############################################################################## +def cache_dir() -> Path: + """The path to the cache directory for the application. + + Returns: + The path to the cache directory for the application. + + Note: + If the directory doesn't exist, it will be created as a side-effect + of calling this function. + """ + (cache := data_dir() / "cache").mkdir(parents=True, exist_ok=True) + return cache + + ############################################################################## def config_dir() -> Path: """The path to the configuration directory for the application. diff --git a/src/peplum/app/peplum.py b/src/peplum/app/peplum.py index 131d49d..4a1a7ef 100644 --- a/src/peplum/app/peplum.py +++ b/src/peplum/app/peplum.py @@ -33,6 +33,11 @@ class Peplum(App[None]): } } + /* Make the LoadingIndicator look less like it was just slapped on. */ + LoadingIndicator { + background: transparent; + } + /* Remove cruft from the Header. */ Header { /* The header icon is ugly and pointless. Remove it. */ diff --git a/src/peplum/app/providers/main.py b/src/peplum/app/providers/main.py index fc88c25..1736c0a 100644 --- a/src/peplum/app/providers/main.py +++ b/src/peplum/app/providers/main.py @@ -24,6 +24,7 @@ TogglePythonVersionsSortOrder, ToggleStatusesSortOrder, ToggleTypesSortOrder, + ViewPEP, ) from .commands_provider import CommandHits, CommandsProvider @@ -59,6 +60,7 @@ def commands(self) -> CommandHits: yield TogglePythonVersionsSortOrder() yield ToggleStatusesSortOrder() yield ToggleTypesSortOrder() + yield ViewPEP() ### main.py ends here diff --git a/src/peplum/app/screens/main.py b/src/peplum/app/screens/main.py index a3a6bf9..efdf61d 100644 --- a/src/peplum/app/screens/main.py +++ b/src/peplum/app/screens/main.py @@ -44,6 +44,7 @@ TogglePythonVersionsSortOrder, ToggleStatusesSortOrder, ToggleTypesSortOrder, + ViewPEP, ) from ..data import ( PEP, @@ -78,6 +79,7 @@ from ..widgets import Navigation, PEPDetails, PEPsView from .help import HelpScreen from .notes_editor import NotesEditor +from .pep_viewer import PEPViewer from .search_input import SearchInput @@ -148,6 +150,7 @@ class Main(Screen[None]): Help, EditNotes, TogglePEPDetails, + ViewPEP, Quit, RedownloadPEPs, # Everything else. @@ -529,5 +532,16 @@ async def action_edit_notes_command(self) -> None: self.all_peps.patch_pep(self.selected_pep.annotate(notes=notes)) ) + @on(ViewPEP) + def action_view_pep_command(self) -> None: + """View the currently-highlighted PEP's source.""" + if self.selected_pep is None: + self.notify("Highlight a PEP to view it.", severity="warning") + return + if self.selected_pep.number == 0: + self.notify("PEP0 has no source to view.", severity="warning") + return + self.app.push_screen(PEPViewer(self.selected_pep)) + ### main.py ends here diff --git a/src/peplum/app/screens/pep_viewer.py b/src/peplum/app/screens/pep_viewer.py new file mode 100644 index 0000000..3ef6930 --- /dev/null +++ b/src/peplum/app/screens/pep_viewer.py @@ -0,0 +1,151 @@ +"""A dialog for viewing the text of a PEP.""" + +############################################################################## +# Python imports. +from pathlib import Path + +############################################################################## +# Textual imports. +from textual import on, work +from textual.app import ComposeResult +from textual.containers import Horizontal, Vertical +from textual.screen import ModalScreen +from textual.widgets import Button, TextArea + +############################################################################## +# Local imports. +from ...peps import API +from ..data import PEP, cache_dir +from ..widgets import TextViewer + + +############################################################################## +class PEPViewer(ModalScreen[None]): + """A modal screen for viewing a PEP's source.""" + + CSS = """ + PEPViewer { + align: center middle; + + &> Vertical { + width: 80%; + height: 80%; + max-height: 80%; + background: $panel; + border: solid $border; + } + + TextViewer { + color: $text-muted; + height: 1fr; + scrollbar-background: $panel; + scrollbar-background-hover: $panel; + scrollbar-background-active: $panel; + &:focus { + color: $text; + } + } + + #buttons { + height: auto; + align-horizontal: right; + border-top: solid $border; + } + + Button { + margin-right: 1; + } + } + """ + + BINDINGS = [("escape", "close"), ("ctrl+r", "refresh"), ("ctrl+c", "copy")] + + def __init__(self, pep: PEP) -> None: + """Initialise the dialog. + + Args: + pep: The PEP to view. + """ + super().__init__() + self._pep = pep + """The PEP to view.""" + + def compose(self) -> ComposeResult: + """Compose the dialog's content.""" + key_colour = ( + "dim" if self.app.current_theme is None else self.app.current_theme.accent + ) + with Vertical() as dialog: + dialog.border_title = f"PEP{self._pep.number}" + yield TextViewer() + with Horizontal(id="buttons"): + yield Button(f"Copy [{key_colour}]\\[^c][/]", id="copy") + yield Button(f"Refresh [{key_colour}]\\[^r][/]", id="refresh") + yield Button(f"Close [{key_colour}]\\[Esc][/]", id="close") + + @property + def _cache_name(self) -> Path: + """The name of the file that is the cached version of the PEP source.""" + return cache_dir() / API.pep_file(self._pep.number) + + @work + async def _download_text(self) -> None: + """Download the text of the PEP. + + Notes: + Once downloaded a local copy will be saved. Subsequently, when + attempting to download the PEP, this local copy will be used + instead. + """ + (text := self.query_one(TextViewer)).loading = True + pep_source = "" + + if self._cache_name.exists(): + try: + pep_source = self._cache_name.read_text(encoding="utf-8") + except IOError: + pass + + if not pep_source: + try: + self._cache_name.write_text( + pep_source := await API().get_pep(self._pep.number), + encoding="utf-8", + ) + except IOError: + pass + except API.RequestError as error: + pep_source = "Error downloading PEP source" + self.notify( + str(error), title="Error downloading PEP source", severity="error" + ) + + text.text = pep_source + text.loading = False + self.set_focus(text) + + def on_mount(self) -> None: + """Populate the dialog once the DOM is ready.""" + self._download_text() + + @on(Button.Pressed, "#close") + def action_close(self) -> None: + """Close the dialog.""" + self.dismiss(None) + + @on(Button.Pressed, "#refresh") + def action_refresh(self) -> None: + """Refresh the PEP source.""" + try: + self._cache_name.unlink(missing_ok=True) + except IOError: + pass + self._download_text() + + @on(Button.Pressed, "#copy") + async def action_copy(self) -> None: + """Copy PEP text to the clipboard.""" + await self.query_one(TextArea).run_action("copy") + + +### pep_viewer.py ends here diff --git a/src/peplum/app/widgets/__init__.py b/src/peplum/app/widgets/__init__.py index 913221b..a0d2c6e 100644 --- a/src/peplum/app/widgets/__init__.py +++ b/src/peplum/app/widgets/__init__.py @@ -5,10 +5,11 @@ from .navigation import Navigation from .pep_details import PEPDetails from .peps_view import PEPsView +from .text_viewer import TextViewer ############################################################################## # Exports. -__all__ = ["Navigation", "PEPDetails", "PEPsView"] +__all__ = ["Navigation", "PEPDetails", "PEPsView", "TextViewer"] ### __init__.py ends here diff --git a/src/peplum/app/widgets/text_viewer.py b/src/peplum/app/widgets/text_viewer.py new file mode 100644 index 0000000..1bc3ff2 --- /dev/null +++ b/src/peplum/app/widgets/text_viewer.py @@ -0,0 +1,82 @@ +"""A widget for viewing text.""" + +############################################################################## +# Textual imports. +from textual.widgets import TextArea + + +############################################################################## +class TextViewer(TextArea): + """A widget for viewing text.""" + + DEFAULT_CSS = """ + TextViewer { + background: transparent; + border: none; + &:focus { + border: none; + } + & > .text-area--cursor-line { + background: transparent; + } + } + """ + + BINDINGS = [ + ("<", "cursor_line_start"), + (">", "cursor_line_end"), + ("c, C", "copy"), + ] + + def __init__( + self, + text: str = "", + *, + name: str | None = None, + id: str | None = None, + classes: str | None = None, + disabled: bool = False, + ) -> None: + """Initialise the object. + + Args: + text: The text to view. + name: The name of the TextViewer. + id: The ID of the TextViewer in the DOM. + classes: The CSS classes of the TextViewer. + disabled: Whether the TextViewer is disabled or not. + """ + super().__init__( + text, + read_only=True, + name=name, + id=id, + classes=classes, + disabled=disabled, + ) + + def action_copy(self) -> None: + """Action for copying text to the clipboard.""" + if for_clipboard := self.selected_text: + self.notify("Selected text copied to the clipboard.") + else: + for_clipboard = self.text + self.notify("All text copied to the clipboard.") + self.app.copy_to_clipboard(for_clipboard) + + def action_cursor_line_start(self, select: bool = False) -> None: + """Add a slightly smarter use of going 'home'.""" + if self.cursor_at_start_of_line: + self.move_cursor(self.document.start, select=select) + else: + super().action_cursor_line_start(select) + + def action_cursor_line_end(self, select: bool = False) -> None: + """Add a slightly smarter use of going 'end'.""" + if self.cursor_at_end_of_line: + self.move_cursor(self.document.end, select=select) + else: + super().action_cursor_line_end(select) + + +### text_viewer.py ends here diff --git a/src/peplum/peps/api.py b/src/peplum/peps/api.py index 3f74066..5eb7fdd 100644 --- a/src/peplum/peps/api.py +++ b/src/peplum/peps/api.py @@ -2,12 +2,13 @@ ############################################################################## # Python imports. +from pathlib import Path from ssl import SSLCertVerificationError from typing import Any, Final ############################################################################## # HTTPX imports. -from httpx import AsyncClient, HTTPStatusError, RequestError +from httpx import AsyncClient, HTTPStatusError, RequestError, Response ############################################################################## @@ -38,19 +39,20 @@ def _client(self) -> AsyncClient: self._client_ = AsyncClient() return self._client_ - async def get_peps(self) -> dict[int, dict[str, Any]]: - """Download a fresh list of all known PEPs. + async def _get(self, url: str) -> Response: + """Make a GET request. + + Args: + url: The URL to make the request of. Returns: - The PEP JSON data. + The response. Raises: - RequestError: If there was a problem getting the PEPS. + RequestError: If there was some sort of error. """ try: - response = await self._client.get( - self._URL, headers={"user-agent": self.AGENT} - ) + response = await self._client.get(url, headers={"user-agent": self.AGENT}) except (RequestError, SSLCertVerificationError) as error: raise self.RequestError(str(error)) from None @@ -59,10 +61,60 @@ async def get_peps(self) -> dict[int, dict[str, Any]]: except HTTPStatusError as error: raise self.RequestError(str(error)) from None - if isinstance(raw_data := response.json(), dict): - return raw_data + return response + + async def get_peps(self) -> dict[int, dict[str, Any]]: + """Download a fresh list of all known PEPs. + + Returns: + The PEP JSON data. + Raises: + RequestError: If there was a problem getting the PEPS. + """ + if isinstance( + raw_data := ( + await self._get("https://peps.python.org/api/peps.json") + ).json(), + dict, + ): + return raw_data raise RequestError("Unexpected data received from the PEP API") + @staticmethod + def pep_file(pep: int) -> Path: + """Generate the name of the source file of a PEP. + + Args: + pep: The number of the PEP. + + Returns: + The name of the source file for that PEP. + """ + return Path(f"pep-{pep:04}.rst") + + @classmethod + def pep_url(cls, pep: int) -> str: + """Generate the URL for the source of a PEP. + + Args: + pep: The number of the PEP. + + Returns: + The URL for the source of the PEP. + """ + return f"https://raw.githubusercontent.com/python/peps/refs/heads/main/peps/{cls.pep_file(pep)}" + + async def get_pep(self, pep: int) -> str: + """Download the text of a given PEP. + + Args: + pep: The number of the PEP to download. + + Returns: + The text for the PEP. + """ + return (await self._get(self.pep_url(pep))).text + ### api.py ends here