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.
- maximem_synap_integrations_common-0.1.0/PKG-INFO +38 -0
- maximem_synap_integrations_common-0.1.0/README.md +20 -0
- maximem_synap_integrations_common-0.1.0/maximem_synap_integrations_common.egg-info/PKG-INFO +38 -0
- maximem_synap_integrations_common-0.1.0/maximem_synap_integrations_common.egg-info/SOURCES.txt +15 -0
- maximem_synap_integrations_common-0.1.0/maximem_synap_integrations_common.egg-info/dependency_links.txt +1 -0
- maximem_synap_integrations_common-0.1.0/maximem_synap_integrations_common.egg-info/requires.txt +10 -0
- maximem_synap_integrations_common-0.1.0/maximem_synap_integrations_common.egg-info/top_level.txt +1 -0
- maximem_synap_integrations_common-0.1.0/pyproject.toml +38 -0
- maximem_synap_integrations_common-0.1.0/setup.cfg +4 -0
- maximem_synap_integrations_common-0.1.0/synap_integrations_common/__init__.py +17 -0
- maximem_synap_integrations_common-0.1.0/synap_integrations_common/async_bridge.py +59 -0
- maximem_synap_integrations_common-0.1.0/synap_integrations_common/errors.py +112 -0
- maximem_synap_integrations_common-0.1.0/synap_integrations_common/scope.py +35 -0
- maximem_synap_integrations_common-0.1.0/synap_integrations_common/testing.py +184 -0
- maximem_synap_integrations_common-0.1.0/tests/test_async_bridge.py +64 -0
- maximem_synap_integrations_common-0.1.0/tests/test_errors.py +72 -0
- maximem_synap_integrations_common-0.1.0/tests/test_scope.py +23 -0
|
@@ -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.
|
maximem_synap_integrations_common-0.1.0/maximem_synap_integrations_common.egg-info/SOURCES.txt
ADDED
|
@@ -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 @@
|
|
|
1
|
+
|
maximem_synap_integrations_common-0.1.0/maximem_synap_integrations_common.egg-info/top_level.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
synap_integrations_common
|
|
@@ -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,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")
|