liquid-api 0.2.0__py3-none-any.whl
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.
- liquid/__init__.py +60 -0
- liquid/_defaults.py +58 -0
- liquid/auth/__init__.py +8 -0
- liquid/auth/classifier.py +73 -0
- liquid/auth/manager.py +108 -0
- liquid/client.py +213 -0
- liquid/discovery/__init__.py +18 -0
- liquid/discovery/base.py +53 -0
- liquid/discovery/browser.py +175 -0
- liquid/discovery/diff.py +66 -0
- liquid/discovery/graphql.py +180 -0
- liquid/discovery/mcp.py +159 -0
- liquid/discovery/openapi.py +227 -0
- liquid/discovery/rest_heuristic.py +157 -0
- liquid/events.py +37 -0
- liquid/exceptions.py +51 -0
- liquid/mapping/__init__.py +9 -0
- liquid/mapping/learning.py +62 -0
- liquid/mapping/proposer.py +150 -0
- liquid/mapping/reviewer.py +84 -0
- liquid/models/__init__.py +36 -0
- liquid/models/adapter.py +35 -0
- liquid/models/llm.py +42 -0
- liquid/models/schema.py +84 -0
- liquid/models/sync.py +35 -0
- liquid/protocols.py +29 -0
- liquid/py.typed +0 -0
- liquid/sync/__init__.py +29 -0
- liquid/sync/auto_repair.py +64 -0
- liquid/sync/engine.py +176 -0
- liquid/sync/fetcher.py +92 -0
- liquid/sync/mapper.py +73 -0
- liquid/sync/pagination.py +102 -0
- liquid/sync/retry.py +47 -0
- liquid/sync/selector.py +32 -0
- liquid/sync/transform.py +103 -0
- liquid_api-0.2.0.dist-info/METADATA +177 -0
- liquid_api-0.2.0.dist-info/RECORD +39 -0
- liquid_api-0.2.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from enum import StrEnum
|
|
4
|
+
|
|
5
|
+
from liquid.models.adapter import FieldMapping
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class MappingStatus(StrEnum):
|
|
9
|
+
PENDING = "pending"
|
|
10
|
+
APPROVED = "approved"
|
|
11
|
+
REJECTED = "rejected"
|
|
12
|
+
CORRECTED = "corrected"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class MappingReview:
|
|
16
|
+
"""Manages the human review workflow for proposed field mappings."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, proposed: list[FieldMapping]) -> None:
|
|
19
|
+
self._proposed = list(proposed)
|
|
20
|
+
self._statuses: list[MappingStatus] = [MappingStatus.PENDING] * len(proposed)
|
|
21
|
+
self._corrections: list[FieldMapping | None] = [None] * len(proposed)
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def proposed(self) -> list[FieldMapping]:
|
|
25
|
+
return list(self._proposed)
|
|
26
|
+
|
|
27
|
+
def __len__(self) -> int:
|
|
28
|
+
return len(self._proposed)
|
|
29
|
+
|
|
30
|
+
def approve(self, index: int) -> None:
|
|
31
|
+
self._check_index(index)
|
|
32
|
+
self._statuses[index] = MappingStatus.APPROVED
|
|
33
|
+
|
|
34
|
+
def reject(self, index: int) -> None:
|
|
35
|
+
self._check_index(index)
|
|
36
|
+
self._statuses[index] = MappingStatus.REJECTED
|
|
37
|
+
|
|
38
|
+
def correct(
|
|
39
|
+
self,
|
|
40
|
+
index: int,
|
|
41
|
+
target_field: str | None = None,
|
|
42
|
+
transform: str | None = None,
|
|
43
|
+
source_path: str | None = None,
|
|
44
|
+
) -> None:
|
|
45
|
+
self._check_index(index)
|
|
46
|
+
original = self._proposed[index]
|
|
47
|
+
self._corrections[index] = FieldMapping(
|
|
48
|
+
source_path=source_path or original.source_path,
|
|
49
|
+
target_field=target_field or original.target_field,
|
|
50
|
+
transform=transform if transform is not None else original.transform,
|
|
51
|
+
confidence=1.0,
|
|
52
|
+
)
|
|
53
|
+
self._statuses[index] = MappingStatus.CORRECTED
|
|
54
|
+
|
|
55
|
+
def approve_all(self) -> None:
|
|
56
|
+
for i in range(len(self._statuses)):
|
|
57
|
+
if self._statuses[i] == MappingStatus.PENDING:
|
|
58
|
+
self._statuses[i] = MappingStatus.APPROVED
|
|
59
|
+
|
|
60
|
+
def finalize(self) -> list[FieldMapping]:
|
|
61
|
+
"""Return only approved and corrected mappings."""
|
|
62
|
+
result: list[FieldMapping] = []
|
|
63
|
+
for i, status in enumerate(self._statuses):
|
|
64
|
+
if status == MappingStatus.APPROVED:
|
|
65
|
+
result.append(self._proposed[i])
|
|
66
|
+
elif status == MappingStatus.CORRECTED and self._corrections[i]:
|
|
67
|
+
result.append(self._corrections[i])
|
|
68
|
+
return result
|
|
69
|
+
|
|
70
|
+
def corrections(self) -> list[tuple[FieldMapping, FieldMapping]]:
|
|
71
|
+
"""Return (original, corrected) pairs for learning."""
|
|
72
|
+
pairs: list[tuple[FieldMapping, FieldMapping]] = []
|
|
73
|
+
for i, status in enumerate(self._statuses):
|
|
74
|
+
if status == MappingStatus.CORRECTED and self._corrections[i]:
|
|
75
|
+
pairs.append((self._proposed[i], self._corrections[i]))
|
|
76
|
+
return pairs
|
|
77
|
+
|
|
78
|
+
def status(self, index: int) -> MappingStatus:
|
|
79
|
+
self._check_index(index)
|
|
80
|
+
return self._statuses[index]
|
|
81
|
+
|
|
82
|
+
def _check_index(self, index: int) -> None:
|
|
83
|
+
if index < 0 or index >= len(self._proposed):
|
|
84
|
+
raise IndexError(f"Mapping index {index} out of range (0-{len(self._proposed) - 1})")
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from liquid.models.adapter import AdapterConfig, FieldMapping, SyncConfig
|
|
2
|
+
from liquid.models.llm import DeliveryResult, LLMResponse, MappedRecord, Message, Tool, ToolCall
|
|
3
|
+
from liquid.models.schema import (
|
|
4
|
+
APISchema,
|
|
5
|
+
AuthRequirement,
|
|
6
|
+
Endpoint,
|
|
7
|
+
OAuthConfig,
|
|
8
|
+
PaginationType,
|
|
9
|
+
Parameter,
|
|
10
|
+
ParameterLocation,
|
|
11
|
+
RateLimits,
|
|
12
|
+
)
|
|
13
|
+
from liquid.models.sync import SyncError, SyncErrorType, SyncResult
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"APISchema",
|
|
17
|
+
"AdapterConfig",
|
|
18
|
+
"AuthRequirement",
|
|
19
|
+
"DeliveryResult",
|
|
20
|
+
"Endpoint",
|
|
21
|
+
"FieldMapping",
|
|
22
|
+
"LLMResponse",
|
|
23
|
+
"MappedRecord",
|
|
24
|
+
"Message",
|
|
25
|
+
"OAuthConfig",
|
|
26
|
+
"PaginationType",
|
|
27
|
+
"Parameter",
|
|
28
|
+
"ParameterLocation",
|
|
29
|
+
"RateLimits",
|
|
30
|
+
"SyncConfig",
|
|
31
|
+
"SyncError",
|
|
32
|
+
"SyncErrorType",
|
|
33
|
+
"SyncResult",
|
|
34
|
+
"Tool",
|
|
35
|
+
"ToolCall",
|
|
36
|
+
]
|
liquid/models/adapter.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime # noqa: TC003
|
|
4
|
+
from uuid import uuid4
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
from liquid.models.schema import APISchema # noqa: TC001
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class FieldMapping(BaseModel):
|
|
12
|
+
source_path: str
|
|
13
|
+
target_field: str
|
|
14
|
+
transform: str | None = None
|
|
15
|
+
confidence: float = Field(default=1.0, ge=0.0, le=1.0)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SyncConfig(BaseModel):
|
|
19
|
+
endpoints: list[str]
|
|
20
|
+
schedule: str = "0 */6 * * *"
|
|
21
|
+
cursor_field: str | None = None
|
|
22
|
+
batch_size: int = 100
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AdapterConfig(BaseModel):
|
|
26
|
+
config_id: str = Field(default_factory=lambda: uuid4().hex)
|
|
27
|
+
schema_: APISchema = Field(alias="schema")
|
|
28
|
+
auth_ref: str
|
|
29
|
+
mappings: list[FieldMapping]
|
|
30
|
+
sync: SyncConfig
|
|
31
|
+
verified_by: str | None = None
|
|
32
|
+
verified_at: datetime | None = None
|
|
33
|
+
version: int = 1
|
|
34
|
+
|
|
35
|
+
model_config = {"populate_by_name": True}
|
liquid/models/llm.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ToolCall(BaseModel):
|
|
9
|
+
id: str
|
|
10
|
+
name: str
|
|
11
|
+
arguments: dict[str, Any] = Field(default_factory=dict)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Message(BaseModel):
|
|
15
|
+
role: Literal["system", "user", "assistant", "tool"]
|
|
16
|
+
content: str
|
|
17
|
+
tool_call_id: str | None = None
|
|
18
|
+
tool_calls: list[ToolCall] | None = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Tool(BaseModel):
|
|
22
|
+
name: str
|
|
23
|
+
description: str
|
|
24
|
+
parameters: dict[str, Any] = Field(default_factory=dict)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class LLMResponse(BaseModel):
|
|
28
|
+
content: str | None = None
|
|
29
|
+
tool_calls: list[ToolCall] | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class MappedRecord(BaseModel):
|
|
33
|
+
source_endpoint: str
|
|
34
|
+
source_data: dict[str, Any]
|
|
35
|
+
mapped_data: dict[str, Any]
|
|
36
|
+
mapping_errors: list[str] | None = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class DeliveryResult(BaseModel):
|
|
40
|
+
delivered: int = 0
|
|
41
|
+
failed: int = 0
|
|
42
|
+
errors: list[str] | None = None
|
liquid/models/schema.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import UTC, datetime
|
|
4
|
+
from enum import StrEnum
|
|
5
|
+
from typing import Any, Literal
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PaginationType(StrEnum):
|
|
11
|
+
CURSOR = "cursor"
|
|
12
|
+
OFFSET = "offset"
|
|
13
|
+
PAGE_NUMBER = "page_number"
|
|
14
|
+
LINK_HEADER = "link_header"
|
|
15
|
+
NONE = "none"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ParameterLocation(StrEnum):
|
|
19
|
+
QUERY = "query"
|
|
20
|
+
PATH = "path"
|
|
21
|
+
HEADER = "header"
|
|
22
|
+
BODY = "body"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Parameter(BaseModel):
|
|
26
|
+
name: str
|
|
27
|
+
location: ParameterLocation
|
|
28
|
+
required: bool = False
|
|
29
|
+
schema_: dict[str, Any] | None = Field(default=None, alias="schema")
|
|
30
|
+
description: str | None = None
|
|
31
|
+
|
|
32
|
+
model_config = {"populate_by_name": True}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class OAuthConfig(BaseModel):
|
|
36
|
+
authorize_url: str
|
|
37
|
+
token_url: str
|
|
38
|
+
scopes: list[str] = Field(default_factory=list)
|
|
39
|
+
client_registration_url: str | None = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class RateLimits(BaseModel):
|
|
43
|
+
requests_per_second: float | None = None
|
|
44
|
+
requests_per_minute: float | None = None
|
|
45
|
+
burst: int | None = None
|
|
46
|
+
retry_after_header: str | None = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Endpoint(BaseModel):
|
|
50
|
+
path: str
|
|
51
|
+
method: str = "GET"
|
|
52
|
+
description: str = ""
|
|
53
|
+
parameters: list[Parameter] = Field(default_factory=list)
|
|
54
|
+
response_schema: dict[str, Any] = Field(default_factory=dict)
|
|
55
|
+
pagination: PaginationType | None = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class AuthRequirement(BaseModel):
|
|
59
|
+
type: Literal["oauth2", "api_key", "basic", "bearer", "custom"]
|
|
60
|
+
tier: Literal["A", "B", "C"]
|
|
61
|
+
oauth_config: OAuthConfig | None = None
|
|
62
|
+
docs_url: str | None = None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class APISchema(BaseModel):
|
|
66
|
+
source_url: str
|
|
67
|
+
service_name: str
|
|
68
|
+
discovery_method: Literal["mcp", "openapi", "graphql", "rest_heuristic", "browser"]
|
|
69
|
+
endpoints: list[Endpoint] = Field(default_factory=list)
|
|
70
|
+
auth: AuthRequirement
|
|
71
|
+
rate_limits: RateLimits | None = None
|
|
72
|
+
discovered_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class SchemaDiff(BaseModel):
|
|
76
|
+
"""Structured diff between two APISchema versions."""
|
|
77
|
+
|
|
78
|
+
added_endpoints: list[Endpoint] = Field(default_factory=list)
|
|
79
|
+
removed_endpoints: list[Endpoint] = Field(default_factory=list)
|
|
80
|
+
unchanged_endpoints: list[Endpoint] = Field(default_factory=list)
|
|
81
|
+
added_fields: list[str] = Field(default_factory=list)
|
|
82
|
+
removed_fields: list[str] = Field(default_factory=list)
|
|
83
|
+
unchanged_fields: list[str] = Field(default_factory=list)
|
|
84
|
+
has_breaking_changes: bool = False
|
liquid/models/sync.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime # noqa: TC003
|
|
4
|
+
from enum import StrEnum
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SyncErrorType(StrEnum):
|
|
11
|
+
FIELD_NOT_FOUND = "field_not_found"
|
|
12
|
+
AUTH_ERROR = "auth_error"
|
|
13
|
+
RATE_LIMIT = "rate_limit"
|
|
14
|
+
SERVICE_DOWN = "service_down"
|
|
15
|
+
ENDPOINT_GONE = "endpoint_gone"
|
|
16
|
+
TRANSFORM_ERROR = "transform_error"
|
|
17
|
+
DELIVERY_ERROR = "delivery_error"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SyncError(BaseModel):
|
|
21
|
+
type: SyncErrorType
|
|
22
|
+
message: str
|
|
23
|
+
endpoint: str | None = None
|
|
24
|
+
details: dict[str, Any] | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SyncResult(BaseModel):
|
|
28
|
+
adapter_id: str
|
|
29
|
+
started_at: datetime
|
|
30
|
+
finished_at: datetime
|
|
31
|
+
records_fetched: int = 0
|
|
32
|
+
records_mapped: int = 0
|
|
33
|
+
records_delivered: int = 0
|
|
34
|
+
errors: list[SyncError] = Field(default_factory=list)
|
|
35
|
+
next_cursor: str | None = None
|
liquid/protocols.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from liquid.models import DeliveryResult, FieldMapping, LLMResponse, MappedRecord, Message, Tool
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@runtime_checkable
|
|
10
|
+
class Vault(Protocol):
|
|
11
|
+
async def store(self, key: str, value: str) -> None: ...
|
|
12
|
+
async def get(self, key: str) -> str: ...
|
|
13
|
+
async def delete(self, key: str) -> None: ...
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@runtime_checkable
|
|
17
|
+
class LLMBackend(Protocol):
|
|
18
|
+
async def chat(self, messages: list[Message], tools: list[Tool] | None = None) -> LLMResponse: ...
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@runtime_checkable
|
|
22
|
+
class DataSink(Protocol):
|
|
23
|
+
async def deliver(self, records: list[MappedRecord]) -> DeliveryResult: ...
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@runtime_checkable
|
|
27
|
+
class KnowledgeStore(Protocol):
|
|
28
|
+
async def find_mapping(self, service: str, target_model: str) -> list[FieldMapping] | None: ...
|
|
29
|
+
async def store_mapping(self, service: str, target_model: str, mappings: list[FieldMapping]) -> None: ...
|
liquid/py.typed
ADDED
|
File without changes
|
liquid/sync/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from liquid.sync.auto_repair import AutoRepairHandler
|
|
2
|
+
from liquid.sync.engine import SyncEngine
|
|
3
|
+
from liquid.sync.fetcher import Fetcher
|
|
4
|
+
from liquid.sync.mapper import RecordMapper
|
|
5
|
+
from liquid.sync.pagination import (
|
|
6
|
+
CursorPagination,
|
|
7
|
+
LinkHeaderPagination,
|
|
8
|
+
NoPagination,
|
|
9
|
+
OffsetPagination,
|
|
10
|
+
PageNumberPagination,
|
|
11
|
+
PaginationStrategy,
|
|
12
|
+
)
|
|
13
|
+
from liquid.sync.retry import RetryPolicy
|
|
14
|
+
from liquid.sync.selector import RecordSelector
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"AutoRepairHandler",
|
|
18
|
+
"CursorPagination",
|
|
19
|
+
"Fetcher",
|
|
20
|
+
"LinkHeaderPagination",
|
|
21
|
+
"NoPagination",
|
|
22
|
+
"OffsetPagination",
|
|
23
|
+
"PageNumberPagination",
|
|
24
|
+
"PaginationStrategy",
|
|
25
|
+
"RecordMapper",
|
|
26
|
+
"RecordSelector",
|
|
27
|
+
"RetryPolicy",
|
|
28
|
+
"SyncEngine",
|
|
29
|
+
]
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Auto-repair handler that triggers adapter repair on persistent failures."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from collections.abc import Awaitable, Callable # noqa: TC003
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
from liquid.events import Event, ReDiscoveryNeeded
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from liquid.client import Liquid
|
|
13
|
+
from liquid.mapping.reviewer import MappingReview
|
|
14
|
+
from liquid.models.adapter import AdapterConfig
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AutoRepairHandler:
|
|
20
|
+
"""Event handler that automatically repairs adapters on ReDiscoveryNeeded.
|
|
21
|
+
|
|
22
|
+
Usage:
|
|
23
|
+
handler = AutoRepairHandler(
|
|
24
|
+
liquid=liquid_client,
|
|
25
|
+
target_model={"amount": "float", "date": "datetime"},
|
|
26
|
+
config_provider=lambda: current_config,
|
|
27
|
+
on_repair=my_repair_callback,
|
|
28
|
+
)
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
liquid: Liquid,
|
|
34
|
+
target_model: dict[str, Any],
|
|
35
|
+
config_provider: Callable[[], AdapterConfig],
|
|
36
|
+
on_repair: Callable[[AdapterConfig | MappingReview], Awaitable[None]],
|
|
37
|
+
auto_approve: bool = False,
|
|
38
|
+
confidence_threshold: float = 0.8,
|
|
39
|
+
) -> None:
|
|
40
|
+
self.liquid = liquid
|
|
41
|
+
self.target_model = target_model
|
|
42
|
+
self.config_provider = config_provider
|
|
43
|
+
self.on_repair = on_repair
|
|
44
|
+
self.auto_approve = auto_approve
|
|
45
|
+
self.confidence_threshold = confidence_threshold
|
|
46
|
+
|
|
47
|
+
async def handle(self, event: Event) -> None:
|
|
48
|
+
if not isinstance(event, ReDiscoveryNeeded):
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
config = self.config_provider()
|
|
52
|
+
logger.info("Auto-repair triggered for adapter %s: %s", config.config_id, event.reason)
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
result = await self.liquid.repair_adapter(
|
|
56
|
+
config=config,
|
|
57
|
+
target_model=self.target_model,
|
|
58
|
+
auto_approve=self.auto_approve,
|
|
59
|
+
confidence_threshold=self.confidence_threshold,
|
|
60
|
+
)
|
|
61
|
+
await self.on_repair(result)
|
|
62
|
+
logger.info("Auto-repair completed for adapter %s", config.config_id)
|
|
63
|
+
except Exception:
|
|
64
|
+
logger.exception("Auto-repair failed for adapter %s", config.config_id)
|
liquid/sync/engine.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from datetime import UTC, datetime
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from liquid.events import ReDiscoveryNeeded, SyncCompleted, SyncFailed
|
|
8
|
+
from liquid.exceptions import SyncRuntimeError
|
|
9
|
+
from liquid.models import SyncResult
|
|
10
|
+
from liquid.models.sync import SyncError, SyncErrorType
|
|
11
|
+
from liquid.sync.fetcher import Fetcher # noqa: TC001
|
|
12
|
+
from liquid.sync.mapper import RecordMapper # noqa: TC001
|
|
13
|
+
from liquid.sync.retry import RetryPolicy, with_retry
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from liquid.events import EventHandler
|
|
17
|
+
from liquid.models.adapter import AdapterConfig
|
|
18
|
+
from liquid.models.schema import Endpoint
|
|
19
|
+
from liquid.protocols import DataSink
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SyncEngine:
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
fetcher: Fetcher,
|
|
28
|
+
mapper: RecordMapper,
|
|
29
|
+
sink: DataSink,
|
|
30
|
+
event_handler: EventHandler | None = None,
|
|
31
|
+
retry_policy: RetryPolicy | None = None,
|
|
32
|
+
failure_threshold: int = 5,
|
|
33
|
+
) -> None:
|
|
34
|
+
self.fetcher = fetcher
|
|
35
|
+
self.mapper = mapper
|
|
36
|
+
self.sink = sink
|
|
37
|
+
self.event_handler = event_handler
|
|
38
|
+
self.retry_policy = retry_policy or RetryPolicy()
|
|
39
|
+
self.failure_threshold = failure_threshold
|
|
40
|
+
self._consecutive_failures = 0
|
|
41
|
+
|
|
42
|
+
async def run(self, config: AdapterConfig, cursor: str | None = None) -> SyncResult:
|
|
43
|
+
started_at = datetime.now(UTC)
|
|
44
|
+
total_fetched = 0
|
|
45
|
+
total_mapped = 0
|
|
46
|
+
total_delivered = 0
|
|
47
|
+
errors: list[SyncError] = []
|
|
48
|
+
current_cursor = cursor
|
|
49
|
+
|
|
50
|
+
for ep_path in config.sync.endpoints:
|
|
51
|
+
endpoint = self._find_endpoint(config, ep_path)
|
|
52
|
+
if endpoint is None:
|
|
53
|
+
errors.append(
|
|
54
|
+
SyncError(
|
|
55
|
+
type=SyncErrorType.ENDPOINT_GONE,
|
|
56
|
+
message=f"Endpoint {ep_path} not found in schema",
|
|
57
|
+
endpoint=ep_path,
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
continue
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
page_cursor = current_cursor
|
|
64
|
+
while True:
|
|
65
|
+
fetch_result = await with_retry(
|
|
66
|
+
lambda ep=endpoint, c=page_cursor: self.fetcher.fetch(
|
|
67
|
+
endpoint=ep,
|
|
68
|
+
base_url=config.schema_.source_url,
|
|
69
|
+
auth_ref=config.auth_ref,
|
|
70
|
+
cursor=c,
|
|
71
|
+
),
|
|
72
|
+
self.retry_policy,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
total_fetched += len(fetch_result.records)
|
|
76
|
+
|
|
77
|
+
mapped_records = self.mapper.map_batch(fetch_result.records, ep_path)
|
|
78
|
+
total_mapped += len(mapped_records)
|
|
79
|
+
|
|
80
|
+
delivery = await self.sink.deliver(mapped_records)
|
|
81
|
+
total_delivered += delivery.delivered
|
|
82
|
+
|
|
83
|
+
if delivery.errors:
|
|
84
|
+
for err_msg in delivery.errors:
|
|
85
|
+
errors.append(
|
|
86
|
+
SyncError(
|
|
87
|
+
type=SyncErrorType.DELIVERY_ERROR,
|
|
88
|
+
message=err_msg,
|
|
89
|
+
endpoint=ep_path,
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
page_cursor = fetch_result.next_cursor
|
|
94
|
+
current_cursor = page_cursor
|
|
95
|
+
if page_cursor is None:
|
|
96
|
+
break
|
|
97
|
+
|
|
98
|
+
except SyncRuntimeError as e:
|
|
99
|
+
error_type = _classify_error(e)
|
|
100
|
+
errors.append(
|
|
101
|
+
SyncError(
|
|
102
|
+
type=error_type,
|
|
103
|
+
message=str(e),
|
|
104
|
+
endpoint=ep_path,
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
result = SyncResult(
|
|
109
|
+
adapter_id=config.config_id,
|
|
110
|
+
started_at=started_at,
|
|
111
|
+
finished_at=datetime.now(UTC),
|
|
112
|
+
records_fetched=total_fetched,
|
|
113
|
+
records_mapped=total_mapped,
|
|
114
|
+
records_delivered=total_delivered,
|
|
115
|
+
errors=errors,
|
|
116
|
+
next_cursor=current_cursor,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
await self._handle_result(result, config)
|
|
120
|
+
return result
|
|
121
|
+
|
|
122
|
+
async def _handle_result(self, result: SyncResult, config: AdapterConfig) -> None:
|
|
123
|
+
if not self.event_handler:
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
if result.errors:
|
|
127
|
+
self._consecutive_failures += 1
|
|
128
|
+
await self.event_handler.handle(
|
|
129
|
+
SyncFailed(
|
|
130
|
+
adapter_id=config.config_id,
|
|
131
|
+
error=result.errors[-1],
|
|
132
|
+
consecutive_failures=self._consecutive_failures,
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
if self._consecutive_failures >= self.failure_threshold:
|
|
136
|
+
failures = self._consecutive_failures
|
|
137
|
+
threshold = self.failure_threshold
|
|
138
|
+
await self.event_handler.handle(
|
|
139
|
+
ReDiscoveryNeeded(
|
|
140
|
+
adapter_id=config.config_id,
|
|
141
|
+
reason=f"Consecutive failures ({failures}) exceeded threshold ({threshold})",
|
|
142
|
+
)
|
|
143
|
+
)
|
|
144
|
+
else:
|
|
145
|
+
self._consecutive_failures = 0
|
|
146
|
+
await self.event_handler.handle(
|
|
147
|
+
SyncCompleted(
|
|
148
|
+
adapter_id=config.config_id,
|
|
149
|
+
result=result,
|
|
150
|
+
)
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
@staticmethod
|
|
154
|
+
def _find_endpoint(config: AdapterConfig, path: str) -> Endpoint | None:
|
|
155
|
+
for ep in config.schema_.endpoints:
|
|
156
|
+
if ep.path == path:
|
|
157
|
+
return ep
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _classify_error(exc: SyncRuntimeError) -> SyncErrorType:
|
|
162
|
+
from liquid.exceptions import AuthError, EndpointGoneError, FieldNotFoundError, RateLimitError, ServiceDownError
|
|
163
|
+
|
|
164
|
+
match exc:
|
|
165
|
+
case FieldNotFoundError():
|
|
166
|
+
return SyncErrorType.FIELD_NOT_FOUND
|
|
167
|
+
case AuthError():
|
|
168
|
+
return SyncErrorType.AUTH_ERROR
|
|
169
|
+
case RateLimitError():
|
|
170
|
+
return SyncErrorType.RATE_LIMIT
|
|
171
|
+
case ServiceDownError():
|
|
172
|
+
return SyncErrorType.SERVICE_DOWN
|
|
173
|
+
case EndpointGoneError():
|
|
174
|
+
return SyncErrorType.ENDPOINT_GONE
|
|
175
|
+
case _:
|
|
176
|
+
return SyncErrorType.FIELD_NOT_FOUND
|