sf-queue-sdk 0.5.0b56__tar.gz → 0.5.0b79__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. {sf_queue_sdk-0.5.0b56 → sf_queue_sdk-0.5.0b79}/.gitignore +2 -0
  2. sf_queue_sdk-0.5.0b56/README.md → sf_queue_sdk-0.5.0b79/PKG-INFO +20 -1
  3. sf_queue_sdk-0.5.0b56/PKG-INFO → sf_queue_sdk-0.5.0b79/README.md +12 -9
  4. {sf_queue_sdk-0.5.0b56 → sf_queue_sdk-0.5.0b79}/pyproject.toml +1 -1
  5. {sf_queue_sdk-0.5.0b56 → sf_queue_sdk-0.5.0b79}/queue_sdk/__init__.py +1 -1
  6. {sf_queue_sdk-0.5.0b56 → sf_queue_sdk-0.5.0b79}/queue_sdk/generated/queue/email/v1/email_pb2.py +3 -3
  7. {sf_queue_sdk-0.5.0b56 → sf_queue_sdk-0.5.0b79}/queue_sdk/generated/queue/prompt_ingest/v1/prompt_ingest_pb2.py +3 -3
  8. {sf_queue_sdk-0.5.0b56 → sf_queue_sdk-0.5.0b79}/queue_sdk/generated/queue/tool_assignment/v1/tool_assignment_pb2.py +3 -3
  9. {sf_queue_sdk-0.5.0b56 → sf_queue_sdk-0.5.0b79}/queue_sdk/generated/queue/topic_assignment/v1/topic_assignment_pb2.py +3 -3
  10. {sf_queue_sdk-0.5.0b56 → sf_queue_sdk-0.5.0b79}/queue_sdk/queues/document_upload_queue.py +33 -5
  11. {sf_queue_sdk-0.5.0b56 → sf_queue_sdk-0.5.0b79}/queue_sdk/redis_client.py +10 -0
  12. {sf_queue_sdk-0.5.0b56 → sf_queue_sdk-0.5.0b79}/queue_sdk/types.py +1 -0
  13. {sf_queue_sdk-0.5.0b56 → sf_queue_sdk-0.5.0b79}/tests/test_idempotency.py +78 -5
  14. {sf_queue_sdk-0.5.0b56 → sf_queue_sdk-0.5.0b79}/queue_sdk/client.py +0 -0
  15. {sf_queue_sdk-0.5.0b56 → sf_queue_sdk-0.5.0b79}/queue_sdk/generated/__init__.py +0 -0
  16. {sf_queue_sdk-0.5.0b56 → sf_queue_sdk-0.5.0b79}/queue_sdk/generated/queue/email/v1/email_pb2.pyi +0 -0
  17. {sf_queue_sdk-0.5.0b56 → sf_queue_sdk-0.5.0b79}/queue_sdk/generated/queue/prompt_ingest/v1/prompt_ingest_pb2.pyi +0 -0
  18. {sf_queue_sdk-0.5.0b56 → sf_queue_sdk-0.5.0b79}/queue_sdk/generated/queue/tool_assignment/v1/tool_assignment_pb2.pyi +0 -0
  19. {sf_queue_sdk-0.5.0b56 → sf_queue_sdk-0.5.0b79}/queue_sdk/generated/queue/topic_assignment/v1/topic_assignment_pb2.pyi +0 -0
  20. {sf_queue_sdk-0.5.0b56 → sf_queue_sdk-0.5.0b79}/queue_sdk/query.py +0 -0
  21. {sf_queue_sdk-0.5.0b56 → sf_queue_sdk-0.5.0b79}/queue_sdk/queues/__init__.py +0 -0
  22. {sf_queue_sdk-0.5.0b56 → sf_queue_sdk-0.5.0b79}/queue_sdk/queues/base_queue.py +0 -0
  23. {sf_queue_sdk-0.5.0b56 → sf_queue_sdk-0.5.0b79}/queue_sdk/queues/document_notes_queue.py +0 -0
  24. {sf_queue_sdk-0.5.0b56 → sf_queue_sdk-0.5.0b79}/queue_sdk/queues/document_persist_queue.py +0 -0
  25. {sf_queue_sdk-0.5.0b56 → sf_queue_sdk-0.5.0b79}/queue_sdk/queues/email_queue.py +0 -0
  26. {sf_queue_sdk-0.5.0b56 → sf_queue_sdk-0.5.0b79}/queue_sdk/queues/pdf_to_image_queue.py +0 -0
  27. {sf_queue_sdk-0.5.0b56 → sf_queue_sdk-0.5.0b79}/queue_sdk/queues/prompt_ingest_queue.py +0 -0
  28. {sf_queue_sdk-0.5.0b56 → sf_queue_sdk-0.5.0b79}/queue_sdk/queues/recommendation_queue.py +0 -0
  29. {sf_queue_sdk-0.5.0b56 → sf_queue_sdk-0.5.0b79}/queue_sdk/queues/tool_assignment_queue.py +0 -0
  30. {sf_queue_sdk-0.5.0b56 → sf_queue_sdk-0.5.0b79}/queue_sdk/queues/topic_assignment_queue.py +0 -0
  31. {sf_queue_sdk-0.5.0b56 → sf_queue_sdk-0.5.0b79}/queue_sdk/sanitize.py +0 -0
