prefactor-core 0.2.1__tar.gz → 0.2.2__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 (27) hide show
  1. {prefactor_core-0.2.1 → prefactor_core-0.2.2}/PKG-INFO +2 -2
  2. {prefactor_core-0.2.1 → prefactor_core-0.2.2}/pyproject.toml +2 -2
  3. {prefactor_core-0.2.1 → prefactor_core-0.2.2}/src/prefactor_core/__init__.py +4 -0
  4. {prefactor_core-0.2.1 → prefactor_core-0.2.2}/src/prefactor_core/client.py +9 -3
  5. {prefactor_core-0.2.1 → prefactor_core-0.2.2}/src/prefactor_core/managers/agent_instance.py +15 -5
  6. {prefactor_core-0.2.1 → prefactor_core-0.2.2}/src/prefactor_core/managers/span.py +15 -5
  7. {prefactor_core-0.2.1 → prefactor_core-0.2.2}/src/prefactor_core/operations.py +2 -0
  8. prefactor_core-0.2.2/src/prefactor_core/utils.py +41 -0
  9. prefactor_core-0.2.2/tests/test_span_manager.py +85 -0
  10. prefactor_core-0.2.2/tests/test_utils.py +46 -0
  11. {prefactor_core-0.2.1 → prefactor_core-0.2.2}/.gitignore +0 -0
  12. {prefactor_core-0.2.1 → prefactor_core-0.2.2}/README.md +0 -0
  13. {prefactor_core-0.2.1 → prefactor_core-0.2.2}/examples/agent_e2e.py +0 -0
  14. {prefactor_core-0.2.1 → prefactor_core-0.2.2}/src/prefactor_core/config.py +0 -0
  15. {prefactor_core-0.2.1 → prefactor_core-0.2.2}/src/prefactor_core/context_stack.py +0 -0
  16. {prefactor_core-0.2.1 → prefactor_core-0.2.2}/src/prefactor_core/exceptions.py +0 -0
  17. {prefactor_core-0.2.1 → prefactor_core-0.2.2}/src/prefactor_core/managers/__init__.py +0 -0
  18. {prefactor_core-0.2.1 → prefactor_core-0.2.2}/src/prefactor_core/models.py +0 -0
  19. {prefactor_core-0.2.1 → prefactor_core-0.2.2}/src/prefactor_core/queue/__init__.py +0 -0
  20. {prefactor_core-0.2.1 → prefactor_core-0.2.2}/src/prefactor_core/queue/base.py +0 -0
  21. {prefactor_core-0.2.1 → prefactor_core-0.2.2}/src/prefactor_core/queue/executor.py +0 -0
  22. {prefactor_core-0.2.1 → prefactor_core-0.2.2}/src/prefactor_core/queue/memory.py +0 -0
  23. {prefactor_core-0.2.1 → prefactor_core-0.2.2}/src/prefactor_core/schema_registry.py +0 -0
  24. {prefactor_core-0.2.1 → prefactor_core-0.2.2}/src/prefactor_core/span_context.py +0 -0
  25. {prefactor_core-0.2.1 → prefactor_core-0.2.2}/tests/test_client.py +0 -0
  26. {prefactor_core-0.2.1 → prefactor_core-0.2.2}/tests/test_imports.py +0 -0
  27. {prefactor_core-0.2.1 → prefactor_core-0.2.2}/tests/test_queue.py +0 -0
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: prefactor-core
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: Core Prefactor SDK with async queue-based operations
5
5
  Author-email: Prefactor Pty Ltd <josh@prefactor.tech>
6
6
  License: MIT
7
7
  Requires-Python: <4.0.0,>=3.11.0
8
- Requires-Dist: prefactor-http>=0.1.0
8
+ Requires-Dist: prefactor-http>=0.1.1
9
9
  Requires-Dist: pydantic>=2.0.0
10
10
  Description-Content-Type: text/markdown
