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] [project.optional-dependencies]
dev = ["pytest>=7", "pytest-cov"] dev = ["pytest>=7", "pytest-cov", "ruff>=0.6"]
[project.scripts] [project.scripts]
openrgb-hue = "openrgb_hue.cli:app" openrgb-hue = "openrgb_hue.cli:app"
@@ -30,3 +30,13 @@ packages = ["src/openrgb_hue"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["tests"] 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 math
import time 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 import client as client_mod
from openrgb_hue.client import LedRef from openrgb_hue.client import LedRef
@@ -124,7 +125,7 @@ def build_animation(
def run_animation( def run_animation(
client: "OpenRGBClient", client: OpenRGBClient,
base: dict[LedRef, float], base: dict[LedRef, float],
gradient: Gradient, gradient: Gradient,
animation: AnimationMode, 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. delegates to the other modules, and renders output.
""" """
from typing import List, Optional
import typer import typer
from rich import box from rich import box
from rich.console import Console from rich.console import Console
@@ -49,7 +47,7 @@ def main(
ctx: typer.Context, ctx: typer.Context,
host: str = typer.Option("127.0.0.1", envvar="OPENRGB_HOST", help="OpenRGB SDK server host."), 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."), 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, "--version", callback=_version_callback, is_eager=True, help="Show the version and exit."
), ),
) -> None: ) -> None:
@@ -61,17 +59,15 @@ def _error_exit(message: str) -> None:
raise typer.Exit(code=1) 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() text = Text()
for color in colors: for color in colors:
text.append(" " * width, style=f"on #{color.to_hex()}") text.append(" " * width, style=f"on #{color.to_hex()}")
return text return text
def _build_target_filter(device: List[str], device_type: List[str], zone: List[str]) -> targets_mod.TargetFilter: def _build_target_filter(device: list[str], device_type: list[str], zone: list[str]) -> targets_mod.TargetFilter:
return targets_mod.TargetFilter( return targets_mod.TargetFilter(device_names=tuple(device), device_types=tuple(device_type), zone_names=tuple(zone))
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: 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)) _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: if dry_run:
return None, targets_mod.synthetic_targets(dry_run_leds) return None, targets_mod.synthetic_targets(dry_run_leds)
try: try:
@@ -111,7 +114,7 @@ def _build_mapping(mapping: str, direction: str, seed: int) -> mapping_mod.Mappi
_error_exit(str(exc)) _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 = {} by_device: dict = {}
for led in targets: for led in targets:
by_device.setdefault(led.device_name, []).append(led) by_device.setdefault(led.device_name, []).append(led)
@@ -155,7 +158,7 @@ def devices(
@scenes_app.command("list") @scenes_app.command("list")
def scenes_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: ) -> None:
"""List all bundled Hue scenes with a gradient preview.""" """List all bundled Hue scenes with a gradient preview."""
scenes = scenes_mod.load_scenes() scenes = scenes_mod.load_scenes()
@@ -194,7 +197,9 @@ def scenes_show(
table.add_column("Hex") table.add_column("Hex")
table.add_column("Brightness", justify="right") table.add_column("Brightness", justify="right")
for light in scene.lights: 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) console.print(table)
gradient = Gradient.from_scene(scene, dedupe=dedupe, interpolation=interpolation, use_brightness=brightness) gradient = Gradient.from_scene(scene, dedupe=dedupe, interpolation=interpolation, use_brightness=brightness)
@@ -222,11 +227,11 @@ def scenes_update(
def apply( def apply(
ctx: typer.Context, ctx: typer.Context,
scene: str = typer.Argument(..., help="Hue scene name (see `scenes list`)."), 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: list[str] = typer.Option([], "--device", help="Target only devices with this name (repeatable)."),
device_type: List[str] = typer.Option( device_type: list[str] = typer.Option(
[], "--device-type", help="Target only devices of this type, e.g. gpu, motherboard, ledstrip (repeatable)." [], "--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)}."), 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)}."), direction: str = typer.Option("left-right", help=f"Matrix direction: {', '.join(mapping_mod.DIRECTIONS)}."),
seed: int = typer.Option(0, help="Shuffle mapping seed."), 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."), 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."), 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: 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: ) -> None:
"""Apply a Hue scene as a static gradient.""" """Apply a Hue scene as a static gradient."""
gradient = _load_gradient(scene, dedupe, interpolation, brightness) gradient = _load_gradient(scene, dedupe, interpolation, brightness)
@@ -259,11 +264,11 @@ def apply(
def animate( def animate(
ctx: typer.Context, ctx: typer.Context,
scene: str = typer.Argument(..., help="Hue scene name (see `scenes list`)."), 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: list[str] = typer.Option([], "--device", help="Target only devices with this name (repeatable)."),
device_type: List[str] = typer.Option( device_type: list[str] = typer.Option(
[], "--device-type", help="Target only devices of this type, e.g. gpu, motherboard, ledstrip (repeatable)." [], "--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)}."), 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)}."), direction: str = typer.Option("left-right", help=f"Matrix direction: {', '.join(mapping_mod.DIRECTIONS)}."),
seed: int = typer.Option(0, help="Shuffle mapping seed."), 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)}."), 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)."), 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."), 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)."), 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."), 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."), 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: 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."), dry_run_frames: int = typer.Option(5, help="Number of sample frames to preview with --dry-run."),
) -> None: ) -> None:
"""Apply a Hue scene as an animated gradient.""" """Apply a Hue scene as an animated gradient."""
@@ -329,9 +334,9 @@ def animate(
@app.command() @app.command()
def off( def off(
ctx: typer.Context, ctx: typer.Context,
device: List[str] = typer.Option([], "--device", help="Target only devices 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)."), 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)."), zone: list[str] = typer.Option([], "--zone", help="Target only zones with this name (repeatable)."),
) -> None: ) -> None:
"""Turn off targeted LEDs (or all LEDs, if no filters are given).""" """Turn off targeted LEDs (or all LEDs, if no filters are given)."""
try: 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(): for device_index, led_colors in by_device.items():
device: Device = client.ee_devices[device_index] device: Device = client.ee_devices[device_index]
current = device.colors current = device.colors
new_colors = [ new_colors = [led_colors[i].to_openrgb() if i in led_colors else current[i] for i in range(len(current))]
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) device.set_colors(new_colors, fast=fast)
def snapshot_colors(client: OpenRGBClient) -> dict[int, list[Color]]: def snapshot_colors(client: OpenRGBClient) -> dict[int, list[Color]]:
return { return {i: [Color(c.red, c.green, c.blue) for c in device.colors] for i, device in enumerate(client.ee_devices)}
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: 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)) object.__setattr__(self, "b", _clamp255(self.b))
@classmethod @classmethod
def from_hex(cls, value: str) -> "Color": def from_hex(cls, value: str) -> Color:
value = value.lstrip("#") value = value.lstrip("#")
if len(value) != 6: if len(value) != 6:
raise ValueError(f"Expected a 6-digit hex color, got {value!r}") raise ValueError(f"Expected a 6-digit hex color, got {value!r}")
@@ -39,12 +39,12 @@ class Color:
def to_hex(self) -> str: def to_hex(self) -> str:
return f"{self.r:02x}{self.g:02x}{self.b:02x}" 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 from openrgb.utils import RGBColor
return RGBColor(self.r, self.g, self.b) 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) factor = max(0.0, factor)
return Color(self.r * factor, self.g * factor, self.b * 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 from __future__ import annotations
import itertools
from collections.abc import Sequence
from dataclasses import dataclass 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 from openrgb_hue.color import Color, hsv_lerp, rgb_lerp
@@ -43,23 +45,23 @@ class Gradient:
return list(self._stops) return list(self._stops)
@classmethod @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) colors = list(colors)
if not colors: if not colors:
raise ValueError("Gradient needs at least one color") raise ValueError("Gradient needs at least one color")
n = len(colors) n = len(colors)
positions = [0.0] if n == 1 else [i / (n - 1) for i in range(n)] 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 @classmethod
def from_scene( def from_scene(
cls, cls,
scene: "Scene", scene: Scene,
*, *,
dedupe: bool = True, dedupe: bool = True,
interpolation: Interpolation = "hsv", interpolation: Interpolation = "hsv",
use_brightness: bool = True, use_brightness: bool = True,
) -> "Gradient": ) -> Gradient:
colors: list[Color] = [] colors: list[Color] = []
for light in scene.lights: for light in scene.lights:
color = light.color.scale_brightness(light.brightness / 255) if use_brightness else light.color 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 t_eff = t + 1.0 if t < stops[0].position else t
extended = stops + [Stop(stops[0].position + 1.0, stops[0].color)] extended = stops + [Stop(stops[0].position + 1.0, stops[0].color)]
lerp = _LERP[self.interpolation] 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: if a.position <= t_eff <= b.position:
span = b.position - a.position span = b.position - a.position
local_t = 0.0 if span == 0 else (t_eff - a.position) / span 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 math
import random import random
from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from typing import Callable, ClassVar, Protocol from typing import ClassVar, Protocol
from openrgb_hue.client import LedRef from openrgb_hue.client import LedRef
from openrgb_hue.color import Color from openrgb_hue.color import Color
@@ -113,8 +114,7 @@ class MatrixMapping:
non_matrix_leds.append(led) non_matrix_leds.append(led)
if non_matrix_leds and not warned: if non_matrix_leds and not warned:
self._warn( self._warn(
"Some targeted LEDs are not part of a matrix zone; they will be held at a " "Some targeted LEDs are not part of a matrix zone; they will be held at a fixed gradient position."
"fixed gradient position."
) )
warned = True warned = True
for led in non_matrix_leds: 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)] positions = [0.0] if n <= 1 else [i / (n - 1) for i in range(n)]
shuffled = list(targets) shuffled = list(targets)
random.Random(self.seed).shuffle(shuffled) random.Random(self.seed).shuffle(shuffled)
return dict(zip(shuffled, positions)) return dict(zip(shuffled, positions, strict=True))
def build_mapping( def build_mapping(
+1 -3
View File
@@ -73,9 +73,7 @@ def get_scene(name: str, path: Path | None = None) -> Scene:
return scene return scene
suggestions = get_close_matches(name, scenes.keys(), n=3) suggestions = get_close_matches(name, scenes.keys(), n=3)
hint = f" Did you mean: {', '.join(suggestions)}?" if suggestions else "" hint = f" Did you mean: {', '.join(suggestions)}?" if suggestions else ""
raise SceneNotFoundError( raise SceneNotFoundError(f"No scene named {name!r}.{hint} Run `openrgb-hue scenes list` to see all scenes.")
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: 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)] targets = [led for led in all_leds if _matches(led, filt)]
if not targets: if not targets:
raise NoTargetsError( raise NoTargetsError(
"No LEDs matched the given filters. Run `openrgb-hue devices --leds` to see available " "No LEDs matched the given filters. Run `openrgb-hue devices --leds` to see available device/zone names."
"device/zone names."
) )
return targets return targets
@@ -59,9 +58,7 @@ def synthetic_targets(n: int | None = None) -> list[LedRef]:
leds: list[LedRef] = [] leds: list[LedRef] = []
# Two linear strips of different lengths... # Two linear strips of different lengths...
for device_index, (device_name, count) in enumerate( for device_index, (device_name, count) in enumerate([("Dry-Run LED Strip", 12), ("Dry-Run Motherboard", 8)]):
[("Dry-Run LED Strip", 12), ("Dry-Run Motherboard", 8)]
):
for i in range(count): for i in range(count):
leds.append( leds.append(
LedRef( LedRef(
+1 -1
View File
@@ -13,8 +13,8 @@ from openrgb_hue.animation import (
build_animation, build_animation,
run_animation, run_animation,
) )
from openrgb_hue.gradient import Gradient
from openrgb_hue.color import Color from openrgb_hue.color import Color
from openrgb_hue.gradient import Gradient
from openrgb_hue.mapping import SequenceMapping, base_positions from openrgb_hue.mapping import SequenceMapping, base_positions
from openrgb_hue.targets import synthetic_targets 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"]) @pytest.mark.parametrize("mode", ["static", "scroll", "pingpong", "pulse", "wave"])
def test_animate_dry_run_across_animation_modes(mode): def test_animate_dry_run_across_animation_modes(mode):
result = runner.invoke( result = runner.invoke(app, ["animate", "Tropical twilight", "--dry-run", "--mode", mode, "--dry-run-frames", "3"])
app, ["animate", "Tropical twilight", "--dry-run", "--mode", mode, "--dry-run-frames", "3"]
)
assert result.exit_code == 0, result.output assert result.exit_code == 0, result.output
assert "t=0.00s" in result.output assert "t=0.00s" in result.output