quillwork-server 0.0.3__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,9 @@
1
+ """Quillwork's reference service: a document service library, an MCP server and an HTTP API.
2
+
3
+ The library is :mod:`quillwork_server.core` (embed it in your application) and
4
+ :mod:`quillwork_server.tools` (the agent tool layer). The front ends are
5
+ :mod:`quillwork_server.mcp_server` (``quillwork-mcp``) and :mod:`quillwork_server.http_app`
6
+ (``quillwork-server``).
7
+ """
8
+
9
+ __version__ = "0.0.3"
@@ -0,0 +1,86 @@
1
+ """Settings shared by the two front ends (``quillwork-mcp``, ``quillwork-server``).
2
+
3
+ Every setting has an environment variable (``QUILLWORK_*``) and a command-line flag; the flag
4
+ wins. Library users skip this module and build a :class:`~.core.DocumentService` themselves.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import os
11
+ from collections.abc import Mapping
12
+ from dataclasses import dataclass, replace
13
+
14
+ from .core import (
15
+ DocumentService,
16
+ Engine,
17
+ FakeEngine,
18
+ FileStore,
19
+ MemoryStore,
20
+ QuillworkEngine,
21
+ ServiceConfig,
22
+ Store,
23
+ )
24
+
25
+ _DEFAULTS = ServiceConfig()
26
+
27
+
28
+ @dataclass(frozen=True, slots=True)
29
+ class Settings:
30
+ #: Directory for :class:`~.core.FileStore`; empty keeps documents in memory.
31
+ store: str = ""
32
+ #: ``quillwork`` (the real binding) or ``fake`` (plain text; for demos and tests).
33
+ engine: str = "quillwork"
34
+ #: Author on every transaction an agent makes through these front ends.
35
+ author: str = "AI"
36
+ #: HTTP: required ``Authorization: Bearer <key>`` (or ``X-API-Key``); empty disables auth.
37
+ api_key: str = ""
38
+ max_upload_bytes: int = _DEFAULTS.max_upload_bytes
39
+ max_ops_per_transaction: int = _DEFAULTS.max_ops_per_transaction
40
+ host: str = "127.0.0.1"
41
+ port: int = 8765
42
+
43
+ @classmethod
44
+ def from_env(cls, env: Mapping[str, str] | None = None) -> Settings:
45
+ env = os.environ if env is None else env
46
+ base = cls()
47
+ return cls(
48
+ store=env.get("QUILLWORK_STORE", base.store),
49
+ engine=env.get("QUILLWORK_ENGINE", base.engine),
50
+ author=env.get("QUILLWORK_AUTHOR", base.author),
51
+ api_key=env.get("QUILLWORK_API_KEY", base.api_key),
52
+ max_upload_bytes=int(env.get("QUILLWORK_MAX_UPLOAD_BYTES", base.max_upload_bytes)),
53
+ max_ops_per_transaction=int(env.get("QUILLWORK_MAX_OPS", base.max_ops_per_transaction)),
54
+ host=env.get("QUILLWORK_HOST", base.host),
55
+ port=int(env.get("QUILLWORK_PORT", base.port)),
56
+ )
57
+
58
+ def service(self) -> DocumentService:
59
+ engine: Engine = (
60
+ FakeEngine(author=self.author) if self.engine == "fake" else QuillworkEngine()
61
+ )
62
+ store: Store = FileStore(self.store) if self.store else MemoryStore()
63
+ config = ServiceConfig(
64
+ max_upload_bytes=self.max_upload_bytes,
65
+ max_ops_per_transaction=self.max_ops_per_transaction,
66
+ default_author=self.author,
67
+ )
68
+ return DocumentService(engine, store, config=config)
69
+
70
+
71
+ def add_arguments(parser: argparse.ArgumentParser) -> None:
72
+ parser.add_argument("--store", help="directory to keep documents in (default: memory)")
73
+ parser.add_argument("--engine", choices=("quillwork", "fake"))
74
+ parser.add_argument("--author", help="author name on agent edits (default: AI)")
75
+ parser.add_argument("--host")
76
+ parser.add_argument("--port", type=int)
77
+
78
+
79
+ def from_args(args: argparse.Namespace, base: Settings | None = None) -> Settings:
80
+ settings = base or Settings.from_env()
81
+ changes = {
82
+ key: getattr(args, key)
83
+ for key in ("store", "engine", "author", "host", "port")
84
+ if getattr(args, key, None) is not None
85
+ }
86
+ return replace(settings, **changes)
@@ -0,0 +1,61 @@
1
+ """The library core: store, locks, engine seam and the document service.
2
+
3
+ Import from here; the submodules are an implementation detail.
4
+ """
5
+
6
+ from .engine import Engine, EngineDocument, QuillworkEngine, asset_id, sniff_image
7
+ from .errors import SPEC_VERSION, document_not_found, envelope, error_object, make_error
8
+ from .fake import FakeDocument, FakeEngine, fake_bytes
9
+ from .locks import LocalLocks, LocalNotifier, LockProvider, LockTimeout, Notifier
10
+ from .service import DocumentService, ExportVariant, ServiceConfig, Session
11
+ from .store import (
12
+ DocumentExists,
13
+ DocumentNotFound,
14
+ FileStore,
15
+ HeadMoved,
16
+ MemoryStore,
17
+ Store,
18
+ StoreError,
19
+ VersionNotFound,
20
+ )
21
+ from .types import JSON, Asset, DocumentInfo, PatchFeed, StoredVersion, VersionKind, VersionRecord
22
+
23
+ __all__ = [
24
+ "JSON",
25
+ "SPEC_VERSION",
26
+ "Asset",
27
+ "DocumentExists",
28
+ "DocumentInfo",
29
+ "DocumentNotFound",
30
+ "DocumentService",
31
+ "Engine",
32
+ "EngineDocument",
33
+ "ExportVariant",
34
+ "FakeDocument",
35
+ "FakeEngine",
36
+ "FileStore",
37
+ "HeadMoved",
38
+ "LocalLocks",
39
+ "LocalNotifier",
40
+ "LockProvider",
41
+ "LockTimeout",
42
+ "MemoryStore",
43
+ "Notifier",
44
+ "PatchFeed",
45
+ "QuillworkEngine",
46
+ "ServiceConfig",
47
+ "Session",
48
+ "Store",
49
+ "StoreError",
50
+ "StoredVersion",
51
+ "VersionKind",
52
+ "VersionNotFound",
53
+ "VersionRecord",
54
+ "asset_id",
55
+ "document_not_found",
56
+ "envelope",
57
+ "error_object",
58
+ "fake_bytes",
59
+ "make_error",
60
+ "sniff_image",
61
+ ]
@@ -0,0 +1,149 @@
1
+ """The engine seam: what the service needs from a document engine.
2
+
3
+ :class:`QuillworkEngine` adapts the real binding (``quillwork.Document``); ``fake.FakeEngine``
4
+ implements the same protocol on plain text for tests. Both speak the spec's JSON shapes: a
5
+ Transaction in, an OpResult out; a ReadOp in, a ReadResult out; errors as
6
+ :class:`quillwork.errors.QuillworkError`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ from typing import Any, Protocol, runtime_checkable
14
+
15
+ from .types import JSON, Asset
16
+
17
+
18
+ @runtime_checkable
19
+ class EngineDocument(Protocol):
20
+ """One open document. Not thread-safe: the service serialises calls on it."""
21
+
22
+ @property
23
+ def version(self) -> int: ...
24
+
25
+ def apply(self, transaction: JSON) -> JSON:
26
+ """Apply a Transaction atomically; return the OpResult. Raises QuillworkError."""
27
+ ...
28
+
29
+ def read_op(self, request: JSON) -> JSON:
30
+ """Run a ReadOp; return the ReadResult. Raises QuillworkError."""
31
+ ...
32
+
33
+ def view_model(self) -> JSON: ...
34
+
35
+ def save(self) -> bytes: ...
36
+
37
+ def sidecar(self) -> JSON | None:
38
+ """Engine state that is not in the .docx (anchor map, change sets), versioned with it."""
39
+ ...
40
+
41
+
42
+ @runtime_checkable
43
+ class Engine(Protocol):
44
+ name: str
45
+
46
+ def open(self, data: bytes, *, version: int = 1, sidecar: JSON | None = None) -> EngineDocument:
47
+ """Open ``data`` as ``version``, restoring ``sidecar`` when given.
48
+
49
+ Raises QuillworkError (BAD_PACKAGE, PROTECTED, LIMIT) for unusable input.
50
+ """
51
+ ...
52
+
53
+ def asset(self, data: bytes, asset_id: str) -> Asset | None:
54
+ """The bytes of an image the document at ``data`` holds, by asset id (``a_<16 hex>``)."""
55
+ ...
56
+
57
+
58
+ def asset_id(data: bytes) -> str:
59
+ """Asset ids are the first 16 hex digits of sha256(bytes) (spec/anchors.md)."""
60
+ return "a_" + hashlib.sha256(data).hexdigest()[:16]
61
+
62
+
63
+ _MAGIC: tuple[tuple[bytes, str], ...] = (
64
+ (b"\x89PNG\r\n\x1a\n", "image/png"),
65
+ (b"\xff\xd8\xff", "image/jpeg"),
66
+ (b"GIF87a", "image/gif"),
67
+ (b"GIF89a", "image/gif"),
68
+ )
69
+
70
+
71
+ def sniff_image(data: bytes) -> str | None:
72
+ """The image MIME type from magic bytes, or ``None``. Never trusts a declared type."""
73
+ for magic, mime in _MAGIC:
74
+ if data.startswith(magic):
75
+ return mime
76
+ if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
77
+ return "image/webp"
78
+ head = data[:512].lstrip().lower()
79
+ if head.startswith(b"<svg") or (head.startswith(b"<?xml") and b"<svg" in head):
80
+ return "image/svg+xml"
81
+ return None
82
+
83
+
84
+ # -- the real binding ------------------------------------------------------------------------
85
+
86
+
87
+ class QuillworkEngine:
88
+ """:class:`Engine` over :class:`quillwork.Document`.
89
+
90
+ Versions and sidecars pass straight through: ``Document.open(data, sidecar=..., version=...)``
91
+ opens at ``max(version, the sidecar's)``, which is how the service reopens an import or a
92
+ restore at ``head + 1`` with an older sidecar.
93
+ """
94
+
95
+ name = "quillwork"
96
+
97
+ def __init__(self, limits: Any | None = None) -> None:
98
+ import quillwork
99
+
100
+ self._quillwork = quillwork
101
+ self._limits = limits
102
+
103
+ def open(self, data: bytes, *, version: int = 1, sidecar: JSON | None = None) -> EngineDocument:
104
+ doc = self._quillwork.Document.open(data, self._limits, sidecar=sidecar, version=version)
105
+ return _QuillworkDocument(doc)
106
+
107
+ def asset(self, data: bytes, asset_id_: str) -> Asset | None:
108
+ package = self._quillwork.Package.open(data)
109
+ for name in package.names():
110
+ if not name.startswith("word/media/"):
111
+ continue
112
+ part = bytes(package.part(name))
113
+ if asset_id(part) == asset_id_:
114
+ mime = (
115
+ sniff_image(part) or package.content_type_of(name) or "application/octet-stream"
116
+ )
117
+ return Asset(part, mime)
118
+ return None
119
+
120
+
121
+ class _QuillworkDocument:
122
+ """:class:`EngineDocument` over an open :class:`quillwork.Document`."""
123
+
124
+ def __init__(self, doc: Any) -> None:
125
+ self._doc = doc
126
+
127
+ @property
128
+ def version(self) -> int:
129
+ return int(self._doc.version)
130
+
131
+ def apply(self, transaction: JSON) -> JSON:
132
+ result: JSON = self._doc.apply(transaction)
133
+ return result
134
+
135
+ def read_op(self, request: JSON) -> JSON:
136
+ result: JSON = self._doc.read_op(request)
137
+ return result
138
+
139
+ def view_model(self) -> JSON:
140
+ result: JSON = self._doc.view_model()
141
+ return result
142
+
143
+ def save(self) -> bytes:
144
+ return bytes(self._doc.save())
145
+
146
+ def sidecar(self) -> JSON | None:
147
+ """The sidecar as a JSON object (the binding caches it from the last save)."""
148
+ result: JSON = json.loads(self._doc.sidecar())
149
+ return result
@@ -0,0 +1,110 @@
1
+ """Spec error objects (``spec/schema/error.schema.json``) built and rendered on the service side.
2
+
3
+ The service raises the binding's own exception classes (:mod:`quillwork.errors`), so a host sees
4
+ one error type whether the engine or the service refused a call. :func:`make_error` builds one
5
+ with a complete spec ``error`` object; :func:`envelope` turns any exception into the
6
+ ``{spec_version, ok: false, error}`` response the front ends return.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, Final
12
+
13
+ from quillwork.errors import QuillworkError, error_from_dict
14
+
15
+ from .types import JSON
16
+
17
+ SPEC_VERSION: Final = "0.1.0"
18
+
19
+ #: retryable per code (spec/errors.md). UNSUPPORTED depends on the reason; see make_error.
20
+ RETRYABLE: Final[dict[str, bool]] = {
21
+ "STALE_ANCHOR": True,
22
+ "AMBIGUOUS": True,
23
+ "NOT_FOUND": True,
24
+ "IN_FIELD": True,
25
+ "INVALID_OP": True,
26
+ "UNSUPPORTED": False,
27
+ "CONFIRM_REQUIRED": True,
28
+ "PROTECTED": False,
29
+ "LIMIT": True,
30
+ "BAD_VERSION": True,
31
+ "CONFLICT": True,
32
+ "BAD_PACKAGE": False,
33
+ "INVARIANT": False,
34
+ "INTERNAL": True,
35
+ }
36
+
37
+ #: UNSUPPORTED reasons a retry with other arguments can get past.
38
+ _RETRYABLE_UNSUPPORTED: Final = frozenset({"direct_only", "track_only"})
39
+
40
+
41
+ def make_error(
42
+ code: str,
43
+ message: str,
44
+ *,
45
+ version: int,
46
+ op_index: int | None = None,
47
+ retryable: bool | None = None,
48
+ **fields: Any,
49
+ ) -> QuillworkError:
50
+ """The binding exception for ``code`` carrying a full spec error object.
51
+
52
+ ``fields`` are the per-code members (``anchor``, ``current``, ``what``, ``reason``, ``limit``,
53
+ ``path``, ``usage``, ``candidates``...); ``None`` values are left out.
54
+ """
55
+ if retryable is None:
56
+ retryable = RETRYABLE.get(code, False)
57
+ if code == "UNSUPPORTED":
58
+ retryable = fields.get("reason") in _RETRYABLE_UNSUPPORTED
59
+ error: JSON = {
60
+ "code": code,
61
+ "message": message,
62
+ "retryable": retryable,
63
+ "version": max(1, int(version)),
64
+ "op_index": op_index,
65
+ }
66
+ for key, value in fields.items():
67
+ if value is not None:
68
+ error[key] = value
69
+ return error_from_dict(error)
70
+
71
+
72
+ def error_object(exc: BaseException, *, version: int | None = None) -> JSON:
73
+ """The spec ``error`` object for any exception.
74
+
75
+ ``version`` fills in a missing current version (the binding leaves it ``None`` for errors raised
76
+ before a document exists, e.g. BAD_PACKAGE on upload).
77
+ """
78
+ if isinstance(exc, QuillworkError):
79
+ error = dict(exc.error)
80
+ error.setdefault("code", exc.code)
81
+ error.setdefault("message", exc.message or str(exc))
82
+ error.setdefault("retryable", exc.retryable)
83
+ error.setdefault("op_index", exc.op_index)
84
+ if not isinstance(error.get("version"), int) or error["version"] < 1:
85
+ error["version"] = max(1, version or exc.version or 1)
86
+ if error["code"] in ("CONFLICT", "INTERNAL"):
87
+ error["retryable"] = True
88
+ if error["code"] in ("INVARIANT", "BAD_PACKAGE", "PROTECTED"):
89
+ error["retryable"] = False
90
+ return error
91
+ return {
92
+ "code": "INTERNAL",
93
+ "message": "The engine failed unexpectedly; retry once, then report it.",
94
+ "retryable": True,
95
+ "version": max(1, version or 1),
96
+ "op_index": None,
97
+ }
98
+
99
+
100
+ def envelope(exc: BaseException, *, version: int | None = None) -> JSON:
101
+ """``{spec_version, ok: false, error}`` for ``exc``."""
102
+ return {"spec_version": SPEC_VERSION, "ok": False, "error": error_object(exc, version=version)}
103
+
104
+
105
+ def document_not_found(document_id: str) -> QuillworkError:
106
+ # `what: "document"` is not in the 0.1.0 enum yet (proposed in docs/SERVER.md). `version` is
107
+ # required (minimum 1) even though no document exists, so it is 1.
108
+ return make_error(
109
+ "NOT_FOUND", f"There is no document {document_id!r}.", version=1, what="document"
110
+ )