printobserver-sdk 0.2.0__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.
- printobserver_sdk/__init__.py +54 -0
- printobserver_sdk/_client.py +189 -0
- printobserver_sdk/_surface.py +150 -0
- printobserver_sdk/contract.py +1865 -0
- printobserver_sdk/py.typed +0 -0
- printobserver_sdk-0.2.0.dist-info/METADATA +9 -0
- printobserver_sdk-0.2.0.dist-info/RECORD +9 -0
- printobserver_sdk-0.2.0.dist-info/WHEEL +4 -0
- printobserver_sdk-0.2.0.dist-info/printobserver-contract-version +1 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""The typed Python client of the printobserver server's HTTP surface.
|
|
2
|
+
|
|
3
|
+
One method per public operation the server declares, generated from the same
|
|
4
|
+
checked-in schemas the server's own routes are folded out of — so this client
|
|
5
|
+
and the command-line program expose the same normalized surface because they
|
|
6
|
+
come from one description rather than because somebody kept two lists aligned.
|
|
7
|
+
|
|
8
|
+
`printobserver_sdk.contract` carries the request and response types those
|
|
9
|
+
methods take and answer, and is generated. Nothing in it is written by hand,
|
|
10
|
+
and `just check-generated` refuses a tree in which it differs from what the
|
|
11
|
+
generator writes.
|
|
12
|
+
|
|
13
|
+
A refused action is an answer rather than a failure of transport: it arrives as
|
|
14
|
+
`RejectedError`, carrying the reason, the value asked for and the range allowed, so
|
|
15
|
+
a caller can ask again for something inside the range.
|
|
16
|
+
|
|
17
|
+
Images are a path, never bytes. No method here answers image bytes or an
|
|
18
|
+
encoding of them; the server answers an absolute path on **its own** filesystem
|
|
19
|
+
and this client hands that path back exactly as it was answered.
|
|
20
|
+
|
|
21
|
+
from printobserver_sdk import Client
|
|
22
|
+
|
|
23
|
+
client = Client("http://127.0.0.1:8420", "operator")
|
|
24
|
+
status = client.status("0198f0a1-2b3c-7d4e-8f90-123456789abc")
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from printobserver_sdk._client import Client
|
|
30
|
+
from printobserver_sdk._surface import (
|
|
31
|
+
ClientError,
|
|
32
|
+
NoReasonError,
|
|
33
|
+
PrinterRefusedError,
|
|
34
|
+
RefusedError,
|
|
35
|
+
RejectedError,
|
|
36
|
+
UnreachableError,
|
|
37
|
+
UnreadableError,
|
|
38
|
+
reason_given,
|
|
39
|
+
)
|
|
40
|
+
from printobserver_sdk.contract import CONTRACT_VERSION, OPERATION_NAMES
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"CONTRACT_VERSION",
|
|
44
|
+
"OPERATION_NAMES",
|
|
45
|
+
"Client",
|
|
46
|
+
"ClientError",
|
|
47
|
+
"NoReasonError",
|
|
48
|
+
"PrinterRefusedError",
|
|
49
|
+
"RefusedError",
|
|
50
|
+
"RejectedError",
|
|
51
|
+
"UnreachableError",
|
|
52
|
+
"UnreadableError",
|
|
53
|
+
"reason_given",
|
|
54
|
+
]
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""The client every generated method is a method of.
|
|
2
|
+
|
|
3
|
+
It carries three things and no more: where the supervisor is, what
|
|
4
|
+
authenticates to it, and who this client acts as. The third is why no generated
|
|
5
|
+
method takes an actor: a call cannot act as somebody the client is not.
|
|
6
|
+
|
|
7
|
+
The one request this makes is written with the standard library's own HTTP
|
|
8
|
+
client rather than with a dependency. This client speaks to a supervisor over a
|
|
9
|
+
loopback or a local network — the supervisor is the thing beside the printer —
|
|
10
|
+
and a package a supervision tool takes as a dependency is better without a
|
|
11
|
+
transport stack of its own.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import http.client
|
|
17
|
+
import json
|
|
18
|
+
from typing import cast
|
|
19
|
+
from urllib.parse import quote, urlsplit
|
|
20
|
+
|
|
21
|
+
from printobserver_sdk._surface import (
|
|
22
|
+
NoReasonError,
|
|
23
|
+
PrinterRefusedError,
|
|
24
|
+
RefusedError,
|
|
25
|
+
RejectedError,
|
|
26
|
+
UnreachableError,
|
|
27
|
+
UnreadableError,
|
|
28
|
+
)
|
|
29
|
+
from printobserver_sdk.contract import (
|
|
30
|
+
ActionAnswer,
|
|
31
|
+
Actor,
|
|
32
|
+
GeneratedClient,
|
|
33
|
+
Range,
|
|
34
|
+
RejectionReasonOutOfBoundsPayload,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
#: The status a rejected action is answered under.
|
|
38
|
+
REJECTED_STATUS = 409
|
|
39
|
+
|
|
40
|
+
#: The status an action the machine itself refused is answered under.
|
|
41
|
+
MACHINE_REFUSED_STATUS = 502
|
|
42
|
+
|
|
43
|
+
#: The status a read or a write that was carried out is answered under.
|
|
44
|
+
SUCCESS_STATUS = 200
|
|
45
|
+
|
|
46
|
+
#: The media type every operation takes a body in and answers in.
|
|
47
|
+
MEDIA_TYPE = "application/json"
|
|
48
|
+
|
|
49
|
+
#: How long one request waits, in seconds.
|
|
50
|
+
TIMEOUT_SECONDS = 30.0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Client(GeneratedClient):
|
|
54
|
+
"""A typed client of one printobserver supervisor.
|
|
55
|
+
|
|
56
|
+
One method per public operation that supervisor serves, each generated from
|
|
57
|
+
the same checked-in description the server's own routes are folded out of.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(self, server: str, actor: Actor, credential: str | None = None) -> None:
|
|
61
|
+
"""Point this client at one supervisor, acting as one actor.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
server: Where the supervisor answers, as its own configuration
|
|
65
|
+
writes it — `http://127.0.0.1:8420` — or as a bare `host:port`.
|
|
66
|
+
actor: Who this client acts as.
|
|
67
|
+
credential: What authenticates to it. This server requires none of
|
|
68
|
+
its API callers; a credential is for a deployment that has put
|
|
69
|
+
something in front of it that does.
|
|
70
|
+
"""
|
|
71
|
+
self.address = _address(server)
|
|
72
|
+
self.actor = actor
|
|
73
|
+
self.credential = credential
|
|
74
|
+
|
|
75
|
+
def call(
|
|
76
|
+
self,
|
|
77
|
+
method: str,
|
|
78
|
+
target: str,
|
|
79
|
+
asked: list[tuple[str, str]],
|
|
80
|
+
sending: dict[str, object] | None,
|
|
81
|
+
) -> object:
|
|
82
|
+
"""Make one call and answer the document that came back.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
The document the supervisor answered, parsed.
|
|
86
|
+
|
|
87
|
+
Raises:
|
|
88
|
+
UnreachableError: If nothing answered at the configured address.
|
|
89
|
+
UnreadableError: If the answer is not a document this client can read.
|
|
90
|
+
RefusedError: If the supervisor will not do what it was asked.
|
|
91
|
+
PrinterRefusedError: If the policy accepted the action and the machine
|
|
92
|
+
refused it.
|
|
93
|
+
RejectedError: If the policy refused the action.
|
|
94
|
+
"""
|
|
95
|
+
headers = {"Accept": MEDIA_TYPE}
|
|
96
|
+
body = None
|
|
97
|
+
if sending is not None:
|
|
98
|
+
body = json.dumps(sending)
|
|
99
|
+
headers["Content-Type"] = MEDIA_TYPE
|
|
100
|
+
if self.credential is not None:
|
|
101
|
+
headers["Authorization"] = f"Bearer {self.credential}"
|
|
102
|
+
|
|
103
|
+
connection = http.client.HTTPConnection(self.address, timeout=TIMEOUT_SECONDS)
|
|
104
|
+
try:
|
|
105
|
+
connection.request(method, _target(target, asked), body=body, headers=headers)
|
|
106
|
+
answered = connection.getresponse()
|
|
107
|
+
status = answered.status
|
|
108
|
+
said = answered.read().decode("utf-8", errors="replace")
|
|
109
|
+
except OSError as unreachable:
|
|
110
|
+
raise UnreachableError(self.address, str(unreachable)) from unreachable
|
|
111
|
+
finally:
|
|
112
|
+
connection.close()
|
|
113
|
+
|
|
114
|
+
if status == SUCCESS_STATUS:
|
|
115
|
+
return _document(status, said)
|
|
116
|
+
if status == REJECTED_STATUS:
|
|
117
|
+
raise _rejection(status, said)
|
|
118
|
+
if status == MACHINE_REFUSED_STATUS:
|
|
119
|
+
raise _machine_refusal(status, said)
|
|
120
|
+
raise RefusedError(status, _said(said))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _address(server: str) -> str:
|
|
124
|
+
"""One supervisor's address, as its own configuration writes it."""
|
|
125
|
+
trimmed = server.strip().rstrip("/")
|
|
126
|
+
split = urlsplit(trimmed)
|
|
127
|
+
return split.netloc if split.netloc else trimmed
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _target(path: str, asked: list[tuple[str, str]]) -> str:
|
|
131
|
+
"""The request target one call is made to, with what it asks for."""
|
|
132
|
+
if not asked:
|
|
133
|
+
return path
|
|
134
|
+
written = "&".join(f"{quote(name, safe='')}={quote(value, safe='')}" for name, value in asked)
|
|
135
|
+
return f"{path}?{written}"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _document(status: int, said: str) -> object:
|
|
139
|
+
"""One answer, parsed.
|
|
140
|
+
|
|
141
|
+
Raises:
|
|
142
|
+
UnreadableError: If it is not a document this client can read.
|
|
143
|
+
"""
|
|
144
|
+
try:
|
|
145
|
+
return json.loads(said)
|
|
146
|
+
except ValueError as unreadable:
|
|
147
|
+
raise UnreadableError(status, str(unreadable)) from unreadable
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _said(said: str) -> str:
|
|
151
|
+
"""What the supervisor said about a request it will not act on."""
|
|
152
|
+
try:
|
|
153
|
+
answer = json.loads(said)
|
|
154
|
+
except ValueError:
|
|
155
|
+
return "it said nothing this client can read"
|
|
156
|
+
if isinstance(answer, dict) and isinstance(answer.get("error"), str):
|
|
157
|
+
return answer["error"]
|
|
158
|
+
return "it said nothing this client can read"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _rejection(status: int, said: str) -> Exception:
|
|
162
|
+
"""The policy's own refusal, as the answer to it carries it."""
|
|
163
|
+
answer = cast(ActionAnswer, _document(status, said))
|
|
164
|
+
decision = answer["record"]["decision"]
|
|
165
|
+
if not isinstance(decision, dict) or "rejected" not in decision:
|
|
166
|
+
return UnreadableError(
|
|
167
|
+
status,
|
|
168
|
+
"the supervisor refused this action and answered a record whose decision "
|
|
169
|
+
"is not a refusal",
|
|
170
|
+
)
|
|
171
|
+
reason = decision["rejected"]
|
|
172
|
+
requested: float | None = None
|
|
173
|
+
allowed: Range | None = None
|
|
174
|
+
if isinstance(reason, dict) and "out_of_bounds" in reason:
|
|
175
|
+
bounds = cast(RejectionReasonOutOfBoundsPayload, reason["out_of_bounds"])
|
|
176
|
+
requested = bounds["requested"]
|
|
177
|
+
allowed = bounds["allowed"]
|
|
178
|
+
return RejectedError(reason, requested, allowed, answer)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _machine_refusal(status: int, said: str) -> Exception:
|
|
182
|
+
"""The machine's own refusal of an action the policy accepted."""
|
|
183
|
+
answer = cast(ActionAnswer, _document(status, said))
|
|
184
|
+
return PrinterRefusedError(
|
|
185
|
+
answer.get("printer_refusal") or "it said nothing this client can read", answer
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
__all__ = ["Client", "NoReasonError"]
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""What every generated method is written against, and every way a call can end.
|
|
2
|
+
|
|
3
|
+
The generated module carries one method per operation the server declares.
|
|
4
|
+
This is what those methods stand on: the two things a call needs from the
|
|
5
|
+
client — who it acts as, and how to make a request — and the closed set of
|
|
6
|
+
failures a caller distinguishes.
|
|
7
|
+
|
|
8
|
+
Nothing here imports the generated module at run time. The type names below are
|
|
9
|
+
that module's, brought in for the type checker alone, so that the two files can
|
|
10
|
+
name each other's shapes without either being unimportable.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING: # pragma: no cover - imported for annotations alone
|
|
18
|
+
from printobserver_sdk.contract import ActionAnswer, Actor, Range, RejectionReason
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ClientError(Exception):
|
|
22
|
+
"""Why a call did not answer what it asked for."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class UnreachableError(ClientError):
|
|
26
|
+
"""Nothing answered at the configured address."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, address: str, detail: str) -> None:
|
|
29
|
+
"""Record where this client looked and what happened there."""
|
|
30
|
+
super().__init__(
|
|
31
|
+
f"nothing answered at {address}: {detail}. Start the supervisor there, or "
|
|
32
|
+
f"point this client at the address it is answering on"
|
|
33
|
+
)
|
|
34
|
+
self.address = address
|
|
35
|
+
self.detail = detail
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class NoReasonError(ClientError):
|
|
39
|
+
"""This client would not send the request, because it carried no reason.
|
|
40
|
+
|
|
41
|
+
Every mutating operation carries a reason, and a call whose reason is empty
|
|
42
|
+
is refused here rather than at the server: nothing reaches the policy, the
|
|
43
|
+
printer or the record.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(self) -> None:
|
|
47
|
+
"""Say what every mutating call owes."""
|
|
48
|
+
super().__init__("every mutating call carries a reason, and this one carries none")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class UnreadableError(ClientError):
|
|
52
|
+
"""The supervisor answered something this client cannot read."""
|
|
53
|
+
|
|
54
|
+
def __init__(self, status: int, detail: str) -> None:
|
|
55
|
+
"""Record the status it answered under and what could not be read."""
|
|
56
|
+
super().__init__(
|
|
57
|
+
f"the supervisor answered {status} with something this client cannot read: {detail}"
|
|
58
|
+
)
|
|
59
|
+
self.status = status
|
|
60
|
+
self.detail = detail
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class RefusedError(ClientError):
|
|
64
|
+
"""The supervisor will not do what it was asked, and said why."""
|
|
65
|
+
|
|
66
|
+
def __init__(self, status: int, detail: str) -> None:
|
|
67
|
+
"""Record the status it answered under and what it said."""
|
|
68
|
+
super().__init__(f"the supervisor answered {status}: {detail}")
|
|
69
|
+
self.status = status
|
|
70
|
+
self.detail = detail
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class PrinterRefusedError(ClientError):
|
|
74
|
+
"""The policy accepted the action and the machine refused it."""
|
|
75
|
+
|
|
76
|
+
def __init__(self, detail: str, answer: ActionAnswer) -> None:
|
|
77
|
+
"""Record what the printer said and the record the request left."""
|
|
78
|
+
super().__init__(
|
|
79
|
+
f"the policy accepted this action and the machine refused it: {detail}. "
|
|
80
|
+
f"Look at the printer, then ask again"
|
|
81
|
+
)
|
|
82
|
+
self.detail = detail
|
|
83
|
+
self.answer = answer
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class RejectedError(ClientError):
|
|
87
|
+
"""The policy refused the action, and the refusal is the answer.
|
|
88
|
+
|
|
89
|
+
`reason` is the refusal itself, matched rather than read; `requested` and
|
|
90
|
+
`allowed` are the value that was asked for and the range that is allowed,
|
|
91
|
+
which the policy states when it ruled on a value and which are `None` when
|
|
92
|
+
it ruled on something else.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
def __init__(
|
|
96
|
+
self,
|
|
97
|
+
reason: RejectionReason,
|
|
98
|
+
requested: float | None,
|
|
99
|
+
allowed: Range | None,
|
|
100
|
+
answer: ActionAnswer,
|
|
101
|
+
) -> None:
|
|
102
|
+
"""Record the reason, the value asked for and the range allowed."""
|
|
103
|
+
said = f"the supervisor's policy refused this action: {reason!r}"
|
|
104
|
+
if requested is not None and allowed is not None:
|
|
105
|
+
said += (
|
|
106
|
+
f". It was asked for {requested}, and what is allowed is "
|
|
107
|
+
f"{allowed['min']} to {allowed['max']}"
|
|
108
|
+
)
|
|
109
|
+
super().__init__(said)
|
|
110
|
+
self.reason = reason
|
|
111
|
+
self.requested = requested
|
|
112
|
+
self.allowed = allowed
|
|
113
|
+
self.answer = answer
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def reason_given(reason: str) -> None:
|
|
117
|
+
"""Refuse a mutating call that carries no reason, before it is made.
|
|
118
|
+
|
|
119
|
+
Raises:
|
|
120
|
+
NoReasonError: If the reason is absent or is nothing but whitespace.
|
|
121
|
+
"""
|
|
122
|
+
if not reason or not reason.strip():
|
|
123
|
+
raise NoReasonError
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class GeneratedSurface:
|
|
127
|
+
"""The two things every generated method needs from the client it is on.
|
|
128
|
+
|
|
129
|
+
A surface on its own makes no request: `Client` is what implements `call`,
|
|
130
|
+
and the generated methods are written against this so that the transport
|
|
131
|
+
and the operation list stay separable.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
#: Who this client acts as, which every mutating call carries.
|
|
135
|
+
actor: Actor
|
|
136
|
+
|
|
137
|
+
def call(
|
|
138
|
+
self,
|
|
139
|
+
method: str,
|
|
140
|
+
target: str,
|
|
141
|
+
asked: list[tuple[str, str]],
|
|
142
|
+
sending: dict[str, object] | None,
|
|
143
|
+
) -> object:
|
|
144
|
+
"""Make one call and answer the document that came back.
|
|
145
|
+
|
|
146
|
+
Raises:
|
|
147
|
+
NotImplementedError: Always. `Client` is what makes a request; a
|
|
148
|
+
surface that answered one would be a second transport.
|
|
149
|
+
"""
|
|
150
|
+
raise NotImplementedError
|