zero-server 0.0.1__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 (50) hide show
  1. zero_server/__init__.py +36 -0
  2. zero_server/codegen.py +133 -0
  3. zero_server/integrations/__init__.py +1 -0
  4. zero_server/integrations/starlette.py +115 -0
  5. zero_server/mutators.py +87 -0
  6. zero_server/protocol/__init__.py +1 -0
  7. zero_server/protocol/application_error.py +36 -0
  8. zero_server/protocol/ast.py +342 -0
  9. zero_server/protocol/custom_queries.py +51 -0
  10. zero_server/protocol/error.py +119 -0
  11. zero_server/protocol/mutate_server.py +33 -0
  12. zero_server/protocol/mutation.py +127 -0
  13. zero_server/protocol/mutation_id.py +12 -0
  14. zero_server/protocol/push.py +25 -0
  15. zero_server/protocol/query_server.py +29 -0
  16. zero_server/protocol/wire.py +88 -0
  17. zero_server/py.typed +0 -0
  18. zero_server/query/__init__.py +1 -0
  19. zero_server/query/builder.py +338 -0
  20. zero_server/query/expression.py +167 -0
  21. zero_server/query/registry.py +143 -0
  22. zero_server/schema.py +142 -0
  23. zero_server/server/__init__.py +1 -0
  24. zero_server/server/adapters/__init__.py +1 -0
  25. zero_server/server/adapters/asyncpg.py +43 -0
  26. zero_server/server/adapters/psycopg.py +55 -0
  27. zero_server/server/adapters/sqlalchemy.py +49 -0
  28. zero_server/server/crud.py +134 -0
  29. zero_server/server/pg_query_executor.py +70 -0
  30. zero_server/server/pg_types.py +69 -0
  31. zero_server/server/process_mutations.py +412 -0
  32. zero_server/server/process_queries.py +109 -0
  33. zero_server/server/push_processor.py +49 -0
  34. zero_server/server/server_schema.py +165 -0
  35. zero_server/server/transaction.py +70 -0
  36. zero_server/server/zql_database.py +129 -0
  37. zero_server/shared/__init__.py +1 -0
  38. zero_server/shared/asserts.py +19 -0
  39. zero_server/shared/error.py +97 -0
  40. zero_server/shared/js.py +121 -0
  41. zero_server/shared/json.py +27 -0
  42. zero_server/shared/registry.py +64 -0
  43. zero_server/z2s/__init__.py +1 -0
  44. zero_server/z2s/compiler.py +375 -0
  45. zero_server/z2s/sql.py +130 -0
  46. zero_server-0.0.1.dist-info/METADATA +134 -0
  47. zero_server-0.0.1.dist-info/RECORD +50 -0
  48. zero_server-0.0.1.dist-info/WHEEL +4 -0
  49. zero_server-0.0.1.dist-info/entry_points.txt +3 -0
  50. zero_server-0.0.1.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,36 @@
