diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e72bac..4427784 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # 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. + +### 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 7a830e8..cea9ed2 100644 --- a/README.md +++ b/README.md @@ -67,14 +67,15 @@ 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 | +| `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 | -### 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` | +`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. ### Auth tokens 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..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 @@ -235,6 +229,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/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/ """ 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 # ---------------------------------------------------------------------------