inputlayer-client-dev 0.1.0__py3-none-any.whl → 0.1.0.dev913__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.
Files changed (52) hide show
  1. inputlayer/__init__.py +87 -75
  2. inputlayer/_ast.py +4 -5
  3. inputlayer/_naming.py +2 -2
  4. inputlayer/_protocol.py +12 -3
  5. inputlayer/_proxy.py +22 -3
  6. inputlayer/_sync.py +104 -0
  7. inputlayer/aggregations.py +10 -10
  8. inputlayer/auth.py +48 -1
  9. inputlayer/client.py +43 -8
  10. inputlayer/client_sync.py +97 -68
  11. inputlayer/compiler.py +125 -104
  12. inputlayer/connection.py +74 -13
  13. inputlayer/derived.py +2 -2
  14. inputlayer/exceptions.py +24 -2
  15. inputlayer/functions.py +2 -2
  16. inputlayer/index.py +2 -2
  17. inputlayer/integrations/__init__.py +8 -0
  18. inputlayer/integrations/langchain/__init__.py +38 -0
  19. inputlayer/integrations/langchain/params.py +130 -0
  20. inputlayer/integrations/langchain/retriever.py +354 -0
  21. inputlayer/integrations/langchain/tool.py +460 -0
  22. inputlayer/integrations/langchain/vector_store.py +651 -0
  23. inputlayer/integrations/langgraph/__init__.py +60 -0
  24. inputlayer/integrations/langgraph/_checkpoint_serde.py +96 -0
  25. inputlayer/integrations/langgraph/_checkpointer_mixin.py +357 -0
  26. inputlayer/integrations/langgraph/_memory_helpers.py +181 -0
  27. inputlayer/integrations/langgraph/_memory_mixin.py +139 -0
  28. inputlayer/integrations/langgraph/_utils.py +145 -0
  29. inputlayer/integrations/langgraph/checkpointer.py +506 -0
  30. inputlayer/integrations/langgraph/memory.py +557 -0
  31. inputlayer/integrations/langgraph/nodes.py +190 -0
  32. inputlayer/integrations/langgraph/router.py +155 -0
  33. inputlayer/integrations/langgraph/state.py +40 -0
  34. inputlayer/knowledge_graph.py +507 -104
  35. inputlayer/migrations/__init__.py +11 -11
  36. inputlayer/migrations/cli.py +14 -5
  37. inputlayer/migrations/executor.py +2 -4
  38. inputlayer/migrations/loader.py +0 -1
  39. inputlayer/migrations/operations.py +12 -12
  40. inputlayer/migrations/recorder.py +3 -3
  41. inputlayer/migrations/state.py +6 -6
  42. inputlayer/migrations/writer.py +32 -7
  43. inputlayer/notifications.py +16 -5
  44. inputlayer/relation.py +3 -3
  45. inputlayer/result.py +20 -6
  46. inputlayer/session.py +5 -7
  47. inputlayer/types.py +4 -4
  48. {inputlayer_client_dev-0.1.0.dist-info → inputlayer_client_dev-0.1.0.dev913.dist-info}/METADATA +222 -11
  49. inputlayer_client_dev-0.1.0.dev913.dist-info/RECORD +53 -0
  50. inputlayer_client_dev-0.1.0.dist-info/RECORD +0 -35
  51. {inputlayer_client_dev-0.1.0.dist-info → inputlayer_client_dev-0.1.0.dev913.dist-info}/WHEEL +0 -0
  52. {inputlayer_client_dev-0.1.0.dist-info → inputlayer_client_dev-0.1.0.dev913.dist-info}/entry_points.txt +0 -0
inputlayer/__init__.py CHANGED
@@ -1,11 +1,7 @@
1
1
  """inputlayer - Python Object-Logic Mapper for InputLayer knowledge graph engine."""
2
2
 
3
- # Core types
4
- from inputlayer.types import Timestamp, Vector, VectorInt8
5
-
6
- # Relation system
7
- from inputlayer.relation import Relation
8
- from inputlayer.derived import Derived, From, RuleClause
3
+ # Functions (re-export all)
4
+ from inputlayer import functions
9
5
 
10
6
  # Aggregations
