From ee75669de200278d4b4006681cdee61ce5dae8d0 Mon Sep 17 00:00:00 2001 From: Travis Matthews Date: Thu, 8 Oct 2026 13:58:42 -0400 Subject: [PATCH] Add hard_delete_redacted_file to TextualNer Calls DELETE /api/unattachedfile/{jobId} to hard delete a file uploaded with start_file_redaction, its job, and all derived data. A 409 (job still processing) raises the new FileNotReadyForDeletion exception. Co-Authored-By: Claude Opus 5.5 --- README.md | 6 +++ docs/source/redact/redacting_files.rst | 13 +++++ .../test_hard_delete_redacted_file.py | 48 +++++++++++++++++++ tonic_textual/classes/tonic_exception.py | 9 ++++ tonic_textual/redact_api.py | 34 +++++++++++++ 5 files changed, 110 insertions(+) create mode 100644 tests/tests/redact_tests/test_hard_delete_redacted_file.py diff --git a/README.md b/README.md index b5a3bd6..d84ccfa 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,12 @@ with open('redacted_file.pdf','wb') as of: The ```download_redacted_file``` method takes similar arguments to the ```redact()``` method. It also supports a ```generator_config``` parameter to adjust which entities are tokenized and synthesized. +When you no longer need the file, permanently delete the uploaded file and everything Textual derived from it: + +```python +textual_ner.hard_delete_redacted_file(ref_id) +``` + ### Consistency When entities are tokenized, the tokenized values are unique to the original value. A given entity always generates to the same unique token. To map a token back to its original value, use the ```unredact``` function call. diff --git a/docs/source/redact/redacting_files.rst b/docs/source/redact/redacting_files.rst index b421fd4..944faa2 100644 --- a/docs/source/redact/redacting_files.rst +++ b/docs/source/redact/redacting_files.rst @@ -30,6 +30,19 @@ To redact an individual file: with open('','wb') as redacted_file: redacted_file.write(new_bytes) +Hard delete a redacted file +--------------------------- + +Textual keeps the uploaded file and the data that it derives from the file, such as detected entities and OCR results, so that you can download the redacted file again with different settings. + +When you no longer need the file, use ``hard_delete_redacted_file`` to permanently delete the uploaded file, its redaction job, and all of the derived data. After you delete the file, you can no longer use the job identifier to download it. + +.. code-block:: python + + redact.hard_delete_redacted_file(j) + +You can only delete a file after its redaction job finishes. If the file is still being processed, ``hard_delete_redacted_file`` raises ``FileNotReadyForDeletion``. + Configure how to handle specific entity types ---------------------------------------------- diff --git a/tests/tests/redact_tests/test_hard_delete_redacted_file.py b/tests/tests/redact_tests/test_hard_delete_redacted_file.py new file mode 100644 index 0000000..ec77723 --- /dev/null +++ b/tests/tests/redact_tests/test_hard_delete_redacted_file.py @@ -0,0 +1,48 @@ +"""Unit tests for TextualNer.hard_delete_redacted_file. These do not require a running Textual server.""" + +from unittest.mock import patch + +import pytest +import requests + +from tonic_textual.classes.tonic_exception import FileNotReadyForDeletion +from tonic_textual.redact_api import TextualNer + + +def _response(status_code: int) -> requests.Response: + response = requests.Response() + response.status_code = status_code + response._content = b"" + response.url = "http://localhost/api/unattachedfile/job-id" + response.request = requests.Request("DELETE", response.url).prepare() + return response + + +@pytest.fixture +def ner(): + return TextualNer("http://localhost", api_key="fake-key") + + +def test_sends_delete_for_job(ner): + with patch("tonic_textual.classes.httpclient.requests.delete", return_value=_response(204)) as delete: + assert ner.hard_delete_redacted_file("job-id") is None + + assert delete.call_args.args[0] == "http://localhost/api/unattachedfile/job-id" + + +def test_still_processing_raises_file_not_ready_for_deletion(ner): + with ( + patch("tonic_textual.classes.httpclient.requests.delete", return_value=_response(409)), + pytest.raises(FileNotReadyForDeletion), + ): + ner.hard_delete_redacted_file("job-id") + + +def test_unknown_job_raises_http_error(ner): + with ( + patch("tonic_textual.classes.httpclient.requests.delete", return_value=_response(404)), + pytest.raises(requests.exceptions.HTTPError) as error, + ): + ner.hard_delete_redacted_file("job-id") + + assert error.value.response.status_code == 404 diff --git a/tonic_textual/classes/tonic_exception.py b/tonic_textual/classes/tonic_exception.py index bf5e40f..24d5351 100644 --- a/tonic_textual/classes/tonic_exception.py +++ b/tonic_textual/classes/tonic_exception.py @@ -66,6 +66,15 @@ def __init__(self, msg): super().__init__(msg) +class FileNotReadyForDeletion(Exception): + """ + Raised when you make a request to delete a file that is still being processed + """ + + def __init__(self, msg): + super().__init__(msg) + + class AudioTranscriptionResultAlreadyRetrieved(Exception): """ Raised when you try to retrieve a transcription result that was previously retrieved and hence deleted diff --git a/tonic_textual/redact_api.py b/tonic_textual/redact_api.py index d488541..680d25d 100644 --- a/tonic_textual/redact_api.py +++ b/tonic_textual/redact_api.py @@ -28,6 +28,7 @@ from tonic_textual.classes.tonic_exception import ( BadArgumentsException, DatasetNameAlreadyExists, + FileNotReadyForDeletion, FileNotReadyForDownload, InvalidJsonForRedactionRequest, ) @@ -1374,6 +1375,39 @@ def download_redacted_file( "This is likely due to a high service load. Try again later." ) + def hard_delete_redacted_file(self, job_id: str) -> None: + """ + Hard delete a file that was uploaded for redaction + + Deletes the uploaded file, its redaction job, and all of the data that + Textual derived from it, such as detected entities and OCR results. + After deletion, the job identifier can no longer be used to download + the redacted file. This cannot be undone. + + Parameters + -------- + job_id: str + The identifier of the redaction job, as returned by + start_file_redaction. + + Raises + ------ + FileNotReadyForDeletion + The file is still being processed. Wait for the job to finish + before you delete the file. + requests.exceptions.HTTPError + A redaction job with the specified identifier was not found (404). + """ + + try: + self.client.http_delete(f"/api/unattachedfile/{job_id}") + except requests.exceptions.HTTPError as err: + if err.response is not None and err.response.status_code == 409: + raise FileNotReadyForDeletion( + "The file is still being processed. Wait for the job to finish before you delete the file." + ) from err + raise + # --- Model-Based Custom Entities --- def create_model_entity(