ippx 0.1.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.
ippx/__init__.py ADDED
@@ -0,0 +1,43 @@
1
+ """ippx: sync and async IPP/IPPS client for sending print jobs and
2
+ monitoring network printers."""
3
+
4
+ from httpx import BasicAuth, DigestAuth
5
+
6
+ from ._async import AsyncIppClient
7
+ from ._codec import Attribute, IppMessage, Resolution, Tag, decode, encode
8
+ from ._models import Job, JobState, Operation, Printer, PrinterState
9
+ from ._sync import IppClient
10
+ from ._tls import TlsConfig
11
+ from .exceptions import (
12
+ IppDecodeError,
13
+ IppError,
14
+ IppHttpError,
15
+ IppResponseError,
16
+ JobTimeoutError,
17
+ )
18
+
19
+ __version__ = "0.1.0"
20
+
21
+ __all__ = [
22
+ "AsyncIppClient",
23
+ "Attribute",
24
+ "BasicAuth",
25
+ "DigestAuth",
26
+ "IppClient",
27
+ "IppDecodeError",
28
+ "IppError",
29
+ "IppHttpError",
30
+ "IppMessage",
31
+ "IppResponseError",
32
+ "Job",
33
+ "JobState",
34
+ "JobTimeoutError",
35
+ "Operation",
36
+ "Printer",
37
+ "PrinterState",
38
+ "Resolution",
39
+ "Tag",
40
+ "TlsConfig",
41
+ "decode",
42
+ "encode",
43
+ ]
ippx/_async.py ADDED
@@ -0,0 +1,184 @@
1
+ """Asynchronous IPP/IPPS client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import time
7
+ from itertools import count
8
+ from types import TracebackType
9
+ from typing import Any
10
+
11
+ import httpx
12
+
13
+ from . import _operations as ops
14
+ from ._base import check_response, httpx_kwargs, normalise_url
15
+ from ._codec import Attribute, IppMessage, Tag, encode
16
+ from ._models import Job, Printer
17
+ from ._tls import TlsConfig
18
+ from .exceptions import JobTimeoutError
19
+
20
+
21
+ class AsyncIppClient:
22
+ """Async client for a single IPP/IPPS printer endpoint.
23
+
24
+ Usage::
25
+
26
+ async with AsyncIppClient("ipps://printer.example.com:631/ipp/print",
27
+ auth=httpx.BasicAuth("user", "pw"),
28
+ tls=TlsConfig(fingerprint="sha256:...")) as printer:
29
+ job = await printer.print_job(pdf_bytes, document_format="application/pdf")
30
+ await printer.wait_for_job(job.job_id, timeout=120)
31
+ """
32
+
33
+ def __init__(
34
+ self,
35
+ url: str,
36
+ *,
37
+ auth: httpx.Auth | tuple[str, str] | None = None,
38
+ tls: TlsConfig | None = None,
39
+ timeout: float | httpx.Timeout = 30.0,
40
+ requesting_user_name: str | None = "ippx",
41
+ version: tuple[int, int] = ops.DEFAULT_VERSION,
42
+ ) -> None:
43
+ self._http_url, self.printer_uri = normalise_url(url)
44
+ self.requesting_user_name = requesting_user_name
45
+ self.version = version
46
+ self._request_ids = count(1)
47
+ self._http = httpx.AsyncClient(**httpx_kwargs(tls, auth, timeout))
48
+
49
+ async def __aenter__(self) -> AsyncIppClient:
50
+ return self
51
+
52
+ async def __aexit__(
53
+ self,
54
+ exc_type: type[BaseException] | None,
55
+ exc: BaseException | None,
56
+ tb: TracebackType | None,
57
+ ) -> None:
58
+ await self.close()
59
+
60
+ async def close(self) -> None:
61
+ await self._http.aclose()
62
+
63
+ async def _send(self, msg: IppMessage) -> IppMessage:
64
+ response = await self._http.post(self._http_url, content=encode(msg))
65
+ return check_response(response)
66
+
67
+ async def get_printer_attributes(
68
+ self, requested_attributes: list[str] | None = None
69
+ ) -> Printer:
70
+ msg = ops.get_printer_attributes_request(
71
+ printer_uri=self.printer_uri,
72
+ request_id=next(self._request_ids),
73
+ requesting_user_name=self.requesting_user_name,
74
+ requested_attributes=requested_attributes,
75
+ version=self.version,
76
+ )
77
+ resp = await self._send(msg)
78
+ return ops.parse_printer(ops.first_group(resp, Tag.PRINTER_ATTRS))
79
+
80
+ async def validate_job(
81
+ self,
82
+ *,
83
+ document_format: str = "application/octet-stream",
84
+ job_attributes: dict[str, Any] | list[Attribute] | None = None,
85
+ ) -> None:
86
+ msg = ops.validate_job_request(
87
+ printer_uri=self.printer_uri,
88
+ request_id=next(self._request_ids),
89
+ document_format=document_format,
90
+ requesting_user_name=self.requesting_user_name,
91
+ job_attributes=job_attributes,
92
+ version=self.version,
93
+ )
94
+ await self._send(msg)
95
+
96
+ async def print_job(
97
+ self,
98
+ document: bytes,
99
+ *,
100
+ document_format: str = "application/octet-stream",
101
+ job_name: str | None = None,
102
+ job_attributes: dict[str, Any] | list[Attribute] | None = None,
103
+ ) -> Job:
104
+ msg = ops.print_job_request(
105
+ printer_uri=self.printer_uri,
106
+ request_id=next(self._request_ids),
107
+ document=document,
108
+ document_format=document_format,
109
+ job_name=job_name,
110
+ requesting_user_name=self.requesting_user_name,
111
+ job_attributes=job_attributes,
112
+ version=self.version,
113
+ )
114
+ resp = await self._send(msg)
115
+ return ops.parse_job(ops.first_group(resp, Tag.JOB_ATTRS))
116
+
117
+ async def get_job_attributes(
118
+ self, job_id: int, requested_attributes: list[str] | None = None
119
+ ) -> Job:
120
+ msg = ops.get_job_attributes_request(
121
+ printer_uri=self.printer_uri,
122
+ request_id=next(self._request_ids),
123
+ job_id=job_id,
124
+ requesting_user_name=self.requesting_user_name,
125
+ requested_attributes=requested_attributes,
126
+ version=self.version,
127
+ )
128
+ resp = await self._send(msg)
129
+ return ops.parse_job(ops.first_group(resp, Tag.JOB_ATTRS))
130
+
131
+ async def cancel_job(self, job_id: int) -> None:
132
+ msg = ops.cancel_job_request(
133
+ printer_uri=self.printer_uri,
134
+ request_id=next(self._request_ids),
135
+ job_id=job_id,
136
+ requesting_user_name=self.requesting_user_name,
137
+ version=self.version,
138
+ )
139
+ await self._send(msg)
140
+
141
+ async def get_jobs(
142
+ self,
143
+ *,
144
+ which_jobs: str = "not-completed",
145
+ my_jobs: bool = False,
146
+ limit: int | None = None,
147
+ requested_attributes: list[str] | None = None,
148
+ ) -> list[Job]:
149
+ msg = ops.get_jobs_request(
150
+ printer_uri=self.printer_uri,
151
+ request_id=next(self._request_ids),
152
+ requesting_user_name=self.requesting_user_name,
153
+ which_jobs=which_jobs,
154
+ my_jobs=my_jobs,
155
+ limit=limit,
156
+ requested_attributes=requested_attributes,
157
+ version=self.version,
158
+ )
159
+ resp = await self._send(msg)
160
+ return [ops.parse_job(g) for g in ops.all_groups(resp, Tag.JOB_ATTRS)]
161
+
162
+ async def wait_for_job(
163
+ self,
164
+ job_id: int,
165
+ *,
166
+ timeout: float = 300.0,
167
+ initial_interval: float = 1.0,
168
+ max_interval: float = 15.0,
169
+ ) -> Job:
170
+ """Poll Get-Job-Attributes with exponential backoff until the job
171
+ reaches a terminal state (completed, canceled, aborted).
172
+
173
+ Raises JobTimeoutError if the deadline passes first."""
174
+ deadline = time.monotonic() + timeout
175
+ interval = initial_interval
176
+ job = await self.get_job_attributes(job_id, ["job-state", "job-state-reasons"])
177
+ while not job.is_terminal:
178
+ remaining = deadline - time.monotonic()
179
+ if remaining <= 0:
180
+ raise JobTimeoutError(job_id, timeout, job.state)
181
+ await asyncio.sleep(min(interval, remaining))
182
+ interval = min(interval * 2, max_interval)
183
+ job = await self.get_job_attributes(job_id, ["job-state", "job-state-reasons"])
184
+ return job
ippx/_base.py ADDED
@@ -0,0 +1,69 @@
1
+ """Shared plumbing between sync and async clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+ from urllib.parse import urlsplit, urlunsplit
7
+
8
+ import httpx
9
+
10
+ from ._codec import IppMessage, decode
11
+ from ._operations import status_message
12
+ from ._tls import TlsConfig
13
+ from .exceptions import IppHttpError, IppResponseError
14
+
15
+ CONTENT_TYPE = "application/ipp"
16
+ DEFAULT_PORT = 631
17
+ DEFAULT_PATH = "/ipp/print"
18
+
19
+ _SCHEME_MAP = {"ipps": "https", "ipp": "http", "https": "https", "http": "http"}
20
+
21
+
22
+ def normalise_url(url: str) -> tuple[str, str]:
23
+ """Return (http_url, printer_uri).
24
+
25
+ ``http_url`` is what httpx talks to; ``printer_uri`` is the ipp/ipps form
26
+ sent in the printer-uri operation attribute.
27
+ """
28
+ parts = urlsplit(url)
29
+ scheme = parts.scheme.lower()
30
+ if scheme not in _SCHEME_MAP:
31
+ raise ValueError(
32
+ f"unsupported scheme {parts.scheme!r}; use ipps://, ipp://, https:// or http://"
33
+ )
34
+ http_scheme = _SCHEME_MAP[scheme]
35
+ ipp_scheme = "ipps" if http_scheme == "https" else "ipp"
36
+ host = parts.hostname or ""
37
+ if not host:
38
+ raise ValueError(f"no host in URL {url!r}")
39
+ port = parts.port or DEFAULT_PORT
40
+ path = parts.path if parts.path and parts.path != "/" else DEFAULT_PATH
41
+ netloc = f"[{host}]:{port}" if ":" in host else f"{host}:{port}"
42
+ http_url = urlunsplit((http_scheme, netloc, path, parts.query, ""))
43
+ printer_uri = urlunsplit((ipp_scheme, netloc, path, parts.query, ""))
44
+ return http_url, printer_uri
45
+
46
+
47
+ def httpx_kwargs(
48
+ tls: TlsConfig | None,
49
+ auth: httpx.Auth | tuple[str, str] | None,
50
+ timeout: float | httpx.Timeout,
51
+ ) -> dict[str, Any]:
52
+ tls = tls or TlsConfig()
53
+ kwargs: dict[str, Any] = {
54
+ "verify": tls.httpx_verify(),
55
+ "timeout": timeout,
56
+ "headers": {"Content-Type": CONTENT_TYPE, "Accept": CONTENT_TYPE},
57
+ }
58
+ if auth is not None:
59
+ kwargs["auth"] = auth
60
+ return kwargs
61
+
62
+
63
+ def check_response(response: httpx.Response) -> IppMessage:
64
+ if response.status_code != 200:
65
+ raise IppHttpError(response.status_code, response.reason_phrase)
66
+ msg = decode(response.content)
67
+ if msg.code >= 0x0100:
68
+ raise IppResponseError(msg.code, status_message(msg))
69
+ return msg
ippx/_codec.py ADDED
@@ -0,0 +1,311 @@
1
+ """RFC 8010 IPP binary encoding and decoding. Pure functions, no I/O."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import struct
6
+ from dataclasses import dataclass, field
7
+ from datetime import datetime, timedelta, timezone
8
+ from enum import IntEnum
9
+ from typing import Any
10
+
11
+ from .exceptions import IppDecodeError
12
+
13
+
14
+ class Tag(IntEnum):
15
+ """IPP delimiter and value tags (RFC 8010 section 3.5)."""
16
+
17
+ # delimiter tags
18
+ OPERATION_ATTRS = 0x01
19
+ JOB_ATTRS = 0x02
20
+ END = 0x03
21
+ PRINTER_ATTRS = 0x04
22
+ UNSUPPORTED_ATTRS = 0x05
23
+ # out-of-band
24
+ UNSUPPORTED = 0x10
25
+ UNKNOWN = 0x12
26
+ NO_VALUE = 0x13
27
+ # integer types
28
+ INTEGER = 0x21
29
+ BOOLEAN = 0x22
30
+ ENUM = 0x23
31
+ # octet-string types
32
+ OCTET_STRING = 0x30
33
+ DATETIME = 0x31
34
+ RESOLUTION = 0x32
35
+ RANGE = 0x33
36
+ BEG_COLLECTION = 0x34
37
+ TEXT_WITH_LANG = 0x35
38
+ NAME_WITH_LANG = 0x36
39
+ END_COLLECTION = 0x37
40
+ # character-string types
41
+ TEXT = 0x41
42
+ NAME = 0x42
43
+ KEYWORD = 0x44
44
+ URI = 0x45
45
+ URI_SCHEME = 0x46
46
+ CHARSET = 0x47
47
+ NATURAL_LANGUAGE = 0x48
48
+ MIME_TYPE = 0x49
49
+ MEMBER_ATTR_NAME = 0x4A
50
+
51
+
52
+ _OUT_OF_BAND = frozenset({Tag.UNSUPPORTED, Tag.UNKNOWN, Tag.NO_VALUE})
53
+ _DELIMITER_MAX = 0x0F
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class Resolution:
58
+ x: int
59
+ y: int
60
+ units: int # 3 = dots per inch, 4 = dots per cm
61
+
62
+ def __str__(self) -> str:
63
+ unit = {3: "dpi", 4: "dpcm"}.get(self.units, f"units={self.units}")
64
+ return f"{self.x}x{self.y} {unit}"
65
+
66
+
67
+ @dataclass
68
+ class Attribute:
69
+ """A single IPP attribute. ``values`` holds one or more Python values."""
70
+
71
+ name: str
72
+ tag: Tag
73
+ values: list[Any]
74
+
75
+ @property
76
+ def value(self) -> Any:
77
+ return self.values[0] if self.values else None
78
+
79
+
80
+ @dataclass
81
+ class IppMessage:
82
+ """An IPP request or response.
83
+
84
+ ``code`` is the operation-id in a request or the status-code in a response.
85
+ ``data`` is the document payload (requests) or trailing data (responses).
86
+ """
87
+
88
+ version: tuple[int, int]
89
+ code: int
90
+ request_id: int
91
+ groups: list[tuple[Tag, list[Attribute]]] = field(default_factory=list)
92
+ data: bytes = b""
93
+
94
+
95
+ def encode(msg: IppMessage) -> bytes:
96
+ out = bytearray()
97
+ out += bytes((msg.version[0], msg.version[1]))
98
+ out += struct.pack(">HI", msg.code, msg.request_id)
99
+ for group_tag, attrs in msg.groups:
100
+ out.append(group_tag)
101
+ for attr in attrs:
102
+ if not attr.values:
103
+ raise ValueError(
104
+ f"attribute {attr.name!r} has no values; use [None] for out-of-band tags"
105
+ )
106
+ first = True
107
+ for value in attr.values:
108
+ out.append(attr.tag)
109
+ name_b = attr.name.encode("utf-8") if first else b""
110
+ out += struct.pack(">H", len(name_b)) + name_b
111
+ value_b = _encode_value(attr.tag, value)
112
+ out += struct.pack(">H", len(value_b)) + value_b
113
+ first = False
114
+ out.append(Tag.END)
115
+ out += msg.data
116
+ return bytes(out)
117
+
118
+
119
+ def _encode_value(tag: Tag, value: Any) -> bytes:
120
+ if tag in _OUT_OF_BAND:
121
+ return b""
122
+ if tag in (Tag.INTEGER, Tag.ENUM):
123
+ return struct.pack(">i", int(value))
124
+ if tag == Tag.BOOLEAN:
125
+ return b"\x01" if value else b"\x00"
126
+ if tag == Tag.DATETIME:
127
+ return _encode_datetime(value)
128
+ if tag == Tag.RESOLUTION:
129
+ res = value if isinstance(value, Resolution) else Resolution(*value)
130
+ return struct.pack(">iib", res.x, res.y, res.units)
131
+ if tag == Tag.RANGE:
132
+ lo, hi = value
133
+ return struct.pack(">ii", lo, hi)
134
+ if tag == Tag.BEG_COLLECTION:
135
+ raise NotImplementedError("encoding IPP collections is not supported yet")
136
+ if tag == Tag.OCTET_STRING:
137
+ return bytes(value)
138
+ if isinstance(value, bytes):
139
+ return value
140
+ return str(value).encode("utf-8")
141
+
142
+
143
+ def _encode_datetime(value: datetime) -> bytes:
144
+ offset = value.utcoffset() or timedelta(0)
145
+ total = int(offset.total_seconds())
146
+ sign = b"+" if total >= 0 else b"-"
147
+ total = abs(total)
148
+ return struct.pack(
149
+ ">H6Bc2B",
150
+ value.year,
151
+ value.month,
152
+ value.day,
153
+ value.hour,
154
+ value.minute,
155
+ value.second,
156
+ value.microsecond // 100_000,
157
+ sign,
158
+ total // 3600,
159
+ (total % 3600) // 60,
160
+ )
161
+
162
+
163
+ def decode(data: bytes) -> IppMessage:
164
+ if len(data) < 9:
165
+ raise IppDecodeError(f"IPP message too short: {len(data)} bytes")
166
+ version = (data[0], data[1])
167
+ code, request_id = struct.unpack(">HI", data[2:8])
168
+ pos = 8
169
+
170
+ groups: list[tuple[Tag, list[Attribute]]] = []
171
+ current_tag: Tag | None = None
172
+ current: list[Attribute] = []
173
+ # collection parsing stack: each frame is [values_dict, member_name, attr_name]
174
+ frames: list[list[Any]] = []
175
+ ended = False
176
+
177
+ def flush_group() -> None:
178
+ nonlocal current, current_tag
179
+ if current_tag is not None:
180
+ groups.append((current_tag, current))
181
+ current = []
182
+
183
+ def deliver(name: str, tag: Tag, value: Any) -> None:
184
+ if name:
185
+ current.append(Attribute(name, tag, [value]))
186
+ elif current:
187
+ current[-1].values.append(value)
188
+ else:
189
+ raise IppDecodeError("additional value with no preceding attribute")
190
+
191
+ while pos < len(data):
192
+ tag_byte = data[pos]
193
+ pos += 1
194
+ if tag_byte <= _DELIMITER_MAX:
195
+ if frames:
196
+ raise IppDecodeError("delimiter inside collection")
197
+ if tag_byte == Tag.END:
198
+ ended = True
199
+ break
200
+ flush_group()
201
+ try:
202
+ current_tag = Tag(tag_byte)
203
+ except ValueError as exc:
204
+ raise IppDecodeError(f"reserved delimiter tag 0x{tag_byte:02X}") from exc
205
+ continue
206
+
207
+ if pos + 2 > len(data):
208
+ raise IppDecodeError("truncated attribute name length")
209
+ (name_len,) = struct.unpack(">H", data[pos : pos + 2])
210
+ pos += 2
211
+ name = data[pos : pos + name_len].decode("utf-8", "replace")
212
+ pos += name_len
213
+ if pos + 2 > len(data):
214
+ raise IppDecodeError("truncated attribute value length")
215
+ (value_len,) = struct.unpack(">H", data[pos : pos + 2])
216
+ pos += 2
217
+ raw = data[pos : pos + value_len]
218
+ if len(raw) != value_len:
219
+ raise IppDecodeError("truncated attribute value")
220
+ pos += value_len
221
+
222
+ try:
223
+ tag = Tag(tag_byte)
224
+ except ValueError:
225
+ # unknown value tag: keep raw bytes under the closest semantics
226
+ if frames:
227
+ _frame_append(frames[-1], bytes(raw))
228
+ else:
229
+ deliver(name, Tag.OCTET_STRING, bytes(raw))
230
+ continue
231
+
232
+ if tag == Tag.BEG_COLLECTION:
233
+ frames.append([{}, None, name])
234
+ continue
235
+ if tag == Tag.END_COLLECTION:
236
+ if not frames:
237
+ raise IppDecodeError("endCollection without begCollection")
238
+ values_dict, _, attr_name = frames.pop()
239
+ collapsed = {k: (v[0] if len(v) == 1 else v) for k, v in values_dict.items()}
240
+ if frames:
241
+ _frame_append(frames[-1], collapsed)
242
+ else:
243
+ deliver(attr_name, Tag.BEG_COLLECTION, collapsed)
244
+ continue
245
+
246
+ try:
247
+ value = _decode_value(tag, raw)
248
+ except (struct.error, ValueError, IndexError) as exc:
249
+ raise IppDecodeError(
250
+ f"malformed {tag.name} value for {name!r} ({len(raw)} bytes)"
251
+ ) from exc
252
+ if frames:
253
+ if tag == Tag.MEMBER_ATTR_NAME:
254
+ frame = frames[-1]
255
+ frame[1] = value
256
+ frame[0].setdefault(value, [])
257
+ else:
258
+ _frame_append(frames[-1], value)
259
+ else:
260
+ deliver(name, tag, value)
261
+
262
+ if frames:
263
+ raise IppDecodeError("unterminated collection")
264
+ if not ended:
265
+ raise IppDecodeError("missing end-of-attributes tag")
266
+ flush_group()
267
+ return IppMessage(version, code, request_id, groups, bytes(data[pos:]))
268
+
269
+
270
+ def _frame_append(frame: list[Any], value: Any) -> None:
271
+ member = frame[1]
272
+ if member is None:
273
+ raise IppDecodeError("collection member value before memberAttrName")
274
+ frame[0][member].append(value)
275
+
276
+
277
+ def _decode_value(tag: Tag, raw: bytes) -> Any:
278
+ if tag in _OUT_OF_BAND:
279
+ return None
280
+ if tag in (Tag.INTEGER, Tag.ENUM):
281
+ return struct.unpack(">i", raw)[0]
282
+ if tag == Tag.BOOLEAN:
283
+ return raw != b"\x00"
284
+ if tag == Tag.DATETIME:
285
+ return _decode_datetime(raw)
286
+ if tag == Tag.RESOLUTION:
287
+ x, y, units = struct.unpack(">iib", raw)
288
+ return Resolution(x, y, units)
289
+ if tag == Tag.RANGE:
290
+ lo, hi = struct.unpack(">ii", raw)
291
+ return (lo, hi)
292
+ if tag in (Tag.TEXT_WITH_LANG, Tag.NAME_WITH_LANG):
293
+ (lang_len,) = struct.unpack(">H", raw[:2])
294
+ (text_len,) = struct.unpack(">H", raw[2 + lang_len : 4 + lang_len])
295
+ return raw[4 + lang_len : 4 + lang_len + text_len].decode("utf-8", "replace")
296
+ if tag == Tag.OCTET_STRING:
297
+ return bytes(raw)
298
+ if tag >= Tag.TEXT:
299
+ return raw.decode("utf-8", "replace")
300
+ return bytes(raw)
301
+
302
+
303
+ def _decode_datetime(raw: bytes) -> Any:
304
+ try:
305
+ year, month, day, hour, minute, second, deci, sign, tzh, tzm = struct.unpack(">H6Bc2B", raw)
306
+ delta = timedelta(hours=tzh, minutes=tzm)
307
+ if sign == b"-":
308
+ delta = -delta
309
+ return datetime(year, month, day, hour, minute, second, deci * 100_000, timezone(delta))
310
+ except (struct.error, ValueError):
311
+ return bytes(raw)