@@ -57,6 +57,7 @@ coverage/
57
57
  TODO-document-upload-queue.md
58
58
 
59
59
  # Local pipeline test output
60
+ benchmark-results/
60
61
  scripts/.env
61
62
  scripts/local-pdfs/
62
63
  scripts/mp3/
@@ -66,3 +67,4 @@ scripts/mp3-results/
66
67
  scripts/studyset/.env
67
68
  scripts/studyset/downloads/
68
69
  scripts/failed-files/
70
+ benchmark-results/
@@ -1,3 +1,11 @@
1
+ Metadata-Version: 2.5
2
+ Name: sf-queue-sdk
3
+ Version: 0.5.0b79
4
+ Summary: Python SDK for sf-queue - Redis-based queue system
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: redis>=5.0.0
7
+ Description-Content-Type: text/markdown
8
+
1
9
  # sf-queue-sdk
2
10
 
3
11
  Python SDK for sf-queue. Enqueues emails via Redis Streams with optional blocking confirmation from the Go consumer service.
@@ -598,9 +606,19 @@ result = client.document_upload.send(
598
606
  material_group_id="group_123",
599
607
  folder_id="folder_456",
600
608
  upload_batch_id="batch_789",
609
+ content_hash="sha256:verified-content-hash",
601
610
  )
602
611
  ```
603
612
 
613
+ ### Cancel a document
614
+
615
+ Record a manual cancellation before or during processing. Cancellation is
616
+ scoped to the configured environment and retained for seven days.
617
+
618
+ ```python
619
+ client.document_upload.cancel_document("file_abc123")
620
+ ```
621
+
604
622
  ### Document Upload Fields
605
623
 
606
624
  | Field | Type | Required | Description |
@@ -629,6 +647,7 @@ result = client.document_upload.send(
629
647
  | `material_group_id` | `Optional[str]` | No | Material group for organization |
630
648
  | `folder_id` | `Optional[str]` | No | Folder for organization |
631
649
  | `upload_batch_id` | `Optional[str]` | No | Batch ID when multiple files uploaded together |
650
+ | `content_hash` | `Optional[str]` | No | Stable source-content identity used to supersede older duplicate batches |
632
651
  | `youtube_title` | `Optional[str]` | No | Title override for YouTube video transcripts |
633
652
  | `skip_material_creation` | `Optional[bool]` | No | Skip folder and material creation when the material already exists (e.g. live lectures) |
634
653
  | `existing_material_id` | `Optional[str]` | No | ID of an existing material to link to topics instead of creating new ones (used with `skip_material_creation`) |
@@ -670,7 +689,7 @@ result = client.document_upload.send(
670
689
 
671
690
  # Poll for progress
672
691
  status = client.document_upload.get_status(result.message_id)
673
- print(f"Status: {status.status}") # "queued", "processing", "completed", or "failed"
692
+ print(f"Status: {status.status}") # Includes "cancelled" and "superseded" terminal states
674
693
  print(f"Step: {status.step} ({status.step_number}/{status.total_steps})")
675
694
  print(f"Progress: {status.percent_complete}%")
676
695
  print(f"Detail: {status.detail}") # e.g. "Generated notes for topic 3/7"
@@ -1,11 +1,3 @@
1
- Metadata-Version: 2.5
2
- Name: sf-queue-sdk
3
- Version: 0.5.0b56
4
- Summary: Python SDK for sf-queue - Redis-based queue system
5
- Requires-Python: >=3.11
6
- Requires-Dist: redis>=5.0.0
7
- Description-Content-Type: text/markdown
8
-
9
1
  # sf-queue-sdk
10
2
 
11
3
  Python SDK for sf-queue. Enqueues emails via Redis Streams with optional blocking confirmation from the Go consumer service.
@@ -606,9 +598,19 @@ result = client.document_upload.send(
606
598
  material_group_id="group_123",
607
599
  folder_id="folder_456",
608
600
  upload_batch_id="batch_789",
601
+ content_hash="sha256:verified-content-hash",
609
602
  )
610
603
  ```