11
11
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "prefactor-core"
3
- version = "0.2.1"
3
+ version = "0.2.2"
4
4
  description = "Core Prefactor SDK with async queue-based operations"
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -9,7 +9,7 @@ authors = [
9
9
  ]
10
10
  requires-python = ">=3.11.0, <4.0.0"
11
11
  dependencies = [
12
- "prefactor-http>=0.1.0",
12
+ "prefactor-http>=0.1.1",
13
13
  "pydantic>=2.0.0",
14
14
  ]
15
15
 
@@ -20,6 +20,7 @@ from .operations import Operation, OperationType
20
20
  from .queue import InMemoryQueue, Queue, QueueClosedError, TaskExecutor
21
21
  from .schema_registry import SchemaRegistry
22
22
  from .span_context import SpanContext
23
+ from .utils import generate_idempotency_key, validate_idempotency_key
23
24
 
24
25
  __version__ = "0.2.1"
25
26
 
@@ -54,4 +55,7 @@ __all__ = [
54
55
  "AgentInstanceHandle",
55
56
  # Schema Registry
56
57
  "SchemaRegistry",
58
+ # Utils
59
+ "generate_idempotency_key",
60
+ "validate_idempotency_key",
57
61
  ]
@@ -188,12 +188,14 @@ class PrefactorCoreClient:
188
188
  await self._http.agent_instances.start(
189
189
  agent_instance_id=operation.payload["instance_id"],
190
190
  timestamp=operation.timestamp,
191
+ idempotency_key=operation.payload.get("idempotency_key"),
191
192
  )
192
193
 
193
194
  elif operation.type == OperationType.FINISH_AGENT_INSTANCE:
194
195
  await self._http.agent_instances.finish(
195
196
  agent_instance_id=operation.payload["instance_id"],
196
197
  timestamp=operation.timestamp,
198
+ idempotency_key=operation.payload.get("idempotency_key"),
197
199
  )
198
200
  elif operation.type == OperationType.CREATE_SPAN:
199
201
  await self._http.agent_spans.create(
@@ -211,16 +213,22 @@ class PrefactorCoreClient:
211
213
  status=operation.payload.get("status", "complete"),
212
214
  result_payload=operation.payload.get("result_payload"),
213
215
  timestamp=operation.timestamp,
216
+ idempotency_key=operation.payload.get("idempotency_key"),
214
217
  )
215
218
 
216
219
  except Exception as e:
217
- # Log error but don't re-raise - we don't want to crash the worker
220
+ # Log error and re-raise so TaskExecutor retries can run
218
221
  logger.error(
219
222
  f"Failed to process operation {operation.type}: {e}",
220
223
  exc_info=True,
221
224
  )
222
225
  raise
223
226
 
227
+ @property
228
+ def instance_manager(self) -> AgentInstanceManager | None:
229
+ """Public accessor for the agent instance manager."""
230
+ return self._instance_manager
231
+
224
232
  async def create_agent_instance(
225
233
  self,
226
234
  agent_id: str,
@@ -344,7 +352,6 @@ class PrefactorCoreClient:
344
352
  instance_id: str,
345
353
  schema_name: str,
346
354
  parent_span_id: str | None = None,
347
- span_id: str | None = None,
348
355
  payload: dict[str, Any] | None = None,
349
356
  ):
