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.
@@ -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}")