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.
@@ -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