350
357
  """Context manager for creating and finishing a span.
@@ -366,7 +373,6 @@ class PrefactorCoreClient:
366
373
  instance_id: ID of the agent instance this span belongs to.
367
374
  schema_name: Name of the schema for this span.
368
375
  parent_span_id: Optional explicit parent span ID.
369
- span_id: Ignored (API generates IDs).
370
376
  payload: Optional initial payload sent via auto-start on exit
371
377
  if ``start()`` is never called explicitly.
372
378
 
@@ -10,6 +10,7 @@ from datetime import datetime, timezone
10
10
  from typing import TYPE_CHECKING, Any, Awaitable, Callable
11
11
 
12
12
  from ..operations import Operation, OperationType
13
+ from ..utils import generate_idempotency_key
13
14
 
14
15
  if TYPE_CHECKING:
15
16
  from prefactor_http.client import PrefactorHttpClient
@@ -71,7 +72,9 @@ class AgentInstanceManager:
71
72
  agent_id: ID of the agent to create an instance for.
72
73
  agent_version: Version information (name, external_identifier, etc.).
73
74
  agent_schema_version: Schema version information.
74
- instance_id: Ignored (API generates IDs with correct partition).
75
+ instance_id: Optional ID to forward to the API as ``id``. When
76
+ provided, the API uses it as the instance ID; when omitted,
77
+ the API generates one.
75
78
 
76
79
  Returns:
77
80
  The instance ID (API-generated).
@@ -81,6 +84,7 @@ class AgentInstanceManager:
81
84
  agent_version=agent_version,
82
85
  agent_schema_version=agent_schema_version,
83
86
  id=instance_id,
87
+ idempotency_key=generate_idempotency_key(),
84
88
  )
85
89
  return result.id
86
90
 
@@ -94,7 +98,10 @@ class AgentInstanceManager:
94
98
  """
95
99
  operation = Operation(
96
100
  type=OperationType.START_AGENT_INSTANCE,
97
- payload={"instance_id": instance_id},
101
+ payload={
102
+ "instance_id": instance_id,
103
+ "idempotency_key": generate_idempotency_key(),
104
+ },
98
105
  timestamp=datetime.now(timezone.utc),
99
106
  )
100
107
 
@@ -110,7 +117,10 @@ class AgentInstanceManager:
110
117
  """
111
118
  operation = Operation(
112
119
  type=OperationType.FINISH_AGENT_INSTANCE,
113
- payload={"instance_id": instance_id},
120
+ payload={
121
+ "instance_id": instance_id,
122
+ "idempotency_key": generate_idempotency_key(),
123
+ },
114
124
  timestamp=datetime.now(timezone.utc),
115
125
  )
116
126
 
@@ -169,7 +179,7 @@ class AgentInstanceHandle:
169
179
  if self._started:
170
180
  return
171
181
 
172
- manager = self._client._instance_manager
182
+ manager = self._client.instance_manager
173
183
  assert manager is not None
174
184
  await manager.start(self._instance_id)
175
185
  self._started = True
@@ -182,7 +192,7 @@ class AgentInstanceHandle:
182
192
  if self._finished:
183
193
  return
184
194
 
185
- manager = self._client._instance_manager
195
+ manager = self._client.instance_manager
186
196
  assert manager is not None
187
197
  await manager.finish(self._instance_id)
188
198
  self._finished = True
@@ -5,13 +5,13 @@ calls into Operation objects that are queued for processing. It also manages
5
5
  the span stack for automatic parent detection.
6
6
  """
7
7
 
8
- import uuid
9
8
  from datetime import datetime, timezone
10
9
  from typing import TYPE_CHECKING, Any, Awaitable, Callable
11
10
 
12
11
  from ..context_stack import SpanContextStack
13
12
  from ..models import Span
14
13
  from ..operations import Operation, OperationType
14
+ from ..utils import generate_idempotency_key
15
15
 
16
16
  if TYPE_CHECKING:
17
17
  from prefactor_http.client import PrefactorHttpClient
@@ -90,7 +90,7 @@ class SpanManager:
90
90
  if parent_span_id is None:
91
91
  parent_span_id = SpanContextStack.peek()
92
92
 
93
- temp_id = str(uuid.uuid4())
93
+ temp_id = generate_idempotency_key()
94
94
 
95
95
  span = Span(
96
96
  id=temp_id,
@@ -139,6 +139,7 @@ class SpanManager:
139
139
  status="active",
140
140
  payload=payload or {},
141
141
  parent_span_id=span.parent_span_id,
142
+ idempotency_key=generate_idempotency_key(),
142
143
  )
143
144
 
144
145
  api_id = result.id
@@ -185,12 +186,14 @@ class SpanManager:
185
186
  status="pending",
186
187
  payload={},
