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 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.