Add ruff static analysis and a Gitea Actions release workflow
Release / release (push) Successful in 52s
Release / release (push) Successful in 52s
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:
@@ -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
@@ -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
|
||||
]
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user