korm 2.0.0a3__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.
korm/__init__.py ADDED
@@ -0,0 +1,32 @@
1
+ """korm — reference implementation of the KORM Protocol v2.
2
+
3
+ KORM is a JSON request/response protocol for dynamic database operations::
4
+
5
+ import korm
6
+
7
+ db = korm.initialize_korm(url="sqlite:///app.db", schema="korm.schema.json")
8
+ result = db.process_sync({"korm": 2, "action": "list", "model": "users",
9
+ "where": {"age": {"gte": 18}}, "limit": 20})
10
+ """
11
+ from .facade import PROTOCOL_VERSION, SPEC_VERSION, Korm, initialize_korm
12
+ from .protocol.errors import ErrorCode, KormError
13
+ from .schema.introspect import diff, generate_schema, sync_database
14
+ from .policy import Policy
15
+ from .schema import Schema
16
+
17
+ __version__ = "2.0.0a3"
18
+
19
+ __all__ = [
20
+ "Korm",
21
+ "initialize_korm",
22
+ "Schema",
23
+ "Policy",
24
+ "ErrorCode",
25
+ "KormError",
26
+ "generate_schema",
27
+ "sync_database",
28
+ "diff",
29
+ "PROTOCOL_VERSION",
30
+ "SPEC_VERSION",
31
+ "__version__",
32
+ ]
korm/cli/__init__.py ADDED
@@ -0,0 +1,110 @@
1
+ """korm CLI: init, generate-schema, sync, diff, serve-mcp (spec §13)."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import sqlalchemy as sa
10
+
11
+ from .. import __version__
12
+ from ..schema.introspect import diff as schema_diff
13
+ from ..schema.introspect import generate_schema, sync_database
14
+ from ..schema import Schema
15
+
16
+ _EXAMPLE_SCHEMA = {
17
+ "kormSchema": 2,
18
+ "models": {
19
+ "users": {
20
+ "table": "users",
21
+ "primaryKey": ["id"],
22
+ "softDelete": {"column": "deleted_at"},
23
+ "timestamps": {"createdAt": "created_at", "updatedAt": "updated_at"},
24
+ "columns": {
25
+ "id": {"type": "increments"},
26
+ "username": {"type": "string", "length": 80, "unique": True, "required": True},
27
+ "email": {"type": "string", "format": "email", "required": True},
28
+ "is_active": {"type": "boolean", "default": True},
29
+ "created_at": {"type": "datetime", "nullable": True},
30
+ "updated_at": {"type": "datetime", "nullable": True},
31
+ "deleted_at": {"type": "datetime", "nullable": True},
32
+ },
33
+ }
34
+ },
35
+ }
36
+
37
+
38
+ def main(argv: list[str] | None = None) -> int:
39
+ parser = argparse.ArgumentParser(prog="korm", description="KORM Protocol v2 tooling")
40
+ parser.add_argument("--version", action="version", version=f"korm {__version__}")
41
+ sub = parser.add_subparsers(dest="command", required=True)
42
+
43
+ p_init = sub.add_parser("init", help="scaffold a korm.schema.json in the current directory")
44
+ p_init.add_argument("--ai", help="AI provider preset for agent tooling (recorded in the scaffold)")
45
+ p_init.add_argument("--out", default="korm.schema.json")
46
+
47
+ p_gen = sub.add_parser("generate-schema", help="introspect a database into korm.schema.json")
48
+ p_gen.add_argument("--url", required=True)
49
+ p_gen.add_argument("--out", default="korm.schema.json")
50
+
51
+ p_sync = sub.add_parser("sync", help="create missing tables from the schema (dev tool)")
52
+ p_sync.add_argument("--url", required=True)
53
+ p_sync.add_argument("--schema", default="korm.schema.json")
54
+
55
+ p_diff = sub.add_parser("diff", help="print a migration skeleton: schema vs live database")
56
+ p_diff.add_argument("--url", required=True)
57
+ p_diff.add_argument("--schema", default="korm.schema.json")
58
+
59
+ p_mcp = sub.add_parser("serve-mcp", help="serve per-model MCP tools over stdio")
60
+ p_mcp.add_argument("--url", required=True)
61
+ p_mcp.add_argument("--schema", default="korm.schema.json")
62
+
63
+ args = parser.parse_args(argv)
64
+
65
+ if args.command == "init":
66
+ out = Path(args.out)
67
+ if out.exists():
68
+ print(f"{out} already exists; not overwriting", file=sys.stderr)
69
+ return 1
70
+ scaffold = dict(_EXAMPLE_SCHEMA)
71
+ if args.ai:
72
+ scaffold = {**scaffold, "x-ai-provider": args.ai}
73
+ out.write_text(json.dumps(scaffold, indent=2) + "\n")
74
+ print(f"wrote {out}")
75
+ return 0
76
+
77
+ if args.command == "generate-schema":
78
+ engine = sa.create_engine(args.url)
79
+ schema = generate_schema(engine)
80
+ Path(args.out).write_text(json.dumps(schema, indent=2) + "\n")
81
+ print(f"wrote {args.out} ({len(schema['models'])} models)")
82
+ return 0
83
+
84
+ if args.command == "sync":
85
+ engine = sa.create_engine(args.url)
86
+ sync_database(engine, Schema.load(args.schema))
87
+ print("database synced (dev tool — use migrations in production)")
88
+ return 0
89
+
90
+ if args.command == "diff":
91
+ engine = sa.create_engine(args.url)
92
+ lines = schema_diff(engine, Schema.load(args.schema))
93
+ if not lines:
94
+ print("-- no differences")
95
+ for line in lines:
96
+ print(line)
97
+ return 0
98
+
99
+ if args.command == "serve-mcp":
100
+ from ..facade import initialize_korm
101
+ from ..mcp import serve_mcp
102
+ korm = initialize_korm(url=args.url, schema=args.schema)
103
+ serve_mcp(korm)
104
+ return 0
105
+
106
+ return 1
107
+
108
+
109
+ if __name__ == "__main__":
110
+ raise SystemExit(main())
korm/cli/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from . import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,13 @@
1
+ """v1 compatibility layer (spec §12) — pure adapter over the v2 surface."""
2
+ from .v1 import (
3
+ convert_v1_join_on,
4
+ convert_v1_request,
5
+ convert_v1_value,
6
+ convert_v1_where,
7
+ downconvert_response,
8
+ parse_formula,
9
+ resolve_refs,
10
+ )
11
+
12
+ __all__ = ["convert_v1_request", "convert_v1_value", "convert_v1_where",
13
+ "convert_v1_join_on", "downconvert_response", "parse_formula", "resolve_refs"]
korm/compat/v1.py ADDED
@@ -0,0 +1,249 @@
1
+ """v1 → v2 compatibility converter (spec §12) and batch $ref resolution (§8).
2
+
3
+ The converter is pure and deterministic: any valid v1 request maps to one v2
4
+ request. Responses to v1-marked requests are down-converted (bare count number,
5
+ bare object for show) so existing clients keep working.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from typing import Any
11
+
12
+ from ..protocol.errors import field_error, validation_failed
13
+
14
+ _V1_ACTION_ALIASES = {"count", "sum"}
15
+
16
+
17
+ # ---------------------------------------------------------------- values
18
+ def _parse_scalar(s: str) -> Any:
19
+ try:
20
+ return int(s)
21
+ except ValueError:
22
+ try:
23
+ return float(s)
24
+ except ValueError:
25
+ return s.strip()
26
+
27
+
28
+ def convert_v1_value(value: Any) -> Any:
29
+ """Convert one v1 stringly-typed operator value to a v2 operator object."""
30
+ if not isinstance(value, str):
31
+ return value
32
+ if value.startswith(">="):
33
+ return {"gte": _parse_scalar(value[2:])}
34
+ if value.startswith("<="):
35
+ return {"lte": _parse_scalar(value[2:])}
36
+ if value.startswith("><"):
37
+ parts = value[2:].split(",")
38
+ if len(parts) != 2:
39
+ raise validation_failed([field_error("where", "INVALID_VALUE", f"malformed v1 between {value!r}")])
40
+ return {"between": [_parse_scalar(parts[0]), _parse_scalar(parts[1])]}
41
+ if value.startswith(">"):
42
+ return {"gt": _parse_scalar(value[1:])}
43
+ if value.startswith("<"):
44
+ return {"lt": _parse_scalar(value[1:])}
45
+ if value.startswith("[]"):
46
+ return {"in": [_parse_scalar(p) for p in value[2:].split(",")]}
47
+ if value.startswith("!"):
48
+ return {"ne": _parse_scalar(value[1:])}
49
+ if "%" in value:
50
+ return {"like": value}
51
+ return value
52
+
53
+
54
+ def convert_v1_where(where: Any) -> Any:
55
+ """Convert a v1 where object, honoring "Or:col" keys (left-to-right OR grouping)."""
56
+ if not isinstance(where, dict):
57
+ return where
58
+ and_part: dict[str, Any] = {}
59
+ or_parts: list[dict[str, Any]] = []
60
+ for key, value in where.items():
61
+ if key.startswith("Or:") or key.startswith("or:"):
62
+ col = key.split(":", 1)[1]
63
+ if isinstance(value, list):
64
+ or_parts.extend({col: convert_v1_value(v)} for v in value)
65
+ else:
66
+ or_parts.append({col: convert_v1_value(value)})
67
+ else:
68
+ and_part[key] = convert_v1_value(value)
69
+ if not or_parts:
70
+ return and_part
71
+ groups: list[dict[str, Any]] = []
72
+ if and_part:
73
+ groups.append(and_part)
74
+ groups.extend(or_parts)
75
+ return {"or": groups}
76
+
77
+
78
+ # ---------------------------------------------------------------- sumFormula
79
+ _TOKEN_RE = re.compile(r"\s*(?:(\d+\.\d+|\d+)|([A-Za-z_][A-Za-z0-9_]*)|([+\-*/()]))")
80
+
81
+
82
+ def parse_formula(formula: str) -> Any:
83
+ """Parse a v1 sumFormula string ("salary + bonus * 2") into a v2 expression tree."""
84
+ tokens: list[Any] = []
85
+ pos = 0
86
+ while pos < len(formula):
87
+ m = _TOKEN_RE.match(formula, pos)
88
+ if not m or m.end() == pos:
89
+ if formula[pos:].strip():
90
+ raise validation_failed([
91
+ field_error("sumFormula", "INVALID_VALUE", f"cannot parse formula at {formula[pos:]!r}"),
92
+ ])
93
+ break
94
+ num, ident, op = m.groups()
95
+ if num is not None:
96
+ tokens.append(float(num) if "." in num else int(num))
97
+ elif ident is not None:
98
+ tokens.append(ident)
99
+ else:
100
+ tokens.append(op)
101
+ pos = m.end()
102
+
103
+ idx = 0
104
+
105
+ def peek():
106
+ return tokens[idx] if idx < len(tokens) else None
107
+
108
+ def parse_expr():
109
+ nonlocal idx
110
+ node = parse_term()
111
+ while peek() in ("+", "-"):
112
+ op = tokens[idx]; idx += 1
113
+ rhs = parse_term()
114
+ node = {"add" if op == "+" else "sub": [node, rhs]}
115
+ return node
116
+
117
+ def parse_term():
118
+ nonlocal idx
119
+ node = parse_atom()
120
+ while peek() in ("*", "/"):
121
+ op = tokens[idx]; idx += 1
122
+ rhs = parse_atom()
123
+ node = {"mul" if op == "*" else "div": [node, rhs]}
124
+ return node
125
+
126
+ def parse_atom():
127
+ nonlocal idx
128
+ tok = peek()
129
+ if tok == "(":
130
+ idx += 1
131
+ node = parse_expr()
132
+ if peek() != ")":
133
+ raise validation_failed([field_error("sumFormula", "INVALID_VALUE", "unbalanced parentheses")])
134
+ idx += 1
135
+ return node
136
+ if isinstance(tok, (int, float, str)) and tok not in ("+", "-", "*", "/", ")", None):
137
+ idx += 1
138
+ return tok
139
+ raise validation_failed([field_error("sumFormula", "INVALID_VALUE", "unexpected token in formula")])
140
+
141
+ if not tokens:
142
+ raise validation_failed([field_error("sumFormula", "INVALID_VALUE", "empty formula")])
143
+ tree = parse_expr()
144
+ if idx != len(tokens):
145
+ raise validation_failed([field_error("sumFormula", "INVALID_VALUE", "trailing tokens in formula")])
146
+ return tree
147
+
148
+
149
+ # ---------------------------------------------------------------- joins
150
+ _JOIN_ON_RE = re.compile(
151
+ r"^\s*([A-Za-z_][\w]*)\.([A-Za-z_][\w]*)\s*=\s*([A-Za-z_][\w]*)\.([A-Za-z_][\w]*)\s*$"
152
+ )
153
+
154
+
155
+ def convert_v1_join_on(on: str) -> dict[str, str]:
156
+ """Parse a v1 string join condition into structured on; failure -> VALIDATION_FAILED (§12)."""
157
+ m = _JOIN_ON_RE.match(on)
158
+ if not m:
159
+ raise validation_failed([
160
+ field_error("join.on", "INVALID_VALUE", "v1 string join conditions must be \"a.col = b.col\""),
161
+ ])
162
+ lt, lc, rt, rc = m.groups()
163
+ return {"left": f"{lt}.{lc}", "right": f"{rt}.{rc}", "op": "eq"}
164
+
165
+
166
+ # ---------------------------------------------------------------- requests
167
+ def convert_v1_request(request: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any] | None]:
168
+ """Up-convert a request lacking the "korm": 2 marker. Returns (v2_request, downconvert_info)."""
169
+ req = dict(request)
170
+ downconvert: dict[str, Any] | None = None
171
+ action = req.get("action")
172
+
173
+ if req.get("where") is not None:
174
+ req["where"] = convert_v1_where(req["where"])
175
+
176
+ if action == "count":
177
+ req["action"] = "aggregate"
178
+ req["aggregates"] = [{"fn": "count", "as": "count"}]
179
+ downconvert = {"bare_number": "count"}
180
+ elif action == "sum":
181
+ formula = req.pop("sumFormula", None) or req.pop("column", None)
182
+ if formula is None:
183
+ raise validation_failed([field_error("sumFormula", "REQUIRED", "v1 sum requires sumFormula")])
184
+ req["action"] = "aggregate"
185
+ req["aggregates"] = [{"fn": "sum", "expr": parse_formula(str(formula)), "as": "sum"}]
186
+ downconvert = {"bare_number": "sum"}
187
+ elif action == "show":
188
+ downconvert = {"bare_object": True}
189
+
190
+ joins = req.get("join")
191
+ if isinstance(joins, list):
192
+ req["join"] = [
193
+ {**j, "on": convert_v1_join_on(j["on"])} if isinstance(j.get("on"), str) else j
194
+ for j in joins if isinstance(j, dict)
195
+ ]
196
+
197
+ req["korm"] = 2
198
+ return req, downconvert
199
+
200
+
201
+ def downconvert_response(response: dict[str, Any], info: dict[str, Any]) -> dict[str, Any]:
202
+ """Down-convert a v2 response for a v1-marked request (§12, §14)."""
203
+ if not response.get("ok"):
204
+ return response
205
+ data = response.get("data")
206
+ if info.get("bare_number") and isinstance(data, list):
207
+ alias = info["bare_number"]
208
+ response = dict(response)
209
+ response["data"] = data[0].get(alias, 0) if data else 0
210
+ elif info.get("bare_object") and isinstance(data, list):
211
+ response = dict(response)
212
+ response["data"] = data[0] if data else None
213
+ return response
214
+
215
+
216
+ # ---------------------------------------------------------------- batch $ref (§8)
217
+ def resolve_refs(node: Any, by_id: dict[str, dict[str, Any]], path: str) -> Any:
218
+ """Replace {"$ref": "subId.json.path"} pointers with values from earlier sub-responses."""
219
+ if isinstance(node, dict):
220
+ if set(node) == {"$ref"} and isinstance(node["$ref"], str):
221
+ return _lookup_ref(node["$ref"], by_id, path)
222
+ return {k: resolve_refs(v, by_id, f"{path}.{k}") for k, v in node.items()}
223
+ if isinstance(node, list):
224
+ return [resolve_refs(v, by_id, f"{path}[{i}]") for i, v in enumerate(node)]
225
+ return node
226
+
227
+
228
+ def _lookup_ref(ref: str, by_id: dict[str, dict[str, Any]], path: str) -> Any:
229
+ parts = ref.split(".")
230
+ sub_id = parts[0]
231
+ if sub_id not in by_id:
232
+ raise validation_failed([
233
+ field_error(path, "UNRESOLVED_REF", f"$ref {ref!r}: no earlier sub-request with id {sub_id!r}"),
234
+ ])
235
+ value: Any = by_id[sub_id]
236
+ for part in parts[1:]:
237
+ if isinstance(value, list):
238
+ try:
239
+ value = value[int(part)]
240
+ continue
241
+ except (ValueError, IndexError):
242
+ raise validation_failed([field_error(path, "UNRESOLVED_REF", f"$ref {ref!r}: bad index {part!r}")])
243
+ if isinstance(value, dict) and part in value:
244
+ value = value[part]
245
+ else:
246
+ raise validation_failed([
247
+ field_error(path, "UNRESOLVED_REF", f"$ref {ref!r}: path segment {part!r} not found"),
248
+ ])
249
+ return value
korm/exec/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """Execution layer: cursor codec (and per-engine strategies as they grow)."""
2
+ from .cursor import decode_cursor, encode_cursor
3
+
4
+ __all__ = ["encode_cursor", "decode_cursor"]
korm/exec/cursor.py ADDED
@@ -0,0 +1,64 @@
1
+ """Opaque cursor tokens for keyset pagination (spec §5.3, §14).
2
+
3
+ Cursors are base64url(JSON) with strict validation on decode: unknown keys,
4
+ type mismatches, or columns not present in the request's orderBy return
5
+ VALIDATION_FAILED — never a 500. Optional HMAC-SHA256 signing via cursor_secret.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+ import hashlib
11
+ import hmac
12
+ import json
13
+ from typing import Any
14
+
15
+ from ..protocol.errors import field_error, validation_failed
16
+
17
+ _SCALARS = (str, int, float, bool, type(None))
18
+
19
+
20
+ def _b64encode(data: bytes) -> str:
21
+ return base64.urlsafe_b64encode(data).decode().rstrip("=")
22
+
23
+
24
+ def _b64decode(token: str) -> bytes:
25
+ pad = "=" * (-len(token) % 4)
26
+ return base64.urlsafe_b64decode(token + pad)
27
+
28
+
29
+ def _sign(payload: str, secret: str) -> str:
30
+ return _b64encode(hmac.new(secret.encode(), payload.encode(), hashlib.sha256).digest())[:32]
31
+
32
+
33
+ def encode_cursor(keys: dict[str, Any], secret: str | None = None) -> str:
34
+ payload = json.dumps({"k": keys}, separators=(",", ":"), default=str)
35
+ token = _b64encode(payload.encode())
36
+ if secret:
37
+ token = f"{token}.{_sign(payload, secret)}"
38
+ return token
39
+
40
+
41
+ def decode_cursor(token: str, order_columns: list[str], secret: str | None = None) -> dict[str, Any]:
42
+ """Decode and strictly validate a cursor against the request's orderBy columns."""
43
+ err = lambda msg: validation_failed([field_error("cursor", "INVALID_CURSOR", msg)]) # noqa: E731
44
+ body, sig = (token.split(".", 1) + [None])[:2] if "." in token else (token, None)
45
+ try:
46
+ payload = _b64decode(body).decode()
47
+ except Exception:
48
+ raise err("cursor is not valid base64")
49
+ if secret:
50
+ if sig is None or not hmac.compare_digest(sig, _sign(payload, secret)):
51
+ raise err("cursor signature mismatch")
52
+ try:
53
+ obj = json.loads(payload)
54
+ except ValueError:
55
+ raise err("cursor payload is not valid JSON")
56
+ if not isinstance(obj, dict) or set(obj) != {"k"} or not isinstance(obj["k"], dict):
57
+ raise err("cursor payload has unexpected structure")
58
+ keys = obj["k"]
59
+ if set(keys) != set(order_columns):
60
+ raise err("cursor keys do not match the request's orderBy columns")
61
+ for k, v in keys.items():
62
+ if not isinstance(v, _SCALARS):
63
+ raise err(f"cursor value for {k!r} has unsupported type")
64
+ return keys