sf-queue-sdk 0.4.0b139__tar.gz → 0.5.0__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.
- {sf_queue_sdk-0.4.0b139 → sf_queue_sdk-0.5.0}/.gitignore +7 -0
- sf_queue_sdk-0.4.0b139/README.md → sf_queue_sdk-0.5.0/PKG-INFO +45 -2
- sf_queue_sdk-0.4.0b139/PKG-INFO → sf_queue_sdk-0.5.0/README.md +37 -10
- {sf_queue_sdk-0.4.0b139 → sf_queue_sdk-0.5.0}/pyproject.toml +1 -1
- {sf_queue_sdk-0.4.0b139 → sf_queue_sdk-0.5.0}/queue_sdk/__init__.py +1 -1
- {sf_queue_sdk-0.4.0b139 → sf_queue_sdk-0.5.0}/queue_sdk/queues/base_queue.py +52 -1
- {sf_queue_sdk-0.4.0b139 → sf_queue_sdk-0.5.0}/queue_sdk/queues/document_upload_queue.py +14 -0
- {sf_queue_sdk-0.4.0b139 → sf_queue_sdk-0.5.0}/queue_sdk/redis_client.py +10 -0
- {sf_queue_sdk-0.4.0b139 → sf_queue_sdk-0.5.0}/queue_sdk/types.py +9 -0
- sf_queue_sdk-0.5.0/tests/test_idempotency.py +109 -0
- {sf_queue_sdk-0.4.0b139 → sf_queue_sdk-0.5.0}/queue_sdk/client.py +0 -0
- {sf_queue_sdk-0.4.0b139 → sf_queue_sdk-0.5.0}/queue_sdk/generated/__init__.py +0 -0
- {sf_queue_sdk-0.4.0b139 → sf_queue_sdk-0.5.0}/queue_sdk/generated/queue/email/v1/email_pb2.py +0 -0
- {sf_queue_sdk-0.4.0b139 → sf_queue_sdk-0.5.0}/queue_sdk/generated/queue/email/v1/email_pb2.pyi +0 -0
- {sf_queue_sdk-0.4.0b139 → sf_queue_sdk-0.5.0}/queue_sdk/generated/queue/prompt_ingest/v1/prompt_ingest_pb2.py +0 -0
- {sf_queue_sdk-0.4.0b139 → sf_queue_sdk-0.5.0}/queue_sdk/generated/queue/prompt_ingest/v1/prompt_ingest_pb2.pyi +0 -0
- {sf_queue_sdk-0.4.0b139 → sf_queue_sdk-0.5.0}/queue_sdk/generated/queue/tool_assignment/v1/tool_assignment_pb2.py +0 -0
- {sf_queue_sdk-0.4.0b139 → sf_queue_sdk-0.5.0}/queue_sdk/generated/queue/tool_assignment/v1/tool_assignment_pb2.pyi +0 -0
- {sf_queue_sdk-0.4.0b139 → sf_queue_sdk-0.5.0}/queue_sdk/generated/queue/topic_assignment/v1/topic_assignment_pb2.py +0 -0
- {sf_queue_sdk-0.4.0b139 → sf_queue_sdk-0.5.0}/queue_sdk/generated/queue/topic_assignment/v1/topic_assignment_pb2.pyi +0 -0
- {sf_queue_sdk-0.4.0b139 → sf_queue_sdk-0.5.0}/queue_sdk/query.py +0 -0
- {sf_queue_sdk-0.4.0b139 → sf_queue_sdk-0.5.0}/queue_sdk/queues/__init__.py +0 -0
- {sf_queue_sdk-0.4.0b139 → sf_queue_sdk-0.5.0}/queue_sdk/queues/document_notes_queue.py +0 -0
- {sf_queue_sdk-0.4.0b139 → sf_queue_sdk-0.5.0}/queue_sdk/queues/document_persist_queue.py +0 -0
- {sf_queue_sdk-0.4.0b139 → sf_queue_sdk-0.5.0}/queue_sdk/queues/email_queue.py +0 -0
- {sf_queue_sdk-0.4.0b139 → sf_queue_sdk-0.5.0}/queue_sdk/queues/pdf_to_image_queue.py +0 -0
- {sf_queue_sdk-0.4.0b139 → sf_queue_sdk-0.5.0}/queue_sdk/queues/prompt_ingest_queue.py +0 -0
- {sf_queue_sdk-0.4.0b139 → sf_queue_sdk-0.5.0}/queue_sdk/queues/recommendation_queue.py +0 -0
- {sf_queue_sdk-0.4.0b139 → sf_queue_sdk-0.5.0}/queue_sdk/queues/tool_assignment_queue.py +0 -0
- {sf_queue_sdk-0.4.0b139 → sf_queue_sdk-0.5.0}/queue_sdk/queues/topic_assignment_queue.py +0 -0
- {sf_queue_sdk-0.4.0b139 → sf_queue_sdk-0.5.0}/queue_sdk/sanitize.py +0 -0
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: sf-queue-sdk
|
|
3
|
+
Version: 0.5.0
|
|
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.
|
|
@@ -546,6 +554,33 @@ result = client.document_upload.send(
|
|
|
546
554
|
print("Enqueued:", result.message_id)
|
|
547
555
|
```
|
|
548
556
|
|
|
557
|
+
### Idempotent verified uploads
|
|
558
|
+
|
|
559
|
+
Pass `idempotency_key` when retrying a verified upload. Calls with the same
|
|
560
|
+
environment, queue, and key create one Redis stream entry and return the same
|
|
561
|
+
`message_id`.
|
|
562
|
+
|
|
563
|
+
```python
|
|
564
|
+
result = client.document_upload.send(
|
|
565
|
+
uploaded_file_id="file_abc123",
|
|
566
|
+
study_set_id="set_xyz",
|
|
567
|
+
user_id="user_123",
|
|
568
|
+
text_extract_s3_key="uploadedfiles/file_abc123/textContent",
|
|
569
|
+
pdf_s3_key="uploads/document.pdf",
|
|
570
|
+
file_type="application/pdf",
|
|
571
|
+
effective_file_type="application/pdf",
|
|
572
|
+
filename="Chapter 5 - Thermodynamics.pdf",
|
|
573
|
+
material_title="Thermodynamics",
|
|
574
|
+
url="https://storage.example.com/uploads/document.pdf",
|
|
575
|
+
generate_notes=True,
|
|
576
|
+
idempotency_key="verified-upload:file_abc123",
|
|
577
|
+
)
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
The key is scoped to `document-upload` and the configured environment. The SDK
|
|
581
|
+
stores a SHA-256 digest of the caller key in Redis and keeps the idempotency
|
|
582
|
+
mapping for seven days.
|
|
583
|
+
|
|
549
584
|
### With optional fields
|
|
550
585
|
|
|
551
586
|
```python
|
|
@@ -591,7 +626,7 @@ result = client.document_upload.send(
|
|
|
591
626
|
| `generate_notes` | `bool` | Yes | Whether to generate study notes |
|
|
592
627
|
| `generate_study_path` | `bool` | Yes | Whether to generate a study path |
|
|
593
628
|
| `splitting` | `str` | No | Splitting strategy for sources with at least 40,000 tokens (default: `"auto"`). Smaller sources always persist as one material after per-topic note generation. |
|
|
594
|
-
| `language` | `str` | No |
|
|
629
|
+
| `language` | `str` | No | Output language as an app ISO code, e.g. `"de"` — not a locale like `"de-DE"`. Note the non-standard `cz` (Czech) and `gr` (Greek). (default: `"en"`) |
|
|
595
630
|
| `use_markdown_ocr` | `bool` | No | Use Markdown OCR for image interpretation (default: `False`) |
|
|
596
631
|
| `flashcard_amount` | `int` | No | Number of flashcards to generate (default: `12`) |
|
|
597
632
|
| `new_url` | `Optional[str]` | No | Alternative URL if file was re-uploaded |
|
|
@@ -605,6 +640,14 @@ result = client.document_upload.send(
|
|
|
605
640
|
| `youtube_title` | `Optional[str]` | No | Title override for YouTube video transcripts |
|
|
606
641
|
| `skip_material_creation` | `Optional[bool]` | No | Skip folder and material creation when the material already exists (e.g. live lectures) |
|
|
607
642
|
| `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`) |
|
|
643
|
+
| `params` | `Optional[dict]` | No | Typed extensible flags for pipeline behavior (see below) |
|
|
644
|
+
| `idempotency_key` | `Optional[str]` | No | Caller key for retry-safe enqueueing. Duplicate environment/queue/key sends return the original `message_id`. |
|
|
645
|
+
|
|
646
|
+
#### `params` fields
|
|
647
|
+
|
|
648
|
+
| Field | Type | Description |
|
|
649
|
+
|-------|------|-------------|
|
|
650
|
+
| `liveLecture` | `bool` | When true, forces `generateNotes=false` and `skipMaterialCreation=true` in the pipeline. Clustering still runs to produce sections/topics for study path generation. Use with `existing_material_id` and `extracted_content`. |
|
|
608
651
|
|
|
609
652
|
### Response Type
|
|
610
653
|
|
|
@@ -787,7 +830,7 @@ result = client.document_persist.send(
|
|
|
787
830
|
| `study_set_id` | `str` | Yes | Study set to persist materials into |
|
|
788
831
|
| `user_id` | `str` | Yes | User who uploaded the document |
|
|
789
832
|
| `stash_key` | `str` | Yes | GCS key of the clustering stash JSON |
|
|
790
|
-
| `language` | `str` | No |
|
|
833
|
+
| `language` | `str` | No | Output language as an app ISO code, e.g. `"de"` — not a locale like `"de-DE"`. Note the non-standard `cz` (Czech) and `gr` (Greek). (default: `"en"`) |
|
|
791
834
|
| `callback_url` | `Optional[str]` | No | URL to POST results to after persistence |
|
|
792
835
|
| `callback_api_key` | `Optional[str]` | No | Auth key for the callback |
|
|
793
836
|
| `approved_topic_ids` | `Optional[list[str]]` | No | Filter to only these topic IDs from the stash (omit to persist all) |
|
|
@@ -1,11 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: sf-queue-sdk
|
|
3
|
-
Version: 0.4.0b139
|
|
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.
|
|
@@ -554,6 +546,33 @@ result = client.document_upload.send(
|
|
|
554
546
|
print("Enqueued:", result.message_id)
|
|
555
547
|
```
|
|
556
548
|
|
|
549
|
+
### Idempotent verified uploads
|
|
550
|
+
|
|
551
|
+
Pass `idempotency_key` when retrying a verified upload. Calls with the same
|
|
552
|
+
environment, queue, and key create one Redis stream entry and return the same
|
|
553
|
+
`message_id`.
|
|
554
|
+
|
|
555
|
+
```python
|
|
556
|
+
result = client.document_upload.send(
|
|
557
|
+
uploaded_file_id="file_abc123",
|
|
558
|
+
study_set_id="set_xyz",
|
|
559
|
+
user_id="user_123",
|
|
560
|
+
text_extract_s3_key="uploadedfiles/file_abc123/textContent",
|
|
561
|
+
pdf_s3_key="uploads/document.pdf",
|
|
562
|
+
file_type="application/pdf",
|
|
563
|
+
effective_file_type="application/pdf",
|
|
564
|
+
filename="Chapter 5 - Thermodynamics.pdf",
|
|
565
|
+
material_title="Thermodynamics",
|
|
566
|
+
url="https://storage.example.com/uploads/document.pdf",
|
|
567
|
+
generate_notes=True,
|
|
568
|
+
idempotency_key="verified-upload:file_abc123",
|
|
569
|
+
)
|
|
570
|
+
```
|
|
571
|
+
|
|
572
|
+
The key is scoped to `document-upload` and the configured environment. The SDK
|
|
573
|
+
stores a SHA-256 digest of the caller key in Redis and keeps the idempotency
|
|
574
|
+
mapping for seven days.
|
|
575
|
+
|
|
557
576
|
### With optional fields
|
|
558
577
|
|
|
559
578
|
```python
|
|
@@ -599,7 +618,7 @@ result = client.document_upload.send(
|
|
|
599
618
|
| `generate_notes` | `bool` | Yes | Whether to generate study notes |
|
|
600
619
|
| `generate_study_path` | `bool` | Yes | Whether to generate a study path |
|
|
601
620
|
| `splitting` | `str` | No | Splitting strategy for sources with at least 40,000 tokens (default: `"auto"`). Smaller sources always persist as one material after per-topic note generation. |
|
|
602
|
-
| `language` | `str` | No |
|
|
621
|
+
| `language` | `str` | No | Output language as an app ISO code, e.g. `"de"` — not a locale like `"de-DE"`. Note the non-standard `cz` (Czech) and `gr` (Greek). (default: `"en"`) |
|
|
603
622
|
| `use_markdown_ocr` | `bool` | No | Use Markdown OCR for image interpretation (default: `False`) |
|
|
604
623
|
| `flashcard_amount` | `int` | No | Number of flashcards to generate (default: `12`) |
|
|
605
624
|
| `new_url` | `Optional[str]` | No | Alternative URL if file was re-uploaded |
|
|
@@ -613,6 +632,14 @@ result = client.document_upload.send(
|
|
|
613
632
|
| `youtube_title` | `Optional[str]` | No | Title override for YouTube video transcripts |
|
|
614
633
|
| `skip_material_creation` | `Optional[bool]` | No | Skip folder and material creation when the material already exists (e.g. live lectures) |
|
|
615
634
|
| `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`) |
|
|
635
|
+
| `params` | `Optional[dict]` | No | Typed extensible flags for pipeline behavior (see below) |
|
|
636
|
+
| `idempotency_key` | `Optional[str]` | No | Caller key for retry-safe enqueueing. Duplicate environment/queue/key sends return the original `message_id`. |
|
|
637
|
+
|
|
638
|
+
#### `params` fields
|
|
639
|
+
|
|
640
|
+
| Field | Type | Description |
|
|
641
|
+
|-------|------|-------------|
|
|
642
|
+
| `liveLecture` | `bool` | When true, forces `generateNotes=false` and `skipMaterialCreation=true` in the pipeline. Clustering still runs to produce sections/topics for study path generation. Use with `existing_material_id` and `extracted_content`. |
|
|
616
643
|
|
|
617
644
|
### Response Type
|
|
618
645
|
|
|
@@ -795,7 +822,7 @@ result = client.document_persist.send(
|
|
|
795
822
|
| `study_set_id` | `str` | Yes | Study set to persist materials into |
|
|
796
823
|
| `user_id` | `str` | Yes | User who uploaded the document |
|
|
797
824
|
| `stash_key` | `str` | Yes | GCS key of the clustering stash JSON |
|
|
798
|
-
| `language` | `str` | No |
|
|
825
|
+
| `language` | `str` | No | Output language as an app ISO code, e.g. `"de"` — not a locale like `"de-DE"`. Note the non-standard `cz` (Czech) and `gr` (Greek). (default: `"en"`) |
|
|
799
826
|
| `callback_url` | `Optional[str]` | No | URL to POST results to after persistence |
|
|
800
827
|
| `callback_api_key` | `Optional[str]` | No | Auth key for the callback |
|
|
801
828
|
| `approved_topic_ids` | `Optional[list[str]]` | No | Filter to only these topic IDs from the stash (omit to persist all) |
|
|
@@ -7,10 +7,32 @@ from typing import Any, Optional
|
|
|
7
7
|
|
|
8
8
|
import redis as redis_lib
|
|
9
9
|
|
|
10
|
-
from queue_sdk.redis_client import response_key, stream_name
|
|
10
|
+
from queue_sdk.redis_client import idempotency_key, response_key, stream_name
|
|
11
11
|
from queue_sdk.types import EmailResponse
|
|
12
12
|
|
|
13
13
|
DEFAULT_TIMEOUT = 30 # seconds
|
|
14
|
+
IDEMPOTENCY_TTL_SECONDS = 7 * 24 * 60 * 60
|
|
15
|
+
IDEMPOTENT_ENQUEUE_SCRIPT = """
|
|
16
|
+
local existing_request_id = redis.call("GET", KEYS[2])
|
|
17
|
+
if existing_request_id then
|
|
18
|
+
return {existing_request_id, 0}
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
redis.call(
|
|
22
|
+
"XADD",
|
|
23
|
+
KEYS[1],
|
|
24
|
+
"*",
|
|
25
|
+
"requestId",
|
|
26
|
+
ARGV[1],
|
|
27
|
+
"payload",
|
|
28
|
+
ARGV[2],
|
|
29
|
+
"createdAt",
|
|
30
|
+
ARGV[3]
|
|
31
|
+
)
|
|
32
|
+
redis.call("SET", KEYS[2], ARGV[1], "EX", ARGV[4])
|
|
33
|
+
|
|
34
|
+
return {ARGV[1], 1}
|
|
35
|
+
"""
|
|
14
36
|
|
|
15
37
|
|
|
16
38
|
class BaseQueue:
|
|
@@ -35,6 +57,35 @@ class BaseQueue:
|
|
|
35
57
|
self._redis.xadd(self._stream, message)
|
|
36
58
|
return request_id
|
|
37
59
|
|
|
60
|
+
def _enqueue_idempotently(
|
|
61
|
+
self, payload: dict[str, Any], caller_key: str
|
|
62
|
+
) -> tuple[str, bool]:
|
|
63
|
+
"""Add one stream entry for a caller key."""
|
|
64
|
+
if not caller_key.strip():
|
|
65
|
+
raise ValueError("idempotency_key must not be empty")
|
|
66
|
+
|
|
67
|
+
request_id = str(uuid.uuid4())
|
|
68
|
+
created_at = datetime.now(timezone.utc).isoformat()
|
|
69
|
+
result = self._redis.eval(
|
|
70
|
+
IDEMPOTENT_ENQUEUE_SCRIPT,
|
|
71
|
+
2,
|
|
72
|
+
self._stream,
|
|
73
|
+
idempotency_key(self._environment, self._queue_name, caller_key),
|
|
74
|
+
request_id,
|
|
75
|
+
json.dumps(payload),
|
|
76
|
+
created_at,
|
|
77
|
+
IDEMPOTENCY_TTL_SECONDS,
|
|
78
|
+
)
|
|
79
|
+
if (
|
|
80
|
+
not isinstance(result, list)
|
|
81
|
+
or len(result) != 2
|
|
82
|
+
or not isinstance(result[0], str)
|
|
83
|
+
or result[1] not in (0, 1)
|
|
84
|
+
):
|
|
85
|
+
raise RuntimeError("Redis returned an invalid idempotent enqueue result")
|
|
86
|
+
|
|
87
|
+
return result[0], result[1] == 1
|
|
88
|
+
|
|
38
89
|
def _enqueue_and_wait(
|
|
39
90
|
self, payload: dict[str, Any], timeout: Optional[int] = None
|
|
40
91
|
) -> EmailResponse:
|
|
@@ -52,6 +52,8 @@ class DocumentUploadQueue(BaseQueue):
|
|
|
52
52
|
study_plan_hook: Optional[bool] = None,
|
|
53
53
|
skip_material_creation: Optional[bool] = None,
|
|
54
54
|
existing_material_id: Optional[str] = None,
|
|
55
|
+
params: Optional[dict] = None,
|
|
56
|
+
idempotency_key: Optional[str] = None,
|
|
55
57
|
) -> SendResult:
|
|
56
58
|
"""Enqueue a document upload processing request (fire and forget)."""
|
|
57
59
|
self._validate(uploaded_file_id, study_set_id, user_id, pdf_s3_key, filename)
|
|
@@ -88,9 +90,18 @@ class DocumentUploadQueue(BaseQueue):
|
|
|
88
90
|
study_plan_hook=study_plan_hook,
|
|
89
91
|
skip_material_creation=skip_material_creation,
|
|
90
92
|
existing_material_id=existing_material_id,
|
|
93
|
+
params=params,
|
|
91
94
|
)
|
|
95
|
+
if idempotency_key is not None:
|
|
96
|
+
message_id, enqueued = self._enqueue_idempotently(payload, idempotency_key)
|
|
97
|
+
if enqueued:
|
|
98
|
+
self._set_initial_status(message_id)
|
|
99
|
+
|
|
100
|
+
return SendResult(message_id=message_id)
|
|
101
|
+
|
|
92
102
|
message_id = self._enqueue(payload)
|
|
93
103
|
self._set_initial_status(message_id)
|
|
104
|
+
|
|
94
105
|
return SendResult(message_id=message_id)
|
|
95
106
|
|
|
96
107
|
def get_status(self, request_id: str) -> DocumentUploadStatus:
|
|
@@ -174,6 +185,7 @@ class DocumentUploadQueue(BaseQueue):
|
|
|
174
185
|
study_plan_hook: Optional[bool],
|
|
175
186
|
skip_material_creation: Optional[bool],
|
|
176
187
|
existing_material_id: Optional[str],
|
|
188
|
+
params: Optional[dict],
|
|
177
189
|
) -> dict[str, Any]:
|
|
178
190
|
"""Build the document upload payload (snake_case -> camelCase)."""
|
|
179
191
|
payload: dict[str, Any] = {
|
|
@@ -227,6 +239,8 @@ class DocumentUploadQueue(BaseQueue):
|
|
|
227
239
|
payload["skipMaterialCreation"] = skip_material_creation
|
|
228
240
|
if existing_material_id is not None:
|
|
229
241
|
payload["existingMaterialId"] = existing_material_id
|
|
242
|
+
if params is not None:
|
|
243
|
+
payload["params"] = params
|
|
230
244
|
|
|
231
245
|
return payload
|
|
232
246
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Redis connection factory."""
|
|
2
2
|
|
|
3
|
+
import hashlib
|
|
3
4
|
from urllib.parse import urlparse
|
|
4
5
|
|
|
5
6
|
import redis
|
|
@@ -45,6 +46,15 @@ def stream_name(env: str, base: str) -> str:
|
|
|
45
46
|
return f"{env}:{base}"
|
|
46
47
|
|
|
47
48
|
|
|
49
|
+
def idempotency_key(env: str, queue: str, caller_key: str) -> str:
|
|
50
|
+
"""Return a bounded key in the queue's Redis Cluster hash slot."""
|
|
51
|
+
digest = hashlib.sha256(caller_key.encode("utf-8")).hexdigest()
|
|
52
|
+
key = f"{{{queue}}}:idempotency:{digest}"
|
|
53
|
+
if not env:
|
|
54
|
+
return key
|
|
55
|
+
return f"{env}:{key}"
|
|
56
|
+
|
|
57
|
+
|
|
48
58
|
def response_key(env: str, queue: str, request_id: str) -> str:
|
|
49
59
|
"""Return the response list key for a given queue and request ID."""
|
|
50
60
|
key = f"{queue}:response:{request_id}"
|
|
@@ -173,6 +173,14 @@ class BatchRecommendationResponse:
|
|
|
173
173
|
processed_at: Optional[str] = None
|
|
174
174
|
|
|
175
175
|
|
|
176
|
+
@dataclass
|
|
177
|
+
class DocumentUploadParams:
|
|
178
|
+
"""Extensible flags for pipeline behavior."""
|
|
179
|
+
|
|
180
|
+
live_lecture: Optional[bool] = None
|
|
181
|
+
source: Optional[str] = None
|
|
182
|
+
|
|
183
|
+
|
|
176
184
|
@dataclass
|
|
177
185
|
class DocumentUploadData:
|
|
178
186
|
"""Data for a document upload processing request."""
|
|
@@ -206,6 +214,7 @@ class DocumentUploadData:
|
|
|
206
214
|
callback_url: Optional[str] = None
|
|
207
215
|
callback_api_key: Optional[str] = None
|
|
208
216
|
study_plan_hook: Optional[bool] = None
|
|
217
|
+
params: Optional[DocumentUploadParams] = None
|
|
209
218
|
|
|
210
219
|
|
|
211
220
|
@dataclass
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Focused idempotent enqueue tests."""
|
|
2
|
+
|
|
3
|
+
import unittest
|
|
4
|
+
|
|
5
|
+
from queue_sdk.queues.document_upload_queue import DocumentUploadQueue
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FakeRedis:
|
|
9
|
+
"""Redis test double for the idempotent enqueue script."""
|
|
10
|
+
|
|
11
|
+
def __init__(self) -> None:
|
|
12
|
+
self.idempotency: dict[str, str] = {}
|
|
13
|
+
self.entries: list[dict[str, str]] = []
|
|
14
|
+
self.status_writes = 0
|
|
15
|
+
|
|
16
|
+
def eval(
|
|
17
|
+
self,
|
|
18
|
+
_script: str,
|
|
19
|
+
key_count: int,
|
|
20
|
+
stream: str,
|
|
21
|
+
deduplication_key: str,
|
|
22
|
+
request_id: str,
|
|
23
|
+
payload: str,
|
|
24
|
+
created_at: str,
|
|
25
|
+
ttl_seconds: int,
|
|
26
|
+
) -> list[str | int]:
|
|
27
|
+
if key_count != 2:
|
|
28
|
+
raise AssertionError("expected two Redis keys")
|
|
29
|
+
if "{document-upload}" not in stream:
|
|
30
|
+
raise AssertionError("stream is missing the queue hash tag")
|
|
31
|
+
if "{document-upload}:idempotency:" not in deduplication_key:
|
|
32
|
+
raise AssertionError("idempotency key is in a different hash slot")
|
|
33
|
+
if ttl_seconds != 7 * 24 * 60 * 60:
|
|
34
|
+
raise AssertionError("unexpected idempotency key TTL")
|
|
35
|
+
|
|
36
|
+
existing_request_id = self.idempotency.get(deduplication_key)
|
|
37
|
+
if existing_request_id is not None:
|
|
38
|
+
return [existing_request_id, 0]
|
|
39
|
+
|
|
40
|
+
self.entries.append(
|
|
41
|
+
{
|
|
42
|
+
"stream": stream,
|
|
43
|
+
"request_id": request_id,
|
|
44
|
+
"payload": payload,
|
|
45
|
+
"created_at": created_at,
|
|
46
|
+
}
|
|
47
|
+
)
|
|
48
|
+
self.idempotency[deduplication_key] = request_id
|
|
49
|
+
|
|
50
|
+
return [request_id, 1]
|
|
51
|
+
|
|
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
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def send_upload(
|
|
60
|
+
queue: DocumentUploadQueue, idempotency_key: str
|
|
61
|
+
):
|
|
62
|
+
"""Send the minimum valid document upload payload."""
|
|
63
|
+
return queue.send(
|
|
64
|
+
uploaded_file_id="file-1",
|
|
65
|
+
study_set_id="set-1",
|
|
66
|
+
user_id="user-1",
|
|
67
|
+
text_extract_s3_key="text/key",
|
|
68
|
+
pdf_s3_key="pdf/key",
|
|
69
|
+
file_type="application/pdf",
|
|
70
|
+
effective_file_type="application/pdf",
|
|
71
|
+
filename="notes.pdf",
|
|
72
|
+
material_title="Notes",
|
|
73
|
+
url="https://example.com/notes.pdf",
|
|
74
|
+
generate_notes=True,
|
|
75
|
+
idempotency_key=idempotency_key,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class IdempotentDocumentUploadTests(unittest.TestCase):
|
|
80
|
+
"""Document upload idempotency contract."""
|
|
81
|
+
|
|
82
|
+
def setUp(self) -> None:
|
|
83
|
+
self.redis = FakeRedis()
|
|
84
|
+
self.queue = DocumentUploadQueue(self.redis, "staging")
|
|
85
|
+
|
|
86
|
+
def test_duplicate_key_enqueues_once_and_returns_one_request_id(self) -> None:
|
|
87
|
+
first = send_upload(self.queue, "verified-upload-1")
|
|
88
|
+
duplicate = send_upload(self.queue, "verified-upload-1")
|
|
89
|
+
|
|
90
|
+
self.assertEqual(duplicate.message_id, first.message_id)
|
|
91
|
+
self.assertEqual(len(self.redis.entries), 1)
|
|
92
|
+
self.assertEqual(self.redis.status_writes, 1)
|
|
93
|
+
|
|
94
|
+
def test_different_keys_create_different_stream_entries(self) -> None:
|
|
95
|
+
first = send_upload(self.queue, "verified-upload-1")
|
|
96
|
+
second = send_upload(self.queue, "verified-upload-2")
|
|
97
|
+
|
|
98
|
+
self.assertNotEqual(second.message_id, first.message_id)
|
|
99
|
+
self.assertEqual(len(self.redis.entries), 2)
|
|
100
|
+
|
|
101
|
+
def test_empty_key_is_rejected(self) -> None:
|
|
102
|
+
with self.assertRaisesRegex(ValueError, "idempotency_key must not be empty"):
|
|
103
|
+
send_upload(self.queue, " ")
|
|
104
|
+
|
|
105
|
+
self.assertEqual(len(self.redis.entries), 0)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
if __name__ == "__main__":
|
|
109
|
+
unittest.main()
|
|
File without changes
|
|
File without changes
|
{sf_queue_sdk-0.4.0b139 → sf_queue_sdk-0.5.0}/queue_sdk/generated/queue/email/v1/email_pb2.py
RENAMED
|
File without changes
|
{sf_queue_sdk-0.4.0b139 → sf_queue_sdk-0.5.0}/queue_sdk/generated/queue/email/v1/email_pb2.pyi
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|