semiont 0.6.12.dev2__py3-none-any.whl
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.
- semiont/__init__.py +20 -0
- semiont/bus.py +236 -0
- semiont/cache.py +345 -0
- semiont/cached.py +152 -0
- semiont/channel.py +81 -0
- semiont/channels.py +1306 -0
- semiont/client.py +161 -0
- semiont/error_codes.py +232 -0
- semiont/errors.py +147 -0
- semiont/event_bus.py +108 -0
- semiont/events.py +101 -0
- semiont/http/__init__.py +24 -0
- semiont/http/agent.py +232 -0
- semiont/http/content.py +234 -0
- semiont/http/exchange.py +200 -0
- semiont/http/oauth.py +346 -0
- semiont/http/session.py +191 -0
- semiont/http/sse.py +71 -0
- semiont/http/stream.py +683 -0
- semiont/http/transport.py +326 -0
- semiont/identifiers.py +114 -0
- semiont/identity.py +59 -0
- semiont/media_types.py +69 -0
- semiont/media_types_table.py +89 -0
- semiont/model.py +33 -0
- semiont/namespaces/__init__.py +19 -0
- semiont/namespaces/auth.py +32 -0
- semiont/namespaces/beckon.py +47 -0
- semiont/namespaces/bind.py +38 -0
- semiont/namespaces/browse.py +338 -0
- semiont/namespaces/follow.py +238 -0
- semiont/namespaces/frame.py +35 -0
- semiont/namespaces/gather.py +82 -0
- semiont/namespaces/job.py +94 -0
- semiont/namespaces/links.py +66 -0
- semiont/namespaces/live.py +301 -0
- semiont/namespaces/mark.py +174 -0
- semiont/namespaces/match.py +67 -0
- semiont/namespaces/refresher.py +283 -0
- semiont/namespaces/system.py +24 -0
- semiont/namespaces/yield_.py +101 -0
- semiont/oauth_clients.py +27 -0
- semiont/operations.py +337 -0
- semiont/py.typed +0 -0
- semiont/refresh.py +188 -0
- semiont/resume.py +95 -0
- semiont/retry.py +164 -0
- semiont/running.py +110 -0
- semiont/session.py +493 -0
- semiont/sign_in.py +54 -0
- semiont/sign_in_store.py +283 -0
- semiont/storage.py +139 -0
- semiont/telemetry.py +116 -0
- semiont/telemetry_table.py +121 -0
- semiont/testing/__init__.py +62 -0
- semiont/testing/client.py +53 -0
- semiont/testing/content.py +124 -0
- semiont/testing/gateway.py +85 -0
- semiont/testing/transport.py +341 -0
- semiont/timing.py +187 -0
- semiont/transport.py +531 -0
- semiont/types.py +5902 -0
- semiont/watched.py +111 -0
- semiont-0.6.12.dev2.dist-info/METADATA +533 -0
- semiont-0.6.12.dev2.dist-info/RECORD +66 -0
- semiont-0.6.12.dev2.dist-info/WHEEL +4 -0
semiont/model.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""The base of every shape the protocol states, and how one is written to the wire."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, JsonValue
|
|
4
|
+
|
|
5
|
+
__all__ = ["WireModel", "written"]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class WireModel(BaseModel, frozen=True, validate_by_name=True, validate_by_alias=True, serialize_by_alias=True):
|
|
9
|
+
"""A shape of the protocol: immutable, and named two ways.
|
|
10
|
+
|
|
11
|
+
A field has the name Python gives it (`annotation_id`) and the name the wire
|
|
12
|
+
gives it (`annotationId`). A model is built by the first, decoded from
|
|
13
|
+
either, and written out by the second.
|
|
14
|
+
|
|
15
|
+
Every subclass says `frozen=True` for itself: a type checker reads it from
|
|
16
|
+
the class's own line, and refuses one that leaves it out. A class's
|
|
17
|
+
configuration is its class keywords, here and in every subclass.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def written(model: WireModel) -> dict[str, JsonValue]:
|
|
22
|
+
"""`model` as the wire carries it: by the wire's names, and without what was never said.
|
|
23
|
+
|
|
24
|
+
A field of the model itself that may be left out, and holds nothing, is
|
|
25
|
+
left out: an option given as `None` is an option not given. What the
|
|
26
|
+
model holds deeper is written as it was given, so a shape read from the
|
|
27
|
+
knowledge base is sent back as it came.
|
|
28
|
+
"""
|
|
29
|
+
wire: dict[str, JsonValue] = model.model_dump(mode="json", exclude_unset=True)
|
|
30
|
+
for name, field in type(model).model_fields.items():
|
|
31
|
+
if not field.is_required() and getattr(model, name) is None:
|
|
32
|
+
wire.pop(field.alias or name, None)
|
|
33
|
+
return wire
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""The namespaces of a client (`semiont.client.SemiontClient`).
|
|
2
|
+
|
|
3
|
+
One per flow of the protocol and three beside them: `frame`, `browse`, `mark`,
|
|
4
|
+
`bind`, `gather`, `match`, `yield_` and `beckon`; and `job`, `auth` and
|
|
5
|
+
`system`.
|
|
6
|
+
|
|
7
|
+
Every method returns one of seven shapes, and its name, its shape and what
|
|
8
|
+
calling it does first are a row of `specs/src/client/surface.json`, which
|
|
9
|
+
every SDK is held to:
|
|
10
|
+
|
|
11
|
+
- an `async def`: asked once, answered once;
|
|
12
|
+
- a `Running`: a long-running operation, its reports and its final value;
|
|
13
|
+
- an `Upload`: an upload's progress and the resource it created;
|
|
14
|
+
- a `Cached`: a query, sent when its `fresh` is called;
|
|
15
|
+
- a plain `def` that returns nothing: a signal, fire-and-forget;
|
|
16
|
+
- an `async def` giving `int | None`: a drive at the other participants, and
|
|
17
|
+
how many the gateway reached;
|
|
18
|
+
- a `Typed`: one channel's events, from now on.
|
|
19
|
+
"""
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Auth: the gateway's view of who is signed in.
|
|
2
|
+
|
|
3
|
+
Signing in happens at the issuer. This only asks the gateway what it sees.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Final, final
|
|
7
|
+
|
|
8
|
+
from semiont.identifiers import ResourceId
|
|
9
|
+
from semiont.transport import GatewayOperations
|
|
10
|
+
from semiont.types import MediaTokenResponse, ProtectedResourceMetadata, UserResponse
|
|
11
|
+
|
|
12
|
+
__all__ = ["AuthNamespace"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@final
|
|
16
|
+
class AuthNamespace:
|
|
17
|
+
"""See the module's documentation."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, gateway: GatewayOperations) -> None:
|
|
20
|
+
self._gateway: Final = gateway
|
|
21
|
+
|
|
22
|
+
async def me(self) -> UserResponse:
|
|
23
|
+
"""The signed-in principal."""
|
|
24
|
+
return await self._gateway.get_current_user()
|
|
25
|
+
|
|
26
|
+
async def media_token(self, resource_id: ResourceId) -> MediaTokenResponse:
|
|
27
|
+
"""A token that lets a browser fetch one resource's bytes."""
|
|
28
|
+
return await self._gateway.get_media_token(resource_id)
|
|
29
|
+
|
|
30
|
+
async def protected_resource_metadata(self) -> ProtectedResourceMetadata:
|
|
31
|
+
"""Which issuer the knowledge base trusts: where to send someone to sign in."""
|
|
32
|
+
return await self._gateway.get_protected_resource_metadata()
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Beckon: attention.
|
|
2
|
+
|
|
3
|
+
The drives go over the wire, to every other participant, and resolve with how
|
|
4
|
+
many the gateway reached, or with no count when it kept none. The signals are
|
|
5
|
+
this viewer's own and never leave the client.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Final, final
|
|
9
|
+
|
|
10
|
+
from semiont.channels import BECKON_FOCUS, BECKON_HOVER, BECKON_SPARKLE, BROWSE_CLICK, BROWSE_RESOURCE_OPEN
|
|
11
|
+
from semiont.identifiers import AnnotationId, ResourceId
|
|
12
|
+
from semiont.namespaces.links import Links
|
|
13
|
+
from semiont.types import BeckonFocusEvent, BeckonHoverEvent, BeckonSparkleEvent, BrowseClickEvent, BrowseResourceOpenEvent
|
|
14
|
+
|
|
15
|
+
__all__ = ["BeckonNamespace"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@final
|
|
19
|
+
class BeckonNamespace:
|
|
20
|
+
"""See the module's documentation."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, links: Links) -> None:
|
|
23
|
+
self._links: Final = links
|
|
24
|
+
|
|
25
|
+
async def attention(self, resource_id: ResourceId, annotation_id: AnnotationId) -> int | None:
|
|
26
|
+
"""Point the other participants at an annotation."""
|
|
27
|
+
return await self._links.drive(BECKON_FOCUS, BeckonFocusEvent(annotation_id=annotation_id, resource_id=resource_id))
|
|
28
|
+
|
|
29
|
+
async def click(self, annotation_id: AnnotationId) -> int | None:
|
|
30
|
+
"""Open an annotation on the other participants' screens."""
|
|
31
|
+
return await self._links.drive(BROWSE_CLICK, BrowseClickEvent(annotation_id=annotation_id))
|
|
32
|
+
|
|
33
|
+
async def open_resource(self, resource_id: ResourceId) -> int | None:
|
|
34
|
+
"""Open a resource on the other participants' screens."""
|
|
35
|
+
return await self._links.drive(BROWSE_RESOURCE_OPEN, BrowseResourceOpenEvent(resource_id=resource_id))
|
|
36
|
+
|
|
37
|
+
async def sparkle_all(self, annotation_id: AnnotationId) -> int | None:
|
|
38
|
+
"""Sparkle an annotation on every participant's viewer."""
|
|
39
|
+
return await self._links.drive(BECKON_SPARKLE, BeckonSparkleEvent(annotation_id=annotation_id))
|
|
40
|
+
|
|
41
|
+
def hover(self, annotation_id: AnnotationId | None) -> None:
|
|
42
|
+
"""Signal: this viewer hovers an annotation, or none."""
|
|
43
|
+
self._links.signal(BECKON_HOVER, BeckonHoverEvent(annotation_id=annotation_id))
|
|
44
|
+
|
|
45
|
+
def sparkle(self, annotation_id: AnnotationId) -> None:
|
|
46
|
+
"""Signal: sparkle an annotation on this viewer alone."""
|
|
47
|
+
self._links.signal(BECKON_SPARKLE, BeckonSparkleEvent(annotation_id=annotation_id))
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Bind: linking a reference to what it refers to."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from typing import Final, final
|
|
5
|
+
|
|
6
|
+
from semiont.channels import BIND_BODY_ERROR, BIND_INITIATE
|
|
7
|
+
from semiont.identifiers import AnnotationId, ResourceId
|
|
8
|
+
from semiont.namespaces.links import Links
|
|
9
|
+
from semiont.operations import BIND_UPDATE_BODY
|
|
10
|
+
from semiont.types import BindBodyOperation, BindInitiateCommand, BindUpdateBodyCommand, ResourceErrorEvent
|
|
11
|
+
|
|
12
|
+
__all__ = ["BindNamespace"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@final
|
|
16
|
+
class BindNamespace:
|
|
17
|
+
"""See the module's documentation."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, links: Links) -> None:
|
|
20
|
+
self._links: Final = links
|
|
21
|
+
|
|
22
|
+
async def body(self, resource_id: ResourceId, annotation_id: AnnotationId, operations: Sequence[BindBodyOperation]) -> None:
|
|
23
|
+
"""Change an annotation's body.
|
|
24
|
+
|
|
25
|
+
Confirmed: it resolves when the change is recorded, and fails with the
|
|
26
|
+
failure the knowledge base answered.
|
|
27
|
+
"""
|
|
28
|
+
await self._links.request(
|
|
29
|
+
BIND_UPDATE_BODY, BindUpdateBodyCommand(annotation_id=annotation_id, resource_id=resource_id, operations=list(operations))
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
def initiate(self, command: BindInitiateCommand) -> None:
|
|
33
|
+
"""Signal: a binding is wanted for an annotation."""
|
|
34
|
+
self._links.signal(BIND_INITIATE, command)
|
|
35
|
+
|
|
36
|
+
def report_body_error(self, event: ResourceErrorEvent) -> None:
|
|
37
|
+
"""Signal: a body update failed where nothing could show it."""
|
|
38
|
+
self._links.signal(BIND_BODY_ERROR, event)
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"""Browse: reads, and this viewer's own signals.
|
|
2
|
+
|
|
3
|
+
The queries (`Cached`) answer from the client's cache: one read with `fresh`,
|
|
4
|
+
or held with `async with` and watched. The one-shot reads are asked once. The
|
|
5
|
+
signals are this viewer's own, with the one report among them going over the
|
|
6
|
+
wire.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import codecs
|
|
11
|
+
from collections.abc import AsyncIterator, Callable, Hashable
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Final, Self, final, override
|
|
14
|
+
|
|
15
|
+
from semiont.cache import Cache, CacheState, Ready
|
|
16
|
+
from semiont.cached import Cached, Keyed, Source, itself, shown
|
|
17
|
+
from semiont.channels import BROWSE_CLICK, BROWSE_RESOURCE_OPEN, BROWSE_RESOURCE_VIEWED
|
|
18
|
+
from semiont.errors import SemiontError, TransportError
|
|
19
|
+
from semiont.identifiers import AnnotationId, ResourceId
|
|
20
|
+
from semiont.namespaces.links import Links
|
|
21
|
+
from semiont.namespaces.live import LIMITS_HOLDERS, WHOLE, Live, ResourceFilters
|
|
22
|
+
from semiont.operations import (
|
|
23
|
+
BROWSE_ANCHORED_TEXT_REQUESTED,
|
|
24
|
+
BROWSE_ANNOTATION_HISTORY_REQUESTED,
|
|
25
|
+
BROWSE_DIRECTORY_REQUESTED,
|
|
26
|
+
BROWSE_KB_REQUESTED,
|
|
27
|
+
)
|
|
28
|
+
from semiont.transport import Content, ContentStream, ContentTransport, ResourceHold
|
|
29
|
+
from semiont.types import (
|
|
30
|
+
AgentSoftware,
|
|
31
|
+
AnchoredTextAnswer,
|
|
32
|
+
Annotation,
|
|
33
|
+
AttributedEvent,
|
|
34
|
+
BrowseAnchoredTextRequest,
|
|
35
|
+
BrowseAnnotationHistoryRequest,
|
|
36
|
+
BrowseClickEvent,
|
|
37
|
+
BrowseDirectoryRequest,
|
|
38
|
+
BrowseDirectoryRequestSort,
|
|
39
|
+
BrowseDirectoryResultResponse,
|
|
40
|
+
BrowseKbRequest,
|
|
41
|
+
BrowseResourceOpenEvent,
|
|
42
|
+
BrowseResourceViewedEvent,
|
|
43
|
+
CollaboratorEntry,
|
|
44
|
+
GetAnnotationHistoryResponse,
|
|
45
|
+
GetAnnotationsResponse,
|
|
46
|
+
GetResourceResponse,
|
|
47
|
+
InferenceLimits,
|
|
48
|
+
InferencePairLimits,
|
|
49
|
+
KbDescription,
|
|
50
|
+
ListResourcesResponse,
|
|
51
|
+
ResourceDescriptor,
|
|
52
|
+
TagSchema,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
__all__ = ["BrowseNamespace", "Collaborator"]
|
|
56
|
+
|
|
57
|
+
# How many resources a list asks for when its caller states no limit. Every
|
|
58
|
+
# SDK asks for as many: the cases of specs/src/client/surface.json hold each to it.
|
|
59
|
+
_LIST_LIMIT: Final = 100
|
|
60
|
+
|
|
61
|
+
# What a browser reads these labels as: the Encoding Standard gives them to
|
|
62
|
+
# windows-1252, of which each is a part.
|
|
63
|
+
_READ_AS_WINDOWS_1252: Final = frozenset({"iso-8859-1", "iso8859-1", "latin1", "l1", "ascii", "us-ascii"})
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@final
|
|
67
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
68
|
+
class Collaborator:
|
|
69
|
+
"""One of the knowledge base's collaborators.
|
|
70
|
+
|
|
71
|
+
Its entry in the directory, and its model's limits when the service
|
|
72
|
+
holding that model's credentials reported them.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
entry: CollaboratorEntry
|
|
76
|
+
limits: InferenceLimits | None = None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _joined(directory: list[CollaboratorEntry], reported: list[InferencePairLimits]) -> list[Collaborator]:
|
|
80
|
+
"""The directory with each reported model's limits on its entries."""
|
|
81
|
+
by_model = {(pair.provider, pair.model): pair.limits for pair in reversed(reported)}
|
|
82
|
+
return [
|
|
83
|
+
Collaborator(
|
|
84
|
+
entry=entry,
|
|
85
|
+
limits=by_model.get((entry.agent.provider, entry.agent.model))
|
|
86
|
+
if isinstance(entry.agent, AgentSoftware) and entry.agent.provider is not None and entry.agent.model is not None
|
|
87
|
+
else None,
|
|
88
|
+
)
|
|
89
|
+
for entry in directory
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _listed(answer: GetAnnotationsResponse) -> list[Annotation]:
|
|
94
|
+
return answer.annotations
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async def _next[S](states: AsyncIterator[S]) -> S | None:
|
|
98
|
+
"""The next state, or nothing when there will be no other."""
|
|
99
|
+
return await anext(states, None)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@final
|
|
103
|
+
class _Joined:
|
|
104
|
+
"""The directory's state, with what the key holders have reported so far.
|
|
105
|
+
|
|
106
|
+
A holder that has not answered delays only its own models' limits.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
def __init__(
|
|
110
|
+
self,
|
|
111
|
+
directory: AsyncIterator[CacheState[list[CollaboratorEntry]]],
|
|
112
|
+
reports: list[AsyncIterator[CacheState[list[InferencePairLimits]]]],
|
|
113
|
+
) -> None:
|
|
114
|
+
self._directory: Final = directory
|
|
115
|
+
self._reports: Final = reports
|
|
116
|
+
self._entries: CacheState[list[CollaboratorEntry]] | None = None
|
|
117
|
+
self._reported: Final[list[list[InferencePairLimits]]] = [[] for _ in reports]
|
|
118
|
+
self._shown: CacheState[list[Collaborator]] | None = None
|
|
119
|
+
"""The state last given: a report that changes nothing a watcher is shown is not a state."""
|
|
120
|
+
|
|
121
|
+
def __aiter__(self) -> Self:
|
|
122
|
+
return self
|
|
123
|
+
|
|
124
|
+
async def __anext__(self) -> CacheState[list[Collaborator]]:
|
|
125
|
+
while True:
|
|
126
|
+
directory = asyncio.ensure_future(_next(self._directory))
|
|
127
|
+
reports = [asyncio.ensure_future(_next(report)) for report in self._reports]
|
|
128
|
+
waits: list[asyncio.Task[object]] = [directory, *reports]
|
|
129
|
+
try:
|
|
130
|
+
await asyncio.wait(waits, return_when=asyncio.FIRST_COMPLETED)
|
|
131
|
+
finally:
|
|
132
|
+
# One that has not answered has taken nothing: it is asked again the next time.
|
|
133
|
+
for wait in waits:
|
|
134
|
+
wait.cancel()
|
|
135
|
+
changed = False
|
|
136
|
+
if directory.done():
|
|
137
|
+
entries = directory.result()
|
|
138
|
+
if entries is None:
|
|
139
|
+
raise StopAsyncIteration
|
|
140
|
+
self._entries, changed = entries, True
|
|
141
|
+
for index, report in enumerate(reports):
|
|
142
|
+
if report.done():
|
|
143
|
+
state = report.result()
|
|
144
|
+
if state is None:
|
|
145
|
+
# The caches end together, when their client closes.
|
|
146
|
+
raise StopAsyncIteration
|
|
147
|
+
# A holder's report is pending, then what it reported: it never fails (`Live`).
|
|
148
|
+
if isinstance(state, Ready):
|
|
149
|
+
self._reported[index], changed = state.value, True
|
|
150
|
+
if self._entries is not None and changed:
|
|
151
|
+
joined = shown(self._entries, self._with_limits)
|
|
152
|
+
if joined != self._shown:
|
|
153
|
+
self._shown = joined
|
|
154
|
+
return joined
|
|
155
|
+
|
|
156
|
+
def _with_limits(self, entries: list[CollaboratorEntry]) -> list[Collaborator]:
|
|
157
|
+
return _joined(entries, [pair for report in self._reported for pair in report])
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@final
|
|
161
|
+
class _Collaborators(Source[list[Collaborator]]):
|
|
162
|
+
"""The collaborator directory with each key holder's limits joined on."""
|
|
163
|
+
|
|
164
|
+
def __init__(self, live: Live) -> None:
|
|
165
|
+
self._live: Final = live
|
|
166
|
+
|
|
167
|
+
@override
|
|
168
|
+
async def fresh(self) -> list[Collaborator]:
|
|
169
|
+
try:
|
|
170
|
+
async with asyncio.TaskGroup() as asking:
|
|
171
|
+
# The directory is asked for first; each key holder's limits after it.
|
|
172
|
+
directory = asking.create_task(self._live.agents.fetch(WHOLE))
|
|
173
|
+
reports = [asking.create_task(self._live.limits.fetch(holder)) for holder in LIMITS_HOLDERS]
|
|
174
|
+
except* SemiontError as failed:
|
|
175
|
+
# The directory's failure, or the client's closing: a holder that fails reports none, and fails nothing.
|
|
176
|
+
raise failed.exceptions[0] from None
|
|
177
|
+
return _joined(directory.result(), [pair for report in reports for pair in report.result()])
|
|
178
|
+
|
|
179
|
+
@override
|
|
180
|
+
def watch(self) -> AsyncIterator[CacheState[list[Collaborator]]]:
|
|
181
|
+
directory = self._live.agents.observe(WHOLE)
|
|
182
|
+
return _Joined(directory, [self._live.limits.observe(holder) for holder in LIMITS_HOLDERS])
|
|
183
|
+
|
|
184
|
+
@override
|
|
185
|
+
def invalidate(self) -> None:
|
|
186
|
+
self._live.invalidate_agents()
|
|
187
|
+
|
|
188
|
+
@override
|
|
189
|
+
def hold(self) -> ResourceHold | None:
|
|
190
|
+
return None
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _charset(media_type: str) -> str | None:
|
|
194
|
+
"""The charset a media type states, in lower case."""
|
|
195
|
+
lower = media_type.lower()
|
|
196
|
+
_, found, stated = lower.partition("charset=")
|
|
197
|
+
if found == "":
|
|
198
|
+
return None
|
|
199
|
+
for index, character in enumerate(stated):
|
|
200
|
+
if character.isspace() or character == ";":
|
|
201
|
+
return stated[:index]
|
|
202
|
+
return stated
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _text(content: Content) -> str:
|
|
206
|
+
"""Bytes as the text their media type says they are.
|
|
207
|
+
|
|
208
|
+
In the encoding the charset names, UTF-8 when none is stated; a leading
|
|
209
|
+
byte-order mark is not part of the text; and a sequence the encoding does
|
|
210
|
+
not have becomes U+FFFD. A charset there is no decoder for is refused by
|
|
211
|
+
name rather than read as something it is not: the bytes are there to
|
|
212
|
+
decode (`resource_representation`).
|
|
213
|
+
"""
|
|
214
|
+
label = (_charset(content.content_type) or "utf-8").strip("\"'")
|
|
215
|
+
try:
|
|
216
|
+
decoder = codecs.lookup("windows-1252" if label in _READ_AS_WINDOWS_1252 else label)
|
|
217
|
+
except LookupError:
|
|
218
|
+
raise TransportError(
|
|
219
|
+
"error", f"The resource's text is {label}, which this build decodes no text from: read its bytes instead"
|
|
220
|
+
) from None
|
|
221
|
+
return decoder.decode(content.data, "replace")[0].removeprefix("\ufeff")
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@final
|
|
225
|
+
class BrowseNamespace:
|
|
226
|
+
"""See the module's documentation."""
|
|
227
|
+
|
|
228
|
+
def __init__(self, links: Links, content: ContentTransport, live: Live) -> None:
|
|
229
|
+
self._links: Final = links
|
|
230
|
+
self._content: Final = content
|
|
231
|
+
self._live: Final = live
|
|
232
|
+
|
|
233
|
+
# ── Queries ─────────────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
def _of[K: Hashable, V, T](self, resource_id: ResourceId, cache: Cache[K, V], key: K, view: Callable[[V], T]) -> Cached[T]:
|
|
236
|
+
"""A query of one resource: watching it holds the resource's scope."""
|
|
237
|
+
return self._live.query(Keyed(cache, key, view, scope=(self._links.wire.transport, resource_id)))
|
|
238
|
+
|
|
239
|
+
def resource(self, resource_id: ResourceId) -> Cached[ResourceDescriptor]:
|
|
240
|
+
"""A resource's description."""
|
|
241
|
+
return self._of(resource_id, self._live.resource, resource_id, itself)
|
|
242
|
+
|
|
243
|
+
def resources(
|
|
244
|
+
self, *, limit: int = _LIST_LIMIT, archived: bool | None = None, entity_type: str | None = None
|
|
245
|
+
) -> Cached[ListResourcesResponse]:
|
|
246
|
+
"""A page of the resources the filters admit: the first hundred when no limit is stated.
|
|
247
|
+
|
|
248
|
+
It is kept true by what every client hears and by the events of the
|
|
249
|
+
resources this client holds the scope of, and by nothing else.
|
|
250
|
+
Finding resources by text is `match.resources`.
|
|
251
|
+
"""
|
|
252
|
+
return self._live.query(Keyed(self._live.lists, ResourceFilters(limit, archived, entity_type), itself))
|
|
253
|
+
|
|
254
|
+
def annotations(self, resource_id: ResourceId) -> Cached[list[Annotation]]:
|
|
255
|
+
"""A resource's annotations."""
|
|
256
|
+
return self._of(resource_id, self._live.annotations, resource_id, _listed)
|
|
257
|
+
|
|
258
|
+
def annotation(self, resource_id: ResourceId, annotation_id: AnnotationId) -> Cached[Annotation]:
|
|
259
|
+
"""One annotation of a resource."""
|
|
260
|
+
self._live.annotation_of[annotation_id] = resource_id
|
|
261
|
+
return self._of(resource_id, self._live.annotation, annotation_id, itself)
|
|
262
|
+
|
|
263
|
+
def entity_types(self) -> Cached[list[str]]:
|
|
264
|
+
"""The knowledge base's entity types."""
|
|
265
|
+
return self._live.query(Keyed(self._live.entity_types, WHOLE, itself))
|
|
266
|
+
|
|
267
|
+
def tag_schemas(self) -> Cached[list[TagSchema]]:
|
|
268
|
+
"""The knowledge base's tag schemas."""
|
|
269
|
+
return self._live.query(Keyed(self._live.tag_schemas, WHOLE, itself))
|
|
270
|
+
|
|
271
|
+
def agents(self) -> Cached[list[Collaborator]]:
|
|
272
|
+
"""The knowledge base's collaborators.
|
|
273
|
+
|
|
274
|
+
The directory, asked for first, with each model's limits as the
|
|
275
|
+
services holding its credentials report them.
|
|
276
|
+
"""
|
|
277
|
+
return self._live.query(_Collaborators(self._live))
|
|
278
|
+
|
|
279
|
+
def events(self, resource_id: ResourceId) -> Cached[list[AttributedEvent]]:
|
|
280
|
+
"""A resource's events, each with who it is attributed to."""
|
|
281
|
+
return self._of(resource_id, self._live.events, resource_id, itself)
|
|
282
|
+
|
|
283
|
+
# ── One-shot reads ──────────────────────────────────────────────────
|
|
284
|
+
|
|
285
|
+
async def resource_content(self, resource_id: ResourceId) -> str:
|
|
286
|
+
"""A resource's bytes as text, in the charset their media type states: UTF-8 when it states none."""
|
|
287
|
+
return _text(await self._content.get_binary(resource_id))
|
|
288
|
+
|
|
289
|
+
async def resource_graph(self, resource_id: ResourceId) -> GetResourceResponse:
|
|
290
|
+
"""A resource's description as linked data: itself, its annotations and the references to it."""
|
|
291
|
+
return await self._content.get_resource_graph(resource_id)
|
|
292
|
+
|
|
293
|
+
async def resource_anchored_text(self, resource_id: ResourceId) -> AnchoredTextAnswer:
|
|
294
|
+
"""A resource's recovered text and the runs that index it, or the named reason there is none."""
|
|
295
|
+
answer = await self._links.request(BROWSE_ANCHORED_TEXT_REQUESTED, BrowseAnchoredTextRequest(resource_id=resource_id))
|
|
296
|
+
return answer.response
|
|
297
|
+
|
|
298
|
+
async def resource_representation(self, resource_id: ResourceId) -> Content:
|
|
299
|
+
"""A resource's bytes, unchanged, with their media type."""
|
|
300
|
+
return await self._content.get_binary(resource_id)
|
|
301
|
+
|
|
302
|
+
async def resource_representation_stream(self, resource_id: ResourceId) -> ContentStream:
|
|
303
|
+
"""The same, as a stream."""
|
|
304
|
+
return await self._content.get_binary_stream(resource_id)
|
|
305
|
+
|
|
306
|
+
async def resource_events(self, resource_id: ResourceId) -> list[AttributedEvent]:
|
|
307
|
+
"""A resource's events, each with who it is attributed to."""
|
|
308
|
+
return await self._live.events_of(resource_id)
|
|
309
|
+
|
|
310
|
+
async def annotation_history(self, resource_id: ResourceId, annotation_id: AnnotationId) -> GetAnnotationHistoryResponse:
|
|
311
|
+
"""The events of one annotation."""
|
|
312
|
+
request = BrowseAnnotationHistoryRequest(resource_id=resource_id, annotation_id=annotation_id)
|
|
313
|
+
return (await self._links.request(BROWSE_ANNOTATION_HISTORY_REQUESTED, request)).response
|
|
314
|
+
|
|
315
|
+
async def files(self, path: str = ".", *, sort: BrowseDirectoryRequestSort = "name") -> BrowseDirectoryResultResponse:
|
|
316
|
+
"""The entries of a directory of the knowledge base's tree: its root when no path is stated, by name when no order is."""
|
|
317
|
+
return (await self._links.request(BROWSE_DIRECTORY_REQUESTED, BrowseDirectoryRequest(path=path, sort=sort))).response
|
|
318
|
+
|
|
319
|
+
async def kb(self) -> KbDescription:
|
|
320
|
+
"""What the knowledge base says of itself: its name and domain, and its working tree's branch.
|
|
321
|
+
|
|
322
|
+
Asked every time: a branch changes with no event to say so.
|
|
323
|
+
"""
|
|
324
|
+
return (await self._links.request(BROWSE_KB_REQUESTED, BrowseKbRequest())).response
|
|
325
|
+
|
|
326
|
+
# ── Signals ─────────────────────────────────────────────────────────
|
|
327
|
+
|
|
328
|
+
def click(self, annotation_id: AnnotationId) -> None:
|
|
329
|
+
"""Signal: open an annotation for this viewer."""
|
|
330
|
+
self._links.signal(BROWSE_CLICK, BrowseClickEvent(annotation_id=annotation_id))
|
|
331
|
+
|
|
332
|
+
def open_resource(self, resource_id: ResourceId) -> None:
|
|
333
|
+
"""Signal: open a resource for this viewer."""
|
|
334
|
+
self._links.signal(BROWSE_RESOURCE_OPEN, BrowseResourceOpenEvent(resource_id=resource_id))
|
|
335
|
+
|
|
336
|
+
def resource_viewed(self, resource_id: ResourceId) -> None:
|
|
337
|
+
"""Report that this viewer is looking at a resource. It goes over the wire, and nobody awaits it."""
|
|
338
|
+
self._links.report(BROWSE_RESOURCE_VIEWED, BrowseResourceViewedEvent(resource_id=resource_id))
|