brussle 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.
- brussle-1.0.0/.gitignore +7 -0
- brussle-1.0.0/PKG-INFO +29 -0
- brussle-1.0.0/README.md +19 -0
- brussle-1.0.0/examples/__init__.py +0 -0
- brussle-1.0.0/examples/spec_6_13.py +95 -0
- brussle-1.0.0/pyproject.toml +33 -0
- brussle-1.0.0/scripts/generate_types.py +306 -0
- brussle-1.0.0/src/brussle/__init__.py +72 -0
- brussle-1.0.0/src/brussle/_brand.py +9 -0
- brussle-1.0.0/src/brussle/_client.py +1015 -0
- brussle-1.0.0/src/brussle/_import.py +130 -0
- brussle-1.0.0/src/brussle/_version.py +2 -0
- brussle-1.0.0/src/brussle/filters.py +22 -0
- brussle-1.0.0/src/brussle/py.typed +0 -0
- brussle-1.0.0/src/brussle/types.py +3604 -0
- brussle-1.0.0/src/brussle/webhooks.py +102 -0
- brussle-1.0.0/tests/conftest.py +61 -0
- brussle-1.0.0/tests/test_daily_limit.py +45 -0
- brussle-1.0.0/tests/test_errors.py +52 -0
- brussle-1.0.0/tests/test_import.py +124 -0
- brussle-1.0.0/tests/test_retries.py +89 -0
- brussle-1.0.0/tests/test_routes.py +383 -0
- brussle-1.0.0/tests/test_webhooks.py +96 -0
- brussle-1.0.0/tests/types_check.py +123 -0
- brussle-1.0.0/uv.lock +618 -0
brussle-1.0.0/.gitignore
ADDED
brussle-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: brussle
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Python SDK for the Brussle API
|
|
5
|
+
License-Expression: LicenseRef-Proprietary
|
|
6
|
+
Requires-Python: >=3.9
|
|
7
|
+
Requires-Dist: httpx<1,>=0.27
|
|
8
|
+
Requires-Dist: typing-extensions>=4.7
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# brussle (Python)
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from brussle import And, Client
|
|
15
|
+
|
|
16
|
+
db = Client(api_key="...")
|
|
17
|
+
ns = db.namespace("acme/prod/tenant_123")
|
|
18
|
+
ns.write(upsert=[{"id": "t_123", "attributes": {"plan": "pro"}, "state": {"subject": "Refund"}}])
|
|
19
|
+
page = ns.query(filters=And(("attributes.plan", "Eq", "pro"), ("answers.needs_escalation.p", "Gte", 0.85)), top_k=10)
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
To load many documents, `import_documents` takes any iterable and writes it in batches, 4 at a time on a thread pool, with retries:
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
summary = ns.import_documents(records(), concurrency=4)
|
|
26
|
+
print(summary.documents, summary.failures)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Methods mirror the API's routes. Every route that changes something takes `idempotency_key=`. Reads, writes and calls with a key are retried after network errors, timeouts and 5xx, 2 times by default (`max_retries`). Requests and responses are plain JSON, typed in `brussle.types`. Filters and `rank_by` are tuples. Errors raise `ApiError` with the API's `code` and `details`, or `unknown` for an error response that is not the API's JSON (such as a proxy's error page), or a success whose body is not JSON. A call your organization's plan doesn't include raises `plan_required` (402), and `details["required_plan"]` names the plan that does. See [the SDK overview](../README.md).
|
brussle-1.0.0/README.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# brussle (Python)
|
|
2
|
+
|
|
3
|
+
```python
|
|
4
|
+
from brussle import And, Client
|
|
5
|
+
|
|
6
|
+
db = Client(api_key="...")
|
|
7
|
+
ns = db.namespace("acme/prod/tenant_123")
|
|
8
|
+
ns.write(upsert=[{"id": "t_123", "attributes": {"plan": "pro"}, "state": {"subject": "Refund"}}])
|
|
9
|
+
page = ns.query(filters=And(("attributes.plan", "Eq", "pro"), ("answers.needs_escalation.p", "Gte", 0.85)), top_k=10)
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
To load many documents, `import_documents` takes any iterable and writes it in batches, 4 at a time on a thread pool, with retries:
|
|
13
|
+
|
|
14
|
+
```python
|
|
15
|
+
summary = ns.import_documents(records(), concurrency=4)
|
|
16
|
+
print(summary.documents, summary.failures)
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Methods mirror the API's routes. Every route that changes something takes `idempotency_key=`. Reads, writes and calls with a key are retried after network errors, timeouts and 5xx, 2 times by default (`max_retries`). Requests and responses are plain JSON, typed in `brussle.types`. Filters and `rank_by` are tuples. Errors raise `ApiError` with the API's `code` and `details`, or `unknown` for an error response that is not the API's JSON (such as a proxy's error page), or a success whose body is not JSON. A call your organization's plan doesn't include raises `plan_required` (402), and `details["required_plan"]` names the plan that does. See [the SDK overview](../README.md).
|
|
File without changes
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""The main spec §6.13 example, made concrete: every call in the order the spec lists them.
|
|
2
|
+
|
|
3
|
+
Run it against staging or a local node with BRUSSLE_API_KEY and BRUSSLE_BASE_URL set:
|
|
4
|
+
|
|
5
|
+
.venv/bin/python examples/spec_6_13.py
|
|
6
|
+
|
|
7
|
+
The tests run it against the mock server.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from typing import Any, Dict
|
|
13
|
+
|
|
14
|
+
from brussle import DEFAULT_BASE_URL, And, Client
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def example(db: Client, *, serves_v15: bool) -> Dict[str, Any]:
|
|
18
|
+
"""`serves_v15`: outcomes and job confirmation are v1.5 flows (§6.9, §6.10); v1 has no job awaiting confirmation."""
|
|
19
|
+
ns = db.namespace("acme/prod/tenant_123")
|
|
20
|
+
|
|
21
|
+
created = ns.judgments.create(
|
|
22
|
+
name="needs_escalation",
|
|
23
|
+
type="bool",
|
|
24
|
+
question="Does this ticket require a human to take over from the automated flow?",
|
|
25
|
+
criteria="Escalate when the customer is at risk of leaving, mentions legal action, or the automation has failed twice.",
|
|
26
|
+
context={"fields": ["state.subject", "state.body", "attributes.plan"], "last_n": {"state.messages": 3}, "max_tokens": 4000},
|
|
27
|
+
engine={"name": "jev", "version": "current"},
|
|
28
|
+
thresholds={"escalate": 0.85},
|
|
29
|
+
freshness={"policy": "on_change"},
|
|
30
|
+
activate=True,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
ns.write(
|
|
34
|
+
upsert=[
|
|
35
|
+
{"id": "t_123", "attributes": {"plan": "pro"}, "state": {"subject": "Refund", "body": "Charged twice.", "messages": []}},
|
|
36
|
+
{"id": "t_124", "attributes": {"plan": "free"}, "state": {"subject": "Login", "body": "Password reset loops."}},
|
|
37
|
+
{"id": "t_125", "attributes": {"plan": "pro"}, "state": {"subject": "Spam", "body": "Buy now."}},
|
|
38
|
+
],
|
|
39
|
+
patch=[{"id": "t_124", "attributes": {"plan": "enterprise"}, "state": {"status": "open"}}],
|
|
40
|
+
append=[{"id": "t_123", "path": "state.messages", "values": [{"role": "customer", "text": "Third time asking."}]}],
|
|
41
|
+
delete=["t_125"],
|
|
42
|
+
wait_for=["needs_escalation"],
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
page = ns.query(
|
|
46
|
+
filters=And(
|
|
47
|
+
("attributes.plan", "In", ["pro", "enterprise"]),
|
|
48
|
+
("answers.needs_escalation.p", "Gte", 0),
|
|
49
|
+
),
|
|
50
|
+
rank_by=("answers.needs_escalation.p", "desc"),
|
|
51
|
+
top_k=10,
|
|
52
|
+
include={"attributes": ["plan"], "answers": ["needs_escalation"]},
|
|
53
|
+
answers="fresh_only",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
document = ns.get("t_123", include_history=True)
|
|
57
|
+
|
|
58
|
+
ns.judgments.update("needs_escalation", freshness={"policy": "on_change", "debounce_ms": 2000}, confirm=True)
|
|
59
|
+
|
|
60
|
+
next_version = ns.judgments.create(
|
|
61
|
+
name="needs_escalation",
|
|
62
|
+
type="bool",
|
|
63
|
+
question="Does this ticket require a human to take over from the automated flow?",
|
|
64
|
+
criteria="Escalate on churn risk, legal threats, two automation failures, or a repeated complaint.",
|
|
65
|
+
context={"fields": ["state.subject", "state.body", "attributes.plan"], "last_n": {"state.messages": 3}},
|
|
66
|
+
engine={"name": "jev", "version": "current"},
|
|
67
|
+
thresholds={"escalate": 0.85},
|
|
68
|
+
)
|
|
69
|
+
if "replay" in next_version:
|
|
70
|
+
raise RuntimeError("only an entity judgment's create returns a replay estimate")
|
|
71
|
+
ns.judgments.activate("needs_escalation", version=next_version["version"], force=True)
|
|
72
|
+
|
|
73
|
+
estimate = ns.judgments.backfill("needs_escalation", confirm=False)
|
|
74
|
+
started = ns.judgments.backfill("needs_escalation", confirm=True)
|
|
75
|
+
if "job_id" not in started:
|
|
76
|
+
raise RuntimeError("a confirmed backfill starts a job")
|
|
77
|
+
job_id = started["job_id"]
|
|
78
|
+
|
|
79
|
+
if serves_v15:
|
|
80
|
+
ns.outcomes.append([
|
|
81
|
+
{"document_id": "t_123", "judgment": "needs_escalation", "value": True, "observed_at": "2026-09-23T12:00:00Z"},
|
|
82
|
+
])
|
|
83
|
+
|
|
84
|
+
db.jobs.get(job_id); db.jobs.pause(job_id); db.jobs.resume(job_id); db.jobs.cancel(job_id) # noqa: E702
|
|
85
|
+
if serves_v15:
|
|
86
|
+
db.jobs.confirm(job_id)
|
|
87
|
+
|
|
88
|
+
return {"created": created, "rows": page["rows"], "document": document, "estimate": estimate}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
if __name__ == "__main__":
|
|
92
|
+
base_url = os.environ.get("BRUSSLE_BASE_URL", DEFAULT_BASE_URL)
|
|
93
|
+
with Client(api_key=os.environ["BRUSSLE_API_KEY"], base_url=base_url) as db:
|
|
94
|
+
result = example(db, serves_v15=os.environ.get("JDB_SERVES_V15") == "1")
|
|
95
|
+
print(json.dumps(result, indent=2))
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "brussle"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "Python SDK for the Brussle API"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.9"
|
|
7
|
+
license = "LicenseRef-Proprietary"
|
|
8
|
+
dependencies = ["httpx>=0.27,<1", "typing_extensions>=4.7"]
|
|
9
|
+
|
|
10
|
+
[dependency-groups]
|
|
11
|
+
dev = ["mypy==1.19.1", "pytest>=8", "pyyaml>=6", "types-PyYAML"]
|
|
12
|
+
|
|
13
|
+
[build-system]
|
|
14
|
+
requires = ["hatchling>=1.26"]
|
|
15
|
+
build-backend = "hatchling.build"
|
|
16
|
+
|
|
17
|
+
[tool.hatch.build.targets.wheel]
|
|
18
|
+
packages = ["src/brussle"]
|
|
19
|
+
|
|
20
|
+
[tool.mypy]
|
|
21
|
+
python_version = "3.9"
|
|
22
|
+
strict = true
|
|
23
|
+
files = ["src", "tests", "examples", "scripts"]
|
|
24
|
+
|
|
25
|
+
# Each method returns the parsed JSON (`Any`) under the type the contract gives
|
|
26
|
+
# it. Nothing is validated at runtime (D-surface-2), so there is nothing to cast.
|
|
27
|
+
[[tool.mypy.overrides]]
|
|
28
|
+
module = "brussle._client"
|
|
29
|
+
warn_return_any = false
|
|
30
|
+
|
|
31
|
+
[tool.pytest.ini_options]
|
|
32
|
+
testpaths = ["tests"]
|
|
33
|
+
pythonpath = ["src", "."]
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"""Generates `src/brussle/types.py` from `api/openapi.yaml`, and `_brand.py` from `docs/brand.json`.
|
|
2
|
+
|
|
3
|
+
`docs/brand.json` is the one place the product name and domain are set (D-surface-41).
|
|
4
|
+
|
|
5
|
+
Every component schema becomes a `TypedDict` or a type alias. They are static
|
|
6
|
+
types over the JSON the API sends and receives: nothing is converted or
|
|
7
|
+
validated at runtime, so fields and enum values the API adds later never break
|
|
8
|
+
a client (decision D-surface-2).
|
|
9
|
+
|
|
10
|
+
The generator covers the JSON Schema subset the contract uses. It fails loudly
|
|
11
|
+
on anything else, so a contract change that needs new handling cannot slip
|
|
12
|
+
through as a wrong type.
|
|
13
|
+
|
|
14
|
+
.venv/bin/python scripts/generate_types.py # write the file
|
|
15
|
+
.venv/bin/python scripts/generate_types.py --check # exit 1 if it is stale
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import keyword
|
|
22
|
+
import re
|
|
23
|
+
import sys
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any, Dict, List, Optional
|
|
26
|
+
|
|
27
|
+
import yaml
|
|
28
|
+
|
|
29
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
30
|
+
OPENAPI = ROOT.parents[1] / "api" / "openapi.yaml"
|
|
31
|
+
OUTPUT = ROOT / "src" / "brussle" / "types.py"
|
|
32
|
+
BRAND = ROOT.parents[1] / "docs" / "brand.json"
|
|
33
|
+
BRAND_OUTPUT = ROOT / "src" / "brussle" / "_brand.py"
|
|
34
|
+
|
|
35
|
+
Schema = Dict[str, Any]
|
|
36
|
+
|
|
37
|
+
# Keywords that only constrain values or help other generators. Types ignore them.
|
|
38
|
+
IGNORED = {
|
|
39
|
+
"description", "examples", "default", "format", "pattern", "minLength", "maxLength",
|
|
40
|
+
"minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "minItems", "maxItems", "uniqueItems", "minProperties",
|
|
41
|
+
"maxProperties", "propertyNames", "discriminator", "if", "then", "not",
|
|
42
|
+
"unevaluatedProperties", "anyOf", "deprecated", "dependentRequired",
|
|
43
|
+
}
|
|
44
|
+
HANDLED = {
|
|
45
|
+
"$ref", "type", "enum", "const", "properties", "required", "additionalProperties",
|
|
46
|
+
"items", "prefixItems", "oneOf", "allOf",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def ref_name(schema: Schema) -> str:
|
|
51
|
+
return str(schema["$ref"]).rsplit("/", 1)[1]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def typed_all_of(schema: Schema) -> List[Schema]:
|
|
55
|
+
"""`allOf` members that add a type. Members that only constrain (`if`/`then`/`else`) are ignored like `if` is."""
|
|
56
|
+
return [
|
|
57
|
+
member for member in schema.get("allOf", [])
|
|
58
|
+
if not set(member) <= {"description", "if", "then", "else"}
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def pascal(name: str) -> str:
|
|
63
|
+
return "".join(part[:1].upper() + part[1:] for part in re.split(r"[_\W]+", name) if part)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class Generator:
|
|
67
|
+
def __init__(self, schemas: Dict[str, Schema]) -> None:
|
|
68
|
+
self.schemas = schemas
|
|
69
|
+
# name -> emitted source; TypedDict classes and aliases.
|
|
70
|
+
self.blocks: Dict[str, str] = {}
|
|
71
|
+
# TypedDict base classes must exist before their subclasses at runtime.
|
|
72
|
+
self.bases: Dict[str, List[str]] = {}
|
|
73
|
+
# Schemas other schemas extend with allOf (a union base's variants included); every other TypedDict is final.
|
|
74
|
+
self.base_names: set[str] = set()
|
|
75
|
+
for schema in schemas.values():
|
|
76
|
+
for ref in typed_all_of(schema):
|
|
77
|
+
base = ref_name(ref)
|
|
78
|
+
self.base_names.add(base)
|
|
79
|
+
self.base_names.update(ref_name(variant) for variant in schemas[base].get("oneOf", []))
|
|
80
|
+
|
|
81
|
+
def check_keywords(self, schema: Schema, where: str) -> None:
|
|
82
|
+
unknown = set(schema) - IGNORED - HANDLED
|
|
83
|
+
if unknown:
|
|
84
|
+
raise SystemExit(f"{where}: unsupported schema keywords {sorted(unknown)}")
|
|
85
|
+
|
|
86
|
+
# --- type expressions -------------------------------------------------
|
|
87
|
+
|
|
88
|
+
def expr(self, schema: Schema, where: str) -> str:
|
|
89
|
+
"""A type expression for `schema`. References are quoted, so order never matters."""
|
|
90
|
+
self.check_keywords(schema, where)
|
|
91
|
+
if "$ref" in schema:
|
|
92
|
+
return f'"{ref_name(schema)}"'
|
|
93
|
+
if "const" in schema:
|
|
94
|
+
return f"Literal[{schema['const']!r}]"
|
|
95
|
+
if "enum" in schema:
|
|
96
|
+
return f"Literal[{', '.join(repr(v) for v in schema['enum'])}]"
|
|
97
|
+
if "oneOf" in schema:
|
|
98
|
+
return self.union([self.expr(s, where) for s in schema["oneOf"]])
|
|
99
|
+
if "allOf" in schema:
|
|
100
|
+
raise SystemExit(f"{where}: allOf is only supported on named schemas")
|
|
101
|
+
types = schema.get("type")
|
|
102
|
+
if isinstance(types, list):
|
|
103
|
+
return self.union([self.expr({**schema, "type": t}, where) for t in types])
|
|
104
|
+
if types == "string":
|
|
105
|
+
return "str"
|
|
106
|
+
if types == "integer":
|
|
107
|
+
return "int"
|
|
108
|
+
if types == "number":
|
|
109
|
+
return "float"
|
|
110
|
+
if types == "boolean":
|
|
111
|
+
return "bool"
|
|
112
|
+
if types == "null":
|
|
113
|
+
return "None"
|
|
114
|
+
if types == "array":
|
|
115
|
+
if "prefixItems" in schema:
|
|
116
|
+
if schema.get("items") is not False:
|
|
117
|
+
raise SystemExit(f"{where}: tuples must close with items: false")
|
|
118
|
+
items = [self.expr(s, where) for s in schema["prefixItems"]]
|
|
119
|
+
return f"Tuple[{', '.join(items)}]"
|
|
120
|
+
if "items" in schema:
|
|
121
|
+
return f"Sequence[{self.expr(schema['items'], where)}]"
|
|
122
|
+
return "Sequence[Any]"
|
|
123
|
+
if types == "object" or "properties" in schema:
|
|
124
|
+
if "properties" in schema:
|
|
125
|
+
name = pascal(where)
|
|
126
|
+
self.typed_dict(name, schema)
|
|
127
|
+
return f'"{name}"'
|
|
128
|
+
extra = schema.get("additionalProperties")
|
|
129
|
+
if isinstance(extra, dict):
|
|
130
|
+
# An inline object value gets its own name, or it would take the map's.
|
|
131
|
+
return f"Mapping[str, {self.expr(extra, where + ' entry')}]"
|
|
132
|
+
return "Mapping[str, Any]"
|
|
133
|
+
if types is None:
|
|
134
|
+
# Any JSON value, such as an evaluation's raw engine response.
|
|
135
|
+
return "Any"
|
|
136
|
+
raise SystemExit(f"{where}: unsupported type {types!r}")
|
|
137
|
+
|
|
138
|
+
@staticmethod
|
|
139
|
+
def union(members: List[str]) -> str:
|
|
140
|
+
unique = list(dict.fromkeys(members))
|
|
141
|
+
if "None" in unique and len(unique) == 2:
|
|
142
|
+
other = next(m for m in unique if m != "None")
|
|
143
|
+
return f"Optional[{other}]"
|
|
144
|
+
return unique[0] if len(unique) == 1 else f"Union[{', '.join(unique)}]"
|
|
145
|
+
|
|
146
|
+
# --- named schemas ------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
def named(self, name: str, schema: Schema) -> None:
|
|
149
|
+
self.check_keywords(schema, name)
|
|
150
|
+
all_of = typed_all_of(schema)
|
|
151
|
+
if all_of:
|
|
152
|
+
if len(all_of) != 1 or "$ref" not in all_of[0]:
|
|
153
|
+
raise SystemExit(f"{name}: allOf must be a single $ref")
|
|
154
|
+
base = ref_name(all_of[0])
|
|
155
|
+
base_schema = self.schemas[base]
|
|
156
|
+
if "oneOf" in base_schema:
|
|
157
|
+
# A union base: extend each variant, then union the results.
|
|
158
|
+
variants = []
|
|
159
|
+
for ref in base_schema["oneOf"]:
|
|
160
|
+
variant = ref_name(ref)
|
|
161
|
+
const = self.schemas[variant]["properties"]["type"]["const"]
|
|
162
|
+
variant_name = f"{name}{pascal(const)}"
|
|
163
|
+
self.typed_dict(variant_name, schema, base=variant, doc=schema.get("description"))
|
|
164
|
+
variants.append(f'"{variant_name}"')
|
|
165
|
+
self.alias(name, self.union(variants), schema.get("description"))
|
|
166
|
+
return
|
|
167
|
+
self.typed_dict(name, schema, base=base)
|
|
168
|
+
return
|
|
169
|
+
if "properties" in schema:
|
|
170
|
+
self.typed_dict(name, schema)
|
|
171
|
+
return
|
|
172
|
+
self.alias(name, self.expr(schema, name), schema.get("description"))
|
|
173
|
+
|
|
174
|
+
def alias(self, name: str, expr: str, doc: Optional[str]) -> None:
|
|
175
|
+
self.blocks[name] = f"{comment(doc)}{name} = {expr}\n"
|
|
176
|
+
|
|
177
|
+
def typed_dict(self, name: str, schema: Schema, base: Optional[str] = None, doc: Optional[str] = None) -> None:
|
|
178
|
+
# `required` only ever names the schema's own properties in this contract.
|
|
179
|
+
required = set(schema.get("required", []))
|
|
180
|
+
fields = []
|
|
181
|
+
for field, field_schema in schema.get("properties", {}).items():
|
|
182
|
+
field_doc = field_schema.get("description")
|
|
183
|
+
# `{$ref, description}` and `{$ref, type: object, minProperties}` are still the ref.
|
|
184
|
+
if "$ref" in field_schema:
|
|
185
|
+
field_schema = {"$ref": field_schema["$ref"]}
|
|
186
|
+
annotation = self.expr(field_schema, f"{name}_{field}")
|
|
187
|
+
if field not in required:
|
|
188
|
+
annotation = f"NotRequired[{annotation}]"
|
|
189
|
+
fields.append((field, annotation, field_doc))
|
|
190
|
+
|
|
191
|
+
doc = doc if doc is not None else schema.get("description")
|
|
192
|
+
self.bases[name] = [base] if base else []
|
|
193
|
+
if any(keyword.iskeyword(f) for f, _, _ in fields):
|
|
194
|
+
if base:
|
|
195
|
+
raise SystemExit(f"{name}: keyword field names need the functional syntax, which cannot inherit")
|
|
196
|
+
body = ", ".join(f"{f!r}: {a}" for f, a, _ in fields)
|
|
197
|
+
self.blocks[name] = f'{comment(doc)}{name} = TypedDict("{name}", {{{body}}})\n'
|
|
198
|
+
return
|
|
199
|
+
lines = [f"class {name}({base or 'TypedDict'}):"]
|
|
200
|
+
if name not in self.base_names:
|
|
201
|
+
# Final, so type checkers narrow a union such as `BackfillEstimate | JobStarted` with `"job_id" in r`.
|
|
202
|
+
lines.insert(0, "@final")
|
|
203
|
+
if doc:
|
|
204
|
+
body = "\n".join(f" {line}".rstrip() for line in doc.strip().splitlines())
|
|
205
|
+
lines.append(f' """{body.lstrip()}"""')
|
|
206
|
+
lines.append("")
|
|
207
|
+
for field, annotation, field_doc in fields:
|
|
208
|
+
if field_doc:
|
|
209
|
+
lines.extend(f" # {line}" for line in field_doc.strip().splitlines())
|
|
210
|
+
lines.append(f" {field}: {annotation}")
|
|
211
|
+
if not fields and not doc:
|
|
212
|
+
lines.append(" pass")
|
|
213
|
+
self.blocks[name] = "\n".join(lines) + "\n"
|
|
214
|
+
|
|
215
|
+
def render(self) -> str:
|
|
216
|
+
for name, schema in self.schemas.items():
|
|
217
|
+
self.named(name, schema)
|
|
218
|
+
emitted: List[str] = []
|
|
219
|
+
done: set[str] = set()
|
|
220
|
+
|
|
221
|
+
def emit(name: str) -> None:
|
|
222
|
+
if name in done:
|
|
223
|
+
return
|
|
224
|
+
for base in self.bases.get(name, []):
|
|
225
|
+
emit(base)
|
|
226
|
+
done.add(name)
|
|
227
|
+
emitted.append(self.blocks[name])
|
|
228
|
+
|
|
229
|
+
for name in self.blocks:
|
|
230
|
+
emit(name)
|
|
231
|
+
return HEADER + "\n\n".join(emitted)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def comment(doc: Optional[str]) -> str:
|
|
235
|
+
if not doc:
|
|
236
|
+
return ""
|
|
237
|
+
return "".join(f"# {line}".rstrip() + "\n" for line in doc.strip().splitlines())
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
HEADER = '''"""Types for every schema in the API contract (`api/openapi.yaml`).
|
|
241
|
+
|
|
242
|
+
Generated by `scripts/generate_types.py`. Do not edit by hand.
|
|
243
|
+
|
|
244
|
+
Request and response bodies are plain JSON: these types describe it for type
|
|
245
|
+
checkers and do nothing at runtime. Filters and `rank_by` are tuples.
|
|
246
|
+
"""
|
|
247
|
+
|
|
248
|
+
# ruff: noqa
|
|
249
|
+
from typing import Any, Mapping, Optional, Sequence, Tuple, Union
|
|
250
|
+
|
|
251
|
+
from typing_extensions import Literal, NotRequired, TypedDict, final
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
'''
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# References to the private main spec (`§7.2.8`, `§9 Plans`) and decision log (`D-v15-611`), which the
|
|
258
|
+
# package's comments never carry. The same patterns as docs/scripts/refs.ts.
|
|
259
|
+
REF = r"(?:§\d+(?:\.\d+)*(?: Plans)?|D-[a-z][a-z0-9]*(?:-[a-z][a-z0-9]*)*-\d+)"
|
|
260
|
+
REFS = rf"{REF}(?:\s*(?:,|;|\band\b|\bto\b)\s*{REF})*"
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def strip_internal_refs(value: Any) -> Any:
|
|
264
|
+
"""Every string in `value` without spec or decision references, as the docs' API reference strips them."""
|
|
265
|
+
if isinstance(value, dict):
|
|
266
|
+
return {key: strip_internal_refs(item) for key, item in value.items()}
|
|
267
|
+
if isinstance(value, list):
|
|
268
|
+
return [strip_internal_refs(item) for item in value]
|
|
269
|
+
if not isinstance(value, str):
|
|
270
|
+
return value
|
|
271
|
+
value = re.sub(rf"\s*\({REFS}\)", "", value)
|
|
272
|
+
value = re.sub(rf",\s*{REFS}(?=\s*[,)])", "", value)
|
|
273
|
+
value = re.sub(rf"\({REFS}\s*,\s*", "(", value)
|
|
274
|
+
return re.sub(rf"\s*\bSee {REF}\.", "", value)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def render_brand(brand: Dict[str, str]) -> str:
|
|
278
|
+
"""`_brand.py`: the product name and the default base URL, from `docs/brand.json`."""
|
|
279
|
+
name, domain = brand["productName"], brand["domain"]
|
|
280
|
+
return f'''"""The product name and the API's default base URL.
|
|
281
|
+
|
|
282
|
+
Generated from docs/brand.json by scripts/generate_types.py. Do not edit by hand. docs/brand.json is
|
|
283
|
+
the one place the product name and domain are set: edit it, then run this script, and `pnpm generate`
|
|
284
|
+
in sdks/typescript and in docs/.
|
|
285
|
+
"""
|
|
286
|
+
|
|
287
|
+
PRODUCT_NAME = {json.dumps(name)}
|
|
288
|
+
DEFAULT_BASE_URL = {json.dumps(f"https://api.{domain}/v1")}
|
|
289
|
+
'''
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def main() -> None:
|
|
293
|
+
document = strip_internal_refs(yaml.safe_load(OPENAPI.read_text()))
|
|
294
|
+
outputs = {
|
|
295
|
+
OUTPUT: Generator(document["components"]["schemas"]).render(),
|
|
296
|
+
BRAND_OUTPUT: render_brand(json.loads(BRAND.read_text())),
|
|
297
|
+
}
|
|
298
|
+
for path, source in outputs.items():
|
|
299
|
+
if "--check" not in sys.argv:
|
|
300
|
+
path.write_text(source)
|
|
301
|
+
elif path.read_text() != source:
|
|
302
|
+
raise SystemExit(f"{path} is stale: run scripts/generate_types.py")
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
if __name__ == "__main__":
|
|
306
|
+
main()
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Python SDK for the Brussle API.
|
|
2
|
+
|
|
3
|
+
from brussle import Client
|
|
4
|
+
|
|
5
|
+
db = Client(api_key=...)
|
|
6
|
+
ns = db.namespace("acme/prod/tenant_123")
|
|
7
|
+
ns.query(filters=And(("attributes.plan", "Eq", "pro"), ("answers.needs_escalation.p", "Gte", 0.85)), top_k=10)
|
|
8
|
+
|
|
9
|
+
Request and response bodies are the JSON of `api/openapi.yaml`, typed by
|
|
10
|
+
`brussle.types`. Filters and `rank_by` are tuples. A pushed event is checked
|
|
11
|
+
with `verify_webhook`, which returns it as `brussle.types.WebhookEvent`.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from . import types
|
|
15
|
+
from ._brand import DEFAULT_BASE_URL, PRODUCT_NAME
|
|
16
|
+
from .filters import And, Not, Or
|
|
17
|
+
from ._import import MAX_WRITE_BYTES, MAX_WRITE_DOCUMENTS, ImportFailure, ImportSummary
|
|
18
|
+
from ._client import (
|
|
19
|
+
MAX_RETRY_AFTER_SECONDS,
|
|
20
|
+
ApiError,
|
|
21
|
+
Client,
|
|
22
|
+
EnginesClient,
|
|
23
|
+
EventsClient,
|
|
24
|
+
GroupsClient,
|
|
25
|
+
JobsClient,
|
|
26
|
+
JudgmentsClient,
|
|
27
|
+
NamespaceClient,
|
|
28
|
+
NamespacesClient,
|
|
29
|
+
NonProductionPrefixesClient,
|
|
30
|
+
OutcomesClient,
|
|
31
|
+
StartersClient,
|
|
32
|
+
SubscriptionsClient,
|
|
33
|
+
TemplatesClient,
|
|
34
|
+
TenantSummaryClient,
|
|
35
|
+
WebhookEndpointsClient,
|
|
36
|
+
)
|
|
37
|
+
from ._version import __version__
|
|
38
|
+
from .webhooks import WebhookHeaders, WebhookVerificationError, verify_webhook
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"DEFAULT_BASE_URL",
|
|
42
|
+
"MAX_RETRY_AFTER_SECONDS",
|
|
43
|
+
"MAX_WRITE_BYTES",
|
|
44
|
+
"MAX_WRITE_DOCUMENTS",
|
|
45
|
+
"PRODUCT_NAME",
|
|
46
|
+
"And",
|
|
47
|
+
"ApiError",
|
|
48
|
+
"Client",
|
|
49
|
+
"EnginesClient",
|
|
50
|
+
"EventsClient",
|
|
51
|
+
"GroupsClient",
|
|
52
|
+
"ImportFailure",
|
|
53
|
+
"ImportSummary",
|
|
54
|
+
"JobsClient",
|
|
55
|
+
"JudgmentsClient",
|
|
56
|
+
"NamespaceClient",
|
|
57
|
+
"NamespacesClient",
|
|
58
|
+
"NonProductionPrefixesClient",
|
|
59
|
+
"Not",
|
|
60
|
+
"Or",
|
|
61
|
+
"OutcomesClient",
|
|
62
|
+
"StartersClient",
|
|
63
|
+
"SubscriptionsClient",
|
|
64
|
+
"TemplatesClient",
|
|
65
|
+
"TenantSummaryClient",
|
|
66
|
+
"WebhookEndpointsClient",
|
|
67
|
+
"WebhookHeaders",
|
|
68
|
+
"WebhookVerificationError",
|
|
69
|
+
"__version__",
|
|
70
|
+
"types",
|
|
71
|
+
"verify_webhook",
|
|
72
|
+
]
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""The product name and the API's default base URL.
|
|
2
|
+
|
|
3
|
+
Generated from docs/brand.json by scripts/generate_types.py. Do not edit by hand. docs/brand.json is
|
|
4
|
+
the one place the product name and domain are set: edit it, then run this script, and `pnpm generate`
|
|
5
|
+
in sdks/typescript and in docs/.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
PRODUCT_NAME = "Brussle"
|
|
9
|
+
DEFAULT_BASE_URL = "https://api.brussle.com/v1"
|