611
604
 
605
+ ### Cancel a document
606
+
607
+ Record a manual cancellation before or during processing. Cancellation is
608
+ scoped to the configured environment and retained for seven days.
609
+
610
+ ```python
611
+ client.document_upload.cancel_document("file_abc123")
612
+ ```
613
+
612
614
  ### Document Upload Fields
613
615
 
614
616
  | Field | Type | Required | Description |
@@ -637,6 +639,7 @@ result = client.document_upload.send(
637
639
  | `material_group_id` | `Optional[str]` | No | Material group for organization |
638
640
  | `folder_id` | `Optional[str]` | No | Folder for organization |
639
641
  | `upload_batch_id` | `Optional[str]` | No | Batch ID when multiple files uploaded together |
642
+ | `content_hash` | `Optional[str]` | No | Stable source-content identity used to supersede older duplicate batches |
640
643
  | `youtube_title` | `Optional[str]` | No | Title override for YouTube video transcripts |
641
644
  | `skip_material_creation` | `Optional[bool]` | No | Skip folder and material creation when the material already exists (e.g. live lectures) |
642
645
  | `existing_material_id` | `Optional[str]` | No | ID of an existing material to link to topics instead of creating new ones (used with `skip_material_creation`) |
@@ -678,7 +681,7 @@ result = client.document_upload.send(
678
681
 
679
682
  # Poll for progress
680
683
  status = client.document_upload.get_status(result.message_id)
681
- print(f"Status: {status.status}") # "queued", "processing", "completed", or "failed"
684
+ print(f"Status: {status.status}") # Includes "cancelled" and "superseded" terminal states
682
685
  print(f"Step: {status.step} ({status.step_number}/{status.total_steps})")
683
686
  print(f"Progress: {status.percent_complete}%")
684
687
  print(f"Detail: {status.detail}") # e.g. "Generated notes for topic 3/7"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sf-queue-sdk"
3
- version = "0.5.0b56"
3
+ version = "0.5.0b79"
4
4
  description = "Python SDK for sf-queue - Redis-based queue system"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -1,6 +1,6 @@
1
1
  """sf-queue Python SDK - Redis-based queue system."""
2
2
 
3
- __version__ = "0.5.0b56"
3
+ __version__ = "0.5.0b79"
4
4
 
5
5
  from queue_sdk.client import QueueClient
6
6
  from queue_sdk.query import QueryClient
@@ -2,7 +2,7 @@
2
2
  # Generated by the protocol buffer compiler. DO NOT EDIT!
3
3
  # NO CHECKED-IN PROTOBUF GENCODE
4
4
  # source: queue/email/v1/email.proto
5
- # Protobuf Python Version: 7.36.0
5
+ # Protobuf Python Version: 7.35.1
6
6
  """Generated protocol buffer code."""
7
7
  from google.protobuf import descriptor as _descriptor
8
8
  from google.protobuf import descriptor_pool as _descriptor_pool
@@ -12,8 +12,8 @@ from google.protobuf.internal import builder as _builder
12
12
  _runtime_version.ValidateProtobufRuntimeVersion(
13
13
  _runtime_version.Domain.PUBLIC,
14
14
  7,
15
- 36,
16
- 0,
15
+ 35,
16
+ 1,
17
17
  '',
18
18
  'queue/email/v1/email.proto'
19
19
  )
@@ -2,7 +2,7 @@
2
2
  # Generated by the protocol buffer compiler. DO NOT EDIT!
3
3
  # NO CHECKED-IN PROTOBUF GENCODE
4
4
  # source: queue/prompt_ingest/v1/prompt_ingest.proto
5
- # Protobuf Python Version: 7.36.0
5
+ # Protobuf Python Version: 7.35.1
6
6
  """Generated protocol buffer code."""
7
7
  from google.protobuf import descriptor as _descriptor
8
8
  from google.protobuf import descriptor_pool as _descriptor_pool
@@ -12,8 +12,8 @@ from google.protobuf.internal import builder as _builder
12
12
  _runtime_version.ValidateProtobufRuntimeVersion(
13
13
  _runtime_version.Domain.PUBLIC,
14
14
  7,
15
- 36,
16
- 0,
15
+ 35,
16
+ 1,
17
17
  '',
18
18
  'queue/prompt_ingest/v1/prompt_ingest.proto'
19
19
  )
@@ -2,7 +2,7 @@
2
2
  # Generated by the protocol buffer compiler. DO NOT EDIT!
3
3
  # NO CHECKED-IN PROTOBUF GENCODE
4
4
  # source: queue/tool_assignment/v1/tool_assignment.proto
5
- # Protobuf Python Version: 7.36.0
5
+ # Protobuf Python Version: 7.35.1
6
6
  """Generated protocol buffer code."""
7
7
  from google.protobuf import descriptor as _descriptor
8
8
  from google.protobuf import descriptor_pool as _descriptor_pool
@@ -12,8 +12,8 @@ from google.protobuf.internal import builder as _builder
12
12
  _runtime_version.ValidateProtobufRuntimeVersion(
13
13
  _runtime_version.Domain.PUBLIC,
14
14
  7,
15
- 36,
16
- 0,
15
+ 35,
16
+ 1,
17
17
  '',
18
18
  'queue/tool_assignment/v1/tool_assignment.proto'
19
19
  )
@@ -2,7 +2,7 @@
2
2
  # Generated by the protocol buffer compiler. DO NOT EDIT!
3
3
  # NO CHECKED-IN PROTOBUF GENCODE
4
4
  # source: queue/topic_assignment/v1/topic_assignment.proto
5
- # Protobuf Python Version: 7.36.0
5
+ # Protobuf Python Version: 7.35.1
6
6
  """Generated protocol buffer code."""
7
7
  from google.protobuf import descriptor as _descriptor
8
8
  from google.protobuf import descriptor_pool as _descriptor_pool
@@ -12,8 +12,8 @@ from google.protobuf.internal import builder as _builder
12
12
  _runtime_version.ValidateProtobufRuntimeVersion(
13
13
  _runtime_version.Domain.PUBLIC,
14
14
  7,
15
- 36,
16
- 0,
15
+ 35,
16
+ 1,
17
17
  '',
18
18
  'queue/topic_assignment/v1/topic_assignment.proto'
19
19
  )
@@ -6,10 +6,11 @@ from typing import Any, Optional
6
6
  import redis as redis_lib
7
7
 
8
8
  from queue_sdk.queues.base_queue import BaseQueue
9
- from queue_sdk.redis_client import status_key
9
+ from queue_sdk.redis_client import document_control_key, status_key
10
10
  from queue_sdk.types import DocumentUploadStatus, SendResult
11
11
 
12
- STATUS_TTL_SECONDS = 3600
12
+ STATUS_TTL_SECONDS = 7200
13
+ TERMINAL_CONTROL_TTL_SECONDS = 7 * 24 * 60 * 60
13
14
 
14
15
 
15
16
  class DocumentUploadQueue(BaseQueue):
@@ -45,6 +46,7 @@ class DocumentUploadQueue(BaseQueue):
45
46
  folder_id: Optional[str] = None,
46
47
  upload_batch_id: Optional[str] = None,
47
48
  batch_size: Optional[int] = None,
49
+ content_hash: Optional[str] = None,
48
50
  youtube_title: Optional[str] = None,
49
51
  extracted_content: Optional[str] = None,
50
52
  callback_url: Optional[str] = None,
@@ -83,6 +85,7 @@ class DocumentUploadQueue(BaseQueue):
83
85
  folder_id=folder_id,
84
86
  upload_batch_id=upload_batch_id,
85
87
  batch_size=batch_size,
88
+ content_hash=content_hash,
86
89
  youtube_title=youtube_title,
87
90
  extracted_content=extracted_content,
88
91
  callback_url=callback_url,
@@ -133,11 +136,29 @@ class DocumentUploadQueue(BaseQueue):
133
136
  error=data.get("error", ""),
134
137
  )
135
138
 
139
+ def cancel_document(self, uploaded_file_id: str) -> None:
140
+ """Record a manual cancellation before or during processing."""
141
+ if not uploaded_file_id.strip():
142
+ raise ValueError("uploaded_file_id must not be empty")
143
+
144
+ key = document_control_key(self._environment, uploaded_file_id)
145
+ pipe = self._redis.pipeline(transaction=False)
146
+ pipe.hset(
147
+ key,
148
+ mapping={
149
+ "manualCancel": "1",
150
+ "cancelReason": "manual",
151
+ "cancelledAt": datetime.now(timezone.utc).isoformat(),
152
+ },
153
+ )
154
+ pipe.expire(key, TERMINAL_CONTROL_TTL_SECONDS)
155
+ pipe.execute()
156
+
136
157
  def _set_initial_status(self, request_id: str) -> None:
137
158
  """Write the initial queued status hash after enqueuing."""
138
159
  key = status_key(self._environment, self._queue_name, request_id)
139
160
  now = datetime.now(timezone.utc).isoformat()
140
- self._redis.hset(key, mapping={
161
+ initial_status = {
141
162
  "status": "queued",
142
163
  "step": "",
143
164
  "stepNumber": "0",
@@ -148,8 +169,12 @@ class DocumentUploadQueue(BaseQueue):
148
169
  "startedAt": "",
149
170
  "updatedAt": now,
150
171
  "error": "",
151
- })
152
- self._redis.expire(key, STATUS_TTL_SECONDS)
172
+ }
173
+ pipe = self._redis.pipeline(transaction=False)
174
+ for field, value in initial_status.items():
175
+ pipe.hsetnx(key, field, value)
176
+ pipe.expire(key, STATUS_TTL_SECONDS)
177
+ pipe.execute()
153
178
 
154
179
  @staticmethod
155
180
  def _build_payload(
@@ -178,6 +203,7 @@ class DocumentUploadQueue(BaseQueue):
178
203
  folder_id: Optional[str],
179
204
  upload_batch_id: Optional[str],
180
205
  batch_size: Optional[int],
206
+ content_hash: Optional[str],
181
207
  youtube_title: Optional[str],
182
208
  extracted_content: Optional[str],
183
209
  callback_url: Optional[str],
@@ -225,6 +251,8 @@ class DocumentUploadQueue(BaseQueue):
225
251
  payload["uploadBatchId"] = upload_batch_id
226
252
  if batch_size is not None:
227
253
  payload["batchSize"] = batch_size
254
+ if content_hash is not None:
255
+ payload["contentHash"] = content_hash
228
256
  if youtube_title is not None:
229
257
  payload["youtubeTitle"] = youtube_title
230
258
  if extracted_content is not None:
@@ -55,6 +55,16 @@ def idempotency_key(env: str, queue: str, caller_key: str) -> str:
55
55
  return f"{env}:{key}"
56
56
 
57
57
 
58
+ def document_control_key(env: str, uploaded_file_id: str) -> str:
59
+ """Return the cancellation-control key for an upload."""
60
+ digest = hashlib.sha256(uploaded_file_id.encode("utf-8")).hexdigest()
61
+ key = f"{{document-upload}}:ctl:document:{digest}"
62
+ if not env:
63
+ return key
64
+
65
+ return f"{env}:{key}"
66
+
67
+
58
68
  def response_key(env: str, queue: str, request_id: str) -> str:
59
69
  """Return the response list key for a given queue and request ID."""
60
70
  key = f"{queue}:response:{request_id}"
@@ -209,6 +209,7 @@ class DocumentUploadData:
209
209
  material_group_id: Optional[str] = None
210
210
  folder_id: Optional[str] = None
211
211
  upload_batch_id: Optional[str] = None
212
+ content_hash: Optional[str] = None
212
213
  youtube_title: Optional[str] = None
213
214
  extracted_content: Optional[str] = None
214
215
  callback_url: Optional[str] = None
@@ -1,10 +1,43 @@
1
1
  """Focused idempotent enqueue tests."""
2
2
 
3
+ import json
3
4
  import unittest
4
5
 
5
6
  from queue_sdk.queues.document_upload_queue import DocumentUploadQueue
6
7
 
7
8
 
9
+ class FakePipeline:
10
+ def __init__(self, redis: "FakeRedis") -> None:
11
+ self.redis = redis
12
+ self.operations: list[tuple[str, tuple[object, ...]]] = []
13
+
14
+ def hsetnx(self, *args: object) -> "FakePipeline":
15
+ self.operations.append(("hsetnx", args))
16
+ return self
17
+
18
+ def hset(self, *args: object, **kwargs: object) -> "FakePipeline":
19
+ self.operations.append(("hset", (*args, kwargs)))
20
+ return self
21
+
22
+ def expire(self, *args: object) -> "FakePipeline":
23
+ self.operations.append(("expire", args))
24
+ return self
25
+
26
+ def execute(self) -> None:
27
+ hsetnx_count = sum(
28
+ operation == "hsetnx"
29
+ for operation, _ in self.operations
30
+ )
31
+ if hsetnx_count:
32
+ if hsetnx_count != 10:
33
+ raise AssertionError("unexpected initial status field count")
34
+ if self.operations[-1][1][1] != 7200:
35
+ raise AssertionError("unexpected status TTL")
36
+ self.redis.status_writes += 1
37
+ else:
38
+ self.redis.cancel_writes.append(self.operations)
39
+
40
+
8
41
  class FakeRedis:
9
42
  """Redis test double for the idempotent enqueue script."""
10
43
 
@@ -12,6 +45,7 @@ class FakeRedis:
12
45
  self.idempotency: dict[str, str] = {}
13
46
  self.entries: list[dict[str, str]] = []
14
47
  self.status_writes = 0
48
+ self.cancel_writes: list[list[tuple[str, tuple[object, ...]]]] = []
15
49
 
16
50
  def eval(
17
51
  self,
@@ -49,11 +83,10 @@ class FakeRedis:
49
83
 
50
84
  return [request_id, 1]
51
85
 
52
- def hset(self, *_args: object, **_kwargs: object) -> None:
53
- self.status_writes += 1
54
-
55
- def expire(self, *_args: object, **_kwargs: object) -> None:
56
- pass
86
+ def pipeline(self, *, transaction: bool) -> FakePipeline:
87
+ if transaction:
88
+ raise AssertionError("status initialization should not use a transaction")
89
+ return FakePipeline(self)
57
90
 
58
91
 
59
92
  def send_upload(
@@ -72,6 +105,7 @@ def send_upload(
72
105
  material_title="Notes",
73
106
  url="https://example.com/notes.pdf",
74
107
  generate_notes=True,
108
+ content_hash="sha256:content-1",
75
109
  idempotency_key=idempotency_key,
76
110
  )
77
111
 
@@ -104,6 +138,45 @@ class IdempotentDocumentUploadTests(unittest.TestCase):
104
138
 
105
139
  self.assertEqual(len(self.redis.entries), 0)
106
140
 
141
+ def test_content_hash_is_serialized(self) -> None:
142
+ send_upload(self.queue, "with-hash")
143
+
144
+ payload = json.loads(self.redis.entries[0]["payload"])
145
+
146
+ self.assertEqual(payload["contentHash"], "sha256:content-1")
147
+
148
+ def test_content_hash_remains_optional(self) -> None:
149
+ result = self.queue.send(
150
+ uploaded_file_id="file-1",
151
+ study_set_id="set-1",
152
+ user_id="user-1",
153
+ text_extract_s3_key="text/key",
154
+ pdf_s3_key="pdf/key",
155
+ file_type="application/pdf",
156
+ effective_file_type="application/pdf",
157
+ filename="notes.pdf",
158
+ material_title="Notes",
159
+ url="https://example.com/notes.pdf",
160
+ generate_notes=True,
161
+ idempotency_key="without-hash",
162
+ )
163
+
164
+ self.assertTrue(result.message_id)
165
+ payload = json.loads(self.redis.entries[0]["payload"])
166
+ self.assertNotIn("contentHash", payload)
167
+
168
+ def test_manual_cancellation_writes_scoped_tombstone(self) -> None:
169
+ self.queue.cancel_document("file-1")
170
+
171
+ self.assertEqual(len(self.redis.cancel_writes), 1)
172
+ operations = self.redis.cancel_writes[0]
173
+ self.assertRegex(
174
+ str(operations[0][1][0]),
175
+ r"^staging:\{document-upload\}:ctl:document:",
176
+ )
177
+ self.assertEqual(operations[0][1][-1]["mapping"]["manualCancel"], "1")
178
+ self.assertEqual(operations[-1][1][1], 7 * 24 * 60 * 60)
179
+
107
180
 
108
181
  if __name__ == "__main__":
109
182
  unittest.main()