1
+ """Community Python server SDK for Rocicorp Zero. Not affiliated with Rocicorp.
2
+
3
+ The SQLAlchemy transaction provider lives in `zero_server.server.adapters.sqlalchemy` and needs the
4
+ `zero-server[sqlalchemy]` extra.
5
+ """
6
+
7
+ from zero_server.mutators import define_mutator, define_mutators, get_mutator, must_get_mutator
8
+ from zero_server.protocol.application_error import ApplicationError
9
+ from zero_server.query.builder import Query, create_builder
10
+ from zero_server.query.registry import QueryParseError, define_queries, define_query, get_query, must_get_query
11
+ from zero_server.schema import Schema, load_schema
12
+ from zero_server.server.process_mutations import handle_mutate_request
13
+ from zero_server.server.process_queries import handle_query_request
14
+ from zero_server.server.push_processor import PushProcessor
15
+
16
+ __version__ = "0.0.1"
17
+
18
+ __all__ = [
19
+ "ApplicationError",
20
+ "PushProcessor",
21
+ "Query",
22
+ "QueryParseError",
23
+ "Schema",
24
+ "create_builder",
25
+ "define_mutator",
26
+ "define_mutators",
27
+ "define_queries",
28
+ "define_query",
29
+ "get_mutator",
30
+ "get_query",
31
+ "handle_mutate_request",
32
+ "handle_query_request",
33
+ "load_schema",
34
+ "must_get_mutator",
35
+ "must_get_query",
36
+ ]
zero_server/codegen.py ADDED
@@ -0,0 +1,133 @@
1
+ """Typed Python for a Zero schema: row types per table, and a builder and `tx.mutate` that use them.
2
+
3
+ zero-server codegen schema.json -o zero_schema.py
4
+
5
+ `schema.json` is `JSON.stringify(schema)` of the TypeScript `schema.ts`. The generated module has, per
6
+ table, a `TypedDict` for a row (`Issue`), for an insert (`IssueInsert`, optional columns may be left
7
+ out), for an update (`IssueUpdate`, the key plus any columns) and for a delete (`IssueKey`), and a
8
+ `Literal` of its column names (`IssueColumn`). Its `create_builder` returns queries typed by those rows
9
+ and columns, and `mutate(tx)` is `tx.mutate` typed the same way. Rows of a `related()` relationship
10
+ come back nested under each row but are not part of the row type.
11
+ """
12
+
13
+ import argparse
14
+ import json
15
+ import keyword
16
+ import re
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ from zero_server.schema import ColumnSchema, Schema, TableSchema, load_schema
21
+
22
+ _TYPES = {"string": "str", "number": "float", "boolean": "bool", "null": "None", "json": "Any"}
23
+
24
+
25
+ def _class_name(table: str) -> str:
26
+ parts = re.split(r"[^0-9A-Za-z]+", table)
27
+ name = "".join(part[:1].upper() + part[1:] for part in parts if part)
28
+ if not name or name[0].isdigit():
29
+ name = f"Table{name}"
30
+ return name
31
+
32
+
33
+ def _is_identifier(name: str) -> bool:
34
+ return name.isidentifier() and not keyword.iskeyword(name)
35
+
36
+
37
+ def _value_type(column: ColumnSchema) -> str:
38
+ base = _TYPES[column.type]
39
+ return f"{base} | None" if column.optional and base not in ("Any", "None") else base
40
+
41
+
42
+ def _typed_dict(name: str, fields: list[tuple[str, str, bool]]) -> str:
43
+ """A TypedDict, in class syntax when every key is an identifier, else in functional syntax.
44
+
45
+ Never empty: every table has a primary key, which all four row types include.
46
+ """
47
+ if all(_is_identifier(key) for key, _, _ in fields):
48
+ lines = [f" {key}: {f'NotRequired[{t}]' if not required else t}" for key, t, required in fields]
49
+ return f"class {name}(TypedDict):\n" + "\n".join(lines) + "\n"
50
+ items = ", ".join(f"{json.dumps(key)}: {f'NotRequired[{t}]' if not required else t}" for key, t, required in fields)
51
+ return f"{name} = TypedDict({json.dumps(name)}, {{{items}}})\n"
52
+
53
+
54
+ def _table(name: str, table: TableSchema, used: set[str]) -> tuple[str, str]:
55
+ """The table's type definitions, and the class name they were given."""
56
+ cls = _class_name(name)
57
+ while cls in used:
58
+ cls += "_"
59
+ used.add(cls)
60
+ pk = set(table.primary_key)
61
+ columns = list(table.columns.items())
62
+ row = [(c, _value_type(s), True) for c, s in columns]
63
+ insert = [(c, _value_type(s), c in pk or not s.optional) for c, s in columns]
64
+ update = [(c, _value_type(s), c in pk) for c, s in columns]
65
+ key = [(c, _value_type(table.columns[c]), True) for c in table.primary_key]
66
+ column_names = ", ".join(json.dumps(c) for c, _ in columns)
67
+ out = [
68
+ f"type {cls}Column = Literal[{column_names}]\n",
69
+ _typed_dict(cls, row),
70
+ _typed_dict(f"{cls}Insert", insert),
71
+ _typed_dict(f"{cls}Update", update),
72
+ _typed_dict(f"{cls}Key", key),
73
+ ]
74
+ return "\n\n".join(out), cls
75
+
76
+
77
+ def generate(schema: Schema, source: str = "schema.json") -> str:
78
+ used: set[str] = set()
79
+ definitions: list[str] = []
80
+ builder: list[str] = []
81
+ mutate: list[str] = []
82
+ for name, table in schema.tables.items():
83
+ types, cls = _table(name, table, used)
84
+ definitions.append(types)
85
+ if _is_identifier(name):
86
+ builder.append(
87
+ f" @property\n def {name}(self) -> Query[{cls}, {cls}Column]:\n"
88
+ f" return self.table({json.dumps(name)})\n"
89
+ )
90
+ crud = f"TableCRUD[{cls}Insert, {cls}Update, {cls}Key]"
91
+ mutate.append(f" @property\n def {name}(self) -> {crud}: ...\n")
92
+ header = f'''"""Types for the Zero schema in {source}. Generated by `zero-server codegen`; do not edit."""
93
+
94
+ from typing import Any, Literal, NotRequired, Protocol, TypedDict, cast
95
+
96
+ from zero_server.query.builder import Builder as _Builder
97
+ from zero_server.query.builder import Query
98
+ from zero_server.schema import Schema
99
+ from zero_server.server.crud import TableCRUD
100
+ from zero_server.server.transaction import ServerTransaction
101
+ '''
102
+ body = "\n\n".join(definitions)
103
+ builder_class = "class Builder(_Builder):\n" + ("\n".join(builder) if builder else " pass\n")
104
+ mutate_class = "class Mutate(Protocol):\n" + ("\n".join(mutate) if mutate else " pass\n")
105
+ footer = '''def create_builder(schema: Schema, *, strict: bool = False) -> Builder:
106
+ return Builder(schema, strict=strict)
107
+
108
+
109
+ def mutate(tx: ServerTransaction[Any]) -> Mutate:
110
+ """`tx.mutate`, typed by this schema."""
111
+ return cast(Mutate, tx.mutate)
112
+ '''
113
+ # One blank line after the imports, two between definitions: what ruff's formatter and isort keep.
114
+ return header + "\n" + "\n\n".join([body, builder_class, mutate_class, footer]).rstrip() + "\n"
115
+
116
+
117
+ def main(argv: list[str] | None = None) -> int:
118
+ parser = argparse.ArgumentParser(prog="zero-server")
119
+ commands = parser.add_subparsers(dest="command", required=True)
120
+ codegen = commands.add_parser("codegen", help="generate Python types for a schema")
121
+ codegen.add_argument("schema", type=Path, help="the JSON of the TypeScript schema")
122
+ codegen.add_argument("-o", "--output", type=Path, help="write here instead of to stdout")
123
+ args = parser.parse_args(argv)
124
+ code = generate(load_schema(args.schema), args.schema.name)
125
+ if args.output is None:
126
+ sys.stdout.write(code)
127
+ else:
128
+ args.output.write_text(code)
129
+ return 0
130
+
131
+
132
+ if __name__ == "__main__": # pragma: no cover - the `zero-server` script calls `main` directly
133
+ sys.exit(main())
@@ -0,0 +1 @@
1
+ """Ready-made endpoints for web frameworks."""
@@ -0,0 +1,115 @@
1
+ """`/query` and `/mutate` as Starlette routes. Needs the `zero-server[starlette]` extra.
2
+
3
+ app = Starlette(routes=zero_routes(
4
+ schema=schema, database=db, queries=queries, mutators=mutators, authenticate=user_id_from_jwt,
5
+ ))
6
+
7
+ FastAPI is Starlette underneath: `app.router.routes.extend(zero_routes(...))`.
8
+ """
9
+
10
+ import inspect
11
+ from collections.abc import Awaitable, Callable
12
+ from typing import Any
13
+
14
+ from starlette.requests import Request
15
+ from starlette.responses import JSONResponse, Response
16
+ from starlette.routing import Route
17
+
18
+ from zero_server.mutators import must_get_mutator
19
+ from zero_server.protocol.mutation import CustomMutation, MutationResponse
20
+ from zero_server.query.registry import must_get_query
21
+ from zero_server.schema import Schema
22
+ from zero_server.server.process_mutations import Database, TransactFn, handle_mutate_request
23
+ from zero_server.server.process_queries import handle_query_request
24
+ from zero_server.shared.json import JSONValue
25
+ from zero_server.shared.registry import Registry
26
+
27
+ type Authenticate = Callable[[Request], str | Awaitable[str | None] | None]
28
+ """The request's user ID, or `None` when logged out. Raise `Unauthorized` to answer 401."""
29
+ type MakeContext = Callable[[Request, str | None], Any]
30
+ """The `ctx` queries and mutators receive. May be async."""
31
+
32
+
33
+ class Unauthorized(Exception):
34
+ """Raised by `authenticate` for a token it rejects. The endpoint answers 401 instead of running."""
35
+
36
+
37
+ async def _maybe_await(value: Any) -> Any:
38
+ return await value if inspect.isawaitable(value) else value
39
+
40
+
41
+ def _default_context(_request: Request, user_id: str | None) -> dict[str, Any]:
42
+ return {"userID": user_id}
43
+
44
+
45
+ def zero_routes(
46
+ *,
47
+ schema: Schema,
48
+ database: Database[Any] | None = None,
49
+ queries: Registry | None = None,
50
+ mutators: Registry | None = None,
51
+ authenticate: Authenticate,
52
+ context: MakeContext = _default_context,
53
+ query_path: str = "/query",
54
+ mutate_path: str = "/mutate",
55
+ ) -> list[Route]:
56
+ """Routes for whichever of the query and mutate endpoints you pass a registry for.
57
+
58
+ Queries resolve by name through `queries` and mutators through `mutators`, each called with the
59
+ `ctx` that `context` builds (by default `{"userID": user_id}`). Mutations run on `database`.
60
+ """
61
+ routes: list[Route] = []
62
+
63
+ async def caller(request: Request) -> tuple[str | None, Any] | Response:
64
+ try:
65
+ user_id = await _maybe_await(authenticate(request))
66
+ except Unauthorized as error:
67
+ return JSONResponse({"error": str(error) or "Unauthorized"}, status_code=401)
68
+ return user_id, await _maybe_await(context(request, user_id))
69
+
70
+ if queries is not None:
71
+
72
+ async def query(request: Request) -> Response:
73
+ who = await caller(request)
74
+ if isinstance(who, Response):
75
+ return who
76
+ user_id, ctx = who
77
+ response = await handle_query_request(
78
+ handler=lambda name, args: must_get_query(queries, name).fn(args=args, ctx=ctx),
79
+ schema=schema,
80
+ body=await request.body(),
81
+ user_id=user_id,
82
+ )
83
+ return JSONResponse(response.to_json())
84
+
85
+ routes.append(Route(query_path, query, methods=["POST"]))
86
+
87
+ if mutators is not None:
88
+ if database is None:
89
+ raise ValueError("the mutate endpoint needs a database")
90
+ db = database
91
+
92
+ async def mutate(request: Request) -> Response:
93
+ who = await caller(request)
94
+ if isinstance(who, Response):
95
+ return who
96
+ user_id, ctx = who
97
+
98
+ async def handler(transact: TransactFn[Any], _mutation: CustomMutation) -> MutationResponse:
99
+ async def run(tx: Any, name: str, args: JSONValue | None) -> None:
100
+ await must_get_mutator(mutators, name).fn(tx=tx, ctx=ctx, args=args)
101
+
102
+ return await transact(run)
103
+
104
+ response = await handle_mutate_request(
105
+ db_provider=db,
106
+ handler=handler,
107
+ query=dict(request.query_params),
108
+ body=await request.body(),
109
+ user_id=user_id,
110
+ )
111
+ return JSONResponse(response.to_json())
112
+
113
+ routes.append(Route(mutate_path, mutate, methods=["POST"]))
114
+
115
+ return routes
@@ -0,0 +1,87 @@
1
+ """`define_mutator` and `define_mutators`: named server mutators the mutate endpoint dispatches to.
2
+
3
+ Ported from rocicorp/mono `packages/zql/src/mutate/mutator.ts` and `mutator-registry.ts` at zero/v1.10.0
4
+ (905d08516ba0caaf314cd28e93a5a47e8acdd482). Apache-2.0, Copyright Rocicorp.
5
+
6
+ A mutator here is the server half of the TypeScript mutator of the same name: the client runs its own
7
+ optimistically, and this one runs authoritatively inside the mutation's transaction.
8
+ """
9
+
10
+ from collections.abc import Awaitable, Callable, Mapping
11
+ from dataclasses import dataclass
12
+ from typing import Any, overload
13
+
14
+ from zero_server.query.registry import is_schema_validator, validate_input
15
+ from zero_server.shared.json import JSONValue
16
+ from zero_server.shared.registry import Registry, build, merge, value_at_path
17
+
18
+ type MutatorFn = Callable[..., Awaitable[None]]
19
+ """Called as `await fn(args=..., ctx=..., tx=...)`."""
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class MutatorDefinition:
24
+ fn: MutatorFn
25
+ validator: Any = None
26
+
27
+
28
+ @overload
29
+ def define_mutator(fn_or_validator: MutatorFn, fn: None = None) -> MutatorDefinition: ...
30
+ @overload
31
+ def define_mutator(fn_or_validator: Any, fn: MutatorFn) -> MutatorDefinition: ...
32
+ @overload
33
+ def define_mutator(fn_or_validator: Any, fn: None = None) -> Callable[[MutatorFn], MutatorDefinition]: ...
34
+ def define_mutator(fn_or_validator: Any, fn: MutatorFn | None = None) -> Any:
35
+ """Define a mutator, called as `await fn(args=..., ctx=..., tx=...)`, in the three forms of
36
+ `define_query`: `define_mutator(fn)` or `@define_mutator`, `define_mutator(validator, fn)`, and
37
+ `@define_mutator(ArgsModel)`."""
38
+ if fn is not None:
39
+ return MutatorDefinition(fn=fn, validator=fn_or_validator)
40
+ if is_schema_validator(fn_or_validator):
41
+ return lambda decorated: MutatorDefinition(fn=decorated, validator=fn_or_validator)
42
+ return MutatorDefinition(fn=fn_or_validator)
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class MutateRequest:
47
+ mutator: "Mutator"
48
+ args: JSONValue | None
49
+
50
+
51
+ class Mutator:
52
+ def __init__(self, name: str, definition: MutatorDefinition) -> None:
53
+ self.mutator_name = name
54
+ self._definition = definition
55
+
56
+ async def fn(self, *, args: JSONValue | None = None, ctx: Any = None, tx: Any) -> None:
57
+ validated = validate_input(self.mutator_name, args, self._definition.validator, "mutator")
58
+ await self._definition.fn(args=validated, ctx=ctx, tx=tx)
59
+
60
+ def __call__(self, args: JSONValue | None = None) -> MutateRequest:
61
+ return MutateRequest(mutator=self, args=args)
62
+
63
+
64
+ type MutatorDefinitions = Mapping[str, "MutatorDefinition | MutatorDefinitions"]
65
+
66
+
67
+ def define_mutators(
68
+ definitions: "MutatorDefinitions | Registry", overrides: "MutatorDefinitions | None" = None
69
+ ) -> Registry:
70
+ """Name each mutator by its dot-separated path through `definitions`, as `define_queries` does."""
71
+ is_definition = lambda value: isinstance(value, MutatorDefinition) # noqa: E731
72
+ base = definitions if isinstance(definitions, Registry) else build(definitions, is_definition, Mutator)
73
+ if overrides is None:
74
+ return base
75
+ return merge(base, build(overrides, is_definition, Mutator))
76
+
77
+
78
+ def get_mutator(mutators: Registry, name: str) -> Mutator | None:
79
+ found = value_at_path(mutators, name, ".")
80
+ return found if isinstance(found, Mutator) else None
81
+
82
+
83
+ def must_get_mutator(mutators: Registry, name: str) -> Mutator:
84
+ mutator = get_mutator(mutators, name)
85
+ if mutator is None:
86
+ raise Exception(f"Mutator not found: {name}")
87
+ return mutator
@@ -0,0 +1 @@
1
+ """Zero's wire types, from rocicorp/mono `packages/zero-protocol`."""
@@ -0,0 +1,36 @@
1
+ """The error application code raises to send a message and JSON details back to the client.
2
+
3
+ Ported from rocicorp/mono `packages/zero-protocol/src/application-error.ts` at zero/v1.10.0
4
+ (905d08516ba0caaf314cd28e93a5a47e8acdd482). Apache-2.0, Copyright Rocicorp.
5
+ """
6
+
7
+ from typing import Literal
8
+
9
+ from zero_server.shared.error import get_error_details, get_error_message
10
+ from zero_server.shared.json import JSONValue
11
+
12
+
13
+ class ApplicationError(Exception):
14
+ def __init__(self, message: str, *, details: JSONValue | None = None, cause: BaseException | None = None) -> None:
15
+ super().__init__(message)
16
+ self.message = message
17
+ self.details = details
18
+ self.__cause__ = cause
19
+
20
+ @property
21
+ def kind(self) -> Literal["Application"]:
22
+ return "Application"
23
+
24
+
25
+ def is_application_error(error: object) -> bool:
26
+ return isinstance(error, ApplicationError)
27
+
28
+
29
+ def wrap_with_application_error(error: object) -> ApplicationError:
30
+ if isinstance(error, ApplicationError):
31
+ return error
32
+ return ApplicationError(
33
+ get_error_message(error),
34
+ cause=error if isinstance(error, BaseException) else None,
35
+ details=get_error_details(error),
36
+ )