187
188
  parent_span_id=span.parent_span_id,
189
+ idempotency_key=generate_idempotency_key(),
188
190
  )
189
191
  api_id = result.id
190
192
 
191
193
  await self._http.agent_spans.finish(
192
194
  agent_span_id=api_id,
193
195
  status="cancelled",
196
+ idempotency_key=generate_idempotency_key(),
194
197
  )
195
198
 
196
199
  span.status = "cancelled"
@@ -261,10 +264,17 @@ class SpanManager:
261
264
  self._spans[span_id].status = status
262
265
  self._spans[span_id].finished_at = datetime.now(timezone.utc)
263
266
 
264
- if SpanContextStack.peek() == span_id:
265
- SpanContextStack.pop()
267
+ stack = SpanContextStack.get_stack()
268
+ if span_id in stack:
269
+ from ..context_stack import _current_span_stack
270
+
271
+ _current_span_stack.set([s for s in stack if s != span_id])
266
272
 
267
- op_payload: dict[str, Any] = {"span_id": span_id, "status": status}
273
+ op_payload: dict[str, Any] = {
274
+ "span_id": span_id,
275
+ "status": status,
276
+ "idempotency_key": generate_idempotency_key(),
277
+ }
268
278
  if result_payload is not None:
269
279
  op_payload["result_payload"] = result_payload
270
280
 
@@ -38,6 +38,8 @@ class Operation:
38
38
  metadata: Optional additional metadata.
39
39
 
40
40
  Example:
