Initial NocoBase CRM MCP server
This commit is contained in:
@@ -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
@@ -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/
|
||||
@@ -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.
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Read-only MCP access to a NocoBase CRM."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -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")),
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)])
|
||||
@@ -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()
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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*"]
|
||||
Executable
+429
@@ -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())
|
||||
Executable
+19
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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" },
|
||||
]
|
||||
Reference in New Issue
Block a user