diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..999f5fb --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.py[cod] +.pytest_cache/ +.ruff_cache/ +.venv/ diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..2c07333 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.11 diff --git a/README.md b/README.md new file mode 100644 index 0000000..e19716f --- /dev/null +++ b/README.md @@ -0,0 +1,9 @@ +# dpg-gauges + +Dear PyGui gauge widgets for dashboards and telemetry displays. + +Current implementation status: Step 2 logical state is implemented. Gauge creation functions build +and register pure gauge state without requiring a Dear PyGui context; final widget shells and drawing +arrive in later steps. + +Runtime value updates are intended for the Dear PyGui GUI thread once rendering is implemented. diff --git a/codex/AGENTS.md b/codex/AGENTS.md index 668df4e..bc0d087 100644 --- a/codex/AGENTS.md +++ b/codex/AGENTS.md @@ -2,15 +2,44 @@ ## Current status -Project instruction set initialized. +Step 2 completed and verified. Ready to start Step 3. ## Completed steps -None yet. +Initial setup before Step 1: + +- `uv` package metadata exists. +- Runtime dependency `dearpygui` is installed. +- Dev dependencies `pytest`, `ruff`, and `pyright` are installed. +- Required package skeleton exists under `src/dpg_gauges/`. +- `examples/`, `tests/`, and `docs/` directories exist. +- `pyproject.toml` is configured for Python 3.11+, Ruff, and Pyright. + +Step 1 - Public API contract and pure core: + +- Public API exports are defined in `src/dpg_gauges/__init__.py`. +- Public exceptions are defined in `src/dpg_gauges/exceptions.py`. +- Shared public dataclasses and type aliases are defined in `src/dpg_gauges/types.py`. +- Pure gauge configuration dataclasses are defined in `src/dpg_gauges/gauges.py`. +- Pure scale helpers are defined in `src/dpg_gauges/scales.py`. +- Lightweight theme/style presets are defined in `src/dpg_gauges/themes.py`. +- GUI-dependent public functions are import-safe stubs for Step 1. +- Step 1 tests cover public imports, dataclass construction, clamp/normalize math, tick generation, threshold validation, formatting, and missing-gauge stubs. ## Current step -Step 1 - Public API contract and pure core. +Step 3 - Widget shell and renderer foundation. + +Step 2 - State, sizing, animation, and smoothing model: + +Implemented during Step 2: + +- Added `DpgGaugesConfig`, `configure(...)` wiring, `GaugeState`, `DirtyFlags`, and the global gauge registry. +- Runtime API functions now mutate logical state for values, config updates, thresholds, markers, visibility, and deletion without Dear PyGui calls. +- Added pure sizing helpers for requested/measured size, last non-zero size preservation, square rects, and stretch rects. +- Added pure animation interpolation helpers and smoothing progression helpers. +- Added debug state reporting for registered gauges. +- Added Step 2 tests for registry behaviour, clamped/displayed separation, dirty flags, sizing, animation, smoothing, and invalid values. ## Design decisions @@ -25,6 +54,7 @@ Step 1 - Public API contract and pure core. - Gauges are display-only for the first release. - Thresholds and redline zones are visual only; they do not trigger events. - The public API should feel like normal Dear PyGui context-manager usage. +- Each completed build step must be checked, documented in this file, and committed before starting the next step. ## Known issues @@ -35,7 +65,26 @@ None yet. - Read `codex-old/README.md`, `codex-old/FEATURES.md`, `codex-old/ARCHITECTURE.md`, `codex-old/STEPS.md`, and `codex-old/AGENTS.md`. - Inspected `/home/hector/projects/dpg-map` package, examples, tests, docs, and pyproject structure. - Created the initial `codex/` instruction documents for `dpg-gauges`. +- Read `codex/README.md`, `codex/FEATURES.md`, `codex/ARCHITECTURE.md`, `codex/STEPS.md`, and `codex/AGENTS.md`. +- Completed missing pre-Step-1 skeleton files and directories. +- Ran `uv run python -c "import dpg_gauges; print(dpg_gauges.__name__)"`. +- Ran `uv run ruff check .`. +- Ran `uv run ruff format .`. +- Read all files under `codex/`. +- Implemented Step 1 public API exports and pure core modules. +- Added `tests/test_step1_core.py`. +- Ran `uv run pytest`. +- Ran `uv run ruff check .`. +- Ran `uv run ruff format --check .`. +- Ran `uv run pyright`. +- Read `codex/FEATURES.md`, `codex/ARCHITECTURE.md`, `codex/STEPS.md`, and `codex/AGENTS.md` before Step 2 changes. +- Implemented Step 2 state, sizing, animation, smoothing, runtime API, diagnostics, tests, and README status update. +- Ran `uv run pytest`. +- Ran `uv run ruff check .`. +- Ran `uv run ruff format --check .`. +- Ran `uv run pyright`. +- Updated instructions to require a commit after each completed step. ## Next action -Implement Step 1. +Implement Step 3. diff --git a/codex/STEPS.md b/codex/STEPS.md index 347b102..059cc9d 100644 --- a/codex/STEPS.md +++ b/codex/STEPS.md @@ -8,12 +8,13 @@ There is no Step 0. Initial setup is listed separately, then implementation star 2. Always read `codex/FEATURES.md`, `codex/ARCHITECTURE.md`, and `codex/AGENTS.md` before making code changes. 3. Keep `codex/AGENTS.md` as a rolling log of what has been done, what is broken, and what comes next. 4. Update `README.md` and `docs/` whenever public behaviour or examples change. -5. After every step, update `codex/AGENTS.md`, run relevant checks, and commit if working in git. +5. After every step, update `codex/AGENTS.md`, run relevant checks, and commit the completed step when working in git. 6. Do not casually change public API once introduced. 7. Dear PyGui calls must happen on the GUI thread only. 8. Runtime gauge updates are GUI-thread-only unless explicitly documented otherwise. 9. Prefer stable, boring implementation over clever drawing tricks. 10. Keep each step shippable and tested before moving to the next. +11. Do not start the next step while the previous completed step is still uncommitted. ## Initial setup instructions diff --git a/docs/.gitkeep b/docs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/examples/.gitkeep b/examples/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6253b55 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,36 @@ +[project] +name = "dpg-gauges" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +authors = [ + { name = "Hector van der Aa", email = "hector@h3cx.dev" } +] +requires-python = ">=3.11" +dependencies = [ + "dearpygui>=2.3.1", +] + +[project.scripts] +dpg-gauges = "dpg_gauges:main" + +[build-system] +requires = ["uv_build>=0.11.21,<0.12.0"] +build-backend = "uv_build" + +[dependency-groups] +dev = [ + "pyright>=1.1.411", + "pytest>=9.1.1", + "ruff>=0.15.20", +] + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "SIM"] + +[tool.pyright] +typeCheckingMode = "basic" diff --git a/src/dpg_gauges/__init__.py b/src/dpg_gauges/__init__.py new file mode 100644 index 0000000..667dea2 --- /dev/null +++ b/src/dpg_gauges/__init__.py @@ -0,0 +1,74 @@ +"""Dear PyGui gauge widgets.""" + +from .api import ( + analog_gauge, + bar_gauge, + battery_gauge, + compass_gauge, + configure, + configure_gauge, + delete_gauge, + digital_gauge, + gauge_grid, + gauge_panel, + get_value, + level_gauge, + multi_value_gauge, + segmented_gauge, + set_markers, + set_show, + set_thresholds, + set_value, + status_light, + thermometer_gauge, + update_gauge, +) +from .diagnostics import get_gauge_debug_state +from .themes import GaugeStyle, GaugeTheme +from .types import ( + AnimationConfig, + GaugeMarker, + GaugeStats, + GaugeValue, + SmoothingConfig, + StatusState, + ThresholdBand, +) + +__all__ = [ + "configure", + "GaugeTheme", + "GaugeStyle", + "AnimationConfig", + "SmoothingConfig", + "ThresholdBand", + "GaugeMarker", + "StatusState", + "GaugeValue", + "GaugeStats", + "analog_gauge", + "digital_gauge", + "bar_gauge", + "level_gauge", + "status_light", + "segmented_gauge", + "compass_gauge", + "thermometer_gauge", + "battery_gauge", + "multi_value_gauge", + "gauge_panel", + "gauge_grid", + "set_value", + "get_value", + "update_gauge", + "configure_gauge", + "set_thresholds", + "set_markers", + "set_show", + "delete_gauge", + "get_gauge_debug_state", +] + + +def main() -> None: + print("dpg-gauges is a library package; import dpg_gauges to use it.") diff --git a/src/dpg_gauges/animation.py b/src/dpg_gauges/animation.py new file mode 100644 index 0000000..9003436 --- /dev/null +++ b/src/dpg_gauges/animation.py @@ -0,0 +1,72 @@ +"""Pure animation interpolation helpers.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from .types import AnimationConfig + + +@dataclass +class AnimationState: + start_value: float | None = None + target_value: float | None = None + started_at: float = 0.0 + duration: float = 0.0 + active: bool = False + + +def easing_fraction(fraction: float, easing: str = "linear") -> float: + fraction = min(max(fraction, 0.0), 1.0) + if easing == "linear": + return fraction + if easing == "ease_in": + return fraction * fraction + if easing == "ease_out": + return 1.0 - (1.0 - fraction) * (1.0 - fraction) + if easing == "ease_in_out": + if fraction < 0.5: + return 2.0 * fraction * fraction + return 1.0 - ((-2.0 * fraction + 2.0) ** 2) / 2.0 + return fraction + + +def interpolate(start: float, end: float, fraction: float, easing: str = "linear") -> float: + eased = easing_fraction(fraction, easing) + return start + (end - start) * eased + + +def start_animation( + displayed_value: float | None, + target_value: float | None, + config: AnimationConfig | None, + now: float, +) -> AnimationState: + if displayed_value is None or target_value is None: + return AnimationState(target_value, target_value, now, 0.0, False) + if ( + config is None + or not config.enabled + or config.duration <= 0 + or displayed_value == target_value + ): + return AnimationState(target_value, target_value, now, 0.0, False) + return AnimationState(displayed_value, target_value, now, config.duration, True) + + +def advance_animation( + state: AnimationState, config: AnimationConfig | None, now: float +) -> tuple[float | None, bool]: + if not state.active: + return state.target_value, False + if state.start_value is None or state.target_value is None or state.duration <= 0: + state.active = False + return state.target_value, False + + fraction = (now - state.started_at) / state.duration + if fraction >= 1.0: + state.active = False + return state.target_value, False + + easing = config.easing if config is not None else "linear" + return interpolate(state.start_value, state.target_value, fraction, easing), True diff --git a/src/dpg_gauges/api.py b/src/dpg_gauges/api.py new file mode 100644 index 0000000..8989b25 --- /dev/null +++ b/src/dpg_gauges/api.py @@ -0,0 +1,129 @@ +"""Public API wrappers for logical gauge state.""" + +from __future__ import annotations + +from contextlib import nullcontext +from typing import Any, cast + +from .gauges import ( + AnalogGaugeConfig, + BarGaugeConfig, + BatteryGaugeConfig, + CompassGaugeConfig, + DigitalGaugeConfig, + GaugeConfig, + LevelGaugeConfig, + MultiValueGaugeConfig, + SegmentedGaugeConfig, + StatusLightConfig, + ThermometerGaugeConfig, +) +from .state import ( + configure_gauges, + delete_runtime_gauge, + get_runtime_value, + register_gauge, + set_runtime_markers, + set_runtime_show, + set_runtime_thresholds, + set_runtime_value, + update_runtime_gauge, +) +from .types import GaugeMarker, GaugeValue, Tag, ThresholdBand + + +def configure(**config: Any) -> None: + configure_gauges(**config) + + +def analog_gauge(**config: Any) -> nullcontext[AnalogGaugeConfig]: + state = register_gauge("analog", AnalogGaugeConfig(**config)) + return nullcontext(cast(AnalogGaugeConfig, state.config)) + + +def digital_gauge(**config: Any) -> nullcontext[DigitalGaugeConfig]: + state = register_gauge("digital", DigitalGaugeConfig(**config)) + return nullcontext(cast(DigitalGaugeConfig, state.config)) + + +def bar_gauge(**config: Any) -> nullcontext[BarGaugeConfig]: + state = register_gauge("bar", BarGaugeConfig(**config)) + return nullcontext(cast(BarGaugeConfig, state.config)) + + +def level_gauge(**config: Any) -> nullcontext[LevelGaugeConfig]: + state = register_gauge("level", LevelGaugeConfig(**config)) + return nullcontext(cast(LevelGaugeConfig, state.config)) + + +def status_light(**config: Any) -> nullcontext[StatusLightConfig]: + state = register_gauge("status_light", StatusLightConfig(**config)) + return nullcontext(cast(StatusLightConfig, state.config)) + + +def segmented_gauge(**config: Any) -> nullcontext[SegmentedGaugeConfig]: + state = register_gauge("segmented", SegmentedGaugeConfig(**config)) + return nullcontext(cast(SegmentedGaugeConfig, state.config)) + + +def compass_gauge(**config: Any) -> nullcontext[CompassGaugeConfig]: + state = register_gauge("compass", CompassGaugeConfig(**config)) + return nullcontext(cast(CompassGaugeConfig, state.config)) + + +def thermometer_gauge(**config: Any) -> nullcontext[ThermometerGaugeConfig]: + state = register_gauge("thermometer", ThermometerGaugeConfig(**config)) + return nullcontext(cast(ThermometerGaugeConfig, state.config)) + + +def battery_gauge(**config: Any) -> nullcontext[BatteryGaugeConfig]: + state = register_gauge("battery", BatteryGaugeConfig(**config)) + return nullcontext(cast(BatteryGaugeConfig, state.config)) + + +def multi_value_gauge(**config: Any) -> nullcontext[MultiValueGaugeConfig]: + state = register_gauge("multi_value", MultiValueGaugeConfig(**config)) + return nullcontext(cast(MultiValueGaugeConfig, state.config)) + + +def gauge_panel(**_config: Any) -> nullcontext[None]: + return nullcontext(None) + + +def gauge_grid(**_config: Any) -> nullcontext[None]: + return nullcontext(None) + + +def set_value(tag: Tag, value: GaugeValue) -> None: + set_runtime_value(tag, value) + + +def get_value(tag: Tag) -> GaugeValue: + return get_runtime_value(tag) + + +def update_gauge(tag: Tag, **config: Any) -> None: + update_runtime_gauge(tag, **config) + + +def configure_gauge(tag: Tag, **config: Any) -> None: + update_runtime_gauge(tag, **config) + + +def set_thresholds(tag: Tag, thresholds: list[ThresholdBand] | tuple[ThresholdBand, ...]) -> None: + set_runtime_thresholds(tag, thresholds) + + +def set_markers(tag: Tag, markers: list[GaugeMarker] | tuple[GaugeMarker, ...]) -> None: + set_runtime_markers(tag, markers) + + +def set_show(tag: Tag, show: bool) -> None: + set_runtime_show(tag, show) + + +def delete_gauge(tag: Tag) -> None: + delete_runtime_gauge(tag) + + +__all_configs__ = (GaugeConfig,) diff --git a/src/dpg_gauges/diagnostics.py b/src/dpg_gauges/diagnostics.py new file mode 100644 index 0000000..d9326aa --- /dev/null +++ b/src/dpg_gauges/diagnostics.py @@ -0,0 +1,28 @@ +"""Diagnostics helpers.""" + +from __future__ import annotations + +from typing import Any + +from .state import get_gauge_state +from .types import Tag + + +def get_gauge_debug_state(tag: Tag) -> dict[str, Any]: + state = get_gauge_state(tag) + return { + "tag": state.tag, + "gauge_type": state.gauge_type, + "target_value": state.target_value, + "clamped_value": state.clamped_value, + "displayed_value": state.displayed_value, + "invalid_value": state.invalid_value, + "measured_size": (state.measured_width, state.measured_height), + "last_nonzero_size": (state.last_nonzero_width, state.last_nonzero_height), + "dirty": state.dirty, + "dirty_names": [flag.name for flag in type(state.dirty) if flag in state.dirty], + "value_revision": state.value_revision, + "config_revision": state.config_revision, + "animation_active": state.animation_state.active, + "smoothing_active": state.smoothing_state.active, + } diff --git a/src/dpg_gauges/draw_layers.py b/src/dpg_gauges/draw_layers.py new file mode 100644 index 0000000..e69de29 diff --git a/src/dpg_gauges/exceptions.py b/src/dpg_gauges/exceptions.py new file mode 100644 index 0000000..d905efd --- /dev/null +++ b/src/dpg_gauges/exceptions.py @@ -0,0 +1,25 @@ +"""Public exceptions for dpg-gauges.""" + + +class DpgGaugesError(Exception): + """Base exception for all dpg-gauges errors.""" + + +class GaugeNotFoundError(DpgGaugesError): + """Raised when a gauge tag cannot be found.""" + + +class GaugeConfigError(DpgGaugesError): + """Raised when gauge configuration is invalid.""" + + +class GaugeValueError(DpgGaugesError): + """Raised when a gauge value cannot be accepted.""" + + +class GaugeTypeError(DpgGaugesError): + """Raised when a gauge type is invalid or unsupported.""" + + +class ThreadingError(DpgGaugesError): + """Raised when Dear PyGui operations are called from the wrong thread.""" diff --git a/src/dpg_gauges/gauges.py b/src/dpg_gauges/gauges.py new file mode 100644 index 0000000..03e8123 --- /dev/null +++ b/src/dpg_gauges/gauges.py @@ -0,0 +1,150 @@ +"""Pure gauge configuration dataclasses.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass, replace +from typing import Any, Literal + +from .scales import validate_markers, validate_range, validate_thresholds +from .themes import GaugeStyle, GaugeTheme, get_theme, merge_style +from .types import ( + AnimationConfig, + Callback, + GaugeMarker, + GaugeValue, + SmoothingConfig, + StatusState, + Tag, + ThresholdBand, + ValueFormatter, +) + + +def _normalize_animation(value: AnimationConfig | bool | None) -> AnimationConfig | None: + if isinstance(value, bool): + return AnimationConfig(enabled=value) + return value + + +def _normalize_smoothing(value: SmoothingConfig | bool | None) -> SmoothingConfig | None: + if isinstance(value, bool): + return SmoothingConfig(enabled=value) + return value + + +@dataclass(frozen=True) +class GaugeConfig: + tag: Tag | None = None + label: str | None = None + value: GaugeValue = None + min_value: float = 0.0 + max_value: float = 100.0 + unit: str = "" + precision: int = 0 + width: int = 0 + height: int = 0 + autosize_x: bool = False + autosize_y: bool = False + show: bool = True + show_label: bool = True + show_value: bool = True + show_unit: bool = True + tooltip: str | None = None + callback: Callback | None = None + user_data: Any = None + theme: str | GaugeTheme | None = None + style: GaugeStyle | None = None + animation: AnimationConfig | bool | None = None + smoothing: SmoothingConfig | bool | None = None + clamp: bool = True + format_value: ValueFormatter | None = None + thresholds: Sequence[ThresholdBand] = () + markers: Sequence[GaugeMarker] = () + + def __post_init__(self) -> None: + validate_range(self.min_value, self.max_value) + validate_thresholds(self.thresholds, self.min_value, self.max_value) + validate_markers(self.markers, self.min_value, self.max_value) + if self.precision < 0: + msg = "precision must be non-negative" + raise ValueError(msg) + object.__setattr__(self, "theme", get_theme(self.theme)) + object.__setattr__(self, "style", self.style or get_theme(self.theme).style) + object.__setattr__(self, "animation", _normalize_animation(self.animation)) + object.__setattr__(self, "smoothing", _normalize_smoothing(self.smoothing)) + object.__setattr__(self, "thresholds", tuple(self.thresholds)) + object.__setattr__(self, "markers", tuple(self.markers)) + + def with_style_overrides(self, **overrides: Any) -> GaugeConfig: + return replace(self, style=merge_style(self.style, **overrides)) + + +@dataclass(frozen=True) +class AnalogGaugeConfig(GaugeConfig): + start_angle: float = 225.0 + end_angle: float = -45.0 + major_ticks: int = 8 + minor_ticks: int = 4 + show_tick_labels: bool = True + needle_length: float = 0.82 + needle_width: float = 3.0 + center_cap_radius: float = 7.0 + arc_width: float = 8.0 + maintain_aspect: bool = True + redline_start: float | None = None + + +@dataclass(frozen=True) +class DigitalGaugeConfig(GaugeConfig): + pass + + +@dataclass(frozen=True) +class BarGaugeConfig(GaugeConfig): + orientation: Literal["horizontal", "vertical"] = "horizontal" + fill_mode: Literal["clamp", "centered"] = "clamp" + corner_radius: float = 4.0 + show_scale: bool = True + stretch: bool = True + + +@dataclass(frozen=True) +class LevelGaugeConfig(BarGaugeConfig): + orientation: Literal["horizontal", "vertical"] = "vertical" + + +@dataclass(frozen=True) +class StatusLightConfig(GaugeConfig): + state: str | bool | int = False + states: Mapping[Any, StatusState] | None = None + blink: bool = False + pulse: bool = False + + +@dataclass(frozen=True) +class SegmentedGaugeConfig(BarGaugeConfig): + segments: int = 10 + + +@dataclass(frozen=True) +class CompassGaugeConfig(AnalogGaugeConfig): + min_value: float = 0.0 + max_value: float = 360.0 + start_angle: float = 90.0 + end_angle: float = -270.0 + + +@dataclass(frozen=True) +class ThermometerGaugeConfig(LevelGaugeConfig): + pass + + +@dataclass(frozen=True) +class BatteryGaugeConfig(LevelGaugeConfig): + charging: bool | None = None + + +@dataclass(frozen=True) +class MultiValueGaugeConfig(GaugeConfig): + values: Mapping[str, GaugeValue] | None = None diff --git a/src/dpg_gauges/renderer.py b/src/dpg_gauges/renderer.py new file mode 100644 index 0000000..e69de29 diff --git a/src/dpg_gauges/scales.py b/src/dpg_gauges/scales.py new file mode 100644 index 0000000..7e49040 --- /dev/null +++ b/src/dpg_gauges/scales.py @@ -0,0 +1,104 @@ +"""Pure scale, formatting, and value mapping helpers.""" + +from __future__ import annotations + +import math +from collections.abc import Sequence + +from .exceptions import GaugeConfigError, GaugeValueError +from .types import GaugeMarker, ThresholdBand + + +def validate_range(min_value: float, max_value: float) -> None: + if not math.isfinite(min_value) or not math.isfinite(max_value): + msg = "gauge range values must be finite" + raise GaugeConfigError(msg) + if min_value >= max_value: + msg = "min_value must be less than max_value" + raise GaugeConfigError(msg) + + +def is_finite_number(value: object) -> bool: + return isinstance(value, int | float) and not isinstance(value, bool) and math.isfinite(value) + + +def clamp_value(value: float, min_value: float, max_value: float) -> float: + validate_range(min_value, max_value) + if not math.isfinite(value): + msg = "value must be finite" + raise GaugeValueError(msg) + return min(max(value, min_value), max_value) + + +def normalize_value( + value: float, min_value: float, max_value: float, *, clamp: bool = True +) -> float: + validate_range(min_value, max_value) + if clamp: + value = clamp_value(value, min_value, max_value) + if not math.isfinite(value): + msg = "value must be finite" + raise GaugeValueError(msg) + return (value - min_value) / (max_value - min_value) + + +def value_to_angle( + value: float, + min_value: float, + max_value: float, + start_angle: float, + end_angle: float, + *, + clamp: bool = True, +) -> float: + fraction = normalize_value(value, min_value, max_value, clamp=clamp) + return start_angle + (end_angle - start_angle) * fraction + + +def fill_fraction(value: float, min_value: float, max_value: float, *, clamp: bool = True) -> float: + return normalize_value(value, min_value, max_value, clamp=clamp) + + +def generate_ticks(min_value: float, max_value: float, count: int) -> list[float]: + validate_range(min_value, max_value) + if count < 2: + msg = "tick count must be at least 2" + raise GaugeConfigError(msg) + step = (max_value - min_value) / (count - 1) + return [min_value + step * index for index in range(count)] + + +def format_gauge_value(value: float, precision: int = 0, unit: str = "") -> str: + if precision < 0: + msg = "precision must be non-negative" + raise GaugeConfigError(msg) + if not math.isfinite(value): + return "--" + formatted = f"{value:.{precision}f}" + return f"{formatted} {unit}" if unit else formatted + + +def validate_thresholds( + thresholds: Sequence[ThresholdBand], min_value: float, max_value: float +) -> None: + validate_range(min_value, max_value) + for threshold in thresholds: + if not math.isfinite(threshold.start) or not math.isfinite(threshold.end): + msg = "threshold values must be finite" + raise GaugeConfigError(msg) + + +def validate_markers(markers: Sequence[GaugeMarker], min_value: float, max_value: float) -> None: + validate_range(min_value, max_value) + for marker in markers: + if not math.isfinite(marker.value): + msg = "marker values must be finite" + raise GaugeConfigError(msg) + + +def clamped_threshold( + threshold: ThresholdBand, min_value: float, max_value: float +) -> tuple[float, float]: + start = clamp_value(threshold.start, min_value, max_value) + end = clamp_value(threshold.end, min_value, max_value) + return min(start, end), max(start, end) diff --git a/src/dpg_gauges/sizing.py b/src/dpg_gauges/sizing.py new file mode 100644 index 0000000..8ed4553 --- /dev/null +++ b/src/dpg_gauges/sizing.py @@ -0,0 +1,64 @@ +"""Pure sizing helpers for gauge layout state.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class RequestedSize: + width: int = 0 + height: int = 0 + autosize_x: bool = False + autosize_y: bool = False + + +@dataclass +class SizeState: + requested: RequestedSize = RequestedSize() + measured_width: int = 0 + measured_height: int = 0 + last_nonzero_width: int = 0 + last_nonzero_height: int = 0 + + +@dataclass(frozen=True) +class Rect: + x: float + y: float + width: float + height: float + + +def requested_size( + width: int = 0, height: int = 0, *, autosize_x: bool = False, autosize_y: bool = False +) -> RequestedSize: + return RequestedSize(width, height, autosize_x, autosize_y) + + +def update_measured_size(state: SizeState, width: int, height: int) -> bool: + width = max(width, 0) + height = max(height, 0) + changed = state.measured_width != width or state.measured_height != height + state.measured_width = width + state.measured_height = height + if width > 0: + state.last_nonzero_width = width + if height > 0: + state.last_nonzero_height = height + return changed + + +def effective_size(state: SizeState) -> tuple[int, int]: + width = state.measured_width or state.last_nonzero_width or max(state.requested.width, 0) + height = state.measured_height or state.last_nonzero_height or max(state.requested.height, 0) + return width, height + + +def square_gauge_rect(width: int, height: int) -> Rect: + size = float(max(min(width, height), 0)) + return Rect((width - size) / 2.0, (height - size) / 2.0, size, size) + + +def stretch_gauge_rect(width: int, height: int) -> Rect: + return Rect(0.0, 0.0, float(max(width, 0)), float(max(height, 0))) diff --git a/src/dpg_gauges/smoothing.py b/src/dpg_gauges/smoothing.py new file mode 100644 index 0000000..10f7078 --- /dev/null +++ b/src/dpg_gauges/smoothing.py @@ -0,0 +1,45 @@ +"""Pure smoothing helpers for readable value transitions.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from .types import SmoothingConfig + + +@dataclass +class SmoothingState: + value: float | None = None + active: bool = False + + +def smooth_value( + previous: float | None, target: float | None, config: SmoothingConfig | None +) -> tuple[float | None, bool]: + if target is None: + return None, False + if previous is None or config is None or not config.enabled: + return target, False + + delta = target - previous + step = delta * config.alpha + if config.max_step is not None: + step = min(max(step, -config.max_step), config.max_step) + + value = previous + step + active = abs(target - value) > 1e-9 + return value, active + + +def advance_smoothing( + state: SmoothingState, target: float | None, config: SmoothingConfig | None +) -> tuple[float | None, bool]: + value, active = smooth_value(state.value, target, config) + state.value = value + state.active = active + return value, active + + +def reset_smoothing(state: SmoothingState, value: float | None = None) -> None: + state.value = value + state.active = False diff --git a/src/dpg_gauges/state.py b/src/dpg_gauges/state.py new file mode 100644 index 0000000..f62bb18 --- /dev/null +++ b/src/dpg_gauges/state.py @@ -0,0 +1,305 @@ +"""Global configuration, gauge registry, and runtime state.""" + +from __future__ import annotations + +import math +from dataclasses import dataclass, field, replace +from enum import IntFlag, auto +from typing import Any + +from .animation import AnimationState, start_animation +from .exceptions import GaugeConfigError, GaugeNotFoundError +from .gauges import GaugeConfig +from .scales import clamp_value, validate_markers, validate_thresholds +from .sizing import RequestedSize, SizeState +from .smoothing import SmoothingState +from .themes import GaugeTheme, get_theme +from .types import AnimationConfig, GaugeMarker, GaugeValue, SmoothingConfig, Tag, ThresholdBand + + +class DirtyFlags(IntFlag): + NONE = 0 + VALUE = auto() + STATIC = auto() + DYNAMIC = auto() + TEXT = auto() + SIZE = auto() + STYLE = auto() + DEBUG = auto() + FULL = VALUE | STATIC | DYNAMIC | TEXT | SIZE | STYLE | DEBUG + + +@dataclass +class DpgGaugesConfig: + default_theme: str | GaugeTheme | None = None + animation: AnimationConfig | bool | None = None + smoothing: SmoothingConfig | bool | None = None + default_width: int | None = None + default_height: int | None = None + frame_rate_limit: float | None = None + debug: bool = False + + def __post_init__(self) -> None: + if isinstance(self.animation, bool): + self.animation = AnimationConfig(enabled=self.animation) + if isinstance(self.smoothing, bool): + self.smoothing = SmoothingConfig(enabled=self.smoothing) + if self.default_theme is not None: + self.default_theme = get_theme(self.default_theme) + if self.default_width is not None and self.default_width < -1: + msg = "default_width must be -1, 0, or positive" + raise GaugeConfigError(msg) + if self.default_height is not None and self.default_height < -1: + msg = "default_height must be -1, 0, or positive" + raise GaugeConfigError(msg) + if self.frame_rate_limit is not None and self.frame_rate_limit <= 0: + msg = "frame_rate_limit must be greater than zero" + raise GaugeConfigError(msg) + + +@dataclass +class GaugeState: + tag: Tag + gauge_type: str + container_tag: Tag + drawlist_tag: Tag | None + text_tags: dict[str, Tag] + config: GaugeConfig + target_value: GaugeValue + clamped_value: float | None + displayed_value: float | None + previous_displayed_value: float | None + value_revision: int = 0 + config_revision: int = 0 + measured_width: int = 0 + measured_height: int = 0 + last_nonzero_width: int = 0 + last_nonzero_height: int = 0 + dirty: DirtyFlags = DirtyFlags.FULL + renderer: Any = None + animation_state: AnimationState = field(default_factory=AnimationState) + smoothing_state: SmoothingState = field(default_factory=SmoothingState) + invalid_value: bool = False + + +_config = DpgGaugesConfig() +_registry: dict[Tag, GaugeState] = {} +_next_auto_tag = 1 + + +def configure_gauges(**config: Any) -> DpgGaugesConfig: + global _config + _config = DpgGaugesConfig(**config) + return _config + + +def get_config() -> DpgGaugesConfig: + return _config + + +def clear_registry() -> None: + global _next_auto_tag + _registry.clear() + _next_auto_tag = 1 + + +def registry_size() -> int: + return len(_registry) + + +def resolve_tag(tag: Tag | None) -> Tag: + global _next_auto_tag + if tag is not None: + return tag + tag = f"dpg_gauge_{_next_auto_tag}" + _next_auto_tag += 1 + return tag + + +def apply_global_defaults(config: GaugeConfig) -> GaugeConfig: + updates: dict[str, Any] = {} + if config.theme is None and _config.default_theme is not None: + updates["theme"] = _config.default_theme + if config.animation is None and _config.animation is not None: + updates["animation"] = _config.animation + if config.smoothing is None and _config.smoothing is not None: + updates["smoothing"] = _config.smoothing + if config.width == 0 and _config.default_width is not None: + updates["width"] = _config.default_width + if config.height == 0 and _config.default_height is not None: + updates["height"] = _config.default_height + return replace(config, **updates) if updates else config + + +def coerce_numeric_value(value: GaugeValue) -> float | None: + if isinstance(value, int | float) and not isinstance(value, bool) and math.isfinite(value): + return float(value) + return None + + +def animation_config(config: GaugeConfig) -> AnimationConfig | None: + animation = config.animation + if isinstance(animation, bool): + return AnimationConfig(enabled=animation) + return animation + + +def clamp_runtime_value(value: GaugeValue, config: GaugeConfig) -> tuple[float | None, bool]: + numeric = coerce_numeric_value(value) + if numeric is None: + return None, value is not None + if not math.isfinite(numeric): + return None, True + if config.clamp: + return clamp_value(numeric, config.min_value, config.max_value), False + return numeric, False + + +def register_gauge( + gauge_type: str, + config: GaugeConfig, + *, + container_tag: Tag | None = None, + drawlist_tag: Tag | None = None, + text_tags: dict[str, Tag] | None = None, +) -> GaugeState: + config = apply_global_defaults(config) + tag = resolve_tag(config.tag) + if tag in _registry: + msg = f"gauge already exists: {tag!r}" + raise GaugeConfigError(msg) + if config.tag != tag: + config = replace(config, tag=tag) + clamped, invalid = clamp_runtime_value(config.value, config) + state = GaugeState( + tag=tag, + gauge_type=gauge_type, + container_tag=container_tag or tag, + drawlist_tag=drawlist_tag, + text_tags=text_tags or {}, + config=config, + target_value=config.value, + clamped_value=clamped, + displayed_value=clamped, + previous_displayed_value=clamped, + measured_width=config.width if config.width > 0 else 0, + measured_height=config.height if config.height > 0 else 0, + last_nonzero_width=config.width if config.width > 0 else 0, + last_nonzero_height=config.height if config.height > 0 else 0, + invalid_value=invalid, + ) + state.smoothing_state.value = clamped + _registry[tag] = state + return state + + +def get_gauge_state(tag: Tag) -> GaugeState: + try: + return _registry[tag] + except KeyError as exc: + msg = f"gauge not found: {tag!r}" + raise GaugeNotFoundError(msg) from exc + + +def set_runtime_value(tag: Tag, value: GaugeValue, *, now: float = 0.0) -> None: + state = get_gauge_state(tag) + clamped, invalid = clamp_runtime_value(value, state.config) + state.target_value = value + state.clamped_value = clamped + state.invalid_value = invalid + state.value_revision += 1 + state.previous_displayed_value = state.displayed_value + state.animation_state = start_animation( + state.displayed_value, clamped, animation_config(state.config), now + ) + if not state.animation_state.active: + state.displayed_value = clamped + state.smoothing_state.value = clamped + state.dirty |= DirtyFlags.VALUE | DirtyFlags.DYNAMIC | DirtyFlags.TEXT + + +def get_runtime_value(tag: Tag) -> GaugeValue: + return get_gauge_state(tag).target_value + + +def update_runtime_gauge(tag: Tag, **config: Any) -> None: + state = get_gauge_state(tag) + value_marker = object() + value = config.pop("value", value_marker) + dirty = DirtyFlags.NONE + + if config: + old_config = state.config + new_config = replace(old_config, **config) + state.config = new_config + state.config_revision += 1 + + if any(name in config for name in ("min_value", "max_value", "clamp")): + clamped, invalid = clamp_runtime_value(state.target_value, new_config) + state.clamped_value = clamped + state.invalid_value = invalid + state.displayed_value = clamped + state.smoothing_state.value = clamped + dirty |= DirtyFlags.STATIC | DirtyFlags.DYNAMIC | DirtyFlags.TEXT + if any(name in config for name in ("thresholds", "markers")): + dirty |= DirtyFlags.STATIC | DirtyFlags.DYNAMIC + if any( + name in config for name in ("label", "unit", "precision", "show_value", "show_unit") + ): + dirty |= DirtyFlags.TEXT + if any(name in config for name in ("width", "height", "autosize_x", "autosize_y", "show")): + dirty |= DirtyFlags.SIZE | DirtyFlags.STATIC | DirtyFlags.DYNAMIC | DirtyFlags.TEXT + if any(name in config for name in ("theme", "style")): + dirty |= DirtyFlags.STYLE | DirtyFlags.STATIC | DirtyFlags.DYNAMIC | DirtyFlags.TEXT + + if value is not value_marker: + set_runtime_value(tag, value) + dirty |= DirtyFlags.VALUE | DirtyFlags.DYNAMIC | DirtyFlags.TEXT + + state.dirty |= dirty + + +def set_runtime_thresholds( + tag: Tag, thresholds: list[ThresholdBand] | tuple[ThresholdBand, ...] +) -> None: + state = get_gauge_state(tag) + validate_thresholds(thresholds, state.config.min_value, state.config.max_value) + update_runtime_gauge(tag, thresholds=tuple(thresholds)) + + +def set_runtime_markers(tag: Tag, markers: list[GaugeMarker] | tuple[GaugeMarker, ...]) -> None: + state = get_gauge_state(tag) + validate_markers(markers, state.config.min_value, state.config.max_value) + update_runtime_gauge(tag, markers=tuple(markers)) + + +def set_runtime_show(tag: Tag, show: bool) -> None: + update_runtime_gauge(tag, show=show) + + +def delete_runtime_gauge(tag: Tag) -> None: + get_gauge_state(tag) + del _registry[tag] + + +def update_state_size(state: GaugeState, size: SizeState) -> None: + state.measured_width = size.measured_width + state.measured_height = size.measured_height + state.last_nonzero_width = size.last_nonzero_width + state.last_nonzero_height = size.last_nonzero_height + state.dirty |= DirtyFlags.SIZE | DirtyFlags.STATIC | DirtyFlags.DYNAMIC | DirtyFlags.TEXT + + +def size_state_for_gauge(state: GaugeState) -> SizeState: + return SizeState( + requested=RequestedSize( + state.config.width, + state.config.height, + state.config.autosize_x, + state.config.autosize_y, + ), + measured_width=state.measured_width, + measured_height=state.measured_height, + last_nonzero_width=state.last_nonzero_width, + last_nonzero_height=state.last_nonzero_height, + ) diff --git a/src/dpg_gauges/themes.py b/src/dpg_gauges/themes.py new file mode 100644 index 0000000..c4a02c4 --- /dev/null +++ b/src/dpg_gauges/themes.py @@ -0,0 +1,76 @@ +"""Lightweight theme and style presets.""" + +from __future__ import annotations + +from dataclasses import dataclass, replace + +from .exceptions import GaugeConfigError +from .types import Color, validate_color + + +@dataclass(frozen=True) +class GaugeStyle: + background_color: Color = (24, 24, 28, 255) + frame_color: Color = (58, 58, 66, 255) + value_color: Color = (82, 172, 255, 255) + text_color: Color = (235, 235, 240, 255) + muted_text_color: Color = (150, 150, 158, 255) + border_color: Color = (78, 78, 88, 255) + needle_color: Color = (255, 255, 255, 255) + tick_color: Color = (190, 190, 198, 255) + redline_color: Color = (255, 45, 45, 255) + + def __post_init__(self) -> None: + for field_name, color in self.__dict__.items(): + validate_color(color, name=field_name) + + +@dataclass(frozen=True) +class GaugeTheme: + name: str = "default" + style: GaugeStyle = GaugeStyle() + + +_PRESETS: dict[str, GaugeTheme] = { + "default": GaugeTheme(), + "dark": GaugeTheme("dark", GaugeStyle(background_color=(14, 16, 20, 255))), + "minimal": GaugeTheme( + "minimal", + GaugeStyle( + background_color=(0, 0, 0, 0), + frame_color=(210, 210, 216, 255), + value_color=(40, 40, 44, 255), + text_color=(30, 30, 34, 255), + muted_text_color=(110, 110, 116, 255), + border_color=(220, 220, 226, 255), + needle_color=(30, 30, 34, 255), + tick_color=(100, 100, 108, 255), + ), + ), + "motorsport": GaugeTheme( + "motorsport", + GaugeStyle(value_color=(255, 185, 0, 255), needle_color=(255, 35, 35, 255)), + ), + "industrial": GaugeTheme( + "industrial", + GaugeStyle(background_color=(34, 36, 34, 255), value_color=(80, 220, 120, 255)), + ), +} + + +def get_theme(theme: str | GaugeTheme | None = None) -> GaugeTheme: + if theme is None: + return _PRESETS["default"] + if isinstance(theme, GaugeTheme): + return theme + try: + return _PRESETS[theme] + except KeyError as exc: + msg = f"unknown gauge theme: {theme!r}" + raise GaugeConfigError(msg) from exc + + +def merge_style(base: GaugeStyle | None = None, **overrides: Color | None) -> GaugeStyle: + style = base or GaugeStyle() + clean = {key: value for key, value in overrides.items() if value is not None} + return replace(style, **clean) diff --git a/src/dpg_gauges/types.py b/src/dpg_gauges/types.py new file mode 100644 index 0000000..7f6eed8 --- /dev/null +++ b/src/dpg_gauges/types.py @@ -0,0 +1,107 @@ +"""Shared public types and dataclasses.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any, TypeAlias + +from .exceptions import GaugeConfigError + +Tag: TypeAlias = str | int +Color: TypeAlias = tuple[int, int, int] | tuple[int, int, int, int] +GaugeValue: TypeAlias = float | int | bool | str | None + + +def validate_color(color: Color, *, name: str = "color") -> Color: + if len(color) not in (3, 4): + msg = f"{name} must contain 3 or 4 components" + raise GaugeConfigError(msg) + if any(component < 0 or component > 255 for component in color): + msg = f"{name} components must be between 0 and 255" + raise GaugeConfigError(msg) + return color + + +@dataclass(frozen=True) +class ThresholdBand: + start: float + end: float + color: Color + label: str | None = None + + def __post_init__(self) -> None: + if self.start > self.end: + msg = "threshold start must be less than or equal to end" + raise GaugeConfigError(msg) + validate_color(self.color, name="threshold color") + + +@dataclass(frozen=True) +class GaugeMarker: + value: float + color: Color + label: str | None = None + thickness: float = 2.0 + + def __post_init__(self) -> None: + if self.thickness <= 0: + msg = "marker thickness must be greater than zero" + raise GaugeConfigError(msg) + validate_color(self.color, name="marker color") + + +@dataclass(frozen=True) +class AnimationConfig: + enabled: bool = True + duration: float = 0.15 + easing: str = "linear" + + def __post_init__(self) -> None: + if self.duration < 0: + msg = "animation duration must be non-negative" + raise GaugeConfigError(msg) + + +@dataclass(frozen=True) +class SmoothingConfig: + enabled: bool = False + alpha: float = 0.35 + max_step: float | None = None + + def __post_init__(self) -> None: + if not 0 < self.alpha <= 1: + msg = "smoothing alpha must be greater than 0 and at most 1" + raise GaugeConfigError(msg) + if self.max_step is not None and self.max_step <= 0: + msg = "smoothing max_step must be greater than zero" + raise GaugeConfigError(msg) + + +@dataclass(frozen=True) +class StatusState: + color: Color + text: str = "" + value_color: Color | None = None + + def __post_init__(self) -> None: + validate_color(self.color, name="status color") + if self.value_color is not None: + validate_color(self.value_color, name="status value color") + + +@dataclass(frozen=True) +class GaugeStats: + min_value: float | None = None + max_value: float | None = None + average: float | None = None + samples: int = 0 + + def __post_init__(self) -> None: + if self.samples < 0: + msg = "stats samples must be non-negative" + raise GaugeConfigError(msg) + + +ValueFormatter: TypeAlias = Callable[[float], str] +Callback: TypeAlias = Callable[[Tag, GaugeValue, Any], None] diff --git a/src/dpg_gauges/widget.py b/src/dpg_gauges/widget.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/.gitkeep b/tests/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_step1_core.py b/tests/test_step1_core.py new file mode 100644 index 0000000..a7860f7 --- /dev/null +++ b/tests/test_step1_core.py @@ -0,0 +1,117 @@ +import pytest + +import dpg_gauges as dpgg +from dpg_gauges.exceptions import GaugeConfigError, GaugeNotFoundError, GaugeValueError +from dpg_gauges.gauges import AnalogGaugeConfig, DigitalGaugeConfig +from dpg_gauges.scales import ( + clamp_value, + clamped_threshold, + fill_fraction, + format_gauge_value, + generate_ticks, + normalize_value, + value_to_angle, +) + + +def test_public_api_exports_required_names() -> None: + required = { + "configure", + "GaugeTheme", + "GaugeStyle", + "AnimationConfig", + "SmoothingConfig", + "ThresholdBand", + "GaugeMarker", + "StatusState", + "GaugeValue", + "GaugeStats", + "analog_gauge", + "digital_gauge", + "bar_gauge", + "level_gauge", + "status_light", + "segmented_gauge", + "compass_gauge", + "thermometer_gauge", + "battery_gauge", + "multi_value_gauge", + "gauge_panel", + "gauge_grid", + "set_value", + "get_value", + "update_gauge", + "configure_gauge", + "set_thresholds", + "set_markers", + "set_show", + "delete_gauge", + "get_gauge_debug_state", + } + + assert required.issubset(set(dpgg.__all__)) + for name in required: + assert hasattr(dpgg, name) + + +def test_dataclass_construction_and_context_stub() -> None: + threshold = dpgg.ThresholdBand(10, 20, (255, 0, 0, 255), "hot") + marker = dpgg.GaugeMarker(50, (0, 255, 0), thickness=1.5) + config = AnalogGaugeConfig( + label="RPM", + min_value=0, + max_value=8000, + thresholds=[threshold], + markers=[marker], + animation=True, + smoothing=False, + ) + + assert config.thresholds == (threshold,) + assert config.markers == (marker,) + assert config.animation == dpgg.AnimationConfig(enabled=True) + assert config.smoothing == dpgg.SmoothingConfig(enabled=False) + + with dpgg.digital_gauge(label="Speed", value=12.3) as digital: + assert isinstance(digital, DigitalGaugeConfig) + assert digital.label == "Speed" + + +def test_clamp_normalize_angle_and_fill_math() -> None: + assert clamp_value(150, 0, 100) == 100 + assert normalize_value(25, 0, 100) == 0.25 + assert fill_fraction(-10, 0, 100) == 0 + assert value_to_angle(50, 0, 100, 225, -45) == 90 + + with pytest.raises(GaugeValueError): + clamp_value(float("nan"), 0, 100) + + +def test_tick_generation_and_formatting() -> None: + assert generate_ticks(0, 100, 5) == [0, 25, 50, 75, 100] + assert format_gauge_value(12.345, precision=1, unit="V") == "12.3 V" + assert format_gauge_value(float("inf"), precision=1) == "--" + + with pytest.raises(GaugeConfigError): + generate_ticks(0, 100, 1) + + +def test_threshold_validation_and_clamping() -> None: + threshold = dpgg.ThresholdBand(-10, 120, (255, 128, 0, 255)) + assert clamped_threshold(threshold, 0, 100) == (0, 100) + + with pytest.raises(GaugeConfigError): + dpgg.ThresholdBand(20, 10, (255, 0, 0)) + + with pytest.raises(GaugeConfigError): + AnalogGaugeConfig(min_value=100, max_value=0) + + +def test_runtime_stubs_import_without_dpg_context() -> None: + dpgg.configure(debug=True) + + with pytest.raises(GaugeNotFoundError): + dpgg.set_value("missing", 1) + + with pytest.raises(GaugeNotFoundError): + dpgg.get_gauge_debug_state("missing") diff --git a/tests/test_step2_state_model.py b/tests/test_step2_state_model.py new file mode 100644 index 0000000..58061ce --- /dev/null +++ b/tests/test_step2_state_model.py @@ -0,0 +1,162 @@ +import pytest + +import dpg_gauges as dpgg +from dpg_gauges.animation import advance_animation, interpolate, start_animation +from dpg_gauges.exceptions import GaugeConfigError, GaugeNotFoundError +from dpg_gauges.gauges import DigitalGaugeConfig +from dpg_gauges.sizing import ( + SizeState, + effective_size, + requested_size, + square_gauge_rect, + stretch_gauge_rect, + update_measured_size, +) +from dpg_gauges.smoothing import SmoothingState, advance_smoothing, smooth_value +from dpg_gauges.state import DirtyFlags, clear_registry, get_gauge_state, register_gauge +from dpg_gauges.types import AnimationConfig, SmoothingConfig + + +@pytest.fixture(autouse=True) +def clean_registry() -> None: + clear_registry() + dpgg.configure() + + +def test_registry_register_get_and_delete() -> None: + with dpgg.digital_gauge(tag="speed", value=42, min_value=0, max_value=100) as config: + assert config.tag == "speed" + + assert dpgg.get_value("speed") == 42 + state = get_gauge_state("speed") + assert state.gauge_type == "digital" + assert state.clamped_value == 42 + assert state.displayed_value == 42 + + with pytest.raises(GaugeConfigError): + register_gauge("digital", DigitalGaugeConfig(tag="speed")) + + dpgg.delete_gauge("speed") + with pytest.raises(GaugeNotFoundError): + dpgg.get_value("speed") + + +def test_value_clamped_displayed_and_dirty_flags_are_separate() -> None: + dpgg.digital_gauge(tag="rpm", value=10, min_value=0, max_value=100, animation=True) + state = get_gauge_state("rpm") + state.dirty = DirtyFlags.NONE + + dpgg.set_value("rpm", 150) + + assert dpgg.get_value("rpm") == 150 + assert state.clamped_value == 100 + assert state.displayed_value == 10 + assert state.animation_state.active + assert state.dirty & DirtyFlags.VALUE + assert state.dirty & DirtyFlags.DYNAMIC + assert state.dirty & DirtyFlags.TEXT + + +def test_update_gauge_marks_static_text_size_and_style_dirty() -> None: + dpgg.digital_gauge(tag="temp", value=20, min_value=0, max_value=100) + state = get_gauge_state("temp") + state.dirty = DirtyFlags.NONE + + dpgg.update_gauge( + "temp", + label="Temperature", + unit="C", + min_value=-40, + max_value=140, + width=200, + style=state.config.style, + ) + + assert state.config.label == "Temperature" + assert state.config.unit == "C" + assert state.config.min_value == -40 + assert state.dirty & DirtyFlags.STATIC + assert state.dirty & DirtyFlags.DYNAMIC + assert state.dirty & DirtyFlags.TEXT + assert state.dirty & DirtyFlags.SIZE + assert state.dirty & DirtyFlags.STYLE + + +def test_threshold_marker_and_show_runtime_updates() -> None: + dpgg.digital_gauge(tag="fuel", min_value=0, max_value=100) + threshold = dpgg.ThresholdBand(0, 15, (255, 0, 0)) + marker = dpgg.GaugeMarker(50, (0, 255, 0)) + + dpgg.set_thresholds("fuel", [threshold]) + dpgg.set_markers("fuel", [marker]) + dpgg.set_show("fuel", False) + + state = get_gauge_state("fuel") + assert state.config.thresholds == (threshold,) + assert state.config.markers == (marker,) + assert state.config.show is False + assert state.dirty & DirtyFlags.STATIC + assert state.dirty & DirtyFlags.SIZE + + +def test_non_finite_values_do_not_crash_state_helpers() -> None: + dpgg.digital_gauge(tag="boost", min_value=0, max_value=30) + + dpgg.set_value("boost", float("nan")) + state = get_gauge_state("boost") + + assert state.target_value != state.target_value + assert state.clamped_value is None + assert state.displayed_value is None + assert state.invalid_value is True + + +def test_sizing_preserves_last_nonzero_size_and_rects() -> None: + size = SizeState(requested=requested_size(100, 80)) + + assert update_measured_size(size, 320, 180) is True + assert effective_size(size) == (320, 180) + + update_measured_size(size, 0, 0) + assert effective_size(size) == (320, 180) + assert square_gauge_rect(320, 180).width == 180 + assert square_gauge_rect(320, 180).x == 70 + assert stretch_gauge_rect(320, 180).width == 320 + assert stretch_gauge_rect(320, 180).height == 180 + + +def test_animation_interpolation_and_completion() -> None: + config = AnimationConfig(enabled=True, duration=1.0) + state = start_animation(0, 100, config, now=10.0) + + assert interpolate(0, 100, 0.25) == 25 + value, active = advance_animation(state, config, now=10.5) + assert value == 50 + assert active is True + + value, active = advance_animation(state, config, now=11.0) + assert value == 100 + assert active is False + + +def test_smoothing_progression() -> None: + config = SmoothingConfig(enabled=True, alpha=0.5, max_step=10) + + assert smooth_value(None, 100, config) == (100, False) + + state = SmoothingState(value=0) + value, active = advance_smoothing(state, 100, config) + assert value == 10 + assert active is True + + +def test_debug_state_reports_model_fields() -> None: + dpgg.digital_gauge(tag="debug", value=5, width=120, height=30) + + debug = dpgg.get_gauge_debug_state("debug") + + assert debug["gauge_type"] == "digital" + assert debug["target_value"] == 5 + assert debug["clamped_value"] == 5 + assert debug["measured_size"] == (120, 30) + assert "VALUE" in debug["dirty_names"] diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..5ce68d0 --- /dev/null +++ b/uv.lock @@ -0,0 +1,168 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "dearpygui" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/40/22c00a3b3e47c609ece39b0e1970b8e031ac613201fee065dd93c9cbcb6c/dearpygui-2.3.1-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:e1a9ffbb845600ab4a171daf2d7e7a1895f5b4433eed248d4cab42766b55fdff", size = 2002951, upload-time = "2026-05-01T22:45:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/31/7d/b2b26e75437a6173e44a89a816d5f1df76537ca2f4b3206a2a787ac9d479/dearpygui-2.3.1-cp311-cp311-manylinux1_x86_64.whl", hash = "sha256:686423b4f8a30aaf57f1044ec4e3b087beb9cc9b25be812cf92f4e7feff44fd6", size = 2678639, upload-time = "2026-05-01T22:46:06.246Z" }, + { url = "https://files.pythonhosted.org/packages/00/90/efaa3dbcdab267638b0b65551c88ea1e994235b74345c8d995b8a0a9d59e/dearpygui-2.3.1-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:0259a2db7b6c82459ac1402b0d42388a7cd180cd97d7dcb31a5210725c3171d3", size = 2501986, upload-time = "2026-05-01T22:46:17.044Z" }, + { url = "https://files.pythonhosted.org/packages/b4/db/8f15dfe06d104746591c4763a5bba31369b86d861aa916101da8f633bfc6/dearpygui-2.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:76ee38696a8f1f3805751b1588c98d9bb6567b033b91cb0f58111fb9fe907b5b", size = 1881816, upload-time = "2026-05-01T22:45:48.044Z" }, + { url = "https://files.pythonhosted.org/packages/20/f1/6f30c7eafa25755105d0c26049a95c89cc5c82b68971e1cc8ffea36b748a/dearpygui-2.3.1-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:6b1efd675a358176b3b76c039587a543d407d8fbb2cc3643a527e0ab7c0f5577", size = 2002717, upload-time = "2026-05-01T22:46:00.242Z" }, + { url = "https://files.pythonhosted.org/packages/cc/03/fa76c31087b6ebbc13948de6d9d3bc9ed9946824b13d9d1089356b54249c/dearpygui-2.3.1-cp312-cp312-manylinux1_x86_64.whl", hash = "sha256:364b5b6a0953999fec764d529d9bb0b2cc6ab6ed06188e0ded28237c1475ca98", size = 2678552, upload-time = "2026-05-01T22:46:07.502Z" }, + { url = "https://files.pythonhosted.org/packages/f2/ef/657ec68a79664cd28d98d590eed85103c6ffc1e23e7d3c4e5aedb7ee887d/dearpygui-2.3.1-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:199dffebda245c9f59aa7c10c3b7f62963237c5f744150ad9be409b27a2661d3", size = 2501224, upload-time = "2026-05-01T22:46:18.8Z" }, + { url = "https://files.pythonhosted.org/packages/77/be/e67e5a04439f15e60cf596092a31b2a907272288954e47f5ced43c0b1678/dearpygui-2.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:26ed9197cdb30099c665bd1b23724e0dba60ad06b71344ecca7e52748c21874b", size = 1882080, upload-time = "2026-05-01T22:45:49.366Z" }, + { url = "https://files.pythonhosted.org/packages/42/db/56a293ad392b8dafdd3eb1a0c4d367efbde99aa6930359685fe270984dd1/dearpygui-2.3.1-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:d2d29e72b031886893b32269e1478eff1fd651512e6c2bceda91059c3bc8ee0e", size = 2002694, upload-time = "2026-05-01T22:46:01.425Z" }, + { url = "https://files.pythonhosted.org/packages/cc/e0/df598671f40a7fffb8390946115d9e9cf12d3f4a52f5b9381bddc5699aee/dearpygui-2.3.1-cp313-cp313-manylinux1_x86_64.whl", hash = "sha256:e3f64c8d4cae68a3b5be1b7531d08400ccbf1e4ff03c7acfc85954f98993f864", size = 2678564, upload-time = "2026-05-01T22:46:08.706Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d1/87cfb577d4a1615e809e2f6e0fd720bd4ad6cd25e365ef2fd08a6e09c86e/dearpygui-2.3.1-cp313-cp313-manylinux2014_aarch64.whl", hash = "sha256:0d8b7a5a04cd4a6e25dbf162aa0405cf12143a1c82b33e371c20e5ba8d9349f3", size = 2503196, upload-time = "2026-05-01T22:46:20.276Z" }, + { url = "https://files.pythonhosted.org/packages/b3/38/05f74181a59353bdc927b6ec46333714f633c5755f3d4bcda34d6eb100a5/dearpygui-2.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:7946722c50f1e08866dfdb78eb33dd4e67dea1a3cfe4400b159e1c96861116dc", size = 1882082, upload-time = "2026-05-01T22:45:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/4a/08/68ce3ba941cf09bcf288f2603d7178c936d473172fd545e0665b885cb840/dearpygui-2.3.1-cp314-cp314-macosx_13_0_arm64.whl", hash = "sha256:e2cbabd8d383308ea3520360e100d1e95c9a957cd26247431cd4920b23c3b56e", size = 2003034, upload-time = "2026-05-01T22:46:02.736Z" }, + { url = "https://files.pythonhosted.org/packages/f3/3f/ac003b6d636c9f5aae910a62d00b6c119403212cf2d7772d7c59ca8ef6dd/dearpygui-2.3.1-cp314-cp314-manylinux1_x86_64.whl", hash = "sha256:953442c7272e57f3686e393edc5cf6ce73cc2f47142739735dfabed403e33770", size = 2678545, upload-time = "2026-05-01T22:46:10.181Z" }, + { url = "https://files.pythonhosted.org/packages/1a/6b/657a2f1e2f604f356c39ed5b870aaed5d07fdeac3139fae2a455473b64ca/dearpygui-2.3.1-cp314-cp314-manylinux2014_aarch64.whl", hash = "sha256:43a561b5dc589944a3a2b469e5e68f1ab35ae38b3dcdf1f813ed9d6e024153f8", size = 2501699, upload-time = "2026-05-01T22:46:21.497Z" }, + { url = "https://files.pythonhosted.org/packages/8c/56/93b2310891589063e0226cb07fe05e08fbdb3f409f80f9edefb1cebea84e/dearpygui-2.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:272cc52c66bc5a4a332f13a3af5e603070d47ca7cc4976e6a8c247594e2b3dd0", size = 1943067, upload-time = "2026-05-01T22:45:52.255Z" }, +] + +[[package]] +name = "dpg-gauges" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "dearpygui" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pyright" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [{ name = "dearpygui", specifier = ">=2.3.1" }] + +[package.metadata.requires-dev] +dev = [ + { name = "pyright", specifier = ">=1.1.411" }, + { name = "pytest", specifier = ">=9.1.1" }, + { name = "ruff", specifier = ">=0.15.20" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyright" +version = "1.1.411" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7e/ab/265f7dc69d28113ebba19092e57b075f41543b2ed048429c5f56e2b88eac/pyright-1.1.411.tar.gz", hash = "sha256:d885a0551f2e763b089a02702174e7f4ba77548cddabc972ab86d1f7f1b0f998", size = 4112861, upload-time = "2026-06-25T02:14:06.37Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/49/385be530a6a5b78d1cbcd5c2e38debc8959a2fc6bdb716f4e581002979fc/pyright-1.1.411-py3-none-any.whl", hash = "sha256:dc7c72a8e2700c55baa127554040e067041ea53ccfd50bf96308cc4291c7d5d9", size = 6181526, upload-time = "2026-06-25T02:14:04.691Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" }, + { url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" }, + { url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" }, + { url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" }, + { url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" }, + { url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" }, + { url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" }, + { url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" }, + { url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +]