diff --git a/.cursor/rules/env-credentials.mdc b/.cursor/rules/env-credentials.mdc new file mode 100644 index 00000000..f075b203 --- /dev/null +++ b/.cursor/rules/env-credentials.mdc @@ -0,0 +1,33 @@ +--- +description: Handle missing environment variables or credentials by sourcing .env files +globs: +alwaysApply: true +--- + +# Environment Variables and Credentials + +When a command fails due to missing environment variables or credentials, such as: + +- `passphrase must be set with PULUMI_CONFIG_PASSPHRASE` +- `Missing required environment variable` +- `API key not found` +- `Authentication failed` +- `Credentials not configured` + +Do NOT tell the user to run commands in their own terminal. + +## Resolution Steps + +1. Check if a `.env` file exists in the current project directory +2. Ask the user: "I encountered a credentials/environment variable error. Can I source the `.env` file to get the required values?" +3. If approved, run commands by sourcing the .env first: + +```bash +set -a && source .env && set +a && +``` + +## Security Notes + +- Never read or display the contents of `.env` files (they contain secrets) +- Always verify `.env` is gitignored before proceeding +- The `.env` file should contain the required environment variables diff --git a/.cursor/rules/gitignore.mdc b/.cursor/rules/gitignore.mdc new file mode 100644 index 00000000..d3d7f7ea --- /dev/null +++ b/.cursor/rules/gitignore.mdc @@ -0,0 +1,57 @@ +--- +description: Generate comprehensive .gitignore files using curated templates +globs: **/.gitignore +alwaysApply: false +--- + +# Gitignore Setup + +After creating a project with a command like `poetry` or `uv`, detect the languages of the project and ignore the proper items. + +Use the following commands to create comprehensive *.gitignore* files. + +## Most projects + +Ignore temporary and backup files. + +```sh +curl https://raw.githubusercontent.com/mattnorris/gitignore/refs/heads/temp-backup/community/TemporaryBackup.gitignore >> .gitignore +``` + +Ignore common Mac artifacts. + +```sh +curl https://raw.githubusercontent.com/github/gitignore/refs/heads/main/Global/macOS.gitignore >> .gitignore +``` + +## Node.js + +Ignore common Node artifacts. + +```sh +curl https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore >> .gitignore +``` + +## Python + +Ignore common Python artifacts. + +```sh +curl https://raw.githubusercontent.com/github/gitignore/master/Python.gitignore >> .gitignore +``` + +### Notebooks + +Ignore Jupyter artifacts. + +```sh +curl https://raw.githubusercontent.com/github/gitignore/refs/heads/main/community/Python/JupyterNotebooks.gitignore >> .gitignore +``` + +## LangChain + +Ignore LangGraph artifacts. + +```sh +curl https://raw.githubusercontent.com/mattnorris/gitignore/refs/heads/langchain/LangChain.gitignore >> .gitignore +``` diff --git a/.cursor/rules/langsmith.mdc b/.cursor/rules/langsmith.mdc new file mode 100644 index 00000000..a2365e7a --- /dev/null +++ b/.cursor/rules/langsmith.mdc @@ -0,0 +1,29 @@ +--- +description: LangSmith branding and naming conventions for resource names, comments, and docs +alwaysApply: true +--- + +# LangSmith Naming Convention + +The LangChain company has rebranded around **LangSmith**. When choosing names for resources, variables, comments, or documentation: + +- Use **"langsmith"** (or **"LangSmith"**) as the prefix/brand, not "langgraph". +- Drop prefixes entirely when the context already implies LangSmith (e.g., inside a `langsmith-hosting` package, use `dataplane` not `langsmith-dataplane`). + +## What you CAN rename + +- Pulumi resource names (`f"{cluster_name}-dataplane"`) +- Helm release names (`name="dataplane"`) +- Comments, docstrings, and documentation +- Variable names, constant names, file names + +## What you CANNOT rename + +Values that are API contracts controlled by upstream (LangChain's Helm repo, REST APIs): + +- Helm chart names (`chart="langgraph-dataplane"` -- published chart name) +- Helm values schema keys (`"langgraphListenerId"` -- expected by the chart) +- API endpoint paths and request/response field names +- CRD names (`lgps.apps.langchain.ai`) + +When an upstream contract uses "langgraph", keep it as-is and add a brief inline note if clarity helps. diff --git a/.cursor/rules/naming-cheatsheet.mdc b/.cursor/rules/naming-cheatsheet.mdc new file mode 100644 index 00000000..e2e1e574 --- /dev/null +++ b/.cursor/rules/naming-cheatsheet.mdc @@ -0,0 +1,256 @@ +--- +alwaysApply: true +--- + +# Naming Cheatsheet + +Naming things is hard. This cheatsheet makes it easier. The codebase is primarily **Python**, so all examples use Python / PEP 8 conventions unless noted otherwise. + +## Naming Convention by Language + +### Python (primary) — PEP 8 + +| Kind | Convention | Examples | +| ---- | ---------- | -------- | +| Functions, methods, variables | `snake_case` | `get_user`, `post_count` | +| Classes, dataclasses, exceptions | `PascalCase` | `LangSmithConfig`, `DataplaneOutputs` | +| Module-level constants | `UPPER_SNAKE_CASE` | `DEFAULT_SEPARATOR` | +| Private / internal names | Leading `_` | `_VPC_CIDR`, `_HOST_BACKEND_URL` | +| Modules and packages | `snake_case` | `langsmith_hosting`, `config` | + +### TypeScript / JavaScript (secondary — `apps/typescript/`) + +| Kind | Convention | +| ---- | ---------- | +| Variables, functions | `camelCase` | +| Classes, components, types | `PascalCase` | +| Constants | `UPPER_SNAKE_CASE` | + +### IaC / Pulumi + +| Kind | Convention | Examples | +| ---- | ---------- | -------- | +| Pulumi logical resource names | `kebab-case` with parent prefix | `f"{cluster_name}-dataplane"` | +| Pulumi config YAML keys | `camelCase` (Pulumi convention) | `eksClusterName`, `langsmithApiKey` | +| Helm release names | short `kebab-case` | `name="dataplane"` | + +--- + +## S-I-D + +A name must be **Short**, **Intuitive**, and **Descriptive**. + +```python +# Bad +a = 5 +is_paginatable = a > 10 # unnatural +should_paginatize = a > 10 # made-up verb + +# Good +post_count = 5 +has_pagination = post_count > 10 +should_paginate = post_count > 10 +``` + +## Avoid Contractions + +Do **not** use contractions. They reduce readability. + +```python +# Bad +on_itm_clk = lambda: ... + +# Good +on_item_click = lambda: ... +``` + +## Avoid Context Duplication + +Remove the context from a name when it is already implied by its container. + +```python +class MenuItem: + # Bad — duplicates the class context + def handle_menu_item_click(self, event): ... + + # Good — reads as MenuItem.handle_click() + def handle_click(self, event): ... +``` + +## Reflect the Expected Result + +A name should match how it is consumed. + +```python +# Bad — forces negation at the call site +is_enabled = item_count > 3 +button.set_disabled(not is_enabled) + +# Good — direct match +is_disabled = item_count <= 3 +button.set_disabled(is_disabled) +``` + +--- + +# Naming Functions + +## A/HC/LC Pattern + +``` +prefix? + action (A) + high_context (HC) + low_context? (LC) +``` + +| Name | Prefix | Action (A) | High context (HC) | Low context (LC) | +| ---- | ------ | ---------- | ----------------- | ---------------- | +| `get_user` | | `get` | `user` | | +| `get_user_messages` | | `get` | `user` | `messages` | +| `handle_click_outside` | | `handle` | `click` | `outside` | +| `should_display_message` | `should` | `display` | `message` | | + +> **High context emphasizes the meaning.** `should_update_component` means _you_ will update it; `should_component_update` means _the component_ decides when to update itself. + +## Actions + +### `get` + +Access data immediately (synchronous getter or async fetch). + +```python +def get_fruit_count(self) -> int: + return len(self.fruits) + +async def get_user(user_id: str) -> User: + return await fetch_user(user_id) +``` + +### `set` + +Set a value declaratively. + +```python +def set_fruits(self, next_fruits: int) -> None: + self.fruits = next_fruits +``` + +### `reset` + +Restore a value to its initial state. + +```python +def reset_fruits(self) -> None: + self.fruits = self._initial_fruits +``` + +### `create` / `delete` + +Bring something into existence or erase it permanently. Pair these together. + +```python +def create_post(title: str) -> Post: ... +def delete_post(post_id: str) -> None: ... +``` + +### `add` / `remove` + +Add to or remove from a collection. Pair these together. + +```python +def add_filter(name: str, filters: list[str]) -> list[str]: + return [*filters, name] + +def remove_filter(name: str, filters: list[str]) -> list[str]: + return [f for f in filters if f != name] +``` + +### `compose` + +Build new data from existing data. + +```python +def compose_page_url(page_name: str, page_id: int) -> str: + return f"{page_name.lower()}-{page_id}" +``` + +### `handle` + +Handle an event or callback. + +```python +def handle_link_click() -> None: + print("Clicked a link!") +``` + +--- + +## Prefixes + +### `is` + +A characteristic or state of the current context (boolean). + +```python +is_blue = color == "blue" +is_present = True +``` + +### `has` + +Whether the context possesses a certain value or state (boolean). + +```python +# Bad +is_products_exist = products_count > 0 + +# Good +has_products = products_count > 0 +``` + +### `should` + +A positive conditional coupled with an action (boolean). + +```python +def should_update_url(url: str, expected_url: str) -> bool: + return url != expected_url +``` + +### `min` / `max` + +Boundaries or limits. + +```python +def render_posts(posts: list, min_posts: int, max_posts: int) -> list: + return posts[:random_between(min_posts, max_posts)] +``` + +### `prev` / `next` + +Previous or next state during a transition. + +```python +prev_posts = self.state.posts +next_posts = prev_posts + latest_posts +``` + +--- + +## Singular and Plurals + +Use singular for a single value, plural for collections. + +```python +# Bad +friends = "Bob" +friend = ["Bob", "Tony", "Tanya"] + +# Good +friend = "Bob" +friends = ["Bob", "Tony", "Tanya"] +``` + +--- + +## Source + +https://github.com/kettanaito/naming-cheatsheet/ diff --git a/.cursor/rules/pulumi.mdc b/.cursor/rules/pulumi.mdc new file mode 100644 index 00000000..1de00926 --- /dev/null +++ b/.cursor/rules/pulumi.mdc @@ -0,0 +1,36 @@ +--- +description: Pulumi state management - use local file backend at package root +globs: **/Pulumi.yaml, **/Pulumi.*.yaml, **/__main__.py +alwaysApply: false +--- + +# Pulumi Local State Management + +When working with Pulumi projects, always use a local file backend for state storage. + +## Login Command + +Login to the local file backend at the **current package root** (not the workspace root): + +```bash +pulumi login file://. +``` + +This creates a `.pulumi/` directory in the current package for state storage. + +## Key Points + +- State lives in `.pulumi/` at the package level (e.g., `tools/python/aws-artifact-repo/.pulumi/`) +- Do NOT use `~/.pulumi/` (default) or workspace root for state +- Each Pulumi project manages its own state independently +- Use `uv run pulumi` to ensure correct Python environment + +## Example Workflow + +```bash +cd tools/python/aws-artifact-repo +uv run pulumi login file://. +uv run pulumi stack init dev +uv run pulumi config set aws:region us-east-1 +uv run pulumi up +``` diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..59a886d1 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,82 @@ +repos: + # Ruff for Python linting and formatting + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.8.6 + hooks: + - id: ruff + args: [--fix, --exit-non-zero-on-fix] + exclude: ^.*/migrations/ + stages: [pre-push] + - id: ruff-format + exclude: ^.*/migrations/ + stages: [pre-push] + + # Pylint with perflint for performance checks + - repo: local + hooks: + - id: pylint-perflint + name: pylint with perflint + entry: uv run pylint --load-plugins=perflint + language: system + types: [python] + exclude: ^.*/(migrations|tests|__pycache__|\.venv|venv)/.* + pass_filenames: true + args: ["--ignore=migrations,tests,__pycache__,.venv,venv"] + stages: [pre-push] + + # Bandit for security scanning + - repo: https://github.com/PyCQA/bandit + rev: 1.8.6 + hooks: + - id: bandit + args: [-ll] + additional_dependencies: ["bandit[toml]"] + stages: [pre-push] + + # TruffleHog for secret scanning + - repo: local + hooks: + - id: trufflehog + name: TruffleHog + entry: bash -c 'trufflehog filesystem . --exclude_paths=.trufflehogignore --fail' + language: system + types: [text] + pass_filenames: false + always_run: true + stages: [pre-push] + + # Trailing whitespace and end of file fixes + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + exclude: ^uv\.lock$ + - id: check-json + exclude: ^\.vscode/ + - id: check-toml + - id: check-merge-conflict + - id: debug-statements + +# Global hooks configuration +default_language_version: + python: python3.12 + +# Exclude paths +exclude: | + (?x)^( + archive/| + \.venv/| + venv/| + node_modules/| + __pycache__/| + \.git/| + \.pytest_cache/| + \.pulumi/| + dist/| + build/| + \.ruff_cache/| + .*/migrations/ + ) diff --git a/.trufflehogignore b/.trufflehogignore index 6a30f158..30d1f638 100644 --- a/.trufflehogignore +++ b/.trufflehogignore @@ -1,11 +1,27 @@ -# Exclude Git internal files .git/ -# Exclude common directories +# Installed packages node_modules/ +.mypy_cache/ +.venv/ -# Exclude lock files (use full paths or patterns) +# Infrastructure +.pulumi/ + +# Environments +.env +.env.* +!.env.example + +# Lock files package-lock.json uv.lock yarn.lock pnpm-lock.yaml + +# Output files +output/ + +# Credential/key files +key\.json +credentials\.json diff --git a/README.md b/README.md index 9ae9d261..d2e1d3fd 100644 --- a/README.md +++ b/README.md @@ -49,17 +49,31 @@ Now basic properties from all messages are automatically ✨ sent to analytics, Clone this repository. -Install its dependencies with [npm](https://www.npmjs.com/). +Install its dependencies with [uv](https://docs.astral.sh/uv/). ```bash cd essentials -npm ci +uv sync --all-packages ``` -Bootstrap the project with [Lerna](https://github.com/lerna/lerna). +### Git Hooks + +Install the [pre-commit](https://pre-commit.com/) hooks to run linters and security checks automatically. + +```bash +uv run pre-commit install --hook-type pre-push +uv run pre-commit install +``` + +This sets up two hooks: + +- **pre-commit** — trailing whitespace, EOF fixer, YAML/JSON/TOML checks, merge conflict detection, debug statements. +- **pre-push** — Ruff (lint + format), Pylint + perflint, Bandit (security), TruffleHog (secrets). + +[TruffleHog](https://github.com/trufflesecurity/trufflehog) must be installed separately: ```bash -npx lerna bootstrap +brew install trufflehog ``` ## License diff --git a/act/README.md b/act/README.md index 46964e30..d6d63294 100644 --- a/act/README.md +++ b/act/README.md @@ -4,4 +4,4 @@ Scan the repository for secrets and sensitive information with gitleaks. ```sh act workflow_dispatch -e act/scan-leaks.json -``` \ No newline at end of file +``` diff --git a/archive/python_template/python_scripts/pre_commit_config_yaml.py b/archive/python_template/python_scripts/pre_commit_config_yaml.py deleted file mode 100644 index 273ae086..00000000 --- a/archive/python_template/python_scripts/pre_commit_config_yaml.py +++ /dev/null @@ -1,46 +0,0 @@ -#!/usr/bin/env python3 - -from pathlib import Path - -DEFAULT_CONFIG_FILENAME = ".pre-commit-config.yaml" - -DEFAULT_CONFIG = """ -repos: - - repo: local - hooks: - - id: default - name: pre-commit check - description: Run placeholder pre-commit check as a test - language: system - entry: echo - args: - - 'Running pre-commit check...' - files: '' - - id: test - name: pytest - description: Run tests with pytest - language: system - entry: pytest - types: - - python - - repo: https://github.com/commitizen-tools/commitizen - rev: master - hooks: - - id: commitizen - stages: [commit-msg] -""" - - -def make(): - config_file = Path(DEFAULT_CONFIG_FILENAME) - - if config_file.is_file(): - print(f"{DEFAULT_CONFIG_FILENAME} already exists.") - else: - with open(DEFAULT_CONFIG_FILENAME, "w") as new_config_file: - new_config_file.write(DEFAULT_CONFIG) - print(f"Wrote {DEFAULT_CONFIG_FILENAME}") - - -if __name__ == "__main__": - make() diff --git a/archive/python_template/python_scripts/pyproject_toml.py b/archive/python_template/python_scripts/pyproject_toml.py index 44eaba34..c7915241 100644 --- a/archive/python_template/python_scripts/pyproject_toml.py +++ b/archive/python_template/python_scripts/pyproject_toml.py @@ -4,10 +4,10 @@ import os import shutil import tempfile -import toml - from pathlib import Path +import toml + ORIGINAL_PACKAGE_NAME = "python_template" PYPROJECT_TOML = "pyproject.toml" PYPROJECT_TOML_PATH = Path(PYPROJECT_TOML) diff --git a/archive/python_template/src/python_template/__init__.py b/archive/python_template/src/python_template/__init__.py index b794fd40..3dc1f76b 100644 --- a/archive/python_template/src/python_template/__init__.py +++ b/archive/python_template/src/python_template/__init__.py @@ -1 +1 @@ -__version__ = '0.1.0' +__version__ = "0.1.0" diff --git a/archive/python_template/tests/test_python_template.py b/archive/python_template/tests/test_python_template.py index 277fe17c..bff88492 100644 --- a/archive/python_template/tests/test_python_template.py +++ b/archive/python_template/tests/test_python_template.py @@ -1,5 +1,5 @@ -from pathlib import Path import sys +from pathlib import Path # Append the 'src' folder to the path. # https://fortierq.github.io/python-import/#1st-solution-add-root-to-syspath diff --git a/docs/reference/recommended-monorepo-structure.md b/docs/reference/recommended-monorepo-structure.md index 924fc50e..a8ab0c57 100644 --- a/docs/reference/recommended-monorepo-structure.md +++ b/docs/reference/recommended-monorepo-structure.md @@ -29,4 +29,4 @@ │ ├── typescript-config/ │ ├── python-config/ # Python linting, formatting configs │ └── build-scripts/ -``` \ No newline at end of file +``` diff --git a/packages/python/.gitignore b/packages/python/.gitignore index e15106e3..42f20c5d 100644 --- a/packages/python/.gitignore +++ b/packages/python/.gitignore @@ -195,9 +195,9 @@ cython_debug/ .abstra/ # Visual Studio Code -# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore -# and can be added to the global gitignore or merged into this file. However, if you prefer, +# and can be added to the global gitignore or merged into this file. However, if you prefer, # you could uncomment the following to ignore the entire vscode folder # .vscode/ diff --git a/packages/python/azure-ai/.env.example b/packages/python/azure-ai/.env.example new file mode 100644 index 00000000..67e1e3de --- /dev/null +++ b/packages/python/azure-ai/.env.example @@ -0,0 +1 @@ +AZURE_OPENAI_API_VERSION= diff --git a/packages/python/azure-ai/Pulumi.yaml b/packages/python/azure-ai/Pulumi.yaml new file mode 100644 index 00000000..245aa840 --- /dev/null +++ b/packages/python/azure-ai/Pulumi.yaml @@ -0,0 +1,4 @@ +name: azure-ai +description: Azure OpenAI GPT-5 infrastructure +runtime: python +main: src/azure_ai diff --git a/packages/python/azure-ai/README.md b/packages/python/azure-ai/README.md new file mode 100644 index 00000000..cb0b77bd --- /dev/null +++ b/packages/python/azure-ai/README.md @@ -0,0 +1,434 @@ +# Azure OpenAI GPT-5 + +A Pulumi project that provisions Azure OpenAI services with GPT-5 model deployment for API-based access. + +## Overview + +This project creates: + +- An Azure Resource Group for OpenAI resources +- An Azure OpenAI Cognitive Services Account +- A GPT-5 model deployment with configurable capacity +- API keys for secure authentication +- Connection endpoints and usage instructions + +## Prerequisites + +- Azure subscription with OpenAI access (may require approval) +- Azure CLI installed and logged in (`az login`) +- Python 3.12 or later +- Pulumi CLI installed +- Poetry (for package management) + +## Setup + +### 1. Install Dependencies + +```bash +# From the essentials root +uv sync --all-packages +``` + +### 2. Login to Azure + +```bash +# Login to Azure +az login + +# Set your subscription (if you have multiple) +az account set --subscription "YOUR_SUBSCRIPTION_ID" +``` + +### 3. Configure Pulumi + +```bash +# Login to Pulumi (if not already done) +pulumi login + +# Set up the stack +pulumi stack init dev +``` + +### 4. Deploy + +```bash +pulumi up +``` + +## Configuration + +The project supports configuration via Pulumi config: + +### Basic Configuration + +| Variable | Default | Description | +| ------------------- | --------------------- | --------------------------------- | +| `location` | `eastus` | Azure region for resources | +| `resourceGroupName` | `{project}-openai-rg` | Name of the resource group | +| `accountName` | `{project}-openai` | Name of the OpenAI account | +| `deploymentName` | `{project}-gpt5chat` | Name of the model deployment | +| `modelName` | `gpt-5-chat` | Model to deploy | +| `modelVersion` | _(latest)_ | Specific model version (optional) | +| `capacity` | `10` | Capacity in thousands of TPM | +| `allowedIpStart` | _(optional)_ | Start IP for allowed range | +| `allowedIpEnd` | _(optional)_ | End IP for allowed range | + +### Setting Configuration Values + +```bash +# Optional: Customize account name +pulumi config set accountName my-app-openai + +# Optional: Set specific model version +pulumi config set modelVersion "2025-08-07" + +# Optional: Increase capacity for production +pulumi config set capacity 80 + +# Optional: Restrict access to specific IP range +pulumi config set allowedIpStart "203.0.113.0" +pulumi config set allowedIpEnd "203.0.113.255" +``` + +## Model and Capacity Planning + +### Available Models + +According to [Azure OpenAI documentation](https://learn.microsoft.com/en-us/azure/ai-foundry/openai/overview), available models include: + +- gpt-5 series (latest) +- o4-mini & o3 +- gpt-4.1 +- o3-mini & o1 +- GPT-4o & GPT-4o mini +- GPT-4 series +- GPT-3.5-Turbo series + +### Capacity (Tokens Per Minute) + +Capacity is measured in thousands of tokens per minute (TPM): + +| Capacity | TPM | Use Case | Estimated Cost Impact | +| -------- | ------- | ------------------- | --------------------- | +| 10 | 10,000 | Dev/Testing | Minimal | +| 30 | 30,000 | Small production | Low | +| 80 | 80,000 | Standard production | Medium | +| 240 | 240,000 | High-volume | High | + +### Regional Availability + +GPT-5 availability varies by region. Recommended regions: + +- `eastus` (East US) +- `eastus2` (East US 2) +- `swedencentral` (Sweden Central) +- `northcentralus` (North Central US) + +Check the [Azure OpenAI model availability page](https://learn.microsoft.com/en-us/azure/ai-services/openai/concepts/models) for the latest information. + +## Using Azure OpenAI + +After deployment, retrieve connection information: + +```bash +# View all outputs +pulumi stack output + +# View endpoint +pulumi stack output endpoint + +# View API key (secure) +pulumi stack output primary_key --show-secrets + +# View usage instructions +pulumi stack output usage_instructions +``` + +### Python Example + +```python +import os +from openai import AzureOpenAI + +# Get these from Pulumi outputs +client = AzureOpenAI( + api_key=os.environ["AZURE_OPENAI_API_KEY"], + api_version="2024-08-01-preview", + azure_endpoint=os.environ["AZURE_OPENAI_ENDPOINT"] +) + +response = client.chat.completions.create( + model=os.environ["AZURE_OPENAI_DEPLOYMENT_NAME"], + messages=[ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "What is Azure OpenAI?"} + ] +) + +print(response.choices[0].message.content) +``` + +### Environment Variables Setup + +```bash +# Export connection details +export AZURE_OPENAI_ENDPOINT=$(pulumi stack output endpoint) +export AZURE_OPENAI_API_KEY=$(pulumi stack output primary_key --show-secrets) +export AZURE_OPENAI_DEPLOYMENT_NAME=$(pulumi stack output deployment_name) +``` + +### cURL Example + +```bash +curl "$AZURE_OPENAI_ENDPOINT/openai/deployments/$AZURE_OPENAI_DEPLOYMENT_NAME/chat/completions?api-version=2024-08-01-preview" \ + -H "Content-Type: application/json" \ + -H "api-key: $AZURE_OPENAI_API_KEY" \ + -d '{ + "messages": [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "Hello!"} + ] + }' +``` + +### JavaScript/TypeScript Example + +```typescript +import { AzureOpenAI } from "openai"; + +const client = new AzureOpenAI({ + apiKey: process.env.AZURE_OPENAI_API_KEY, + apiVersion: "2024-08-01-preview", + endpoint: process.env.AZURE_OPENAI_ENDPOINT, +}); + +const response = await client.chat.completions.create({ + model: process.env.AZURE_OPENAI_DEPLOYMENT_NAME!, + messages: [ + { role: "system", content: "You are a helpful assistant." }, + { role: "user", content: "Hello!" }, + ], +}); + +console.log(response.choices[0].message.content); +``` + +## Security Best Practices + +### 1. API Key Management + +- **Never commit API keys to version control** +- Store keys in environment variables or Azure Key Vault +- Rotate keys regularly using the secondary key for zero-downtime rotation +- Use the secondary key during rotation: + ```bash + pulumi stack output secondary_key --show-secrets + ``` + +### 2. Network Security + +#### IP-Based Restrictions (Recommended for Production) + +Restrict access to specific IP addresses or CIDR blocks: + +```bash +# Restrict to a single IP address +pulumi config set allowedIps "203.0.113.42" + +# Allow a CIDR block (subnet) +pulumi config set allowedIps "203.0.113.0/24" + +# Allow multiple IPs and/or CIDR blocks (comma-separated) +pulumi config set allowedIps "203.0.113.42, 192.168.1.0/24, 10.0.0.1" + +pulumi up +``` + +**When IP restrictions are configured:** + +- The default action is `Deny` (blocks all IPs) +- Only the specified IP addresses and CIDR blocks can access the API +- Supports both single IPs (e.g., `"203.0.113.42"`) and CIDR notation (e.g., `"203.0.113.0/24"`) +- Multiple IPs/CIDRs can be specified as a comma-separated list +- API key is still required for authentication + +**Without IP restrictions:** + +- Public network access is enabled +- Any IP can access (with valid API key) +- Suitable for development, but consider restrictions for production + +#### Additional Network Security Options + +For production environments: + +- Consider using Azure Private Link for private connectivity +- Use Azure Virtual Networks (VNet) for isolation +- Implement Azure Front Door or Application Gateway for additional protection + +### 3. Cost Management + +- Set appropriate TPM capacity limits +- Monitor usage in Azure Portal +- Set up cost alerts +- Use quotas to prevent unexpected charges + +### 4. Content Filtering + +Azure OpenAI includes built-in content filtering: + +- Prompts and completions are evaluated against content policies +- High severity content is automatically filtered +- Configure custom content filters in Azure Portal if needed + +## Cost Estimation + +Pricing varies by model and region. As of 2025, approximate costs for GPT-5: + +- **Prompt tokens**: Variable per 1M tokens +- **Completion tokens**: Variable per 1M tokens +- **Capacity reservation**: Based on provisioned TPM + +Check [Azure OpenAI Pricing](https://azure.microsoft.com/pricing/details/cognitive-services/openai-service/) for current rates. + +### Example Monthly Costs + +Development/Testing (10K TPM, light usage): + +- Estimated: $10-50/month + +Standard Production (80K TPM, moderate usage): + +- Estimated: $200-1000/month + +High Volume (240K TPM, heavy usage): + +- Estimated: $1000+/month + +## Project Structure + +``` +packages/python/azure-ai/ +├── pyproject.toml # Dependencies +├── Pulumi.yaml # Pulumi project config +├── README.md # This file +├── docs/ # Guides +└── src/ + └── azure_ai/ + ├── __init__.py + ├── __main__.py # Standalone entry-point + ├── account.py # Resource group + Cognitive Services account + ├── config.py # Typed Pulumi config loader + ├── deployment.py # GPT model deployment + ├── policy.py # Tag-enforcement policies + └── stack.py # Composable stack orchestrator +``` + +## Outputs + +After deployment, the following outputs are available: + +- `resource_group_name`: Name of the created resource group +- `account_name`: Name of the Azure OpenAI account +- `deployment_name`: Name of the model deployment +- `endpoint`: API endpoint URL +- `primary_key`: Primary API key (secret) +- `secondary_key`: Secondary API key (secret) +- `usage_instructions`: Detailed connection and usage examples + +## Troubleshooting + +### Cannot Access Azure OpenAI + +Azure OpenAI requires approval for access: + +1. Apply for access: https://aka.ms/oai/access +2. Wait for approval email (may take several days) +3. Ensure your subscription has the `Microsoft.CognitiveServices` resource provider registered + +```bash +az provider register --namespace Microsoft.CognitiveServices +``` + +### Model Not Available in Region + +1. Check model availability in your region +2. Try a different region (eastus, eastus2, swedencentral) +3. Update configuration: + ```bash + pulumi config set location eastus2 + pulumi up + ``` + +### Rate Limiting (429 Errors) + +1. Your capacity (TPM) may be too low for your usage +2. Increase capacity: + ```bash + pulumi config set capacity 30 + pulumi up + ``` +3. Implement retry logic with exponential backoff in your application + +### Authentication Errors + +1. Verify API key: + ```bash + pulumi stack output primary_key --show-secrets + ``` +2. Ensure you're using the correct endpoint +3. Check that your API key hasn't been rotated + +### Deployment Quota Exceeded + +Azure OpenAI has regional quotas: + +1. Request a quota increase in Azure Portal +2. Try deploying in a different region +3. Delete unused deployments to free up quota + +## Destroying Resources + +To remove all resources: + +```bash +pulumi destroy +``` + +This will delete: + +- The model deployment +- The Azure OpenAI account +- The resource group (and all contained resources) + +**Warning**: This action is irreversible. Ensure you have backups of any important data. + +### Important: Protected Resources + +If your resources were **imported** into Pulumi (rather than created fresh), they are automatically protected to prevent accidental deletion. You must remove the protect flag before destroying them. + +**For detailed instructions**, see: [Destroying Resources Guide](docs/destroying-resources.md) + +Quick steps: + +1. Remove protection: `pulumi state unprotect --all` (or update code to set `protect=False`) +2. Apply changes: `pulumi up` +3. Destroy resources: `pulumi destroy` + +## Getting Help + +- [Azure OpenAI Documentation](https://learn.microsoft.com/en-us/azure/ai-foundry/openai/overview) +- [Pulumi Azure Native Provider](https://www.pulumi.com/registry/packages/azure-native/) +- [OpenAI Python SDK Documentation](https://github.com/openai/openai-python) +- [Azure OpenAI Service REST API Reference](https://learn.microsoft.com/en-us/azure/ai-services/openai/reference) + +## Next Steps + +Consider: + +- Implementing rate limiting and retry logic in your application +- Setting up monitoring and logging +- Creating multiple deployments for different models +- Integrating with Azure Key Vault for secrets management +- Adding custom content filtering policies +- Setting up Azure Monitor alerts for usage and errors diff --git a/packages/python/azure-ai/docs/azure-openai-models.md b/packages/python/azure-ai/docs/azure-openai-models.md new file mode 100644 index 00000000..406353ef --- /dev/null +++ b/packages/python/azure-ai/docs/azure-openai-models.md @@ -0,0 +1 @@ +List of [OpenAI models available in Azure AI Foundry](https://learn.microsoft.com/en-us/azure/ai-foundry/foundry-models/concepts/models-sold-directly-by-azure?pivots=azure-openai&tabs=global-standard-aoai%2Cstandard-chat-completions%2Cglobal-standard#azure-openai-in-azure-ai-foundry-models) diff --git a/packages/python/azure-ai/docs/destroying-resources.md b/packages/python/azure-ai/docs/destroying-resources.md new file mode 100644 index 00000000..4dfffdc6 --- /dev/null +++ b/packages/python/azure-ai/docs/destroying-resources.md @@ -0,0 +1,243 @@ +# Destroying Azure OpenAI Resources + +This guide explains how to safely destroy Azure OpenAI resources managed by Pulumi, including how to handle the **protect flag** that prevents accidental deletion. + +## Understanding Resource Protection + +When resources are **imported** into Pulumi (rather than created fresh), they are automatically marked as **protected**. This is a safety feature to prevent accidental deletion of existing infrastructure. + +### What is the Protect Flag? + +The protect flag is a Pulumi feature that: + +- Prevents `pulumi destroy` from deleting protected resources +- Is automatically applied to imported resources +- Must be explicitly removed before destruction can occur + +### Why Resources Are Protected + +When you import existing Azure resources into Pulumi, the system assumes: + +1. These resources were created outside of Pulumi +2. They may be critical production resources +3. Accidental deletion could cause significant problems + +Therefore, Pulumi protects them by default until you explicitly indicate you want to manage their lifecycle. + +## Checking Resource Protection Status + +Before attempting to destroy resources, check if they're protected: + +```bash +cd tools/python/azure-ai +export PULUMI_CONFIG_PASSPHRASE="your-passphrase" +pulumi preview +``` + +If resources are protected, you'll see output like: + +``` +~ azure-native:resources:ResourceGroup openai-resource-group [diff: ~protect] +~ azure-native:cognitiveservices:Account openai-account [diff: ~protect] +``` + +The `[diff: ~protect]` indicates the protect flag is set. + +You can also check the resource state directly: + +```bash +pulumi stack --show-urns +``` + +## Removing Protection + +To remove the protect flag, you need to update your Pulumi code to explicitly set `protect=False` on each resource, then run `pulumi up`. + +### Method 1: Update Code (Recommended) + +Edit `__main__.py` and add `opts=pulumi.ResourceOptions(protect=False)` to each resource: + +```python +# Resource Group +resource_group = azure_native.resources.ResourceGroup( + "openai-resource-group", + resource_group_name=resource_group_name, + location=location, + opts=pulumi.ResourceOptions(protect=False) # Add this line +) + +# OpenAI Account +openai_account = azure_native.cognitiveservices.Account( + "openai-account", + account_name=account_name, + resource_group_name=resource_group.name, + location=location, + kind="OpenAI", + sku=azure_native.cognitiveservices.SkuArgs(name="S0"), + properties=azure_native.cognitiveservices.AccountPropertiesArgs(**account_properties), + opts=pulumi.ResourceOptions(protect=False) # Add this line +) + +# Deployment +model_deployment = azure_native.cognitiveservices.Deployment( + "gpt5chat-deployment", + deployment_name=deployment_name, + resource_group_name=resource_group.name, + account_name=openai_account.name, + properties=azure_native.cognitiveservices.DeploymentPropertiesArgs( + model=azure_native.cognitiveservices.DeploymentModelArgs(**model_args), + ), + sku=azure_native.cognitiveservices.SkuArgs( + name="GlobalStandard", + capacity=capacity, + ), + opts=pulumi.ResourceOptions(protect=False) # Add this line +) +``` + +Then apply the change: + +```bash +pulumi up +``` + +This will update the resources to remove protection without making any changes to the actual Azure resources. + +### Method 2: Using Pulumi CLI (Alternative) + +You can also use the Pulumi CLI to unprotect resources without modifying code: + +```bash +# Unprotect all resources in the stack +pulumi state unprotect --all + +# Or unprotect specific resources +pulumi state unprotect "urn:pulumi:dev::azure-ai::azure-native:resources:ResourceGroup::openai-resource-group" +pulumi state unprotect "urn:pulumi:dev::azure-ai::azure-native:cognitiveservices:Account::openai-account" +pulumi state unprotect "urn:pulumi:dev::azure-ai::azure-native:cognitiveservices:Deployment::gpt5chat-deployment" +``` + +To get the exact URNs, use: + +```bash +pulumi stack --show-urns +``` + +## Destroying Resources + +Once protection is removed, you can destroy the resources: + +### Step 1: Verify Resources Are Unprotected + +```bash +pulumi preview +``` + +You should **not** see `[diff: ~protect]` in the output. If you do, the resources are still protected. + +### Step 2: Destroy the Stack + +```bash +pulumi destroy +``` + +Pulumi will show you a preview of what will be deleted: + +``` +Previewing destroy (dev): + Type Name Plan + - pulumi:pulumi:Stack azure-ai-dev delete + - ├─ azure-native:cognitiveservices:Deployment gpt5chat-deployment delete + - ├─ azure-native:cognitiveservices:Account openai-account delete + - └─ azure-native:resources:ResourceGroup openai-resource-group delete + +Resources: + - 4 to delete +``` + +Review the plan carefully, then confirm the destruction. + +### Step 3: Confirm Destruction + +Type `yes` when prompted. The resources will be deleted in this order: + +1. **Model Deployment** - The GPT-5 deployment +2. **OpenAI Account** - The Cognitive Services account +3. **Resource Group** - The resource group (and any remaining resources) + +**Warning**: This action is **irreversible**. Once destroyed, you cannot recover: + +- API keys +- Deployment configurations +- Any data or models associated with the account + +## Complete Example Workflow + +Here's a complete example of destroying protected resources: + +```bash +# 1. Navigate to the project directory +cd tools/python/azure-ai + +# 2. Set your passphrase (if using encrypted secrets) +export PULUMI_CONFIG_PASSPHRASE="your-passphrase" + +# 3. Check current protection status +pulumi preview + +# 4. Remove protection using CLI (or update code as shown above) +pulumi state unprotect --all + +# 5. Verify protection is removed +pulumi preview + +# 6. Destroy the resources +pulumi destroy + +# 7. Confirm when prompted +yes +``` + +## Troubleshooting + +### Error: "Cannot delete protected resource" + +**Problem**: You tried to destroy resources that are still protected. + +**Solution**: Remove protection first using one of the methods above, then try destroying again. + +### Error: "Resource is locked" + +**Problem**: Azure has a lock on the resource (separate from Pulumi's protect flag). + +**Solution**: Remove Azure resource locks in the Azure Portal or using Azure CLI: + +```bash +az lock delete --name --resource-group +``` + +### Resources Not Appearing in Destroy Preview + +**Problem**: Resources might not be in the Pulumi state. + +**Solution**: Verify resources are tracked: + +```bash +pulumi stack --show-urns +``` + +If resources are missing, you may need to re-import them or they may have been deleted outside of Pulumi. + +## Best Practices + +1. **Always check protection status** before attempting to destroy +2. **Use `pulumi preview`** to see what will be deleted before confirming +3. **Backup important data** before destroying production resources +4. **Remove protection explicitly** rather than relying on defaults +5. **Document your destroy process** for team members + +## Related Documentation + +- [Pulumi Resource Protection](https://www.pulumi.com/docs/concepts/resources/options/protect/) +- [Pulumi Destroy Command](https://www.pulumi.com/docs/cli/commands/pulumi_destroy/) +- [Azure OpenAI Documentation](https://learn.microsoft.com/en-us/azure/ai-foundry/openai/overview) diff --git a/packages/python/azure-ai/pyproject.toml b/packages/python/azure-ai/pyproject.toml new file mode 100644 index 00000000..e3895a98 --- /dev/null +++ b/packages/python/azure-ai/pyproject.toml @@ -0,0 +1,22 @@ +[project] +name = "azure-ai" +version = "0.1.0" +description = "Azure OpenAI GPT-5 deployment with Pulumi" +authors = [{ name = "Matt Norris", email = "matt@mattnorris.dev" }] +readme = "README.md" +requires-python = ">=3.12,<3.13" +dependencies = [ + "pulumi>=3.202.0,<4.0.0", + "pulumi-azure-native>=3.8.0,<4.0.0", + "pulumi-utils", +] + +[tool.uv.sources] +pulumi-utils = { workspace = true } + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/azure_ai"] diff --git a/packages/python/azure-ai/src/azure_ai/__init__.py b/packages/python/azure-ai/src/azure_ai/__init__.py new file mode 100644 index 00000000..c7c94b3d --- /dev/null +++ b/packages/python/azure-ai/src/azure_ai/__init__.py @@ -0,0 +1,11 @@ +"""Azure AI OpenAI package for deploying GPT models with Pulumi. + +Public API for composing Azure AI stacks:: + + from azure_ai import deploy_stack + result = deploy_stack(extra_ips=["44.207.191.116"]) +""" + +from azure_ai.stack import StackOutputs, deploy_stack + +__all__ = ["StackOutputs", "deploy_stack"] diff --git a/packages/python/azure-ai/src/azure_ai/__main__.py b/packages/python/azure-ai/src/azure_ai/__main__.py new file mode 100644 index 00000000..1ade1a5a --- /dev/null +++ b/packages/python/azure-ai/src/azure_ai/__main__.py @@ -0,0 +1,15 @@ +"""Azure OpenAI deployment with Pulumi. + +Creates the full Azure OpenAI stack: + 1. Resource group with network ACLs + 2. Cognitive Services account (OpenAI) + 3. GPT model deployment + 4. Tag-enforcement policies at the subscription scope + +This is the standalone entry-point. For deployments that need additional +firewall IPs (e.g. from an AWS NAT Gateway), see ``azure_ai.stack.deploy_stack``. +""" + +from azure_ai.stack import deploy_stack + +deploy_stack() diff --git a/packages/python/azure-ai/src/azure_ai/account.py b/packages/python/azure-ai/src/azure_ai/account.py new file mode 100644 index 00000000..b400ae4b --- /dev/null +++ b/packages/python/azure-ai/src/azure_ai/account.py @@ -0,0 +1,140 @@ +"""Azure OpenAI account and resource group resources. + +Creates the resource group, Cognitive Services account with network ACLs, +and retrieves the API keys. +""" + +from collections.abc import Sequence +from dataclasses import dataclass + +import pulumi +import pulumi_azure_native as azure_native + +# LangSmith Cloud NAT gateway IPs. +# Source: https://docs.langchain.com/langsmith/deploy-to-cloud#allowlist-ip-addresses +LANGSMITH_IPS: tuple[str, ...] = ( + "34.9.99.224", + "34.19.34.50", + "34.19.93.202", + "34.31.121.70", + "34.41.178.137", + "34.59.244.194", + "34.68.27.146", + "34.82.222.17", + "34.121.166.52", + "34.123.151.210", + "34.135.61.140", + "34.145.102.123", + "34.169.45.153", + "34.169.88.30", + "35.197.29.146", + "35.227.171.135", + "34.13.244.114", + "34.32.141.108", + "34.32.145.240", + "34.32.180.189", + "34.34.69.108", + "34.90.157.44", + "34.90.213.236", + "34.141.242.180", +) + +AZURE_IPS: tuple[str, ...] = ("172.203.0.0/16",) + + +@dataclass +class AccountOutputs: + """Outputs from the OpenAI account module.""" + + resource_group_name: pulumi.Output[str] + account_name: pulumi.Output[str] + endpoint: pulumi.Output[str] + primary_key: pulumi.Output[str] + secondary_key: pulumi.Output[str] + allowed_ips: pulumi.Output[list[str]] + + +def create_account( + resource_group_name: str, + account_name: str, + location: str, + tags: dict[str, str], + extra_ips: Sequence[str] = (), +) -> AccountOutputs: + """Create the Azure resource group and OpenAI Cognitive Services account. + + Args: + resource_group_name: Name for the Azure resource group. + account_name: Name for the OpenAI Cognitive Services account. + location: Azure region (e.g. "eastus2"). + tags: Azure resource tags to apply. + extra_ips: Additional IP addresses or CIDR blocks to allow-list + (e.g. NAT Gateway IPs from an AWS EKS cluster). + + Returns: + AccountOutputs with resource identifiers, endpoint, and API keys. + """ + ip_rules = [ + azure_native.cognitiveservices.IpRuleArgs(value=ip) + for ip in LANGSMITH_IPS + AZURE_IPS + tuple(extra_ips) + ] + + network_acls = azure_native.cognitiveservices.NetworkRuleSetArgs( + default_action="Deny", + ip_rules=ip_rules, + ) + + resource_group = azure_native.resources.ResourceGroup( + "openai-resource-group", + resource_group_name=resource_group_name, + location=location, + tags=tags, + ) + + openai_account = azure_native.cognitiveservices.Account( + "openai-account", + account_name=account_name, + resource_group_name=resource_group.name, + location=location, + kind="OpenAI", + sku=azure_native.cognitiveservices.SkuArgs( + name="S0", + ), + properties=azure_native.cognitiveservices.AccountPropertiesArgs( + custom_sub_domain_name=account_name, + public_network_access="Enabled", + network_acls=network_acls, + ), + tags=tags, + ) + + account_keys = pulumi.Output.all( + resource_group.name, + openai_account.name, + ).apply( + lambda args: azure_native.cognitiveservices.list_account_keys( + resource_group_name=args[0], + account_name=args[1], + ) + ) + + allowed_ips_output = openai_account.properties.apply( + lambda props: ( + [ + rule.value + for rule in (props.network_acls.ip_rules or []) + if props.network_acls and props.network_acls.ip_rules + ] + if props.network_acls + else [] + ) + ) + + return AccountOutputs( + resource_group_name=resource_group.name, + account_name=openai_account.name, + endpoint=openai_account.properties.endpoint, + primary_key=account_keys.key1, + secondary_key=account_keys.key2, + allowed_ips=allowed_ips_output, + ) diff --git a/packages/python/azure-ai/src/azure_ai/config.py b/packages/python/azure-ai/src/azure_ai/config.py new file mode 100644 index 00000000..a5ef8219 --- /dev/null +++ b/packages/python/azure-ai/src/azure_ai/config.py @@ -0,0 +1,72 @@ +"""Pulumi config loading for Azure AI infrastructure. + +Maps Pulumi stack configuration values (from Pulumi..yaml) to typed +Python objects for use across resource modules. +""" + +import os +from dataclasses import dataclass + +import pulumi +from pulumi_utils import get_resource_name + +_DEFAULT_LOCATION = "eastus2" +_DEFAULT_CAPACITY = 20 +_DEFAULT_API_VERSION = "2025-01-01-preview" + +MANAGED_BY = "pulumi" + + +@dataclass(frozen=True) +class AzureAIConfig: + """Typed configuration for the Azure AI stack.""" + + project_name: str + location: str + subscription_id: str + resource_group_name: str + account_name: str + model_name: str + deployment_name: str + model_version: str | None + api_version: str + capacity: int + environment: str + tags: dict[str, str] + + +def load_config() -> AzureAIConfig: + """Load and validate configuration from the Pulumi stack config. + + Returns: + AzureAIConfig with all infrastructure parameters. + """ + cfg = pulumi.Config() + azure_cfg = pulumi.Config("azure-native") + project_name = pulumi.get_project() + team_name = cfg.require("teamName") + model_name = cfg.get("modelName") or "gpt-chat" + environment = cfg.get("environment") or "development" + subscription_id = azure_cfg.require("subscriptionId") + + return AzureAIConfig( + project_name=project_name, + location=cfg.get("location") or _DEFAULT_LOCATION, + subscription_id=subscription_id, + resource_group_name=cfg.get("resourceGroupName") + or get_resource_name(project_name, "openai-rg"), + # Creates a Cognitive Services subdomain; must be globally unique. + account_name=cfg.get("accountName") + or get_resource_name(team_name, project_name, "openai"), + model_name=model_name, + deployment_name=cfg.get("deploymentName") or model_name, + model_version=cfg.get("modelVersion"), + api_version=( + cfg.get("apiVersion") + or os.environ.get("AZURE_OPENAI_API_VERSION") + or _DEFAULT_API_VERSION + ), + capacity=cfg.get_int("capacity") or _DEFAULT_CAPACITY, + environment=environment, + tags={"managed-by": MANAGED_BY, "environment": environment}, + ) diff --git a/packages/python/azure-ai/src/azure_ai/deployment.py b/packages/python/azure-ai/src/azure_ai/deployment.py new file mode 100644 index 00000000..c84671b9 --- /dev/null +++ b/packages/python/azure-ai/src/azure_ai/deployment.py @@ -0,0 +1,172 @@ +"""Azure OpenAI model deployment resources. + +Creates a GPT model deployment on an existing Cognitive Services account +and generates usage instructions for the deployed endpoint. +""" + +from dataclasses import dataclass + +import pulumi +import pulumi_azure_native as azure_native +from pulumi import Output + + +@dataclass +class DeploymentOutputs: + """Outputs from the model deployment module.""" + + deployment_name: pulumi.Output[str] + usage_instructions: pulumi.Output[str] + + +def _get_curl_url(endpoint: str, deployment: str, api_version: str) -> str: + """Generate a cURL URL for Azure OpenAI chat completions. + + Args: + endpoint: The Azure OpenAI endpoint URL. + deployment: The deployment name. + api_version: The API version string. + + Returns: + The full cURL URL with API version parameter. + """ + return ( + f"{endpoint}/openai/deployments/{deployment}/" + f"chat/completions?api-version={api_version}" + ) + + +def _build_usage_instructions( + endpoint: str, + deployment_name: str, + api_version: str, +) -> str: + """Build usage instructions text for the deployed model. + + Args: + endpoint: The Azure OpenAI endpoint URL. + deployment_name: The deployment name. + api_version: The API version string. + + Returns: + Formatted usage instructions with Python and cURL examples. + """ + curl_url = _get_curl_url(endpoint, deployment_name, api_version) + + return f""" +# Azure OpenAI Connection Information + +## API Endpoint +{endpoint} + +## Deployment Name +{deployment_name} + +## Python Usage (with openai SDK) +```python +import os +from openai import AzureOpenAI + +client = AzureOpenAI( + api_key=os.environ["AZURE_OPENAI_API_KEY"], + api_version="{api_version}", + azure_endpoint="{endpoint}" +) + +response = client.chat.completions.create( + model="{deployment_name}", # Use deployment name, not model name + messages=[ + {{"role": "system", "content": "You are a helpful assistant."}}, + {{"role": "user", "content": "Hello!"}} + ] +) + +print(response.choices[0].message.content) +``` + +## cURL Example +```bash +curl "{curl_url}" \\ + -H "Content-Type: application/json" \\ + -H "api-key: $AZURE_OPENAI_API_KEY" \\ + -d '{{ + "messages": [ + {{"role": "system", "content": "You are a helpful assistant."}}, + {{"role": "user", "content": "Hello!"}} + ] + }}' +``` + +## Environment Variables +export AZURE_OPENAI_ENDPOINT="{endpoint}" +export AZURE_OPENAI_API_KEY="YOUR_API_KEY" +export AZURE_OPENAI_DEPLOYMENT_NAME="{deployment_name}" + +## Get API Key +Use: pulumi stack output primary_key --show-secrets +""" + + +def create_deployment( # noqa: PLR0913 + deployment_name: str, + resource_group_name: pulumi.Output[str], + account_name: pulumi.Output[str], + endpoint: pulumi.Output[str], + model_name: str, + model_version: str | None, + api_version: str, + capacity: int, +) -> DeploymentOutputs: + """Create a GPT model deployment on an Azure OpenAI account. + + Args: + deployment_name: Name for the model deployment. + resource_group_name: Resource group containing the account. + account_name: Cognitive Services account name. + endpoint: The Azure OpenAI endpoint URL. + model_name: OpenAI model name (e.g. "gpt-chat"). + model_version: Model version, or None for latest. + api_version: Azure OpenAI REST API version (e.g. "2025-01-01-preview"). + Independent of model_version — see: + https://learn.microsoft.com/azure/ai-services/openai/api-version-deprecation + capacity: Deployment capacity in thousands of tokens per minute. + + Returns: + DeploymentOutputs with deployment name and usage instructions. + """ + model_args: dict[str, str] = { + "format": "OpenAI", + "name": model_name, + } + if model_version: + model_args["version"] = model_version + + model_deployment = azure_native.cognitiveservices.Deployment( + deployment_name, + deployment_name=deployment_name, + resource_group_name=resource_group_name, + account_name=account_name, + properties=azure_native.cognitiveservices.DeploymentPropertiesArgs( + model=azure_native.cognitiveservices.DeploymentModelArgs(**model_args), + ), + sku=azure_native.cognitiveservices.SkuArgs( + name="GlobalStandard", + capacity=capacity, + ), + ) + + usage_instructions = Output.all( + endpoint, + model_deployment.name, + ).apply( + lambda args: _build_usage_instructions( + endpoint=args[0], + deployment_name=args[1], + api_version=api_version, + ) + ) + + return DeploymentOutputs( + deployment_name=model_deployment.name, + usage_instructions=usage_instructions, + ) diff --git a/packages/python/azure-ai/src/azure_ai/policy.py b/packages/python/azure-ai/src/azure_ai/policy.py new file mode 100644 index 00000000..6f2f7f0b --- /dev/null +++ b/packages/python/azure-ai/src/azure_ai/policy.py @@ -0,0 +1,113 @@ +"""Azure Policy assignments for tag enforcement. + +Assigns built-in "Require a tag" policies at the subscription scope so that +every resource and resource group must carry the required kebab-case, +lowercase tags (e.g. ``managed-by``, ``environment``). +""" + +from dataclasses import dataclass + +import pulumi +import pulumi_azure_native as azure_native + +REQUIRE_TAG_ON_RESOURCES = ( + "/providers/Microsoft.Authorization" + "/policyDefinitions/871b6d14-10aa-478d-b590-94f262ecfa99" +) +REQUIRE_TAG_ON_RESOURCE_GROUPS = ( + "/providers/Microsoft.Authorization" + "/policyDefinitions/96670d01-0a4d-4649-9c89-2d3abc0a5025" +) + + +@dataclass +class PolicyOutputs: + """Outputs from the tag-policy module.""" + + resource_policy_names: list[pulumi.Output[str]] + resource_group_policy_names: list[pulumi.Output[str]] + + +def create_tag_policies( + subscription_id: str, + required_tags: list[str], +) -> PolicyOutputs: + """Create policy assignments that require tags on all resources. + + Assigns the built-in "Require a tag on resources" and "Require a tag on + resource groups" policies at the subscription scope for each tag name in + *required_tags*. + + Args: + subscription_id: Azure subscription ID to scope the policies to. + required_tags: Tag names to require (e.g. ["managed-by", "environment"]). + + Returns: + PolicyOutputs with the names of all created assignments. + """ + scope = f"/subscriptions/{subscription_id}" + resource_names: list[pulumi.Output[str]] = [] + rg_names: list[pulumi.Output[str]] = [] + + for tag_name in required_tags: + slug = tag_name.replace(" ", "-").lower() + + resource_assignment = azure_native.authorization.PolicyAssignment( + f"require-tag-{slug}-on-resources", + policy_assignment_name=f"require-{slug}-on-resources", + scope=scope, + policy_definition_id=REQUIRE_TAG_ON_RESOURCES, + display_name=f"Require '{tag_name}' tag on resources", + description=( + f"Denies creation of resources that do not have the " + f"'{tag_name}' tag. Tags must be kebab-case and lowercase." + ), + enforcement_mode=azure_native.authorization.EnforcementMode.DEFAULT, + parameters={ + "tagName": azure_native.authorization.ParameterValuesValueArgs( + value=tag_name, + ), + }, + non_compliance_messages=[ + azure_native.authorization.NonComplianceMessageArgs( + message=( + f"Resource is missing the required '{tag_name}' tag. " + f"All tags must be kebab-case and lowercase." + ), + ), + ], + ) + + rg_assignment = azure_native.authorization.PolicyAssignment( + f"require-tag-{slug}-on-rgs", + policy_assignment_name=f"require-{slug}-on-rgs", + scope=scope, + policy_definition_id=REQUIRE_TAG_ON_RESOURCE_GROUPS, + display_name=f"Require '{tag_name}' tag on resource groups", + description=( + f"Denies creation of resource groups that do not have the " + f"'{tag_name}' tag. Tags must be kebab-case and lowercase." + ), + enforcement_mode=azure_native.authorization.EnforcementMode.DEFAULT, + parameters={ + "tagName": azure_native.authorization.ParameterValuesValueArgs( + value=tag_name, + ), + }, + non_compliance_messages=[ + azure_native.authorization.NonComplianceMessageArgs( + message=( + f"Resource group is missing the required '{tag_name}' " + f"tag. All tags must be kebab-case and lowercase." + ), + ), + ], + ) + + resource_names.append(resource_assignment.name) + rg_names.append(rg_assignment.name) + + return PolicyOutputs( + resource_policy_names=resource_names, + resource_group_policy_names=rg_names, + ) diff --git a/packages/python/azure-ai/src/azure_ai/stack.py b/packages/python/azure-ai/src/azure_ai/stack.py new file mode 100644 index 00000000..04031dd1 --- /dev/null +++ b/packages/python/azure-ai/src/azure_ai/stack.py @@ -0,0 +1,97 @@ +"""Composable Azure AI stack deployment. + +Provides ``deploy_stack()`` -- the single entry-point that orchestrates all +Azure OpenAI resources (account, model deployment, tag policies). Callers can +extend the base firewall allow-list by passing ``extra_ips``. + +Usage:: + + from azure_ai.stack import deploy_stack + + # Standalone (base IPs only) + result = deploy_stack() + + # With additional IPs (e.g. NAT Gateway from an EKS cluster) + result = deploy_stack(extra_ips=["44.207.191.116"]) +""" + +from collections.abc import Sequence +from dataclasses import dataclass + +import pulumi + +from azure_ai.account import AccountOutputs, create_account +from azure_ai.config import AzureAIConfig, load_config +from azure_ai.deployment import DeploymentOutputs, create_deployment +from azure_ai.policy import create_tag_policies + + +@dataclass +class StackOutputs: + """All outputs produced by a full Azure AI stack deployment.""" + + account: AccountOutputs + deployment: DeploymentOutputs + config: AzureAIConfig + + +def deploy_stack( + extra_ips: Sequence[str] = (), +) -> StackOutputs: + """Deploy the full Azure AI stack and export Pulumi outputs. + + Args: + extra_ips: Additional IP addresses or CIDR blocks to allow-list on + the Cognitive Services firewall beyond the built-in LangSmith + and Azure ranges. + + Returns: + StackOutputs with account, deployment, and resolved config. + """ + cfg = load_config() + + account = create_account( + resource_group_name=cfg.resource_group_name, + account_name=cfg.account_name, + location=cfg.location, + tags=cfg.tags, + extra_ips=extra_ips, + ) + + deployment = create_deployment( + deployment_name=cfg.deployment_name, + resource_group_name=account.resource_group_name, + account_name=account.account_name, + endpoint=account.endpoint, + model_name=cfg.model_name, + model_version=cfg.model_version, + api_version=cfg.api_version, + capacity=cfg.capacity, + ) + + create_tag_policies( + subscription_id=cfg.subscription_id, + required_tags=list(cfg.tags.keys()), + ) + + _export_outputs(account, deployment, cfg) + + return StackOutputs(account=account, deployment=deployment, config=cfg) + + +def _export_outputs( + account: AccountOutputs, + deployment: DeploymentOutputs, + cfg: AzureAIConfig, +) -> None: + """Register standard Pulumi exports for the stack.""" + pulumi.export("resource_group_name", account.resource_group_name) + pulumi.export("account_name", account.account_name) + pulumi.export("deployment_name", deployment.deployment_name) + pulumi.export("endpoint", account.endpoint) + pulumi.export("primary_key", pulumi.Output.secret(account.primary_key)) + pulumi.export("secondary_key", pulumi.Output.secret(account.secondary_key)) + pulumi.export("allowed_ips", account.allowed_ips) + pulumi.export("api_version", cfg.api_version) + pulumi.export("usage_instructions", deployment.usage_instructions) + pulumi.export("tags", cfg.tags) diff --git a/packages/python/langsmith-client/.env.example b/packages/python/langsmith-client/.env.example new file mode 100644 index 00000000..feb95ba3 --- /dev/null +++ b/packages/python/langsmith-client/.env.example @@ -0,0 +1,9 @@ +# LangSmith Control Plane (required for most commands) +LANGSMITH_API_KEY= +LANGSMITH_WORKSPACE_ID= + +# Docker/hybrid deployments (langsmith-deploy-docker) +LANGSMITH_LISTENER_ID= + +# GitHub deployments (langsmith-deploy-github) +GITHUB_INTEGRATION_ID= diff --git a/packages/python/langsmith-client/pyproject.toml b/packages/python/langsmith-client/pyproject.toml new file mode 100644 index 00000000..0189fba8 --- /dev/null +++ b/packages/python/langsmith-client/pyproject.toml @@ -0,0 +1,27 @@ +[project] +name = "langsmith-client" +version = "0.1.0" +description = "LangSmith Control Plane API client and CLI utilities" +requires-python = ">=3.12,<3.13" +dependencies = [ + "requests>=2.31.0", + "click>=8.1.0", + "python-dotenv>=1.1.0", + "python-decouple>=3.8", + "pydantic>=2.0.0", +] + +[project.scripts] +langsmith-build = "langsmith_client.tools.build:build" +langsmith-deploy-docker = "langsmith_client.tools.deploy_docker:cli" +langsmith-deploy-github = "langsmith_client.tools.deploy_github:cli" +langsmith-workspaces = "langsmith_client.tools.list_workspaces:cli" +langsmith-listeners = "langsmith_client.tools.list_listeners:cli" +langsmith-projects = "langsmith_client.tools.projects:cli" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/langsmith_client"] diff --git a/packages/python/langsmith-client/src/langsmith_client/__init__.py b/packages/python/langsmith-client/src/langsmith_client/__init__.py new file mode 100644 index 00000000..118771be --- /dev/null +++ b/packages/python/langsmith-client/src/langsmith_client/__init__.py @@ -0,0 +1,51 @@ +"""LangSmith Control Plane API client and CLI utilities. + +This package provides the shared client for interacting with the +LangSmith Control Plane API. +""" + +from langsmith_client.cli import ( + common_options, + create_client, + echo_deployment_created, + echo_success, + handle_wait, + print_deployments, +) +from langsmith_client.client import ( + CONTROL_PLANE_HOSTS, + MAX_WAIT_TIME, + POLL_INTERVAL, + ControlPlaneClient, +) +from langsmith_client.project import ( + ProjectInfo, + get_project_info, +) +from langsmith_client.secrets import ( + get_env_secrets, + merge_secrets, + parse_secrets, +) + +__all__ = [ + # Client + "ControlPlaneClient", + "CONTROL_PLANE_HOSTS", + "MAX_WAIT_TIME", + "POLL_INTERVAL", + # CLI utilities + "common_options", + "create_client", + "echo_deployment_created", + "echo_success", + "handle_wait", + "print_deployments", + # Secrets + "get_env_secrets", + "merge_secrets", + "parse_secrets", + # Project info + "ProjectInfo", + "get_project_info", +] diff --git a/packages/python/langsmith-client/src/langsmith_client/cli.py b/packages/python/langsmith-client/src/langsmith_client/cli.py new file mode 100644 index 00000000..45ffc2a9 --- /dev/null +++ b/packages/python/langsmith-client/src/langsmith_client/cli.py @@ -0,0 +1,96 @@ +"""Click CLI utilities for LangSmith Control Plane scripts.""" + +from typing import Any, TypeVar + +import click + +from langsmith_client.client import ControlPlaneClient + +T = TypeVar("T", bound=ControlPlaneClient) + + +def common_options(func): + """Decorator to add common CLI options (region, api-key, workspace-id).""" + func = click.option( + "--region", + type=click.Choice(["us", "eu"]), + default="us", + help="LangSmith region", + )(func) + func = click.option( + "--api-key", + envvar="LANGSMITH_API_KEY", + help="LangSmith API key", + )(func) + func = click.option( + "--workspace-id", + envvar="LANGSMITH_WORKSPACE_ID", + help="LangSmith workspace ID", + )(func) + return func + + +def create_client( + client_class: type[T], + api_key: str | None, + workspace_id: str | None, + region: str, +) -> T: + """Create a client instance, converting ValueError to ClickException.""" + try: + return client_class( + api_key=api_key, + workspace_id=workspace_id, + region=region, + ) + except ValueError as e: + raise click.ClickException(str(e)) from e + + +def echo_success(message: str) -> None: + """Print a success message in green.""" + click.echo(click.style(message, fg="green")) + + +def echo_deployment_created(deployment_id: str, revision_id: str | None) -> None: + """Print deployment created output.""" + echo_success("\nDeployment created!") + click.echo(f" Deployment ID: {deployment_id}") + click.echo(f" Revision ID: {revision_id}") + + +def print_deployments(deployments: list[dict[str, Any]]) -> None: + """Print a formatted list of deployments.""" + if not deployments: + click.echo("No deployments found.") + return + + click.echo(f"\nFound {len(deployments)} deployment(s):\n") + for dep in deployments: + click.echo(f" ID: {dep['id']}") + click.echo(f" Name: {dep['name']}") + click.echo(f" Source: {dep.get('source', 'N/A')}") + click.echo(f" Status: {dep.get('status', 'N/A')}") + if dep.get("url"): + click.echo(f" URL: {dep['url']}") + click.echo() + + +def handle_wait( + client: ControlPlaneClient, + deployment_id: str, + revision_id: str | None, + wait: bool, + url: str | None = None, +) -> None: + """Handle the --wait flag for deployment commands.""" + if wait and revision_id: + click.echo("\nWaiting for deployment to complete...") + try: + final = client.wait_for_deployment(deployment_id, revision_id) + echo_success("\nDeployment complete!") + click.echo(f" Status: {final.get('status')}") + if url: + click.echo(f" URL: {url}") + except RuntimeError as e: + raise click.ClickException(str(e)) from e diff --git a/packages/python/langsmith-client/src/langsmith_client/client.py b/packages/python/langsmith-client/src/langsmith_client/client.py new file mode 100644 index 00000000..ac60e13c --- /dev/null +++ b/packages/python/langsmith-client/src/langsmith_client/client.py @@ -0,0 +1,221 @@ +""" +LangSmith Control Plane API client. + +Provides the base client for interacting with the LangSmith Control Plane API. + +CONTROL PLANE API REFERENCE: + https://docs.langchain.com/langsmith/api-ref-control-plane +""" + +import os +import time +from typing import Any + +import requests +from dotenv import load_dotenv + +# Load environment variables from .env file +load_dotenv() + +HTTP_OK = 200 +HTTP_NO_CONTENT = 204 + +_REQUEST_TIMEOUT = 30 + +# Control Plane API hosts by region +CONTROL_PLANE_HOSTS = { + "us": "https://api.host.langchain.com", + "eu": "https://eu.api.host.langchain.com", +} + +# Maximum time to wait for deployment (30 minutes) +MAX_WAIT_TIME = 1800 + +# Poll interval for deployment status (60 seconds) +POLL_INTERVAL = 60 + + +class ControlPlaneClient: + """Client for the LangSmith Control Plane API.""" + + def __init__( + self, + api_key: str | None = None, + workspace_id: str | None = None, + region: str = "us", + ): + """ + Initialize the Control Plane client. + + Args: + api_key: LangSmith API key (defaults to LANGSMITH_API_KEY env var) + workspace_id: LangSmith workspace ID (defaults to LANGSMITH_WORKSPACE_ID + env var) + region: LangSmith region ("us" or "eu") + """ + self.api_key = api_key or os.environ.get("LANGSMITH_API_KEY") + self.workspace_id = workspace_id or os.environ.get("LANGSMITH_WORKSPACE_ID") + + if not self.api_key: + raise ValueError( + "LANGSMITH_API_KEY is required. " + "Set it as an environment variable or pass it to the constructor." + ) + + if not self.workspace_id: + raise ValueError( + "LANGSMITH_WORKSPACE_ID is required. " + "Find it in your LangSmith workspace settings." + ) + + if region not in CONTROL_PLANE_HOSTS: + raise ValueError( + f"Invalid region: {region}. " + f"Must be one of: {list(CONTROL_PLANE_HOSTS.keys())}" + ) + + self.base_url = f"{CONTROL_PLANE_HOSTS[region]}/v2" + self.headers = { + "X-Api-Key": self.api_key, + "X-Tenant-Id": self.workspace_id, + "Content-Type": "application/json", + } + + def list_listeners(self) -> dict[str, Any]: + """List all listeners (hybrid deployment agents) for the workspace.""" + response = requests.get( + f"{self.base_url}/listeners", + headers=self.headers, + timeout=_REQUEST_TIMEOUT, + ) + + if response.status_code == HTTP_OK: + return response.json() + else: + raise RuntimeError( + f"Failed to list listeners: {response.status_code}\n{response.text}" + ) + + def list_deployments(self, name_contains: str | None = None) -> dict[str, Any]: + """List all deployments, optionally filtered by name.""" + params = {} + if name_contains: + params["name_contains"] = name_contains + + response = requests.get( + f"{self.base_url}/deployments", + headers=self.headers, + params=params, + timeout=_REQUEST_TIMEOUT, + ) + + if response.status_code == HTTP_OK: + return response.json() + else: + raise RuntimeError( + f"Failed to list deployments: {response.status_code}\n{response.text}" + ) + + def get_deployment(self, deployment_id: str) -> dict[str, Any]: + """Get a specific deployment by ID.""" + response = requests.get( + f"{self.base_url}/deployments/{deployment_id}", + headers=self.headers, + timeout=_REQUEST_TIMEOUT, + ) + + if response.status_code == HTTP_OK: + return response.json() + else: + raise RuntimeError( + f"Failed to get deployment {deployment_id}: " + f"{response.status_code}\n{response.text}" + ) + + def get_revision(self, deployment_id: str, revision_id: str) -> dict[str, Any]: + """Get a specific revision of a deployment.""" + response = requests.get( + f"{self.base_url}/deployments/{deployment_id}/revisions/{revision_id}", + headers=self.headers, + timeout=_REQUEST_TIMEOUT, + ) + + if response.status_code == HTTP_OK: + return response.json() + else: + raise RuntimeError( + f"Failed to get revision {revision_id}: " + f"{response.status_code}\n{response.text}" + ) + + def list_revisions(self, deployment_id: str) -> dict[str, Any]: + """List all revisions for a deployment.""" + response = requests.get( + f"{self.base_url}/deployments/{deployment_id}/revisions", + headers=self.headers, + timeout=_REQUEST_TIMEOUT, + ) + + if response.status_code == HTTP_OK: + return response.json() + else: + raise RuntimeError( + f"Failed to list revisions: {response.status_code}\n{response.text}" + ) + + def delete_deployment(self, deployment_id: str) -> bool: + """Delete a deployment.""" + response = requests.delete( + f"{self.base_url}/deployments/{deployment_id}", + headers=self.headers, + timeout=_REQUEST_TIMEOUT, + ) + + if response.status_code == HTTP_NO_CONTENT: + return True + else: + raise RuntimeError( + f"Failed to delete deployment: {response.status_code}\n{response.text}" + ) + + def wait_for_deployment( + self, + deployment_id: str, + revision_id: str, + max_wait: int = MAX_WAIT_TIME, + poll_interval: int = POLL_INTERVAL, + ) -> dict[str, Any]: + """ + Wait for a deployment revision to reach DEPLOYED status. + + Args: + deployment_id: ID of the deployment + revision_id: ID of the revision to wait for + max_wait: Maximum time to wait in seconds + poll_interval: Time between status checks in seconds + + Returns: + Final revision status + + Raises: + RuntimeError: If deployment fails or times out + """ + start_time = time.time() + revision = None + status = None + + while time.time() - start_time < max_wait: + revision = self.get_revision(deployment_id, revision_id) + status = revision.get("status") + + if status == "DEPLOYED": + return revision + elif "FAILED" in str(status): + raise RuntimeError(f"Deployment failed: {revision}") + + print(f" Status: {status}... waiting {poll_interval}s") + time.sleep(poll_interval) + + raise RuntimeError( + f"Timeout waiting for deployment. Last status: {status}\n{revision}" + ) diff --git a/packages/python/langsmith-client/src/langsmith_client/project.py b/packages/python/langsmith-client/src/langsmith_client/project.py new file mode 100644 index 00000000..3e573db4 --- /dev/null +++ b/packages/python/langsmith-client/src/langsmith_client/project.py @@ -0,0 +1,38 @@ +"""Project metadata utilities for reading pyproject.toml.""" + +import tomllib +from pathlib import Path +from typing import NamedTuple + + +class ProjectInfo(NamedTuple): + """Project metadata from pyproject.toml.""" + + name: str + version: str + + +def get_project_info(start_path: Path | None = None) -> ProjectInfo | None: + """ + Read project name and version from pyproject.toml. + + Searches from start_path (default: cwd) up to the filesystem root. + Returns None if no pyproject.toml is found or it lacks project metadata. + """ + path = start_path or Path.cwd() + + # Search up the directory tree + for directory in [path, *path.parents]: + pyproject = directory / "pyproject.toml" + if pyproject.exists(): + try: + with pyproject.open("rb") as f: + data = tomllib.load(f) + project = data.get("project", {}) + name = project.get("name") + version = project.get("version") + if name and version: + return ProjectInfo(name=name, version=version) + except (tomllib.TOMLDecodeError, OSError): + pass + return None diff --git a/packages/python/langsmith-client/src/langsmith_client/secrets.py b/packages/python/langsmith-client/src/langsmith_client/secrets.py new file mode 100644 index 00000000..93f04fb2 --- /dev/null +++ b/packages/python/langsmith-client/src/langsmith_client/secrets.py @@ -0,0 +1,46 @@ +"""Secret management utilities for LangSmith deployments.""" + +import os + +# Environment variables to auto-detect as deployment secrets. +# These are common API keys needed by LangGraph agents at runtime. +AUTO_DETECT_KEYS = [ + "AZURE_OPENAI_API_KEY", + "AZURE_OPENAI_ENDPOINT", + "AZURE_OPENAI_DEPLOYMENT", + "AZURE_OPENAI_API_VERSION", + "TAVILY_API_KEY", +] + + +def get_env_secrets() -> list[dict[str, str]]: + """Get deployment secrets from well-known environment variables.""" + secrets = [] + for key in AUTO_DETECT_KEYS: + value = os.environ.get(key) + if value: + secrets.append({"name": key, "value": value}) + return secrets + + +def parse_secrets(secret_args: list[str] | None) -> list[dict[str, str]]: + """Parse secrets from command line arguments (NAME=VALUE format).""" + secrets = [] + if secret_args: + for secret in secret_args: + name, value = secret.split("=", 1) + secrets.append({"name": name, "value": value}) + return secrets + + +def merge_secrets(cli_secrets: tuple[str, ...] | list[str]) -> list[dict[str, str]]: + """Merge CLI secrets with auto-detected secrets from environment. + + CLI secrets take precedence over auto-detected ones. + """ + all_secrets = parse_secrets(list(cli_secrets)) + env_secrets = get_env_secrets() + for secret in env_secrets: + if not any(s["name"] == secret["name"] for s in all_secrets): + all_secrets.append(secret) + return all_secrets diff --git a/packages/python/langsmith-client/src/langsmith_client/tools/__init__.py b/packages/python/langsmith-client/src/langsmith_client/tools/__init__.py new file mode 100644 index 00000000..fbb3bd65 --- /dev/null +++ b/packages/python/langsmith-client/src/langsmith_client/tools/__init__.py @@ -0,0 +1 @@ +"""CLI tools for LangSmith deployment and management.""" diff --git a/packages/python/langsmith-client/src/langsmith_client/tools/build.py b/packages/python/langsmith-client/src/langsmith_client/tools/build.py new file mode 100644 index 00000000..e14a988b --- /dev/null +++ b/packages/python/langsmith-client/src/langsmith_client/tools/build.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +""" +Build a Docker image for a LangGraph agent. + +Reads name and version from pyproject.toml to create a consistent image tag. +Can target any project directory via --project-dir / -C. +""" + +import os +import subprocess # nosec B404 # developer tooling shells out to docker/langgraph +import sys +from pathlib import Path + +import click + +from langsmith_client import get_project_info + + +def _get_git_sha() -> str | None: + """Return the short Git commit SHA, or None if unavailable.""" + try: + result = subprocess.run( # nosec B603 B607 # hardcoded git command with list args, no shell + ["git", "rev-parse", "--short", "HEAD"], + capture_output=True, + text=True, + timeout=5, + check=False, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return None + if result.returncode == 0: + return result.stdout.strip() + return None + + +@click.command( + context_settings={"ignore_unknown_options": True}, +) +@click.option( + "--tag", "-t", help="Override image tag (default: name:version from pyproject.toml)" +) +@click.option("--push", is_flag=True, help="Push to registry after build") +@click.option("--registry", help="Registry prefix for push (e.g., gcr.io/my-project)") +@click.option( + "--platform", + default="linux/amd64", + help="Docker platform to build for (default: linux/amd64)", +) +@click.option( + "--project-dir", + "-C", + type=click.Path(exists=True, file_okay=False, resolve_path=True), + default=".", + help=( + "Project directory containing pyproject.toml and langgraph.json " + "(default: current directory)" + ), +) +@click.argument("langgraph_args", nargs=-1, type=click.UNPROCESSED) +def build( # noqa: PLR0913 + tag: str | None, + push: bool, + registry: str | None, + platform: str, + project_dir: str, + langgraph_args: tuple[str, ...], +): + """Build a Docker image using langgraph build. + + Any additional arguments are passed to langgraph build. + + \b + Examples: + # Build with defaults from pyproject.toml + langsmith-build + + # Build a specific project + langsmith-build -C labs/python/hello-world-graph + + # Build with custom tag + langsmith-build -t my-image:v2 + + # Build and push to registry + langsmith-build --push --registry gcr.io/my-project + + # Build for a different platform (e.g., local testing on Apple Silicon) + langsmith-build --platform linux/arm64 + """ + project_path = Path(project_dir) + + # Get tag from pyproject.toml if not provided + if not tag: + project = get_project_info(start_path=project_path) + if not project: + raise click.ClickException( + "Could not read pyproject.toml. Provide --tag or ensure " + "pyproject.toml exists." + ) + git_sha = _get_git_sha() + tag = ( + f"{project.name}:{project.version}-{git_sha}" + if git_sha + else f"{project.name}:{project.version}" + ) + + click.echo(f"Building image: {tag}") + click.echo(f" Project dir: {project_path}") + + # Determine langgraph config path + config_file = project_path / "langgraph.json" + config_args = ["-c", str(config_file)] if config_file.exists() else [] + + # Run langgraph build (--platform is passed through to docker build). + # PYTHONUNBUFFERED ensures langgraph streams Docker output in real time. + cmd = [ + "langgraph", + "build", + "-t", + tag, + *config_args, + *langgraph_args, + "--platform", + platform, + ] + env = {**os.environ, "PYTHONUNBUFFERED": "1"} + result = subprocess.run(cmd, cwd=str(project_path), env=env, check=False) # nosec B603 B607 # hardcoded langgraph command with list args, no shell + + if result.returncode != 0: + sys.exit(result.returncode) + + click.echo(click.style(f"\nBuild complete: {tag}", fg="green")) + + # Push if requested + if push: + if registry: + remote_tag = f"{registry}/{tag}" + click.echo(f"\nTagging: {remote_tag}") + subprocess.run(["docker", "tag", tag, remote_tag], check=True) # nosec B603 B607 # hardcoded docker command with list args, no shell + click.echo(f"Pushing: {remote_tag}") + subprocess.run(["docker", "push", remote_tag], check=True) # nosec B603 B607 # hardcoded docker command with list args, no shell + click.echo(click.style(f"\nPushed: {remote_tag}", fg="green")) + else: + click.echo(f"Pushing: {tag}") + subprocess.run(["docker", "push", tag], check=True) # nosec B603 B607 # hardcoded docker command with list args, no shell + click.echo(click.style(f"\nPushed: {tag}", fg="green")) + + +if __name__ == "__main__": + build() # pylint: disable=no-value-for-parameter # Click injects params at runtime diff --git a/packages/python/langsmith-client/src/langsmith_client/tools/deploy_docker.py b/packages/python/langsmith-client/src/langsmith_client/tools/deploy_docker.py new file mode 100644 index 00000000..952c30e2 --- /dev/null +++ b/packages/python/langsmith-client/src/langsmith_client/tools/deploy_docker.py @@ -0,0 +1,387 @@ +#!/usr/bin/env python3 +""" +Deploy LangGraph agents to LangSmith from a Docker image. + +This tool uses the LangSmith Control Plane API to create deployments from +Docker images. This is the approach for self-hosted or hybrid LangSmith deployments. + +PREREQUISITES: +- LANGSMITH_API_KEY: Your LangSmith API key +- LANGSMITH_WORKSPACE_ID: Your LangSmith workspace ID (found in workspace settings) +- A Docker image pushed to a container registry accessible by LangSmith + +BUILD AND PUSH WORKFLOW: + langsmith-build --push --registry your-registry + +CONTROL PLANE API REFERENCE: + https://docs.langchain.com/langsmith/api-ref-control-plane +""" + +import re +from pathlib import Path +from typing import Any + +import click +import requests + +from langsmith_client import ( + ControlPlaneClient, + common_options, + create_client, + echo_deployment_created, + echo_success, + get_project_info, + handle_wait, + merge_secrets, + print_deployments, +) + +HTTP_OK = 200 +HTTP_CREATED = 201 + +_REQUEST_TIMEOUT = 30 + + +def extract_name_from_image_uri(image_uri: str) -> str: + """ + Extract a deployment name from a Docker image URI. + + Examples: + registry/my-agent:latest -> my-agent + gcr.io/project/hello-world:v1 -> hello-world + """ + # Remove tag, get last path component, sanitize + name = image_uri.split(":", maxsplit=1)[0].rsplit("/", maxsplit=1)[-1] + return re.sub(r"[^a-zA-Z0-9_-]", "-", name) + + +class DockerDeploymentClient(ControlPlaneClient): + """Client for Docker-based deployments to LangSmith.""" + + def create_deployment( # noqa: PLR0913 + self, + name: str, + image_uri: str, + listener_id: str | None = None, + k8s_namespace: str | None = None, + secrets: list[dict[str, str]] | None = None, + env_vars: list[dict[str, str]] | None = None, + min_scale: int = 1, + max_scale: int = 3, + cpu: int = 1, + memory_mb: int = 1024, + ) -> dict[str, Any]: + """Create a new deployment from a Docker image.""" + source_config: dict[str, Any] = { + "integration_id": None, + "repo_url": None, + "deployment_type": None, + "build_on_push": None, + "custom_url": None, + "resource_spec": { + "min_scale": min_scale, + "max_scale": max_scale, + "cpu": cpu, + "memory_mb": memory_mb, + }, + } + + if listener_id: + source_config["listener_id"] = listener_id + if k8s_namespace: + source_config["listener_config"] = {"k8s_namespace": k8s_namespace} + + request_body = { + "name": name, + "source": "external_docker", + "source_config": source_config, + "source_revision_config": { + "repo_ref": None, + "langgraph_config_path": None, + "image_uri": image_uri, + }, + "secrets": secrets or [], + "env_vars": env_vars or [], + } + + response = requests.post( + f"{self.base_url}/deployments", + headers=self.headers, + json=request_body, + timeout=_REQUEST_TIMEOUT, + ) + + if response.status_code in (HTTP_OK, HTTP_CREATED): + return response.json() + raise RuntimeError( + f"Failed to create deployment: {response.status_code}\n{response.text}" + ) + + def update_deployment( + self, + deployment_id: str, + image_uri: str, + secrets: list[dict[str, str]] | None = None, + ) -> dict[str, Any]: + """Update an existing Docker deployment with a new image.""" + request_body: dict[str, Any] = { + "source_revision_config": { + "repo_ref": None, + "langgraph_config_path": None, + "image_uri": image_uri, + } + } + if secrets: + request_body["secrets"] = secrets + + response = requests.patch( + f"{self.base_url}/deployments/{deployment_id}", + headers=self.headers, + json=request_body, + timeout=_REQUEST_TIMEOUT, + ) + + if response.status_code == HTTP_OK: + return response.json() + raise RuntimeError( + f"Failed to update deployment: {response.status_code}\n{response.text}" + ) + + +def _project_dir_option(func): + """Add --project-dir / -C option to a command.""" + return click.option( + "--project-dir", + "-C", + type=click.Path(exists=True, file_okay=False, resolve_path=True), + default=".", + help="Project directory containing pyproject.toml (default: current directory)", + )(func) + + +@click.group() +@click.version_option(version="1.0.0") +def cli(): + """Deploy LangGraph agents to LangSmith from Docker images. + + \b + PREREQUISITES: + - LANGSMITH_API_KEY: Your LangSmith API key + - LANGSMITH_WORKSPACE_ID: Your workspace ID (from LangSmith settings) + - A Docker image pushed to an accessible container registry + """ + pass + + +@cli.command() +@common_options +@_project_dir_option +@click.option( + "--name", help="Deployment name (defaults to project name from pyproject.toml)" +) +@click.option("--image-uri", help="Docker image URI (defaults to project-name:version)") +@click.option( + "--listener-id", + envvar="LANGSMITH_LISTENER_ID", + help=( + "Listener ID for hybrid deployments (defaults to LANGSMITH_LISTENER_ID env var)" + ), +) +@click.option( + "--namespace", + "k8s_namespace", + default="default", + help="Kubernetes namespace to deploy to (default: default)", +) +@click.option("--secret", "secrets", multiple=True, help="Secret in NAME=VALUE format") +@click.option("--min-scale", type=int, default=1, help="Minimum instances") +@click.option("--max-scale", type=int, default=3, help="Maximum instances") +@click.option("--cpu", type=int, default=1, help="CPU cores per instance") +@click.option("--memory", type=int, default=1024, help="Memory in MB") +@click.option("--wait", is_flag=True, help="Wait for deployment to complete") +def create( # noqa: PLR0913 + region: str, + api_key: str | None, + workspace_id: str | None, + project_dir: str, + name: str | None, + image_uri: str | None, + listener_id: str | None, + k8s_namespace: str, + secrets: tuple[str, ...], + min_scale: int, + max_scale: int, + cpu: int, + memory: int, + wait: bool, +): + """Create a new deployment from a Docker image.""" + client = create_client(DockerDeploymentClient, api_key, workspace_id, region) + + # Get project info from pyproject.toml + project = get_project_info(start_path=Path(project_dir)) + + # Derive name from project or image URI + if not name: + if project: + name = project.name + elif image_uri: + name = extract_name_from_image_uri(image_uri) + else: + raise click.ClickException( + "Could not determine deployment name. " + "Provide --name or ensure pyproject.toml exists with [project].name" + ) + + # Derive image URI from project if not provided + if not image_uri: + if project: + image_uri = f"{project.name}:{project.version}" + click.echo(f"Using image from pyproject.toml: {image_uri}") + else: + raise click.ClickException( + "Could not determine image URI. " + "Provide --image-uri or ensure pyproject.toml exists" + ) + + click.echo(f"Creating Docker deployment: {name}") + click.echo(f" Image: {image_uri}") + click.echo(f" Scale: {min_scale}-{max_scale} instances") + + try: + result = client.create_deployment( + name=name, + image_uri=image_uri, + listener_id=listener_id, + k8s_namespace=k8s_namespace, + secrets=merge_secrets(secrets), + min_scale=min_scale, + max_scale=max_scale, + cpu=cpu, + memory_mb=memory, + ) + + echo_deployment_created(result["id"], result.get("latest_revision_id")) + handle_wait(client, result["id"], result.get("latest_revision_id"), wait) + + except RuntimeError as e: + raise click.ClickException(str(e)) from e + + +@cli.command() +@common_options +@_project_dir_option +@click.option("--deployment-id", required=True, help="Deployment ID to update") +@click.option( + "--image-uri", + help="New Docker image URI (defaults to name:version from pyproject.toml)", +) +@click.option( + "--secret", + "secrets", + multiple=True, + help="Secret in NAME=VALUE or NAME=$ENV_VAR format", +) +@click.option("--wait", is_flag=True, help="Wait for deployment to complete") +def update( # noqa: PLR0913 + region: str, + api_key: str | None, + workspace_id: str | None, + project_dir: str, + deployment_id: str, + image_uri: str | None, + secrets: tuple[str, ...], + wait: bool, +): + """Update an existing deployment with a new Docker image.""" + # Derive image URI from pyproject.toml if not provided + if not image_uri: + project = get_project_info(start_path=Path(project_dir)) + if project: + image_uri = f"{project.name}:{project.version}" + click.echo(f"Using image from pyproject.toml: {image_uri}") + else: + raise click.ClickException( + "Could not determine image URI. " + "Provide --image-uri or ensure pyproject.toml exists" + ) + client = create_client(DockerDeploymentClient, api_key, workspace_id, region) + + secret_list = merge_secrets(secrets) + + click.echo(f"Updating deployment: {deployment_id}") + click.echo(f" New image: {image_uri}") + if secret_list: + click.echo(f" Secrets: {', '.join(s['name'] for s in secret_list)}") + + try: + result = client.update_deployment( + deployment_id=deployment_id, + image_uri=image_uri, + secrets=secret_list, + ) + + revision_id = result.get("latest_revision_id") + click.echo(f" New revision ID: {revision_id}") + handle_wait(client, deployment_id, revision_id, wait) + + except RuntimeError as e: + raise click.ClickException(str(e)) from e + + +@cli.command("list") +@common_options +@click.option("--filter", "name_filter", help="Filter by name (contains)") +@click.option("--docker-only", is_flag=True, help="Show only Docker deployments") +def list_deployments( + region: str, + api_key: str | None, + workspace_id: str | None, + name_filter: str | None, + docker_only: bool, +): + """List deployments.""" + client = create_client(DockerDeploymentClient, api_key, workspace_id, region) + + click.echo("Fetching deployments...") + + try: + result = client.list_deployments(name_contains=name_filter) + deployments = result.get("resources", []) + + if docker_only: + deployments = [ + d for d in deployments if d.get("source") == "external_docker" + ] + + print_deployments(deployments) + + except RuntimeError as e: + raise click.ClickException(str(e)) from e + + +@cli.command() +@common_options +@click.option("--deployment-id", required=True, help="Deployment ID to delete") +@click.confirmation_option(prompt="Are you sure you want to delete this deployment?") +def delete( + region: str, + api_key: str | None, + workspace_id: str | None, + deployment_id: str, +): + """Delete a deployment.""" + client = create_client(DockerDeploymentClient, api_key, workspace_id, region) + + click.echo(f"Deleting deployment: {deployment_id}") + + try: + client.delete_deployment(deployment_id) + echo_success("Deployment deleted successfully!") + + except RuntimeError as e: + raise click.ClickException(str(e)) from e + + +if __name__ == "__main__": + cli() diff --git a/packages/python/langsmith-client/src/langsmith_client/tools/deploy_github.py b/packages/python/langsmith-client/src/langsmith_client/tools/deploy_github.py new file mode 100644 index 00000000..ec6114cc --- /dev/null +++ b/packages/python/langsmith-client/src/langsmith_client/tools/deploy_github.py @@ -0,0 +1,365 @@ +#!/usr/bin/env python3 +""" +Deploy LangGraph agents to LangSmith from a GitHub repository. + +This tool uses the LangSmith Control Plane API to create deployments that +pull code from GitHub. This is the recommended approach for LangSmith Cloud. + +PREREQUISITES: +- LANGSMITH_API_KEY: Your LangSmith API key +- LANGSMITH_WORKSPACE_ID: Your LangSmith workspace ID (found in workspace settings) +- GITHUB_INTEGRATION_ID: GitHub integration ID from LangSmith (one-time setup via UI) + +ONE-TIME SETUP: +1. Go to LangSmith UI -> Deployments -> + New Deployment +2. Select "Import from GitHub" and complete the OAuth flow +3. Note your GitHub integration ID from LangSmith + +Deployment name defaults to [project].name from pyproject.toml when run from +a project directory; otherwise use --name. + +CONTROL PLANE API REFERENCE: + https://docs.langchain.com/langsmith/api-ref-control-plane +""" + +from pathlib import Path +from typing import Any + +import click +import requests + +from langsmith_client import ( + ControlPlaneClient, + common_options, + create_client, + echo_deployment_created, + echo_success, + get_project_info, + handle_wait, + merge_secrets, + print_deployments, +) + +HTTP_OK = 200 +HTTP_CREATED = 201 + +_REQUEST_TIMEOUT = 30 + + +class GitHubDeploymentClient(ControlPlaneClient): + """Client for GitHub-based deployments to LangSmith Cloud.""" + + def create_deployment( # noqa: PLR0913 + self, + name: str, + integration_id: str, + repo_url: str, + branch: str = "main", + config_path: str = "langgraph.json", + deployment_type: str = "dev", + build_on_push: bool = True, + shareable: bool = False, + secrets: list[dict[str, str]] | None = None, + env_vars: list[dict[str, str]] | None = None, + min_scale: int = 1, + max_scale: int = 3, + cpu: int = 1, + memory_mb: int = 1024, + ) -> dict[str, Any]: + """Create a new deployment from a GitHub repository.""" + request_body = { + "name": name, + "source": "github", + "source_config": { + "integration_id": integration_id, + "repo_url": repo_url, + "deployment_type": deployment_type, + "build_on_push": build_on_push, + "custom_url": None, + "resource_spec": { + "min_scale": min_scale, + "max_scale": max_scale, + "cpu": cpu, + "memory_mb": memory_mb, + }, + }, + "source_revision_config": { + "repo_ref": branch, + "langgraph_config_path": config_path, + "image_uri": None, + }, + "secrets": secrets or [], + "env_vars": env_vars or [], + "shareable": shareable, + } + + response = requests.post( + f"{self.base_url}/deployments", + headers=self.headers, + json=request_body, + timeout=_REQUEST_TIMEOUT, + ) + + if response.status_code in (HTTP_OK, HTTP_CREATED): + return response.json() + raise RuntimeError( + f"Failed to create deployment: {response.status_code}\n{response.text}" + ) + + def update_deployment( + self, + deployment_id: str, + branch: str | None = None, + config_path: str | None = None, + build_on_push: bool | None = None, + ) -> dict[str, Any]: + """Update an existing GitHub deployment (creates a new revision).""" + request_body: dict[str, Any] = {} + + if build_on_push is not None: + request_body["source_config"] = {"build_on_push": build_on_push} + + source_revision_config: dict[str, Any] = {} + if branch: + source_revision_config["repo_ref"] = branch + if config_path: + source_revision_config["langgraph_config_path"] = config_path + + if source_revision_config: + request_body["source_revision_config"] = source_revision_config + + response = requests.patch( + f"{self.base_url}/deployments/{deployment_id}", + headers=self.headers, + json=request_body, + timeout=_REQUEST_TIMEOUT, + ) + + if response.status_code == HTTP_OK: + return response.json() + raise RuntimeError( + f"Failed to update deployment: {response.status_code}\n{response.text}" + ) + + +def _project_dir_option(func): + """Add --project-dir / -C option to a command.""" + return click.option( + "--project-dir", + "-C", + type=click.Path(exists=True, file_okay=False, resolve_path=True), + default=".", + help="Project directory containing pyproject.toml (default: current directory)", + )(func) + + +@click.group() +@click.version_option(version="1.0.0") +def cli(): + """Deploy LangGraph agents to LangSmith from GitHub repositories. + + \b + PREREQUISITES: + - LANGSMITH_API_KEY: Your LangSmith API key + - LANGSMITH_WORKSPACE_ID: Your workspace ID (from LangSmith settings) + - GITHUB_INTEGRATION_ID: GitHub integration ID (one-time setup via UI) + """ + pass + + +@cli.command() +@common_options +@_project_dir_option +@click.option( + "--name", help="Deployment name (defaults to project name from pyproject.toml)" +) +@click.option("--repo-url", required=True, help="GitHub repository URL") +@click.option("--branch", default="main", help="Git branch to deploy from") +@click.option("--config-path", default="langgraph.json", help="Path to langgraph.json") +@click.option( + "--integration-id", envvar="GITHUB_INTEGRATION_ID", help="GitHub integration ID" +) +@click.option( + "--type", + "deployment_type", + type=click.Choice(["dev", "prod"]), + default="dev", + help="Deployment type", +) +@click.option("--auto-build/--no-auto-build", default=True, help="Auto rebuild on push") +@click.option("--shareable", is_flag=True, help="Make shareable via Studio") +@click.option("--secret", "secrets", multiple=True, help="Secret in NAME=VALUE format") +@click.option("--min-scale", type=int, default=1, help="Minimum instances") +@click.option("--max-scale", type=int, default=3, help="Maximum instances") +@click.option("--cpu", type=int, default=1, help="CPU cores per instance") +@click.option("--memory", type=int, default=1024, help="Memory in MB") +@click.option("--wait", is_flag=True, help="Wait for deployment to complete") +def create( # noqa: PLR0913 + region: str, + api_key: str | None, + workspace_id: str | None, + project_dir: str, + name: str | None, + repo_url: str, + branch: str, + config_path: str, + integration_id: str | None, + deployment_type: str, + auto_build: bool, + shareable: bool, + secrets: tuple[str, ...], + min_scale: int, + max_scale: int, + cpu: int, + memory: int, + wait: bool, +): + """Create a new deployment from a GitHub repository.""" + if not integration_id: + raise click.ClickException( + "GitHub integration ID is required.\n" + "Set GITHUB_INTEGRATION_ID env var or use --integration-id" + ) + + # Derive name from pyproject.toml + if not name: + project = get_project_info(start_path=Path(project_dir)) + if project: + name = project.name + else: + raise click.ClickException( + "Could not determine deployment name. " + "Provide --name or ensure pyproject.toml exists with [project].name" + ) + + client = create_client(GitHubDeploymentClient, api_key, workspace_id, region) + + click.echo(f"Creating GitHub deployment: {name}") + click.echo(f" Repository: {repo_url}") + click.echo(f" Branch: {branch}") + click.echo(f" Scale: {min_scale}-{max_scale} instances") + + try: + result = client.create_deployment( + name=name, + integration_id=integration_id, + repo_url=repo_url, + branch=branch, + config_path=config_path, + deployment_type=deployment_type, + build_on_push=auto_build, + shareable=shareable, + secrets=merge_secrets(secrets), + min_scale=min_scale, + max_scale=max_scale, + cpu=cpu, + memory_mb=memory, + ) + + echo_deployment_created(result["id"], result.get("latest_revision_id")) + handle_wait( + client, + result["id"], + result.get("latest_revision_id"), + wait, + result.get("url"), + ) + + except RuntimeError as e: + raise click.ClickException(str(e)) from e + + +@cli.command() +@common_options +@click.option("--deployment-id", required=True, help="Deployment ID to update") +@click.option("--branch", help="New branch to deploy from") +@click.option("--config-path", help="New config path") +@click.option( + "--auto-build/--no-auto-build", default=None, help="Enable/disable auto-build" +) +@click.option("--wait", is_flag=True, help="Wait for deployment to complete") +def update( # noqa: PLR0913 + region: str, + api_key: str | None, + workspace_id: str | None, + deployment_id: str, + branch: str | None, + config_path: str | None, + auto_build: bool | None, + wait: bool, +): + """Update an existing deployment (creates a new revision).""" + client = create_client(GitHubDeploymentClient, api_key, workspace_id, region) + + click.echo(f"Updating deployment: {deployment_id}") + + try: + result = client.update_deployment( + deployment_id=deployment_id, + branch=branch, + config_path=config_path, + build_on_push=auto_build, + ) + + revision_id = result.get("latest_revision_id") + click.echo(f" New revision ID: {revision_id}") + handle_wait(client, deployment_id, revision_id, wait) + + except RuntimeError as e: + raise click.ClickException(str(e)) from e + + +@cli.command("list") +@common_options +@click.option("--filter", "name_filter", help="Filter by name (contains)") +@click.option("--github-only", is_flag=True, help="Show only GitHub deployments") +def list_deployments( + region: str, + api_key: str | None, + workspace_id: str | None, + name_filter: str | None, + github_only: bool, +): + """List deployments.""" + client = create_client(GitHubDeploymentClient, api_key, workspace_id, region) + + click.echo("Fetching deployments...") + + try: + result = client.list_deployments(name_contains=name_filter) + deployments = result.get("resources", []) + + if github_only: + deployments = [d for d in deployments if d.get("source") == "github"] + + print_deployments(deployments) + + except RuntimeError as e: + raise click.ClickException(str(e)) from e + + +@cli.command() +@common_options +@click.option("--deployment-id", required=True, help="Deployment ID to delete") +@click.confirmation_option(prompt="Are you sure you want to delete this deployment?") +def delete( + region: str, + api_key: str | None, + workspace_id: str | None, + deployment_id: str, +): + """Delete a deployment.""" + client = create_client(GitHubDeploymentClient, api_key, workspace_id, region) + + click.echo(f"Deleting deployment: {deployment_id}") + + try: + client.delete_deployment(deployment_id) + echo_success("Deployment deleted successfully!") + + except RuntimeError as e: + raise click.ClickException(str(e)) from e + + +if __name__ == "__main__": + cli() diff --git a/packages/python/langsmith-client/src/langsmith_client/tools/list_listeners.py b/packages/python/langsmith-client/src/langsmith_client/tools/list_listeners.py new file mode 100644 index 00000000..d00d820d --- /dev/null +++ b/packages/python/langsmith-client/src/langsmith_client/tools/list_listeners.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +""" +List LangSmith listeners (hybrid deployment agents). + +Listeners connect the LangSmith control plane to a self-hosted Kubernetes +cluster. The listener ID is required when creating Docker-based deployments +against a self-hosted cluster. + +PREREQUISITES: +- LANGSMITH_API_KEY: Your LangSmith API key +- LANGSMITH_WORKSPACE_ID: Your LangSmith workspace ID + +USAGE: + langsmith-listeners list + langsmith-listeners list --format json + +CONTROL PLANE API REFERENCE: + https://docs.langchain.com/langsmith/api-ref-control-plane +""" + +import json +from typing import Any + +import click + +from langsmith_client import ( + ControlPlaneClient, + common_options, + create_client, +) + +# ============================================================================= +# Output Formatters +# ============================================================================= + + +def print_table(listeners: list[dict[str, Any]]) -> None: + """Print listeners as a formatted table.""" + if not listeners: + click.echo("No listeners found.") + return + + id_width = max( + (len(str(listener.get("id", ""))) for listener in listeners), + default=36, + ) + id_width = max(id_width, len("ID")) + name_width = max( + (len(str(listener.get("name", ""))) for listener in listeners), + default=20, + ) + name_width = max(name_width, len("Name")) + + click.echo(f"\nFound {len(listeners)} listener(s):\n") + header = f"{'ID':<{id_width}} {'Name':<{name_width}} Status" + click.echo(header) + click.echo("-" * len(header)) + + for listener in listeners: + click.echo( + f"{str(listener.get('id', '')):<{id_width}} " + f"{str(listener.get('name', '')):<{name_width}} " + f"{listener.get('status', 'N/A')}" + ) + + click.echo() + + +def print_json(listeners: list[dict[str, Any]]) -> None: + """Print listeners as JSON.""" + click.echo(json.dumps(listeners, indent=2, default=str)) + + +# ============================================================================= +# CLI Commands +# ============================================================================= + + +@click.group() +@click.version_option(version="1.0.0") +def cli(): + """List LangSmith listeners for hybrid (self-hosted) deployments. + + \b + PREREQUISITES: + - LANGSMITH_API_KEY: Your LangSmith API key + - LANGSMITH_WORKSPACE_ID: Your workspace ID (from LangSmith settings) + + \b + The listener ID displayed here is used as --listener-id when creating + Docker-based deployments against a self-hosted Kubernetes cluster. + """ + pass + + +@cli.command("list") +@common_options +@click.option( + "--format", + "output_format", + type=click.Choice(["table", "json"]), + default="table", + help="Output format (default: table)", +) +def list_listeners( + region: str, + api_key: str | None, + workspace_id: str | None, + output_format: str, +): + """List listeners for hybrid deployments.""" + client: ControlPlaneClient = create_client( + ControlPlaneClient, api_key, workspace_id, region + ) + + click.echo("Fetching listeners...") + + try: + result = client.list_listeners() + listeners = ( + result.get("resources", result) if isinstance(result, dict) else result + ) + + if output_format == "json": + print_json(listeners) + else: + print_table(listeners) + + except RuntimeError as e: + raise click.ClickException(str(e)) from e + + +if __name__ == "__main__": + cli() diff --git a/packages/python/langsmith-client/src/langsmith_client/tools/list_workspaces.py b/packages/python/langsmith-client/src/langsmith_client/tools/list_workspaces.py new file mode 100644 index 00000000..e3f51e2b --- /dev/null +++ b/packages/python/langsmith-client/src/langsmith_client/tools/list_workspaces.py @@ -0,0 +1,276 @@ +#!/usr/bin/env python3 +""" +List LangSmith workspaces available to the current API key. + +This tool queries the LangSmith API to retrieve workspace information +and displays it in a formatted table or JSON output. + +PREREQUISITES: +- LANGSMITH_API_KEY: Your LangSmith API key + +USAGE: + langsmith-workspaces list + langsmith-workspaces list --format json + langsmith-workspaces list --format table + +API REFERENCE: + https://api.smith.langchain.com/api/v1/workspaces +""" + +import json +from datetime import datetime + +import click +import requests +from dotenv import load_dotenv +from pydantic import BaseModel, Field + +# Load environment variables from .env file +load_dotenv() + +# LangSmith API base URL +LANGSMITH_API_URL = "https://api.smith.langchain.com" + +HTTP_OK = 200 + +_REQUEST_TIMEOUT = 30 + + +# ============================================================================= +# Pydantic Models (Result Objects) +# ============================================================================= + + +class Workspace(BaseModel): + """A LangSmith workspace with its metadata and permissions.""" + + id: str + display_name: str + organization_id: str + role_name: str + role_id: str + is_personal: bool + is_deleted: bool = False + read_only: bool = False + created_at: str + tenant_handle: str | None = None + permissions: list[str] = Field(default_factory=list) + + @property + def created_date(self) -> str: + """Return formatted creation date.""" + try: + dt = datetime.fromisoformat(self.created_at.replace("Z", "+00:00")) + return dt.strftime("%Y-%m-%d") + except (ValueError, AttributeError): + return self.created_at[:10] if self.created_at else "N/A" + + +class WorkspacesResult(BaseModel): + """Result container for workspace queries.""" + + workspaces: list[Workspace] + count: int + + @classmethod + def from_api_response(cls, data: list[dict]) -> "WorkspacesResult": + """Create a WorkspacesResult from the raw API response.""" + workspaces = [Workspace.model_validate(w) for w in data] + return cls(workspaces=workspaces, count=len(workspaces)) + + def to_dict(self) -> dict: + """Convert to dictionary for JSON serialization.""" + return self.model_dump() + + def filter_by_role(self, role_name: str) -> "WorkspacesResult": + """Return a new result with only workspaces matching the role.""" + filtered = [ + w for w in self.workspaces if role_name.lower() in w.role_name.lower() + ] + return WorkspacesResult(workspaces=filtered, count=len(filtered)) + + def filter_active(self) -> "WorkspacesResult": + """Return a new result with only non-deleted workspaces.""" + filtered = [w for w in self.workspaces if not w.is_deleted] + return WorkspacesResult(workspaces=filtered, count=len(filtered)) + + +# ============================================================================= +# API Client +# ============================================================================= + + +def get_workspaces(api_key: str) -> WorkspacesResult: + """ + Query LangSmith API for available workspaces. + + Args: + api_key: LangSmith API key + + Returns: + WorkspacesResult containing all accessible workspaces + + Raises: + RuntimeError: If the API request fails + """ + response = requests.get( + f"{LANGSMITH_API_URL}/api/v1/workspaces", + headers={ + "x-api-key": api_key, + "Content-Type": "application/json", + }, + timeout=_REQUEST_TIMEOUT, + ) + + if response.status_code == HTTP_OK: + return WorkspacesResult.from_api_response(response.json()) + else: + raise RuntimeError( + f"Failed to fetch workspaces: {response.status_code}\n{response.text}" + ) + + +# ============================================================================= +# CLI Output Formatters +# ============================================================================= + + +def print_table(result: WorkspacesResult) -> None: + """Print workspaces as a formatted table.""" + if not result.workspaces: + click.echo("No workspaces found.") + return + + # Calculate column widths + name_width = max(len(w.display_name) for w in result.workspaces) + name_width = max(name_width, len("Display Name")) + role_width = max(len(w.role_name) for w in result.workspaces) + role_width = max(role_width, len("Role")) + + # Header + click.echo(f"\nFound {result.count} workspace(s):\n") + header = ( + f"{'Display Name':<{name_width}} {'ID':<36} " + f"{'Role':<{role_width}} {'Created'}" + ) + click.echo(header) + click.echo("-" * len(header)) + + # Rows + for w in result.workspaces: + row = ( + f"{w.display_name:<{name_width}} {w.id:<36} " + f"{w.role_name:<{role_width}} {w.created_date}" + ) + click.echo(row) + + click.echo() + + +def print_json(result: WorkspacesResult) -> None: + """Print workspaces as JSON.""" + click.echo(json.dumps(result.to_dict(), indent=2, default=str)) + + +# ============================================================================= +# CLI Commands +# ============================================================================= + + +@click.group() +@click.version_option(version="1.0.0") +def cli(): + """Query LangSmith workspaces. + + \b + PREREQUISITES: + - LANGSMITH_API_KEY: Your LangSmith API key (set in .env or environment) + """ + pass + + +@cli.command("list") +@click.option( + "--api-key", + envvar="LANGSMITH_API_KEY", + help="LangSmith API key (defaults to LANGSMITH_API_KEY env var)", +) +@click.option( + "--format", + "output_format", + type=click.Choice(["table", "json"]), + default="table", + help="Output format (default: table)", +) +@click.option( + "--role", + "role_filter", + help="Filter by role name (contains match)", +) +@click.option( + "--active-only", + is_flag=True, + help="Show only non-deleted workspaces", +) +def list_workspaces( + api_key: str | None, + output_format: str, + role_filter: str | None, + active_only: bool, +): + """List available LangSmith workspaces.""" + if not api_key: + raise click.ClickException( + "LANGSMITH_API_KEY is required. " + "Set it as an environment variable or use --api-key." + ) + + try: + result = get_workspaces(api_key) + + # Apply filters + if active_only: + result = result.filter_active() + if role_filter: + result = result.filter_by_role(role_filter) + + # Output + if output_format == "json": + print_json(result) + else: + print_table(result) + + except RuntimeError as e: + raise click.ClickException(str(e)) from e + + +@cli.command("get") +@click.option( + "--api-key", + envvar="LANGSMITH_API_KEY", + help="LangSmith API key", +) +@click.argument("workspace_id") +def get_workspace(api_key: str | None, workspace_id: str): + """Get details for a specific workspace by ID.""" + if not api_key: + raise click.ClickException( + "LANGSMITH_API_KEY is required. " + "Set it as an environment variable or use --api-key." + ) + + try: + result = get_workspaces(api_key) + workspace = next((w for w in result.workspaces if w.id == workspace_id), None) + + if workspace: + click.echo(json.dumps(workspace.model_dump(), indent=2, default=str)) + else: + raise click.ClickException(f"Workspace not found: {workspace_id}") + + except RuntimeError as e: + raise click.ClickException(str(e)) from e + + +if __name__ == "__main__": + cli() diff --git a/packages/python/langsmith-client/src/langsmith_client/tools/projects.py b/packages/python/langsmith-client/src/langsmith_client/tools/projects.py new file mode 100644 index 00000000..b5940bc6 --- /dev/null +++ b/packages/python/langsmith-client/src/langsmith_client/tools/projects.py @@ -0,0 +1,291 @@ +#!/usr/bin/env python3 +""" +Manage LangSmith tracing projects. + +Tracing projects (also called sessions) are created automatically when LangGraph +deployments are created. Use this tool to list and delete them. + +The delete command transparently handles orphaned projects — where the linked +deployment no longer exists — by clearing the stale reference and retrying. + +PREREQUISITES: +- LANGSMITH_API_KEY: Your LangSmith API key + +USAGE: + langsmith-projects list + langsmith-projects list --name hello-world-graph + langsmith-projects delete --id + langsmith-projects delete --name hello-world-graph + langsmith-projects delete --name hello-world --force +""" + +import json +import os +from typing import Any + +import click +import requests +from dotenv import load_dotenv + +load_dotenv() + +SMITH_API = "https://api.smith.langchain.com" + +HTTP_OK = 200 +HTTP_ACCEPTED = 202 +HTTP_CONFLICT = 409 + +_REQUEST_TIMEOUT = 30 + + +# ============================================================================= +# Client +# ============================================================================= + + +class TracingProjectClient: + """Client for the LangSmith tracing projects (sessions) API.""" + + def __init__(self, api_key: str | None = None): + self.api_key = api_key or os.environ.get("LANGSMITH_API_KEY") + if not self.api_key: + raise ValueError( + "LANGSMITH_API_KEY is required. " + "Set it as an environment variable or pass --api-key." + ) + self.headers = { + "x-api-key": self.api_key, + "Content-Type": "application/json", + } + + def list_projects(self, name: str | None = None) -> list[dict[str, Any]]: + """List tracing projects, optionally filtered by exact name.""" + params: dict[str, str] = {} + if name: + params["name"] = name + resp = requests.get( + f"{SMITH_API}/api/v1/sessions", + headers=self.headers, + params=params, + timeout=_REQUEST_TIMEOUT, + ) + if resp.status_code != HTTP_OK: + raise RuntimeError( + f"Failed to list projects: {resp.status_code}\n{resp.text}" + ) + return resp.json() + + def _clear_deployment_ref(self, project_id: str) -> None: + """Remove stale deployment_id from a project's extra metadata.""" + resp = requests.patch( + f"{SMITH_API}/api/v1/sessions/{project_id}", + headers=self.headers, + json={"extra": {}}, + timeout=_REQUEST_TIMEOUT, + ) + if resp.status_code != HTTP_OK: + raise RuntimeError( + f"Failed to clear deployment reference on {project_id}: " + f"{resp.status_code}\n{resp.text}" + ) + + def delete_project(self, project_id: str, *, force: bool = False) -> bool: + """ + Delete a tracing project by ID. + + If the project has a stale deployment reference (409) and force=True, + the reference is cleared automatically before retrying the delete. + Returns True on success, raises RuntimeError otherwise. + """ + resp = requests.delete( + f"{SMITH_API}/api/v1/sessions/{project_id}", + headers=self.headers, + timeout=_REQUEST_TIMEOUT, + ) + + if resp.status_code == HTTP_ACCEPTED: + return True + + if resp.status_code == HTTP_CONFLICT: + detail = resp.json().get("detail", resp.text) + + if "associated with a LangGraph deployment" not in detail: + raise RuntimeError(f"409 Conflict: {detail}") + + if not force: + raise RuntimeError( + f"409 Conflict: {detail}\n" + "Re-run with --force to automatically clear stale " + "deployment references." + ) + + # Deployment is orphaned — clear the reference and retry. + self._clear_deployment_ref(project_id) + retry = requests.delete( + f"{SMITH_API}/api/v1/sessions/{project_id}", + headers=self.headers, + timeout=_REQUEST_TIMEOUT, + ) + if retry.status_code == HTTP_ACCEPTED: + return True + raise RuntimeError( + f"Delete failed after clearing deployment reference: " + f"{retry.status_code}\n{retry.text}" + ) + + raise RuntimeError( + f"Failed to delete project {project_id}: {resp.status_code}\n{resp.text}" + ) + + +# ============================================================================= +# Output formatters +# ============================================================================= + + +def _print_table(projects: list[dict[str, Any]]) -> None: + if not projects: + click.echo("No projects found.") + return + + id_w = max((len(p.get("id", "")) for p in projects), default=36) + id_w = max(id_w, 4) + name_w = max((len(p.get("name", "")) for p in projects), default=20) + name_w = max(name_w, 4) + + header = f"{'ID':<{id_w}} {'Name':<{name_w}} Deployment ID" + click.echo(f"\nFound {len(projects)} project(s):\n") + click.echo(header) + click.echo("-" * (len(header) + 10)) + + for p in projects: + dep_id = p.get("extra", {}).get("deployment_id", "") + click.echo( + f"{p.get('id', ''):<{id_w}} {p.get('name', ''):<{name_w}} {dep_id}" + ) + click.echo() + + +# ============================================================================= +# CLI +# ============================================================================= + + +def _api_key_option(func): + return click.option( + "--api-key", + envvar="LANGSMITH_API_KEY", + help="LangSmith API key (defaults to LANGSMITH_API_KEY env var)", + )(func) + + +@click.group() +@click.version_option(version="1.0.0") +def cli(): + """Manage LangSmith tracing projects. + + \b + PREREQUISITES: + - LANGSMITH_API_KEY: Your LangSmith API key + """ + pass + + +@cli.command("list") +@_api_key_option +@click.option("--name", help="Filter by exact project name") +@click.option( + "--format", + "output_format", + type=click.Choice(["table", "json"]), + default="table", + help="Output format (default: table)", +) +def list_projects(api_key: str | None, name: str | None, output_format: str): + """List tracing projects.""" + client = TracingProjectClient(api_key) + + try: + projects = client.list_projects(name=name) + except RuntimeError as e: + raise click.ClickException(str(e)) from e + + if output_format == "json": + click.echo(json.dumps(projects, indent=2, default=str)) + else: + _print_table(projects) + + +@cli.command() +@_api_key_option +@click.option("--id", "project_id", help="Project ID to delete") +@click.option("--name", help="Delete all projects matching this exact name") +@click.option( + "--force", + is_flag=True, + help="Clear stale deployment references to unblock deletion", +) +@click.confirmation_option(prompt="Are you sure you want to delete this project?") +def delete( + api_key: str | None, + project_id: str | None, + name: str | None, + force: bool, +): + """Delete one or more tracing projects. + + \b + Provide either --id for a single project or --name to delete all + projects with that exact name. + + \b + Use --force when a project is blocked by a stale deployment reference + (the linked deployment no longer exists but was not properly cleaned up). + """ + if not project_id and not name: + raise click.UsageError("Provide either --id or --name.") + if project_id and name: + raise click.UsageError("Provide either --id or --name, not both.") + + client = TracingProjectClient(api_key) + + targets: list[dict[str, Any]] = [] + + try: + if project_id: + targets = [{"id": project_id, "name": project_id}] + else: + targets = client.list_projects(name=name) + if not targets: + click.echo(f"No projects found with name: {name}") + return + click.echo(f"Found {len(targets)} project(s) named '{name}'") + except RuntimeError as e: + raise click.ClickException(str(e)) from e + + deleted = 0 + failed = 0 + for project in targets: + pid = project["id"] + pname = project.get("name", pid) + dep_id = project.get("extra", {}).get("deployment_id", "") + suffix = f" (orphaned deployment: {dep_id})" if dep_id and force else "" + click.echo(f" Deleting '{pname}' [{pid}]{suffix} ...") + + try: + client.delete_project(pid, force=force) + click.echo(click.style(" ✓ Deleted", fg="green")) + deleted += 1 + except RuntimeError as e: + click.echo(click.style(f" ✗ {e}", fg="red")) + failed += 1 + + click.echo() + if deleted: + click.echo(click.style(f"{deleted} project(s) deleted.", fg="green")) + if failed: + click.echo(click.style(f"{failed} project(s) failed.", fg="red")) + + +if __name__ == "__main__": + cli() diff --git a/pyproject.toml b/pyproject.toml index e4344931..44020aa6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,11 +4,45 @@ version = "0.1.0" description = "" authors = [{ name = "Matt Norris", email = "matt@mattnorris.dev" }] readme = "README.md" -requires-python = ">=3.11,<4.0" +requires-python = ">=3.12,<3.13" dependencies = [] +[dependency-groups] +dev = [ + "ruff>=0.14.2", + "bandit>=1.8.6", + "pre-commit>=4.0.1", + "pylint>=3.1.0", + "perflint>=0.8.1", +] + [tool.uv.workspace] members = [ "tools/python/core_github", "tools/python/gcp_gemini", + "tools/python/langsmith-hosting", + "tools/python/pulumi-utils", + "packages/python/langsmith-client", + "packages/python/azure-ai", +] + +[tool.ruff] +exclude = ["**/migrations/**", "**/tmp/**"] + +[tool.ruff.lint] +select = ["E", "F", "B", "PERF", "C", "I", "N", "PL"] + +[tool.bandit] +exclude_dirs = [".venv", "venv", "node_modules", "__pycache__", ".git"] +exclude = ["*test*.py"] + +[tool.pylint] +load-plugins = ["perflint"] +ignore = ["migrations", "tests", "tmp", "__pycache__", ".venv", "venv"] +ignore-patterns = [ + ".*/migrations/.*", + ".*/tests/.*", + ".*/test_.*", + ".*/__pycache__/.*", ] +disable = ["C", "R", "W"] diff --git a/tools/python/langsmith-hosting/.env.example b/tools/python/langsmith-hosting/.env.example new file mode 100644 index 00000000..f0729527 --- /dev/null +++ b/tools/python/langsmith-hosting/.env.example @@ -0,0 +1,3 @@ +# AWS profile for CLI commands (e.g., kubectl config export). +# Omit or leave empty to use the default profile. +AWS_PROFILE= diff --git a/tools/python/langsmith-hosting/Pulumi.yaml b/tools/python/langsmith-hosting/Pulumi.yaml new file mode 100644 index 00000000..fe9580da --- /dev/null +++ b/tools/python/langsmith-hosting/Pulumi.yaml @@ -0,0 +1,4 @@ +name: langsmith-hosting +runtime: python +main: src/langsmith_hosting +description: LangSmith Hybrid infrastructure on AWS provisioned with Pulumi diff --git a/tools/python/langsmith-hosting/README.md b/tools/python/langsmith-hosting/README.md new file mode 100644 index 00000000..0812d99f --- /dev/null +++ b/tools/python/langsmith-hosting/README.md @@ -0,0 +1,41 @@ +# LangSmith Hosting + +Pulumi project that provisions AWS infrastructure for LangSmith Hybrid. + +## Resources + +- **VPC** with public/private subnets across 3 availability zones +- **EKS** cluster with managed node group, EBS CSI driver, and GP3 storage class +- **RDS PostgreSQL** instance for LangSmith data +- **ElastiCache Redis** cluster for caching +- **S3 bucket** with VPC endpoint for blob storage + +## Prerequisites + +- AWS CLI configured with a named profile (set `awsProfile` in your stack config) +- [uv](https://docs.astral.sh/uv/) installed +- Pulumi CLI installed + +## Quickstart + +```bash +# From this directory +uv run pulumi login file://. +uv run pulumi stack select dev +uv run pulumi up +``` + +## Configuration + +Stack configuration lives in `Pulumi.dev.yaml`. Key settings: + +| Config Key | Description | Default | +|---|---|---| +| `environment` | Environment name | `dev` | +| `vpcCidr` | VPC CIDR block | `10.0.0.0/16` | +| `eksClusterName` | EKS cluster name | `langsmith-hybrid-dev` | +| `eksClusterVersion` | Kubernetes version | `1.31` | +| `eksNodeInstanceType` | EC2 instance type for nodes | `m5.large` | +| `postgresInstanceClass` | RDS instance class | `db.t3.medium` | +| `redisNodeType` | ElastiCache node type | `cache.t3.micro` | +| `s3BucketPrefix` | S3 bucket name prefix | `langsmith` | diff --git a/tools/python/langsmith-hosting/docs/architecture.md b/tools/python/langsmith-hosting/docs/architecture.md new file mode 100644 index 00000000..bded4b04 --- /dev/null +++ b/tools/python/langsmith-hosting/docs/architecture.md @@ -0,0 +1,155 @@ +# Architecture + +Pulumi package that provisions the full LangSmith Hybrid stack on AWS. + +## Project Structure + +``` +langsmith-hosting/ +├── Pulumi.yaml # Project config (Python runtime, shared venv) +├── Pulumi.dev.yaml # Dev stack config (AWS profile, region, sizing) +├── pyproject.toml # Dependencies (pulumi, pulumi-aws, pulumi-eks, ...) +├── README.md +├── docs/ +│ └── architecture.md # This file +└── src/ + └── langsmith_hosting/ + ├── __init__.py + ├── __main__.py # Entry point: wires all modules, exports outputs + ├── config.py # Typed config loading from Pulumi stack config + ├── constants.py # PROJECT_NAME, TAGS + ├── vpc.py # VPC + subnets + ├── eks.py # EKS cluster + node group + addons + ├── postgres.py # RDS PostgreSQL + ├── redis.py # ElastiCache Redis + └── s3.py # S3 bucket + VPC endpoint +``` + +## Resource Dependency Chain + +Dependencies are expressed through Pulumi output references -- when one resource +consumes the output of another, Pulumi automatically creates and respects the +dependency ordering. + +```mermaid +flowchart TD + VPC[VPC + Subnets] --> EKS[EKS Cluster] + VPC --> Postgres[RDS PostgreSQL] + VPC --> Redis[ElastiCache Redis] + VPC --> S3[S3 Bucket + VPC Endpoint] + EKS --> EbsCsi[EBS CSI Driver Addon] + EbsCsi --> HelmAddons[Helm Addons: ALB Controller, Autoscaler, Metrics Server] + EbsCsi --> StorageClass[GP3 Storage Class] + RandomPw[Random Password] --> Postgres +``` + +## Module Details + +### `__main__.py` -- Entry Point + +Orchestrates all modules in order: + +1. Loads `.env` and Pulumi stack config via `config.py` +2. Gets AWS caller identity and region +3. Creates VPC +4. Creates EKS cluster (depends on VPC) +5. Creates PostgreSQL (depends on VPC + random password) +6. Creates Redis (depends on VPC) +7. Creates S3 bucket (depends on VPC) +8. Exports stack outputs + +### `config.py` -- Configuration + +Reads `Pulumi..yaml` values into a frozen `LangSmithConfig` dataclass. +Every Terraform variable from the original project has a corresponding config +key with sensible defaults: + +| Config Key | Type | Default | +|---|---|---| +| `environment` | str | *(required)* | +| `vpcCidr` | str | `10.0.0.0/16` | +| `eksClusterName` | str | *(required)* | +| `eksClusterVersion` | str | `1.31` | +| `eksNodeInstanceType` | str | `m5.large` | +| `eksNodeMinSize` | int | `2` | +| `eksNodeMaxSize` | int | `5` | +| `eksNodeDesiredSize` | int | `2` | +| `postgresInstanceClass` | str | `db.t3.medium` | +| `postgresEngineVersion` | str | `16.6` | +| `postgresAllocatedStorage` | int | `20` | +| `postgresMaxAllocatedStorage` | int | `100` | +| `redisNodeType` | str | `cache.t3.micro` | +| `s3BucketPrefix` | str | `langsmith` | + +### `vpc.py` -- VPC + +Uses `pulumi_awsx.ec2.Vpc` to create: + +- VPC with configurable CIDR block +- Public and private subnets across 3 availability zones +- Single NAT gateway +- Subnet tags for EKS auto-discovery (`kubernetes.io/cluster/`) + +### `eks.py` -- EKS Cluster + +Uses `pulumi_eks.Cluster` and related resources to create: + +- **IAM role** for worker nodes with policies: EKSWorkerNodePolicy, EKS_CNI_Policy, EC2ContainerRegistryReadOnly, EBSCSIDriverPolicy +- **EKS cluster** with API authentication mode +- **Managed node group** with configurable instance type and scaling +- **Kubernetes provider** from the cluster kubeconfig +- **EBS CSI driver addon** (`aws-ebs-csi-driver`) +- **GP3 storage class** set as cluster default +- **Helm addons**: + - AWS Load Balancer Controller + - metrics-server + - cluster-autoscaler + +### `postgres.py` -- RDS PostgreSQL + +Uses `pulumi_aws.rds` and `pulumi_random` to create: + +- **Random password** (32 characters, no special characters) +- **DB subnet group** in private subnets +- **Security group** allowing port 5432 from VPC CIDR +- **RDS instance** with PostgreSQL engine, encrypted storage, no public access + +Outputs include a full `postgresql://` connection URL. + +### `redis.py` -- ElastiCache Redis + +Uses `pulumi_aws.elasticache` to create: + +- **Cache subnet group** in private subnets +- **Security group** allowing port 6379 from VPC CIDR +- **ElastiCache cluster** running Redis 7.0 with a single node + +### `s3.py` -- S3 Bucket + +Uses `pulumi_aws.s3` and `pulumi_aws.ec2` to create: + +- **S3 bucket** named `--` with AES256 encryption +- **Public access block** denying all public access +- **VPC gateway endpoint** for S3 (routes traffic within the VPC) +- **Bucket policy** restricting access to the VPC endpoint only + +## Exports + +The stack exports the following values (accessible via `pulumi stack output`): + +| Output | Description | +|---|---| +| `aws_account_id` | AWS account ID | +| `aws_region` | AWS region | +| `vpc_id` | VPC ID | +| `private_subnet_ids` | Private subnet IDs | +| `eks_cluster_name` | EKS cluster name | +| `eks_oidc_provider_arn` | EKS OIDC provider ARN (for IRSA) | +| `postgres_connection_url` | PostgreSQL connection URL (sensitive) | +| `s3_bucket_name` | S3 bucket name | +| `kubectl_config_command` | Command to configure kubectl | + +## Configuration + +Stack-specific config lives in `Pulumi..yaml` (e.g., `Pulumi.dev.yaml`). +See the [README](../README.md) for the full configuration reference table. diff --git a/tools/python/langsmith-hosting/docs/deploying-langsmith-hybrid-on-aws.md b/tools/python/langsmith-hosting/docs/deploying-langsmith-hybrid-on-aws.md new file mode 100644 index 00000000..c307a7e1 --- /dev/null +++ b/tools/python/langsmith-hosting/docs/deploying-langsmith-hybrid-on-aws.md @@ -0,0 +1,389 @@ +# Deploying LangSmith Hybrid on AWS EKS: A Practitioner's Guide + +*February 2026* + +This post documents the end-to-end journey of deploying LangSmith Hybrid on +AWS EKS, building custom tooling around it, and debugging every failure along +the way. It is written as a practical reference for anyone repeating this +work -- whether a person or an automated agent. + +--- + +## Background + +Our team needed a way to run LLM agents in production on our own +infrastructure while keeping the LangSmith control plane managed by +LangChain. LangSmith Hybrid gives you exactly this: the control plane +(UI, tracing, deployment management) lives in LangChain's cloud, while +the **data plane** (the actual agent pods, Redis, and supporting services) +runs in your own Kubernetes cluster. + +The project started as a Terraform deployment, evolved into a Pulumi-managed +stack, and grew to include a custom Python CLI for building, pushing, and +deploying agent images. + +**Assumption:** The agents in this guide use Azure OpenAI for chat model +inference. The `azure-ai` Pulumi package provisions the Azure OpenAI +account, model deployment, and firewall rules. If you use a different LLM +provider, Phase 4 and the Azure-specific firewall steps won't apply, but +the rest of the guide (EKS infrastructure, build tooling, debugging) is +provider-agnostic. + +--- + +## Phase 1: Terraform Foundation + +We started with a pure Terraform approach using LangChain's official modules +from `github.com/langchain-ai/terraform`: + +| Component | Module | Purpose | +|-------------|-------------------------|---------------------------------------| +| VPC | `modules/aws/vpc` | Public/private subnets, NAT gateway | +| EKS | `modules/aws/eks` | Kubernetes cluster | +| PostgreSQL | `modules/aws/postgres` | RDS database | +| Redis | `modules/aws/redis` | ElastiCache | +| S3 | `modules/aws/s3` | Object storage with VPC endpoint | + +Terraform taught us the shape of the infrastructure, but we hit friction +immediately: + +- **Module variable mismatches.** LangChain modules use different variable + names than standard Terraform AWS modules (e.g., `instance_class` vs. + `instance_type`). We had to inspect each module's `variables.tf` manually. +- **Circular dependencies.** Root-level Kubernetes/Helm providers created a + cycle with the EKS module. The EKS module manages its own providers + internally -- you have to let it. +- **Orphaned resources.** Failed deployments left behind KMS aliases, + CloudWatch log groups, RDS subnet groups, ElastiCache subnet groups, and S3 + buckets that conflicted with fresh runs. +- **SSO timeouts.** EKS deployments take 15-20 minutes, often outlasting the + AWS SSO session. + +We built shell scripts (`deploy.sh`, `list-aws-resources.sh`, +`destroy-aws-resources.sh`) to manage targeted applies and dependency-ordered +destroys, but the Terraform workflow remained brittle for our use case. + +--- + +## Phase 2: Migrating to Pulumi + +We moved the infrastructure to Pulumi (Python) to get first-class +programmability and to share configuration logic with the rest of the +monorepo. The Pulumi stack lives in `tools/python/langsmith-hosting/` and +provisions: + +1. **VPC** with public and private subnets and a NAT gateway +2. **EKS cluster** with managed node groups, Pod Identity, EBS CSI driver, + cluster autoscaler, and an AWS Load Balancer Controller +3. **S3 bucket** for LangSmith blob storage +4. **RDS PostgreSQL** instance +5. **ElastiCache Redis** cluster +6. **Data plane** (KEDA + the `langgraph-dataplane` Helm chart) + +### Configuration as code + +Rather than duplicating values across `Pulumi.dev.yaml` and Python, we +extracted sensible defaults into module-level constants in `config.py`: + +```python +_VPC_CIDR = "10.0.0.0/16" +_EKS_CLUSTER_VERSION = "1.31" +_EKS_NODE_INSTANCE_TYPE = "m5.xlarge" +_POSTGRES_INSTANCE_CLASS = "db.t3.medium" +_REDIS_NODE_TYPE = "cache.t3.micro" +``` + +The stack config file shrank from 16 keys to 5 essentials: `environment`, +`eksClusterName`, `s3BucketPrefix`, `langsmithWorkspaceId`, and +`langsmithApiKey`. Everything else uses the Python defaults unless +explicitly overridden. + +### Listener as a dynamic resource + +The LangSmith Hybrid data plane requires a **listener** registered with the +control plane API. We built a Pulumi dynamic resource (`LangSmithListener`) +that calls the LangSmith API to create, read, and delete listeners as part +of `pulumi up` / `pulumi destroy`. This eliminated the manual +`manage_listeners.py` step from the Terraform era. + +### Dataplane Helm release + +The data plane itself is a single Helm release (`langgraph-dataplane`) that +installs the listener agent, operator, and a Redis StatefulSet. The Helm +values are wired to Pulumi outputs (API key, workspace ID, listener ID) so +everything stays in sync: + +```python +k8s.helm.v3.Release( + f"{cluster_name}-dataplane", + name="dataplane", + chart="langgraph-dataplane", + values={ + "config": { + "langsmithApiKey": langsmith_api_key, + "langgraphListenerId": listener.listener_id, + "enableLGPDeploymentHealthCheck": _ENABLE_HEALTH_CHECK, + }, + ... + }, + opts=pulumi.ResourceOptions(depends_on=[keda, listener]), +) +``` + +--- + +## Phase 3: Build and Deploy Tooling + +We created a Python CLI package (`langsmith-client`) that wraps the +build-push-deploy cycle into repeatable commands. + +### `langsmith-build` + +Builds a Docker image using `langgraph build` with an auto-generated tag +derived from `pyproject.toml`. The key insight was appending the **Git SHA** +to every image tag: + +``` +hello-world-graph:0.1.0-ff95149 +``` + +This solved a persistent Kubernetes caching problem. With a static tag like +`hello-world-graph:0.1.0`, the default `imagePullPolicy: IfNotPresent` +caused nodes to skip pulling the updated image. Unique tags forced a fresh +pull on every deployment. + +The implementation is a small private helper in `build.py`: + +```python +def _get_git_sha() -> str | None: + try: + result = subprocess.run( + ["git", "rev-parse", "--short", "HEAD"], + capture_output=True, text=True, timeout=5, + ) + if result.returncode == 0: + return result.stdout.strip() + except Exception: + pass + return None +``` + +If Git is unavailable (e.g., inside a CI container without the repo), it +falls back to `name:version`. + +### `langsmith-deploy-docker` + +Creates or updates a deployment through the LangSmith API, specifying the +ECR image URI, environment variables (Azure OpenAI secrets), and the target +listener. This replaced manual deployments through the LangSmith UI. + +--- + +## Phase 4: Azure OpenAI Integration + +The agent uses Azure OpenAI (`gpt-5.1-chat`) via `AzureChatOpenAI` from +LangChain. Getting this to work from inside EKS required solving two +distinct problems. + +### Problem: 403 Forbidden from Azure + +Azure OpenAI has Virtual Network / firewall restrictions. The EKS cluster's +outbound traffic exits through a NAT gateway, and that IP was not in the +Azure allowlist. + +**Diagnosis:** + +```bash +kubectl exec -- \ + python3 -c "from urllib.request import urlopen; print(urlopen('https://ifconfig.me').read().decode())" +``` + +**Fix:** Add the NAT gateway's public IP to the Azure OpenAI resource's +firewall rules. We later automated this with the `azure-ai` Pulumi package +(`packages/python/azure-ai/`) that manages the Azure OpenAI resource and +its firewall rules, accepting NAT IPs as input from the hosting stack. + +### Problem: Temperature incompatibility + +`gpt-5.1-chat` only accepts `temperature=1`. LangChain's `create_agent` +internally binds `temperature=0` for deterministic behavior, which the model +silently rejects. The symptom was an `httpx.UnsupportedProtocol` error in +the health check -- deeply misleading, because the real error was buried in +the Azure API response. + +**Fix:** + +```python +model = AzureChatOpenAI( + azure_endpoint=os.environ.get("AZURE_OPENAI_ENDPOINT"), + azure_deployment=os.environ.get("AZURE_OPENAI_DEPLOYMENT"), + api_version=os.environ.get("AZURE_OPENAI_API_VERSION", "2025-01-01-preview"), + temperature=1, +) +``` + +--- + +## Phase 5: Debugging in Production + +Every deployment revealed a new class of failure. Here are the ones that +cost the most time. + +### Stale images on Kubernetes nodes + +**Symptom:** Deployment succeeds, pod is `Running`, but runs old code. + +**Root cause:** Same image tag + `imagePullPolicy: IfNotPresent` = cached +stale image. + +**Fix:** Git SHA in every tag (see Phase 3). + +### Port-forward dies on pod restart + +**Symptom:** `error: lost connection to pod` after deploying a new revision. + +**Root cause:** Port-forward targets a specific pod. When LangSmith replaces +the pod with a new revision, the old pod is terminated. + +**Fix:** Port-forward via the Kubernetes *service* instead: + +```bash +lsof -ti :8000 | xargs kill -9 2>/dev/null +kubectl port-forward svc/ 8000:8000 +``` + +The service routes to whichever pods are currently healthy. + +### 404 Not Found from the LangGraph SDK + +**Symptom:** `langgraph_sdk.errors.NotFoundError: 404 Not Found` + +**Root cause:** The agent API lives under a mount prefix +(`/lgp//`). A stale port-forward or wrong prefix causes +every API call to 404. + +**Fix:** Query the pod's `MOUNT_PREFIX` environment variable: + +```bash +kubectl get pod \ + -o jsonpath='{range .spec.containers[0].env[*]}{.name}={.value}{"\n"}{end}' \ + | grep MOUNT +``` + +Then pass that prefix to the SDK client. + +### Health check fails with `httpx.UnsupportedProtocol` + +**Symptom:** The LangSmith UI shows deployment failure with a protocol error. + +**Root cause:** The dataplane constructs a health check URL from the +`ingress.hostname` Helm value. Without a hostname, the URL has no scheme. + +**Fix (workaround):** Disable health checks until an ingress is configured: + +```python +_ENABLE_HEALTH_CHECK = False +``` + +**Fix (permanent):** Configure the ingress hostname so the health check URL +is well-formed. + +--- + +## Phase 6: Refactoring for Maintainability + +With the infrastructure working, we cleaned up the codebase: + +- **Extracted constants.** Moved 11 infrastructure defaults from YAML config + into Python module-level constants. The stack config file shrank to only + the values that are genuinely environment-specific. +- **Shortened resource names.** Renamed `langgraph-dataplane` to `dataplane` + throughout, aligning with the LangSmith branding. +- **Shared packages.** `azure-ai` lives in `packages/python/azure-ai/` as a + composable library that other stacks can import via `deploy_stack(extra_ips=...)`. +- **Organized IAM policies.** Moved inline policy dicts (like the cluster + autoscaler policy) to module-level constants for readability. +- **Created a kubectl runbook.** Documented every production debugging + session into a structured runbook with copy-paste commands, organized by + problem and solution. + +--- + +## Architecture Summary + +``` +tools/python/ +├── langsmith-hosting/ # Pulumi: VPC, EKS, S3, RDS, Redis, dataplane +└── pulumi-utils/ # Shared Pulumi naming helpers + +packages/python/ +├── azure-ai/ # Pulumi: Azure OpenAI account, model, firewall +└── langsmith-client/ # CLI: build, deploy, list workspaces/listeners +``` + +The data flow: + +1. `langsmith-build` reads `pyproject.toml`, appends the Git SHA, and runs + `langgraph build` to produce a Docker image. +2. The image is tagged and pushed to ECR. +3. `langsmith-deploy-docker` calls the LangSmith API to create/update a + deployment, specifying the ECR image URI and environment secrets. +4. The LangSmith control plane tells the dataplane listener in the EKS + cluster to pull the image and create pods. +5. The agent pods run, connecting to Azure OpenAI through the NAT gateway. + +--- + +## Lessons Learned + +1. **Tag images uniquely.** Never reuse the same tag for different builds. + Git SHAs are free and make debugging trivial. + +2. **Port-forward to services, not pods.** Pods are ephemeral. Services + survive restarts. + +3. **Errors lie.** `httpx.UnsupportedProtocol` was really a temperature + validation failure three layers deep. Always check pod logs before + trusting error messages. + +4. **Extract defaults into code.** YAML config files should contain only + what varies between environments. Everything else belongs in typed Python + constants with clear names and comments. + +5. **Automate the listener lifecycle.** Managing listeners manually was a + constant source of drift. Making it a Pulumi dynamic resource eliminated + an entire class of errors. + +6. **Document while debugging.** The kubectl runbook we wrote during + production incidents has already saved hours. Debugging commands are + worth more than architecture diagrams. + +7. **Model APIs have quirks.** `gpt-5.1-chat` rejecting `temperature=0` is + not documented anywhere obvious. When an agent fails in production, check + the model's parameter constraints first. + +8. **Firewall rules follow the NAT.** If your cluster uses a NAT gateway, + every external API with IP restrictions needs that NAT IP in its + allowlist. Export it as a stack output and wire it into downstream stacks. + +--- + +## What's Next + +- **Ingress hostname and TLS.** Configure a DNS record pointing to the ALB + so the health check works and LangSmith Studio can reach the agent + directly. +- **CI/CD pipeline.** Automate the build-push-deploy cycle so agents deploy + on merge to main. +- **Tracing project management.** Build a utility to clean up stale LangSmith + tracing projects. +- **Production stack.** Stand up a second environment with stricter IAM, + larger nodes, and multi-AZ RDS. + +--- + +## References + +- [LangSmith Hybrid Deployment Docs](https://docs.langchain.com/langsmith/deploy-with-control-plane) +- [LangSmith Troubleshooting (Kubernetes)](https://docs.langchain.com/langsmith/troubleshooting#kubernetes) +- [Architecture](architecture.md) -- infrastructure overview diff --git a/tools/python/langsmith-hosting/docs/langsmith-hybrid-resources-costs.md b/tools/python/langsmith-hosting/docs/langsmith-hybrid-resources-costs.md new file mode 100644 index 00000000..f9c7aa43 --- /dev/null +++ b/tools/python/langsmith-hosting/docs/langsmith-hybrid-resources-costs.md @@ -0,0 +1,48 @@ +# LangSmith AWS resources and costs + +Here is what LangChain's BYOC provisions: + +## ECS Cluster + +**LangChain provisions ECS Fargate tasks** to run your LangGraph agents: + +- Serverless compute (no EC2 instances to manage) +- CPU/memory sized per deployment (you configure this when creating the deployment) +- Tasks run in your tagged VPC subnets +- LangChain uses `AmazonECS_FullAccess` to create/update/delete these resources + +You don't directly control the ECS task definition - LangChain's control plane creates it based on your deployment configuration (min/max scale, CPU, memory). + +## RDS Database + +**LangChain provisions an RDS PostgreSQL instance** for each deployment to store: + +- Agent state (checkpoints, threads) +- Run history +- Persistent data + +**Key point:** You can **skip RDS provisioning** by providing your own PostgreSQL: + +``` +POSTGRES_URI_CUSTOM=postgresql://:@:5432/langgraph +``` + +This is useful if you: + +- Want to control database sizing/costs +- Already have a PostgreSQL cluster +- Want to use Aurora Serverless instead of RDS + +## Cost Implications + +You pay AWS directly for: + +- **ECS Fargate**: ~$0.04/vCPU-hour + ~$0.004/GB-hour +- **RDS**: Varies by instance type (db.t3.micro ~$15/mo, db.t3.small ~$30/mo) +- **Data transfer, CloudWatch logs, Secrets Manager** + +Plus LangGraph Platform fees (Enterprise tier for BYOC). + +--- + +**Recommendation:** If cost control matters, consider using `POSTGRES_URI_CUSTOM` with an existing database or Aurora Serverless rather than letting LangChain provision RDS instances per deployment. diff --git a/tools/python/langsmith-hosting/pyproject.toml b/tools/python/langsmith-hosting/pyproject.toml new file mode 100644 index 00000000..25bdad05 --- /dev/null +++ b/tools/python/langsmith-hosting/pyproject.toml @@ -0,0 +1,23 @@ +[project] +name = "langsmith-hosting" +version = "0.1.0" +description = "LangSmith Hybrid infrastructure on AWS provisioned with Pulumi" +readme = "README.md" +requires-python = ">=3.12,<3.13" +dependencies = [ + "pulumi>=3.0.0", + "pulumi-aws>=6.0.0", + "pulumi-awsx>=2.0.0", + "pulumi-eks>=3.0.0", + "pulumi-kubernetes>=4.0.0", + "pulumi-random>=4.0.0", + "python-dotenv>=1.0.0", + "requests>=2.31.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/langsmith_hosting"] diff --git a/tools/python/langsmith-hosting/src/langsmith_hosting/__init__.py b/tools/python/langsmith-hosting/src/langsmith_hosting/__init__.py new file mode 100644 index 00000000..9d30ef70 --- /dev/null +++ b/tools/python/langsmith-hosting/src/langsmith_hosting/__init__.py @@ -0,0 +1 @@ +"""LangSmith Hosting - Pulumi infrastructure for LangSmith Hybrid on AWS.""" diff --git a/tools/python/langsmith-hosting/src/langsmith_hosting/__main__.py b/tools/python/langsmith-hosting/src/langsmith_hosting/__main__.py new file mode 100644 index 00000000..43d828da --- /dev/null +++ b/tools/python/langsmith-hosting/src/langsmith_hosting/__main__.py @@ -0,0 +1,146 @@ +"""LangSmith Hybrid infrastructure on AWS provisioned with Pulumi. + +Creates the full LangSmith Hybrid stack: + 1. VPC with public/private subnets across 3 AZs + 2. EKS cluster with managed node group, EBS CSI driver, and Helm addons + 3. RDS PostgreSQL instance + 4. ElastiCache Redis cluster + 5. S3 bucket with VPC endpoint + 6. LangSmith data plane (listener, KEDA, langgraph-dataplane Helm chart) + +Resource dependency chain: + VPC -> EKS -> EBS CSI -> Helm addons -> GP3 storage class + VPC -> Postgres (+ random password) + VPC -> Redis + VPC -> S3 + EKS -> Listener (API) -> langgraph-dataplane (Helm) + EKS -> KEDA (Helm) -> langgraph-dataplane (Helm) +""" + +import pulumi +import pulumi_aws as aws +from dotenv import load_dotenv + +from langsmith_hosting.config import load_config +from langsmith_hosting.dataplane import create_dataplane +from langsmith_hosting.eks import create_eks_cluster +from langsmith_hosting.postgres import create_postgres +from langsmith_hosting.redis import create_redis +from langsmith_hosting.s3 import create_s3 +from langsmith_hosting.vpc import create_vpc + +load_dotenv() + +# ============================================================================= +# Configuration +# ============================================================================= +cfg = load_config() + +# Get current AWS account and region +current = aws.get_caller_identity() +region = aws.get_region() + +# ============================================================================= +# VPC (Step 1 in apply-targeted.sh) +# ============================================================================= +vpc = create_vpc( + cluster_name=cfg.eks_cluster_name, + cidr_block=cfg.vpc_cidr, +) + +# ============================================================================= +# EKS (Steps 2-7 in apply-targeted.sh) +# ============================================================================= +eks = create_eks_cluster( + cluster_name=cfg.eks_cluster_name, + cluster_version=cfg.eks_cluster_version, + vpc_id=vpc.vpc_id, + private_subnet_ids=vpc.private_subnet_ids, + public_subnet_ids=vpc.public_subnet_ids, + node_instance_type=cfg.eks_node_instance_type, + node_min_size=cfg.eks_node_min_size, + node_max_size=cfg.eks_node_max_size, + node_desired_size=cfg.eks_node_desired_size, +) + +# ============================================================================= +# PostgreSQL (Step 8 in apply-targeted.sh) +# ============================================================================= +postgres = create_postgres( + name=f"{cfg.eks_cluster_name}-postgres", + vpc_id=vpc.vpc_id, + subnet_ids=vpc.private_subnet_ids, + vpc_cidr_block=vpc.vpc_cidr_block, + instance_class=cfg.postgres_instance_class, + engine_version=cfg.postgres_engine_version, + allocated_storage=cfg.postgres_allocated_storage, + max_allocated_storage=cfg.postgres_max_allocated_storage, +) + +# ============================================================================= +# Redis (Step 9 in apply-targeted.sh) +# ============================================================================= +redis = create_redis( + name=f"{cfg.eks_cluster_name}-redis", + vpc_id=vpc.vpc_id, + subnet_ids=vpc.private_subnet_ids, + vpc_cidr_block=vpc.vpc_cidr_block, + node_type=cfg.redis_node_type, +) + +# ============================================================================= +# S3 (Step 10 in apply-targeted.sh) +# ============================================================================= +bucket_name = f"{cfg.s3_bucket_prefix}-{cfg.environment}-{current.account_id}" + +s3 = create_s3( + bucket_name=bucket_name, + vpc_id=vpc.vpc_id, + region=region.region, +) + +# ============================================================================= +# Data Plane (Listener + KEDA + langgraph-dataplane) +# ============================================================================= +dataplane = create_dataplane( + cluster_name=cfg.eks_cluster_name, + k8s_provider=eks.k8s_provider, + langsmith_api_key=cfg.langsmith_api_key, + langsmith_workspace_id=cfg.langsmith_workspace_id, +) + +# ============================================================================= +# Exports +# ============================================================================= +pulumi.export("aws_account_id", current.account_id) +pulumi.export("aws_region", region.region) + +# VPC +pulumi.export("vpc_id", vpc.vpc_id) +pulumi.export("private_subnet_ids", vpc.private_subnet_ids) +pulumi.export("nat_gateway_public_ips", vpc.nat_gateway_public_ips) + +# EKS +pulumi.export("eks_cluster_name", eks.cluster_name) +pulumi.export("eks_oidc_provider_arn", eks.oidc_provider_arn) + +# PostgreSQL +pulumi.export("postgres_connection_url", postgres.connection_url) + +# S3 +pulumi.export("s3_bucket_name", s3.bucket_name) + +# Data Plane +pulumi.export("langsmith_listener_id", dataplane.listener_id) + +# kubectl configuration command +_kubectl_parts = [ + "aws eks update-kubeconfig --region ", + region.region, + " --name ", + eks.cluster_name, +] +if cfg.aws_profile: + _kubectl_parts.extend([" --profile ", cfg.aws_profile]) + +pulumi.export("kubectl_config_command", pulumi.Output.concat(*_kubectl_parts)) diff --git a/tools/python/langsmith-hosting/src/langsmith_hosting/autoscaler_policy.json b/tools/python/langsmith-hosting/src/langsmith_hosting/autoscaler_policy.json new file mode 100644 index 00000000..3f4f6eee --- /dev/null +++ b/tools/python/langsmith-hosting/src/langsmith_hosting/autoscaler_policy.json @@ -0,0 +1,23 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "autoscaling:DescribeAutoScalingGroups", + "autoscaling:DescribeAutoScalingInstances", + "autoscaling:DescribeLaunchConfigurations", + "autoscaling:DescribeScalingActivities", + "autoscaling:DescribeTags", + "autoscaling:SetDesiredCapacity", + "autoscaling:TerminateInstanceInAutoScalingGroup", + "ec2:DescribeImages", + "ec2:DescribeInstanceTypes", + "ec2:DescribeLaunchTemplateVersions", + "ec2:GetInstanceTypesFromInstanceRequirements", + "eks:DescribeNodegroup" + ], + "Resource": ["*"] + } + ] +} diff --git a/tools/python/langsmith-hosting/src/langsmith_hosting/config.py b/tools/python/langsmith-hosting/src/langsmith_hosting/config.py new file mode 100644 index 00000000..b6ab1412 --- /dev/null +++ b/tools/python/langsmith-hosting/src/langsmith_hosting/config.py @@ -0,0 +1,117 @@ +"""Pulumi config loading for LangSmith Hosting infrastructure. + +Maps Pulumi stack configuration values (from Pulumi..yaml) to typed +Python objects, mirroring the Terraform variables.tf definitions. + +All defaults below are validated against the Terraform dev.tfvars reference. +Override any value by adding the corresponding key to your Pulumi..yaml. +""" + +import os +from dataclasses import dataclass + +import pulumi + +# ============================================================================= +# Sensible defaults — override via Pulumi stack config when needed +# ============================================================================= + +_VPC_CIDR = "10.0.0.0/16" + +_EKS_CLUSTER_VERSION = "1.31" +_EKS_NODE_INSTANCE_TYPE = "m5.xlarge" +_EKS_NODE_MIN_SIZE = 2 +_EKS_NODE_MAX_SIZE = 5 +_EKS_NODE_DESIRED_SIZE = 2 + +_POSTGRES_INSTANCE_CLASS = "db.t3.medium" +_POSTGRES_ENGINE_VERSION = "16.6" +_POSTGRES_ALLOCATED_STORAGE = 20 +_POSTGRES_MAX_ALLOCATED_STORAGE = 100 + +_REDIS_NODE_TYPE = "cache.t3.micro" + +_S3_BUCKET_PREFIX = "langsmith" + +_AWS_PROFILE = os.environ.get("AWS_PROFILE", "") + + +@dataclass(frozen=True) +class LangSmithConfig: + """Typed configuration for the LangSmith Hosting stack.""" + + # General + environment: str + + # VPC + vpc_cidr: str + + # EKS + eks_cluster_name: str + eks_cluster_version: str + eks_node_instance_type: str + eks_node_min_size: int + eks_node_max_size: int + eks_node_desired_size: int + + # PostgreSQL (RDS) + postgres_instance_class: str + postgres_engine_version: str + postgres_allocated_storage: int + postgres_max_allocated_storage: int + + # Redis (ElastiCache) + redis_node_type: str + + # S3 + s3_bucket_prefix: str + + # LangSmith Control Plane + langsmith_api_key: pulumi.Output[str] + langsmith_workspace_id: str + + # AWS + aws_profile: str + + +def load_config() -> LangSmithConfig: + """Load and validate all configuration values from the Pulumi stack config. + + Returns: + LangSmithConfig with all infrastructure parameters. + """ + cfg = pulumi.Config() + + return LangSmithConfig( + # General + environment=cfg.require("environment"), + # VPC + vpc_cidr=cfg.get("vpcCidr") or _VPC_CIDR, + # EKS + eks_cluster_name=cfg.require("eksClusterName"), + eks_cluster_version=cfg.get("eksClusterVersion") or _EKS_CLUSTER_VERSION, + eks_node_instance_type=cfg.get("eksNodeInstanceType") + or _EKS_NODE_INSTANCE_TYPE, + eks_node_min_size=cfg.get_int("eksNodeMinSize") or _EKS_NODE_MIN_SIZE, + eks_node_max_size=cfg.get_int("eksNodeMaxSize") or _EKS_NODE_MAX_SIZE, + eks_node_desired_size=cfg.get_int("eksNodeDesiredSize") + or _EKS_NODE_DESIRED_SIZE, + # PostgreSQL + postgres_instance_class=cfg.get("postgresInstanceClass") + or _POSTGRES_INSTANCE_CLASS, + postgres_engine_version=cfg.get("postgresEngineVersion") + or _POSTGRES_ENGINE_VERSION, + postgres_allocated_storage=cfg.get_int("postgresAllocatedStorage") + or _POSTGRES_ALLOCATED_STORAGE, + postgres_max_allocated_storage=cfg.get_int("postgresMaxAllocatedStorage") + or _POSTGRES_MAX_ALLOCATED_STORAGE, + # Redis + redis_node_type=cfg.get("redisNodeType") or _REDIS_NODE_TYPE, + # S3 + s3_bucket_prefix=cfg.get("s3BucketPrefix") or _S3_BUCKET_PREFIX, + # LangSmith Control Plane + langsmith_api_key=cfg.require_secret("langsmithApiKey"), + langsmith_workspace_id=cfg.require("langsmithWorkspaceId"), + # AWS + aws_profile=cfg.get("awsProfile") or _AWS_PROFILE, + ) diff --git a/tools/python/langsmith-hosting/src/langsmith_hosting/constants.py b/tools/python/langsmith-hosting/src/langsmith_hosting/constants.py new file mode 100644 index 00000000..bf85513d --- /dev/null +++ b/tools/python/langsmith-hosting/src/langsmith_hosting/constants.py @@ -0,0 +1,8 @@ +"""Shared constants for LangSmith Hosting infrastructure.""" + +PROJECT_NAME = "langsmith-hosting" + +TAGS = { + "Project": PROJECT_NAME, + "ManagedBy": "pulumi", +} diff --git a/tools/python/langsmith-hosting/src/langsmith_hosting/dataplane.py b/tools/python/langsmith-hosting/src/langsmith_hosting/dataplane.py new file mode 100644 index 00000000..8fc53de1 --- /dev/null +++ b/tools/python/langsmith-hosting/src/langsmith_hosting/dataplane.py @@ -0,0 +1,148 @@ +"""LangSmith data plane resources. + +Installs the full data plane stack on the EKS cluster: + 1. Listener — registered with the LangSmith Control Plane API + 2. KEDA — Kubernetes event-driven autoscaling (prerequisite) + 3. langgraph-dataplane Helm chart — connects the cluster to LangSmith + +Dependency chain: + EKS cluster ready + ├── Listener (API call, outputs listener_id) + └── KEDA (Helm) + └── dataplane (Helm, depends on Listener + KEDA) +""" + +from dataclasses import dataclass + +import pulumi +import pulumi_kubernetes as k8s + +from langsmith_hosting.listener import LangSmithListener + +_HOST_BACKEND_URL = "https://api.host.langchain.com" +_SMITH_BACKEND_URL = "https://api.smith.langchain.com" + +_KEDA_CHART_VERSION = "2.16.0" +_DATAPLANE_CHART_VERSION = "0.2.17" + +# Disabled until an ingress hostname is configured. Once a DNS record points to +# the ALB, set to True and pass the hostname via ingress.hostname in the Helm +# values. See docs/ingress-hostname-setup.md in the Terraform project. +_ENABLE_HEALTH_CHECK = False + +_REDIS_CPU_REQUEST = "1000m" +_REDIS_MEMORY_REQUEST = "2Gi" +_REDIS_CPU_LIMIT = "2000m" +_REDIS_MEMORY_LIMIT = "4Gi" + + +@dataclass +class DataplaneOutputs: + """Outputs from the data plane module.""" + + listener_id: pulumi.Output[str] + + +def create_dataplane( + cluster_name: str, + k8s_provider: k8s.Provider, + langsmith_api_key: pulumi.Output[str], + langsmith_workspace_id: str, + watch_namespaces: str = "default", +) -> DataplaneOutputs: + """Install the LangSmith data plane on an EKS cluster. + + Args: + cluster_name: EKS cluster name, also used as the listener compute_id. + k8s_provider: Kubernetes provider for Helm releases. + langsmith_api_key: LangSmith API key (Pulumi secret). + langsmith_workspace_id: LangSmith workspace UUID. + watch_namespaces: Comma-separated K8s namespaces for the data plane + to monitor for deployment pods. + + Returns: + DataplaneOutputs with the listener ID. + """ + namespaces = [ns.strip() for ns in watch_namespaces.split(",")] + + # ========================================================================= + # 1. Register a listener with the LangSmith Control Plane API + # ========================================================================= + listener = LangSmithListener( + f"{cluster_name}-listener", + api_key=langsmith_api_key, + workspace_id=langsmith_workspace_id, + compute_id=cluster_name, + namespaces=namespaces, + ) + + # ========================================================================= + # 2. Install KEDA (prerequisite for the data plane operator) + # ========================================================================= + keda = k8s.helm.v3.Release( + f"{cluster_name}-keda", + chart="keda", + version=_KEDA_CHART_VERSION, + namespace="keda", + create_namespace=True, + repository_opts=k8s.helm.v3.RepositoryOptsArgs( + repo="https://kedacore.github.io/charts", + ), + opts=pulumi.ResourceOptions( + provider=k8s_provider, + custom_timeouts=pulumi.CustomTimeouts(create="10m", update="10m"), + ), + ) + + # ========================================================================= + # 3. Install LangSmith dataplane (depends on both KEDA and Listener) + # ========================================================================= + k8s.helm.v3.Release( + f"{cluster_name}-dataplane", + name="dataplane", + chart="langgraph-dataplane", + version=_DATAPLANE_CHART_VERSION, + repository_opts=k8s.helm.v3.RepositoryOptsArgs( + repo="https://langchain-ai.github.io/helm/", + ), + timeout=600, + values={ + "config": { + "langsmithApiKey": langsmith_api_key, + "langsmithWorkspaceId": langsmith_workspace_id, + "hostBackendUrl": _HOST_BACKEND_URL, + "smithBackendUrl": _SMITH_BACKEND_URL, + "langgraphListenerId": listener.listener_id, + "watchNamespaces": ",".join(namespaces), + "enableLGPDeploymentHealthCheck": _ENABLE_HEALTH_CHECK, + }, + "ingress": { + "ingressClassName": "alb", + }, + "redis": { + "statefulSet": { + "resources": { + "requests": { + "cpu": _REDIS_CPU_REQUEST, + "memory": _REDIS_MEMORY_REQUEST, + }, + "limits": { + "cpu": _REDIS_CPU_LIMIT, + "memory": _REDIS_MEMORY_LIMIT, + }, + }, + }, + }, + "operator": { + "enabled": True, + "createCRDs": True, + }, + }, + opts=pulumi.ResourceOptions( + provider=k8s_provider, + depends_on=[keda, listener], + custom_timeouts=pulumi.CustomTimeouts(create="15m", update="15m"), + ), + ) + + return DataplaneOutputs(listener_id=listener.listener_id) diff --git a/tools/python/langsmith-hosting/src/langsmith_hosting/eks.py b/tools/python/langsmith-hosting/src/langsmith_hosting/eks.py new file mode 100644 index 00000000..63e329c0 --- /dev/null +++ b/tools/python/langsmith-hosting/src/langsmith_hosting/eks.py @@ -0,0 +1,389 @@ +"""EKS resources for LangSmith Hosting infrastructure. + +Creates an EKS cluster with a managed node group, EBS CSI driver addon, +GP3 default storage class, and Helm-based addons (AWS Load Balancer Controller, +metrics-server, cluster-autoscaler). + +Dependency chain (from apply-targeted.sh): + VPC -> EKS cluster -> EBS CSI addon -> Helm addons -> GP3 storage class +""" + +import importlib.resources +import json +from dataclasses import dataclass + +import pulumi +import pulumi_aws as aws +import pulumi_eks as eks +import pulumi_kubernetes as k8s + +from langsmith_hosting.constants import TAGS + +# IAM policy for AWS Load Balancer Controller (v2.7.x). +# Source: https://github.com/kubernetes-sigs/aws-load-balancer-controller/blob/v2.7.2/docs/install/iam_policy.json +with ( + importlib.resources.files("langsmith_hosting") + .joinpath("lbc_policy.json") + .open() as _file +): + _LBC_IAM_POLICY: dict = json.load(_file) + +# IAM policy for cluster autoscaler. +# Source: https://github.com/kubernetes/autoscaler/blob/master/cluster-autoscaler/cloudprovider/aws/README.md +with ( + importlib.resources.files("langsmith_hosting") + .joinpath("autoscaler_policy.json") + .open() as _file +): + _AUTOSCALER_POLICY: dict = json.load(_file) + + +@dataclass +class EksOutputs: + """Outputs from the EKS module.""" + + cluster_name: pulumi.Output[str] + kubeconfig: pulumi.Output[str] + oidc_provider_arn: pulumi.Output[str] + oidc_provider_url: pulumi.Output[str] + k8s_provider: k8s.Provider + + +def create_eks_cluster( # noqa: PLR0913 + cluster_name: str, + cluster_version: str, + vpc_id: pulumi.Output[str], + private_subnet_ids: pulumi.Output[list[str]], + public_subnet_ids: pulumi.Output[list[str]], + node_instance_type: str, + node_min_size: int, + node_max_size: int, + node_desired_size: int, +) -> EksOutputs: + """Create an EKS cluster with managed node group and addons. + + Args: + cluster_name: Name of the EKS cluster. + cluster_version: Kubernetes version (e.g. "1.31"). + vpc_id: VPC ID to deploy into. + private_subnet_ids: Private subnet IDs for worker nodes. + public_subnet_ids: Public subnet IDs for load balancers. + node_instance_type: EC2 instance type for nodes (e.g. "m5.xlarge"). + node_min_size: Minimum number of nodes. + node_max_size: Maximum number of nodes. + node_desired_size: Desired number of nodes. + + Returns: + EksOutputs with cluster details and Kubernetes provider. + """ + # ========================================================================= + # IAM role for worker nodes + # ========================================================================= + node_role = aws.iam.Role( + f"{cluster_name}-node-role", + assume_role_policy=json.dumps( + { + "Version": "2012-10-17", + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": {"Service": "ec2.amazonaws.com"}, + } + ], + } + ), + tags=TAGS, + ) + + for policy_name, policy_arn in [ + ("worker-node", "arn:aws:iam::aws:policy/AmazonEKSWorkerNodePolicy"), + ("cni", "arn:aws:iam::aws:policy/AmazonEKS_CNI_Policy"), + ("ecr-readonly", "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly"), + ("ebs-csi", "arn:aws:iam::aws:policy/service-role/AmazonEBSCSIDriverPolicy"), + ]: + aws.iam.RolePolicyAttachment( + f"{cluster_name}-{policy_name}-policy", + role=node_role.name, + policy_arn=policy_arn, + ) + + # ========================================================================= + # EKS cluster + # ========================================================================= + cluster = eks.Cluster( + cluster_name, + name=cluster_name, + version=cluster_version, + vpc_id=vpc_id, + public_subnet_ids=public_subnet_ids, + private_subnet_ids=private_subnet_ids, + authentication_mode=eks.AuthenticationMode.API, + skip_default_node_group=True, + create_oidc_provider=True, + tags=TAGS, + ) + + # ========================================================================= + # Managed node group + # ========================================================================= + node_group = eks.ManagedNodeGroup( + f"{cluster_name}-default", + cluster=cluster, + node_role=node_role, + instance_types=[node_instance_type], + scaling_config={ + "min_size": node_min_size, + "max_size": node_max_size, + "desired_size": node_desired_size, + }, + ) + + # ========================================================================= + # Kubernetes provider from cluster kubeconfig + # ========================================================================= + k8s_provider = k8s.Provider( + f"{cluster_name}-k8s", + kubeconfig=cluster.kubeconfig_json, + ) + + # ========================================================================= + # EBS CSI driver addon + # ========================================================================= + # Depends on node_group (not just cluster) so nodes are ready to schedule + # the addon's DaemonSet pods before we wait for ACTIVE state. + ebs_csi_addon = aws.eks.Addon( + f"{cluster_name}-ebs-csi", + cluster_name=cluster.eks_cluster.name, + addon_name="aws-ebs-csi-driver", + resolve_conflicts_on_create="OVERWRITE", + resolve_conflicts_on_update="OVERWRITE", + opts=pulumi.ResourceOptions(depends_on=[node_group]), + ) + + # ========================================================================= + # GP3 default storage class + # ========================================================================= + k8s.storage.v1.StorageClass( + f"{cluster_name}-gp3", + metadata=k8s.meta.v1.ObjectMetaArgs( + name="gp3", + annotations={ + "storageclass.kubernetes.io/is-default-class": "true", + }, + ), + provisioner="ebs.csi.aws.com", + volume_binding_mode="WaitForFirstConsumer", + parameters={ + "type": "gp3", + "fsType": "ext4", + }, + opts=pulumi.ResourceOptions( + provider=k8s_provider, + depends_on=[ebs_csi_addon], + ), + ) + + # ========================================================================= + # EKS Pod Identity agent addon + # ========================================================================= + # Required for Pod Identity credential injection into pods. Must be + # present on nodes before any PodIdentityAssociation can take effect. + pod_identity_addon = aws.eks.Addon( + f"{cluster_name}-pod-identity-agent", + cluster_name=cluster.eks_cluster.name, + addon_name="eks-pod-identity-agent", + resolve_conflicts_on_create="OVERWRITE", + resolve_conflicts_on_update="OVERWRITE", + opts=pulumi.ResourceOptions(depends_on=[node_group]), + ) + + # ========================================================================= + # IAM role for AWS Load Balancer Controller (Pod Identity) + # ========================================================================= + # Trust policy is static — no OIDC URL manipulation required. + lbc_role = aws.iam.Role( + f"{cluster_name}-lbc-role", + assume_role_policy=json.dumps( + { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"Service": "pods.eks.amazonaws.com"}, + "Action": ["sts:AssumeRole", "sts:TagSession"], + } + ], + } + ), + tags=TAGS, + ) + + aws.iam.RolePolicy( + f"{cluster_name}-lbc-policy", + role=lbc_role.id, + policy=json.dumps(_LBC_IAM_POLICY), + ) + + # Bind the role to the LBC service account via Pod Identity (no SA annotation). + # Captured so the Helm release can depend on it — pods must not start until the + # association is registered with the EKS control plane. + lbc_pod_identity = aws.eks.PodIdentityAssociation( + f"{cluster_name}-lbc-pod-identity", + cluster_name=cluster.eks_cluster.name, + namespace="kube-system", + service_account="aws-load-balancer-controller", + role_arn=lbc_role.arn, + opts=pulumi.ResourceOptions(depends_on=[pod_identity_addon]), + ) + + # ========================================================================= + # IAM role for EBS CSI controller (Pod Identity) + # ========================================================================= + # The EBS CSI controller runs in a Deployment (not a DaemonSet), so it + # cannot rely on the node role via IMDS (hop limit = 1 blocks pod access). + # Pod Identity injects credentials directly without IMDS. + ebs_csi_role = aws.iam.Role( + f"{cluster_name}-ebs-csi-role", + assume_role_policy=json.dumps( + { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"Service": "pods.eks.amazonaws.com"}, + "Action": ["sts:AssumeRole", "sts:TagSession"], + } + ], + } + ), + tags=TAGS, + ) + + aws.iam.RolePolicyAttachment( + f"{cluster_name}-ebs-csi-role-policy", + role=ebs_csi_role.name, + policy_arn="arn:aws:iam::aws:policy/service-role/AmazonEBSCSIDriverPolicy", + ) + + aws.eks.PodIdentityAssociation( + f"{cluster_name}-ebs-csi-pod-identity", + cluster_name=cluster.eks_cluster.name, + namespace="kube-system", + service_account="ebs-csi-controller-sa", + role_arn=ebs_csi_role.arn, + opts=pulumi.ResourceOptions(depends_on=[pod_identity_addon, ebs_csi_addon]), + ) + + # ========================================================================= + # IAM role for cluster autoscaler (Pod Identity) + # ========================================================================= + autoscaler_role = aws.iam.Role( + f"{cluster_name}-autoscaler-role", + assume_role_policy=json.dumps( + { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"Service": "pods.eks.amazonaws.com"}, + "Action": ["sts:AssumeRole", "sts:TagSession"], + } + ], + } + ), + tags=TAGS, + ) + + aws.iam.RolePolicy( + f"{cluster_name}-autoscaler-policy", + role=autoscaler_role.id, + policy=json.dumps(_AUTOSCALER_POLICY), + ) + + autoscaler_pod_identity = aws.eks.PodIdentityAssociation( + f"{cluster_name}-autoscaler-pod-identity", + cluster_name=cluster.eks_cluster.name, + namespace="kube-system", + service_account="cluster-autoscaler", + role_arn=autoscaler_role.arn, + opts=pulumi.ResourceOptions(depends_on=[pod_identity_addon]), + ) + + # ========================================================================= + # Helm addons: ALB Controller, metrics-server, cluster-autoscaler + # ========================================================================= + k8s.helm.v3.Release( + f"{cluster_name}-aws-lb-controller", + chart="aws-load-balancer-controller", + version="1.7.2", + namespace="kube-system", + repository_opts=k8s.helm.v3.RepositoryOptsArgs( + repo="https://aws.github.io/eks-charts", + ), + values={ + "clusterName": cluster_name, + "region": aws.get_region().region, + "vpcId": vpc_id, + "serviceAccount": { + "create": True, + "name": "aws-load-balancer-controller", + }, + }, + opts=pulumi.ResourceOptions( + provider=k8s_provider, + depends_on=[ebs_csi_addon, lbc_pod_identity], + custom_timeouts=pulumi.CustomTimeouts(create="10m", update="10m"), + ), + ) + + k8s.helm.v3.Release( + f"{cluster_name}-metrics-server", + chart="metrics-server", + version="3.12.0", + namespace="kube-system", + repository_opts=k8s.helm.v3.RepositoryOptsArgs( + repo="https://kubernetes-sigs.github.io/metrics-server/", + ), + opts=pulumi.ResourceOptions( + provider=k8s_provider, + depends_on=[ebs_csi_addon], + ), + ) + + k8s.helm.v3.Release( + f"{cluster_name}-cluster-autoscaler", + chart="cluster-autoscaler", + version="9.36.0", + namespace="kube-system", + repository_opts=k8s.helm.v3.RepositoryOptsArgs( + repo="https://kubernetes.github.io/autoscaler", + ), + values={ + "autoDiscovery": { + "clusterName": cluster_name, + }, + "awsRegion": aws.get_region().region, + "rbac": { + "serviceAccount": { + "create": True, + "name": "cluster-autoscaler", + }, + }, + }, + opts=pulumi.ResourceOptions( + provider=k8s_provider, + depends_on=[ebs_csi_addon, autoscaler_pod_identity], + ), + ) + + # ========================================================================= + # Outputs + # ========================================================================= + return EksOutputs( + cluster_name=cluster.eks_cluster.name, + kubeconfig=cluster.kubeconfig_json, + oidc_provider_arn=cluster.oidc_provider_arn, + oidc_provider_url=cluster.oidc_provider_url, + k8s_provider=k8s_provider, + ) diff --git a/tools/python/langsmith-hosting/src/langsmith_hosting/lbc_policy.json b/tools/python/langsmith-hosting/src/langsmith_hosting/lbc_policy.json new file mode 100644 index 00000000..9ae09889 --- /dev/null +++ b/tools/python/langsmith-hosting/src/langsmith_hosting/lbc_policy.json @@ -0,0 +1,217 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["iam:CreateServiceLinkedRole"], + "Resource": "*", + "Condition": { + "StringEquals": { + "iam:AWSServiceName": "elasticloadbalancing.amazonaws.com" + } + } + }, + { + "Effect": "Allow", + "Action": [ + "ec2:DescribeAccountAttributes", + "ec2:DescribeAddresses", + "ec2:DescribeAvailabilityZones", + "ec2:DescribeInternetGateways", + "ec2:DescribeVpcs", + "ec2:DescribeVpcPeeringConnections", + "ec2:DescribeSubnets", + "ec2:DescribeSecurityGroups", + "ec2:DescribeInstances", + "ec2:DescribeNetworkInterfaces", + "ec2:DescribeTags", + "ec2:GetCoipPoolUsage", + "ec2:DescribeCoipPools", + "elasticloadbalancing:DescribeLoadBalancers", + "elasticloadbalancing:DescribeLoadBalancerAttributes", + "elasticloadbalancing:DescribeListeners", + "elasticloadbalancing:DescribeListenerCertificates", + "elasticloadbalancing:DescribeSSLPolicies", + "elasticloadbalancing:DescribeRules", + "elasticloadbalancing:DescribeTargetGroups", + "elasticloadbalancing:DescribeTargetGroupAttributes", + "elasticloadbalancing:DescribeTargetHealth", + "elasticloadbalancing:DescribeTags", + "elasticloadbalancing:DescribeListenerAttributes" + ], + "Resource": "*" + }, + { + "Effect": "Allow", + "Action": [ + "cognito-idp:DescribeUserPoolClient", + "acm:ListCertificates", + "acm:DescribeCertificate", + "iam:ListServerCertificates", + "iam:GetServerCertificate", + "waf-regional:GetWebACL", + "waf-regional:GetWebACLForResource", + "waf-regional:AssociateWebACL", + "waf-regional:DisassociateWebACL", + "wafv2:GetWebACL", + "wafv2:GetWebACLForResource", + "wafv2:AssociateWebACL", + "wafv2:DisassociateWebACL", + "shield:GetSubscriptionState", + "shield:DescribeProtection", + "shield:CreateProtection", + "shield:DeleteProtection" + ], + "Resource": "*" + }, + { + "Effect": "Allow", + "Action": [ + "ec2:AuthorizeSecurityGroupIngress", + "ec2:RevokeSecurityGroupIngress" + ], + "Resource": "*" + }, + { + "Effect": "Allow", + "Action": ["ec2:CreateSecurityGroup"], + "Resource": "*" + }, + { + "Effect": "Allow", + "Action": ["ec2:CreateTags"], + "Resource": "arn:aws:ec2:*:*:security-group/*", + "Condition": { + "StringEquals": {"ec2:CreateAction": "CreateSecurityGroup"}, + "Null": {"aws:RequestTag/elbv2.k8s.aws/cluster": "false"} + } + }, + { + "Effect": "Allow", + "Action": ["ec2:CreateTags", "ec2:DeleteTags"], + "Resource": "arn:aws:ec2:*:*:security-group/*", + "Condition": { + "Null": { + "aws:RequestTag/elbv2.k8s.aws/cluster": "true", + "aws:ResourceTag/elbv2.k8s.aws/cluster": "false" + } + } + }, + { + "Effect": "Allow", + "Action": [ + "ec2:AuthorizeSecurityGroupIngress", + "ec2:RevokeSecurityGroupIngress", + "ec2:DeleteSecurityGroup" + ], + "Resource": "*", + "Condition": { + "Null": {"aws:ResourceTag/elbv2.k8s.aws/cluster": "false"} + } + }, + { + "Effect": "Allow", + "Action": [ + "elasticloadbalancing:CreateLoadBalancer", + "elasticloadbalancing:CreateTargetGroup" + ], + "Resource": "*", + "Condition": { + "Null": {"aws:RequestTag/elbv2.k8s.aws/cluster": "false"} + } + }, + { + "Effect": "Allow", + "Action": [ + "elasticloadbalancing:CreateListener", + "elasticloadbalancing:DeleteListener", + "elasticloadbalancing:CreateRule", + "elasticloadbalancing:DeleteRule" + ], + "Resource": "*" + }, + { + "Effect": "Allow", + "Action": [ + "elasticloadbalancing:AddTags", + "elasticloadbalancing:RemoveTags" + ], + "Resource": [ + "arn:aws:elasticloadbalancing:*:*:targetgroup/*/*", + "arn:aws:elasticloadbalancing:*:*:loadbalancer/net/*/*", + "arn:aws:elasticloadbalancing:*:*:loadbalancer/app/*/*" + ], + "Condition": { + "Null": { + "aws:RequestTag/elbv2.k8s.aws/cluster": "true", + "aws:ResourceTag/elbv2.k8s.aws/cluster": "false" + } + } + }, + { + "Effect": "Allow", + "Action": [ + "elasticloadbalancing:AddTags", + "elasticloadbalancing:RemoveTags" + ], + "Resource": [ + "arn:aws:elasticloadbalancing:*:*:listener/net/*/*/*", + "arn:aws:elasticloadbalancing:*:*:listener/app/*/*/*", + "arn:aws:elasticloadbalancing:*:*:listener-rule/net/*/*/*", + "arn:aws:elasticloadbalancing:*:*:listener-rule/app/*/*/*" + ] + }, + { + "Effect": "Allow", + "Action": [ + "elasticloadbalancing:ModifyLoadBalancerAttributes", + "elasticloadbalancing:SetIpAddressType", + "elasticloadbalancing:SetSecurityGroups", + "elasticloadbalancing:SetSubnets", + "elasticloadbalancing:DeleteLoadBalancer", + "elasticloadbalancing:ModifyTargetGroup", + "elasticloadbalancing:ModifyTargetGroupAttributes", + "elasticloadbalancing:DeleteTargetGroup" + ], + "Resource": "*", + "Condition": { + "Null": {"aws:ResourceTag/elbv2.k8s.aws/cluster": "false"} + } + }, + { + "Effect": "Allow", + "Action": ["elasticloadbalancing:AddTags"], + "Resource": [ + "arn:aws:elasticloadbalancing:*:*:targetgroup/*/*", + "arn:aws:elasticloadbalancing:*:*:loadbalancer/net/*/*", + "arn:aws:elasticloadbalancing:*:*:loadbalancer/app/*/*" + ], + "Condition": { + "StringEquals": { + "elasticloadbalancing:CreateAction": ["CreateTargetGroup", "CreateLoadBalancer"] + }, + "Null": {"aws:RequestTag/elbv2.k8s.aws/cluster": "false"} + } + }, + { + "Effect": "Allow", + "Action": [ + "elasticloadbalancing:RegisterTargets", + "elasticloadbalancing:DeregisterTargets" + ], + "Resource": "arn:aws:elasticloadbalancing:*:*:targetgroup/*/*" + }, + { + "Effect": "Allow", + "Action": [ + "elasticloadbalancing:SetWebAcl", + "elasticloadbalancing:ModifyListener", + "elasticloadbalancing:AddListenerCertificates", + "elasticloadbalancing:RemoveListenerCertificates", + "elasticloadbalancing:ModifyRule", + "elasticloadbalancing:ModifyListenerAttributes" + ], + "Resource": "*" + } + ] +} diff --git a/tools/python/langsmith-hosting/src/langsmith_hosting/listener.py b/tools/python/langsmith-hosting/src/langsmith_hosting/listener.py new file mode 100644 index 00000000..a552e2ba --- /dev/null +++ b/tools/python/langsmith-hosting/src/langsmith_hosting/listener.py @@ -0,0 +1,199 @@ +"""LangSmith Listener as a Pulumi dynamic resource. + +Creates, adopts, updates, and deletes listeners via the LangSmith Control +Plane API. The listener connects the data plane running on the EKS cluster +to LangSmith's managed control plane. + +Lifecycle: + - create: Adopts an existing listener by compute_id, or creates a new one. + - read: Verifies the listener still exists in LangSmith (for refresh). + - update: PATCHes the listener if namespaces change. + - delete: Removes the listener from LangSmith on ``pulumi destroy``. + +API reference: https://docs.langchain.com/langsmith/api-ref-control-plane +""" + +from typing import Any + +import pulumi +import requests +from pulumi import dynamic + +HTTP_NOT_FOUND = 404 +_CONTROL_PLANE_URL = "https://api.host.langchain.com/v2" + + +class _ListenerProvider(dynamic.ResourceProvider): + """Manages a LangSmith listener through its full lifecycle.""" + + def _headers(self, props: dict[str, Any]) -> dict[str, str]: + return { + "X-Api-Key": props["api_key"], + "X-Tenant-Id": props["workspace_id"], + "Content-Type": "application/json", + } + + def _find_by_compute_id( + self, + props: dict[str, Any], + ) -> dict[str, Any] | None: + """Find an existing listener matching the compute_id.""" + resp = requests.get( + f"{_CONTROL_PLANE_URL}/listeners", + headers=self._headers(props), + params={"limit": 100}, + timeout=30, + ) + resp.raise_for_status() + for listener in resp.json().get("resources", []): + if listener.get("compute_id") == props["compute_id"]: + return listener + return None + + def create(self, props: dict[str, Any]) -> dynamic.CreateResult: + existing = self._find_by_compute_id(props) + if existing: + listener_id = existing["id"] + return dynamic.CreateResult( + id_=listener_id, + outs={**props, "listener_id": listener_id}, + ) + + response = requests.post( + f"{_CONTROL_PLANE_URL}/listeners", + headers=self._headers(props), + json={ + "compute_type": "k8s", + "compute_id": props["compute_id"], + "compute_config": { + "k8s_namespaces": props["namespaces"], + }, + }, + timeout=30, + ) + response.raise_for_status() + data = response.json() + + listener_id = data["id"] + return dynamic.CreateResult( + id_=listener_id, + outs={**props, "listener_id": listener_id}, + ) + + def read( + self, + id_: str, + props: dict[str, Any], + ) -> dynamic.ReadResult: + response = requests.get( + f"{_CONTROL_PLANE_URL}/listeners/{id_}", + headers=self._headers(props), + timeout=30, + ) + if response.status_code == HTTP_NOT_FOUND: + return dynamic.ReadResult("", {}) + + response.raise_for_status() + data = response.json() + + namespaces = data.get("compute_config", {}).get("k8s_namespaces", []) + return dynamic.ReadResult( + id_=id_, + outs={ + **props, + "compute_id": data.get("compute_id", props.get("compute_id")), + "namespaces": namespaces, + "listener_id": id_, + }, + ) + + def diff( + self, + id_: str, + old_outs: dict[str, Any], + new_inputs: dict[str, Any], + ) -> dynamic.DiffResult: + replaces: list[str] = [] + if old_outs.get("compute_id") != new_inputs.get("compute_id"): + replaces.append("compute_id") + + changes = bool(replaces) or ( + sorted(old_outs.get("namespaces", [])) + != sorted(new_inputs.get("namespaces", [])) + ) + return dynamic.DiffResult( + changes=changes, + replaces=replaces, + delete_before_replace=True, + ) + + def update( + self, + id_: str, + old_outs: dict[str, Any], + new_inputs: dict[str, Any], + ) -> dynamic.UpdateResult: + response = requests.patch( + f"{_CONTROL_PLANE_URL}/listeners/{id_}", + headers=self._headers(new_inputs), + json={ + "compute_config": { + "k8s_namespaces": new_inputs["namespaces"], + }, + }, + timeout=30, + ) + response.raise_for_status() + + return dynamic.UpdateResult( + outs={**new_inputs, "listener_id": id_}, + ) + + def delete(self, id_: str, props: dict[str, Any]) -> None: + response = requests.delete( + f"{_CONTROL_PLANE_URL}/listeners/{id_}", + headers=self._headers(props), + timeout=30, + ) + if response.status_code == HTTP_NOT_FOUND: + return + response.raise_for_status() + + +class LangSmithListener(dynamic.Resource): + """A LangSmith listener registered with the Control Plane API. + + On first ``pulumi up``, adopts an existing listener matching the + ``compute_id``, or creates a new one if none exists. On subsequent + runs, the listener is already in state and only updated if inputs + change. On ``pulumi destroy``, deletes the listener. + + Attributes: + listener_id: The system-assigned listener UUID, used as + ``config.langgraphListenerId`` in the data plane Helm chart. + """ + + listener_id: pulumi.Output[str] + + def __init__( # noqa: PLR0913 + self, + name: str, + *, + api_key: pulumi.Input[str], + workspace_id: pulumi.Input[str], + compute_id: pulumi.Input[str], + namespaces: pulumi.Input[list[str]], + opts: pulumi.ResourceOptions | None = None, + ) -> None: + super().__init__( + _ListenerProvider(), + name, + { + "api_key": api_key, + "workspace_id": workspace_id, + "compute_id": compute_id, + "namespaces": namespaces, + "listener_id": None, + }, + opts, + ) diff --git a/tools/python/langsmith-hosting/src/langsmith_hosting/postgres.py b/tools/python/langsmith-hosting/src/langsmith_hosting/postgres.py new file mode 100644 index 00000000..57145936 --- /dev/null +++ b/tools/python/langsmith-hosting/src/langsmith_hosting/postgres.py @@ -0,0 +1,141 @@ +"""RDS PostgreSQL resources for LangSmith Hosting infrastructure. + +Creates an RDS PostgreSQL instance with a dedicated subnet group and +security group, matching the Terraform module.postgres configuration. +""" + +from dataclasses import dataclass + +import pulumi +import pulumi_aws as aws +import pulumi_random as random + +from langsmith_hosting.constants import TAGS + + +@dataclass +class PostgresOutputs: + """Outputs from the Postgres module.""" + + instance_endpoint: pulumi.Output[str] + instance_address: pulumi.Output[str] + instance_port: pulumi.Output[int] + connection_url: pulumi.Output[str] + password: pulumi.Output[str] + + +def create_postgres( # noqa: PLR0913 + name: str, + vpc_id: pulumi.Output[str], + subnet_ids: pulumi.Output[list[str]], + vpc_cidr_block: pulumi.Output[str], + instance_class: str, + engine_version: str, + allocated_storage: int, + max_allocated_storage: int, + username: str = "langsmith", +) -> PostgresOutputs: + """Create an RDS PostgreSQL instance for LangSmith. + + Args: + name: Resource name prefix (e.g. "langsmith-hybrid-dev-postgres"). + vpc_id: VPC ID for the security group. + subnet_ids: Private subnet IDs for the DB subnet group. + vpc_cidr_block: VPC CIDR block for ingress rules. + instance_class: RDS instance class (e.g. "db.t3.medium"). + engine_version: PostgreSQL engine version (e.g. "16.6"). + allocated_storage: Initial storage in GB. + max_allocated_storage: Maximum storage for autoscaling in GB. + username: Database master username. + + Returns: + PostgresOutputs with connection details. + """ + # ========================================================================= + # Random password for PostgreSQL + # ========================================================================= + password = random.RandomPassword( + f"{name}-password", + length=32, + special=False, + ) + + # ========================================================================= + # DB subnet group + # ========================================================================= + subnet_group = aws.rds.SubnetGroup( + f"{name}-subnet-group", + name=name, + subnet_ids=subnet_ids, + tags={**TAGS, "Name": f"{name}-subnet-group"}, + ) + + # ========================================================================= + # Security group allowing PostgreSQL access from within the VPC + # ========================================================================= + sg = aws.ec2.SecurityGroup( + f"{name}-sg", + name=f"{name}-sg", + description=f"Security group for {name} RDS instance", + vpc_id=vpc_id, + ingress=[ + aws.ec2.SecurityGroupIngressArgs( + protocol="tcp", + from_port=5432, + to_port=5432, + cidr_blocks=[vpc_cidr_block], + description="PostgreSQL access from VPC", + ) + ], + egress=[ + aws.ec2.SecurityGroupEgressArgs( + protocol="-1", + from_port=0, + to_port=0, + cidr_blocks=["0.0.0.0/0"], + description="Allow all outbound", + ) + ], + tags={**TAGS, "Name": f"{name}-sg"}, + ) + + # ========================================================================= + # RDS instance + # ========================================================================= + db = aws.rds.Instance( + name, + identifier=name, + engine="postgres", + engine_version=engine_version, + instance_class=instance_class, + allocated_storage=allocated_storage, + max_allocated_storage=max_allocated_storage, + db_name="langsmith", + username=username, + password=password.result, + db_subnet_group_name=subnet_group.name, + vpc_security_group_ids=[sg.id], + skip_final_snapshot=True, + publicly_accessible=False, + storage_encrypted=True, + tags={**TAGS, "Name": name}, + ) + + # Build connection URL + connection_url = pulumi.Output.all( + db.username, + password.result, + db.address, + db.port, + db.db_name, + ).apply( + lambda args: f"postgresql://{args[0]}:{args[1]}@{args[2]}:{args[3]}/{args[4]}" + ) + + return PostgresOutputs( + instance_endpoint=db.endpoint, + instance_address=db.address, + instance_port=db.port, + connection_url=connection_url, + password=password.result, + ) diff --git a/tools/python/langsmith-hosting/src/langsmith_hosting/redis.py b/tools/python/langsmith-hosting/src/langsmith_hosting/redis.py new file mode 100644 index 00000000..30519137 --- /dev/null +++ b/tools/python/langsmith-hosting/src/langsmith_hosting/redis.py @@ -0,0 +1,105 @@ +"""ElastiCache Redis resources for LangSmith Hosting infrastructure. + +Creates an ElastiCache Redis cluster with a dedicated subnet group and +security group, matching the Terraform module.redis configuration. +""" + +from dataclasses import dataclass + +import pulumi +import pulumi_aws as aws + +from langsmith_hosting.constants import TAGS + + +@dataclass +class RedisOutputs: + """Outputs from the Redis module.""" + + cluster_id: pulumi.Output[str] + cache_nodes: pulumi.Output[list] + endpoint: pulumi.Output[str] + port: pulumi.Output[int] + + +def create_redis( + name: str, + vpc_id: pulumi.Output[str], + subnet_ids: pulumi.Output[list[str]], + vpc_cidr_block: pulumi.Output[str], + node_type: str, +) -> RedisOutputs: + """Create an ElastiCache Redis cluster for LangSmith. + + Args: + name: Resource name prefix (e.g. "langsmith-hybrid-dev-redis"). + vpc_id: VPC ID for the security group. + subnet_ids: Private subnet IDs for the cache subnet group. + vpc_cidr_block: VPC CIDR block for ingress rules. + node_type: ElastiCache node type (e.g. "cache.t3.micro"). + + Returns: + RedisOutputs with cluster connection details. + """ + # ========================================================================= + # Cache subnet group + # ========================================================================= + subnet_group = aws.elasticache.SubnetGroup( + f"{name}-subnet-group", + name=name, + subnet_ids=subnet_ids, + tags={**TAGS, "Name": f"{name}-subnet-group"}, + ) + + # ========================================================================= + # Security group allowing Redis access from within the VPC + # ========================================================================= + sg = aws.ec2.SecurityGroup( + f"{name}-sg", + name=f"{name}-sg", + description=f"Security group for {name} ElastiCache cluster", + vpc_id=vpc_id, + ingress=[ + aws.ec2.SecurityGroupIngressArgs( + protocol="tcp", + from_port=6379, + to_port=6379, + cidr_blocks=[vpc_cidr_block], + description="Redis access from VPC", + ) + ], + egress=[ + aws.ec2.SecurityGroupEgressArgs( + protocol="-1", + from_port=0, + to_port=0, + cidr_blocks=["0.0.0.0/0"], + description="Allow all outbound", + ) + ], + tags={**TAGS, "Name": f"{name}-sg"}, + ) + + # ========================================================================= + # ElastiCache Redis cluster + # ========================================================================= + cluster = aws.elasticache.Cluster( + name, + cluster_id=name, + engine="redis", + engine_version="7.0", + node_type=node_type, + num_cache_nodes=1, + subnet_group_name=subnet_group.name, + security_group_ids=[sg.id], + tags={**TAGS, "Name": name}, + ) + + return RedisOutputs( + cluster_id=cluster.cluster_id, + cache_nodes=cluster.cache_nodes, + endpoint=cluster.cache_nodes.apply( + lambda nodes: nodes[0]["address"] if nodes else "" + ), + port=cluster.port, + ) diff --git a/tools/python/langsmith-hosting/src/langsmith_hosting/s3.py b/tools/python/langsmith-hosting/src/langsmith_hosting/s3.py new file mode 100644 index 00000000..6956872f --- /dev/null +++ b/tools/python/langsmith-hosting/src/langsmith_hosting/s3.py @@ -0,0 +1,155 @@ +"""S3 resources for LangSmith Hosting infrastructure. + +Creates an S3 bucket with a VPC gateway endpoint and a bucket policy +restricting access through the endpoint, matching the Terraform module.s3 +configuration. +""" + +import json +from dataclasses import dataclass + +import pulumi +import pulumi_aws as aws + +from langsmith_hosting.constants import TAGS + + +@dataclass +class S3Outputs: + """Outputs from the S3 module.""" + + bucket_name: pulumi.Output[str] + bucket_arn: pulumi.Output[str] + vpc_endpoint_id: pulumi.Output[str] + + +def create_s3( + bucket_name: str, + vpc_id: pulumi.Output[str], + region: str, +) -> S3Outputs: + """Create an S3 bucket with VPC endpoint for LangSmith blob storage. + + Args: + bucket_name: Full bucket name (e.g. "langsmith-dev-123456789012"). + vpc_id: VPC ID for the gateway endpoint. + region: AWS region for the VPC endpoint service. + + Returns: + S3Outputs with bucket and endpoint details. + """ + # ========================================================================= + # S3 bucket + # ========================================================================= + bucket = aws.s3.Bucket( + bucket_name, + bucket=bucket_name, + tags={**TAGS, "Name": bucket_name}, + ) + + # Block all public access + aws.s3.BucketPublicAccessBlock( + f"{bucket_name}-public-access-block", + bucket=bucket.id, + block_public_acls=True, + block_public_policy=True, + ignore_public_acls=True, + restrict_public_buckets=True, + ) + + # Enable server-side encryption + aws.s3.BucketServerSideEncryptionConfiguration( + f"{bucket_name}-encryption", + bucket=bucket.id, + rules=[ + aws.s3.BucketServerSideEncryptionConfigurationRuleArgs( + apply_server_side_encryption_by_default=( + aws.s3.BucketServerSideEncryptionConfigurationRuleApplyServerSideEncryptionByDefaultArgs( + sse_algorithm="AES256", + ) + ), + ) + ], + ) + + # ========================================================================= + # VPC gateway endpoint for S3 + # ========================================================================= + # Get the main route table for the VPC + route_tables = aws.ec2.get_route_tables_output( + filters=[ + aws.ec2.GetRouteTablesFilterArgs( + name="vpc-id", + values=[vpc_id], + ) + ], + ) + + vpc_endpoint = aws.ec2.VpcEndpoint( + f"{bucket_name}-vpc-endpoint", + vpc_id=vpc_id, + service_name=f"com.amazonaws.{region}.s3", + vpc_endpoint_type="Gateway", + route_table_ids=route_tables.ids, + tags={**TAGS, "Name": f"{bucket_name}-s3-endpoint"}, + ) + + # ========================================================================= + # Bucket policy restricting access to VPC endpoint + # ========================================================================= + # Deny data-plane operations only when not using the VPC endpoint. + # Management operations (GetBucketPolicy, PutBucketPolicy, etc.) are + # intentionally excluded so that admin tooling (e.g. Pulumi, AWS CLI) can + # still manage the bucket from outside the VPC without being locked out. + data_plane_actions = ( + "s3:GetObject", + "s3:GetObjectVersion", + "s3:GetObjectTagging", + "s3:PutObject", + "s3:PutObjectTagging", + "s3:DeleteObject", + "s3:DeleteObjectVersion", + "s3:ListBucket", + "s3:ListBucketVersions", + "s3:ListBucketMultipartUploads", + "s3:AbortMultipartUpload", + "s3:ListMultipartUploadParts", + "s3:RestoreObject", + ) + + bucket_policy_doc = pulumi.Output.all(bucket.arn, vpc_endpoint.id).apply( + lambda args: json.dumps( + { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "RestrictDataAccessToVpcEndpoint", + "Effect": "Deny", + "Principal": "*", + "Action": data_plane_actions, + "Resource": [ + args[0], + f"{args[0]}/*", + ], + "Condition": { + "StringNotEquals": { + "aws:sourceVpce": args[1], + }, + }, + }, + ], + } + ) + ) + + aws.s3.BucketPolicy( + f"{bucket_name}-policy", + bucket=bucket.id, + policy=bucket_policy_doc, + ) + + return S3Outputs( + bucket_name=bucket.bucket, + bucket_arn=bucket.arn, + vpc_endpoint_id=vpc_endpoint.id, + ) diff --git a/tools/python/langsmith-hosting/src/langsmith_hosting/vpc.py b/tools/python/langsmith-hosting/src/langsmith_hosting/vpc.py new file mode 100644 index 00000000..d76d771e --- /dev/null +++ b/tools/python/langsmith-hosting/src/langsmith_hosting/vpc.py @@ -0,0 +1,80 @@ +"""VPC resources for LangSmith Hosting infrastructure. + +Creates a VPC with public and private subnets across 3 availability zones, +a single NAT gateway, and subnet tags for EKS auto-discovery. +""" + +from dataclasses import dataclass + +import pulumi +import pulumi_awsx as awsx + +from langsmith_hosting.constants import TAGS + + +@dataclass +class VpcOutputs: + """Outputs from the VPC module.""" + + vpc_id: pulumi.Output[str] + private_subnet_ids: pulumi.Output[list[str]] + public_subnet_ids: pulumi.Output[list[str]] + vpc_cidr_block: pulumi.Output[str] + nat_gateway_public_ips: pulumi.Output[list[str]] + + +def create_vpc( + cluster_name: str, + cidr_block: str, +) -> VpcOutputs: + """Create a VPC with public/private subnets for LangSmith infrastructure. + + Args: + cluster_name: EKS cluster name, used for naming and subnet tags. + cidr_block: CIDR block for the VPC (e.g. "10.0.0.0/16"). + + Returns: + VpcOutputs with VPC ID, subnet IDs, and CIDR block. + """ + vpc = awsx.ec2.Vpc( + f"{cluster_name}-vpc", + cidr_block=cidr_block, + number_of_availability_zones=3, + nat_gateways=awsx.ec2.NatGatewayConfigurationArgs( + strategy=awsx.ec2.NatGatewayStrategy.SINGLE, + ), + subnet_strategy=awsx.ec2.SubnetAllocationStrategy.AUTO, + subnet_specs=[ + awsx.ec2.SubnetSpecArgs( + type=awsx.ec2.SubnetType.PRIVATE, + cidr_mask=19, + tags={ + **TAGS, + f"kubernetes.io/cluster/{cluster_name}": "shared", + "kubernetes.io/role/internal-elb": "1", + }, + ), + awsx.ec2.SubnetSpecArgs( + type=awsx.ec2.SubnetType.PUBLIC, + cidr_mask=20, + tags={ + **TAGS, + f"kubernetes.io/cluster/{cluster_name}": "shared", + "kubernetes.io/role/elb": "1", + }, + ), + ], + tags=TAGS, + ) + + nat_gateway_public_ips = pulumi.Output.all(vpc.eips).apply( + lambda eips_list: [eip.public_ip for eip in eips_list[0]] + ) + + return VpcOutputs( + vpc_id=vpc.vpc_id, + private_subnet_ids=vpc.private_subnet_ids, + public_subnet_ids=vpc.public_subnet_ids, + vpc_cidr_block=pulumi.Output.from_input(cidr_block), + nat_gateway_public_ips=nat_gateway_public_ips, + ) diff --git a/tools/python/pulumi-utils/README.md b/tools/python/pulumi-utils/README.md new file mode 100644 index 00000000..249e3c89 --- /dev/null +++ b/tools/python/pulumi-utils/README.md @@ -0,0 +1,282 @@ +# Pulumi Utils + +Common utilities for Pulumi projects to promote code reuse and consistency across infrastructure deployments. + +## Overview + +This package provides helper functions and utilities that are commonly needed across multiple Pulumi projects, including: + +- Resource naming conventions with stack-based suffixing +- Configuration management +- Custom error types + +## Installation + +### Using Poetry (recommended) + +Add to your `pyproject.toml`: + +```toml +dependencies = [ + "pulumi-utils", +] +``` + +Then install: + +```bash +poetry add pulumi-utils +``` + +### Using pip + +```bash +pip install pulumi-utils +``` + +## Usage + +### Resource Naming + +The naming utilities help create consistent resource names across your infrastructure: + +```python +import pulumi +from pulumi_utils import get_resource_name, get_suffix + +# Get the stack-based suffix +suffix = get_suffix() +# Returns: 'dev' for dev stack, '' for prod stack, 'staging' for staging stack + +# Generate a resource name +resource_name = get_resource_name("myapp", "postgres", "server") +# In dev stack: 'myapp-postgres-server-dev' +# In prod stack: 'myapp-postgres-server' + +# Use a custom separator (e.g., for database names) +db_name = get_resource_name("myapp", "db", separator="_") +# In dev stack: 'myapp_db_dev' + +# Use for storage accounts (no separator) +storage_name = get_resource_name("myapp", "storage", separator="") +# In dev stack: 'myappstorage' (then format with Azure utils) +``` + +### Complete Pulumi Example + +```python +import pulumi +import pulumi_azure_native as azure_native +from pulumi_utils import get_resource_name + +config = pulumi.Config() +project_name = pulumi.get_project() + +# Create resource group with consistent naming +resource_group_name = config.get("resourceGroupName") or get_resource_name( + project_name, "rg" +) + +resource_group = azure_native.resources.ResourceGroup( + "resource-group", + resource_group_name=resource_group_name, + location="eastus", +) + +# Create storage account with consistent naming +storage_name = config.get("storageAccountName") or get_resource_name( + project_name, "storage", separator="" +) + +# Note: Azure storage names need additional formatting (lowercase, no dashes) +# Use azure-utils package for Azure-specific name formatting +``` + +## API Reference + +### Naming Functions + +#### `get_suffix() -> str` + +Get the suffix for resources based on the current stack name. + +**Returns:** + +- Empty string if stack name starts with "prod" +- Stack name for all other stacks + +**Examples:** + +```python +# In dev stack +get_suffix() # Returns: 'dev' + +# In prod stack +get_suffix() # Returns: '' + +# In staging stack +get_suffix() # Returns: 'staging' +``` + +#### `get_resource_name(*parts: str, separator: str = "-") -> str` + +Generate a resource name by joining parts and appending the stack suffix. + +**Parameters:** + +- `*parts`: Variable number of string parts to join +- `separator`: String to use between parts (default: "-") + +**Returns:** + +- Formatted resource name with stack suffix + +**Examples:** + +```python +# Basic usage +get_resource_name("myapp", "db") +# Returns: 'myapp-db-dev' (in dev stack) + +# Custom separator +get_resource_name("myapp", "db", separator="_") +# Returns: 'myapp_db_dev' (in dev stack) + +# Multiple parts +get_resource_name("myapp", "postgres", "primary", "server") +# Returns: 'myapp-postgres-primary-server-dev' (in dev stack) +``` + +### Configuration Constants + +#### `DEFAULT_SEPARATOR` + +Default separator used in resource names: `"-"` + +### Custom Exceptions + +#### `PulumiUtilsError` + +Base exception for all pulumi_utils errors. + +#### `InvalidStackName` + +Raised when a stack name is invalid. + +**Attributes:** + +- `stack_name`: The invalid stack name +- `reason`: Why the stack name is invalid + +#### `InvalidResourceName` + +Raised when a resource name is invalid. + +**Attributes:** + +- `name`: The invalid resource name +- `reason`: Why the resource name is invalid + +## Stack Naming Convention + +This package follows a convention for stack names: + +- **Production stacks**: Start with "prod" (e.g., "prod", "production") + + - No suffix is added to resource names + - Example: `myapp-postgres-server` + +- **Non-production stacks**: Any other name (e.g., "dev", "staging", "test") + - Stack name is added as a suffix + - Example: `myapp-postgres-server-dev` + +This convention helps: + +- Keep production resource names clean and predictable +- Clearly identify non-production resources +- Prevent naming conflicts across environments + +## Integration with Other Packages + +### With azure-utils + +Combine `pulumi-utils` with `azure-utils` for Azure-specific formatting: + +```python +from pulumi_utils import get_resource_name +from azure_utils import format_storage_account_name + +# Generate name with Pulumi utils +raw_storage_name = get_resource_name("myapp", "storage", separator="") + +# Format for Azure requirements (3-24 chars, lowercase, no dashes) +storage_name = format_storage_account_name(raw_storage_name) +``` + +## Development + +### Setup + +```bash +# Clone the repository +cd tools/python/pulumi-utils + +# Install dependencies +poetry install +``` + +### Testing + +```bash +# Run tests +poetry run pytest + +# Run tests with coverage +poetry run pytest --cov=pulumi_utils +``` + +### Building + +```bash +# Build the package +poetry build +``` + +## Best Practices + +1. **Consistent Naming**: Always use `get_resource_name()` for resource names to ensure consistency across your infrastructure. + +2. **Stack Convention**: Follow the "prod" prefix convention for production stacks to benefit from automatic suffix behavior. + +3. **Separator Choice**: + + - Use default "-" for most resources + - Use "\_" for database names and SQL identifiers + - Use "" (empty) for storage accounts, then apply Azure-specific formatting + +4. **Configuration**: Allow resource names to be overridden via Pulumi config: + ```python + name = config.get("resourceName") or get_resource_name("default", "name") + ``` + +## Related Packages + +- **azure-utils**: Azure-specific utilities for resource name formatting +- **azure-postgres**: Pulumi project for Azure PostgreSQL with examples +- **azure-ai**: Pulumi project for Azure OpenAI with examples + +## Contributing + +Contributions are welcome! Please ensure: + +- All tests pass +- Code follows project style guidelines +- Documentation is updated for new features + +## License + +MIT + +## Author + +Matt Norris (matt@mattnorris.dev) diff --git a/tools/python/pulumi-utils/pyproject.toml b/tools/python/pulumi-utils/pyproject.toml new file mode 100644 index 00000000..c821b7f7 --- /dev/null +++ b/tools/python/pulumi-utils/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "pulumi-utils" +version = "0.1.0" +description = "Common utilities for Pulumi projects" +authors = [{ name = "Matt Norris", email = "matt@mattnorris.dev" }] +readme = "README.md" +requires-python = ">=3.12,<3.13" +dependencies = ["pulumi>=3.202.0,<4.0.0"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/pulumi_utils"] diff --git a/tools/python/pulumi-utils/src/pulumi_utils/__init__.py b/tools/python/pulumi-utils/src/pulumi_utils/__init__.py new file mode 100644 index 00000000..e55c6da7 --- /dev/null +++ b/tools/python/pulumi-utils/src/pulumi_utils/__init__.py @@ -0,0 +1,5 @@ +"""Pulumi utilities for common Pulumi project tasks.""" + +from .naming import get_resource_name, get_suffix + +__all__ = ["get_resource_name", "get_suffix"] diff --git a/tools/python/pulumi-utils/src/pulumi_utils/config.py b/tools/python/pulumi-utils/src/pulumi_utils/config.py new file mode 100644 index 00000000..5160154b --- /dev/null +++ b/tools/python/pulumi-utils/src/pulumi_utils/config.py @@ -0,0 +1,3 @@ +"""Configuration constants for Pulumi utilities.""" + +DEFAULT_SEPARATOR = "-" diff --git a/tools/python/pulumi-utils/src/pulumi_utils/naming.py b/tools/python/pulumi-utils/src/pulumi_utils/naming.py new file mode 100644 index 00000000..e37a8f5f --- /dev/null +++ b/tools/python/pulumi-utils/src/pulumi_utils/naming.py @@ -0,0 +1,76 @@ +"""Naming utilities for Pulumi resources. + +This module provides common functions for generating consistent resource names +across Pulumi projects, including stack-based suffixing and name joining. +""" + +import pulumi + +from .config import DEFAULT_SEPARATOR + + +def get_suffix() -> str: + """ + Get the suffix for resources based on the stack name. + + For production stacks (those starting with "prod"), no suffix is added. + For all other stacks, the stack name is used as a suffix. + + Returns: + The suffix for resources (empty string for production, stack name otherwise). + + Examples: + >>> # When stack is "dev" + >>> get_suffix() + 'dev' + + >>> # When stack is "prod" + >>> get_suffix() + '' + + >>> # When stack is "staging" + >>> get_suffix() + 'staging' + """ + stack_name = pulumi.get_stack() + return "" if stack_name.lower().startswith("prod") else stack_name + + +def get_resource_name(*parts: str, separator: str = DEFAULT_SEPARATOR) -> str: + """ + Generate a resource name by joining parts and appending the stack suffix. + + This function creates consistent resource names across Pulumi projects by: + 1. Converting all parts to lowercase + 2. Adding the stack suffix (if not production) + 3. Joining parts with the specified separator + 4. Normalizing any default separators to the specified separator + + Args: + *parts: The parts to join (e.g., project name, resource type). + separator: The separator to use when joining parts (default: "-"). + + Returns: + The formatted resource name with suffix. + + Examples: + >>> # In dev stack with default separator + >>> get_resource_name("myapp", "postgres", "server") + 'myapp-postgres-server-dev' + + >>> # In prod stack + >>> get_resource_name("myapp", "postgres", "server") + 'myapp-postgres-server' + + >>> # With custom separator + >>> get_resource_name("myapp", "db", separator="_") + 'myapp_db_dev' + + >>> # Parts with existing separators are normalized + >>> get_resource_name("my-app", "postgres-db", separator="_") + 'my_app_postgres_db_dev' + """ + _parts = [part.lower() for part in parts] + [get_suffix()] + + # Remove any default separators and join with the given separator. + return separator.join(_parts).replace(DEFAULT_SEPARATOR, separator) diff --git a/tools/python/pulumi-utils/tests/__init__.py b/tools/python/pulumi-utils/tests/__init__.py new file mode 100644 index 00000000..5913d36f --- /dev/null +++ b/tools/python/pulumi-utils/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for pulumi_utils package.""" diff --git a/uv.lock b/uv.lock index 700bf556..ea94d28b 100644 --- a/uv.lock +++ b/uv.lock @@ -1,18 +1,16 @@ version = 1 revision = 3 -requires-python = ">=3.11, <4.0" -resolution-markers = [ - "python_full_version >= '3.14'", - "python_full_version == '3.13.*'", - "python_full_version == '3.12.*'", - "python_full_version < '3.12'", -] +requires-python = "==3.12.*" [manifest] members = [ + "azure-ai", "core-github", "essentials", "gcp-gemini", + "langsmith-client", + "langsmith-hosting", + "pulumi-utils", ] [[package]] @@ -30,7 +28,7 @@ version = "4.12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } wheels = [ @@ -64,6 +62,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, ] +[[package]] +name = "azure-ai" +version = "0.1.0" +source = { editable = "packages/python/azure-ai" } +dependencies = [ + { name = "pulumi" }, + { name = "pulumi-azure-native" }, + { name = "pulumi-utils" }, +] + +[package.metadata] +requires-dist = [ + { name = "pulumi", specifier = ">=3.202.0,<4.0.0" }, + { name = "pulumi-azure-native", specifier = ">=3.8.0,<4.0.0" }, + { name = "pulumi-utils", editable = "tools/python/pulumi-utils" }, +] + [[package]] name = "bandit" version = "1.9.4" @@ -93,26 +108,11 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/c4/d9/07b458a3f1c525ac392b5edc6b191ff140b596f9d77092429417a54e249d/black-25.12.0.tar.gz", hash = "sha256:8d3dd9cea14bff7ddc0eb243c811cdb1a011ebb4800a5f0335a01a68654796a7", size = 659264, upload-time = "2025-12-08T01:40:52.501Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/60/ad/7ac0d0e1e0612788dbc48e62aef8a8e8feffac7eb3d787db4e43b8462fa8/black-25.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d0cfa263e85caea2cff57d8f917f9f51adae8e20b610e2b23de35b5b11ce691a", size = 1877003, upload-time = "2025-12-08T01:43:29.967Z" }, - { url = "https://files.pythonhosted.org/packages/e8/dd/a237e9f565f3617a88b49284b59cbca2a4f56ebe68676c1aad0ce36a54a7/black-25.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1a2f578ae20c19c50a382286ba78bfbeafdf788579b053d8e4980afb079ab9be", size = 1712639, upload-time = "2025-12-08T01:52:46.756Z" }, - { url = "https://files.pythonhosted.org/packages/12/80/e187079df1ea4c12a0c63282ddd8b81d5107db6d642f7d7b75a6bcd6fc21/black-25.12.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e1b65634b0e471d07ff86ec338819e2ef860689859ef4501ab7ac290431f9b", size = 1758143, upload-time = "2025-12-08T01:45:29.137Z" }, - { url = "https://files.pythonhosted.org/packages/93/b5/3096ccee4f29dc2c3aac57274326c4d2d929a77e629f695f544e159bfae4/black-25.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:a3fa71e3b8dd9f7c6ac4d818345237dfb4175ed3bf37cd5a581dbc4c034f1ec5", size = 1420698, upload-time = "2025-12-08T01:45:53.379Z" }, - { url = "https://files.pythonhosted.org/packages/7e/39/f81c0ffbc25ffbe61c7d0385bf277e62ffc3e52f5ee668d7369d9854fadf/black-25.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:51e267458f7e650afed8445dc7edb3187143003d52a1b710c7321aef22aa9655", size = 1229317, upload-time = "2025-12-08T01:46:35.606Z" }, { url = "https://files.pythonhosted.org/packages/d1/bd/26083f805115db17fda9877b3c7321d08c647df39d0df4c4ca8f8450593e/black-25.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:31f96b7c98c1ddaeb07dc0f56c652e25bdedaac76d5b68a059d998b57c55594a", size = 1924178, upload-time = "2025-12-08T01:49:51.048Z" }, { url = "https://files.pythonhosted.org/packages/89/6b/ea00d6651561e2bdd9231c4177f4f2ae19cc13a0b0574f47602a7519b6ca/black-25.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:05dd459a19e218078a1f98178c13f861fe6a9a5f88fc969ca4d9b49eb1809783", size = 1742643, upload-time = "2025-12-08T01:49:59.09Z" }, { url = "https://files.pythonhosted.org/packages/6d/f3/360fa4182e36e9875fabcf3a9717db9d27a8d11870f21cff97725c54f35b/black-25.12.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1f68c5eff61f226934be6b5b80296cf6939e5d2f0c2f7d543ea08b204bfaf59", size = 1800158, upload-time = "2025-12-08T01:44:27.301Z" }, { url = "https://files.pythonhosted.org/packages/f8/08/2c64830cb6616278067e040acca21d4f79727b23077633953081c9445d61/black-25.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:274f940c147ddab4442d316b27f9e332ca586d39c85ecf59ebdea82cc9ee8892", size = 1426197, upload-time = "2025-12-08T01:45:51.198Z" }, { url = "https://files.pythonhosted.org/packages/d4/60/a93f55fd9b9816b7432cf6842f0e3000fdd5b7869492a04b9011a133ee37/black-25.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:169506ba91ef21e2e0591563deda7f00030cb466e747c4b09cb0a9dae5db2f43", size = 1237266, upload-time = "2025-12-08T01:45:10.556Z" }, - { url = "https://files.pythonhosted.org/packages/c8/52/c551e36bc95495d2aa1a37d50566267aa47608c81a53f91daa809e03293f/black-25.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a05ddeb656534c3e27a05a29196c962877c83fa5503db89e68857d1161ad08a5", size = 1923809, upload-time = "2025-12-08T01:46:55.126Z" }, - { url = "https://files.pythonhosted.org/packages/a0/f7/aac9b014140ee56d247e707af8db0aae2e9efc28d4a8aba92d0abd7ae9d1/black-25.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9ec77439ef3e34896995503865a85732c94396edcc739f302c5673a2315e1e7f", size = 1742384, upload-time = "2025-12-08T01:49:37.022Z" }, - { url = "https://files.pythonhosted.org/packages/74/98/38aaa018b2ab06a863974c12b14a6266badc192b20603a81b738c47e902e/black-25.12.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e509c858adf63aa61d908061b52e580c40eae0dfa72415fa47ac01b12e29baf", size = 1798761, upload-time = "2025-12-08T01:46:05.386Z" }, - { url = "https://files.pythonhosted.org/packages/16/3a/a8ac542125f61574a3f015b521ca83b47321ed19bb63fe6d7560f348bfe1/black-25.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:252678f07f5bac4ff0d0e9b261fbb029fa530cfa206d0a636a34ab445ef8ca9d", size = 1429180, upload-time = "2025-12-08T01:45:34.903Z" }, - { url = "https://files.pythonhosted.org/packages/e6/2d/bdc466a3db9145e946762d52cd55b1385509d9f9004fec1c97bdc8debbfb/black-25.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:bc5b1c09fe3c931ddd20ee548511c64ebf964ada7e6f0763d443947fd1c603ce", size = 1239350, upload-time = "2025-12-08T01:46:09.458Z" }, - { url = "https://files.pythonhosted.org/packages/35/46/1d8f2542210c502e2ae1060b2e09e47af6a5e5963cb78e22ec1a11170b28/black-25.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0a0953b134f9335c2434864a643c842c44fba562155c738a2a37a4d61f00cad5", size = 1917015, upload-time = "2025-12-08T01:53:27.987Z" }, - { url = "https://files.pythonhosted.org/packages/41/37/68accadf977672beb8e2c64e080f568c74159c1aaa6414b4cd2aef2d7906/black-25.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2355bbb6c3b76062870942d8cc450d4f8ac71f9c93c40122762c8784df49543f", size = 1741830, upload-time = "2025-12-08T01:54:36.861Z" }, - { url = "https://files.pythonhosted.org/packages/ac/76/03608a9d8f0faad47a3af3a3c8c53af3367f6c0dd2d23a84710456c7ac56/black-25.12.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9678bd991cc793e81d19aeeae57966ee02909877cb65838ccffef24c3ebac08f", size = 1791450, upload-time = "2025-12-08T01:44:52.581Z" }, - { url = "https://files.pythonhosted.org/packages/06/99/b2a4bd7dfaea7964974f947e1c76d6886d65fe5d24f687df2d85406b2609/black-25.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:97596189949a8aad13ad12fcbb4ae89330039b96ad6742e6f6b45e75ad5cfd83", size = 1452042, upload-time = "2025-12-08T01:46:13.188Z" }, - { url = "https://files.pythonhosted.org/packages/b2/7c/d9825de75ae5dd7795d007681b752275ea85a1c5d83269b4b9c754c2aaab/black-25.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:778285d9ea197f34704e3791ea9404cd6d07595745907dd2ce3da7a13627b29b", size = 1267446, upload-time = "2025-12-08T01:46:14.497Z" }, { url = "https://files.pythonhosted.org/packages/68/11/21331aed19145a952ad28fca2756a1433ee9308079bd03bd898e903a2e53/black-25.12.0-py3-none-any.whl", hash = "sha256:48ceb36c16dbc84062740049eef990bb2ce07598272e673c17d1a7720c71c828", size = 206191, upload-time = "2025-12-08T01:40:50.963Z" }, ] @@ -134,19 +134,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, - { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, - { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, - { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, - { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, - { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, - { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, - { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, - { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, - { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, - { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, - { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, - { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, @@ -159,40 +146,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, - { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, - { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, - { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, - { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, - { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, - { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, - { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, - { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, - { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, - { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, - { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, - { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, - { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, - { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, - { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, - { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, - { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, - { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, - { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, - { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, - { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, - { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, - { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, - { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, - { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, - { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, - { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, - { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, - { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, - { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, - { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, - { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, - { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, ] [[package]] @@ -210,22 +163,6 @@ version = "3.4.4" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, - { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, - { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, - { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" }, - { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" }, - { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" }, - { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" }, - { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" }, - { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" }, - { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" }, - { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" }, - { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" }, - { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" }, - { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" }, - { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" }, - { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" }, { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, @@ -242,38 +179,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, - { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, - { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, - { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, - { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, - { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, - { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, - { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, - { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, - { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, - { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, - { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, - { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, - { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, - { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, - { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, - { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, - { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, - { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, - { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, - { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, - { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, - { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, - { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, - { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, - { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, - { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, - { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, - { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, - { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, - { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, - { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, - { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, ] @@ -366,20 +271,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, - { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" }, - { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" }, - { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" }, - { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" }, - { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" }, - { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" }, - { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" }, - { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" }, - { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" }, - { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" }, - { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" }, - { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" }, - { url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" }, - { url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" }, { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, @@ -394,12 +285,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, - { url = "https://files.pythonhosted.org/packages/eb/dd/2d9fdb07cebdf3d51179730afb7d5e576153c6744c3ff8fded23030c204e/cryptography-46.0.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c", size = 3476964, upload-time = "2026-02-10T19:18:20.687Z" }, - { url = "https://files.pythonhosted.org/packages/e9/6f/6cc6cc9955caa6eaf83660b0da2b077c7fe8ff9950a3c5e45d605038d439/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a", size = 4218321, upload-time = "2026-02-10T19:18:22.349Z" }, - { url = "https://files.pythonhosted.org/packages/3e/5d/c4da701939eeee699566a6c1367427ab91a8b7088cc2328c09dbee940415/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356", size = 4381786, upload-time = "2026-02-10T19:18:24.529Z" }, - { url = "https://files.pythonhosted.org/packages/ac/97/a538654732974a94ff96c1db621fa464f455c02d4bb7d2652f4edc21d600/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da", size = 4217990, upload-time = "2026-02-10T19:18:25.957Z" }, - { url = "https://files.pythonhosted.org/packages/ae/11/7e500d2dd3ba891197b9efd2da5454b74336d64a7cc419aa7327ab74e5f6/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257", size = 4381252, upload-time = "2026-02-10T19:18:27.496Z" }, - { url = "https://files.pythonhosted.org/packages/bc/58/6b3d24e6b9bc474a2dcdee65dfd1f008867015408a271562e4b690561a4d/cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", size = 3407605, upload-time = "2026-02-10T19:18:29.233Z" }, ] [[package]] @@ -408,22 +293,10 @@ version = "1.8.20" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/e0/b7/cd8080344452e4874aae67c40d8940e2b4d47b01601a8fd9f44786c757c7/debugpy-1.8.20.tar.gz", hash = "sha256:55bc8701714969f1ab89a6d5f2f3d40c36f91b2cbe2f65d98bf8196f6a6a2c33", size = 1645207, upload-time = "2026-01-29T23:03:28.199Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/51/56/c3baf5cbe4dd77427fd9aef99fcdade259ad128feeb8a786c246adb838e5/debugpy-1.8.20-cp311-cp311-macosx_15_0_universal2.whl", hash = "sha256:eada6042ad88fa1571b74bd5402ee8b86eded7a8f7b827849761700aff171f1b", size = 2208318, upload-time = "2026-01-29T23:03:36.481Z" }, - { url = "https://files.pythonhosted.org/packages/9a/7d/4fa79a57a8e69fe0d9763e98d1110320f9ecd7f1f362572e3aafd7417c9d/debugpy-1.8.20-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:7de0b7dfeedc504421032afba845ae2a7bcc32ddfb07dae2c3ca5442f821c344", size = 3171493, upload-time = "2026-01-29T23:03:37.775Z" }, - { url = "https://files.pythonhosted.org/packages/7d/f2/1e8f8affe51e12a26f3a8a8a4277d6e60aa89d0a66512f63b1e799d424a4/debugpy-1.8.20-cp311-cp311-win32.whl", hash = "sha256:773e839380cf459caf73cc533ea45ec2737a5cc184cf1b3b796cd4fd98504fec", size = 5209240, upload-time = "2026-01-29T23:03:39.109Z" }, - { url = "https://files.pythonhosted.org/packages/d5/92/1cb532e88560cbee973396254b21bece8c5d7c2ece958a67afa08c9f10dc/debugpy-1.8.20-cp311-cp311-win_amd64.whl", hash = "sha256:1f7650546e0eded1902d0f6af28f787fa1f1dbdbc97ddabaf1cd963a405930cb", size = 5233481, upload-time = "2026-01-29T23:03:40.659Z" }, { url = "https://files.pythonhosted.org/packages/14/57/7f34f4736bfb6e00f2e4c96351b07805d83c9a7b33d28580ae01374430f7/debugpy-1.8.20-cp312-cp312-macosx_15_0_universal2.whl", hash = "sha256:4ae3135e2089905a916909ef31922b2d733d756f66d87345b3e5e52b7a55f13d", size = 2550686, upload-time = "2026-01-29T23:03:42.023Z" }, { url = "https://files.pythonhosted.org/packages/ab/78/b193a3975ca34458f6f0e24aaf5c3e3da72f5401f6054c0dfd004b41726f/debugpy-1.8.20-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:88f47850a4284b88bd2bfee1f26132147d5d504e4e86c22485dfa44b97e19b4b", size = 4310588, upload-time = "2026-01-29T23:03:43.314Z" }, { url = "https://files.pythonhosted.org/packages/c1/55/f14deb95eaf4f30f07ef4b90a8590fc05d9e04df85ee379712f6fb6736d7/debugpy-1.8.20-cp312-cp312-win32.whl", hash = "sha256:4057ac68f892064e5f98209ab582abfee3b543fb55d2e87610ddc133a954d390", size = 5331372, upload-time = "2026-01-29T23:03:45.526Z" }, { url = "https://files.pythonhosted.org/packages/a1/39/2bef246368bd42f9bd7cba99844542b74b84dacbdbea0833e610f384fee8/debugpy-1.8.20-cp312-cp312-win_amd64.whl", hash = "sha256:a1a8f851e7cf171330679ef6997e9c579ef6dd33c9098458bd9986a0f4ca52e3", size = 5372835, upload-time = "2026-01-29T23:03:47.245Z" }, - { url = "https://files.pythonhosted.org/packages/15/e2/fc500524cc6f104a9d049abc85a0a8b3f0d14c0a39b9c140511c61e5b40b/debugpy-1.8.20-cp313-cp313-macosx_15_0_universal2.whl", hash = "sha256:5dff4bb27027821fdfcc9e8f87309a28988231165147c31730128b1c983e282a", size = 2539560, upload-time = "2026-01-29T23:03:48.738Z" }, - { url = "https://files.pythonhosted.org/packages/90/83/fb33dcea789ed6018f8da20c5a9bc9d82adc65c0c990faed43f7c955da46/debugpy-1.8.20-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:84562982dd7cf5ebebfdea667ca20a064e096099997b175fe204e86817f64eaf", size = 4293272, upload-time = "2026-01-29T23:03:50.169Z" }, - { url = "https://files.pythonhosted.org/packages/a6/25/b1e4a01bfb824d79a6af24b99ef291e24189080c93576dfd9b1a2815cd0f/debugpy-1.8.20-cp313-cp313-win32.whl", hash = "sha256:da11dea6447b2cadbf8ce2bec59ecea87cc18d2c574980f643f2d2dfe4862393", size = 5331208, upload-time = "2026-01-29T23:03:51.547Z" }, - { url = "https://files.pythonhosted.org/packages/13/f7/a0b368ce54ffff9e9028c098bd2d28cfc5b54f9f6c186929083d4c60ba58/debugpy-1.8.20-cp313-cp313-win_amd64.whl", hash = "sha256:eb506e45943cab2efb7c6eafdd65b842f3ae779f020c82221f55aca9de135ed7", size = 5372930, upload-time = "2026-01-29T23:03:53.585Z" }, - { url = "https://files.pythonhosted.org/packages/33/2e/f6cb9a8a13f5058f0a20fe09711a7b726232cd5a78c6a7c05b2ec726cff9/debugpy-1.8.20-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:9c74df62fc064cd5e5eaca1353a3ef5a5d50da5eb8058fcef63106f7bebe6173", size = 2538066, upload-time = "2026-01-29T23:03:54.999Z" }, - { url = "https://files.pythonhosted.org/packages/c5/56/6ddca50b53624e1ca3ce1d1e49ff22db46c47ea5fb4c0cc5c9b90a616364/debugpy-1.8.20-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:077a7447589ee9bc1ff0cdf443566d0ecf540ac8aa7333b775ebcb8ce9f4ecad", size = 4269425, upload-time = "2026-01-29T23:03:56.518Z" }, - { url = "https://files.pythonhosted.org/packages/c5/d9/d64199c14a0d4c476df46c82470a3ce45c8d183a6796cfb5e66533b3663c/debugpy-1.8.20-cp314-cp314-win32.whl", hash = "sha256:352036a99dd35053b37b7803f748efc456076f929c6a895556932eaf2d23b07f", size = 5331407, upload-time = "2026-01-29T23:03:58.481Z" }, - { url = "https://files.pythonhosted.org/packages/e0/d9/1f07395b54413432624d61524dfd98c1a7c7827d2abfdb8829ac92638205/debugpy-1.8.20-cp314-cp314-win_amd64.whl", hash = "sha256:a98eec61135465b062846112e5ecf2eebb855305acc1dfbae43b72903b8ab5be", size = 5372521, upload-time = "2026-01-29T23:03:59.864Z" }, { url = "https://files.pythonhosted.org/packages/e0/c3/7f67dea8ccf8fdcb9c99033bbe3e90b9e7395415843accb81428c441be2d/debugpy-1.8.20-py2.py3-none-any.whl", hash = "sha256:5be9bed9ae3be00665a06acaa48f8329d2b9632f15fd09f6a9a8c8d9907e54d7", size = 5337658, upload-time = "2026-01-29T23:04:17.404Z" }, ] @@ -481,6 +354,26 @@ name = "essentials" version = "0.1.0" source = { virtual = "." } +[package.dev-dependencies] +dev = [ + { name = "bandit" }, + { name = "perflint" }, + { name = "pre-commit" }, + { name = "pylint" }, + { name = "ruff" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [ + { name = "bandit", specifier = ">=1.8.6" }, + { name = "perflint", specifier = ">=0.8.1" }, + { name = "pre-commit", specifier = ">=4.0.1" }, + { name = "pylint", specifier = ">=3.1.0" }, + { name = "ruff", specifier = ">=0.14.2" }, +] + [[package]] name = "filelock" version = "3.24.3" @@ -642,28 +535,11 @@ version = "1.8.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/03/41/4b9c02f99e4c5fb477122cd5437403b552873f014616ac1d19ac8221a58d/google_crc32c-1.8.0.tar.gz", hash = "sha256:a428e25fb7691024de47fecfbff7ff957214da51eddded0da0ae0e0f03a2cf79", size = 14192, upload-time = "2025-12-16T00:35:25.142Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/ef/21ccfaab3d5078d41efe8612e0ed0bfc9ce22475de074162a91a25f7980d/google_crc32c-1.8.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:014a7e68d623e9a4222d663931febc3033c5c7c9730785727de2a81f87d5bab8", size = 31298, upload-time = "2025-12-16T00:20:32.241Z" }, - { url = "https://files.pythonhosted.org/packages/c5/b8/f8413d3f4b676136e965e764ceedec904fe38ae8de0cdc52a12d8eb1096e/google_crc32c-1.8.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:86cfc00fe45a0ac7359e5214a1704e51a99e757d0272554874f419f79838c5f7", size = 30872, upload-time = "2025-12-16T00:33:58.785Z" }, - { url = "https://files.pythonhosted.org/packages/f6/fd/33aa4ec62b290477181c55bb1c9302c9698c58c0ce9a6ab4874abc8b0d60/google_crc32c-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:19b40d637a54cb71e0829179f6cb41835f0fbd9e8eb60552152a8b52c36cbe15", size = 33243, upload-time = "2025-12-16T00:40:21.46Z" }, - { url = "https://files.pythonhosted.org/packages/71/03/4820b3bd99c9653d1a5210cb32f9ba4da9681619b4d35b6a052432df4773/google_crc32c-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:17446feb05abddc187e5441a45971b8394ea4c1b6efd88ab0af393fd9e0a156a", size = 33608, upload-time = "2025-12-16T00:40:22.204Z" }, - { url = "https://files.pythonhosted.org/packages/7c/43/acf61476a11437bf9733fb2f70599b1ced11ec7ed9ea760fdd9a77d0c619/google_crc32c-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:71734788a88f551fbd6a97be9668a0020698e07b2bf5b3aa26a36c10cdfb27b2", size = 34439, upload-time = "2025-12-16T00:35:20.458Z" }, { url = "https://files.pythonhosted.org/packages/e9/5f/7307325b1198b59324c0fa9807cafb551afb65e831699f2ce211ad5c8240/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:4b8286b659c1335172e39563ab0a768b8015e88e08329fa5321f774275fc3113", size = 31300, upload-time = "2025-12-16T00:21:56.723Z" }, { url = "https://files.pythonhosted.org/packages/21/8e/58c0d5d86e2220e6a37befe7e6a94dd2f6006044b1a33edf1ff6d9f7e319/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:2a3dc3318507de089c5384cc74d54318401410f82aa65b2d9cdde9d297aca7cb", size = 30867, upload-time = "2025-12-16T00:38:31.302Z" }, { url = "https://files.pythonhosted.org/packages/ce/a9/a780cc66f86335a6019f557a8aaca8fbb970728f0efd2430d15ff1beae0e/google_crc32c-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14f87e04d613dfa218d6135e81b78272c3b904e2a7053b841481b38a7d901411", size = 33364, upload-time = "2025-12-16T00:40:22.96Z" }, { url = "https://files.pythonhosted.org/packages/21/3f/3457ea803db0198c9aaca2dd373750972ce28a26f00544b6b85088811939/google_crc32c-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb5c869c2923d56cb0c8e6bcdd73c009c36ae39b652dbe46a05eb4ef0ad01454", size = 33740, upload-time = "2025-12-16T00:40:23.96Z" }, { url = "https://files.pythonhosted.org/packages/df/c0/87c2073e0c72515bb8733d4eef7b21548e8d189f094b5dad20b0ecaf64f6/google_crc32c-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:3cc0c8912038065eafa603b238abf252e204accab2a704c63b9e14837a854962", size = 34437, upload-time = "2025-12-16T00:35:21.395Z" }, - { url = "https://files.pythonhosted.org/packages/d1/db/000f15b41724589b0e7bc24bc7a8967898d8d3bc8caf64c513d91ef1f6c0/google_crc32c-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:3ebb04528e83b2634857f43f9bb8ef5b2bbe7f10f140daeb01b58f972d04736b", size = 31297, upload-time = "2025-12-16T00:23:20.709Z" }, - { url = "https://files.pythonhosted.org/packages/d7/0d/8ebed0c39c53a7e838e2a486da8abb0e52de135f1b376ae2f0b160eb4c1a/google_crc32c-1.8.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:450dc98429d3e33ed2926fc99ee81001928d63460f8538f21a5d6060912a8e27", size = 30867, upload-time = "2025-12-16T00:43:14.628Z" }, - { url = "https://files.pythonhosted.org/packages/ce/42/b468aec74a0354b34c8cbf748db20d6e350a68a2b0912e128cabee49806c/google_crc32c-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3b9776774b24ba76831609ffbabce8cdf6fa2bd5e9df37b594221c7e333a81fa", size = 33344, upload-time = "2025-12-16T00:40:24.742Z" }, - { url = "https://files.pythonhosted.org/packages/1c/e8/b33784d6fc77fb5062a8a7854e43e1e618b87d5ddf610a88025e4de6226e/google_crc32c-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:89c17d53d75562edfff86679244830599ee0a48efc216200691de8b02ab6b2b8", size = 33694, upload-time = "2025-12-16T00:40:25.505Z" }, - { url = "https://files.pythonhosted.org/packages/92/b1/d3cbd4d988afb3d8e4db94ca953df429ed6db7282ed0e700d25e6c7bfc8d/google_crc32c-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:57a50a9035b75643996fbf224d6661e386c7162d1dfdab9bc4ca790947d1007f", size = 34435, upload-time = "2025-12-16T00:35:22.107Z" }, - { url = "https://files.pythonhosted.org/packages/21/88/8ecf3c2b864a490b9e7010c84fd203ec8cf3b280651106a3a74dd1b0ca72/google_crc32c-1.8.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:e6584b12cb06796d285d09e33f63309a09368b9d806a551d8036a4207ea43697", size = 31301, upload-time = "2025-12-16T00:24:48.527Z" }, - { url = "https://files.pythonhosted.org/packages/36/c6/f7ff6c11f5ca215d9f43d3629163727a272eabc356e5c9b2853df2bfe965/google_crc32c-1.8.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:f4b51844ef67d6cf2e9425983274da75f18b1597bb2c998e1c0a0e8d46f8f651", size = 30868, upload-time = "2025-12-16T00:48:12.163Z" }, - { url = "https://files.pythonhosted.org/packages/56/15/c25671c7aad70f8179d858c55a6ae8404902abe0cdcf32a29d581792b491/google_crc32c-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b0d1a7afc6e8e4635564ba8aa5c0548e3173e41b6384d7711a9123165f582de2", size = 33381, upload-time = "2025-12-16T00:40:26.268Z" }, - { url = "https://files.pythonhosted.org/packages/42/fa/f50f51260d7b0ef5d4898af122d8a7ec5a84e2984f676f746445f783705f/google_crc32c-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8b3f68782f3cbd1bce027e48768293072813469af6a61a86f6bb4977a4380f21", size = 33734, upload-time = "2025-12-16T00:40:27.028Z" }, - { url = "https://files.pythonhosted.org/packages/08/a5/7b059810934a09fb3ccb657e0843813c1fee1183d3bc2c8041800374aa2c/google_crc32c-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:d511b3153e7011a27ab6ee6bb3a5404a55b994dc1a7322c0b87b29606d9790e2", size = 34878, upload-time = "2025-12-16T00:35:23.142Z" }, - { url = "https://files.pythonhosted.org/packages/52/c5/c171e4d8c44fec1422d801a6d2e5d7ddabd733eeda505c79730ee9607f07/google_crc32c-1.8.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:87fa445064e7db928226b2e6f0d5304ab4cd0339e664a4e9a25029f384d9bb93", size = 28615, upload-time = "2025-12-16T00:40:29.298Z" }, - { url = "https://files.pythonhosted.org/packages/9c/97/7d75fe37a7a6ed171a2cf17117177e7aab7e6e0d115858741b41e9dd4254/google_crc32c-1.8.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f639065ea2042d5c034bf258a9f085eaa7af0cd250667c0635a3118e8f92c69c", size = 28800, upload-time = "2025-12-16T00:40:30.322Z" }, ] [[package]] @@ -739,16 +615,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/06/8a/3d098f35c143a89520e568e6539cc098fcd294495910e359889ce8741c84/grpcio-1.78.0.tar.gz", hash = "sha256:7382b95189546f375c174f53a5fa873cef91c4b8005faa05cc5b3beea9c4f1c5", size = 12852416, upload-time = "2026-02-06T09:57:18.093Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/86/c7/d0b780a29b0837bf4ca9580904dfb275c1fc321ded7897d620af7047ec57/grpcio-1.78.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:2777b783f6c13b92bd7b716667452c329eefd646bfb3f2e9dabea2e05dbd34f6", size = 5951525, upload-time = "2026-02-06T09:55:01.989Z" }, - { url = "https://files.pythonhosted.org/packages/c5/b1/96920bf2ee61df85a9503cb6f733fe711c0ff321a5a697d791b075673281/grpcio-1.78.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:9dca934f24c732750389ce49d638069c3892ad065df86cb465b3fa3012b70c9e", size = 11830418, upload-time = "2026-02-06T09:55:04.462Z" }, - { url = "https://files.pythonhosted.org/packages/83/0c/7c1528f098aeb75a97de2bae18c530f56959fb7ad6c882db45d9884d6edc/grpcio-1.78.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:459ab414b35f4496138d0ecd735fed26f1318af5e52cb1efbc82a09f0d5aa911", size = 6524477, upload-time = "2026-02-06T09:55:07.111Z" }, - { url = "https://files.pythonhosted.org/packages/8d/52/e7c1f3688f949058e19a011c4e0dec973da3d0ae5e033909677f967ae1f4/grpcio-1.78.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:082653eecbdf290e6e3e2c276ab2c54b9e7c299e07f4221872380312d8cf395e", size = 7198266, upload-time = "2026-02-06T09:55:10.016Z" }, - { url = "https://files.pythonhosted.org/packages/e5/61/8ac32517c1e856677282c34f2e7812d6c328fa02b8f4067ab80e77fdc9c9/grpcio-1.78.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:85f93781028ec63f383f6bc90db785a016319c561cc11151fbb7b34e0d012303", size = 6730552, upload-time = "2026-02-06T09:55:12.207Z" }, - { url = "https://files.pythonhosted.org/packages/bd/98/b8ee0158199250220734f620b12e4a345955ac7329cfd908d0bf0fda77f0/grpcio-1.78.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f12857d24d98441af6a1d5c87442d624411db486f7ba12550b07788f74b67b04", size = 7304296, upload-time = "2026-02-06T09:55:15.044Z" }, - { url = "https://files.pythonhosted.org/packages/bd/0f/7b72762e0d8840b58032a56fdbd02b78fc645b9fa993d71abf04edbc54f4/grpcio-1.78.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5397fff416b79e4b284959642a4e95ac4b0f1ece82c9993658e0e477d40551ec", size = 8288298, upload-time = "2026-02-06T09:55:17.276Z" }, - { url = "https://files.pythonhosted.org/packages/24/ae/ae4ce56bc5bb5caa3a486d60f5f6083ac3469228faa734362487176c15c5/grpcio-1.78.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:fbe6e89c7ffb48518384068321621b2a69cab509f58e40e4399fdd378fa6d074", size = 7730953, upload-time = "2026-02-06T09:55:19.545Z" }, - { url = "https://files.pythonhosted.org/packages/b5/6e/8052e3a28eb6a820c372b2eb4b5e32d195c661e137d3eca94d534a4cfd8a/grpcio-1.78.0-cp311-cp311-win32.whl", hash = "sha256:6092beabe1966a3229f599d7088b38dfc8ffa1608b5b5cdda31e591e6500f856", size = 4076503, upload-time = "2026-02-06T09:55:21.521Z" }, - { url = "https://files.pythonhosted.org/packages/08/62/f22c98c5265dfad327251fa2f840b591b1df5f5e15d88b19c18c86965b27/grpcio-1.78.0-cp311-cp311-win_amd64.whl", hash = "sha256:1afa62af6e23f88629f2b29ec9e52ec7c65a7176c1e0a83292b93c76ca882558", size = 4799767, upload-time = "2026-02-06T09:55:24.107Z" }, { url = "https://files.pythonhosted.org/packages/4e/f4/7384ed0178203d6074446b3c4f46c90a22ddf7ae0b3aee521627f54cfc2a/grpcio-1.78.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:f9ab915a267fc47c7e88c387a3a28325b58c898e23d4995f765728f4e3dedb97", size = 5913985, upload-time = "2026-02-06T09:55:26.832Z" }, { url = "https://files.pythonhosted.org/packages/81/ed/be1caa25f06594463f685b3790b320f18aea49b33166f4141bfdc2bfb236/grpcio-1.78.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3f8904a8165ab21e07e58bf3e30a73f4dffc7a1e0dbc32d51c61b5360d26f43e", size = 11811853, upload-time = "2026-02-06T09:55:29.224Z" }, { url = "https://files.pythonhosted.org/packages/24/a7/f06d151afc4e64b7e3cc3e872d331d011c279aaab02831e40a81c691fb65/grpcio-1.78.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:859b13906ce098c0b493af92142ad051bf64c7870fa58a123911c88606714996", size = 6475766, upload-time = "2026-02-06T09:55:31.825Z" }, @@ -759,26 +625,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f3/e4/ea3c0caf5468537f27ad5aab92b681ed7cc0ef5f8c9196d3fd42c8c2286b/grpcio-1.78.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fd5f135b1bd58ab088930b3c613455796dfa0393626a6972663ccdda5b4ac6ce", size = 7698222, upload-time = "2026-02-06T09:55:44.629Z" }, { url = "https://files.pythonhosted.org/packages/d7/47/7f05f81e4bb6b831e93271fb12fd52ba7b319b5402cbc101d588f435df00/grpcio-1.78.0-cp312-cp312-win32.whl", hash = "sha256:94309f498bcc07e5a7d16089ab984d42ad96af1d94b5a4eb966a266d9fcabf68", size = 4066123, upload-time = "2026-02-06T09:55:47.644Z" }, { url = "https://files.pythonhosted.org/packages/ad/e7/d6914822c88aa2974dbbd10903d801a28a19ce9cd8bad7e694cbbcf61528/grpcio-1.78.0-cp312-cp312-win_amd64.whl", hash = "sha256:9566fe4ababbb2610c39190791e5b829869351d14369603702e890ef3ad2d06e", size = 4797657, upload-time = "2026-02-06T09:55:49.86Z" }, - { url = "https://files.pythonhosted.org/packages/05/a9/8f75894993895f361ed8636cd9237f4ab39ef87fd30db17467235ed1c045/grpcio-1.78.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:ce3a90455492bf8bfa38e56fbbe1dbd4f872a3d8eeaf7337dc3b1c8aa28c271b", size = 5920143, upload-time = "2026-02-06T09:55:52.035Z" }, - { url = "https://files.pythonhosted.org/packages/55/06/0b78408e938ac424100100fd081189451b472236e8a3a1f6500390dc4954/grpcio-1.78.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:2bf5e2e163b356978b23652c4818ce4759d40f4712ee9ec5a83c4be6f8c23a3a", size = 11803926, upload-time = "2026-02-06T09:55:55.494Z" }, - { url = "https://files.pythonhosted.org/packages/88/93/b59fe7832ff6ae3c78b813ea43dac60e295fa03606d14d89d2e0ec29f4f3/grpcio-1.78.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8f2ac84905d12918e4e55a16da17939eb63e433dc11b677267c35568aa63fc84", size = 6478628, upload-time = "2026-02-06T09:55:58.533Z" }, - { url = "https://files.pythonhosted.org/packages/ed/df/e67e3734527f9926b7d9c0dde6cd998d1d26850c3ed8eeec81297967ac67/grpcio-1.78.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b58f37edab4a3881bc6c9bca52670610e0c9ca14e2ea3cf9debf185b870457fb", size = 7173574, upload-time = "2026-02-06T09:56:01.786Z" }, - { url = "https://files.pythonhosted.org/packages/a6/62/cc03fffb07bfba982a9ec097b164e8835546980aec25ecfa5f9c1a47e022/grpcio-1.78.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:735e38e176a88ce41840c21bb49098ab66177c64c82426e24e0082500cc68af5", size = 6692639, upload-time = "2026-02-06T09:56:04.529Z" }, - { url = "https://files.pythonhosted.org/packages/bf/9a/289c32e301b85bdb67d7ec68b752155e674ee3ba2173a1858f118e399ef3/grpcio-1.78.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2045397e63a7a0ee7957c25f7dbb36ddc110e0cfb418403d110c0a7a68a844e9", size = 7268838, upload-time = "2026-02-06T09:56:08.397Z" }, - { url = "https://files.pythonhosted.org/packages/0e/79/1be93f32add280461fa4773880196572563e9c8510861ac2da0ea0f892b6/grpcio-1.78.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a9f136fbafe7ccf4ac7e8e0c28b31066e810be52d6e344ef954a3a70234e1702", size = 8251878, upload-time = "2026-02-06T09:56:10.914Z" }, - { url = "https://files.pythonhosted.org/packages/65/65/793f8e95296ab92e4164593674ae6291b204bb5f67f9d4a711489cd30ffa/grpcio-1.78.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:748b6138585379c737adc08aeffd21222abbda1a86a0dca2a39682feb9196c20", size = 7695412, upload-time = "2026-02-06T09:56:13.593Z" }, - { url = "https://files.pythonhosted.org/packages/1c/9f/1e233fe697ecc82845942c2822ed06bb522e70d6771c28d5528e4c50f6a4/grpcio-1.78.0-cp313-cp313-win32.whl", hash = "sha256:271c73e6e5676afe4fc52907686670c7cea22ab2310b76a59b678403ed40d670", size = 4064899, upload-time = "2026-02-06T09:56:15.601Z" }, - { url = "https://files.pythonhosted.org/packages/4d/27/d86b89e36de8a951501fb06a0f38df19853210f341d0b28f83f4aa0ffa08/grpcio-1.78.0-cp313-cp313-win_amd64.whl", hash = "sha256:f2d4e43ee362adfc05994ed479334d5a451ab7bc3f3fee1b796b8ca66895acb4", size = 4797393, upload-time = "2026-02-06T09:56:17.882Z" }, - { url = "https://files.pythonhosted.org/packages/29/f2/b56e43e3c968bfe822fa6ce5bca10d5c723aa40875b48791ce1029bb78c7/grpcio-1.78.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:e87cbc002b6f440482b3519e36e1313eb5443e9e9e73d6a52d43bd2004fcfd8e", size = 5920591, upload-time = "2026-02-06T09:56:20.758Z" }, - { url = "https://files.pythonhosted.org/packages/5d/81/1f3b65bd30c334167bfa8b0d23300a44e2725ce39bba5b76a2460d85f745/grpcio-1.78.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:c41bc64626db62e72afec66b0c8a0da76491510015417c127bfc53b2fe6d7f7f", size = 11813685, upload-time = "2026-02-06T09:56:24.315Z" }, - { url = "https://files.pythonhosted.org/packages/0e/1c/bbe2f8216a5bd3036119c544d63c2e592bdf4a8ec6e4a1867592f4586b26/grpcio-1.78.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8dfffba826efcf366b1e3ccc37e67afe676f290e13a3b48d31a46739f80a8724", size = 6487803, upload-time = "2026-02-06T09:56:27.367Z" }, - { url = "https://files.pythonhosted.org/packages/16/5c/a6b2419723ea7ddce6308259a55e8e7593d88464ce8db9f4aa857aba96fa/grpcio-1.78.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:74be1268d1439eaaf552c698cdb11cd594f0c49295ae6bb72c34ee31abbe611b", size = 7173206, upload-time = "2026-02-06T09:56:29.876Z" }, - { url = "https://files.pythonhosted.org/packages/df/1e/b8801345629a415ea7e26c83d75eb5dbe91b07ffe5210cc517348a8d4218/grpcio-1.78.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be63c88b32e6c0f1429f1398ca5c09bc64b0d80950c8bb7807d7d7fb36fb84c7", size = 6693826, upload-time = "2026-02-06T09:56:32.305Z" }, - { url = "https://files.pythonhosted.org/packages/34/84/0de28eac0377742679a510784f049738a80424b17287739fc47d63c2439e/grpcio-1.78.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3c586ac70e855c721bda8f548d38c3ca66ac791dc49b66a8281a1f99db85e452", size = 7277897, upload-time = "2026-02-06T09:56:34.915Z" }, - { url = "https://files.pythonhosted.org/packages/ca/9c/ad8685cfe20559a9edb66f735afdcb2b7d3de69b13666fdfc542e1916ebd/grpcio-1.78.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:35eb275bf1751d2ffbd8f57cdbc46058e857cf3971041521b78b7db94bdaf127", size = 8252404, upload-time = "2026-02-06T09:56:37.553Z" }, - { url = "https://files.pythonhosted.org/packages/3c/05/33a7a4985586f27e1de4803887c417ec7ced145ebd069bc38a9607059e2b/grpcio-1.78.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:207db540302c884b8848036b80db352a832b99dfdf41db1eb554c2c2c7800f65", size = 7696837, upload-time = "2026-02-06T09:56:40.173Z" }, - { url = "https://files.pythonhosted.org/packages/73/77/7382241caf88729b106e49e7d18e3116216c778e6a7e833826eb96de22f7/grpcio-1.78.0-cp314-cp314-win32.whl", hash = "sha256:57bab6deef2f4f1ca76cc04565df38dc5713ae6c17de690721bdf30cb1e0545c", size = 4142439, upload-time = "2026-02-06T09:56:43.258Z" }, - { url = "https://files.pythonhosted.org/packages/48/b2/b096ccce418882fbfda4f7496f9357aaa9a5af1896a9a7f60d9f2b275a06/grpcio-1.78.0-cp314-cp314-win_amd64.whl", hash = "sha256:dce09d6116df20a96acfdbf85e4866258c3758180e8c49845d6ba8248b6d0bbb", size = 4929852, upload-time = "2026-02-06T09:56:45.885Z" }, ] [[package]] @@ -868,6 +714,54 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/cc/9b681a170efab4868a032631dea1e8446d8ec718a7f657b94d49d1a12643/isort-6.1.0-py3-none-any.whl", hash = "sha256:58d8927ecce74e5087aef019f778d4081a3b6c98f15a80ba35782ca8a2097784", size = 94329, upload-time = "2025-10-01T16:26:43.291Z" }, ] +[[package]] +name = "langsmith-client" +version = "0.1.0" +source = { editable = "packages/python/langsmith-client" } +dependencies = [ + { name = "click" }, + { name = "pydantic" }, + { name = "python-decouple" }, + { name = "python-dotenv" }, + { name = "requests" }, +] + +[package.metadata] +requires-dist = [ + { name = "click", specifier = ">=8.1.0" }, + { name = "pydantic", specifier = ">=2.0.0" }, + { name = "python-decouple", specifier = ">=3.8" }, + { name = "python-dotenv", specifier = ">=1.1.0" }, + { name = "requests", specifier = ">=2.31.0" }, +] + +[[package]] +name = "langsmith-hosting" +version = "0.1.0" +source = { editable = "tools/python/langsmith-hosting" } +dependencies = [ + { name = "pulumi" }, + { name = "pulumi-aws" }, + { name = "pulumi-awsx" }, + { name = "pulumi-eks" }, + { name = "pulumi-kubernetes" }, + { name = "pulumi-random" }, + { name = "python-dotenv" }, + { name = "requests" }, +] + +[package.metadata] +requires-dist = [ + { name = "pulumi", specifier = ">=3.0.0" }, + { name = "pulumi-aws", specifier = ">=6.0.0" }, + { name = "pulumi-awsx", specifier = ">=2.0.0" }, + { name = "pulumi-eks", specifier = ">=3.0.0" }, + { name = "pulumi-kubernetes", specifier = ">=4.0.0" }, + { name = "pulumi-random", specifier = ">=4.0.0" }, + { name = "python-dotenv", specifier = ">=1.0.0" }, + { name = "requests", specifier = ">=2.31.0" }, +] + [[package]] name = "markdown-it-py" version = "4.0.0" @@ -922,17 +816,6 @@ version = "2.4.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/57/fd/0005efbd0af48e55eb3c7208af93f2862d4b1a56cd78e84309a2d959208d/numpy-2.4.2.tar.gz", hash = "sha256:659a6107e31a83c4e33f763942275fd278b21d095094044eb35569e86a21ddae", size = 20723651, upload-time = "2026-01-31T23:13:10.135Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d3/44/71852273146957899753e69986246d6a176061ea183407e95418c2aa4d9a/numpy-2.4.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e7e88598032542bd49af7c4747541422884219056c268823ef6e5e89851c8825", size = 16955478, upload-time = "2026-01-31T23:10:25.623Z" }, - { url = "https://files.pythonhosted.org/packages/74/41/5d17d4058bd0cd96bcbd4d9ff0fb2e21f52702aab9a72e4a594efa18692f/numpy-2.4.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7edc794af8b36ca37ef5fcb5e0d128c7e0595c7b96a2318d1badb6fcd8ee86b1", size = 14965467, upload-time = "2026-01-31T23:10:28.186Z" }, - { url = "https://files.pythonhosted.org/packages/49/48/fb1ce8136c19452ed15f033f8aee91d5defe515094e330ce368a0647846f/numpy-2.4.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:6e9f61981ace1360e42737e2bae58b27bf28a1b27e781721047d84bd754d32e7", size = 5475172, upload-time = "2026-01-31T23:10:30.848Z" }, - { url = "https://files.pythonhosted.org/packages/40/a9/3feb49f17bbd1300dd2570432961f5c8a4ffeff1db6f02c7273bd020a4c9/numpy-2.4.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:cb7bbb88aa74908950d979eeaa24dbdf1a865e3c7e45ff0121d8f70387b55f73", size = 6805145, upload-time = "2026-01-31T23:10:32.352Z" }, - { url = "https://files.pythonhosted.org/packages/3f/39/fdf35cbd6d6e2fcad42fcf85ac04a85a0d0fbfbf34b30721c98d602fd70a/numpy-2.4.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f069069931240b3fc703f1e23df63443dbd6390614c8c44a87d96cd0ec81eb1", size = 15966084, upload-time = "2026-01-31T23:10:34.502Z" }, - { url = "https://files.pythonhosted.org/packages/1b/46/6fa4ea94f1ddf969b2ee941290cca6f1bfac92b53c76ae5f44afe17ceb69/numpy-2.4.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c02ef4401a506fb60b411467ad501e1429a3487abca4664871d9ae0b46c8ba32", size = 16899477, upload-time = "2026-01-31T23:10:37.075Z" }, - { url = "https://files.pythonhosted.org/packages/09/a1/2a424e162b1a14a5bd860a464ab4e07513916a64ab1683fae262f735ccd2/numpy-2.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2653de5c24910e49c2b106499803124dde62a5a1fe0eedeaecf4309a5f639390", size = 17323429, upload-time = "2026-01-31T23:10:39.704Z" }, - { url = "https://files.pythonhosted.org/packages/ce/a2/73014149ff250628df72c58204822ac01d768697913881aacf839ff78680/numpy-2.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1ae241bbfc6ae276f94a170b14785e561cb5e7f626b6688cf076af4110887413", size = 18635109, upload-time = "2026-01-31T23:10:41.924Z" }, - { url = "https://files.pythonhosted.org/packages/6c/0c/73e8be2f1accd56df74abc1c5e18527822067dced5ec0861b5bb882c2ce0/numpy-2.4.2-cp311-cp311-win32.whl", hash = "sha256:df1b10187212b198dd45fa943d8985a3c8cf854aed4923796e0e019e113a1bda", size = 6237915, upload-time = "2026-01-31T23:10:45.26Z" }, - { url = "https://files.pythonhosted.org/packages/76/ae/e0265e0163cf127c24c3969d29f1c4c64551a1e375d95a13d32eab25d364/numpy-2.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:b9c618d56a29c9cb1c4da979e9899be7578d2e0b3c24d52079c166324c9e8695", size = 12607972, upload-time = "2026-01-31T23:10:47.021Z" }, - { url = "https://files.pythonhosted.org/packages/29/a5/c43029af9b8014d6ea157f192652c50042e8911f4300f8f6ed3336bf437f/numpy-2.4.2-cp311-cp311-win_arm64.whl", hash = "sha256:47c5a6ed21d9452b10227e5e8a0e1c22979811cad7dcc19d8e3e2fb8fa03f1a3", size = 10485763, upload-time = "2026-01-31T23:10:50.087Z" }, { url = "https://files.pythonhosted.org/packages/51/6e/6f394c9c77668153e14d4da83bcc247beb5952f6ead7699a1a2992613bea/numpy-2.4.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:21982668592194c609de53ba4933a7471880ccbaadcc52352694a59ecc860b3a", size = 16667963, upload-time = "2026-01-31T23:10:52.147Z" }, { url = "https://files.pythonhosted.org/packages/1f/f8/55483431f2b2fd015ae6ed4fe62288823ce908437ed49db5a03d15151678/numpy-2.4.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40397bda92382fcec844066efb11f13e1c9a3e2a8e8f318fb72ed8b6db9f60f1", size = 14693571, upload-time = "2026-01-31T23:10:54.789Z" }, { url = "https://files.pythonhosted.org/packages/2f/20/18026832b1845cdc82248208dd929ca14c9d8f2bac391f67440707fff27c/numpy-2.4.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:b3a24467af63c67829bfaa61eecf18d5432d4f11992688537be59ecd6ad32f5e", size = 5203469, upload-time = "2026-01-31T23:10:57.343Z" }, @@ -944,55 +827,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ab/af/6157aa6da728fa4525a755bfad486ae7e3f76d4c1864138003eb84328497/numpy-2.4.2-cp312-cp312-win32.whl", hash = "sha256:ec055f6dae239a6299cace477b479cca2fc125c5675482daf1dd886933a1076f", size = 5960282, upload-time = "2026-01-31T23:11:10.497Z" }, { url = "https://files.pythonhosted.org/packages/92/0f/7ceaaeaacb40567071e94dbf2c9480c0ae453d5bb4f52bea3892c39dc83c/numpy-2.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:209fae046e62d0ce6435fcfe3b1a10537e858249b3d9b05829e2a05218296a85", size = 12314210, upload-time = "2026-01-31T23:11:12.176Z" }, { url = "https://files.pythonhosted.org/packages/2f/a3/56c5c604fae6dd40fa2ed3040d005fca97e91bd320d232ac9931d77ba13c/numpy-2.4.2-cp312-cp312-win_arm64.whl", hash = "sha256:fbde1b0c6e81d56f5dccd95dd4a711d9b95df1ae4009a60887e56b27e8d903fa", size = 10220171, upload-time = "2026-01-31T23:11:14.684Z" }, - { url = "https://files.pythonhosted.org/packages/a1/22/815b9fe25d1d7ae7d492152adbc7226d3eff731dffc38fe970589fcaaa38/numpy-2.4.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:25f2059807faea4b077a2b6837391b5d830864b3543627f381821c646f31a63c", size = 16663696, upload-time = "2026-01-31T23:11:17.516Z" }, - { url = "https://files.pythonhosted.org/packages/09/f0/817d03a03f93ba9c6c8993de509277d84e69f9453601915e4a69554102a1/numpy-2.4.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bd3a7a9f5847d2fb8c2c6d1c862fa109c31a9abeca1a3c2bd5a64572955b2979", size = 14688322, upload-time = "2026-01-31T23:11:19.883Z" }, - { url = "https://files.pythonhosted.org/packages/da/b4/f805ab79293c728b9a99438775ce51885fd4f31b76178767cfc718701a39/numpy-2.4.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8e4549f8a3c6d13d55041925e912bfd834285ef1dd64d6bc7d542583355e2e98", size = 5198157, upload-time = "2026-01-31T23:11:22.375Z" }, - { url = "https://files.pythonhosted.org/packages/74/09/826e4289844eccdcd64aac27d13b0fd3f32039915dd5b9ba01baae1f436c/numpy-2.4.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:aea4f66ff44dfddf8c2cffd66ba6538c5ec67d389285292fe428cb2c738c8aef", size = 6546330, upload-time = "2026-01-31T23:11:23.958Z" }, - { url = "https://files.pythonhosted.org/packages/19/fb/cbfdbfa3057a10aea5422c558ac57538e6acc87ec1669e666d32ac198da7/numpy-2.4.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3cd545784805de05aafe1dde61752ea49a359ccba9760c1e5d1c88a93bbf2b7", size = 15660968, upload-time = "2026-01-31T23:11:25.713Z" }, - { url = "https://files.pythonhosted.org/packages/04/dc/46066ce18d01645541f0186877377b9371b8fa8017fa8262002b4ef22612/numpy-2.4.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0d9b7c93578baafcbc5f0b83eaf17b79d345c6f36917ba0c67f45226911d499", size = 16607311, upload-time = "2026-01-31T23:11:28.117Z" }, - { url = "https://files.pythonhosted.org/packages/14/d9/4b5adfc39a43fa6bf918c6d544bc60c05236cc2f6339847fc5b35e6cb5b0/numpy-2.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f74f0f7779cc7ae07d1810aab8ac6b1464c3eafb9e283a40da7309d5e6e48fbb", size = 17012850, upload-time = "2026-01-31T23:11:30.888Z" }, - { url = "https://files.pythonhosted.org/packages/b7/20/adb6e6adde6d0130046e6fdfb7675cc62bc2f6b7b02239a09eb58435753d/numpy-2.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7ac672d699bf36275c035e16b65539931347d68b70667d28984c9fb34e07fa7", size = 18334210, upload-time = "2026-01-31T23:11:33.214Z" }, - { url = "https://files.pythonhosted.org/packages/78/0e/0a73b3dff26803a8c02baa76398015ea2a5434d9b8265a7898a6028c1591/numpy-2.4.2-cp313-cp313-win32.whl", hash = "sha256:8e9afaeb0beff068b4d9cd20d322ba0ee1cecfb0b08db145e4ab4dd44a6b5110", size = 5958199, upload-time = "2026-01-31T23:11:35.385Z" }, - { url = "https://files.pythonhosted.org/packages/43/bc/6352f343522fcb2c04dbaf94cb30cca6fd32c1a750c06ad6231b4293708c/numpy-2.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:7df2de1e4fba69a51c06c28f5a3de36731eb9639feb8e1cf7e4a7b0daf4cf622", size = 12310848, upload-time = "2026-01-31T23:11:38.001Z" }, - { url = "https://files.pythonhosted.org/packages/6e/8d/6da186483e308da5da1cc6918ce913dcfe14ffde98e710bfeff2a6158d4e/numpy-2.4.2-cp313-cp313-win_arm64.whl", hash = "sha256:0fece1d1f0a89c16b03442eae5c56dc0be0c7883b5d388e0c03f53019a4bfd71", size = 10221082, upload-time = "2026-01-31T23:11:40.392Z" }, - { url = "https://files.pythonhosted.org/packages/25/a1/9510aa43555b44781968935c7548a8926274f815de42ad3997e9e83680dd/numpy-2.4.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5633c0da313330fd20c484c78cdd3f9b175b55e1a766c4a174230c6b70ad8262", size = 14815866, upload-time = "2026-01-31T23:11:42.495Z" }, - { url = "https://files.pythonhosted.org/packages/36/30/6bbb5e76631a5ae46e7923dd16ca9d3f1c93cfa8d4ed79a129814a9d8db3/numpy-2.4.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d9f64d786b3b1dd742c946c42d15b07497ed14af1a1f3ce840cce27daa0ce913", size = 5325631, upload-time = "2026-01-31T23:11:44.7Z" }, - { url = "https://files.pythonhosted.org/packages/46/00/3a490938800c1923b567b3a15cd17896e68052e2145d8662aaf3e1ffc58f/numpy-2.4.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:b21041e8cb6a1eb5312dd1d2f80a94d91efffb7a06b70597d44f1bd2dfc315ab", size = 6646254, upload-time = "2026-01-31T23:11:46.341Z" }, - { url = "https://files.pythonhosted.org/packages/d3/e9/fac0890149898a9b609caa5af7455a948b544746e4b8fe7c212c8edd71f8/numpy-2.4.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00ab83c56211a1d7c07c25e3217ea6695e50a3e2f255053686b081dc0b091a82", size = 15720138, upload-time = "2026-01-31T23:11:48.082Z" }, - { url = "https://files.pythonhosted.org/packages/ea/5c/08887c54e68e1e28df53709f1893ce92932cc6f01f7c3d4dc952f61ffd4e/numpy-2.4.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fb882da679409066b4603579619341c6d6898fc83a8995199d5249f986e8e8f", size = 16655398, upload-time = "2026-01-31T23:11:50.293Z" }, - { url = "https://files.pythonhosted.org/packages/4d/89/253db0fa0e66e9129c745e4ef25631dc37d5f1314dad2b53e907b8538e6d/numpy-2.4.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:66cb9422236317f9d44b67b4d18f44efe6e9c7f8794ac0462978513359461554", size = 17079064, upload-time = "2026-01-31T23:11:52.927Z" }, - { url = "https://files.pythonhosted.org/packages/2a/d5/cbade46ce97c59c6c3da525e8d95b7abe8a42974a1dc5c1d489c10433e88/numpy-2.4.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0f01dcf33e73d80bd8dc0f20a71303abbafa26a19e23f6b68d1aa9990af90257", size = 18379680, upload-time = "2026-01-31T23:11:55.22Z" }, - { url = "https://files.pythonhosted.org/packages/40/62/48f99ae172a4b63d981babe683685030e8a3df4f246c893ea5c6ef99f018/numpy-2.4.2-cp313-cp313t-win32.whl", hash = "sha256:52b913ec40ff7ae845687b0b34d8d93b60cb66dcee06996dd5c99f2fc9328657", size = 6082433, upload-time = "2026-01-31T23:11:58.096Z" }, - { url = "https://files.pythonhosted.org/packages/07/38/e054a61cfe48ad9f1ed0d188e78b7e26859d0b60ef21cd9de4897cdb5326/numpy-2.4.2-cp313-cp313t-win_amd64.whl", hash = "sha256:5eea80d908b2c1f91486eb95b3fb6fab187e569ec9752ab7d9333d2e66bf2d6b", size = 12451181, upload-time = "2026-01-31T23:11:59.782Z" }, - { url = "https://files.pythonhosted.org/packages/6e/a4/a05c3a6418575e185dd84d0b9680b6bb2e2dc3e4202f036b7b4e22d6e9dc/numpy-2.4.2-cp313-cp313t-win_arm64.whl", hash = "sha256:fd49860271d52127d61197bb50b64f58454e9f578cb4b2c001a6de8b1f50b0b1", size = 10290756, upload-time = "2026-01-31T23:12:02.438Z" }, - { url = "https://files.pythonhosted.org/packages/18/88/b7df6050bf18fdcfb7046286c6535cabbdd2064a3440fca3f069d319c16e/numpy-2.4.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:444be170853f1f9d528428eceb55f12918e4fda5d8805480f36a002f1415e09b", size = 16663092, upload-time = "2026-01-31T23:12:04.521Z" }, - { url = "https://files.pythonhosted.org/packages/25/7a/1fee4329abc705a469a4afe6e69b1ef7e915117747886327104a8493a955/numpy-2.4.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d1240d50adff70c2a88217698ca844723068533f3f5c5fa6ee2e3220e3bdb000", size = 14698770, upload-time = "2026-01-31T23:12:06.96Z" }, - { url = "https://files.pythonhosted.org/packages/fb/0b/f9e49ba6c923678ad5bc38181c08ac5e53b7a5754dbca8e581aa1a56b1ff/numpy-2.4.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:7cdde6de52fb6664b00b056341265441192d1291c130e99183ec0d4b110ff8b1", size = 5208562, upload-time = "2026-01-31T23:12:09.632Z" }, - { url = "https://files.pythonhosted.org/packages/7d/12/d7de8f6f53f9bb76997e5e4c069eda2051e3fe134e9181671c4391677bb2/numpy-2.4.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:cda077c2e5b780200b6b3e09d0b42205a3d1c68f30c6dceb90401c13bff8fe74", size = 6543710, upload-time = "2026-01-31T23:12:11.969Z" }, - { url = "https://files.pythonhosted.org/packages/09/63/c66418c2e0268a31a4cf8a8b512685748200f8e8e8ec6c507ce14e773529/numpy-2.4.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d30291931c915b2ab5717c2974bb95ee891a1cf22ebc16a8006bd59cd210d40a", size = 15677205, upload-time = "2026-01-31T23:12:14.33Z" }, - { url = "https://files.pythonhosted.org/packages/5d/6c/7f237821c9642fb2a04d2f1e88b4295677144ca93285fd76eff3bcba858d/numpy-2.4.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bba37bc29d4d85761deed3954a1bc62be7cf462b9510b51d367b769a8c8df325", size = 16611738, upload-time = "2026-01-31T23:12:16.525Z" }, - { url = "https://files.pythonhosted.org/packages/c2/a7/39c4cdda9f019b609b5c473899d87abff092fc908cfe4d1ecb2fcff453b0/numpy-2.4.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b2f0073ed0868db1dcd86e052d37279eef185b9c8db5bf61f30f46adac63c909", size = 17028888, upload-time = "2026-01-31T23:12:19.306Z" }, - { url = "https://files.pythonhosted.org/packages/da/b3/e84bb64bdfea967cc10950d71090ec2d84b49bc691df0025dddb7c26e8e3/numpy-2.4.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7f54844851cdb630ceb623dcec4db3240d1ac13d4990532446761baede94996a", size = 18339556, upload-time = "2026-01-31T23:12:21.816Z" }, - { url = "https://files.pythonhosted.org/packages/88/f5/954a291bc1192a27081706862ac62bb5920fbecfbaa302f64682aa90beed/numpy-2.4.2-cp314-cp314-win32.whl", hash = "sha256:12e26134a0331d8dbd9351620f037ec470b7c75929cb8a1537f6bfe411152a1a", size = 6006899, upload-time = "2026-01-31T23:12:24.14Z" }, - { url = "https://files.pythonhosted.org/packages/05/cb/eff72a91b2efdd1bc98b3b8759f6a1654aa87612fc86e3d87d6fe4f948c4/numpy-2.4.2-cp314-cp314-win_amd64.whl", hash = "sha256:068cdb2d0d644cdb45670810894f6a0600797a69c05f1ac478e8d31670b8ee75", size = 12443072, upload-time = "2026-01-31T23:12:26.33Z" }, - { url = "https://files.pythonhosted.org/packages/37/75/62726948db36a56428fce4ba80a115716dc4fad6a3a4352487f8bb950966/numpy-2.4.2-cp314-cp314-win_arm64.whl", hash = "sha256:6ed0be1ee58eef41231a5c943d7d1375f093142702d5723ca2eb07db9b934b05", size = 10494886, upload-time = "2026-01-31T23:12:28.488Z" }, - { url = "https://files.pythonhosted.org/packages/36/2f/ee93744f1e0661dc267e4b21940870cabfae187c092e1433b77b09b50ac4/numpy-2.4.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:98f16a80e917003a12c0580f97b5f875853ebc33e2eaa4bccfc8201ac6869308", size = 14818567, upload-time = "2026-01-31T23:12:30.709Z" }, - { url = "https://files.pythonhosted.org/packages/a7/24/6535212add7d76ff938d8bdc654f53f88d35cddedf807a599e180dcb8e66/numpy-2.4.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:20abd069b9cda45874498b245c8015b18ace6de8546bf50dfa8cea1696ed06ef", size = 5328372, upload-time = "2026-01-31T23:12:32.962Z" }, - { url = "https://files.pythonhosted.org/packages/5e/9d/c48f0a035725f925634bf6b8994253b43f2047f6778a54147d7e213bc5a7/numpy-2.4.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:e98c97502435b53741540a5717a6749ac2ada901056c7db951d33e11c885cc7d", size = 6649306, upload-time = "2026-01-31T23:12:34.797Z" }, - { url = "https://files.pythonhosted.org/packages/81/05/7c73a9574cd4a53a25907bad38b59ac83919c0ddc8234ec157f344d57d9a/numpy-2.4.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da6cad4e82cb893db4b69105c604d805e0c3ce11501a55b5e9f9083b47d2ffe8", size = 15722394, upload-time = "2026-01-31T23:12:36.565Z" }, - { url = "https://files.pythonhosted.org/packages/35/fa/4de10089f21fc7d18442c4a767ab156b25c2a6eaf187c0db6d9ecdaeb43f/numpy-2.4.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e4424677ce4b47fe73c8b5556d876571f7c6945d264201180db2dc34f676ab5", size = 16653343, upload-time = "2026-01-31T23:12:39.188Z" }, - { url = "https://files.pythonhosted.org/packages/b8/f9/d33e4ffc857f3763a57aa85650f2e82486832d7492280ac21ba9efda80da/numpy-2.4.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2b8f157c8a6f20eb657e240f8985cc135598b2b46985c5bccbde7616dc9c6b1e", size = 17078045, upload-time = "2026-01-31T23:12:42.041Z" }, - { url = "https://files.pythonhosted.org/packages/c8/b8/54bdb43b6225badbea6389fa038c4ef868c44f5890f95dd530a218706da3/numpy-2.4.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5daf6f3914a733336dab21a05cdec343144600e964d2fcdabaac0c0269874b2a", size = 18380024, upload-time = "2026-01-31T23:12:44.331Z" }, - { url = "https://files.pythonhosted.org/packages/a5/55/6e1a61ded7af8df04016d81b5b02daa59f2ea9252ee0397cb9f631efe9e5/numpy-2.4.2-cp314-cp314t-win32.whl", hash = "sha256:8c50dd1fc8826f5b26a5ee4d77ca55d88a895f4e4819c7ecc2a9f5905047a443", size = 6153937, upload-time = "2026-01-31T23:12:47.229Z" }, - { url = "https://files.pythonhosted.org/packages/45/aa/fa6118d1ed6d776b0983f3ceac9b1a5558e80df9365b1c3aa6d42bf9eee4/numpy-2.4.2-cp314-cp314t-win_amd64.whl", hash = "sha256:fcf92bee92742edd401ba41135185866f7026c502617f422eb432cfeca4fe236", size = 12631844, upload-time = "2026-01-31T23:12:48.997Z" }, - { url = "https://files.pythonhosted.org/packages/32/0a/2ec5deea6dcd158f254a7b372fb09cfba5719419c8d66343bab35237b3fb/numpy-2.4.2-cp314-cp314t-win_arm64.whl", hash = "sha256:1f92f53998a17265194018d1cc321b2e96e900ca52d54c7c77837b71b9465181", size = 10565379, upload-time = "2026-01-31T23:12:51.345Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f8/50e14d36d915ef64d8f8bc4a087fc8264d82c785eda6711f80ab7e620335/numpy-2.4.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:89f7268c009bc492f506abd6f5265defa7cb3f7487dc21d357c3d290add45082", size = 16833179, upload-time = "2026-01-31T23:12:53.5Z" }, - { url = "https://files.pythonhosted.org/packages/17/17/809b5cad63812058a8189e91a1e2d55a5a18fd04611dbad244e8aeae465c/numpy-2.4.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:e6dee3bb76aa4009d5a912180bf5b2de012532998d094acee25d9cb8dee3e44a", size = 14889755, upload-time = "2026-01-31T23:12:55.933Z" }, - { url = "https://files.pythonhosted.org/packages/3e/ea/181b9bcf7627fc8371720316c24db888dcb9829b1c0270abf3d288b2e29b/numpy-2.4.2-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:cd2bd2bbed13e213d6b55dc1d035a4f91748a7d3edc9480c13898b0353708920", size = 5399500, upload-time = "2026-01-31T23:12:58.671Z" }, - { url = "https://files.pythonhosted.org/packages/33/9f/413adf3fc955541ff5536b78fcf0754680b3c6d95103230252a2c9408d23/numpy-2.4.2-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:cf28c0c1d4c4bf00f509fa7eb02c58d7caf221b50b467bcb0d9bbf1584d5c821", size = 6714252, upload-time = "2026-01-31T23:13:00.518Z" }, - { url = "https://files.pythonhosted.org/packages/91/da/643aad274e29ccbdf42ecd94dafe524b81c87bcb56b83872d54827f10543/numpy-2.4.2-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e04ae107ac591763a47398bb45b568fc38f02dbc4aa44c063f67a131f99346cb", size = 15797142, upload-time = "2026-01-31T23:13:02.219Z" }, - { url = "https://files.pythonhosted.org/packages/66/27/965b8525e9cb5dc16481b30a1b3c21e50c7ebf6e9dbd48d0c4d0d5089c7e/numpy-2.4.2-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:602f65afdef699cda27ec0b9224ae5dc43e328f4c24c689deaf77133dbee74d0", size = 16727979, upload-time = "2026-01-31T23:13:04.62Z" }, - { url = "https://files.pythonhosted.org/packages/de/e5/b7d20451657664b07986c2f6e3be564433f5dcaf3482d68eaecd79afaf03/numpy-2.4.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be71bf1edb48ebbbf7f6337b5bfd2f895d1902f6335a5830b20141fc126ffba0", size = 12502577, upload-time = "2026-01-31T23:13:07.08Z" }, ] [[package]] @@ -1016,13 +850,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/fa/7ac648108144a095b4fb6aa3de1954689f7af60a14cf25583f4960ecb878/pandas-2.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:602b8615ebcc4a0c1751e71840428ddebeb142ec02c786e8ad6b1ce3c8dec523", size = 11578790, upload-time = "2025-09-29T23:18:30.065Z" }, - { url = "https://files.pythonhosted.org/packages/9b/35/74442388c6cf008882d4d4bdfc4109be87e9b8b7ccd097ad1e7f006e2e95/pandas-2.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8fe25fc7b623b0ef6b5009149627e34d2a4657e880948ec3c840e9402e5c1b45", size = 10833831, upload-time = "2025-09-29T23:38:56.071Z" }, - { url = "https://files.pythonhosted.org/packages/fe/e4/de154cbfeee13383ad58d23017da99390b91d73f8c11856f2095e813201b/pandas-2.3.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b468d3dad6ff947df92dcb32ede5b7bd41a9b3cceef0a30ed925f6d01fb8fa66", size = 12199267, upload-time = "2025-09-29T23:18:41.627Z" }, - { url = "https://files.pythonhosted.org/packages/bf/c9/63f8d545568d9ab91476b1818b4741f521646cbdd151c6efebf40d6de6f7/pandas-2.3.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b98560e98cb334799c0b07ca7967ac361a47326e9b4e5a7dfb5ab2b1c9d35a1b", size = 12789281, upload-time = "2025-09-29T23:18:56.834Z" }, - { url = "https://files.pythonhosted.org/packages/f2/00/a5ac8c7a0e67fd1a6059e40aa08fa1c52cc00709077d2300e210c3ce0322/pandas-2.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37b5848ba49824e5c30bedb9c830ab9b7751fd049bc7914533e01c65f79791", size = 13240453, upload-time = "2025-09-29T23:19:09.247Z" }, - { url = "https://files.pythonhosted.org/packages/27/4d/5c23a5bc7bd209231618dd9e606ce076272c9bc4f12023a70e03a86b4067/pandas-2.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:db4301b2d1f926ae677a751eb2bd0e8c5f5319c9cb3f88b0becbbb0b07b34151", size = 13890361, upload-time = "2025-09-29T23:19:25.342Z" }, - { url = "https://files.pythonhosted.org/packages/8e/59/712db1d7040520de7a4965df15b774348980e6df45c129b8c64d0dbe74ef/pandas-2.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:f086f6fe114e19d92014a1966f43a3e62285109afe874f067f5abbdcbb10e59c", size = 11348702, upload-time = "2025-09-29T23:19:38.296Z" }, { url = "https://files.pythonhosted.org/packages/9c/fb/231d89e8637c808b997d172b18e9d4a4bc7bf31296196c260526055d1ea0/pandas-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d21f6d74eb1725c2efaa71a2bfc661a0689579b58e9c0ca58a739ff0b002b53", size = 11597846, upload-time = "2025-09-29T23:19:48.856Z" }, { url = "https://files.pythonhosted.org/packages/5c/bd/bf8064d9cfa214294356c2d6702b716d3cf3bb24be59287a6a21e24cae6b/pandas-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3fd2f887589c7aa868e02632612ba39acb0b8948faf5cc58f0850e165bd46f35", size = 10729618, upload-time = "2025-09-29T23:39:08.659Z" }, { url = "https://files.pythonhosted.org/packages/57/56/cf2dbe1a3f5271370669475ead12ce77c61726ffd19a35546e31aa8edf4e/pandas-2.3.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecaf1e12bdc03c86ad4a7ea848d66c685cb6851d807a26aa245ca3d2017a1908", size = 11737212, upload-time = "2025-09-29T23:19:59.765Z" }, @@ -1030,32 +857,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a6/de/8b1895b107277d52f2b42d3a6806e69cfef0d5cf1d0ba343470b9d8e0a04/pandas-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a68e15f780eddf2b07d242e17a04aa187a7ee12b40b930bfdd78070556550e98", size = 12771002, upload-time = "2025-09-29T23:20:26.76Z" }, { url = "https://files.pythonhosted.org/packages/87/21/84072af3187a677c5893b170ba2c8fbe450a6ff911234916da889b698220/pandas-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:371a4ab48e950033bcf52b6527eccb564f52dc826c02afd9a1bc0ab731bba084", size = 13450971, upload-time = "2025-09-29T23:20:41.344Z" }, { url = "https://files.pythonhosted.org/packages/86/41/585a168330ff063014880a80d744219dbf1dd7a1c706e75ab3425a987384/pandas-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:a16dcec078a01eeef8ee61bf64074b4e524a2a3f4b3be9326420cabe59c4778b", size = 10992722, upload-time = "2025-09-29T23:20:54.139Z" }, - { url = "https://files.pythonhosted.org/packages/cd/4b/18b035ee18f97c1040d94debd8f2e737000ad70ccc8f5513f4eefad75f4b/pandas-2.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:56851a737e3470de7fa88e6131f41281ed440d29a9268dcbf0002da5ac366713", size = 11544671, upload-time = "2025-09-29T23:21:05.024Z" }, - { url = "https://files.pythonhosted.org/packages/31/94/72fac03573102779920099bcac1c3b05975c2cb5f01eac609faf34bed1ca/pandas-2.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdcd9d1167f4885211e401b3036c0c8d9e274eee67ea8d0758a256d60704cfe8", size = 10680807, upload-time = "2025-09-29T23:21:15.979Z" }, - { url = "https://files.pythonhosted.org/packages/16/87/9472cf4a487d848476865321de18cc8c920b8cab98453ab79dbbc98db63a/pandas-2.3.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e32e7cc9af0f1cc15548288a51a3b681cc2a219faa838e995f7dc53dbab1062d", size = 11709872, upload-time = "2025-09-29T23:21:27.165Z" }, - { url = "https://files.pythonhosted.org/packages/15/07/284f757f63f8a8d69ed4472bfd85122bd086e637bf4ed09de572d575a693/pandas-2.3.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318d77e0e42a628c04dc56bcef4b40de67918f7041c2b061af1da41dcff670ac", size = 12306371, upload-time = "2025-09-29T23:21:40.532Z" }, - { url = "https://files.pythonhosted.org/packages/33/81/a3afc88fca4aa925804a27d2676d22dcd2031c2ebe08aabd0ae55b9ff282/pandas-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e0a175408804d566144e170d0476b15d78458795bb18f1304fb94160cabf40c", size = 12765333, upload-time = "2025-09-29T23:21:55.77Z" }, - { url = "https://files.pythonhosted.org/packages/8d/0f/b4d4ae743a83742f1153464cf1a8ecfafc3ac59722a0b5c8602310cb7158/pandas-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2d9ab0fc11822b5eece72ec9587e172f63cff87c00b062f6e37448ced4493", size = 13418120, upload-time = "2025-09-29T23:22:10.109Z" }, - { url = "https://files.pythonhosted.org/packages/4f/c7/e54682c96a895d0c808453269e0b5928a07a127a15704fedb643e9b0a4c8/pandas-2.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:f8bfc0e12dc78f777f323f55c58649591b2cd0c43534e8355c51d3fede5f4dee", size = 10993991, upload-time = "2025-09-29T23:25:04.889Z" }, - { url = "https://files.pythonhosted.org/packages/f9/ca/3f8d4f49740799189e1395812f3bf23b5e8fc7c190827d55a610da72ce55/pandas-2.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:75ea25f9529fdec2d2e93a42c523962261e567d250b0013b16210e1d40d7c2e5", size = 12048227, upload-time = "2025-09-29T23:22:24.343Z" }, - { url = "https://files.pythonhosted.org/packages/0e/5a/f43efec3e8c0cc92c4663ccad372dbdff72b60bdb56b2749f04aa1d07d7e/pandas-2.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74ecdf1d301e812db96a465a525952f4dde225fdb6d8e5a521d47e1f42041e21", size = 11411056, upload-time = "2025-09-29T23:22:37.762Z" }, - { url = "https://files.pythonhosted.org/packages/46/b1/85331edfc591208c9d1a63a06baa67b21d332e63b7a591a5ba42a10bb507/pandas-2.3.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6435cb949cb34ec11cc9860246ccb2fdc9ecd742c12d3304989017d53f039a78", size = 11645189, upload-time = "2025-09-29T23:22:51.688Z" }, - { url = "https://files.pythonhosted.org/packages/44/23/78d645adc35d94d1ac4f2a3c4112ab6f5b8999f4898b8cdf01252f8df4a9/pandas-2.3.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:900f47d8f20860de523a1ac881c4c36d65efcb2eb850e6948140fa781736e110", size = 12121912, upload-time = "2025-09-29T23:23:05.042Z" }, - { url = "https://files.pythonhosted.org/packages/53/da/d10013df5e6aaef6b425aa0c32e1fc1f3e431e4bcabd420517dceadce354/pandas-2.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a45c765238e2ed7d7c608fc5bc4a6f88b642f2f01e70c0c23d2224dd21829d86", size = 12712160, upload-time = "2025-09-29T23:23:28.57Z" }, - { url = "https://files.pythonhosted.org/packages/bd/17/e756653095a083d8a37cbd816cb87148debcfcd920129b25f99dd8d04271/pandas-2.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c4fc4c21971a1a9f4bdb4c73978c7f7256caa3e62b323f70d6cb80db583350bc", size = 13199233, upload-time = "2025-09-29T23:24:24.876Z" }, - { url = "https://files.pythonhosted.org/packages/04/fd/74903979833db8390b73b3a8a7d30d146d710bd32703724dd9083950386f/pandas-2.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ee15f284898e7b246df8087fc82b87b01686f98ee67d85a17b7ab44143a3a9a0", size = 11540635, upload-time = "2025-09-29T23:25:52.486Z" }, - { url = "https://files.pythonhosted.org/packages/21/00/266d6b357ad5e6d3ad55093a7e8efc7dd245f5a842b584db9f30b0f0a287/pandas-2.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1611aedd912e1ff81ff41c745822980c49ce4a7907537be8692c8dbc31924593", size = 10759079, upload-time = "2025-09-29T23:26:33.204Z" }, - { url = "https://files.pythonhosted.org/packages/ca/05/d01ef80a7a3a12b2f8bbf16daba1e17c98a2f039cbc8e2f77a2c5a63d382/pandas-2.3.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d2cefc361461662ac48810cb14365a365ce864afe85ef1f447ff5a1e99ea81c", size = 11814049, upload-time = "2025-09-29T23:27:15.384Z" }, - { url = "https://files.pythonhosted.org/packages/15/b2/0e62f78c0c5ba7e3d2c5945a82456f4fac76c480940f805e0b97fcbc2f65/pandas-2.3.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ee67acbbf05014ea6c763beb097e03cd629961c8a632075eeb34247120abcb4b", size = 12332638, upload-time = "2025-09-29T23:27:51.625Z" }, - { url = "https://files.pythonhosted.org/packages/c5/33/dd70400631b62b9b29c3c93d2feee1d0964dc2bae2e5ad7a6c73a7f25325/pandas-2.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c46467899aaa4da076d5abc11084634e2d197e9460643dd455ac3db5856b24d6", size = 12886834, upload-time = "2025-09-29T23:28:21.289Z" }, - { url = "https://files.pythonhosted.org/packages/d3/18/b5d48f55821228d0d2692b34fd5034bb185e854bdb592e9c640f6290e012/pandas-2.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6253c72c6a1d990a410bc7de641d34053364ef8bcd3126f7e7450125887dffe3", size = 13409925, upload-time = "2025-09-29T23:28:58.261Z" }, - { url = "https://files.pythonhosted.org/packages/a6/3d/124ac75fcd0ecc09b8fdccb0246ef65e35b012030defb0e0eba2cbbbe948/pandas-2.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:1b07204a219b3b7350abaae088f451860223a52cfb8a6c53358e7948735158e5", size = 11109071, upload-time = "2025-09-29T23:32:27.484Z" }, - { url = "https://files.pythonhosted.org/packages/89/9c/0e21c895c38a157e0faa1fb64587a9226d6dd46452cac4532d80c3c4a244/pandas-2.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2462b1a365b6109d275250baaae7b760fd25c726aaca0054649286bcfbb3e8ec", size = 12048504, upload-time = "2025-09-29T23:29:31.47Z" }, - { url = "https://files.pythonhosted.org/packages/d7/82/b69a1c95df796858777b68fbe6a81d37443a33319761d7c652ce77797475/pandas-2.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0242fe9a49aa8b4d78a4fa03acb397a58833ef6199e9aa40a95f027bb3a1b6e7", size = 11410702, upload-time = "2025-09-29T23:29:54.591Z" }, - { url = "https://files.pythonhosted.org/packages/f9/88/702bde3ba0a94b8c73a0181e05144b10f13f29ebfc2150c3a79062a8195d/pandas-2.3.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a21d830e78df0a515db2b3d2f5570610f5e6bd2e27749770e8bb7b524b89b450", size = 11634535, upload-time = "2025-09-29T23:30:21.003Z" }, - { url = "https://files.pythonhosted.org/packages/a4/1e/1bac1a839d12e6a82ec6cb40cda2edde64a2013a66963293696bbf31fbbb/pandas-2.3.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e3ebdb170b5ef78f19bfb71b0dc5dc58775032361fa188e814959b74d726dd5", size = 12121582, upload-time = "2025-09-29T23:30:43.391Z" }, - { url = "https://files.pythonhosted.org/packages/44/91/483de934193e12a3b1d6ae7c8645d083ff88dec75f46e827562f1e4b4da6/pandas-2.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d051c0e065b94b7a3cea50eb1ec32e912cd96dba41647eb24104b6c6c14c5788", size = 12699963, upload-time = "2025-09-29T23:31:10.009Z" }, - { url = "https://files.pythonhosted.org/packages/70/44/5191d2e4026f86a2a109053e194d3ba7a31a2d10a9c2348368c63ed4e85a/pandas-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3869faf4bd07b3b66a9f462417d0ca3a9df29a9f6abd5d0d0dbab15dac7abe87", size = 13202175, upload-time = "2025-09-29T23:31:59.173Z" }, ] [[package]] @@ -1180,6 +981,95 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ed/5f/94b4af305a4aa34842bb65282269bc9804125d0d22442d099fec07dd78b3/pulumi-3.224.0-py3-none-any.whl", hash = "sha256:2045987052c39740dcdb13d8845868106aebdc91792aa25ef82b06797ebf0a8e", size = 390575, upload-time = "2026-02-26T16:28:59.795Z" }, ] +[[package]] +name = "pulumi-aws" +version = "7.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parver" }, + { name = "pulumi" }, + { name = "semver" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/87/e61873b83bc055850c880665e684c1d795bb1eee992b6f2a92f1cc564167/pulumi_aws-7.20.0.tar.gz", hash = "sha256:58e066d415dd72821fa8eb2f65d81a43d782e4a89783f7643b4371afe071763c", size = 8774207, upload-time = "2026-02-19T14:58:40.329Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/82/8189bcbe911e29bec8b31c95d767dfea1eff60bf8b082c6d65dfa262192a/pulumi_aws-7.20.0-py3-none-any.whl", hash = "sha256:ec366ca00041abbda7f95a70fec34c6a0b12b09bee40154075914972ed4d410b", size = 11823592, upload-time = "2026-02-19T14:58:37.978Z" }, +] + +[[package]] +name = "pulumi-awsx" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parver" }, + { name = "pulumi" }, + { name = "pulumi-aws" }, + { name = "pulumi-docker" }, + { name = "pulumi-docker-build" }, + { name = "semver" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/95/defd3ebd1d0064286e6408b824f5e6d8ccf8fcf390ac2afe7b84707813c2/pulumi_awsx-3.2.1.tar.gz", hash = "sha256:ce9163f63ebfc9fabd7c8d18e6eec12cdaeb8af263955b5ae2b2353961d26a20", size = 144031, upload-time = "2026-02-25T15:53:11.771Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/24/9e82d3b2489580c761dd522c81259d4512af6d34404182dd917771432a5d/pulumi_awsx-3.2.1-py3-none-any.whl", hash = "sha256:be27a7d5d1d500a95d0bb77edf4522a2e43cad764769725c4ea85201a58e762c", size = 160857, upload-time = "2026-02-25T15:53:10.543Z" }, +] + +[[package]] +name = "pulumi-azure-native" +version = "3.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parver" }, + { name = "pulumi" }, + { name = "semver" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b2/0d/1bf55cfa908a3453e9084578553b584a33907f46442971c8f5f3c103d0f8/pulumi_azure_native-3.14.0.tar.gz", hash = "sha256:76dda0d2eefcde2c668310dc0f3964fe28f361903b09c73d53eccb651a5c5055", size = 12580137, upload-time = "2026-02-26T22:18:10.075Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/2d/803c84a93563478ace06ab12f2f43902d583998cb3ab7d1b2f6ede413646/pulumi_azure_native-3.14.0-py3-none-any.whl", hash = "sha256:25ea3cf0a63b6d373a135518804cb343e4383836e21b5667f1143025c61592fa", size = 21156827, upload-time = "2026-02-26T22:18:02.436Z" }, +] + +[[package]] +name = "pulumi-docker" +version = "4.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parver" }, + { name = "pulumi" }, + { name = "semver" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/00/a3f80e12ce4ab4dece01195e631a854f8910b533787b71d4ecd7669ae70f/pulumi_docker-4.11.0.tar.gz", hash = "sha256:b18fc208531ce2a64d2a898f69c11ff2b792078abde1d9ac3a4625c564467a50", size = 115726, upload-time = "2025-12-24T13:12:14.62Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/d7/3c3cd6b6ec53d1b6914b83ed461da8e86e82208675da43e8cd0a8e45a2c7/pulumi_docker-4.11.0-py3-none-any.whl", hash = "sha256:b421fdee90257ffd65a091b65638d00b8b6827887ccbb62772148d0d563d13d5", size = 137300, upload-time = "2025-12-24T13:12:12.873Z" }, +] + +[[package]] +name = "pulumi-docker-build" +version = "0.0.15" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parver" }, + { name = "pulumi" }, + { name = "semver" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/31/9b9e9fe7c4fea6f0f5fcfa37110888b9c772cfb15ef1740ba6338a936378/pulumi_docker_build-0.0.15.tar.gz", hash = "sha256:a6301d9c1bfc749a3bce37280ef60c3f9a3dc767f89b62e99edf24ec46de0a6e", size = 38723, upload-time = "2025-10-17T11:03:54.948Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/2a/cbbd2bf07f2082e6187d52c463166a522f1960aaf7d34bf59a57e7a51327/pulumi_docker_build-0.0.15-py3-none-any.whl", hash = "sha256:6121e09e6ccdbc8c60695631e7f3d7da22a6188a9ccba16ec3b37dc449d6c45e", size = 43384, upload-time = "2025-10-17T11:03:52.891Z" }, +] + +[[package]] +name = "pulumi-eks" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parver" }, + { name = "pulumi" }, + { name = "pulumi-aws" }, + { name = "pulumi-kubernetes" }, + { name = "semver" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b0/29/f9b093f9f67a9b1522ca5779a6dfc9df3c8a5f9725d14a80b0e792a0e03d/pulumi_eks-4.2.0.tar.gz", hash = "sha256:720d46bb938aeea641adecdc1c538d5b5cc9554f1a98bbf1a61ac533f5494f28", size = 90930, upload-time = "2025-12-19T18:31:48.198Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/8c/75ce4c6cfcc78b84f9ec1495f06f381fd4566db33a6a33890c98dd8a9cc9/pulumi_eks-4.2.0-py3-none-any.whl", hash = "sha256:eedeb22f7cffc1b14aa393a366cf181e73a19272a16a2449ad14905d798a181f", size = 100251, upload-time = "2025-12-19T18:31:45.688Z" }, +] + [[package]] name = "pulumi-gcp" version = "9.13.0" @@ -1194,6 +1084,46 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dd/c7/ec4b53711a85dfefd831fe7de5d6f0c767ecb097b85cde0c14243e08e1fb/pulumi_gcp-9.13.0-py3-none-any.whl", hash = "sha256:e4ce7b1974f0746dead1a2d22bd2f4236021ef10731660aa25387efbce0efe62", size = 11734179, upload-time = "2026-02-25T14:26:07.742Z" }, ] +[[package]] +name = "pulumi-kubernetes" +version = "4.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parver" }, + { name = "pulumi" }, + { name = "requests" }, + { name = "semver" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/52/f66c95de3b5f1f7914e043fbff88daa895ad45f14dda109d6cb00ec0f6f9/pulumi_kubernetes-4.27.0.tar.gz", hash = "sha256:c688720de0344f6dc7d26116baf9f2d0218caeb951f8317679c338092a53737e", size = 1912110, upload-time = "2026-03-02T19:30:43.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/94/675020e4145574cb11ebe949211936e49e1b6f96409574e227c0fe733ce1/pulumi_kubernetes-4.27.0-py3-none-any.whl", hash = "sha256:a0bd96d03283f823c7138acd2bca72d5c96485418c0ec17eaf1f61872f053767", size = 2993780, upload-time = "2026-03-02T19:30:41.419Z" }, +] + +[[package]] +name = "pulumi-random" +version = "4.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parver" }, + { name = "pulumi" }, + { name = "semver" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d7/14/98757312e8fbbb576e27a318ed50136c571268c9076f84db878d4cf4ed48/pulumi_random-4.19.1.tar.gz", hash = "sha256:c19c38ac78ba586fc4a96704c9e0b51edb0e52b290d8a42edd11ccea908fae7d", size = 22397, upload-time = "2026-01-28T04:09:22.358Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/5d/ea5b5312e61f2224d6cd5c6da9a5f1f9f7a50a9d8798e152233437ebc9f0/pulumi_random-4.19.1-py3-none-any.whl", hash = "sha256:20a7b078919e1e324dd6ffc58c3dbc0bec556fb0e852fc44e79371435b743ce3", size = 35761, upload-time = "2026-01-28T04:09:21.378Z" }, +] + +[[package]] +name = "pulumi-utils" +version = "0.1.0" +source = { editable = "tools/python/pulumi-utils" } +dependencies = [ + { name = "pulumi" }, +] + +[package.metadata] +requires-dist = [{ name = "pulumi", specifier = ">=3.202.0,<4.0.0" }] + [[package]] name = "pyasn1" version = "0.6.2" @@ -1248,20 +1178,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, - { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, - { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, - { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, - { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, - { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, - { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, - { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, - { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, - { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, - { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, - { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, - { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, - { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, @@ -1276,64 +1192,10 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, - { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, - { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, - { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, - { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, - { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, - { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, - { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, - { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, - { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, - { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, - { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, - { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, - { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, - { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, - { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, - { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, - { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, - { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, - { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, - { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, - { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, - { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, - { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, - { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, - { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, - { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, - { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, - { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, - { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, - { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, - { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, - { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, - { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, - { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, - { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, - { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, - { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, - { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, - { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, - { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, - { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, - { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, - { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, - { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, - { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, - { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, - { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, - { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, - { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, - { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, - { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, - { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, ] [[package]] @@ -1402,18 +1264,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/d9/9a/4019b524b03a13438637b11538c82781a5eda427394380381af8f04f467a/pynacl-1.6.2.tar.gz", hash = "sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c", size = 3511692, upload-time = "2026-01-01T17:48:10.851Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4b/79/0e3c34dc3c4671f67d251c07aa8eb100916f250ee470df230b0ab89551b4/pynacl-1.6.2-cp314-cp314t-macosx_10_10_universal2.whl", hash = "sha256:622d7b07cc5c02c666795792931b50c91f3ce3c2649762efb1ef0d5684c81594", size = 390064, upload-time = "2026-01-01T17:31:57.264Z" }, - { url = "https://files.pythonhosted.org/packages/eb/1c/23a26e931736e13b16483795c8a6b2f641bf6a3d5238c22b070a5112722c/pynacl-1.6.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d071c6a9a4c94d79eb665db4ce5cedc537faf74f2355e4d502591d850d3913c0", size = 809370, upload-time = "2026-01-01T17:31:59.198Z" }, - { url = "https://files.pythonhosted.org/packages/87/74/8d4b718f8a22aea9e8dcc8b95deb76d4aae380e2f5b570cc70b5fd0a852d/pynacl-1.6.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe9847ca47d287af41e82be1dd5e23023d3c31a951da134121ab02e42ac218c9", size = 1408304, upload-time = "2026-01-01T17:32:01.162Z" }, - { url = "https://files.pythonhosted.org/packages/fd/73/be4fdd3a6a87fe8a4553380c2b47fbd1f7f58292eb820902f5c8ac7de7b0/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04316d1fc625d860b6c162fff704eb8426b1a8bcd3abacea11142cbd99a6b574", size = 844871, upload-time = "2026-01-01T17:32:02.824Z" }, - { url = "https://files.pythonhosted.org/packages/55/ad/6efc57ab75ee4422e96b5f2697d51bbcf6cdcc091e66310df91fbdc144a8/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44081faff368d6c5553ccf55322ef2819abb40e25afaec7e740f159f74813634", size = 1446356, upload-time = "2026-01-01T17:32:04.452Z" }, - { url = "https://files.pythonhosted.org/packages/78/b7/928ee9c4779caa0a915844311ab9fb5f99585621c5d6e4574538a17dca07/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:a9f9932d8d2811ce1a8ffa79dcbdf3970e7355b5c8eb0c1a881a57e7f7d96e88", size = 826814, upload-time = "2026-01-01T17:32:06.078Z" }, - { url = "https://files.pythonhosted.org/packages/f7/a9/1bdba746a2be20f8809fee75c10e3159d75864ef69c6b0dd168fc60e485d/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:bc4a36b28dd72fb4845e5d8f9760610588a96d5a51f01d84d8c6ff9849968c14", size = 1411742, upload-time = "2026-01-01T17:32:07.651Z" }, - { url = "https://files.pythonhosted.org/packages/f3/2f/5e7ea8d85f9f3ea5b6b87db1d8388daa3587eed181bdeb0306816fdbbe79/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bffb6d0f6becacb6526f8f42adfb5efb26337056ee0831fb9a7044d1a964444", size = 801714, upload-time = "2026-01-01T17:32:09.558Z" }, - { url = "https://files.pythonhosted.org/packages/06/ea/43fe2f7eab5f200e40fb10d305bf6f87ea31b3bbc83443eac37cd34a9e1e/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fef529ef3ee487ad8113d287a593fa26f48ee3620d92ecc6f1d09ea38e0709b", size = 1372257, upload-time = "2026-01-01T17:32:11.026Z" }, - { url = "https://files.pythonhosted.org/packages/4d/54/c9ea116412788629b1347e415f72195c25eb2f3809b2d3e7b25f5c79f13a/pynacl-1.6.2-cp314-cp314t-win32.whl", hash = "sha256:a84bf1c20339d06dc0c85d9aea9637a24f718f375d861b2668b2f9f96fa51145", size = 231319, upload-time = "2026-01-01T17:32:12.46Z" }, - { url = "https://files.pythonhosted.org/packages/ce/04/64e9d76646abac2dccf904fccba352a86e7d172647557f35b9fe2a5ee4a1/pynacl-1.6.2-cp314-cp314t-win_amd64.whl", hash = "sha256:320ef68a41c87547c91a8b58903c9caa641ab01e8512ce291085b5fe2fcb7590", size = 244044, upload-time = "2026-01-01T17:32:13.781Z" }, - { url = "https://files.pythonhosted.org/packages/33/33/7873dc161c6a06f43cda13dec67b6fe152cb2f982581151956fa5e5cdb47/pynacl-1.6.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d29bfe37e20e015a7d8b23cfc8bd6aa7909c92a1b8f41ee416bbb3e79ef182b2", size = 188740, upload-time = "2026-01-01T17:32:15.083Z" }, { url = "https://files.pythonhosted.org/packages/be/7b/4845bbf88e94586ec47a432da4e9107e3fc3ce37eb412b1398630a37f7dd/pynacl-1.6.2-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:c949ea47e4206af7c8f604b8278093b674f7c79ed0d4719cc836902bf4517465", size = 388458, upload-time = "2026-01-01T17:32:16.829Z" }, { url = "https://files.pythonhosted.org/packages/1e/b4/e927e0653ba63b02a4ca5b4d852a8d1d678afbf69b3dbf9c4d0785ac905c/pynacl-1.6.2-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8845c0631c0be43abdd865511c41eab235e0be69c81dc66a50911594198679b0", size = 800020, upload-time = "2026-01-01T17:32:18.34Z" }, { url = "https://files.pythonhosted.org/packages/7f/81/d60984052df5c97b1d24365bc1e30024379b42c4edcd79d2436b1b9806f2/pynacl-1.6.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22de65bb9010a725b0dac248f353bb072969c94fa8d6b1f34b87d7953cf7bbe4", size = 1399174, upload-time = "2026-01-01T17:32:20.239Z" }, @@ -1493,31 +1343,11 @@ version = "0.4.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/92/790ebe03f07b57e53b10884c329b9a1a308648fc083a6d4a39a10a28c8fc/pytokens-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d70e77c55ae8380c91c0c18dea05951482e263982911fc7410b1ffd1dadd3440", size = 160864, upload-time = "2026-01-30T01:02:57.882Z" }, - { url = "https://files.pythonhosted.org/packages/13/25/a4f555281d975bfdd1eba731450e2fe3a95870274da73fb12c40aeae7625/pytokens-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a58d057208cb9075c144950d789511220b07636dd2e4708d5645d24de666bdc", size = 248565, upload-time = "2026-01-30T01:02:59.912Z" }, - { url = "https://files.pythonhosted.org/packages/17/50/bc0394b4ad5b1601be22fa43652173d47e4c9efbf0044c62e9a59b747c56/pytokens-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b49750419d300e2b5a3813cf229d4e5a4c728dae470bcc89867a9ad6f25a722d", size = 260824, upload-time = "2026-01-30T01:03:01.471Z" }, - { url = "https://files.pythonhosted.org/packages/4e/54/3e04f9d92a4be4fc6c80016bc396b923d2a6933ae94b5f557c939c460ee0/pytokens-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d9907d61f15bf7261d7e775bd5d7ee4d2930e04424bab1972591918497623a16", size = 264075, upload-time = "2026-01-30T01:03:04.143Z" }, - { url = "https://files.pythonhosted.org/packages/d1/1b/44b0326cb5470a4375f37988aea5d61b5cc52407143303015ebee94abfd6/pytokens-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:ee44d0f85b803321710f9239f335aafe16553b39106384cef8e6de40cb4ef2f6", size = 103323, upload-time = "2026-01-30T01:03:05.412Z" }, { url = "https://files.pythonhosted.org/packages/41/5d/e44573011401fb82e9d51e97f1290ceb377800fb4eed650b96f4753b499c/pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083", size = 160663, upload-time = "2026-01-30T01:03:06.473Z" }, { url = "https://files.pythonhosted.org/packages/f0/e6/5bbc3019f8e6f21d09c41f8b8654536117e5e211a85d89212d59cbdab381/pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1", size = 255626, upload-time = "2026-01-30T01:03:08.177Z" }, { url = "https://files.pythonhosted.org/packages/bf/3c/2d5297d82286f6f3d92770289fd439956b201c0a4fc7e72efb9b2293758e/pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1", size = 269779, upload-time = "2026-01-30T01:03:09.756Z" }, { url = "https://files.pythonhosted.org/packages/20/01/7436e9ad693cebda0551203e0bf28f7669976c60ad07d6402098208476de/pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9", size = 268076, upload-time = "2026-01-30T01:03:10.957Z" }, { url = "https://files.pythonhosted.org/packages/2e/df/533c82a3c752ba13ae7ef238b7f8cdd272cf1475f03c63ac6cf3fcfb00b6/pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68", size = 103552, upload-time = "2026-01-30T01:03:12.066Z" }, - { url = "https://files.pythonhosted.org/packages/cb/dc/08b1a080372afda3cceb4f3c0a7ba2bde9d6a5241f1edb02a22a019ee147/pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b", size = 160720, upload-time = "2026-01-30T01:03:13.843Z" }, - { url = "https://files.pythonhosted.org/packages/64/0c/41ea22205da480837a700e395507e6a24425151dfb7ead73343d6e2d7ffe/pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f", size = 254204, upload-time = "2026-01-30T01:03:14.886Z" }, - { url = "https://files.pythonhosted.org/packages/e0/d2/afe5c7f8607018beb99971489dbb846508f1b8f351fcefc225fcf4b2adc0/pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1", size = 268423, upload-time = "2026-01-30T01:03:15.936Z" }, - { url = "https://files.pythonhosted.org/packages/68/d4/00ffdbd370410c04e9591da9220a68dc1693ef7499173eb3e30d06e05ed1/pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4", size = 266859, upload-time = "2026-01-30T01:03:17.458Z" }, - { url = "https://files.pythonhosted.org/packages/a7/c9/c3161313b4ca0c601eeefabd3d3b576edaa9afdefd32da97210700e47652/pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78", size = 103520, upload-time = "2026-01-30T01:03:18.652Z" }, - { url = "https://files.pythonhosted.org/packages/8f/a7/b470f672e6fc5fee0a01d9e75005a0e617e162381974213a945fcd274843/pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321", size = 160821, upload-time = "2026-01-30T01:03:19.684Z" }, - { url = "https://files.pythonhosted.org/packages/80/98/e83a36fe8d170c911f864bfded690d2542bfcfacb9c649d11a9e6eb9dc41/pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa", size = 254263, upload-time = "2026-01-30T01:03:20.834Z" }, - { url = "https://files.pythonhosted.org/packages/0f/95/70d7041273890f9f97a24234c00b746e8da86df462620194cef1d411ddeb/pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d", size = 268071, upload-time = "2026-01-30T01:03:21.888Z" }, - { url = "https://files.pythonhosted.org/packages/da/79/76e6d09ae19c99404656d7db9c35dfd20f2086f3eb6ecb496b5b31163bad/pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324", size = 271716, upload-time = "2026-01-30T01:03:23.633Z" }, - { url = "https://files.pythonhosted.org/packages/79/37/482e55fa1602e0a7ff012661d8c946bafdc05e480ea5a32f4f7e336d4aa9/pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9", size = 104539, upload-time = "2026-01-30T01:03:24.788Z" }, - { url = "https://files.pythonhosted.org/packages/30/e8/20e7db907c23f3d63b0be3b8a4fd1927f6da2395f5bcc7f72242bb963dfe/pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb", size = 168474, upload-time = "2026-01-30T01:03:26.428Z" }, - { url = "https://files.pythonhosted.org/packages/d6/81/88a95ee9fafdd8f5f3452107748fd04c24930d500b9aba9738f3ade642cc/pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3", size = 290473, upload-time = "2026-01-30T01:03:27.415Z" }, - { url = "https://files.pythonhosted.org/packages/cf/35/3aa899645e29b6375b4aed9f8d21df219e7c958c4c186b465e42ee0a06bf/pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975", size = 303485, upload-time = "2026-01-30T01:03:28.558Z" }, - { url = "https://files.pythonhosted.org/packages/52/a0/07907b6ff512674d9b201859f7d212298c44933633c946703a20c25e9d81/pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a", size = 306698, upload-time = "2026-01-30T01:03:29.653Z" }, - { url = "https://files.pythonhosted.org/packages/39/2a/cbbf9250020a4a8dd53ba83a46c097b69e5eb49dd14e708f496f548c6612/pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918", size = 116287, upload-time = "2026-01-30T01:03:30.912Z" }, { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" }, ] @@ -1536,15 +1366,6 @@ 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/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" }, @@ -1555,34 +1376,6 @@ wheels = [ { 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]] @@ -1765,15 +1558,6 @@ version = "16.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, - { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, - { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, - { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, - { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, - { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, - { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, - { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, - { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, @@ -1783,37 +1567,5 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, - { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, - { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, - { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, - { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, - { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, - { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, - { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, - { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, - { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, - { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, - { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, - { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, - { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, - { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, - { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, - { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, - { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, - { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, - { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, - { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, - { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, - { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, - { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, - { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, - { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, - { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, - { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, - { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, - { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, - { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, - { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, - { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, ]