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 +43 -0
- ippx/_async.py +184 -0
- ippx/_base.py +69 -0
- ippx/_codec.py +311 -0
- ippx/_models.py +144 -0
- ippx/_operations.py +277 -0
- ippx/_sync.py +177 -0
- ippx/_tls.py +143 -0
- ippx/exceptions.py +77 -0
- ippx/py.typed +0 -0
- ippx-0.1.0.dist-info/METADATA +196 -0
- ippx-0.1.0.dist-info/RECORD +14 -0
- ippx-0.1.0.dist-info/WHEEL +4 -0
- ippx-0.1.0.dist-info/licenses/LICENSE +21 -0
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)
|