From 549efe97016441a4f20f31a7ae646f2128c7f886 Mon Sep 17 00:00:00 2001 From: Rafael Ferreira Date: Mon, 31 Aug 2026 13:22:23 -0400 Subject: [PATCH 1/2] feat: add delete and delete_trace for split result/trace deletion (1.1.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Propagates the openapi v2.2 delete endpoints (scanii/openapi#46, superseded by #48) to scanii-python, following the naming locked by scanii-java and the translation pattern shipped in dotnet 7.3.x, go 2.3.0, node 1.5.0, rust 1.4.0 and php 6.4.0. - ScaniiClient.delete(id) -> bool — DELETE /v2.2/files/{id}; removes the processing result only, leaving the trace readable - ScaniiClient.delete_trace(id) -> bool — DELETE /v2.2/files/{id}/trace; removes the trace only, leaving the result readable - bool return mirrors scanii-java and this SDK's existing delete_auth_token - Both raise ScaniiError on 404 (also returned by a repeated delete of the same id) and ScaniiAuthError on 401/403, via the existing _raise_for_status - Version 1.0.1 -> 1.1.0 in pyproject.toml and src/scanii/_version.py (kept in sync per CLAUDE.md §6) Tests: 6 integration tests hard-asserting the split semantics against scanii-cli, plus 9 unit tests covering verb, path, id url-encoding, and the 403/404 error mapping. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 14 ++++++++ README.md | 6 ++++ pyproject.toml | 2 +- src/scanii/_version.py | 2 +- src/scanii/client.py | 39 ++++++++++++++++++++ tests/test_client_integration.py | 53 +++++++++++++++++++++++++++ tests/test_client_unit.py | 62 ++++++++++++++++++++++++++++++++ 7 files changed, 176 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e72bac..033c43f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## 1.1.0 — 2026-08-31 + +### Added + +- `ScaniiClient.delete(id)` — deletes a previously processed file result + (`DELETE /files/{id}`). Returns `True` on success; the processing trace is + left intact. +- `ScaniiClient.delete_trace(id)` — deletes the processing trace separately + (`DELETE /files/{id}/trace`). Returns `True` on success; the processing + result is left intact. + + The two resources are independent: deleting one does not remove the other. + To erase a scan entirely, call both. + ## 1.0.1 — 2026-08-15 ### Changed diff --git a/README.md b/README.md index 7a830e8..f469849 100644 --- a/README.md +++ b/README.md @@ -67,8 +67,14 @@ Supply either `key` + `secret` (HTTP Basic Auth) or `token` (auth-token authenti | `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 | +| `delete(id)` | Delete a scan result. Returns `True`; the processing trace is left intact | +| `delete_trace(id)` | Delete a processing trace. Returns `True`; the scan result is left intact | | `fetch(url, metadata=None, callback=None)` | Server-side async fetch-and-scan of a remote URL | +`delete()` and `delete_trace()` act on independent resources: deleting a scan result +leaves its trace readable, and deleting a trace leaves the result readable. To erase a +scan entirely, call both. + ### v2.2 preview methods | Method | Description | diff --git a/pyproject.toml b/pyproject.toml index e44f1de..098c71e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "scanii-python" -version = "1.0.1" +version = "1.1.0" description = "Zero-dependency Python SDK for the Scanii content security API" readme = "README.md" license = "Apache-2.0" diff --git a/src/scanii/_version.py b/src/scanii/_version.py index 5c4105c..6849410 100644 --- a/src/scanii/_version.py +++ b/src/scanii/_version.py @@ -1 +1 @@ -__version__ = "1.0.1" +__version__ = "1.1.0" diff --git a/src/scanii/client.py b/src/scanii/client.py index 957cd41..99292c1 100644 --- a/src/scanii/client.py +++ b/src/scanii/client.py @@ -235,6 +235,45 @@ def process_from_url( self._raise_for_status(status, resp_body, headers, expected=201) return ScaniiProcessingResult.from_response(resp_body, headers) + def delete(self, id: str) -> bool: + """Delete a previously processed file result. + + The processing trace is a separate resource and is **not** removed by + this call — it stays readable via :meth:`retrieve_trace` until you + delete it with :meth:`delete_trace`. To erase a scan entirely, call + both. + + :param id: processing id returned by :meth:`process` or :meth:`process_file` + :see: https://scanii.github.io/openapi/v22/ DELETE /files/{id} + :return: ``True`` on success (HTTP 204) + :raises ScaniiError: when no result exists for the id (HTTP 404), which is + also what a repeated delete of the same id returns + """ + if not id: + raise ValueError("id must not be empty") + status, resp_body, headers = self._request("DELETE", f"/files/{_urlencode(id)}") + self._raise_for_status(status, resp_body, headers, expected=204) + return True + + def delete_trace(self, id: str) -> bool: + """Delete the processing trace for a previously processed file. + + Leaves the processing result itself untouched. + + :param id: processing id returned by :meth:`process` or :meth:`process_file` + :see: https://scanii.github.io/openapi/v22/ DELETE /files/{id}/trace + :return: ``True`` on success (HTTP 204) + :raises ScaniiError: when no trace exists for the id (HTTP 404), which is + also what a repeated delete of the same id returns + """ + if not id: + raise ValueError("id must not be empty") + status, resp_body, headers = self._request( + "DELETE", f"/files/{_urlencode(id)}/trace" + ) + self._raise_for_status(status, resp_body, headers, expected=204) + return True + # ------------------------------------------------------------------ # Other API methods # ------------------------------------------------------------------ diff --git a/tests/test_client_integration.py b/tests/test_client_integration.py index 2d63074..d013edb 100644 --- a/tests/test_client_integration.py +++ b/tests/test_client_integration.py @@ -203,6 +203,59 @@ def test_process_from_url_eicar_returns_finding(self, client): assert "content.malicious.eicar-test-signature" in result.findings +# --------------------------------------------------------------------------- +# v2.2 surface — delete / delete_trace (hard-assert, no self-skip) +# +# The result and the trace are independent resources: deleting one must leave +# the other readable. These assertions are the whole point of the split, so they +# are deliberately strict. +# --------------------------------------------------------------------------- + +class TestDelete: + UNKNOWN_ID = "00000000-0000-0000-0000-000000000000" + + def test_delete_removes_result_and_leaves_trace(self, client): + result = client.process_file(make_clean_file()) + + assert client.delete(result.id) is True + + with pytest.raises(ScaniiError): + client.retrieve(result.id) + assert client.retrieve_trace(result.id) is not None, ( + "trace must survive deletion of the result" + ) + + def test_delete_trace_removes_trace_and_leaves_result(self, client): + result = client.process_file(make_clean_file()) + + assert client.delete_trace(result.id) is True + + assert client.retrieve_trace(result.id) is None + assert client.retrieve(result.id).id == result.id, ( + "result must survive deletion of the trace" + ) + + def test_repeated_delete_raises(self, client): + result = client.process_file(make_clean_file()) + assert client.delete(result.id) is True + with pytest.raises(ScaniiError): + client.delete(result.id) + + def test_delete_unknown_id_raises(self, client): + with pytest.raises(ScaniiError): + client.delete(self.UNKNOWN_ID) + + def test_delete_trace_unknown_id_raises(self, client): + with pytest.raises(ScaniiError): + client.delete_trace(self.UNKNOWN_ID) + + def test_delete_empty_id_raises_value_error(self, client): + with pytest.raises(ValueError): + client.delete("") + with pytest.raises(ValueError): + client.delete_trace("") + + # --------------------------------------------------------------------------- # Auth token lifecycle # --------------------------------------------------------------------------- diff --git a/tests/test_client_unit.py b/tests/test_client_unit.py index a7b495e..3b35cd6 100644 --- a/tests/test_client_unit.py +++ b/tests/test_client_unit.py @@ -447,6 +447,68 @@ def test_delete_auth_token_empty_id_raises(self): _make_client().delete_auth_token("") +# --------------------------------------------------------------------------- +# delete / delete_trace +# --------------------------------------------------------------------------- + +class TestDelete: + def test_delete_sends_delete_to_files_path(self): + mock_resp = _mock_response(204, "") + with patch("urllib.request.urlopen", return_value=mock_resp) as m: + assert _make_client().delete("abc") is True + req = m.call_args[0][0] + assert req.get_method() == "DELETE" + assert req.full_url == f"{ENDPOINT}/v2.2/files/abc" + + def test_delete_trace_sends_delete_to_trace_path(self): + mock_resp = _mock_response(204, "") + with patch("urllib.request.urlopen", return_value=mock_resp) as m: + assert _make_client().delete_trace("abc") is True + req = m.call_args[0][0] + assert req.get_method() == "DELETE" + assert req.full_url == f"{ENDPOINT}/v2.2/files/abc/trace" + + def test_delete_url_encodes_the_id(self): + mock_resp = _mock_response(204, "") + with patch("urllib.request.urlopen", return_value=mock_resp) as m: + _make_client().delete("a b/c") + req = m.call_args[0][0] + assert req.full_url == f"{ENDPOINT}/v2.2/files/a%20b%2Fc" + + def test_delete_404_raises(self): + mock_resp = _mock_response(404, json.dumps({"error": "not found"})) + with patch("urllib.request.urlopen", return_value=mock_resp): + with pytest.raises(ScaniiError): + _make_client().delete("missing") + + def test_delete_trace_404_raises(self): + mock_resp = _mock_response(404, json.dumps({"error": "no trace"})) + with patch("urllib.request.urlopen", return_value=mock_resp): + with pytest.raises(ScaniiError): + _make_client().delete_trace("missing") + + def test_delete_403_raises_auth_error(self): + # Per the spec, a temporary auth token is not privileged to delete. + mock_resp = _mock_response(403, json.dumps({"error": "forbidden"})) + with patch("urllib.request.urlopen", return_value=mock_resp): + with pytest.raises(ScaniiAuthError): + _make_client().delete("abc") + + def test_delete_trace_403_raises_auth_error(self): + mock_resp = _mock_response(403, json.dumps({"error": "forbidden"})) + with patch("urllib.request.urlopen", return_value=mock_resp): + with pytest.raises(ScaniiAuthError): + _make_client().delete_trace("abc") + + def test_delete_empty_id_raises(self): + with pytest.raises(ValueError): + _make_client().delete("") + + def test_delete_trace_empty_id_raises(self): + with pytest.raises(ValueError): + _make_client().delete_trace("") + + # --------------------------------------------------------------------------- # Deprecation warning on ScaniiProcessingResult.error # --------------------------------------------------------------------------- From 416cf1034aa5d09fc27e4b8a9b0071906a7103be Mon Sep 17 00:00:00 2001 From: Rafael Ferreira Date: Mon, 31 Aug 2026 13:39:24 -0400 Subject: [PATCH 2/2] docs: drop the "v2.2 preview" designation from retrieve_trace and process_from_url MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v2.2 spec only ever marked GET /files/{id}/trace as preview — the `location` parameter behind process_from_url was listed as a plain 2.2 feature, so labelling it preview was drift introduced during the v2.2 propagation. The trace endpoint's preview marking is being dropped from the contract as well (openapi v22.yaml), so neither method carries it now. Docs only — no behavior, signature or return-type change. - Removed the preview paragraph from retrieve_trace() and process_from_url() docstrings and from the ScaniiTraceResult model docstring - Folded the README's "v2.2 preview methods" section into the file-scanning table; both methods are now ordinary rows - Historical CHANGELOG entries left as-is; 1.1.0 records the change Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 6 ++++++ README.md | 9 ++------- src/scanii/client.py | 6 ------ src/scanii/models.py | 3 --- 4 files changed, 8 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 033c43f..4427784 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,12 @@ The two resources are independent: deleting one does not remove the other. To erase a scan entirely, call both. +### Changed + +- Dropped the "v2.2 preview" designation from `retrieve_trace()` and + `process_from_url()` in the README and docstrings. Neither is marked preview + in the API contract; the methods themselves are unchanged. + ## 1.0.1 — 2026-08-15 ### Changed diff --git a/README.md b/README.md index f469849..cea9ed2 100644 --- a/README.md +++ b/README.md @@ -70,18 +70,13 @@ Supply either `key` + `secret` (HTTP Basic Auth) or `token` (auth-token authenti | `delete(id)` | Delete a scan result. Returns `True`; the processing trace is left intact | | `delete_trace(id)` | Delete a processing trace. Returns `True`; the scan result is left intact | | `fetch(url, metadata=None, callback=None)` | Server-side async fetch-and-scan of a remote URL | +| `process_from_url(location, callback=None, metadata=None)` | Synchronous scan of a remote URL via `POST /files` | +| `retrieve_trace(id)` | Retrieve processing event trace; returns `None` on 404 | `delete()` and `delete_trace()` act on independent resources: deleting a scan result leaves its trace readable, and deleting a trace leaves the result readable. To erase a scan entirely, call both. -### 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 | diff --git a/src/scanii/client.py b/src/scanii/client.py index 99292c1..6d29dd5 100644 --- a/src/scanii/client.py +++ b/src/scanii/client.py @@ -182,9 +182,6 @@ def retrieve_trace(self, id: str) -> ScaniiTraceResult | None: 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`` @@ -214,9 +211,6 @@ def process_from_url( ``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 diff --git a/src/scanii/models.py b/src/scanii/models.py index b54be02..5fdf596 100644 --- a/src/scanii/models.py +++ b/src/scanii/models.py @@ -29,9 +29,6 @@ def from_dict(cls, raw: dict[str, object]) -> "ScaniiTraceEvent": 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/ """