taisce 0.1.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.
- taisce-0.1.0/PKG-INFO +14 -0
- taisce-0.1.0/README.md +5 -0
- taisce-0.1.0/pyproject.toml +15 -0
- taisce-0.1.0/setup.cfg +4 -0
- taisce-0.1.0/taisce/__init__.py +7 -0
- taisce-0.1.0/taisce/client.py +187 -0
- taisce-0.1.0/taisce/memory.py +64 -0
- taisce-0.1.0/taisce.egg-info/PKG-INFO +14 -0
- taisce-0.1.0/taisce.egg-info/SOURCES.txt +12 -0
- taisce-0.1.0/taisce.egg-info/dependency_links.txt +1 -0
- taisce-0.1.0/taisce.egg-info/requires.txt +1 -0
- taisce-0.1.0/taisce.egg-info/top_level.txt +1 -0
- taisce-0.1.0/tests/test_client.py +20 -0
- taisce-0.1.0/tests/test_context_message.py +29 -0
taisce-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: taisce
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Client for a Taisce memory deployment: the v1 contract, no agent framework.
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: httpx>=0.27
|
|
9
|
+
|
|
10
|
+
# taisce
|
|
11
|
+
|
|
12
|
+
The client for a Taisce memory deployment over the v1 contract: `observe`, `freshness`, `recall`
|
|
13
|
+
and `resolve_citation`, asynchronous, with `httpx` as its only dependency. The agent framework
|
|
14
|
+
adapters in this repository are built on it.
|
taisce-0.1.0/README.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "taisce"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Client for a Taisce memory deployment: the v1 contract, no agent framework."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = "Apache-2.0"
|
|
8
|
+
dependencies = ["httpx>=0.27"]
|
|
9
|
+
|
|
10
|
+
[build-system]
|
|
11
|
+
requires = ["setuptools>=61"]
|
|
12
|
+
build-backend = "setuptools.build_meta"
|
|
13
|
+
|
|
14
|
+
[tool.setuptools.packages.find]
|
|
15
|
+
include = ["taisce*"]
|
taisce-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Copyright 2026 The Taisce Authors
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Taisce for Python: the client over the v1 contract, and what every adapter renders the same."""
|
|
4
|
+
from .client import Client, Freshness, TaisceError, bundle_is_empty
|
|
5
|
+
from .memory import MEMORY_MESSAGE_PREFIX, is_memory_text, render_context_message, render_memory_message, turn_key
|
|
6
|
+
|
|
7
|
+
__all__ = ["Client", "Freshness", "TaisceError", "bundle_is_empty", "MEMORY_MESSAGE_PREFIX", "is_memory_text", "render_context_message", "render_memory_message", "turn_key"]
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# Copyright 2026 The Taisce Authors
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""The client: the v1 contract of a Taisce deployment, and nothing about any agent framework.
|
|
4
|
+
|
|
5
|
+
The adapters are built on this, so a caller who wants governed memory without a framework does not
|
|
6
|
+
acquire one by asking. It is asynchronous because the frameworks it serves are; the sync wrapper
|
|
7
|
+
exists for scripts. The credential is a bearer token bound to one project, sent nowhere but the
|
|
8
|
+
deployment this client was built for, and never logged.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Any, Mapping, Optional, Sequence
|
|
14
|
+
|
|
15
|
+
import base64
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TaisceError(Exception):
|
|
21
|
+
"""A refusal from the deployment, carrying the contract's own code. Branch on ``code``."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, status: int, code: str, message: str) -> None:
|
|
24
|
+
super().__init__(f"{status} {code}: {message}")
|
|
25
|
+
self.status = status
|
|
26
|
+
self.code = code
|
|
27
|
+
self.message = message
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class Freshness:
|
|
32
|
+
"""How far behind memory is: the highest offset stored, the highest formed, the parked count."""
|
|
33
|
+
|
|
34
|
+
scope: str
|
|
35
|
+
stored: Optional[int]
|
|
36
|
+
formed: Optional[int]
|
|
37
|
+
parked: int
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Client:
|
|
41
|
+
"""An asynchronous client over the v1 contract."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, base_url: str, token: str, *, http: Optional[httpx.AsyncClient] = None, timeout: float = 30.0) -> None:
|
|
44
|
+
if not base_url or not base_url.strip():
|
|
45
|
+
raise ValueError("a deployment address is required")
|
|
46
|
+
if not token or not token.strip():
|
|
47
|
+
raise ValueError("a credential is required")
|
|
48
|
+
self.base_url = base_url.rstrip("/")
|
|
49
|
+
self._owns_http = http is None
|
|
50
|
+
self._http = http or httpx.AsyncClient(timeout=timeout)
|
|
51
|
+
self._headers = {"Authorization": "Bearer " + token, "Content-Type": "application/json"}
|
|
52
|
+
|
|
53
|
+
async def aclose(self) -> None:
|
|
54
|
+
if self._owns_http:
|
|
55
|
+
await self._http.aclose()
|
|
56
|
+
|
|
57
|
+
async def __aenter__(self) -> "Client":
|
|
58
|
+
return self
|
|
59
|
+
|
|
60
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
61
|
+
await self.aclose()
|
|
62
|
+
|
|
63
|
+
async def observe(self, *, idempotency_key: str, messages: Sequence[Mapping[str, Any]],
|
|
64
|
+
data_subject_id: Optional[str] = None, occurred_at: Optional[str] = None) -> dict:
|
|
65
|
+
"""Records a turn. Durable when it returns; formation follows."""
|
|
66
|
+
body: dict = {"idempotency_key": idempotency_key, "messages": list(messages)}
|
|
67
|
+
if data_subject_id:
|
|
68
|
+
body["data_subject_id"] = data_subject_id
|
|
69
|
+
if occurred_at:
|
|
70
|
+
body["occurred_at"] = occurred_at
|
|
71
|
+
return await self._post("/v1/observations", body, 201)
|
|
72
|
+
|
|
73
|
+
async def freshness(self) -> Freshness:
|
|
74
|
+
"""How far behind memory is."""
|
|
75
|
+
response = await self._http.get(self.base_url + "/v1/freshness", headers=self._headers)
|
|
76
|
+
data = self._read(response, 200)
|
|
77
|
+
return Freshness(scope=data.get("scope", ""), stored=data.get("stored"), formed=data.get("formed"), parked=int(data.get("parked", 0)))
|
|
78
|
+
|
|
79
|
+
async def recall(self, *, question: str, data_subject_id: Optional[str] = None, **controls: Any) -> dict:
|
|
80
|
+
"""Asks memory a question. ``controls`` are the contract's: max_characters, source_roles, hops, surfaces, themes, as_of, as_known_at."""
|
|
81
|
+
body: dict = {"question": question}
|
|
82
|
+
if data_subject_id:
|
|
83
|
+
body["data_subject_id"] = data_subject_id
|
|
84
|
+
body.update({k: v for k, v in controls.items() if v is not None})
|
|
85
|
+
return await self._post("/v1/recalls", body, 200)
|
|
86
|
+
|
|
87
|
+
async def put_artifact(self, *, artifact_id: str, data_subject_id: str, kind: str, content: bytes,
|
|
88
|
+
name: Optional[str] = None, expected_version: Optional[str] = None) -> dict:
|
|
89
|
+
"""Stores an opaque object under a person, replacing the version it names.
|
|
90
|
+
|
|
91
|
+
The bytes are the application's and the deployment never interprets them. It holds them
|
|
92
|
+
under a project and a person, expires them with that person's retention and removes them
|
|
93
|
+
with that person's erasure — which is the whole reason to keep session state here rather
|
|
94
|
+
than in an application's own database, where a deletion request would have two places to
|
|
95
|
+
sweep and a counted residual for only one of them.
|
|
96
|
+
"""
|
|
97
|
+
body: dict = {"id": artifact_id, "data_subject_id": data_subject_id, "kind": kind,
|
|
98
|
+
"content": base64.b64encode(content).decode("ascii")}
|
|
99
|
+
if name:
|
|
100
|
+
body["name"] = name
|
|
101
|
+
if expected_version:
|
|
102
|
+
body["expected_version"] = expected_version
|
|
103
|
+
return await self._post("/v1/artifacts/put", body, 200)
|
|
104
|
+
|
|
105
|
+
async def get_artifact(self, *, artifact_id: str, data_subject_id: Optional[str] = None) -> dict:
|
|
106
|
+
"""Reads one object, optionally requiring it to belong to the person named.
|
|
107
|
+
|
|
108
|
+
One credential opens a project, and a project holds every end user's objects. Naming the
|
|
109
|
+
subject makes "this one is theirs" a requirement the deployment enforces rather than a habit
|
|
110
|
+
the application keeps: another person's object is answered exactly as one that is not there.
|
|
111
|
+
"""
|
|
112
|
+
body: dict = {"id": artifact_id}
|
|
113
|
+
if data_subject_id:
|
|
114
|
+
body["data_subject_id"] = data_subject_id
|
|
115
|
+
return await self._post("/v1/artifacts/get", body, 200)
|
|
116
|
+
|
|
117
|
+
async def delete_artifact(self, *, artifact_id: str, data_subject_id: Optional[str] = None,
|
|
118
|
+
expected_version: Optional[str] = None) -> dict:
|
|
119
|
+
"""Removes one object, optionally requiring it to belong to the person named."""
|
|
120
|
+
body: dict = {"id": artifact_id}
|
|
121
|
+
if data_subject_id:
|
|
122
|
+
body["data_subject_id"] = data_subject_id
|
|
123
|
+
if expected_version:
|
|
124
|
+
body["expected_version"] = expected_version
|
|
125
|
+
return await self._post("/v1/artifacts/delete", body, 200)
|
|
126
|
+
|
|
127
|
+
async def search_passages(self, *, question: str, limit: Optional[int] = None,
|
|
128
|
+
data_subject_id: Optional[str] = None,
|
|
129
|
+
source_role: Optional[str] = None) -> dict:
|
|
130
|
+
"""Searches the stored words themselves, for a question no fact answers.
|
|
131
|
+
|
|
132
|
+
This is the other half of retrieval and deliberately separate from :meth:`recall`: recall
|
|
133
|
+
answers from what was inferred, this answers from what was said. A caller that wants
|
|
134
|
+
grounding without adopting the memory model uses only this.
|
|
135
|
+
|
|
136
|
+
The answer carries ``approximate`` and ``covered_through_offset`` because a passage search
|
|
137
|
+
reads an embedding generation, and a generation is built up to a point in the log. Treating
|
|
138
|
+
an answer as complete while the build is behind quotes an index rather than the memory.
|
|
139
|
+
"""
|
|
140
|
+
body: dict = {"question": question}
|
|
141
|
+
if limit is not None:
|
|
142
|
+
body["limit"] = limit
|
|
143
|
+
if data_subject_id:
|
|
144
|
+
body["data_subject_id"] = data_subject_id
|
|
145
|
+
if source_role:
|
|
146
|
+
body["source_role"] = source_role
|
|
147
|
+
return await self._post("/v1/passages/search", body, 200)
|
|
148
|
+
|
|
149
|
+
async def context(self, *, data_subject_id: str, max_characters: Optional[int] = None) -> dict:
|
|
150
|
+
"""One subject's history under a budget: the newest turns verbatim and, over the rest, the
|
|
151
|
+
segments the deployment wrote. No model call is made; what the deployment has not rolled up
|
|
152
|
+
yet comes back verbatim and cut from the oldest end."""
|
|
153
|
+
if not data_subject_id or not data_subject_id.strip():
|
|
154
|
+
raise ValueError("a data subject is required: a context is one subject's history")
|
|
155
|
+
body: dict = {"data_subject_id": data_subject_id}
|
|
156
|
+
if max_characters is not None:
|
|
157
|
+
body["max_characters"] = max_characters
|
|
158
|
+
return await self._post("/v1/contexts", body, 200)
|
|
159
|
+
|
|
160
|
+
async def resolve_citation(self, *, fact_id: str, limit: Optional[int] = None) -> dict:
|
|
161
|
+
"""Resolves a fact to its record."""
|
|
162
|
+
body: dict = {"id": fact_id}
|
|
163
|
+
if limit is not None:
|
|
164
|
+
body["limit"] = limit
|
|
165
|
+
return await self._post("/v1/citations/resolve", body, 200)
|
|
166
|
+
|
|
167
|
+
async def _post(self, path: str, body: dict, success: int) -> dict:
|
|
168
|
+
response = await self._http.post(self.base_url + path, json=body, headers=self._headers)
|
|
169
|
+
return self._read(response, success)
|
|
170
|
+
|
|
171
|
+
@staticmethod
|
|
172
|
+
def _read(response: httpx.Response, success: int) -> dict:
|
|
173
|
+
if response.status_code == success:
|
|
174
|
+
return response.json()
|
|
175
|
+
code, message = "unexpected_response", "the deployment answered " + str(response.status_code)
|
|
176
|
+
try:
|
|
177
|
+
error = response.json().get("error") or {}
|
|
178
|
+
code = error.get("code") or code
|
|
179
|
+
message = error.get("message") or message
|
|
180
|
+
except ValueError:
|
|
181
|
+
pass
|
|
182
|
+
raise TaisceError(response.status_code, code, message)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def bundle_is_empty(bundle: Mapping[str, Any]) -> bool:
|
|
186
|
+
"""Whether a recall returned nothing at all: no facts, no reports, no passages."""
|
|
187
|
+
return not (bundle.get("facts") or bundle.get("reports") or bundle.get("passages"))
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Copyright 2026 The Taisce Authors
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""What every adapter renders the same way: the memory message and the turn key.
|
|
4
|
+
|
|
5
|
+
These live in the client so that the two Python adapters share one implementation, and so that
|
|
6
|
+
the bytes agree with the .NET adapter's, which derives them the same way: a turn stored by two
|
|
7
|
+
adapters is one observation, and a memory message from either parses the same.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import uuid
|
|
14
|
+
from typing import Mapping, Optional, Sequence
|
|
15
|
+
|
|
16
|
+
#: The first line of every injected memory message; the conformance suite parses it.
|
|
17
|
+
MEMORY_MESSAGE_PREFIX = "taisce-memory/v1 untrusted"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def render_memory_message(watermark: Mapping[str, object], bundle: Mapping[str, object]) -> str:
|
|
21
|
+
"""The prefix line, then a JSON document with the watermark, the plan (what the recall did)
|
|
22
|
+
and the recall's own arrays unchanged."""
|
|
23
|
+
document = {
|
|
24
|
+
"watermark": {"stored": watermark.get("stored"), "formed": watermark.get("formed"), "parked": watermark.get("parked", 0)},
|
|
25
|
+
"plan": {"controls": bundle.get("controls"), "degraded": bundle.get("degraded") or [], "reach": bundle.get("reach")},
|
|
26
|
+
"facts": bundle.get("facts") or [],
|
|
27
|
+
"reports": bundle.get("reports") or [],
|
|
28
|
+
"passages": bundle.get("passages") or [],
|
|
29
|
+
}
|
|
30
|
+
return MEMORY_MESSAGE_PREFIX + "\n" + json.dumps(document, separators=(",", ":"))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def render_context_message(context: Mapping[str, object]) -> str:
|
|
34
|
+
"""The prefix line, then a JSON document with the watermark, the assembly's cost as the plan,
|
|
35
|
+
and the context's segments and turns unchanged: what replaces a history is the deployment's
|
|
36
|
+
answer and nothing an adapter wrote."""
|
|
37
|
+
watermark = context.get("watermark") or {}
|
|
38
|
+
if not isinstance(watermark, Mapping):
|
|
39
|
+
watermark = {}
|
|
40
|
+
document = {
|
|
41
|
+
"watermark": {"stored": watermark.get("stored"), "formed": watermark.get("formed"), "parked": watermark.get("parked", 0)},
|
|
42
|
+
"plan": {"characters": context.get("characters") or 0, "truncated": bool(context.get("truncated"))},
|
|
43
|
+
"segments": context.get("segments") or [],
|
|
44
|
+
"turns": context.get("turns") or [],
|
|
45
|
+
}
|
|
46
|
+
return MEMORY_MESSAGE_PREFIX + "\n" + json.dumps(document, separators=(",", ":"))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def is_memory_text(text: Optional[str]) -> bool:
|
|
50
|
+
"""Whether a message's text is one an adapter injected, by its first line."""
|
|
51
|
+
return bool(text) and text.startswith(MEMORY_MESSAGE_PREFIX)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def turn_key(data_subject_id: Optional[str], run_id: Optional[str], messages: Sequence[Mapping[str, str]]) -> str:
|
|
55
|
+
"""A turn's key: a version-5 UUID over the subject, the run and the messages, so a retried
|
|
56
|
+
store is one observation and a different turn is another."""
|
|
57
|
+
text = (data_subject_id or "") + "\n" + (run_id or "") + "\n"
|
|
58
|
+
for m in messages:
|
|
59
|
+
text += m["role"] + "\n" + m["content"] + "\n"
|
|
60
|
+
digest = hashlib.sha256(text.encode("utf-8")).digest()
|
|
61
|
+
raw = bytearray(digest[:16])
|
|
62
|
+
raw[6] = (raw[6] & 0x0F) | 0x50
|
|
63
|
+
raw[8] = (raw[8] & 0x3F) | 0x80
|
|
64
|
+
return str(uuid.UUID(bytes=bytes(raw)))
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: taisce
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Client for a Taisce memory deployment: the v1 contract, no agent framework.
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: httpx>=0.27
|
|
9
|
+
|
|
10
|
+
# taisce
|
|
11
|
+
|
|
12
|
+
The client for a Taisce memory deployment over the v1 contract: `observe`, `freshness`, `recall`
|
|
13
|
+
and `resolve_citation`, asynchronous, with `httpx` as its only dependency. The agent framework
|
|
14
|
+
adapters in this repository are built on it.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
taisce/__init__.py
|
|
4
|
+
taisce/client.py
|
|
5
|
+
taisce/memory.py
|
|
6
|
+
taisce.egg-info/PKG-INFO
|
|
7
|
+
taisce.egg-info/SOURCES.txt
|
|
8
|
+
taisce.egg-info/dependency_links.txt
|
|
9
|
+
taisce.egg-info/requires.txt
|
|
10
|
+
taisce.egg-info/top_level.txt
|
|
11
|
+
tests/test_client.py
|
|
12
|
+
tests/test_context_message.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
httpx>=0.27
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
taisce
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Copyright 2026 The Taisce Authors
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from taisce import Client, bundle_is_empty
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_a_client_refuses_to_be_built_without_a_deployment_or_a_credential():
|
|
9
|
+
with pytest.raises(ValueError):
|
|
10
|
+
Client("", "tsk")
|
|
11
|
+
with pytest.raises(ValueError):
|
|
12
|
+
Client("http://127.0.0.1:1", " ")
|
|
13
|
+
client = Client("http://127.0.0.1:1/", "tsk")
|
|
14
|
+
assert client.base_url == "http://127.0.0.1:1"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_an_empty_bundle_is_empty():
|
|
18
|
+
assert bundle_is_empty({"facts": [], "reports": [], "passages": []})
|
|
19
|
+
assert not bundle_is_empty({"facts": [{"fact_id": "f1"}], "reports": [], "passages": []})
|
|
20
|
+
assert not bundle_is_empty({"facts": [], "reports": [{"title": "t"}], "passages": []})
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Copyright 2026 The Taisce Authors
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from taisce import Client, MEMORY_MESSAGE_PREFIX, is_memory_text, render_context_message
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_the_context_message_is_the_prefix_line_and_the_deployments_arrays_unchanged():
|
|
11
|
+
context = {"watermark": {"stored": 12, "formed": 12}, "characters": 140, "truncated": False,
|
|
12
|
+
"segments": [{"level": 1, "summary": "The office moved."}],
|
|
13
|
+
"turns": [{"log_offset": 9, "messages": [{"role": "user", "content": "Book it."}]}]}
|
|
14
|
+
rendered = render_context_message(context)
|
|
15
|
+
assert rendered.startswith(MEMORY_MESSAGE_PREFIX + "\n") and is_memory_text(rendered)
|
|
16
|
+
document = json.loads(rendered.split("\n", 1)[1])
|
|
17
|
+
assert document["watermark"] == {"stored": 12, "formed": 12, "parked": 0}
|
|
18
|
+
assert document["plan"] == {"characters": 140, "truncated": False}
|
|
19
|
+
assert document["segments"] == context["segments"] and document["turns"] == context["turns"]
|
|
20
|
+
# A context with nothing in it renders empty arrays, never a missing field.
|
|
21
|
+
bare = json.loads(render_context_message({}).split("\n", 1)[1])
|
|
22
|
+
assert bare["segments"] == [] and bare["turns"] == [] and bare["watermark"]["stored"] is None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.mark.asyncio
|
|
26
|
+
async def test_a_context_needs_a_subject():
|
|
27
|
+
async with Client("http://127.0.0.1:1", "tsk") as client:
|
|
28
|
+
with pytest.raises(ValueError):
|
|
29
|
+
await client.context(data_subject_id=" ")
|