diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f7ba856 --- /dev/null +++ b/Makefile @@ -0,0 +1,20 @@ +sync-dev: + uv sync --group dev + +start: + uv run fastapi dev app/main.py + +build-docker: + docker build -t ecologits-api + +run-docker: + docker run -p 8000:80 ecologits-api + +test: + uv run pytest + +format: + uv run ruff check --select I --fix && uv run ruff format + +check-fix: + uv run ruff check --fix && uv run ruff check --select I --fix \ No newline at end of file diff --git a/app/api/v1/router.py b/app/api/v1/router.py deleted file mode 100644 index 1a86f12..0000000 --- a/app/api/v1/router.py +++ /dev/null @@ -1,81 +0,0 @@ -from fastapi import APIRouter, HTTPException, Body -from ecologits.model_repository import Providers, models -from ecologits.electricity_mix_repository import electricity_mixes -from ecologits.tracers.utils import llm_impacts - -api_router = APIRouter() - -@api_router.get("/providers", response_model=dict, summary="Get all providers") -def get_providers(): - try: - providers_list = [provider.value for provider in Providers] - return { - "providers": providers_list, - } - except Exception as e: - raise HTTPException(status_code=500, detail="Failed to retrieve providers") - -@api_router.get( - "/models/{provider_name}", - response_model=dict, - summary="Get all models", - description="

The returned models may include warning and error indicators. For detailed information about interpreting these warning and error values, please refer to the documentation:
https://ecologits.ai/latest/tutorial/warnings_and_errors/

" -) -def get_models(provider_name: str): - try: - provider = Providers[provider_name] - except KeyError: - raise HTTPException(status_code=404, detail="Provider not found") - - try: - filter_model = [] - for model in models.list_models(): - if model.provider == provider: - filter_model.append(model) - return { - "models": filter_model, - } - except Exception as e: - raise HTTPException(status_code=500, detail="Failed to retrieve models") - -@api_router.get( - "/electricity-mix-zones/{zone}", - response_model=dict, - summary="Get electricity mix data for a zone", - description="

Retrieve the electricity mix data for a specified zone using ISO 3166-1 alpha-3 country codes or special regional codes.

Supported zone types:

Response: Returns the electricity mix composition data for the zone, or 404 if the zone is not supported by EcoLogits.

The electricity mix data includes the breakdown of energy sources used for electricity generation in the specified region, which is essential for accurate carbon impact calculations.

" -) -def get_electricity_mix_zones(zone: str): - try: - electricity_mix = electricity_mixes.find_electricity_mix(zone) - except Exception as e: - raise HTTPException(status_code=500, detail="Failed to retrieve electricity mix zone") - - if electricity_mix == None: - raise HTTPException(status_code=404, detail=f"Electricity mix zone '{zone}' is not supported by EcoLogits") - - return {"electricity_mix": electricity_mix} - -@api_router.post( - "/estimations", - response_model=dict, - summary="Estimate the impacts of an LLM generation requests" -) -def post_estimations( - provider: str = Body(..., embed=True, examples=["openai"], description="Name of the provider."), - model_name: str = Body(..., embed=True, examples=["gpt-4o-mini"], description="Name of the LLM used."), - output_token_count: int = Body(..., embed=True, examples=[300], description="Number of generated tokens."), - request_latency: float = Body(..., embed=True, examples=[1.5], description="Measured request latency in seconds."), - electricity_mix_zone: str | None = Body(default=None, embed=True, examples=["WOR"], description="ISO 3166-1 alpha-3 code of the electricity mix zone (WOR by default).") -): - try: - impacts = llm_impacts( - provider=provider, - model_name=model_name, - output_token_count=output_token_count, - request_latency=request_latency, - electricity_mix_zone=electricity_mix_zone, - ) - return {"impacts": impacts} - - except Exception as e: - raise HTTPException(status_code=500, detail="Failed to Estimate impacts") \ No newline at end of file diff --git a/app/api/v1/__init__.py b/app/api/v1beta/__init__.py similarity index 100% rename from app/api/v1/__init__.py rename to app/api/v1beta/__init__.py diff --git a/app/api/v1beta/responses/__init__.py b/app/api/v1beta/responses/__init__.py new file mode 100644 index 0000000..471102f --- /dev/null +++ b/app/api/v1beta/responses/__init__.py @@ -0,0 +1,11 @@ +from app.api.v1beta.responses.electricity_mix import ELECTRICITY_MIX_RESPONSES +from app.api.v1beta.responses.estimations import ESTIMATIONS_RESPONSES +from app.api.v1beta.responses.models import MODELS_RESPONSES +from app.api.v1beta.responses.providers import PROVIDERS_RESPONSES + +__all__ = [ + "PROVIDERS_RESPONSES", + "MODELS_RESPONSES", + "ELECTRICITY_MIX_RESPONSES", + "ESTIMATIONS_RESPONSES", +] diff --git a/app/api/v1beta/responses/electricity_mix.py b/app/api/v1beta/responses/electricity_mix.py new file mode 100644 index 0000000..dab3961 --- /dev/null +++ b/app/api/v1beta/responses/electricity_mix.py @@ -0,0 +1,28 @@ +ELECTRICITY_MIX_RESPONSES = { + 200: { + "description": "Electricity mix composition for the specified zone.", + "content": { + "application/json": { + "example": { + "electricity_mix": { + "zone": "FRA", + "adpe": 4.858e-08, + "pe": 9.3135, + "gwp": 0.04418, + "wue": 3.6737, + } + } + } + }, + }, + 404: { + "description": "Zone not supported by EcoLogits.", + "content": { + "application/json": { + "example": { + "detail": "Electricity mix zone 'XYZ' is not supported by EcoLogits" + } + } + }, + }, +} diff --git a/app/api/v1beta/responses/estimations.py b/app/api/v1beta/responses/estimations.py new file mode 100644 index 0000000..4393937 --- /dev/null +++ b/app/api/v1beta/responses/estimations.py @@ -0,0 +1,149 @@ +ESTIMATIONS_RESPONSES = { + 200: { + "description": "Environmental impact estimation with min/max intervals.", + "content": { + "application/json": { + "example": { + "impacts": { + "energy": { + "type": "energy", + "name": "Energy", + "value": { + "min": 0.00001740232832301384, + "max": 0.000021502108309942407, + }, + "unit": "kWh", + }, + "gwp": { + "type": "GWP", + "name": "Global Warming Potential", + "value": { + "min": 0.000008448613075332601, + "max": 0.000010387850006949683, + }, + "unit": "kgCO2eq", + }, + "adpe": { + "type": "ADPe", + "name": "Abiotic Depletion Potential (elements)", + "value": { + "min": 1.2955834115064265e-11, + "max": 1.3011140147087931e-11, + }, + "unit": "kgSbeq", + }, + "pe": { + "type": "PE", + "name": "Primary Energy", + "value": { + "min": 0.0000476420806326274, + "max": 0.0000582486214368103, + }, + "unit": "MJ", + }, + "wcf": { + "type": "WCF", + "name": "Water Consumption Footprint", + "value": { + "min": 0.00009151188371940057, + "max": 0.00011307098675866313, + }, + "unit": "L", + }, + "usage": { + "type": "usage", + "name": "Usage", + "energy": { + "type": "energy", + "name": "Energy", + "value": { + "min": 0.00001740232832301384, + "max": 0.000021502108309942407, + }, + "unit": "kWh", + }, + "gwp": { + "type": "GWP", + "name": "Global Warming Potential", + "value": { + "min": 0.000008231475320068776, + "max": 0.000010170712251685857, + }, + "unit": "kgCO2eq", + }, + "adpe": { + "type": "ADPe", + "name": "Abiotic Depletion Potential (elements)", + "value": { + "min": 2.3475740907745673e-13, + "max": 2.9006344110112307e-13, + }, + "unit": "kgSbeq", + }, + "pe": { + "type": "PE", + "name": "Primary Energy", + "value": { + "min": 0.000045021563604469106, + "max": 0.000055628104408652, + }, + "unit": "MJ", + }, + "wcf": { + "type": "WCF", + "name": "Water Consumption Footprint", + "value": { + "min": 0.00009151188371940057, + "max": 0.00011307098675866313, + }, + "unit": "L", + }, + }, + "embodied": { + "type": "embodied", + "name": "Embodied", + "gwp": { + "type": "GWP", + "name": "Global Warming Potential", + "value": { + "min": 2.1713775526382546e-7, + "max": 2.1713775526382546e-7, + }, + "unit": "kgCO2eq", + }, + "adpe": { + "type": "ADPe", + "name": "Abiotic Depletion Potential (elements)", + "value": { + "min": 1.2721076705986809e-11, + "max": 1.2721076705986809e-11, + }, + "unit": "kgSbeq", + }, + "pe": { + "type": "PE", + "name": "Primary Energy", + "value": { + "min": 0.0000026205170281582953, + "max": 0.0000026205170281582953, + }, + "unit": "MJ", + }, + }, + "warnings": [ + { + "code": "model-arch-not-released", + "message": "The model architecture has not been released, expect lower precision.", + }, + { + "code": "model-arch-multimodal", + "message": "The model architecture is multimodal, expect lower precision.", + }, + ], + "errors": None, + } + } + } + }, + }, +} diff --git a/app/api/v1beta/responses/models.py b/app/api/v1beta/responses/models.py new file mode 100644 index 0000000..2380f84 --- /dev/null +++ b/app/api/v1beta/responses/models.py @@ -0,0 +1,35 @@ +MODELS_RESPONSES = { + 200: { + "description": "List of models for the provider.", + "content": { + "application/json": { + "example": { + "models": [ + { + "provider": "openai", + "name": "gpt-4o-mini", + "architecture": { + "type": "moe", + "parameters": { + "total": 440, + "active": {"min": 44, "max": 132}, + }, + }, + "warnings": [ + { + "code": "model-arch-not-released", + "message": "The model architecture has not been released, expect lower precision.", + } + ], + "sources": [], + } + ] + } + } + }, + }, + 404: { + "description": "Provider not found.", + "content": {"application/json": {"example": {"detail": "Provider not found"}}}, + }, +} diff --git a/app/api/v1beta/responses/providers.py b/app/api/v1beta/responses/providers.py new file mode 100644 index 0000000..7ca24f5 --- /dev/null +++ b/app/api/v1beta/responses/providers.py @@ -0,0 +1,19 @@ +PROVIDERS_RESPONSES = { + 200: { + "description": "List of provider identifiers.", + "content": { + "application/json": { + "example": { + "providers": [ + "anthropic", + "mistralai", + "openai", + "huggingface_hub", + "cohere", + "google_genai", + ] + } + } + }, + }, +} diff --git a/app/api/v1beta/router.py b/app/api/v1beta/router.py new file mode 100644 index 0000000..8bdc14d --- /dev/null +++ b/app/api/v1beta/router.py @@ -0,0 +1,140 @@ +from ecologits.electricity_mix_repository import electricity_mixes +from ecologits.model_repository import Providers, models +from ecologits.tracers.utils import llm_impacts +from fastapi import APIRouter, Body, HTTPException + +from app.api.v1beta.responses import ( + ELECTRICITY_MIX_RESPONSES, + ESTIMATIONS_RESPONSES, + MODELS_RESPONSES, + PROVIDERS_RESPONSES, +) + +api_router_v1beta = APIRouter(prefix="/v1beta") + + +@api_router_v1beta.get( + "/providers", + response_model=dict, + tags=["Catalog"], + summary="List all supported providers", + responses=PROVIDERS_RESPONSES, +) +def get_providers(): + try: + providers_list = [provider.value for provider in Providers] + return { + "providers": providers_list, + } + except Exception: + raise HTTPException(status_code=500, detail="Failed to retrieve providers") + + +@api_router_v1beta.get( + "/models/{provider_name}", + response_model=dict, + tags=["Catalog"], + summary="List models for a provider", + description=( + "Models may include **warning** and **error** indicators " + "([details](https://ecologits.ai/latest/tutorial/warnings_and_errors/))." + ), + responses=MODELS_RESPONSES, +) +def get_models(provider_name: str): + try: + provider = Providers[provider_name] + except KeyError: + raise HTTPException(status_code=404, detail="Provider not found") + + try: + filter_model = [] + for model in models.list_models(): + if model.provider == provider: + filter_model.append(model) + return { + "models": filter_model, + } + except Exception: + raise HTTPException(status_code=500, detail="Failed to retrieve models") + + +@api_router_v1beta.get( + "/electricity-mix-zones/{zone}", + response_model=dict, + tags=["Electricity mix"], + summary="Get electricity mix for a zone", + description=( + "Use ISO 3166-1 alpha-3 codes (`USA`, `FRA`, `DEU`) " + "or regional codes (`EEE` for Europe, `WOR` for World average)." + ), + responses=ELECTRICITY_MIX_RESPONSES, +) +def get_electricity_mix_zones(zone: str): + try: + electricity_mix = electricity_mixes.find_electricity_mix(zone) + except Exception: + raise HTTPException( + status_code=500, detail="Failed to retrieve electricity mix zone" + ) + + if electricity_mix is None: + raise HTTPException( + status_code=404, + detail=f"Electricity mix zone '{zone}' is not supported by EcoLogits", + ) + + return {"electricity_mix": electricity_mix} + + +@api_router_v1beta.post( + "/estimations", + response_model=dict, + tags=["Estimations"], + summary="Estimate environmental impacts of an LLM request", + responses=ESTIMATIONS_RESPONSES, +) +def post_estimations( + provider: str = Body( + ..., + embed=True, + examples=["openai"], + description="Provider identifier (use `GET /v1beta/providers` to list valid values).", + ), + model_name: str = Body( + ..., + embed=True, + examples=["gpt-4o-mini"], + description="Model identifier as registered in EcoLogits (use `GET /v1beta/models/{provider}` to list valid values).", + ), + output_token_count: int = Body( + ..., + embed=True, + examples=[300], + description="Number of tokens generated by the model.", + ), + request_latency: float = Body( + ..., + embed=True, + examples=[1.5], + description="Measured request latency in seconds.", + ), + electricity_mix_zone: str | None = Body( + default=None, + embed=True, + examples=["WOR"], + description="ISO 3166-1 alpha-3 zone code for the electricity mix. Defaults to `WOR` (world average). (use `GET /v1beta/electricity-mix-zones/{zone}` to check zone availability)", + ), +): + try: + impacts = llm_impacts( + provider=provider, + model_name=model_name, + output_token_count=output_token_count, + request_latency=request_latency, + electricity_mix_zone=electricity_mix_zone, + ) + return {"impacts": impacts} + + except Exception: + raise HTTPException(status_code=500, detail="Failed to Estimate impacts") diff --git a/app/core/config.py b/app/core/config.py index 6d5ce3f..80ee510 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -1,23 +1,25 @@ +from pathlib import Path from typing import List from pydantic_settings import BaseSettings, SettingsConfigDict +_DESCRIPTION_FILE = Path(__file__).parent / "description.md" + class Settings(BaseSettings): - model_config = SettingsConfigDict(env_file='.env', env_file_encoding='utf-8') + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8") # API Configuration app_name: str = "EcoLogits API" app_version: str = "0.0.1beta" - description: str = "API to use the EcoLogits solution for AI environmental impact tracking" - + description: str = _DESCRIPTION_FILE.read_text() + # CORS Configuration allowed_origins: List[str] = ["*"] allowed_methods: List[str] = ["*"] allowed_headers: List[str] = ["*"] - + # API Configuration - api_v1_prefix: str = "/v1" docs_url: str = "/docs" redoc_url: str = "/redoc" diff --git a/app/core/description.md b/app/core/description.md new file mode 100644 index 0000000..92b60d4 --- /dev/null +++ b/app/core/description.md @@ -0,0 +1,27 @@ +**EcoLogits API** provides a language-agnostic HTTP interface to the +[EcoLogits](https://ecologits.ai) library, so any stack — not just Python — can +estimate the environmental footprint of generative-AI inference. + +## What is EcoLogits? + +EcoLogits is an open-source project (part of the CodeCarbon non-profit) that estimates +the environmental impacts of AI model usage at inference time. +It follows Life Cycle Assessment (LCA) principles defined by ISO 14044. + +## Environmental metrics + +| Metric | Unit | Description | +|---|---|---| +| **Energy** | kWh | Energy consumed by the request | +| **GWP** (Global Warming Potential) | kgCO₂eq | Greenhouse gas emissions | +| **ADPe** (Abiotic Depletion Potential) | kgSbeq | Mineral & metal resource depletion | +| **PE** (Primary Energy) | MJ | Total primary energy consumed | +| **WCF** (Water Consumption Footprint) | L | Fresh water consumed by data centers and power generation, not returned to its source | + +Results are returned as **approximation intervals** (min/max range), not single point estimates. + +## Useful links + +- [EcoLogits documentation](https://ecologits.ai/dev/) +- [Methodology](https://ecologits.ai/dev/methodology/) +- [GitHub repository](https://github.com/mlco2/ecologits) diff --git a/app/main.py b/app/main.py index 00ddee9..89c5711 100644 --- a/app/main.py +++ b/app/main.py @@ -1,15 +1,39 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from app.api.v1.router import api_router + +from app.api.v1beta.router import api_router_v1beta from app.core.config import settings +# OpenAPI tag definitions +tags_metadata = [ + { + "name": "Estimations", + "description": "Estimate the environmental impacts of an LLM inference request.", + }, + { + "name": "Catalog", + "description": "Browse the AI providers and models supported by EcoLogits. " + "Use these endpoints to discover valid values for the estimation request.", + }, + { + "name": "Electricity mix", + "description": "Retrieve the electricity mix composition for a given geographic zone. " + "This data is used to calculate carbon impacts.", + }, +] + # Create FastAPI instance with metadata app = FastAPI( title=settings.app_name, description=settings.description, version=settings.app_version, docs_url=settings.docs_url, - redoc_url=settings.redoc_url + redoc_url=settings.redoc_url, + openapi_tags=tags_metadata, + contact={ + "name": "EcoLogits", + "url": "https://ecologits.ai", + }, ) # Add CORS middleware @@ -22,4 +46,4 @@ ) # Include API router with version prefix -app.include_router(api_router, prefix=settings.api_v1_prefix) +app.include_router(api_router_v1beta) diff --git a/pyproject.toml b/pyproject.toml index df74e53..386568e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,7 @@ dependencies = [ "fastapi[standard]>=0.128.4,<1.0.0", "ecologits>=0.9.2,<1.0.0", "pydantic-settings>=2.12.0", + "ruff>=0.15.6", ] [dependency-groups] diff --git a/tests/v1/__init__.py b/tests/v1beta/__init__.py similarity index 100% rename from tests/v1/__init__.py rename to tests/v1beta/__init__.py diff --git a/tests/v1/test_router.py b/tests/v1beta/test_router.py similarity index 81% rename from tests/v1/test_router.py rename to tests/v1beta/test_router.py index 13b1bea..a588ea4 100644 --- a/tests/v1/test_router.py +++ b/tests/v1beta/test_router.py @@ -1,56 +1,66 @@ -from ecologits.model_repository import Providers, models +from ecologits.model_repository import Providers from ecologits.tracers.utils import llm_impacts from fastapi.testclient import TestClient + from app.main import app - + client = TestClient(app) + def test_get_providers(): - response = client.get("/v1/providers") + response = client.get("/v1beta/providers") assert response.status_code == 200 assert "providers" in response.json() assert response.json() == {"providers": [provider.value for provider in Providers]} + def test_get_models_valid_provider(): - response = client.get("/v1/models/openai") + response = client.get("/v1beta/models/openai") assert response.status_code == 200 assert "models" in response.json() assert isinstance(response.json()["models"], list) + def test_get_models_invalid_provider(): - response = client.get("/v1/models/invalid_provider") + response = client.get("/v1beta/models/invalid_provider") assert response.status_code == 404 assert response.json() == {"detail": "Provider not found"} + def test_find_electricity_mix_zones_valid(): """Test the GET /electricity-mix-zones/{zone} endpoint with a valid zone""" - response = client.get("/v1/electricity-mix-zones/WOR") + response = client.get("/v1beta/electricity-mix-zones/WOR") assert response.status_code == 200 - + response_data = response.json() assert "electricity_mix" in response_data assert response_data["electricity_mix"] is not None assert "zone" in response_data["electricity_mix"] assert response_data["electricity_mix"]["zone"] == "WOR" + def test_find_electricity_mix_zones_invalid(): """Test the GET /electricity-mix-zones/{zone} endpoint with an invalid zone""" - response = client.get("/v1/electricity-mix-zones/INVALID") + response = client.get("/v1beta/electricity-mix-zones/INVALID") assert response.status_code == 404 - assert response.json() == {"detail": "Electricity mix zone 'INVALID' is not supported by EcoLogits"} + assert response.json() == { + "detail": "Electricity mix zone 'INVALID' is not supported by EcoLogits" + } + def test_find_electricity_mix_zones_other_valid_zones(): """Test the GET /electricity-mix-zones/{zone} endpoint with other valid zones""" valid_zones = ["WOR", "USA", "FRA"] - + for zone in valid_zones: - response = client.get(f"/v1/electricity-mix-zones/{zone}") + response = client.get(f"/v1beta/electricity-mix-zones/{zone}") assert response.status_code == 200 - + response_data = response.json() assert "electricity_mix" in response_data assert response_data["electricity_mix"]["zone"] == zone + def test_post_estimations(): """Test the POST /estimations endpoint""" payload = { @@ -58,7 +68,7 @@ def test_post_estimations(): "model_name": "gpt-4o-mini", "output_token_count": 300, "request_latency": 1.5, - "electricity_mix_zone": "WOR" + "electricity_mix_zone": "WOR", } # Get the expected impacts directly from llm_impacts @@ -71,7 +81,7 @@ def test_post_estimations(): ) # Call the API endpoint - response = client.post("/v1/estimations", json=payload) + response = client.post("/v1beta/estimations", json=payload) assert response.status_code == 200 response_data = response.json() @@ -82,13 +92,14 @@ def test_post_estimations(): expected_impacts_dict = expected_impacts.model_dump() assert response_data["impacts"] == expected_impacts_dict + def test_post_estimations_default_electricity_mix(): """Test the POST /estimations endpoint without electricity_mix_zone (should use default)""" payload = { - "provider": "openai", + "provider": "openai", "model_name": "gpt-4o-mini", "output_token_count": 150, - "request_latency": 0.8 + "request_latency": 0.8, # electricity_mix_zone not provided, should default to "WOR" } @@ -99,10 +110,10 @@ def test_post_estimations_default_electricity_mix(): output_token_count=payload["output_token_count"], request_latency=payload["request_latency"], ) - - response = client.post("/v1/estimations", json=payload) + + response = client.post("/v1beta/estimations", json=payload) assert response.status_code == 200 - + response_data = response.json() assert "impacts" in response_data assert response_data["impacts"] is not None @@ -111,13 +122,14 @@ def test_post_estimations_default_electricity_mix(): expected_impacts_dict = expected_impacts.model_dump() assert response_data["impacts"] == expected_impacts_dict + def test_post_estimations_missing_required_fields(): """Test the POST /estimations endpoint with missing required fields""" payload = { "provider": "openai", - "model_name": "gpt-4o-mini" + "model_name": "gpt-4o-mini", # Missing output_token_count and request_latency } - - response = client.post("/v1/estimations", json=payload) - assert response.status_code == 422 # Unprocessable Entity for validation errors \ No newline at end of file + + response = client.post("/v1beta/estimations", json=payload) + assert response.status_code == 422 # Unprocessable Entity for validation errors diff --git a/uv.lock b/uv.lock index 6f58f04..3e3a67b 100644 --- a/uv.lock +++ b/uv.lock @@ -96,6 +96,7 @@ dependencies = [ { name = "ecologits" }, { name = "fastapi", extra = ["standard"] }, { name = "pydantic-settings" }, + { name = "ruff" }, ] [package.dev-dependencies] @@ -109,6 +110,7 @@ requires-dist = [ { name = "ecologits", specifier = ">=0.9.2,<1.0.0" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.128.4,<1.0.0" }, { name = "pydantic-settings", specifier = ">=2.12.0" }, + { name = "ruff", specifier = ">=0.15.6" }, ] [package.metadata.requires-dev] @@ -752,6 +754,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/9c/3881ad34f01942af0cf713e25e476bf851e04e389cc3ff146c3b459ab861/rignore-0.6.4-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:7e6c425603db2c147eace4f752ca3cd4551e7568c9d332175d586c68bcbe3d8d", size = 1122433, upload-time = "2025-07-19T19:24:43.973Z" }, ] +[[package]] +name = "ruff" +version = "0.15.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/df/f8629c19c5318601d3121e230f74cbee7a3732339c52b21daa2b82ef9c7d/ruff-0.15.6.tar.gz", hash = "sha256:8394c7bb153a4e3811a4ecdacd4a8e6a4fa8097028119160dffecdcdf9b56ae4", size = 4597916, upload-time = "2026-03-12T23:05:47.51Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/2f/4e03a7e5ce99b517e98d3b4951f411de2b0fa8348d39cf446671adcce9a2/ruff-0.15.6-py3-none-linux_armv6l.whl", hash = "sha256:7c98c3b16407b2cf3d0f2b80c80187384bc92c6774d85fefa913ecd941256fff", size = 10508953, upload-time = "2026-03-12T23:05:17.246Z" }, + { url = "https://files.pythonhosted.org/packages/70/60/55bcdc3e9f80bcf39edf0cd272da6fa511a3d94d5a0dd9e0adf76ceebdb4/ruff-0.15.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ee7dcfaad8b282a284df4aa6ddc2741b3f4a18b0555d626805555a820ea181c3", size = 10942257, upload-time = "2026-03-12T23:05:23.076Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f9/005c29bd1726c0f492bfa215e95154cf480574140cb5f867c797c18c790b/ruff-0.15.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3bd9967851a25f038fc8b9ae88a7fbd1b609f30349231dffaa37b6804923c4bb", size = 10322683, upload-time = "2026-03-12T23:05:33.738Z" }, + { url = "https://files.pythonhosted.org/packages/5f/74/2f861f5fd7cbb2146bddb5501450300ce41562da36d21868c69b7a828169/ruff-0.15.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13f4594b04e42cd24a41da653886b04d2ff87adbf57497ed4f728b0e8a4866f8", size = 10660986, upload-time = "2026-03-12T23:05:53.245Z" }, + { url = "https://files.pythonhosted.org/packages/c1/a1/309f2364a424eccb763cdafc49df843c282609f47fe53aa83f38272389e0/ruff-0.15.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e2ed8aea2f3fe57886d3f00ea5b8aae5bf68d5e195f487f037a955ff9fbaac9e", size = 10332177, upload-time = "2026-03-12T23:05:56.145Z" }, + { url = "https://files.pythonhosted.org/packages/30/41/7ebf1d32658b4bab20f8ac80972fb19cd4e2c6b78552be263a680edc55ac/ruff-0.15.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70789d3e7830b848b548aae96766431c0dc01a6c78c13381f423bf7076c66d15", size = 11170783, upload-time = "2026-03-12T23:06:01.742Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/6d488f6adca047df82cd62c304638bcb00821c36bd4881cfca221561fdfc/ruff-0.15.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:542aaf1de3154cea088ced5a819ce872611256ffe2498e750bbae5247a8114e9", size = 12044201, upload-time = "2026-03-12T23:05:28.697Z" }, + { url = "https://files.pythonhosted.org/packages/71/68/e6f125df4af7e6d0b498f8d373274794bc5156b324e8ab4bf5c1b4fc0ec7/ruff-0.15.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c22e6f02c16cfac3888aa636e9eba857254d15bbacc9906c9689fdecb1953ab", size = 11421561, upload-time = "2026-03-12T23:05:31.236Z" }, + { url = "https://files.pythonhosted.org/packages/f1/9f/f85ef5fd01a52e0b472b26dc1b4bd228b8f6f0435975442ffa4741278703/ruff-0.15.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98893c4c0aadc8e448cfa315bd0cc343a5323d740fe5f28ef8a3f9e21b381f7e", size = 11310928, upload-time = "2026-03-12T23:05:45.288Z" }, + { url = "https://files.pythonhosted.org/packages/8c/26/b75f8c421f5654304b89471ed384ae8c7f42b4dff58fa6ce1626d7f2b59a/ruff-0.15.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:70d263770d234912374493e8cc1e7385c5d49376e41dfa51c5c3453169dc581c", size = 11235186, upload-time = "2026-03-12T23:05:50.677Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d4/d5a6d065962ff7a68a86c9b4f5500f7d101a0792078de636526c0edd40da/ruff-0.15.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:55a1ad63c5a6e54b1f21b7514dfadc0c7fb40093fa22e95143cf3f64ebdcd512", size = 10635231, upload-time = "2026-03-12T23:05:37.044Z" }, + { url = "https://files.pythonhosted.org/packages/d6/56/7c3acf3d50910375349016cf33de24be021532042afbed87942858992491/ruff-0.15.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8dc473ba093c5ec238bb1e7429ee676dca24643c471e11fbaa8a857925b061c0", size = 10340357, upload-time = "2026-03-12T23:06:04.748Z" }, + { url = "https://files.pythonhosted.org/packages/06/54/6faa39e9c1033ff6a3b6e76b5df536931cd30caf64988e112bbf91ef5ce5/ruff-0.15.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:85b042377c2a5561131767974617006f99f7e13c63c111b998f29fc1e58a4cfb", size = 10860583, upload-time = "2026-03-12T23:05:58.978Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/509a201b843b4dfb0b32acdedf68d951d3377988cae43949ba4c4133a96a/ruff-0.15.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cef49e30bc5a86a6a92098a7fbf6e467a234d90b63305d6f3ec01225a9d092e0", size = 11410976, upload-time = "2026-03-12T23:05:39.955Z" }, + { url = "https://files.pythonhosted.org/packages/6c/25/3fc9114abf979a41673ce877c08016f8e660ad6cf508c3957f537d2e9fa9/ruff-0.15.6-py3-none-win32.whl", hash = "sha256:bbf67d39832404812a2d23020dda68fee7f18ce15654e96fb1d3ad21a5fe436c", size = 10616872, upload-time = "2026-03-12T23:05:42.451Z" }, + { url = "https://files.pythonhosted.org/packages/89/7a/09ece68445ceac348df06e08bf75db72d0e8427765b96c9c0ffabc1be1d9/ruff-0.15.6-py3-none-win_amd64.whl", hash = "sha256:aee25bc84c2f1007ecb5037dff75cef00414fdf17c23f07dc13e577883dca406", size = 11787271, upload-time = "2026-03-12T23:05:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/7f/d0/578c47dd68152ddddddf31cd7fc67dc30b7cdf639a86275fda821b0d9d98/ruff-0.15.6-py3-none-win_arm64.whl", hash = "sha256:c34de3dd0b0ba203be50ae70f5910b17188556630e2178fd7d79fc030eb0d837", size = 11060497, upload-time = "2026-03-12T23:05:25.968Z" }, +] + [[package]] name = "sentry-sdk" version = "2.34.1"