From a3db62cf02abbe72108b132cbdb02c4c517a4a7f Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Sun, 26 Apr 2026 20:30:39 -0400 Subject: [PATCH] Enable Ruff's UP rules and modernize annotations Replaces typing.{List,Dict,Tuple,Optional,Union,Mapping} with PEP 585 builtins, PEP 604 union syntax, and collections.abc imports. Drops the `from __future__ import annotations` future imports that are no longer needed on Python 3.10+. --- pyproject.toml | 2 +- src/vesta/chars.py | 18 ++++-------- src/vesta/clients.py | 68 ++++++++++++++++++++----------------------- src/vesta/http.py | 16 ++++------ src/vesta/vbml.py | 26 +++++++---------- tests/test_clients.py | 2 -- 6 files changed, 55 insertions(+), 77 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9785895..9d8c9de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,7 +70,7 @@ addopts = "--cov=vesta --cov-report=term-missing --doctest-modules" src = ["src", "tests"] [tool.ruff.lint] -select = ["E", "F", "I", "RUF", "W"] +select = ["E", "F", "I", "RUF", "UP", "W"] [tool.ruff.lint.isort] force-single-line = true diff --git a/src/vesta/chars.py b/src/vesta/chars.py index e220cc9..5fd4bed 100644 --- a/src/vesta/chars.py +++ b/src/vesta/chars.py @@ -18,19 +18,13 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -from __future__ import annotations - import enum import math import sys -from typing import Container +from collections.abc import Container from typing import Final -from typing import List from typing import Literal -from typing import Optional from typing import TextIO -from typing import Tuple -from typing import Union from typing import cast PRINTABLE = " ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890!@#$() - +&=;: '\"%,. /? °" @@ -43,10 +37,10 @@ ROWS: Final[int] = 6 #: A row of character codes. -Row = List[int] +Row = list[int] #: A list of rows, forming a character grid. -Rows = List[Row] +Rows = list[Row] def validate_rows(rows: Rows) -> None: @@ -159,7 +153,7 @@ def encode_text( s: str, *, align: Literal["left", "center", "right"] = "left", - valign: Optional[Literal["top", "middle", "bottom"]] = "top", + valign: Literal["top", "middle", "bottom"] | None = "top", max_rows: int = ROWS, margin: int = 0, fill: int = Color.BLANK, @@ -211,7 +205,7 @@ def encode_text( # Find a place to break the line at a `breaks` character that results in # the longest row possible (up to `max_cols`). The result is a pair of # positions: the end of the resulting row and the start of the remainder. - def find_break(line: Row) -> Tuple[int, int]: + def find_break(line: Row) -> tuple[int, int]: end = min(len(line), max_cols) for pos in range(end, 0, -1): if line[pos] in breaks: @@ -266,7 +260,7 @@ def _format_row(row: Row, align: str, margin: int, fill: int) -> Row: def pprint( - data: Union[Row, Rows], + data: Row | Rows, stream: TextIO = sys.stdout, *, sep: str = "|", diff --git a/src/vesta/clients.py b/src/vesta/clients.py index effb27b..cb7b6ad 100644 --- a/src/vesta/clients.py +++ b/src/vesta/clients.py @@ -18,17 +18,11 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -from __future__ import annotations - import json import warnings +from collections.abc import Mapping from typing import Any -from typing import Dict -from typing import List from typing import Literal -from typing import Mapping -from typing import Optional -from typing import Union from typing import cast from . import http @@ -65,7 +59,7 @@ def __init__( api_secret: str, *, base_url: str = "https://platform.vestaboard.com", - headers: Optional[Mapping[str, str]] = None, + headers: Mapping[str, str] | None = None, ): warnings.warn( "Vestaboard has deprecated the Platform API (Client). " @@ -85,12 +79,12 @@ def __init__( def __repr__(self): return f'{type(self).__name__}(base_url="{self.http.base_url!s}")' - def get_subscriptions(self) -> List[Dict[str, Any]]: + def get_subscriptions(self) -> list[dict[str, Any]]: """Lists all subscriptions to which the viewer has access.""" resp = self.http.request("GET", "/subscriptions") return resp.json({}).get("subscriptions", []) - def get_viewer(self) -> Dict[str, Any]: + def get_viewer(self) -> dict[str, Any]: """Describes the currently authenticated viewer.""" resp = self.http.request("GET", "/viewer") return resp.json() @@ -98,8 +92,8 @@ def get_viewer(self) -> Dict[str, Any]: def post_message( self, subscription_id: str, - message: Union[str, Rows], - ) -> Dict[str, Any]: + message: str | Rows, + ) -> dict[str, Any]: """Post a new message to a subscription. The authenticated viewer must have access to the subscription. @@ -115,7 +109,7 @@ def post_message( :raises ValueError: if ``message`` is a list with unsupported dimensions """ - data: Dict[str, Union[str, Rows]] + data: dict[str, str | Rows] if isinstance(message, str): data = {"text": message} elif isinstance(message, list): @@ -150,7 +144,7 @@ class LocalClient: def __init__( self, - local_api_key: Optional[str] = None, + local_api_key: str | None = None, *, base_url: str = "http://vestaboard.local:7000", ): @@ -162,10 +156,10 @@ def __repr__(self): return f'{type(self).__name__}(base_url="{self.http.base_url!s}")' @property - def api_key(self) -> Optional[str]: + def api_key(self) -> str | None: """The client's Local API key.""" return cast( - Optional[str], + str | None, self.http.headers.get("X-Vestaboard-Local-Api-Key"), ) @@ -179,7 +173,7 @@ def enabled(self) -> bool: support has been enabled.""" return self.api_key is not None - def enable(self, enablement_token) -> Optional[str]: + def enable(self, enablement_token) -> str | None: """Enable the Vestaboard's Local API using a Local API Enablement Token. If successful, the Vestaboard's Local API key will be returned and the @@ -197,7 +191,7 @@ def enable(self, enablement_token) -> Optional[str]: return local_api_key - def read_message(self) -> Optional[Rows]: + def read_message(self) -> Rows | None: """Read the Vestaboard's current message.""" if not self.enabled: raise RuntimeError("Local API has not been enabled") @@ -239,7 +233,7 @@ def __init__( read_write_key: str, *, base_url: str = "https://rw.vestaboard.com/", - headers: Optional[Mapping[str, str]] = None, + headers: Mapping[str, str] | None = None, ): warnings.warn( "Vestaboard has renamed the Read / Write API to the Cloud API. " @@ -256,7 +250,7 @@ def __init__( def __repr__(self): return f'{type(self).__name__}(base_url="{self.http.base_url!s}")' - def read_message(self) -> Optional[Rows]: + def read_message(self) -> Rows | None: """Read the Vestaboard's current message.""" resp = self.http.request("GET", "") layout = resp.json({}).get("currentMessage", {}).get("layout") @@ -264,7 +258,7 @@ def read_message(self) -> Optional[Rows]: return json.loads(layout) return None - def write_message(self, message: Union[str, Rows]) -> bool: + def write_message(self, message: str | Rows) -> bool: """Write a message to the Vestaboard. `message` can be either a string of text or a two-dimensional (6, 22) @@ -278,7 +272,7 @@ def write_message(self, message: Union[str, Rows]) -> bool: :raises ValueError: if ``message`` is a list with unsupported dimensions """ - data: Union[Dict[str, str], Rows] + data: dict[str, str] | Rows if isinstance(message, str): data = {"text": message} elif isinstance(message, list): @@ -309,7 +303,7 @@ def __init__( api_secret: str, *, base_url: str = "https://subscriptions.vestaboard.com", - headers: Optional[Mapping[str, str]] = None, + headers: Mapping[str, str] | None = None, ): all_headers = { "x-vestaboard-api-key": api_key, @@ -323,7 +317,7 @@ def __init__( def __repr__(self): return f'{type(self).__name__}(base_url="{self.http.base_url!s}")' - def get_subscriptions(self) -> List[Dict[str, Any]]: + def get_subscriptions(self) -> list[dict[str, Any]]: """Lists all subscriptions to which the viewer has access.""" resp = self.http.request("GET", "/subscriptions") return resp.json() @@ -331,8 +325,8 @@ def get_subscriptions(self) -> List[Dict[str, Any]]: def send_message( self, subscription_id: str, - message: Union[str, Rows], - ) -> Dict[str, Any]: + message: str | Rows, + ) -> dict[str, Any]: """Send a new message to a subscription. The authenticated viewer must have access to the subscription. @@ -348,7 +342,7 @@ def send_message( :raises ValueError: if ``message`` is a list with unsupported dimensions """ - data: Dict[str, Union[str, Rows]] + data: dict[str, str | Rows] if isinstance(message, str): data = {"text": message} elif isinstance(message, list): @@ -387,7 +381,7 @@ def __init__( api_token: str, *, base_url: str = "https://cloud.vestaboard.com", - headers: Optional[Mapping[str, str]] = None, + headers: Mapping[str, str] | None = None, ): all_headers = {"X-Vestaboard-Token": api_token} if headers: @@ -398,7 +392,7 @@ def __init__( def __repr__(self): return f'{type(self).__name__}(base_url="{self.http.base_url!s}")' - def read_message(self) -> Optional[Rows]: + def read_message(self) -> Rows | None: """Read the Vestaboard's current message.""" resp = self.http.request("GET", "") layout = resp.json({}).get("currentMessage", {}).get("layout") @@ -408,7 +402,7 @@ def read_message(self) -> Optional[Rows]: def write_message( self, - message: Union[str, Rows], + message: str | Rows, *, forced: bool = False, ) -> bool: @@ -428,7 +422,7 @@ def write_message( :raises ValueError: if ``message`` is a list with unsupported dimensions """ - data: Dict[str, Any] + data: dict[str, Any] if isinstance(message, str): data = {"text": message} elif isinstance(message, list): @@ -443,7 +437,7 @@ def write_message( resp = self.http.request("POST", "", json=data) return 200 <= resp.status < 300 - def get_transition(self) -> Dict[str, Any]: + def get_transition(self) -> dict[str, Any]: """Get the current transition settings. :returns: A dict with ``transition`` and ``transitionSpeed`` keys. @@ -455,7 +449,7 @@ def set_transition( self, transition: Literal["classic", "wave", "drift", "curtain"], speed: Literal["gentle", "fast"], - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """Update the transition settings. ``transition`` selects the animation style: ``"classic"``, ``"wave"``, @@ -481,7 +475,7 @@ def __init__( self, *, base_url: str = "https://vbml.vestaboard.com/", - headers: Optional[Mapping[str, str]] = None, + headers: Mapping[str, str] | None = None, ): self.http = http.Client(base_url=base_url, headers=headers) @@ -490,8 +484,8 @@ def __repr__(self): def compose( self, - components: List[Component], - props: Optional[Props] = None, + components: list[Component], + props: Props | None = None, ) -> Rows: """Composes one or more styled components into rows of character codes. @@ -505,7 +499,7 @@ def compose( if not components: raise ValueError("expected at least one component") - data: Dict[str, Union[Props, List[Dict]]] = { + data: dict[str, Props | list[dict]] = { "components": [component.asdict() for component in components], } if props: diff --git a/src/vesta/http.py b/src/vesta/http.py index 8cb9ee8..adcb765 100644 --- a/src/vesta/http.py +++ b/src/vesta/http.py @@ -18,19 +18,15 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -from __future__ import annotations - import urllib.error import urllib.request +from collections.abc import Mapping from json import JSONDecodeError from json import dumps as json_dumps from json import loads as json_loads from typing import Any -from typing import Dict from typing import Literal -from typing import Mapping from typing import NamedTuple -from typing import Optional from urllib.parse import urljoin from urllib.parse import urlsplit @@ -43,7 +39,7 @@ class Response(NamedTuple): status: int body: bytes - headers: Dict[str, str] + headers: dict[str, str] def json(self, default: Any = None): try: @@ -61,10 +57,10 @@ class Client: def __init__( self, base_url: str = "", - headers: Optional[Mapping[str, str]] = None, + headers: Mapping[str, str] | None = None, ): self.base_url = base_url - self.headers: Dict[str, str] = dict(headers) if headers else {} + self.headers: dict[str, str] = dict(headers) if headers else {} self._opener = urllib.request.build_opener() def request( @@ -72,8 +68,8 @@ def request( method: Literal["GET", "POST", "PUT"], path: str, *, - json: Optional[Any] = None, - headers: Optional[Mapping[str, str]] = None, + json: Any | None = None, + headers: Mapping[str, str] | None = None, ) -> Response: """Execute HTTP request and return Response. diff --git a/src/vesta/vbml.py b/src/vesta/vbml.py index dd3d703..743d367 100644 --- a/src/vesta/vbml.py +++ b/src/vesta/vbml.py @@ -18,13 +18,9 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -from __future__ import annotations - +from collections.abc import Mapping from typing import Any -from typing import Dict from typing import Literal -from typing import Mapping -from typing import Optional from typing import TypedDict from .chars import Rows @@ -104,15 +100,15 @@ class Component: def __init__( self, - template: Optional[str] = None, - raw_characters: Optional[Rows] = None, - style: Optional[Style] = None, + template: str | None = None, + raw_characters: Rows | None = None, + style: Style | None = None, *, - height: Optional[int] = None, - width: Optional[int] = None, - justify: Optional[Justification] = None, - align: Optional[Alignment] = None, - absolute_position: Optional[Position] = None, + height: int | None = None, + width: int | None = None, + justify: Justification | None = None, + align: Alignment | None = None, + absolute_position: Position | None = None, ): if not (template is not None or raw_characters is not None): raise ValueError("expected template or raw_characters") @@ -131,9 +127,9 @@ def __init__( if absolute_position is not None: self.style["absolutePosition"] = absolute_position - def asdict(self) -> Dict[str, Any]: + def asdict(self) -> dict[str, Any]: """Returns the component's JSON dictionary representation.""" - d: Dict[str, Any] = {} + d: dict[str, Any] = {} if self.raw_characters is not None: d["rawCharacters"] = self.raw_characters else: diff --git a/tests/test_clients.py b/tests/test_clients.py index d5b0b0f..d59b101 100644 --- a/tests/test_clients.py +++ b/tests/test_clients.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from json import dumps as json_dumps import pytest