From dfd2c92d00432347087cbb97f5c043b2f0957c5c Mon Sep 17 00:00:00 2001 From: Rafael Ferreira Date: Tue, 5 May 2026 10:40:34 -0400 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20scanii-python=20v1.0.0=20=E2=80=94?= =?UTF-8?q?=20full=20rewrite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zero-dependency Python SDK for the Scanii content security API. Ships with stream-first API (process/process_file), v2.2 preview surface (retrieve_trace, process_from_url), Apache 2.0 license, full type hints + py.typed, and CI via setup-cli-action. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/pr.yml | 46 ++++ .github/workflows/release.yml | 31 +++ .gitignore | 15 +- CHANGELOG.md | 42 +++ LICENSE | 382 +++++++++++++------------- README | 27 -- README.md | 147 +++++++++++ pyproject.toml | 39 +++ scanii.py | 191 ------------- src/scanii/__init__.py | 28 ++ src/scanii/_multipart.py | 67 +++++ src/scanii/_version.py | 1 + src/scanii/client.py | 441 +++++++++++++++++++++++++++++++ src/scanii/errors.py | 47 ++++ src/scanii/models.py | 177 +++++++++++++ src/scanii/py.typed | 0 tests/__init__.py | 0 tests/test_client_integration.py | 243 +++++++++++++++++ tests/test_client_unit.py | 437 ++++++++++++++++++++++++++++++ 19 files changed, 1939 insertions(+), 422 deletions(-) create mode 100644 .github/workflows/pr.yml create mode 100644 .github/workflows/release.yml create mode 100644 CHANGELOG.md mode change 100755 => 100644 LICENSE delete mode 100644 README create mode 100644 README.md create mode 100644 pyproject.toml delete mode 100755 scanii.py create mode 100644 src/scanii/__init__.py create mode 100644 src/scanii/_multipart.py create mode 100644 src/scanii/_version.py create mode 100644 src/scanii/client.py create mode 100644 src/scanii/errors.py create mode 100644 src/scanii/models.py create mode 100644 src/scanii/py.typed create mode 100644 tests/__init__.py create mode 100644 tests/test_client_integration.py create mode 100644 tests/test_client_unit.py diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 0000000..778f5b2 --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,46 @@ +name: PR + +on: pull_request + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + verify: + name: Python ${{ matrix.python }} on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python: ["3.13", "3.14"] + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + + - name: Setup scanii-cli + uses: scanii/setup-cli-action@v1 + + - name: Install dependencies + run: pip install -e ".[dev]" + + - name: Run tests + run: pytest + env: + SCANII_TEST_ENDPOINT: http://localhost:4000 + + - name: Type check + run: mypy src + + - name: Lint + run: ruff check + + - name: Build + run: | + pip install build + python -m build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..cb90c1d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,31 @@ +name: Release + +on: + release: + types: [published] + +jobs: + publish: + name: Publish to PyPI + runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write + contents: read + + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.release.tag_name }} + + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Build + run: | + python -m pip install --upgrade pip build + python -m build + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.gitignore b/.gitignore index 4f71e24..62e2203 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,15 @@ -*.pyc +__pycache__/ +*.py[cod] +*.egg-info/ +dist/ +build/ +.venv/ +venv/ +.mypy_cache/ +.ruff_cache/ +.pytest_cache/ +.coverage +htmlcov/ +.DS_Store *.db *.swp -.DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..47572f7 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,42 @@ +# Changelog + +## 1.0.0 — 2026-05-05 + +Initial release. Replaces the unmaintained `scanii.py` skeleton. + +**Reference:** scanii-java v8.1.0 (includes v2.2 API surface). + +### API surface + +- `ScaniiClient(key, secret, endpoint, timeout)` — synchronous client, zero runtime dependencies, stdlib only +- `process(content, filename, content_type, metadata, callback)` — stream-first synchronous scan; `content` is any object with `read(n) -> bytes` +- `process_file(path, metadata, callback)` — path convenience; opens the file and delegates to `process` +- `process_async(content, filename, content_type, metadata, callback)` — stream-first async-on-server submission +- `process_async_file(path, metadata, callback)` — path convenience for async submission +- `retrieve_trace(id)` — **(v2.2 preview)** retrieve processing event trace; returns `None` on 404 +- `process_from_url(location, callback, metadata)` — **(v2.2 preview)** synchronous URL submission via `POST /files` +- `fetch(url, metadata, callback)` — async server-side fetch-and-scan via `POST /files/fetch` +- `retrieve(id)` — retrieve a previous scan result +- `ping()` — health check +- `create_auth_token(timeout_seconds)` / `retrieve_auth_token(id)` / `delete_auth_token(id)` — auth token lifecycle + +### Streaming design + +The SDK uses a stream-first API per the workspace streaming standardization: +`process(content, filename)` accepts any IO-like object (`io.BytesIO`, open file handles, network streams). `process_file(path)` is a thin wrapper for the common disk-file case. There is exactly one HTTP-handling code path; the file convenience is syntactic sugar. + +### Error handling + +- `ScaniiError` — base exception; carries `status_code`, `request_id`, `host_id`, `body` +- `ScaniiAuthError(ScaniiError)` — HTTP 401/403 +- `ScaniiRateLimitError(ScaniiError)` — HTTP 429; carries `retry_after` (seconds) when the server provides it +- Network-level failures (`urllib.error.URLError`) propagate unwrapped per SDK Principle 3 + +### Deprecations (from day one) + +- `ScaniiProcessingResult.error` — ships deprecated from day one. The server never populates this field on successful responses; errors arrive as non-2xx HTTP responses that raise `ScaniiError` subclasses. Constructing the dataclass with a non-`None` `error` value emits a `DeprecationWarning`. Will be removed in a future major version. + +### Notes + +- Supersedes the `scanii==0.0.1` placeholder when PyPI name override clears +- PyPI name override pending (https://github.com/pypi/support/issues/10444); install from source until then: `pip install git+https://github.com/scanii/scanii-python.git@main` diff --git a/LICENSE b/LICENSE old mode 100755 new mode 100644 index ca82b59..fd835ce --- a/LICENSE +++ b/LICENSE @@ -1,202 +1,180 @@ - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - -TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - -1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - -2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - -3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - -4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - -5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - -6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - -7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - -8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - -9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - -END OF TERMS AND CONDITIONS - -APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - -Copyright 2010 Uva Software, LLC - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. \ No newline at end of file + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship made available under + the License, as indicated by a copyright notice that is included in + or attached to the work (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other transformations + represent, as a whole, an original work of authorship. For the purposes + of this document, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean, as submitted to the Licensor for inclusion + in the Work by the copyright owner or by an individual or Legal Entity + authorized to submit on behalf of the copyright owner. For the purposes + of this definition, "submitted" means any form of electronic, verbal, + or written communication sent to the Licensor or its representatives, + including but not limited to communication on electronic mailing lists, + source code control systems, and issue tracking systems that are managed + by, or on behalf of, the Licensor for the purpose of discussing and + improving the Work, but excluding communication that is conspicuously + marked or designated in writing by the copyright owner as "Not a + Contribution." + + "Contributor" shall mean Licensor and any Legal Entity on behalf of + whom a Contribution has been received by the Licensor and included + within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those Contributions necessary to + make, have made, use, offer to sell, sell, import, and otherwise + transfer the Work, where such license applies only to those + Contributions necessary to make, have made, use, offer to sell, sell, + import, and otherwise transfer the Work, where such license applies only + to those Contributions necessary to make, have made, use, offer to sell, + sell, import, and otherwise transfer the Work. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, You must include a readable copy of the + attribution notices contained within such NOTICE file, in + at least one of the following places: within a NOTICE text + file distributed as part of the Derivative Works; within + the Source form or documentation, if provided along with the + Derivative Works; or, within a display generated by the + Derivative Works, if and where such third-party notices normally + appear. The contents of the NOTICE file are for informational + purposes only and do not modify the License. You may add Your + own attribution notices within Derivative Works that You + distribute, alongside or in addition to the NOTICE text from + the Work, provided that such additional attribution notices + cannot be construed as modifying the License. + + You may add Your own license statement for Your modifications and + may provide additional grant of rights to use, copy, modify, merge, + publish, distribute, sublicense, and/or sell copies of the Work, + and you may offer support, acceptance, or other conditions. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or reproducing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or exemplary damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or all other + commercial damages or losses), even if such Contributor has been + advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may offer only + on Your behalf, on the sole responsibility of each Contributor, and + only if You agree to indemnify, defend, and hold each Contributor + harmless for any liability incurred by, or claims asserted against, + such Contributor by reason of your accepting any warranty or + additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2024 Scanii + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README b/README deleted file mode 100644 index c5e319f..0000000 --- a/README +++ /dev/null @@ -1,27 +0,0 @@ -Python interface to Scanii, a web based virus scanning engine (http://scanii.com) - -Using it: - 1. Go to scanii.com and sign up for an API key - 2. Once armed with your API key and secret, you can run by replacing KEY and SECRET with the proper - information - - $ ./scanii.py -c KEY:SECRET . - Scanii python client version 0.2 - Using API key: XXXXX - - Building file listing for target . - Scaniing 3 file(s) - ./LICENSE: CLEAN in 25.17 msec - ./README: CLEAN in 26.15 msec - ./scanii.py: CLEAN in 32.29 msec - - -------- Scan Summary -------- - infected files: 0 - clean files: 3 - errors: 0 - rafael-mb:scanii-python rafael$ - -Import things to note: - 1. This client can also be used as a standalone library in your application - 2. You can store your credentials in a environment variable called SCANII_CRED - 3. The license is MIT/X11 so use it and abuse it :) \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..7bf4250 --- /dev/null +++ b/README.md @@ -0,0 +1,147 @@ +# scanii-python + +Python SDK for the [Scanii](https://scanii.com) content security API. + +## Installation + +```bash +pip install scanii +``` + +> _Until the PyPI name is registered, install from source: `pip install git+https://github.com/scanii/scanii-python.git@main`._ + +## SDK Principles + +1. **Light.** Zero runtime dependencies, stdlib only. +2. **Up to date.** Always current with the latest Scanii API. +3. **Integration-only.** Wraps the REST API — retries, concurrency, and batching are the caller's responsibility. + +## Quickstart + +```python +from scanii import ScaniiClient + +client = ScaniiClient(key="your-key", secret="your-secret") +result = client.process_file("./document.pdf") +print(result.findings) # [] when clean +``` + +## Streaming + +Pass any IO-like object (anything with `read(n) -> bytes`) to `process()` directly — no temp file needed: + +```python +import io + +# Scan bytes already in memory +result = client.process(io.BytesIO(my_bytes), filename="upload.bin") + +# Scan content from an open file handle +with open("./large.pdf", "rb") as f: + result = client.process(f, filename="large.pdf") +``` + +## API Reference + +### Constructor + +```python +ScaniiClient( + key: str | None = None, + secret: str | None = None, + token: str | None = None, + endpoint: str = "https://api.scanii.com", + timeout: float = 60.0, +) +``` + +Supply either `key` + `secret` (HTTP Basic Auth) or `token` (auth-token authentication). Mixing both raises `ValueError`. + +### File scanning + +| Method | Description | +|---|---| +| `process(content, filename, content_type=None, metadata=None, callback=None)` | Synchronous scan of an IO-like object | +| `process_file(path, metadata=None, callback=None)` | Synchronous scan of a file on disk | +| `process_async(content, filename, content_type=None, metadata=None, callback=None)` | Async-on-server scan of an IO-like object; returns `ScaniiPendingResult` | +| `process_async_file(path, metadata=None, callback=None)` | Async-on-server scan of a file on disk; returns `ScaniiPendingResult` | +| `retrieve(id)` | Retrieve a previous scan result | +| `fetch(url, metadata=None, callback=None)` | Server-side async fetch-and-scan of a remote URL | + +### v2.2 preview methods + +| Method | Description | +|---|---| +| `retrieve_trace(id)` **(v2.2 preview)** | Retrieve processing event trace; returns `None` on 404 | +| `process_from_url(location, callback=None, metadata=None)` **(v2.2 preview)** | Synchronous scan of a remote URL via `POST /files` | + +### Auth tokens + +| Method | Description | +|---|---| +| `create_auth_token(timeout_seconds)` | Mint a short-lived token | +| `retrieve_auth_token(id)` | Inspect a token | +| `delete_auth_token(id)` | Revoke a token | + +### Health check + +| Method | Description | +|---|---| +| `ping()` | Returns `True` when the API is reachable with valid credentials | + +## Regional Endpoints + +| Region | Endpoint | +|---|---| +| United States (default) | `https://api.scanii.com` | +| European Union | `https://api-eu.scanii.com` | + +```python +client = ScaniiClient(key="your-key", secret="your-secret", endpoint="https://api-eu.scanii.com") +``` + +## Error Handling + +```python +from scanii import ScaniiClient, ScaniiError, ScaniiAuthError, ScaniiRateLimitError +import time + +client = ScaniiClient(key="your-key", secret="your-secret") + +try: + result = client.process_file("./document.pdf") +except ScaniiAuthError: + print("Invalid credentials") +except ScaniiRateLimitError as e: + if e.retry_after: + time.sleep(e.retry_after) +except ScaniiError as e: + print(f"API error {e.status_code}: {e}") +``` + +Network-level failures (`urllib.error.URLError`) propagate unwrapped — retries are the caller's responsibility per SDK Principle 3. + +## Local Testing with scanii-cli + +All integration tests run against a locally-hosted scanii-cli mock server — no real credentials needed. + +```bash +docker run -d --name scanii-cli -p 4000:4000 ghcr.io/scanii/scanii-cli:latest server + +# Run tests +pip install -e ".[dev]" +pytest +``` + +```python +client = ScaniiClient(key="key", secret="secret", endpoint="http://localhost:4000") +result = client.ping() # True +``` + +## Contributing + +Pull requests welcome. Please open an issue first for substantial changes. + +## License + +Apache 2.0 — see [LICENSE](LICENSE). diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b625665 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,39 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "scanii" +version = "1.0.0" +description = "Zero-dependency Python SDK for the Scanii content security API" +readme = "README.md" +license = "Apache-2.0" +requires-python = ">=3.13" +authors = [{ name = "Scanii" }] +dependencies = [] + +[project.optional-dependencies] +dev = ["pytest>=8", "pytest-cov", "mypy", "ruff"] + +[project.urls] +Homepage = "https://scanii.com" +Repository = "https://github.com/scanii/scanii-python" +Documentation = "https://scanii.github.io/openapi/v22/" + +[tool.hatch.build.targets.wheel] +packages = ["src/scanii"] + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.mypy] +python_version = "3.13" +strict = true +files = ["src"] + +[tool.ruff] +line-length = 100 +target-version = "py313" + +[tool.ruff.lint] +select = ["E", "F", "W", "I"] diff --git a/scanii.py b/scanii.py deleted file mode 100755 index e7c8c3d..0000000 --- a/scanii.py +++ /dev/null @@ -1,191 +0,0 @@ -#!/usr/bin/env python -# encoding: utf-8 -""" -scanii.py - -Created by Rafael Ferreira on 2009-07-19. -Copyright (c) 2009 Uva Software, LLC. For Licensing see LICENSE. - -Note: - * crendetials can be passed or stored in a environment varialbe called SCANII_CRED -""" - -import sys, os.path, logging, optparse -import urllib2 -import time - -try: - import json -except: - import simplesjon as json - - -log = logging.getLogger('scanii') - -__version__ = "0.2" -__license__ ="MIT/X11" - -DESC = "Scanii's python command line client" -API_URL = "https://scanii.com/api/scan/" -ENV_VAR = 'SCANII_CRED' - -class Client(object): - """ - Simple and reusable client for scanii.com - """ - def __init__(self,key, secret,url=API_URL,): - self.url = url - self.key = key - self.secret = secret - self.infected = [] - self.clean= [] - - log.debug('client init with endpoint %s' % url) - - def api_call(self,data): - """http heavy lifting """ - - req = urllib2.Request(self.url, data ) - passman = urllib2.HTTPPasswordMgrWithDefaultRealm() - - passman.add_password(None, self.url, self.key, self.secret) - - authhandler = urllib2.HTTPBasicAuthHandler(passman) - # create the AuthHandler - - opener = urllib2.build_opener(authhandler) - - urllib2.install_opener(opener) - resp = urllib2.urlopen(req) - j = json.loads(resp.read()) - - log.debug('raw json response: %s' % j) - - return j - - def scan(self, filename): - """ converts a file into bytes and scans it using the internal api""" - - file = open(filename, 'r') - try: - return self.api_call(file.read() ) - - - finally: - file.close() - - -def main(): - if len(sys.argv) < 2: - sys.argv.append('-h') - - parser = optparse.OptionParser(usage="scanii.py [options] PATH",description=DESC,version=__version__) - - parser.add_option("-c","--cred", dest="cred", help="your API key and secret in KEY:SECRET format", action="store") - parser.add_option("-v","--verbose", dest="verbose", help="runs in verbose mode", action="store_true", default=False) - parser.add_option("-r","--recursive", dest="recursive", help="descends throught PATH pulling all files", action="store_true", default=False) - parser.add_option("-u","--url", dest="url", help="API address to be used instead of scanii's default", action="store", default=API_URL) - - - (options,args) = parser.parse_args() - - # add ch to logger - - if (options.verbose): - ch = logging.StreamHandler() - # create formatter - formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s") - ch.setFormatter(formatter) - log.setLevel(logging.DEBUG) - log.addHandler(ch) - - target = sys.argv[-1] - log.info('run target %s' % sys.argv[-1]) - log.info('discovering credentials, first from args') - key = None - secret = None - - try: - key,secret = options.cred.split(':') - except: - pass - - if key is None or secret is None: - log.info('trying to pull creds from environment') - try: - key, secret = os.environ[ENV_VAR].split(':') - except: - print('could not load your api credentials, try using -c') - sys.exit(1) - - - log.debug('using key %s secret %s' %(key,secret)) - - files = [] - - print("Scanii python client version %s" % __version__) - print("Using url: %s" % options.url) - print("Using API key: %s" % key) - client = Client( key, secret, url=options.url) - - print('') - print('Building file listing for target %s' % target ) - - if os.path.isfile(target): - files.append(target) - - elif os.path.isdir(target): - if options.recursive is True: - for root, dir, names in os.walk(target): - for name in names: - files.append(os.path.join(root,name) ) - else: - for file in os.listdir(target): - if os.path.isfile(os.path.join(target,file)): - files.append(os.path.join(target,file) ) - - print('Scaniing %s file(s)' % len(files)) - total_infected = 0 - total_clean = 0 - total_error = 0 - - for file in files: - - start = time.time() - log.debug('attempting to scan file %s' % file) - try: - j = client.scan(file) - - except Exception, ex: - print('fatal error [%s] while scanning file %s' % (ex,file) ) - continue - - - elapsed = time.time() - start - - result = 'error' - - if j['status'] == 'infected': - result = 'INFECTED with virus %s ' % j['virus'][0] - total_infected +=1 - - elif j['status'] == 'clean': - result = 'CLEAN' - total_clean +=1 - - elif j['status'] == 'oops': - result = 'ERROR - %s' % j['reason'] - total_error +=1 - - print '%s: %s in %.2f msec' % (file, result, elapsed*1000) - - print('') - print('-------- Scan Summary --------') - print(' infected files: %s' % total_infected) - print(' clean files: %s' % total_clean) - print(' errors: %s' % total_error ) - - -if __name__ == '__main__': - main() - diff --git a/src/scanii/__init__.py b/src/scanii/__init__.py new file mode 100644 index 0000000..282d6f6 --- /dev/null +++ b/src/scanii/__init__.py @@ -0,0 +1,28 @@ +"""Scanii Python SDK — zero-dependency client for the Scanii content security API. + +See https://scanii.github.io/openapi/v22/ for the full API reference. +""" + +from scanii._version import __version__ +from scanii.client import ScaniiClient +from scanii.errors import ScaniiAuthError, ScaniiError, ScaniiRateLimitError +from scanii.models import ( + ScaniiAuthToken, + ScaniiPendingResult, + ScaniiProcessingResult, + ScaniiTraceEvent, + ScaniiTraceResult, +) + +__all__ = [ + "__version__", + "ScaniiClient", + "ScaniiProcessingResult", + "ScaniiPendingResult", + "ScaniiAuthToken", + "ScaniiTraceResult", + "ScaniiTraceEvent", + "ScaniiError", + "ScaniiAuthError", + "ScaniiRateLimitError", +] diff --git a/src/scanii/_multipart.py b/src/scanii/_multipart.py new file mode 100644 index 0000000..586ea97 --- /dev/null +++ b/src/scanii/_multipart.py @@ -0,0 +1,67 @@ +"""Hand-rolled multipart/form-data encoder (RFC 7578). + +Internal module — underscore-prefixed, not part of the public API. +""" + +from __future__ import annotations + +import mimetypes +import secrets +from typing import IO + + +def _make_boundary() -> str: + return f"----scanii-python-boundary-{secrets.token_hex(16)}" + + +def _guess_content_type(filename: str) -> str: + ct, _ = mimetypes.guess_type(filename) + return ct or "application/octet-stream" + + +def encode( + fields: dict[str, str], + file_obj: IO[bytes] | None = None, + filename: str | None = None, + content_type: str | None = None, +) -> tuple[bytes, str]: + """Encode a multipart/form-data body. + + When ``file_obj`` is provided (anything with ``read(n) -> bytes``), reads + from it into a file part. When ``file_obj`` is ``None``, produces a + fields-only multipart body (used by ``process_from_url``'s ``location=`` + case). + + Returns ``(body_bytes, content_type_header)`` where ``content_type_header`` + is the full ``Content-Type: multipart/form-data; boundary=...`` string. + """ + boundary = _make_boundary() + parts: list[bytes] = [] + + for name, value in fields.items(): + parts.append( + f"--{boundary}\r\n" + f'Content-Disposition: form-data; name="{name}"\r\n' + f"Content-Type: text/plain; charset=UTF-8\r\n" + f"\r\n" + f"{value}\r\n".encode("utf-8") + ) + + if file_obj is not None: + if filename is None: + raise ValueError("filename is required when file_obj is provided") + ct = content_type or _guess_content_type(filename) + header = ( + f"--{boundary}\r\n" + f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n' + f"Content-Type: {ct}\r\n" + f"\r\n" + ).encode("utf-8") + file_bytes = file_obj.read() + parts.append(header + file_bytes + b"\r\n") + + parts.append(f"--{boundary}--\r\n".encode("utf-8")) + + body = b"".join(parts) + ct_header = f"multipart/form-data; boundary={boundary}" + return body, ct_header diff --git a/src/scanii/_version.py b/src/scanii/_version.py new file mode 100644 index 0000000..5becc17 --- /dev/null +++ b/src/scanii/_version.py @@ -0,0 +1 @@ +__version__ = "1.0.0" diff --git a/src/scanii/client.py b/src/scanii/client.py new file mode 100644 index 0000000..191d5bd --- /dev/null +++ b/src/scanii/client.py @@ -0,0 +1,441 @@ +"""Scanii SDK client.""" + +from __future__ import annotations + +import base64 +import json +import os +import urllib.error +import urllib.parse +import urllib.request +from typing import IO + +from scanii._multipart import encode as _multipart_encode +from scanii._version import __version__ +from scanii.errors import ScaniiAuthError, ScaniiError, ScaniiRateLimitError +from scanii.models import ( + ScaniiAuthToken, + ScaniiPendingResult, + ScaniiProcessingResult, + ScaniiTraceResult, +) + +_API_VERSION = "/v2.2" + + +class ScaniiClient: + """Synchronous client for the Scanii REST API v2.2. + + Construct with either ``key`` + ``secret`` (HTTP Basic Auth) or ``token`` + (auth-token authentication). Mixing the two raises ``ValueError``. + + Per SDK Principle 3 the client is integration-only: it does not retry, + batch, or paginate. Each public method maps to exactly one HTTP request. + + See https://scanii.github.io/openapi/v22/ + + Example — scan a file from disk:: + + client = ScaniiClient(key="your-key", secret="your-secret") + result = client.process_file("./file.pdf") + print(result.findings) # [] when clean + + Example — scan content already in memory:: + + import io + result = client.process(io.BytesIO(my_bytes), filename="upload.bin") + """ + + def __init__( + self, + key: str | None = None, + secret: str | None = None, + token: str | None = None, + endpoint: str = "https://api.scanii.com", + timeout: float = 60.0, + ) -> None: + self._auth_header = self._build_auth_header(key, secret, token) + self._endpoint = endpoint.rstrip("/") + if not self._endpoint: + raise ValueError("endpoint must not be empty") + parsed = urllib.parse.urlparse(self._endpoint) + if parsed.scheme not in ("http", "https"): + raise ValueError("endpoint must be http or https") + self._base_url = self._endpoint + _API_VERSION + self._timeout = float(timeout) + self._user_agent = f"scanii-python/{__version__}" + + # ------------------------------------------------------------------ + # File scanning — stream-first + # ------------------------------------------------------------------ + + def process( + self, + content: IO[bytes], + filename: str, + content_type: str | None = None, + metadata: dict[str, str] | None = None, + callback: str | None = None, + ) -> ScaniiProcessingResult: + """Submit an IO-like object for synchronous scanning. + + ``content`` is duck-typed: anything with ``read(n) -> bytes``. + Both ``open(path, 'rb')`` and ``io.BytesIO(...)`` work. + + :param content: IO-like object (anything with ``read(n) -> bytes``) + :param filename: filename sent in the multipart part + :param content_type: content-type of the file part; guessed from filename when None + :param metadata: arbitrary key/value pairs attached to the result + :param callback: URL to POST the result to on completion + :see: https://scanii.github.io/openapi/v22/ POST /files + :return: :class:`~scanii.ScaniiProcessingResult` + """ + fields = self._build_fields(metadata, callback) + body, ct = _multipart_encode(fields, file_obj=content, filename=filename, + content_type=content_type) + status, resp_body, headers = self._post("/files", body=body, content_type=ct) + self._raise_for_status(status, resp_body, headers, expected=201) + return ScaniiProcessingResult.from_response(resp_body, headers) + + def process_file( + self, + path: str | os.PathLike[str], + metadata: dict[str, str] | None = None, + callback: str | None = None, + ) -> ScaniiProcessingResult: + """Submit a file path for synchronous scanning. + + Opens the file in binary mode, streams it to Scanii, and closes it. + Delegates to :meth:`process` with ``filename`` set to the basename. + + :param path: path to the file to upload + :param metadata: arbitrary key/value pairs attached to the result + :param callback: URL to POST the result to on completion + :see: https://scanii.github.io/openapi/v22/ POST /files + :return: :class:`~scanii.ScaniiProcessingResult` + """ + path = os.fspath(path) + with open(path, "rb") as f: + return self.process(f, filename=os.path.basename(path), + metadata=metadata, callback=callback) + + def process_async( + self, + content: IO[bytes], + filename: str, + content_type: str | None = None, + metadata: dict[str, str] | None = None, + callback: str | None = None, + ) -> ScaniiPendingResult: + """Submit an IO-like object for server-side asynchronous scanning. + + Returns a pending id; the final result is delivered to ``callback`` + (when supplied) or fetched via :meth:`retrieve`. + + :param content: IO-like object (anything with ``read(n) -> bytes``) + :param filename: filename sent in the multipart part + :param content_type: content-type of the file part; guessed from filename when None + :param metadata: arbitrary key/value pairs attached to the result + :param callback: URL to POST the result to on completion + :see: https://scanii.github.io/openapi/v22/ POST /files/async + :return: :class:`~scanii.ScaniiPendingResult` + """ + fields = self._build_fields(metadata, callback) + body, ct = _multipart_encode(fields, file_obj=content, filename=filename, + content_type=content_type) + status, resp_body, headers = self._post("/files/async", body=body, content_type=ct) + self._raise_for_status(status, resp_body, headers, expected=202) + return ScaniiPendingResult.from_response(resp_body, headers) + + def process_async_file( + self, + path: str | os.PathLike[str], + metadata: dict[str, str] | None = None, + callback: str | None = None, + ) -> ScaniiPendingResult: + """Submit a file path for server-side asynchronous scanning. + + Opens the file in binary mode and delegates to :meth:`process_async`. + + :param path: path to the file to upload + :param metadata: arbitrary key/value pairs attached to the result + :param callback: URL to POST the result to on completion + :see: https://scanii.github.io/openapi/v22/ POST /files/async + :return: :class:`~scanii.ScaniiPendingResult` + """ + path = os.fspath(path) + with open(path, "rb") as f: + return self.process_async(f, filename=os.path.basename(path), + metadata=metadata, callback=callback) + + # ------------------------------------------------------------------ + # v2.2 surface + # ------------------------------------------------------------------ + + def retrieve_trace(self, id: str) -> ScaniiTraceResult | None: + """Retrieve the processing event trace for a previously submitted scan. + + Returns ``None`` when no trace exists for the given id (HTTP 404). + + This is a v2.2 preview surface; the API shape may shift before it is + marked stable. + + :param id: processing id returned by :meth:`process` or :meth:`process_file` + :see: https://scanii.github.io/openapi/v22/ GET /files/{id}/trace + :return: :class:`~scanii.ScaniiTraceResult` or ``None`` + """ + if not id: + raise ValueError("id must not be empty") + status, resp_body, headers = self._request("GET", f"/files/{_urlencode(id)}/trace") + if status == 404: + return None + self._raise_for_status(status, resp_body, headers, expected=200) + return ScaniiTraceResult.from_response(resp_body, headers) + + def process_from_url( + self, + location: str, + callback: str | None = None, + metadata: dict[str, str] | None = None, + ) -> ScaniiProcessingResult: + """Submit a remote URL for synchronous scanning. + + Sends the URL as a ``location`` field in a ``multipart/form-data`` POST + to ``/files``. The Scanii server fetches and scans the URL synchronously + and returns a :class:`~scanii.ScaniiProcessingResult`. This is distinct + from :meth:`fetch`, which submits to ``/files/fetch`` for asynchronous + server-side fetching. + + ``location`` must be a string URL — matches the existing :meth:`fetch` + string-URL convention and the Java reference (``processFromUrl(String)``). + + This is a v2.2 preview surface; the API shape may shift before it is + marked stable. + + :param location: URL of the content to scan + :param callback: URL to POST the result to on completion + :param metadata: arbitrary key/value pairs attached to the result + :see: https://scanii.github.io/openapi/v22/ POST /files + :return: :class:`~scanii.ScaniiProcessingResult` + """ + if not location: + raise ValueError("location must not be empty") + fields = self._build_fields(metadata, callback) + fields["location"] = location + # Fields-only multipart — POST /v2.2/files rejects urlencoded on this endpoint. + body, ct = _multipart_encode(fields) + status, resp_body, headers = self._post("/files", body=body, content_type=ct) + self._raise_for_status(status, resp_body, headers, expected=201) + return ScaniiProcessingResult.from_response(resp_body, headers) + + # ------------------------------------------------------------------ + # Other API methods + # ------------------------------------------------------------------ + + def fetch( + self, + url: str, + metadata: dict[str, str] | None = None, + callback: str | None = None, + ) -> ScaniiPendingResult: + """Ask Scanii to download a remote URL and scan it asynchronously. + + :see: https://scanii.github.io/openapi/v22/ POST /files/fetch + :return: :class:`~scanii.ScaniiPendingResult` + """ + if not url: + raise ValueError("url must not be empty") + form: dict[str, str] = {"location": url} + if callback: + form["callback"] = callback + for k, v in (metadata or {}).items(): + form[f"metadata[{k}]"] = str(v) + body = urllib.parse.urlencode(form).encode("utf-8") + status, resp_body, headers = self._post( + "/files/fetch", body=body, + content_type="application/x-www-form-urlencoded" + ) + self._raise_for_status(status, resp_body, headers, expected=202) + return ScaniiPendingResult.from_response(resp_body, headers) + + def retrieve(self, id: str) -> ScaniiProcessingResult: + """Retrieve a previously submitted scan result by id. + + :see: https://scanii.github.io/openapi/v22/ GET /files/{id} + :return: :class:`~scanii.ScaniiProcessingResult` + """ + if not id: + raise ValueError("id must not be empty") + status, resp_body, headers = self._request("GET", f"/files/{_urlencode(id)}") + self._raise_for_status(status, resp_body, headers, expected=200) + return ScaniiProcessingResult.from_response(resp_body, headers) + + def ping(self) -> bool: + """Verify that the configured credentials reach the API. + + :see: https://scanii.github.io/openapi/v22/ GET /ping + :return: ``True`` when the API responds 200 + :raises ScaniiAuthError: when credentials are rejected + """ + status, resp_body, headers = self._request("GET", "/ping") + if status == 200: + return True + self._raise_for_status(status, resp_body, headers, expected=200) + return False # unreachable; keeps type checker happy + + def create_auth_token(self, timeout_seconds: int) -> ScaniiAuthToken: + """Mint a short-lived auth token. + + ``timeout_seconds`` must be a positive integer. + + :see: https://scanii.github.io/openapi/v22/ POST /auth/tokens + :return: :class:`~scanii.ScaniiAuthToken` + """ + ts = int(timeout_seconds) + if ts <= 0: + raise ValueError("timeout_seconds must be positive") + body = urllib.parse.urlencode({"timeout": ts}).encode("utf-8") + status, resp_body, headers = self._post( + "/auth/tokens", body=body, + content_type="application/x-www-form-urlencoded" + ) + if status not in (200, 201): + self._raise_for_status(status, resp_body, headers, expected=201) + return ScaniiAuthToken.from_response(resp_body, headers) + + def retrieve_auth_token(self, id: str) -> ScaniiAuthToken: + """Inspect a previously created auth token. + + :see: https://scanii.github.io/openapi/v22/ GET /auth/tokens/{id} + :return: :class:`~scanii.ScaniiAuthToken` + """ + if not id: + raise ValueError("id must not be empty") + status, resp_body, headers = self._request("GET", f"/auth/tokens/{_urlencode(id)}") + self._raise_for_status(status, resp_body, headers, expected=200) + return ScaniiAuthToken.from_response(resp_body, headers) + + def delete_auth_token(self, id: str) -> bool: + """Revoke an auth token. + + :see: https://scanii.github.io/openapi/v22/ DELETE /auth/tokens/{id} + :return: ``True`` on success (HTTP 204) + """ + if not id: + raise ValueError("id must not be empty") + status, resp_body, headers = self._request("DELETE", f"/auth/tokens/{_urlencode(id)}") + self._raise_for_status(status, resp_body, headers, expected=204) + return True + + # ------------------------------------------------------------------ + # Private helpers + # ------------------------------------------------------------------ + + def _build_auth_header( + self, key: str | None, secret: str | None, token: str | None + ) -> str: + if token: + if key or secret: + raise ValueError("supply either token or key+secret, not both") + credentials = base64.b64encode(f"{token}:".encode("utf-8")).decode("ascii") + return f"Basic {credentials}" + if not key: + raise ValueError("key must be set (or use token for auth-token mode)") + if ":" in key: + raise ValueError("key must not contain a colon") + if not secret: + raise ValueError("secret must be set when using key auth") + credentials = base64.b64encode(f"{key}:{secret}".encode("utf-8")).decode("ascii") + return f"Basic {credentials}" + + def _build_fields( + self, metadata: dict[str, str] | None, callback: str | None + ) -> dict[str, str]: + fields: dict[str, str] = {} + for k, v in (metadata or {}).items(): + fields[f"metadata[{k}]"] = str(v) + if callback: + fields["callback"] = callback + return fields + + def _post( + self, path: str, body: bytes, content_type: str + ) -> tuple[int, str, dict[str, str]]: + return self._request("POST", path, body=body, content_type=content_type) + + def _request( + self, + method: str, + path: str, + body: bytes | None = None, + content_type: str | None = None, + ) -> tuple[int, str, dict[str, str]]: + url = self._base_url + path + headers: dict[str, str] = { + "Authorization": self._auth_header, + "User-Agent": self._user_agent, + "Accept": "application/json", + } + if content_type: + headers["Content-Type"] = content_type + + req = urllib.request.Request(url, data=body, headers=headers, method=method) + try: + with urllib.request.urlopen(req, timeout=self._timeout) as resp: + resp_body = resp.read().decode("utf-8", errors="replace") + resp_headers = {k.lower(): v for k, v in resp.headers.items()} + return resp.status, resp_body, resp_headers + except urllib.error.HTTPError as e: + resp_body = e.read().decode("utf-8", errors="replace") + resp_headers = {k.lower(): v for k, v in e.headers.items()} + return e.code, resp_body, resp_headers + except urllib.error.URLError: + raise + + def _raise_for_status( + self, + status: int, + body: str, + headers: dict[str, str], + *, + expected: int, + ) -> None: + if status == expected: + return + request_id = headers.get("x-scanii-request-id") + host_id = headers.get("x-scanii-host-id") + message = self._extract_error_message(body) or f"HTTP {status}" + + if status in (401, 403): + raise ScaniiAuthError( + message, status_code=status, request_id=request_id, + host_id=host_id, body=body + ) + if status == 429: + retry_after_raw = headers.get("retry-after") + retry_after = int(retry_after_raw) if retry_after_raw else None + raise ScaniiRateLimitError( + message, status_code=status, request_id=request_id, + host_id=host_id, body=body, retry_after=retry_after + ) + raise ScaniiError( + message, status_code=status, request_id=request_id, + host_id=host_id, body=body + ) + + def _extract_error_message(self, body: str) -> str | None: + if not body: + return None + try: + decoded = json.loads(body) + if isinstance(decoded, dict): + error = decoded.get("error") + if isinstance(error, str): + return error + return body + except (json.JSONDecodeError, ValueError): + return body + + +def _urlencode(value: str) -> str: + return urllib.parse.quote(value, safe="") diff --git a/src/scanii/errors.py b/src/scanii/errors.py new file mode 100644 index 0000000..c95895f --- /dev/null +++ b/src/scanii/errors.py @@ -0,0 +1,47 @@ +"""Scanii SDK exception hierarchy.""" + +from __future__ import annotations + + +class ScaniiError(Exception): + """Base exception for all Scanii API errors. + + Raised on HTTP 4xx/5xx responses. Carries the API-supplied message plus + optional diagnostic headers for support handoffs. + + Per SDK Principle 3 (integration-only) the SDK does not retry on the + caller's behalf — backoff is the caller's responsibility. + + See https://scanii.github.io/openapi/v22/ + """ + + def __init__( + self, + message: str, + *, + status_code: int | None = None, + request_id: str | None = None, + host_id: str | None = None, + body: str | None = None, + ) -> None: + super().__init__(message) + self.status_code = status_code + self.request_id = request_id + self.host_id = host_id + self.body = body + + +class ScaniiAuthError(ScaniiError): + """Raised on HTTP 401 or 403 — credentials rejected by the API.""" + + +class ScaniiRateLimitError(ScaniiError): + """Raised on HTTP 429. + + ``retry_after`` carries the value of the ``Retry-After`` response header in + seconds when the server provided one, otherwise ``None``. + """ + + def __init__(self, message: str, *, retry_after: int | None = None, **kwargs: object) -> None: + super().__init__(message, **kwargs) # type: ignore[arg-type] + self.retry_after = retry_after diff --git a/src/scanii/models.py b/src/scanii/models.py new file mode 100644 index 0000000..b54be02 --- /dev/null +++ b/src/scanii/models.py @@ -0,0 +1,177 @@ +"""Scanii SDK response model dataclasses.""" + +from __future__ import annotations + +import json +import warnings +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ScaniiTraceEvent: + """A single processing event in a scan trace. + + See https://scanii.github.io/openapi/v22/ + """ + + timestamp: str + message: str + + @classmethod + def from_dict(cls, raw: dict[str, object]) -> "ScaniiTraceEvent": + return cls( + timestamp=str(raw.get("timestamp") or ""), + message=str(raw.get("message") or ""), + ) + + +@dataclass(frozen=True) +class ScaniiTraceResult: + """Result of :meth:`~scanii.ScaniiClient.retrieve_trace`. + + This is a v2.2 preview surface; the API shape may shift before it is + marked stable. + + See https://scanii.github.io/openapi/v22/ + """ + + resource_id: str + events: tuple[ScaniiTraceEvent, ...] + request_id: str | None + host_id: str | None + + @classmethod + def from_response(cls, body: str, headers: dict[str, str]) -> "ScaniiTraceResult": + raw = json.loads(body) if body else {} + return cls( + resource_id=str(raw.get("id") or ""), + events=tuple(ScaniiTraceEvent.from_dict(e) for e in raw.get("events") or []), + request_id=headers.get("x-scanii-request-id"), + host_id=headers.get("x-scanii-host-id"), + ) + + +@dataclass(frozen=True) +class ScaniiProcessingResult: + """Result of a synchronous scan returned by :meth:`~scanii.ScaniiClient.process`, + :meth:`~scanii.ScaniiClient.process_file`, :meth:`~scanii.ScaniiClient.process_from_url`, + and :meth:`~scanii.ScaniiClient.retrieve`. + + ``findings`` is always a tuple. An empty tuple means the content is clean. + + See https://scanii.github.io/openapi/v22/ + """ + + id: str + findings: tuple[str, ...] + checksum: str | None + content_length: int | None + content_type: str | None + metadata: dict[str, str] + creation_date: str | None + request_id: str | None + host_id: str | None + resource_location: str | None + error: str | None = None + """ + .. deprecated:: 1.0.0 + Server-side errors arrive as :class:`~scanii.ScaniiError` subclasses on + non-2xx responses; this field is never populated on success. + Will be removed in a future major version. + """ + + def __post_init__(self) -> None: + if self.error is not None: + warnings.warn( + "ScaniiProcessingResult.error is deprecated and will be removed in a " + "future major version. Server-side errors arrive as ScaniiError " + "subclasses on non-2xx responses; this field is never populated on success.", + DeprecationWarning, + stacklevel=2, + ) + + @classmethod + def from_response(cls, body: str, headers: dict[str, str]) -> "ScaniiProcessingResult": + raw = json.loads(body) if body else {} + return cls( + id=str(raw.get("id") or ""), + findings=tuple(str(f) for f in (raw.get("findings") or [])), + checksum=str(raw["checksum"]) if raw.get("checksum") is not None else None, + content_length=( + int(raw["content_length"]) if raw.get("content_length") is not None else None + ), + content_type=( + str(raw["content_type"]) if raw.get("content_type") is not None else None + ), + metadata={str(k): str(v) for k, v in (raw.get("metadata") or {}).items()}, + creation_date=( + str(raw["creation_date"]) if raw.get("creation_date") is not None else None + ), + request_id=headers.get("x-scanii-request-id"), + host_id=headers.get("x-scanii-host-id"), + resource_location=headers.get("location"), + error=str(raw["error"]) if raw.get("error") is not None else None, + ) + + +@dataclass(frozen=True) +class ScaniiPendingResult: + """Result of an asynchronous scan submission returned by + :meth:`~scanii.ScaniiClient.process_async`, :meth:`~scanii.ScaniiClient.process_async_file`, + and :meth:`~scanii.ScaniiClient.fetch`. + + The actual scan result is fetched later via :meth:`~scanii.ScaniiClient.retrieve` + or delivered to the supplied callback URL. + + See https://scanii.github.io/openapi/v22/ + """ + + id: str + request_id: str | None + host_id: str | None + resource_location: str | None + + @classmethod + def from_response(cls, body: str, headers: dict[str, str]) -> "ScaniiPendingResult": + raw = json.loads(body) if body else {} + return cls( + id=str(raw.get("id") or ""), + request_id=headers.get("x-scanii-request-id"), + host_id=headers.get("x-scanii-host-id"), + resource_location=headers.get("location"), + ) + + +@dataclass(frozen=True) +class ScaniiAuthToken: + """Short-lived auth token returned by :meth:`~scanii.ScaniiClient.create_auth_token` + and :meth:`~scanii.ScaniiClient.retrieve_auth_token`. + + Pass ``id`` as the ``token`` argument when constructing a :class:`~scanii.ScaniiClient` + to authenticate using the token instead of API key + secret. + + See https://scanii.github.io/openapi/v22/ + """ + + id: str + creation_date: str | None + expiration_date: str | None + request_id: str | None + host_id: str | None + resource_location: str | None + + @classmethod + def from_response(cls, body: str, headers: dict[str, str]) -> "ScaniiAuthToken": + raw = json.loads(body) if body else {} + return cls( + id=str(raw.get("id") or ""), + creation_date=( + str(raw["creation_date"]) if raw.get("creation_date") is not None else None + ), + expiration_date=( + str(raw["expiration_date"]) if raw.get("expiration_date") is not None else None + ), + request_id=headers.get("x-scanii-request-id"), + host_id=headers.get("x-scanii-host-id"), + resource_location=headers.get("location"), + ) diff --git a/src/scanii/py.typed b/src/scanii/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_client_integration.py b/tests/test_client_integration.py new file mode 100644 index 0000000..db2b805 --- /dev/null +++ b/tests/test_client_integration.py @@ -0,0 +1,243 @@ +"""Integration tests for ScaniiClient — requires scanii-cli on http://localhost:4000. + +Run scanii-cli locally: + docker run -d --name scanii-cli -p 4000:4000 ghcr.io/scanii/scanii-cli:latest server + +In CI the action scanii/setup-cli-action@v1 handles startup. Tests self-skip +with a message when scanii-cli is not reachable. +""" + +from __future__ import annotations + +import io +import os +import tempfile +from pathlib import Path + +import pytest + +from scanii import ScaniiClient +from scanii.errors import ScaniiAuthError, ScaniiError + +KEY = "key" +SECRET = "secret" +ENDPOINT = os.environ.get("SCANII_TEST_ENDPOINT", "http://localhost:4000") + +LOCAL_MALWARE_UUID = "38DCC0C9-7FB6-4D0D-9C37-288A380C6BB9" +LOCAL_MALWARE_FINDING = "content.malicious.local-test-file" + +_cli_available: bool | None = None + + +def _is_cli_reachable() -> bool: + global _cli_available + if _cli_available is not None: + return _cli_available + try: + c = ScaniiClient(key=KEY, secret=SECRET, endpoint=ENDPOINT, timeout=2.0) + c.ping() + _cli_available = True + except Exception: + _cli_available = False + return _cli_available + + +@pytest.fixture(autouse=True) +def require_cli(): + if not _is_cli_reachable(): + pytest.skip(f"scanii-cli not reachable at {ENDPOINT}") + + +@pytest.fixture +def client() -> ScaniiClient: + return ScaniiClient(key=KEY, secret=SECRET, endpoint=ENDPOINT) + + +def make_malware_fixture() -> Path: + path = Path(tempfile.gettempdir()) / "scanii-test-malware.bin" + path.write_text(LOCAL_MALWARE_UUID, encoding="utf-8") + return path + + +def make_clean_file() -> Path: + path = Path(tempfile.gettempdir()) / "scanii-test-clean.txt" + path.write_text("hello world, nothing to see here", encoding="utf-8") + return path + + +# --------------------------------------------------------------------------- +# ping +# --------------------------------------------------------------------------- + +class TestPing: + def test_ping_with_valid_credentials(self, client): + assert client.ping() is True + + def test_ping_with_bad_credentials_raises_auth_error(self): + bad = ScaniiClient(key="bad", secret="creds", endpoint=ENDPOINT) + with pytest.raises(ScaniiAuthError): + bad.ping() + + +# --------------------------------------------------------------------------- +# process_file (path convenience) +# --------------------------------------------------------------------------- + +class TestProcessFile: + def test_clean_file_returns_no_findings(self, client): + path = make_clean_file() + result = client.process_file(path, metadata={"source": "integration"}) + assert result.id + assert result.findings == () + + def test_malware_uuid_fixture_returns_finding(self, client): + path = make_malware_fixture() + result = client.process_file(path) + if LOCAL_MALWARE_FINDING not in result.findings: + pytest.skip( + f"scanii-cli did not flag the UUID fixture (older build); " + f"got: {result.findings!r}" + ) + assert LOCAL_MALWARE_FINDING in result.findings + + def test_retrieve_after_process_returns_same_id(self, client): + path = make_clean_file() + result = client.process_file(path) + retrieved = client.retrieve(result.id) + assert retrieved.id == result.id + + +# --------------------------------------------------------------------------- +# process (stream-based — IO-like object) +# --------------------------------------------------------------------------- + +class TestProcess: + def test_bytesio_clean_returns_no_findings(self, client): + content = io.BytesIO(b"nothing suspicious here") + result = client.process(content, filename="clean.txt") + assert result.id + assert result.findings == () + + def test_bytesio_malware_uuid_returns_finding(self, client): + content = io.BytesIO(LOCAL_MALWARE_UUID.encode("utf-8")) + result = client.process(content, filename="malware-test.bin") + if LOCAL_MALWARE_FINDING not in result.findings: + pytest.skip( + f"scanii-cli did not flag the UUID fixture (older build); " + f"got: {result.findings!r}" + ) + assert LOCAL_MALWARE_FINDING in result.findings + + def test_file_io_clean_returns_no_findings(self, client): + path = make_clean_file() + with open(path, "rb") as f: + result = client.process(f, filename=path.name) + assert result.findings == () + + def test_streaming_parity_with_process_file(self, client): + path = make_malware_fixture() + result_file = client.process_file(path) + with open(path, "rb") as f: + result_stream = client.process(f, filename=path.name) + assert set(result_file.findings) == set(result_stream.findings) + + +# --------------------------------------------------------------------------- +# process_async / process_async_file +# --------------------------------------------------------------------------- + +class TestProcessAsync: + def test_process_async_file_returns_pending(self, client): + from scanii.models import ScaniiPendingResult + path = make_clean_file() + result = client.process_async_file(path) + assert isinstance(result, ScaniiPendingResult) + assert result.id + + def test_process_async_stream_returns_pending(self, client): + from scanii.models import ScaniiPendingResult + content = io.BytesIO(b"async content") + result = client.process_async(content, filename="async.bin") + assert isinstance(result, ScaniiPendingResult) + assert result.id + + +# --------------------------------------------------------------------------- +# v2.2 surface — retrieve_trace (hard-assert, no self-skip) +# --------------------------------------------------------------------------- + +class TestRetrieveTrace: + def test_retrieve_trace_known_id_returns_events(self, client): + path = make_clean_file() + process_result = client.process_file(path) + trace = client.retrieve_trace(process_result.id) + assert trace is not None + assert isinstance(trace.events, tuple) + assert len(trace.events) > 0 + + def test_retrieve_trace_unknown_id_returns_none(self, client): + result = client.retrieve_trace("00000000-0000-0000-0000-000000000000") + assert result is None + + +# --------------------------------------------------------------------------- +# v2.2 surface — process_from_url (hard-assert, no self-skip) +# --------------------------------------------------------------------------- + +class TestProcessFromUrl: + def test_process_from_url_eicar_returns_finding(self, client): + # EICAR via URL is safe — the file never lands on the test runner's disk; + # cli fetches it server-side. The §5 quarantine warning does NOT apply here. + url = f"{ENDPOINT}/static/eicar.txt" + result = client.process_from_url(url) + assert result is not None + assert result.id + assert "content.malicious.eicar-test-signature" in result.findings + + +# --------------------------------------------------------------------------- +# Auth token lifecycle +# --------------------------------------------------------------------------- + +class TestAuthTokenLifecycle: + def test_full_token_lifecycle(self, client): + from scanii.models import ScaniiAuthToken + + token = client.create_auth_token(300) + assert isinstance(token, ScaniiAuthToken) + assert token.id + + retrieved = client.retrieve_auth_token(token.id) + assert retrieved.id == token.id + + deleted = client.delete_auth_token(token.id) + assert deleted is True + + def test_token_auth_ping(self, client): + # Create token and use it for a client — may self-skip on older cli + # if token-auth ping is not supported. + token = client.create_auth_token(60) + token_client = ScaniiClient(token=token.id, endpoint=ENDPOINT, timeout=2.0) + try: + result = token_client.ping() + assert result is True + except ScaniiError as e: + if e.status_code in (401, 403): + pytest.skip("token-auth ping not supported on this scanii-cli build") + raise + + +# --------------------------------------------------------------------------- +# Error cases +# --------------------------------------------------------------------------- + +class TestErrors: + def test_bad_credentials_raises_auth_error(self): + bad = ScaniiClient(key="bad", secret="creds", endpoint=ENDPOINT) + path = make_clean_file() + with pytest.raises(ScaniiAuthError): + bad.process_file(path) + + def test_unknown_resource_id_raises_error(self, client): + with pytest.raises(ScaniiError): + client.retrieve("00000000-0000-0000-0000-000000000000") diff --git a/tests/test_client_unit.py b/tests/test_client_unit.py new file mode 100644 index 0000000..40dcda1 --- /dev/null +++ b/tests/test_client_unit.py @@ -0,0 +1,437 @@ +"""Unit tests for ScaniiClient — no network required.""" + +from __future__ import annotations + +import io +import json +from http.client import HTTPMessage +from unittest.mock import MagicMock, patch + +import pytest + +from scanii import ScaniiClient # noqa: E402 +from scanii.errors import ScaniiAuthError, ScaniiError, ScaniiRateLimitError +from scanii.models import ( + ScaniiAuthToken, + ScaniiPendingResult, + ScaniiProcessingResult, + ScaniiTraceResult, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +ENDPOINT = "http://localhost:4000" +KEY = "key" +SECRET = "secret" + + +def _make_client(**kwargs) -> ScaniiClient: + return ScaniiClient(key=KEY, secret=SECRET, endpoint=ENDPOINT, **kwargs) + + +def _mock_response(status: int, body: str, headers: dict | None = None) -> MagicMock: + """Return a mock that mimics urllib.request.urlopen()'s response object.""" + msg = HTTPMessage() + for k, v in (headers or {}).items(): + msg[k] = v + + resp = MagicMock() + resp.status = status + resp.headers = msg + resp.read.return_value = body.encode("utf-8") + resp.__enter__ = lambda s: s + resp.__exit__ = MagicMock(return_value=False) + return resp + + +def _processing_body(id: str = "test-id", findings: list | None = None) -> str: + return json.dumps({ + "id": id, + "findings": findings or [], + "checksum": "abc123", + "content_length": 42, + "content_type": "text/plain", + "metadata": {}, + "creation_date": "2026-01-01T00:00:00Z", + }) + + +def _pending_body(id: str = "pending-id") -> str: + return json.dumps({"id": id}) + + +# --------------------------------------------------------------------------- +# Constructor validation +# --------------------------------------------------------------------------- + +class TestConstructor: + def test_key_and_secret_accepted(self): + c = _make_client() + assert c._user_agent == "scanii-python/1.0.0" + + def test_token_accepted(self): + c = ScaniiClient(token="mytoken", endpoint=ENDPOINT) + assert "Basic" in c._auth_header + + def test_both_key_and_token_raises(self): + with pytest.raises(ValueError, match="not both"): + ScaniiClient(key="k", secret="s", token="t", endpoint=ENDPOINT) + + def test_missing_key_raises(self): + with pytest.raises(ValueError): + ScaniiClient(endpoint=ENDPOINT) + + def test_missing_secret_raises(self): + with pytest.raises(ValueError): + ScaniiClient(key="k", endpoint=ENDPOINT) + + def test_key_with_colon_raises(self): + with pytest.raises(ValueError, match="colon"): + ScaniiClient(key="bad:key", secret="s", endpoint=ENDPOINT) + + def test_empty_endpoint_raises(self): + with pytest.raises(ValueError): + ScaniiClient(key=KEY, secret=SECRET, endpoint="") + + def test_non_http_endpoint_raises(self): + with pytest.raises(ValueError, match="http"): + ScaniiClient(key=KEY, secret=SECRET, endpoint="ftp://example.com") + + def test_trailing_slash_stripped(self): + c = ScaniiClient(key=KEY, secret=SECRET, endpoint="http://localhost:4000///") + assert c._endpoint == "http://localhost:4000" + + def test_user_agent_reads_from_version_module(self): + from scanii._version import __version__ + c = _make_client() + assert c._user_agent == f"scanii-python/{__version__}" + + +# --------------------------------------------------------------------------- +# process +# --------------------------------------------------------------------------- + +class TestProcess: + def test_process_sends_multipart(self): + body = _processing_body(findings=["content.malicious.local-test-file"]) + mock_resp = _mock_response(201, body, {"x-scanii-request-id": "req-1"}) + with patch("urllib.request.urlopen", return_value=mock_resp) as m: + c = _make_client() + result = c.process(io.BytesIO(b"data"), filename="test.bin") + + assert isinstance(result, ScaniiProcessingResult) + assert result.findings == ("content.malicious.local-test-file",) + call_args = m.call_args[0][0] + assert b"multipart/form-data" in call_args.get_header("Content-type").encode() + + def test_process_returns_findings(self): + body = _processing_body(findings=["f1", "f2"]) + mock_resp = _mock_response(201, body) + with patch("urllib.request.urlopen", return_value=mock_resp): + c = _make_client() + result = c.process(io.BytesIO(b"x"), filename="x.bin") + assert result.findings == ("f1", "f2") + + def test_process_raises_auth_error_on_401(self): + import urllib.error + from http.client import HTTPMessage + + err = urllib.error.HTTPError( + url="http://localhost", code=401, msg="Unauthorized", + hdrs=HTTPMessage(), fp=io.BytesIO(b'{"error":"bad credentials"}') + ) + with patch("urllib.request.urlopen", side_effect=err): + with pytest.raises(ScaniiAuthError): + _make_client().process(io.BytesIO(b"x"), filename="x.bin") + + def test_process_raises_rate_limit_error_on_429(self): + import urllib.error + from http.client import HTTPMessage + + msg = HTTPMessage() + msg["retry-after"] = "30" + err = urllib.error.HTTPError( + url="http://localhost", code=429, msg="Too Many Requests", + hdrs=msg, fp=io.BytesIO(b'{"error":"slow down"}') + ) + with patch("urllib.request.urlopen", side_effect=err): + with pytest.raises(ScaniiRateLimitError) as exc_info: + _make_client().process(io.BytesIO(b"x"), filename="x.bin") + assert exc_info.value.retry_after == 30 + + def test_process_raises_scanii_error_on_500(self): + import urllib.error + from http.client import HTTPMessage + + err = urllib.error.HTTPError( + url="http://localhost", code=500, msg="Server Error", + hdrs=HTTPMessage(), fp=io.BytesIO(b'{"error":"oops"}') + ) + with patch("urllib.request.urlopen", side_effect=err): + with pytest.raises(ScaniiError): + _make_client().process(io.BytesIO(b"x"), filename="x.bin") + + def test_process_network_error_propagates(self): + import urllib.error + with patch("urllib.request.urlopen", side_effect=urllib.error.URLError("refused")): + with pytest.raises(urllib.error.URLError): + _make_client().process(io.BytesIO(b"x"), filename="x.bin") + + +# --------------------------------------------------------------------------- +# process_file delegates to process +# --------------------------------------------------------------------------- + +class TestProcessFile: + def test_process_file_opens_file(self, tmp_path): + p = tmp_path / "test.bin" + p.write_bytes(b"hello") + body = _processing_body() + mock_resp = _mock_response(201, body) + with patch("urllib.request.urlopen", return_value=mock_resp) as m: + c = _make_client() + result = c.process_file(p) + assert isinstance(result, ScaniiProcessingResult) + call_args = m.call_args[0][0] + # filename derived from basename + assert b"test.bin" in call_args.data + + +# --------------------------------------------------------------------------- +# process_async / process_async_file +# --------------------------------------------------------------------------- + +class TestProcessAsync: + def test_process_async_returns_pending(self): + body = _pending_body() + mock_resp = _mock_response(202, body) + with patch("urllib.request.urlopen", return_value=mock_resp): + c = _make_client() + result = c.process_async(io.BytesIO(b"x"), filename="x.bin") + assert isinstance(result, ScaniiPendingResult) + assert result.id == "pending-id" + + def test_process_async_file_opens_file(self, tmp_path): + p = tmp_path / "async.bin" + p.write_bytes(b"data") + body = _pending_body() + mock_resp = _mock_response(202, body) + with patch("urllib.request.urlopen", return_value=mock_resp): + result = _make_client().process_async_file(p) + assert isinstance(result, ScaniiPendingResult) + + +# --------------------------------------------------------------------------- +# retrieve +# --------------------------------------------------------------------------- + +class TestRetrieve: + def test_retrieve_returns_result(self): + body = _processing_body(id="abc") + mock_resp = _mock_response(200, body) + with patch("urllib.request.urlopen", return_value=mock_resp): + result = _make_client().retrieve("abc") + assert result.id == "abc" + + def test_retrieve_empty_id_raises(self): + with pytest.raises(ValueError): + _make_client().retrieve("") + + +# --------------------------------------------------------------------------- +# retrieve_trace +# --------------------------------------------------------------------------- + +class TestRetrieveTrace: + def test_retrieve_trace_returns_result(self): + body = json.dumps({ + "id": "trace-id", + "events": [{"timestamp": "2026-01-01T00:00:00Z", "message": "started"}], + }) + mock_resp = _mock_response(200, body) + with patch("urllib.request.urlopen", return_value=mock_resp): + result = _make_client().retrieve_trace("trace-id") + assert isinstance(result, ScaniiTraceResult) + assert result.resource_id == "trace-id" + assert len(result.events) == 1 + assert result.events[0].message == "started" + + def test_retrieve_trace_returns_none_on_404(self): + import urllib.error + from http.client import HTTPMessage + + err = urllib.error.HTTPError( + url="http://localhost", code=404, msg="Not Found", + hdrs=HTTPMessage(), fp=io.BytesIO(b"") + ) + with patch("urllib.request.urlopen", side_effect=err): + result = _make_client().retrieve_trace("unknown") + assert result is None + + def test_retrieve_trace_empty_id_raises(self): + with pytest.raises(ValueError): + _make_client().retrieve_trace("") + + +# --------------------------------------------------------------------------- +# process_from_url +# --------------------------------------------------------------------------- + +class TestProcessFromUrl: + def test_process_from_url_sends_multipart(self): + body = _processing_body() + mock_resp = _mock_response(201, body) + with patch("urllib.request.urlopen", return_value=mock_resp) as m: + c = _make_client() + result = c.process_from_url("https://example.com/file.pdf") + assert isinstance(result, ScaniiProcessingResult) + call_args = m.call_args[0][0] + assert b"multipart/form-data" in call_args.get_header("Content-type").encode() + assert b"location" in call_args.data + + def test_process_from_url_empty_location_raises(self): + with pytest.raises(ValueError, match="location"): + _make_client().process_from_url("") + + +# --------------------------------------------------------------------------- +# fetch +# --------------------------------------------------------------------------- + +class TestFetch: + def test_fetch_returns_pending(self): + body = _pending_body() + mock_resp = _mock_response(202, body) + with patch("urllib.request.urlopen", return_value=mock_resp) as m: + result = _make_client().fetch("https://example.com/file.pdf") + assert isinstance(result, ScaniiPendingResult) + call_args = m.call_args[0][0] + assert b"application/x-www-form-urlencoded" in call_args.get_header("Content-type").encode() + + def test_fetch_empty_url_raises(self): + with pytest.raises(ValueError): + _make_client().fetch("") + + +# --------------------------------------------------------------------------- +# ping +# --------------------------------------------------------------------------- + +class TestPing: + def test_ping_returns_true(self): + mock_resp = _mock_response(200, "") + with patch("urllib.request.urlopen", return_value=mock_resp): + assert _make_client().ping() is True + + def test_ping_raises_auth_error_on_401(self): + import urllib.error + from http.client import HTTPMessage + + err = urllib.error.HTTPError( + url="http://localhost", code=401, msg="Unauthorized", + hdrs=HTTPMessage(), fp=io.BytesIO(b'{"error":"bad credentials"}') + ) + with patch("urllib.request.urlopen", side_effect=err): + with pytest.raises(ScaniiAuthError): + _make_client().ping() + + +# --------------------------------------------------------------------------- +# Auth token +# --------------------------------------------------------------------------- + +class TestAuthTokens: + def _token_body(self) -> str: + return json.dumps({ + "id": "token-abc", + "creation_date": "2026-01-01T00:00:00Z", + "expiration_date": "2026-01-01T01:00:00Z", + }) + + def test_create_auth_token(self): + mock_resp = _mock_response(201, self._token_body()) + with patch("urllib.request.urlopen", return_value=mock_resp): + token = _make_client().create_auth_token(300) + assert isinstance(token, ScaniiAuthToken) + assert token.id == "token-abc" + + def test_create_auth_token_zero_raises(self): + with pytest.raises(ValueError, match="positive"): + _make_client().create_auth_token(0) + + def test_retrieve_auth_token(self): + mock_resp = _mock_response(200, self._token_body()) + with patch("urllib.request.urlopen", return_value=mock_resp): + token = _make_client().retrieve_auth_token("token-abc") + assert token.id == "token-abc" + + def test_delete_auth_token(self): + mock_resp = _mock_response(204, "") + with patch("urllib.request.urlopen", return_value=mock_resp): + assert _make_client().delete_auth_token("token-abc") is True + + def test_delete_auth_token_empty_id_raises(self): + with pytest.raises(ValueError): + _make_client().delete_auth_token("") + + +# --------------------------------------------------------------------------- +# Deprecation warning on ScaniiProcessingResult.error +# --------------------------------------------------------------------------- + +class TestDeprecationWarning: + def test_error_field_emits_deprecation_warning(self): + with pytest.warns(DeprecationWarning, match="deprecated"): + ScaniiProcessingResult( + id="x", + findings=(), + checksum=None, + content_length=None, + content_type=None, + metadata={}, + creation_date=None, + request_id=None, + host_id=None, + resource_location=None, + error="something went wrong", + ) + + def test_no_warning_when_error_is_none(self): + import warnings + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + # Should not raise + ScaniiProcessingResult( + id="x", + findings=(), + checksum=None, + content_length=None, + content_type=None, + metadata={}, + creation_date=None, + request_id=None, + host_id=None, + resource_location=None, + error=None, + ) + + +# --------------------------------------------------------------------------- +# User-Agent header +# --------------------------------------------------------------------------- + +class TestUserAgent: + def test_user_agent_sent(self): + body = _processing_body() + mock_resp = _mock_response(201, body) + with patch("urllib.request.urlopen", return_value=mock_resp) as m: + _make_client().process(io.BytesIO(b"x"), filename="x.bin") + req = m.call_args[0][0] + assert req.get_header("User-agent").startswith("scanii-python/") + + def test_user_agent_includes_version(self): + from scanii._version import __version__ + c = _make_client() + assert c._user_agent == f"scanii-python/{__version__}" From ab4dddc22eea1541d54c37821d5e109ddc6fb9bc Mon Sep 17 00:00:00 2001 From: Rafael Ferreira Date: Tue, 5 May 2026 11:00:44 -0400 Subject: [PATCH 2/4] fix: true socket-level streaming for multipart uploads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactor _multipart.encode to return a streaming _ChainedIO instead of a buffered bytes blob. Mirrors scanii-ruby's ChainedIO / stream_encode pattern. The file body is only read when urllib reads from the chained stream on the wire — _multipart.encode never calls file_obj.read(). Content-Length is computed upfront via fileno/fstat for real files, getvalue() for BytesIO, and seek/tell for other seekable IOs. IOs with none of the above (pipes, generators) buffer through SpooledTemporaryFile(max_size=1 MiB) — small payloads stay in memory, larger ones spill to disk. Resolves Uncertainty #3 from the initial v1.0.0 report. Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 5 +- src/scanii/_multipart.py | 198 +++++++++++++++++++++++++------ src/scanii/client.py | 42 +++++-- tests/test_client_integration.py | 8 ++ tests/test_client_unit.py | 77 +++++++++++- 5 files changed, 281 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47572f7..e969c9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,10 @@ Initial release. Replaces the unmaintained `scanii.py` skeleton. ### Streaming design -The SDK uses a stream-first API per the workspace streaming standardization: +True socket-level streaming for `process` and `process_file` — uploads of any size do not buffer the full body in memory. Matches the cross-SDK streaming standard. + +The multipart encoder builds a chained reader (prologue bytes → caller's IO → epilogue bytes) without reading the file body at construction time. The file content is transferred directly to the socket as urllib reads from the chain. `process_file(path)` streams from disk through the chained reader to the socket without intermediate buffering. IOs without `fileno()` or `seek`/`tell` (pipes, generators) buffer through `SpooledTemporaryFile(max_size=1 MiB)` — small payloads stay in memory, larger ones spill to disk. + `process(content, filename)` accepts any IO-like object (`io.BytesIO`, open file handles, network streams). `process_file(path)` is a thin wrapper for the common disk-file case. There is exactly one HTTP-handling code path; the file convenience is syntactic sugar. ### Error handling diff --git a/src/scanii/_multipart.py b/src/scanii/_multipart.py index 586ea97..4ec77c1 100644 --- a/src/scanii/_multipart.py +++ b/src/scanii/_multipart.py @@ -1,13 +1,24 @@ """Hand-rolled multipart/form-data encoder (RFC 7578). Internal module — underscore-prefixed, not part of the public API. + +The encoder is stream-first: when a file_obj is provided it builds a +:class:`_ChainedIO` that chains prologue bytes + the caller's IO + epilogue +bytes without reading the file body at construction time. The file content is +only read when urllib reads from the chained stream on the wire. + +Mirrors the approach of scanii-ruby's Multipart::ChainedIO and +scanii-rust's prologue/epilogue pattern. """ from __future__ import annotations +import io import mimetypes +import os import secrets -from typing import IO +import tempfile +from typing import IO, cast def _make_boundary() -> str: @@ -19,49 +30,170 @@ def _guess_content_type(filename: str) -> str: return ct or "application/octet-stream" +def _io_remaining_bytes(file_obj: IO[bytes]) -> int | None: + """Return the number of bytes remaining to be read from file_obj, or None. + + Tries three strategies in order: + 1. ``fileno()`` + ``os.fstat`` — most reliable for real files. + 2. ``getvalue()`` — works for in-memory :class:`io.BytesIO` and + :class:`tempfile.SpooledTemporaryFile` when still in RAM. + 3. ``seek``/``tell`` — works for any seekable IO; restores position. + + Returns ``None`` when all three fail (pipes, generators, sockets). + """ + # Strategy 1 — real files with an OS file descriptor + try: + fd = file_obj.fileno() + size = os.fstat(fd).st_size + pos = file_obj.tell() + return size - pos + except (AttributeError, io.UnsupportedOperation, OSError): + pass + + # Strategy 2 — BytesIO / in-memory SpooledTemporaryFile + try: + val: bytes = file_obj.getvalue() # type: ignore[attr-defined] + pos = file_obj.tell() + return len(val) - pos + except AttributeError: + pass + + # Strategy 3 — any seekable IO + try: + pos = file_obj.tell() + file_obj.seek(0, 2) + end = file_obj.tell() + file_obj.seek(pos) + return end - pos + except (AttributeError, OSError, io.UnsupportedOperation): + pass + + return None + + +class _ChainedIO: + """Sequential reader over a list of IO parts. + + Reads from parts[0] until exhausted, then parts[1], etc. — without + reading any part at construction time. Used to chain prologue bytes + + a caller-supplied file IO + epilogue bytes so the file body is only + read when urllib transfers bytes to the socket. + + Mirrors Ruby's ``Scanii::Multipart::ChainedIO``. + """ + + def __init__(self, parts: list[IO[bytes]]) -> None: + self._parts = parts + self._idx = 0 + + def read(self, n: int = -1) -> bytes: + if n < 0: + return self._read_all() + return self._read_n(n) + + def _read_all(self) -> bytes: + chunks: list[bytes] = [] + while self._idx < len(self._parts): + chunk = self._parts[self._idx].read() + if chunk: + chunks.append(chunk) + self._idx += 1 + return b"".join(chunks) + + def _read_n(self, n: int) -> bytes: + if n == 0: + return b"" + chunks: list[bytes] = [] + remaining = n + while remaining > 0 and self._idx < len(self._parts): + chunk = self._parts[self._idx].read(remaining) + if chunk: + chunks.append(chunk) + remaining -= len(chunk) + else: + self._idx += 1 + return b"".join(chunks) + + def encode( fields: dict[str, str], file_obj: IO[bytes] | None = None, filename: str | None = None, content_type: str | None = None, -) -> tuple[bytes, str]: +) -> tuple[IO[bytes], str, int]: """Encode a multipart/form-data body. - When ``file_obj`` is provided (anything with ``read(n) -> bytes``), reads - from it into a file part. When ``file_obj`` is ``None``, produces a - fields-only multipart body (used by ``process_from_url``'s ``location=`` - case). + **File mode** (``file_obj`` provided): returns a :class:`_ChainedIO` that + streams prologue bytes → file body → epilogue bytes without reading the + file at construction time. The caller's IO is only read when urllib reads + from the returned stream. Paths and ``BytesIO`` get true socket-level + streaming; IOs without ``fileno()`` or ``seek``/``tell`` fall back to a + ``SpooledTemporaryFile(max_size=1 MiB)`` — small payloads stay in memory, + larger ones spill to disk. + + **Fields-only mode** (``file_obj=None``): returns an ``io.BytesIO`` + containing a multipart body with no file part. Used by + ``process_from_url``. - Returns ``(body_bytes, content_type_header)`` where ``content_type_header`` - is the full ``Content-Type: multipart/form-data; boundary=...`` string. + Returns ``(body_stream, content_type_header, content_length)``. """ boundary = _make_boundary() - parts: list[bytes] = [] + ct_header = f"multipart/form-data; boundary={boundary}" + # Build text-field parts (same for both modes) + text_bytes: list[bytes] = [] for name, value in fields.items(): - parts.append( - f"--{boundary}\r\n" - f'Content-Disposition: form-data; name="{name}"\r\n' - f"Content-Type: text/plain; charset=UTF-8\r\n" - f"\r\n" - f"{value}\r\n".encode("utf-8") + text_bytes.append( + ( + f"--{boundary}\r\n" + f'Content-Disposition: form-data; name="{name}"\r\n' + f"Content-Type: text/plain; charset=UTF-8\r\n" + f"\r\n" + f"{value}\r\n" + ).encode("utf-8") ) - if file_obj is not None: - if filename is None: - raise ValueError("filename is required when file_obj is provided") - ct = content_type or _guess_content_type(filename) - header = ( - f"--{boundary}\r\n" - f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n' - f"Content-Type: {ct}\r\n" - f"\r\n" - ).encode("utf-8") - file_bytes = file_obj.read() - parts.append(header + file_bytes + b"\r\n") - - parts.append(f"--{boundary}--\r\n".encode("utf-8")) - - body = b"".join(parts) - ct_header = f"multipart/form-data; boundary={boundary}" - return body, ct_header + if file_obj is None: + # Fields-only mode — closing boundary with no file part + text_bytes.append(f"--{boundary}--\r\n".encode("utf-8")) + body = b"".join(text_bytes) + return io.BytesIO(body), ct_header, len(body) + + # File mode — stream prologue + file + epilogue + if filename is None: + raise ValueError("filename is required when file_obj is provided") + + ct = content_type or _guess_content_type(filename) + prologue = b"".join(text_bytes) + ( + f"--{boundary}\r\n" + f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n' + f"Content-Type: {ct}\r\n" + f"\r\n" + ).encode("utf-8") + # Epilogue: CRLF closes the file data part, then the final boundary. + epilogue = b"\r\n" + f"--{boundary}--\r\n".encode("utf-8") + + # Determine file size without reading the body + file_size = _io_remaining_bytes(file_obj) + + if file_size is None: + # Size-indeterminate IO (pipes, sockets, generators) — buffer via + # SpooledTemporaryFile so we can compute length and still stream. + spooled: tempfile.SpooledTemporaryFile[bytes] = tempfile.SpooledTemporaryFile( + max_size=1024 * 1024 + ) + buf = file_obj.read(65536) + while buf: + spooled.write(buf) + buf = file_obj.read(65536) + spooled.seek(0) + file_size = _io_remaining_bytes(cast(IO[bytes], spooled)) + if file_size is None: + spooled.seek(0, 2) + file_size = spooled.tell() + spooled.seek(0) + file_obj = cast(IO[bytes], spooled) + + total_length = len(prologue) + file_size + len(epilogue) + chained = _ChainedIO([io.BytesIO(prologue), file_obj, io.BytesIO(epilogue)]) + return cast(IO[bytes], chained), ct_header, total_length diff --git a/src/scanii/client.py b/src/scanii/client.py index 191d5bd..0155eee 100644 --- a/src/scanii/client.py +++ b/src/scanii/client.py @@ -91,9 +91,12 @@ def process( :return: :class:`~scanii.ScaniiProcessingResult` """ fields = self._build_fields(metadata, callback) - body, ct = _multipart_encode(fields, file_obj=content, filename=filename, - content_type=content_type) - status, resp_body, headers = self._post("/files", body=body, content_type=ct) + body_stream, ct, content_length = _multipart_encode( + fields, file_obj=content, filename=filename, content_type=content_type + ) + status, resp_body, headers = self._post( + "/files", body=body_stream, content_type=ct, content_length=content_length + ) self._raise_for_status(status, resp_body, headers, expected=201) return ScaniiProcessingResult.from_response(resp_body, headers) @@ -141,9 +144,12 @@ def process_async( :return: :class:`~scanii.ScaniiPendingResult` """ fields = self._build_fields(metadata, callback) - body, ct = _multipart_encode(fields, file_obj=content, filename=filename, - content_type=content_type) - status, resp_body, headers = self._post("/files/async", body=body, content_type=ct) + body_stream, ct, content_length = _multipart_encode( + fields, file_obj=content, filename=filename, content_type=content_type + ) + status, resp_body, headers = self._post( + "/files/async", body=body_stream, content_type=ct, content_length=content_length + ) self._raise_for_status(status, resp_body, headers, expected=202) return ScaniiPendingResult.from_response(resp_body, headers) @@ -223,8 +229,10 @@ def process_from_url( fields = self._build_fields(metadata, callback) fields["location"] = location # Fields-only multipart — POST /v2.2/files rejects urlencoded on this endpoint. - body, ct = _multipart_encode(fields) - status, resp_body, headers = self._post("/files", body=body, content_type=ct) + body_stream, ct, content_length = _multipart_encode(fields) + status, resp_body, headers = self._post( + "/files", body=body_stream, content_type=ct, content_length=content_length + ) self._raise_for_status(status, resp_body, headers, expected=201) return ScaniiProcessingResult.from_response(resp_body, headers) @@ -359,16 +367,24 @@ def _build_fields( return fields def _post( - self, path: str, body: bytes, content_type: str + self, + path: str, + body: bytes | IO[bytes], + content_type: str, + content_length: int | None = None, ) -> tuple[int, str, dict[str, str]]: - return self._request("POST", path, body=body, content_type=content_type) + return self._request( + "POST", path, body=body, content_type=content_type, + content_length=content_length + ) def _request( self, method: str, path: str, - body: bytes | None = None, + body: bytes | IO[bytes] | None = None, content_type: str | None = None, + content_length: int | None = None, ) -> tuple[int, str, dict[str, str]]: url = self._base_url + path headers: dict[str, str] = { @@ -378,6 +394,10 @@ def _request( } if content_type: headers["Content-Type"] = content_type + # Set Content-Length explicitly for stream bodies (urllib can't compute it). + # For bytes bodies urllib sets it automatically via len(); we leave that alone. + if content_length is not None: + headers["Content-Length"] = str(content_length) req = urllib.request.Request(url, data=body, headers=headers, method=method) try: diff --git a/tests/test_client_integration.py b/tests/test_client_integration.py index db2b805..4b144d3 100644 --- a/tests/test_client_integration.py +++ b/tests/test_client_integration.py @@ -106,6 +106,14 @@ def test_retrieve_after_process_returns_same_id(self, client): retrieved = client.retrieve(result.id) assert retrieved.id == result.id + def test_process_file_1mib_synthetic_upload(self, client): + """Verify the streaming path handles a 1 MiB upload end-to-end.""" + path = Path(tempfile.gettempdir()) / "scanii-test-1mib.bin" + path.write_bytes(b"A" * (1024 * 1024)) + result = client.process_file(path) + assert result.id + assert result.findings == () + # --------------------------------------------------------------------------- # process (stream-based — IO-like object) diff --git a/tests/test_client_unit.py b/tests/test_client_unit.py index 40dcda1..a3c6c3d 100644 --- a/tests/test_client_unit.py +++ b/tests/test_client_unit.py @@ -194,9 +194,10 @@ def test_process_file_opens_file(self, tmp_path): c = _make_client() result = c.process_file(p) assert isinstance(result, ScaniiProcessingResult) - call_args = m.call_args[0][0] - # filename derived from basename - assert b"test.bin" in call_args.data + req = m.call_args[0][0] + # The prologue is a BytesIO and contains the filename even after the file closes. + # Reading _parts[0] doesn't touch the actual file. + assert b"test.bin" in req.data._parts[0].read() # --------------------------------------------------------------------------- @@ -289,7 +290,7 @@ def test_process_from_url_sends_multipart(self): assert isinstance(result, ScaniiProcessingResult) call_args = m.call_args[0][0] assert b"multipart/form-data" in call_args.get_header("Content-type").encode() - assert b"location" in call_args.data + assert b"location" in call_args.data.read() def test_process_from_url_empty_location_raises(self): with pytest.raises(ValueError, match="location"): @@ -435,3 +436,71 @@ def test_user_agent_includes_version(self): from scanii._version import __version__ c = _make_client() assert c._user_agent == f"scanii-python/{__version__}" + + +# --------------------------------------------------------------------------- +# Streaming encoder — true streaming verification +# --------------------------------------------------------------------------- + +class TestStreamingEncoder: + def test_encoder_does_not_read_file_body_at_construction_time(self, tmp_path): + """The encoder must not read the caller's IO at construction time.""" + from scanii._multipart import encode + + p = tmp_path / "payload.bin" + p.write_bytes(b"x" * 4096) + with open(p, "rb") as f: + stream, ct, length = encode({"key": "val"}, file_obj=f, filename="payload.bin") + # The file body must NOT have been read yet + assert f.tell() == 0, ( + "encoder read the file at construction time; " + "file IO must be deferred until urllib reads from the chained stream" + ) + # Reading now (file still open) must produce the correct total length + body = stream.read() + assert len(body) == length + + def test_encoder_file_content_present_in_stream(self, tmp_path): + from scanii._multipart import encode + + content = b"sentinel-content-abc123" + p = tmp_path / "data.bin" + p.write_bytes(content) + with open(p, "rb") as f: + stream, _ct, _length = encode({}, file_obj=f, filename="data.bin") + body = stream.read() # must read while file is still open + assert content in body + + def test_encoder_spooled_fallback_for_unseekable_io(self): + """IOs without fileno/seek/getvalue buffer via SpooledTemporaryFile.""" + from scanii._multipart import encode + + class _UnseekableIO: + def __init__(self, data: bytes) -> None: + self._data = data + self._pos = 0 + + def read(self, n: int = -1) -> bytes: + if n < 0: + result = self._data[self._pos:] + self._pos = len(self._data) + return result + result = self._data[self._pos:self._pos + n] + self._pos += len(result) + return result + + content = b"fallback-test-payload" + fake_io = _UnseekableIO(content) + stream, ct, length = encode({}, file_obj=fake_io, filename="test.bin") + body = stream.read() + assert content in body + assert len(body) == length, "reported length must match actual body size" + + def test_content_length_header_set_for_stream_body(self): + """Content-Length is included in the request when body is a stream.""" + body_resp = _processing_body() + mock_resp = _mock_response(201, body_resp) + with patch("urllib.request.urlopen", return_value=mock_resp) as m: + _make_client().process(io.BytesIO(b"data"), filename="test.bin") + req = m.call_args[0][0] + assert req.get_header("Content-length") is not None From 64d1e05862fd5c61a207d5509049c2072aa7707c Mon Sep 17 00:00:00 2001 From: Rafael Ferreira Date: Tue, 5 May 2026 11:05:39 -0400 Subject: [PATCH 3/4] chore: rename PyPI distribution from scanii to scanii-python Rafael secured the scanii-python name on PyPI directly with OIDC trusted publisher pre-configured. Distribution name is now scanii-python; import name (src/scanii/) is unchanged. Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 3 +-- README.md | 4 ++-- pyproject.toml | 2 +- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e969c9c..1d8b794 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,5 +41,4 @@ The multipart encoder builds a chained reader (prologue bytes → caller's IO ### Notes -- Supersedes the `scanii==0.0.1` placeholder when PyPI name override clears -- PyPI name override pending (https://github.com/pypi/support/issues/10444); install from source until then: `pip install git+https://github.com/scanii/scanii-python.git@main` +- Published as `scanii-python` on PyPI; import name is `scanii` diff --git a/README.md b/README.md index 7bf4250..149702a 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,10 @@ Python SDK for the [Scanii](https://scanii.com) content security API. ## Installation ```bash -pip install scanii +pip install scanii-python ``` -> _Until the PyPI name is registered, install from source: `pip install git+https://github.com/scanii/scanii-python.git@main`._ +Distribution name is `scanii-python`; import as `from scanii import ScaniiClient`. ## SDK Principles diff --git a/pyproject.toml b/pyproject.toml index b625665..761dbe6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["hatchling"] build-backend = "hatchling.build" [project] -name = "scanii" +name = "scanii-python" version = "1.0.0" description = "Zero-dependency Python SDK for the Scanii content security API" readme = "README.md" From eea6f1de1f6c0596c493fdba9b8818ca3269fb76 Mon Sep 17 00:00:00 2001 From: Rafael Ferreira Date: Tue, 5 May 2026 11:32:29 -0400 Subject: [PATCH 4/4] feat: introduce ScaniiTarget typed regional endpoint class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the endpoint: str constructor parameter with a required target: ScaniiTarget. Six regional constants (US1/EU1/EU2/AP1/AP2/CA1) match the openapi/src/v22.yaml servers block exactly. AUTO is intentionally absent — data-residency compliance requires an explicit regional choice. For local testing: ScaniiTarget("http://localhost:4000"). Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 6 +- README.md | 31 ++++++---- src/scanii/__init__.py | 2 + src/scanii/client.py | 39 ++++++------ src/scanii/target.py | 62 +++++++++++++++++++ tests/test_client_integration.py | 12 ++-- tests/test_client_unit.py | 101 +++++++++++++++++++++++++------ 7 files changed, 197 insertions(+), 56 deletions(-) create mode 100644 src/scanii/target.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d8b794..f531a9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ Initial release. Replaces the unmaintained `scanii.py` skeleton. ### API surface -- `ScaniiClient(key, secret, endpoint, timeout)` — synchronous client, zero runtime dependencies, stdlib only +- `ScaniiClient(*, key, secret, target, timeout)` — synchronous client, zero runtime dependencies, stdlib only; `target` is a required `ScaniiTarget` instance - `process(content, filename, content_type, metadata, callback)` — stream-first synchronous scan; `content` is any object with `read(n) -> bytes` - `process_file(path, metadata, callback)` — path convenience; opens the file and delegates to `process` - `process_async(content, filename, content_type, metadata, callback)` — stream-first async-on-server submission @@ -39,6 +39,10 @@ The multipart encoder builds a chained reader (prologue bytes → caller's IO - `ScaniiProcessingResult.error` — ships deprecated from day one. The server never populates this field on successful responses; errors arrive as non-2xx HTTP responses that raise `ScaniiError` subclasses. Constructing the dataclass with a non-`None` `error` value emits a `DeprecationWarning`. Will be removed in a future major version. +### Regional endpoints + +Explicit `ScaniiTarget` required at construction. Regional endpoints are typed constants on `ScaniiTarget` (`US1`, `EU1`, `EU2`, `AP1`, `AP2`, `CA1`). The constructor takes a `target: ScaniiTarget` parameter with no default — customers must choose explicitly. For testing against scanii-cli or other local mocks, construct `ScaniiTarget("http://localhost:4000")` directly. Latency-based routing (`AUTO` in other Scanii SDKs) is intentionally not provided in Python; chain-of-custody and data-residency compliance require an explicit regional choice. + ### Notes - Published as `scanii-python` on PyPI; import name is `scanii` diff --git a/README.md b/README.md index 149702a..7a830e8 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,9 @@ Distribution name is `scanii-python`; import as `from scanii import ScaniiClient ## Quickstart ```python -from scanii import ScaniiClient +from scanii import ScaniiClient, ScaniiTarget -client = ScaniiClient(key="your-key", secret="your-secret") +client = ScaniiClient(key="your-key", secret="your-secret", target=ScaniiTarget.US1) result = client.process_file("./document.pdf") print(result.findings) # [] when clean ``` @@ -47,15 +47,16 @@ with open("./large.pdf", "rb") as f: ```python ScaniiClient( + *, key: str | None = None, secret: str | None = None, token: str | None = None, - endpoint: str = "https://api.scanii.com", + target: ScaniiTarget, timeout: float = 60.0, ) ``` -Supply either `key` + `secret` (HTTP Basic Auth) or `token` (auth-token authentication). Mixing both raises `ValueError`. +Supply either `key` + `secret` (HTTP Basic Auth) or `token` (auth-token authentication). Mixing both raises `ValueError`. `target` is required — see [Regional Endpoints](#regional-endpoints). ### File scanning @@ -91,22 +92,28 @@ Supply either `key` + `secret` (HTTP Basic Auth) or `token` (auth-token authenti ## Regional Endpoints -| Region | Endpoint | -|---|---| -| United States (default) | `https://api.scanii.com` | -| European Union | `https://api-eu.scanii.com` | +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")`. + +| Constant | URL | Location | +|---|---|---| +| `ScaniiTarget.US1` | `https://api-us1.scanii.com` | Virginia, USA | +| `ScaniiTarget.EU1` | `https://api-eu1.scanii.com` | Dublin, Ireland | +| `ScaniiTarget.EU2` | `https://api-eu2.scanii.com` | London, United Kingdom | +| `ScaniiTarget.AP1` | `https://api-ap1.scanii.com` | Sydney, Australia | +| `ScaniiTarget.AP2` | `https://api-ap2.scanii.com` | Singapore | +| `ScaniiTarget.CA1` | `https://api-ca1.scanii.com` | Montreal, Canada | ```python -client = ScaniiClient(key="your-key", secret="your-secret", endpoint="https://api-eu.scanii.com") +client = ScaniiClient(key="your-key", secret="your-secret", target=ScaniiTarget.EU1) ``` ## Error Handling ```python -from scanii import ScaniiClient, ScaniiError, ScaniiAuthError, ScaniiRateLimitError +from scanii import ScaniiClient, ScaniiTarget, ScaniiError, ScaniiAuthError, ScaniiRateLimitError import time -client = ScaniiClient(key="your-key", secret="your-secret") +client = ScaniiClient(key="your-key", secret="your-secret", target=ScaniiTarget.US1) try: result = client.process_file("./document.pdf") @@ -134,7 +141,7 @@ pytest ``` ```python -client = ScaniiClient(key="key", secret="secret", endpoint="http://localhost:4000") +client = ScaniiClient(key="key", secret="secret", target=ScaniiTarget("http://localhost:4000")) result = client.ping() # True ``` diff --git a/src/scanii/__init__.py b/src/scanii/__init__.py index 282d6f6..d13732f 100644 --- a/src/scanii/__init__.py +++ b/src/scanii/__init__.py @@ -13,10 +13,12 @@ ScaniiTraceEvent, ScaniiTraceResult, ) +from scanii.target import ScaniiTarget __all__ = [ "__version__", "ScaniiClient", + "ScaniiTarget", "ScaniiProcessingResult", "ScaniiPendingResult", "ScaniiAuthToken", diff --git a/src/scanii/client.py b/src/scanii/client.py index 0155eee..957cd41 100644 --- a/src/scanii/client.py +++ b/src/scanii/client.py @@ -19,6 +19,7 @@ ScaniiProcessingResult, ScaniiTraceResult, ) +from scanii.target import ScaniiTarget _API_VERSION = "/v2.2" @@ -29,39 +30,37 @@ class ScaniiClient: Construct with either ``key`` + ``secret`` (HTTP Basic Auth) or ``token`` (auth-token authentication). Mixing the two raises ``ValueError``. + ``target`` is required — choose a regional constant for production or + construct a custom ``ScaniiTarget`` for local testing:: + + client = ScaniiClient(key="your-key", secret="your-secret", target=ScaniiTarget.US1) + Per SDK Principle 3 the client is integration-only: it does not retry, batch, or paginate. Each public method maps to exactly one HTTP request. See https://scanii.github.io/openapi/v22/ - - Example — scan a file from disk:: - - client = ScaniiClient(key="your-key", secret="your-secret") - result = client.process_file("./file.pdf") - print(result.findings) # [] when clean - - Example — scan content already in memory:: - - import io - result = client.process(io.BytesIO(my_bytes), filename="upload.bin") """ def __init__( self, + *, key: str | None = None, secret: str | None = None, token: str | None = None, - endpoint: str = "https://api.scanii.com", + target: ScaniiTarget | None = None, timeout: float = 60.0, ) -> None: + if not isinstance(target, ScaniiTarget): + raise TypeError( + "ScaniiClient requires a ScaniiTarget for data residency compliance. Examples:\n" + " ScaniiClient(key=..., secret=..., target=ScaniiTarget.US1)\n" + " ScaniiClient(key=..., secret=..., target=ScaniiTarget.EU1)\n" + "For local testing against scanii-cli:\n" + ' ScaniiClient(key=..., secret=..., target=ScaniiTarget("http://localhost:4000"))\n' + "Available regional constants: US1, EU1, EU2, AP1, AP2, CA1." + ) self._auth_header = self._build_auth_header(key, secret, token) - self._endpoint = endpoint.rstrip("/") - if not self._endpoint: - raise ValueError("endpoint must not be empty") - parsed = urllib.parse.urlparse(self._endpoint) - if parsed.scheme not in ("http", "https"): - raise ValueError("endpoint must be http or https") - self._base_url = self._endpoint + _API_VERSION + self._target = target self._timeout = float(timeout) self._user_agent = f"scanii-python/{__version__}" @@ -386,7 +385,7 @@ def _request( content_type: str | None = None, content_length: int | None = None, ) -> tuple[int, str, dict[str, str]]: - url = self._base_url + path + url = self._target.resolve(_API_VERSION + path) headers: dict[str, str] = { "Authorization": self._auth_header, "User-Agent": self._user_agent, diff --git a/src/scanii/target.py b/src/scanii/target.py new file mode 100644 index 0000000..596171d --- /dev/null +++ b/src/scanii/target.py @@ -0,0 +1,62 @@ +"""Scanii regional API endpoints.""" + +from __future__ import annotations + +from typing import ClassVar +from urllib.parse import urljoin + + +class ScaniiTarget: + """ + Scanii regional API endpoint. + + Use one of the predefined regional constants (e.g. ``ScaniiTarget.US1``) + for production. The constructor accepts an arbitrary URL for testing + against scanii-cli or other local mocks:: + + target = ScaniiTarget("http://localhost:4000") + + Note: ``ScaniiTarget.AUTO`` (latency-based routing) is intentionally + not provided. Customer data residency / chain-of-custody compliance + requires an explicit regional choice. Other Scanii SDKs that historically + defaulted to AUTO are being updated to deprecate it. + """ + + US1: ClassVar[ScaniiTarget] + EU1: ClassVar[ScaniiTarget] + EU2: ClassVar[ScaniiTarget] + AP1: ClassVar[ScaniiTarget] + AP2: ClassVar[ScaniiTarget] + CA1: ClassVar[ScaniiTarget] + + def __init__(self, url: str) -> None: + if not url: + raise ValueError("ScaniiTarget URL must be a non-empty string") + self._url = url + + @property + def endpoint(self) -> str: + """The base URL this target points at.""" + return self._url + + def resolve(self, path: str) -> str: + """Join the target's base URL with a request path.""" + base = self._url if self._url.endswith("/") else self._url + "/" + return urljoin(base, path) + + def __repr__(self) -> str: + return f"ScaniiTarget({self._url!r})" + + def __eq__(self, other: object) -> bool: + return isinstance(other, ScaniiTarget) and self._url == other._url + + def __hash__(self) -> int: + return hash(self._url) + + +ScaniiTarget.US1 = ScaniiTarget("https://api-us1.scanii.com") +ScaniiTarget.EU1 = ScaniiTarget("https://api-eu1.scanii.com") +ScaniiTarget.EU2 = ScaniiTarget("https://api-eu2.scanii.com") +ScaniiTarget.AP1 = ScaniiTarget("https://api-ap1.scanii.com") +ScaniiTarget.AP2 = ScaniiTarget("https://api-ap2.scanii.com") +ScaniiTarget.CA1 = ScaniiTarget("https://api-ca1.scanii.com") diff --git a/tests/test_client_integration.py b/tests/test_client_integration.py index 4b144d3..2d63074 100644 --- a/tests/test_client_integration.py +++ b/tests/test_client_integration.py @@ -16,7 +16,7 @@ import pytest -from scanii import ScaniiClient +from scanii import ScaniiClient, ScaniiTarget from scanii.errors import ScaniiAuthError, ScaniiError KEY = "key" @@ -34,7 +34,7 @@ def _is_cli_reachable() -> bool: if _cli_available is not None: return _cli_available try: - c = ScaniiClient(key=KEY, secret=SECRET, endpoint=ENDPOINT, timeout=2.0) + c = ScaniiClient(key=KEY, secret=SECRET, target=ScaniiTarget(ENDPOINT), timeout=2.0) c.ping() _cli_available = True except Exception: @@ -50,7 +50,7 @@ def require_cli(): @pytest.fixture def client() -> ScaniiClient: - return ScaniiClient(key=KEY, secret=SECRET, endpoint=ENDPOINT) + return ScaniiClient(key=KEY, secret=SECRET, target=ScaniiTarget(ENDPOINT)) def make_malware_fixture() -> Path: @@ -74,7 +74,7 @@ def test_ping_with_valid_credentials(self, client): assert client.ping() is True def test_ping_with_bad_credentials_raises_auth_error(self): - bad = ScaniiClient(key="bad", secret="creds", endpoint=ENDPOINT) + bad = ScaniiClient(key="bad", secret="creds", target=ScaniiTarget(ENDPOINT)) with pytest.raises(ScaniiAuthError): bad.ping() @@ -225,7 +225,7 @@ def test_token_auth_ping(self, client): # Create token and use it for a client — may self-skip on older cli # if token-auth ping is not supported. token = client.create_auth_token(60) - token_client = ScaniiClient(token=token.id, endpoint=ENDPOINT, timeout=2.0) + token_client = ScaniiClient(token=token.id, target=ScaniiTarget(ENDPOINT), timeout=2.0) try: result = token_client.ping() assert result is True @@ -241,7 +241,7 @@ def test_token_auth_ping(self, client): class TestErrors: def test_bad_credentials_raises_auth_error(self): - bad = ScaniiClient(key="bad", secret="creds", endpoint=ENDPOINT) + bad = ScaniiClient(key="bad", secret="creds", target=ScaniiTarget(ENDPOINT)) path = make_clean_file() with pytest.raises(ScaniiAuthError): bad.process_file(path) diff --git a/tests/test_client_unit.py b/tests/test_client_unit.py index a3c6c3d..e0b5571 100644 --- a/tests/test_client_unit.py +++ b/tests/test_client_unit.py @@ -5,11 +5,12 @@ import io import json from http.client import HTTPMessage +from pathlib import Path from unittest.mock import MagicMock, patch import pytest -from scanii import ScaniiClient # noqa: E402 +from scanii import ScaniiClient, ScaniiTarget from scanii.errors import ScaniiAuthError, ScaniiError, ScaniiRateLimitError from scanii.models import ( ScaniiAuthToken, @@ -25,10 +26,11 @@ ENDPOINT = "http://localhost:4000" KEY = "key" SECRET = "secret" +TARGET = ScaniiTarget(ENDPOINT) def _make_client(**kwargs) -> ScaniiClient: - return ScaniiClient(key=KEY, secret=SECRET, endpoint=ENDPOINT, **kwargs) + return ScaniiClient(key=KEY, secret=SECRET, target=TARGET, **kwargs) def _mock_response(status: int, body: str, headers: dict | None = None) -> MagicMock: @@ -62,6 +64,75 @@ def _pending_body(id: str = "pending-id") -> str: return json.dumps({"id": id}) +# --------------------------------------------------------------------------- +# ScaniiTarget +# --------------------------------------------------------------------------- + +class TestScaniiTarget: + def test_regional_constants_exist(self): + for name in ("US1", "EU1", "EU2", "AP1", "AP2", "CA1"): + assert isinstance(getattr(ScaniiTarget, name), ScaniiTarget) + + def test_regional_constant_urls(self): + assert ScaniiTarget.US1.endpoint == "https://api-us1.scanii.com" + assert ScaniiTarget.EU1.endpoint == "https://api-eu1.scanii.com" + assert ScaniiTarget.EU2.endpoint == "https://api-eu2.scanii.com" + assert ScaniiTarget.AP1.endpoint == "https://api-ap1.scanii.com" + assert ScaniiTarget.AP2.endpoint == "https://api-ap2.scanii.com" + assert ScaniiTarget.CA1.endpoint == "https://api-ca1.scanii.com" + + def test_resolve_absolute_path(self): + t = ScaniiTarget("http://localhost:4000") + assert t.resolve("/v2.2/files") == "http://localhost:4000/v2.2/files" + + def test_resolve_regional_constant(self): + assert ScaniiTarget.US1.resolve("/v2.2/ping") == "https://api-us1.scanii.com/v2.2/ping" + + def test_empty_url_raises(self): + with pytest.raises(ValueError, match="non-empty"): + ScaniiTarget("") + + def test_equality_same_url(self): + a = ScaniiTarget("http://localhost:4000") + b = ScaniiTarget("http://localhost:4000") + assert a == b + + def test_inequality_different_url(self): + assert ScaniiTarget("http://localhost:4000") != ScaniiTarget("http://localhost:5000") + + def test_hash_equal_for_same_url(self): + a = ScaniiTarget("http://localhost:4000") + b = ScaniiTarget("http://localhost:4000") + assert hash(a) == hash(b) + assert {a, b} == {a} # set deduplicates + + def test_repr(self): + assert repr(ScaniiTarget("http://localhost:4000")) == "ScaniiTarget('http://localhost:4000')" + + def test_constants_match_openapi_spec(self): + """Drift detection: constants must match openapi/src/v22.yaml servers block.""" + import re + + spec_path = Path(__file__).parent.parent.parent / "openapi" / "src" / "v22.yaml" + if not spec_path.exists(): + pytest.skip("openapi/src/v22.yaml not present — run from workspace root to enable") + text = spec_path.read_text(encoding="utf-8") + urls = set(re.findall(r"- url: (https://api-\S+\.scanii\.com)", text)) + constants = { + ScaniiTarget.US1.endpoint, + ScaniiTarget.EU1.endpoint, + ScaniiTarget.EU2.endpoint, + ScaniiTarget.AP1.endpoint, + ScaniiTarget.AP2.endpoint, + ScaniiTarget.CA1.endpoint, + } + assert urls == constants, ( + f"ScaniiTarget constants drift from openapi spec.\n" + f" Spec: {sorted(urls)}\n" + f" Constants: {sorted(constants)}" + ) + + # --------------------------------------------------------------------------- # Constructor validation # --------------------------------------------------------------------------- @@ -72,36 +143,32 @@ def test_key_and_secret_accepted(self): assert c._user_agent == "scanii-python/1.0.0" def test_token_accepted(self): - c = ScaniiClient(token="mytoken", endpoint=ENDPOINT) + c = ScaniiClient(token="mytoken", target=TARGET) assert "Basic" in c._auth_header def test_both_key_and_token_raises(self): with pytest.raises(ValueError, match="not both"): - ScaniiClient(key="k", secret="s", token="t", endpoint=ENDPOINT) + ScaniiClient(key="k", secret="s", token="t", target=TARGET) def test_missing_key_raises(self): with pytest.raises(ValueError): - ScaniiClient(endpoint=ENDPOINT) + ScaniiClient(target=TARGET) def test_missing_secret_raises(self): with pytest.raises(ValueError): - ScaniiClient(key="k", endpoint=ENDPOINT) + ScaniiClient(key="k", target=TARGET) def test_key_with_colon_raises(self): with pytest.raises(ValueError, match="colon"): - ScaniiClient(key="bad:key", secret="s", endpoint=ENDPOINT) - - def test_empty_endpoint_raises(self): - with pytest.raises(ValueError): - ScaniiClient(key=KEY, secret=SECRET, endpoint="") + ScaniiClient(key="bad:key", secret="s", target=TARGET) - def test_non_http_endpoint_raises(self): - with pytest.raises(ValueError, match="http"): - ScaniiClient(key=KEY, secret=SECRET, endpoint="ftp://example.com") + def test_missing_target_raises_with_helpful_message(self): + with pytest.raises(TypeError, match="ScaniiTarget"): + ScaniiClient(key="x", secret="y") # type: ignore[call-arg] - def test_trailing_slash_stripped(self): - c = ScaniiClient(key=KEY, secret=SECRET, endpoint="http://localhost:4000///") - assert c._endpoint == "http://localhost:4000" + def test_wrong_target_type_raises_with_helpful_message(self): + with pytest.raises(TypeError, match="ScaniiTarget"): + ScaniiClient(key="x", secret="y", target="http://localhost:4000") # type: ignore[arg-type] def test_user_agent_reads_from_version_module(self): from scanii._version import __version__