maximem-synap-integrations-common 0.1.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.
@@ -0,0 +1,38 @@
1
+ Metadata-Version: 2.4
2
+ Name: maximem-synap-integrations-common
3
+ Version: 0.1.0
4
+ Summary: Shared utilities for Synap framework integrations
5
+ Author: Synap Team
6
+ License-Expression: Apache-2.0
7
+ Keywords: synap,memory,ai,agents,integrations
8
+ Requires-Python: >=3.9
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: maximem-synap>=0.2.0
11
+ Requires-Dist: nest_asyncio>=1.5
12
+ Provides-Extra: testing
13
+ Requires-Dist: pytest>=7.0; extra == "testing"
14
+ Requires-Dist: pytest-asyncio>=0.21; extra == "testing"
15
+ Provides-Extra: dev
16
+ Requires-Dist: pytest>=7.0; extra == "dev"
17
+ Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
18
+
19
+ # maximem-synap-integrations-common
20
+
21
+ Shared utilities used across Synap framework integrations (LangChain, LlamaIndex,
22
+ CrewAI, Haystack, AutoGen, Google ADK, OpenAI Agents, Pydantic AI, Semantic
23
+ Kernel).
24
+
25
+ This package exists so that every integration converges on one implementation
26
+ of:
27
+
28
+ - `run_async` — the sync-to-async bridge required because most agent
29
+ frameworks expose **sync** protocol methods while the Synap SDK is async.
30
+ - `SynapIntegrationError` + `wrap_sdk_errors` — uniform error handling so
31
+ integrations don't silently swallow SDK failures.
32
+ - `default_scope` — consistent scope-path construction
33
+ (`/<customer_id>/<user_id>` vs `/<user_id>`).
34
+ - `synap_integrations_common.testing` — shared pytest fixtures and response
35
+ factories, previously duplicated across each integration's `_helpers.py`.
36
+
37
+ Not intended for direct end-user use. Pin as a runtime dependency of each
38
+ integration package.
@@ -0,0 +1,20 @@
1
+ # maximem-synap-integrations-common
2
+
3
+ Shared utilities used across Synap framework integrations (LangChain, LlamaIndex,
4
+ CrewAI, Haystack, AutoGen, Google ADK, OpenAI Agents, Pydantic AI, Semantic
5
+ Kernel).
6
+
7
+ This package exists so that every integration converges on one implementation
8
+ of:
9
+
10
+ - `run_async` — the sync-to-async bridge required because most agent
11
+ frameworks expose **sync** protocol methods while the Synap SDK is async.
12
+ - `SynapIntegrationError` + `wrap_sdk_errors` — uniform error handling so
13
+ integrations don't silently swallow SDK failures.
14
+ - `default_scope` — consistent scope-path construction
15
+ (`/<customer_id>/<user_id>` vs `/<user_id>`).
16
+ - `synap_integrations_common.testing` — shared pytest fixtures and response
17
+ factories, previously duplicated across each integration's `_helpers.py`.
18
+
19
+ Not intended for direct end-user use. Pin as a runtime dependency of each
20
+ integration package.
@@ -0,0 +1,38 @@
1
+ Metadata-Version: 2.4
2
+ Name: maximem-synap-integrations-common
3
+ Version: 0.1.0
4
+ Summary: Shared utilities for Synap framework integrations
5
+ Author: Synap Team
6
+ License-Expression: Apache-2.0
7
+ Keywords: synap,memory,ai,agents,integrations
8
+ Requires-Python: >=3.9
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: maximem-synap>=0.2.0
11
+ Requires-Dist: nest_asyncio>=1.5
12
+ Provides-Extra: testing
13
+ Requires-Dist: pytest>=7.0; extra == "testing"
14
+ Requires-Dist: pytest-asyncio>=0.21; extra == "testing"
15
+ Provides-Extra: dev
16
+ Requires-Dist: pytest>=7.0; extra == "dev"
17
+ Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
18
+
19
+ # maximem-synap-integrations-common
20
+
21
+ Shared utilities used across Synap framework integrations (LangChain, LlamaIndex,
22
+ CrewAI, Haystack, AutoGen, Google ADK, OpenAI Agents, Pydantic AI, Semantic
23
+ Kernel).
24
+
25
+ This package exists so that every integration converges on one implementation
26
+ of:
27
+
28
+ - `run_async` — the sync-to-async bridge required because most agent
29
+ frameworks expose **sync** protocol methods while the Synap SDK is async.
30
+ - `SynapIntegrationError` + `wrap_sdk_errors` — uniform error handling so
31
+ integrations don't silently swallow SDK failures.
32
+ - `default_scope` — consistent scope-path construction
33
+ (`/<customer_id>/<user_id>` vs `/<user_id>`).
34
+ - `synap_integrations_common.testing` — shared pytest fixtures and response
35
+ factories, previously duplicated across each integration's `_helpers.py`.
36
+
37
+ Not intended for direct end-user use. Pin as a runtime dependency of each
38
+ integration package.
@@ -0,0 +1,15 @@
1
+ README.md
2
+ pyproject.toml
3
+ maximem_synap_integrations_common.egg-info/PKG-INFO
4
+ maximem_synap_integrations_common.egg-info/SOURCES.txt
5
+ maximem_synap_integrations_common.egg-info/dependency_links.txt
6
+ maximem_synap_integrations_common.egg-info/requires.txt
7
+ maximem_synap_integrations_common.egg-info/top_level.txt
8
+ synap_integrations_common/__init__.py
9
+ synap_integrations_common/async_bridge.py
10
+ synap_integrations_common/errors.py
11
+ synap_integrations_common/scope.py
12
+ synap_integrations_common/testing.py
13
+ tests/test_async_bridge.py
14
+ tests/test_errors.py
15
+ tests/test_scope.py
@@ -0,0 +1,10 @@
1
+ maximem-synap>=0.2.0
2
+ nest_asyncio>=1.5
3
+
4
+ [dev]
5
+ pytest>=7.0
6
+ pytest-asyncio>=0.21
7
+
8
+ [testing]
9
+ pytest>=7.0
10
+ pytest-asyncio>=0.21
@@ -0,0 +1,38 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "maximem-synap-integrations-common"
7
+ version = "0.1.0"
8
+ description = "Shared utilities for Synap framework integrations"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "Apache-2.0"
12
+ authors = [
13
+ {name = "Synap Team"}
14
+ ]
15
+ keywords = ["synap", "memory", "ai", "agents", "integrations"]
16
+
17
+ dependencies = [
18
+ "maximem-synap>=0.2.0",
19
+ "nest_asyncio>=1.5",
20
+ ]
21
+
22
+ [project.optional-dependencies]
23
+ testing = [
24
+ "pytest>=7.0",
25
+ "pytest-asyncio>=0.21",
26
+ ]
27
+ dev = [
28
+ "pytest>=7.0",
29
+ "pytest-asyncio>=0.21",
30
+ ]
31
+
32
+ [tool.setuptools.packages.find]
33
+ where = ["."]
34
+ include = ["synap_integrations_common*"]
35
+
36
+ [tool.pytest.ini_options]
37
+ testpaths = ["tests"]
38
+ asyncio_mode = "auto"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,17 @@
1
+ """Shared utilities for Synap framework integrations."""
2
+
3
+ from synap_integrations_common.async_bridge import run_async
4
+ from synap_integrations_common.errors import (
5
+ SynapIntegrationError,
6
+ wrap_sdk_errors,
7
+ wrap_sdk_errors_async,
8
+ )
9
+ from synap_integrations_common.scope import default_scope
10
+
11
+ __all__ = [
12
+ "run_async",
13
+ "SynapIntegrationError",
14
+ "wrap_sdk_errors",
15
+ "wrap_sdk_errors_async",
16
+ "default_scope",
17
+ ]
@@ -0,0 +1,59 @@
1
+ """Sync-to-async bridge for framework integrations.
2
+
3
+ Most agent frameworks (CrewAI StorageBackend, Haystack @component.run,
4
+ LangChain's sync retriever/tool protocols, Semantic Kernel kernel_function,
5
+ Google ADK FunctionTool) expose synchronous entry points. The Synap SDK is
6
+ async-first. Integrations therefore need to run a coroutine from sync code.
7
+
8
+ The naive approach — `asyncio.run()` — fails when the caller is itself
9
+ running inside an event loop (e.g. Haystack pipelines driven by an async
10
+ runner). We fall back to ``nest_asyncio`` in that case, but:
11
+
12
+ - ``nest_asyncio.apply()`` patches the running loop class globally. We call
13
+ it at most once per loop via a module-level sentinel so we don't re-patch
14
+ on every call.
15
+ - If the caller is already async they should ``await`` the SDK directly;
16
+ ``run_async`` is the last-resort bridge, not the primary API.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ from typing import Any, Awaitable, TypeVar
23
+
24
+ T = TypeVar("T")
25
+
26
+ _patched_loops: "set[int]" = set()
27
+
28
+
29
+ def run_async(coro: Awaitable[T]) -> T:
30
+ """Run an awaitable from a synchronous context.
31
+
32
+ - When no event loop is running, uses ``asyncio.run``.
33
+ - When a loop is already running, applies ``nest_asyncio`` to that loop
34
+ (once) and drives the coroutine to completion on it.
35
+
36
+ Prefer awaiting the underlying async API directly whenever the caller
37
+ can — this helper exists only to satisfy sync framework protocols.
38
+ """
39
+ try:
40
+ loop = asyncio.get_running_loop()
41
+ except RuntimeError:
42
+ return asyncio.run(coro) # type: ignore[arg-type]
43
+
44
+ loop_id = id(loop)
45
+ if loop_id not in _patched_loops:
46
+ import nest_asyncio
47
+
48
+ nest_asyncio.apply(loop)
49
+ _patched_loops.add(loop_id)
50
+
51
+ return loop.run_until_complete(coro) # type: ignore[arg-type]
52
+
53
+
54
+ def _reset_patched_loops_for_tests() -> None:
55
+ """Test-only hook to clear the patched-loop registry."""
56
+ _patched_loops.clear()
57
+
58
+
59
+ __all__ = ["run_async"]
@@ -0,0 +1,112 @@
1
+ """Uniform error handling for Synap integrations.
2
+
3
+ The pre-existing integrations were inconsistent: some swallowed every SDK
4
+ exception as a warning (silent ingestion failures in Haystack, silent
5
+ save failures in CrewAI), others let raw SDK exceptions propagate into
6
+ framework internals with no context about which operation failed.
7
+
8
+ This module gives every integration one shape:
9
+
10
+ with wrap_sdk_errors("crewai.asave", logger, record_id=record.id):
11
+ await sdk.memories.create(...)
12
+
13
+ - On success: pass-through.
14
+ - On failure: log once with the operation name and structured context,
15
+ then re-raise as ``SynapIntegrationError`` (preserving the original via
16
+ ``__cause__``).
17
+
18
+ Callers decide whether to catch ``SynapIntegrationError``; the contract is
19
+ that integration code NEVER silently discards an SDK failure.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import logging
25
+ from contextlib import asynccontextmanager, contextmanager
26
+ from typing import Any, Iterator, AsyncIterator, Optional
27
+
28
+
29
+ class SynapIntegrationError(RuntimeError):
30
+ """Raised when a Synap SDK call fails inside an integration.
31
+
32
+ The original exception is available via ``__cause__``. The ``operation``
33
+ and ``context`` attributes are populated so framework-level error
34
+ handlers can log structured data without re-parsing messages.
35
+ """
36
+
37
+ def __init__(
38
+ self,
39
+ operation: str,
40
+ message: str,
41
+ context: Optional[dict] = None,
42
+ ) -> None:
43
+ super().__init__(f"{operation}: {message}")
44
+ self.operation = operation
45
+ self.context = context or {}
46
+
47
+
48
+ def _log_and_wrap(
49
+ operation: str,
50
+ logger: logging.Logger,
51
+ exc: BaseException,
52
+ context: dict,
53
+ ) -> SynapIntegrationError:
54
+ logger.error(
55
+ "Synap integration call failed: op=%s error=%s context=%s",
56
+ operation,
57
+ exc,
58
+ context,
59
+ exc_info=True,
60
+ )
61
+ wrapped = SynapIntegrationError(operation, str(exc), context)
62
+ wrapped.__cause__ = exc
63
+ return wrapped
64
+
65
+
66
+ @contextmanager
67
+ def wrap_sdk_errors(
68
+ operation: str,
69
+ logger: logging.Logger,
70
+ **context: Any,
71
+ ) -> Iterator[None]:
72
+ """Sync context manager that wraps SDK exceptions.
73
+
74
+ Example::
75
+
76
+ with wrap_sdk_errors("haystack.writer.run", logger, count=len(docs)):
77
+ run_async(sdk.memories.create(...))
78
+ """
79
+ try:
80
+ yield
81
+ except SynapIntegrationError:
82
+ raise
83
+ except Exception as exc: # noqa: BLE001 — intentional broad catch at boundary
84
+ raise _log_and_wrap(operation, logger, exc, context) from exc
85
+
86
+
87
+ @asynccontextmanager
88
+ async def wrap_sdk_errors_async(
89
+ operation: str,
90
+ logger: logging.Logger,
91
+ **context: Any,
92
+ ) -> AsyncIterator[None]:
93
+ """Async variant of :func:`wrap_sdk_errors`.
94
+
95
+ Example::
96
+
97
+ async with wrap_sdk_errors_async("crewai.asave", logger, id=rec.id):
98
+ await sdk.memories.create(...)
99
+ """
100
+ try:
101
+ yield
102
+ except SynapIntegrationError:
103
+ raise
104
+ except Exception as exc: # noqa: BLE001
105
+ raise _log_and_wrap(operation, logger, exc, context) from exc
106
+
107
+
108
+ __all__ = [
109
+ "SynapIntegrationError",
110
+ "wrap_sdk_errors",
111
+ "wrap_sdk_errors_async",
112
+ ]
@@ -0,0 +1,35 @@
1
+ """Scope path construction for Synap integrations.
2
+
3
+ Synap's scope chain narrows from WORLD -> CLIENT -> CUSTOMER -> USER. When
4
+ an integration caches a record locally (CrewAI) or annotates a framework
5
+ record with its origin scope (LlamaIndex, LangChain), it needs a consistent
6
+ path string.
7
+
8
+ Historically the integrations did this ad-hoc: llamaindex retriever built
9
+ one path, llamaindex memory built another; CrewAI defaulted to ``/user``
10
+ while LangChain left it blank. This module is the single source of truth.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Optional
16
+
17
+
18
+ def default_scope(user_id: str, customer_id: Optional[str] = None) -> str:
19
+ """Return the canonical default scope path for a record.
20
+
21
+ - ``default_scope("alice")`` -> ``"/alice"``
22
+ - ``default_scope("alice", "acme")`` -> ``"/acme/alice"``
23
+ - ``default_scope("alice", "")`` -> ``"/alice"`` (empty string treated as absent)
24
+
25
+ ``user_id`` is required; an empty ``user_id`` raises ``ValueError``
26
+ because a record with no user scope is ambiguous in Synap's model.
27
+ """
28
+ if not user_id:
29
+ raise ValueError("default_scope requires a non-empty user_id")
30
+ if customer_id:
31
+ return f"/{customer_id}/{user_id}"
32
+ return f"/{user_id}"
33
+
34
+
35
+ __all__ = ["default_scope"]
@@ -0,0 +1,184 @@
1
+ """Shared pytest fixtures and response factories for integration tests.
2
+
3
+ Each integration previously kept its own ``tests/_helpers.py`` that
4
+ re-implemented the same ``mock_sdk`` fixture and the same
5
+ ``make_fact`` / ``make_preference`` / ``make_episode`` / etc. factories.
6
+ That duplication drifted over time and none of the helpers exercised
7
+ SDK-failure paths.
8
+
9
+ To use in an integration's ``tests/conftest.py``::
10
+
11
+ from synap_integrations_common.testing import (
12
+ mock_sdk, # pytest fixture — re-export
13
+ failing_sdk, # pytest fixture — SDK whose calls raise
14
+ make_unified_response,
15
+ make_fact,
16
+ )
17
+
18
+ The individual ``make_*`` helpers are plain functions so tests can build
19
+ custom response payloads without monkey-patching fixtures.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from datetime import datetime, timezone
25
+ from unittest.mock import AsyncMock, MagicMock
26
+
27
+ import pytest
28
+
29
+ try:
30
+ from maximem_synap import MaximemSynapSDK
31
+ from maximem_synap.models.context import (
32
+ ContextForPromptResponse,
33
+ Emotion,
34
+ Episode,
35
+ Fact,
36
+ Preference,
37
+ ResponseMetadata,
38
+ TemporalEvent,
39
+ UnifiedContextResponse,
40
+ )
41
+ except ImportError: # pragma: no cover — surfaces clearly in dev setups
42
+ MaximemSynapSDK = None # type: ignore[assignment]
43
+
44
+
45
+ def make_metadata(**overrides):
46
+ defaults = {
47
+ "correlation_id": "test-corr",
48
+ "ttl_seconds": 300,
49
+ "source": "cloud",
50
+ "retrieved_at": datetime.now(timezone.utc),
51
+ }
52
+ defaults.update(overrides)
53
+ return ResponseMetadata(**defaults)
54
+
55
+
56
+ def make_fact(id="f1", content="User is an engineer", confidence=0.9):
57
+ return Fact(
58
+ id=id,
59
+ content=content,
60
+ confidence=confidence,
61
+ source="test",
62
+ extracted_at=datetime.now(timezone.utc),
63
+ )
64
+
65
+
66
+ def make_preference(id="p1", content="Prefers dark mode", strength=0.8):
67
+ return Preference(
68
+ id=id,
69
+ category="general",
70
+ content=content,
71
+ strength=strength,
72
+ extracted_at=datetime.now(timezone.utc),
73
+ )
74
+
75
+
76
+ def make_episode(id="e1", summary="Had a support call", significance=0.7):
77
+ return Episode(
78
+ id=id,
79
+ summary=summary,
80
+ occurred_at=datetime.now(timezone.utc),
81
+ significance=significance,
82
+ )
83
+
84
+
85
+ def make_emotion(id="em1", emotion_type="frustrated", context="Long wait", intensity=0.6):
86
+ return Emotion(
87
+ id=id,
88
+ emotion_type=emotion_type,
89
+ intensity=intensity,
90
+ detected_at=datetime.now(timezone.utc),
91
+ context=context,
92
+ )
93
+
94
+
95
+ def make_temporal_event(id="t1", content="Trial expires April 15"):
96
+ return TemporalEvent(
97
+ id=id,
98
+ content=content,
99
+ event_date=datetime.now(timezone.utc),
100
+ temporal_category="temporal_fact",
101
+ temporal_confidence=0.9,
102
+ )
103
+
104
+
105
+ def make_unified_response(**overrides):
106
+ defaults = {
107
+ "facts": [make_fact()],
108
+ "preferences": [make_preference()],
109
+ "scope_map": {"f1": "user", "p1": "user"},
110
+ "scopes_queried": ["user"],
111
+ "total_items": 2,
112
+ "formatted_context": "## User Context\n### Facts\n- User is an engineer",
113
+ "metadata": make_metadata(),
114
+ }
115
+ defaults.update(overrides)
116
+ return UnifiedContextResponse(**defaults)
117
+
118
+
119
+ def _build_mock_sdk():
120
+ sdk = MagicMock(spec=MaximemSynapSDK)
121
+ sdk.instance_id = "test-instance"
122
+ sdk._initialized = True
123
+
124
+ sdk.fetch = AsyncMock(return_value=make_unified_response())
125
+
126
+ sdk.conversation = MagicMock()
127
+ sdk.conversation.record_message = AsyncMock(
128
+ return_value={
129
+ "message_id": "msg-001",
130
+ "conversation_id": "conv-1",
131
+ "session_id": "sess-1",
132
+ "recorded_at": datetime.now(timezone.utc).isoformat(),
133
+ }
134
+ )
135
+ sdk.conversation.context = MagicMock()
136
+ sdk.conversation.context.get_context_for_prompt = AsyncMock(
137
+ return_value=ContextForPromptResponse(
138
+ formatted_context="Recent conversation summary",
139
+ available=True,
140
+ )
141
+ )
142
+
143
+ sdk.memories = MagicMock()
144
+ create_result = MagicMock()
145
+ create_result.ingestion_id = "ing-001"
146
+ sdk.memories.create = AsyncMock(return_value=create_result)
147
+
148
+ sdk.cache = MagicMock()
149
+ return sdk
150
+
151
+
152
+ @pytest.fixture
153
+ def mock_sdk():
154
+ """SDK mock pre-wired with successful default responses."""
155
+ return _build_mock_sdk()
156
+
157
+
158
+ @pytest.fixture
159
+ def failing_sdk():
160
+ """SDK mock whose every async method raises ``RuntimeError("sdk boom")``.
161
+
162
+ Use in tests that assert integrations surface SDK failures instead of
163
+ silently swallowing them.
164
+ """
165
+ sdk = _build_mock_sdk()
166
+ err = RuntimeError("sdk boom")
167
+ sdk.fetch = AsyncMock(side_effect=err)
168
+ sdk.conversation.record_message = AsyncMock(side_effect=err)
169
+ sdk.conversation.context.get_context_for_prompt = AsyncMock(side_effect=err)
170
+ sdk.memories.create = AsyncMock(side_effect=err)
171
+ return sdk
172
+
173
+
174
+ __all__ = [
175
+ "make_metadata",
176
+ "make_fact",
177
+ "make_preference",
178
+ "make_episode",
179
+ "make_emotion",
180
+ "make_temporal_event",
181
+ "make_unified_response",
182
+ "mock_sdk",
183
+ "failing_sdk",
184
+ ]
@@ -0,0 +1,64 @@
1
+ import asyncio
2
+
3
+ import pytest
4
+
5
+ from synap_integrations_common.async_bridge import (
6
+ _reset_patched_loops_for_tests,
7
+ run_async,
8
+ )
9
+
10
+
11
+ @pytest.fixture(autouse=True)
12
+ def _reset_registry():
13
+ _reset_patched_loops_for_tests()
14
+ yield
15
+ _reset_patched_loops_for_tests()
16
+
17
+
18
+ def test_run_async_no_running_loop_uses_asyncio_run():
19
+ async def work():
20
+ await asyncio.sleep(0)
21
+ return 42
22
+
23
+ assert run_async(work()) == 42
24
+
25
+
26
+ def test_run_async_propagates_exceptions():
27
+ async def boom():
28
+ raise ValueError("nope")
29
+
30
+ with pytest.raises(ValueError, match="nope"):
31
+ run_async(boom())
32
+
33
+
34
+ def test_run_async_from_within_running_loop():
35
+ """Simulate the frameworks-in-async-context path.
36
+
37
+ When a running loop is present we apply nest_asyncio and drive the
38
+ coroutine on the existing loop. This exercises the registry branch.
39
+ """
40
+
41
+ async def outer():
42
+ async def inner():
43
+ await asyncio.sleep(0)
44
+ return "inner-ok"
45
+
46
+ # Call the sync bridge from within an async context — this is the
47
+ # scenario frameworks like Haystack create.
48
+ return run_async(inner())
49
+
50
+ assert asyncio.run(outer()) == "inner-ok"
51
+
52
+
53
+ def test_run_async_patches_each_loop_once():
54
+ """Second invocation on the same loop must not re-patch."""
55
+
56
+ async def outer():
57
+ async def inner(v):
58
+ return v
59
+
60
+ first = run_async(inner("a"))
61
+ second = run_async(inner("b"))
62
+ return first, second
63
+
64
+ assert asyncio.run(outer()) == ("a", "b")
@@ -0,0 +1,72 @@
1
+ import logging
2
+
3
+ import pytest
4
+
5
+ from synap_integrations_common.errors import (
6
+ SynapIntegrationError,
7
+ wrap_sdk_errors,
8
+ wrap_sdk_errors_async,
9
+ )
10
+
11
+
12
+ def test_sync_wrapper_passthrough():
13
+ logger = logging.getLogger("t")
14
+ with wrap_sdk_errors("op", logger):
15
+ result = 1 + 1
16
+ assert result == 2
17
+
18
+
19
+ def test_sync_wrapper_wraps_and_preserves_cause():
20
+ logger = logging.getLogger("t")
21
+ original = RuntimeError("underlying")
22
+
23
+ with pytest.raises(SynapIntegrationError) as exc_info:
24
+ with wrap_sdk_errors("crewai.asave", logger, record_id="r1"):
25
+ raise original
26
+
27
+ err = exc_info.value
28
+ assert err.operation == "crewai.asave"
29
+ assert err.context == {"record_id": "r1"}
30
+ assert err.__cause__ is original
31
+ assert "crewai.asave" in str(err)
32
+
33
+
34
+ def test_sync_wrapper_does_not_double_wrap():
35
+ logger = logging.getLogger("t")
36
+ inner = SynapIntegrationError("inner.op", "already wrapped")
37
+
38
+ with pytest.raises(SynapIntegrationError) as exc_info:
39
+ with wrap_sdk_errors("outer.op", logger):
40
+ raise inner
41
+
42
+ assert exc_info.value is inner
43
+
44
+
45
+ def test_sync_wrapper_logs_error(caplog):
46
+ logger = logging.getLogger("synap.test")
47
+ with caplog.at_level(logging.ERROR, logger="synap.test"):
48
+ with pytest.raises(SynapIntegrationError):
49
+ with wrap_sdk_errors("op", logger, key="val"):
50
+ raise RuntimeError("boom")
51
+
52
+ assert any("op=op" in rec.message for rec in caplog.records)
53
+ assert any("key" in rec.message for rec in caplog.records)
54
+
55
+
56
+ @pytest.mark.asyncio
57
+ async def test_async_wrapper_wraps():
58
+ logger = logging.getLogger("t")
59
+ with pytest.raises(SynapIntegrationError) as exc_info:
60
+ async with wrap_sdk_errors_async("haystack.writer", logger, count=3):
61
+ raise RuntimeError("ingest failed")
62
+
63
+ assert exc_info.value.operation == "haystack.writer"
64
+ assert exc_info.value.context == {"count": 3}
65
+ assert isinstance(exc_info.value.__cause__, RuntimeError)
66
+
67
+
68
+ @pytest.mark.asyncio
69
+ async def test_async_wrapper_passthrough():
70
+ logger = logging.getLogger("t")
71
+ async with wrap_sdk_errors_async("op", logger):
72
+ pass # no error -> no raise
@@ -0,0 +1,23 @@
1
+ import pytest
2
+
3
+ from synap_integrations_common.scope import default_scope
4
+
5
+
6
+ def test_user_only():
7
+ assert default_scope("alice") == "/alice"
8
+
9
+
10
+ def test_user_and_customer():
11
+ assert default_scope("alice", "acme") == "/acme/alice"
12
+
13
+
14
+ def test_empty_customer_treated_as_absent():
15
+ assert default_scope("alice", "") == "/alice"
16
+ assert default_scope("alice", None) == "/alice"
17
+
18
+
19
+ def test_empty_user_id_raises():
20
+ with pytest.raises(ValueError):
21
+ default_scope("")
22
+ with pytest.raises(ValueError):
23
+ default_scope("", "acme")