scanii-python 1.0.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.
- scanii/__init__.py +30 -0
- scanii/_multipart.py +199 -0
- scanii/_version.py +1 -0
- scanii/client.py +460 -0
- scanii/errors.py +47 -0
- scanii/models.py +177 -0
- scanii/py.typed +0 -0
- scanii/target.py +62 -0
- scanii_python-1.0.0.dist-info/METADATA +172 -0
- scanii_python-1.0.0.dist-info/RECORD +12 -0
- scanii_python-1.0.0.dist-info/WHEEL +4 -0
- scanii_python-1.0.0.dist-info/licenses/LICENSE +180 -0
scanii/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Scanii Python SDK — zero-dependency client for the Scanii content security API.
|
|
2
|
+
|
|
3
|
+
See https://scanii.github.io/openapi/v22/ for the full API reference.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from scanii._version import __version__
|
|
7
|
+
from scanii.client import ScaniiClient
|
|
8
|
+
from scanii.errors import ScaniiAuthError, ScaniiError, ScaniiRateLimitError
|
|
9
|
+
from scanii.models import (
|
|
10
|
+
ScaniiAuthToken,
|
|
11
|
+
ScaniiPendingResult,
|
|
12
|
+
ScaniiProcessingResult,
|
|
13
|
+
ScaniiTraceEvent,
|
|
14
|
+
ScaniiTraceResult,
|
|
15
|
+
)
|
|
16
|
+
from scanii.target import ScaniiTarget
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"__version__",
|
|
20
|
+
"ScaniiClient",
|
|
21
|
+
"ScaniiTarget",
|
|
22
|
+
"ScaniiProcessingResult",
|
|
23
|
+
"ScaniiPendingResult",
|
|
24
|
+
"ScaniiAuthToken",
|
|
25
|
+
"ScaniiTraceResult",
|
|
26
|
+
"ScaniiTraceEvent",
|
|
27
|
+
"ScaniiError",
|
|
28
|
+
"ScaniiAuthError",
|
|
29
|
+
"ScaniiRateLimitError",
|
|
30
|
+
]
|
scanii/_multipart.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Hand-rolled multipart/form-data encoder (RFC 7578).
|
|
2
|
+
|
|
3
|
+
Internal module — underscore-prefixed, not part of the public API.
|
|
4
|
+
|
|
5
|
+
The encoder is stream-first: when a file_obj is provided it builds a
|
|
6
|
+
:class:`_ChainedIO` that chains prologue bytes + the caller's IO + epilogue
|
|
7
|
+
bytes without reading the file body at construction time. The file content is
|
|
8
|
+
only read when urllib reads from the chained stream on the wire.
|
|
9
|
+
|
|
10
|
+
Mirrors the approach of scanii-ruby's Multipart::ChainedIO and
|
|
11
|
+
scanii-rust's prologue/epilogue pattern.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import io
|
|
17
|
+
import mimetypes
|
|
18
|
+
import os
|
|
19
|
+
import secrets
|
|
20
|
+
import tempfile
|
|
21
|
+
from typing import IO, cast
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _make_boundary() -> str:
|
|
25
|
+
return f"----scanii-python-boundary-{secrets.token_hex(16)}"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _guess_content_type(filename: str) -> str:
|
|
29
|
+
ct, _ = mimetypes.guess_type(filename)
|
|
30
|
+
return ct or "application/octet-stream"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _io_remaining_bytes(file_obj: IO[bytes]) -> int | None:
|
|
34
|
+
"""Return the number of bytes remaining to be read from file_obj, or None.
|
|
35
|
+
|
|
36
|
+
Tries three strategies in order:
|
|
37
|
+
1. ``fileno()`` + ``os.fstat`` — most reliable for real files.
|
|
38
|
+
2. ``getvalue()`` — works for in-memory :class:`io.BytesIO` and
|
|
39
|
+
:class:`tempfile.SpooledTemporaryFile` when still in RAM.
|
|
40
|
+
3. ``seek``/``tell`` — works for any seekable IO; restores position.
|
|
41
|
+
|
|
42
|
+
Returns ``None`` when all three fail (pipes, generators, sockets).
|
|
43
|
+
"""
|
|
44
|
+
# Strategy 1 — real files with an OS file descriptor
|
|
45
|
+
try:
|
|
46
|
+
fd = file_obj.fileno()
|
|
47
|
+
size = os.fstat(fd).st_size
|
|
48
|
+
pos = file_obj.tell()
|
|
49
|
+
return size - pos
|
|
50
|
+
except (AttributeError, io.UnsupportedOperation, OSError):
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
# Strategy 2 — BytesIO / in-memory SpooledTemporaryFile
|
|
54
|
+
try:
|
|
55
|
+
val: bytes = file_obj.getvalue() # type: ignore[attr-defined]
|
|
56
|
+
pos = file_obj.tell()
|
|
57
|
+
return len(val) - pos
|
|
58
|
+
except AttributeError:
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
# Strategy 3 — any seekable IO
|
|
62
|
+
try:
|
|
63
|
+
pos = file_obj.tell()
|
|
64
|
+
file_obj.seek(0, 2)
|
|
65
|
+
end = file_obj.tell()
|
|
66
|
+
file_obj.seek(pos)
|
|
67
|
+
return end - pos
|
|
68
|
+
except (AttributeError, OSError, io.UnsupportedOperation):
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class _ChainedIO:
|
|
75
|
+
"""Sequential reader over a list of IO parts.
|
|
76
|
+
|
|
77
|
+
Reads from parts[0] until exhausted, then parts[1], etc. — without
|
|
78
|
+
reading any part at construction time. Used to chain prologue bytes +
|
|
79
|
+
a caller-supplied file IO + epilogue bytes so the file body is only
|
|
80
|
+
read when urllib transfers bytes to the socket.
|
|
81
|
+
|
|
82
|
+
Mirrors Ruby's ``Scanii::Multipart::ChainedIO``.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
def __init__(self, parts: list[IO[bytes]]) -> None:
|
|
86
|
+
self._parts = parts
|
|
87
|
+
self._idx = 0
|
|
88
|
+
|
|
89
|
+
def read(self, n: int = -1) -> bytes:
|
|
90
|
+
if n < 0:
|
|
91
|
+
return self._read_all()
|
|
92
|
+
return self._read_n(n)
|
|
93
|
+
|
|
94
|
+
def _read_all(self) -> bytes:
|
|
95
|
+
chunks: list[bytes] = []
|
|
96
|
+
while self._idx < len(self._parts):
|
|
97
|
+
chunk = self._parts[self._idx].read()
|
|
98
|
+
if chunk:
|
|
99
|
+
chunks.append(chunk)
|
|
100
|
+
self._idx += 1
|
|
101
|
+
return b"".join(chunks)
|
|
102
|
+
|
|
103
|
+
def _read_n(self, n: int) -> bytes:
|
|
104
|
+
if n == 0:
|
|
105
|
+
return b""
|
|
106
|
+
chunks: list[bytes] = []
|
|
107
|
+
remaining = n
|
|
108
|
+
while remaining > 0 and self._idx < len(self._parts):
|
|
109
|
+
chunk = self._parts[self._idx].read(remaining)
|
|
110
|
+
if chunk:
|
|
111
|
+
chunks.append(chunk)
|
|
112
|
+
remaining -= len(chunk)
|
|
113
|
+
else:
|
|
114
|
+
self._idx += 1
|
|
115
|
+
return b"".join(chunks)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def encode(
|
|
119
|
+
fields: dict[str, str],
|
|
120
|
+
file_obj: IO[bytes] | None = None,
|
|
121
|
+
filename: str | None = None,
|
|
122
|
+
content_type: str | None = None,
|
|
123
|
+
) -> tuple[IO[bytes], str, int]:
|
|
124
|
+
"""Encode a multipart/form-data body.
|
|
125
|
+
|
|
126
|
+
**File mode** (``file_obj`` provided): returns a :class:`_ChainedIO` that
|
|
127
|
+
streams prologue bytes → file body → epilogue bytes without reading the
|
|
128
|
+
file at construction time. The caller's IO is only read when urllib reads
|
|
129
|
+
from the returned stream. Paths and ``BytesIO`` get true socket-level
|
|
130
|
+
streaming; IOs without ``fileno()`` or ``seek``/``tell`` fall back to a
|
|
131
|
+
``SpooledTemporaryFile(max_size=1 MiB)`` — small payloads stay in memory,
|
|
132
|
+
larger ones spill to disk.
|
|
133
|
+
|
|
134
|
+
**Fields-only mode** (``file_obj=None``): returns an ``io.BytesIO``
|
|
135
|
+
containing a multipart body with no file part. Used by
|
|
136
|
+
``process_from_url``.
|
|
137
|
+
|
|
138
|
+
Returns ``(body_stream, content_type_header, content_length)``.
|
|
139
|
+
"""
|
|
140
|
+
boundary = _make_boundary()
|
|
141
|
+
ct_header = f"multipart/form-data; boundary={boundary}"
|
|
142
|
+
|
|
143
|
+
# Build text-field parts (same for both modes)
|
|
144
|
+
text_bytes: list[bytes] = []
|
|
145
|
+
for name, value in fields.items():
|
|
146
|
+
text_bytes.append(
|
|
147
|
+
(
|
|
148
|
+
f"--{boundary}\r\n"
|
|
149
|
+
f'Content-Disposition: form-data; name="{name}"\r\n'
|
|
150
|
+
f"Content-Type: text/plain; charset=UTF-8\r\n"
|
|
151
|
+
f"\r\n"
|
|
152
|
+
f"{value}\r\n"
|
|
153
|
+
).encode("utf-8")
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
if file_obj is None:
|
|
157
|
+
# Fields-only mode — closing boundary with no file part
|
|
158
|
+
text_bytes.append(f"--{boundary}--\r\n".encode("utf-8"))
|
|
159
|
+
body = b"".join(text_bytes)
|
|
160
|
+
return io.BytesIO(body), ct_header, len(body)
|
|
161
|
+
|
|
162
|
+
# File mode — stream prologue + file + epilogue
|
|
163
|
+
if filename is None:
|
|
164
|
+
raise ValueError("filename is required when file_obj is provided")
|
|
165
|
+
|
|
166
|
+
ct = content_type or _guess_content_type(filename)
|
|
167
|
+
prologue = b"".join(text_bytes) + (
|
|
168
|
+
f"--{boundary}\r\n"
|
|
169
|
+
f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n'
|
|
170
|
+
f"Content-Type: {ct}\r\n"
|
|
171
|
+
f"\r\n"
|
|
172
|
+
).encode("utf-8")
|
|
173
|
+
# Epilogue: CRLF closes the file data part, then the final boundary.
|
|
174
|
+
epilogue = b"\r\n" + f"--{boundary}--\r\n".encode("utf-8")
|
|
175
|
+
|
|
176
|
+
# Determine file size without reading the body
|
|
177
|
+
file_size = _io_remaining_bytes(file_obj)
|
|
178
|
+
|
|
179
|
+
if file_size is None:
|
|
180
|
+
# Size-indeterminate IO (pipes, sockets, generators) — buffer via
|
|
181
|
+
# SpooledTemporaryFile so we can compute length and still stream.
|
|
182
|
+
spooled: tempfile.SpooledTemporaryFile[bytes] = tempfile.SpooledTemporaryFile(
|
|
183
|
+
max_size=1024 * 1024
|
|
184
|
+
)
|
|
185
|
+
buf = file_obj.read(65536)
|
|
186
|
+
while buf:
|
|
187
|
+
spooled.write(buf)
|
|
188
|
+
buf = file_obj.read(65536)
|
|
189
|
+
spooled.seek(0)
|
|
190
|
+
file_size = _io_remaining_bytes(cast(IO[bytes], spooled))
|
|
191
|
+
if file_size is None:
|
|
192
|
+
spooled.seek(0, 2)
|
|
193
|
+
file_size = spooled.tell()
|
|
194
|
+
spooled.seek(0)
|
|
195
|
+
file_obj = cast(IO[bytes], spooled)
|
|
196
|
+
|
|
197
|
+
total_length = len(prologue) + file_size + len(epilogue)
|
|
198
|
+
chained = _ChainedIO([io.BytesIO(prologue), file_obj, io.BytesIO(epilogue)])
|
|
199
|
+
return cast(IO[bytes], chained), ct_header, total_length
|
scanii/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.0.0"
|
scanii/client.py
ADDED
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
"""Scanii SDK client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import urllib.error
|
|
9
|
+
import urllib.parse
|
|
10
|
+
import urllib.request
|
|
11
|
+
from typing import IO
|
|
12
|
+
|
|
13
|
+
from scanii._multipart import encode as _multipart_encode
|
|
14
|
+
from scanii._version import __version__
|
|
15
|
+
from scanii.errors import ScaniiAuthError, ScaniiError, ScaniiRateLimitError
|
|
16
|
+
from scanii.models import (
|
|
17
|
+
ScaniiAuthToken,
|
|
18
|
+
ScaniiPendingResult,
|
|
19
|
+
ScaniiProcessingResult,
|
|
20
|
+
ScaniiTraceResult,
|
|
21
|
+
)
|
|
22
|
+
from scanii.target import ScaniiTarget
|
|
23
|
+
|
|
24
|
+
_API_VERSION = "/v2.2"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ScaniiClient:
|
|
28
|
+
"""Synchronous client for the Scanii REST API v2.2.
|
|
29
|
+
|
|
30
|
+
Construct with either ``key`` + ``secret`` (HTTP Basic Auth) or ``token``
|
|
31
|
+
(auth-token authentication). Mixing the two raises ``ValueError``.
|
|
32
|
+
|
|
33
|
+
``target`` is required — choose a regional constant for production or
|
|
34
|
+
construct a custom ``ScaniiTarget`` for local testing::
|
|
35
|
+
|
|
36
|
+
client = ScaniiClient(key="your-key", secret="your-secret", target=ScaniiTarget.US1)
|
|
37
|
+
|
|
38
|
+
Per SDK Principle 3 the client is integration-only: it does not retry,
|
|
39
|
+
batch, or paginate. Each public method maps to exactly one HTTP request.
|
|
40
|
+
|
|
41
|
+
See https://scanii.github.io/openapi/v22/
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
*,
|
|
47
|
+
key: str | None = None,
|
|
48
|
+
secret: str | None = None,
|
|
49
|
+
token: str | None = None,
|
|
50
|
+
target: ScaniiTarget | None = None,
|
|
51
|
+
timeout: float = 60.0,
|
|
52
|
+
) -> None:
|
|
53
|
+
if not isinstance(target, ScaniiTarget):
|
|
54
|
+
raise TypeError(
|
|
55
|
+
"ScaniiClient requires a ScaniiTarget for data residency compliance. Examples:\n"
|
|
56
|
+
" ScaniiClient(key=..., secret=..., target=ScaniiTarget.US1)\n"
|
|
57
|
+
" ScaniiClient(key=..., secret=..., target=ScaniiTarget.EU1)\n"
|
|
58
|
+
"For local testing against scanii-cli:\n"
|
|
59
|
+
' ScaniiClient(key=..., secret=..., target=ScaniiTarget("http://localhost:4000"))\n'
|
|
60
|
+
"Available regional constants: US1, EU1, EU2, AP1, AP2, CA1."
|
|
61
|
+
)
|
|
62
|
+
self._auth_header = self._build_auth_header(key, secret, token)
|
|
63
|
+
self._target = target
|
|
64
|
+
self._timeout = float(timeout)
|
|
65
|
+
self._user_agent = f"scanii-python/{__version__}"
|
|
66
|
+
|
|
67
|
+
# ------------------------------------------------------------------
|
|
68
|
+
# File scanning — stream-first
|
|
69
|
+
# ------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
def process(
|
|
72
|
+
self,
|
|
73
|
+
content: IO[bytes],
|
|
74
|
+
filename: str,
|
|
75
|
+
content_type: str | None = None,
|
|
76
|
+
metadata: dict[str, str] | None = None,
|
|
77
|
+
callback: str | None = None,
|
|
78
|
+
) -> ScaniiProcessingResult:
|
|
79
|
+
"""Submit an IO-like object for synchronous scanning.
|
|
80
|
+
|
|
81
|
+
``content`` is duck-typed: anything with ``read(n) -> bytes``.
|
|
82
|
+
Both ``open(path, 'rb')`` and ``io.BytesIO(...)`` work.
|
|
83
|
+
|
|
84
|
+
:param content: IO-like object (anything with ``read(n) -> bytes``)
|
|
85
|
+
:param filename: filename sent in the multipart part
|
|
86
|
+
:param content_type: content-type of the file part; guessed from filename when None
|
|
87
|
+
:param metadata: arbitrary key/value pairs attached to the result
|
|
88
|
+
:param callback: URL to POST the result to on completion
|
|
89
|
+
:see: https://scanii.github.io/openapi/v22/ POST /files
|
|
90
|
+
:return: :class:`~scanii.ScaniiProcessingResult`
|
|
91
|
+
"""
|
|
92
|
+
fields = self._build_fields(metadata, callback)
|
|
93
|
+
body_stream, ct, content_length = _multipart_encode(
|
|
94
|
+
fields, file_obj=content, filename=filename, content_type=content_type
|
|
95
|
+
)
|
|
96
|
+
status, resp_body, headers = self._post(
|
|
97
|
+
"/files", body=body_stream, content_type=ct, content_length=content_length
|
|
98
|
+
)
|
|
99
|
+
self._raise_for_status(status, resp_body, headers, expected=201)
|
|
100
|
+
return ScaniiProcessingResult.from_response(resp_body, headers)
|
|
101
|
+
|
|
102
|
+
def process_file(
|
|
103
|
+
self,
|
|
104
|
+
path: str | os.PathLike[str],
|
|
105
|
+
metadata: dict[str, str] | None = None,
|
|
106
|
+
callback: str | None = None,
|
|
107
|
+
) -> ScaniiProcessingResult:
|
|
108
|
+
"""Submit a file path for synchronous scanning.
|
|
109
|
+
|
|
110
|
+
Opens the file in binary mode, streams it to Scanii, and closes it.
|
|
111
|
+
Delegates to :meth:`process` with ``filename`` set to the basename.
|
|
112
|
+
|
|
113
|
+
:param path: path to the file to upload
|
|
114
|
+
:param metadata: arbitrary key/value pairs attached to the result
|
|
115
|
+
:param callback: URL to POST the result to on completion
|
|
116
|
+
:see: https://scanii.github.io/openapi/v22/ POST /files
|
|
117
|
+
:return: :class:`~scanii.ScaniiProcessingResult`
|
|
118
|
+
"""
|
|
119
|
+
path = os.fspath(path)
|
|
120
|
+
with open(path, "rb") as f:
|
|
121
|
+
return self.process(f, filename=os.path.basename(path),
|
|
122
|
+
metadata=metadata, callback=callback)
|
|
123
|
+
|
|
124
|
+
def process_async(
|
|
125
|
+
self,
|
|
126
|
+
content: IO[bytes],
|
|
127
|
+
filename: str,
|
|
128
|
+
content_type: str | None = None,
|
|
129
|
+
metadata: dict[str, str] | None = None,
|
|
130
|
+
callback: str | None = None,
|
|
131
|
+
) -> ScaniiPendingResult:
|
|
132
|
+
"""Submit an IO-like object for server-side asynchronous scanning.
|
|
133
|
+
|
|
134
|
+
Returns a pending id; the final result is delivered to ``callback``
|
|
135
|
+
(when supplied) or fetched via :meth:`retrieve`.
|
|
136
|
+
|
|
137
|
+
:param content: IO-like object (anything with ``read(n) -> bytes``)
|
|
138
|
+
:param filename: filename sent in the multipart part
|
|
139
|
+
:param content_type: content-type of the file part; guessed from filename when None
|
|
140
|
+
:param metadata: arbitrary key/value pairs attached to the result
|
|
141
|
+
:param callback: URL to POST the result to on completion
|
|
142
|
+
:see: https://scanii.github.io/openapi/v22/ POST /files/async
|
|
143
|
+
:return: :class:`~scanii.ScaniiPendingResult`
|
|
144
|
+
"""
|
|
145
|
+
fields = self._build_fields(metadata, callback)
|
|
146
|
+
body_stream, ct, content_length = _multipart_encode(
|
|
147
|
+
fields, file_obj=content, filename=filename, content_type=content_type
|
|
148
|
+
)
|
|
149
|
+
status, resp_body, headers = self._post(
|
|
150
|
+
"/files/async", body=body_stream, content_type=ct, content_length=content_length
|
|
151
|
+
)
|
|
152
|
+
self._raise_for_status(status, resp_body, headers, expected=202)
|
|
153
|
+
return ScaniiPendingResult.from_response(resp_body, headers)
|
|
154
|
+
|
|
155
|
+
def process_async_file(
|
|
156
|
+
self,
|
|
157
|
+
path: str | os.PathLike[str],
|
|
158
|
+
metadata: dict[str, str] | None = None,
|
|
159
|
+
callback: str | None = None,
|
|
160
|
+
) -> ScaniiPendingResult:
|
|
161
|
+
"""Submit a file path for server-side asynchronous scanning.
|
|
162
|
+
|
|
163
|
+
Opens the file in binary mode and delegates to :meth:`process_async`.
|
|
164
|
+
|
|
165
|
+
:param path: path to the file to upload
|
|
166
|
+
:param metadata: arbitrary key/value pairs attached to the result
|
|
167
|
+
:param callback: URL to POST the result to on completion
|
|
168
|
+
:see: https://scanii.github.io/openapi/v22/ POST /files/async
|
|
169
|
+
:return: :class:`~scanii.ScaniiPendingResult`
|
|
170
|
+
"""
|
|
171
|
+
path = os.fspath(path)
|
|
172
|
+
with open(path, "rb") as f:
|
|
173
|
+
return self.process_async(f, filename=os.path.basename(path),
|
|
174
|
+
metadata=metadata, callback=callback)
|
|
175
|
+
|
|
176
|
+
# ------------------------------------------------------------------
|
|
177
|
+
# v2.2 surface
|
|
178
|
+
# ------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
def retrieve_trace(self, id: str) -> ScaniiTraceResult | None:
|
|
181
|
+
"""Retrieve the processing event trace for a previously submitted scan.
|
|
182
|
+
|
|
183
|
+
Returns ``None`` when no trace exists for the given id (HTTP 404).
|
|
184
|
+
|
|
185
|
+
This is a v2.2 preview surface; the API shape may shift before it is
|
|
186
|
+
marked stable.
|
|
187
|
+
|
|
188
|
+
:param id: processing id returned by :meth:`process` or :meth:`process_file`
|
|
189
|
+
:see: https://scanii.github.io/openapi/v22/ GET /files/{id}/trace
|
|
190
|
+
:return: :class:`~scanii.ScaniiTraceResult` or ``None``
|
|
191
|
+
"""
|
|
192
|
+
if not id:
|
|
193
|
+
raise ValueError("id must not be empty")
|
|
194
|
+
status, resp_body, headers = self._request("GET", f"/files/{_urlencode(id)}/trace")
|
|
195
|
+
if status == 404:
|
|
196
|
+
return None
|
|
197
|
+
self._raise_for_status(status, resp_body, headers, expected=200)
|
|
198
|
+
return ScaniiTraceResult.from_response(resp_body, headers)
|
|
199
|
+
|
|
200
|
+
def process_from_url(
|
|
201
|
+
self,
|
|
202
|
+
location: str,
|
|
203
|
+
callback: str | None = None,
|
|
204
|
+
metadata: dict[str, str] | None = None,
|
|
205
|
+
) -> ScaniiProcessingResult:
|
|
206
|
+
"""Submit a remote URL for synchronous scanning.
|
|
207
|
+
|
|
208
|
+
Sends the URL as a ``location`` field in a ``multipart/form-data`` POST
|
|
209
|
+
to ``/files``. The Scanii server fetches and scans the URL synchronously
|
|
210
|
+
and returns a :class:`~scanii.ScaniiProcessingResult`. This is distinct
|
|
211
|
+
from :meth:`fetch`, which submits to ``/files/fetch`` for asynchronous
|
|
212
|
+
server-side fetching.
|
|
213
|
+
|
|
214
|
+
``location`` must be a string URL — matches the existing :meth:`fetch`
|
|
215
|
+
string-URL convention and the Java reference (``processFromUrl(String)``).
|
|
216
|
+
|
|
217
|
+
This is a v2.2 preview surface; the API shape may shift before it is
|
|
218
|
+
marked stable.
|
|
219
|
+
|
|
220
|
+
:param location: URL of the content to scan
|
|
221
|
+
:param callback: URL to POST the result to on completion
|
|
222
|
+
:param metadata: arbitrary key/value pairs attached to the result
|
|
223
|
+
:see: https://scanii.github.io/openapi/v22/ POST /files
|
|
224
|
+
:return: :class:`~scanii.ScaniiProcessingResult`
|
|
225
|
+
"""
|
|
226
|
+
if not location:
|
|
227
|
+
raise ValueError("location must not be empty")
|
|
228
|
+
fields = self._build_fields(metadata, callback)
|
|
229
|
+
fields["location"] = location
|
|
230
|
+
# Fields-only multipart — POST /v2.2/files rejects urlencoded on this endpoint.
|
|
231
|
+
body_stream, ct, content_length = _multipart_encode(fields)
|
|
232
|
+
status, resp_body, headers = self._post(
|
|
233
|
+
"/files", body=body_stream, content_type=ct, content_length=content_length
|
|
234
|
+
)
|
|
235
|
+
self._raise_for_status(status, resp_body, headers, expected=201)
|
|
236
|
+
return ScaniiProcessingResult.from_response(resp_body, headers)
|
|
237
|
+
|
|
238
|
+
# ------------------------------------------------------------------
|
|
239
|
+
# Other API methods
|
|
240
|
+
# ------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
def fetch(
|
|
243
|
+
self,
|
|
244
|
+
url: str,
|
|
245
|
+
metadata: dict[str, str] | None = None,
|
|
246
|
+
callback: str | None = None,
|
|
247
|
+
) -> ScaniiPendingResult:
|
|
248
|
+
"""Ask Scanii to download a remote URL and scan it asynchronously.
|
|
249
|
+
|
|
250
|
+
:see: https://scanii.github.io/openapi/v22/ POST /files/fetch
|
|
251
|
+
:return: :class:`~scanii.ScaniiPendingResult`
|
|
252
|
+
"""
|
|
253
|
+
if not url:
|
|
254
|
+
raise ValueError("url must not be empty")
|
|
255
|
+
form: dict[str, str] = {"location": url}
|
|
256
|
+
if callback:
|
|
257
|
+
form["callback"] = callback
|
|
258
|
+
for k, v in (metadata or {}).items():
|
|
259
|
+
form[f"metadata[{k}]"] = str(v)
|
|
260
|
+
body = urllib.parse.urlencode(form).encode("utf-8")
|
|
261
|
+
status, resp_body, headers = self._post(
|
|
262
|
+
"/files/fetch", body=body,
|
|
263
|
+
content_type="application/x-www-form-urlencoded"
|
|
264
|
+
)
|
|
265
|
+
self._raise_for_status(status, resp_body, headers, expected=202)
|
|
266
|
+
return ScaniiPendingResult.from_response(resp_body, headers)
|
|
267
|
+
|
|
268
|
+
def retrieve(self, id: str) -> ScaniiProcessingResult:
|
|
269
|
+
"""Retrieve a previously submitted scan result by id.
|
|
270
|
+
|
|
271
|
+
:see: https://scanii.github.io/openapi/v22/ GET /files/{id}
|
|
272
|
+
:return: :class:`~scanii.ScaniiProcessingResult`
|
|
273
|
+
"""
|
|
274
|
+
if not id:
|
|
275
|
+
raise ValueError("id must not be empty")
|
|
276
|
+
status, resp_body, headers = self._request("GET", f"/files/{_urlencode(id)}")
|
|
277
|
+
self._raise_for_status(status, resp_body, headers, expected=200)
|
|
278
|
+
return ScaniiProcessingResult.from_response(resp_body, headers)
|
|
279
|
+
|
|
280
|
+
def ping(self) -> bool:
|
|
281
|
+
"""Verify that the configured credentials reach the API.
|
|
282
|
+
|
|
283
|
+
:see: https://scanii.github.io/openapi/v22/ GET /ping
|
|
284
|
+
:return: ``True`` when the API responds 200
|
|
285
|
+
:raises ScaniiAuthError: when credentials are rejected
|
|
286
|
+
"""
|
|
287
|
+
status, resp_body, headers = self._request("GET", "/ping")
|
|
288
|
+
if status == 200:
|
|
289
|
+
return True
|
|
290
|
+
self._raise_for_status(status, resp_body, headers, expected=200)
|
|
291
|
+
return False # unreachable; keeps type checker happy
|
|
292
|
+
|
|
293
|
+
def create_auth_token(self, timeout_seconds: int) -> ScaniiAuthToken:
|
|
294
|
+
"""Mint a short-lived auth token.
|
|
295
|
+
|
|
296
|
+
``timeout_seconds`` must be a positive integer.
|
|
297
|
+
|
|
298
|
+
:see: https://scanii.github.io/openapi/v22/ POST /auth/tokens
|
|
299
|
+
:return: :class:`~scanii.ScaniiAuthToken`
|
|
300
|
+
"""
|
|
301
|
+
ts = int(timeout_seconds)
|
|
302
|
+
if ts <= 0:
|
|
303
|
+
raise ValueError("timeout_seconds must be positive")
|
|
304
|
+
body = urllib.parse.urlencode({"timeout": ts}).encode("utf-8")
|
|
305
|
+
status, resp_body, headers = self._post(
|
|
306
|
+
"/auth/tokens", body=body,
|
|
307
|
+
content_type="application/x-www-form-urlencoded"
|
|
308
|
+
)
|
|
309
|
+
if status not in (200, 201):
|
|
310
|
+
self._raise_for_status(status, resp_body, headers, expected=201)
|
|
311
|
+
return ScaniiAuthToken.from_response(resp_body, headers)
|
|
312
|
+
|
|
313
|
+
def retrieve_auth_token(self, id: str) -> ScaniiAuthToken:
|
|
314
|
+
"""Inspect a previously created auth token.
|
|
315
|
+
|
|
316
|
+
:see: https://scanii.github.io/openapi/v22/ GET /auth/tokens/{id}
|
|
317
|
+
:return: :class:`~scanii.ScaniiAuthToken`
|
|
318
|
+
"""
|
|
319
|
+
if not id:
|
|
320
|
+
raise ValueError("id must not be empty")
|
|
321
|
+
status, resp_body, headers = self._request("GET", f"/auth/tokens/{_urlencode(id)}")
|
|
322
|
+
self._raise_for_status(status, resp_body, headers, expected=200)
|
|
323
|
+
return ScaniiAuthToken.from_response(resp_body, headers)
|
|
324
|
+
|
|
325
|
+
def delete_auth_token(self, id: str) -> bool:
|
|
326
|
+
"""Revoke an auth token.
|
|
327
|
+
|
|
328
|
+
:see: https://scanii.github.io/openapi/v22/ DELETE /auth/tokens/{id}
|
|
329
|
+
:return: ``True`` on success (HTTP 204)
|
|
330
|
+
"""
|
|
331
|
+
if not id:
|
|
332
|
+
raise ValueError("id must not be empty")
|
|
333
|
+
status, resp_body, headers = self._request("DELETE", f"/auth/tokens/{_urlencode(id)}")
|
|
334
|
+
self._raise_for_status(status, resp_body, headers, expected=204)
|
|
335
|
+
return True
|
|
336
|
+
|
|
337
|
+
# ------------------------------------------------------------------
|
|
338
|
+
# Private helpers
|
|
339
|
+
# ------------------------------------------------------------------
|
|
340
|
+
|
|
341
|
+
def _build_auth_header(
|
|
342
|
+
self, key: str | None, secret: str | None, token: str | None
|
|
343
|
+
) -> str:
|
|
344
|
+
if token:
|
|
345
|
+
if key or secret:
|
|
346
|
+
raise ValueError("supply either token or key+secret, not both")
|
|
347
|
+
credentials = base64.b64encode(f"{token}:".encode("utf-8")).decode("ascii")
|
|
348
|
+
return f"Basic {credentials}"
|
|
349
|
+
if not key:
|
|
350
|
+
raise ValueError("key must be set (or use token for auth-token mode)")
|
|
351
|
+
if ":" in key:
|
|
352
|
+
raise ValueError("key must not contain a colon")
|
|
353
|
+
if not secret:
|
|
354
|
+
raise ValueError("secret must be set when using key auth")
|
|
355
|
+
credentials = base64.b64encode(f"{key}:{secret}".encode("utf-8")).decode("ascii")
|
|
356
|
+
return f"Basic {credentials}"
|
|
357
|
+
|
|
358
|
+
def _build_fields(
|
|
359
|
+
self, metadata: dict[str, str] | None, callback: str | None
|
|
360
|
+
) -> dict[str, str]:
|
|
361
|
+
fields: dict[str, str] = {}
|
|
362
|
+
for k, v in (metadata or {}).items():
|
|
363
|
+
fields[f"metadata[{k}]"] = str(v)
|
|
364
|
+
if callback:
|
|
365
|
+
fields["callback"] = callback
|
|
366
|
+
return fields
|
|
367
|
+
|
|
368
|
+
def _post(
|
|
369
|
+
self,
|
|
370
|
+
path: str,
|
|
371
|
+
body: bytes | IO[bytes],
|
|
372
|
+
content_type: str,
|
|
373
|
+
content_length: int | None = None,
|
|
374
|
+
) -> tuple[int, str, dict[str, str]]:
|
|
375
|
+
return self._request(
|
|
376
|
+
"POST", path, body=body, content_type=content_type,
|
|
377
|
+
content_length=content_length
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
def _request(
|
|
381
|
+
self,
|
|
382
|
+
method: str,
|
|
383
|
+
path: str,
|
|
384
|
+
body: bytes | IO[bytes] | None = None,
|
|
385
|
+
content_type: str | None = None,
|
|
386
|
+
content_length: int | None = None,
|
|
387
|
+
) -> tuple[int, str, dict[str, str]]:
|
|
388
|
+
url = self._target.resolve(_API_VERSION + path)
|
|
389
|
+
headers: dict[str, str] = {
|
|
390
|
+
"Authorization": self._auth_header,
|
|
391
|
+
"User-Agent": self._user_agent,
|
|
392
|
+
"Accept": "application/json",
|
|
393
|
+
}
|
|
394
|
+
if content_type:
|
|
395
|
+
headers["Content-Type"] = content_type
|
|
396
|
+
# Set Content-Length explicitly for stream bodies (urllib can't compute it).
|
|
397
|
+
# For bytes bodies urllib sets it automatically via len(); we leave that alone.
|
|
398
|
+
if content_length is not None:
|
|
399
|
+
headers["Content-Length"] = str(content_length)
|
|
400
|
+
|
|
401
|
+
req = urllib.request.Request(url, data=body, headers=headers, method=method)
|
|
402
|
+
try:
|
|
403
|
+
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
|
|
404
|
+
resp_body = resp.read().decode("utf-8", errors="replace")
|
|
405
|
+
resp_headers = {k.lower(): v for k, v in resp.headers.items()}
|
|
406
|
+
return resp.status, resp_body, resp_headers
|
|
407
|
+
except urllib.error.HTTPError as e:
|
|
408
|
+
resp_body = e.read().decode("utf-8", errors="replace")
|
|
409
|
+
resp_headers = {k.lower(): v for k, v in e.headers.items()}
|
|
410
|
+
return e.code, resp_body, resp_headers
|
|
411
|
+
except urllib.error.URLError:
|
|
412
|
+
raise
|
|
413
|
+
|
|
414
|
+
def _raise_for_status(
|
|
415
|
+
self,
|
|
416
|
+
status: int,
|
|
417
|
+
body: str,
|
|
418
|
+
headers: dict[str, str],
|
|
419
|
+
*,
|
|
420
|
+
expected: int,
|
|
421
|
+
) -> None:
|
|
422
|
+
if status == expected:
|
|
423
|
+
return
|
|
424
|
+
request_id = headers.get("x-scanii-request-id")
|
|
425
|
+
host_id = headers.get("x-scanii-host-id")
|
|
426
|
+
message = self._extract_error_message(body) or f"HTTP {status}"
|
|
427
|
+
|
|
428
|
+
if status in (401, 403):
|
|
429
|
+
raise ScaniiAuthError(
|
|
430
|
+
message, status_code=status, request_id=request_id,
|
|
431
|
+
host_id=host_id, body=body
|
|
432
|
+
)
|
|
433
|
+
if status == 429:
|
|
434
|
+
retry_after_raw = headers.get("retry-after")
|
|
435
|
+
retry_after = int(retry_after_raw) if retry_after_raw else None
|
|
436
|
+
raise ScaniiRateLimitError(
|
|
437
|
+
message, status_code=status, request_id=request_id,
|
|
438
|
+
host_id=host_id, body=body, retry_after=retry_after
|
|
439
|
+
)
|
|
440
|
+
raise ScaniiError(
|
|
441
|
+
message, status_code=status, request_id=request_id,
|
|
442
|
+
host_id=host_id, body=body
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
def _extract_error_message(self, body: str) -> str | None:
|
|
446
|
+
if not body:
|
|
447
|
+
return None
|
|
448
|
+
try:
|
|
449
|
+
decoded = json.loads(body)
|
|
450
|
+
if isinstance(decoded, dict):
|
|
451
|
+
error = decoded.get("error")
|
|
452
|
+
if isinstance(error, str):
|
|
453
|
+
return error
|
|
454
|
+
return body
|
|
455
|
+
except (json.JSONDecodeError, ValueError):
|
|
456
|
+
return body
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _urlencode(value: str) -> str:
|
|
460
|
+
return urllib.parse.quote(value, safe="")
|
scanii/errors.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Scanii SDK exception hierarchy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ScaniiError(Exception):
|
|
7
|
+
"""Base exception for all Scanii API errors.
|
|
8
|
+
|
|
9
|
+
Raised on HTTP 4xx/5xx responses. Carries the API-supplied message plus
|
|
10
|
+
optional diagnostic headers for support handoffs.
|
|
11
|
+
|
|
12
|
+
Per SDK Principle 3 (integration-only) the SDK does not retry on the
|
|
13
|
+
caller's behalf — backoff is the caller's responsibility.
|
|
14
|
+
|
|
15
|
+
See https://scanii.github.io/openapi/v22/
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
message: str,
|
|
21
|
+
*,
|
|
22
|
+
status_code: int | None = None,
|
|
23
|
+
request_id: str | None = None,
|
|
24
|
+
host_id: str | None = None,
|
|
25
|
+
body: str | None = None,
|
|
26
|
+
) -> None:
|
|
27
|
+
super().__init__(message)
|
|
28
|
+
self.status_code = status_code
|
|
29
|
+
self.request_id = request_id
|
|
30
|
+
self.host_id = host_id
|
|
31
|
+
self.body = body
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ScaniiAuthError(ScaniiError):
|
|
35
|
+
"""Raised on HTTP 401 or 403 — credentials rejected by the API."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ScaniiRateLimitError(ScaniiError):
|
|
39
|
+
"""Raised on HTTP 429.
|
|
40
|
+
|
|
41
|
+
``retry_after`` carries the value of the ``Retry-After`` response header in
|
|
42
|
+
seconds when the server provided one, otherwise ``None``.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(self, message: str, *, retry_after: int | None = None, **kwargs: object) -> None:
|
|
46
|
+
super().__init__(message, **kwargs) # type: ignore[arg-type]
|
|
47
|
+
self.retry_after = retry_after
|
scanii/models.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Scanii SDK response model dataclasses."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import warnings
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class ScaniiTraceEvent:
|
|
12
|
+
"""A single processing event in a scan trace.
|
|
13
|
+
|
|
14
|
+
See https://scanii.github.io/openapi/v22/
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
timestamp: str
|
|
18
|
+
message: str
|
|
19
|
+
|
|
20
|
+
@classmethod
|
|
21
|
+
def from_dict(cls, raw: dict[str, object]) -> "ScaniiTraceEvent":
|
|
22
|
+
return cls(
|
|
23
|
+
timestamp=str(raw.get("timestamp") or ""),
|
|
24
|
+
message=str(raw.get("message") or ""),
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class ScaniiTraceResult:
|
|
30
|
+
"""Result of :meth:`~scanii.ScaniiClient.retrieve_trace`.
|
|
31
|
+
|
|
32
|
+
This is a v2.2 preview surface; the API shape may shift before it is
|
|
33
|
+
marked stable.
|
|
34
|
+
|
|
35
|
+
See https://scanii.github.io/openapi/v22/
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
resource_id: str
|
|
39
|
+
events: tuple[ScaniiTraceEvent, ...]
|
|
40
|
+
request_id: str | None
|
|
41
|
+
host_id: str | None
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def from_response(cls, body: str, headers: dict[str, str]) -> "ScaniiTraceResult":
|
|
45
|
+
raw = json.loads(body) if body else {}
|
|
46
|
+
return cls(
|
|
47
|
+
resource_id=str(raw.get("id") or ""),
|
|
48
|
+
events=tuple(ScaniiTraceEvent.from_dict(e) for e in raw.get("events") or []),
|
|
49
|
+
request_id=headers.get("x-scanii-request-id"),
|
|
50
|
+
host_id=headers.get("x-scanii-host-id"),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class ScaniiProcessingResult:
|
|
56
|
+
"""Result of a synchronous scan returned by :meth:`~scanii.ScaniiClient.process`,
|
|
57
|
+
:meth:`~scanii.ScaniiClient.process_file`, :meth:`~scanii.ScaniiClient.process_from_url`,
|
|
58
|
+
and :meth:`~scanii.ScaniiClient.retrieve`.
|
|
59
|
+
|
|
60
|
+
``findings`` is always a tuple. An empty tuple means the content is clean.
|
|
61
|
+
|
|
62
|
+
See https://scanii.github.io/openapi/v22/
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
id: str
|
|
66
|
+
findings: tuple[str, ...]
|
|
67
|
+
checksum: str | None
|
|
68
|
+
content_length: int | None
|
|
69
|
+
content_type: str | None
|
|
70
|
+
metadata: dict[str, str]
|
|
71
|
+
creation_date: str | None
|
|
72
|
+
request_id: str | None
|
|
73
|
+
host_id: str | None
|
|
74
|
+
resource_location: str | None
|
|
75
|
+
error: str | None = None
|
|
76
|
+
"""
|
|
77
|
+
.. deprecated:: 1.0.0
|
|
78
|
+
Server-side errors arrive as :class:`~scanii.ScaniiError` subclasses on
|
|
79
|
+
non-2xx responses; this field is never populated on success.
|
|
80
|
+
Will be removed in a future major version.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
def __post_init__(self) -> None:
|
|
84
|
+
if self.error is not None:
|
|
85
|
+
warnings.warn(
|
|
86
|
+
"ScaniiProcessingResult.error is deprecated and will be removed in a "
|
|
87
|
+
"future major version. Server-side errors arrive as ScaniiError "
|
|
88
|
+
"subclasses on non-2xx responses; this field is never populated on success.",
|
|
89
|
+
DeprecationWarning,
|
|
90
|
+
stacklevel=2,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
@classmethod
|
|
94
|
+
def from_response(cls, body: str, headers: dict[str, str]) -> "ScaniiProcessingResult":
|
|
95
|
+
raw = json.loads(body) if body else {}
|
|
96
|
+
return cls(
|
|
97
|
+
id=str(raw.get("id") or ""),
|
|
98
|
+
findings=tuple(str(f) for f in (raw.get("findings") or [])),
|
|
99
|
+
checksum=str(raw["checksum"]) if raw.get("checksum") is not None else None,
|
|
100
|
+
content_length=(
|
|
101
|
+
int(raw["content_length"]) if raw.get("content_length") is not None else None
|
|
102
|
+
),
|
|
103
|
+
content_type=(
|
|
104
|
+
str(raw["content_type"]) if raw.get("content_type") is not None else None
|
|
105
|
+
),
|
|
106
|
+
metadata={str(k): str(v) for k, v in (raw.get("metadata") or {}).items()},
|
|
107
|
+
creation_date=(
|
|
108
|
+
str(raw["creation_date"]) if raw.get("creation_date") is not None else None
|
|
109
|
+
),
|
|
110
|
+
request_id=headers.get("x-scanii-request-id"),
|
|
111
|
+
host_id=headers.get("x-scanii-host-id"),
|
|
112
|
+
resource_location=headers.get("location"),
|
|
113
|
+
error=str(raw["error"]) if raw.get("error") is not None else None,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass(frozen=True)
|
|
118
|
+
class ScaniiPendingResult:
|
|
119
|
+
"""Result of an asynchronous scan submission returned by
|
|
120
|
+
:meth:`~scanii.ScaniiClient.process_async`, :meth:`~scanii.ScaniiClient.process_async_file`,
|
|
121
|
+
and :meth:`~scanii.ScaniiClient.fetch`.
|
|
122
|
+
|
|
123
|
+
The actual scan result is fetched later via :meth:`~scanii.ScaniiClient.retrieve`
|
|
124
|
+
or delivered to the supplied callback URL.
|
|
125
|
+
|
|
126
|
+
See https://scanii.github.io/openapi/v22/
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
id: str
|
|
130
|
+
request_id: str | None
|
|
131
|
+
host_id: str | None
|
|
132
|
+
resource_location: str | None
|
|
133
|
+
|
|
134
|
+
@classmethod
|
|
135
|
+
def from_response(cls, body: str, headers: dict[str, str]) -> "ScaniiPendingResult":
|
|
136
|
+
raw = json.loads(body) if body else {}
|
|
137
|
+
return cls(
|
|
138
|
+
id=str(raw.get("id") or ""),
|
|
139
|
+
request_id=headers.get("x-scanii-request-id"),
|
|
140
|
+
host_id=headers.get("x-scanii-host-id"),
|
|
141
|
+
resource_location=headers.get("location"),
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass(frozen=True)
|
|
146
|
+
class ScaniiAuthToken:
|
|
147
|
+
"""Short-lived auth token returned by :meth:`~scanii.ScaniiClient.create_auth_token`
|
|
148
|
+
and :meth:`~scanii.ScaniiClient.retrieve_auth_token`.
|
|
149
|
+
|
|
150
|
+
Pass ``id`` as the ``token`` argument when constructing a :class:`~scanii.ScaniiClient`
|
|
151
|
+
to authenticate using the token instead of API key + secret.
|
|
152
|
+
|
|
153
|
+
See https://scanii.github.io/openapi/v22/
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
id: str
|
|
157
|
+
creation_date: str | None
|
|
158
|
+
expiration_date: str | None
|
|
159
|
+
request_id: str | None
|
|
160
|
+
host_id: str | None
|
|
161
|
+
resource_location: str | None
|
|
162
|
+
|
|
163
|
+
@classmethod
|
|
164
|
+
def from_response(cls, body: str, headers: dict[str, str]) -> "ScaniiAuthToken":
|
|
165
|
+
raw = json.loads(body) if body else {}
|
|
166
|
+
return cls(
|
|
167
|
+
id=str(raw.get("id") or ""),
|
|
168
|
+
creation_date=(
|
|
169
|
+
str(raw["creation_date"]) if raw.get("creation_date") is not None else None
|
|
170
|
+
),
|
|
171
|
+
expiration_date=(
|
|
172
|
+
str(raw["expiration_date"]) if raw.get("expiration_date") is not None else None
|
|
173
|
+
),
|
|
174
|
+
request_id=headers.get("x-scanii-request-id"),
|
|
175
|
+
host_id=headers.get("x-scanii-host-id"),
|
|
176
|
+
resource_location=headers.get("location"),
|
|
177
|
+
)
|
scanii/py.typed
ADDED
|
File without changes
|
scanii/target.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Scanii regional API endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import ClassVar
|
|
6
|
+
from urllib.parse import urljoin
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ScaniiTarget:
|
|
10
|
+
"""
|
|
11
|
+
Scanii regional API endpoint.
|
|
12
|
+
|
|
13
|
+
Use one of the predefined regional constants (e.g. ``ScaniiTarget.US1``)
|
|
14
|
+
for production. The constructor accepts an arbitrary URL for testing
|
|
15
|
+
against scanii-cli or other local mocks::
|
|
16
|
+
|
|
17
|
+
target = ScaniiTarget("http://localhost:4000")
|
|
18
|
+
|
|
19
|
+
Note: ``ScaniiTarget.AUTO`` (latency-based routing) is intentionally
|
|
20
|
+
not provided. Customer data residency / chain-of-custody compliance
|
|
21
|
+
requires an explicit regional choice. Other Scanii SDKs that historically
|
|
22
|
+
defaulted to AUTO are being updated to deprecate it.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
US1: ClassVar[ScaniiTarget]
|
|
26
|
+
EU1: ClassVar[ScaniiTarget]
|
|
27
|
+
EU2: ClassVar[ScaniiTarget]
|
|
28
|
+
AP1: ClassVar[ScaniiTarget]
|
|
29
|
+
AP2: ClassVar[ScaniiTarget]
|
|
30
|
+
CA1: ClassVar[ScaniiTarget]
|
|
31
|
+
|
|
32
|
+
def __init__(self, url: str) -> None:
|
|
33
|
+
if not url:
|
|
34
|
+
raise ValueError("ScaniiTarget URL must be a non-empty string")
|
|
35
|
+
self._url = url
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def endpoint(self) -> str:
|
|
39
|
+
"""The base URL this target points at."""
|
|
40
|
+
return self._url
|
|
41
|
+
|
|
42
|
+
def resolve(self, path: str) -> str:
|
|
43
|
+
"""Join the target's base URL with a request path."""
|
|
44
|
+
base = self._url if self._url.endswith("/") else self._url + "/"
|
|
45
|
+
return urljoin(base, path)
|
|
46
|
+
|
|
47
|
+
def __repr__(self) -> str:
|
|
48
|
+
return f"ScaniiTarget({self._url!r})"
|
|
49
|
+
|
|
50
|
+
def __eq__(self, other: object) -> bool:
|
|
51
|
+
return isinstance(other, ScaniiTarget) and self._url == other._url
|
|
52
|
+
|
|
53
|
+
def __hash__(self) -> int:
|
|
54
|
+
return hash(self._url)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
ScaniiTarget.US1 = ScaniiTarget("https://api-us1.scanii.com")
|
|
58
|
+
ScaniiTarget.EU1 = ScaniiTarget("https://api-eu1.scanii.com")
|
|
59
|
+
ScaniiTarget.EU2 = ScaniiTarget("https://api-eu2.scanii.com")
|
|
60
|
+
ScaniiTarget.AP1 = ScaniiTarget("https://api-ap1.scanii.com")
|
|
61
|
+
ScaniiTarget.AP2 = ScaniiTarget("https://api-ap2.scanii.com")
|
|
62
|
+
ScaniiTarget.CA1 = ScaniiTarget("https://api-ca1.scanii.com")
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: scanii-python
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Zero-dependency Python SDK for the Scanii content security API
|
|
5
|
+
Project-URL: Homepage, https://scanii.com
|
|
6
|
+
Project-URL: Repository, https://github.com/scanii/scanii-python
|
|
7
|
+
Project-URL: Documentation, https://scanii.github.io/openapi/v22/
|
|
8
|
+
Author: Scanii
|
|
9
|
+
License-Expression: Apache-2.0
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Python: >=3.13
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
14
|
+
Requires-Dist: pytest-cov; extra == 'dev'
|
|
15
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
16
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# scanii-python
|
|
20
|
+
|
|
21
|
+
Python SDK for the [Scanii](https://scanii.com) content security API.
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install scanii-python
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Distribution name is `scanii-python`; import as `from scanii import ScaniiClient`.
|
|
30
|
+
|
|
31
|
+
## SDK Principles
|
|
32
|
+
|
|
33
|
+
1. **Light.** Zero runtime dependencies, stdlib only.
|
|
34
|
+
2. **Up to date.** Always current with the latest Scanii API.
|
|
35
|
+
3. **Integration-only.** Wraps the REST API — retries, concurrency, and batching are the caller's responsibility.
|
|
36
|
+
|
|
37
|
+
## Quickstart
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from scanii import ScaniiClient, ScaniiTarget
|
|
41
|
+
|
|
42
|
+
client = ScaniiClient(key="your-key", secret="your-secret", target=ScaniiTarget.US1)
|
|
43
|
+
result = client.process_file("./document.pdf")
|
|
44
|
+
print(result.findings) # [] when clean
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Streaming
|
|
48
|
+
|
|
49
|
+
Pass any IO-like object (anything with `read(n) -> bytes`) to `process()` directly — no temp file needed:
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
import io
|
|
53
|
+
|
|
54
|
+
# Scan bytes already in memory
|
|
55
|
+
result = client.process(io.BytesIO(my_bytes), filename="upload.bin")
|
|
56
|
+
|
|
57
|
+
# Scan content from an open file handle
|
|
58
|
+
with open("./large.pdf", "rb") as f:
|
|
59
|
+
result = client.process(f, filename="large.pdf")
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## API Reference
|
|
63
|
+
|
|
64
|
+
### Constructor
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
ScaniiClient(
|
|
68
|
+
*,
|
|
69
|
+
key: str | None = None,
|
|
70
|
+
secret: str | None = None,
|
|
71
|
+
token: str | None = None,
|
|
72
|
+
target: ScaniiTarget,
|
|
73
|
+
timeout: float = 60.0,
|
|
74
|
+
)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Supply either `key` + `secret` (HTTP Basic Auth) or `token` (auth-token authentication). Mixing both raises `ValueError`. `target` is required — see [Regional Endpoints](#regional-endpoints).
|
|
78
|
+
|
|
79
|
+
### File scanning
|
|
80
|
+
|
|
81
|
+
| Method | Description |
|
|
82
|
+
|---|---|
|
|
83
|
+
| `process(content, filename, content_type=None, metadata=None, callback=None)` | Synchronous scan of an IO-like object |
|
|
84
|
+
| `process_file(path, metadata=None, callback=None)` | Synchronous scan of a file on disk |
|
|
85
|
+
| `process_async(content, filename, content_type=None, metadata=None, callback=None)` | Async-on-server scan of an IO-like object; returns `ScaniiPendingResult` |
|
|
86
|
+
| `process_async_file(path, metadata=None, callback=None)` | Async-on-server scan of a file on disk; returns `ScaniiPendingResult` |
|
|
87
|
+
| `retrieve(id)` | Retrieve a previous scan result |
|
|
88
|
+
| `fetch(url, metadata=None, callback=None)` | Server-side async fetch-and-scan of a remote URL |
|
|
89
|
+
|
|
90
|
+
### v2.2 preview methods
|
|
91
|
+
|
|
92
|
+
| Method | Description |
|
|
93
|
+
|---|---|
|
|
94
|
+
| `retrieve_trace(id)` **(v2.2 preview)** | Retrieve processing event trace; returns `None` on 404 |
|
|
95
|
+
| `process_from_url(location, callback=None, metadata=None)` **(v2.2 preview)** | Synchronous scan of a remote URL via `POST /files` |
|
|
96
|
+
|
|
97
|
+
### Auth tokens
|
|
98
|
+
|
|
99
|
+
| Method | Description |
|
|
100
|
+
|---|---|
|
|
101
|
+
| `create_auth_token(timeout_seconds)` | Mint a short-lived token |
|
|
102
|
+
| `retrieve_auth_token(id)` | Inspect a token |
|
|
103
|
+
| `delete_auth_token(id)` | Revoke a token |
|
|
104
|
+
|
|
105
|
+
### Health check
|
|
106
|
+
|
|
107
|
+
| Method | Description |
|
|
108
|
+
|---|---|
|
|
109
|
+
| `ping()` | Returns `True` when the API is reachable with valid credentials |
|
|
110
|
+
|
|
111
|
+
## Regional Endpoints
|
|
112
|
+
|
|
113
|
+
Choose a regional target explicitly. Six regional constants are available: `US1`, `EU1`, `EU2`, `AP1`, `AP2`, `CA1`. Latency-based routing (`AUTO` in other Scanii SDKs) is intentionally not provided — chain-of-custody and data-residency compliance require an explicit regional choice. For local testing against scanii-cli, construct a target with an arbitrary URL: `ScaniiTarget("http://localhost:4000")`.
|
|
114
|
+
|
|
115
|
+
| Constant | URL | Location |
|
|
116
|
+
|---|---|---|
|
|
117
|
+
| `ScaniiTarget.US1` | `https://api-us1.scanii.com` | Virginia, USA |
|
|
118
|
+
| `ScaniiTarget.EU1` | `https://api-eu1.scanii.com` | Dublin, Ireland |
|
|
119
|
+
| `ScaniiTarget.EU2` | `https://api-eu2.scanii.com` | London, United Kingdom |
|
|
120
|
+
| `ScaniiTarget.AP1` | `https://api-ap1.scanii.com` | Sydney, Australia |
|
|
121
|
+
| `ScaniiTarget.AP2` | `https://api-ap2.scanii.com` | Singapore |
|
|
122
|
+
| `ScaniiTarget.CA1` | `https://api-ca1.scanii.com` | Montreal, Canada |
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
client = ScaniiClient(key="your-key", secret="your-secret", target=ScaniiTarget.EU1)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Error Handling
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
from scanii import ScaniiClient, ScaniiTarget, ScaniiError, ScaniiAuthError, ScaniiRateLimitError
|
|
132
|
+
import time
|
|
133
|
+
|
|
134
|
+
client = ScaniiClient(key="your-key", secret="your-secret", target=ScaniiTarget.US1)
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
result = client.process_file("./document.pdf")
|
|
138
|
+
except ScaniiAuthError:
|
|
139
|
+
print("Invalid credentials")
|
|
140
|
+
except ScaniiRateLimitError as e:
|
|
141
|
+
if e.retry_after:
|
|
142
|
+
time.sleep(e.retry_after)
|
|
143
|
+
except ScaniiError as e:
|
|
144
|
+
print(f"API error {e.status_code}: {e}")
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Network-level failures (`urllib.error.URLError`) propagate unwrapped — retries are the caller's responsibility per SDK Principle 3.
|
|
148
|
+
|
|
149
|
+
## Local Testing with scanii-cli
|
|
150
|
+
|
|
151
|
+
All integration tests run against a locally-hosted scanii-cli mock server — no real credentials needed.
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
docker run -d --name scanii-cli -p 4000:4000 ghcr.io/scanii/scanii-cli:latest server
|
|
155
|
+
|
|
156
|
+
# Run tests
|
|
157
|
+
pip install -e ".[dev]"
|
|
158
|
+
pytest
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
client = ScaniiClient(key="key", secret="secret", target=ScaniiTarget("http://localhost:4000"))
|
|
163
|
+
result = client.ping() # True
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## Contributing
|
|
167
|
+
|
|
168
|
+
Pull requests welcome. Please open an issue first for substantial changes.
|
|
169
|
+
|
|
170
|
+
## License
|
|
171
|
+
|
|
172
|
+
Apache 2.0 — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
scanii/__init__.py,sha256=UAJKqYO1q8UgR-rGysuj52B6i8m7_O0NVYRQVtpsV84,778
|
|
2
|
+
scanii/_multipart.py,sha256=4V_4Hx3se_0xMP3uIKqZXTK8rasZOtPa2CgCYa8XpEg,6864
|
|
3
|
+
scanii/_version.py,sha256=J-j-u0itpEFT6irdmWmixQqYMadNl1X91TxUmoiLHMI,22
|
|
4
|
+
scanii/client.py,sha256=MoLEIJ5TZ_LgRrm3vAvOL6NaGIIc77rFJz141MbZt4c,18891
|
|
5
|
+
scanii/errors.py,sha256=YYyakKG8fZXMo8CKwOf3gUlF-OFk9NpGDBA4tyfH2bM,1415
|
|
6
|
+
scanii/models.py,sha256=-2U-MfBdyRFdawF0701sHyJAj63KpqeDA_fugC6lzfI,6064
|
|
7
|
+
scanii/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
scanii/target.py,sha256=CjnQdgrwmmp9x_GYGwG2XeSrc5IQ1Aw5BoiqAFZFCXs,2066
|
|
9
|
+
scanii_python-1.0.0.dist-info/METADATA,sha256=kh3tm-CLESlJQkzCSYp2yr0niAIPIRDkkh77by_MR7s,5709
|
|
10
|
+
scanii_python-1.0.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
11
|
+
scanii_python-1.0.0.dist-info/licenses/LICENSE,sha256=wPxTrn3rOsJl9dpsbbrr2lLGQbGj5eKShvCgcsto5HQ,10017
|
|
12
|
+
scanii_python-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
6
|
+
|
|
7
|
+
1. Definitions.
|
|
8
|
+
|
|
9
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
10
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
11
|
+
|
|
12
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
13
|
+
the copyright owner that is granting the License.
|
|
14
|
+
|
|
15
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
16
|
+
other entities that control, are controlled by, or are under common
|
|
17
|
+
control with that entity. For the purposes of this definition,
|
|
18
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
19
|
+
direction or management of such entity, whether by contract or
|
|
20
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
21
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
22
|
+
|
|
23
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
24
|
+
exercising permissions granted by this License.
|
|
25
|
+
|
|
26
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
27
|
+
including but not limited to software source code, documentation
|
|
28
|
+
source, and configuration files.
|
|
29
|
+
|
|
30
|
+
"Object" form shall mean any form resulting from mechanical
|
|
31
|
+
transformation or translation of a Source form, including but
|
|
32
|
+
not limited to compiled object code, generated documentation,
|
|
33
|
+
and conversions to other media types.
|
|
34
|
+
|
|
35
|
+
"Work" shall mean the work of authorship made available under
|
|
36
|
+
the License, as indicated by a copyright notice that is included in
|
|
37
|
+
or attached to the work (an example is provided in the Appendix below).
|
|
38
|
+
|
|
39
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
40
|
+
form, that is based on (or derived from) the Work and for which the
|
|
41
|
+
editorial revisions, annotations, elaborations, or other transformations
|
|
42
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
43
|
+
of this document, Derivative Works shall not include works that remain
|
|
44
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
45
|
+
the Work and Derivative Works thereof.
|
|
46
|
+
|
|
47
|
+
"Contribution" shall mean, as submitted to the Licensor for inclusion
|
|
48
|
+
in the Work by the copyright owner or by an individual or Legal Entity
|
|
49
|
+
authorized to submit on behalf of the copyright owner. For the purposes
|
|
50
|
+
of this definition, "submitted" means any form of electronic, verbal,
|
|
51
|
+
or written communication sent to the Licensor or its representatives,
|
|
52
|
+
including but not limited to communication on electronic mailing lists,
|
|
53
|
+
source code control systems, and issue tracking systems that are managed
|
|
54
|
+
by, or on behalf of, the Licensor for the purpose of discussing and
|
|
55
|
+
improving the Work, but excluding communication that is conspicuously
|
|
56
|
+
marked or designated in writing by the copyright owner as "Not a
|
|
57
|
+
Contribution."
|
|
58
|
+
|
|
59
|
+
"Contributor" shall mean Licensor and any Legal Entity on behalf of
|
|
60
|
+
whom a Contribution has been received by the Licensor and included
|
|
61
|
+
within the Work.
|
|
62
|
+
|
|
63
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
64
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
65
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
66
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
67
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
68
|
+
Work and such Derivative Works in Source or Object form.
|
|
69
|
+
|
|
70
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
71
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
72
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
73
|
+
(except as stated in this section) patent license to make, have made,
|
|
74
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
75
|
+
where such license applies only to those Contributions necessary to
|
|
76
|
+
make, have made, use, offer to sell, sell, import, and otherwise
|
|
77
|
+
transfer the Work, where such license applies only to those
|
|
78
|
+
Contributions necessary to make, have made, use, offer to sell, sell,
|
|
79
|
+
import, and otherwise transfer the Work, where such license applies only
|
|
80
|
+
to those Contributions necessary to make, have made, use, offer to sell,
|
|
81
|
+
sell, import, and otherwise transfer the Work.
|
|
82
|
+
|
|
83
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
84
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
85
|
+
modifications, and in Source or Object form, provided that You
|
|
86
|
+
meet the following conditions:
|
|
87
|
+
|
|
88
|
+
(a) You must give any other recipients of the Work or
|
|
89
|
+
Derivative Works a copy of this License; and
|
|
90
|
+
|
|
91
|
+
(b) You must cause any modified files to carry prominent notices
|
|
92
|
+
stating that You changed the files; and
|
|
93
|
+
|
|
94
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
95
|
+
that You distribute, all copyright, patent, trademark, and
|
|
96
|
+
attribution notices from the Source form of the Work,
|
|
97
|
+
excluding those notices that do not pertain to any part of
|
|
98
|
+
the Derivative Works; and
|
|
99
|
+
|
|
100
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
101
|
+
distribution, You must include a readable copy of the
|
|
102
|
+
attribution notices contained within such NOTICE file, in
|
|
103
|
+
at least one of the following places: within a NOTICE text
|
|
104
|
+
file distributed as part of the Derivative Works; within
|
|
105
|
+
the Source form or documentation, if provided along with the
|
|
106
|
+
Derivative Works; or, within a display generated by the
|
|
107
|
+
Derivative Works, if and where such third-party notices normally
|
|
108
|
+
appear. The contents of the NOTICE file are for informational
|
|
109
|
+
purposes only and do not modify the License. You may add Your
|
|
110
|
+
own attribution notices within Derivative Works that You
|
|
111
|
+
distribute, alongside or in addition to the NOTICE text from
|
|
112
|
+
the Work, provided that such additional attribution notices
|
|
113
|
+
cannot be construed as modifying the License.
|
|
114
|
+
|
|
115
|
+
You may add Your own license statement for Your modifications and
|
|
116
|
+
may provide additional grant of rights to use, copy, modify, merge,
|
|
117
|
+
publish, distribute, sublicense, and/or sell copies of the Work,
|
|
118
|
+
and you may offer support, acceptance, or other conditions.
|
|
119
|
+
|
|
120
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
121
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
122
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
123
|
+
this License, without any additional terms or conditions.
|
|
124
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
125
|
+
the terms of any separate license agreement you may have executed
|
|
126
|
+
with Licensor regarding such Contributions.
|
|
127
|
+
|
|
128
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
129
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
130
|
+
except as required for reasonable and customary use in describing the
|
|
131
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
132
|
+
|
|
133
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
134
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
135
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
136
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
137
|
+
implied, including, without limitation, any warranties or conditions
|
|
138
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
139
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
140
|
+
appropriateness of using or reproducing the Work and assume any
|
|
141
|
+
risks associated with Your exercise of permissions under this License.
|
|
142
|
+
|
|
143
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
144
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
145
|
+
unless required by applicable law (such as deliberate and grossly
|
|
146
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
147
|
+
liable to You for damages, including any direct, indirect, special,
|
|
148
|
+
incidental, or exemplary damages of any character arising as a
|
|
149
|
+
result of this License or out of the use or inability to use the
|
|
150
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
151
|
+
work stoppage, computer failure or malfunction, or all other
|
|
152
|
+
commercial damages or losses), even if such Contributor has been
|
|
153
|
+
advised of the possibility of such damages.
|
|
154
|
+
|
|
155
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
156
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
157
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
158
|
+
or other liability obligations and/or rights consistent with this
|
|
159
|
+
License. However, in accepting such obligations, You may offer only
|
|
160
|
+
on Your behalf, on the sole responsibility of each Contributor, and
|
|
161
|
+
only if You agree to indemnify, defend, and hold each Contributor
|
|
162
|
+
harmless for any liability incurred by, or claims asserted against,
|
|
163
|
+
such Contributor by reason of your accepting any warranty or
|
|
164
|
+
additional liability.
|
|
165
|
+
|
|
166
|
+
END OF TERMS AND CONDITIONS
|
|
167
|
+
|
|
168
|
+
Copyright 2024 Scanii
|
|
169
|
+
|
|
170
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
171
|
+
you may not use this file except in compliance with the License.
|
|
172
|
+
You may obtain a copy of the License at
|
|
173
|
+
|
|
174
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
175
|
+
|
|
176
|
+
Unless required by applicable law or agreed to in writing, software
|
|
177
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
178
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
179
|
+
See the License for the specific language governing permissions and
|
|
180
|
+
limitations under the License.
|