diff --git a/.dockerignore b/.dockerignore index a5c47a7..ab0acb8 100644 --- a/.dockerignore +++ b/.dockerignore @@ -19,3 +19,4 @@ AWSCLIV2.pkg docker_container grpc_stubs stubs +tests diff --git a/.gitignore b/.gitignore index db416f0..03d34ad 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,4 @@ docker_container/ !.env.example AWSCLIV2.pkg .python-version + diff --git a/README.md b/README.md index 51a2be6..4abb20a 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ Note: Installation of ventis only needs to be done on the machine where you are - **Python 3.10+** - **Docker** — Used to manage agents. +- **Docker Buildx** (optional) — If available, `ventis build` builds all agent/workflow images in a single parallel `docker buildx bake` pass; otherwise it falls back to building them sequentially. --- diff --git a/examples/agents/finance_agent.py b/examples/finance/agents/finance_agent.py similarity index 100% rename from examples/agents/finance_agent.py rename to examples/finance/agents/finance_agent.py diff --git a/examples/agents/finance_agent.yaml b/examples/finance/agents/finance_agent.yaml similarity index 100% rename from examples/agents/finance_agent.yaml rename to examples/finance/agents/finance_agent.yaml diff --git a/examples/agents/market_agent.py b/examples/finance/agents/market_agent.py similarity index 100% rename from examples/agents/market_agent.py rename to examples/finance/agents/market_agent.py diff --git a/examples/agents/market_agent.yaml b/examples/finance/agents/market_agent.yaml similarity index 100% rename from examples/agents/market_agent.yaml rename to examples/finance/agents/market_agent.yaml diff --git a/examples/agents/vllm_agent.py b/examples/finance/agents/vllm_agent.py similarity index 100% rename from examples/agents/vllm_agent.py rename to examples/finance/agents/vllm_agent.py diff --git a/examples/agents/vllm_agent.yaml b/examples/finance/agents/vllm_agent.yaml similarity index 100% rename from examples/agents/vllm_agent.yaml rename to examples/finance/agents/vllm_agent.yaml diff --git a/examples/config/global_controller.ec2_smoke.yaml b/examples/finance/config/global_controller.ec2_smoke.yaml similarity index 100% rename from examples/config/global_controller.ec2_smoke.yaml rename to examples/finance/config/global_controller.ec2_smoke.yaml diff --git a/examples/config/global_controller.yaml b/examples/finance/config/global_controller.yaml similarity index 96% rename from examples/config/global_controller.yaml rename to examples/finance/config/global_controller.yaml index 6e07e24..7199d6d 100644 --- a/examples/config/global_controller.yaml +++ b/examples/finance/config/global_controller.yaml @@ -34,7 +34,7 @@ agents: replicas: 1 type: workflow redis_port: 6379 - workflow_file: workflows/example_workflow.py + workflow_file: workflow/example_workflow.py provider: local poll_interval: 5 diff --git a/examples/config/policy.yaml b/examples/finance/config/policy.yaml similarity index 100% rename from examples/config/policy.yaml rename to examples/finance/config/policy.yaml diff --git a/examples/workflows/example_workflow.py b/examples/finance/workflow/example_workflow.py similarity index 100% rename from examples/workflows/example_workflow.py rename to examples/finance/workflow/example_workflow.py diff --git a/ventis/templates/README.md b/examples/helloworld/README.md similarity index 96% rename from ventis/templates/README.md rename to examples/helloworld/README.md index 1a74295..a182896 100644 --- a/ventis/templates/README.md +++ b/examples/helloworld/README.md @@ -26,7 +26,7 @@ curl http://:8080/status/ ├── agents/ # Agent implementations and YAML definitions │ ├── example_agent.py │ └── example_agent.yaml -├── workflows/ # Workflow scripts (deployed as REST APIs) +├── workflow/ # Workflow scripts (deployed as REST APIs) │ └── example_workflow.py ├── config/ │ ├── global_controller.yaml # Deployment configuration diff --git a/ventis/templates/agents/example_agent.py b/examples/helloworld/agents/example_agent.py similarity index 100% rename from ventis/templates/agents/example_agent.py rename to examples/helloworld/agents/example_agent.py diff --git a/ventis/templates/agents/example_agent.yaml b/examples/helloworld/agents/example_agent.yaml similarity index 100% rename from ventis/templates/agents/example_agent.yaml rename to examples/helloworld/agents/example_agent.yaml diff --git a/ventis/templates/agents/vllm_agent.py b/examples/helloworld/agents/vllm_agent.py similarity index 100% rename from ventis/templates/agents/vllm_agent.py rename to examples/helloworld/agents/vllm_agent.py diff --git a/ventis/templates/agents/vllm_agent.yaml b/examples/helloworld/agents/vllm_agent.yaml similarity index 100% rename from ventis/templates/agents/vllm_agent.yaml rename to examples/helloworld/agents/vllm_agent.yaml diff --git a/ventis/templates/config/global_controller.yaml b/examples/helloworld/config/global_controller.yaml similarity index 96% rename from ventis/templates/config/global_controller.yaml rename to examples/helloworld/config/global_controller.yaml index 4056d96..0b9c194 100644 --- a/ventis/templates/config/global_controller.yaml +++ b/examples/helloworld/config/global_controller.yaml @@ -27,7 +27,7 @@ agents: type: workflow redis_port: 6379 api_port: 8080 # Only needed for workflows, defaults to 8080 if not filled - workflow_file: workflows/example_workflow.py + workflow_file: workflow/example_workflow.py provider: EC2 instance_type: t3.micro diff --git a/ventis/templates/config/policy.yaml b/examples/helloworld/config/policy.yaml similarity index 100% rename from ventis/templates/config/policy.yaml rename to examples/helloworld/config/policy.yaml diff --git a/ventis/templates/workflows/example_workflow.py b/examples/helloworld/workflow/example_workflow.py similarity index 100% rename from ventis/templates/workflows/example_workflow.py rename to examples/helloworld/workflow/example_workflow.py diff --git a/examples/portfolio/agents/advisor_agent.py b/examples/portfolio/agents/advisor_agent.py index e1b558a..035a87c 100644 --- a/examples/portfolio/agents/advisor_agent.py +++ b/examples/portfolio/agents/advisor_agent.py @@ -2,7 +2,9 @@ # # Final stage. Turns the computed portfolio metrics and risk figures into a # short, plain-English briefing using a small, cheap model on AWS Bedrock -# (Converse API). Configure with env vars: +# (Converse API), called via ventis.llm.bedrock so token/cost telemetry gets +# recorded onto this execution's future::metrics hash. Configure +# with env vars: # BEDROCK_MODEL_ID (default: meta.llama3-8b-instruct-v1:0) # AWS_REGION (default: us-east-1) # @@ -13,6 +15,11 @@ import os +try: + from ventis.llm.bedrock import call_bedrock +except ImportError: + from bedrock import call_bedrock + class AdvisorAgent(object): def __init__(self): @@ -26,13 +33,11 @@ def summarize(self, holdings: dict, metrics: dict, risk: dict) -> str: """Write a short plain-English briefing on the portfolio.""" prompt = self._build_prompt(holdings, metrics, risk) try: - import boto3 - - client = boto3.client("bedrock-runtime", region_name=self.region) - response = client.converse( - modelId=self.model_id, + response = call_bedrock( + model_id=self.model_id, messages=[{"role": "user", "content": [{"text": prompt}]}], - inferenceConfig={"maxTokens": 400, "temperature": 0.2}, + inference_config={"maxTokens": 400, "temperature": 0.2}, + region=self.region, ) return response["output"]["message"]["content"][0]["text"] except Exception as e: diff --git a/examples/portfolio/agents/intent_agent.py b/examples/portfolio/agents/intent_agent.py new file mode 100644 index 0000000..1eb15d7 --- /dev/null +++ b/examples/portfolio/agents/intent_agent.py @@ -0,0 +1,127 @@ +# Intent Agent +# +# Stage 0. Turns a free-text portfolio request into the structured input the +# rest of the pipeline needs: +# +# "Analyze 40% Apple, 35% Microsoft and 25% Nvidia over the last 6 months" +# -> {"holdings": {"AAPL": 0.4, "MSFT": 0.35, "NVDA": 0.25}, +# "lookback_days": 180} +# +# Calls AWS Bedrock (Converse API) via ventis.llm.bedrock -- same pattern as +# AdvisorAgent -- so token/cost telemetry gets recorded onto this execution's +# future::metrics hash. Configure with env vars: +# BEDROCK_MODEL_ID (default: meta.llama3-8b-instruct-v1:0) +# AWS_REGION (default: us-east-1) +# +# If the LLM is unavailable or returns unparseable output, parse() raises: +# there is no fallback, the request fails loudly rather than guessing at the +# holdings. Weights are renormalized to 1.0. +# +# Resource profile: cheap CPU, single call per request, on the critical path +# before the fan-out. + +import os +import re +import json + +try: + from ventis.llm.bedrock import call_bedrock +except ImportError: + from bedrock import call_bedrock + +DEFAULT_LOOKBACK_DAYS = 365 + + +class IntentAgent(object): + def __init__(self): + self.tools = [self.parse] + self.model_id = os.environ.get( + "BEDROCK_MODEL_ID", "meta.llama3-8b-instruct-v1:0" + ) + self.region = os.environ.get("AWS_REGION", "us-east-1") + + def parse(self, query: str) -> dict: + """Parse a natural-language portfolio request into holdings + lookback.""" + response = call_bedrock( + model_id=self.model_id, + messages=[{"role": "user", "content": [{"text": self._build_prompt(query)}]}], + inference_config={"maxTokens": 300, "temperature": 0.0}, + region=self.region, + ) + text = response["output"]["message"]["content"][0]["text"] + if not text: + raise ValueError("IntentAgent: LLM returned no output for the request.") + + parsed = self._extract_json(text) + if parsed is None: + raise ValueError( + f"IntentAgent: could not parse holdings from LLM output: {text!r}" + ) + + result = self._sanitize(parsed) + if not result["holdings"]: + raise ValueError( + f"IntentAgent: no valid holdings found in request: {query!r}" + ) + return result + + def _build_prompt(self, query: str) -> str: + return ( + "You convert a plain-English portfolio request into JSON. Return ONLY " + "a JSON object, no prose, with exactly two keys:\n" + ' "holdings": an object mapping stock TICKER symbols (uppercase) to ' + "their weight as a decimal fraction (weights should sum to about 1.0), and\n" + ' "lookback_days": an integer number of calendar days for the analysis ' + f"window (default {DEFAULT_LOOKBACK_DAYS} if unspecified; 1 month = 30 " + "days, 1 year = 365 days).\n" + "Resolve company names to their ticker (Apple->AAPL, Microsoft->MSFT, " + "Nvidia->NVDA, etc.). If weights are given as percentages, convert to " + "fractions. If a holding has no explicit weight, split the remainder " + "equally among the unweighted holdings.\n\n" + f'Request: "{query}"\n\n' + "JSON:" + ) + + def _extract_json(self, text: str): + """Pull the first JSON object out of the model's response text.""" + # Models sometimes wrap the JSON in prose or code fences; grab the + # outermost {...} span. + match = re.search(r"\{.*\}", text, re.DOTALL) + if not match: + return None + try: + return json.loads(match.group(0)) + except (ValueError, TypeError): + return None + + def _sanitize(self, parsed: dict) -> dict: + """Validate types and renormalize weights to sum to 1.0.""" + raw = (parsed or {}).get("holdings") or {} + holdings = {} + for ticker, weight in raw.items(): + try: + w = float(weight) + except (ValueError, TypeError): + continue + if w > 0: + holdings[str(ticker).upper()] = w + + total = sum(holdings.values()) + if total > 0: + holdings = {t: round(w / total, 4) for t, w in holdings.items()} + + try: + lookback = int((parsed or {}).get("lookback_days", DEFAULT_LOOKBACK_DAYS)) + except (ValueError, TypeError): + lookback = DEFAULT_LOOKBACK_DAYS + if lookback <= 0: + lookback = DEFAULT_LOOKBACK_DAYS + + return {"holdings": holdings, "lookback_days": lookback} + + +if __name__ == "__main__": + agent = IntentAgent() + print(agent.parse( + query="Analyze 40% Apple, 35% Microsoft and 25% Nvidia over the last 6 months" + )) diff --git a/examples/portfolio/agents/intent_agent.yaml b/examples/portfolio/agents/intent_agent.yaml new file mode 100644 index 0000000..7e5155f --- /dev/null +++ b/examples/portfolio/agents/intent_agent.yaml @@ -0,0 +1,10 @@ +agent: + name: IntentAgent + functions: + - name: parse + description: Parse a natural-language portfolio request into holdings + lookback. + arguments: + - name: query + type: str + returns: + type: dict diff --git a/examples/portfolio/agents/metrics_agent.py b/examples/portfolio/agents/metrics_agent.py index 32701ff..f9f063a 100644 --- a/examples/portfolio/agents/metrics_agent.py +++ b/examples/portfolio/agents/metrics_agent.py @@ -9,6 +9,7 @@ # # Resource profile: cheap CPU, high fan-out — one compute() call per holding. +import json import math from price_agent import PriceAgent @@ -23,9 +24,12 @@ def __init__(self): def compute(self, ticker: str, lookback_days: int = 365) -> dict: """Compute return/volatility/Sharpe/drawdown metrics for one ticker.""" - history = self.price.get_history( - ticker=ticker, lookback_days=lookback_days - ).value() + # get_history() returns a dict, but a Future's .value() only ever gives back + # the raw string ventis stored in Redis -- it never auto-deserializes + # non-str return types, so the JSON has to be parsed back out here. + history = json.loads( + self.price.get_history(ticker=ticker, lookback_days=lookback_days).value() + ) closes = history.get("closes", []) if len(closes) < 2: diff --git a/examples/portfolio/agents/price_agent.py b/examples/portfolio/agents/price_agent.py index 5c53ead..353efc7 100644 --- a/examples/portfolio/agents/price_agent.py +++ b/examples/portfolio/agents/price_agent.py @@ -18,11 +18,21 @@ def __init__(self): def get_history(self, ticker: str, lookback_days: int = 365) -> dict: """Fetch daily closing prices for a ticker over the lookback window.""" try: + import math + import yfinance as yf hist = yf.Ticker(ticker).history(period=f"{lookback_days}d") - closes = [float(c) for c in hist["Close"].tolist()] - dates = [str(d.date()) for d in hist.index] + raw_closes = hist["Close"].tolist() + # Yahoo sometimes appends the newest trading day before its close has + # settled, leaving that row's Close as NaN -- drop it rather than let + # NaN poison every downstream metric. + closes = [float(c) for c in raw_closes if not math.isnan(c)] + dates = [ + str(d.date()) + for d, c in zip(hist.index, raw_closes) + if not math.isnan(c) + ] if closes: return { "ticker": ticker, diff --git a/examples/portfolio/config/global_controller.yaml b/examples/portfolio/config/global_controller.yaml index c05236f..bbc160b 100644 --- a/examples/portfolio/config/global_controller.yaml +++ b/examples/portfolio/config/global_controller.yaml @@ -6,63 +6,76 @@ # reflect each stage's real cost so the scheduler has placement decisions to make. agents: - # Stage 0: price history fetch. Network/IO-bound, cheap CPU. Called by + # Stage 0: parse the free-text request into structured holdings + lookback + # window (calls Bedrock directly via ventis.llm.bedrock). Cheap CPU, one + # call per request, on the critical path before the fan-out. + - name: IntentAgent + redis_port: 6379 + replicas: 1 + resources: + cpu: 1 + memory: 256 + entrypoint: agents/intent_agent.py + provider: EC2 + instance_type: t3.micro + + # Stage 0b: price history fetch. Network/IO-bound, cheap CPU. Called by # MetricsAgent, one fetch per ticker — high fan-out, so multiple replicas. - name: PriceAgent - host: localhost - port: 8071 redis_port: 6379 - replicas: 2 + replicas: 1 resources: cpu: 1 memory: 256 entrypoint: agents/price_agent.py + provider: EC2 + instance_type: t3.micro # Stage 1: per-ticker metrics. Cheap CPU, high fan-out (one call per holding). # Calls PriceAgent remotely, then computes return/vol/Sharpe/drawdown. - name: MetricsAgent - host: localhost - port: 8072 redis_port: 6379 - replicas: 2 + replicas: 1 resources: cpu: 1 memory: 256 entrypoint: agents/metrics_agent.py + provider: EC2 + instance_type: t3.micro # Stage 2: portfolio-level risk aggregation. Single call per request; needs # every ticker's metrics (the barrier). - name: RiskAgent - host: localhost - port: 8073 redis_port: 6379 replicas: 1 resources: cpu: 1 memory: 256 entrypoint: agents/risk_agent.py + provider: EC2 + instance_type: t3.micro # Stage 3: LLM briefing via Bedrock. On the critical path, one call per # request. - name: AdvisorAgent - host: localhost - port: 8074 redis_port: 6379 replicas: 1 resources: cpu: 1 memory: 512 entrypoint: agents/advisor_agent.py + provider: EC2 + instance_type: t3.micro # The workflow, exposed as a REST API. - name: Workflow - host: localhost - port: 8070 # LC gRPC port type: workflow api_port: 8080 # Flask REST API port redis_port: 6379 replicas: 1 - workflow_file: workflows/portfolio_workflow.py + workflow_file: workflow/portfolio_workflow.py + provider: EC2 + instance_type: t3.micro # Polling interval in seconds poll_interval: 5 diff --git a/examples/portfolio/config/policy.yaml b/examples/portfolio/config/policy.yaml index 0188503..834c88e 100644 --- a/examples/portfolio/config/policy.yaml +++ b/examples/portfolio/config/policy.yaml @@ -14,6 +14,7 @@ rules: - match: {} access: - Workflow + - IntentAgent - PriceAgent - MetricsAgent - RiskAgent diff --git a/examples/portfolio/workflows/portfolio_workflow.py b/examples/portfolio/workflow/portfolio_workflow.py similarity index 59% rename from examples/portfolio/workflows/portfolio_workflow.py rename to examples/portfolio/workflow/portfolio_workflow.py index 778a54d..b8b684a 100644 --- a/examples/portfolio/workflows/portfolio_workflow.py +++ b/examples/portfolio/workflow/portfolio_workflow.py @@ -1,21 +1,26 @@ # Portfolio analysis workflow deployed as a REST API endpoint. # # Fan-out / aggregate pipeline: +# 0. IntentAgent - parse the free-text request into holdings + lookback window # 1. MetricsAgent - per-ticker return/vol/Sharpe/drawdown (FAN-OUT, 1 per holding) # (MetricsAgent internally calls PriceAgent to fetch price history) # 2. RiskAgent - roll the per-ticker metrics into portfolio-level risk # 3. AdvisorAgent - LLM briefing (Bedrock) grounded in the computed figures # -# The holdings map (ticker -> weight) and lookback window come in on the request -# body; the whole JSON body is splatted into main() as kwargs by deploy(). +# The request is a single natural-language sentence describing the portfolio, +# e.g. "Analyze 40% Apple, 35% Microsoft and 25% Nvidia over the last 6 months". +# IntentAgent (Stage 0) parses it into a holdings map (ticker -> weight) and a +# lookback window before the fan-out begins. The whole JSON body is splatted +# into main() as kwargs by deploy(). # # Start agents first: python -m ventis.controller.global_controller # Test: # curl -X POST http://localhost:8080/main \ # -H 'Content-Type: application/json' \ -# -d '{"holdings": {"AAPL": 0.4, "MSFT": 0.35, "NVDA": 0.25}, "lookback_days": 180}' +# -d '{"query": "Analyze 40% Apple, 35% Microsoft and 25% Nvidia over the last 6 months"}' # curl http://localhost:8080/status/ +import json import sys import os @@ -26,19 +31,28 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "grpc_stubs")) from deploy import deploy +from intent_agent import IntentAgent from metrics_agent import MetricsAgent from risk_agent import RiskAgent from advisor_agent import AdvisorAgent def main( - holdings: dict = {"AAPL": 0.4, "MSFT": 0.35, "NVDA": 0.25}, - lookback_days: int = 365, + query: str = "Analyze 40% Apple, 35% Microsoft and 25% Nvidia over the last year", ): + intent_agent = IntentAgent() metrics_agent = MetricsAgent() risk_agent = RiskAgent() advisor = AdvisorAgent() + # Stage 0: parse the free-text request into structured holdings + window. + # parse() returns a dict, but a Future's .value() only ever gives back the + # raw string ventis stored in Redis -- same deserialization requirement as + # every other dict-returning agent call below. + intent = json.loads(intent_agent.parse(query=query).value()) + holdings = intent["holdings"] + lookback_days = intent["lookback_days"] + tickers = list(holdings.keys()) # Stage 1: fan out one metrics computation per holding. Every call returns @@ -48,11 +62,15 @@ def main( t: metrics_agent.compute(ticker=t, lookback_days=lookback_days) for t in tickers } - per_ticker = {t: f.value() for t, f in metric_futures.items()} + # compute() returns a dict, but a Future's .value() only ever gives back the + # raw string ventis stored in Redis -- it never auto-deserializes non-str + # return types, so the JSON has to be parsed back out here. + per_ticker = {t: json.loads(f.value()) for t, f in metric_futures.items()} # Stage 2: aggregate. RiskAgent needs every ticker's metrics (incl. the raw # return series) to build the covariance — this is the barrier. - risk = risk_agent.assess(holdings=holdings, metrics=per_ticker).value() + # assess() also returns a dict -- same deserialization requirement as above. + risk = json.loads(risk_agent.assess(holdings=holdings, metrics=per_ticker).value()) # Stage 3: LLM briefing grounded in the computed numbers. summary = advisor.summarize( diff --git a/examples/text2sql/agents/vllm_agent.py b/examples/text2sql/agents/vllm_agent.py index 7401187..cd48b7b 100644 --- a/examples/text2sql/agents/vllm_agent.py +++ b/examples/text2sql/agents/vllm_agent.py @@ -1,24 +1,48 @@ -# Simple VLLM Agent Example +# VLLM Agent # -# Simulated LLM backend so the pipeline runs without a GPU or vLLM install. -# To make this a real vLLM caller, load the model in __init__ (e.g. -# `from vllm import LLM; self.llm = LLM(model=...)`) and call it in generate(). +# LLM backend for SQL candidate generation, called remotely by +# SQLGeneratorAgent. Calls AWS Bedrock (Converse API) via ventis.llm.bedrock +# so token/cost telemetry gets recorded onto this execution's +# future::metrics hash — same pattern as +# examples/portfolio/agents/advisor_agent.py. +# Configure with env vars: +# BEDROCK_MODEL_ID (default: meta.llama3-8b-instruct-v1:0) +# AWS_REGION (default: us-east-1) +# +# Falls back to a synthetic placeholder response if Bedrock is unavailable. + +import os + +try: + from ventis.llm.bedrock import call_bedrock +except ImportError: + from bedrock import call_bedrock + class VllmAgent(object): def __init__(self): self.tools = [self.generate] - # In a real scenario, this is where you would initialize: - # from vllm import LLM - # self.llm = LLM(model="meta-llama/Llama-3.2-1B-Instruct") + self.model_id = os.environ.get("BEDROCK_MODEL_ID", "meta.llama3-8b-instruct-v1:0") + self.region = os.environ.get("AWS_REGION", "us-east-1") def generate(self, prompt: str) -> str: """Generates a response using an LLM model based on the given prompt.""" - print(f"VllmAgent: Received prompt: '{prompt}'") + try: + response = call_bedrock( + model_id=self.model_id, + messages=[{"role": "user", "content": [{"text": prompt}]}], + inference_config={"maxTokens": 400, "temperature": 0.2}, + region=self.region, + ) + return response["output"]["message"]["content"][0]["text"] + except Exception as e: + print(f"VllmAgent: Bedrock call failed ({e}); using synthetic response.") + return self._fallback_response(prompt) + + def _fallback_response(self, prompt: str) -> str: + return f"This is an LLM generated response to: '{prompt}'" - # Simulated LLM generation - synthetic_response = f"This is an LLM generated response to: '{prompt}'" - return synthetic_response if __name__ == "__main__": agent = VllmAgent() - print(agent.generate("What is the stock price?")) \ No newline at end of file + print(agent.generate("What is the stock price?")) diff --git a/examples/text2sql/config/global_controller.yaml b/examples/text2sql/config/global_controller.yaml index 3b5ddb8..89642b2 100644 --- a/examples/text2sql/config/global_controller.yaml +++ b/examples/text2sql/config/global_controller.yaml @@ -10,8 +10,6 @@ agents: # Embeddings are batchable, so this agent benefits from cross-request # coalescing before the GPU. Multiple replicas share the embedding load. - name: SchemaRetrievalAgent - host: localhost - port: 8061 redis_port: 6379 replicas: 2 resources: @@ -19,38 +17,38 @@ agents: memory: 2048 gpu: 1 entrypoint: agents/schema_agent.py + provider: EC2 + instance_type: t3.micro # Stage 2: candidate generation. LLM-bound; calls VllmAgent remotely. # Sits on the request's critical path. - name: SQLGeneratorAgent - host: localhost - port: 8062 redis_port: 6379 replicas: 1 resources: cpu: 1 memory: 512 entrypoint: agents/sql_generator_agent.py + provider: EC2 + instance_type: t3.micro # Stage 3: static validation. Cheap CPU, high fan-out (one call per # candidate). Produces the EXPLAIN cost signal used for admission control. - name: SQLValidatorAgent - host: localhost - port: 8063 redis_port: 6379 replicas: 2 resources: cpu: 1 memory: 256 entrypoint: agents/sql_validator_agent.py + provider: EC2 + instance_type: t3.micro # Stage 4: sandbox execution against a small sampled replica. # `stateful: true` => all calls within one request_id route to the same # instance (session affinity), keeping the seeded sample warm. `replicas: 2` # models the warm sample-DB pool the scheduler manages. - name: SandboxExecutorAgent - host: localhost - port: 8064 redis_port: 6379 stateful: true replicas: 2 @@ -58,41 +56,43 @@ agents: cpu: 2 memory: 1024 entrypoint: agents/sandbox_agent.py + provider: EC2 + instance_type: t3.micro # Stage 5: production execution. Scarce, expensive warehouse slots — kept to # a single replica so the cost-gated admission control has something to # protect. This is the resource the whole staged pipeline exists to guard. - name: ProductionExecutorAgent - host: localhost - port: 8065 redis_port: 6379 replicas: 1 resources: cpu: 1 memory: 512 entrypoint: agents/production_agent.py + provider: EC2 + instance_type: t3.micro - # LLM backend shared by the generator. + # Stage: LLM backend via Bedrock (Converse API), called remotely by + # SQLGeneratorAgent — same pattern as portfolio's AdvisorAgent. - name: VllmAgent - host: localhost - port: 8066 redis_port: 6379 replicas: 1 resources: - cpu: 2 - memory: 2048 - gpu: 1 + cpu: 1 + memory: 512 entrypoint: agents/vllm_agent.py + provider: EC2 + instance_type: t3.micro # The workflow, exposed as a REST API. - name: Workflow - host: localhost - port: 8060 # LC gRPC port type: workflow api_port: 8080 # Flask REST API port redis_port: 6379 replicas: 1 - workflow_file: workflows/text2sql_workflow.py + workflow_file: workflow/text2sql_workflow.py + provider: EC2 + instance_type: t3.micro # Polling interval in seconds poll_interval: 5 diff --git a/examples/text2sql/workflows/text2sql_workflow.py b/examples/text2sql/workflow/text2sql_workflow.py similarity index 73% rename from examples/text2sql/workflows/text2sql_workflow.py rename to examples/text2sql/workflow/text2sql_workflow.py index 8b65db0..d2f32eb 100644 --- a/examples/text2sql/workflows/text2sql_workflow.py +++ b/examples/text2sql/workflow/text2sql_workflow.py @@ -14,6 +14,7 @@ # -d '{"question": "total order amount per customer region"}' # curl http://localhost:8080/status/ +import json import sys import os @@ -38,14 +39,19 @@ def main(question: str = "total order amount per customer region", n_candidates: sandbox = SandboxExecutorAgent() production = ProductionExecutorAgent() - # Stage 1: retrieve schema. Future is chained directly into generation — - # the framework resolves it before the generator runs. - schema = schema_agent.get_relevant_schema(question=question) + # Stage 1: retrieve schema. Resolved here (not chained unresolved into the + # next call) since a Future's result only ever lives on the Redis of + # whichever node created it -- here, this workflow's own -- so resolving + # it where it was created is always safe, regardless of which node ends + # up running the next stage. + schema = json.loads(schema_agent.get_relevant_schema(question=question).value()) # Stage 2: fan out candidate SQL queries (LLM calls happen inside). - candidates = generator.generate_candidates( - question=question, schema=schema, n=n_candidates - ).value() + candidates = json.loads( + generator.generate_candidates( + question=question, schema=schema, n=n_candidates + ).value() + ) # Stage 3: validate each candidate. lint (cheap) gates admission; the cost # estimate feeds the production admission gate later. Two futures per @@ -57,8 +63,8 @@ def main(question: str = "total order amount per customer region", n_candidates: survivors = [] costs = {} for sql in candidates: - lint = lint_futures[sql].value() - cost = cost_futures[sql].value() + lint = json.loads(lint_futures[sql].value()) + cost = json.loads(cost_futures[sql].value()) costs[sql] = cost["estimated_cost"] if lint["valid"]: survivors.append(sql) @@ -67,14 +73,18 @@ def main(question: str = "total order amount per customer region", n_candidates: return {"question": question, "error": "no candidate passed static validation"} # Stage 4: execute survivors on the sampled replica, then vote. - sample_results = [sandbox.run_on_sample(sql=sql).value() for sql in survivors] - selection = sandbox.select_best(results=sample_results).value() + sample_results = [ + json.loads(sandbox.run_on_sample(sql=sql).value()) for sql in survivors + ] + selection = json.loads(sandbox.select_best(results=sample_results).value()) best_sql = selection.get("selected") # Stage 5: run the winner on production behind the cost gate. - prod = production.run_on_production( - sql=best_sql, estimated_cost=costs.get(best_sql, 0.0) - ).value() + prod = json.loads( + production.run_on_production( + sql=best_sql, estimated_cost=costs.get(best_sql, 0.0) + ).value() + ) return { "question": question, diff --git a/pyproject.toml b/pyproject.toml index 2bde8ca..2efc1ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ "psycopg[binary]", "pyyaml", "flask", + "psutil", ] [project.scripts] diff --git a/requirements.txt b/requirements.txt index 488f274..b0dd97e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,3 +8,4 @@ ipdb ipython sqlalchemy psycopg[binary] +psutil diff --git a/tests/test_cli.py b/tests/test_cli.py index 8bec94b..eb898ae 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,3 +1,4 @@ +import json import os import sys import tempfile @@ -98,79 +99,175 @@ def test_preflight_does_not_require_ssh_fields(self, require_docker, ensure_grpc class CliBuildTests(unittest.TestCase): - def test_build_does_not_build_global_controller_image(self): + def _run_build( + self, project_dir, agent_yaml_paths, buildx_available, platform="linux/amd64" + ): + """Run cmd_build against project_dir with docker/subprocess calls mocked. + + Returns the list of subprocess.run commands that were invoked. + """ + config_path = project_dir / "config" / "global_controller.yaml" + args = SimpleNamespace(config=str(config_path)) + docker_calls = [] + + def fake_run(cmd, check): + docker_calls.append(cmd) + return SimpleNamespace(returncode=0) + + with ( + patch( + "ventis.cli._get_package_dir", + return_value=str(project_dir / "package"), + ), + patch( + "ventis.cli.glob.glob", + side_effect=[agent_yaml_paths, ["proto/a.proto"]], + ), + patch("ventis.stub_generator.generate_stub"), + patch("ventis.stub_generator.generate_docker"), + patch("ventis.stub_generator.generate_workflow_docker"), + patch("ventis.cli.subprocess.run", side_effect=fake_run), + patch("ventis.cli._docker_available", return_value=buildx_available), + patch("ventis.cli._docker_platform", return_value=platform), + ): + cwd = os.getcwd() + os.chdir(project_dir) + try: + cli.cmd_build(args) + finally: + os.chdir(cwd) + + return docker_calls + + def _write_agent_and_workflow_config(self, project_dir): + """Scaffold a project with one agent + one workflow entry; returns the agent YAML path.""" + (project_dir / "config").mkdir() + (project_dir / "agents").mkdir() + (project_dir / "workflows").mkdir() + (project_dir / "docker").mkdir() + (project_dir / "docker" / "global-controller.Dockerfile").write_text( + "FROM scratch\n" + ) + (project_dir / "agents" / "example_agent.py").write_text("print('ok')\n") + (project_dir / "workflows" / "example_workflow.py").write_text("print('ok')\n") + agent_yaml = project_dir / "agents" / "example_agent.yaml" + agent_yaml.write_text("agent:\n name: ExampleAgent\n") + config_path = project_dir / "config" / "global_controller.yaml" + config_path.write_text( + yaml.safe_dump( + { + "agents": [ + { + "name": "ExampleAgent", + "entrypoint": "agents/example_agent.py", + "provider": "local", + }, + { + "name": "Workflow", + "type": "workflow", + "workflow_file": "workflows/example_workflow.py", + "provider": "local", + }, + ] + } + ) + ) + return agent_yaml + + def test_build_falls_back_to_sequential_docker_build_without_buildx(self): with tempfile.TemporaryDirectory() as tmpdir: project_dir = Path(tmpdir) - (project_dir / "config").mkdir() - (project_dir / "agents").mkdir() - (project_dir / "workflows").mkdir() - (project_dir / "docker").mkdir() - (project_dir / "docker" / "global-controller.Dockerfile").write_text( - "FROM scratch\n" + agent_yaml = self._write_agent_and_workflow_config(project_dir) + + docker_calls = self._run_build( + project_dir, [str(agent_yaml)], buildx_available=False ) - (project_dir / "agents" / "example_agent.py").write_text("print('ok')\n") - (project_dir / "workflows" / "example_workflow.py").write_text( - "print('ok')\n" + + flattened = [" ".join(call) for call in docker_calls] + self.assertFalse( + any("global-controller.Dockerfile" in call for call in flattened) + ) + self.assertEqual( + sum(call[:2] == ["docker", "build"] for call in docker_calls), 2 + ) + self.assertFalse( + any(call[:3] == ["docker", "buildx", "bake"] for call in docker_calls) + ) + + def test_build_uses_buildx_bake_when_available(self): + with tempfile.TemporaryDirectory() as tmpdir: + project_dir = Path(tmpdir) + agent_yaml = self._write_agent_and_workflow_config(project_dir) + + docker_calls = self._run_build( + project_dir, [str(agent_yaml)], buildx_available=True ) - agent_yaml = project_dir / "agents" / "example_agent.yaml" - agent_yaml.write_text("agent:\n name: ExampleAgent\n") + + bake_file = project_dir / "docker_container" / "docker-bake.json" + self.assertTrue(bake_file.is_file()) + with open(bake_file) as f: + bake_config = json.load(f) + + self.assertEqual( + sum(call[:2] == ["docker", "build"] for call in docker_calls), 0 + ) + bake_calls = [ + call for call in docker_calls if call[:3] == ["docker", "buildx", "bake"] + ] + self.assertEqual(len(bake_calls), 1) + self.assertIn("--file", bake_calls[0]) + self.assertEqual( + os.path.realpath(bake_calls[0][bake_calls[0].index("--file") + 1]), + os.path.realpath(bake_file), + ) + + targets = bake_config["target"] + self.assertEqual(len(targets), 2) + self.assertEqual( + os.path.realpath(targets["exampleagent"]["context"]), + os.path.realpath(project_dir / "docker_container" / "ExampleAgent"), + ) + self.assertTrue(os.path.isabs(targets["exampleagent"]["context"])) + self.assertEqual(targets["exampleagent"]["tags"], ["ventis-exampleagent"]) + self.assertEqual(targets["exampleagent"]["platforms"], ["linux/amd64"]) + self.assertEqual(targets["exampleagent"]["output"], ["type=docker"]) + self.assertEqual( + os.path.realpath(targets["workflow"]["context"]), + os.path.realpath(project_dir / "docker_container" / "Workflow"), + ) + self.assertEqual(targets["workflow"]["tags"], ["ventis-workflow"]) + + def test_build_with_no_agents_builds_nothing(self): + with tempfile.TemporaryDirectory() as tmpdir: + project_dir = Path(tmpdir) + (project_dir / "config").mkdir() + (project_dir / "agents").mkdir() + config_path = project_dir / "config" / "global_controller.yaml" + config_path.write_text(yaml.safe_dump({"agents": []})) + + docker_calls = self._run_build(project_dir, [], buildx_available=True) + + self.assertFalse(any(call[0] == "docker" for call in docker_calls)) + + def test_build_skips_agent_without_entrypoint(self): + with tempfile.TemporaryDirectory() as tmpdir: + project_dir = Path(tmpdir) + (project_dir / "config").mkdir() + (project_dir / "agents").mkdir() config_path = project_dir / "config" / "global_controller.yaml" config_path.write_text( yaml.safe_dump( { "agents": [ - { - "name": "ExampleAgent", - "entrypoint": "agents/example_agent.py", - "provider": "local", - }, - { - "name": "Workflow", - "type": "workflow", - "workflow_file": "workflows/example_workflow.py", - "provider": "local", - }, + {"name": "NoEntrypointAgent", "provider": "local"}, ] } ) ) - args = SimpleNamespace(config=str(config_path)) - docker_calls = [] - - def fake_run(cmd, check): - docker_calls.append(cmd) - return SimpleNamespace(returncode=0) - - with ( - patch( - "ventis.cli._get_package_dir", - return_value=str(project_dir / "package"), - ), - patch( - "ventis.cli.glob.glob", - side_effect=[[str(agent_yaml)], ["proto/a.proto"]], - ), - patch("ventis.stub_generator.generate_stub"), - patch("ventis.stub_generator.generate_docker"), - patch("ventis.stub_generator.generate_workflow_docker"), - patch("ventis.cli.subprocess.run", side_effect=fake_run), - patch.dict(os.environ, {}, clear=False), - ): - cwd = os.getcwd() - os.chdir(project_dir) - try: - cli.cmd_build(args) - finally: - os.chdir(cwd) + docker_calls = self._run_build(project_dir, [], buildx_available=True) - flattened = [" ".join(call) for call in docker_calls] - self.assertFalse( - any("global-controller.Dockerfile" in call for call in flattened) - ) - self.assertEqual( - sum(call[:2] == ["docker", "build"] for call in docker_calls), 2 - ) + self.assertFalse(any(call[0] == "docker" for call in docker_calls)) if __name__ == "__main__": diff --git a/tests/test_deploy.py b/tests/test_deploy.py new file mode 100644 index 0000000..bea78fa --- /dev/null +++ b/tests/test_deploy.py @@ -0,0 +1,342 @@ +import contextlib +import os +import sys +import unittest +from unittest.mock import MagicMock, patch + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +import ventis.deploy as deploy_module + + +class _FakeRedis: + def __init__(self): + self.store = {} + self.ttls = {} + + def set(self, key, value): + self.store[key] = value + + def get(self, key): + return self.store.get(key) + + def sadd(self, name, *values): + self.store.setdefault(name, set()).update(values) + + def expire(self, key, seconds): + """Record the TTL only for keys that exist, matching Redis's no-op on + missing keys -- the tests assert on which keys actually got one.""" + if key in self.store: + self.ttls[key] = seconds + + +class _SyncThread: + """Runs the target synchronously instead of on a real thread. None of + these tests need real concurrency, and real daemon threads left running + past their test method's return would otherwise fire against whichever + mock happens to be active in a later test.""" + + def __init__(self, target=None, args=(), kwargs=None, daemon=None): + self._target = target + self._args = args + self._kwargs = kwargs or {} + + def start(self): + self._target(*self._args, **self._kwargs) + + +def _noop_workflow(x=1): + return {"x": x} + + +def _failing_workflow(x=1): + raise RuntimeError("workflow blew up") + + +@contextlib.contextmanager +def _deployed_app(workflow_fn=_noop_workflow): + """Run deploy() with Flask's blocking app.run() replaced by a no-op that + captures the app instance, so the route handlers can be exercised via + Flask's test client without starting a real server. The workflow's + background thread is replaced with a synchronous stand-in -- and that + patch (along with RedisClient's) must stay active for the whole `with` + block, not just the deploy() call, since threading.Thread is only + actually constructed later, inside handle_workflow(), when a request + comes in via the test client.""" + captured = {} + fake_redis = _FakeRedis() + + def fake_run(self, *args, **kwargs): + captured["app"] = self + + with contextlib.ExitStack() as stack: + stack.enter_context(patch.object(deploy_module.Flask, "run", fake_run)) + stack.enter_context( + patch.object(deploy_module, "RedisClient", return_value=fake_redis) + ) + stack.enter_context( + patch.object(deploy_module.threading, "Thread", _SyncThread) + ) + deploy_module.deploy(workflow_fn) + app = captured["app"] + # deploy() keeps its RedisClient in a closure, so hang the fake off the app + # to give tests a way to inspect what landed in Redis. + app.fake_redis = fake_redis + yield app + + +class DeployHandleWorkflowTests(unittest.TestCase): + def setUp(self): + os.environ.pop("VENTIS_DATABASE_URL", None) + os.environ.pop("VENTIS_PROJECT_ID", None) + + def tearDown(self): + os.environ.pop("VENTIS_DATABASE_URL", None) + os.environ.pop("VENTIS_PROJECT_ID", None) + + def test_records_working_status_before_dispatch_when_configured(self): + os.environ["VENTIS_DATABASE_URL"] = "postgresql://example/db" + os.environ["VENTIS_PROJECT_ID"] = "11111111-1111-1111-1111-111111111111" + + with patch.object(deploy_module, "upsert_session") as mock_upsert, \ + _deployed_app() as app: + client = app.test_client() + resp = client.post("/_noop_workflow", json={"x": 2}) + + self.assertEqual(resp.status_code, 202) + request_id = resp.get_json()["request_id"] + first_call_args = mock_upsert.call_args_list[0].args + self.assertEqual(first_call_args[0], "postgresql://example/db") + self.assertEqual(first_call_args[1], "11111111-1111-1111-1111-111111111111") + self.assertEqual(first_call_args[2], request_id) + self.assertEqual(first_call_args[3], "running") + first_call_kwargs = mock_upsert.call_args_list[0].kwargs + self.assertEqual(first_call_kwargs["input_payload"], {"x": 2}) + + def test_skips_session_upsert_when_not_configured(self): + with patch.object(deploy_module, "upsert_session") as mock_upsert, \ + _deployed_app() as app: + client = app.test_client() + resp = client.post("/_noop_workflow", json={"x": 2}) + + self.assertEqual(resp.status_code, 202) + mock_upsert.assert_not_called() + + def test_session_upsert_failure_does_not_fail_the_request(self): + os.environ["VENTIS_DATABASE_URL"] = "postgresql://example/db" + os.environ["VENTIS_PROJECT_ID"] = "11111111-1111-1111-1111-111111111111" + + with patch.object( + deploy_module, "upsert_session", side_effect=RuntimeError("db down") + ), _deployed_app() as app: + client = app.test_client() + resp = client.post("/_noop_workflow", json={"x": 2}) + + self.assertEqual(resp.status_code, 202) + self.assertIn("request_id", resp.get_json()) + + def test_marks_session_success_when_workflow_completes(self): + os.environ["VENTIS_DATABASE_URL"] = "postgresql://example/db" + os.environ["VENTIS_PROJECT_ID"] = "11111111-1111-1111-1111-111111111111" + + with patch.object(deploy_module, "upsert_session") as mock_upsert, \ + _deployed_app() as app: + client = app.test_client() + resp = client.post("/_noop_workflow", json={"x": 2}) + + self.assertEqual(resp.status_code, 202) + statuses = [call.args[3] for call in mock_upsert.call_args_list] + self.assertEqual(statuses, ["running", "completed"]) + success_call_kwargs = mock_upsert.call_args_list[1].kwargs + self.assertEqual(success_call_kwargs["output_payload"], {"x": 2}) + + def test_marks_session_failed_when_workflow_raises(self): + os.environ["VENTIS_DATABASE_URL"] = "postgresql://example/db" + os.environ["VENTIS_PROJECT_ID"] = "11111111-1111-1111-1111-111111111111" + + with patch.object(deploy_module, "upsert_session") as mock_upsert, \ + _deployed_app(workflow_fn=_failing_workflow) as app: + client = app.test_client() + resp = client.post("/_failing_workflow", json={"x": 2}) + + self.assertEqual(resp.status_code, 202) + statuses = [call.args[3] for call in mock_upsert.call_args_list] + self.assertEqual(statuses, ["running", "failed"]) + failed_call_kwargs = mock_upsert.call_args_list[1].kwargs + self.assertEqual( + failed_call_kwargs["output_payload"], {"error": "workflow blew up"} + ) + + def test_skips_session_upsert_when_project_id_is_missing(self): + # project_id is NOT NULL in the session table, so a URL without a project + # id can only produce failing writes -- don't attempt them at all. + os.environ["VENTIS_DATABASE_URL"] = "postgresql://example/db" + + with patch.object(deploy_module, "upsert_session") as mock_upsert, \ + _deployed_app() as app: + client = app.test_client() + resp = client.post("/_noop_workflow", json={"x": 2}) + + self.assertEqual(resp.status_code, 202) + mock_upsert.assert_not_called() + + def test_does_not_write_dead_workflow_and_created_at_keys(self): + with _deployed_app() as app: + client = app.test_client() + resp = client.post("/_noop_workflow", json={"x": 2}) + request_id = resp.get_json()["request_id"] + + self.assertNotIn(f"request:{request_id}:workflow", app.fake_redis.store) + self.assertNotIn(f"request:{request_id}:created_at", app.fake_redis.store) + + +class DeployRequestKeyExpiryTests(unittest.TestCase): + """Finished requests must leave their Redis keys with a TTL -- without one they + accumulate for the lifetime of the Redis instance and eventually OOM it.""" + + def setUp(self): + os.environ.pop("VENTIS_DATABASE_URL", None) + os.environ.pop("VENTIS_PROJECT_ID", None) + + def test_expires_status_and_result_on_success(self): + with _deployed_app() as app: + client = app.test_client() + resp = client.post("/_noop_workflow", json={"x": 2}) + request_id = resp.get_json()["request_id"] + + self.assertEqual( + app.fake_redis.ttls, + { + f"request:{request_id}:status": deploy_module.COMPLETED_TTL_SECONDS, + f"request:{request_id}:result": deploy_module.COMPLETED_TTL_SECONDS, + }, + ) + + def test_expires_status_and_error_on_failure(self): + with _deployed_app(workflow_fn=_failing_workflow) as app: + client = app.test_client() + resp = client.post("/_failing_workflow", json={"x": 2}) + request_id = resp.get_json()["request_id"] + + self.assertEqual( + app.fake_redis.ttls, + { + f"request:{request_id}:status": deploy_module.COMPLETED_TTL_SECONDS, + f"request:{request_id}:error": deploy_module.COMPLETED_TTL_SECONDS, + }, + ) + + def test_expires_context_when_one_was_supplied(self): + with _deployed_app() as app: + client = app.test_client() + resp = client.post( + "/_noop_workflow", json={"x": 2, "_context": {"role": "admin"}} + ) + request_id = resp.get_json()["request_id"] + + self.assertEqual( + app.fake_redis.ttls.get(f"request:{request_id}:context"), + deploy_module.COMPLETED_TTL_SECONDS, + ) + + +class DeployStatusFallbackTests(unittest.TestCase): + """Once the Redis keys expire, /status must keep its contract by reading the + session row instead of 404-ing.""" + + def setUp(self): + os.environ["VENTIS_DATABASE_URL"] = "postgresql://example/db" + os.environ["VENTIS_PROJECT_ID"] = "11111111-1111-1111-1111-111111111111" + + def tearDown(self): + os.environ.pop("VENTIS_DATABASE_URL", None) + os.environ.pop("VENTIS_PROJECT_ID", None) + + def test_maps_completed_session_to_done_with_result(self): + row = {"status": "completed", "output": {"x": 2}} + with patch.object(deploy_module, "get_session", return_value=row), \ + _deployed_app() as app: + resp = app.test_client().get("/status/expired-id") + + self.assertEqual(resp.status_code, 200) + self.assertEqual( + resp.get_json(), + {"request_id": "expired-id", "status": "done", "result": {"x": 2}}, + ) + + def test_maps_failed_session_to_error_and_decodes_text_output(self): + # Postgres hands back decoded JSONB; sqlite (and any driver without a JSON + # type) hands back text. + row = {"status": "failed", "output": '{"error": "workflow blew up"}'} + with patch.object(deploy_module, "get_session", return_value=row), \ + _deployed_app() as app: + resp = app.test_client().get("/status/expired-id") + + self.assertEqual(resp.status_code, 200) + self.assertEqual( + resp.get_json(), + { + "request_id": "expired-id", + "status": "error", + "error": "workflow blew up", + }, + ) + + def test_maps_running_session_without_payload(self): + row = {"status": "running", "output": None} + with patch.object(deploy_module, "get_session", return_value=row), \ + _deployed_app() as app: + resp = app.test_client().get("/status/expired-id") + + self.assertEqual(resp.status_code, 200) + self.assertEqual( + resp.get_json(), {"request_id": "expired-id", "status": "running"} + ) + + def test_404s_when_there_is_no_session_row(self): + with patch.object(deploy_module, "get_session", return_value=None), \ + _deployed_app() as app: + resp = app.test_client().get("/status/unknown-id") + + self.assertEqual(resp.status_code, 404) + self.assertEqual(resp.get_json(), {"error": "Request not found"}) + + def test_404s_instead_of_500_when_the_lookup_fails(self): + with patch.object( + deploy_module, "get_session", side_effect=RuntimeError("db down") + ), _deployed_app() as app: + resp = app.test_client().get("/status/expired-id") + + self.assertEqual(resp.status_code, 404) + + def test_does_not_touch_postgres_while_redis_still_has_the_request(self): + with patch.object(deploy_module, "upsert_session"), \ + patch.object(deploy_module, "get_session") as mock_get, \ + _deployed_app() as app: + client = app.test_client() + request_id = client.post("/_noop_workflow", json={"x": 2}).get_json()[ + "request_id" + ] + resp = client.get(f"/status/{request_id}") + + self.assertEqual(resp.status_code, 200) + self.assertEqual( + resp.get_json(), + {"request_id": request_id, "status": "done", "result": {"x": 2}}, + ) + mock_get.assert_not_called() + + def test_skips_the_fallback_when_the_database_is_not_configured(self): + os.environ.pop("VENTIS_DATABASE_URL", None) + os.environ.pop("VENTIS_PROJECT_ID", None) + + with patch.object(deploy_module, "get_session") as mock_get, \ + _deployed_app() as app: + resp = app.test_client().get("/status/expired-id") + + self.assertEqual(resp.status_code, 404) + mock_get.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_error_propagation.py b/tests/test_error_propagation.py index ddd0a0b..a998cac 100644 --- a/tests/test_error_propagation.py +++ b/tests/test_error_propagation.py @@ -1,6 +1,7 @@ import os import sys import unittest +import json from types import SimpleNamespace from unittest.mock import MagicMock @@ -15,7 +16,9 @@ ) from ventis.controller.local_controller import LocalController +from ventis.controller.local_controller_frontend import LocalControllerServicer from ventis.future import Future +import local_controler_pb2 class _FakeRedis: @@ -25,19 +28,29 @@ def __init__(self): def hset(self, name, field, value): self.hashes.setdefault(name, {})[field] = value + def hset_multiple(self, name, mapping): + self.hashes.setdefault(name, {}).update(mapping) + def hget(self, name, field): return self.hashes.get(name, {}).get(field) +def _bind_failure_marker(controller): + controller._mark_future_failed = lambda future_id, error, origin=None: ( + LocalController._mark_future_failed(controller, future_id, error, origin) + ) + return controller + + class ErrorPropagationTests(unittest.TestCase): def test_forward_request_writes_future_error_on_grpc_failure(self): redis = _FakeRedis() stub = SimpleNamespace(Execute=MagicMock(side_effect=RuntimeError("boom"))) - controller = SimpleNamespace( + controller = _bind_failure_marker(SimpleNamespace( redis=redis, _my_endpoint="172.31.19.107:50051", _get_remote_stub=lambda endpoint: stub, - ) + )) data = { "future_id": "future-1", "service": "ExampleAgent", @@ -50,19 +63,28 @@ def test_forward_request_writes_future_error_on_grpc_failure(self): self.assertEqual(redis.hget("future:future-1", "error"), "boom") stub.Execute.assert_called_once() - def test_future_poll_redis_raises_runtime_error_when_error_is_present(self): + def test_future_value_raises_runtime_error_when_error_is_present(self): redis = _FakeRedis() redis.hset("future:future-1", "error", "boom") - future = SimpleNamespace(redis=redis, _key=lambda: "future:future-1") + future = SimpleNamespace( + redis=redis, + _key=lambda: "future:future-1", + _poll_redis=lambda: Future._poll_redis(future), + result=None, + ) with self.assertRaisesRegex(RuntimeError, "boom"): - Future._poll_redis(future) + Future.value(future) def test_future_poll_redis_returns_result_when_error_is_absent(self): redis = _FakeRedis() redis.hset("future:future-1", "result", "Hello, World!") future = SimpleNamespace( - redis=redis, _key=lambda: "future:future-1", result=None + redis=redis, + _key=lambda: "future:future-1", + _poll_redis=lambda: Future._poll_redis(future), + id="future-1", + result=None, ) result = Future._poll_redis(future) @@ -70,6 +92,89 @@ def test_future_poll_redis_returns_result_when_error_is_absent(self): self.assertEqual(result, "Hello, World!") self.assertEqual(future.result, "Hello, World!") + def test_future_value_raises_when_metrics_mark_it_failed(self): + redis = _FakeRedis() + redis.hset_multiple( + "future:future-1:metrics", + {"failed": 1, "error_message": "agent exploded"}, + ) + future = SimpleNamespace( + redis=redis, + _key=lambda: "future:future-1", + _poll_redis=lambda: Future._poll_redis(future), + id="future-1", + result=None, + ) + + with self.assertRaisesRegex(RuntimeError, "agent exploded"): + Future.value(future) + + def test_result_callback_sends_error_separately_from_result(self): + redis = _FakeRedis() + stub = SimpleNamespace(WriteResult=MagicMock()) + controller = SimpleNamespace( + redis=redis, + agent_name="ExampleAgent", + _get_remote_stub=lambda endpoint: stub, + ) + + LocalController._send_result_callback( + controller, + "origin:50051", + "future-1", + failed=1, + error_message="agent exploded", + ) + + payload = stub.WriteResult.call_args.args[0].resonse + self.assertEqual( + json.loads(payload), + { + "future_id": "future-1", + "result": None, + "failed": 1, + "error_message": "agent exploded", + }, + ) + + def test_write_result_persists_remote_error_as_terminal_failure(self): + redis = _FakeRedis() + servicer = SimpleNamespace(redis=redis) + request = local_controler_pb2.JsonResponse( + resonse=json.dumps( + { + "future_id": "future-1", + "failed": 1, + "error_message": "remote exploded", + } + ) + ) + context = SimpleNamespace(peer=lambda: "peer:50051") + + LocalControllerServicer.WriteResult(servicer, request, context) + + self.assertEqual( + redis.hget("future:future-1:metrics", "failed"), 1 + ) + self.assertEqual( + redis.hget("future:future-1:metrics", "error_message"), + "remote exploded", + ) + + def test_malformed_request_with_future_id_is_marked_failed(self): + redis = _FakeRedis() + controller = _bind_failure_marker( + SimpleNamespace(redis=redis, _my_endpoint="localhost:50051") + ) + + LocalController._process_request(controller, {"future_id": "future-1"}) + + self.assertEqual( + redis.hget("future:future-1", "error"), + "Malformed request: missing service, function, or future_id", + ) + self.assertEqual(redis.hget("future:future-1:metrics", "failed"), 1) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_future.py b/tests/test_future.py new file mode 100644 index 0000000..c277358 --- /dev/null +++ b/tests/test_future.py @@ -0,0 +1,83 @@ +import os +import sys +import unittest +from unittest.mock import MagicMock + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) +sys.path.insert( + 0, + os.path.abspath( + os.path.join( + os.path.dirname(__file__), "..", "ventis", "templates", "grpc_stubs" + ) + ), +) + +import ventis.future as future_module +import ventis.ventis_context as ventis_context + + +class _FakeRedis: + def __init__(self): + self.hashes = {} + self.sets = {} + + def hset_multiple(self, name, mapping): + self.hashes.setdefault(name, {}).update(mapping) + + def hset(self, name, field, value): + self.hashes.setdefault(name, {})[field] = value + + def hget(self, name, field): + return self.hashes.get(name, {}).get(field) + + def sadd(self, name, *values): + self.sets.setdefault(name, set()).update(values) + + +class FutureParentIdTests(unittest.TestCase): + def setUp(self): + self.fake_redis = _FakeRedis() + self._orig_redis = future_module.Future.redis + self._orig_stub = future_module.Future._stub + future_module.Future.redis = self.fake_redis + future_module.Future._stub = MagicMock() + ventis_context.set_current_future_id("") + + def tearDown(self): + future_module.Future.redis = self._orig_redis + future_module.Future._stub = self._orig_stub + ventis_context.set_current_future_id("") + + def test_parent_defaults_to_empty_when_no_future_executing(self): + f = future_module.Future( + parent="some/file.py", service="Svc", method="do_thing" + ) + self.assertEqual(f.parent, "") + self.assertEqual(self.fake_redis.hashes[f"future:{f.id}"]["parent"], "") + + def test_parent_is_the_currently_executing_future_id(self): + ventis_context.set_current_future_id("caller-future-id") + + f = future_module.Future( + parent="ignored/file.py", service="Svc", method="do_thing" + ) + + self.assertEqual(f.parent, "caller-future-id") + self.assertEqual( + self.fake_redis.hashes[f"future:{f.id}"]["parent"], "caller-future-id" + ) + + def test_submission_failure_is_raised_by_value_not_constructor(self): + future_module.Future._stub.Execute.side_effect = RuntimeError("submit failed") + + future = future_module.Future( + parent="ignored/file.py", service="Svc", method="do_thing" + ) + + with self.assertRaisesRegex(RuntimeError, "submit failed"): + future.value() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_gpu_metrics.py b/tests/test_gpu_metrics.py new file mode 100644 index 0000000..9eac921 --- /dev/null +++ b/tests/test_gpu_metrics.py @@ -0,0 +1,38 @@ +import os +import sys +import unittest +from types import SimpleNamespace +from unittest.mock import patch + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from ventis.controller.utils.gpu_metrics import read_gpu_percent + + +class ReadGpuPercentTests(unittest.TestCase): + def test_falls_back_to_zero_when_nvidia_smi_missing(self): + with patch( + "ventis.controller.utils.gpu_metrics.subprocess.run", + side_effect=FileNotFoundError(), + ): + self.assertEqual(read_gpu_percent(), 0.0) + + def test_parses_nvidia_smi_output(self): + fake_result = SimpleNamespace(returncode=0, stdout="42\n") + with patch( + "ventis.controller.utils.gpu_metrics.subprocess.run", + return_value=fake_result, + ): + self.assertEqual(read_gpu_percent(), 42.0) + + def test_falls_back_on_nonzero_returncode(self): + fake_result = SimpleNamespace(returncode=1, stdout="") + with patch( + "ventis.controller.utils.gpu_metrics.subprocess.run", + return_value=fake_result, + ): + self.assertEqual(read_gpu_percent(), 0.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_instance_manager_runtime.py b/tests/test_instance_manager_runtime.py index ba09958..88e7577 100644 --- a/tests/test_instance_manager_runtime.py +++ b/tests/test_instance_manager_runtime.py @@ -2,7 +2,7 @@ import sys import unittest from types import SimpleNamespace -from unittest.mock import MagicMock, patch +from unittest.mock import ANY, MagicMock, patch sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) @@ -65,6 +65,7 @@ def _fake_controller(): containers={}, node_redis={}, redis_containers={}, + config={"poll_interval": 5}, _run_cmd=MagicMock(return_value=SimpleNamespace(returncode=0)), ) @@ -93,6 +94,10 @@ def test_local_instances_keep_default_host_and_increment_host_ports(self): alpha = manager.ensure_instances([{"name": "Alpha", "provider": "local"}])[0] beta = manager.ensure_instances([{"name": "Beta", "provider": "local"}])[0] + alpha_agent_id = alpha.pop("agent_id") + self.assertEqual(len(alpha_agent_id), 32) + int(alpha_agent_id, 16) # raises ValueError if not a hex string + self.assertEqual( alpha, { @@ -131,6 +136,8 @@ def test_local_instances_keep_default_host_and_increment_host_ports(self): "VENTIS_REDIS_HOST=host.docker.internal", "-e", "VENTIS_REDIS_PORT=6379", + "-e", + "VENTIS_POLL_INTERVAL=5", "ventis-alpha", ], "localhost", @@ -138,6 +145,16 @@ def test_local_instances_keep_default_host_and_increment_host_ports(self): ), ) + def test_bootstrap_instance_passes_poll_interval_env_var(self): + controller = _fake_controller() + controller.config = {"poll_interval": 7} + manager = InstanceManager(controller, controller.redis) + + manager.ensure_instances([{"name": "Alpha", "provider": "local"}]) + + cmd = controller._run_cmd.call_args.args[0] + self.assertIn("VENTIS_POLL_INTERVAL=7", cmd) + def test_local_workflow_and_resource_flags_stay_the_same(self): controller = _fake_controller() manager = InstanceManager(controller, controller.redis) @@ -174,6 +191,8 @@ def test_local_workflow_and_resource_flags_stay_the_same(self): "VENTIS_REDIS_HOST=host.docker.internal", "-e", "VENTIS_REDIS_PORT=6379", + "-e", + "VENTIS_POLL_INTERVAL=5", "-p", "8080:8080", "--cpus", @@ -189,6 +208,26 @@ def test_local_workflow_and_resource_flags_stay_the_same(self): ), ) + def test_agent_id_is_stable_across_repeated_ensure_instances_calls(self): + controller = _fake_controller() + manager = InstanceManager(controller, controller.redis) + + first = manager.ensure_instances([{"name": "Alpha", "provider": "local"}])[0] + second = manager.ensure_instances([{"name": "Alpha", "provider": "local"}])[0] + + self.assertEqual(first["agent_id"], second["agent_id"]) + + def test_agent_id_is_published_under_the_controller_endpoint_key(self): + controller = _fake_controller() + manager = InstanceManager(controller, controller.redis) + + alpha = manager.ensure_instances([{"name": "Alpha", "provider": "local"}])[0] + + self.assertEqual( + controller.redis.get("controller:host.docker.internal:8000:agent_id"), + alpha["agent_id"], + ) + def test_local_remove_instance_still_removes_the_same_container(self): controller = _fake_controller() manager = InstanceManager(controller, controller.redis) @@ -211,7 +250,6 @@ def test_manager_keeps_ec2_runtime_boundary_behavior(self): provisioned = { "host": "10.0.0.30", "runtime_id": "ventis-ec2-remote-0--i-test1", - "ec2_instance_id": "i-test1", "redis_port": 6390, } instance = { @@ -225,7 +263,6 @@ def test_manager_keeps_ec2_runtime_boundary_behavior(self): "redis_host": "10.0.0.30", "redis_port": "6390", "runtime_id": "ventis-ec2-remote-0--i-test1", - "ec2_instance_id": "i-test1", } runtime = _fake_runtime( @@ -275,6 +312,7 @@ def test_manager_keeps_ec2_runtime_boundary_behavior(self): "redis_port": 6390, }, 0, + ANY, ) runtime.terminate_instance.assert_called_once_with(instance) self.assertEqual(created, instance) @@ -306,7 +344,6 @@ def test_manager_uses_same_runtime_contract_for_local_and_ec2(self): "redis_host": "10.0.0.30", "redis_port": "6379", "runtime_id": "ventis-ec2-remote-0--i-test1", - "ec2_instance_id": "i-test1", } local_runtime = _fake_runtime( @@ -339,7 +376,7 @@ def runtime_for(provider): # Local jobs get a pre-reserved port (8000 is the first free port). self.assertEqual(local_provision_args[2]("localhost"), 8000) local_runtime.bootstrap_instance.assert_called_once_with( - {}, {"name": "Local", "provider": "local"}, 0 + {}, {"name": "Local", "provider": "local"}, 0, ANY ) ec2_runtime.provision_instance.assert_called_once() ec2_provision_args = ec2_runtime.provision_instance.call_args.args @@ -349,7 +386,7 @@ def runtime_for(provider): ) self.assertIsNone(ec2_provision_args[2]("10.0.0.30")) ec2_runtime.bootstrap_instance.assert_called_once_with( - {}, {"name": "Remote", "provider": "EC2", "instance_type": "t3.small"}, 0 + {}, {"name": "Remote", "provider": "EC2", "instance_type": "t3.small"}, 0, ANY ) def test_local_provider_runtime_does_not_require_ec2_import(self): diff --git a/tests/test_local_controller_metrics.py b/tests/test_local_controller_metrics.py new file mode 100644 index 0000000..90491a7 --- /dev/null +++ b/tests/test_local_controller_metrics.py @@ -0,0 +1,277 @@ +import os +import sys +import threading +import unittest +import json +from unittest.mock import MagicMock +from concurrent.futures import ThreadPoolExecutor +from types import SimpleNamespace +from unittest.mock import patch + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) +sys.path.insert( + 0, + os.path.abspath( + os.path.join( + os.path.dirname(__file__), "..", "ventis", "templates", "grpc_stubs" + ) + ), +) + +from ventis.controller.local_controller import LocalController + + +def _bind_failure_marker(controller): + controller._mark_future_failed = lambda future_id, error, origin=None: ( + LocalController._mark_future_failed(controller, future_id, error, origin) + ) + controller._send_result_callback = ( + lambda origin, future_id, result=None, failed=0, error_message="": LocalController._send_result_callback( + controller, origin, future_id, result, failed, error_message + ) + ) + return controller + + +class _FakeRedisClient: + def __init__(self): + self.counters = {} + + def incr(self, key): + self.counters[key] = self.counters.get(key, 0) + 1 + return self.counters[key] + + +class _FakeRedis: + def __init__(self): + self.hashes = {} + self.strings = {} + self.client = _FakeRedisClient() + + def hset(self, name, field, value): + self.hashes.setdefault(name, {})[field] = value + + def hincrby(self, name, field, amount=1): + bucket = self.hashes.setdefault(name, {}) + bucket[field] = int(bucket.get(field, 0)) + amount + return bucket[field] + + def hset_multiple(self, name, mapping): + self.hashes.setdefault(name, {}).update(mapping) + + def hget(self, name, field): + return self.hashes.get(name, {}).get(field) + + def hgetall(self, name): + return dict(self.hashes.get(name, {})) + + def set(self, key, value): + self.strings[key] = value + + def get(self, key): + return self.strings.get(key) + + +class LocalControllerMetricsTests(unittest.TestCase): + def test_collect_metrics_returns_expected_keys(self): + controller = SimpleNamespace( + _executor=ThreadPoolExecutor(max_workers=1), + _metrics_interval=5, + ) + with patch( + "ventis.controller.local_controller.read_gpu_percent", return_value=0.0 + ): + metrics = LocalController._collect_metrics(controller) + self.assertEqual(metrics["status"], "healthy") + self.assertEqual( + set(metrics.keys()), + { + "status", + "cpu_percent", + "gpu_percent", + "disk_percent", + "memory_percent", + "uptime_seconds", + "queue_length", + "updated_at", + }, + ) + float(metrics["cpu_percent"]) + float(metrics["gpu_percent"]) + float(metrics["disk_percent"]) + float(metrics["memory_percent"]) + float(metrics["uptime_seconds"]) + int(metrics["queue_length"]) + float(metrics["updated_at"]) + + def test_metrics_loop_writes_hash_and_refreshes_status(self): + redis = _FakeRedis() + stop_event = threading.Event() + controller = SimpleNamespace( + redis=redis, + _metrics_key="controller:localhost:50051:metrics", + _status_key="controller:localhost:50051:status", + _metrics_stop_event=stop_event, + _metrics_interval=5, + _collect_metrics=lambda: { + "status": "healthy", + "cpu_percent": "1.0", + "gpu_percent": "0.0", + "uptime_seconds": "10.0", + "updated_at": "100.0", + }, + ) + + def stop_after_one_tick(timeout): + stop_event.set() + + stop_event.wait = stop_after_one_tick + + LocalController._metrics_loop(controller) + + self.assertEqual( + redis.hgetall("controller:localhost:50051:metrics")["cpu_percent"], "1.0" + ) + self.assertEqual(redis.get("controller:localhost:50051:status"), "healthy") + + def test_execute_locally_writes_gpu_resource_to_future_hash(self): + redis = _FakeRedis() + agent = SimpleNamespace(greet=lambda name: f"hello {name}") + controller = _bind_failure_marker(SimpleNamespace( + redis=redis, + agent=agent, + agent_name="Greeter", + agent_id="1f2e3d4c5b6a7988fedcba9876543210", + _my_endpoint="localhost:50051", + _metrics_key="controller:localhost:50051:metrics", + _resolve_future_args=lambda args: args, + )) + + with patch( + "ventis.controller.local_controller.read_gpu_percent", return_value=17.5 + ): + LocalController._execute_locally( + controller, "Greeter", "greet", {"name": "world"}, "future-1" + ) + + self.assertEqual(redis.hget("future:future-1:metrics", "gpu_resource"), 17.5) + self.assertEqual(redis.hget("future:future-1", "result"), "hello world") + self.assertEqual( + redis.hget("future:future-1:metrics", "agent"), + "1f2e3d4c5b6a7988fedcba9876543210", + ) + self.assertEqual( + redis.hget("controller:localhost:50051:metrics", "requests_served"), 1 + ) + + def test_execute_locally_counts_failed_executions_as_served(self): + redis = _FakeRedis() + + def boom(name): + raise ValueError("nope") + + agent = SimpleNamespace(greet=boom) + controller = _bind_failure_marker(SimpleNamespace( + redis=redis, + agent=agent, + agent_name="Greeter", + agent_id="aabbccddeeff00112233445566778899", + _my_endpoint="localhost:50051", + _metrics_key="controller:localhost:50051:metrics", + _resolve_future_args=lambda args: args, + )) + + with patch( + "ventis.controller.local_controller.read_gpu_percent", return_value=0.0 + ): + LocalController._execute_locally( + controller, "Greeter", "greet", {"name": "world"}, "future-2" + ) + + self.assertEqual(redis.hget("future:future-2", "error"), "nope") + self.assertIsNone(redis.hget("future:future-2", "result")) + self.assertEqual( + redis.hget("future:future-2:metrics", "failed"), 1 + ) + self.assertEqual( + redis.hget("future:future-2:metrics", "error_message"), "nope" + ) + self.assertEqual( + redis.hget("controller:localhost:50051:metrics", "requests_served"), 1 + ) + self.assertEqual( + redis.hget("controller:localhost:50051:metrics", "full_failures"), 1 + ) + + def test_execute_locally_marks_missing_agent_as_failed(self): + redis = _FakeRedis() + controller = _bind_failure_marker(SimpleNamespace( + redis=redis, + agent=None, + agent_name="MissingAgent", + agent_id="agent-1", + _my_endpoint="localhost:50051", + _metrics_key="controller:localhost:50051:metrics", + )) + + with patch( + "ventis.controller.local_controller.read_gpu_percent", return_value=0.0 + ): + LocalController._execute_locally( + controller, "MissingAgent", "greet", {}, "future-3" + ) + + self.assertEqual( + redis.hget("future:future-3", "error"), "No agent loaded" + ) + self.assertEqual(redis.hget("future:future-3:metrics", "failed"), 1) + self.assertEqual( + redis.hget("future:future-3:metrics", "error_message"), + "No agent loaded", + ) + + def test_remote_execution_failure_sends_error_callback(self): + redis = _FakeRedis() + stub = SimpleNamespace(WriteResult=MagicMock()) + + def boom(): + raise ValueError("remote nope") + + controller = _bind_failure_marker(SimpleNamespace( + redis=redis, + agent=SimpleNamespace(greet=boom), + agent_name="Greeter", + agent_id="agent-1", + _my_endpoint="target:50051", + _metrics_key="controller:target:50051:metrics", + _resolve_future_args=lambda args: args, + _get_remote_stub=lambda endpoint: stub, + )) + + with patch( + "ventis.controller.local_controller.read_gpu_percent", return_value=0.0 + ): + LocalController._execute_locally( + controller, + "Greeter", + "greet", + {}, + "future-4", + origin="origin:50051", + ) + + self.assertEqual(redis.hget("future:future-4", "error"), "remote nope") + payload = stub.WriteResult.call_args.args[0].resonse + self.assertEqual( + json.loads(payload), + { + "future_id": "future-4", + "result": None, + "failed": 1, + "error_message": "remote nope", + }, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_runtime_ec2.py b/tests/test_runtime_ec2.py index a8e3d64..c9853db 100644 --- a/tests/test_runtime_ec2.py +++ b/tests/test_runtime_ec2.py @@ -161,7 +161,7 @@ def test_provision_and_bootstrap_instance_return_runtime_record(self): patch.object(ec2_runtime, "_check_controller_health", return_value=True), ): provisioned = ec2_runtime.provision_instance(spec, 2) - instance = ec2_runtime.bootstrap_instance(provisioned, spec, 2) + instance = ec2_runtime.bootstrap_instance(provisioned, spec, 2, "agent-id-2") self.assertEqual(instance["host"], "10.0.0.30") self.assertEqual(instance["endpoint"], "10.0.0.30:50051") @@ -179,7 +179,7 @@ def test_bootstrap_instance_terminates_instance_when_bootstrap_fails(self): ), self.assertRaisesRegex(RuntimeError, "boom"), ): - ec2_runtime.bootstrap_instance(provisioned, spec, 0) + ec2_runtime.bootstrap_instance(provisioned, spec, 0, "agent-id-0") self.assertEqual(self.fake_client.terminate_requests, [["i-test1"]]) @@ -192,6 +192,7 @@ def test_bootstrap_uses_ssh_user(self): "run", return_value=SimpleNamespace(returncode=0, stderr="", stdout=""), ), + patch.object(ec2_runtime, "RedisClient", return_value=MagicMock()), ): ec2_runtime._bootstrap_instance( "10.0.0.30", @@ -200,6 +201,7 @@ def test_bootstrap_uses_ssh_user(self): self.controller.config["ec2"], redis_host="10.0.0.30", redis_port=6390, + agent_id="agent-id-2", ) self.assertTrue(self.controller._run_cmd.called) @@ -224,7 +226,7 @@ def test_bootstrap_instance_terminates_instance_when_health_check_fails(self): ), self.assertRaises(TimeoutError), ): - ec2_runtime.bootstrap_instance(provisioned, spec, 0) + ec2_runtime.bootstrap_instance(provisioned, spec, 0, "agent-id-0") self.assertEqual(self.fake_client.terminate_requests, [["i-test1"]]) diff --git a/tests/test_runtime_sqlalchemy.py b/tests/test_runtime_sqlalchemy.py deleted file mode 100644 index 6d6ed1d..0000000 --- a/tests/test_runtime_sqlalchemy.py +++ /dev/null @@ -1,129 +0,0 @@ -import os -import sys -import tempfile -import unittest - -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) - -from sqlalchemy import create_engine, text - -import ventis.controller.utils.sqlalchemy as sqlmod - - -class _FakeRedis: - def __init__(self, hashes): - self.hashes = hashes - - def scan_keys(self, pattern): - prefix = pattern.rstrip("*") - return [k for k in self.hashes if k.startswith(prefix)] - - def hgetall(self, name): - return dict(self.hashes.get(name, {})) - - def get(self, name): - return self.hashes.get(name) - - -_CREATE = """ -CREATE TABLE runtime_information ( - future_id TEXT PRIMARY KEY, - session_id TEXT, - workflow TEXT, - agent TEXT, - execution_time REAL, - cpu_resource REAL, - gpu_resource REAL, - created_at REAL, - updated_at REAL -) -""" - - -class RuntimeSqlalchemyTests(unittest.TestCase): - def setUp(self): - self.db = tempfile.NamedTemporaryFile(suffix=".db", delete=False) - self.db.close() - os.environ["VENTIS_DATABASE_URL"] = f"sqlite:///{self.db.name}" - sqlmod._engine = None - with create_engine(os.environ["VENTIS_DATABASE_URL"]).begin() as conn: - conn.execute(text(_CREATE)) - - def tearDown(self): - sqlmod._engine = None - os.unlink(self.db.name) - - def test_pull_and_upsert(self): - redis = _FakeRedis( - { - "future:abc": { - "id": "abc", - "request_id": "req1", - "agent": "AgentA", - "created_at": "1.0", - }, - "request:req1:workflow": "main", - "future:abc:consumers": {"x": "1"}, - } - ) - rows = sqlmod.pull_data(redis) - self.assertEqual(len(rows), 1) - self.assertEqual(rows[0]["future_id"], "abc") - - sqlmod.send_data(rows, {"AgentA": {"cpu": 2, "gpu": 1}}, redis) - with sqlmod._get_engine("").connect() as conn: - row = conn.execute( - text( - "SELECT execution_time, cpu_resource, gpu_resource, workflow " - "FROM runtime_information WHERE future_id='abc'" - ) - ).fetchone() - self.assertGreaterEqual(row[0], 0) - self.assertEqual(row[1], 2.0) - self.assertEqual(row[2], 1.0) - self.assertEqual(row[3], "main") - - rows[0]["finished_at"] = "9.0" - sqlmod.send_data(rows, {"AgentA": {"cpu": 2, "gpu": 1}}, redis) - with sqlmod._get_engine("").connect() as conn: - row = conn.execute( - text("SELECT * FROM runtime_information WHERE future_id='abc'") - ).fetchone() - self.assertEqual(row[4], 8.0) - self.assertEqual(row[8], 9.0) - for value in row: - self.assertNotIn(value, (None, "")) - - def test_observed_cpu_overrides_config_and_gpu_uses_config(self): - redis = _FakeRedis( - { - "future:xyz": { - "id": "xyz", - "request_id": "req2", - "agent": "AgentB", - "created_at": "10.0", - "finished_at": "12.0", - "execution_time": "1.25", - "cpu_resource": "37.5", - "gpu_resource": "256", - }, - "request:req2:workflow": "wf2", - } - ) - - rows = sqlmod.pull_data(redis) - sqlmod.send_data(rows, {"AgentB": {"cpu": 8, "gpu": 0}}, redis) - with sqlmod._get_engine("").connect() as conn: - row = conn.execute( - text( - "SELECT execution_time, cpu_resource, gpu_resource " - "FROM runtime_information WHERE future_id='xyz'" - ) - ).fetchone() - self.assertEqual(row[0], 2.0) - self.assertEqual(row[1], 37.5) - self.assertEqual(row[2], 0.0) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_session_logging.py b/tests/test_session_logging.py new file mode 100644 index 0000000..55ccccc --- /dev/null +++ b/tests/test_session_logging.py @@ -0,0 +1,162 @@ +import json +import os +import sys +import tempfile +import unittest +from datetime import datetime, timezone + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from sqlalchemy import text + +import ventis.controller.utils.session_logging as session_logging + + +def _stored_epoch(stored): + """Unix epoch seconds held by a stored TIMESTAMPTZ.""" + return datetime.fromisoformat(str(stored)).replace(tzinfo=timezone.utc).timestamp() + + +class SessionStoreTests(unittest.TestCase): + def setUp(self): + self.db = tempfile.NamedTemporaryFile(suffix=".db", delete=False) + self.db.close() + os.environ["VENTIS_DATABASE_URL"] = f"sqlite:///{self.db.name}" + session_logging._engine = None + # session_logging no longer bootstraps the schema itself (that's expected + # to already exist on the real database) -- tests create it directly. + with session_logging._get_engine("").begin() as conn: + conn.execute( + text( + """ + CREATE TABLE session ( + session_id VARCHAR(255) PRIMARY KEY, + project_id UUID NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'running', + input JSONB, + output JSONB, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL + ) + """ + ) + ) + + def tearDown(self): + session_logging._engine = None + os.unlink(self.db.name) + + def test_upsert_session_inserts_a_row_with_running_status(self): + session_logging.upsert_session( + "", "11111111-1111-1111-1111-111111111111", "req1", "running", 1000.0 + ) + with session_logging._get_engine("").connect() as conn: + row = ( + conn.execute(text("SELECT * FROM session WHERE session_id='req1'")) + .mappings() + .fetchone() + ) + self.assertEqual(row["project_id"], "11111111-1111-1111-1111-111111111111") + self.assertEqual(row["status"], "running") + self.assertEqual(row["created_at"], row["updated_at"]) + self.assertEqual(_stored_epoch(row["created_at"]), 1000.0) + + def test_upsert_session_transitions_status_in_place(self): + session_logging.upsert_session( + "", "11111111-1111-1111-1111-111111111111", "req1", "running", 1.0 + ) + session_logging.upsert_session( + "", "11111111-1111-1111-1111-111111111111", "req1", "completed", 999.0 + ) + with session_logging._get_engine("").connect() as conn: + row = ( + conn.execute(text("SELECT * FROM session WHERE session_id='req1'")) + .mappings() + .fetchone() + ) + # created_at/project_id come from the first call and stay put; status + # and updated_at reflect the second call. + self.assertEqual(_stored_epoch(row["created_at"]), 1.0) + self.assertEqual(row["project_id"], "11111111-1111-1111-1111-111111111111") + self.assertEqual(row["status"], "completed") + self.assertEqual(_stored_epoch(row["updated_at"]), 999.0) + + def test_upsert_session_input_and_output_round_trip(self): + session_logging.upsert_session( + "", + "11111111-1111-1111-1111-111111111111", + "req1", + "running", + 1.0, + input_payload={"query": "abc"}, + ) + session_logging.upsert_session( + "", + "11111111-1111-1111-1111-111111111111", + "req1", + "completed", + 999.0, + output_payload={"result": 42}, + ) + with session_logging._get_engine("").connect() as conn: + row = ( + conn.execute(text("SELECT * FROM session WHERE session_id='req1'")) + .mappings() + .fetchone() + ) + # input from the first call stays put; output from the second call is + # added without clobbering it. + self.assertEqual(json.loads(row["input"]), {"query": "abc"}) + self.assertEqual(json.loads(row["output"]), {"result": 42}) + + def test_upsert_session_first_call_can_be_any_status(self): + # There's no separate insert-only path -- a first call with any status + # (e.g. the workflow already failed before a "running" row existed) + # just creates the row directly, no error. + session_logging.upsert_session( + "", "11111111-1111-1111-1111-111111111111", "req-new", "failed", 1.0 + ) + with session_logging._get_engine("").connect() as conn: + row = ( + conn.execute(text("SELECT * FROM session WHERE session_id='req-new'")) + .mappings() + .fetchone() + ) + self.assertEqual(row["status"], "failed") + + def test_get_session_returns_status_and_output(self): + session_logging.upsert_session( + "", + "11111111-1111-1111-1111-111111111111", + "req1", + "completed", + 1.0, + input_payload={"query": "abc"}, + output_payload={"result": 42}, + ) + row = session_logging.get_session( + "", "11111111-1111-1111-1111-111111111111", "req1" + ) + self.assertEqual(row["status"], "completed") + self.assertEqual(json.loads(row["output"]), {"result": 42}) + + def test_get_session_returns_none_for_unknown_session(self): + self.assertIsNone( + session_logging.get_session( + "", "11111111-1111-1111-1111-111111111111", "nope" + ) + ) + + def test_get_session_is_scoped_to_the_project(self): + session_logging.upsert_session( + "", "11111111-1111-1111-1111-111111111111", "req1", "completed", 1.0 + ) + self.assertIsNone( + session_logging.get_session( + "", "22222222-2222-2222-2222-222222222222", "req1" + ) + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_telemetry_logging.py b/tests/test_telemetry_logging.py new file mode 100644 index 0000000..37ec8c0 --- /dev/null +++ b/tests/test_telemetry_logging.py @@ -0,0 +1,544 @@ +import fnmatch +import os +import sys +import tempfile +import unittest +from datetime import datetime, timezone + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from sqlalchemy import create_engine, text + +import ventis.controller.utils.telemetry_logging as sqlmod + + +def _parse_shifted(stored): + """Turn a stored TIMESTAMPTZ back into the Unix epoch seconds it holds.""" + return datetime.fromisoformat(str(stored)).replace(tzinfo=timezone.utc).timestamp() + + +class _FakeRedis: + def __init__(self, hashes, sets=None): + self.hashes = hashes + self.sets = sets or {} + + def scan_keys(self, pattern): + return [k for k in self.hashes if fnmatch.fnmatch(k, pattern)] + + def hgetall(self, name): + return dict(self.hashes.get(name, {})) + + def get(self, name): + return self.hashes.get(name) + + def sadd(self, name, *values): + self.sets.setdefault(name, set()).update(values) + + def srem(self, name, *values): + self.sets.get(name, set()).difference_update(values) + + def smembers(self, name): + return set(self.sets.get(name, set())) + + +_RUNTIME_CREATE_TABLE = text( + f""" + CREATE TABLE IF NOT EXISTS {sqlmod.RUNTIME_TABLE_NAME} ( + future_id VARCHAR(255) PRIMARY KEY, + parent_id VARCHAR(255), + session_id VARCHAR(255) NOT NULL, + project_id UUID NOT NULL, + agent_id VARCHAR(255), + model VARCHAR(255), + cpu DOUBLE PRECISION, + gpu DOUBLE PRECISION, + started_at TIMESTAMPTZ NOT NULL, + finished_at TIMESTAMPTZ, + execution_time_ms BIGINT, + queue_time_ms BIGINT, + input_token_count BIGINT NOT NULL DEFAULT 0, + output_token_count BIGINT NOT NULL DEFAULT 0, + token_count BIGINT NOT NULL DEFAULT 0, + errors INTEGER NOT NULL DEFAULT 0, + failed BOOLEAN NOT NULL DEFAULT false, + server_cost NUMERIC(12,6) NOT NULL DEFAULT 0, + token_cost NUMERIC(12,6) NOT NULL DEFAULT 0, + total_cost NUMERIC(12,6) NOT NULL DEFAULT 0, + cached_tokens BIGINT NOT NULL DEFAULT 0, + cache_hit_ratio DOUBLE PRECISION, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """ +) + +_AGENT_CREATE_TABLE = text( + f""" + CREATE TABLE IF NOT EXISTS {sqlmod.AGENT_TABLE_NAME} ( + agent_id VARCHAR(255) PRIMARY KEY, + name TEXT, + health VARCHAR(32), + queue_length INTEGER NOT NULL DEFAULT 0, + cpu_percent DOUBLE PRECISION, + gpu_percent DOUBLE PRECISION, + disk_percent DOUBLE PRECISION, + memory_percent DOUBLE PRECISION, + error_count BIGINT NOT NULL DEFAULT 0, + full_failures BIGINT NOT NULL DEFAULT 0, + requests_served BIGINT NOT NULL DEFAULT 0, + throughput DOUBLE PRECISION, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """ +) + + +class RuntimeSqlalchemyTests(unittest.TestCase): + def setUp(self): + self.db = tempfile.NamedTemporaryFile(suffix=".db", delete=False) + self.db.close() + os.environ["VENTIS_DATABASE_URL"] = f"sqlite:///{self.db.name}" + # sqlmod no longer creates the schema itself (a separate service owns + # that in real deployments) -- create it here so tests still get a + # ready-to-use database, matching what that service is assumed to do. + engine = create_engine(os.environ["VENTIS_DATABASE_URL"]) + with engine.begin() as conn: + conn.execute(_RUNTIME_CREATE_TABLE) + conn.execute(_AGENT_CREATE_TABLE) + engine.dispose() + sqlmod._engine = None + sqlmod._project_id = "11111111-1111-1111-1111-111111111111" + + def tearDown(self): + sqlmod._engine = None + sqlmod._project_id = None + os.unlink(self.db.name) + + def test_pull_and_upsert(self): + redis = _FakeRedis( + { + "future:abc:metrics": { + "id": "abc", + "request_id": "req1", + "agent": "1f2e3d4c5b6a7988fedcba9876543210", + "parent": "aabbccddeeff00112233445566778899", + "created_at": "1.0", + "cpu_resource": "2.0", + "gpu_resource": "3.0", + "queue_time": "0.5", + "failed": "0", + }, + "request:req1:workflow": "main", + "future:abc:consumers": {"x": "1"}, + } + ) + rows = sqlmod.pull_runtime_information(redis) + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0]["future_id"], "abc") + + # Still in-flight (no finished_at yet) -- must not insert a premature + # snapshot with a fabricated finish time and zeroed cpu/gpu. + sqlmod.send_runtime_information(rows, redis) + with sqlmod._get_engine("").connect() as conn: + row = conn.execute( + text("SELECT * FROM runtime_information WHERE future_id='abc'") + ).fetchone() + self.assertIsNone(row) + + rows[0]["finished_at"] = "9.0" + sqlmod.send_runtime_information(rows, redis) + with sqlmod._get_engine("").connect() as conn: + row = conn.execute( + text("SELECT * FROM runtime_information WHERE future_id='abc'") + ).mappings().fetchone() + self.assertEqual(row["execution_time_ms"], 8000) + self.assertEqual(row["cpu"], 2.0) + self.assertEqual(row["gpu"], 3.0) + self.assertEqual(row["project_id"], "11111111-1111-1111-1111-111111111111") + self.assertEqual(row["queue_time_ms"], 500) + self.assertEqual(row["parent_id"], "aabbccddeeff00112233445566778899") + self.assertEqual(bool(row["failed"]), False) + self.assertEqual(_parse_shifted(row["finished_at"]), 9.0) + self.assertIsNotNone(row["created_at"]) + + def test_parent_id_defaults_to_none_when_absent(self): + redis = _FakeRedis( + { + "future:solo:metrics": { + "id": "solo", + "request_id": "req9", + "agent": "1f2e3d4c5b6a7988fedcba9876543210", + "created_at": "1.0", + "finished_at": "2.0", + }, + "request:req9:workflow": "wf9", + } + ) + + rows = sqlmod.pull_runtime_information(redis) + sqlmod.send_runtime_information(rows, redis) + with sqlmod._get_engine("").connect() as conn: + row = conn.execute( + text( + "SELECT parent_id FROM runtime_information WHERE future_id='solo'" + ) + ).fetchone() + self.assertIsNone(row[0]) + + def test_observed_cpu_and_gpu_are_recorded(self): + redis = _FakeRedis( + { + "future:xyz:metrics": { + "id": "xyz", + "request_id": "req2", + "agent": "aabbccddeeff00112233445566778899", + "created_at": "10.0", + "finished_at": "12.0", + "execution_time": "1.25", + "cpu_resource": "37.5", + "gpu_resource": "62.5", + }, + "request:req2:workflow": "wf2", + } + ) + + rows = sqlmod.pull_runtime_information(redis) + sqlmod.send_runtime_information(rows, redis) + with sqlmod._get_engine("").connect() as conn: + row = conn.execute( + text( + "SELECT execution_time_ms, cpu, gpu " + "FROM runtime_information WHERE future_id='xyz'" + ) + ).fetchone() + self.assertEqual(row[0], 2000) + self.assertEqual(row[1], 37.5) + self.assertEqual(row[2], 62.5) + + def test_llm_token_and_error_fields_are_recorded(self): + redis = _FakeRedis( + { + "future:llm1:metrics": { + "id": "llm1", + "request_id": "req4", + "agent": "1f2e3d4c5b6a7988fedcba9876543210", + "created_at": "1.0", + "finished_at": "2.0", + "input_token_count": "120", + "output_token_count": "45", + "token_count": "165", + "errors": "1", + "input_cache_tokens": "30", + }, + "request:req4:workflow": "wf4", + } + ) + + rows = sqlmod.pull_runtime_information(redis) + sqlmod.send_runtime_information(rows, redis) + with sqlmod._get_engine("").connect() as conn: + row = conn.execute( + text( + "SELECT input_token_count, output_token_count, token_count, errors, " + "cache_hit_ratio, cached_tokens " + "FROM runtime_information WHERE future_id='llm1'" + ) + ).fetchone() + self.assertEqual(row[0], 120) + self.assertEqual(row[1], 45) + self.assertEqual(row[2], 165) + self.assertEqual(row[3], 1) + self.assertAlmostEqual(row[4], 30 / 165) + self.assertEqual(row[5], 30) + + def test_llm_token_and_error_fields_default_when_absent(self): + redis = _FakeRedis( + { + "future:nollm:metrics": { + "id": "nollm", + "request_id": "req5", + "agent": "00112233445566778899aabbccddeeff", + "created_at": "1.0", + "finished_at": "2.0", + }, + "request:req5:workflow": "wf5", + } + ) + + rows = sqlmod.pull_runtime_information(redis) + sqlmod.send_runtime_information(rows, redis) + with sqlmod._get_engine("").connect() as conn: + row = conn.execute( + text( + "SELECT input_token_count, output_token_count, token_count, errors, " + "cache_hit_ratio, cached_tokens " + "FROM runtime_information WHERE future_id='nollm'" + ) + ).fetchone() + self.assertEqual(row[0], 0) + self.assertEqual(row[1], 0) + self.assertEqual(row[2], 0) + self.assertEqual(row[3], 0) + self.assertEqual(row[4], 0.0) + self.assertEqual(row[5], 0) + + def test_cpu_and_gpu_default_to_zero_when_not_observed(self): + redis = _FakeRedis( + { + "future:noop:metrics": { + "id": "noop", + "request_id": "req3", + "agent": "00112233445566778899aabbccddeeff", + "created_at": "1.0", + "finished_at": "2.0", + }, + "request:req3:workflow": "wf3", + } + ) + + rows = sqlmod.pull_runtime_information(redis) + sqlmod.send_runtime_information(rows, redis) + with sqlmod._get_engine("").connect() as conn: + row = conn.execute( + text( + "SELECT cpu, gpu " + "FROM runtime_information WHERE future_id='noop'" + ) + ).fetchone() + self.assertEqual(row[0], 0.0) + self.assertEqual(row[1], 0.0) + + def test_total_cost_computed_from_model_token_pricing(self): + redis = _FakeRedis( + { + "future:cost1:metrics": { + "id": "cost1", + "request_id": "req6", + "agent": "1f2e3d4c5b6a7988fedcba9876543210", + "created_at": "1.0", + "finished_at": "2.0", + "model": "anthropic.claude-haiku-4-5-v1:0", + "input_token_count": "1000000", + "output_token_count": "1000000", + "token_count": "2000000", + }, + "request:req6:workflow": "wf6", + } + ) + + rows = sqlmod.pull_runtime_information(redis) + sqlmod.send_runtime_information(rows, redis) + with sqlmod._get_engine("").connect() as conn: + row = conn.execute( + text( + "SELECT total_cost, token_cost FROM runtime_information " + "WHERE future_id='cost1'" + ) + ).fetchone() + # anthropic.claude-haiku-4-5-v1:0 is priced at $1/$5 per million input/output tokens. + self.assertAlmostEqual(row[0], 6.0) + self.assertAlmostEqual(row[1], 6.0) + + def test_total_cost_defaults_to_zero_for_unknown_model(self): + redis = _FakeRedis( + { + "future:cost2:metrics": { + "id": "cost2", + "request_id": "req7", + "agent": "1f2e3d4c5b6a7988fedcba9876543210", + "created_at": "1.0", + "finished_at": "2.0", + "input_token_count": "1000000", + "output_token_count": "1000000", + }, + "request:req7:workflow": "wf7", + } + ) + + rows = sqlmod.pull_runtime_information(redis) + sqlmod.send_runtime_information(rows, redis) + with sqlmod._get_engine("").connect() as conn: + row = conn.execute( + text("SELECT total_cost FROM runtime_information WHERE future_id='cost2'") + ).fetchone() + self.assertEqual(row[0], 0.0) + + def test_total_cost_includes_server_cost_from_agent_instance_type(self): + redis = _FakeRedis( + { + "future:cost3:metrics": { + "id": "cost3", + "request_id": "req8", + "agent": "ec2agent1", + "created_at": "0.0", + "finished_at": "3600.0", + }, + "request:req8:workflow": "wf8", + "agent:ec2agent1:instance_type": "m5.large", + } + ) + + rows = sqlmod.pull_runtime_information(redis) + sqlmod.send_runtime_information(rows, redis) + with sqlmod._get_engine("").connect() as conn: + row = conn.execute( + text("SELECT total_cost, server_cost FROM runtime_information WHERE future_id='cost3'") + ).fetchone() + # m5.large is $0.096/hr; a full hour of execution_time should bill the full rate. + self.assertAlmostEqual(row[0], 0.096) + self.assertAlmostEqual(row[1], 0.096) + + def test_demo_cost_multipliers_scale_costs_independently_and_warn(self): + redis = _FakeRedis( + { + "future:cost4:metrics": { + "id": "cost4", + "request_id": "req9", + "agent": "ec2agent2", + "created_at": "0.0", + "finished_at": "3600.0", + "model": "anthropic.claude-haiku-4-5-v1:0", + "input_token_count": "1000000", + "output_token_count": "1000000", + "token_count": "2000000", + }, + "request:req9:workflow": "wf9", + "agent:ec2agent2:instance_type": "m5.large", + } + ) + rows = sqlmod.pull_runtime_information(redis) + + os.environ["VENTIS_DEMO_TOKEN_COST_MULTIPLIER"] = "2" + os.environ["VENTIS_DEMO_SERVER_COST_MULTIPLIER"] = "3" + try: + with self.assertLogs("ventis.controller.utils.telemetry_logging", level="WARNING") as cm: + sqlmod.send_runtime_information(rows, redis) + self.assertTrue( + any("VENTIS_DEMO_TOKEN_COST_MULTIPLIER" in msg for msg in cm.output) + ) + self.assertTrue( + any("VENTIS_DEMO_SERVER_COST_MULTIPLIER" in msg for msg in cm.output) + ) + finally: + del os.environ["VENTIS_DEMO_TOKEN_COST_MULTIPLIER"] + del os.environ["VENTIS_DEMO_SERVER_COST_MULTIPLIER"] + + with sqlmod._get_engine("").connect() as conn: + row = conn.execute( + text( + "SELECT total_cost, server_cost, token_cost FROM runtime_information " + "WHERE future_id='cost4'" + ) + ).fetchone() + # m5.large is $0.096/hr, scaled 3x. Haiku 4.5 is $1.00/$5.00 per million + # tokens (1M in + 1M out = $6.00), scaled 2x. + self.assertAlmostEqual(row[1], 0.096 * 3) + self.assertAlmostEqual(row[2], 6.00 * 2) + self.assertAlmostEqual(row[0], row[1] + row[2]) + + def test_send_agent_information_inserts_and_updates(self): + row = { + "agent_id": "local:AgentA:0", + "agent_name": "AgentA", + "provider": "local", + "host": "localhost", + "host_port": "50051", + "status": "healthy", + "cpu_percent": "12.5", + "gpu_percent": "0.0", + "disk_percent": "45.0", + "memory_percent": "60.0", + "uptime_seconds": "100.0", + "queue_length": "3", + "requests_served": "5", + "throughput": "1.0", + "full_failures": "2", + "error_count": "4", + "updated_at": "1.0", + } + sqlmod.send_agent_information([row]) + with sqlmod._get_engine("").connect() as conn: + fetched = conn.execute( + text( + "SELECT health, cpu_percent, disk_percent, memory_percent, queue_length, " + "requests_served, throughput, updated_at, " + "full_failures, error_count, name " + "FROM agent_information WHERE agent_id='local:AgentA:0'" + ) + ).fetchone() + self.assertEqual(fetched[0], "healthy") + self.assertEqual(fetched[1], 12.5) + self.assertEqual(fetched[2], 45.0) + self.assertEqual(fetched[3], 60.0) + self.assertEqual(fetched[4], 3) + self.assertEqual(fetched[5], 5) + self.assertEqual(fetched[6], 1.0) + self.assertEqual(_parse_shifted(fetched[7]), 1.0) + self.assertEqual(fetched[8], 2) + self.assertEqual(fetched[9], 4) + self.assertEqual(fetched[10], "AgentA") + + row["status"] = "unhealthy" + row["cpu_percent"] = "99.0" + row["disk_percent"] = "90.0" + row["memory_percent"] = "80.0" + row["queue_length"] = "7" + row["requests_served"] = "0" + row["throughput"] = "0.0" + row["full_failures"] = "0" + row["error_count"] = "0" + row["updated_at"] = "5.0" + sqlmod.send_agent_information([row]) + with sqlmod._get_engine("").connect() as conn: + fetched = conn.execute( + text( + "SELECT health, cpu_percent, disk_percent, memory_percent, queue_length, " + "requests_served, throughput, updated_at, full_failures, error_count, name " + "FROM agent_information WHERE agent_id='local:AgentA:0'" + ) + ).fetchone() + self.assertEqual(fetched[0], "unhealthy") + self.assertEqual(fetched[1], 99.0) + self.assertEqual(fetched[2], 90.0) + self.assertEqual(fetched[3], 80.0) + self.assertEqual(fetched[4], 7) + self.assertEqual(fetched[5], 0) # reset to 0 after the poll interval drained it + self.assertEqual(fetched[6], 0.0) + self.assertEqual(_parse_shifted(fetched[7]), 5.0) + self.assertEqual(fetched[8], 0) # failures reset to 0 after the poll interval drained it + self.assertEqual(fetched[9], 0) # errors reset to 0 after the poll interval drained it + self.assertEqual(fetched[10], "AgentA") + + def test_send_agent_information_defaults_missing_fields(self): + sqlmod.send_agent_information([{"agent_id": "local:AgentB:0"}]) + with sqlmod._get_engine("").connect() as conn: + fetched = conn.execute( + text( + "SELECT health, cpu_percent, gpu_percent, disk_percent, memory_percent, " + "queue_length, requests_served, throughput, " + "full_failures, error_count, name " + "FROM agent_information WHERE agent_id='local:AgentB:0'" + ) + ).fetchone() + self.assertEqual(fetched[0], "unknown") + self.assertEqual(fetched[1], 0.0) + self.assertEqual(fetched[2], 0.0) + self.assertEqual(fetched[3], 0.0) + self.assertEqual(fetched[4], 0.0) + self.assertEqual(fetched[5], 0) + self.assertEqual(fetched[6], 0) + self.assertEqual(fetched[7], 0.0) + self.assertEqual(fetched[8], 0) + self.assertEqual(fetched[9], 0) + self.assertIsNone(fetched[10]) + + def test_send_agent_information_noop_on_empty_rows(self): + sqlmod.send_agent_information([]) + sqlmod.send_agent_information(None) + with sqlmod._get_engine("").connect() as conn: + count = conn.execute( + text("SELECT COUNT(*) FROM agent_information") + ).scalar() + self.assertEqual(count, 0) + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_ventis_context.py b/tests/test_ventis_context.py new file mode 100644 index 0000000..bec4122 --- /dev/null +++ b/tests/test_ventis_context.py @@ -0,0 +1,49 @@ +import os +import sys +import unittest + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +import ventis.ventis_context as ventis_context + + +class VentisContextTests(unittest.TestCase): + def setUp(self): + ventis_context._local = ventis_context.threading.local() + + def tearDown(self): + ventis_context._local = ventis_context.threading.local() + + def test_request_id_defaults_to_empty_string(self): + self.assertEqual(ventis_context.get_request_id(), "") + + def test_request_id_round_trips(self): + ventis_context.set_request_id("req-123") + self.assertEqual(ventis_context.get_request_id(), "req-123") + + def test_current_future_id_defaults_to_empty_string(self): + self.assertEqual(ventis_context.get_current_future_id(), "") + + def test_current_future_id_round_trips(self): + ventis_context.set_current_future_id("future-abc") + self.assertEqual(ventis_context.get_current_future_id(), "future-abc") + + def test_request_id_and_future_id_are_independent(self): + ventis_context.set_request_id("req-123") + ventis_context.set_current_future_id("future-abc") + self.assertEqual(ventis_context.get_request_id(), "req-123") + self.assertEqual(ventis_context.get_current_future_id(), "future-abc") + + def test_current_metrics_key_defaults_to_empty_string(self): + self.assertEqual(ventis_context.get_current_metrics_key(), "") + + def test_current_metrics_key_round_trips(self): + ventis_context.set_current_metrics_key("controller:localhost:50051:metrics") + self.assertEqual( + ventis_context.get_current_metrics_key(), + "controller:localhost:50051:metrics", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..9b7b188 --- /dev/null +++ b/uv.lock @@ -0,0 +1,760 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "async-timeout" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, +] + +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + +[[package]] +name = "boto3" +version = "1.43.58" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/95/bd6276870084c9c1a94e17d1dc73b2de275e59bd7a0ad8bfff0a1598cec4/boto3-1.43.58.tar.gz", hash = "sha256:12871fb50c383f1b9aa4ed6dd386ba689062baef730552e79d5a9cd782b53058", size = 112685, upload-time = "2026-07-28T19:35:09.336Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/85/b0709066efb4ce7b86aba4092c739ad0e458be9d62cf32550fc4c130c93f/boto3-1.43.58-py3-none-any.whl", hash = "sha256:ce1a20cbcfaa1d0b3c8f568e2b6c7fbd34b842ea00e729d49a4b4de522828db9", size = 140026, upload-time = "2026-07-28T19:35:06.993Z" }, +] + +[[package]] +name = "botocore" +version = "1.43.58" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/64/5cd46a7e72b0647e6d78fc8da016259ad66b9ae0818f4c5d629c75e7ca49/botocore-1.43.58.tar.gz", hash = "sha256:e110ca53f65c128fe98df4d6d36a459b1db17c6114671c8a18becf151bf20909", size = 15742412, upload-time = "2026-07-28T19:34:57.468Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/82/6f8fbbea47b773734ba0199643d2d851e5c2f75bc3699fe99db8af344d96/botocore-1.43.58-py3-none-any.whl", hash = "sha256:f516159f0732da8249206163ccea3bd1f82ad2a9d184fe6ed447e1abdba4330e", size = 15426503, upload-time = "2026-07-28T19:34:53.508Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[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 = "flask" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, +] + +[[package]] +name = "greenlet" +version = "3.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/74/b13368064b09053253555d3f2839cc2684d22d5aed0d2ccffbf7a6736558/greenlet-3.5.4.tar.gz", hash = "sha256:0232ae1de90a8e07867bb127d7a6ba2301e859145489f25cda8a6096dabe1d20", size = 206538, upload-time = "2026-07-22T12:47:14.468Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/9d/58f80897f4121f5c218bb931cf6d3b6514873f02ad0b729f744352926b9f/greenlet-3.5.4-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:ac5bf81d79d2c8eeb2ef6359b2e1687a1e9ebf46c2b1f970da9a9255df51d190", size = 293072, upload-time = "2026-07-22T11:38:14.299Z" }, + { url = "https://files.pythonhosted.org/packages/dd/9f/b4bc9bbd6a7855cbd8ad8a83c874eeeca56c24de9132b3323f81c03a30ba/greenlet-3.5.4-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:89f3738167bab8c1084b94e23023d41d247117ac149fa0fbcb5bd4cf6262b353", size = 609393, upload-time = "2026-07-22T12:26:37.964Z" }, + { url = "https://files.pythonhosted.org/packages/05/0e/744b5e063af127d2e3c74fe0f1aef15573064c83b6066883524f5b258b17/greenlet-3.5.4-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e9a5e3406e3ed8125ae1a3b37c12f3434e2b1f0fa053197c5557895b4fb09606", size = 622750, upload-time = "2026-07-22T12:28:59.546Z" }, + { url = "https://files.pythonhosted.org/packages/d6/6b/d78ea2908e8e08985348f28ac396c2950be7ab66321dfe0054c73bd1f456/greenlet-3.5.4-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4ab9f0704bccf6d3b38e0d2130b7b33271cff11453690da074fa280c3aa8e8e7", size = 622920, upload-time = "2026-07-22T11:51:06.83Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e4/3ce7009c948920b01527f8d9da29f501a31ac3d98318829e981fd879b850/greenlet-3.5.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2cdaadc3d31445a8f782bde3cd37e49a2c2a9c6da6daf76a3e34c683b271a3c7", size = 1582262, upload-time = "2026-07-22T12:25:01.131Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4c/0408366102a33829f7bdd6a992dad75abbf75e86cc1e76caf19e57311d29/greenlet-3.5.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:70bdfacdc183dac838b2a0aaff2dd6134a457c52fe68a9c6bbab435483d2b9df", size = 1648906, upload-time = "2026-07-22T11:51:08.627Z" }, + { url = "https://files.pythonhosted.org/packages/13/52/ebfe8f6a1aeb8e430540b406c844ecc4e3367072b0192f69dcb85eeeec2b/greenlet-3.5.4-cp310-cp310-win_amd64.whl", hash = "sha256:69173331fbc5d64bfac0065d7e22c39cfcd089e9b18d125bdcd5079363b09616", size = 246036, upload-time = "2026-07-22T11:38:30.073Z" }, + { url = "https://files.pythonhosted.org/packages/61/16/71eefcf68267bbf06a9b6bff57d0b222e49432326e85d74348b67694b8d4/greenlet-3.5.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:e883de250e299654b1f1680f72a1a9f9ba62c9bd1bce84099c90657349a8dfbb", size = 294266, upload-time = "2026-07-22T11:37:56.142Z" }, + { url = "https://files.pythonhosted.org/packages/36/ea/a0b19adfc35d07e10acb626e9d22a3893b95f1309c42c4a20161dec16800/greenlet-3.5.4-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32802705c2c1ff25e8237b3bdacf2594fa02be80af8a66703eb7853ea7e68686", size = 613712, upload-time = "2026-07-22T12:26:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a121978b3337407d05a1ce5f79b4aa5998a43a9d8422f9726029b90b4471/greenlet-3.5.4-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:57aa201b351f7c7c75627c60d29e4d5b97a07d37efeb62b903466fca42c097d7", size = 625582, upload-time = "2026-07-22T12:29:00.814Z" }, + { url = "https://files.pythonhosted.org/packages/34/c2/080f16cf870e929e592f55767f01d6c98d2ee83bfdc36c3b892f2d0459ab/greenlet-3.5.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c3fe76c2cac86b4f7a1e92865ac0a54384deb05c92986287c1a7110d9bd53071", size = 624663, upload-time = "2026-07-22T11:51:08.016Z" }, + { url = "https://files.pythonhosted.org/packages/9e/bb/8f3ca88370b817369008faeceeee85970adc16c92a70a3e5fe5fea495a57/greenlet-3.5.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1e1a4a684b16c45ba324e60b32a4386a87722bcb815d2a149d2182f9b401ca72", size = 1585010, upload-time = "2026-07-22T12:25:02.539Z" }, + { url = "https://files.pythonhosted.org/packages/51/c2/45877154689709ebce9a0b83c2235e6ca0f31577889b02af308c8cc5f8fb/greenlet-3.5.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e849e6e139b9671adeac505f72fc05f4af7fd1921faef40295e214fc3b361b59", size = 1651283, upload-time = "2026-07-22T11:51:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7d/8711a75cb61d85246277c07ff6e1a6504621ba473d808c11ad225ffca43f/greenlet-3.5.4-cp311-cp311-win_amd64.whl", hash = "sha256:dc418cf4c873357964d6624445ed09472e50def990c65dd4e76fc3ba8cd9cef6", size = 246434, upload-time = "2026-07-22T11:43:15.557Z" }, + { url = "https://files.pythonhosted.org/packages/00/62/e290b3bce433da8f0324ac02da0b128d683482229f1a8b789fa47818a4cd/greenlet-3.5.4-cp311-cp311-win_arm64.whl", hash = "sha256:c38c902a0986eba1f6e7ba1ab39ad5195926abde90f3fe080e08212db62176da", size = 244990, upload-time = "2026-07-22T11:39:22.626Z" }, + { url = "https://files.pythonhosted.org/packages/f3/04/81bd731d6d1e3a469d9a4c36f5eb069bcf0cbb2d5d342c9fec22245b91fc/greenlet-3.5.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3d66250e8b09f182ede05490998c818b5961f7a3640332d44c4927caec7bbfe4", size = 295909, upload-time = "2026-07-22T11:38:09.261Z" }, + { url = "https://files.pythonhosted.org/packages/cc/dd/f5f22903a6ae70f5ea328ed0beaec92ad903f0e3b7d2845133b354abc4b8/greenlet-3.5.4-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c90e930c9c192e5b3ee9fb8bcd920ea3926155e2e3ded39fc697323addecee17", size = 612011, upload-time = "2026-07-22T12:26:40.69Z" }, + { url = "https://files.pythonhosted.org/packages/8e/10/92a4a88d12b915d74ea5b6d288e4afefda4771647caa34442c156f7a454f/greenlet-3.5.4-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:791fdfeeb9c6e0c7b10fa151bf110d2a6974866f13dcb5b1c7efae698245893a", size = 624299, upload-time = "2026-07-22T12:29:02.089Z" }, + { url = "https://files.pythonhosted.org/packages/50/6d/0b14bb9db2989f32cd9fe7f76afedea01ee8bee3f87c07e69f24adfe7e63/greenlet-3.5.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f88193799d43dbf8c8a806d6405c9c52fe2af40bf75072a606357b33cc336c7f", size = 621541, upload-time = "2026-07-22T11:51:09.464Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/25e9a2d9eb6b2e8b7ca4e80a3a26cb887cce6c8e0a87c921164f11bc5574/greenlet-3.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b7a5f095767c4493afcd06067f2bb3b8716e3f3f9e92b99c88e7e99f885b3d4d", size = 1581444, upload-time = "2026-07-22T12:25:03.818Z" }, + { url = "https://files.pythonhosted.org/packages/b9/96/4c9bf2e2c408dcc0556edce69efa9f802e82223573c53240136a086821f1/greenlet-3.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:42afdc1ab5f66da8c586c32af9224a74a706b4f0ea0dc3a4188a0860a09c65c9", size = 1645842, upload-time = "2026-07-22T11:51:12.295Z" }, + { url = "https://files.pythonhosted.org/packages/b5/41/303ecb26a3a56122c0f4d4073ee078881847bd6b6f463ae0ec57ec20223b/greenlet-3.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:60149df8f462d1b230038e6590c23c3b4768bb5d6c022b3b6e82532b34b0b8a3", size = 247169, upload-time = "2026-07-22T11:38:19.893Z" }, + { url = "https://files.pythonhosted.org/packages/a4/e3/ef56864b4c35fcb3eb3b41b869f6cc46f4cd3f5e2c68e74acde8ac433951/greenlet-3.5.4-cp312-cp312-win_arm64.whl", hash = "sha256:77d6ce04fed0d9aeed42e0f37923cc43eba9b027bdd9c34546bb4ccd143d0fe0", size = 245565, upload-time = "2026-07-22T11:38:27.061Z" }, + { url = "https://files.pythonhosted.org/packages/c0/9a/e51225dcd58713f16ccbdcc501a8da21098ea14515b7870f1f94459e5ff5/greenlet-3.5.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:24e61b88cb7e1b1d794b32a10cc346ac779681d6d74ff137a3e0a444d2bf1f02", size = 294831, upload-time = "2026-07-22T11:38:53.389Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ea/de50a50fadf979713ab18b46f22ad5ff5f2dcfc637a3ebdecf669801e1a5/greenlet-3.5.4-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:870d730fec833f5a06906a32596cc099b9161594642a92a520b7a88911c95356", size = 614619, upload-time = "2026-07-22T12:26:42.282Z" }, + { url = "https://files.pythonhosted.org/packages/db/c7/2aae27fea41205b8650294c301f042a2a4bb6155eea48c995b890a92f2c1/greenlet-3.5.4-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec5ff0d1878df6af3bf9b638a5a92a7d5693291de77c91bff10fa48519c604ef", size = 627021, upload-time = "2026-07-22T12:29:03.445Z" }, + { url = "https://files.pythonhosted.org/packages/eb/56/79fd826f9ccaae0b84e1b4ef68dabba5e105bb044ffcd448a0b782fcba9a/greenlet-3.5.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d84d993f6e575c950d91a23c1345d18fe1a4310d447bf630849d7809196b52f0", size = 624002, upload-time = "2026-07-22T11:51:11.391Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1a/27319f97e731298513dcba1a2e91b63e9d8811d9de22130f960b129b1bf1/greenlet-3.5.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:58023945f421093de5e6fa108c0985a8659d43f49e0216da25099369a121bcbd", size = 1581533, upload-time = "2026-07-22T12:25:05.322Z" }, + { url = "https://files.pythonhosted.org/packages/b1/6d/24240bf562e9786dd2799ee0a4a4dadb4ded22510f41b20245099159ac8c/greenlet-3.5.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bae2728e1897aa8df8cb1af38cd48b3a743aefe29372de7b8b7a9f532501e69f", size = 1645781, upload-time = "2026-07-22T11:51:14.805Z" }, + { url = "https://files.pythonhosted.org/packages/c1/5a/442ab1a9ef7ca6bf7210e5397a95972206a91a31033a03c8900866a10039/greenlet-3.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:ca5726c0b08ca35ae873557266a78b2c3f3b2b7d7401aa5ff886c2045dd0111c", size = 247133, upload-time = "2026-07-22T11:39:20.661Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e6/9160210222386b1a378ff94db846b9508ca24a121cf684991561fdb69280/greenlet-3.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:7c1303791d603080cac6fc3b34df51c3b75b723739c282c8029e48a0d241672f", size = 245500, upload-time = "2026-07-22T11:40:22.185Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a7/6ab1d4f9cd548d15ab90da29947f2076100130bb179b0bde59f795a459e3/greenlet-3.5.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:7e8afa5eac028f8140ceafe5ceec66e6aa127ddcb21452d2a564dcd2900b5f22", size = 295410, upload-time = "2026-07-22T11:40:35.747Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7a/422f63b4715cbc0b24385305407adf38b48f6bb68b3e6b04090e994d0f5a/greenlet-3.5.4-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:73b37afe369021423ea53dd3123e04bffa7e93ac64429b9f50835b2e4fcae7cf", size = 661286, upload-time = "2026-07-22T12:26:43.8Z" }, + { url = "https://files.pythonhosted.org/packages/d0/31/5a1cac663bf5582190c5a714ef81364f03cde232227f39748f8ae4c11da5/greenlet-3.5.4-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3ef964f56dfcb6f9bbef2a190d9126795eac408716aeae47b5e7c73c32aafca9", size = 673517, upload-time = "2026-07-22T12:29:04.815Z" }, + { url = "https://files.pythonhosted.org/packages/15/4a/2a82a1e3f8aaca020853ac8d12211280ca2b231aa08ea39f636f1060c319/greenlet-3.5.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c53ff01a5c53a40f2c16820ebc56d7c61a77f5fbe009dadd96292d5682f80f8", size = 670917, upload-time = "2026-07-22T11:51:13.589Z" }, + { url = "https://files.pythonhosted.org/packages/68/b0/e379a152b17bfdfa95795af4049e37c0fd1b4d81f020d426db104ed07c77/greenlet-3.5.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecca4d80d55a01ad6b23b33262662956149fbb7b2c6be2910f1705921958cbf3", size = 1628478, upload-time = "2026-07-22T12:25:06.678Z" }, + { url = "https://files.pythonhosted.org/packages/5e/43/bffdfa64f7317f954c5c1230b5dd5922676ce198689a68c1ac1ed4b1b1a5/greenlet-3.5.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ffbc533e0eaf8e80d8471411646ab88fe58f641d508c0b02b24494479f4d9ec", size = 1692021, upload-time = "2026-07-22T11:51:17.008Z" }, + { url = "https://files.pythonhosted.org/packages/d0/11/f799f9637e2c6e9b0b716015e339040598b058cf7654dfc0d67468b177ed/greenlet-3.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:305f69e6c4523d7f6979ed001cff4e5853c063e5da04880296603aa0227e544c", size = 248031, upload-time = "2026-07-22T11:40:11.007Z" }, + { url = "https://files.pythonhosted.org/packages/05/75/625bcdd74d5e6b2dca1ecba3c3ac77bcf8a026c21a649a46cef23e421f97/greenlet-3.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:f260930bbbbcf9caee661211235a5111c86dfe5832fdf6ae4570da1e0995320f", size = 246892, upload-time = "2026-07-22T11:40:27.357Z" }, + { url = "https://files.pythonhosted.org/packages/ec/69/35c62ed49c320cb4d98e14698ccca5467d3bfe683984172be9cb564d9ce3/greenlet-3.5.4-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:41ddab54e4b238f4a6c323f39b4e59e176affd5a94d461a9fb7583dac74240a3", size = 305571, upload-time = "2026-07-22T11:40:31.659Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6c/64d60216b3640dcb0b62d913dd9e0d80030c09115bb2e4ba70c95d10ca45/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3dabe3e2809013052c68bdf0b7fa5f5f2859c43a80803131ad61af9cabd7867", size = 672568, upload-time = "2026-07-22T12:26:45.298Z" }, + { url = "https://files.pythonhosted.org/packages/5c/de/ba3ab0a96292e53039530333b0d2ae18d9e508f3a325cd7bf15f8172944c/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:27d3f00718634d4520a3a150154ac5da36f257869d41321953375b90bfbbc72c", size = 680076, upload-time = "2026-07-22T12:29:06.125Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/aeada79083c6f1c15f45d77a332f9c441af263ee298e3eb17522cd337d22/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbd60b5763c6543c1827e48faaf14ea9bfbad245f52b1a4d76a2a2d8884c6c66", size = 676733, upload-time = "2026-07-22T11:51:16.027Z" }, + { url = "https://files.pythonhosted.org/packages/e9/10/2392fc3a98948652ef5fd1e7275c04f861dd13f74b78a2b4309f4ee4d090/greenlet-3.5.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f00f910f0e7b35416c63b23ad78b769aeccfc1775f712b43c4ee525624a2eef7", size = 1637327, upload-time = "2026-07-22T12:25:07.879Z" }, + { url = "https://files.pythonhosted.org/packages/55/c6/e7237a3dfa1f205ed0d9ea1e46d70bd2811b32d516266399fb59d28ab90a/greenlet-3.5.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:91c26423753b92caf41ab3f98fd547d7374d4d9fc2d85be041886c1579d9255e", size = 1697493, upload-time = "2026-07-22T11:51:19.214Z" }, + { url = "https://files.pythonhosted.org/packages/55/e3/4ba8154ba2a3d43729e499f72471b4b5c993f3826d3e24da81d5f06d6572/greenlet-3.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:ee032b91fd8ec29ec6c4cea2b8c561b178435134bd0752c7334b94e9c736c132", size = 251637, upload-time = "2026-07-22T11:40:37.44Z" }, + { url = "https://files.pythonhosted.org/packages/90/03/e3f96dfc100261a29545ddc8270cafe58f9195b6651466910e820910de77/greenlet-3.5.4-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:178111881dd7a6c946471fda85485ec796e1043c2b939f694b096e2ecf986809", size = 296076, upload-time = "2026-07-22T11:39:38.364Z" }, + { url = "https://files.pythonhosted.org/packages/a4/3d/da52d208e5c977bce8667e784729e584e38b5785f4c1ec0f4c836e9a1c42/greenlet-3.5.4-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d92df08dd65fede97fc37aad36c2e9dcda3b31c467f8e0c2c096456cb818e927", size = 666870, upload-time = "2026-07-22T12:26:46.691Z" }, + { url = "https://files.pythonhosted.org/packages/2d/8a/7e6dee25cb8a8cf9b362c8e597cc269593378bd916f16c736c059a52e85a/greenlet-3.5.4-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:99e8f8c4ebc4fd80aa26c1280ae9ad43a0976e786349703a181cf0bae60413e5", size = 677678, upload-time = "2026-07-22T12:29:07.508Z" }, + { url = "https://files.pythonhosted.org/packages/6c/21/5a38699fa45de749e3857d93b8f07e4c20489e77c2d35d915a2e1c456606/greenlet-3.5.4-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:394de08dad5ffcb1f50c2159d93e398d9d2da3ed437645eaa54771fa720db9f0", size = 676067, upload-time = "2026-07-22T11:51:18.163Z" }, + { url = "https://files.pythonhosted.org/packages/5e/ba/863116ab8ff1ca7a729e327800268939d182db47aa433db70e216e7d9194/greenlet-3.5.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:c883d61f2282d72c767a14936641b3efcbde9d82f1080712aaea0b1d3126cb88", size = 1633489, upload-time = "2026-07-22T12:25:09.605Z" }, + { url = "https://files.pythonhosted.org/packages/fa/06/7466ced82818d6132462d7f26b3f83c66ea15d2b193a6c0088d558ed7d95/greenlet-3.5.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2a924f15d17957e252a810acefcb5942f5ca712298e8b6fcaed9a307d357522c", size = 1696584, upload-time = "2026-07-22T11:51:21.304Z" }, + { url = "https://files.pythonhosted.org/packages/f9/4d/55b638489260065de9ffce606c8b5d04507bef705de4b212a0c3d6a1a0df/greenlet-3.5.4-cp315-cp315-win_amd64.whl", hash = "sha256:ed17e5f3420360d5b459de8462efb52060399a5326a613d4cde31cef63ef95da", size = 248297, upload-time = "2026-07-22T11:42:04.055Z" }, + { url = "https://files.pythonhosted.org/packages/bb/08/9dd4ae635da93d41dc268bc34bd62a9d711ed8b8825c5d22ac910c7d6e6d/greenlet-3.5.4-cp315-cp315-win_arm64.whl", hash = "sha256:f908898d6fa484ce4b6f447ce70ea99b52c503fee419e53cf74d60a16bc9e667", size = 247423, upload-time = "2026-07-22T11:44:00.764Z" }, + { url = "https://files.pythonhosted.org/packages/19/66/7c87ed9cdbf1d49c2c6cd1c7b9dd4d16c33b24235ca03972293a1876b30c/greenlet-3.5.4-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:1833637f17d5e7472548a48575c394fe39f1b1890d676d162d86593610f44d8c", size = 306487, upload-time = "2026-07-22T11:41:25.118Z" }, + { url = "https://files.pythonhosted.org/packages/5b/05/0a4201e7c0054866eefc05da234f236dd4c950d0fbf9ca0517141f01b269/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:12cda9122e03341f1cb6b8207a19d7a9d375e52f1b4e9243918375f40fd7b4b9", size = 676479, upload-time = "2026-07-22T12:26:48.129Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c0/4b6b8c5a3aec70f0649cd89662d120fdd6421e2bdc8e3b15c3ab5ec568d8/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d83ae0e32d14957ab7170785a20f582635c8474deab1bfbb552b17e769a6ce25", size = 684321, upload-time = "2026-07-22T12:29:08.925Z" }, + { url = "https://files.pythonhosted.org/packages/24/c9/b49c31c9a972eee91e260445770e922244a7efc542697f51a012ec046d0f/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f1467de1bb767f75db0aa34c195e3a496d8d1278c796e70c24ce205d3e99cde", size = 681293, upload-time = "2026-07-22T11:51:20.43Z" }, + { url = "https://files.pythonhosted.org/packages/95/6f/7f2d4653770500eee667866016d42d7a68e3d3462f80df6b8e3fcd48a0eb/greenlet-3.5.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:0fa53040b78b578120eecdc0265e3f1051487cc425d11a2b7c761daadf4feaa8", size = 1642474, upload-time = "2026-07-22T12:25:10.819Z" }, + { url = "https://files.pythonhosted.org/packages/5a/d8/8cba31036a4caae448087ba5d150660ab03b4a0f54d9150f6495a3be7262/greenlet-3.5.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:60e0bc961d367df506660e9ac0177a76bc6d81305300704b0977d1634f76efe2", size = 1701012, upload-time = "2026-07-22T11:51:23.17Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cd/3f77a4cce3bae631b08eb52f53a82a976669600337e21dfdba811cb50267/greenlet-3.5.4-cp315-cp315t-win_amd64.whl", hash = "sha256:f680e549edb3eaf21eea4e7fe101e15ec180c74b7879ab46adc080f22d4015d2", size = 251977, upload-time = "2026-07-22T11:41:38.125Z" }, + { url = "https://files.pythonhosted.org/packages/93/e8/65e8707d00fe2a49bf12f609a9b2b39ba6dd23c2810eacad877c4fc94bfe/greenlet-3.5.4-cp315-cp315t-win_arm64.whl", hash = "sha256:08fc36de8442d5c3e95b044550dbea9bf144d31ec0cc58e36fb241cb6ef6a994", size = 250538, upload-time = "2026-07-22T11:40:17.985Z" }, +] + +[[package]] +name = "grpcio" +version = "1.83.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/98/304898ac4e04e2d5e4e4c2eadc178b1f2a16d5f4bc2f91306c87d64680b9/grpcio-1.83.0.tar.gz", hash = "sha256:7674587248fbbb2ac6e4eecf83a8a0f3d91a928f941de571acfd3a2f007fbc24", size = 13428824, upload-time = "2026-07-23T15:20:37.759Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/fd/655c8a773d728bc3c93fb4713ae4bf79ffc75996f86fb78b2974c8e1dfbd/grpcio-1.83.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:fba099b716e73512d61b97f71ea3c31a72abb36904036e316bf4dd148ca8dcc8", size = 6334247, upload-time = "2026-07-23T15:18:53.099Z" }, + { url = "https://files.pythonhosted.org/packages/d4/9a/1ce5760d35a04a992006dd2f79afff2db548f93ee7426fa95c9f1fc90c61/grpcio-1.83.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:6755ed67cc3e454d51ae9f6e1915b80d3942fa4de956ef48dacd45ab7f40b727", size = 12168650, upload-time = "2026-07-23T15:18:56.348Z" }, + { url = "https://files.pythonhosted.org/packages/8d/ab/bbcb5be0a1a6cb21f036e2afdd4f7a70147cfb7a7b42648a310d7c43acfc/grpcio-1.83.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5882c1a721b50ce0123ee5e839e1ab059ad72a7ade76cdf2d5bd833b56791acf", size = 6916899, upload-time = "2026-07-23T15:18:58.339Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d2/4c27977ecb3b3f9f363b93f570e001cb24ef264a9a907d7fd0f949ed59f0/grpcio-1.83.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4e3eedfc92b6b9f2960115e7e620cf0cbf80bb7849a51ce3820dc54dfd88b6b9", size = 7648761, upload-time = "2026-07-23T15:19:00.071Z" }, + { url = "https://files.pythonhosted.org/packages/23/49/0c823a7627ff2e69a61e4a53c4edf215272892fc2c47c6431f033d46f4cc/grpcio-1.83.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4fcaa7c45c45b4a89e2867d1f1785d9481a788399d915e341ed2eb49aeef9dd4", size = 7074920, upload-time = "2026-07-23T15:19:02.293Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ce/963f01ff7c789a76909c9691b704112e02ca1e11c10405cd99c2bd7c40f1/grpcio-1.83.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6b6c666a1d5613ff360c9e90f44665e3a88b25a815209ddbc0917eec281931cb", size = 7598046, upload-time = "2026-07-23T15:19:03.921Z" }, + { url = "https://files.pythonhosted.org/packages/eb/de/1ce6bdefc847a7973040d10cebc8996c653a2a687c0a4da8d05dcab4e397/grpcio-1.83.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6be5c807b717be3dd649446f021301fd7907e376318675d2147823071034112a", size = 8634792, upload-time = "2026-07-23T15:19:05.633Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8b/7fe6a73895e3bdd788101d1276e48e0d262ebb165afacec1ec4efebcd785/grpcio-1.83.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c834e86d8fd2f03d7e4db49a027f7c5b89c5b88eed305543a5295bd6fee61e40", size = 8000286, upload-time = "2026-07-23T15:19:07.739Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b0/9a779de2bcda8722501a056fad1bec3d1117977af0c080ab1fc0655fdf35/grpcio-1.83.0-cp310-cp310-win32.whl", hash = "sha256:35a5b1c192496b6c25956eebfa963468935612206fd2543ac3ce981e6a5e0f03", size = 4404616, upload-time = "2026-07-23T15:19:09.988Z" }, + { url = "https://files.pythonhosted.org/packages/f0/8e/ce9a23590cac33a6c24e6386cc0ffc55821cc13212acc822e98f00a67161/grpcio-1.83.0-cp310-cp310-win_amd64.whl", hash = "sha256:8f6c395e493d20c39b29392ca200e9aaeb78d0bc2f04db0c0a7da7ddc939aa57", size = 5162304, upload-time = "2026-07-23T15:19:11.467Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f6/3b781cd07a715ea5f5125ae264226e7fc4d87603d6d3955022cabfdc5da2/grpcio-1.83.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:8ff0b8767ddd62704e0d9571c1890af08d84a3a689ebba1807e62519d0b3277f", size = 6338720, upload-time = "2026-07-23T15:19:13.177Z" }, + { url = "https://files.pythonhosted.org/packages/21/cc/d14833d15d5984e366f1b027fa78bd038c9b028c66880bffb0f5a4d25ee2/grpcio-1.83.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:4772402f43517b4824980be4b3b2274a81eec0004a70009473c31b340d43e223", size = 12178773, upload-time = "2026-07-23T15:19:15.401Z" }, + { url = "https://files.pythonhosted.org/packages/6b/98/8acbb416544e7871132d8e42a07ed70c802d70e6a16c6009e505a34d32a4/grpcio-1.83.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f4cee5fc86e84a0cf7ad1574b454c3320e087c07f55b7df5dc0ac6a873fb90c0", size = 6921203, upload-time = "2026-07-23T15:19:17.824Z" }, + { url = "https://files.pythonhosted.org/packages/45/9c/0fdbfaf4fc54e5c88f6bce4008a065092fe7fbc4460eb5617ae8b20fd505/grpcio-1.83.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f5e822a7e7d03282f6ad225e710493c48b9057a353358344a5f7c42b2b37618d", size = 7648508, upload-time = "2026-07-23T15:19:19.685Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ea/107b9dbb2ed3ad14dd774fd3dde7d29ff9938a6c198654becb2c3a0e9a6a/grpcio-1.83.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5f410d7c2903eabb34789dfd6342eef04af1ad459943936b7e09a9f5bd417b9", size = 7079466, upload-time = "2026-07-23T15:19:21.478Z" }, + { url = "https://files.pythonhosted.org/packages/3b/06/9fa9941089e6fae83b060b6ce61c1e81053e52decae43197245f45e07d36/grpcio-1.83.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ee94a4016fdf8699fb1fd8a38652475ff677f1c72074cee44deeeb9a7e95e745", size = 7605583, upload-time = "2026-07-23T15:19:23.74Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2f/f10fb56062dc2771c630827a82d9ad0ecd05cad572ea3b08d49f6631680a/grpcio-1.83.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c6444666317338e903093c7c756e6cc88eee59f798cb8dd41e87725bf54e1617", size = 8637810, upload-time = "2026-07-23T15:19:25.536Z" }, + { url = "https://files.pythonhosted.org/packages/99/55/f84927258f6a1b6ea6dea661fdc6de859b35e560c96f3012d15ccd39f85e/grpcio-1.83.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:aa074041231f03959cb097dd5517b0677b8ea49215bae01d5710a7b69dd59969", size = 8008021, upload-time = "2026-07-23T15:19:27.863Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6b/cdf72161397ccd29d4ca2192f641524536c9cf54ad948c9dd0e0e01138fa/grpcio-1.83.0-cp311-cp311-win32.whl", hash = "sha256:cb056f6e171c42639a50460b2929c82241fda51f71cf3dcdd68090fe45095a45", size = 4404376, upload-time = "2026-07-23T15:19:30.137Z" }, + { url = "https://files.pythonhosted.org/packages/df/ed/e0ffeb4c848699c194dc9fb6a29ab29bcb2b6aac8c416bf18c51bfe8242c/grpcio-1.83.0-cp311-cp311-win_amd64.whl", hash = "sha256:7416952ca770477990257206276999056f8316d79196f2f25942393e58a20b49", size = 5164469, upload-time = "2026-07-23T15:19:31.941Z" }, + { url = "https://files.pythonhosted.org/packages/15/2b/51e32514a4e9b715375c99721aadff0f24164cc2049b8269eda4de82a814/grpcio-1.83.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:28f6c35ac8fcf10e4594f138e468f194360089dde40d126a7033e863fc479930", size = 6303167, upload-time = "2026-07-23T15:19:33.78Z" }, + { url = "https://files.pythonhosted.org/packages/39/33/b5b50fc2c6fbe350e04814047bb2d409feec7b36ef8b170254c050e06bc0/grpcio-1.83.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:33898e6a28e4ae598f1577cb1c4fec2a15c033d0ec52b9b45a09610dd045b9da", size = 12160538, upload-time = "2026-07-23T15:19:35.958Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5f/734e72e7b9f79bcf0b2c270b8d3bca0e4ebb97a27a50d06240b145f6d41e/grpcio-1.83.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6fb8a1dd0c6f0f931e69e9d0dc6d1c406ed2a44fa963414eafba07b7fb685d16", size = 6869310, upload-time = "2026-07-23T15:19:38.607Z" }, + { url = "https://files.pythonhosted.org/packages/a4/17/a1735f215b2a5cd43c38b79eac072ad197e61be9829905b6b29550abd0db/grpcio-1.83.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:2b5e75c34842cd9c1b95285ca395c6a569664b81e3ffa6b714125922942abaaf", size = 7613472, upload-time = "2026-07-23T15:19:40.645Z" }, + { url = "https://files.pythonhosted.org/packages/b2/78/c9e81f806ac704b6b145cb01628db398985b1f8dfdc10e23b55fb0902b3d/grpcio-1.83.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeb339838db07600481ef869507279b75326c75eac6d10f7afa62a0da1d2bcdd", size = 7040616, upload-time = "2026-07-23T15:19:42.349Z" }, + { url = "https://files.pythonhosted.org/packages/9a/ba/94cd5af859876049d340480acbb61a959096c84b567f215534faa78d0424/grpcio-1.83.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f47d62808b4c0a97b78bff88a6d4ca283a2a492b9a04a87d814af95ca3b9c19c", size = 7570491, upload-time = "2026-07-23T15:19:44.357Z" }, + { url = "https://files.pythonhosted.org/packages/3e/15/108d30d5a5c964312ae8b9cb0e8cc5b3c1cc68d8f757cca52b3565534d26/grpcio-1.83.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62003babc444a606dcd1f009cd16391ce23669ae4ad6ec267a873da7937a69f5", size = 8605036, upload-time = "2026-07-23T15:19:46.454Z" }, + { url = "https://files.pythonhosted.org/packages/ea/23/3828ae13c3db8233d123ad612747665817b952d8a954f32390230b582336/grpcio-1.83.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1aa567f8c3f19850ffd5d2858c9a8ea7c80f0db6c01186b71eb31e923ec984f5", size = 7981587, upload-time = "2026-07-23T15:19:48.913Z" }, + { url = "https://files.pythonhosted.org/packages/17/5b/77af31228f55f55a2a5112bb0077ad0a1c4d23dbb0c2853a62475bbdcc14/grpcio-1.83.0-cp312-cp312-win32.whl", hash = "sha256:cb2906c61db4f9c64cc360054b5df70eeb81846228e9e56a4944bd415a63dadc", size = 4394004, upload-time = "2026-07-23T15:19:50.618Z" }, + { url = "https://files.pythonhosted.org/packages/c0/da/f706e39550e7a3732ce2b9c5926107a93d74a802775b19b642a6df27dc96/grpcio-1.83.0-cp312-cp312-win_amd64.whl", hash = "sha256:1c699bbb20f143c8f2bff219de578aa2dc1f919399d67dc702b038b986ee62df", size = 5158525, upload-time = "2026-07-23T15:19:52.246Z" }, + { url = "https://files.pythonhosted.org/packages/56/eb/135daaa713f32d33b8f99b4153b3f8dc3b2a124996ac15581bf9ebdad3c3/grpcio-1.83.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:6662f3b1e07cc7493d437351860dc867bddc6a93c83ecf33bbfdaf0c217ab2d0", size = 6304480, upload-time = "2026-07-23T15:19:53.962Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a1/121806ce69f23138dabe06aa595b0e5f1ae051a37e4c1954eed7d692c800/grpcio-1.83.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:74fe6f9e8a35c7dbf32255ee154d15e3e5338a81ed39173d079d594d2e544cd1", size = 12154419, upload-time = "2026-07-23T15:19:56.3Z" }, + { url = "https://files.pythonhosted.org/packages/b0/e8/d0389e09cd6b4c4d3089b92967ae4e3ffd64795bd349bf2f85cd6656d3da/grpcio-1.83.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:10b3fa0475eb572c9a81a6fe37fa16a9c500c0c91cfc148cac15692b7e3c2867", size = 6873200, upload-time = "2026-07-23T15:19:58.701Z" }, + { url = "https://files.pythonhosted.org/packages/f8/51/f464c1d211fa50d5adbabe1b2e519948d99c13757052bfc9ea7afa28e284/grpcio-1.83.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:5f20a988480b0f28207f057f7f7ae1313393c3cef0adcfeae8248f9947eaf881", size = 7618811, upload-time = "2026-07-23T15:20:00.733Z" }, + { url = "https://files.pythonhosted.org/packages/e8/c0/539fe0832f2dd6500a28f5263071623fb34e8d4867aec632ccf81bd21156/grpcio-1.83.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7bd82671b39065ba18cd536e9cd45b27ff649053f81ddd2c6a966d595067080f", size = 7042310, upload-time = "2026-07-23T15:20:02.675Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ca/ccf617d37ffa72567fa8e005ec7090c99da922799be2fb9847c8b21ca18c/grpcio-1.83.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc60215b5cb9fc8ca72942c498b551ac2305bd08f6ef8d4e3f0d21b64fbecd61", size = 7575412, upload-time = "2026-07-23T15:20:04.712Z" }, + { url = "https://files.pythonhosted.org/packages/eb/b9/fd8d5245f823a8e0fd35d90e20ea3aa4acd47f8d5318fa8df307df52dec6/grpcio-1.83.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f1c3e5689d4b90987b1d72022bcfe866a9a3dc66197484cf856d96b6150e7f45", size = 8604248, upload-time = "2026-07-23T15:20:06.77Z" }, + { url = "https://files.pythonhosted.org/packages/14/1e/f37632fc11db72dfa4bba86c3a43e54358e53030df111ecae5e91a733ad6/grpcio-1.83.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a21cb4eeeba124443f399be2e8b624943cde864dcbe588cb42e5c483a52a906c", size = 7977458, upload-time = "2026-07-23T15:20:09.109Z" }, + { url = "https://files.pythonhosted.org/packages/93/b6/d70b69ae5c0cfc341b9ba474980e4ed99cbf05c0e4a14e9eee8cb73db0a5/grpcio-1.83.0-cp313-cp313-win32.whl", hash = "sha256:8fe04f1050a59f875601eb55d42b4f66946fe89817f967e34db1462ccd07dadf", size = 4393993, upload-time = "2026-07-23T15:20:11.017Z" }, + { url = "https://files.pythonhosted.org/packages/0f/13/45d4cccb555cf4c476226979bf3d2fd0b0254216f7564c3a053e35117efc/grpcio-1.83.0-cp313-cp313-win_amd64.whl", hash = "sha256:6e01ecd9d8ef280abe1365138a4dc318f9a5287f4cb1b41d07816f796653f735", size = 5159650, upload-time = "2026-07-23T15:20:12.979Z" }, + { url = "https://files.pythonhosted.org/packages/9c/60/f2cca8147ea213d3e43ae9158d03ad04e020fdf32ff027253e1fe93f921d/grpcio-1.83.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:3f351629f6ae16ecc0ec3553e586a6763ffd9f6114044286d0cbec3e09241bfa", size = 6305607, upload-time = "2026-07-23T15:20:15.353Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ab/d3874931d123a95e83a3ebf8aa04537988fb62425cedb8bf3cefc5ad41b2/grpcio-1.83.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d05ff664100d429335b93c91b8b34ddf9e94a112205e7fa06dede309e44a4e4c", size = 12166617, upload-time = "2026-07-23T15:20:17.435Z" }, + { url = "https://files.pythonhosted.org/packages/92/ff/6f18f9426b69306f4e00a9add3b0ee2748da8aad53836ef80cab0d62d04f/grpcio-1.83.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7936f2a56cf04f6514705c0fedf400971de01b6aa1719327e4718f410a765e2b", size = 6880213, upload-time = "2026-07-23T15:20:19.98Z" }, + { url = "https://files.pythonhosted.org/packages/70/21/706d1147c6b93b98f179240c13991fbcc56880eba0c868abb1ad40d8a0a6/grpcio-1.83.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b0a0be840e51b6b7ee9df9269770faf77bdf4b771053c257c21d12bad607714c", size = 7618335, upload-time = "2026-07-23T15:20:22.161Z" }, + { url = "https://files.pythonhosted.org/packages/74/04/1a8443c889115ec9e213a213e86bc93a71ee9088027e5befa09aaa0edd9d/grpcio-1.83.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:009667eaf3dcd5224c713589cdc98e7ca4ed0ff0b61132c6b276e930eb83a2df", size = 7043416, upload-time = "2026-07-23T15:20:24.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/c6/94e0fee5b12bc1da1370185b680988db6f739d19b42d9959db01a7ea50bf/grpcio-1.83.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bb669918fd88936b15599caff4160a77ab74bdeb25f2231f6e45b61282d6107b", size = 7583253, upload-time = "2026-07-23T15:20:26.313Z" }, + { url = "https://files.pythonhosted.org/packages/a0/97/de1ccb671fb85575bc5192faedf9ecdbdf5b390d2e6584dcf552bcbd370e/grpcio-1.83.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c19b454d3d3f28db81f2c7c4dbaee96e7f6fd149721733ffe79d6bc530f17404", size = 8605102, upload-time = "2026-07-23T15:20:28.437Z" }, + { url = "https://files.pythonhosted.org/packages/17/0f/0e0ec749a7034ffcbaa050e39779872950ead90c22e7e0116be3f28b2b46/grpcio-1.83.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:61007cd08640abc5c54547ee32505474c482cd733a53cb87551ea81faa6350af", size = 7979826, upload-time = "2026-07-23T15:20:31.182Z" }, + { url = "https://files.pythonhosted.org/packages/83/fa/c3fda157287f64bc65acee6c5aa90c41acf9e0d3a8e69a265eecff6d00a1/grpcio-1.83.0-cp314-cp314-win32.whl", hash = "sha256:32e11c37f5285b0c6fa3042c05fe06903696689749833fc64e67dec71b9bbe33", size = 4471765, upload-time = "2026-07-23T15:20:33.195Z" }, + { url = "https://files.pythonhosted.org/packages/a1/00/b1b26431c9d54eee11724fd6e5585473a2ed47fbc1fb95e5204906a642ce/grpcio-1.83.0-cp314-cp314-win_amd64.whl", hash = "sha256:2bb48cb5e6dd005ca12b89ce4b6ac0b48ff3112c747542ee7986ef611a8ca6d9", size = 5298932, upload-time = "2026-07-23T15:20:35.48Z" }, +] + +[[package]] +name = "grpcio-tools" +version = "1.83.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "grpcio" }, + { name = "protobuf" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c1/b1/50b17b3a2ba8970dbb00b2035a4df218bc6ec88d2d77fc7da4e42e1c7b19/grpcio_tools-1.83.0.tar.gz", hash = "sha256:515907265d14fa9975d0c7723f95a9da01463d7ac607546a03f8741f86a1bb07", size = 6400437, upload-time = "2026-07-23T15:22:18.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/00/4a0b426262c8488a02d7fcabe72a070c4691ea79ebf050d543d5cf054234/grpcio_tools-1.83.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:f281cb706999676bb841bcd57129a69091b9286236c89d6114c752ebf6cd5a1b", size = 2652630, upload-time = "2026-07-23T15:20:49.826Z" }, + { url = "https://files.pythonhosted.org/packages/10/22/c5ebf22b6975b9846d54b0e5328eea1780ba5906419e43d305415e110611/grpcio_tools-1.83.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:3647c6adae9528dd56183061371151e3a96f71c299dc69c540318c3af2233a88", size = 5967265, upload-time = "2026-07-23T15:20:52.272Z" }, + { url = "https://files.pythonhosted.org/packages/3d/06/c0a9bad5cc5b1eef47647c480fc9aac82ae67e723db8f5e90e56bdb5adf9/grpcio_tools-1.83.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6346c688d25bcf264e55f0c5f48ae825f7a2906ab969ed3b4a93df53e48bf07", size = 2704488, upload-time = "2026-07-23T15:20:53.779Z" }, + { url = "https://files.pythonhosted.org/packages/3f/8e/e54c7475e0fb528861f66a2e2a0b892a86b5a636905336337d29e091c2c1/grpcio_tools-1.83.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:281d5d056d7ba839f4fff9b63f9ad239fe3353fe10e28e782251bdae6ba68306", size = 3032303, upload-time = "2026-07-23T15:20:55.589Z" }, + { url = "https://files.pythonhosted.org/packages/e0/4f/2221bc79d7c2315d5e3604f353c470b380386fe5d7f8d4740a7d00630394/grpcio_tools-1.83.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35af1be2fe409abf9817ec43c34aab8a99189b15530eb78f3a94a6b1266d8b12", size = 2773919, upload-time = "2026-07-23T15:20:57.187Z" }, + { url = "https://files.pythonhosted.org/packages/93/94/50b1e7b1526e11e8e7cc6796cf71707ce6bc8714d9bacdd7290a2039b140/grpcio_tools-1.83.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7f5f5b6e1a91422069601fbf94f7fc970a7647fa69d2bc9f59e38913523117af", size = 3226535, upload-time = "2026-07-23T15:20:58.64Z" }, + { url = "https://files.pythonhosted.org/packages/fa/df/3de83d76f30a76ba85464d72a579199a1191ad027f5c89786db767b22f83/grpcio_tools-1.83.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:cad9333c0d5afcc2ffb26bebc5e8f097e3218b964758c3c65609dbcb77ec2aa7", size = 3798914, upload-time = "2026-07-23T15:21:00.05Z" }, + { url = "https://files.pythonhosted.org/packages/f4/51/957d040037a142b7232b5f1bfc0abfdac0acbdaec74f2ba3f39cc570ccb2/grpcio_tools-1.83.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2106b29b9dae5068acab7ee2f6b104d3054b4f31289f105b4afe1fcfbf1966c4", size = 3457747, upload-time = "2026-07-23T15:21:01.511Z" }, + { url = "https://files.pythonhosted.org/packages/49/27/7199a053df071d5c9924aee050d6aea391892f05cb6eb266365a72c8b05c/grpcio_tools-1.83.0-cp310-cp310-win32.whl", hash = "sha256:a47e674e6afac5d73ee3a87d57ed53f7b79cc01f44d602fe6ee90919aa171583", size = 1022559, upload-time = "2026-07-23T15:21:03.039Z" }, + { url = "https://files.pythonhosted.org/packages/32/d4/6201e4948618a60b5fdfecd3b193da9b86c76e1aa8555573da78649900a5/grpcio_tools-1.83.0-cp310-cp310-win_amd64.whl", hash = "sha256:88fc53ee3ce28d3ea2fe8fe1d3ed57854d0d25d6dac18e74c1f24e0a377bb509", size = 1192122, upload-time = "2026-07-23T15:21:04.398Z" }, + { url = "https://files.pythonhosted.org/packages/b9/f0/d55c59b3af39d2ca975a2bafc5be1a60e0460a1b507bdbcf7bb8a17567db/grpcio_tools-1.83.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:72471a4a46909f1d798836c0a0aa2f568e10f6404585d7b22ac7330dd6a7bc74", size = 2652835, upload-time = "2026-07-23T15:21:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/82/97/59bdf27c5f99848087b3b268c90b3fcc557d400c8d0224cf49c7ac573e60/grpcio_tools-1.83.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:7da547dd1e0b1fe3d6d5677e9c1848969ecdbd51a92c342cf82d885c5935de7d", size = 5967887, upload-time = "2026-07-23T15:21:08.19Z" }, + { url = "https://files.pythonhosted.org/packages/f6/c4/117d6688c4cb40240ce48da1ffac005e8da8bf63785634bdbb9143ca367e/grpcio_tools-1.83.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:33350bd94c4913f8eaf6ca79ce69cc673bb46ca5f800e6474b1d6899ed321dad", size = 2704869, upload-time = "2026-07-23T15:21:09.857Z" }, + { url = "https://files.pythonhosted.org/packages/1a/51/e4d1a89f69072bb93b975548c40fe9524219ab5777cce113c1e2183b1f2d/grpcio_tools-1.83.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0f27da6dc58c910f31dcee4fcbfd05659c10c14b9e132f4f17da48d867e75eb4", size = 3032318, upload-time = "2026-07-23T15:21:11.55Z" }, + { url = "https://files.pythonhosted.org/packages/ae/26/f9a082b79ae7f7dac7050047614000721dcf3d1c6d14bffa881b1f7a9774/grpcio_tools-1.83.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b3a5000e8540efb80f74d9c760f36ed9408294d76edb0fa4b87fd287b0a8a258", size = 2774113, upload-time = "2026-07-23T15:21:13.037Z" }, + { url = "https://files.pythonhosted.org/packages/de/59/b715d431218f0e8382490db7b1d01b3d32392c359c1886eee7e2551edaa8/grpcio_tools-1.83.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f710032264ed114c9d3b52f4c5cf71d68ccf70f25c4cb5776fe60388374bc01b", size = 3226715, upload-time = "2026-07-23T15:21:14.88Z" }, + { url = "https://files.pythonhosted.org/packages/5f/99/c4e91a2116062f7b42ab99459eeda15ec59a07daad01f4f38753fb1584ac/grpcio_tools-1.83.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ad00de87334154901e9af50a7f3f95261a0133502c6c0cea1f4e6107245154c3", size = 3799000, upload-time = "2026-07-23T15:21:16.352Z" }, + { url = "https://files.pythonhosted.org/packages/b8/f7/0bb5e45a1f51822c0bcf5a355a06132c43b9dbe5c283edc8d0b63bec7626/grpcio_tools-1.83.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:de2b0c645363f7c4b005f145e06e790870dfb21bb7e162dec6fee2f054de9291", size = 3457774, upload-time = "2026-07-23T15:21:17.953Z" }, + { url = "https://files.pythonhosted.org/packages/e7/51/52aec42a3059bad4263a7a82c715a0fc08220f7533be41f0b9177b05468a/grpcio_tools-1.83.0-cp311-cp311-win32.whl", hash = "sha256:6d1a1c9e62689d04b63b227558b759a55dce8fb81a3934d3b5f95d1ee26a2b45", size = 1022798, upload-time = "2026-07-23T15:21:19.854Z" }, + { url = "https://files.pythonhosted.org/packages/46/22/034d6daa5b2fae4e4c1718111c1b791589c3743d7d8fb07984e0b59c7f2c/grpcio_tools-1.83.0-cp311-cp311-win_amd64.whl", hash = "sha256:a6ac3cc2c2d77f869a96dfaf2b1315852878babddfe2dc49b9fd47afbf502865", size = 1192474, upload-time = "2026-07-23T15:21:21.467Z" }, + { url = "https://files.pythonhosted.org/packages/27/4d/02e4d809d880785a613697e4c0f8134a436ec0142dc3c11989ad1a1c787a/grpcio_tools-1.83.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:bd93bbe18c4424805fd2e39854f75d76f80655175621254dc43cb45ec8e91e85", size = 2653283, upload-time = "2026-07-23T15:21:22.973Z" }, + { url = "https://files.pythonhosted.org/packages/01/c9/23e8423ac54c3858a5cfbca8a954fa292cab7b8a9a1ee9dc3259b906b763/grpcio_tools-1.83.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:dc2d370563ee1ee6c1769e49df35ef3f6e75cea8f25acf4ac6e54b335e6f788f", size = 5965938, upload-time = "2026-07-23T15:21:24.686Z" }, + { url = "https://files.pythonhosted.org/packages/bf/a1/6eb17cf322bfbb76a9b9a8a5ca4a6b27f0af84821145969fc464feedaa0e/grpcio_tools-1.83.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8350e236470700b02bc4ba7f27a8796559630170e6527dda41c0344fbe988e56", size = 2705429, upload-time = "2026-07-23T15:21:26.519Z" }, + { url = "https://files.pythonhosted.org/packages/d4/84/f3c7e5e91e5d40ee792f112260bd329db5381de6f83b21387dd6163ebe51/grpcio_tools-1.83.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b1649f47c4675c1540ad2a77005f4d08392c06202686a0b1bb6b894f96cb75fe", size = 3033412, upload-time = "2026-07-23T15:21:28.183Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ec/acd6c1800925b0d60f8a670b68cf5bc3566aee60e7cb90178b34253bf53a/grpcio_tools-1.83.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d4edc6ba9fdca70bbf585ff6ab5971b8cd6140b4b316df66fd91d168bc1b617", size = 2774500, upload-time = "2026-07-23T15:21:29.955Z" }, + { url = "https://files.pythonhosted.org/packages/36/e6/c7c6f4e25b7344f1fbc5df69606bfd4b9692d4e32d17781c665d3ed45e70/grpcio_tools-1.83.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:762a9f8a4a4a39bda02feebed94efb8d778e0e5a82d0c8f786dce5ddcb950c7f", size = 3229875, upload-time = "2026-07-23T15:21:31.617Z" }, + { url = "https://files.pythonhosted.org/packages/77/fc/9cbdc4606f378a9c2b569c0b6b57f181f97787006be4131a5820d469b70a/grpcio_tools-1.83.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c6c469c928a183f1a99ab26e263fe307347ee7023fa623b55bc778846b2f51b9", size = 3803163, upload-time = "2026-07-23T15:21:33.35Z" }, + { url = "https://files.pythonhosted.org/packages/7b/0b/3b754886a02ead1487a967fd11ff13e920218fcf6c8e174f6de0c26dd819/grpcio_tools-1.83.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7980b3ca9dd31c42468c5af8cec97037f83715ea6efbe1b936ecb9c6832ac0f5", size = 3461815, upload-time = "2026-07-23T15:21:34.982Z" }, + { url = "https://files.pythonhosted.org/packages/eb/29/4629c154853f6f677299cde014e615084c15ad1d4fedd6845d2e7d354c7e/grpcio_tools-1.83.0-cp312-cp312-win32.whl", hash = "sha256:fd2ff46917f566b3b63dae191d1b05ef2188fe51e756ef321cbdd707ab29dbfb", size = 1022490, upload-time = "2026-07-23T15:21:36.696Z" }, + { url = "https://files.pythonhosted.org/packages/bb/59/696b9671e32a693c67299724a1f70f81dfb78aca6a3283ea6a65d54e92b8/grpcio_tools-1.83.0-cp312-cp312-win_amd64.whl", hash = "sha256:92d2343806b5c21162a57fbaad24fcd8d935ef530f95a0f706a4e2546fdc0662", size = 1192286, upload-time = "2026-07-23T15:21:38.446Z" }, + { url = "https://files.pythonhosted.org/packages/63/2f/a7a4465b2a5b74b479373bf44d86da5840d7d20871764a39fb300e55e093/grpcio_tools-1.83.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:3277cfbb7cbbd2d72921fbcd7aba6c8ab1c91a9ab27e8045ac0a0f2e0517cec9", size = 2652845, upload-time = "2026-07-23T15:21:40.173Z" }, + { url = "https://files.pythonhosted.org/packages/ab/69/ed8ebaae3bd0ecd2387b0fdb3a696bd4b4d4565d18589ee3ba7c6affcbed/grpcio_tools-1.83.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1aa9617ce9c2bcbfb8f2fa08e6259e1b3cadaab0316e41f71496847af2f0a664", size = 5963575, upload-time = "2026-07-23T15:21:41.943Z" }, + { url = "https://files.pythonhosted.org/packages/13/23/75bddae583077f1374c64e2a87b5924bdbffe162b3855af30c30a0fc8c5c/grpcio_tools-1.83.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ad37c786ea92825534466052f5f22f1f29983b1d00ca71ad43e256715a86bba3", size = 2705094, upload-time = "2026-07-23T15:21:43.675Z" }, + { url = "https://files.pythonhosted.org/packages/f2/ac/7e1b12b1c5afff4f7d578f3c4eafbb1849f8004ddcc236cd3ff95c8be607/grpcio_tools-1.83.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:c82216864d435ecf6f535798d03e9f6b9025a672a2e815715d629aba4ba70349", size = 3033061, upload-time = "2026-07-23T15:21:45.657Z" }, + { url = "https://files.pythonhosted.org/packages/b8/6d/113291a7aad0c47a1e2ba2375595dfc3c2ab648e0ea05ff3d6f0f89055b3/grpcio_tools-1.83.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:04284627655629387b63278591e5efd0aded28d3a08432fe8a8765e4daf2d5b2", size = 2773649, upload-time = "2026-07-23T15:21:47.404Z" }, + { url = "https://files.pythonhosted.org/packages/dd/c2/ce1c6c2475ed5cb0f7c6689bde57e3c105714676227da11b423ff37620a9/grpcio_tools-1.83.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4fe627a5248d8e712f5ec3e019420050e61b10600ed241264aefb379a0f1b338", size = 3229788, upload-time = "2026-07-23T15:21:49.441Z" }, + { url = "https://files.pythonhosted.org/packages/c5/53/823fd52c29630398706de400ce7003b03e17ce91df024fc53cde810d2758/grpcio_tools-1.83.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1d5a9a9664d2f4bdda000652e7febf9ab4545a6391c8f05babd52ecb27d7e03e", size = 3802531, upload-time = "2026-07-23T15:21:51.37Z" }, + { url = "https://files.pythonhosted.org/packages/30/85/942ee07caf97b75ead416c6ad5f2fb12b16b8bc92fa3d60bbbea4c06e076/grpcio_tools-1.83.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4bf0e421e1ab5f2cd638de44fc903aed3ba4a2fcb19b93c6f528ff0ec63e3a6a", size = 3461032, upload-time = "2026-07-23T15:21:53.343Z" }, + { url = "https://files.pythonhosted.org/packages/d4/0b/54cb428ea18912fa9541509fbc2e10f9807d96e88cf435f86e63c540ac25/grpcio_tools-1.83.0-cp313-cp313-win32.whl", hash = "sha256:7b1bd6db403b38addded54866187eba6f9ab9afadf72bb8d0515ed13f0b16c5c", size = 1022116, upload-time = "2026-07-23T15:21:55.036Z" }, + { url = "https://files.pythonhosted.org/packages/77/80/2369320766091f6daedb924d133037a9f8b84bfb3e4d02d6ccffcd57b0cd/grpcio_tools-1.83.0-cp313-cp313-win_amd64.whl", hash = "sha256:d654c645af7cf608a30644bffb8d1ef6b14e8846482c3d0131d0dda91f6fb590", size = 1191934, upload-time = "2026-07-23T15:21:56.862Z" }, + { url = "https://files.pythonhosted.org/packages/b2/bd/3abb9c200f90110805553ca0e8f7908a0b89485ea99bb271daa37eaafb72/grpcio_tools-1.83.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:1ea047ff4bd2bb32fe5268042cb9c9e7bb054e932b52ad756642622f032ef656", size = 2652841, upload-time = "2026-07-23T15:21:58.726Z" }, + { url = "https://files.pythonhosted.org/packages/a2/46/d5beb04f0ffe552e55eaddd3413786e17a1fa68edb2fd8398969c38bc7e8/grpcio_tools-1.83.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:7a8ac9cb3fbf7a5e4fe59f211e77b4fa4d51279c9f480e6ff98037cf56da1ad8", size = 5963493, upload-time = "2026-07-23T15:22:00.547Z" }, + { url = "https://files.pythonhosted.org/packages/40/1b/d8e01ca3281cb59722372c415024a7e70e8a653e70e2075e875394e4f761/grpcio_tools-1.83.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a5fa95fb33a600d2491867a1048f47baa27a830eac01f475043b8ccf63a471eb", size = 2705303, upload-time = "2026-07-23T15:22:02.448Z" }, + { url = "https://files.pythonhosted.org/packages/83/43/75b382d8f116e274ce639ec55a6908dc792627b01d3c47b4f8991701203b/grpcio_tools-1.83.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0ca67941524662e01adea91571bb79df1ac9b4b2641812ef8636e21945119bee", size = 3033047, upload-time = "2026-07-23T15:22:04.445Z" }, + { url = "https://files.pythonhosted.org/packages/0f/9f/200fb55729b735192fded061f53aa37c88cf9f58933cb108e16f5c1fd967/grpcio_tools-1.83.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e2a5816a5b6b06b42a6989f02944841c2a8b3daaa7f033dac9267f70078028ef", size = 2773830, upload-time = "2026-07-23T15:22:06.519Z" }, + { url = "https://files.pythonhosted.org/packages/d1/bc/e585c4e255599ec45f2e07e0f570352158354285afd65ef30ceda97b445e/grpcio_tools-1.83.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f4a83f002895b11c4a862c366d77165825e0064aff14bf2c7453f59f66599b0e", size = 3229907, upload-time = "2026-07-23T15:22:08.358Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/0c098b4ff64d948666b79d0036ce104b8c2209e6f4d1594046044dcfa25d/grpcio_tools-1.83.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:1cfee967ae073bc064862971871229248965422f38556096aec76db19d8a8c79", size = 3802600, upload-time = "2026-07-23T15:22:10.393Z" }, + { url = "https://files.pythonhosted.org/packages/d4/e1/249047aab0da8c6b3b2e0156e6868aa0b598973ddf53f59186c43664ff96/grpcio_tools-1.83.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f7e82ee718ae09f879cb832e4517a56691de24383a2da673be184ad8b18e452f", size = 3461307, upload-time = "2026-07-23T15:22:12.33Z" }, + { url = "https://files.pythonhosted.org/packages/39/aa/11a228c47acecde2e05715bda4480e5a695690e00776c1db391e5a02c1f7/grpcio_tools-1.83.0-cp314-cp314-win32.whl", hash = "sha256:846fd211ebb72f50d39d3874cc0d616c2b9bcb71db51121ca86af29eec013c74", size = 1045047, upload-time = "2026-07-23T15:22:14.23Z" }, + { url = "https://files.pythonhosted.org/packages/30/b1/d1f150b2ab3b4ae9932c05104fe1edbcb7fbf505587ea8db99e49341a05f/grpcio_tools-1.83.0-cp314-cp314-win_amd64.whl", hash = "sha256:b8c9686b0c19f70b63d8d6cfeff5ad3480bdedecd60f14711fe43950f5397253", size = 1224199, upload-time = "2026-07-23T15:22:16.064Z" }, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "jmespath" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "protobuf" +version = "7.35.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/01/9ef0afd7999eb9badb3a768b4aedd78c86d4c65cfaf1958ab276199e76b4/protobuf-7.35.1.tar.gz", hash = "sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a", size = 458717, upload-time = "2026-06-11T21:55:40.257Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6", size = 433226, upload-time = "2026-06-11T21:55:31.719Z" }, + { url = "https://files.pythonhosted.org/packages/37/4b/dfb89eb0e652a1ff073c39a59fb5e3a83cfe9b57a2c83fa6d78270101767/protobuf-7.35.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799", size = 328847, upload-time = "2026-06-11T21:55:34.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/58/dc12f2cd484951524af6e3382c785869b9b3fb5e52ee95ae23add53ee8f9/protobuf-7.35.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4", size = 344030, upload-time = "2026-06-11T21:55:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/e4/be/5b3cfe508bfab6761414ff944e3366eb13be4fd71efcd69450f89ba39f43/protobuf-7.35.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4", size = 327130, upload-time = "2026-06-11T21:55:35.921Z" }, + { url = "https://files.pythonhosted.org/packages/d8/bc/6d6c7ba8709c85f8f2c390b2b118d6fb08a783676a572271851bf45a7d22/protobuf-7.35.1-cp310-abi3-win32.whl", hash = "sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30", size = 428945, upload-time = "2026-06-11T21:55:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/0a/19/8d0cb6f20a1ef7b18f1c8986ad5783f22f84cce39c6ce9a6e645ea55192e/protobuf-7.35.1-cp310-abi3-win_amd64.whl", hash = "sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87", size = 439996, upload-time = "2026-06-11T21:55:38.123Z" }, + { url = "https://files.pythonhosted.org/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9", size = 171659, upload-time = "2026-06-11T21:55:39.155Z" }, +] + +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, + { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + +[[package]] +name = "psycopg" +version = "3.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/2f/cb91e5502ec9de1de6f1b76cfbf69531932725361168bb06963620c77e2e/psycopg-3.3.4.tar.gz", hash = "sha256:e21207764952cff81b6b8bdacad9a3939f2793367fdac2987b3aac36a651b5bc", size = 165799, upload-time = "2026-05-01T23:31:55.179Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/e0/7b3dee031daae7743609ce3c746565d4a3ed7c2c186479eb48e34e838c64/psycopg-3.3.4-py3-none-any.whl", hash = "sha256:b6bbc25ccf05c8fad3b061d9db2ef0909a555171b84b07f29458a447253d679a", size = 213001, upload-time = "2026-05-01T23:20:50.816Z" }, +] + +[package.optional-dependencies] +binary = [ + { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, +] + +[[package]] +name = "psycopg-binary" +version = "3.3.4" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/bf/70d8a60488f9955cbbcd538beae44d56bb2f1d19e673b72788f2d343ff55/psycopg_binary-3.3.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b7bfff1ca23732b488cbca3076fc11bc98d520ee122514fdb17a8e20d3338f5a", size = 4609750, upload-time = "2026-05-01T23:24:20.06Z" }, + { url = "https://files.pythonhosted.org/packages/db/b0/29e98ba210c9dbc75a6dc91e3f99b9e06ea901a62ca95804e02a1ae13e6b/psycopg_binary-3.3.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:32a6fbf8481e3a370d0d72b860d35948a693cb01281da217f7b2f307636e591a", size = 4676700, upload-time = "2026-05-01T23:25:21.727Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ab/3df087b3c12bf74e47c08204172b2fabb5a144679110d5c7ad12d9201323/psycopg_binary-3.3.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bdef84570ebbce1d42b4e7ea952d21c414c5f118ad02fee00c5625f35e134429", size = 5496319, upload-time = "2026-05-01T23:25:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/87/9a/f088207b4cd6772f9e0d8a91807e79fa2458d4eb9eb1ae406c68415f2bec/psycopg_binary-3.3.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa1cbc10768a796c96d3243656016bf4e337c81c71097270bb7b0ad6210d9765", size = 5171906, upload-time = "2026-05-01T23:25:34.004Z" }, + { url = "https://files.pythonhosted.org/packages/48/45/4523a857f253871d75c22e1c2e79fd47e599e736bcba1bad58d83e24be02/psycopg_binary-3.3.4-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf7f73a4a792bc5db58a4b385d8a1467e8d468f7548702fb0ed1e9b7501b1c13", size = 6762621, upload-time = "2026-05-01T23:25:41.392Z" }, + { url = "https://files.pythonhosted.org/packages/7c/d1/925bf776503345bef428e6c45fb017d0139ddbe0e211814b585c4253dca8/psycopg_binary-3.3.4-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d7b4d40c153fa352ab3cca530f3a0baedf7621b2ebcbd7f084009522c21788fc", size = 5006319, upload-time = "2026-05-01T23:25:51.419Z" }, + { url = "https://files.pythonhosted.org/packages/6f/aa/99727337206fbba357ca084bf4ea8b29dc986f61842a2685859af61416db/psycopg_binary-3.3.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f9b1c2533af01cd7648378599f82b0b8ae32f293296e6eec5753a625bc97ef28", size = 4535388, upload-time = "2026-05-01T23:25:57.957Z" }, + { url = "https://files.pythonhosted.org/packages/0b/a4/567ba2c37d19d8c2f63d836385dfd2495aa5897bbee6cfab104d9ee58624/psycopg_binary-3.3.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ad3bc94054876155549fdaedf4a46d1ec69d39a5bcee377148afe498e84c4b8e", size = 4224544, upload-time = "2026-05-01T23:26:03.832Z" }, + { url = "https://files.pythonhosted.org/packages/b7/23/86457f5a82731685d7701de7bfaa5eb783dd1fecbf875321897d9d9ce33a/psycopg_binary-3.3.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:eb4eed2079c01a4850bf467deacfab56d356d4225040170af03dc9958321242d", size = 3956282, upload-time = "2026-05-01T23:26:09.983Z" }, + { url = "https://files.pythonhosted.org/packages/a7/d8/249456df16d47de082abd9b73bce8ccdeb0293eb12e590f9150c7cbdb788/psycopg_binary-3.3.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f80e3f2b5331dbbf0901bcb658056c03eeb2c1ef31d774afb0d61598b242e744", size = 4261736, upload-time = "2026-05-01T23:26:16.798Z" }, + { url = "https://files.pythonhosted.org/packages/15/6b/c4abe228acafd8a385c1fb615d4f1e3c9b8ad7a4e4f0e84118ba3ffeed9c/psycopg_binary-3.3.4-cp310-cp310-win_amd64.whl", hash = "sha256:574ea21a9651958f1535c5a1c649c7409e9168bcbffa29a3f2f961f58b322949", size = 3570620, upload-time = "2026-05-01T23:26:22.655Z" }, + { url = "https://files.pythonhosted.org/packages/b6/82/df3312c0ca083d5b43b352f27d4dd8b1e614bd334473074715d9e0000da4/psycopg_binary-3.3.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:612a627d733f695b1de1f9b4bd511c15f999a5d8b915d444bbd7dd71cf3370da", size = 4609813, upload-time = "2026-05-01T23:26:30.612Z" }, + { url = "https://files.pythonhosted.org/packages/1f/b5/d74d542458d3e8ac0571d8a88f57ca369999b9a82f4fa528052d0d7d3e4c/psycopg_binary-3.3.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:13a7f380824c35896dcac7fe0f61440f7ca49d6dc73f3c13a9a4471e6a3b302e", size = 4676799, upload-time = "2026-05-01T23:26:38.475Z" }, + { url = "https://files.pythonhosted.org/packages/09/67/06bab9c60671999f4c6ceff1b334f3ac1f9fc5789eb467c714623ea21de9/psycopg_binary-3.3.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:276904e3452d6a23d474ef9a21eee19f20eed3d53ddd2576af033827e0ba0992", size = 5497050, upload-time = "2026-05-01T23:26:47.061Z" }, + { url = "https://files.pythonhosted.org/packages/72/9b/023433e2b20f970de1e22d29132a95281277646da0b2e2879dd4ee94b8c1/psycopg_binary-3.3.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab8cca8ef8fb1ccf5b048ae5bd78ba55b9e4b5d472e3ce5ca39ff4d2a9c249e4", size = 5172428, upload-time = "2026-05-01T23:26:56.708Z" }, + { url = "https://files.pythonhosted.org/packages/08/cd/ae16da8fde228a38b2fe9269bbc13cf89e0186173f2265600f02d6a71e64/psycopg_binary-3.3.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7465bfe6087d2d5b42d4c53b9b11ca9f218e477317a4a162a10e3c19e984ba8e", size = 6762746, upload-time = "2026-05-01T23:27:07.023Z" }, + { url = "https://files.pythonhosted.org/packages/4f/81/0ba09fa5f5f88779093a2541a8e02489825721f258ab88058b11d68b3eb5/psycopg_binary-3.3.4-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22cdbf5f91ef7bb91fe0c5757e1962d3127a8010256eefd9c61fcaf441802097", size = 5006033, upload-time = "2026-05-01T23:27:12.221Z" }, + { url = "https://files.pythonhosted.org/packages/73/6a/629136040cc3497adb442a305710b5913f2a754d4630fc3d3717c4c0df65/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e2631da29253a98bd496e6c4813b24e09a4fe3fb2a9e88513305d6f8747cce95", size = 4534175, upload-time = "2026-05-01T23:27:18.248Z" }, + { url = "https://files.pythonhosted.org/packages/7c/32/1027f843c6dc2d5d51960ee62cc0c2cf755a4c39455aff1371173edbef7d/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7f7668f30b9dd5163197e5cbf4e0efd54e00f0a859cc566ce56cfc31f4054839", size = 4224203, upload-time = "2026-05-01T23:27:24.3Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e1/380a724d9093c74adb14d4fce920ea8327838abb61f760b1448586b14a8e/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:cffc3408d77a27973f33e5d909b624cce683db5fc25964b02fe0aae7886c1007", size = 3954509, upload-time = "2026-05-01T23:27:30.815Z" }, + { url = "https://files.pythonhosted.org/packages/db/cd/895893ae575a09c97ccfd5def070d88993d955ef34df45a881fd5ff506d6/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0579252a1202cd73e4da137a1426e2dae993ae44e757605344282af3a082848c", size = 4259551, upload-time = "2026-05-01T23:27:38.828Z" }, + { url = "https://files.pythonhosted.org/packages/dd/c6/2330a20794e37a3ec609ef2fd8522919ec7a4395a1abf979a8e2d1775cd5/psycopg_binary-3.3.4-cp311-cp311-win_amd64.whl", hash = "sha256:41f2ec0fea529832982bcb6c9415de3c86264ebe562b77a467c0fbcd7efbba8d", size = 3572054, upload-time = "2026-05-01T23:27:45.455Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/03818e13ba7f36de93573c93ee3482006d3dfa8b0f8d28df511bad0a1a92/psycopg_binary-3.3.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5ab28a2a7649df3b72e6b674b4c190e448e8e77cf496a65bd846472048de2089", size = 4591122, upload-time = "2026-05-01T23:27:56.162Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b9/11b341edf8d54e2694726b273fe9652b254d989f4f63e3ac6816ad6b55f4/psycopg_binary-3.3.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6402a9d8146cf4b3974ded3fd28a971e83dc6a0333eb7822524a3aa20b546578", size = 4669943, upload-time = "2026-05-01T23:28:04.522Z" }, + { url = "https://files.pythonhosted.org/packages/8b/18/4665bacd65e7865b4372fcd8abb8b9186ada4b0025f8c2ca691b364a556c/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:580ae30a5f95ccd90008ec697d3ed6a4a2047a516407ad904283fa42086936e9", size = 5469697, upload-time = "2026-05-01T23:28:11.337Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b1/b83136c6e510593d9b0c759ba5384337bc4ad82d19fda675adc4b2703c84/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7510c37550f91a187e3660a8cc50d4b760f8c3b8b2f89ebc5698cd2c7f2c85d", size = 5152995, upload-time = "2026-05-01T23:28:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/67/8d/a9821e2a648afe6091989929982a3b0f00b2631a859cb81379728f08fb75/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77df19583501ea288eaf15ac0fe7ad01e6d8091a91d5c41df5c718f307d8e31b", size = 6738180, upload-time = "2026-05-01T23:28:30.654Z" }, + { url = "https://files.pythonhosted.org/packages/7e/58/2e349e8d23905dc2317b80ac65f48fb6f821a4777a4e994a60da91c4850f/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:018fbed325936da502feb546642c982dcc4b9ffdea32dfef78dbf3b7f7ad4070", size = 4978828, upload-time = "2026-05-01T23:28:37.277Z" }, + { url = "https://files.pythonhosted.org/packages/45/48/57b00d03b4721878326122a1f1e6b0a90b85bcaec56b5b2f8ea6cfa45235/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:17a21953a9e5ff3a16dab692625a3676e2f101db5e40072f39dbee2250194d68", size = 4509757, upload-time = "2026-05-01T23:28:43.078Z" }, + { url = "https://files.pythonhosted.org/packages/25/37/33b47d8c007df69aec500df5889767c4d313748e8e9e27a2fef8a6dabcee/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:eb05ee1c2b817d27c537333224c9e83c7afb86fe7296ba970990068baf819b16", size = 4190546, upload-time = "2026-05-01T23:28:50.016Z" }, + { url = "https://files.pythonhosted.org/packages/ca/c6/32b0835dbc2122617902b649d76a91c1e75406e76bf3d595b0c3bb5ffad6/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:773d573e11f437ce0bdb95b7c18dc58390494f96d43f8b45b9760436114f7652", size = 3926197, upload-time = "2026-05-01T23:28:55.55Z" }, + { url = "https://files.pythonhosted.org/packages/cd/68/d190ef0c0c5b16ded07831dabc8ddd412f4cdab07ec6e30ed38d9bda0e1f/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e55ccbdfae79a2ed9c6369c3008a3025817ff9d7e27b32a2d84e2a4267e66e", size = 4236627, upload-time = "2026-05-01T23:29:05.336Z" }, + { url = "https://files.pythonhosted.org/packages/25/8f/81dcbc2e8454b74d14881275ea45f00791052dac531a9fa8be1730d1685b/psycopg_binary-3.3.4-cp312-cp312-win_amd64.whl", hash = "sha256:494ca54901be8cf9eb7e02c25b731f2317c378efa44f43e8f9bd0e1184ae7be4", size = 3560782, upload-time = "2026-05-01T23:29:11.967Z" }, + { url = "https://files.pythonhosted.org/packages/09/43/13e9c406fbbf354580476e248a16b64802a376873ebe6339e30bb655572d/psycopg_binary-3.3.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fbd1d4ed566895ad2d3bf4ddfd8bae90026930ddf29df3b9d91d32c8c47866a7", size = 4590377, upload-time = "2026-05-01T23:29:18.782Z" }, + { url = "https://files.pythonhosted.org/packages/22/be/2923cd7c3683e7afdecf4f10796a18de02f5c5ddc0969aa2ad0a8cdd3bbd/psycopg_binary-3.3.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:75a9067e236f9b9ae3535b66fe99bddb33d39c0de10112e49b9ab11eee53dc31", size = 4669023, upload-time = "2026-05-01T23:29:25.884Z" }, + { url = "https://files.pythonhosted.org/packages/96/a0/2c913d6fe13d6a8bd13597d36739bf47af063ad9399e402cfecab16f3c1e/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b56b603ebcea8aa10b46228b8410ba7f13e7c2ee54389d4d9be0927fd8ce2a70", size = 5467423, upload-time = "2026-05-01T23:29:33.416Z" }, + { url = "https://files.pythonhosted.org/packages/e7/38/205d10bc1ad0df4a21c5c51659126bd3ea0ef98fcad1e852f78c249bb9c3/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c677c4ad433cb7150c8cd304a0769ae3bcfbe5ea0676eb53faa7b1443b16d0d3", size = 5151137, upload-time = "2026-05-01T23:29:42.013Z" }, + { url = "https://files.pythonhosted.org/packages/36/fc/f0381ddcd45eff3bb70dbca6823a996048d7f507b2ec3fc92c6fabc0fe87/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26df2717e59c0473e4465a97dfb1b7afebaa479277870fd5784d1436470db47c", size = 6736671, upload-time = "2026-05-01T23:29:51.626Z" }, + { url = "https://files.pythonhosted.org/packages/95/40/fa545ae152c24327651e5624e4902121e808270be36c10b12e9939be09bc/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dc1f79fd16bb1f3f4421417a514607539f17804d95c7ed617265369d1981cae", size = 4979601, upload-time = "2026-05-01T23:29:56.961Z" }, + { url = "https://files.pythonhosted.org/packages/86/e4/2f8a47ee97f90cd2b933d0463081d35631ff419de2b8c984a5f369857de0/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:136f199a407b5348b9b857c504aff60c77622a28482e7195839ce1b51238c4cc", size = 4510513, upload-time = "2026-05-01T23:30:07.243Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0e/94e842ff4a7f98ed162580ca2e8b8864b28c1e0350f2443f8ee47f821167/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b6f5a29e9c775b9f12a1a717aa7a2c80f9e1db6f27ba44a5b59c80ac61d2ffcf", size = 4187243, upload-time = "2026-05-01T23:30:15.352Z" }, + { url = "https://files.pythonhosted.org/packages/d0/83/fc6c174b672e29b7de996ea77b6cbddf46c891751c3355f6974292baa6b4/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ee17a2cf4943cde261adfad1bbc5bf38d6b3776d7afff74c7cabcbeaeb08c260", size = 3927347, upload-time = "2026-05-01T23:30:21.186Z" }, + { url = "https://files.pythonhosted.org/packages/e9/65/768364d4a97a15b1a7f47ba52688c1686f22941d8332a8398cefc468e25f/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c4ab71be17bdca30cb34c34c4e1496e2f5d6f20c199c12bad226070b22ef9bf", size = 4236393, upload-time = "2026-05-01T23:30:26.211Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3b/218efbc9e645becd80cdf651acda05f85cfe546b7a9c0458c7cbc8fe1f74/psycopg_binary-3.3.4-cp313-cp313-win_amd64.whl", hash = "sha256:dbfdb9b6cc79f31104a7b162a2b921b765fcc62af6c00540a167a8de47e4ed38", size = 3564592, upload-time = "2026-05-01T23:30:31.764Z" }, + { url = "https://files.pythonhosted.org/packages/48/a6/828c9185701dab71b234c2a76c38a08b098ebfec5020716b4e93807492b5/psycopg_binary-3.3.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:28b7398fdd19db3232c884fb24550bdfe951221f510e195e233299e4c9b78f97", size = 4607292, upload-time = "2026-05-01T23:30:38.962Z" }, + { url = "https://files.pythonhosted.org/packages/92/58/5b40dbc9d839045c9dae956960e4fb6d20bcabe6c59a2aa34fc3a371913f/psycopg_binary-3.3.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1fbaa292a3c8bb61b45df1ad3da1908ccee7cb889db9425e3557d9e34e2a4829", size = 4687023, upload-time = "2026-05-01T23:30:47.227Z" }, + { url = "https://files.pythonhosted.org/packages/85/a9/793f0ac107a9003b48441d0d1f9f616d96e0f37458dd8dc12528ceff55fb/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94596f9e7633ee3f6440711d43bb70aa31cc0a46a900ab8b4201a366ace5c9e7", size = 5486985, upload-time = "2026-05-01T23:30:55.517Z" }, + { url = "https://files.pythonhosted.org/packages/8f/26/42e8533497e2592334f68ec529cf5f840f7fa4e99575a4bb61aa184dbfbf/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8c0056529e68dbe9184cd4019a1f3d8f3a4ead2f6fc7a5afcf27d3314edd1277", size = 5168745, upload-time = "2026-05-01T23:31:01.904Z" }, + { url = "https://files.pythonhosted.org/packages/15/af/b7151776cc08d5935d45c833ec818a9beb417cf7c08239af1aafbdae78ee/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c09aad7051326e7603c14e50636db9c01f78272dc54b3accff03d46370461e6", size = 6761486, upload-time = "2026-05-01T23:31:14.511Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ed/c92533b9124712d592cbf1cd6c76da933a2e0acea81dfe1fbe7e735f0cff/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:514404ed543efd620c85602b747df2a23cf1241b4067199e1a66f2d2757aaa41", size = 4997427, upload-time = "2026-05-01T23:31:20.901Z" }, + { url = "https://files.pythonhosted.org/packages/a2/23/ccadfd0de416aa188356daa199453af24087b042e296088706d190ae0295/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:46893c26858be12cc49ca4226ed6a60b4bfccadd946b3bebb783a60b38788228", size = 4533549, upload-time = "2026-05-01T23:31:26.204Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a0/c8f43cee36386f7bc891ab41a9d31ea07cf9826038e732da79f26b1e5f34/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:df1d567fc430f6df15c9fcf67d87685fc49bdb325adc0db5af1adfb2f44eb5c9", size = 4210256, upload-time = "2026-05-01T23:31:33.884Z" }, + { url = "https://files.pythonhosted.org/packages/4e/2c/c1547871be3790676e8868b38655496422f94f0978dfb66b74bdba2f1676/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:6b9016b1714da4dd5ecaaa75b82098aa5a0b87854ce9b092e21c27c4ae23e014", size = 3946204, upload-time = "2026-05-01T23:31:39.626Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b1/f6670f00fa7ea601584623f6c11602ab92117d83eaff885e0210f6de7418/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:47c656a8a7ba6eb0cff1801a4caaa9c8bdc12d03080e273aff1c8ac39971a77e", size = 4255811, upload-time = "2026-05-01T23:31:44.986Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e6/5fff07a70d1f945ed90ae131c3bd76cab32beff7c58c6db15ad5820b6d1f/psycopg_binary-3.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:c37e024c07308cd06cf3ec51bfd0e7f6157585a4d84d1bce4a7f5f7913719bf8", size = 3666849, upload-time = "2026-05-01T23:31:51.165Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "redis" +version = "8.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "async-timeout", marker = "python_full_version < '3.11.3'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cc/c3/928b290c2c0ca99ab96eea5b4ff8f30be8112b075301a7d3ba214a3c8c12/redis-8.0.1.tar.gz", hash = "sha256:afc5a7a2f5a084f5b1880dec548dd45be17db7e43c82a30d84f952aefb05cfb0", size = 5114170, upload-time = "2026-06-23T14:52:37.728Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/0a/c2345ebf1ebe70840ce3f6c6ee612f8fa749cfbd1b03069c53bf0c62aaad/redis-8.0.1-py3-none-any.whl", hash = "sha256:47daa35a058c23468d6437f17a8c76882cb316b838ef763036af99b96cedd743", size = 502406, upload-time = "2026-06-23T14:52:36.137Z" }, +] + +[[package]] +name = "s3transfer" +version = "0.19.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/43/35e4d8aa320bffe8287fe8f65f578fa2d2db0a64212f0e710dce58267854/s3transfer-0.19.2.tar.gz", hash = "sha256:ba0309fd86be3c27dbf78cdd813c13c5e1df16e5874b99d2535ebbdfb9892993", size = 165592, upload-time = "2026-07-22T19:30:44.432Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl", hash = "sha256:d8168eccca828cbb2cd573675333f3bddd254313a9c42494b84c76b539e8ba25", size = 90216, upload-time = "2026-07-22T19:30:43.251Z" }, +] + +[[package]] +name = "setuptools" +version = "83.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/34/26/f5d29e25ffdb535afef2d35cdb55b325298f96debd670da4c325e08d70f4/setuptools-83.0.0.tar.gz", hash = "sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef", size = 1154254, upload-time = "2026-07-04T15:31:22.699Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090, upload-time = "2026-07-04T15:31:20.885Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.51" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/f1/a7a892f18d4d224e6b26f706531eafccc41e37594d37d304786969ee13cb/sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9", size = 9912201, upload-time = "2026-06-15T15:41:20.012Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/76/b3ea1d8842e7b62c718a88d302809003d65ed82011460ca48907dde658c4/sqlalchemy-2.0.51-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e8203d2fbd5c6254692ef0a72c740d75b2f3c7ca345404f4c1a4604813c77c0", size = 2162087, upload-time = "2026-06-15T16:05:15.795Z" }, + { url = "https://files.pythonhosted.org/packages/6c/22/f19552eb7876774d50cfd025337ef5d67acc10cd8f29adab7716cf47c352/sqlalchemy-2.0.51-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1af05726b3d0cdba1c55284bf408fd3b792e690fe2399bfb8304565551cda652", size = 3244579, upload-time = "2026-06-15T16:10:36.165Z" }, + { url = "https://files.pythonhosted.org/packages/fc/97/e4a2eb5a8ec5cd3c2a0615a2f15f0afca89ac039229599b9ed0c0ed28e5e/sqlalchemy-2.0.51-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e54ff2dd657f2e3e0fbf2b097db1182f7bfea263eca4353f00065bae2a67c3d", size = 3243515, upload-time = "2026-06-15T16:12:22.627Z" }, + { url = "https://files.pythonhosted.org/packages/74/c6/5900ec624fab3360aa2ec59b99bb2046dd79799e310bb78a0514eaa4038e/sqlalchemy-2.0.51-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1e47b1199c2e832e325eacabc8d32d2487f58c9358f97e9a00f5eb93c5680d84", size = 3195492, upload-time = "2026-06-15T16:10:38.097Z" }, + { url = "https://files.pythonhosted.org/packages/8f/41/2ee3c4e1ac4fd22309349823fe13f33febeab1a71db1d7e9d60293a07dcb/sqlalchemy-2.0.51-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c68568f3facf8f66fa76c60e0ced69b67666ffa9941d1d0a3756fda196049080", size = 3215782, upload-time = "2026-06-15T16:12:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/ce/1c/3bd72c341f1cb5faed5a7457ea840228a46be51cfbaf31a9db72fc963f11/sqlalchemy-2.0.51-cp310-cp310-win32.whl", hash = "sha256:0592bdadf86ddcabfd72d9ab66ea8a5d8d2cc6be1cc51fa7e66c03868ac5eac1", size = 2122119, upload-time = "2026-06-15T16:13:26.915Z" }, + { url = "https://files.pythonhosted.org/packages/2a/63/b6dfdd646abf91c3bedb13727226a5e765e5f8365e898d43818e6672fa46/sqlalchemy-2.0.51-cp310-cp310-win_amd64.whl", hash = "sha256:740cf6f35351b1ac3d82369152acf1d51d37e3dcf85d4dc0a22ca01410eabe2a", size = 2145158, upload-time = "2026-06-15T16:13:28.386Z" }, + { url = "https://files.pythonhosted.org/packages/3a/69/a67c69e5f28fc9c99d6f7bd60bd50e91f2fed2423e3b30fb228fa00e51f3/sqlalchemy-2.0.51-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1aa10c0daee6705294d181daadaa793221e1a59ed55000a3fab1d42b088ce4ba", size = 2161838, upload-time = "2026-06-15T16:05:17.144Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a4/c8c22b8438bddc0a030157c6ec0f6ef97b3c38effa444bdab2a27af04090/sqlalchemy-2.0.51-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5b2ed6d828f1f09bd812861f4f59ca3bc3803f9df871f4555187f0faf018604", size = 3319402, upload-time = "2026-06-15T16:10:40.002Z" }, + { url = "https://files.pythonhosted.org/packages/90/54/44012d32fd77d991256d2ff793ba3807c51d40cb27a85b4796224f6744df/sqlalchemy-2.0.51-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:436728ce18a80f6951a1e11cc6112c2ede9faf20766f1a26195a7c441ca12dbd", size = 3319675, upload-time = "2026-06-15T16:12:25.658Z" }, + { url = "https://files.pythonhosted.org/packages/29/a5/de0592acaf5906cd7430874392d6f7e8b4a7c8437610953ee2d1501c0b44/sqlalchemy-2.0.51-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dc261707bf5739aea8a541593f3cc1d463c2701fb05fbcbba0ce031b69a21260", size = 3270777, upload-time = "2026-06-15T16:10:42.125Z" }, + { url = "https://files.pythonhosted.org/packages/cb/14/a44c90739c780b362238e4ac3cb19dd0ca40d13e6ddc5daa112166ddab4f/sqlalchemy-2.0.51-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a6d26094615306d116dd5e4a51b0304c99dd2356fc569eed6922a80a6bd3b265", size = 3293940, upload-time = "2026-06-15T16:12:27.156Z" }, + { url = "https://files.pythonhosted.org/packages/65/eb/fbd0f206a330e66f8c602a99c37c4e731f107faed62954b41b01f16dd9d9/sqlalchemy-2.0.51-cp311-cp311-win32.whl", hash = "sha256:ca8435d13829b92f4a97362d91975154a4015db3a2634154e1754e9a915e6b86", size = 2121183, upload-time = "2026-06-15T16:13:29.905Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fd/005bf80f3cf6e5c62b5dd68616280f51cd012c60840fa74781b3ed7b1623/sqlalchemy-2.0.51-cp311-cp311-win_amd64.whl", hash = "sha256:4a011ea4510683319ce4ed274b56ee05194b39b6da9d09ca7a39388f0fa84dcc", size = 2145796, upload-time = "2026-06-15T16:13:31.283Z" }, + { url = "https://files.pythonhosted.org/packages/d5/70/e868bc5412acd101a8280f25c95f10eeae0771c4eb806b02491142810ee8/sqlalchemy-2.0.51-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d78702b26ba1c18b2d0fb2ea940ba7f17a9581b42e8361ff93920ebbee1235a", size = 2160291, upload-time = "2026-06-15T16:08:48.918Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1c/71ee0f8a6b9d7316a1ccd30430b4c62b6c2e36adc96017a4e3a72dce49d6/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581921d849d6e6f994d560389192955e80e2950e18fcdfe2ccea863e01158e6e", size = 3343835, upload-time = "2026-06-15T16:19:42.613Z" }, + { url = "https://files.pythonhosted.org/packages/2b/7c/7ab9f9aadc5944fdd06612484ed7918fe376ad871a5f50404dc1536e0194/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d21ce524ab86c23046e992a5b81cb54c21079c6df6e78b8fc77d77cac70a6b9", size = 3358470, upload-time = "2026-06-15T16:26:38.011Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7d/ff77169fee6186de145a7f2b87006c39638391130abbab2b1f63ac6ea583/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c5d98a2709840027f5a347c3af0a7c3d5f6c1ff93af2ca1c54494e23cba8f389", size = 3289874, upload-time = "2026-06-15T16:19:45.212Z" }, + { url = "https://files.pythonhosted.org/packages/6f/3b/6c505903710d781b55bc3141ee34a062bf9745a6b5bc7333305b9ed63b33/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1181256e0f16479691b5616d36375dc2620ad8332b25978763c3d206ad3f3f1d", size = 3321692, upload-time = "2026-06-15T16:26:39.747Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b7/c5ffe50aa2f4d947c9250e1519d939260329a07fe6272edfccd784b3d007/sqlalchemy-2.0.51-cp312-cp312-win32.whl", hash = "sha256:9f380393be5abeb6815f68fd39271b95127173511b6706b0a630a9995d53f8f5", size = 2119674, upload-time = "2026-06-15T16:23:09.543Z" }, + { url = "https://files.pythonhosted.org/packages/25/dc/46a65916af68a06ef6b972c6050ba4c8f97070fe3fb33097d34229d9bef6/sqlalchemy-2.0.51-cp312-cp312-win_amd64.whl", hash = "sha256:2cf39aabdf48e87c1c2c2ed6d20d33ffa0733b3071ce9c5f66357947dd009080", size = 2146670, upload-time = "2026-06-15T16:23:11.048Z" }, + { url = "https://files.pythonhosted.org/packages/54/fe/a210d52fd1a90ecfae8a78e9d8b27e18d733d60818a8bf250ff690b75120/sqlalchemy-2.0.51-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c2056838b6685b72fdb36c99996cf862753461a62f2e84f4196371d3b2d6a07", size = 2157184, upload-time = "2026-06-15T16:08:50.374Z" }, + { url = "https://files.pythonhosted.org/packages/17/6b/2dce8369b199cb855110e056032f94a9f66dacc2237d3d39c115a86eac56/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:483b11bd46bf35fc14c52faf338b04300c9e6ce554bce9b11be85bfec3bc3195", size = 3284735, upload-time = "2026-06-15T16:19:46.934Z" }, + { url = "https://files.pythonhosted.org/packages/53/ff/dbc495b8a14da840faffb353857a72d4190113cac33727906fb997047f0f/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1bed1ee8b01da6088210aa9412023326fb98a599ba502e6118308601dcbef77f", size = 3302756, upload-time = "2026-06-15T16:26:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d5/fde8f4dddcf518ee15ab35a7c6a28acc32c8ba548d1d2aa451f96e6dbb0b/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:72ca54c952107ba5cd58854b67a5a6268631289d21651a1235396f3b98b47400", size = 3232055, upload-time = "2026-06-15T16:19:49.286Z" }, + { url = "https://files.pythonhosted.org/packages/67/d1/43d3a0ac955a58601c24fa23038b1c55ee3a1ec02c0f96ebb1eae2bcf614/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b3e693d15533a45cd5906f0589f9c35090bef6ef45bf1e8195c424aa0ae06a8d", size = 3269850, upload-time = "2026-06-15T16:26:43.017Z" }, + { url = "https://files.pythonhosted.org/packages/94/df/de669c7054cd47c4439ac34b1b2ee8b804a794791fbb10720e997a2c87c7/sqlalchemy-2.0.51-cp313-cp313-win32.whl", hash = "sha256:b93ab07b5292dbe7e6b8da89475275e7042744283921344b56105f3eeb0f828b", size = 2117721, upload-time = "2026-06-15T16:23:12.36Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8a/403c51d064196bae20a0bc2476577f83a3f8dd299719a97417086b7f2ec5/sqlalchemy-2.0.51-cp313-cp313-win_amd64.whl", hash = "sha256:0f053118c30e53161857a953e4de667d90e274980dccbe5dd3829bbbeece72a5", size = 2143615, upload-time = "2026-06-15T16:23:13.906Z" }, + { url = "https://files.pythonhosted.org/packages/b1/49/a739be2e1d02a96a658eb71ab45d921c874249252358ad24a5bffdd02525/sqlalchemy-2.0.51-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6ea306caaae6bd5afd0a46050003c88f6bf33227377a49298c498c3cb88ff491", size = 2158999, upload-time = "2026-06-15T16:08:51.759Z" }, + { url = "https://files.pythonhosted.org/packages/23/6b/2e0e38cf75c8780eca78d9b2e78164f8bcfd70125e5caa588ff5cbb9c9f4/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c45a496d6bc05dec41dcd4c3a2b183723f47473255c159cd80b503c8f246424d", size = 3282539, upload-time = "2026-06-15T16:19:51.065Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a1/e77854cb5336fd37dc3c6ae3b71de242c98caac5725120be0b526b31cbd0/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4004ada0aafe8ae1991b2cd1d99c6d9146126e123bd6f883c260d974aa012e54", size = 3287545, upload-time = "2026-06-15T16:26:44.735Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ab/9e17272fd4dac8df3b83c4fbe52b998a1c9d89a843c8c35ff29b74ff7364/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f6bcad487aee1c638d707235682fc96f741de00663619881ab235400d03289e", size = 3230929, upload-time = "2026-06-15T16:19:52.625Z" }, + { url = "https://files.pythonhosted.org/packages/02/3c/52f408ea701781caee975606beccc48845f2aee8711ac29843d612c0306c/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:39a76529db6305693d8d4affa58ad5b5e2e18edd62daea628b29b97930b3513d", size = 3252888, upload-time = "2026-06-15T16:26:46.454Z" }, + { url = "https://files.pythonhosted.org/packages/24/16/3efd2ee6bc4ca4693a30a1dd17a91b606cae15d517d2a4746611d9b73ce8/sqlalchemy-2.0.51-cp314-cp314-win32.whl", hash = "sha256:08a204d8b5638717c26a24df18fcf40af45a6b22e35b70b1d62f0113c2e278e8", size = 2120551, upload-time = "2026-06-15T16:23:15.629Z" }, + { url = "https://files.pythonhosted.org/packages/7b/78/55b12e70f45bccc40d9e483925c065027b3b98ea4cbbdf6f8c2546feaf6c/sqlalchemy-2.0.51-cp314-cp314-win_amd64.whl", hash = "sha256:96747bfbadb055466e5b46d572618170046b45ce5a4879167f50d70a5319a499", size = 2146318, upload-time = "2026-06-15T16:23:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/21/db/a9574ed40fed418924b1b1a3e54f47ee3963053b3d3d325a0d36b41f2c08/sqlalchemy-2.0.51-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5ea1a213be1fcd5e49d9904c3b9939211ded90bc2a64e93f4c01963474285de", size = 2178920, upload-time = "2026-06-15T15:59:56.285Z" }, + { url = "https://files.pythonhosted.org/packages/bf/90/a1bb5c7cbba76b7bc1fbd586d0a5479a7bc9c27b4a8298f22ec9423b2bb3/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c6b36ed71f41942bdcd2ad2522be46bfce09d5705be5640ecf19bbc7660e4b7", size = 3566534, upload-time = "2026-06-15T15:58:35.024Z" }, + { url = "https://files.pythonhosted.org/packages/15/4b/481f1fed30e0e9e8dd24aecbb49f29eb57fe7657ece5cf06ee9b84bb97d8/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c2c62877097e1a0db401fba5cb4debee33265e5b2a55c4ccb489c02c53b4f72", size = 3535844, upload-time = "2026-06-15T16:02:43.973Z" }, + { url = "https://files.pythonhosted.org/packages/02/71/0aa64aeda645510af0a43f7d9ee70932f0d1dc4263aed34c50ee891d9df3/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0378d055e9e8cd6ce4d8dff683bdd3d7d413533c4ee51d67a2b1e0f9eacc0f23", size = 3475355, upload-time = "2026-06-15T15:58:36.592Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/6061db32316446135a3abae5f308d144ab988a34234726042da3e58b1c63/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6e46fc36029eff666391e0531e5387b62ce6c4f1d8e50b3fb3099eaca1b42522", size = 3486591, upload-time = "2026-06-15T16:02:45.346Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c9/f14fdf71bb8957e0c7e39db69bbdf12b5c80f4ef775fdfa127bf4e0d6760/sqlalchemy-2.0.51-cp314-cp314t-win32.whl", hash = "sha256:9161cfc9efce70d1715f47d6ff40f79c6778c00d53be4fbc09d70301e4b83ba7", size = 2151313, upload-time = "2026-06-15T16:03:39.127Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c6/673e618e6f4f297e126d9b56ea2f6478708f6c1af4e3223835c22e2c3697/sqlalchemy-2.0.51-cp314-cp314t-win_amd64.whl", hash = "sha256:159bb6ba32059f57ad7375a8f50d844dd2f19d14954ecf820cd33e20debd46b2", size = 2186280, upload-time = "2026-06-15T16:03:40.569Z" }, + { url = "https://files.pythonhosted.org/packages/e2/22/dbf013a12ec759e54a34a119e9e217435b3f71b2dd5c61a7ade0a25dae87/sqlalchemy-2.0.51-py3-none-any.whl", hash = "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5", size = 1944334, upload-time = "2026-06-15T16:09:22.418Z" }, +] + +[[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" }, +] + +[[package]] +name = "tzdata" +version = "2026.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "ventis" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "boto3" }, + { name = "flask" }, + { name = "grpcio" }, + { name = "grpcio-tools" }, + { name = "psutil" }, + { name = "psycopg", extra = ["binary"] }, + { name = "pyyaml" }, + { name = "redis" }, + { name = "sqlalchemy" }, +] + +[package.metadata] +requires-dist = [ + { name = "boto3" }, + { name = "flask" }, + { name = "grpcio" }, + { name = "grpcio-tools" }, + { name = "psutil" }, + { name = "psycopg", extras = ["binary"] }, + { name = "pyyaml" }, + { name = "redis" }, + { name = "sqlalchemy" }, +] + +[[package]] +name = "werkzeug" +version = "3.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" }, +] diff --git a/ventis/cli.py b/ventis/cli.py index 31d8d5d..3aceb18 100644 --- a/ventis/cli.py +++ b/ventis/cli.py @@ -9,6 +9,7 @@ import argparse import glob +import json import logging import os import shutil @@ -60,13 +61,13 @@ def _docker_build_cmd(*args): return ["docker", "build", "--platform", _docker_platform(), *args] -def _docker_available(): +def _docker_available(probe_cmd=("docker", "info")): if not shutil.which("docker"): return False try: result = subprocess.run( - ["docker", "info"], + list(probe_cmd), capture_output=True, text=True, check=False, @@ -77,6 +78,32 @@ def _docker_available(): return result.returncode == 0 +def _write_bake_file(bake_targets, bake_file_path, platform): + """Write a docker-buildx-bake JSON file describing all build targets. + + Context paths are written absolute: `docker buildx bake` resolves relative + `context` values against the invocation cwd (not the bake file's own + directory), so an absolute path sidesteps that ambiguity entirely. + """ + bake_config = { + "target": { + target["name"]: { + "context": os.path.abspath(target["context"]), + "dockerfile": "Dockerfile", + "tags": [target["image_name"]], + "platforms": [platform], + "output": ["type=docker"], + # type=docker could be changed to tarring it up, which would be + # faster but skipped because that change would alter ventis deploy + } + for target in bake_targets + } + } + with open(bake_file_path, "w") as f: + json.dump(bake_config, f, indent=2) + return bake_file_path + + def _require_docker_for_ec2(command_name): if _docker_available(): return @@ -218,8 +245,9 @@ def cmd_build(args): ) # -------------------------------------------------------------- # - # Step 4: Generate Docker contexts and build images # + # Step 4: Generate Docker contexts # # -------------------------------------------------------------- # + bake_targets = [] for agent_cfg in agents: agent_name = agent_cfg["name"] agent_type = agent_cfg.get("type", "agent") @@ -248,12 +276,6 @@ def cmd_build(args): api_port=agent_cfg.get("api_port", 8080), ) - image_name = f"ventis-{agent_name.lower()}" - logger.info("Building Docker image: %s", image_name) - subprocess.run( - _docker_build_cmd("-t", image_name, docker_context), check=True - ) - else: # Agent container entrypoint = agent_cfg.get("entrypoint") @@ -296,10 +318,43 @@ def cmd_build(args): stub_files=stub_paths, ) - image_name = f"ventis-{agent_name.lower()}" - logger.info("Building Docker image: %s", image_name) + bake_targets.append( + { + "name": agent_name.lower(), + "context": docker_context, + "image_name": f"ventis-{agent_name.lower()}", + } + ) + + # -------------------------------------------------------------- # + # Step 5: Build all Docker images # + # -------------------------------------------------------------- # + if not bake_targets: + logger.info("No Docker images to build.") + elif _docker_available() and _docker_available(("docker", "buildx", "version")): + docker_container_dir = os.path.join(project_dir, "docker_container") + os.makedirs(docker_container_dir, exist_ok=True) + bake_file_path = os.path.join(docker_container_dir, "docker-bake.json") + _write_bake_file(bake_targets, bake_file_path, _docker_platform()) + + target_names = [target["name"] for target in bake_targets] + logger.info( + "Building %d Docker image(s) via `docker buildx bake`.", + len(bake_targets), + ) + subprocess.run( + ["docker", "buildx", "bake", "--file", bake_file_path, *target_names], + check=True, + ) + else: + logger.info( + "docker buildx unavailable; falling back to sequential `docker build`." + ) + for target in bake_targets: + logger.info("Building Docker image: %s", target["image_name"]) subprocess.run( - _docker_build_cmd("-t", image_name, docker_context), check=True + _docker_build_cmd("-t", target["image_name"], target["context"]), + check=True, ) logger.info("Build complete.") diff --git a/ventis/controller/cloud_provider_logic/EC2/_runtime.py b/ventis/controller/cloud_provider_logic/EC2/_runtime.py index 60cd224..4c74005 100644 --- a/ventis/controller/cloud_provider_logic/EC2/_runtime.py +++ b/ventis/controller/cloud_provider_logic/EC2/_runtime.py @@ -23,6 +23,7 @@ import boto3 +from ventis.controller.utils.redis_utils import _wait_for_redis from ventis.utils.redis_client import RedisClient logger = logging.getLogger(__name__) @@ -80,6 +81,7 @@ def provision_instance(spec, replica_index, next_host_port=None): "SecurityGroupIds": cfg["security_group_ids"], "MinCount": 1, "MaxCount": 1, + "IamInstanceProfile": {"Name": "ec2launch"}, "TagSpecifications": [ { "ResourceType": "instance", @@ -138,14 +140,13 @@ def provision_instance(spec, replica_index, next_host_port=None): record = { "host": host, "runtime_id": runtime_id, - "ec2_instance_id": instance_id, "redis_host": host, "redis_port": redis_port, } return record -def bootstrap_instance(provisioned, spec, replica_index): +def bootstrap_instance(provisioned, spec, replica_index, agent_id): """Start the agent container on the new EC2 host and return its record.""" cfg, _ = _aws_clients() host = provisioned["host"] @@ -161,6 +162,7 @@ def bootstrap_instance(provisioned, spec, replica_index): cfg, redis_host=redis_host, redis_port=redis_port, + agent_id=agent_id, ) _check_controller_health( f"{host}:{CONTAINER_PORT}", @@ -169,6 +171,7 @@ def bootstrap_instance(provisioned, spec, replica_index): return { "agent_name": spec["name"], "provider": "EC2", + "instance_type": spec["instance_type"], "replica_index": str(replica_index), "host": host, "host_port": str(CONTAINER_PORT), @@ -177,14 +180,13 @@ def bootstrap_instance(provisioned, spec, replica_index): "redis_host": redis_host, "redis_port": str(redis_port), "runtime_id": runtime_id, - "ec2_instance_id": provisioned.get("ec2_instance_id"), } except Exception: terminate_instance(provisioned) raise -def _bootstrap_instance(host, spec, replica_index, cfg, redis_host, redis_port): +def _bootstrap_instance(host, spec, replica_index, cfg, redis_host, redis_port, agent_id): """Run the agent container over SSH.""" ssh_user = cfg["ssh_user"] @@ -216,9 +218,14 @@ def _bootstrap_instance(host, spec, replica_index, cfg, redis_host, redis_port): f"Failed to start Redis on {host}: {(result.stderr or result.stdout or '').strip()}" ) getattr(_controller, "redis_containers", {})[host] = redis_container - getattr(_controller, "node_redis", {})[host] = RedisClient( - host=host, port=int(redis_port) - ) + node_redis = RedisClient(host=host, port=int(redis_port)) + getattr(_controller, "node_redis", {})[host] = node_redis + + _wait_for_redis(node_redis, host, redis_port) + + node_redis.set(f"controller:{host}:{CONTAINER_PORT}:agent_id", agent_id) + if spec.get("instance_type"): + node_redis.set(f"agent:{agent_id}:instance_type", spec["instance_type"]) agent_name = spec["name"] image = f"ventis-{agent_name.lower()}" @@ -265,8 +272,17 @@ def _bootstrap_instance(host, spec, replica_index, cfg, redis_host, redis_port): f"VENTIS_AGENT_HOST={host}", "-e", f"VENTIS_AGENT_PORT={CONTAINER_PORT}", - image, + "-e", + f"VENTIS_POLL_INTERVAL={_controller.config.get('poll_interval', 5)}", ] + if spec.get("type") == "workflow": + db_url = _controller.config.get("database", {}).get("url") + project_id = _controller.config.get("project_id") + if db_url: + cmd.extend(["-e", f"VENTIS_DATABASE_URL={db_url}"]) + if project_id: + cmd.extend(["-e", f"VENTIS_PROJECT_ID={project_id}"]) + cmd.append(image) result = _controller._run_cmd(cmd, host, user=ssh_user) if result.returncode != 0: raise RuntimeError( diff --git a/ventis/controller/cloud_provider_logic/Local/_runtime.py b/ventis/controller/cloud_provider_logic/Local/_runtime.py index 4cc4c8e..7780ce4 100644 --- a/ventis/controller/cloud_provider_logic/Local/_runtime.py +++ b/ventis/controller/cloud_provider_logic/Local/_runtime.py @@ -49,7 +49,7 @@ def provision_instance(spec, replica_index, next_host_port): } -def bootstrap_instance(provisioned, spec, replica_index): +def bootstrap_instance(provisioned, spec, replica_index, agent_id): agent_name = spec["name"] resources = spec.get("resources", {}) ctrl_type = spec.get("type", "agent") @@ -60,6 +60,9 @@ def bootstrap_instance(provisioned, spec, replica_index): redis_host = provisioned["redis_host"] runtime_id = provisioned["runtime_id"] + endpoint = routing_endpoint_for(provisioned) + _require_controller().redis.set(f"controller:{endpoint}:agent_id", agent_id) + cmd = [ "docker", "run", @@ -78,9 +81,18 @@ def bootstrap_instance(provisioned, spec, replica_index): f"VENTIS_REDIS_HOST={redis_host}", "-e", f"VENTIS_REDIS_PORT={spec.get('redis_port', 6379)}", + "-e", + f"VENTIS_POLL_INTERVAL={_require_controller().config.get('poll_interval', 5)}", ] if ctrl_type == "workflow": cmd.extend(["-p", f"{spec.get('api_port', 8080)}:8080"]) + config = _require_controller().config + db_url = config.get("database", {}).get("url") + project_id = config.get("project_id") + if db_url: + cmd.extend(["-e", f"VENTIS_DATABASE_URL={db_url}"]) + if project_id: + cmd.extend(["-e", f"VENTIS_PROJECT_ID={project_id}"]) if resources.get("cpu"): cmd.extend(["--cpus", str(resources["cpu"])]) if resources.get("memory"): diff --git a/ventis/controller/global_controller.py b/ventis/controller/global_controller.py index 4615b52..4e416b4 100644 --- a/ventis/controller/global_controller.py +++ b/ventis/controller/global_controller.py @@ -16,7 +16,12 @@ from ventis.controller.instance_manager import InstanceManager from ventis.controller.utils.agent_specs import write_agent_specs from ventis.controller.utils.redis_utils import _wait_for_redis -from ventis.controller.utils.sqlalchemy import pull_data, send_data +from ventis.controller.utils.telemetry_logging import ( + assign_project_id, + pull_runtime_information, + send_runtime_information, + send_agent_information, +) from ventis.utils.redis_client import RedisClient # Add generated grpc_stubs from the local project to the path @@ -72,9 +77,11 @@ def __init__(self, config_path): self.redis_containers = {} # host -> container_name self.node_redis = {} # host -> RedisClient self._last_status = {} # (host, port) -> last known status + self._last_metrics_poll_time = {} # (host, port) -> time.time() of last metrics read self._lc_stubs = {} # endpoint -> gRPC stub self.instance_manager = InstanceManager(self) - + assign_project_id(self.config.get("project_id",0)) + # Clean up any stale containers from previous runs self._cleanup_stale_containers() @@ -387,7 +394,10 @@ def run(self): ) try: while self.running: - self._poll_controllers() + try: + self._poll_controllers() + except Exception as e: + logger.warning("Polling loop encountered an error: %s", e) time.sleep(self.poll_interval) except KeyboardInterrupt: self.stop() @@ -402,14 +412,64 @@ def _poll_controllers(self): host = instance["host"] port = instance["host_port"] node_redis = self._get_node_redis_for(host) - send_data( - pull_data(node_redis), - {c["name"]: c.get("resources", {}) for c in self.controllers}, - node_redis, - self.config.get("database", {}).get("url"), - ) + try: + send_runtime_information( + pull_runtime_information(node_redis), + node_redis, + self.config.get("database", {}).get("url"), + ) + except Exception as e: + logger.warning( + "Failed to write runtime information for instance %s (%s:%s) " + "(non-fatal): %s", + name, + host, + port, + e, + ) agent_host = self._agent_host_key(host) status_key = f"controller:{agent_host}:{port}:status" + metrics_key = f"controller:{agent_host}:{port}:metrics" + + # Getting metrics from local controllers + # See LocalController._execute_locally + metrics = node_redis.hgetall(metrics_key) + if metrics: + now = time.time() + requests_served = int(float(metrics.get("requests_served") or 0)) + elapsed = now - self._last_metrics_poll_time.get( + (host, port), now - self.poll_interval + ) + throughput = requests_served / elapsed if elapsed > 0 else 0.0 + self._last_metrics_poll_time[(host, port)] = now + + try: + send_agent_information( + [ + { + **instance, + **metrics, + "requests_served": requests_served, + "throughput": throughput, + } + ], + self.config.get("database", {}).get("url"), + ) + except Exception as e: + logger.warning( + "Failed to write agent information for instance %s (%s:%s) " + "(non-fatal): %s", + name, + host, + port, + e, + ) + else: + # Only clear the accumulated counters once they've actually been persisted + node_redis.hset_multiple( + metrics_key, + {"full_failures": 0, "error_count": 0, "requests_served": 0}, + ) status = node_redis.get(status_key) or "unknown" prev = self._last_status.get((host, port)) diff --git a/ventis/controller/instance_manager.py b/ventis/controller/instance_manager.py index 936c381..e904c5c 100644 --- a/ventis/controller/instance_manager.py +++ b/ventis/controller/instance_manager.py @@ -8,6 +8,7 @@ import json import os +import uuid from concurrent.futures import ThreadPoolExecutor, as_completed from ventis.controller.cloud_provider_logic.Local import _runtime as local_runtime @@ -104,7 +105,9 @@ def _provision_one(self, job): provisioned = runtime.provision_instance( agent_spec, replica_index, next_host_port ) - instance = runtime.bootstrap_instance(provisioned, agent_spec, replica_index) + agent_id = uuid.uuid4().hex + instance = runtime.bootstrap_instance(provisioned, agent_spec, replica_index, agent_id) + instance["agent_id"] = agent_id self._write_instance(instance) return instance @@ -113,6 +116,7 @@ def _write_instance(self, instance): instance["provider"], instance["agent_name"], int(instance["replica_index"]) ) mapping = { + "agent_id": instance["agent_id"], "agent_name": instance["agent_name"], "provider": instance["provider"], "replica_index": str(instance["replica_index"]), @@ -124,12 +128,24 @@ def _write_instance(self, instance): "redis_port": str(instance["redis_port"]), "runtime_id": instance["runtime_id"], } - if instance.get("ec2_instance_id"): - mapping["ec2_instance_id"] = instance["ec2_instance_id"] if instance.get("user"): mapping["user"] = instance["user"] + if instance.get("instance_type"): + mapping["instance_type"] = instance["instance_type"] self.redis.hset_multiple(key, mapping) + node_redis = self.controller.node_redis.get(instance["host"]) or self.redis + + endpoint = self._routing_endpoint_for(instance) + node_redis.set(f"controller:{endpoint}:agent_id", instance["agent_id"]) + + # Direct agent_id -> instance_type lookup so cost computation can look up + # hourly pricing from just the agent_id already stamped on each future. + if instance.get("instance_type"): + node_redis.set( + f"agent:{instance['agent_id']}:instance_type", instance["instance_type"] + ) + def _add_instance_to_agent(self, agent_name, instance_id): self.redis.sadd(f"agent:{agent_name}:instances", instance_id) diff --git a/ventis/controller/local_controller.py b/ventis/controller/local_controller.py index c1a34ed..9f53a3e 100644 --- a/ventis/controller/local_controller.py +++ b/ventis/controller/local_controller.py @@ -7,16 +7,20 @@ import os import random import sys +import threading import time import importlib.util from concurrent.futures import ThreadPoolExecutor import grpc +import psutil try: from ventis.controller.local_controller_frontend import start_server + from ventis.controller.utils.gpu_metrics import read_gpu_percent from ventis.utils.redis_client import RedisClient except ImportError: + from gpu_metrics import read_gpu_percent from local_controller_frontend import start_server from redis_client import RedisClient @@ -65,6 +69,21 @@ def __init__(self, port=50051): self._status_key = f"controller:{self.agent_host}:{self.public_port}:status" self.redis.set(self._status_key, "healthy") + # Set once by InstanceManager when this replica was provisioned; read back + # here so completed requests can be stamped with which replica ran them. + self.agent_id = self.redis.get( + f"controller:{self.agent_host}:{self.public_port}:agent_id" + ) + + # Periodically publish instance metrics, on the same cadence + # GlobalController polls with (via VENTIS_POLL_INTERVAL). + self._metrics_key = f"controller:{self.agent_host}:{self.public_port}:metrics" + self._metrics_interval = float(os.environ.get("VENTIS_POLL_INTERVAL", 5)) + psutil.cpu_percent(interval=None) # prime so the first real reading isn't 0.0 + self._metrics_stop_event = threading.Event() + self._metrics_thread = threading.Thread(target=self._metrics_loop, daemon=True) + self._metrics_thread.start() + # Cache for gRPC stubs to remote controllers self._remote_channels = {} # endpoint -> grpc.Channel self._remote_stubs = {} # endpoint -> LocalControllerStub @@ -87,6 +106,37 @@ def __init__(self, port=50051): # Load the agent class dynamically self.agent = self._load_agent() + def _collect_metrics(self): + """Snapshot current instance health/resource metrics. + + requests_served is deliberately absent here -- it's incremented directly on + the metrics hash (see _execute_locally) and drained by GlobalController after + it reads it, not self-reset on this heartbeat's own timer. Self-resetting it + here would silently drop counts for any interval GlobalController's poll + loop falls behind on. + """ + return { + "status": "healthy", + "cpu_percent": str(psutil.cpu_percent(interval=None)), + "gpu_percent": str(read_gpu_percent()), + "disk_percent": str(psutil.disk_usage("/").percent), + "memory_percent": str(psutil.virtual_memory().percent), + "uptime_seconds": str(max(time.time() - psutil.boot_time(), 0.0)), + "queue_length": str(self._executor._work_queue.qsize()), + "updated_at": str(time.time()), + } + + def _metrics_loop(self): + """Background thread: periodically publish instance metrics and refresh status.""" + while not self._metrics_stop_event.is_set(): + try: + metrics = self._collect_metrics() + self.redis.hset_multiple(self._metrics_key, metrics) + self.redis.set(self._status_key, "healthy") + except Exception as e: + logger.warning("Metrics loop encountered an error: %s", e) + self._metrics_stop_event.wait(self._metrics_interval) + def _load_agent(self): """Dynamically load and instantiate the agent class.""" if not self.agent_name or not self.agent_file: @@ -236,6 +286,7 @@ def run(self): while True: if not self.request_queue.empty(): raw = self.request_queue.get() + data = None try: data = json.loads(raw) self._process_request(data) @@ -243,11 +294,34 @@ def run(self): logger.error("Invalid JSON in request: %s", raw) except Exception as e: logger.error("Error processing request: %s", e) + self._mark_future_failed( + data.get("future_id"), e, data.get("origin") + ) else: time.sleep(0.001) except KeyboardInterrupt: self.stop() + def _mark_future_failed(self, future_id, error, origin=None): + """Persist a terminal failure locally and, when needed, notify the origin.""" + if not future_id: + return + + error_message = str(error) or "Unknown error" + self.redis.hset(f"future:{future_id}", "error", error_message) + self.redis.hset_multiple( + f"future:{future_id}:metrics", + {"failed": 1, "error_message": error_message}, + ) + + if origin and origin != self._my_endpoint: + self._send_result_callback( + origin, + future_id, + failed=1, + error_message=error_message, + ) + def _process_request(self, data): """ Route a request to the correct controller. @@ -258,6 +332,7 @@ def _process_request(self, data): service = data.get("service") function = data.get("function") args = data.get("args", {}) + parent = data.get("parent") future_id = data.get("future_id") origin = data.get("origin") # endpoint of the LC that originated this request request_id = data.get("request_id") # tracing ID from deploy module @@ -283,15 +358,18 @@ def _process_request(self, data): if not service or not function or not future_id: logger.error("Malformed request, missing required fields: %s", data) + self._mark_future_failed( + future_id, + "Malformed request: missing service, function, or future_id", + origin, + ) return # Check policy before routing if not self._check_policy(service, context): err_msg = f"Unauthorized: Policy denied access to service '{service}'" logger.warning(err_msg) - self.redis.hset(f"future:{future_id}", "result", err_msg) - if origin and origin != self._my_endpoint: - self._send_result_callback(origin, future_id, err_msg) + self._mark_future_failed(future_id, err_msg, origin) return # Resolve which endpoint to route to. @@ -309,9 +387,15 @@ def _process_request(self, data): logger.error( "No endpoint found for service '%s' in routing table.", service ) + self._mark_future_failed( + future_id, + f"No endpoint found for service '{service}'", + origin, + ) return if endpoint == self._my_endpoint: + submitted_at = time.time() self._executor.submit( self._execute_locally, service, @@ -320,6 +404,8 @@ def _process_request(self, data): future_id, origin, request_id, + submitted_at, + parent, ) else: # Register the target as a consumer for any Future args @@ -400,6 +486,17 @@ def _resolve_future_args(self, args, poll_interval=0.01, timeout=300): ) start = time.time() while True: + error = self.redis.hget(future_key, "error") + if error: + raise RuntimeError(error) + failed = self.redis.hget(f"future:{value}:metrics", "failed") + if str(failed) == "1": + raise RuntimeError( + self.redis.hget( + f"future:{value}:metrics", "error_message" + ) + or "Unknown error" + ) # print("Waiting for result for future next iteration %s", value) result = self.redis.hget(future_key, "result") if result is not None and result != "": @@ -418,24 +515,59 @@ def _resolve_future_args(self, args, poll_interval=0.01, timeout=300): return resolved def _execute_locally( - self, service, function, args, future_id, origin=None, request_id=None + self, + service, + function, + args, + future_id, + origin=None, + request_id=None, + submitted_at=None, + parent=None, ): """Execute a request on the local agent and write the result to Redis.""" wall_start = time.time() thread_cpu_start = time.thread_time() - # Propagate the request_id context into this worker thread + # Write a complete, self-contained metrics record for this execution step + # entirely to this node's own Redis -- unlike future:{future_id} (created on + # the calling node), this is never split across two Redis instances, since + # both the "start" and "finish" writes below happen on the same node. + self.redis.hset_multiple( + f"future:{future_id}:metrics", + { + "id": future_id, + "request_id": request_id or "", + "result": "", + "parent": parent or "", + "service": service, + "method": function, + "args": json.dumps(args), + "created_at": wall_start, + "failed": 0, + "error_message": "", + }, + ) if request_id: + self.redis.sadd(f"request:{request_id}:futures", future_id) ventis_context.set_request_id(request_id) + ventis_context.set_current_future_id(future_id) + ventis_context.set_current_metrics_key(self._metrics_key) if self.agent is None: logger.error("No agent loaded, cannot execute %s.%s", service, function) + self._mark_future_failed(future_id, "No agent loaded", origin) return method = getattr(self.agent, function, None) if method is None: logger.error("Agent %s has no method '%s'", self.agent_name, function) + self._mark_future_failed( + future_id, f"Agent {self.agent_name} has no method '{function}'", origin + ) return + self.redis.hincrby(self._metrics_key, "requests_served", 1) + try: # Resolve any Future IDs in the args before executing args = self._resolve_future_args(args) @@ -456,7 +588,13 @@ def _execute_locally( # If the request came from another node, send result back to origin if origin and origin != self._my_endpoint: - self._send_result_callback(origin, future_id, serialized) + self._send_result_callback( + origin, + future_id, + result=serialized, + failed=0, + error_message="", + ) logger.info( "Completed %s.%s (future=%s) -> %s", @@ -465,22 +603,36 @@ def _execute_locally( future_id, serialized, ) + self.redis.hset(f"future:{future_id}:metrics", "failed", 0) except Exception as e: logger.error("Failed to execute %s.%s: %s", service, function, e) - # Treat script-level crash as a string result to avoid hanging - self.redis.hset(f"future:{future_id}", "result", f"Execution failed: {e}") - if origin and origin != self._my_endpoint: - self._send_result_callback(origin, future_id, f"Execution failed: {e}") - - wall_end = time.time() - self.redis.hset(f"future:{future_id}", "finished_at", wall_end) - - wall_duration = max(wall_end - wall_start, 0.0) - cpu_seconds = max(time.thread_time() - thread_cpu_start, 0.0) - cpu_percent = (cpu_seconds / wall_duration * 100.0) if wall_duration else 0.0 - - self.redis.hset(f"future:{future_id}", "cpu_resource", cpu_percent) + self._mark_future_failed(future_id, e, origin) + self.redis.hincrby(self._metrics_key, "full_failures", 1) + finally: + wall_end = time.time() + wall_duration = max(wall_end - wall_start, 0.0) + cpu_seconds = max(time.thread_time() - thread_cpu_start, 0.0) + cpu_percent = ( + (cpu_seconds / wall_duration * 100.0) if wall_duration else 0.0 + ) + gpu_percent = read_gpu_percent() + + self.redis.hset_multiple( + f"future:{future_id}:metrics", + { + "finished_at": wall_end, + "cpu_resource": cpu_percent, + "gpu_resource": gpu_percent, + "agent": self.agent_id, + **( + {"queue_time": max(wall_start - submitted_at, 0.0)} + if submitted_at is not None + else {} + ), + }, + ) + ventis_context.set_current_future_id(parent or "") # ------------------------------------------------------------------ # # Request forwarding # @@ -511,12 +663,12 @@ def _forward_request(self, endpoint, data): logger.debug("Forwarded request to %s", endpoint) except Exception as e: logger.error("Failed to forward request to %s: %s", endpoint, e) - future_id = data.get("future_id") - if future_id: - self.redis.hset(f"future:{future_id}", "error", str(e)) + self._mark_future_failed(data.get("future_id"), e) - def _send_result_callback(self, origin, future_id, result): - """Send a result back to the originating controller via WriteResult RPC.""" + def _send_result_callback( + self, origin, future_id, result=None, failed=0, error_message="" + ): + """Send a result and its failure metadata to the originating controller.""" if not result: logger.warning( "Agent '%s' is sending an empty/None result for future %s to origin %s, result: %s", @@ -527,7 +679,12 @@ def _send_result_callback(self, origin, future_id, result): ) stub = self._get_remote_stub(origin) - payload = json.dumps({"future_id": future_id, "result": result}) + payload = json.dumps({ + "future_id": future_id, + "result": result, + "failed": int(bool(failed)), + "error_message": str(error_message or ""), + }) logger.info("Payload: Future %s,Sent %s ", future_id, payload) request = local_controler_pb2.JsonResponse(resonse=payload) try: @@ -541,6 +698,7 @@ def _send_result_callback(self, origin, future_id, result): except Exception as e: logger.error("Failed to send result callback to %s: %s", origin, e) + self._mark_future_failed(future_id, f"Result callback failed: {e}") # ------------------------------------------------------------------ # # Shutdown # @@ -549,6 +707,8 @@ def _send_result_callback(self, origin, future_id, result): def stop(self): """Gracefully shut down the server.""" logger.info("Shutting down local controller...") + self._metrics_stop_event.set() + self._metrics_thread.join(timeout=2) self._executor.shutdown(wait=True) self.redis.set(self._status_key, "stopped") self.server.stop(0) diff --git a/ventis/controller/local_controller_frontend.py b/ventis/controller/local_controller_frontend.py index c1d8c30..0117c4a 100644 --- a/ventis/controller/local_controller_frontend.py +++ b/ventis/controller/local_controller_frontend.py @@ -41,8 +41,6 @@ def Execute(self, request, context): """Accept an Execute request and push it into the queue.""" logger.info(f"Received request: {request.resonse}") data = json.loads(request.resonse) - future_id = data.get("future_id") - self.redis.hset(f"future:{future_id}", "agent", data.get("service")) self.request_queue.put(request.resonse) return local_controler_pb2.JsonResponse(resonse="Request queued successfully") @@ -52,7 +50,8 @@ def WriteResult(self, request, context): data = json.loads(request.resonse) future_id = data.get("future_id") result = data.get("result") - error = data.get("error") + failed = int(bool(data.get("failed", 0))) + error_message = str(data.get("error_message") or "") logger.info( f"WriteResult: received result for future {future_id}: {result}" @@ -63,10 +62,16 @@ def WriteResult(self, request, context): ) if future_id: - if error is not None: - self.redis.hset(f"future:{future_id}", "error", error) + self.redis.hset_multiple( + f"future:{future_id}:metrics", + {"failed": failed, "error_message": error_message}, + ) + if failed: + self.redis.hset( + f"future:{future_id}", "error", error_message or "Unknown error" + ) logger.info("WriteResult: wrote error for future %s", future_id) - if result is not None: + elif result is not None: self.redis.hset(f"future:{future_id}", "result", result) logger.info( "WriteResult: wrote result for future %s, result %s", @@ -119,6 +124,7 @@ def _cleanup_request(self, request_id): f"future:{fid}", f"future:{fid}:children", f"future:{fid}:consumers", + f"future:{fid}:metrics", ] ) self.redis.delete(*keys_to_delete) diff --git a/ventis/controller/utils/aws_pricing_chart.db b/ventis/controller/utils/aws_pricing_chart.db new file mode 100644 index 0000000..6cd6103 Binary files /dev/null and b/ventis/controller/utils/aws_pricing_chart.db differ diff --git a/ventis/controller/utils/gpu_metrics.py b/ventis/controller/utils/gpu_metrics.py new file mode 100644 index 0000000..fad5c23 --- /dev/null +++ b/ventis/controller/utils/gpu_metrics.py @@ -0,0 +1,30 @@ +"""GPU utilization reporting via nvidia-smi.""" +# Disclaimer, only works for NVIDIA (obviously but wanted to make note) + +import subprocess + + +def read_gpu_percent(): + """Return current GPU utilization percent; 0.0 if unavailable.""" + try: + result = subprocess.run( + [ + "nvidia-smi", + "--query-gpu=utilization.gpu", + "--format=csv,noheader,nounits", + ], + capture_output=True, + text=True, + timeout=2, + ) + if result.returncode != 0: + return 0.0 + first_line = result.stdout.strip().splitlines()[0] + return float(first_line) + except ( + FileNotFoundError, + subprocess.SubprocessError, + ValueError, + IndexError, + ): + return 0.0 diff --git a/ventis/controller/utils/pricing.py b/ventis/controller/utils/pricing.py new file mode 100644 index 0000000..aa7060e --- /dev/null +++ b/ventis/controller/utils/pricing.py @@ -0,0 +1,55 @@ +"""Lazily-cached lookups over the static aws_pricing_chart.db reference data.""" + +import os + +from sqlalchemy import create_engine, text + +_PRICING_DB_PATH = os.path.join(os.path.dirname(__file__), "aws_pricing_chart.db") + +_hourly_cost_by_instance_type = None +_token_cost_by_model_id = None + + +def _load_cache(): + global _hourly_cost_by_instance_type, _token_cost_by_model_id + if _hourly_cost_by_instance_type is not None: + return + + engine = create_engine(f"sqlite:///{_PRICING_DB_PATH}") + with engine.connect() as conn: + instance_rows = conn.execute( + text("SELECT instance_type, hourly_cost FROM aws_instance_pricing") + ).fetchall() + model_rows = conn.execute( + text( + "SELECT model_id, input_cost_per_million_tokens, " + "output_cost_per_million_tokens FROM bedrock_model_pricing" + ) + ).fetchall() + engine.dispose() + + _hourly_cost_by_instance_type = {row[0]: row[1] for row in instance_rows} + _token_cost_by_model_id = {row[0]: (row[1], row[2]) for row in model_rows} + + +def compute_token_cost(model_id, input_token_count, output_token_count): + """Return the USD cost of a Bedrock call, or 0.0 if the model_id is unknown.""" + _load_cache() + costs = _token_cost_by_model_id.get(model_id) + if costs is None: + return 0.0 + input_cost_per_million, output_cost_per_million = costs + return ( + input_token_count * input_cost_per_million + + output_token_count * output_cost_per_million + ) / 1_000_000 + + +def compute_server_cost(instance_type, execution_time_seconds): + """Return the USD cost of occupying an EC2 instance for execution_time_seconds, + or 0.0 if the instance_type is unknown.""" + _load_cache() + hourly_cost = _hourly_cost_by_instance_type.get(instance_type) + if hourly_cost is None: + return 0.0 + return hourly_cost * execution_time_seconds / 3600 diff --git a/ventis/controller/utils/session_logging.py b/ventis/controller/utils/session_logging.py new file mode 100644 index 0000000..6be176b --- /dev/null +++ b/ventis/controller/utils/session_logging.py @@ -0,0 +1,100 @@ +"""Session-row reads and writes for the `session` Postgres table. + +upsert_session is called synchronously from deploy.py's handle_workflow(), before the +workflow's background thread is dispatched, so a request's session row is always committed +before any Future for that request can exist -- this is what makes runtime_information's +session_id foreign key never race against session creation. + +get_session is the read side, used by deploy.py's /status endpoint once a finished request's +Redis keys have expired and the session row is the only remaining copy of its outcome. +""" + +import json +import os +from datetime import datetime, timezone + +from sqlalchemy import create_engine, text + +SESSION_TABLE_NAME = "session" + +_SESSION_UPSERT = text( + f""" + INSERT INTO {SESSION_TABLE_NAME} ( + session_id, project_id, status, input, output, created_at, updated_at + ) VALUES ( + :session_id, :project_id, :status, :input_payload, :output_payload, :created_at, :updated_at + ) + ON CONFLICT (session_id) DO UPDATE SET + status = excluded.status, + updated_at = excluded.updated_at, + input = COALESCE(excluded.input, {SESSION_TABLE_NAME}.input), + output = COALESCE(excluded.output, {SESSION_TABLE_NAME}.output) + """ +) + +_SESSION_SELECT = text( + f""" + SELECT status, output FROM {SESSION_TABLE_NAME} + WHERE session_id = :session_id AND project_id = :project_id + """ +) + +_engine = None + + +def _get_engine(database_url): + global _engine + if _engine is None: + url = os.environ.get("VENTIS_DATABASE_URL", str(database_url)) + if url.startswith("postgresql://"): + url = "postgresql+psycopg://" + url[len("postgresql://"):] + _engine = create_engine(url) + return _engine + + +def upsert_session( + database_url, + project_id, + session_id, + status, + timestamp, + input_payload=None, + output_payload=None, +): + """Create or update a session row with the given status.""" + ts = datetime.fromtimestamp(float(timestamp), tz=timezone.utc) + with _get_engine(database_url).begin() as conn: + conn.execute( + _SESSION_UPSERT, + { + "session_id": session_id, + "project_id": project_id, + "status": status, + "input_payload": ( + json.dumps(input_payload) if input_payload is not None else None + ), + "output_payload": ( + json.dumps(output_payload) if output_payload is not None else None + ), + "created_at": ts, + "updated_at": ts, + }, + ) + + +def get_session(database_url, project_id, session_id): + """Return {"status": ..., "output": ...} for a session, or None if there is no such row. + + This is what lets deploy.py answer GET /status once a finished request's Redis + keys have aged out -- the session row is the durable copy of the same state. + """ + with _get_engine(database_url).connect() as conn: + row = ( + conn.execute( + _SESSION_SELECT, + {"session_id": session_id, "project_id": project_id}, + ) + .mappings() + .first() + ) + return dict(row) if row is not None else None diff --git a/ventis/controller/utils/sqlalchemy.py b/ventis/controller/utils/sqlalchemy.py deleted file mode 100644 index bfe111a..0000000 --- a/ventis/controller/utils/sqlalchemy.py +++ /dev/null @@ -1,119 +0,0 @@ -"""Pull future hashes from Redis and upsert runtime_information rows.""" - -import os -import time - -from sqlalchemy import create_engine, text -from ventis.utils.redis_client import RedisClient - -_engine = None - -TABLE_NAME = "runtime_information" - -_UPSERT = text( - f""" - INSERT INTO {TABLE_NAME} ( - future_id, session_id, workflow, agent, execution_time, - cpu_resource, gpu_resource, created_at, updated_at - ) VALUES ( - :future_id, :session_id, :workflow, :agent, :execution_time, - :cpu_resource, :gpu_resource, :created_at, :updated_at - ) - ON CONFLICT(future_id) DO UPDATE SET - session_id=excluded.session_id, - workflow=excluded.workflow, - agent=excluded.agent, - execution_time=excluded.execution_time, - cpu_resource=excluded.cpu_resource, - gpu_resource=excluded.gpu_resource, - created_at=excluded.created_at, - updated_at=excluded.updated_at - """ -) - - -_CREATE_TABLE = text( - f""" - CREATE TABLE IF NOT EXISTS {TABLE_NAME} ( - future_id TEXT PRIMARY KEY, - session_id TEXT, - workflow TEXT, - agent TEXT, - execution_time REAL, - cpu_resource REAL, - gpu_resource REAL, - created_at TEXT, - updated_at TEXT - ) - """ -) - - -def _get_engine(database_url): - global _engine - if _engine is None: - _engine = create_engine( - os.environ.get("VENTIS_DATABASE_URL", str(database_url)) - ) - with _engine.begin() as conn: - conn.execute(_CREATE_TABLE) - return _engine - - -def pull_data(redis_client): - """Scan node Redis for future data""" - rows = [] - for key in redis_client.scan_keys("future:*"): - if key.count(":") != 1: - continue - data = redis_client.hgetall(key) - if data: - data["future_id"] = data.get("id") or key.split(":", 1)[1] - rows.append(data) - return rows - - -def send_data( - rows, - resources_by_agent=None, - redis_client: RedisClient | None = None, - database_url="", -): - """UPSERT rows with observed CPU and configured GPU resource values.""" - if not rows: - return - resources_by_agent = resources_by_agent or {} - with _get_engine(database_url).begin() as conn: - for raw in rows: - agent = raw.get("agent") - res = resources_by_agent.get(agent, {}) - fid = raw.get("future_id") - if not fid: - continue - session_id = raw.get("request_id") - if not session_id: - continue - workflow = ( - redis_client.get(f"request:{session_id}:workflow") - if redis_client is not None - else None - ) - start = float(raw.get("created_at") or 0) - end = float(raw.get("finished_at") or time.time()) - cpu_resource = float(raw.get("cpu_resource") or res.get("cpu", 0)) - gpu_resource = float(res.get("gpu", 0)) - - conn.execute( - _UPSERT, - { - "future_id": fid, - "session_id": session_id, - "workflow": workflow, - "agent": agent, - "execution_time": end - start, - "cpu_resource": cpu_resource, - "gpu_resource": gpu_resource, - "created_at": start, - "updated_at": end, - }, - ) diff --git a/ventis/controller/utils/telemetry_logging.py b/ventis/controller/utils/telemetry_logging.py new file mode 100644 index 0000000..3c168c7 --- /dev/null +++ b/ventis/controller/utils/telemetry_logging.py @@ -0,0 +1,223 @@ +"""Pull future hashes from Redis and upsert runtime_information rows.""" + +import logging +import os +import time +from datetime import datetime, timezone + +from sqlalchemy import create_engine, text +from ventis.controller.utils import pricing +from ventis.utils.redis_client import RedisClient + +logger = logging.getLogger(__name__) + +_engine = None +_project_id = None + +RUNTIME_TABLE_NAME = "runtime_information" + +_RUNTIME_UPSERT = text( + f""" + INSERT INTO {RUNTIME_TABLE_NAME} ( + future_id, parent_id, session_id, project_id, agent_id, model, + cpu, gpu, started_at, finished_at, execution_time_ms, queue_time_ms, + input_token_count, output_token_count, token_count, errors, failed, + server_cost, token_cost, total_cost, cached_tokens, cache_hit_ratio + ) VALUES ( + :future_id, :parent_id, :session_id, :project_id, :agent_id, :model, + :cpu, :gpu, :started_at, :finished_at, :execution_time_ms, :queue_time_ms, + :input_token_count, :output_token_count, :token_count, :errors, :failed, + :server_cost, :token_cost, :total_cost, :cached_tokens, :cache_hit_ratio + ) + ON CONFLICT(future_id) DO UPDATE SET + parent_id=excluded.parent_id, + session_id=excluded.session_id, + project_id=excluded.project_id, + agent_id=excluded.agent_id, + model=excluded.model, + cpu=excluded.cpu, + gpu=excluded.gpu, + started_at=excluded.started_at, + finished_at=excluded.finished_at, + execution_time_ms=excluded.execution_time_ms, + queue_time_ms=excluded.queue_time_ms, + input_token_count=excluded.input_token_count, + output_token_count=excluded.output_token_count, + token_count=excluded.token_count, + errors=excluded.errors, + failed=excluded.failed, + server_cost=excluded.server_cost, + token_cost=excluded.token_cost, + total_cost=excluded.total_cost, + cached_tokens=excluded.cached_tokens, + cache_hit_ratio=excluded.cache_hit_ratio + """ +) + + +AGENT_TABLE_NAME = "agent_information" + +_AGENT_UPSERT = text( + f""" + INSERT INTO {AGENT_TABLE_NAME} ( + agent_id, name, health, queue_length, cpu_percent, gpu_percent, disk_percent, memory_percent, + error_count, full_failures, requests_served, throughput, updated_at + ) VALUES ( + :agent_id, :name, :health, :queue_length, :cpu_percent, :gpu_percent, :disk_percent, :memory_percent, + :error_count, :full_failures, :requests_served, :throughput, :updated_at + ) + ON CONFLICT(agent_id) DO UPDATE SET + name=excluded.name, + health=excluded.health, + queue_length=excluded.queue_length, + cpu_percent=excluded.cpu_percent, + gpu_percent=excluded.gpu_percent, + disk_percent=excluded.disk_percent, + memory_percent=excluded.memory_percent, + error_count=excluded.error_count, + full_failures=excluded.full_failures, + requests_served=excluded.requests_served, + throughput=excluded.throughput, + updated_at=excluded.updated_at + """ +) + + +def assign_project_id(project_id) -> None: + global _project_id + _project_id = project_id + +def _get_engine(database_url): + global _engine + if _engine is None: + url = os.environ.get("VENTIS_DATABASE_URL", str(database_url)) + if url.startswith("postgresql://"): + url = "postgresql+psycopg://" + url[len("postgresql://"):] + _engine = create_engine(url) + return _engine + + +def pull_runtime_information(redis_client): + """Scan node Redis for future execution metrics. + + Each future's metrics live at future:{future_id}:metrics, written entirely by + whichever node executed it -- never split across nodes like the main + future:{future_id} key (used for the result hand-off) can be. + """ + rows = [] + for key in redis_client.scan_keys("future:*:metrics"): + data = redis_client.hgetall(key) + if data: + data["future_id"] = data.get("id") or key.split(":")[1] + rows.append(data) + return rows + + +def send_runtime_information( + rows, + redis_client: RedisClient | None = None, + database_url="", +): + """UPSERT rows with observed CPU and GPU resource values.""" + if not rows: + return + + # Demo-only multipliers for scaling displayed costs; not real recorded costs. + token_cost_multiplier = 10000 + server_cost_multiplier = 100000 + + with _get_engine(database_url).begin() as conn: + for raw in rows: + agent_id = raw.get("agent") + fid = raw.get("future_id") + if not fid: + continue + session_id = raw.get("request_id") + if not session_id: + continue + # A future without finished_at is still executing. Now that metrics live + # entirely on the executing node (future:{future_id}:metrics is only ever + # written by the one process that runs it), "incomplete" genuinely means + # "still running" -- skip it and let a later poll, once it has actually + # finished, write the real measurements instead. + if not raw.get("finished_at"): + continue + start = float(raw.get("created_at") or 0) + end = float(raw.get("finished_at")) + input_token_count = int(float(raw.get("input_token_count") or 0)) + output_token_count = int(float(raw.get("output_token_count") or 0)) + token_count = int(float(raw.get("token_count") or 0)) + cached_tokens = int(float(raw.get("input_cache_tokens") or 0)) + model = raw.get("model") + token_cost = pricing.compute_token_cost( + model, input_token_count, output_token_count + ) + server_cost = pricing.compute_server_cost( + redis_client.get(f"agent:{agent_id}:instance_type") + if redis_client is not None and agent_id + else None, + end - start, + ) + + server_cost *= server_cost_multiplier + token_cost *= token_cost_multiplier + + conn.execute( + _RUNTIME_UPSERT, + { + "future_id": fid, + "parent_id": raw.get("parent") or None, + "session_id": session_id, + "project_id": _project_id, + "agent_id": agent_id, + "model": model, + "cpu": float(raw.get("cpu_resource") or 0), + "gpu": float(raw.get("gpu_resource", 0)), + "started_at": datetime.fromtimestamp(start, tz=timezone.utc), + "finished_at": datetime.fromtimestamp(end, tz=timezone.utc), + "execution_time_ms": round((end - start) * 1000), + "queue_time_ms": round(float(raw.get("queue_time") or 0) * 1000), + "input_token_count": input_token_count, + "output_token_count": output_token_count, + "token_count": token_count, + "errors": int(raw.get("errors") or 0), + "failed": bool(int(raw.get("failed", 1))), + "server_cost": server_cost, + "token_cost": token_cost, + "total_cost": server_cost + token_cost, + "cached_tokens": cached_tokens, + "cache_hit_ratio": cached_tokens / token_count if token_count else 0.0, + }, + ) + + +def send_agent_information(rows, database_url=""): + """UPSERT per-instance telemetry rows (health/cpu/gpu/uptime heartbeat).""" + if not rows: + return + now = time.time() + with _get_engine(database_url).begin() as conn: + for raw in rows: + agent_id = raw.get("agent_id") + if not agent_id: + continue + conn.execute( + _AGENT_UPSERT, + { + "agent_id": agent_id, + "name": raw.get("agent_name"), + "health": raw.get("status") or "unknown", + "queue_length": int(float(raw.get("queue_length") or 0)), + "cpu_percent": float(raw.get("cpu_percent") or 0.0), + "gpu_percent": float(raw.get("gpu_percent") or 0.0), + "disk_percent": float(raw.get("disk_percent") or 0.0), + "memory_percent": float(raw.get("memory_percent") or 0.0), + "error_count": int(raw.get("error_count") or 0), + "full_failures": int(raw.get("full_failures") or 0), + "requests_served": int(float(raw.get("requests_served") or 0)), + "throughput": float(raw.get("throughput") or 0.0), + "updated_at": datetime.fromtimestamp( + float(raw.get("updated_at") or now), tz=timezone.utc + ), + }, + ) diff --git a/ventis/deploy.py b/ventis/deploy.py index 436fef0..b6ac721 100644 --- a/ventis/deploy.py +++ b/ventis/deploy.py @@ -24,6 +24,7 @@ def my_workflow(query: str): import logging import os import threading +import time import traceback import uuid @@ -35,9 +36,26 @@ def my_workflow(query: str): except ImportError: from redis_client import RedisClient +try: + from ventis.controller.utils.session_logging import get_session, upsert_session +except ImportError: + from session_logging import get_session, upsert_session + logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) +# How long a finished request's Redis keys stick around before Redis reclaims them. +COMPLETED_TTL_SECONDS = 300 + +# session.status is a Postgres enum whose value set ("running"/"failed"/"completed") +# is owned by the database schema, not by us -- it cannot be renamed to match the +# /status API's vocabulary ("running"/"error"/"done"). Translate between them here. +_SESSION_STATUS_TO_REQUEST_STATUS = { + "completed": "done", + "failed": "error", + "running": "running", +} + def deploy(workflow_fn, port=8080, host="0.0.0.0", redis_host=None, redis_port=None): """ @@ -58,9 +76,42 @@ def deploy(workflow_fn, port=8080, host="0.0.0.0", redis_host=None, redis_port=N redis_port = redis_port or int(os.environ.get("VENTIS_REDIS_PORT", 6379)) redis_client = RedisClient(host=redis_host, port=redis_port) + db_url = os.environ.get("VENTIS_DATABASE_URL") + project_id = os.environ.get("VENTIS_PROJECT_ID") + fn_name = workflow_fn.__name__ app = Flask(f"ventis-{fn_name}") + def _expire_request_keys(request_id): + """Let a finished request's Redis keys age out instead of living forever.""" + for suffix in ("status", "result", "error", "context"): + redis_client.expire( + f"request:{request_id}:{suffix}", COMPLETED_TTL_SECONDS + ) + + def _record_session(request_id, status, input_payload=None, output_payload=None): + """Best-effort session upsert -- logs and swallows failures so a Postgres + hiccup never takes down the request itself.""" + if not (db_url and project_id): + return + try: + upsert_session( + db_url, + project_id, + request_id, + status, + time.time(), + input_payload=input_payload, + output_payload=output_payload, + ) + except Exception as e: + logger.warning( + "Failed to update session %s status to %s (non-fatal): %s", + request_id, + status, + e, + ) + def _execute_workflow(request_id, kwargs, context=None): """Run the workflow in a background thread and store results in Redis.""" status_key = f"request:{request_id}:status" @@ -82,22 +133,26 @@ def _execute_workflow(request_id, kwargs, context=None): result = workflow_fn(**kwargs) # Serialize the result - if isinstance(result, dict): - serialized = json.dumps(result) - else: - serialized = json.dumps({"value": result}) + output_payload = result if isinstance(result, dict) else {"value": result} + serialized = json.dumps(output_payload) redis_client.set(result_key, serialized) redis_client.set(status_key, "done") redis_client.sadd("request:completed", request_id) + _expire_request_keys(request_id) logger.info("Request %s completed successfully.", request_id) + _record_session(request_id, "completed", output_payload=output_payload) + except Exception as e: logger.error("Request %s failed: %s", request_id, e) logger.error(traceback.format_exc()) redis_client.set(error_key, str(e)) redis_client.set(status_key, "error") redis_client.sadd("request:completed", request_id) + _expire_request_keys(request_id) + + _record_session(request_id, "failed", output_payload={"error": str(e)}) @app.route(f"/{fn_name}", methods=["POST"]) def handle_workflow(): @@ -111,7 +166,11 @@ def handle_workflow(): request_id = uuid.uuid4().hex status_key = f"request:{request_id}:status" redis_client.set(status_key, "pending") - redis_client.set(f"request:{request_id}:workflow", fn_name) + + # Create the session row synchronously, before any Future for this request + # can exist (the workflow dispatch below is what spawns those Futures), so + # runtime_information's session_id foreign key can never race against it. + _record_session(request_id, "running", input_payload=kwargs) # Dispatch the workflow in a background thread thread = threading.Thread( @@ -130,6 +189,45 @@ def handle_workflow(): return jsonify({"request_id": request_id}), 202 + def _status_from_session(request_id): + """Rebuild a /status response from the session row in Postgres. + + Used once a finished request's Redis keys have expired. Returns None when + there is nothing to serve, so the caller can fall through to its 404. + """ + if not (db_url and project_id): + return None + + try: + row = get_session(db_url, project_id, request_id) + if row is None: + return None + + status = _SESSION_STATUS_TO_REQUEST_STATUS.get(row["status"]) + if status is None: + return None + + response = {"request_id": request_id, "status": status} + output = row["output"] + # JSONB comes back already decoded from Postgres, but as text from + # drivers without a JSON type (e.g. sqlite in the tests). + if isinstance(output, str): + output = json.loads(output) + + if status == "done" and output: + response["result"] = output + elif status == "error" and isinstance(output, dict) and output.get("error"): + response["error"] = output["error"] + + return response + except Exception as e: + logger.warning( + "Failed to read session %s from Postgres (non-fatal): %s", + request_id, + e, + ) + return None + @app.route("/status/", methods=["GET"]) def get_status(request_id): """Check the status of a workflow request.""" @@ -139,6 +237,11 @@ def get_status(request_id): status = redis_client.get(status_key) if status is None: + # Redis only holds finished requests for COMPLETED_TTL_SECONDS; after + # that the session row is the record. + from_session = _status_from_session(request_id) + if from_session is not None: + return jsonify(from_session), 200 return jsonify({"error": "Request not found"}), 404 response = {"request_id": request_id, "status": status} diff --git a/ventis/future.py b/ventis/future.py index 58f5649..598bc4f 100644 --- a/ventis/future.py +++ b/ventis/future.py @@ -68,7 +68,7 @@ def __init__(self, parent, service, method, args=None): self.funtionality = None self.executor = None self.result = None - self.parent = parent + self.parent = ventis_context.get_current_future_id() self.service = service self.method = method self.args = args or {} @@ -106,6 +106,7 @@ def _submit_request(self): "args": self.args, "future_id": self.id, "request_id": self.request_id, + "parent": self.parent, } ) request = local_controler_pb2.JsonResponse(resonse=request_payload) @@ -117,7 +118,11 @@ def _submit_request(self): ) except Exception as e: logger.error("gRPC call failed for %s.%s: %s", self.service, self.method, e) - raise + self.redis.hset(f"future:{self.id}", "error", str(e)) + self.redis.hset_multiple(f"future:{self.id}:metrics", { + "failed": 1, + "error_message": str(e), + }) def _key(self): """Redis key for this future's hash.""" @@ -148,6 +153,13 @@ def value(self, timeout=None): Returns immediately if the result is already available locally. Polls Redis periodically to check for computed results. """ + failed = self.redis.hget(f"future:{self.id}:metrics", "failed") + if str(failed) == "1": + raise RuntimeError( + self.redis.hget(f"future:{self.id}:metrics", "error_message") + or "Unknown error" + ) + if self.result is not None: return self.result diff --git a/ventis/llm/__init__.py b/ventis/llm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ventis/llm/bedrock.py b/ventis/llm/bedrock.py new file mode 100644 index 0000000..4135352 --- /dev/null +++ b/ventis/llm/bedrock.py @@ -0,0 +1,52 @@ +import os + +try: + from ventis.utils.redis_client import RedisClient + import ventis.ventis_context as ventis_context +except ImportError: + from redis_client import RedisClient + import ventis_context + +_redis = RedisClient( + host=os.environ.get("VENTIS_REDIS_HOST", "localhost"), + port=int(os.environ.get("VENTIS_REDIS_PORT", 6379)), +) + + +def call_bedrock(model_id: str, messages: list, inference_config: dict, region: str = "us-east-1") -> dict: + """Call Bedrock's converse() API and log token/error telemetry onto the + currently executing future's metrics hash (future::metrics).""" + import boto3 + + client = boto3.client("bedrock-runtime", region_name=region) + future_id = ventis_context.get_current_future_id() + error_count = 0 + response = None + try: + response = client.converse( + modelId=model_id, messages=messages, inferenceConfig=inference_config + ) + return response + except Exception as e: + error_count += 1 + metrics_key = ventis_context.get_current_metrics_key() + if metrics_key: + _redis.hincrby(metrics_key, "error_count", 1) + if future_id: + _redis.hset_multiple(f"future:{future_id}:metrics", { + "failed": 1, + "error_message": str(e), + }) + raise + finally: + if future_id: + usage = (response or {}).get("usage", {}) + _redis.hset_multiple(f"future:{future_id}:metrics", { + "model": model_id, + "input_token_count": str(usage.get("inputTokens", "")), + "output_token_count": str(usage.get("outputTokens", "")), + "token_count": str(usage.get("totalTokens", "")), + "errors": str(error_count), + "input_cache_tokens": str(usage.get("cacheReadInputTokens", "")), + "input_cache_write_tokens": str(usage.get("cacheWriteInputTokens", "")), + }) diff --git a/ventis/stub_generator.py b/ventis/stub_generator.py index eb7f1b7..8c956ef 100644 --- a/ventis/stub_generator.py +++ b/ventis/stub_generator.py @@ -295,7 +295,9 @@ def generate_docker( os.makedirs(output_dir, exist_ok=True) # ---- requirements.txt ------------------------------------------------ - requirements = "grpcio\ngrpcio-tools\nredis\npyyaml\nipdb\nipython\nboto3\nyfinance\n" + # psutil is required unconditionally -- local_controller.py imports it at + # module level for CPU/disk/memory metrics reporting on every agent. + requirements = "grpcio\ngrpcio-tools\nredis\npyyaml\nboto3\nyfinance\npsutil\nipdb\nipython\n" with open(os.path.join(output_dir, "requirements.txt"), "w") as f: f.write(requirements) @@ -313,6 +315,11 @@ def generate_docker( "local_controller_frontend.py", ), (os.path.join(script_dir, "utils", "redis_client.py"), "redis_client.py"), + ( + os.path.join(script_dir, "controller", "utils", "gpu_metrics.py"), + "gpu_metrics.py", + ), + (os.path.join(script_dir, "llm", "bedrock.py"), "bedrock.py"), ] # Copy provided agent stubs @@ -344,12 +351,14 @@ def generate_docker( # ---- Dockerfile ------------------------------------------------------ agent_basename = os.path.basename(agent_file) - dockerfile = f"""FROM python:3.11-slim + dockerfile = f"""# syntax=docker/dockerfile:1 +FROM python:3.11-slim +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ WORKDIR /app COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt +RUN --mount=type=cache,target=/root/.cache/uv uv pip install --system -r requirements.txt COPY . . @@ -358,7 +367,7 @@ def generate_docker( EXPOSE 50051 -CMD python local_controller.py --port 50051 +CMD ["python", "local_controller.py", "--port", "50051"] """ with open(os.path.join(output_dir, "Dockerfile"), "w") as f: f.write(dockerfile) @@ -395,7 +404,13 @@ def generate_workflow_docker( os.makedirs(output_dir, exist_ok=True) # ---- requirements.txt ------------------------------------------------ - requirements = "grpcio\ngrpcio-tools\nredis\npyyaml\nflask\nipdb\nipython\nboto3\nyfinance\n" + # psutil is required unconditionally -- local_controller.py imports it at + # module level for CPU/disk/memory metrics reporting on every controller, + # including the Workflow's own embedded one. + requirements = ( + "grpcio\ngrpcio-tools\nredis\npyyaml\nflask\nboto3\nyfinance\npsutil\nipdb\nipython\n" + "sqlalchemy\npsycopg[binary]\n" + ) with open(os.path.join(output_dir, "requirements.txt"), "w") as f: f.write(requirements) @@ -416,6 +431,10 @@ def generate_workflow_docker( "local_controller_frontend.py", ), (os.path.join(script_dir, "utils", "redis_client.py"), "redis_client.py"), + *[ + (os.path.join(script_dir, "controller", "utils", name), name) + for name in ("gpu_metrics.py", "session_logging.py") + ], ] # Copy stub files @@ -461,19 +480,21 @@ def start_lc(): f.write(launcher) # ---- Dockerfile ------------------------------------------------------ - dockerfile = f"""FROM python:3.11-slim + dockerfile = f"""# syntax=docker/dockerfile:1 +FROM python:3.11-slim +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ WORKDIR /app COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt +RUN --mount=type=cache,target=/root/.cache/uv uv pip install --system -r requirements.txt COPY . . EXPOSE 50051 EXPOSE {api_port} -CMD python workflow_launcher.py +CMD ["python", "workflow_launcher.py"] """ with open(os.path.join(output_dir, "Dockerfile"), "w") as f: f.write(dockerfile) diff --git a/ventis/utils/redis_client.py b/ventis/utils/redis_client.py index f05c500..9b52d8e 100644 --- a/ventis/utils/redis_client.py +++ b/ventis/utils/redis_client.py @@ -28,6 +28,10 @@ def setnx(self, key, value): """Set key to value only if it does not already exist. Returns True if set, False otherwise.""" return self.client.setnx(key, value) + def expire(self, key, seconds): + """Set a TTL (in seconds) on a key. No-op if the key does not exist.""" + return self.client.expire(key, seconds) + # --- Hash operations --- def hset(self, name, field, value): @@ -38,6 +42,10 @@ def hset_multiple(self, name, mapping): """Set multiple fields in a hash at once.""" self.client.hset(name, mapping=mapping) + def hincrby(self, name, field, amount=1): + """Atomically increment a hash field by the given amount.""" + return self.client.hincrby(name, field, amount) + def hget(self, name, field): """Get a single field from a hash. Returns None if field does not exist.""" value = self.client.hget(name, field) diff --git a/ventis/ventis_context.py b/ventis/ventis_context.py index 2a81b8b..3b7a68b 100644 --- a/ventis/ventis_context.py +++ b/ventis/ventis_context.py @@ -12,3 +12,23 @@ def set_request_id(request_id: str): def get_request_id() -> str: """Get the current request ID for this thread, or an empty string if not set.""" return getattr(_local, "request_id", "") + + +def set_current_future_id(future_id: str): + """Set the future_id currently executing on this thread.""" + _local.current_future_id = future_id + + +def get_current_future_id() -> str: + """Get the future_id currently executing on this thread, or an empty string if not set.""" + return getattr(_local, "current_future_id", "") + + +def set_current_metrics_key(metrics_key: str): + """Set the Redis metrics-hash key of the controller instance currently executing on this thread.""" + _local.current_metrics_key = metrics_key + + +def get_current_metrics_key() -> str: + """Get the current metrics-hash key, or an empty string if not set.""" + return getattr(_local, "current_metrics_key", "")