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 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 14:39:41 +02:00
co-authored by Claude Sonnet 5
parent 71921b5600
commit 47540ace11
12 changed files with 104 additions and 59 deletions
+39
View File
@@ -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 }}
+11 -1
View File
@@ -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
]
+3 -2
View File
@@ -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,
+29 -24
View File
@@ -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:
+2 -7
View File
@@ -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:
+3 -3
View File
@@ -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)
+8 -6
View File
@@ -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
+4 -4
View File
@@ -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(
+1 -3
View File
@@ -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:
+2 -5
View File
@@ -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(
+1 -1
View File
@@ -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
+1 -3
View File
@@ -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