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.
@@ -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
+ ]
@@ -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
@@ -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
@@ -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