Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
13 changes: 7 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/scanii/_version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "1.0.1"
__version__ = "1.1.0"
45 changes: 39 additions & 6 deletions src/scanii/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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``
Expand Down Expand Up @@ -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
Expand All @@ -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
# ------------------------------------------------------------------
Expand Down
3 changes: 0 additions & 3 deletions src/scanii/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/
"""

Expand Down
53 changes: 53 additions & 0 deletions tests/test_client_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down
62 changes: 62 additions & 0 deletions tests/test_client_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down