sf-queue-sdk 0.5.0b4__tar.gz → 0.5.0b30__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.0b4 → sf_queue_sdk-0.5.0b30}/.gitignore +1 -0
  2. sf_queue_sdk-0.5.0b4/README.md → sf_queue_sdk-0.5.0b30/PKG-INFO +36 -0
  3. sf_queue_sdk-0.5.0b4/PKG-INFO → sf_queue_sdk-0.5.0b30/README.md +28 -8
  4. {sf_queue_sdk-0.5.0b4 → sf_queue_sdk-0.5.0b30}/pyproject.toml +1 -1
  5. {sf_queue_sdk-0.5.0b4 → sf_queue_sdk-0.5.0b30}/queue_sdk/__init__.py +1 -1
  6. {sf_queue_sdk-0.5.0b4 → sf_queue_sdk-0.5.0b30}/queue_sdk/generated/queue/email/v1/email_pb2.py +3 -3
  7. {sf_queue_sdk-0.5.0b4 → sf_queue_sdk-0.5.0b30}/queue_sdk/generated/queue/prompt_ingest/v1/prompt_ingest_pb2.py +3 -3
  8. {sf_queue_sdk-0.5.0b4 → sf_queue_sdk-0.5.0b30}/queue_sdk/generated/queue/tool_assignment/v1/tool_assignment_pb2.py +3 -3
  9. {sf_queue_sdk-0.5.0b4 → sf_queue_sdk-0.5.0b30}/queue_sdk/generated/queue/topic_assignment/v1/topic_assignment_pb2.py +3 -3
  10. {sf_queue_sdk-0.5.0b4 → sf_queue_sdk-0.5.0b30}/queue_sdk/queues/base_queue.py +52 -1
  11. {sf_queue_sdk-0.5.0b4 → sf_queue_sdk-0.5.0b30}/queue_sdk/queues/document_upload_queue.py +9 -0
  12. {sf_queue_sdk-0.5.0b4 → sf_queue_sdk-0.5.0b30}/queue_sdk/redis_client.py +10 -0
  13. {sf_queue_sdk-0.5.0b4 → sf_queue_sdk-0.5.0b30}/queue_sdk/types.py +1 -0
  14. sf_queue_sdk-0.5.0b30/tests/test_idempotency.py +109 -0
  15. {sf_queue_sdk-0.5.0b4 → sf_queue_sdk-0.5.0b30}/queue_sdk/client.py +0 -0
  16. {sf_queue_sdk-0.5.0b4 → sf_queue_sdk-0.5.0b30}/queue_sdk/generated/__init__.py +0 -0
  17. {sf_queue_sdk-0.5.0b4 → sf_queue_sdk-0.5.0b30}/queue_sdk/generated/queue/email/v1/email_pb2.pyi +0 -0
  18. {sf_queue_sdk-0.5.0b4 → sf_queue_sdk-0.5.0b30}/queue_sdk/generated/queue/prompt_ingest/v1/prompt_ingest_pb2.pyi +0 -0
  19. {sf_queue_sdk-0.5.0b4 → sf_queue_sdk-0.5.0b30}/queue_sdk/generated/queue/tool_assignment/v1/tool_assignment_pb2.pyi +0 -0
  20. {sf_queue_sdk-0.5.0b4 → sf_queue_sdk-0.5.0b30}/queue_sdk/generated/queue/topic_assignment/v1/topic_assignment_pb2.pyi +0 -0
  21. {sf_queue_sdk-0.5.0b4 → sf_queue_sdk-0.5.0b30}/queue_sdk/query.py +0 -0
  22. {sf_queue_sdk-0.5.0b4 → sf_queue_sdk-0.5.0b30}/queue_sdk/queues/__init__.py +0 -0
  23. {sf_queue_sdk-0.5.0b4 → sf_queue_sdk-0.5.0b30}/queue_sdk/queues/document_notes_queue.py +0 -0
  24. {sf_queue_sdk-0.5.0b4 → sf_queue_sdk-0.5.0b30}/queue_sdk/queues/document_persist_queue.py +0 -0
  25. {sf_queue_sdk-0.5.0b4 → sf_queue_sdk-0.5.0b30}/queue_sdk/queues/email_queue.py +0 -0
  26. {sf_queue_sdk-0.5.0b4 → sf_queue_sdk-0.5.0b30}/queue_sdk/queues/pdf_to_image_queue.py +0 -0
  27. {sf_queue_sdk-0.5.0b4 → sf_queue_sdk-0.5.0b30}/queue_sdk/queues/prompt_ingest_queue.py +0 -0
  28. {sf_queue_sdk-0.5.0b4 → sf_queue_sdk-0.5.0b30}/queue_sdk/queues/recommendation_queue.py +0 -0
  29. {sf_queue_sdk-0.5.0b4 → sf_queue_sdk-0.5.0b30}/queue_sdk/queues/tool_assignment_queue.py +0 -0
  30. {sf_queue_sdk-0.5.0b4 → sf_queue_sdk-0.5.0b30}/queue_sdk/queues/topic_assignment_queue.py +0 -0
  31. {sf_queue_sdk-0.5.0b4 → sf_queue_sdk-0.5.0b30}/queue_sdk/sanitize.py +0 -0
