Initial NocoBase CRM MCP server

This commit is contained in:
nac
2026-07-08 21:51:08 +00:00
commit f84be83245
20 changed files with 4282 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
# CodeGraph data files
# These are local to each machine and should not be committed
# Database
*.db
*.db-wal
*.db-shm
# Cache
cache/
# Logs
*.log
# Hook markers
.dirty
+21
View File
@@ -0,0 +1,21 @@
# Secrets / local runtime config
.env
config.json
.runtime/
# Python / build artifacts
__pycache__/
*.py[cod]
*$py.class
.pytest_cache/
.ruff_cache/
.mypy_cache/
.coverage
htmlcov/
build/
dist/
*.egg-info/
# Local environments
.venv/
venv/
+128
View File
@@ -0,0 +1,128 @@
# NocoBase CRM MCP
Read-only MCP server for remote agents that need customer, contact, follow-up, and CRM overview data from NocoBase.
The server calls the official `nb api resource list|get|query` commands. It does not expose create, update, destroy, or arbitrary command execution tools.
## Configure
This repository already includes a local `config.json` generated for the current CRM. To regenerate it from the example:
```bash
cp config.example.json config.json
```
The known CRM collection names in this environment are:
- customers: `t_nt3pjadelqi` (`客户`)
- contacts: `t_j77ftx1rf84` (`客户联系人`)
- followups: `t_07667a5evtr` (`跟进记录`)
The checked-in example config uses the confirmed mapping:
- customer owner: `kehu_followup_person` (`跟进人`)
- customer status/progress: `kh_progress` (`进度`)
- customer source: `f_225h6n6v654` (`客户来源`)
- contact-to-customer relation: `kehulianxiren_client`
- follow-up-to-customer relation: `gjjl_client`
- follow-up-to-contact relation: `gjjl_client_contacts`
- follow-up owner fields: `gjjl_followup_person` and `createdBy`
- follow-up date/content: `f_srysmjkwfq1` and `f_19iplxz0nds`
The default NocoBase CLI env is `nb-mcp`. Override it with:
```bash
export NOCOBASE_MCP_ENV=nb-mcp
export NOCOBASE_MCP_CONFIG=/home/nac/repo/nocobase-mcp/config.json
```
## Local stdio MCP
```bash
uv run nocobase-crm-mcp --config config.json
```
Exposed tools:
- `crm_health`
- `crm_schema`
- `crm_overview`
- `crm_filter_options`
- `resolve_owner`
- `list_customers`
- `get_customer`
- `list_contacts`
- `list_followups`
`crm_config_template` is hidden by default because it needs NocoBase data-modeling metadata permissions. Enable it only during configuration work:
```bash
NOCOBASE_MCP_ENABLE_CONFIG_TOOLS=1 uv run nocobase-crm-mcp --config config.json
```
The `owner` parameter on `crm_overview`, `list_customers`, and `list_followups` accepts a user's username, nickname, email, or English name. The server resolves that value through the `users` resource, then filters CRM relation fields by user id. If the value is missing or ambiguous, the tool returns a structured `error` object with candidate users instead of throwing an MCP error. Remote agents should call `resolve_owner` first when they are unsure which user the requester means.
Use `crm_filter_options` to discover status/source values and owner candidates before constructing filtered queries. List tools clamp `limit` to `1..100` and normalize negative offsets to `0`.
## Permissions
Runtime tools are read-only, but the role behind `nb-mcp` needs these read permissions:
- CRM collections: `t_nt3pjadelqi`, `t_j77ftx1rf84`, `t_07667a5evtr`
- user lookup collection: `users`, at least `id`, `username`, `nickname`, `email`, `f_yh_english_name`
`crm_schema(include_fields=true)` and the opt-in `crm_config_template` are configuration helpers. They use this read-only NocoBase data-modeling command:
```bash
nb api data-modeling collections fields list --collection-name <collection> --env nb-mcp --yes
```
Use a super-admin or configuration-capable role for this one-time discovery step. The remote Agent does not need data-modeling access after `config.json` is generated.
Do not grant write permissions such as collection apply/destroy, fields apply, or business data create/update/destroy for this MCP.
## Remote HTTP MCP
HTTP mode uses streamable HTTP with a separate bearer token for MCP access:
```bash
export NOCOBASE_MCP_TOKEN='<long-random-token>'
uv run nocobase-crm-mcp --transport streamable-http --config config.json
```
Default bind is `0.0.0.0:10087` with path `/mcp`.
Remote clients must send:
```http
Authorization: Bearer <long-random-token>
```
For a managed user-level systemd service:
```bash
bash scripts/mcp_service.sh start
```
The first `start` creates `.runtime/nocobase-crm-mcp.env` with a random bearer token automatically. Use `new-token` only when rotating the token later.
Show the saved URL/header/client config:
```bash
bash scripts/mcp_service.sh show-config
```
Check service state, saved paths, URL, and local HTTP probe:
```bash
bash scripts/mcp_service.sh status
```
Stop or uninstall:
```bash
bash scripts/mcp_service.sh stop
bash scripts/mcp_service.sh uninstall
```
The systemd unit references `.runtime/nocobase-crm-mcp.env`; it does not embed the bearer token. The env file is written with `0600` permissions and `.runtime/` is ignored.
+112
View File
@@ -0,0 +1,112 @@
{
"env": "nb-mcp",
"dataSource": "main",
"users": {
"resource": "users",
"fields": ["id", "username", "nickname", "email", "f_yh_english_name"],
"matchFields": ["username", "nickname", "email", "f_yh_english_name"]
},
"collections": {
"customers": {
"resource": "t_nt3pjadelqi",
"fields": [
"f_kyf51uh0ky7",
"f_urr1u4dw9jb",
"f_opwl6asmjfr",
"f_territorial_name",
"id",
"f_225h6n6v654",
"f_spgoie0arjq",
"f_iocbiswmj0w",
"f_6poubouiyav",
"createdBy",
"kh_followup_recrod",
"f_icnu7fer21g",
"f_ybwmst59acr",
"f_kh_projects",
"kh_progress",
"f_nkgemi2z0jq",
"kehu_lookup_people",
"f_8ny30oijdia",
"updatedBy",
"f_ef0jz62x69p",
"kehu_followup_person",
"f_8aeiunkk9iu",
"updatedAt",
"f_7jd0dccle4d",
"f_kh_english_name",
"f_epunagf8fim",
"f_03vk8tul04x",
"f_vrmce31l4tm",
"kehu_manage_person",
"f_xuodi4wytid",
"f_q2q60q7bqbg",
"createdAt",
"f_l014k58id1f",
"kehu_contacts",
"f_y8iyf2jhana",
"kh_tags"
],
"searchFields": ["f_q2q60q7bqbg", "f_kh_english_name", "f_territorial_name"],
"statusField": "kh_progress",
"sourceField": "f_225h6n6v654",
"ownerField": "kehu_followup_person",
"sort": ["-updatedAt"]
},
"contacts": {
"resource": "t_j77ftx1rf84",
"fields": [
"f_8ra9vxjwsz3",
"f_6mm7pz2lely",
"khlxr_address",
"updatedBy",
"f_2zpg91gtmwp",
"createdAt",
"khlxr_email",
"f_wv58431wiuu",
"kehulianxiren_client",
"f_relationship",
"f_tn8zdm2f938",
"khlxr_phone_number",
"updatedAt",
"khlxr_followup_record",
"id",
"createdBy",
"f_k81ghephjvp",
"f_xmlxr_projects"
],
"searchFields": ["f_2zpg91gtmwp", "khlxr_phone_number", "khlxr_email"],
"customerField": "kehulianxiren_client",
"sort": ["-updatedAt"]
},
"followups": {
"resource": "t_07667a5evtr",
"fields": [
"gjjl_client_contacts",
"createdAt",
"f_gjjl_attachment",
"gjjl_followup_person",
"createdBy",
"gjjl_client",
"f_srysmjkwfq1",
"f_z96kfc5yhx8",
"gjjl_followup_type",
"f_19iplxz0nds",
"updatedBy",
"id",
"updatedAt",
"f_gjjl_projects",
"f_b5c4hfi5og6",
"f_hmsffov90ty",
"f_hskl7gjvos9"
],
"searchFields": ["f_19iplxz0nds"],
"customerField": "gjjl_client",
"contactField": "gjjl_client_contacts",
"ownerField": "gjjl_followup_person",
"ownerFields": ["gjjl_followup_person", "createdBy"],
"dateField": "f_srysmjkwfq1",
"sort": ["-f_srysmjkwfq1", "-createdAt"]
}
}
}
+3
View File
@@ -0,0 +1,3 @@
"""Read-only MCP access to a NocoBase CRM."""
__version__ = "0.1.0"
+231
View File
@@ -0,0 +1,231 @@
from __future__ import annotations
import json
import os
from dataclasses import dataclass, replace
from pathlib import Path
from typing import Any
@dataclass(frozen=True)
class CustomerConfig:
resource: str = "customers"
id_field: str = "id"
fields: list[str] | None = None
search_fields: list[str] | None = None
status_field: str = "status"
source_field: str = "source"
owner_field: str = "owner"
sort: list[str] | None = None
def __post_init__(self) -> None:
if self.fields is None:
object.__setattr__(
self,
"fields",
["id", "name", "code", "status", "source", "owner", "createdAt", "updatedAt"],
)
if self.search_fields is None:
object.__setattr__(self, "search_fields", ["name", "code"])
if self.sort is None:
object.__setattr__(self, "sort", ["-updatedAt"])
@dataclass(frozen=True)
class ContactConfig:
resource: str = "contacts"
id_field: str = "id"
fields: list[str] | None = None
search_fields: list[str] | None = None
customer_field: str = "customer"
sort: list[str] | None = None
def __post_init__(self) -> None:
if self.fields is None:
object.__setattr__(
self,
"fields",
["id", "name", "phone", "email", "title", "customer", "createdAt", "updatedAt"],
)
if self.search_fields is None:
object.__setattr__(self, "search_fields", ["name", "phone", "email"])
if self.sort is None:
object.__setattr__(self, "sort", ["-updatedAt"])
@dataclass(frozen=True)
class FollowupConfig:
resource: str = "followups"
id_field: str = "id"
fields: list[str] | None = None
search_fields: list[str] | None = None
customer_field: str = "customer"
contact_field: str = "contact"
owner_field: str = "owner"
owner_fields: list[str] | None = None
date_field: str = "followedAt"
sort: list[str] | None = None
def __post_init__(self) -> None:
if self.owner_fields is None:
object.__setattr__(self, "owner_fields", [self.owner_field] if self.owner_field else [])
if self.fields is None:
object.__setattr__(
self,
"fields",
[
"id",
"customer",
"contact",
"owner",
"content",
"followedAt",
"nextFollowAt",
"createdAt",
"updatedAt",
],
)
if self.search_fields is None:
object.__setattr__(self, "search_fields", ["content"])
if self.sort is None:
object.__setattr__(self, "sort", ["-followedAt", "-createdAt"])
@dataclass(frozen=True)
class UserConfig:
resource: str = "users"
id_field: str = "id"
fields: list[str] | None = None
match_fields: list[str] | None = None
def __post_init__(self) -> None:
if self.fields is None:
object.__setattr__(
self,
"fields",
["id", "username", "nickname", "email", "f_yh_english_name"],
)
if self.match_fields is None:
object.__setattr__(self, "match_fields", ["username", "nickname", "email", "f_yh_english_name"])
@dataclass(frozen=True)
class CrmConfig:
env_name: str = "nb-mcp"
data_source: str = "main"
customer: CustomerConfig = CustomerConfig()
contact: ContactConfig = ContactConfig()
followup: FollowupConfig = FollowupConfig()
user: UserConfig = UserConfig()
def _load_json(path: Path) -> dict[str, Any]:
with path.open("r", encoding="utf-8") as handle:
data = json.load(handle)
if not isinstance(data, dict):
raise ValueError(f"CRM config must be a JSON object: {path}")
return data
def _string_list(value: Any) -> list[str] | None:
if value is None:
return None
if not isinstance(value, list):
raise ValueError("Expected a JSON array of strings")
return [str(item) for item in value]
def _pick(data: dict[str, Any], *names: str) -> Any:
for name in names:
if name in data:
return data[name]
return None
def _customer_config(data: dict[str, Any] | None) -> CustomerConfig:
if not data:
return CustomerConfig()
base = CustomerConfig()
return replace(
base,
resource=str(_pick(data, "resource") or base.resource),
id_field=str(_pick(data, "idField", "id_field") or base.id_field),
fields=_string_list(_pick(data, "fields")) or base.fields,
search_fields=_string_list(_pick(data, "searchFields", "search_fields")) or base.search_fields,
status_field=str(_pick(data, "statusField", "status_field") or base.status_field),
source_field=str(_pick(data, "sourceField", "source_field") or base.source_field),
owner_field=str(_pick(data, "ownerField", "owner_field") or base.owner_field),
sort=_string_list(_pick(data, "sort")) or base.sort,
)
def _contact_config(data: dict[str, Any] | None) -> ContactConfig:
if not data:
return ContactConfig()
base = ContactConfig()
return replace(
base,
resource=str(_pick(data, "resource") or base.resource),
id_field=str(_pick(data, "idField", "id_field") or base.id_field),
fields=_string_list(_pick(data, "fields")) or base.fields,
search_fields=_string_list(_pick(data, "searchFields", "search_fields")) or base.search_fields,
customer_field=str(_pick(data, "customerField", "customer_field") or base.customer_field),
sort=_string_list(_pick(data, "sort")) or base.sort,
)
def _followup_config(data: dict[str, Any] | None) -> FollowupConfig:
if not data:
return FollowupConfig()
base = FollowupConfig()
owner_field = str(_pick(data, "ownerField", "owner_field") or base.owner_field)
owner_fields = _string_list(_pick(data, "ownerFields", "owner_fields")) or ([owner_field] if owner_field else [])
return replace(
base,
resource=str(_pick(data, "resource") or base.resource),
id_field=str(_pick(data, "idField", "id_field") or base.id_field),
fields=_string_list(_pick(data, "fields")) or base.fields,
search_fields=_string_list(_pick(data, "searchFields", "search_fields")) or base.search_fields,
customer_field=str(_pick(data, "customerField", "customer_field") or base.customer_field),
contact_field=str(_pick(data, "contactField", "contact_field") or base.contact_field),
owner_field=owner_field,
owner_fields=owner_fields,
date_field=str(_pick(data, "dateField", "date_field") or base.date_field),
sort=_string_list(_pick(data, "sort")) or base.sort,
)
def _user_config(data: dict[str, Any] | None) -> UserConfig:
if not data:
return UserConfig()
base = UserConfig()
return replace(
base,
resource=str(_pick(data, "resource") or base.resource),
id_field=str(_pick(data, "idField", "id_field") or base.id_field),
fields=_string_list(_pick(data, "fields")) or base.fields,
match_fields=_string_list(_pick(data, "matchFields", "match_fields")) or base.match_fields,
)
def load_config(path: str | Path | None = None) -> CrmConfig:
configured_path = path or os.environ.get("NOCOBASE_MCP_CONFIG")
raw: dict[str, Any] = {}
if configured_path:
raw = _load_json(Path(configured_path).expanduser())
collections = raw.get("collections") or {}
if not isinstance(collections, dict):
raise ValueError("CRM config collections must be a JSON object")
env_name = os.environ.get("NOCOBASE_MCP_ENV") or raw.get("env") or raw.get("envName") or "nb-mcp"
data_source = os.environ.get("NOCOBASE_MCP_DATA_SOURCE") or raw.get("dataSource") or raw.get("data_source") or "main"
return CrmConfig(
env_name=str(env_name),
data_source=str(data_source),
customer=_customer_config(collections.get("customers")),
contact=_contact_config(collections.get("contacts")),
followup=_followup_config(collections.get("followups")),
user=_user_config(raw.get("users") or raw.get("user")),
)
+822
View File
@@ -0,0 +1,822 @@
from __future__ import annotations
from typing import Any, Mapping
from .config import CrmConfig
from .filters import (
build_and_filter,
build_date_range_condition,
build_eq_condition,
build_relation_id_condition,
build_relation_id_or_condition,
build_search_filter,
)
from .nb_client import NbClient, NbCommandError
DEFAULT_PAGE_SIZE = 20
MAX_PAGE_SIZE = 100
CUSTOMER_STATUS_LABELS = {
"kh_p_not_start": "未开始",
"kh_p_inprogress": "进行中",
"kh_p_ordered": "已下单",
"kh_p_audited": "已审核",
}
CUSTOMER_NAME_KEYS = ("f_q2q60q7bqbg", "name", "title", "code", "id")
CONTACT_NAME_KEYS = ("f_2zpg91gtmwp", "name", "phone", "email", "title", "id")
USER_LABEL_KEYS = ("nickname", "username", "email", "f_yh_english_name", "name", "id")
FOLLOWUP_CONTENT_KEYS = ("f_19iplxz0nds", "content", "description", "remark")
FOLLOWUP_DATE_KEYS = ("f_srysmjkwfq1", "followedAt", "createdAt", "updatedAt")
class CrmService:
def __init__(self, config: CrmConfig, client: NbClient) -> None:
self.config = config
self.client = client
def crm_health(self) -> dict[str, Any]:
health = self.client.health()
configured = {
"env": self.config.env_name,
"data_source": self.config.data_source,
"collections": {
"customers": self.config.customer.resource,
"contacts": self.config.contact.resource,
"followups": self.config.followup.resource,
},
}
return {"ok": bool(health.get("ok")), "nb": health, "configured": configured}
def crm_schema(self, include_fields: bool = False) -> dict[str, Any]:
schema: dict[str, Any] = {
"env": self.config.env_name,
"data_source": self.config.data_source,
"collections": {
"customers": self.config.customer.__dict__,
"contacts": self.config.contact.__dict__,
"followups": self.config.followup.__dict__,
"users": self.config.user.__dict__,
},
}
if include_fields:
schema["metadata"] = self._safe_metadata()
return schema
def crm_config_template(self) -> dict[str, Any]:
metadata = self._safe_metadata()
fields_by_kind = metadata.get("fields", {}) if isinstance(metadata, dict) else {}
customer_fields = _field_list(fields_by_kind.get("customers"))
contact_fields = _field_list(fields_by_kind.get("contacts"))
followup_fields = _field_list(fields_by_kind.get("followups"))
customer_resource = self.config.customer.resource
contact_resource = self.config.contact.resource
return {
"env": self.config.env_name,
"dataSource": self.config.data_source,
"users": {
"resource": self.config.user.resource,
"fields": self.config.user.fields,
"matchFields": self.config.user.match_fields,
},
"collections": {
"customers": {
"resource": customer_resource,
"fields": _field_names(customer_fields),
"searchFields": _configured_or_search_fields(
customer_fields,
["f_q2q60q7bqbg", "f_kh_english_name", "f_territorial_name"],
),
"statusField": _find_named_field(customer_fields, ["kh_progress"])
or _find_field(customer_fields, ["进度", "状态", "status"]),
"sourceField": _find_named_field(customer_fields, ["f_225h6n6v654"])
or _find_field(customer_fields, ["客户来源", "来源", "source"]),
"ownerField": _find_named_field(customer_fields, ["kehu_followup_person"])
or self.config.customer.owner_field,
"sort": self.config.customer.sort,
},
"contacts": {
"resource": contact_resource,
"fields": _field_names(contact_fields),
"searchFields": _configured_or_search_fields(
contact_fields,
["f_2zpg91gtmwp", "khlxr_phone_number", "khlxr_email"],
),
"customerField": _find_named_field(contact_fields, ["kehulianxiren_client"])
or _relation_field(contact_fields, ["公司", "客户"])
or self.config.contact.customer_field,
"sort": self.config.contact.sort,
},
"followups": {
"resource": self.config.followup.resource,
"fields": _field_names(followup_fields),
"searchFields": _configured_or_search_fields(followup_fields, ["f_19iplxz0nds"]),
"customerField": _find_named_field(followup_fields, ["gjjl_client"])
or _relation_field(followup_fields, ["客户"])
or self.config.followup.customer_field,
"contactField": _find_named_field(followup_fields, ["gjjl_client_contacts"])
or _relation_field(followup_fields, ["客户联系人", "联系人"])
or self.config.followup.contact_field,
"ownerField": _find_named_field(followup_fields, ["gjjl_followup_person"])
or self.config.followup.owner_field,
"ownerFields": _existing_field_names(
followup_fields,
["gjjl_followup_person", "createdBy"],
)
or self.config.followup.owner_fields,
"dateField": _find_named_field(followup_fields, ["f_srysmjkwfq1"])
or _date_field(followup_fields)
or self.config.followup.date_field,
"sort": _template_sort(
_find_named_field(followup_fields, ["f_srysmjkwfq1"])
or _date_field(followup_fields)
or self.config.followup.date_field,
self.config.followup.sort,
),
},
},
"metadata": {
"field_errors": metadata.get("field_errors", {}) if isinstance(metadata, dict) else {},
"note": "Review inferred owner/status/source/date fields before using this template in production.",
},
}
def crm_overview(
self,
date_from: str | None = None,
date_to: str | None = None,
owner: str | None = None,
) -> dict[str, Any]:
owner_id, owner_error = self._resolve_owner_id_or_error(owner)
if owner_error:
return {"error": owner_error}
customer_filter = build_and_filter([build_relation_id_condition(self.config.customer.owner_field, owner_id)])
followup_filter = build_and_filter(
[
build_relation_id_or_condition(self.config.followup.owner_fields, owner_id),
build_date_range_condition(self.config.followup.date_field, date_from, date_to),
]
)
totals = {
"customers": self._count(self.config.customer.resource, self.config.customer.id_field, customer_filter),
"contacts": self._count(self.config.contact.resource, self.config.contact.id_field, {}),
"followups": self._count(self.config.followup.resource, self.config.followup.id_field, followup_filter),
}
overview = {
"totals": totals,
"customer_status": self._group_count(
self.config.customer.resource,
self.config.customer.id_field,
self.config.customer.status_field,
customer_filter,
),
"customer_source": self._group_count(
self.config.customer.resource,
self.config.customer.id_field,
self.config.customer.source_field,
customer_filter,
),
"customer_owner": self._group_count(
self.config.customer.resource,
self.config.customer.id_field,
[self.config.customer.owner_field, "id"],
customer_filter,
),
"recent_followups": self._decorate_followups(
self._data(
self.client.list_records(
self.config.followup.resource,
filter=followup_filter,
fields=self.config.followup.fields,
sort=self.config.followup.sort,
page=1,
page_size=10,
)
)
),
}
overview["customer_status"] = self._decorate_status_counts(overview["customer_status"])
overview["customer_source"] = self._decorate_source_counts(overview["customer_source"])
overview["customer_owner"] = self._decorate_owner_counts(overview["customer_owner"])
return overview
def crm_filter_options(self, owner_query: str | None = None) -> dict[str, Any]:
owner_candidates: list[dict[str, Any]] = []
if (owner_query or "").strip():
owner_candidates = self.resolve_owner(owner_query or "")["results"]
return {
"customer_status": self._decorate_status_counts(
self._group_count(
self.config.customer.resource,
self.config.customer.id_field,
self.config.customer.status_field,
{},
)
),
"customer_source": self._decorate_source_counts(
self._group_count(
self.config.customer.resource,
self.config.customer.id_field,
self.config.customer.source_field,
{},
)
),
"owner_candidates": owner_candidates,
"max_limit": MAX_PAGE_SIZE,
}
def resolve_owner(self, query: str) -> dict[str, Any]:
normalized = (query or "").strip()
if not normalized:
return {"query": "", "results": [], "count": 0}
rows = self._owner_matches(normalized)
results = [_format_user_candidate(row, self.config.user.id_field) for row in rows]
return {"query": normalized, "results": results, "count": len(results)}
def list_customers(
self,
query: str | None = None,
status: str | None = None,
owner: str | None = None,
source: str | None = None,
limit: int = 20,
offset: int = 0,
) -> dict[str, Any]:
limit, offset = _normalize_pagination(limit, offset)
owner_id, owner_error = self._resolve_owner_id_or_error(owner)
if owner_error:
return {"error": owner_error}
filter_value = build_and_filter(
[
build_eq_condition(self.config.customer.status_field, status),
build_relation_id_condition(self.config.customer.owner_field, owner_id),
build_eq_condition(self.config.customer.source_field, source),
build_search_filter(query, self.config.customer.search_fields),
]
)
response = self.client.list_records(
self.config.customer.resource,
filter=filter_value,
fields=self.config.customer.fields,
sort=self.config.customer.sort,
page=_page_from_offset(limit, offset),
page_size=limit,
)
return _paginated_response(response, limit, offset)
def get_customer(
self,
customer_id: Any,
include_contacts: bool = True,
include_recent_followups: bool = True,
followup_limit: int = 10,
) -> dict[str, Any]:
customer = self._single(
self.client.get_record(
self.config.customer.resource,
customer_id,
fields=self.config.customer.fields,
)
)
result: dict[str, Any] = {"customer": customer}
if include_contacts:
result["contacts"] = self.list_contacts(customer_id=customer_id, limit=50, offset=0)["results"]
if include_recent_followups:
result["recent_followups"] = self.list_followups(
customer_id=customer_id,
limit=followup_limit,
offset=0,
)["results"]
return result
def list_contacts(
self,
customer_id: Any | None = None,
query: str | None = None,
limit: int = 20,
offset: int = 0,
) -> dict[str, Any]:
limit, offset = _normalize_pagination(limit, offset)
filter_value = build_and_filter(
[
build_relation_id_condition(self.config.contact.customer_field, customer_id),
build_search_filter(query, self.config.contact.search_fields),
]
)
response = self.client.list_records(
self.config.contact.resource,
filter=filter_value,
fields=self.config.contact.fields,
sort=self.config.contact.sort,
page=_page_from_offset(limit, offset),
page_size=limit,
)
return _paginated_response(response, limit, offset)
def list_followups(
self,
customer_id: Any | None = None,
contact_id: Any | None = None,
owner: str | None = None,
date_from: str | None = None,
date_to: str | None = None,
limit: int = 20,
offset: int = 0,
) -> dict[str, Any]:
limit, offset = _normalize_pagination(limit, offset)
owner_id, owner_error = self._resolve_owner_id_or_error(owner)
if owner_error:
return {"error": owner_error}
filter_value = build_and_filter(
[
build_relation_id_condition(self.config.followup.customer_field, customer_id),
build_relation_id_condition(self.config.followup.contact_field, contact_id),
build_relation_id_or_condition(self.config.followup.owner_fields, owner_id),
build_date_range_condition(self.config.followup.date_field, date_from, date_to),
]
)
response = self.client.list_records(
self.config.followup.resource,
filter=filter_value,
fields=self.config.followup.fields,
sort=self.config.followup.sort,
page=_page_from_offset(limit, offset),
page_size=limit,
)
result = _paginated_response(response, limit, offset)
result["results"] = self._decorate_followups(result["results"])
return result
def _safe_metadata(self) -> dict[str, Any]:
collections = {
"customers": self.config.customer.resource,
"contacts": self.config.contact.resource,
"followups": self.config.followup.resource,
}
fields: dict[str, list[dict[str, Any]]] = {}
field_errors: dict[str, dict[str, str]] = {}
for label, collection_name in collections.items():
try:
response = self.client.list_collection_fields(
collection_name,
page_size=500,
paginate=False,
)
fields[label] = self._data(response)
except Exception as error:
field_errors[label] = _metadata_error(error)
return {"collections": collections, "fields": fields, "field_errors": field_errors}
def _owner_matches(self, owner: str) -> list[dict[str, Any]]:
normalized = owner.strip()
if not normalized:
return []
rows = self._data(
self.client.list_records(
self.config.user.resource,
filter=build_search_filter(normalized, self.config.user.match_fields),
fields=self.config.user.fields,
page=1,
page_size=50,
)
)
return _pick_user_matches(rows, normalized, self.config.user.match_fields or [])
def _resolve_owner_id_or_error(self, owner: str | None) -> tuple[Any | None, dict[str, Any] | None]:
normalized = (owner or "").strip()
if not normalized:
return None, None
matches = self._owner_matches(normalized)
candidates = [_format_user_candidate(row, self.config.user.id_field) for row in matches[:10]]
if not matches:
return None, {
"code": "owner_not_found",
"message": f"No user matched owner {normalized!r}",
"query": normalized,
"candidates": [],
}
if len(matches) > 1:
return None, {
"code": "owner_ambiguous",
"message": f"Multiple users matched owner {normalized!r}",
"query": normalized,
"candidates": candidates,
}
user_id = matches[0].get(self.config.user.id_field)
if user_id is None or user_id == "":
return None, {
"code": "owner_invalid",
"message": f"Matched owner {normalized!r} has no {self.config.user.id_field!r}",
"query": normalized,
"candidates": candidates,
}
return user_id, None
def _resolve_owner_id(self, owner: str | None) -> Any | None:
owner_id, error = self._resolve_owner_id_or_error(owner)
if error:
raise ValueError(str(error["message"]))
return owner_id
def _count(self, resource: str, id_field: str, filter_value: Mapping[str, Any]) -> int:
response = self.client.query_records(
resource,
measures=[{"field": [id_field], "aggregation": "count", "alias": "count"}],
filter=filter_value,
limit=1,
)
return _first_numeric(self._data(response), "count")
def _group_count(
self,
resource: str,
id_field: str,
dimension_field: str | list[str],
filter_value: Mapping[str, Any],
) -> list[dict[str, Any]]:
if not dimension_field:
return []
dimension_path = _field_path(dimension_field)
dimension_alias = "_".join(dimension_path)
response = self.client.query_records(
resource,
dimensions=[{"field": dimension_path, "alias": dimension_alias}],
measures=[{"field": [id_field], "aggregation": "count", "alias": "count"}],
orders=[{"field": dimension_path, "alias": dimension_alias, "order": "asc"}],
filter=filter_value,
limit=50,
)
return self._data(response)
def _decorate_status_counts(self, rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
return _decorate_dimension_counts(
rows,
preferred_keys=[self.config.customer.status_field, "kh_progress", "status"],
labels=CUSTOMER_STATUS_LABELS,
display_key="display",
)
def _decorate_source_counts(self, rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
return _decorate_dimension_counts(
rows,
preferred_keys=[self.config.customer.source_field, "f_225h6n6v654", "source"],
labels={},
)
def _decorate_owner_counts(self, rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
owner_alias = "_".join(_field_path([self.config.customer.owner_field, "id"]))
decorated: list[dict[str, Any]] = []
for row in rows:
value, _key = _dimension_value(row, [owner_alias, "owner_id", "kehu_followup_person_id"])
item = dict(row)
if value is not None:
item["owner_id"] = value
item.setdefault("value", value)
decorated.append(item)
return decorated
def _decorate_followups(self, rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [{**row, "summary": self._followup_summary(row)} for row in rows]
def _followup_summary(self, row: Mapping[str, Any]) -> dict[str, Any]:
content_fields = list(self.config.followup.search_fields or []) + list(FOLLOWUP_CONTENT_KEYS)
date_fields = [self.config.followup.date_field, *FOLLOWUP_DATE_KEYS]
customer_fields = [self.config.followup.customer_field, "gjjl_client", "customer", "client"]
contact_fields = [self.config.followup.contact_field, "gjjl_client_contacts", "contact", "contacts"]
owner_fields = [*self.config.followup.owner_fields, "gjjl_followup_person", "createdBy", "owner"]
return {
"content": _first_text(row, content_fields),
"followedAt": _first_text(row, date_fields),
"customer_names": _relation_labels(row, customer_fields, CUSTOMER_NAME_KEYS),
"contact_names": _relation_labels(row, contact_fields, CONTACT_NAME_KEYS),
"owner_names": _relation_labels(row, owner_fields, USER_LABEL_KEYS),
}
@staticmethod
def _data(response: Mapping[str, Any]) -> list[dict[str, Any]]:
return _data(response)
@staticmethod
def _single(response: Mapping[str, Any]) -> Any:
data = response.get("data")
if isinstance(data, list):
return data[0] if data else None
return data
def _page_from_offset(limit: int, offset: int) -> int:
bounded_limit = max(1, int(limit))
bounded_offset = max(0, int(offset))
return bounded_offset // bounded_limit + 1
def _normalize_pagination(limit: Any, offset: Any) -> tuple[int, int]:
return min(max(1, _int_or_default(limit, DEFAULT_PAGE_SIZE)), MAX_PAGE_SIZE), max(0, _int_or_default(offset, 0))
def _int_or_default(value: Any, default: int) -> int:
try:
return int(value)
except (TypeError, ValueError):
return default
def _data(response: Mapping[str, Any]) -> list[dict[str, Any]]:
data = response.get("data")
if isinstance(data, list):
return [item for item in data if isinstance(item, dict)]
if isinstance(data, dict):
for key in ("data", "rows", "results"):
nested = data.get(key)
if isinstance(nested, list):
return [item for item in nested if isinstance(item, dict)]
return []
def _total(response: Mapping[str, Any], fallback: int) -> int:
meta = response.get("meta")
if isinstance(meta, dict):
for key in ("count", "total", "totalCount"):
value = meta.get(key)
if isinstance(value, int):
return value
data = response.get("data")
if isinstance(data, dict):
for key in ("count", "total", "totalCount"):
value = data.get(key)
if isinstance(value, int):
return value
return fallback
def _paginated_response(response: Mapping[str, Any], limit: int, offset: int) -> dict[str, Any]:
rows = _data(response)
total = _total(response, len(rows))
next_offset = offset + limit
return {
"results": rows,
"total": total,
"returned": len(rows),
"limit": limit,
"offset": offset,
"has_more": next_offset < total,
"next_offset": next_offset if next_offset < total else None,
}
def _first_numeric(rows: list[dict[str, Any]], preferred_key: str) -> int:
for row in rows:
value = row.get(preferred_key)
if isinstance(value, (int, float)):
return int(value)
for nested_value in row.values():
if isinstance(nested_value, (int, float)):
return int(nested_value)
return 0
def _decorate_dimension_counts(
rows: list[dict[str, Any]],
*,
preferred_keys: list[str],
labels: Mapping[Any, str],
display_key: str = "label",
) -> list[dict[str, Any]]:
decorated: list[dict[str, Any]] = []
for row in rows:
value, _key = _dimension_value(row, preferred_keys)
item = dict(row)
if value is not None:
label = labels.get(value) or str(value)
item["value"] = value
item["label"] = label
item[display_key] = label
decorated.append(item)
return decorated
def _dimension_value(row: Mapping[str, Any], preferred_keys: list[str]) -> tuple[Any | None, str | None]:
for key in preferred_keys:
if key in row:
return row[key], key
for key, value in row.items():
if key == "count":
continue
if not isinstance(value, (dict, list)):
return value, str(key)
return None, None
def _first_text(row: Mapping[str, Any], fields: list[str]) -> str | None:
for field in _unique_strings(fields):
value = row.get(field)
if value is None or value == "":
continue
if isinstance(value, (dict, list)):
continue
return str(value)
return None
def _relation_labels(row: Mapping[str, Any], fields: list[str], label_keys: tuple[str, ...]) -> list[str]:
labels: list[str] = []
for field in _unique_strings(fields):
_append_relation_labels(labels, row.get(field), label_keys)
return labels
def _append_relation_labels(labels: list[str], value: Any, label_keys: tuple[str, ...]) -> None:
if value is None or value == "":
return
if isinstance(value, list):
for item in value:
_append_relation_labels(labels, item, label_keys)
return
if isinstance(value, dict):
label = _label_from_mapping(value, label_keys)
if label and label not in labels:
labels.append(label)
return
label = str(value)
if label not in labels:
labels.append(label)
def _label_from_mapping(row: Mapping[str, Any], label_keys: tuple[str, ...]) -> str | None:
for key in label_keys:
value = row.get(key)
if value is not None and value != "":
return str(value)
return None
def _unique_strings(values: list[str]) -> list[str]:
unique: list[str] = []
for value in values:
normalized = str(value or "").strip()
if normalized and normalized not in unique:
unique.append(normalized)
return unique
def _metadata_error(error: Exception) -> dict[str, str]:
message = str(error)
if "403" in message or "No permissions" in message:
return {
"code": "metadata_permission_required",
"message": message,
"hint": (
"Grant the agent role read-only permission for "
"`data-modeling collections fields list`, then retry crm_schema(include_fields=true)."
),
}
return {"code": "metadata_error", "message": message, "hint": "Check NocoBase metadata API reachability and ACL."}
def _field_list(value: Any) -> list[dict[str, Any]]:
return [item for item in value or [] if isinstance(item, dict)]
def _field_names(fields: list[dict[str, Any]]) -> list[str]:
return [str(field["name"]) for field in fields if field.get("name")]
def _field_path(field: str | list[str]) -> list[str]:
if isinstance(field, str):
return [part for part in field.split(".") if part]
return [str(part) for part in field if str(part)]
def _field_text(field: dict[str, Any]) -> str:
pieces = [
str(field.get("name") or ""),
str(field.get("title") or ""),
str(field.get("interface") or ""),
str(field.get("type") or ""),
]
ui_schema = field.get("uiSchema")
if isinstance(ui_schema, dict):
pieces.append(str(ui_schema.get("title") or ""))
return " ".join(pieces).lower()
def _find_named_field(fields: list[dict[str, Any]], names: list[str]) -> str | None:
available = {str(field.get("name")) for field in fields if field.get("name")}
for name in names:
if name in available:
return name
return None
def _existing_field_names(fields: list[dict[str, Any]], names: list[str]) -> list[str]:
available = {str(field.get("name")) for field in fields if field.get("name")}
return [name for name in names if name in available]
def _configured_or_search_fields(fields: list[dict[str, Any]], preferred_names: list[str]) -> list[str]:
preferred = _existing_field_names(fields, preferred_names)
if preferred:
return preferred
return _search_fields(fields)
def _search_fields(fields: list[dict[str, Any]]) -> list[str]:
preferred_interfaces = {"input", "textarea", "email", "phone", "url"}
preferred_types = {"string", "text"}
names = [
str(field["name"])
for field in fields
if field.get("name")
and (
str(field.get("interface") or "").lower() in preferred_interfaces
or str(field.get("type") or "").lower() in preferred_types
)
]
if names:
return names[:4]
fallback = [str(field["name"]) for field in fields if field.get("name") and str(field.get("name")) != "id"]
return fallback[:2]
def _find_field(fields: list[dict[str, Any]], needles: list[str]) -> str | None:
lowered = [needle.lower() for needle in needles]
for field in fields:
name = field.get("name")
if not name:
continue
text = _field_text(field)
if any(needle in text for needle in lowered):
return str(name)
return None
def _relation_field(fields: list[dict[str, Any]], needles: list[str]) -> str | None:
lowered = [needle.lower() for needle in needles]
for field in fields:
if not field.get("name"):
continue
if str(field.get("type") or "").lower() not in {"belongsto", "belongstomany", "hasmany", "hasone"}:
continue
text = _field_text(field)
if any(needle in text for needle in lowered):
return str(field["name"])
return None
def _date_field(fields: list[dict[str, Any]]) -> str | None:
preferred = _find_field(fields, ["跟进日期", "跟进时间", "follow", "date", "time"])
if preferred:
return preferred
for field in fields:
if field.get("name") and str(field.get("interface") or "").lower() in {"date", "datetime", "createdat"}:
return str(field["name"])
if field.get("name") and str(field.get("type") or "").lower() in {"date", "datetime"}:
return str(field["name"])
return None
def _template_sort(date_field: str | None, fallback: list[str] | None) -> list[str] | None:
if date_field:
sort = [f"-{date_field}"]
if date_field != "createdAt":
sort.append("-createdAt")
return sort
return fallback
def _pick_user_matches(rows: list[dict[str, Any]], query: str, match_fields: list[str]) -> list[dict[str, Any]]:
normalized = query.casefold()
exact = [
row
for row in rows
if any(str(row.get(field) or "").strip().casefold() == normalized for field in match_fields)
]
if exact:
return exact
fuzzy = [
row
for row in rows
if any(normalized in str(row.get(field) or "").strip().casefold() for field in match_fields)
]
return fuzzy
def _user_label(row: dict[str, Any]) -> str:
for key in ("nickname", "username", "email", "f_yh_english_name", "id"):
value = row.get(key)
if value is not None and value != "":
return str(value)
return "<unnamed>"
def _format_user_candidate(row: dict[str, Any], id_field: str) -> dict[str, Any]:
candidate: dict[str, Any] = {
"id": row.get(id_field),
"label": _user_label(row),
}
for key in ("username", "nickname", "email", "f_yh_english_name"):
value = row.get(key)
if value is not None and value != "":
candidate[key] = value
return candidate
+80
View File
@@ -0,0 +1,80 @@
from __future__ import annotations
from typing import Any, Iterable, Sequence
Filter = dict[str, Any]
def build_search_filter(query: str | None, fields: Iterable[str] | None) -> Filter | None:
normalized = (query or "").strip()
field_names = [field for field in (fields or []) if field]
if not normalized or not field_names:
return None
return {"$or": [{field: {"$includes": normalized}} for field in field_names]}
def build_eq_condition(field: str | None, value: Any) -> Filter | None:
if not field or value is None or value == "":
return None
return {field: {"$eq": value}}
def build_relation_id_condition(field: str | Sequence[str] | None, value: Any) -> Filter | None:
if not field or value is None or value == "":
return None
path = _relation_path(field)
if not path:
return None
return _nested_eq(path, value)
def build_relation_id_or_condition(fields: Iterable[str | Sequence[str]] | None, value: Any) -> Filter | None:
if value is None or value == "":
return None
conditions = [condition for field in fields or [] if (condition := build_relation_id_condition(field, value))]
if not conditions:
return None
if len(conditions) == 1:
return {"$or": conditions}
return {"$or": conditions}
def build_date_range_condition(field: str | None, date_from: str | None, date_to: str | None) -> Filter | None:
if not field:
return None
lower = (date_from or "").strip()
upper = (date_to or "").strip()
if lower and upper:
return {field: {"$dateBetween": [lower, upper]}}
if lower:
return {field: {"$dateNotBefore": lower}}
if upper:
return {field: {"$dateNotAfter": upper}}
return None
def build_and_filter(conditions: Iterable[Filter | None]) -> Filter:
filtered = [condition for condition in conditions if condition]
if not filtered:
return {}
return {"$and": filtered}
def _relation_path(field: str | Sequence[str]) -> list[str]:
if isinstance(field, str):
parts = [part for part in field.split(".") if part]
else:
parts = [str(part) for part in field if str(part)]
if not parts:
return []
if len(parts) == 1:
return [parts[0], "id"]
return parts
def _nested_eq(path: Sequence[str], value: Any) -> Filter:
current: Any = {"$eq": value}
for part in reversed(path):
current = {part: current}
return current
+275
View File
@@ -0,0 +1,275 @@
from __future__ import annotations
import json
import os
import subprocess
from pathlib import Path
from typing import Any, Callable, Mapping, Sequence
Runner = Callable[..., subprocess.CompletedProcess[str]]
class NbCommandError(RuntimeError):
"""Raised when the NocoBase CLI command fails or returns invalid output."""
class NbClient:
def __init__(
self,
*,
env_name: str = "nb-mcp",
data_source: str = "main",
nb_bin: str = "nb",
api_token: str | None = None,
role: str | None = None,
cwd: str | Path | None = None,
timeout: int = 30,
runner: Runner | None = None,
) -> None:
self.env_name = env_name
self.data_source = data_source
self.nb_bin = nb_bin
self.api_token = api_token
self.role = role
self.cwd = Path(cwd).resolve() if cwd else None
self.timeout = timeout
self.runner = runner or subprocess.run
def health(self) -> dict[str, Any]:
result = self._run_raw(["env", "status", self.env_name])
output = result.stdout.strip()
return {
"ok": result.returncode == 0 and "unreachable" not in output.lower(),
"env": self.env_name,
"status_output": output,
"stderr": self._redact(result.stderr.strip()),
}
def list_records(
self,
resource: str,
*,
filter: Mapping[str, Any] | None = None,
fields: Sequence[str] | None = None,
appends: Sequence[str] | None = None,
except_fields: Sequence[str] | None = None,
sort: Sequence[str] | None = None,
page: int | None = None,
page_size: int | None = None,
paginate: bool | None = None,
) -> dict[str, Any]:
command = self._resource_command("list", resource)
self._append_json(command, "--filter", filter)
self._append_repeated(command, "--fields", fields)
self._append_repeated(command, "--appends", appends)
self._append_repeated(command, "--except", except_fields)
self._append_repeated(command, "--sort", sort)
self._append_value(command, "--page", page)
self._append_value(command, "--page-size", page_size)
if paginate is True:
command.append("--paginate")
elif paginate is False:
command.append("--no-paginate")
return self._run_json(command)
def get_record(
self,
resource: str,
filter_by_tk: Any,
*,
fields: Sequence[str] | None = None,
appends: Sequence[str] | None = None,
except_fields: Sequence[str] | None = None,
) -> dict[str, Any]:
command = self._resource_command("get", resource)
self._append_json_or_scalar(command, "--filter-by-tk", filter_by_tk)
self._append_repeated(command, "--fields", fields)
self._append_repeated(command, "--appends", appends)
self._append_repeated(command, "--except", except_fields)
return self._run_json(command)
def query_records(
self,
resource: str,
*,
measures: Sequence[Mapping[str, Any]] | None = None,
dimensions: Sequence[Mapping[str, Any]] | None = None,
orders: Sequence[Mapping[str, Any]] | None = None,
filter: Mapping[str, Any] | None = None,
having: Mapping[str, Any] | None = None,
limit: int | None = None,
offset: int | None = None,
timezone: str | None = None,
) -> dict[str, Any]:
command = self._resource_command("query", resource)
self._append_json(command, "--measures", measures)
self._append_json(command, "--dimensions", dimensions)
self._append_json(command, "--orders", orders)
self._append_json(command, "--filter", filter)
self._append_json(command, "--having", having)
self._append_value(command, "--limit", limit)
self._append_value(command, "--offset", offset)
self._append_value(command, "--timezone", timezone)
return self._run_json(command)
def get_collection(
self,
collection_name: str,
*,
appends: Sequence[str] | None = None,
except_fields: Sequence[str] | None = None,
) -> dict[str, Any]:
command = self._data_modeling_command("collections", "get")
command.extend(["--filter-by-tk", collection_name])
self._append_repeated(command, "--appends", appends)
self._append_repeated(command, "--except", except_fields)
return self._run_json(command)
def list_collection_fields(
self,
collection_name: str,
*,
fields: Sequence[str] | None = None,
appends: Sequence[str] | None = None,
except_fields: Sequence[str] | None = None,
page: int | None = None,
page_size: int | None = None,
paginate: bool | None = None,
) -> dict[str, Any]:
command = self._data_modeling_command("collections", "fields", "list")
command.extend(["--collection-name", collection_name])
self._append_repeated(command, "--fields", fields)
self._append_repeated(command, "--appends", appends)
self._append_repeated(command, "--except", except_fields)
self._append_value(command, "--page", page)
self._append_value(command, "--page-size", page_size)
if paginate is True:
command.append("--paginate")
elif paginate is False:
command.append("--no-paginate")
return self._run_json(command)
def _resource_command(self, action: str, resource: str) -> list[str]:
command = [
self.nb_bin,
"api",
"resource",
action,
"--resource",
resource,
"--env",
self.env_name,
"--yes",
"--json-output",
]
if self.data_source:
command.extend(["--data-source", self.data_source])
if self.role:
command.extend(["--role", self.role])
if self.api_token:
command.extend(["--token", self.api_token])
return command
def _data_modeling_command(self, *parts: str) -> list[str]:
command = [
self.nb_bin,
"api",
"data-modeling",
*parts,
"--env",
self.env_name,
"--yes",
"--json-output",
]
if self.role:
command.extend(["--role", self.role])
if self.api_token:
command.extend(["--token", self.api_token])
return command
def _run_json(self, command: list[str]) -> dict[str, Any]:
result = self._run_raw(command[1:], full_command=command)
if result.returncode != 0:
raise NbCommandError(self._format_error(command, result))
try:
parsed = json.loads(result.stdout)
except json.JSONDecodeError as error:
raise NbCommandError(
f"nb returned invalid JSON: {error.msg}\nstdout={self._redact(result.stdout.strip())}"
) from error
if not isinstance(parsed, dict):
raise NbCommandError("nb returned JSON that is not an object")
return parsed
def _run_raw(
self,
args: list[str],
*,
full_command: list[str] | None = None,
) -> subprocess.CompletedProcess[str]:
command = full_command or [self.nb_bin, *args]
env = os.environ.copy()
env["NB_CLI_LOG_DISABLED"] = "1"
env["NB_SKIP_STARTUP_UPDATE"] = "1"
return self.runner(
command,
cwd=str(self.cwd) if self.cwd else None,
env=env,
timeout=self.timeout,
capture_output=True,
text=True,
check=False,
)
def _format_error(self, command: list[str], result: subprocess.CompletedProcess[str]) -> str:
return (
f"nb command failed with exit code {result.returncode}: {self._redact_command(command)}\n"
f"stdout={self._redact(result.stdout.strip())}\n"
f"stderr={self._redact(result.stderr.strip())}"
)
def _redact_command(self, command: Sequence[str]) -> str:
redacted: list[str] = []
skip_next = False
for token in command:
if skip_next:
redacted.append("<redacted>")
skip_next = False
continue
redacted.append(token)
if token == "--token":
skip_next = True
return " ".join(redacted)
def _redact(self, value: str) -> str:
redacted = value
if self.api_token:
redacted = redacted.replace(self.api_token, "<redacted>")
return redacted
@staticmethod
def _append_json(command: list[str], flag: str, value: Any) -> None:
if value is None or value == {}:
return
command.extend([flag, json.dumps(value, ensure_ascii=False, separators=(",", ":"))])
@staticmethod
def _append_json_or_scalar(command: list[str], flag: str, value: Any) -> None:
if value is None:
return
if isinstance(value, (list, dict)):
command.extend([flag, json.dumps(value, ensure_ascii=False, separators=(",", ":"))])
else:
command.extend([flag, str(value)])
@staticmethod
def _append_repeated(command: list[str], flag: str, values: Sequence[str] | None) -> None:
for value in values or []:
command.extend([flag, str(value)])
@staticmethod
def _append_value(command: list[str], flag: str, value: Any) -> None:
if value is None or value == "":
return
command.extend([flag, str(value)])
+311
View File
@@ -0,0 +1,311 @@
from __future__ import annotations
import argparse
import hmac
import os
from pathlib import Path
from typing import Annotated, Any
try:
from pydantic import Field
except ModuleNotFoundError:
def Field(*args: Any, **kwargs: Any) -> Any:
return None
try:
from mcp.server.fastmcp import FastMCP
from mcp.server.fastmcp.server import TransportSecuritySettings
except ModuleNotFoundError:
FastMCP = None
TransportSecuritySettings = None
from .config import load_config
from .crm import CrmService
from .nb_client import NbClient
DEFAULT_HTTP_HOST = "0.0.0.0"
DEFAULT_HTTP_PORT = 10087
DEFAULT_HTTP_PATH = "/mcp"
DEFAULT_TOKEN_ENV = "NOCOBASE_MCP_TOKEN"
ENABLE_CONFIG_TOOLS_ENV = "NOCOBASE_MCP_ENABLE_CONFIG_TOOLS"
def _bearer_token(authorization: str) -> str:
scheme, _, token = authorization.partition(" ")
if scheme.lower() != "bearer" or not token:
return ""
return token.strip()
def _authorization_header(scope: dict[str, Any]) -> str:
headers = {key.lower(): value for key, value in scope.get("headers", [])}
return headers.get(b"authorization", b"").decode("latin1")
class BearerTokenAuthMiddleware:
def __init__(self, app: Any, token: str) -> None:
if not token:
raise ValueError("HTTP MCP token must not be empty")
self.app = app
self.token = token
async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None:
if scope.get("type") != "http":
await self.app(scope, receive, send)
return
if str(scope.get("method") or "").upper() == "OPTIONS":
await send(
{
"type": "http.response.start",
"status": 204,
"headers": [
(b"access-control-allow-origin", b"*"),
(b"access-control-allow-methods", b"POST, OPTIONS"),
(b"access-control-allow-headers", b"authorization, content-type, mcp-session-id"),
],
}
)
await send({"type": "http.response.body", "body": b""})
return
token = _bearer_token(_authorization_header(scope))
if not hmac.compare_digest(token, self.token):
await send(
{
"type": "http.response.start",
"status": 401,
"headers": [
(b"content-type", b"application/json"),
(b"www-authenticate", b"Bearer"),
],
}
)
await send({"type": "http.response.body", "body": b'{"error":"unauthorized"}'})
return
await self.app(scope, receive, send)
def _make_service(config_path: str | Path | None = None) -> CrmService:
config = load_config(config_path)
client = NbClient(env_name=config.env_name, data_source=config.data_source)
return CrmService(config, client)
def create_server(
*,
crm_service: CrmService | None = None,
config_path: str | Path | None = None,
readonly: bool = True,
host: str = DEFAULT_HTTP_HOST,
port: int = DEFAULT_HTTP_PORT,
streamable_http_path: str = DEFAULT_HTTP_PATH,
allowed_hosts: list[str] | None = None,
allowed_origins: list[str] | None = None,
) -> Any:
if FastMCP is None:
raise RuntimeError("Missing MCP SDK. Install dependencies with: uv sync")
del readonly
service = crm_service or _make_service(config_path)
transport_security = None
if TransportSecuritySettings is not None and (allowed_hosts is not None or allowed_origins is not None):
transport_security = TransportSecuritySettings(
allowed_hosts=allowed_hosts or [],
allowed_origins=allowed_origins or [],
)
server = FastMCP(
"nocobase-crm",
host=host,
port=port,
streamable_http_path=streamable_http_path,
transport_security=transport_security,
)
@server.tool()
def crm_health() -> dict[str, Any]:
"""Check NocoBase CLI reachability and the configured CRM collection mapping."""
return service.crm_health()
@server.tool()
def crm_schema(
include_fields: Annotated[
bool,
Field(description="When true, try to include NocoBase collection and field metadata."),
] = False,
) -> dict[str, Any]:
"""Return the configured CRM collection and field mapping for this MCP server."""
return service.crm_schema(include_fields=include_fields)
@server.tool()
def crm_overview(
date_from: Annotated[
str | None,
Field(description="Optional lower bound for follow-up date, in YYYY-MM-DD or an accepted NocoBase date shortcut."),
] = None,
date_to: Annotated[
str | None,
Field(description="Optional upper bound for follow-up date, in YYYY-MM-DD or an accepted NocoBase date shortcut."),
] = None,
owner: Annotated[
str | None,
Field(description="Optional owner name, username, email, or English name to filter customers and follow-ups."),
] = None,
) -> dict[str, Any]:
"""Return CRM totals, common customer distributions, and recent follow-ups."""
return service.crm_overview(date_from=date_from, date_to=date_to, owner=owner)
@server.tool()
def crm_filter_options(
owner_query: Annotated[
str | None,
Field(description="Optional owner search text. Returns matching user candidates for owner filters."),
] = None,
) -> dict[str, Any]:
"""Return available customer filter values and optional owner candidates."""
return service.crm_filter_options(owner_query=owner_query)
@server.tool()
def resolve_owner(
query: Annotated[str, Field(description="Owner username, nickname, email, or English name to resolve.")],
) -> dict[str, Any]:
"""Return candidate NocoBase users for an owner filter before querying CRM data."""
return service.resolve_owner(query)
if os.environ.get(ENABLE_CONFIG_TOOLS_ENV, "").strip() == "1":
@server.tool()
def crm_config_template() -> dict[str, Any]:
"""Infer a config.json template from collection field metadata without reading business records."""
return service.crm_config_template()
@server.tool()
def list_customers(
query: Annotated[str | None, Field(description="Optional customer keyword search.")] = None,
status: Annotated[str | None, Field(description="Optional exact customer status.")] = None,
owner: Annotated[str | None, Field(description="Optional customer owner name, username, email, or English name.")] = None,
source: Annotated[str | None, Field(description="Optional exact customer source.")] = None,
limit: Annotated[int, Field(description="Maximum rows to return. Defaults to 20.")] = 20,
offset: Annotated[int, Field(description="Zero-based result offset. Defaults to 0.")] = 0,
) -> dict[str, Any]:
"""List CRM customers with pagination metadata."""
return service.list_customers(query=query, status=status, owner=owner, source=source, limit=limit, offset=offset)
@server.tool()
def get_customer(
customer_id: Annotated[Any, Field(description="Customer primary key value.")],
include_contacts: Annotated[bool, Field(description="Include related contacts.")] = True,
include_recent_followups: Annotated[bool, Field(description="Include recent related follow-ups.")] = True,
followup_limit: Annotated[int, Field(description="Maximum related follow-ups to return.")] = 10,
) -> dict[str, Any]:
"""Get one customer and optionally include contacts and recent follow-ups."""
return service.get_customer(
customer_id,
include_contacts=include_contacts,
include_recent_followups=include_recent_followups,
followup_limit=followup_limit,
)
@server.tool()
def list_contacts(
customer_id: Annotated[Any | None, Field(description="Optional customer primary key for relation filtering.")] = None,
query: Annotated[str | None, Field(description="Optional contact keyword search.")] = None,
limit: Annotated[int, Field(description="Maximum rows to return. Defaults to 20.")] = 20,
offset: Annotated[int, Field(description="Zero-based result offset. Defaults to 0.")] = 0,
) -> dict[str, Any]:
"""List CRM contacts, optionally filtered by customer."""
return service.list_contacts(customer_id=customer_id, query=query, limit=limit, offset=offset)
@server.tool()
def list_followups(
customer_id: Annotated[Any | None, Field(description="Optional customer primary key for relation filtering.")] = None,
contact_id: Annotated[Any | None, Field(description="Optional contact primary key for relation filtering.")] = None,
owner: Annotated[str | None, Field(description="Optional follow-up owner name, username, email, or English name.")] = None,
date_from: Annotated[str | None, Field(description="Optional lower follow-up date bound.")] = None,
date_to: Annotated[str | None, Field(description="Optional upper follow-up date bound.")] = None,
limit: Annotated[int, Field(description="Maximum rows to return. Defaults to 20.")] = 20,
offset: Annotated[int, Field(description="Zero-based result offset. Defaults to 0.")] = 0,
) -> dict[str, Any]:
"""List customer follow-up records with pagination metadata."""
return service.list_followups(
customer_id=customer_id,
contact_id=contact_id,
owner=owner,
date_from=date_from,
date_to=date_to,
limit=limit,
offset=offset,
)
return server
def _read_token_from_env(env_name: str) -> str:
token = os.environ.get(env_name, "").strip()
if not token:
raise RuntimeError(f"{env_name} must be set for HTTP MCP transport")
return token
def create_http_app(server: Any, token: str) -> Any:
return BearerTokenAuthMiddleware(server.streamable_http_app(), token)
def run_http_server(
*,
config_path: Path | None,
host: str,
port: int,
token_env: str,
streamable_http_path: str,
allowed_hosts: list[str] | None,
allowed_origins: list[str] | None,
) -> None:
import uvicorn
token = _read_token_from_env(token_env)
server = create_server(
config_path=config_path,
readonly=True,
host=host,
port=port,
streamable_http_path=streamable_http_path,
allowed_hosts=allowed_hosts,
allowed_origins=allowed_origins,
)
app = create_http_app(server, token)
uvicorn.run(app, host=host, port=port, log_level="info")
def main() -> None:
parser = argparse.ArgumentParser(description="Run the read-only NocoBase CRM MCP server.")
parser.add_argument("--config", default=None, type=Path, help="CRM mapping JSON config path.")
parser.add_argument(
"--transport",
choices=["stdio", "streamable-http"],
default="stdio",
help="MCP transport. HTTP mode requires a bearer token.",
)
parser.add_argument("--host", default=DEFAULT_HTTP_HOST, help="HTTP bind host.")
parser.add_argument("--port", default=DEFAULT_HTTP_PORT, type=int, help="HTTP port.")
parser.add_argument("--path", default=DEFAULT_HTTP_PATH, help="Streamable HTTP MCP path.")
parser.add_argument("--token-env", default=DEFAULT_TOKEN_ENV, help="Environment variable containing the HTTP bearer token.")
parser.add_argument("--allowed-host", action="append", dest="allowed_hosts")
parser.add_argument("--allowed-origin", action="append", dest="allowed_origins")
args = parser.parse_args()
if args.transport == "stdio":
create_server(config_path=args.config).run(transport="stdio")
return
run_http_server(
config_path=args.config,
host=args.host,
port=args.port,
token_env=args.token_env,
streamable_http_path=args.path,
allowed_hosts=args.allowed_hosts,
allowed_origins=args.allowed_origins,
)
if __name__ == "__main__":
main()
+20
View File
@@ -0,0 +1,20 @@
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"nocobase-crm": {
"type": "local",
"command": [
"uv",
"run",
"nocobase-crm-mcp",
"--config",
"config.json"
],
"cwd": ".",
"environment": {
"UV_CACHE_DIR": "/tmp/uv-cache"
},
"enabled": true
}
}
}
+18
View File
@@ -0,0 +1,18 @@
[project]
name = "nocobase-crm-mcp"
version = "0.1.0"
description = "Read-only MCP server for querying NocoBase CRM data through nb CLI."
requires-python = ">=3.12"
dependencies = [
"mcp[cli]>=1.27,<2",
]
[project.scripts]
nocobase-crm-mcp = "nocobase_crm_mcp.server:main"
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
include = ["nocobase_crm_mcp*"]
+429
View File
@@ -0,0 +1,429 @@
#!/usr/bin/env python3
"""Manage the user-level read-only HTTP NocoBase CRM MCP systemd service."""
from __future__ import annotations
import argparse
import os
import secrets
import shlex
import shutil
import socket
import subprocess
import urllib.error
import urllib.request
from dataclasses import dataclass
from pathlib import Path
from typing import Sequence
SERVICE_NAME = "nocobase-crm-mcp.service"
ENV_RELATIVE_PATH = Path(".runtime") / "nocobase-crm-mcp.env"
DEFAULT_HOST = "0.0.0.0"
DEFAULT_PORT = 10087
DEFAULT_PATH = "/mcp"
DEFAULT_NB_ENV = "nb-mcp"
DEFAULT_CONFIG_PATH = "config.json"
DEFAULT_TOKEN_BYTES = 32
DEFAULT_UV_CACHE_DIR = "/tmp/uv-cache"
@dataclass(frozen=True)
class ServicePaths:
project_root: Path
unit_path: Path
env_path: Path
def generate_token(num_bytes: int = DEFAULT_TOKEN_BYTES) -> str:
return secrets.token_urlsafe(num_bytes)
def systemd_quote(value: str | Path) -> str:
return shlex.quote(str(value))
def find_uv_path() -> Path:
configured = os.environ.get("UV_PATH", "").strip()
discovered = configured or shutil.which("uv")
if not discovered:
raise RuntimeError("Could not find uv on PATH. Install uv or set UV_PATH to its absolute path.")
return Path(discovered).expanduser().resolve()
def resolve_service_paths(project_root: Path, config_home: Path | None = None) -> ServicePaths:
root = project_root.expanduser().resolve()
base_config = config_home or Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
unit_path = base_config.expanduser() / "systemd" / "user" / SERVICE_NAME
return ServicePaths(project_root=root, unit_path=unit_path, env_path=root / ENV_RELATIVE_PATH)
def render_service_unit(
*,
project_root: Path,
env_file: Path,
host: str = DEFAULT_HOST,
port: int = DEFAULT_PORT,
path: str = DEFAULT_PATH,
uv_cache_dir: str = DEFAULT_UV_CACHE_DIR,
uv_path: Path | None = None,
) -> str:
resolved_uv_path = uv_path or find_uv_path()
command = " ".join(
[
systemd_quote(resolved_uv_path),
"run",
"--no-sync",
"nocobase-crm-mcp",
"--transport",
"streamable-http",
"--host",
host,
"--port",
str(port),
"--path",
path,
]
)
return (
"[Unit]\n"
"Description=NocoBase CRM readonly HTTP MCP server\n"
"After=network-online.target\n"
"Wants=network-online.target\n"
"\n"
"[Service]\n"
"Type=simple\n"
f"WorkingDirectory={systemd_quote(project_root)}\n"
f"EnvironmentFile={systemd_quote(env_file)}\n"
f"Environment=UV_CACHE_DIR={systemd_quote(uv_cache_dir)}\n"
f"ExecStart={command}\n"
"Restart=on-failure\n"
"RestartSec=5\n"
"\n"
"[Install]\n"
"WantedBy=default.target\n"
)
def write_token_env_file(
env_path: Path,
token: str,
*,
host: str = DEFAULT_HOST,
port: int = DEFAULT_PORT,
path: str = DEFAULT_PATH,
nb_env: str = DEFAULT_NB_ENV,
config_path: str = DEFAULT_CONFIG_PATH,
) -> None:
env_path.parent.mkdir(parents=True, exist_ok=True)
env_path.parent.chmod(0o700)
fd = os.open(env_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as env_file:
env_file.write(f"NOCOBASE_MCP_TOKEN={token}\n")
env_file.write(f"NOCOBASE_MCP_HOST={host}\n")
env_file.write(f"NOCOBASE_MCP_PORT={port}\n")
env_file.write(f"NOCOBASE_MCP_PATH={path}\n")
env_file.write(f"NOCOBASE_MCP_ENV={nb_env}\n")
env_file.write(f"NOCOBASE_MCP_CONFIG={config_path}\n")
env_path.chmod(0o600)
def read_service_env_file(env_path: Path) -> dict[str, str]:
if not env_path.exists():
raise RuntimeError(
f"Missing MCP service env file: {env_path}. Run scripts/mcp_service.sh start to create it, "
"or scripts/mcp_service.sh new-token to rotate it."
)
values: dict[str, str] = {}
for raw_line in env_path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#"):
continue
key, separator, value = line.partition("=")
if separator:
values[key.strip()] = value.strip()
return values
def saved_service_config(project_root: Path) -> tuple[str, str, int, str, str, str]:
paths = resolve_service_paths(project_root)
values = read_service_env_file(paths.env_path)
token = values.get("NOCOBASE_MCP_TOKEN", "").strip()
if not token:
raise RuntimeError(f"Missing NOCOBASE_MCP_TOKEN in {paths.env_path}")
host = values.get("NOCOBASE_MCP_HOST") or DEFAULT_HOST
port = int(values.get("NOCOBASE_MCP_PORT") or DEFAULT_PORT)
path = values.get("NOCOBASE_MCP_PATH") or DEFAULT_PATH
nb_env = values.get("NOCOBASE_MCP_ENV") or DEFAULT_NB_ENV
config_path = values.get("NOCOBASE_MCP_CONFIG") or DEFAULT_CONFIG_PATH
return token, host, port, path, nb_env, config_path
def write_service_unit(paths: ServicePaths, *, host: str = DEFAULT_HOST, port: int = DEFAULT_PORT, path: str = DEFAULT_PATH) -> None:
paths.unit_path.parent.mkdir(parents=True, exist_ok=True)
unit = render_service_unit(project_root=paths.project_root, env_file=paths.env_path, host=host, port=port, path=path)
paths.unit_path.write_text(unit, encoding="utf-8")
def detect_remote_hosts() -> list[str]:
hosts: list[str] = []
try:
result = subprocess.run(["hostname", "-I"], capture_output=True, text=True, check=False)
except OSError:
result = None
if result and result.returncode == 0:
hosts.extend(result.stdout.split())
try:
for info in socket.getaddrinfo(socket.gethostname(), None):
hosts.append(info[4][0])
except OSError:
pass
cleaned: list[str] = []
for host in hosts:
normalized = host.strip()
if not normalized or normalized.startswith("127.") or normalized == "::1" or normalized.startswith("fe80:"):
continue
if normalized not in cleaned:
cleaned.append(normalized)
return cleaned
def render_remote_config(
*,
hosts: Sequence[str],
token: str,
port: int = DEFAULT_PORT,
path: str = DEFAULT_PATH,
service_status: str | None = None,
) -> str:
host = hosts[0] if hosts else "<server-ip>"
url = f"http://{host}:{port}{path}"
alternatives = "\n".join(f"- http://{candidate}:{port}{path}" for candidate in hosts[1:])
alternatives_section = f"\nOther detected addresses:\n{alternatives}\n" if alternatives else ""
status_section = f"Service status: {service_status}\n\n" if service_status else ""
return (
"Saved MCP client config.\n"
"\n"
f"{status_section}"
"Use this URL from another machine:\n"
f"{url}\n"
f"{alternatives_section}"
"\n"
"Use this header:\n"
f"Authorization: Bearer {token}\n"
"\n"
"Example MCP client config:\n"
"{\n"
' "mcpServers": {\n'
' "nocobase-crm": {\n'
' "type": "streamable-http",\n'
f' "url": "{url}",\n'
' "headers": {\n'
f' "Authorization": "Bearer {token}"\n'
" }\n"
" }\n"
" }\n"
"}\n"
)
def show_config(project_root: Path) -> str:
token, _host, port, path, _nb_env, _config_path = saved_service_config(project_root)
return render_remote_config(
hosts=detect_remote_hosts(),
token=token,
port=port,
path=path,
service_status=service_state(),
)
def run_systemctl(args: Sequence[str], *, check: bool = True) -> subprocess.CompletedProcess[str]:
return subprocess.run(["systemctl", "--user", *args], text=True, check=check)
def service_state() -> str:
result = subprocess.run(
["systemctl", "--user", "is-active", SERVICE_NAME],
capture_output=True,
text=True,
check=False,
)
state = result.stdout.strip()
if state:
return state
if result.returncode == 0:
return "active"
return "unknown"
def probe_http_service(host: str, port: int, path: str, *, token: str, timeout: float = 2.0) -> dict[str, object]:
url = f"http://{host}:{port}{path}"
request = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"}, method="GET")
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
return {"reachable": True, "status": response.status, "url": url}
except urllib.error.HTTPError as error:
return {"reachable": True, "status": error.code, "url": url}
except OSError as error:
return {"reachable": False, "error": str(error), "url": url}
def restart_service_if_installed(paths: ServicePaths) -> bool:
if not paths.unit_path.exists():
return False
run_systemctl(["restart", SERVICE_NAME])
return True
def new_token(
*,
project_root: Path,
host: str = DEFAULT_HOST,
port: int = DEFAULT_PORT,
path: str = DEFAULT_PATH,
nb_env: str = DEFAULT_NB_ENV,
config_path: str = DEFAULT_CONFIG_PATH,
token_bytes: int = DEFAULT_TOKEN_BYTES,
) -> str:
paths = resolve_service_paths(project_root)
token = generate_token(token_bytes)
write_token_env_file(paths.env_path, token, host=host, port=port, path=path, nb_env=nb_env, config_path=config_path)
restart_service_if_installed(paths)
return render_remote_config(hosts=detect_remote_hosts(), token=token, port=port, path=path, service_status=service_state())
def start_service(*, project_root: Path) -> str:
paths = resolve_service_paths(project_root)
if not paths.env_path.exists():
write_token_env_file(
paths.env_path,
generate_token(),
host=DEFAULT_HOST,
port=DEFAULT_PORT,
path=DEFAULT_PATH,
nb_env=DEFAULT_NB_ENV,
config_path=DEFAULT_CONFIG_PATH,
)
token, host, port, path, _nb_env, _config_path = saved_service_config(project_root)
write_service_unit(paths, host=host, port=port, path=path)
run_systemctl(["daemon-reload"])
run_systemctl(["enable", SERVICE_NAME])
run_systemctl(["restart", SERVICE_NAME])
try:
run_systemctl(["is-active", "--quiet", SERVICE_NAME])
except subprocess.CalledProcessError as error:
raise RuntimeError(f"{SERVICE_NAME} did not become active after restart") from error
probe = probe_http_service("127.0.0.1", port, path, token=token)
if not probe.get("reachable"):
raise RuntimeError(f"{SERVICE_NAME} is active but HTTP probe failed: {probe.get('error')}")
return render_remote_config(hosts=detect_remote_hosts(), token=token, port=port, path=path, service_status="active")
def stop_service() -> None:
run_systemctl(["stop", SERVICE_NAME])
def uninstall_service(project_root: Path) -> None:
paths = resolve_service_paths(project_root)
run_systemctl(["disable", "--now", SERVICE_NAME], check=False)
if paths.unit_path.exists():
paths.unit_path.unlink()
if paths.env_path.exists():
paths.env_path.unlink()
try:
paths.env_path.parent.rmdir()
except OSError:
pass
run_systemctl(["daemon-reload"], check=False)
run_systemctl(["reset-failed", SERVICE_NAME], check=False)
def service_status(*, project_root: Path) -> str:
paths = resolve_service_paths(project_root)
state = service_state()
lines = [
f"Service status: {state}",
f"Unit file: {paths.unit_path}",
f"Env file: {paths.env_path}",
]
if paths.env_path.exists():
token, _host, port, path, _nb_env, _config_path = saved_service_config(project_root)
hosts = detect_remote_hosts()
host = hosts[0] if hosts else "<server-ip>"
lines.append(f"MCP URL: http://{host}:{port}{path}")
probe = probe_http_service("127.0.0.1", port, path, token=token)
if probe.get("reachable"):
lines.append(f"HTTP probe: reachable (status {probe.get('status')})")
else:
lines.append(f"HTTP probe: not reachable ({probe.get('error')})")
else:
lines.append("MCP URL: unavailable until first start creates the env file")
lines.append("HTTP probe: skipped")
return "\n".join(lines) + "\n"
def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Manage the readonly HTTP NocoBase CRM MCP user service.")
subparsers = parser.add_subparsers(dest="command", required=True)
new_token_parser = subparsers.add_parser("new-token", help="Generate a new token and print remote MCP client configuration.")
new_token_parser.add_argument("--project-root", default=Path(__file__).resolve().parent.parent, type=Path)
new_token_parser.add_argument("--host", default=DEFAULT_HOST)
new_token_parser.add_argument("--port", default=DEFAULT_PORT, type=int)
new_token_parser.add_argument("--path", default=DEFAULT_PATH)
new_token_parser.add_argument("--nb-env", default=DEFAULT_NB_ENV)
new_token_parser.add_argument("--config-path", default=DEFAULT_CONFIG_PATH)
new_token_parser.add_argument("--token-bytes", default=DEFAULT_TOKEN_BYTES, type=int)
start_parser = subparsers.add_parser("start", help="Install or update the user service using the saved token, then start it.")
start_parser.add_argument("--project-root", default=Path(__file__).resolve().parent.parent, type=Path)
subparsers.add_parser("stop", help="Stop the user service without deleting service files.")
uninstall_parser = subparsers.add_parser("uninstall", help="Stop and remove the managed user service and token file.")
uninstall_parser.add_argument("--project-root", default=Path(__file__).resolve().parent.parent, type=Path)
show_config_parser = subparsers.add_parser("show-config", help="Print the saved token and remote MCP client configuration.")
show_config_parser.add_argument("--project-root", default=Path(__file__).resolve().parent.parent, type=Path)
status_parser = subparsers.add_parser("status", help="Show systemd status for the managed service.")
status_parser.add_argument("--project-root", default=Path(__file__).resolve().parent.parent, type=Path)
args = parser.parse_args(argv)
if args.command == "new-token":
print(
new_token(
project_root=args.project_root,
host=args.host,
port=args.port,
path=args.path,
nb_env=args.nb_env,
config_path=args.config_path,
token_bytes=args.token_bytes,
),
end="",
)
return 0
if args.command == "start":
print(start_service(project_root=args.project_root), end="")
return 0
if args.command == "stop":
stop_service()
print(f"Stopped {SERVICE_NAME}.")
return 0
if args.command == "uninstall":
uninstall_service(args.project_root)
print(f"Uninstalled {SERVICE_NAME}.")
return 0
if args.command == "show-config":
print(show_config(args.project_root), end="")
return 0
if args.command == "status":
print(service_status(project_root=args.project_root), end="")
return 0
raise AssertionError(f"unhandled command: {args.command}")
if __name__ == "__main__":
raise SystemExit(main())
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
if [[ $# -eq 0 ]]; then
exec python3 "$PROJECT_ROOT/scripts/manage_mcp_service.py" --help
fi
COMMAND="$1"
shift
case "$COMMAND" in
new-token | start | show-config | status | uninstall)
exec python3 "$PROJECT_ROOT/scripts/manage_mcp_service.py" "$COMMAND" --project-root "$PROJECT_ROOT" "$@"
;;
*)
exec python3 "$PROJECT_ROOT/scripts/manage_mcp_service.py" "$COMMAND" "$@"
;;
esac
+167
View File
@@ -0,0 +1,167 @@
import json
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from nocobase_crm_mcp.config import load_config
from nocobase_crm_mcp.filters import (
build_and_filter,
build_date_range_condition,
build_relation_id_condition,
build_relation_id_or_condition,
build_search_filter,
)
class ConfigTests(unittest.TestCase):
def test_load_config_uses_crm_defaults(self) -> None:
with patch.dict(os.environ, {}, clear=True):
config = load_config(None)
self.assertEqual(config.env_name, "nb-mcp")
self.assertEqual(config.data_source, "main")
self.assertEqual(config.customer.resource, "customers")
self.assertEqual(config.contact.resource, "contacts")
self.assertEqual(config.followup.resource, "followups")
self.assertIn("name", config.customer.search_fields)
self.assertEqual(config.contact.customer_field, "customer")
self.assertEqual(config.followup.customer_field, "customer")
def test_load_config_merges_file_and_environment_overrides(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
config_path = Path(tmp) / "crm.json"
config_path.write_text(
json.dumps(
{
"env": "file-env",
"dataSource": "analytics",
"collections": {
"customers": {
"resource": "crmAccounts",
"fields": ["id", "displayName"],
"searchFields": ["displayName", "shortName"],
},
"contacts": {"resource": "crmContacts", "customerField": "account"},
},
}
),
encoding="utf-8",
)
with patch.dict(os.environ, {"NOCOBASE_MCP_ENV": "env-override"}, clear=True):
config = load_config(config_path)
self.assertEqual(config.env_name, "env-override")
self.assertEqual(config.data_source, "analytics")
self.assertEqual(config.customer.resource, "crmAccounts")
self.assertEqual(config.customer.fields, ["id", "displayName"])
self.assertEqual(config.customer.search_fields, ["displayName", "shortName"])
self.assertEqual(config.contact.resource, "crmContacts")
self.assertEqual(config.contact.customer_field, "account")
self.assertEqual(config.followup.resource, "followups")
def test_load_config_supports_user_and_multiple_followup_owner_fields(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
config_path = Path(tmp) / "crm.json"
config_path.write_text(
json.dumps(
{
"users": {
"resource": "staff",
"fields": ["id", "username", "nickname"],
"matchFields": ["username", "nickname"],
},
"collections": {
"followups": {
"ownerFields": ["participants", "createdBy"],
},
},
}
),
encoding="utf-8",
)
config = load_config(config_path)
self.assertEqual(config.user.resource, "staff")
self.assertEqual(config.user.fields, ["id", "username", "nickname"])
self.assertEqual(config.user.match_fields, ["username", "nickname"])
self.assertEqual(config.followup.owner_fields, ["participants", "createdBy"])
class FilterBuilderTests(unittest.TestCase):
def test_build_search_filter_uses_or_includes_conditions(self) -> None:
self.assertEqual(
build_search_filter("Acme", ["name", "code"]),
{
"$or": [
{"name": {"$includes": "Acme"}},
{"code": {"$includes": "Acme"}},
]
},
)
def test_build_and_filter_wraps_conditions_and_skips_empty_values(self) -> None:
result = build_and_filter(
[
{"status": {"$eq": "active"}},
None,
{},
build_search_filter("Acme", ["name"]),
]
)
self.assertEqual(
result,
{
"$and": [
{"status": {"$eq": "active"}},
{"$or": [{"name": {"$includes": "Acme"}}]},
]
},
)
def test_build_date_range_condition_uses_nocobase_date_between(self) -> None:
self.assertEqual(
build_date_range_condition("followedAt", "2026-07-01", "2026-07-08"),
{"followedAt": {"$dateBetween": ["2026-07-01", "2026-07-08"]}},
)
self.assertEqual(
build_date_range_condition("followedAt", "2026-07-01", None),
{"followedAt": {"$dateNotBefore": "2026-07-01"}},
)
self.assertEqual(
build_date_range_condition("followedAt", None, "2026-07-08"),
{"followedAt": {"$dateNotAfter": "2026-07-08"}},
)
def test_build_relation_id_condition_supports_nested_dimension_paths(self) -> None:
self.assertEqual(
build_relation_id_condition("kehu_followup_person", 54),
{"kehu_followup_person": {"id": {"$eq": 54}}},
)
self.assertEqual(
build_relation_id_condition("kehu_followup_person.id", 54),
{"kehu_followup_person": {"id": {"$eq": 54}}},
)
self.assertEqual(
build_relation_id_condition(["kehu_followup_person", "id"], 54),
{"kehu_followup_person": {"id": {"$eq": 54}}},
)
def test_build_relation_id_or_condition_filters_multiple_owner_fields(self) -> None:
self.assertEqual(
build_relation_id_or_condition(["gjjl_followup_person", "createdBy"], 54),
{
"$or": [
{"gjjl_followup_person": {"id": {"$eq": 54}}},
{"createdBy": {"id": {"$eq": 54}}},
]
},
)
if __name__ == "__main__":
unittest.main()
+330
View File
@@ -0,0 +1,330 @@
import unittest
import json
import tempfile
from pathlib import Path
from nocobase_crm_mcp.config import load_config
from nocobase_crm_mcp.crm import CrmService
class FakeNbClient:
def __init__(self) -> None:
self.list_calls = []
self.get_calls = []
self.query_calls = []
self.field_calls = []
def health(self):
return {"ok": True, "status": "reachable"}
def list_records(self, resource, **kwargs):
self.list_calls.append((resource, kwargs))
if resource == "users":
return {
"data": [
{
"id": 54,
"username": "alice",
"nickname": "Alice Wang",
"email": "alice@example.com",
"f_yh_english_name": "Alice Wang",
}
],
"meta": {"count": 1},
}
if resource == "followups":
return {
"data": [
{
"id": 1,
"f_srysmjkwfq1": "2026-07-08T09:00:00.000Z",
"f_19iplxz0nds": "Called customer about sample status",
"gjjl_client": [{"id": 7, "f_q2q60q7bqbg": "Acme China"}],
"gjjl_client_contacts": [{"id": 8, "f_2zpg91gtmwp": "Alice Contact"}],
"gjjl_followup_person": [{"id": 54, "nickname": "Alice Wang"}],
"createdBy": {"id": 54, "nickname": "Alice Wang"},
}
],
"meta": {"count": 1},
}
return {"data": [{"id": 1, "name": "Acme"}], "meta": {"count": 1}}
def get_record(self, resource, filter_by_tk, **kwargs):
self.get_calls.append((resource, filter_by_tk, kwargs))
return {"data": {"id": filter_by_tk, "name": "Acme", "f_q2q60q7bqbg": "Acme China"}}
def query_records(self, resource, **kwargs):
self.query_calls.append((resource, kwargs))
dimensions = kwargs.get("dimensions") or []
alias = dimensions[0].get("alias") if dimensions else None
if alias in {"status", "kh_progress"}:
return {"data": [{"count": 3, "kh_progress": "kh_p_inprogress"}]}
if alias in {"source", "f_225h6n6v654"}:
return {"data": [{"count": 2, "f_225h6n6v654": "客户推荐"}]}
if alias == "owner_id":
return {"data": [{"count": 1, "owner_id": 54}]}
if alias == "kehu_followup_person_id":
return {"data": [{"count": 1, "kehu_followup_person_id": 54}]}
return {"data": [{"count": 3}]}
def list_collection_fields(self, collection_name, **kwargs):
self.field_calls.append((collection_name, kwargs))
if collection_name == "contacts":
return {
"data": [
{"name": "id", "title": "ID", "type": "bigInt"},
{"name": "f_2zpg91gtmwp", "title": "联系人姓名", "type": "string"},
{"name": "khlxr_phone_number", "title": "联系人电话", "type": "string"},
{"name": "khlxr_email", "title": "联系人邮箱", "type": "string"},
{"name": "kehulianxiren_client", "title": "公司", "type": "belongsToMany"},
]
}
if collection_name == "followups":
return {
"data": [
{"name": "id", "title": "跟进ID", "type": "bigInt"},
{"name": "gjjl_client", "title": "客户", "type": "belongsToMany"},
{"name": "gjjl_client_contacts", "title": "客户联系人", "type": "belongsToMany"},
{"name": "gjjl_followup_person", "title": "参与跟进人员", "type": "belongsToMany"},
{"name": "createdBy", "title": "{{t(\"Created by\")}}", "type": "belongsTo"},
{"name": "f_srysmjkwfq1", "title": "跟进时间", "type": "date"},
{"name": "f_19iplxz0nds", "title": "跟进内容", "type": "text"},
]
}
return {
"data": [
{"name": "id", "title": "ID", "type": "bigInt"},
{"name": "f_q2q60q7bqbg", "title": "中文名称", "type": "string"},
{"name": "f_kh_english_name", "title": "英文名称", "type": "string"},
{"name": "f_territorial_name", "title": "属地名称", "type": "string"},
{"name": "kh_progress", "title": "进度", "type": "string"},
{"name": "f_225h6n6v654", "title": "客户来源", "type": "string"},
{"name": "kehu_followup_person", "title": "跟进人", "type": "belongsToMany"},
]
}
class CrmServiceTests(unittest.TestCase):
def test_list_customers_applies_configured_fields_and_filters(self) -> None:
client = FakeNbClient()
service = CrmService(load_config(None), client)
result = service.list_customers(query="Acme", status="active", owner="Alice", limit=10, offset=20)
self.assertEqual(result["results"], [{"id": 1, "name": "Acme"}])
self.assertEqual(result["total"], 1)
self.assertEqual(client.list_calls[0][0], "users")
resource, kwargs = client.list_calls[1]
self.assertEqual(resource, "customers")
self.assertEqual(kwargs["fields"], load_config(None).customer.fields)
self.assertEqual(kwargs["page_size"], 10)
self.assertEqual(kwargs["page"], 3)
self.assertEqual(
kwargs["filter"],
{
"$and": [
{"status": {"$eq": "active"}},
{"owner": {"id": {"$eq": 54}}},
{
"$or": [
{"name": {"$includes": "Acme"}},
{"code": {"$includes": "Acme"}},
]
},
]
},
)
def test_get_customer_loads_related_contacts_and_followups(self) -> None:
client = FakeNbClient()
service = CrmService(load_config(None), client)
result = service.get_customer(7, include_contacts=True, include_recent_followups=True, followup_limit=5)
self.assertEqual(result["customer"]["id"], 7)
self.assertEqual(result["customer"]["name"], "Acme")
self.assertEqual(result["contacts"], [{"id": 1, "name": "Acme"}])
self.assertEqual(result["recent_followups"][0]["id"], 1)
self.assertEqual(result["recent_followups"][0]["summary"]["content"], "Called customer about sample status")
self.assertEqual(client.get_calls[0][0], "customers")
self.assertEqual(client.list_calls[0][0], "contacts")
self.assertEqual(client.list_calls[0][1]["filter"], {"$and": [{"customer": {"id": {"$eq": 7}}}]})
self.assertEqual(client.list_calls[1][0], "followups")
self.assertEqual(client.list_calls[1][1]["page_size"], 5)
def test_crm_overview_uses_count_and_group_queries(self) -> None:
client = FakeNbClient()
service = CrmService(load_config(None), client)
result = service.crm_overview(owner="Alice")
self.assertEqual(result["totals"]["customers"], 3)
self.assertEqual(result["totals"]["contacts"], 3)
self.assertEqual(result["totals"]["followups"], 3)
self.assertEqual(result["customer_status"][0]["display"], "进行中")
self.assertEqual(result["customer_owner"][0]["owner_id"], 54)
self.assertGreaterEqual(len(client.query_calls), 3)
self.assertEqual(client.query_calls[0][0], "customers")
self.assertEqual(client.query_calls[0][1]["measures"][0]["aggregation"], "count")
def test_crm_schema_include_fields_reads_each_configured_collection_fields(self) -> None:
client = FakeNbClient()
service = CrmService(load_config(None), client)
schema = service.crm_schema(include_fields=True)
self.assertEqual(
[call[0] for call in client.field_calls],
["customers", "contacts", "followups"],
)
self.assertEqual(client.field_calls[0][1].get("fields"), None)
self.assertEqual(schema["metadata"]["fields"]["customers"][1]["name"], "f_q2q60q7bqbg")
self.assertEqual(schema["metadata"]["field_errors"], {})
def test_crm_schema_include_fields_reports_metadata_acl_hint(self) -> None:
class ForbiddenFieldsClient(FakeNbClient):
def list_collection_fields(self, collection_name, **kwargs):
raise Exception("Request failed with status 403: No permissions")
service = CrmService(load_config(None), ForbiddenFieldsClient())
schema = service.crm_schema(include_fields=True)
self.assertIn("metadata_permission_required", schema["metadata"]["field_errors"]["customers"]["code"])
self.assertIn("data-modeling collections fields list", schema["metadata"]["field_errors"]["customers"]["hint"])
def test_crm_config_template_infers_mapping_from_metadata(self) -> None:
client = FakeNbClient()
service = CrmService(load_config(None), client)
template = service.crm_config_template()
self.assertEqual(
template["collections"]["customers"]["searchFields"],
["f_q2q60q7bqbg", "f_kh_english_name", "f_territorial_name"],
)
self.assertEqual(template["collections"]["customers"]["statusField"], "kh_progress")
self.assertEqual(template["collections"]["customers"]["sourceField"], "f_225h6n6v654")
self.assertEqual(template["collections"]["customers"]["ownerField"], "kehu_followup_person")
self.assertEqual(template["collections"]["contacts"]["customerField"], "kehulianxiren_client")
self.assertEqual(template["collections"]["followups"]["customerField"], "gjjl_client")
self.assertEqual(template["collections"]["followups"]["contactField"], "gjjl_client_contacts")
self.assertEqual(template["collections"]["followups"]["ownerFields"], ["gjjl_followup_person", "createdBy"])
self.assertEqual(template["collections"]["followups"]["dateField"], "f_srysmjkwfq1")
def test_list_customers_resolves_owner_name_to_relation_id_filter(self) -> None:
client = FakeNbClient()
service = CrmService(load_config(None), client)
service.list_customers(owner="alice")
self.assertEqual(client.list_calls[0][0], "users")
self.assertEqual(
client.list_calls[1][1]["filter"],
{"$and": [{"owner": {"id": {"$eq": 54}}}]},
)
def test_list_followups_filters_owner_by_participant_or_creator(self) -> None:
client = FakeNbClient()
with tempfile.TemporaryDirectory() as tmp:
config_path = Path(tmp) / "crm.json"
config_path.write_text(
json.dumps(
{
"collections": {
"followups": {
"ownerFields": ["gjjl_followup_person", "createdBy"],
}
}
}
),
encoding="utf-8",
)
service = CrmService(load_config(config_path), client)
service.list_followups(owner="alice")
self.assertEqual(client.list_calls[0][0], "users")
self.assertEqual(
client.list_calls[1][1]["filter"],
{
"$and": [
{
"$or": [
{"gjjl_followup_person": {"id": {"$eq": 54}}},
{"createdBy": {"id": {"$eq": 54}}},
]
}
]
},
)
def test_owner_resolution_returns_structured_errors_for_no_or_ambiguous_matches(self) -> None:
class NoUserClient(FakeNbClient):
def list_records(self, resource, **kwargs):
self.list_calls.append((resource, kwargs))
if resource == "users":
return {"data": [], "meta": {"count": 0}}
return super().list_records(resource, **kwargs)
missing = CrmService(load_config(None), NoUserClient()).list_customers(owner="missing")
self.assertEqual(missing["error"]["code"], "owner_not_found")
self.assertEqual(missing["error"]["query"], "missing")
class AmbiguousUserClient(FakeNbClient):
def list_records(self, resource, **kwargs):
self.list_calls.append((resource, kwargs))
if resource == "users":
return {
"data": [
{"id": 1, "username": "alice", "nickname": "Alice"},
{"id": 2, "username": "alice2", "nickname": "Alice"},
],
"meta": {"count": 2},
}
return super().list_records(resource, **kwargs)
ambiguous = CrmService(load_config(None), AmbiguousUserClient()).list_customers(owner="alice")
self.assertEqual(ambiguous["error"]["code"], "owner_ambiguous")
self.assertEqual(len(ambiguous["error"]["candidates"]), 2)
def test_resolve_owner_returns_candidate_users(self) -> None:
result = CrmService(load_config(None), FakeNbClient()).resolve_owner("alice")
self.assertEqual(result["query"], "alice")
self.assertEqual(result["results"][0]["id"], 54)
self.assertEqual(result["results"][0]["label"], "Alice Wang")
def test_crm_filter_options_exposes_filter_values_and_owner_candidates(self) -> None:
client = FakeNbClient()
result = CrmService(load_config(None), client).crm_filter_options(owner_query="alice")
self.assertEqual(result["customer_status"][0]["value"], "kh_p_inprogress")
self.assertEqual(result["customer_status"][0]["label"], "进行中")
self.assertEqual(result["customer_source"][0]["value"], "客户推荐")
self.assertEqual(result["owner_candidates"][0]["id"], 54)
def test_list_tools_clamp_limit_and_offset_for_remote_agents(self) -> None:
client = FakeNbClient()
service = CrmService(load_config(None), client)
result = service.list_customers(limit=10000, offset=-50)
self.assertEqual(result["limit"], 100)
self.assertEqual(result["offset"], 0)
self.assertEqual(client.list_calls[0][1]["page_size"], 100)
self.assertEqual(client.list_calls[0][1]["page"], 1)
def test_list_followups_adds_human_readable_summary_without_dropping_raw_fields(self) -> None:
result = CrmService(load_config(None), FakeNbClient()).list_followups(limit=1)
row = result["results"][0]
self.assertEqual(row["f_19iplxz0nds"], "Called customer about sample status")
self.assertEqual(row["summary"]["content"], "Called customer about sample status")
self.assertEqual(row["summary"]["followedAt"], "2026-07-08T09:00:00.000Z")
self.assertEqual(row["summary"]["customer_names"], ["Acme China"])
self.assertEqual(row["summary"]["owner_names"], ["Alice Wang"])
if __name__ == "__main__":
unittest.main()
+264
View File
@@ -0,0 +1,264 @@
import os
import stat
import subprocess
import tempfile
import unittest
from pathlib import Path
from unittest.mock import call, patch
import sys
SCRIPTS_DIR = Path(__file__).resolve().parent.parent / "scripts"
if str(SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPTS_DIR))
import manage_mcp_service
class McpServiceConfigTests(unittest.TestCase):
def test_service_management_has_one_shell_entrypoint(self) -> None:
entrypoint = SCRIPTS_DIR / "mcp_service.sh"
self.assertTrue(entrypoint.exists())
content = entrypoint.read_text(encoding="utf-8")
self.assertIn("manage_mcp_service.py", content)
self.assertIn('exec python3 "$PROJECT_ROOT/scripts/manage_mcp_service.py"', content)
self.assertFalse((SCRIPTS_DIR / "new_mcp_service_token.sh").exists())
self.assertFalse((SCRIPTS_DIR / "start_mcp_service.sh").exists())
self.assertFalse((SCRIPTS_DIR / "show_mcp_service_config.sh").exists())
self.assertFalse((SCRIPTS_DIR / "stop_mcp_service.sh").exists())
self.assertFalse((SCRIPTS_DIR / "uninstall_mcp_service.sh").exists())
def test_missing_env_message_points_to_start_and_token_rotation(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
env_path = Path(tmp) / ".runtime" / "nocobase-crm-mcp.env"
with self.assertRaisesRegex(RuntimeError, r"scripts/mcp_service\.sh start"):
manage_mcp_service.read_service_env_file(env_path)
def test_render_service_unit_uses_http_defaults_without_embedding_token(self) -> None:
unit = manage_mcp_service.render_service_unit(
project_root=Path("/opt/nocobase-mcp"),
env_file=Path("/opt/nocobase-mcp/.runtime/nocobase-crm-mcp.env"),
uv_path=Path("/home/nac/.local/bin/uv"),
)
self.assertIn("WorkingDirectory=/opt/nocobase-mcp", unit)
self.assertIn("EnvironmentFile=/opt/nocobase-mcp/.runtime/nocobase-crm-mcp.env", unit)
self.assertIn("/home/nac/.local/bin/uv run --no-sync nocobase-crm-mcp", unit)
self.assertIn("--transport streamable-http", unit)
self.assertIn("--host 0.0.0.0", unit)
self.assertIn("--port 10087", unit)
self.assertNotIn("NOCOBASE_MCP_TOKEN=", unit)
def test_write_token_env_file_is_private_and_contains_runtime_settings(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
env_path = Path(tmp) / ".runtime" / "nocobase-crm-mcp.env"
manage_mcp_service.write_token_env_file(
env_path,
"test-token",
host="0.0.0.0",
port=10087,
path="/mcp",
nb_env="nb-mcp",
config_path="config.json",
)
content = env_path.read_text(encoding="utf-8")
self.assertIn("NOCOBASE_MCP_TOKEN=test-token\n", content)
self.assertIn("NOCOBASE_MCP_HOST=0.0.0.0\n", content)
self.assertIn("NOCOBASE_MCP_PORT=10087\n", content)
self.assertIn("NOCOBASE_MCP_PATH=/mcp\n", content)
self.assertIn("NOCOBASE_MCP_ENV=nb-mcp\n", content)
self.assertIn("NOCOBASE_MCP_CONFIG=config.json\n", content)
self.assertEqual(stat.S_IMODE(os.stat(env_path).st_mode), 0o600)
self.assertEqual(stat.S_IMODE(os.stat(env_path.parent).st_mode), 0o700)
def test_render_remote_config_prints_url_and_authorization_header(self) -> None:
output = manage_mcp_service.render_remote_config(
hosts=["192.168.1.10"],
token="test-token",
port=10087,
path="/mcp",
service_status="inactive",
)
self.assertIn("http://192.168.1.10:10087/mcp", output)
self.assertIn("Authorization: Bearer test-token", output)
self.assertIn('"nocobase-crm"', output)
self.assertIn('"type": "streamable-http"', output)
self.assertIn("Service status: inactive", output)
self.assertNotIn("MCP service is running.", output)
def test_new_token_writes_token_without_touching_systemd_when_service_is_not_installed(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
project_root = Path(tmp) / "project"
project_root.mkdir()
config_home = Path(tmp) / "config"
with (
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}),
patch.object(manage_mcp_service, "generate_token", return_value="new-token"),
patch.object(manage_mcp_service, "detect_remote_hosts", return_value=["10.0.0.2"]),
patch.object(manage_mcp_service, "run_systemctl") as run_systemctl,
):
output = manage_mcp_service.new_token(project_root=project_root)
env_path = project_root / ".runtime" / "nocobase-crm-mcp.env"
env_content = env_path.read_text(encoding="utf-8")
self.assertIn("NOCOBASE_MCP_TOKEN=new-token\n", env_content)
self.assertIn("NOCOBASE_MCP_PORT=10087\n", env_content)
self.assertIn("http://10.0.0.2:10087/mcp", output)
run_systemctl.assert_not_called()
def test_start_service_uses_saved_token_and_restarts_service(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
project_root = Path(tmp) / "project"
project_root.mkdir()
config_home = Path(tmp) / "config"
manage_mcp_service.write_token_env_file(
project_root / ".runtime" / "nocobase-crm-mcp.env",
"saved-token",
host="0.0.0.0",
port=10087,
path="/mcp",
nb_env="nb-mcp",
config_path="config.json",
)
with (
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}),
patch.object(manage_mcp_service, "detect_remote_hosts", return_value=["10.0.0.2"]),
patch.object(manage_mcp_service, "find_uv_path", return_value=Path("/home/nac/.local/bin/uv")),
patch.object(manage_mcp_service, "run_systemctl") as run_systemctl,
patch.object(
manage_mcp_service,
"probe_http_service",
return_value={"reachable": True, "status": 401},
create=True,
) as probe_http_service,
):
output = manage_mcp_service.start_service(project_root=project_root)
unit_path = config_home / "systemd" / "user" / manage_mcp_service.SERVICE_NAME
unit_content = unit_path.read_text(encoding="utf-8")
self.assertIn("/home/nac/.local/bin/uv run --no-sync nocobase-crm-mcp", unit_content)
self.assertIn("EnvironmentFile=", unit_content)
self.assertIn("http://10.0.0.2:10087/mcp", output)
self.assertIn("Bearer saved-token", output)
run_systemctl.assert_has_calls(
[
call(["daemon-reload"]),
call(["enable", manage_mcp_service.SERVICE_NAME]),
call(["restart", manage_mcp_service.SERVICE_NAME]),
call(["is-active", "--quiet", manage_mcp_service.SERVICE_NAME]),
]
)
probe_http_service.assert_called_once_with("127.0.0.1", 10087, "/mcp", token="saved-token")
def test_start_service_generates_token_on_first_run(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
project_root = Path(tmp) / "project"
project_root.mkdir()
config_home = Path(tmp) / "config"
with (
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}),
patch.object(manage_mcp_service, "generate_token", return_value="first-token"),
patch.object(manage_mcp_service, "detect_remote_hosts", return_value=["10.0.0.2"]),
patch.object(manage_mcp_service, "find_uv_path", return_value=Path("/home/nac/.local/bin/uv")),
patch.object(manage_mcp_service, "run_systemctl") as run_systemctl,
patch.object(
manage_mcp_service,
"probe_http_service",
return_value={"reachable": True, "status": 401},
create=True,
) as probe_http_service,
):
output = manage_mcp_service.start_service(project_root=project_root)
env_path = project_root / ".runtime" / "nocobase-crm-mcp.env"
self.assertIn("NOCOBASE_MCP_TOKEN=first-token\n", env_path.read_text(encoding="utf-8"))
self.assertIn("Bearer first-token", output)
unit_path = config_home / "systemd" / "user" / manage_mcp_service.SERVICE_NAME
self.assertTrue(unit_path.exists())
run_systemctl.assert_has_calls(
[
call(["daemon-reload"]),
call(["enable", manage_mcp_service.SERVICE_NAME]),
call(["restart", manage_mcp_service.SERVICE_NAME]),
call(["is-active", "--quiet", manage_mcp_service.SERVICE_NAME]),
]
)
probe_http_service.assert_called_once_with("127.0.0.1", 10087, "/mcp", token="first-token")
def test_start_service_fails_when_systemd_does_not_become_active(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
project_root = Path(tmp) / "project"
project_root.mkdir()
config_home = Path(tmp) / "config"
manage_mcp_service.write_token_env_file(
project_root / ".runtime" / "nocobase-crm-mcp.env",
"saved-token",
)
def fake_systemctl(args, *, check=True):
if args[:2] == ["is-active", "--quiet"]:
raise subprocess.CalledProcessError(3, ["systemctl", "--user", *args])
return subprocess.CompletedProcess(["systemctl", "--user", *args], 0)
with (
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}),
patch.object(manage_mcp_service, "find_uv_path", return_value=Path("/home/nac/.local/bin/uv")),
patch.object(manage_mcp_service, "run_systemctl", side_effect=fake_systemctl),
):
with self.assertRaisesRegex(RuntimeError, "did not become active"):
manage_mcp_service.start_service(project_root=project_root)
def test_show_config_reports_saved_config_not_running_claim(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
project_root = Path(tmp) / "project"
project_root.mkdir()
manage_mcp_service.write_token_env_file(
project_root / ".runtime" / "nocobase-crm-mcp.env",
"saved-token",
)
with (
patch.object(manage_mcp_service, "detect_remote_hosts", return_value=["10.0.0.2"]),
patch.object(manage_mcp_service, "service_state", return_value="inactive", create=True),
):
output = manage_mcp_service.show_config(project_root)
self.assertIn("Saved MCP client config.", output)
self.assertIn("Service status: inactive", output)
self.assertNotIn("MCP service is running.", output)
def test_service_status_output_includes_paths_url_and_probe_state(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
project_root = Path(tmp) / "project"
project_root.mkdir()
manage_mcp_service.write_token_env_file(
project_root / ".runtime" / "nocobase-crm-mcp.env",
"saved-token",
port=10087,
path="/mcp",
)
with (
patch.object(manage_mcp_service, "detect_remote_hosts", return_value=["10.0.0.2"]),
patch.object(manage_mcp_service, "service_state", return_value="active", create=True),
patch.object(
manage_mcp_service,
"probe_http_service",
return_value={"reachable": True, "status": 401},
create=True,
),
):
output = manage_mcp_service.service_status(project_root=project_root)
self.assertIn("Service status: active", output)
self.assertIn("Unit file:", output)
self.assertIn("Env file:", output)
self.assertIn("http://10.0.0.2:10087/mcp", output)
self.assertIn("HTTP probe: reachable", output)
if __name__ == "__main__":
unittest.main()
+171
View File
@@ -0,0 +1,171 @@
import json
import os
import subprocess
import unittest
from unittest.mock import patch
from nocobase_crm_mcp.nb_client import NbClient, NbCommandError
class NbClientTests(unittest.TestCase):
def test_list_records_builds_safe_nb_command_and_environment(self) -> None:
calls: list[dict] = []
def runner(cmd, *, cwd, env, timeout, capture_output, text, check):
calls.append(
{
"cmd": cmd,
"cwd": cwd,
"env": env,
"timeout": timeout,
"capture_output": capture_output,
"text": text,
"check": check,
}
)
return subprocess.CompletedProcess(cmd, 0, stdout='{"data":[{"id":1}],"meta":{"count":1}}', stderr="")
client = NbClient(env_name="nb-mcp", data_source="main", runner=runner)
result = client.list_records(
"customers",
filter={"$and": [{"status": {"$eq": "active"}}]},
fields=["id", "name"],
sort=["-updatedAt"],
page=2,
page_size=20,
)
self.assertEqual(result["data"], [{"id": 1}])
command = calls[0]["cmd"]
self.assertEqual(command[:4], ["nb", "api", "resource", "list"])
self.assertIn("--resource", command)
self.assertIn("customers", command)
self.assertIn("--env", command)
self.assertIn("nb-mcp", command)
self.assertIn("--yes", command)
self.assertIn("--json-output", command)
self.assertIn("--data-source", command)
self.assertIn("main", command)
self.assertIn("--filter", command)
self.assertIn(json.dumps({"$and": [{"status": {"$eq": "active"}}]}, separators=(",", ":")), command)
self.assertIn("--fields", command)
self.assertIn("id", command)
self.assertIn("--sort", command)
self.assertIn("-updatedAt", command)
self.assertEqual(calls[0]["env"]["NB_CLI_LOG_DISABLED"], "1")
self.assertEqual(calls[0]["env"]["NB_SKIP_STARTUP_UPDATE"], "1")
self.assertTrue(calls[0]["capture_output"])
self.assertTrue(calls[0]["text"])
self.assertFalse(calls[0]["check"])
def test_get_record_and_query_records_use_expected_commands(self) -> None:
commands: list[list[str]] = []
def runner(cmd, **kwargs):
commands.append(cmd)
return subprocess.CompletedProcess(cmd, 0, stdout='{"data":[]}', stderr="")
client = NbClient(env_name="nb-mcp", runner=runner)
client.get_record("customers", 7, fields=["id"])
client.query_records(
"customers",
measures=[{"field": ["id"], "aggregation": "count", "alias": "count"}],
dimensions=[{"field": ["status"], "alias": "status"}],
orders=[{"field": ["status"], "alias": "status", "order": "asc"}],
limit=10,
timezone="+08:00",
)
self.assertEqual(commands[0][:4], ["nb", "api", "resource", "get"])
self.assertIn("--filter-by-tk", commands[0])
self.assertIn("7", commands[0])
self.assertEqual(commands[1][:4], ["nb", "api", "resource", "query"])
self.assertIn("--measures", commands[1])
self.assertIn("--dimensions", commands[1])
self.assertIn("--orders", commands[1])
self.assertIn("--timezone", commands[1])
def test_list_collection_fields_uses_data_modeling_command(self) -> None:
commands: list[list[str]] = []
def runner(cmd, **kwargs):
commands.append(cmd)
return subprocess.CompletedProcess(
cmd,
0,
stdout='{"data":[{"name":"f_customer_name","title":"客户名称","interface":"input"}]}',
stderr="",
)
result = NbClient(env_name="nb-mcp", runner=runner).list_collection_fields("t_nt3pjadelqi")
self.assertEqual(result["data"][0]["name"], "f_customer_name")
self.assertEqual(
commands[0][:6],
["nb", "api", "data-modeling", "collections", "fields", "list"],
)
self.assertIn("--collection-name", commands[0])
self.assertIn("t_nt3pjadelqi", commands[0])
self.assertIn("--env", commands[0])
self.assertIn("nb-mcp", commands[0])
self.assertIn("--yes", commands[0])
self.assertIn("--json-output", commands[0])
def test_get_collection_uses_data_modeling_command(self) -> None:
commands: list[list[str]] = []
def runner(cmd, **kwargs):
commands.append(cmd)
return subprocess.CompletedProcess(cmd, 0, stdout='{"data":{"name":"t_nt3pjadelqi"}}', stderr="")
NbClient(env_name="nb-mcp", runner=runner).get_collection("t_nt3pjadelqi", appends=["fields"])
self.assertEqual(commands[0][:5], ["nb", "api", "data-modeling", "collections", "get"])
self.assertIn("--filter-by-tk", commands[0])
self.assertIn("t_nt3pjadelqi", commands[0])
self.assertIn("--appends", commands[0])
self.assertIn("fields", commands[0])
def test_command_errors_redact_api_token(self) -> None:
def runner(cmd, **kwargs):
return subprocess.CompletedProcess(
cmd,
1,
stdout="",
stderr="Authorization failed for Bearer super-secret-token and --token super-secret-token",
)
client = NbClient(env_name="nb-mcp", api_token="super-secret-token", runner=runner)
with self.assertRaises(NbCommandError) as caught:
client.list_records("customers")
message = str(caught.exception)
self.assertNotIn("super-secret-token", message)
self.assertIn("<redacted>", message)
def test_invalid_json_output_raises_command_error(self) -> None:
def runner(cmd, **kwargs):
return subprocess.CompletedProcess(cmd, 0, stdout="HTTP 200", stderr="")
client = NbClient(env_name="nb-mcp", runner=runner)
with self.assertRaises(NbCommandError):
client.list_records("customers")
def test_environment_overrides_can_change_nb_home_without_shell(self) -> None:
captured_env = {}
def runner(cmd, *, env, **kwargs):
captured_env.update(env)
return subprocess.CompletedProcess(cmd, 0, stdout='{"data":[]}', stderr="")
with patch.dict(os.environ, {"NB_CLI_ROOT": "/tmp/custom-nb-root"}, clear=True):
NbClient(env_name="nb-mcp", runner=runner).list_records("customers")
self.assertEqual(captured_env["NB_CLI_ROOT"], "/tmp/custom-nb-root")
if __name__ == "__main__":
unittest.main()
+126
View File
@@ -0,0 +1,126 @@
import asyncio
import unittest
from unittest.mock import patch
from nocobase_crm_mcp.server import BearerTokenAuthMiddleware, FastMCP, create_http_app, create_server
async def _ok_app(scope, receive, send):
await send({"type": "http.response.start", "status": 200, "headers": []})
await send({"type": "http.response.body", "body": b"ok"})
def run_asgi_request(app, *, authorization=None, method="POST", path="/mcp"):
events = []
headers = []
if authorization is not None:
headers.append((b"authorization", authorization.encode("latin1")))
async def receive():
return {"type": "http.request", "body": b"", "more_body": False}
async def send(message):
events.append(message)
scope = {"type": "http", "method": method, "path": path, "headers": headers}
asyncio.run(app(scope, receive, send))
return events
class FakeCrmService:
def crm_health(self):
return {"ok": True}
def crm_schema(self, include_fields=False):
return {"include_fields": include_fields}
def crm_overview(self, **kwargs):
return {"totals": {"customers": 1}}
def crm_config_template(self):
return {"collections": {}}
def crm_filter_options(self, **kwargs):
return {"customer_status": []}
def resolve_owner(self, **kwargs):
return {"results": []}
def list_customers(self, **kwargs):
return {"results": []}
def get_customer(self, **kwargs):
return {"customer": None}
def list_contacts(self, **kwargs):
return {"results": []}
def list_followups(self, **kwargs):
return {"results": []}
class ServerTests(unittest.TestCase):
def test_bearer_token_middleware_rejects_missing_or_wrong_token(self) -> None:
app = BearerTokenAuthMiddleware(_ok_app, "secret")
self.assertEqual(run_asgi_request(app)[0]["status"], 401)
self.assertEqual(run_asgi_request(app, authorization="Bearer wrong")[0]["status"], 401)
def test_bearer_token_middleware_allows_matching_token(self) -> None:
app = BearerTokenAuthMiddleware(_ok_app, "secret")
events = run_asgi_request(app, authorization="Bearer secret")
self.assertEqual(events[0]["status"], 200)
self.assertEqual(events[1]["body"], b"ok")
def test_bearer_token_middleware_allows_options_preflight_without_token(self) -> None:
app = BearerTokenAuthMiddleware(_ok_app, "secret")
events = run_asgi_request(app, method="OPTIONS")
self.assertEqual(events[0]["status"], 204)
def test_readonly_server_exposes_crm_tools_only(self) -> None:
if FastMCP is None:
self.skipTest("MCP SDK is not installed")
server = create_server(crm_service=FakeCrmService(), readonly=True)
tools = asyncio.run(server.list_tools())
self.assertEqual(
{tool.name for tool in tools},
{
"crm_health",
"crm_schema",
"crm_overview",
"crm_filter_options",
"resolve_owner",
"list_customers",
"get_customer",
"list_contacts",
"list_followups",
},
)
def test_config_template_tool_is_opt_in(self) -> None:
if FastMCP is None:
self.skipTest("MCP SDK is not installed")
with patch.dict("os.environ", {"NOCOBASE_MCP_ENABLE_CONFIG_TOOLS": "1"}):
server = create_server(crm_service=FakeCrmService(), readonly=True)
tools = asyncio.run(server.list_tools())
self.assertIn("crm_config_template", {tool.name for tool in tools})
def test_create_http_app_requires_bearer_token(self) -> None:
if FastMCP is None:
self.skipTest("MCP SDK is not installed")
server = create_server(crm_service=FakeCrmService(), readonly=True)
with self.assertRaises(ValueError):
create_http_app(server, "")
if __name__ == "__main__":
unittest.main()
Generated
+739
View File
@@ -0,0 +1,739 @@
version = 1
revision = 3
requires-python = ">=3.12"
resolution-markers = [
"python_full_version >= '3.14' and sys_platform == 'win32'",
"python_full_version >= '3.14' and sys_platform != 'win32'",
"python_full_version < '3.14' and sys_platform == 'win32'",
"python_full_version < '3.14' and sys_platform != 'win32'",
]
[[package]]
name = "annotated-doc"
version = "0.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
]
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[package]]
name = "anyio"
version = "4.14.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" },
]
[[package]]
name = "attrs"
version = "26.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
]
[[package]]
name = "certifi"
version = "2026.6.17"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" },
]
[[package]]
name = "cffi"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/85/990925db5df586ec90beb97529c853497e7f85ba0234830447faf41c3057/cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f", size = 184829, upload-time = "2026-07-06T21:32:44.324Z" },
{ url = "https://files.pythonhosted.org/packages/4b/92/e7bb136ad6b5352603732cf907ef862ca103f20f2031c1735a46300c20c9/cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde", size = 184728, upload-time = "2026-07-06T21:32:45.683Z" },
{ url = "https://files.pythonhosted.org/packages/c3/c0/d1ec30ffb370f748f2fb54425972bfef9871e0132e82fb589c46b6676049/cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d", size = 214815, upload-time = "2026-07-06T21:32:48.557Z" },
{ url = "https://files.pythonhosted.org/packages/1b/dc/5620cf930688be01f2d673804291de757a934c90b946dbdc3d84130c2ea4/cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7", size = 222429, upload-time = "2026-07-06T21:32:49.848Z" },
{ url = "https://files.pythonhosted.org/packages/4b/a4/77b53abbf7a1e0beb9637edbef2a94d15f9c822f591e85d439ffd91519a6/cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b", size = 210315, upload-time = "2026-07-06T21:32:51.221Z" },
{ url = "https://files.pythonhosted.org/packages/58/0c/f528df19cc94b675087324d4760d9e6d5bfae97d6217aa4fac43de4f5fcc/cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7", size = 208859, upload-time = "2026-07-06T21:32:52.512Z" },
{ url = "https://files.pythonhosted.org/packages/62/f2/c9522a81c32132799a1972c39f5c5f8b4c8b9f00488a23feaa6c06f07741/cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66", size = 221844, upload-time = "2026-07-06T21:32:53.704Z" },
{ url = "https://files.pythonhosted.org/packages/6e/28/bd53988b9833e8f8ad539d26f4c07a6b3f6bcb1e9e02e7ca038250b3428d/cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe", size = 225287, upload-time = "2026-07-06T21:32:54.907Z" },
{ url = "https://files.pythonhosted.org/packages/79/99/0d0fd37f055224085f42bbb2c022d002e17dde4a97972822327b07d84101/cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b", size = 223681, upload-time = "2026-07-06T21:32:56.329Z" },
{ url = "https://files.pythonhosted.org/packages/b0/80/c138990aa2a70b1a269f6e06348729836d733d6f970867943f61d367f8cc/cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a", size = 175269, upload-time = "2026-07-06T21:32:57.777Z" },
{ url = "https://files.pythonhosted.org/packages/a8/eb/f636456ff21a83fc13c032b58cc5dde061691546ac79efa284b2989b7982/cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384", size = 185881, upload-time = "2026-07-06T21:32:59.253Z" },
{ url = "https://files.pythonhosted.org/packages/dd/2c/400ea43e721727dca8a65c4521390e9196757caba4a45643acb2b63271b8/cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6", size = 180088, upload-time = "2026-07-06T21:33:02.278Z" },
{ url = "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", size = 194331, upload-time = "2026-07-06T21:33:03.697Z" },
{ url = "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", size = 196966, upload-time = "2026-07-06T21:33:05.353Z" },
{ url = "https://files.pythonhosted.org/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", size = 184795, upload-time = "2026-07-06T21:33:06.699Z" },
{ url = "https://files.pythonhosted.org/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", size = 184746, upload-time = "2026-07-06T21:33:08.071Z" },
{ url = "https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", size = 214747, upload-time = "2026-07-06T21:33:09.671Z" },
{ url = "https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", size = 222392, upload-time = "2026-07-06T21:33:10.896Z" },
{ url = "https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", size = 210285, upload-time = "2026-07-06T21:33:12.251Z" },
{ url = "https://files.pythonhosted.org/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", size = 208801, upload-time = "2026-07-06T21:33:13.617Z" },
{ url = "https://files.pythonhosted.org/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", size = 221808, upload-time = "2026-07-06T21:33:15.466Z" },
{ url = "https://files.pythonhosted.org/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", size = 225241, upload-time = "2026-07-06T21:33:16.869Z" },
{ url = "https://files.pythonhosted.org/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", size = 223588, upload-time = "2026-07-06T21:33:18.239Z" },
{ url = "https://files.pythonhosted.org/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", size = 175248, upload-time = "2026-07-06T21:33:19.799Z" },
{ url = "https://files.pythonhosted.org/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", size = 185717, upload-time = "2026-07-06T21:33:21.47Z" },
{ url = "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", size = 180114, upload-time = "2026-07-06T21:33:22.515Z" },
{ url = "https://files.pythonhosted.org/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a", size = 194142, upload-time = "2026-07-06T21:33:23.657Z" },
{ url = "https://files.pythonhosted.org/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3", size = 196819, upload-time = "2026-07-06T21:33:25.007Z" },
{ url = "https://files.pythonhosted.org/packages/20/71/7c8372d30e42415602ed9f268f7cfd66f1b855fed881ecd168bcb45dbc0b/cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d", size = 184965, upload-time = "2026-07-06T21:33:26.605Z" },
{ url = "https://files.pythonhosted.org/packages/d6/5c/584e626835f0375c928176c04137c96927165cb8733cdb3150ec04e5ee5e/cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac", size = 184952, upload-time = "2026-07-06T21:33:27.823Z" },
{ url = "https://files.pythonhosted.org/packages/2e/d2/065fcae1c73979fac8e054462478d0ff8a29c40cdc2ed7ea5676a061df53/cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6", size = 222353, upload-time = "2026-07-06T21:33:29.178Z" },
{ url = "https://files.pythonhosted.org/packages/ed/a5/e8bbb1ce5b3ac2f53ad6a10bde44318a5a8d99d4f4a000d44a6e39aeb3e4/cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913", size = 210051, upload-time = "2026-07-06T21:33:30.534Z" },
{ url = "https://files.pythonhosted.org/packages/28/ed/c127d3ac36e899c965e3361357c3befacd6578c03f40125183e41c3b219e/cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d", size = 208630, upload-time = "2026-07-06T21:33:31.753Z" },
{ url = "https://files.pythonhosted.org/packages/cc/d7/97d3136f81db489ec8d1d67748c110d6c994268fd7528014aa9f2b085e4e/cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5", size = 221593, upload-time = "2026-07-06T21:33:33.044Z" },
{ url = "https://files.pythonhosted.org/packages/d3/27/93195977168ee63aed233a1a0993a2178798654d1f4bddcdd321d6fd3b21/cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce", size = 225146, upload-time = "2026-07-06T21:33:34.224Z" },
{ url = "https://files.pythonhosted.org/packages/b3/c1/6dbd291ee2ae5a50a034aa057207081f545923bbf15dad4511e985aafff5/cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326", size = 223240, upload-time = "2026-07-06T21:33:35.57Z" },
{ url = "https://files.pythonhosted.org/packages/0f/6f/ade5ce9863a57992a6ea3d0d10d7e29b8749fc127204b3d493d667b2815f/cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd", size = 177723, upload-time = "2026-07-06T21:33:51.626Z" },
{ url = "https://files.pythonhosted.org/packages/41/de/92b9eeed4ae4a21d6fd9b2a2c8505cbed573299902ea73981cc13f7ff62c/cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb", size = 187937, upload-time = "2026-07-06T21:33:53.403Z" },
{ url = "https://files.pythonhosted.org/packages/2e/1a/cc6ae6c2913a03aab8898eee57963cf1035b8df5872ed8b9115fcc7e2be8/cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804", size = 183001, upload-time = "2026-07-06T21:33:54.74Z" },
{ url = "https://files.pythonhosted.org/packages/14/f0/134c00ce0779ec86dea2aa1aac69339c2741a8045072676763512363a2ea/cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714", size = 188538, upload-time = "2026-07-06T21:33:36.792Z" },
{ url = "https://files.pythonhosted.org/packages/50/d8/3b86aba791cb610d24e8a3e1b2cd529e71fa15096b04e4d4e360049d4a4c/cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376", size = 188230, upload-time = "2026-07-06T21:33:38.011Z" },
{ url = "https://files.pythonhosted.org/packages/14/d0/117dcd9209255ad8571fbc8c92ef32593a1d294dcec91ddc4e4db50606f2/cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98", size = 223899, upload-time = "2026-07-06T21:33:39.514Z" },
{ url = "https://files.pythonhosted.org/packages/b6/3d/f20f8b886b254e3ad10e15cd4186d3aed49f3e6a35ab37aab9f8f25f7c03/cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13", size = 211652, upload-time = "2026-07-06T21:33:40.851Z" },
{ url = "https://files.pythonhosted.org/packages/28/3b/fad54de07260b93ddeef4b96d0131d57ea900675df1d410ae1deee52d7a6/cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d", size = 210755, upload-time = "2026-07-06T21:33:42.183Z" },
{ url = "https://files.pythonhosted.org/packages/cc/82/3d5c705acb7abbba9bbd7d79b8e62e0f25b6120eb7ae6ac49f1b721722fe/cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056", size = 223933, upload-time = "2026-07-06T21:33:43.603Z" },
{ url = "https://files.pythonhosted.org/packages/6c/d0/47e338384ab6b1004241002fa616301020cea4fc95f283506565d252f276/cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4", size = 226749, upload-time = "2026-07-06T21:33:45.046Z" },
{ url = "https://files.pythonhosted.org/packages/70/25/65bd5b58ea4bfdfc15cde02cb5365f89ef8ab8b2adfb8fe5c4bd4233382f/cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94", size = 225703, upload-time = "2026-07-06T21:33:46.374Z" },
{ url = "https://files.pythonhosted.org/packages/dc/78/aa01ac599a8a4322533d45a1f9bc93b338276d2d59dabbe7c6d92a775c81/cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76", size = 182857, upload-time = "2026-07-06T21:33:47.696Z" },
{ url = "https://files.pythonhosted.org/packages/b9/26/d00496b22de4d4228f32dde94ad996f350c8aad676d63bcca0743c8dea4d/cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5", size = 194065, upload-time = "2026-07-06T21:33:48.953Z" },
{ url = "https://files.pythonhosted.org/packages/d5/dd/0c7dbf815a579ff005008a2d815a55d6bb047c349eef536d9dc53d3f0a8d/cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8", size = 186404, upload-time = "2026-07-06T21:33:50.309Z" },
{ url = "https://files.pythonhosted.org/packages/55/c7/8c8c50cb11c6750051daf12164098a9a6f027ac4356967fd4d800a07f242/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c", size = 194121, upload-time = "2026-07-06T21:33:56.109Z" },
{ url = "https://files.pythonhosted.org/packages/99/e2/67680bf19a6b60d2bb7ff83baefa2a4c3d2d7dc0f3277034b802e1fc504c/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001", size = 196820, upload-time = "2026-07-06T21:33:57.288Z" },
{ url = "https://files.pythonhosted.org/packages/ed/da/4bbe583a3b3a5c8c60892124fe17f3fa3656523faf0d3484eae90f091853/cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3", size = 184936, upload-time = "2026-07-06T21:33:58.765Z" },
{ url = "https://files.pythonhosted.org/packages/e5/4b/1f4c36ab273980d7aa75bb126ea4f8971f24a96108acad3a0a084028c57b/cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc", size = 185045, upload-time = "2026-07-06T21:34:00.085Z" },
{ url = "https://files.pythonhosted.org/packages/ef/c3/ad299dc38f3583f8d916b299f028af418a9ec98bc695fcbebeae7420691c/cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699", size = 222342, upload-time = "2026-07-06T21:34:01.814Z" },
{ url = "https://files.pythonhosted.org/packages/eb/d8/df4543cc087245044ed02ef3ad8e0a26619d0075ac7a77a12dc81177851b/cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022", size = 210073, upload-time = "2026-07-06T21:34:03.255Z" },
{ url = "https://files.pythonhosted.org/packages/2c/0e/fac738d73728c6cea2a88a2883dca54892496cbba88a1dc1f2909cb8a6f5/cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0", size = 208551, upload-time = "2026-07-06T21:34:04.433Z" },
{ url = "https://files.pythonhosted.org/packages/e6/3f/0b04a700dd64f465c93020253a793a82c9b4dff9961f48facd0df945d9b8/cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1", size = 221649, upload-time = "2026-07-06T21:34:06.157Z" },
{ url = "https://files.pythonhosted.org/packages/5d/7c/b7379a5704c79eda57ce075869ba70a0368d1c850f803b3c0d078d39dcaf/cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28", size = 225203, upload-time = "2026-07-06T21:34:07.489Z" },
{ url = "https://files.pythonhosted.org/packages/5a/02/d5e6c43ea85c41bda2a184a3418f195fe7cf602967a8d2b94e085b83deef/cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629", size = 223263, upload-time = "2026-07-06T21:34:08.712Z" },
{ url = "https://files.pythonhosted.org/packages/2c/d8/772b8259bf75749adffb1c546828978381fb516f60cf701f6c83daf60c85/cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6", size = 177696, upload-time = "2026-07-06T21:34:26.355Z" },
{ url = "https://files.pythonhosted.org/packages/2f/dd/afa2191fc6d57fedd26e5844a2fe2fcc0bbfa00961bbaa5a41e4921e7cca/cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853", size = 187914, upload-time = "2026-07-06T21:34:27.58Z" },
{ url = "https://files.pythonhosted.org/packages/05/ef/6cd4f8c671517162379dc79cfae5aea9106bc38abb89628d5c16adf6a838/cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda", size = 183004, upload-time = "2026-07-06T21:34:28.905Z" },
{ url = "https://files.pythonhosted.org/packages/11/b6/12fc55092817a5faa26fb8c40c7f9d662e11a46ee248c137aafc42517d92/cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc", size = 188378, upload-time = "2026-07-06T21:34:09.926Z" },
{ url = "https://files.pythonhosted.org/packages/8d/2e/cdac88979f295fde5daa69622c7d2111e56e7ceb94f211357fbe452339e4/cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca", size = 188319, upload-time = "2026-07-06T21:34:11.101Z" },
{ url = "https://files.pythonhosted.org/packages/e0/27/1d0b408497e41a74795af122d7b603c418c5fed0171450f899afd04e594f/cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d", size = 223904, upload-time = "2026-07-06T21:34:12.606Z" },
{ url = "https://files.pythonhosted.org/packages/8b/31/e115c985105dd7ffb32444505f18ceb874bb42d992af05d5dced7ecf1980/cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8", size = 211554, upload-time = "2026-07-06T21:34:13.987Z" },
{ url = "https://files.pythonhosted.org/packages/5a/67/9e6e09409336d9e515c58367e7cfcf4f89df06ad25252675595a58eb59d5/cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd", size = 210795, upload-time = "2026-07-06T21:34:15.972Z" },
{ url = "https://files.pythonhosted.org/packages/19/e5/d3cc82a4a0be7902af279c04181ad038449c096734464a5ae1de3e1401bd/cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f", size = 223843, upload-time = "2026-07-06T21:34:17.509Z" },
{ url = "https://files.pythonhosted.org/packages/b9/65/b434abc97ce7cecc2c640fde160507c0ecc7e21544b483ba3325d2e2ea17/cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc", size = 226773, upload-time = "2026-07-06T21:34:19.05Z" },
{ url = "https://files.pythonhosted.org/packages/b5/9f/d4dc66ca651eb1145a133314cda721abf13cfac3d28c4a0402263ae6ad75/cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9", size = 225719, upload-time = "2026-07-06T21:34:20.576Z" },
{ url = "https://files.pythonhosted.org/packages/68/5a/e536c528bc8057496c360c0978559a2dc45653f89dd6151078aa7d8fca1a/cffi-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b", size = 182760, upload-time = "2026-07-06T21:34:22.059Z" },
{ url = "https://files.pythonhosted.org/packages/d3/0b/0ffe8b82d3875bced5fa1e7986a7a46b748262a40ab7f60b475eb9fb1bb3/cffi-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5", size = 193769, upload-time = "2026-07-06T21:34:23.589Z" },
{ url = "https://files.pythonhosted.org/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", size = 186405, upload-time = "2026-07-06T21:34:24.857Z" },
]
[[package]]
name = "click"
version = "8.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "cryptography"
version = "49.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" },
{ url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" },
{ url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" },
{ url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" },
{ url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" },
{ url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" },
{ url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" },
{ url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" },
{ url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" },
{ url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" },
{ url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" },
{ url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" },
{ url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" },
{ url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" },
{ url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" },
{ url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" },
{ url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" },
{ url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" },
{ url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" },
{ url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" },
{ url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" },
{ url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" },
{ url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" },
{ url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" },
{ url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" },
{ url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" },
{ url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" },
{ url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" },
{ url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" },
{ url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" },
{ url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" },
{ url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" },
{ url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" },
{ url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" },
{ url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" },
{ url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" },
{ url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" },
{ url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" },
{ url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "httpx-sse"
version = "0.4.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" },
]
[[package]]
name = "idna"
version = "3.18"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
]
[[package]]
name = "jsonschema"
version = "4.26.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "jsonschema-specifications" },
{ name = "referencing" },
{ name = "rpds-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" },
]
[[package]]
name = "jsonschema-specifications"
version = "2025.9.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "referencing" },
]
sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
]
[[package]]
name = "markdown-it-py"
version = "4.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
]
[[package]]
name = "mcp"
version = "1.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "httpx" },
{ name = "httpx-sse" },
{ name = "jsonschema" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "pyjwt", extra = ["crypto"] },
{ name = "python-multipart" },
{ name = "pywin32", marker = "sys_platform == 'win32'" },
{ name = "sse-starlette" },
{ name = "starlette" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6e/77/9450b8f251a13affb6281997d0523c4615f8a8b35d0b21ff30db3a5aac9d/mcp-1.28.1.tar.gz", hash = "sha256:d51e36a5f5644faea4f85ea649bfffa6bc6c26770d42798ad6a3de3d2ba69683", size = 638501, upload-time = "2026-06-26T12:57:29.093Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e2/5e/d118fce19f87a2e7d8101c35c8ae0ec289098a4df0ff244cec23e415aca0/mcp-1.28.1-py3-none-any.whl", hash = "sha256:2726bca5e7193f61c5dde8b12500a6de2d9acf6d1a1c0be9e8c2e706437991df", size = 222620, upload-time = "2026-06-26T12:57:27.218Z" },
]
[package.optional-dependencies]
cli = [
{ name = "python-dotenv" },
{ name = "typer" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "nocobase-crm-mcp"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "mcp", extra = ["cli"] },
]
[package.metadata]
requires-dist = [{ name = "mcp", extras = ["cli"], specifier = ">=1.27,<2" }]
[[package]]
name = "pycparser"
version = "3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
]
[[package]]
name = "pydantic"
version = "2.13.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
]
[[package]]
name = "pydantic-core"
version = "2.46.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" },
{ url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" },
{ url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" },
{ url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" },
{ url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" },
{ url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" },
{ url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" },
{ url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" },
{ url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" },
{ url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" },
{ url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" },
{ url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" },
{ url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" },
{ url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" },
{ url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" },
{ url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" },
{ url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" },
{ url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" },
{ url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" },
{ url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" },
{ url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" },
{ url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" },
{ url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" },
{ url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" },
{ url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" },
{ url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" },
{ url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" },
{ url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" },
{ url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" },
{ url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" },
{ url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" },
{ url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" },
{ url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" },
{ url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" },
{ url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" },
{ url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" },
{ url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" },
{ url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" },
{ url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" },
{ url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" },
{ url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" },
{ url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" },
{ url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" },
{ url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" },
{ url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" },
{ url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" },
{ url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" },
{ url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" },
{ url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" },
{ url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" },
{ url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" },
{ url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" },
{ url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" },
{ url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" },
{ url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" },
{ url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" },
{ url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" },
{ url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" },
{ url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" },
{ url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" },
{ url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" },
{ url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" },
{ url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" },
]
[[package]]
name = "pydantic-settings"
version = "2.14.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pyjwt"
version = "2.13.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" },
]
[package.optional-dependencies]
crypto = [
{ name = "cryptography" },
]
[[package]]
name = "python-dotenv"
version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
]
[[package]]
name = "python-multipart"
version = "0.0.32"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" },
]
[[package]]
name = "pywin32"
version = "312"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" },
{ url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" },
{ url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" },
{ url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" },
{ url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" },
{ url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" },
{ url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" },
{ url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" },
{ url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" },
{ url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" },
{ url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" },
{ url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" },
]
[[package]]
name = "referencing"
version = "0.37.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "rpds-py" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" },
]
[[package]]
name = "rich"
version = "15.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
]
[[package]]
name = "rpds-py"
version = "2026.6.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" },
{ url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" },
{ url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" },
{ url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" },
{ url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" },
{ url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" },
{ url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" },
{ url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" },
{ url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" },
{ url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" },
{ url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" },
{ url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" },
{ url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" },
{ url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" },
{ url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" },
{ url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" },
{ url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" },
{ url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" },
{ url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" },
{ url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" },
{ url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" },
{ url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" },
{ url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" },
{ url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" },
{ url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" },
{ url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" },
{ url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" },
{ url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" },
{ url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" },
{ url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" },
{ url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" },
{ url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" },
{ url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" },
{ url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" },
{ url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" },
{ url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" },
{ url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" },
{ url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" },
{ url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" },
{ url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" },
{ url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" },
{ url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" },
{ url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" },
{ url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" },
{ url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" },
{ url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" },
{ url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" },
{ url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" },
{ url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" },
{ url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" },
{ url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" },
{ url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" },
{ url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" },
{ url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" },
{ url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" },
{ url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" },
{ url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" },
{ url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" },
{ url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" },
{ url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" },
{ url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" },
{ url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" },
{ url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" },
{ url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" },
{ url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" },
{ url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" },
{ url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" },
{ url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" },
{ url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" },
{ url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" },
{ url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" },
{ url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" },
{ url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" },
{ url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" },
{ url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" },
{ url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" },
{ url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" },
{ url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" },
{ url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" },
{ url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" },
{ url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" },
{ url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" },
{ url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" },
{ url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" },
{ url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" },
{ url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" },
{ url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" },
]
[[package]]
name = "shellingham"
version = "1.5.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
]
[[package]]
name = "sse-starlette"
version = "3.4.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "starlette" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d2/1b/bc9e3e7a72dcdad7dc7888758f5d00f56f8909ed5cfdff822bd72bb4c520/sse_starlette-3.4.5.tar.gz", hash = "sha256:83072538bc211a2f68b7b0422226c4af3e9b62e106e07034664b832ca019842a", size = 35249, upload-time = "2026-06-20T17:36:58.322Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/75/c88d3f5dafd59c791da1ce27650d30bf5b70cbf1cbf01cd00e5f9e360915/sse_starlette-3.4.5-py3-none-any.whl", hash = "sha256:e71bad53323f65573c3864a6c3bd0c1eb6e5f092b2e48082b0c35927d19ca296", size = 16518, upload-time = "2026-06-20T17:36:56.729Z" },
]
[[package]]
name = "starlette"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" },
]
[[package]]
name = "typer"
version = "0.26.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "rich" },
{ name = "shellingham" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7c/f7/68adc395201b20b872d68e975386832e8005ffeacedd43a1d837a32815be/typer-0.26.8.tar.gz", hash = "sha256:c244a6bd558886fe3f8780efb6bdd28bb9aff005a94eedebaa5cb32926fe2f7e", size = 202097, upload-time = "2026-06-26T09:22:45.705Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/80/87/b9fd69c92c6102a066e1b86a35243f53e70bd4c709f2a26d9f4fee4f4dc0/typer-0.26.8-py3-none-any.whl", hash = "sha256:3512ca79ac5c11113414b36e80281b872884477722440691c89d1112e321a49c", size = 122564, upload-time = "2026-06-26T09:22:44.72Z" },
]
[[package]]
name = "typing-extensions"
version = "4.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]
[[package]]
name = "uvicorn"
version = "0.51.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a2/65/b7c6c443ccc58678c91e1e973bbe2a878591538655d6e1d47f24ba1c51f3/uvicorn-0.51.0.tar.gz", hash = "sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0", size = 94412, upload-time = "2026-07-08T10:59:05.962Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/45/ec/dbb7e5a6b91f86bfb9eb7d2988a2730907b6a729875b949c7f022e8b88fa/uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b", size = 73219, upload-time = "2026-07-08T10:59:04.44Z" },
]