41
+ from datetime import datetime, timezone
42
+
41
43
  operation = Operation(
42
44
  type=OperationType.CREATE_SPAN,
43
45
  payload={
@@ -0,0 +1,41 @@
1
+ """Utility functions for prefactor-core."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+
7
+
8
+ def generate_idempotency_key() -> str:
9
+ """Generate a new UUID-based idempotency key.
10
+
11
+ The returned key is a UUID4 string (36 characters), always within the
12
+ 64-character API limit.
13
+
14
+ Returns:
15
+ A unique idempotency key string.
16
+ """
17
+ return str(uuid.uuid4())
18
+
19
+
20
+ def validate_idempotency_key(key: str) -> str:
21
+ """Validate that an idempotency key is a non-empty string of at most 64 characters.
22
+
23
+ Args:
24
+ key: The idempotency key to validate.
25
+
26
+ Returns:
27
+ The key unchanged if valid.
28
+
29
+ Raises:
30
+ ValueError: If the key is empty or exceeds 64 characters.
31
+ """
32
+ if not key:
33
+ raise ValueError("Idempotency key must not be empty")
34
+ if len(key) > 64:
35
+ raise ValueError(
36
+ f"Idempotency key must be at most 64 characters, got {len(key)}"
37
+ )
38
+ return key
39
+
40
+
41
+ __all__ = ["generate_idempotency_key", "validate_idempotency_key"]
@@ -0,0 +1,85 @@
1
+ """Tests for SpanManager idempotency key auto-generation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from unittest.mock import AsyncMock, MagicMock
7
+
8
+ import pytest
9
+ from prefactor_core.managers.span import SpanManager
10
+
11
+
12
+ def _make_span_result(span_id: str = "api-span-id") -> MagicMock:
13
+ result = MagicMock()
14
+ result.id = span_id
15
+ return result
16
+
17
+
18
+ @pytest.fixture
19
+ def http_client():
20
+ """Return a mock HTTP client with stubbed agent_spans create/finish methods."""
21
+ client = MagicMock()
22
+ client.agent_spans = MagicMock()
23
+ client.agent_spans.create = AsyncMock(return_value=_make_span_result())
24
+ client.agent_spans.finish = AsyncMock(return_value=_make_span_result())
25
+ return client
26
+
27
+
28
+ @pytest.fixture
29
+ def enqueue():
30
+ """Return an async mock for the enqueue callback."""
31
+ return AsyncMock()
32
+
33
+
34
+ @pytest.fixture
35
+ def manager(http_client, enqueue):
36
+ """Return a SpanManager wired to the mock http_client and enqueue fixtures."""
37
+ return SpanManager(http_client, enqueue)
38
+
39
+
40
+ class TestSpanManagerIdempotencyKeys:
41
+ """Tests that SpanManager generates and propagates idempotency keys correctly."""
42
+
43
+ async def test_start_passes_idempotency_key(self, manager, http_client):
44
+ """start() should pass a valid UUID idempotency key to agent_spans.create."""
45
+ temp_id = manager.prepare(instance_id="inst-1", schema_name="agent:llm")
46
+ await manager.start(temp_id)
47
+
48
+ call_kwargs = http_client.agent_spans.create.call_args.kwargs
49
+ key = call_kwargs.get("idempotency_key")
50
+ assert key is not None
51
+ assert len(key) <= 64
52
+ uuid.UUID(key) # must be valid UUID
53
+
54
+ async def test_cancel_unstarted_passes_distinct_keys(self, manager, http_client):
55
+ """cancel_unstarted() should use distinct keys for create and finish calls."""
56
+ temp_id = manager.prepare(instance_id="inst-1", schema_name="agent:llm")
57
+ await manager.cancel_unstarted(temp_id)
58
+
59
+ create_key = http_client.agent_spans.create.call_args.kwargs.get(
60
+ "idempotency_key"
61
+ )
62
+ finish_key = http_client.agent_spans.finish.call_args.kwargs.get(
63
+ "idempotency_key"
64
+ )
65
+
66
+ assert create_key is not None
67
+ assert finish_key is not None
68
+ assert create_key != finish_key
69
+
70
+ async def test_finish_includes_idempotency_key_in_operation(
71
+ self, manager, http_client, enqueue
72
+ ):
73
+ """finish() should enqueue an operation with a valid UUID idempotency key."""
74
+ # Start a span first to get a real API id
75
+ temp_id = manager.prepare(instance_id="inst-1", schema_name="agent:llm")
76
+ api_id = await manager.start(temp_id)
77
+
78
+ await manager.finish(api_id)
79
+
80
+ enqueue.assert_called_once()
81
+ operation = enqueue.call_args.args[0]
82
+ key = operation.payload.get("idempotency_key")
83
+ assert key is not None
84
+ assert len(key) <= 64
85
+ uuid.UUID(key)
@@ -0,0 +1,46 @@
1
+ """Tests for prefactor_core utility functions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+
7
+ import pytest
8
+ from prefactor_core.utils import generate_idempotency_key, validate_idempotency_key
9
+
10
+
11
+ class TestGenerateIdempotencyKey:
12
+ def test_returns_valid_uuid(self):
13
+ key = generate_idempotency_key()
14
+ parsed = uuid.UUID(key)
15
+ assert parsed.version == 4
16
+
17
+ def test_max_length(self):
18
+ key = generate_idempotency_key()
19
+ assert len(key) <= 64
20
+
21
+ def test_unique_per_call(self):
22
+ keys = {generate_idempotency_key() for _ in range(10)}
23
+ assert len(keys) == 10
24
+
25
+
26
+ class TestValidateIdempotencyKey:
27
+ def test_accepts_valid_key(self):
28
+ key = "a" * 64
29
+ assert validate_idempotency_key(key) == key
30
+
31
+ def test_accepts_short_key(self):
32
+ key = "abc"
33
+ assert validate_idempotency_key(key) == key
34
+
35
+ def test_rejects_empty_key(self):
36
+ with pytest.raises(ValueError, match="must not be empty"):
37
+ validate_idempotency_key("")
38
+
39
+ def test_rejects_oversized_key(self):
40
+ key = "a" * 65
41
+ with pytest.raises(ValueError, match="64 characters"):
42
+ validate_idempotency_key(key)
43
+
44
+ def test_accepts_uuid_key(self):
45
+ key = str(uuid.uuid4())
46
+ assert validate_idempotency_key(key) == key
File without changes