inputlayer-client-dev 0.1.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.
- inputlayer/__init__.py +151 -0
- inputlayer/_ast.py +133 -0
- inputlayer/_naming.py +44 -0
- inputlayer/_protocol.py +225 -0
- inputlayer/_proxy.py +244 -0
- inputlayer/aggregations.py +126 -0
- inputlayer/auth.py +70 -0
- inputlayer/client.py +178 -0
- inputlayer/client_sync.py +231 -0
- inputlayer/compiler.py +672 -0
- inputlayer/connection.py +314 -0
- inputlayer/derived.py +141 -0
- inputlayer/exceptions.py +76 -0
- inputlayer/functions.py +285 -0
- inputlayer/index.py +40 -0
- inputlayer/knowledge_graph.py +589 -0
- inputlayer/migrations/__init__.py +51 -0
- inputlayer/migrations/autodetector.py +120 -0
- inputlayer/migrations/cli.py +254 -0
- inputlayer/migrations/executor.py +95 -0
- inputlayer/migrations/loader.py +91 -0
- inputlayer/migrations/operations.py +298 -0
- inputlayer/migrations/recorder.py +44 -0
- inputlayer/migrations/state.py +107 -0
- inputlayer/migrations/writer.py +183 -0
- inputlayer/notifications.py +83 -0
- inputlayer/py.typed +0 -0
- inputlayer/relation.py +102 -0
- inputlayer/result.py +89 -0
- inputlayer/session.py +75 -0
- inputlayer/types.py +192 -0
- inputlayer_client_dev-0.1.0.dist-info/METADATA +364 -0
- inputlayer_client_dev-0.1.0.dist-info/RECORD +35 -0
- inputlayer_client_dev-0.1.0.dist-info/WHEEL +4 -0
- inputlayer_client_dev-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Notification dispatcher for push events from the server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any, AsyncIterator, Callable
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class NotificationEvent:
|
|
12
|
+
"""A single notification event from the server."""
|
|
13
|
+
|
|
14
|
+
type: str # persistent_update, rule_change, kg_change, schema_change
|
|
15
|
+
seq: int
|
|
16
|
+
timestamp_ms: int
|
|
17
|
+
session_id: str | None = None
|
|
18
|
+
knowledge_graph: str | None = None
|
|
19
|
+
relation: str | None = None
|
|
20
|
+
operation: str | None = None
|
|
21
|
+
count: int | None = None
|
|
22
|
+
rule_name: str | None = None
|
|
23
|
+
entity: str | None = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
Callback = Callable[[NotificationEvent], Any]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class NotificationDispatcher:
|
|
30
|
+
"""Routes notification events to registered callbacks."""
|
|
31
|
+
|
|
32
|
+
def __init__(self) -> None:
|
|
33
|
+
self._callbacks: list[tuple[str | None, str | None, str | None, Callback]] = []
|
|
34
|
+
self._queue: asyncio.Queue[NotificationEvent] = asyncio.Queue()
|
|
35
|
+
self._last_seq: int = 0
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def last_seq(self) -> int:
|
|
39
|
+
return self._last_seq
|
|
40
|
+
|
|
41
|
+
def on(
|
|
42
|
+
self,
|
|
43
|
+
event_type: str | None = None,
|
|
44
|
+
*,
|
|
45
|
+
relation: str | None = None,
|
|
46
|
+
knowledge_graph: str | None = None,
|
|
47
|
+
callback: Callback | None = None,
|
|
48
|
+
) -> Callable | None:
|
|
49
|
+
"""Register a callback for notifications. Can be used as a decorator."""
|
|
50
|
+
def decorator(fn: Callback) -> Callback:
|
|
51
|
+
self._callbacks.append((event_type, relation, knowledge_graph, fn))
|
|
52
|
+
return fn
|
|
53
|
+
|
|
54
|
+
if callback is not None:
|
|
55
|
+
self._callbacks.append((event_type, relation, knowledge_graph, callback))
|
|
56
|
+
return None
|
|
57
|
+
return decorator
|
|
58
|
+
|
|
59
|
+
def dispatch(self, event: NotificationEvent) -> None:
|
|
60
|
+
"""Dispatch a notification to matching callbacks and the async queue."""
|
|
61
|
+
self._last_seq = max(self._last_seq, event.seq)
|
|
62
|
+
# Push to async iterator queue
|
|
63
|
+
self._queue.put_nowait(event)
|
|
64
|
+
# Call matching callbacks
|
|
65
|
+
for evt_type, rel, kg, cb in self._callbacks:
|
|
66
|
+
if evt_type is not None and event.type != evt_type:
|
|
67
|
+
continue
|
|
68
|
+
if rel is not None and event.relation != rel:
|
|
69
|
+
continue
|
|
70
|
+
if kg is not None and event.knowledge_graph != kg:
|
|
71
|
+
continue
|
|
72
|
+
try:
|
|
73
|
+
result = cb(event)
|
|
74
|
+
if asyncio.iscoroutine(result):
|
|
75
|
+
asyncio.ensure_future(result)
|
|
76
|
+
except Exception:
|
|
77
|
+
pass # Callbacks should not break the dispatcher
|
|
78
|
+
|
|
79
|
+
async def __aiter__(self) -> AsyncIterator[NotificationEvent]:
|
|
80
|
+
"""Async iterator yielding notification events."""
|
|
81
|
+
while True:
|
|
82
|
+
event = await self._queue.get()
|
|
83
|
+
yield event
|
inputlayer/py.typed
ADDED
|
File without changes
|
inputlayer/relation.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Relation base class - user-facing schema definition via Pydantic models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, ClassVar, get_type_hints
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict
|
|
8
|
+
from pydantic._internal._model_construction import ModelMetaclass
|
|
9
|
+
|
|
10
|
+
from inputlayer._naming import camel_to_snake
|
|
11
|
+
from inputlayer._proxy import ColumnProxy, RelationRef
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class _RelationMeta(ModelMetaclass):
|
|
15
|
+
"""Metaclass that adds ColumnProxy attribute access to Relation subclasses.
|
|
16
|
+
|
|
17
|
+
When you write ``Employee.name`` on the class (not an instance), this
|
|
18
|
+
metaclass intercepts the lookup and returns a ColumnProxy for query building.
|
|
19
|
+
Pydantic's own ``ModelMetaclass.__getattr__`` blocks this, so we override it.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __getattr__(cls, name: str) -> Any:
|
|
23
|
+
if name.startswith("_"):
|
|
24
|
+
raise AttributeError(name)
|
|
25
|
+
# model_fields is a dict after Pydantic model construction.
|
|
26
|
+
# During construction it may be a descriptor, so guard carefully.
|
|
27
|
+
try:
|
|
28
|
+
fields = super().__getattribute__("model_fields")
|
|
29
|
+
except AttributeError:
|
|
30
|
+
fields = None
|
|
31
|
+
if isinstance(fields, dict) and name in fields:
|
|
32
|
+
rel_name = _resolve_name(cls)
|
|
33
|
+
return ColumnProxy(rel_name, name)
|
|
34
|
+
raise AttributeError(f"type object '{cls.__name__}' has no attribute '{name}'")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _resolve_name(cls: type) -> str:
|
|
38
|
+
"""Get the Datalog relation name for a Relation subclass."""
|
|
39
|
+
rn = getattr(cls, "__relation_name__", None)
|
|
40
|
+
if rn is not None:
|
|
41
|
+
return rn
|
|
42
|
+
return camel_to_snake(cls.__name__)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Relation(BaseModel, metaclass=_RelationMeta):
|
|
46
|
+
"""Base class for all InputLayer relations.
|
|
47
|
+
|
|
48
|
+
Subclass this with typed fields to define a relation schema::
|
|
49
|
+
|
|
50
|
+
class Employee(Relation):
|
|
51
|
+
id: int
|
|
52
|
+
name: str
|
|
53
|
+
department: str
|
|
54
|
+
salary: float
|
|
55
|
+
active: bool
|
|
56
|
+
|
|
57
|
+
Column proxy access:
|
|
58
|
+
``Employee.name`` returns a ``ColumnProxy`` for query building.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
model_config = ConfigDict(frozen=True)
|
|
62
|
+
|
|
63
|
+
__relation_name__: ClassVar[str | None] = None
|
|
64
|
+
|
|
65
|
+
# ── Class-level introspection ─────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def _resolve_name(cls, relation_cls: type[Relation] | None = None) -> str:
|
|
69
|
+
"""Get the Datalog relation name for a Relation subclass."""
|
|
70
|
+
target = relation_cls or cls
|
|
71
|
+
return _resolve_name(target)
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def _get_columns(cls, relation_cls: type[Relation] | None = None) -> list[str]:
|
|
75
|
+
"""Get ordered column names (excludes Pydantic internals)."""
|
|
76
|
+
target = relation_cls or cls
|
|
77
|
+
return list(target.model_fields.keys())
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def _get_column_types(cls, relation_cls: type[Relation] | None = None) -> dict[str, type]:
|
|
81
|
+
"""Get column name → Python type mapping."""
|
|
82
|
+
target = relation_cls or cls
|
|
83
|
+
hints = get_type_hints(target)
|
|
84
|
+
return {k: hints[k] for k in target.model_fields}
|
|
85
|
+
|
|
86
|
+
# ── Self-join support ─────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
@classmethod
|
|
89
|
+
def refs(cls, n: int) -> tuple[RelationRef, ...]:
|
|
90
|
+
"""Create n independent references for self-joins.
|
|
91
|
+
|
|
92
|
+
Usage::
|
|
93
|
+
|
|
94
|
+
r1, r2 = Follow.refs(2)
|
|
95
|
+
kg.query(r1.follower, r2.followee,
|
|
96
|
+
join=[r1, r2],
|
|
97
|
+
on=lambda a, b: a.followee == b.follower)
|
|
98
|
+
"""
|
|
99
|
+
return tuple(
|
|
100
|
+
RelationRef(cls, f"{_resolve_name(cls)}_{i}")
|
|
101
|
+
for i in range(1, n + 1)
|
|
102
|
+
)
|
inputlayer/result.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""ResultSet - typed, iterable query results."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from types import SimpleNamespace
|
|
7
|
+
from typing import Any, Iterator
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class ResultSet:
|
|
12
|
+
"""Container for query results.
|
|
13
|
+
|
|
14
|
+
Supports iteration, indexing, and conversion to dicts/tuples/DataFrames.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
columns: list[str]
|
|
18
|
+
rows: list[list[Any]]
|
|
19
|
+
row_count: int = 0
|
|
20
|
+
total_count: int = 0
|
|
21
|
+
truncated: bool = False
|
|
22
|
+
execution_time_ms: int = 0
|
|
23
|
+
row_provenance: list[str] | None = None
|
|
24
|
+
has_ephemeral: bool = False
|
|
25
|
+
ephemeral_sources: list[str] = field(default_factory=list)
|
|
26
|
+
warnings: list[str] = field(default_factory=list)
|
|
27
|
+
|
|
28
|
+
# Optional Relation class for typed iteration
|
|
29
|
+
_relation_cls: type | None = field(default=None, repr=False)
|
|
30
|
+
|
|
31
|
+
def __post_init__(self) -> None:
|
|
32
|
+
if self.row_count == 0:
|
|
33
|
+
self.row_count = len(self.rows)
|
|
34
|
+
if self.total_count == 0:
|
|
35
|
+
self.total_count = self.row_count
|
|
36
|
+
|
|
37
|
+
def __len__(self) -> int:
|
|
38
|
+
return self.row_count
|
|
39
|
+
|
|
40
|
+
def __bool__(self) -> bool:
|
|
41
|
+
return self.row_count > 0
|
|
42
|
+
|
|
43
|
+
def __iter__(self) -> Iterator[Any]:
|
|
44
|
+
for row in self.rows:
|
|
45
|
+
yield self._row_to_obj(row)
|
|
46
|
+
|
|
47
|
+
def __getitem__(self, idx: int) -> Any:
|
|
48
|
+
return self._row_to_obj(self.rows[idx])
|
|
49
|
+
|
|
50
|
+
def first(self) -> Any | None:
|
|
51
|
+
"""Return the first row as an object, or None if empty."""
|
|
52
|
+
if not self.rows:
|
|
53
|
+
return None
|
|
54
|
+
return self._row_to_obj(self.rows[0])
|
|
55
|
+
|
|
56
|
+
def scalar(self) -> Any:
|
|
57
|
+
"""Return the single value from a 1×1 result."""
|
|
58
|
+
if not self.rows or not self.rows[0]:
|
|
59
|
+
raise ValueError("No results to extract scalar from")
|
|
60
|
+
return self.rows[0][0]
|
|
61
|
+
|
|
62
|
+
def to_dicts(self) -> list[dict[str, Any]]:
|
|
63
|
+
"""Convert all rows to list of dicts."""
|
|
64
|
+
return [dict(zip(self.columns, row)) for row in self.rows]
|
|
65
|
+
|
|
66
|
+
def to_tuples(self) -> list[tuple[Any, ...]]:
|
|
67
|
+
"""Convert all rows to list of tuples."""
|
|
68
|
+
return [tuple(row) for row in self.rows]
|
|
69
|
+
|
|
70
|
+
def to_df(self) -> Any:
|
|
71
|
+
"""Convert to a pandas DataFrame. Requires pandas."""
|
|
72
|
+
try:
|
|
73
|
+
import pandas as pd
|
|
74
|
+
except ImportError:
|
|
75
|
+
raise ImportError(
|
|
76
|
+
"pandas is required for to_df(). "
|
|
77
|
+
"Install with: pip install inputlayer[pandas]"
|
|
78
|
+
)
|
|
79
|
+
return pd.DataFrame(self.rows, columns=self.columns)
|
|
80
|
+
|
|
81
|
+
def _row_to_obj(self, row: list[Any]) -> Any:
|
|
82
|
+
"""Convert a row to a typed object or SimpleNamespace."""
|
|
83
|
+
if self._relation_cls is not None:
|
|
84
|
+
try:
|
|
85
|
+
kwargs = dict(zip(self.columns, row))
|
|
86
|
+
return self._relation_cls(**kwargs)
|
|
87
|
+
except Exception:
|
|
88
|
+
pass
|
|
89
|
+
return SimpleNamespace(**dict(zip(self.columns, row)))
|
inputlayer/session.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Session - ephemeral facts and rules (no + prefix)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from inputlayer.compiler import compile_bulk_insert, compile_insert, compile_rule
|
|
8
|
+
from inputlayer.relation import Relation
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from inputlayer.connection import Connection
|
|
12
|
+
from inputlayer.derived import Derived
|
|
13
|
+
from inputlayer.result import ResultSet
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Session:
|
|
17
|
+
"""Manage session-scoped (ephemeral) data.
|
|
18
|
+
|
|
19
|
+
Session inserts and rules omit the ``+`` prefix, making them ephemeral
|
|
20
|
+
(cleared on disconnect or KG switch).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, connection: Connection) -> None:
|
|
24
|
+
self._conn = connection
|
|
25
|
+
|
|
26
|
+
async def insert(self, facts: Relation | list[Relation]) -> None:
|
|
27
|
+
"""Insert ephemeral session facts (no + prefix)."""
|
|
28
|
+
if isinstance(facts, list):
|
|
29
|
+
if not facts:
|
|
30
|
+
return
|
|
31
|
+
datalog = compile_bulk_insert(type(facts[0]), facts, persistent=False)
|
|
32
|
+
else:
|
|
33
|
+
datalog = compile_insert(facts, persistent=False)
|
|
34
|
+
await self._conn.execute(datalog)
|
|
35
|
+
|
|
36
|
+
async def define_rules(self, *targets: type[Derived]) -> None:
|
|
37
|
+
"""Define session-scoped rules (no + prefix)."""
|
|
38
|
+
from inputlayer.derived import Derived
|
|
39
|
+
|
|
40
|
+
for target in targets:
|
|
41
|
+
head_name = Relation._resolve_name(target)
|
|
42
|
+
head_columns = Relation._get_columns(target)
|
|
43
|
+
for clause in target.rules:
|
|
44
|
+
datalog = compile_rule(
|
|
45
|
+
head_name,
|
|
46
|
+
head_columns,
|
|
47
|
+
clause.select_map,
|
|
48
|
+
clause.relations,
|
|
49
|
+
clause.condition,
|
|
50
|
+
persistent=False,
|
|
51
|
+
)
|
|
52
|
+
await self._conn.execute(datalog)
|
|
53
|
+
|
|
54
|
+
async def list_rules(self) -> list[str]:
|
|
55
|
+
"""List session rules."""
|
|
56
|
+
result = await self._conn.execute(".session list")
|
|
57
|
+
return [row[0] for row in result.rows] if result.rows else []
|
|
58
|
+
|
|
59
|
+
async def drop_rule(
|
|
60
|
+
self,
|
|
61
|
+
name: str | None = None,
|
|
62
|
+
*,
|
|
63
|
+
index: int | None = None,
|
|
64
|
+
) -> None:
|
|
65
|
+
"""Drop a session rule by name, or a specific clause by index."""
|
|
66
|
+
if name and index is not None:
|
|
67
|
+
await self._conn.execute(f".session remove {name} {index}")
|
|
68
|
+
elif name:
|
|
69
|
+
await self._conn.execute(f".session drop {name}")
|
|
70
|
+
else:
|
|
71
|
+
raise ValueError("Must provide rule name")
|
|
72
|
+
|
|
73
|
+
async def clear(self) -> None:
|
|
74
|
+
"""Clear all session facts and rules."""
|
|
75
|
+
await self._conn.execute(".session clear")
|
inputlayer/types.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""InputLayer type system - Python types that map to Datalog storage types."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import Any, ClassVar
|
|
8
|
+
|
|
9
|
+
from pydantic import GetCoreSchemaHandler
|
|
10
|
+
from pydantic_core import CoreSchema, core_schema
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _VectorMeta(type):
|
|
14
|
+
"""Metaclass for Vector that supports Vector[N] syntax."""
|
|
15
|
+
|
|
16
|
+
_dim: int | None = None
|
|
17
|
+
_cache: ClassVar[dict[int, type]] = {}
|
|
18
|
+
|
|
19
|
+
def __getitem__(cls, dim: int) -> type:
|
|
20
|
+
if not isinstance(dim, int) or dim <= 0:
|
|
21
|
+
raise TypeError(f"Vector dimension must be a positive integer, got {dim!r}")
|
|
22
|
+
if dim not in cls._cache:
|
|
23
|
+
ns = type.__new__(_VectorMeta, f"Vector[{dim}]", (cls,), {"_dim": dim})
|
|
24
|
+
cls._cache[dim] = ns
|
|
25
|
+
return cls._cache[dim]
|
|
26
|
+
|
|
27
|
+
def __instancecheck__(cls, instance: object) -> bool:
|
|
28
|
+
if not isinstance(instance, list):
|
|
29
|
+
return False
|
|
30
|
+
if cls._dim is not None and len(instance) != cls._dim:
|
|
31
|
+
return False
|
|
32
|
+
return all(isinstance(v, (int, float)) for v in instance)
|
|
33
|
+
|
|
34
|
+
def __repr__(cls) -> str:
|
|
35
|
+
if cls._dim is not None:
|
|
36
|
+
return f"Vector[{cls._dim}]"
|
|
37
|
+
return "Vector"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Vector(list, metaclass=_VectorMeta):
|
|
41
|
+
"""Float32 vector type. Use Vector[N] for a fixed-dimensionality vector."""
|
|
42
|
+
|
|
43
|
+
_dim: ClassVar[int | None] = None
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def __get_pydantic_core_schema__(
|
|
47
|
+
cls, source_type: Any, handler: GetCoreSchemaHandler
|
|
48
|
+
) -> CoreSchema:
|
|
49
|
+
dim = getattr(source_type, "_dim", None)
|
|
50
|
+
|
|
51
|
+
def validate(v: Any) -> list:
|
|
52
|
+
if isinstance(v, (list, tuple)):
|
|
53
|
+
v = list(v)
|
|
54
|
+
if not isinstance(v, list):
|
|
55
|
+
raise ValueError(f"Expected list for Vector, got {type(v).__name__}")
|
|
56
|
+
if dim is not None and len(v) != dim:
|
|
57
|
+
raise ValueError(f"Expected vector of dimension {dim}, got {len(v)}")
|
|
58
|
+
return [float(x) for x in v]
|
|
59
|
+
|
|
60
|
+
return core_schema.no_info_plain_validator_function(
|
|
61
|
+
validate,
|
|
62
|
+
serialization=core_schema.plain_serializer_function_ser_schema(
|
|
63
|
+
lambda v: v, info_arg=False
|
|
64
|
+
),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class _VectorInt8Meta(type):
|
|
69
|
+
"""Metaclass for VectorInt8 that supports VectorInt8[N] syntax."""
|
|
70
|
+
|
|
71
|
+
_dim: int | None = None
|
|
72
|
+
_cache: ClassVar[dict[int, type]] = {}
|
|
73
|
+
|
|
74
|
+
def __getitem__(cls, dim: int) -> type:
|
|
75
|
+
if not isinstance(dim, int) or dim <= 0:
|
|
76
|
+
raise TypeError(
|
|
77
|
+
f"VectorInt8 dimension must be a positive integer, got {dim!r}"
|
|
78
|
+
)
|
|
79
|
+
if dim not in cls._cache:
|
|
80
|
+
ns = type.__new__(
|
|
81
|
+
_VectorInt8Meta, f"VectorInt8[{dim}]", (cls,), {"_dim": dim}
|
|
82
|
+
)
|
|
83
|
+
cls._cache[dim] = ns
|
|
84
|
+
return cls._cache[dim]
|
|
85
|
+
|
|
86
|
+
def __instancecheck__(cls, instance: object) -> bool:
|
|
87
|
+
if not isinstance(instance, list):
|
|
88
|
+
return False
|
|
89
|
+
if cls._dim is not None and len(instance) != cls._dim:
|
|
90
|
+
return False
|
|
91
|
+
return all(isinstance(v, int) and -128 <= v <= 127 for v in instance)
|
|
92
|
+
|
|
93
|
+
def __repr__(cls) -> str:
|
|
94
|
+
if cls._dim is not None:
|
|
95
|
+
return f"VectorInt8[{cls._dim}]"
|
|
96
|
+
return "VectorInt8"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class VectorInt8(list, metaclass=_VectorInt8Meta):
|
|
100
|
+
"""Int8 quantized vector type. Use VectorInt8[N] for fixed dimensionality."""
|
|
101
|
+
|
|
102
|
+
_dim: ClassVar[int | None] = None
|
|
103
|
+
|
|
104
|
+
@classmethod
|
|
105
|
+
def __get_pydantic_core_schema__(
|
|
106
|
+
cls, source_type: Any, handler: GetCoreSchemaHandler
|
|
107
|
+
) -> CoreSchema:
|
|
108
|
+
dim = getattr(source_type, "_dim", None)
|
|
109
|
+
|
|
110
|
+
def validate(v: Any) -> list:
|
|
111
|
+
if isinstance(v, (list, tuple)):
|
|
112
|
+
v = list(v)
|
|
113
|
+
if not isinstance(v, list):
|
|
114
|
+
raise ValueError(f"Expected list for VectorInt8, got {type(v).__name__}")
|
|
115
|
+
if dim is not None and len(v) != dim:
|
|
116
|
+
raise ValueError(f"Expected vector of dimension {dim}, got {len(v)}")
|
|
117
|
+
for x in v:
|
|
118
|
+
if not isinstance(x, int) or not (-128 <= x <= 127):
|
|
119
|
+
raise ValueError(f"VectorInt8 values must be int in [-128, 127], got {x}")
|
|
120
|
+
return [int(x) for x in v]
|
|
121
|
+
|
|
122
|
+
return core_schema.no_info_plain_validator_function(
|
|
123
|
+
validate,
|
|
124
|
+
serialization=core_schema.plain_serializer_function_ser_schema(
|
|
125
|
+
lambda v: v, info_arg=False
|
|
126
|
+
),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class Timestamp(int):
|
|
131
|
+
"""Timestamp as Unix milliseconds since epoch."""
|
|
132
|
+
|
|
133
|
+
@classmethod
|
|
134
|
+
def now(cls) -> Timestamp:
|
|
135
|
+
"""Current time as a Timestamp."""
|
|
136
|
+
return cls(int(time.time() * 1000))
|
|
137
|
+
|
|
138
|
+
@classmethod
|
|
139
|
+
def from_datetime(cls, dt: datetime) -> Timestamp:
|
|
140
|
+
"""Convert a datetime to a Timestamp (Unix ms)."""
|
|
141
|
+
return cls(int(dt.timestamp() * 1000))
|
|
142
|
+
|
|
143
|
+
def to_datetime(self) -> datetime:
|
|
144
|
+
"""Convert to a timezone-aware UTC datetime."""
|
|
145
|
+
return datetime.fromtimestamp(int(self) / 1000.0, tz=timezone.utc)
|
|
146
|
+
|
|
147
|
+
@classmethod
|
|
148
|
+
def __get_pydantic_core_schema__(
|
|
149
|
+
cls, source_type: Any, handler: GetCoreSchemaHandler
|
|
150
|
+
) -> CoreSchema:
|
|
151
|
+
return core_schema.no_info_plain_validator_function(
|
|
152
|
+
lambda v: Timestamp(int(v)),
|
|
153
|
+
serialization=core_schema.plain_serializer_function_ser_schema(
|
|
154
|
+
lambda v: int(v), info_arg=False
|
|
155
|
+
),
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# Map Python types to InputLayer Datalog type names
|
|
160
|
+
# Order matters: more specific types first (bool before int, Timestamp before int)
|
|
161
|
+
TYPE_MAP: dict[type, str] = {
|
|
162
|
+
bool: "bool",
|
|
163
|
+
Timestamp: "timestamp",
|
|
164
|
+
int: "int",
|
|
165
|
+
float: "float",
|
|
166
|
+
str: "string",
|
|
167
|
+
Vector: "vector",
|
|
168
|
+
VectorInt8: "vector_int8",
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def python_type_to_datalog(tp: type) -> str:
|
|
173
|
+
"""Convert a Python type annotation to its InputLayer Datalog type string.
|
|
174
|
+
|
|
175
|
+
Handles Vector[N], VectorInt8[N], and plain types.
|
|
176
|
+
"""
|
|
177
|
+
# Check for dimensioned vectors
|
|
178
|
+
if isinstance(tp, _VectorMeta) and tp._dim is not None:
|
|
179
|
+
return f"vector[{tp._dim}]"
|
|
180
|
+
if isinstance(tp, _VectorInt8Meta) and tp._dim is not None:
|
|
181
|
+
return f"vector_int8[{tp._dim}]"
|
|
182
|
+
# Check base types
|
|
183
|
+
for base_type, name in TYPE_MAP.items():
|
|
184
|
+
if tp is base_type:
|
|
185
|
+
return name
|
|
186
|
+
# Handle subclasses (e.g., Vector without dim)
|
|
187
|
+
try:
|
|
188
|
+
if issubclass(tp, base_type) and tp is not base_type:
|
|
189
|
+
return name
|
|
190
|
+
except TypeError:
|
|
191
|
+
pass
|
|
192
|
+
raise TypeError(f"Unsupported type for InputLayer schema: {tp!r}")
|