@@ -65,3 +65,4 @@ scripts/docx/
65
65
  scripts/mp3-results/
66
66
  scripts/studyset/.env
67
67
  scripts/studyset/downloads/
68
+ scripts/failed-files/
@@ -1,3 +1,11 @@
1
+ Metadata-Version: 2.5
2
+ Name: sf-queue-sdk
3
+ Version: 0.5.0b30
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
@@ -606,6 +641,7 @@ result = client.document_upload.send(
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`) |
608
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`. |
609
645
 
610
646
  #### `params` fields
611
647
 
@@ -1,11 +1,3 @@
1
- Metadata-Version: 2.5
2
- Name: sf-queue-sdk
3
- Version: 0.5.0b4
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
@@ -614,6 +633,7 @@ result = client.document_upload.send(
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`) |
616
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`. |
617
637
 
618
638
  #### `params` fields
619
639
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sf-queue-sdk"
3
- version = "0.5.0b4"
3
+ version = "0.5.0b30"
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.0b4"
3
+ __version__ = "0.5.0b30"
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.35.1
5
+ # Protobuf Python Version: 7.36.0
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
- 35,
16
- 1,
15
+ 36,
16
+ 0,
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.35.1
5
+ # Protobuf Python Version: 7.36.0
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
- 35,
16
- 1,
15
+ 36,
16
+ 0,
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.35.1
5
+ # Protobuf Python Version: 7.36.0
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
- 35,
16
- 1,
15
+ 36,
16
+ 0,
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.35.1
5
+ # Protobuf Python Version: 7.36.0
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
- 35,
16
- 1,
15
+ 36,
16
+ 0,
17
17
  '',
18
18
  'queue/topic_assignment/v1/topic_assignment.proto'
19
19
  )
@@ -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:
@@ -53,6 +53,7 @@ class DocumentUploadQueue(BaseQueue):
53
53
  skip_material_creation: Optional[bool] = None,
54
54
  existing_material_id: Optional[str] = None,
55
55
  params: Optional[dict] = None,
56
+ idempotency_key: Optional[str] = None,
56
57
  ) -> SendResult:
57
58
  """Enqueue a document upload processing request (fire and forget)."""
58
59
  self._validate(uploaded_file_id, study_set_id, user_id, pdf_s3_key, filename)
@@ -91,8 +92,16 @@ class DocumentUploadQueue(BaseQueue):
91
92
  existing_material_id=existing_material_id,
92
93
  params=params,
93
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
+
94
102
  message_id = self._enqueue(payload)
95
103
  self._set_initial_status(message_id)
104
+
96
105
  return SendResult(message_id=message_id)
97
106
 
98
107
  def get_status(self, request_id: str) -> DocumentUploadStatus:
@@ -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}"
@@ -178,6 +178,7 @@ class DocumentUploadParams:
178
178
  """Extensible flags for pipeline behavior."""
179
179
 
180
180
  live_lecture: Optional[bool] = None
181
+ source: Optional[str] = None
181
182
 
182
183
 
183
184
  @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()