From efc09e1a711e62baf52b95c2e4897d70d3c21dd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Kr=C3=BCger?= Date: Sun, 16 Aug 2026 14:39:41 +0200 Subject: [PATCH] Add ruff static analysis and a Gitea Actions release workflow Adds ruff (lint + format check) as a dev dependency and gate, applies its fixes across the codebase (modernized typing, safer zip usage, line length), and adds a Gitea Actions workflow that lints, tests, builds, and publishes to the Gitea PyPI registry on v*.*.* tag pushes. Co-Authored-By: Claude Sonnet 5 --- .gitea/workflows/release.yaml | 39 ++++++++++++++++++++++++++ pyproject.toml | 12 +++++++- src/openrgb_hue/animation.py | 5 ++-- src/openrgb_hue/cli.py | 53 +++++++++++++++++++---------------- src/openrgb_hue/client.py | 9 ++---- src/openrgb_hue/color.py | 6 ++-- src/openrgb_hue/gradient.py | 14 +++++---- src/openrgb_hue/mapping.py | 8 +++--- src/openrgb_hue/scenes.py | 4 +-- src/openrgb_hue/targets.py | 7 ++--- tests/test_animation.py | 2 +- tests/test_cli.py | 4 +-- 12 files changed, 104 insertions(+), 59 deletions(-) create mode 100644 .gitea/workflows/release.yaml diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml new file mode 100644 index 0000000..481e2b6 --- /dev/null +++ b/.gitea/workflows/release.yaml @@ -0,0 +1,39 @@ +name: Release + +on: + push: + tags: + - "v*.*.*" + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: https://github.com/actions/checkout@v4 + + - uses: https://github.com/actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install build dependencies + run: pip install ".[dev]" build twine + + - name: Lint + run: ruff check . + + - name: Format check + run: ruff format --check . + + - run: pytest + + - name: Set package version from the tag + run: sed -i "s/^__version__ = .*/__version__ = \"${GITHUB_REF_NAME#v}\"/" src/openrgb_hue/__init__.py + + - name: Build sdist and wheel + run: python -m build + + - name: Publish to Gitea PyPI registry + run: twine upload --repository-url https://dev.pivoine.art/api/packages/valknar/pypi dist/* + env: + TWINE_USERNAME: valknar + TWINE_PASSWORD: ${{ secrets.PACKAGE_TOKEN }} diff --git a/pyproject.toml b/pyproject.toml index 9f0ae32..0904638 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ dependencies = [ ] [project.optional-dependencies] -dev = ["pytest>=7", "pytest-cov"] +dev = ["pytest>=7", "pytest-cov", "ruff>=0.6"] [project.scripts] openrgb-hue = "openrgb_hue.cli:app" @@ -30,3 +30,13 @@ packages = ["src/openrgb_hue"] [tool.pytest.ini_options] testpaths = ["tests"] + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B"] +ignore = [ + "B008", # typer.Option(...)/typer.Argument(...) as a parameter default is the idiomatic Typer pattern +] diff --git a/src/openrgb_hue/animation.py b/src/openrgb_hue/animation.py index bada1c4..3ad9abe 100644 --- a/src/openrgb_hue/animation.py +++ b/src/openrgb_hue/animation.py @@ -11,7 +11,8 @@ from __future__ import annotations import math import time -from typing import TYPE_CHECKING, Callable, ClassVar, Protocol +from collections.abc import Callable +from typing import TYPE_CHECKING, ClassVar, Protocol from openrgb_hue import client as client_mod from openrgb_hue.client import LedRef @@ -124,7 +125,7 @@ def build_animation( def run_animation( - client: "OpenRGBClient", + client: OpenRGBClient, base: dict[LedRef, float], gradient: Gradient, animation: AnimationMode, diff --git a/src/openrgb_hue/cli.py b/src/openrgb_hue/cli.py index c7b2ad2..5b9d37c 100644 --- a/src/openrgb_hue/cli.py +++ b/src/openrgb_hue/cli.py @@ -4,8 +4,6 @@ mapping/animation logic lives here -- this module only parses options, delegates to the other modules, and renders output. """ -from typing import List, Optional - import typer from rich import box from rich.console import Console @@ -49,7 +47,7 @@ def main( ctx: typer.Context, host: str = typer.Option("127.0.0.1", envvar="OPENRGB_HOST", help="OpenRGB SDK server host."), port: int = typer.Option(6742, envvar="OPENRGB_PORT", help="OpenRGB SDK server port."), - version: Optional[bool] = typer.Option( + version: bool | None = typer.Option( None, "--version", callback=_version_callback, is_eager=True, help="Show the version and exit." ), ) -> None: @@ -61,17 +59,15 @@ def _error_exit(message: str) -> None: raise typer.Exit(code=1) -def _swatch(colors: List[Color], width: int = 2) -> Text: +def _swatch(colors: list[Color], width: int = 2) -> Text: text = Text() for color in colors: text.append(" " * width, style=f"on #{color.to_hex()}") return text -def _build_target_filter(device: List[str], device_type: List[str], zone: List[str]) -> targets_mod.TargetFilter: - return targets_mod.TargetFilter( - device_names=tuple(device), device_types=tuple(device_type), zone_names=tuple(zone) - ) +def _build_target_filter(device: list[str], device_type: list[str], zone: list[str]) -> targets_mod.TargetFilter: + return targets_mod.TargetFilter(device_names=tuple(device), device_types=tuple(device_type), zone_names=tuple(zone)) def _load_gradient(scene_name: str, dedupe: bool, interpolation: str, brightness: bool) -> Gradient: @@ -85,7 +81,14 @@ def _load_gradient(scene_name: str, dedupe: bool, interpolation: str, brightness _error_exit(str(exc)) -def _resolve_targets(ctx: typer.Context, device: List[str], device_type: List[str], zone: List[str], dry_run: bool, dry_run_leds: Optional[int]): +def _resolve_targets( + ctx: typer.Context, + device: list[str], + device_type: list[str], + zone: list[str], + dry_run: bool, + dry_run_leds: int | None, +): if dry_run: return None, targets_mod.synthetic_targets(dry_run_leds) try: @@ -111,7 +114,7 @@ def _build_mapping(mapping: str, direction: str, seed: int) -> mapping_mod.Mappi _error_exit(str(exc)) -def _print_led_preview(targets: List[LedRef], colors: dict) -> None: +def _print_led_preview(targets: list[LedRef], colors: dict) -> None: by_device: dict = {} for led in targets: by_device.setdefault(led.device_name, []).append(led) @@ -155,7 +158,7 @@ def devices( @scenes_app.command("list") def scenes_list( - name_filter: Optional[str] = typer.Option(None, "--filter", help="Case-insensitive substring filter on scene name."), + name_filter: str | None = typer.Option(None, "--filter", help="Case-insensitive substring filter on scene name."), ) -> None: """List all bundled Hue scenes with a gradient preview.""" scenes = scenes_mod.load_scenes() @@ -194,7 +197,9 @@ def scenes_show( table.add_column("Hex") table.add_column("Brightness", justify="right") for light in scene.lights: - table.add_row(light.light_id, _swatch([light.color], width=4), f"#{light.color.to_hex()}", str(light.brightness)) + table.add_row( + light.light_id, _swatch([light.color], width=4), f"#{light.color.to_hex()}", str(light.brightness) + ) console.print(table) gradient = Gradient.from_scene(scene, dedupe=dedupe, interpolation=interpolation, use_brightness=brightness) @@ -222,11 +227,11 @@ def scenes_update( def apply( ctx: typer.Context, scene: str = typer.Argument(..., help="Hue scene name (see `scenes list`)."), - device: List[str] = typer.Option([], "--device", help="Target only devices with this name (repeatable)."), - device_type: List[str] = typer.Option( + device: list[str] = typer.Option([], "--device", help="Target only devices with this name (repeatable)."), + device_type: list[str] = typer.Option( [], "--device-type", help="Target only devices of this type, e.g. gpu, motherboard, ledstrip (repeatable)." ), - zone: List[str] = typer.Option([], "--zone", help="Target only zones with this name (repeatable)."), + zone: list[str] = typer.Option([], "--zone", help="Target only zones with this name (repeatable)."), mapping: str = typer.Option("sequence", help=f"Mapping mode: {', '.join(mapping_mod.MAPPING_NAMES)}."), direction: str = typer.Option("left-right", help=f"Matrix direction: {', '.join(mapping_mod.DIRECTIONS)}."), seed: int = typer.Option(0, help="Shuffle mapping seed."), @@ -236,7 +241,7 @@ def apply( dim: float = typer.Option(1.0, help="Global brightness multiplier (0-1), independent of scene brightness."), fast: bool = typer.Option(False, help="Use OpenRGB's fast (no-readback) update mode."), dry_run: bool = typer.Option(False, help="Compute and preview without connecting to OpenRGB."), - dry_run_leds: Optional[int] = typer.Option(None, help="Number of synthetic flat LEDs for --dry-run."), + dry_run_leds: int | None = typer.Option(None, help="Number of synthetic flat LEDs for --dry-run."), ) -> None: """Apply a Hue scene as a static gradient.""" gradient = _load_gradient(scene, dedupe, interpolation, brightness) @@ -259,11 +264,11 @@ def apply( def animate( ctx: typer.Context, scene: str = typer.Argument(..., help="Hue scene name (see `scenes list`)."), - device: List[str] = typer.Option([], "--device", help="Target only devices with this name (repeatable)."), - device_type: List[str] = typer.Option( + device: list[str] = typer.Option([], "--device", help="Target only devices with this name (repeatable)."), + device_type: list[str] = typer.Option( [], "--device-type", help="Target only devices of this type, e.g. gpu, motherboard, ledstrip (repeatable)." ), - zone: List[str] = typer.Option([], "--zone", help="Target only zones with this name (repeatable)."), + zone: list[str] = typer.Option([], "--zone", help="Target only zones with this name (repeatable)."), mapping: str = typer.Option("sequence", help=f"Mapping mode: {', '.join(mapping_mod.MAPPING_NAMES)}."), direction: str = typer.Option("left-right", help=f"Matrix direction: {', '.join(mapping_mod.DIRECTIONS)}."), seed: int = typer.Option(0, help="Shuffle mapping seed."), @@ -274,12 +279,12 @@ def animate( mode: str = typer.Option("scroll", help=f"Animation mode: {', '.join(animation_mod.ANIMATION_NAMES)}."), speed: float = typer.Option(0.1, help="Cycles/sec (scroll, pingpong) or Hz (pulse, wave)."), fps: float = typer.Option(30.0, help="Target frames per second."), - duration: Optional[float] = typer.Option(None, help="Seconds to run; omit to run until Ctrl+C."), + duration: float | None = typer.Option(None, help="Seconds to run; omit to run until Ctrl+C."), min_brightness: float = typer.Option(0.05, help="Brightness floor for pulse/wave (0-1)."), wavelength: float = typer.Option(2.0, help="Brightness peaks across the target set, for wave."), restore: bool = typer.Option(True, help="Restore original colors when the animation stops."), dry_run: bool = typer.Option(False, help="Preview sample frames without connecting to OpenRGB."), - dry_run_leds: Optional[int] = typer.Option(None, help="Number of synthetic flat LEDs for --dry-run."), + dry_run_leds: int | None = typer.Option(None, help="Number of synthetic flat LEDs for --dry-run."), dry_run_frames: int = typer.Option(5, help="Number of sample frames to preview with --dry-run."), ) -> None: """Apply a Hue scene as an animated gradient.""" @@ -329,9 +334,9 @@ def animate( @app.command() def off( ctx: typer.Context, - device: List[str] = typer.Option([], "--device", help="Target only devices with this name (repeatable)."), - device_type: List[str] = typer.Option([], "--device-type", help="Target only devices of this type (repeatable)."), - zone: List[str] = typer.Option([], "--zone", help="Target only zones with this name (repeatable)."), + device: list[str] = typer.Option([], "--device", help="Target only devices with this name (repeatable)."), + device_type: list[str] = typer.Option([], "--device-type", help="Target only devices of this type (repeatable)."), + zone: list[str] = typer.Option([], "--zone", help="Target only zones with this name (repeatable)."), ) -> None: """Turn off targeted LEDs (or all LEDs, if no filters are given).""" try: diff --git a/src/openrgb_hue/client.py b/src/openrgb_hue/client.py index 847e641..9ade906 100644 --- a/src/openrgb_hue/client.py +++ b/src/openrgb_hue/client.py @@ -94,17 +94,12 @@ def apply_colors(client: OpenRGBClient, colors: dict[LedRef, Color], fast: bool for device_index, led_colors in by_device.items(): device: Device = client.ee_devices[device_index] current = device.colors - new_colors = [ - led_colors[i].to_openrgb() if i in led_colors else current[i] for i in range(len(current)) - ] + new_colors = [led_colors[i].to_openrgb() if i in led_colors else current[i] for i in range(len(current))] device.set_colors(new_colors, fast=fast) def snapshot_colors(client: OpenRGBClient) -> dict[int, list[Color]]: - return { - i: [Color(c.red, c.green, c.blue) for c in device.colors] - for i, device in enumerate(client.ee_devices) - } + return {i: [Color(c.red, c.green, c.blue) for c in device.colors] for i, device in enumerate(client.ee_devices)} def restore_colors(client: OpenRGBClient, snapshot: dict[int, list[Color]], fast: bool = True) -> None: diff --git a/src/openrgb_hue/color.py b/src/openrgb_hue/color.py index 7492249..1dd951b 100644 --- a/src/openrgb_hue/color.py +++ b/src/openrgb_hue/color.py @@ -30,7 +30,7 @@ class Color: object.__setattr__(self, "b", _clamp255(self.b)) @classmethod - def from_hex(cls, value: str) -> "Color": + def from_hex(cls, value: str) -> Color: value = value.lstrip("#") if len(value) != 6: raise ValueError(f"Expected a 6-digit hex color, got {value!r}") @@ -39,12 +39,12 @@ class Color: def to_hex(self) -> str: return f"{self.r:02x}{self.g:02x}{self.b:02x}" - def to_openrgb(self) -> "openrgb.utils.RGBColor": # noqa: F821 + def to_openrgb(self) -> openrgb.utils.RGBColor: # noqa: F821 from openrgb.utils import RGBColor return RGBColor(self.r, self.g, self.b) - def scale_brightness(self, factor: float) -> "Color": + def scale_brightness(self, factor: float) -> Color: factor = max(0.0, factor) return Color(self.r * factor, self.g * factor, self.b * factor) diff --git a/src/openrgb_hue/gradient.py b/src/openrgb_hue/gradient.py index 26d2d13..c1250f1 100644 --- a/src/openrgb_hue/gradient.py +++ b/src/openrgb_hue/gradient.py @@ -10,8 +10,10 @@ logic in the animation layer. from __future__ import annotations +import itertools +from collections.abc import Sequence from dataclasses import dataclass -from typing import TYPE_CHECKING, Literal, Sequence +from typing import TYPE_CHECKING, Literal from openrgb_hue.color import Color, hsv_lerp, rgb_lerp @@ -43,23 +45,23 @@ class Gradient: return list(self._stops) @classmethod - def from_colors(cls, colors: Sequence[Color], interpolation: Interpolation = "hsv") -> "Gradient": + def from_colors(cls, colors: Sequence[Color], interpolation: Interpolation = "hsv") -> Gradient: colors = list(colors) if not colors: raise ValueError("Gradient needs at least one color") n = len(colors) positions = [0.0] if n == 1 else [i / (n - 1) for i in range(n)] - return cls([Stop(p, c) for p, c in zip(positions, colors)], interpolation=interpolation) + return cls([Stop(p, c) for p, c in zip(positions, colors, strict=True)], interpolation=interpolation) @classmethod def from_scene( cls, - scene: "Scene", + scene: Scene, *, dedupe: bool = True, interpolation: Interpolation = "hsv", use_brightness: bool = True, - ) -> "Gradient": + ) -> Gradient: colors: list[Color] = [] for light in scene.lights: color = light.color.scale_brightness(light.brightness / 255) if use_brightness else light.color @@ -81,7 +83,7 @@ class Gradient: t_eff = t + 1.0 if t < stops[0].position else t extended = stops + [Stop(stops[0].position + 1.0, stops[0].color)] lerp = _LERP[self.interpolation] - for a, b in zip(extended, extended[1:]): + for a, b in itertools.pairwise(extended): if a.position <= t_eff <= b.position: span = b.position - a.position local_t = 0.0 if span == 0 else (t_eff - a.position) / span diff --git a/src/openrgb_hue/mapping.py b/src/openrgb_hue/mapping.py index 68f8189..3c2f4a1 100644 --- a/src/openrgb_hue/mapping.py +++ b/src/openrgb_hue/mapping.py @@ -12,8 +12,9 @@ from __future__ import annotations import math import random +from collections.abc import Callable from dataclasses import dataclass -from typing import Callable, ClassVar, Protocol +from typing import ClassVar, Protocol from openrgb_hue.client import LedRef from openrgb_hue.color import Color @@ -113,8 +114,7 @@ class MatrixMapping: non_matrix_leds.append(led) if non_matrix_leds and not warned: self._warn( - "Some targeted LEDs are not part of a matrix zone; they will be held at a " - "fixed gradient position." + "Some targeted LEDs are not part of a matrix zone; they will be held at a fixed gradient position." ) warned = True for led in non_matrix_leds: @@ -171,7 +171,7 @@ class ShuffleMapping: positions = [0.0] if n <= 1 else [i / (n - 1) for i in range(n)] shuffled = list(targets) random.Random(self.seed).shuffle(shuffled) - return dict(zip(shuffled, positions)) + return dict(zip(shuffled, positions, strict=True)) def build_mapping( diff --git a/src/openrgb_hue/scenes.py b/src/openrgb_hue/scenes.py index 22c3ea3..4119ff9 100644 --- a/src/openrgb_hue/scenes.py +++ b/src/openrgb_hue/scenes.py @@ -73,9 +73,7 @@ def get_scene(name: str, path: Path | None = None) -> Scene: return scene suggestions = get_close_matches(name, scenes.keys(), n=3) hint = f" Did you mean: {', '.join(suggestions)}?" if suggestions else "" - raise SceneNotFoundError( - f"No scene named {name!r}.{hint} Run `openrgb-hue scenes list` to see all scenes." - ) + raise SceneNotFoundError(f"No scene named {name!r}.{hint} Run `openrgb-hue scenes list` to see all scenes.") def refresh_bundled_csv(dest: Path | None = None, url: str = GIST_RAW_URL, timeout: float = 10.0) -> Path: diff --git a/src/openrgb_hue/targets.py b/src/openrgb_hue/targets.py index f070179..acadfdd 100644 --- a/src/openrgb_hue/targets.py +++ b/src/openrgb_hue/targets.py @@ -32,8 +32,7 @@ def select_targets(all_leds: list[LedRef], filt: TargetFilter) -> list[LedRef]: targets = [led for led in all_leds if _matches(led, filt)] if not targets: raise NoTargetsError( - "No LEDs matched the given filters. Run `openrgb-hue devices --leds` to see available " - "device/zone names." + "No LEDs matched the given filters. Run `openrgb-hue devices --leds` to see available device/zone names." ) return targets @@ -59,9 +58,7 @@ def synthetic_targets(n: int | None = None) -> list[LedRef]: leds: list[LedRef] = [] # Two linear strips of different lengths... - for device_index, (device_name, count) in enumerate( - [("Dry-Run LED Strip", 12), ("Dry-Run Motherboard", 8)] - ): + for device_index, (device_name, count) in enumerate([("Dry-Run LED Strip", 12), ("Dry-Run Motherboard", 8)]): for i in range(count): leds.append( LedRef( diff --git a/tests/test_animation.py b/tests/test_animation.py index d15a150..da79dca 100644 --- a/tests/test_animation.py +++ b/tests/test_animation.py @@ -13,8 +13,8 @@ from openrgb_hue.animation import ( build_animation, run_animation, ) -from openrgb_hue.gradient import Gradient from openrgb_hue.color import Color +from openrgb_hue.gradient import Gradient from openrgb_hue.mapping import SequenceMapping, base_positions from openrgb_hue.targets import synthetic_targets diff --git a/tests/test_cli.py b/tests/test_cli.py index 2cb7a39..217a0e6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -47,9 +47,7 @@ def test_apply_dry_run_matrix_radial(): @pytest.mark.parametrize("mode", ["static", "scroll", "pingpong", "pulse", "wave"]) def test_animate_dry_run_across_animation_modes(mode): - result = runner.invoke( - app, ["animate", "Tropical twilight", "--dry-run", "--mode", mode, "--dry-run-frames", "3"] - ) + result = runner.invoke(app, ["animate", "Tropical twilight", "--dry-run", "--mode", mode, "--dry-run-frames", "3"]) assert result.exit_code == 0, result.output assert "t=0.00s" in result.output