11
7
  from inputlayer.aggregations import (
@@ -20,11 +16,13 @@ from inputlayer.aggregations import (
20
16
  within_radius,
21
17
  )
22
18
 
23
- # Functions (re-export all)
24
- from inputlayer import functions
19
+ # Auth
20
+ from inputlayer.auth import AclEntry, ApiKeyInfo, UserInfo
25
21
 
26
- # Index
27
- from inputlayer.index import HnswIndex
22
+ # Client
23
+ from inputlayer.client import InputLayer
24
+ from inputlayer.client_sync import InputLayerSync
25
+ from inputlayer.derived import Derived, From, RuleClause
28
26
 
29
27
  # Exceptions
30
28
  from inputlayer.exceptions import (
@@ -32,11 +30,14 @@ from inputlayer.exceptions import (
32
30
  CannotDropError,
33
31
  ConnectionError,
34
32
  IndexNotFoundError,
33
+ InputLayerConnectionError,
35
34
  InputLayerError,
35
+ InputLayerPermissionError,
36
36
  InternalError,
37
37
  KnowledgeGraphExistsError,
38
38
  KnowledgeGraphNotFoundError,
39
39
  PermissionError,
40
+ QueryError,
40
41
  QueryTimeoutError,
41
42
  RelationNotFoundError,
42
43
  RuleNotFoundError,
@@ -44,108 +45,119 @@ from inputlayer.exceptions import (
44
45
  ValidationError,
45
46
  )
46
47
 
47
- # Result
48
- from inputlayer.result import ResultSet
49
-
50
- # Client
51
- from inputlayer.client import InputLayer
52
- from inputlayer.client_sync import InputLayerSync
48
+ # Index
49
+ from inputlayer.index import HnswIndex
53
50
 
54
51
  # Knowledge Graph
55
52
  from inputlayer.knowledge_graph import (
56
53
  ClearResult,
57
54
  ColumnInfo,
55
+ DebugResult,
58
56
  DeleteResult,
59
- ExplainResult,
60
57
  IndexInfo,
61
58
  IndexStats,
62
59
  InsertResult,
63
60
  KnowledgeGraph,
61
+ ProofNode,
62
+ ProofTree,
64
63
  RelationDescription,
65
64
  RelationInfo,
66
65
  RuleInfo,
67
66
  ServerStatus,
67
+ WhyNotResult,
68
+ WhyResult,
68
69
  )
69
70
 
70
- # Auth
71
- from inputlayer.auth import AclEntry, ApiKeyInfo, UserInfo
72
-
73
- # Session
74
- from inputlayer.session import Session
71
+ # Migrations
72
+ from inputlayer.migrations import Migration
75
73
 
76
74
  # Notifications
77
75
  from inputlayer.notifications import NotificationEvent
78
76
 
79
- # Migrations
80
- from inputlayer.migrations import Migration
77
+ # Relation system
78
+ from inputlayer.relation import Relation
79
+
80
+ # Result
81
+ from inputlayer.result import ResultSet
82
+
83
+ # Session
84
+ from inputlayer.session import Session
85
+ from inputlayer.types import Timestamp, Vector, VectorInt8
81
86
 
82
87
  __version__ = "0.1.0"
83
88
 
84
89
  __all__ = [
85
- # Types
86
- "Vector",
87
- "VectorInt8",
88
- "Timestamp",
89
- # Relation
90
- "Relation",
90
+ "AclEntry",
91
+ "ApiKeyInfo",
92
+ "AuthenticationError",
93
+ "CannotDropError",
94
+ "ClearResult",
95
+ "ColumnInfo",
96
+ "ConnectionError",
97
+ "DebugResult",
98
+ "DeleteResult",
91
99
  "Derived",
92
100
  "From",
93
- "RuleClause",
94
- # Aggregations
95
- "count",
96
- "count_distinct",
97
- "sum_",
98
- "min_",
99
- "max_",
100
- "avg",
101
- "top_k",
102
- "top_k_threshold",
103
- "within_radius",
104
- # Functions
105
- "functions",
106
101
  # Index
107
102
  "HnswIndex",
108
- # Exceptions
109
- "InputLayerError",
110
- "ConnectionError",
111
- "AuthenticationError",
112
- "SchemaConflictError",
113
- "ValidationError",
114
- "QueryTimeoutError",
115
- "PermissionError",
116
- "KnowledgeGraphNotFoundError",
117
- "KnowledgeGraphExistsError",
118
- "CannotDropError",
119
- "RelationNotFoundError",
120
- "RuleNotFoundError",
103
+ "IndexInfo",
121
104
  "IndexNotFoundError",
122
- "InternalError",
123
- # Result
124
- "ResultSet",
105
+ "IndexStats",
125
106
  # Client
126
107
  "InputLayer",
108
+ # Exceptions
109
+ "InputLayerConnectionError",
110
+ "InputLayerError",
111
+ "InputLayerPermissionError",
127
112
  "InputLayerSync",
113
+ "InsertResult",
114
+ "InternalError",
128
115
  # KG
129
116
  "KnowledgeGraph",
130
- "RelationInfo",
117
+ "KnowledgeGraphExistsError",
118
+ "KnowledgeGraphNotFoundError",
119
+ # Migrations
120
+ "Migration",
121
+ # Notifications
122
+ "NotificationEvent",
123
+ "PermissionError",
124
+ "ProofNode",
125
+ "ProofTree",
126
+ "QueryError",
127
+ "QueryTimeoutError",
128
+ # Relation
129
+ "Relation",
131
130
  "RelationDescription",
132
- "ColumnInfo",
131
+ "RelationInfo",
132
+ "RelationNotFoundError",
133
+ # Result
134
+ "ResultSet",
135
+ "RuleClause",
133
136
  "RuleInfo",
134
- "IndexInfo",
135
- "IndexStats",
136
- "InsertResult",
137
- "DeleteResult",
138
- "ClearResult",
139
- "ExplainResult",
137
+ "RuleNotFoundError",
138
+ "SchemaConflictError",
140
139
  "ServerStatus",
141
- # Auth
142
- "UserInfo",
143
- "ApiKeyInfo",
144
- "AclEntry",
145
140
  # Session
146
141
  "Session",
147
- # Notifications
148
- "NotificationEvent",
149
- # Migrations
150
- "Migration",
142
+ "Timestamp",
143
+ # Auth
144
+ "UserInfo",
145
+ "ValidationError",
146
+ # Types
147
+ "Vector",
148
+ "VectorInt8",
149
+ "WhyNotResult",
150
+ "WhyResult",
151
+ "avg",
152
+ # Aggregations
153
+ "count",
154
+ "count_distinct",
155
+ # Functions
156
+ "functions",
157
+ "max_",
158
+ "min_",
159
+ "sum_",
160
+ "top_k",
161
+ "top_k_threshold",
162
+ "within_radius",
151
163
  ]
inputlayer/_ast.py CHANGED
@@ -1,11 +1,10 @@
1
- """Internal AST nodes for expression trees compiled to Datalog."""
1
+ """Internal AST nodes for expression trees compiled to IQL."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from dataclasses import dataclass, field
5
+ from dataclasses import dataclass
6
6
  from typing import Any
7
7
 
8
-
9
8
  # ── Base ──────────────────────────────────────────────────────────────
10
9
 
11
10
  @dataclass(frozen=True)
@@ -93,7 +92,7 @@ class Comparison(BoolExpr):
93
92
 
94
93
  @dataclass(frozen=True)
95
94
  class And(BoolExpr):
96
- """Logical AND of two conditions (Datalog comma)."""
95
+ """Logical AND of two conditions (IQL comma)."""
97
96
  left: BoolExpr
98
97
  right: BoolExpr
99
98
 
@@ -107,7 +106,7 @@ class Or(BoolExpr):
107
106
 
108
107
  @dataclass(frozen=True)
109
108
  class Not(BoolExpr):
110
- """Negation: !relation(X, Y) in Datalog."""
109
+ """Negation: !relation(X, Y) in IQL."""
111
110
  operand: BoolExpr
112
111
 
113
112
 
inputlayer/_naming.py CHANGED
@@ -1,4 +1,4 @@
1
- """Naming convention utilities: CamelCase ↔ snake_case, column → Datalog variable."""
1
+ """Naming convention utilities: CamelCase ↔ snake_case, column → IQL variable."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -33,7 +33,7 @@ def snake_to_camel(name: str) -> str:
33
33
 
34
34
 
35
35
  def column_to_variable(column_name: str) -> str:
36
- """Convert a snake_case column name to a Datalog variable (Capitalized).
36
+ """Convert a snake_case column name to an IQL variable (Capitalized).
37
37
 
38
38
  Examples:
39
39
  id -> Id
inputlayer/_protocol.py CHANGED
@@ -6,10 +6,9 @@ Matches the AsyncAPI spec at ``docs/spec/asyncapi.yaml``.
6
6
  from __future__ import annotations
7
7
 
8
8
  import json
9
- from dataclasses import dataclass, field
9
+ from dataclasses import dataclass
10
10
  from typing import Any
11
11
 
12
-
13
12
  # ── Client → Server messages ──────────────────────────────────────────
14
13
 
15
14
  @dataclass(frozen=True)
@@ -79,6 +78,8 @@ class ResultResponse:
79
78
  row_provenance: list[str] | None = None
80
79
  metadata: dict[str, Any] | None = None
81
80
  switched_kg: str | None = None
81
+ proof_trees: list[dict[str, Any]] | None = None
82
+ timing_breakdown: dict[str, Any] | None = None
82
83
 
83
84
 
84
85
  @dataclass(frozen=True)
@@ -95,6 +96,8 @@ class ResultStartResponse:
95
96
  execution_time_ms: int
96
97
  metadata: dict[str, Any] | None = None
97
98
  switched_kg: str | None = None
99
+ proof_trees: list[dict[str, Any]] | None = None
100
+ timing_breakdown: dict[str, Any] | None = None
98
101
 
99
102
 
100
103
  @dataclass(frozen=True)
@@ -149,7 +152,9 @@ ServerMessage = (
149
152
 
150
153
  # ── Serialization / Deserialization ───────────────────────────────────
151
154
 
152
- def serialize_message(msg: LoginMessage | AuthenticateMessage | ExecuteMessage | PingMessage) -> str:
155
+ def serialize_message(
156
+ msg: LoginMessage | AuthenticateMessage | ExecuteMessage | PingMessage,
157
+ ) -> str:
153
158
  """Serialize a client message to JSON."""
154
159
  return msg.to_json()
155
160
 
@@ -181,6 +186,8 @@ def deserialize_message(data: str | bytes) -> ServerMessage:
181
186
  row_provenance=obj.get("row_provenance"),
182
187
  metadata=obj.get("metadata"),
183
188
  switched_kg=obj.get("switched_kg"),
189
+ proof_trees=obj.get("proof_trees"),
190
+ timing_breakdown=obj.get("timing_breakdown"),
184
191
  )
185
192
  if msg_type == "error":
186
193
  return ErrorResponse(
@@ -195,6 +202,8 @@ def deserialize_message(data: str | bytes) -> ServerMessage:
195
202
  execution_time_ms=obj["execution_time_ms"],
196
203
  metadata=obj.get("metadata"),
197
204
  switched_kg=obj.get("switched_kg"),
205
+ proof_trees=obj.get("proof_trees"),
206
+ timing_breakdown=obj.get("timing_breakdown"),
198
207
  )
199
208
  if msg_type == "result_chunk":
200
209
  return ResultChunkResponse(
inputlayer/_proxy.py CHANGED
@@ -5,11 +5,9 @@ from __future__ import annotations
5
5
  from typing import TYPE_CHECKING, Any
6
6
 
7
7
  from inputlayer._ast import (
8
- AggExpr,
9
8
  And,
10
9
  Arithmetic,
11
10
  BoolExpr,
12
- Column as AstColumn,
13
11
  Comparison,
14
12
  Expr,
15
13
  InExpr,
@@ -20,6 +18,9 @@ from inputlayer._ast import (
20
18
  Or,
21
19
  OrderedColumn,
22
20
  )
21
+ from inputlayer._ast import (
22
+ Column as AstColumn,
23
+ )
23
24
 
24
25
  if TYPE_CHECKING:
25
26
  from inputlayer.relation import Relation
@@ -28,10 +29,28 @@ if TYPE_CHECKING:
28
29
  class ColumnProxy:
29
30
  """Proxy returned by Relation.column_name - builds AST nodes via operators."""
30
31
 
31
- def __init__(self, relation: str, name: str, *, ref_alias: str | None = None) -> None:
32
+ def __init__(
33
+ self,
34
+ relation: str,
35
+ name: str,
36
+ *,
37
+ ref_alias: str | None = None,
38
+ relation_cls: type | None = None,
39
+ ) -> None:
32
40
  self._relation = relation
33
41
  self._name = name
34
42
  self._ref_alias = ref_alias
43
+ self._relation_cls = relation_cls
44
+
45
+ @property
46
+ def relation_cls(self) -> type | None:
47
+ """The originating ``Relation`` subclass, if known.
48
+
49
+ Set when the proxy was created via attribute access on a
50
+ ``Relation`` class (``Sale.region``). Stays ``None`` for
51
+ proxies built from raw column references.
52
+ """
53
+ return self._relation_cls
35
54
 
36
55
  @property
37
56
  def relation(self) -> str:
inputlayer/_sync.py ADDED
@@ -0,0 +1,104 @@
1
+ """Sync-from-async bridge using a dedicated background event loop thread.
2
+
3
+ Safe to call from any context: plain scripts, Jupyter notebooks,
4
+ inside running event loops (FastAPI, LangGraph), etc.
5
+
6
+ This is the same pattern used by httpx.Client and playwright.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import atexit
13
+ import logging
14
+ import threading
15
+ from collections.abc import Coroutine
16
+ from typing import Any, TypeVar
17
+
18
+ T = TypeVar("T")
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ # Default timeout (seconds) for waiting on coroutine results.
23
+ # None means wait indefinitely (matching httpx behavior).
24
+ DEFAULT_TIMEOUT: float | None = None
25
+
26
+
27
+ class _LoopThread:
28
+ """Background thread owning its own event loop."""
29
+
30
+ def __init__(self, timeout: float | None = DEFAULT_TIMEOUT) -> None:
31
+ self._loop: asyncio.AbstractEventLoop | None = None
32
+ self._thread: threading.Thread | None = None
33
+ self._lock = threading.Lock()
34
+ self._timeout = timeout
35
+
36
+ def _ensure_running(self) -> asyncio.AbstractEventLoop:
37
+ # Fast path: already running (no lock needed for a quick read -
38
+ # the lock below handles the actual creation race).
39
+ loop = self._loop
40
+ if loop is not None and loop.is_running():
41
+ return loop
42
+ with self._lock:
43
+ # Re-check under lock to avoid creating a duplicate.
44
+ if self._loop is not None and self._loop.is_running():
45
+ return self._loop
46
+ new_loop = asyncio.new_event_loop()
47
+ thread = threading.Thread(
48
+ target=new_loop.run_forever,
49
+ daemon=True,
50
+ name="inputlayer-sync",
51
+ )
52
+ thread.start()
53
+ self._loop = new_loop
54
+ self._thread = thread
55
+ return new_loop
56
+
57
+ def run(self, coro: Coroutine[Any, Any, T]) -> T:
58
+ """Submit a coroutine to the background loop and block until done."""
59
+ if threading.current_thread() is self._thread:
60
+ raise RuntimeError(
61
+ "Cannot call run_sync from the background event loop thread "
62
+ "- use 'await' instead. This would deadlock."
63
+ )
64
+ loop = self._ensure_running()
65
+ future = asyncio.run_coroutine_threadsafe(coro, loop)
66
+ return future.result(timeout=self._timeout)
67
+
68
+ def shutdown(self) -> None:
69
+ """Stop the background loop and join the thread."""
70
+ with self._lock:
71
+ loop = self._loop
72
+ thread = self._thread
73
+ self._loop = None
74
+ self._thread = None
75
+
76
+ if loop is not None and loop.is_running():
77
+ try:
78
+ loop.call_soon_threadsafe(loop.stop)
79
+ except RuntimeError:
80
+ # Loop already closed or stopping - not an error.
81
+ logger.debug("Loop already stopped during shutdown")
82
+ if thread is not None:
83
+ thread.join(timeout=5)
84
+ if thread.is_alive():
85
+ logger.warning(
86
+ "inputlayer-sync thread did not stop within 5 s; "
87
+ "it will be cleaned up at process exit (daemon thread)."
88
+ )
89
+ if loop is not None and not loop.is_closed():
90
+ loop.close()
91
+
92
+
93
+ _default_thread = _LoopThread()
94
+ atexit.register(_default_thread.shutdown)
95
+
96
+
97
+ def run_sync(coro: Coroutine[Any, Any, T]) -> T:
98
+ """Run an async coroutine from synchronous code. Always safe.
99
+
100
+ Uses a module-level background thread with its own event loop,
101
+ so it works even when the caller already has a running loop
102
+ (Jupyter, FastAPI, LangGraph, etc.).
103
+ """
104
+ return _default_thread.run(coro)
@@ -1,4 +1,4 @@
1
- """Aggregation functions that compile to Datalog aggregates."""
1
+ """Aggregation functions that compile to IQL aggregates."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -20,7 +20,7 @@ def _to_expr(col: ColumnProxy | Expr) -> Expr:
20
20
  def count(column: ColumnProxy | type[Relation] | None = None) -> AggExpr:
21
21
  """Count rows. If a column is given, counts non-null values.
22
22
 
23
- Datalog: count<Var>
23
+ IQL: count<Var>
24
24
  """
25
25
  if column is None or (isinstance(column, type)):
26
26
  # count(*) - needs at least one column from the body
@@ -31,7 +31,7 @@ def count(column: ColumnProxy | type[Relation] | None = None) -> AggExpr:
31
31
  def count_distinct(column: ColumnProxy) -> AggExpr:
32
32
  """Count distinct values.
33
33
 
34
- Datalog: count_distinct<Var>
34
+ IQL: count_distinct<Var>
35
35
  """
36
36
  return AggExpr(func="count_distinct", column=_to_expr(column))
37
37
 
@@ -39,7 +39,7 @@ def count_distinct(column: ColumnProxy) -> AggExpr:
39
39
  def sum_(column: ColumnProxy) -> AggExpr:
40
40
  """Sum numeric values.
41
41
 
42
- Datalog: sum<Var>
42
+ IQL: sum<Var>
43
43
  """
44
44
  return AggExpr(func="sum", column=_to_expr(column))
45
45
 
@@ -47,7 +47,7 @@ def sum_(column: ColumnProxy) -> AggExpr:
47
47
  def min_(column: ColumnProxy) -> AggExpr:
48
48
  """Minimum value.
49
49
 
50
- Datalog: min<Var>
50
+ IQL: min<Var>
51
51
  """
52
52
  return AggExpr(func="min", column=_to_expr(column))
53
53
 
@@ -55,7 +55,7 @@ def min_(column: ColumnProxy) -> AggExpr:
55
55
  def max_(column: ColumnProxy) -> AggExpr:
56
56
  """Maximum value.
57
57
 
58
- Datalog: max<Var>
58
+ IQL: max<Var>
59
59
  """
60
60
  return AggExpr(func="max", column=_to_expr(column))
61
61
 
@@ -63,7 +63,7 @@ def max_(column: ColumnProxy) -> AggExpr:
63
63
  def avg(column: ColumnProxy) -> AggExpr:
64
64
  """Average value.
65
65
 
66
- Datalog: avg<Var>
66
+ IQL: avg<Var>
67
67
  """
68
68
  return AggExpr(func="avg", column=_to_expr(column))
69
69
 
@@ -76,7 +76,7 @@ def top_k(
76
76
  ) -> AggExpr:
77
77
  """Top-K aggregation with ordering.
78
78
 
79
- Datalog: top_k<k, Passthrough..., OrderCol:desc>
79
+ IQL: top_k<k, Passthrough..., OrderCol:desc>
80
80
  """
81
81
  return AggExpr(
82
82
  func="top_k",
@@ -96,7 +96,7 @@ def top_k_threshold(
96
96
  ) -> AggExpr:
97
97
  """Top-K with threshold aggregation.
98
98
 
99
- Datalog: top_k_threshold<k, threshold, Passthrough..., OrderCol:desc>
99
+ IQL: top_k_threshold<k, threshold, Passthrough..., OrderCol:desc>
100
100
  """
101
101
  return AggExpr(
102
102
  func="top_k_threshold",
@@ -115,7 +115,7 @@ def within_radius(
115
115
  ) -> AggExpr:
116
116
  """Within-radius aggregation.
117
117
 
118
- Datalog: within_radius<r, Passthrough..., DistCol:asc>
118
+ IQL: within_radius<r, Passthrough..., DistCol:asc>
119
119
  """
120
120
  return AggExpr(
121
121
  func="within_radius",
inputlayer/auth.py CHANGED
@@ -1,9 +1,40 @@
1
- """Authentication helpers - data classes and meta-command compilation for user/key/ACL management."""
1
+ """Authentication helpers.
2
+
3
+ Data classes and meta-command compilation for user/key/ACL management.
4
+ """
2
5
 
3
6
  from __future__ import annotations
4
7
 
8
+ import re
5
9
  from dataclasses import dataclass
6
10
 
11
+ _SAFE_IDENTIFIER = re.compile(r"^[A-Za-z0-9_.-]+$")
12
+ # Passwords allow more characters but no whitespace or control chars
13
+ _SAFE_PASSWORD = re.compile(r"^\S+$")
14
+
15
+
16
+ def _validate_identifier(value: str, name: str) -> str:
17
+ """Validate that a value is safe for use in meta-commands."""
18
+ if not value:
19
+ raise ValueError(f"{name} must not be empty")
20
+ if not _SAFE_IDENTIFIER.match(value):
21
+ raise ValueError(
22
+ f"{name} contains invalid characters: {value!r}. "
23
+ f"Only letters, digits, underscores, dots, and hyphens are allowed."
24
+ )
25
+ return value
26
+
27
+
28
+ def _validate_password(value: str, name: str) -> str:
29
+ """Validate a password for use in space-delimited meta-commands."""
30
+ if not value:
31
+ raise ValueError(f"{name} must not be empty")
32
+ if not _SAFE_PASSWORD.match(value):
33
+ raise ValueError(
34
+ f"{name} must not contain whitespace: {value!r}"
35
+ )
36
+ return value
37
+
7
38
 
8
39
  @dataclass(frozen=True)
9
40
  class UserInfo:
@@ -27,18 +58,26 @@ class AclEntry:
27
58
 
28
59
 
29
60
  def compile_create_user(username: str, password: str, role: str = "viewer") -> str:
61
+ _validate_identifier(username, "username")
62
+ _validate_password(password, "password")
63
+ _validate_identifier(role, "role")
30
64
  return f".user create {username} {password} {role}"
31
65
 
32
66
 
33
67
  def compile_drop_user(username: str) -> str:
68
+ _validate_identifier(username, "username")
34
69
  return f".user drop {username}"
35
70
 
36
71
 
37
72
  def compile_set_password(username: str, new_password: str) -> str:
73
+ _validate_identifier(username, "username")
74
+ _validate_password(new_password, "new_password")
38
75
  return f".user password {username} {new_password}"
39
76
 
40
77
 
41
78
  def compile_set_role(username: str, role: str) -> str:
79
+ _validate_identifier(username, "username")
80
+ _validate_identifier(role, "role")
42
81
  return f".user role {username} {role}"
43
82
 
44
83
 
@@ -47,6 +86,7 @@ def compile_list_users() -> str:
47
86
 
48
87
 
49
88
  def compile_create_api_key(label: str) -> str:
89
+ _validate_identifier(label, "label")
50
90
  return f".apikey create {label}"
51
91
 
52
92
 
@@ -55,16 +95,23 @@ def compile_list_api_keys() -> str:
55
95
 
56
96
 
57
97
  def compile_revoke_api_key(label: str) -> str:
98
+ _validate_identifier(label, "label")
58
99
  return f".apikey revoke {label}"
59
100
 
60
101
 
61
102
  def compile_grant_access(kg: str, username: str, role: str) -> str:
103
+ _validate_identifier(kg, "kg")
104
+ _validate_identifier(username, "username")
105
+ _validate_identifier(role, "role")
62
106
  return f".kg acl grant {kg} {username} {role}"
63
107
 
64
108
 
65
109
  def compile_revoke_access(kg: str, username: str) -> str:
110
+ _validate_identifier(kg, "kg")
111
+ _validate_identifier(username, "username")
66
112
  return f".kg acl revoke {kg} {username}"
67
113
 
68
114
 
69
115
  def compile_list_acl(kg: str) -> str:
116
+ _validate_identifier(kg, "kg")
70
117
  return f".kg acl list {kg}"