xyfra-sdk 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- xyfra_sdk-1.0.0/.gitignore +137 -0
- xyfra_sdk-1.0.0/PKG-INFO +49 -0
- xyfra_sdk-1.0.0/README.md +34 -0
- xyfra_sdk-1.0.0/pyproject.toml +29 -0
- xyfra_sdk-1.0.0/scripts/gen_graphql_client.py +58 -0
- xyfra_sdk-1.0.0/scripts/gen_mcp_client.py +68 -0
- xyfra_sdk-1.0.0/scripts/gen_rest_client.py +51 -0
- xyfra_sdk-1.0.0/tests/test_roundtrip.py +14 -0
- xyfra_sdk-1.0.0/xyfra_sdk/__init__.py +4 -0
- xyfra_sdk-1.0.0/xyfra_sdk/auth.py +20 -0
- xyfra_sdk-1.0.0/xyfra_sdk/client.py +273 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# =========================
|
|
2
|
+
# .NET / C# Build Artifacts
|
|
3
|
+
# =========================
|
|
4
|
+
bin/
|
|
5
|
+
obj/
|
|
6
|
+
[Dd]ebug/
|
|
7
|
+
[Rr]elease/
|
|
8
|
+
x64/
|
|
9
|
+
x86/
|
|
10
|
+
*.dll
|
|
11
|
+
*.exe
|
|
12
|
+
*.pdb
|
|
13
|
+
*.user
|
|
14
|
+
*.userosscache
|
|
15
|
+
*.sln.docstates
|
|
16
|
+
|
|
17
|
+
# FEAT-EXPR-ACCEL v1 Bundle C — BenchmarkDotNet output (generated
|
|
18
|
+
# locally by `dotnet run -c Release --project ...Tests.Bench`).
|
|
19
|
+
# Not source-controlled; engineers regenerate per local hardware.
|
|
20
|
+
BenchmarkDotNet.Artifacts/
|
|
21
|
+
|
|
22
|
+
# NuGet Packages
|
|
23
|
+
*.nupkg
|
|
24
|
+
**/packages/*
|
|
25
|
+
!**/packages/build/
|
|
26
|
+
|
|
27
|
+
# =========================
|
|
28
|
+
# Node.js / Next.js
|
|
29
|
+
# =========================
|
|
30
|
+
node_modules/
|
|
31
|
+
.next/
|
|
32
|
+
out/
|
|
33
|
+
.pnpm-debug.log*
|
|
34
|
+
npm-debug.log*
|
|
35
|
+
yarn-debug.log*
|
|
36
|
+
yarn-error.log*
|
|
37
|
+
|
|
38
|
+
# =========================
|
|
39
|
+
# IDE / Editor Files
|
|
40
|
+
# =========================
|
|
41
|
+
.idea/
|
|
42
|
+
.vscode/
|
|
43
|
+
*.swp
|
|
44
|
+
*.swo
|
|
45
|
+
*~
|
|
46
|
+
.vs/
|
|
47
|
+
|
|
48
|
+
# =========================
|
|
49
|
+
# Environment & Secrets
|
|
50
|
+
# =========================
|
|
51
|
+
.env
|
|
52
|
+
.env.local
|
|
53
|
+
.env.development.local
|
|
54
|
+
.env.test.local
|
|
55
|
+
.env.production.local
|
|
56
|
+
*.env
|
|
57
|
+
!.env.example
|
|
58
|
+
appsettings.*.local.json
|
|
59
|
+
|
|
60
|
+
# Secrets baseline (for detect-secrets pre-commit hook)
|
|
61
|
+
.secrets.baseline
|
|
62
|
+
|
|
63
|
+
# =========================
|
|
64
|
+
# Logs
|
|
65
|
+
# =========================
|
|
66
|
+
logs/
|
|
67
|
+
*.log
|
|
68
|
+
|
|
69
|
+
# =========================
|
|
70
|
+
# Test Results & Coverage
|
|
71
|
+
# =========================
|
|
72
|
+
TestResults/
|
|
73
|
+
test-results/
|
|
74
|
+
coverage/
|
|
75
|
+
*.trx
|
|
76
|
+
*.coverage
|
|
77
|
+
*.coveragexml
|
|
78
|
+
|
|
79
|
+
# =========================
|
|
80
|
+
# OS Generated Files
|
|
81
|
+
# =========================
|
|
82
|
+
.DS_Store
|
|
83
|
+
.DS_Store?
|
|
84
|
+
._*
|
|
85
|
+
Thumbs.db
|
|
86
|
+
ehthumbs.db
|
|
87
|
+
Desktop.ini
|
|
88
|
+
|
|
89
|
+
# =========================
|
|
90
|
+
# Backup Files
|
|
91
|
+
# =========================
|
|
92
|
+
docs_backup_*/
|
|
93
|
+
*.bak
|
|
94
|
+
*.backup
|
|
95
|
+
|
|
96
|
+
# =========================
|
|
97
|
+
# Process ID Files
|
|
98
|
+
# =========================
|
|
99
|
+
.pids/
|
|
100
|
+
|
|
101
|
+
# =========================
|
|
102
|
+
# Misc
|
|
103
|
+
# =========================
|
|
104
|
+
*.tmp
|
|
105
|
+
*.temp
|
|
106
|
+
*.cache
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# IDE / agent working directories
|
|
110
|
+
.claude/
|
|
111
|
+
# .cursor/* ignores immediate children (mcp.json, etc.) without blocking git
|
|
112
|
+
# from descending into subdirectories. The negation rules below then
|
|
113
|
+
# selectively re-include team-facing config (skills + rules).
|
|
114
|
+
.cursor/*
|
|
115
|
+
!.cursor/skills/
|
|
116
|
+
!.cursor/skills/**
|
|
117
|
+
!.cursor/rules/
|
|
118
|
+
!.cursor/rules/**
|
|
119
|
+
|
|
120
|
+
# Python bytecode (added 2026-05-09 — was missing; tests/aichat-schema-recovery/__pycache__/ had been silently tracked)
|
|
121
|
+
__pycache__/
|
|
122
|
+
*.pyc
|
|
123
|
+
*.pyo
|
|
124
|
+
|
|
125
|
+
# Build/export artifacts (added 2026-05-17)
|
|
126
|
+
ai-sdlc-bundle.zip
|
|
127
|
+
|
|
128
|
+
# FEAT-PARTNER-SURFACE Phase P4b SDK artefacts
|
|
129
|
+
src/sdk/typescript/node_modules/
|
|
130
|
+
src/sdk/typescript/dist/
|
|
131
|
+
src/sdk/typescript/*.tgz
|
|
132
|
+
src/sdk/python/.venv/
|
|
133
|
+
src/sdk/python/venv/
|
|
134
|
+
src/sdk/python/dist/
|
|
135
|
+
src/sdk/python/build/
|
|
136
|
+
src/sdk/python/*.egg-info/
|
|
137
|
+
src/sdk/python/__pycache__/
|
xyfra_sdk-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xyfra-sdk
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Xyfra Data Platform partner SDK (Python)
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Requires-Dist: httpx>=0.27.0
|
|
8
|
+
Requires-Dist: pydantic>=2.0
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: ariadne-codegen>=0.13.0; extra == 'dev'
|
|
11
|
+
Requires-Dist: datamodel-code-generator>=0.25.0; extra == 'dev'
|
|
12
|
+
Requires-Dist: openapi-python-client>=0.21.0; extra == 'dev'
|
|
13
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# xyfra-sdk
|
|
17
|
+
|
|
18
|
+
Python SDK for the Xyfra Data Platform partner surface.
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install xyfra-sdk
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
import asyncio
|
|
30
|
+
from xyfra_sdk import XyfraClient, XyfraClientOptions
|
|
31
|
+
|
|
32
|
+
async def main():
|
|
33
|
+
client = XyfraClient(XyfraClientOptions(
|
|
34
|
+
base_url="https://api.xyfra.ai",
|
|
35
|
+
refresh_token="xyfra-rt-...",
|
|
36
|
+
tenant_id="tenant-123",
|
|
37
|
+
))
|
|
38
|
+
await client.refresh()
|
|
39
|
+
result = await client.call_tool("semantic.query", {"query": "..."})
|
|
40
|
+
print(result)
|
|
41
|
+
|
|
42
|
+
asyncio.run(main())
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Codegen
|
|
46
|
+
|
|
47
|
+
The `scripts/` directory contains generators that consume the partner OpenAPI,
|
|
48
|
+
GraphQL SDL, and MCP schema artefacts and emit typed clients under
|
|
49
|
+
`xyfra_sdk/client/`.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# xyfra-sdk
|
|
2
|
+
|
|
3
|
+
Python SDK for the Xyfra Data Platform partner surface.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install xyfra-sdk
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
import asyncio
|
|
15
|
+
from xyfra_sdk import XyfraClient, XyfraClientOptions
|
|
16
|
+
|
|
17
|
+
async def main():
|
|
18
|
+
client = XyfraClient(XyfraClientOptions(
|
|
19
|
+
base_url="https://api.xyfra.ai",
|
|
20
|
+
refresh_token="xyfra-rt-...",
|
|
21
|
+
tenant_id="tenant-123",
|
|
22
|
+
))
|
|
23
|
+
await client.refresh()
|
|
24
|
+
result = await client.call_tool("semantic.query", {"query": "..."})
|
|
25
|
+
print(result)
|
|
26
|
+
|
|
27
|
+
asyncio.run(main())
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Codegen
|
|
31
|
+
|
|
32
|
+
The `scripts/` directory contains generators that consume the partner OpenAPI,
|
|
33
|
+
GraphQL SDL, and MCP schema artefacts and emit typed clients under
|
|
34
|
+
`xyfra_sdk/client/`.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "xyfra-sdk"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Xyfra Data Platform partner SDK (Python)"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"httpx>=0.27.0",
|
|
14
|
+
"pydantic>=2.0",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.optional-dependencies]
|
|
18
|
+
dev = [
|
|
19
|
+
"pytest>=8.0",
|
|
20
|
+
"openapi-python-client>=0.21.0",
|
|
21
|
+
"ariadne-codegen>=0.13.0",
|
|
22
|
+
"datamodel-code-generator>=0.25.0",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[tool.hatch.build.targets.wheel]
|
|
26
|
+
packages = ["xyfra_sdk"]
|
|
27
|
+
|
|
28
|
+
[tool.pytest.ini_options]
|
|
29
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Generate the GraphQL typed operations client from the partner SDL artefact."""
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main() -> None:
|
|
13
|
+
base_url = os.environ.get("XYFRA_SDK_BASE_URL", "http://localhost:5000")
|
|
14
|
+
tenant_id = os.environ.get("XYFRA_SDK_TENANT_ID", "test-tenant")
|
|
15
|
+
version = os.environ.get("XYFRA_SDK_API_VERSION", "v1")
|
|
16
|
+
refresh_token = os.environ.get("XYFRA_CI_SANDBOX_REFRESH_TOKEN", "")
|
|
17
|
+
|
|
18
|
+
out_dir = Path(__file__).parent.parent / "xyfra_sdk" / "client" / "graphql"
|
|
19
|
+
|
|
20
|
+
if not refresh_token:
|
|
21
|
+
raise RuntimeError("XYFRA_CI_SANDBOX_REFRESH_TOKEN is required to fetch the partner GraphQL SDL artefact")
|
|
22
|
+
|
|
23
|
+
# Refresh to an access JWT before fetching the partner-gated endpoint.
|
|
24
|
+
from xyfra_sdk.auth import refresh_access_token
|
|
25
|
+
|
|
26
|
+
tokens = asyncio.run(refresh_access_token(base_url, refresh_token))
|
|
27
|
+
sdl_url = f"{base_url}/api/_meta/graphql/schema.graphql?tenant_id={tenant_id}&version={version}"
|
|
28
|
+
|
|
29
|
+
response = httpx.get(
|
|
30
|
+
sdl_url,
|
|
31
|
+
headers={"Authorization": f"Bearer {tokens['accessToken']}", "Accept": "text/plain"},
|
|
32
|
+
)
|
|
33
|
+
response.raise_for_status()
|
|
34
|
+
|
|
35
|
+
# ariadne-codegen emits a Python package from the SDL into the target path.
|
|
36
|
+
if out_dir.exists():
|
|
37
|
+
shutil.rmtree(out_dir)
|
|
38
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
|
|
40
|
+
schema_path = out_dir / "schema.graphql"
|
|
41
|
+
schema_path.write_text(response.text)
|
|
42
|
+
|
|
43
|
+
subprocess.run(
|
|
44
|
+
[
|
|
45
|
+
"ariadne-codegen",
|
|
46
|
+
"--schema",
|
|
47
|
+
str(schema_path),
|
|
48
|
+
"--target-package-path",
|
|
49
|
+
str(out_dir),
|
|
50
|
+
],
|
|
51
|
+
check=True,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
print(f"Generated GraphQL client from {sdl_url} -> {out_dir}")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
if __name__ == "__main__":
|
|
58
|
+
main()
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Generate typed MCP tool-input Pydantic models from the partner-projected schema dump."""
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main() -> None:
|
|
14
|
+
base_url = os.environ.get("XYFRA_SDK_BASE_URL", "http://localhost:5000")
|
|
15
|
+
refresh_token = os.environ.get("XYFRA_CI_SANDBOX_REFRESH_TOKEN", "")
|
|
16
|
+
|
|
17
|
+
list_url = f"{base_url}/mcp/list_tools_with_schemas"
|
|
18
|
+
out_dir = Path(__file__).parent.parent / "xyfra_sdk" / "client" / "mcp"
|
|
19
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
20
|
+
|
|
21
|
+
if not refresh_token:
|
|
22
|
+
raise RuntimeError("XYFRA_CI_SANDBOX_REFRESH_TOKEN is required to fetch the partner MCP schema artefact")
|
|
23
|
+
|
|
24
|
+
# Refresh to an access JWT before fetching the partner-gated endpoint.
|
|
25
|
+
from xyfra_sdk.auth import refresh_access_token
|
|
26
|
+
|
|
27
|
+
tokens = asyncio.run(refresh_access_token(base_url, refresh_token))
|
|
28
|
+
|
|
29
|
+
response = httpx.get(list_url, headers={"Authorization": f"Bearer {tokens['accessToken']}"})
|
|
30
|
+
response.raise_for_status()
|
|
31
|
+
envelope = response.json()
|
|
32
|
+
if not envelope.get("ok") or "data" not in envelope:
|
|
33
|
+
raise RuntimeError(envelope.get("error", {}).get("code", "mcp_schema_fetch_failed"))
|
|
34
|
+
|
|
35
|
+
init_lines = []
|
|
36
|
+
for tool in envelope["data"]["tools"]:
|
|
37
|
+
safe_name = re.sub(r"[^a-zA-Z0-9_]", "_", tool["name"])
|
|
38
|
+
schema = {
|
|
39
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
40
|
+
"title": f"{safe_name}_input",
|
|
41
|
+
**tool["inputSchema"],
|
|
42
|
+
}
|
|
43
|
+
schema_file = out_dir / f"{safe_name}.schema.json"
|
|
44
|
+
schema_file.write_text(json.dumps(schema, indent=2))
|
|
45
|
+
|
|
46
|
+
model_file = out_dir / f"{safe_name}.py"
|
|
47
|
+
subprocess.run(
|
|
48
|
+
[
|
|
49
|
+
"datamodel-codegen",
|
|
50
|
+
"--input",
|
|
51
|
+
str(schema_file),
|
|
52
|
+
"--input-file-type",
|
|
53
|
+
"jsonschema",
|
|
54
|
+
"--output",
|
|
55
|
+
str(model_file),
|
|
56
|
+
"--class-name",
|
|
57
|
+
f"{safe_name}_input",
|
|
58
|
+
],
|
|
59
|
+
check=True,
|
|
60
|
+
)
|
|
61
|
+
init_lines.append(f"from .{safe_name} import {safe_name}_input")
|
|
62
|
+
|
|
63
|
+
(out_dir / "__init__.py").write_text("\n".join(init_lines) + "\n")
|
|
64
|
+
print(f"Generated MCP client from {list_url} -> {out_dir}")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
if __name__ == "__main__":
|
|
68
|
+
main()
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Generate the REST client module from the partner OpenAPI artefact."""
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main() -> None:
|
|
12
|
+
base_url = os.environ.get("XYFRA_SDK_BASE_URL", "http://localhost:5000")
|
|
13
|
+
version = os.environ.get("XYFRA_SDK_API_VERSION", "v1")
|
|
14
|
+
refresh_token = os.environ.get("XYFRA_CI_SANDBOX_REFRESH_TOKEN", "")
|
|
15
|
+
|
|
16
|
+
out_dir = Path(__file__).parent.parent / "xyfra_sdk" / "client" / "rest"
|
|
17
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
18
|
+
|
|
19
|
+
if not refresh_token:
|
|
20
|
+
raise RuntimeError("XYFRA_CI_SANDBOX_REFRESH_TOKEN is required to fetch the partner OpenAPI artefact")
|
|
21
|
+
|
|
22
|
+
# Refresh to an access JWT before fetching the partner-gated endpoint.
|
|
23
|
+
from xyfra_sdk.auth import refresh_access_token
|
|
24
|
+
|
|
25
|
+
tokens = asyncio.run(refresh_access_token(base_url, refresh_token))
|
|
26
|
+
spec_url = f"{base_url}/api/_meta/openapi.json?version={version}"
|
|
27
|
+
|
|
28
|
+
response = httpx.get(spec_url, headers={"Authorization": f"Bearer {tokens['accessToken']}"})
|
|
29
|
+
response.raise_for_status()
|
|
30
|
+
|
|
31
|
+
spec_path = out_dir / "openapi.json"
|
|
32
|
+
spec_path.write_text(response.text)
|
|
33
|
+
|
|
34
|
+
subprocess.run(
|
|
35
|
+
[
|
|
36
|
+
"openapi-python-client",
|
|
37
|
+
"generate",
|
|
38
|
+
"--path",
|
|
39
|
+
str(spec_path),
|
|
40
|
+
"--meta",
|
|
41
|
+
"none",
|
|
42
|
+
"--output-path",
|
|
43
|
+
str(out_dir),
|
|
44
|
+
],
|
|
45
|
+
check=True,
|
|
46
|
+
)
|
|
47
|
+
print(f"Generated REST client from {spec_url} -> {out_dir}")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
if __name__ == "__main__":
|
|
51
|
+
main()
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from xyfra_sdk import XyfraClient, XyfraClientOptions
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_client_init() -> None:
|
|
7
|
+
client = XyfraClient(
|
|
8
|
+
XyfraClientOptions(
|
|
9
|
+
base_url="https://api.xyfra.ai/",
|
|
10
|
+
refresh_token="rt-test",
|
|
11
|
+
tenant_id="t-test",
|
|
12
|
+
)
|
|
13
|
+
)
|
|
14
|
+
assert client is not None
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
async def refresh_access_token(
|
|
8
|
+
base_url: str,
|
|
9
|
+
refresh_token: str,
|
|
10
|
+
) -> dict[str, Any]:
|
|
11
|
+
"""Refresh an access JWT from a partner refresh token."""
|
|
12
|
+
response = await httpx.AsyncClient().post(
|
|
13
|
+
f"{base_url}/api/partners/me/credentials/refresh",
|
|
14
|
+
headers={"Authorization": f"Bearer {refresh_token}"},
|
|
15
|
+
)
|
|
16
|
+
response.raise_for_status()
|
|
17
|
+
envelope = response.json()
|
|
18
|
+
if not envelope.get("ok") or "data" not in envelope:
|
|
19
|
+
raise RuntimeError(envelope.get("error", {}).get("code", "refresh_failed"))
|
|
20
|
+
return envelope["data"]
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
import hashlib
|
|
3
|
+
import hmac
|
|
4
|
+
import uuid
|
|
5
|
+
from typing import Any, Callable, Optional
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from .auth import refresh_access_token
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class XyfraClientOptions:
|
|
14
|
+
base_url: str
|
|
15
|
+
refresh_token: str
|
|
16
|
+
tenant_id: str
|
|
17
|
+
sandbox_deterministic: bool = False
|
|
18
|
+
traceparent: Optional[str] = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _ToolsNamespace:
|
|
22
|
+
def __init__(self, client: "XyfraClient") -> None:
|
|
23
|
+
self._client = client
|
|
24
|
+
|
|
25
|
+
async def call(self, tool_name: str, arguments: dict[str, Any]) -> Any:
|
|
26
|
+
return await self._client._post("/mcp/call_tool", {"tool_name": tool_name, "arguments": arguments})
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class _EventsNamespace:
|
|
30
|
+
def __init__(self, client: "XyfraClient") -> None:
|
|
31
|
+
self._client = client
|
|
32
|
+
|
|
33
|
+
async def subscribe(
|
|
34
|
+
self,
|
|
35
|
+
surface: str,
|
|
36
|
+
on_event: Callable[[dict[str, Any]], None],
|
|
37
|
+
*,
|
|
38
|
+
since: Optional[str] = None,
|
|
39
|
+
signal: Any = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
params: dict[str, Any] = {"surface": surface}
|
|
42
|
+
if since:
|
|
43
|
+
params["since"] = since
|
|
44
|
+
headers = await self._client.headers()
|
|
45
|
+
headers["Accept"] = "text/event-stream"
|
|
46
|
+
async with self._client._http.stream(
|
|
47
|
+
"GET",
|
|
48
|
+
f"{self._client._base_url}/api/events",
|
|
49
|
+
headers=headers,
|
|
50
|
+
params=params,
|
|
51
|
+
) as response:
|
|
52
|
+
response.raise_for_status()
|
|
53
|
+
buffer = ""
|
|
54
|
+
last_event_id: Optional[str] = None
|
|
55
|
+
event_name: Optional[str] = None
|
|
56
|
+
async for chunk in response.aiter_text():
|
|
57
|
+
buffer += chunk
|
|
58
|
+
lines = buffer.split("\n")
|
|
59
|
+
buffer = lines.pop()
|
|
60
|
+
for line in lines:
|
|
61
|
+
if line.startswith("id:"):
|
|
62
|
+
last_event_id = line[3:].strip()
|
|
63
|
+
elif line.startswith("event:"):
|
|
64
|
+
event_name = line[6:].strip()
|
|
65
|
+
elif line.startswith("data:"):
|
|
66
|
+
raw = line[5:].strip()
|
|
67
|
+
try:
|
|
68
|
+
import json
|
|
69
|
+
|
|
70
|
+
data = json.loads(raw)
|
|
71
|
+
except Exception:
|
|
72
|
+
data = raw
|
|
73
|
+
on_event({"id": last_event_id, "event": event_name, "data": data})
|
|
74
|
+
elif line.strip() == "":
|
|
75
|
+
event_name = None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class _WebhooksNamespace:
|
|
79
|
+
def __init__(self, client: "XyfraClient") -> None:
|
|
80
|
+
self._client = client
|
|
81
|
+
|
|
82
|
+
def verify_signature(self, payload: str, signature: str, secret: str) -> bool:
|
|
83
|
+
expected = hmac.new(secret.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).hexdigest()
|
|
84
|
+
return hmac.compare_digest(signature, expected)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class _AuditNamespace:
|
|
88
|
+
def __init__(self, client: "XyfraClient") -> None:
|
|
89
|
+
self._client = client
|
|
90
|
+
|
|
91
|
+
async def list(
|
|
92
|
+
self,
|
|
93
|
+
*,
|
|
94
|
+
tenant_id: Optional[str] = None,
|
|
95
|
+
since: Optional[str] = None,
|
|
96
|
+
cursor: Optional[str] = None,
|
|
97
|
+
limit: Optional[int] = None,
|
|
98
|
+
) -> Any:
|
|
99
|
+
params: dict[str, Any] = {}
|
|
100
|
+
if tenant_id:
|
|
101
|
+
params["tenant_id"] = tenant_id
|
|
102
|
+
if since:
|
|
103
|
+
params["since"] = since
|
|
104
|
+
if cursor:
|
|
105
|
+
params["cursor"] = cursor
|
|
106
|
+
if limit:
|
|
107
|
+
params["limit"] = limit
|
|
108
|
+
return await self._client._get("/api/audit", params)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class _BillingNamespace:
|
|
112
|
+
def __init__(self, client: "XyfraClient") -> None:
|
|
113
|
+
self._client = client
|
|
114
|
+
|
|
115
|
+
async def get_period(self, period: Optional[str] = None, tenant_id: Optional[str] = None) -> Any:
|
|
116
|
+
params: dict[str, Any] = {}
|
|
117
|
+
if period:
|
|
118
|
+
params["period"] = period
|
|
119
|
+
if tenant_id:
|
|
120
|
+
params["tenantId"] = tenant_id
|
|
121
|
+
return await self._client._get("/api/partner-usage", params)
|
|
122
|
+
|
|
123
|
+
async def list_periods(self, cursor: Optional[str] = None, limit: Optional[int] = None) -> Any:
|
|
124
|
+
params: dict[str, Any] = {}
|
|
125
|
+
if cursor:
|
|
126
|
+
params["cursor"] = cursor
|
|
127
|
+
if limit:
|
|
128
|
+
params["limit"] = limit
|
|
129
|
+
return await self._client._get("/api/partner-usage/periods", params)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class _MetricsNamespace:
|
|
133
|
+
def __init__(self, client: "XyfraClient") -> None:
|
|
134
|
+
self._client = client
|
|
135
|
+
|
|
136
|
+
async def subscribe(
|
|
137
|
+
self, tenant_id: str, prometheus_remote_write_url: str, auth_header: str
|
|
138
|
+
) -> Any:
|
|
139
|
+
return await self._client._post(
|
|
140
|
+
"/api/partners/me/metrics-subscriptions",
|
|
141
|
+
{
|
|
142
|
+
"tenantId": tenant_id,
|
|
143
|
+
"prometheusRemoteWriteUrl": prometheus_remote_write_url,
|
|
144
|
+
"authHeader": auth_header,
|
|
145
|
+
},
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
async def reactivate(self, subscription_id: str) -> Any:
|
|
149
|
+
return await self._client._post(
|
|
150
|
+
f"/api/partners/me/metrics-subscriptions/{subscription_id}/reactivate",
|
|
151
|
+
{},
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
async def delete(self, subscription_id: str) -> None:
|
|
155
|
+
response = await self._client._http.delete(
|
|
156
|
+
f"{self._client._base_url}/api/partners/me/metrics-subscriptions/{subscription_id}",
|
|
157
|
+
headers=await self._client.headers(),
|
|
158
|
+
)
|
|
159
|
+
response.raise_for_status()
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class _QueryNamespace:
|
|
163
|
+
def __init__(self, client: "XyfraClient") -> None:
|
|
164
|
+
self._client = client
|
|
165
|
+
|
|
166
|
+
async def execute(self, query: str, variables: Optional[dict[str, Any]] = None) -> Any:
|
|
167
|
+
return await self._client._post("/graphql", {"query": query, "variables": variables or {}})
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class XyfraClient:
|
|
171
|
+
"""Hand-written thin wrapper over generated REST/GraphQL/MCP client modules."""
|
|
172
|
+
|
|
173
|
+
def __init__(self, options: XyfraClientOptions) -> None:
|
|
174
|
+
base_url = options.base_url.strip() if options.base_url else ""
|
|
175
|
+
refresh_token = options.refresh_token.strip() if options.refresh_token else ""
|
|
176
|
+
tenant_id = options.tenant_id.strip() if options.tenant_id else ""
|
|
177
|
+
|
|
178
|
+
if not base_url:
|
|
179
|
+
raise ValueError("XyfraClient: base_url is required")
|
|
180
|
+
if not refresh_token:
|
|
181
|
+
raise ValueError("XyfraClient: refresh_token is required")
|
|
182
|
+
if not tenant_id:
|
|
183
|
+
raise ValueError("XyfraClient: tenant_id is required")
|
|
184
|
+
|
|
185
|
+
self._base_url = base_url.rstrip("/")
|
|
186
|
+
self._tenant_id = tenant_id
|
|
187
|
+
self._refresh_token = refresh_token
|
|
188
|
+
self._sandbox_deterministic = options.sandbox_deterministic
|
|
189
|
+
self._traceparent = options.traceparent or str(uuid.uuid4())
|
|
190
|
+
self._access_token: Optional[str] = None
|
|
191
|
+
self._http = httpx.AsyncClient()
|
|
192
|
+
self._closed = False
|
|
193
|
+
|
|
194
|
+
self.query = _QueryNamespace(self)
|
|
195
|
+
self.tools = _ToolsNamespace(self)
|
|
196
|
+
self.events = _EventsNamespace(self)
|
|
197
|
+
self.webhooks = _WebhooksNamespace(self)
|
|
198
|
+
self.audit = _AuditNamespace(self)
|
|
199
|
+
self.billing = _BillingNamespace(self)
|
|
200
|
+
self.metrics = _MetricsNamespace(self)
|
|
201
|
+
|
|
202
|
+
async def refresh(self) -> None:
|
|
203
|
+
self._assert_open()
|
|
204
|
+
result = await refresh_access_token(self._base_url, self._refresh_token)
|
|
205
|
+
self._access_token = result["accessToken"]
|
|
206
|
+
self._refresh_token = result["refreshToken"]
|
|
207
|
+
|
|
208
|
+
async def access_token(self) -> str:
|
|
209
|
+
self._assert_open()
|
|
210
|
+
if self._access_token is None:
|
|
211
|
+
await self.refresh()
|
|
212
|
+
if self._access_token is None:
|
|
213
|
+
raise RuntimeError("refresh_failed")
|
|
214
|
+
return self._access_token
|
|
215
|
+
|
|
216
|
+
async def headers(self) -> dict[str, str]:
|
|
217
|
+
headers = {
|
|
218
|
+
"Authorization": f"Bearer {await self.access_token()}",
|
|
219
|
+
"X-Tenant-Id": self._tenant_id,
|
|
220
|
+
"X-Request-ID": self._traceparent,
|
|
221
|
+
"Content-Type": "application/json",
|
|
222
|
+
}
|
|
223
|
+
if self._sandbox_deterministic:
|
|
224
|
+
headers["X-Sandbox-Deterministic"] = "true"
|
|
225
|
+
return headers
|
|
226
|
+
|
|
227
|
+
async def close(self) -> None:
|
|
228
|
+
if self._closed:
|
|
229
|
+
return
|
|
230
|
+
self._closed = True
|
|
231
|
+
self._access_token = None
|
|
232
|
+
await self._http.aclose()
|
|
233
|
+
|
|
234
|
+
async def aclose(self) -> None:
|
|
235
|
+
await self.close()
|
|
236
|
+
|
|
237
|
+
async def __aenter__(self) -> "XyfraClient":
|
|
238
|
+
return self
|
|
239
|
+
|
|
240
|
+
async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
|
|
241
|
+
await self.close()
|
|
242
|
+
|
|
243
|
+
@property
|
|
244
|
+
def is_closed(self) -> bool:
|
|
245
|
+
return self._closed
|
|
246
|
+
|
|
247
|
+
def _assert_open(self) -> None:
|
|
248
|
+
if self._closed:
|
|
249
|
+
raise RuntimeError("XyfraClient has been closed")
|
|
250
|
+
|
|
251
|
+
async def _get(self, path: str, params: Optional[dict[str, Any]] = None) -> Any:
|
|
252
|
+
response = await self._http.get(
|
|
253
|
+
f"{self._base_url}{path}",
|
|
254
|
+
headers=await self.headers(),
|
|
255
|
+
params=params,
|
|
256
|
+
)
|
|
257
|
+
response.raise_for_status()
|
|
258
|
+
content_type = response.headers.get("content-type", "")
|
|
259
|
+
if "application/json" in content_type:
|
|
260
|
+
return response.json()
|
|
261
|
+
return response.text
|
|
262
|
+
|
|
263
|
+
async def _post(self, path: str, body: Any) -> Any:
|
|
264
|
+
response = await self._http.post(
|
|
265
|
+
f"{self._base_url}{path}",
|
|
266
|
+
headers=await self.headers(),
|
|
267
|
+
json=body,
|
|
268
|
+
)
|
|
269
|
+
response.raise_for_status()
|
|
270
|
+
content_type = response.headers.get("content-type", "")
|
|
271
|
+
if "application/json" in content_type:
|
|
272
|
+
return response.json()
|
|
273
|
+
return response.text
|