diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8b61e17 --- /dev/null +++ b/Makefile @@ -0,0 +1,39 @@ +.PHONY: help lint format lint-fix check install dev-install clean test + +help: + @echo "Available commands:" + @echo " install Install dependencies" + @echo " dev-install Install development dependencies" + @echo " lint Run linter (check only)" + @echo " format Format code" + @echo " lint-fix Run linter with auto-fix" + @echo " check Run all checks (lint + format check)" + @echo " clean Clean cache files" + @echo " test Run tests" + +install: + uv sync --no-dev + +dev-install: + uv sync + +lint: + uv run ruff check . + +format: + uv run ruff format . + +lint-fix: + uv run ruff check --fix --unsafe-fixes . + +check: lint + uv run ruff format --check . + +clean: + rm -rf .ruff_cache + rm -rf __pycache__ + find . -type d -name "__pycache__" -exec rm -rf {} + + find . -type f -name "*.pyc" -delete + +test: + @echo "No tests configured yet" diff --git a/README.md b/README.md index aed623e..4f4f12c 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,43 @@ -# TRMNL Weather & Pollen Report +# trmnl weather & pollen report -A custom TRMNL plugin that fetches and displays weather and pollen data. +a custom trmnl plugin that fetches and displays weather and pollen data. -## Setup +## setup -1. Set up a virtual environment and install dependencies: +1. set up a virtual environment and install dependencies: ```bash python -m venv .venv source .venv/bin/activate uv sync ``` -2. Set required environment variables: +2. set required environment variables: ```bash export WEATHER_API_KEY="your_openweathermap_api_key" export AUTH_TOKEN="your_chosen_auth_token" ``` -3. Run the application: +3. run the application: ```bash fastapi run main.py --port 8887 ``` -## Docker +## development -Build and run with Docker: +### install development dependencies + +```bash +make dev-install +``` + +## docker + +build and run with docker: ```bash docker build -t trmnl-report . docker run -p 8887:8887 -e WEATHER_API_KEY=your_key -e AUTH_TOKEN=your_token trmnl-report ``` -## API +## api -Access the API at `http://localhost:8887/?token=your_auth_token` \ No newline at end of file +access the api at `http://localhost:8887/?token=your_auth_token` \ No newline at end of file diff --git a/main.py b/main.py index 660c045..e696a7b 100644 --- a/main.py +++ b/main.py @@ -1,230 +1,535 @@ import asyncio from datetime import datetime, timedelta +from typing import TypedDict import functools import logging import os -import pprint import zoneinfo +from urllib.parse import urljoin, urlencode from fastapi import FastAPI, HTTPException from cachetools import TTLCache import httpx -pp = pprint.PrettyPrinter() logger = logging.getLogger("uvicorn.error") -eastern = zoneinfo.ZoneInfo("America/New_York") -app = FastAPI() - -weather_cache = TTLCache(maxsize=100, ttl=900) # 15 minutes -pollen_cache = TTLCache(maxsize=100, ttl=900) # 15 minutes - -CONFIG = { - # salem, ma - "zip": "01970", - "lat": "42.3554334", - "lon": "-71.060511", - "weather_api_key": os.environ.get("WEATHER_API_KEY", ""), - "auth_token": os.environ["AUTH_TOKEN"], -} +EASTERN_TZ = zoneinfo.ZoneInfo("America/New_York") +CACHE_TTL_SECONDS = 900 # 15 minutes +CACHE_MAX_SIZE = 100 +POLLEN_MAX_INDEX = 12.0 +HTTP_TIMEOUT = 10.0 +DEFAULT_ZIP_CODE = "01970" # salem, ma +DEFAULT_LATITUDE = "42.3554334" +DEFAULT_LONGITUDE = "-71.060511" -def format_date(dt): - dt = dt.astimezone(eastern) - return dt.strftime("%a %d").lower() +def get_required_env(key: str) -> str: + """Get required environment variable or raise error.""" + value = os.environ.get(key) + if not value: + raise ValueError(f"Required environment variable {key} is not set") + return value -def format_time(dt): - dt = dt.astimezone(eastern) - return dt.strftime("%I:%M%p").lower() +class WeatherCondition(TypedDict): + description: str + main: str + id: int + icon: str -def format_datetime(dt): - dt = dt.astimezone(eastern) - return f"{format_date(dt)} {format_time(dt)}" +class CurrentWeather(TypedDict): + dt: int + sunrise: int + sunset: int + temp: float + feels_like: float + pressure: int + humidity: int + weather: list[WeatherCondition] -def relative_day_to_date(rel_dt): - dt = datetime.now() - day = timedelta(days=1) - match rel_dt.lower().strip(): - case "yesterday": - return format_date(dt - day) - case "today": - return format_date(dt) - case "tomorrow": - return format_date(dt + day) - case passthrough: - return passthrough +class DailyTemperature(TypedDict): + min: float + max: float -def build_daily_data(date, pollen_periods, weather_periods): - daily_data = {} - if date in pollen_periods: - daily_data["pollen"] = pollen_periods[date]["index"] - if date in weather_periods: - weather_data = weather_periods[date] - daily_data.update({ - "low": weather_data["low"], - "high": weather_data["high"], - "desc": weather_data["desc"], - "humidity": weather_data["humidity"], - "sunrise": weather_data["sunrise"], - "sunset": weather_data["sunset"], - "pressure": weather_data["pressure"], - }) - return daily_data +class DailyWeather(TypedDict): + dt: int + sunrise: int + sunset: int + temp: DailyTemperature + humidity: int + pressure: int + weather: list[WeatherCondition] -def fallback_handler(func): +class WeatherApiResponse(TypedDict): + current: CurrentWeather + daily: list[DailyWeather] + + +class PollenLocation(TypedDict): + periods: list[dict[str, str | int | float]] + + +class PollenApiResponse(TypedDict): + ForecastDate: str + Location: PollenLocation + + +class WeatherPeriod(TypedDict): + low: int + high: int + desc: str + humidity: int + sunrise: str + sunset: str + pressure: int + period: str + + +class PollenPeriod(TypedDict): + index: int + period: str + + +class WeatherReport(TypedDict): + forecast_date: str + current_temp: int + current_feels_like: int + current_humidity: int + sunrise: str + sunset: str + current_pressure: int + current_desc: str + periods: list[WeatherPeriod] + + +class PollenReport(TypedDict): + forecast_date: str + periods: list[PollenPeriod] + + +class CurrentWeatherData(TypedDict): + temp: int + feels_like: int + desc: str + humidity: int + pressure: int + + +class DailyData(TypedDict, total=False): + pollen: int + low: int + high: int + desc: str + humidity: int + sunrise: str + sunset: str + pressure: int + + +class FinalReport(TypedDict): + fetched_at: str + current: CurrentWeatherData + today: DailyData + tomorrow: DailyData + + +app = FastAPI(title="trmnl weather & pollen report") + +weather_cache: TTLCache = TTLCache(maxsize=CACHE_MAX_SIZE, ttl=CACHE_TTL_SECONDS) +pollen_cache: TTLCache = TTLCache(maxsize=CACHE_MAX_SIZE, ttl=CACHE_TTL_SECONDS) + + +class Config: + """Application configuration.""" + + def __init__( + self, + zip_code: str, + latitude: str, + longitude: str, + weather_api_key: str, + auth_token: str, + ): + self.zip_code = zip_code + self.latitude = latitude + self.longitude = longitude + self.weather_api_key = weather_api_key + self.auth_token = auth_token + + +config = Config( + zip_code=DEFAULT_ZIP_CODE, + latitude=DEFAULT_LATITUDE, + longitude=DEFAULT_LONGITUDE, + weather_api_key=get_required_env("WEATHER_API_KEY"), + auth_token=get_required_env("AUTH_TOKEN"), +) + + +class DateTimeFormatter: + """Utility class for datetime formatting.""" + + @staticmethod + def format_date(dt: datetime) -> str: + """Format datetime as abbreviated date string (e.g., 'mon 15').""" + dt = dt.astimezone(EASTERN_TZ) + return dt.strftime("%a %d").lower() + + @staticmethod + def format_time(dt: datetime) -> str: + """Format datetime as time string (e.g., '02:30pm').""" + dt = dt.astimezone(EASTERN_TZ) + return dt.strftime("%I:%M%p").lower() + + @staticmethod + def format_datetime(dt: datetime) -> str: + """Format datetime as date and time string.""" + return f"{DateTimeFormatter.format_date(dt)} {DateTimeFormatter.format_time(dt)}" + + @staticmethod + def relative_day_to_date(relative_day: str) -> str: + """Convert relative day string to formatted date string.""" + now = datetime.now() + day_delta = timedelta(days=1) + + relative_day = relative_day.lower().strip() + + if relative_day == "yesterday": + return DateTimeFormatter.format_date(now - day_delta) + elif relative_day == "today": + return DateTimeFormatter.format_date(now) + elif relative_day == "tomorrow": + return DateTimeFormatter.format_date(now + day_delta) + else: + return relative_day + + +class WeatherData: + """Data model for weather information.""" + + def __init__(self, raw_data: WeatherApiResponse): + self.raw_data = raw_data + self._validate_data() + + def _validate_data(self) -> None: + """Validate required fields in weather data.""" + required_fields = ["current", "daily"] + for field in required_fields: + if field not in self.raw_data: + raise ValueError(f"Missing required field in weather data: {field}") + + @property + def current(self) -> CurrentWeather: + """Get current weather data.""" + return self.raw_data["current"] + + @property + def daily_periods(self) -> list[DailyWeather]: + """Get daily forecast periods (limited to next 2 days).""" + return self.raw_data["daily"][:2] + + +class PollenData: + """Data model for pollen information.""" + + def __init__(self, raw_data: PollenApiResponse): + self.raw_data = raw_data + self._validate_data() + + def _validate_data(self) -> None: + """Validate required fields in pollen data.""" + required_fields = ["ForecastDate", "Location"] + for field in required_fields: + if field not in self.raw_data: + raise ValueError(f"Missing required field in pollen data: {field}") + + @property + def forecast_date(self) -> str: + """Get formatted forecast date.""" + dt = datetime.fromisoformat(self.raw_data["ForecastDate"]) + return DateTimeFormatter.format_datetime(dt) + + @property + def periods(self) -> list[PollenPeriod]: + """Get pollen periods for today and tomorrow.""" + periods = self.raw_data["Location"].get("periods", []) + valid_periods: list[PollenPeriod] = [] + + for period in periods: + period_type = str(period.get("Type", "")).lower().strip() + if period_type in ["today", "tomorrow"]: + index_value = float(period.get("Index", 0)) + # convert pollen index to percentage scale + pollen_percentage = int(index_value / POLLEN_MAX_INDEX * 100) + + valid_periods.append( + PollenPeriod( + { + "index": pollen_percentage, + "period": DateTimeFormatter.relative_day_to_date(period_type), + } + ) + ) + + return valid_periods + + +def error_handler(func): + """Decorator to handle exceptions in async functions.""" + @functools.wraps(func) async def wrapper(*args, **kwargs): try: - result = await func(*args, **kwargs) + return await func(*args, **kwargs) except Exception as e: - logger.exception(e) + logger.exception(f"Error in {func.__name__}: {e}") return [] - return result return wrapper -@fallback_handler -async def fetch_pollen(zipcode): - if zipcode in pollen_cache: - return pollen_cache[zipcode] +class WeatherService: + """Service for fetching weather data.""" - url = f"https://www.pollen.com/api/forecast/current/pollen/{zipcode}" - headers = { - "User-Agent": ( - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/123.0.0.0 Safari/537.36" - ), - "Accept": "application/json, text/plain, */*", - "Referer": url, - "Cookie": f"geo={zipcode}", - } - async with httpx.AsyncClient(timeout=10.0) as client: - response = await client.get(url, headers=headers) - response.raise_for_status() - data = response.json() - result = [ - { - "forecast_date": format_datetime( - datetime.fromisoformat(data["ForecastDate"]) - ), - "periods": [ - { - "index": int(d["Index"] / 12. * 100), - "period": relative_day_to_date(d["Type"]), - } - for d in data["Location"]["periods"] - if d["Type"].lower().strip() in ["today", "tomorrow"] - ], - } + @staticmethod + @error_handler + async def fetch_weather(latitude: str, longitude: str, api_key: str) -> list[WeatherReport]: + """Fetch weather data from OpenWeatherMap API.""" + cache_key = (latitude, longitude) + + if cache_key in weather_cache: + return weather_cache[cache_key] + + base_url = "https://api.openweathermap.org/data/3.0/onecall" + params = { + "lat": latitude, + "lon": longitude, + "appid": api_key, + "units": "imperial", + } + url = f"{base_url}?{urlencode(params)}" + + async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client: + response = await client.get(url) + response.raise_for_status() + raw_data = response.json() + + weather_data = WeatherData(raw_data) + result = WeatherService._format_weather_data(weather_data) + + weather_cache[cache_key] = result + return result + + @staticmethod + def _format_weather_data(weather_data: WeatherData) -> list[WeatherReport]: + """Format weather data for API response.""" + current = weather_data.current + daily_periods = weather_data.daily_periods + + current_dt = datetime.fromtimestamp(current["dt"]) + + return [ + WeatherReport( + { + "forecast_date": DateTimeFormatter.format_datetime(current_dt), + "current_temp": int(round(current["temp"])), + "current_feels_like": int(round(current["feels_like"])), + "current_humidity": current["humidity"], + "sunrise": DateTimeFormatter.format_time( + datetime.fromtimestamp(current["sunrise"]) + ), + "sunset": DateTimeFormatter.format_time( + datetime.fromtimestamp(current["sunset"]) + ), + "current_pressure": current["pressure"], + "current_desc": current["weather"][0]["description"], + "periods": [ + WeatherService._format_daily_period(period) for period in daily_periods + ], + } + ) ] - pollen_cache[zipcode] = result - return result + @staticmethod + def _format_daily_period(period: DailyWeather) -> WeatherPeriod: + """Format a single daily weather period.""" + period_dt = datetime.fromtimestamp(period["dt"]) -@fallback_handler -async def fetch_weather(lat, lon, weather_api_key): - cache_key = (lat, lon) - if cache_key in weather_cache: - return weather_cache[cache_key] - - url = f"https://api.openweathermap.org/data/3.0/onecall?lat={lat}&lon={lon}&appid={weather_api_key}&units=imperial" - async with httpx.AsyncClient(timeout=10.0) as client: - response = await client.get(url) - response.raise_for_status() - data = response.json() - current, periods = data["current"], data["daily"][:2] - result = [ + return WeatherPeriod( { - "forecast_date": format_datetime(datetime.fromtimestamp(current["dt"])), - "current_temp": int(round(current["temp"])), - "current_feels_like": int(round(current["feels_like"])), - "current_humidity": current["humidity"], - "sunrise": format_time(datetime.fromtimestamp(current["sunrise"])), - "sunset": format_time(datetime.fromtimestamp(current["sunset"])), - "current_pressure": current["pressure"], - "current_desc": current["weather"][0]["description"], - "periods": [ - { - "low": int(round(p["temp"]["min"])), - "high": int(round(p["temp"]["max"])), - "desc": p["weather"][0]["description"], - "humidity": p["humidity"], - "sunrise": format_time(datetime.fromtimestamp(p["sunrise"])), - "sunset": format_time(datetime.fromtimestamp(p["sunset"])), - "pressure": p["pressure"], - "period": format_date(datetime.fromtimestamp(p["dt"])), - } - for p in periods - ], + "low": int(round(period["temp"]["min"])), + "high": int(round(period["temp"]["max"])), + "desc": period["weather"][0]["description"], + "humidity": period["humidity"], + "sunrise": DateTimeFormatter.format_time(datetime.fromtimestamp(period["sunrise"])), + "sunset": DateTimeFormatter.format_time(datetime.fromtimestamp(period["sunset"])), + "pressure": period["pressure"], + "period": DateTimeFormatter.format_date(period_dt), } - ] - weather_cache[cache_key] = result - return result + ) + + +class PollenService: + """Service for fetching pollen data.""" + + @staticmethod + @error_handler + async def fetch_pollen(zip_code: str) -> list[PollenReport]: + """Fetch pollen data from pollen.com API.""" + if zip_code in pollen_cache: + return pollen_cache[zip_code] + + base_url = "https://www.pollen.com/api/forecast/current/pollen/" + url = urljoin(base_url, zip_code) + headers = { + "User-Agent": ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/123.0.0.0 Safari/537.36" + ), + "Accept": "application/json, text/plain, */*", + "Referer": url, + "Cookie": f"geo={zip_code}", + } + + async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client: + response = await client.get(url, headers=headers) + response.raise_for_status() + raw_data = response.json() + + pollen_data = PollenData(raw_data) + result = [ + PollenReport( + { + "forecast_date": pollen_data.forecast_date, + "periods": pollen_data.periods, + } + ) + ] + + pollen_cache[zip_code] = result + return result + + +class DataAggregator: + """Service for aggregating weather and pollen data.""" + + @staticmethod + def build_daily_data( + date: str, + pollen_periods: dict[str, PollenPeriod], + weather_periods: dict[str, WeatherPeriod], + ) -> DailyData: + """Build daily data combining weather and pollen information.""" + daily_data: DailyData = {} + + if date in pollen_periods: + daily_data["pollen"] = pollen_periods[date]["index"] + + if date in weather_periods: + weather_data = weather_periods[date] + daily_data.update( + { + "low": weather_data["low"], + "high": weather_data["high"], + "desc": weather_data["desc"], + "humidity": weather_data["humidity"], + "sunrise": weather_data["sunrise"], + "sunset": weather_data["sunset"], + "pressure": weather_data["pressure"], + } + ) + + return daily_data + + @staticmethod + def create_periods_lookup( + pollen_data: list[PollenReport], weather_data: list[WeatherReport] + ) -> tuple[dict[str, PollenPeriod], dict[str, WeatherPeriod], CurrentWeatherData]: + """Create lookup dictionaries for pollen and weather periods.""" + pollen_periods: dict[str, PollenPeriod] = {} + weather_periods: dict[str, WeatherPeriod] = {} + + if pollen_data and pollen_data[0].get("periods"): + pollen_periods = {p["period"]: p for p in pollen_data[0]["periods"]} + + if weather_data and weather_data[0].get("periods"): + weather_periods = {p["period"]: p for p in weather_data[0]["periods"]} + + current_weather_info = CurrentWeatherData( + { + "temp": 0, + "feels_like": 0, + "desc": "", + "humidity": 0, + "pressure": 0, + } + ) + + if weather_data and weather_data[0]: + data = weather_data[0] + current_weather_info = CurrentWeatherData( + { + "temp": data["current_temp"], + "feels_like": data["current_feels_like"], + "desc": data["current_desc"], + "humidity": data["current_humidity"], + "pressure": data["current_pressure"], + } + ) + + return pollen_periods, weather_periods, current_weather_info @app.get("/") -async def read_root(token: str): - if token != CONFIG["auth_token"]: - raise HTTPException(status_code=403, detail="unauthorized") +async def get_weather_pollen_report(token: str) -> FinalReport: + """ + Get weather and pollen report. - [ - pollen, - weather, - ] = await asyncio.gather( - fetch_pollen(CONFIG["zip"]), - fetch_weather(CONFIG["lat"], CONFIG["lon"], CONFIG["weather_api_key"]), + Args: + token: Authentication token + + Returns: + Dictionary containing current, today, and tomorrow weather/pollen data + + Raises: + HTTPException: If authentication fails + """ + if token != config.auth_token: + raise HTTPException(status_code=403, detail="Unauthorized") + + pollen_data, weather_data = await asyncio.gather( + PollenService.fetch_pollen(config.zip_code), + WeatherService.fetch_weather(config.latitude, config.longitude, config.weather_api_key), ) - pollen_periods = {p["period"]: p for p in pollen[0]["periods"]} if pollen and pollen[0]["periods"] else {} - weather_periods = {p["period"]: p for p in weather[0]["periods"]} if weather and weather[0]["periods"] else {} + pollen_periods, weather_periods, current_weather_info = DataAggregator.create_periods_lookup( + pollen_data, weather_data + ) - # Add current weather data as a "current" period - if weather and weather[0]: - data = weather[0] - weather_periods["current"] = { - "period": "current", - "temp": data["current_temp"], - "feels_like": data["current_feels_like"], - "humidity": data["current_humidity"], - "pressure": data["current_pressure"], - "desc": data["current_desc"], - "sunrise": data["sunrise"], - "sunset": data["sunset"] + now = datetime.now() + today_date = DateTimeFormatter.format_date(now) + tomorrow_date = DateTimeFormatter.format_date(now + timedelta(days=1)) + + result: FinalReport = FinalReport( + { + "fetched_at": DateTimeFormatter.format_datetime(now), + "current": current_weather_info, + "today": DailyData({}), + "tomorrow": DailyData({}), } + ) - today_date = format_date(datetime.now()) - tomorrow_date = format_date(datetime.now() + timedelta(days=1)) - - result = { - "fetched_at": format_datetime(datetime.now()), - "current": {}, - "today": {}, - "tomorrow": {}, - } - - if "current" in weather_periods: - weather_data = weather_periods["current"] - result["current"] = { - "temp": weather_data["temp"], - "feels_like": weather_data["feels_like"], - "desc": weather_data["desc"], - "humidity": weather_data["humidity"], - "pressure": weather_data["pressure"] - } - - today_data = build_daily_data(today_date, pollen_periods, weather_periods) + today_data = DataAggregator.build_daily_data(today_date, pollen_periods, weather_periods) if today_data: result["today"] = today_data - tomorrow_data = build_daily_data(tomorrow_date, pollen_periods, weather_periods) + tomorrow_data = DataAggregator.build_daily_data(tomorrow_date, pollen_periods, weather_periods) if tomorrow_data: result["tomorrow"] = tomorrow_data diff --git a/pyproject.toml b/pyproject.toml index 30a8326..8758764 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,14 @@ dependencies = [ "cachetools>=5.0.0", ] +[tool.ruff] +target-version = "py313" +line-length = 100 +indent-width = 4 + [tool.pyright] venvPath = "." venv = ".venv" + +[dependency-groups] +dev = ["ruff>=0.12.11"] \ No newline at end of file diff --git a/uv.lock b/uv.lock index 6008a3a..1f71db1 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.13" [[package]] @@ -364,6 +364,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2e/3c/7a824c0514e87c61000583ac22c8321da6dc8e58a93d5f56e583482a2ee0/rich_toolkit-0.14.6-py3-none-any.whl", hash = "sha256:764f3a5f9e4b539ce805596863299e8982599514906dc5e3ccc2d390ef74c301", size = 24815, upload-time = "2025-05-12T19:19:13.713Z" }, ] +[[package]] +name = "ruff" +version = "0.12.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/de/55/16ab6a7d88d93001e1ae4c34cbdcfb376652d761799459ff27c1dc20f6fa/ruff-0.12.11.tar.gz", hash = "sha256:c6b09ae8426a65bbee5425b9d0b82796dbb07cb1af045743c79bfb163001165d", size = 5347103, upload-time = "2025-08-28T13:59:08.87Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/a2/3b3573e474de39a7a475f3fbaf36a25600bfeb238e1a90392799163b64a0/ruff-0.12.11-py3-none-linux_armv6l.whl", hash = "sha256:93fce71e1cac3a8bf9200e63a38ac5c078f3b6baebffb74ba5274fb2ab276065", size = 11979885, upload-time = "2025-08-28T13:58:26.654Z" }, + { url = "https://files.pythonhosted.org/packages/76/e4/235ad6d1785a2012d3ded2350fd9bc5c5af8c6f56820e696b0118dfe7d24/ruff-0.12.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b8e33ac7b28c772440afa80cebb972ffd823621ded90404f29e5ab6d1e2d4b93", size = 12742364, upload-time = "2025-08-28T13:58:30.256Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0d/15b72c5fe6b1e402a543aa9d8960e0a7e19dfb079f5b0b424db48b7febab/ruff-0.12.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d69fb9d4937aa19adb2e9f058bc4fbfe986c2040acb1a4a9747734834eaa0bfd", size = 11920111, upload-time = "2025-08-28T13:58:33.677Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c0/f66339d7893798ad3e17fa5a1e587d6fd9806f7c1c062b63f8b09dda6702/ruff-0.12.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:411954eca8464595077a93e580e2918d0a01a19317af0a72132283e28ae21bee", size = 12160060, upload-time = "2025-08-28T13:58:35.74Z" }, + { url = "https://files.pythonhosted.org/packages/03/69/9870368326db26f20c946205fb2d0008988aea552dbaec35fbacbb46efaa/ruff-0.12.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6a2c0a2e1a450f387bf2c6237c727dd22191ae8c00e448e0672d624b2bbd7fb0", size = 11799848, upload-time = "2025-08-28T13:58:38.051Z" }, + { url = "https://files.pythonhosted.org/packages/25/8c/dd2c7f990e9b3a8a55eee09d4e675027d31727ce33cdb29eab32d025bdc9/ruff-0.12.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ca4c3a7f937725fd2413c0e884b5248a19369ab9bdd850b5781348ba283f644", size = 13536288, upload-time = "2025-08-28T13:58:40.046Z" }, + { url = "https://files.pythonhosted.org/packages/7a/30/d5496fa09aba59b5e01ea76775a4c8897b13055884f56f1c35a4194c2297/ruff-0.12.11-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:4d1df0098124006f6a66ecf3581a7f7e754c4df7644b2e6704cd7ca80ff95211", size = 14490633, upload-time = "2025-08-28T13:58:42.285Z" }, + { url = "https://files.pythonhosted.org/packages/9b/2f/81f998180ad53445d403c386549d6946d0748e536d58fce5b5e173511183/ruff-0.12.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a8dd5f230efc99a24ace3b77e3555d3fbc0343aeed3fc84c8d89e75ab2ff793", size = 13888430, upload-time = "2025-08-28T13:58:44.641Z" }, + { url = "https://files.pythonhosted.org/packages/87/71/23a0d1d5892a377478c61dbbcffe82a3476b050f38b5162171942a029ef3/ruff-0.12.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4dc75533039d0ed04cd33fb8ca9ac9620b99672fe7ff1533b6402206901c34ee", size = 12913133, upload-time = "2025-08-28T13:58:47.039Z" }, + { url = "https://files.pythonhosted.org/packages/80/22/3c6cef96627f89b344c933781ed38329bfb87737aa438f15da95907cbfd5/ruff-0.12.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4fc58f9266d62c6eccc75261a665f26b4ef64840887fc6cbc552ce5b29f96cc8", size = 13169082, upload-time = "2025-08-28T13:58:49.157Z" }, + { url = "https://files.pythonhosted.org/packages/05/b5/68b3ff96160d8b49e8dd10785ff3186be18fd650d356036a3770386e6c7f/ruff-0.12.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5a0113bd6eafd545146440225fe60b4e9489f59eb5f5f107acd715ba5f0b3d2f", size = 13139490, upload-time = "2025-08-28T13:58:51.593Z" }, + { url = "https://files.pythonhosted.org/packages/59/b9/050a3278ecd558f74f7ee016fbdf10591d50119df8d5f5da45a22c6afafc/ruff-0.12.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0d737b4059d66295c3ea5720e6efc152623bb83fde5444209b69cd33a53e2000", size = 11958928, upload-time = "2025-08-28T13:58:53.943Z" }, + { url = "https://files.pythonhosted.org/packages/f9/bc/93be37347db854806904a43b0493af8d6873472dfb4b4b8cbb27786eb651/ruff-0.12.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:916fc5defee32dbc1fc1650b576a8fed68f5e8256e2180d4d9855aea43d6aab2", size = 11764513, upload-time = "2025-08-28T13:58:55.976Z" }, + { url = "https://files.pythonhosted.org/packages/7a/a1/1471751e2015a81fd8e166cd311456c11df74c7e8769d4aabfbc7584c7ac/ruff-0.12.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c984f07d7adb42d3ded5be894fb4007f30f82c87559438b4879fe7aa08c62b39", size = 12745154, upload-time = "2025-08-28T13:58:58.16Z" }, + { url = "https://files.pythonhosted.org/packages/68/ab/2542b14890d0f4872dd81b7b2a6aed3ac1786fae1ce9b17e11e6df9e31e3/ruff-0.12.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e07fbb89f2e9249f219d88331c833860489b49cdf4b032b8e4432e9b13e8a4b9", size = 13227653, upload-time = "2025-08-28T13:59:00.276Z" }, + { url = "https://files.pythonhosted.org/packages/22/16/2fbfc61047dbfd009c58a28369a693a1484ad15441723be1cd7fe69bb679/ruff-0.12.11-py3-none-win32.whl", hash = "sha256:c792e8f597c9c756e9bcd4d87cf407a00b60af77078c96f7b6366ea2ce9ba9d3", size = 11944270, upload-time = "2025-08-28T13:59:02.347Z" }, + { url = "https://files.pythonhosted.org/packages/08/a5/34276984705bfe069cd383101c45077ee029c3fe3b28225bf67aa35f0647/ruff-0.12.11-py3-none-win_amd64.whl", hash = "sha256:a3283325960307915b6deb3576b96919ee89432ebd9c48771ca12ee8afe4a0fd", size = 13046600, upload-time = "2025-08-28T13:59:04.751Z" }, + { url = "https://files.pythonhosted.org/packages/84/a8/001d4a7c2b37623a3fd7463208267fb906df40ff31db496157549cfd6e72/ruff-0.12.11-py3-none-win_arm64.whl", hash = "sha256:bae4d6e6a2676f8fb0f98b74594a048bae1b944aab17e9f5d504062303c6dbea", size = 12135290, upload-time = "2025-08-28T13:59:06.933Z" }, +] + [[package]] name = "shellingham" version = "1.5.4" @@ -404,6 +430,11 @@ dependencies = [ { name = "httpx" }, ] +[package.dev-dependencies] +dev = [ + { name = "ruff" }, +] + [package.metadata] requires-dist = [ { name = "cachetools", specifier = ">=5.0.0" }, @@ -411,6 +442,9 @@ requires-dist = [ { name = "httpx", specifier = ">=0.28.1" }, ] +[package.metadata.requires-dev] +dev = [{ name = "ruff", specifier = ">=0.12.11" }] + [[package]] name = "typer" version = "0.16.0"