editbuffer 0.2.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.
editbuffer/__init__.py ADDED
@@ -0,0 +1,30 @@
1
+ from .buffer import EditBuffer
2
+ from .errors import (
3
+ AmbiguousTargetError,
4
+ EditBufferError,
5
+ FuzzyMatchError,
6
+ InvalidOperationError,
7
+ StaleVersionError,
8
+ TargetNotFoundError,
9
+ ValidationError,
10
+ )
11
+ from .history import EditHistory, EditRecord
12
+ from .operations import EditOperation
13
+ from .resolver import SelectionResolver
14
+ from .selection import Selection
15
+
16
+ __all__ = [
17
+ "AmbiguousTargetError",
18
+ "EditBuffer",
19
+ "EditBufferError",
20
+ "EditHistory",
21
+ "EditOperation",
22
+ "EditRecord",
23
+ "FuzzyMatchError",
24
+ "InvalidOperationError",
25
+ "Selection",
26
+ "SelectionResolver",
27
+ "StaleVersionError",
28
+ "TargetNotFoundError",
29
+ "ValidationError",
30
+ ]
editbuffer/blocks.py ADDED
@@ -0,0 +1,41 @@
1
+ import re
2
+
3
+ from .resolver import ResolvedSelection
4
+
5
+ _FENCE = re.compile(
6
+ r"^(?P<fence>`{3,}|~{3,})[^\n]*\beditbuffer:id=(?P<id>[A-Za-z0-9_.-]+)[^\n]*\n",
7
+ re.MULTILINE,
8
+ )
9
+ _REGION = re.compile(
10
+ r"^<!--\s*editbuffer:block\s+(?P<id>[A-Za-z0-9_.-]+)\s*-->\s*\n",
11
+ re.MULTILINE,
12
+ )
13
+ _REGION_END = re.compile(r"^<!--\s*/editbuffer:block\s*-->", re.MULTILINE)
14
+
15
+
16
+ def find_blocks(content: str, block_id: str) -> tuple[ResolvedSelection, ...]:
17
+ matches = list(_fenced_blocks(content, block_id))
18
+ matches.extend(_regions(content, block_id))
19
+ return tuple(sorted(matches, key=lambda match: match.start))
20
+
21
+
22
+ def _fenced_blocks(content: str, block_id: str):
23
+ for opening in _FENCE.finditer(content):
24
+ if opening.group("id") != block_id:
25
+ continue
26
+ fence = re.escape(opening.group("fence"))
27
+ closing = re.search(rf"^{fence}[^\n]*(?:\n|$)", content[opening.end() :], re.MULTILINE)
28
+ if closing:
29
+ yield ResolvedSelection(
30
+ opening.end(),
31
+ opening.end() + closing.start(),
32
+ )
33
+
34
+
35
+ def _regions(content: str, block_id: str):
36
+ for opening in _REGION.finditer(content):
37
+ if opening.group("id") != block_id:
38
+ continue
39
+ closing = _REGION_END.search(content, opening.end())
40
+ if closing:
41
+ yield ResolvedSelection(opening.end(), closing.start())
editbuffer/buffer.py ADDED
@@ -0,0 +1,178 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable, Mapping
4
+ from typing import Any
5
+
6
+ from .errors import InvalidOperationError
7
+ from .history import EditHistory, EditRecord
8
+ from .operations import EditOperation
9
+ from .resolver import ResolvedSelection, SelectionResolver
10
+ from .selection import Selection
11
+
12
+ Validator = Callable[[str], None]
13
+
14
+
15
+ class EditBuffer:
16
+ def __init__(
17
+ self,
18
+ content: str = "",
19
+ *,
20
+ validators: tuple[Validator, ...] = (),
21
+ ) -> None:
22
+ self._content = content
23
+ self._version = 0
24
+ self._committed = False
25
+ self._validators = validators
26
+ self._resolver = SelectionResolver()
27
+ self._snapshots = {0: content}
28
+ self.history = EditHistory()
29
+
30
+ @property
31
+ def version(self) -> int:
32
+ return self._version
33
+
34
+ @property
35
+ def committed(self) -> bool:
36
+ return self._committed
37
+
38
+ @property
39
+ def versions(self) -> tuple[int, ...]:
40
+ return tuple(self._snapshots)
41
+
42
+ def view(self) -> str:
43
+ return self._content
44
+
45
+ def append(self, text: str) -> EditRecord:
46
+ return self._apply(EditOperation("append", text=text))
47
+
48
+ def replace(
49
+ self,
50
+ target: Selection | Mapping[str, Any],
51
+ text: str,
52
+ ) -> EditRecord:
53
+ return self._apply(EditOperation("replace", self._selection(target), text))
54
+
55
+ def insert_before(
56
+ self,
57
+ target: Selection | Mapping[str, Any],
58
+ text: str,
59
+ ) -> EditRecord:
60
+ return self._apply(
61
+ EditOperation("insert_before", self._selection(target), text)
62
+ )
63
+
64
+ def insert_after(
65
+ self,
66
+ target: Selection | Mapping[str, Any],
67
+ text: str,
68
+ ) -> EditRecord:
69
+ return self._apply(
70
+ EditOperation("insert_after", self._selection(target), text)
71
+ )
72
+
73
+ def delete(self, target: Selection | Mapping[str, Any]) -> EditRecord:
74
+ return self._apply(EditOperation("delete", self._selection(target)))
75
+
76
+ def apply(
77
+ self,
78
+ operation: Mapping[str, Any] | EditOperation,
79
+ ) -> EditRecord:
80
+ return self._apply(
81
+ operation
82
+ if isinstance(operation, EditOperation)
83
+ else EditOperation.from_dict(operation)
84
+ )
85
+
86
+ def commit(self) -> str:
87
+ self._ensure_open()
88
+ self._committed = True
89
+ return self._content
90
+
91
+ def rollback(self, version: int) -> EditRecord:
92
+ return self._apply(EditOperation("rollback", version=version))
93
+
94
+ def _apply(self, operation: EditOperation) -> EditRecord:
95
+ self._ensure_open()
96
+ if operation.kind == "rollback":
97
+ return self._rollback(operation)
98
+ resolved = self._resolve(operation)
99
+ before = self._content[resolved.start : resolved.end]
100
+ after = _replacement(operation, before)
101
+ candidate = (
102
+ self._content[: resolved.start]
103
+ + after
104
+ + self._content[resolved.end :]
105
+ )
106
+ for validator in self._validators:
107
+ validator(candidate)
108
+
109
+ version_before = self._version
110
+ self._content = candidate
111
+ self._version += 1
112
+ self._snapshots[self._version] = candidate
113
+ record = EditRecord(
114
+ operation=operation,
115
+ start=resolved.start,
116
+ end=resolved.end,
117
+ before=before,
118
+ after=after,
119
+ version_before=version_before,
120
+ version_after=self._version,
121
+ confidence=resolved.confidence,
122
+ )
123
+ self.history.append(record)
124
+ return record
125
+
126
+ def _rollback(self, operation: EditOperation) -> EditRecord:
127
+ if operation.version not in self._snapshots:
128
+ raise InvalidOperationError(f"unknown version: {operation.version}")
129
+ candidate = self._snapshots[operation.version]
130
+ for validator in self._validators:
131
+ validator(candidate)
132
+ before = self._content
133
+ version_before = self._version
134
+ self._content = candidate
135
+ self._version += 1
136
+ self._snapshots[self._version] = candidate
137
+ record = EditRecord(
138
+ operation=operation,
139
+ start=0,
140
+ end=len(before),
141
+ before=before,
142
+ after=candidate,
143
+ version_before=version_before,
144
+ version_after=self._version,
145
+ )
146
+ self.history.append(record)
147
+ return record
148
+
149
+ def _resolve(self, operation: EditOperation) -> ResolvedSelection:
150
+ if operation.kind == "append":
151
+ end = len(self._content)
152
+ return ResolvedSelection(end, end)
153
+ if operation.target is None:
154
+ raise InvalidOperationError(f"{operation.kind} requires a target")
155
+ return self._resolver.resolve(
156
+ self._content,
157
+ operation.target,
158
+ version=self._version,
159
+ )
160
+
161
+ def _selection(self, value: Selection | Mapping[str, Any]) -> Selection:
162
+ return value if isinstance(value, Selection) else Selection.from_dict(value)
163
+
164
+ def _ensure_open(self) -> None:
165
+ if self._committed:
166
+ raise InvalidOperationError("buffer is already committed")
167
+
168
+
169
+ def _replacement(operation: EditOperation, before: str) -> str:
170
+ if operation.kind == "append" or operation.kind == "replace":
171
+ return operation.text
172
+ if operation.kind == "insert_before":
173
+ return operation.text + before
174
+ if operation.kind == "insert_after":
175
+ return before + operation.text
176
+ if operation.kind == "delete":
177
+ return ""
178
+ raise InvalidOperationError(f"unknown operation: {operation.kind!r}")
editbuffer/cli.py ADDED
@@ -0,0 +1,138 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import tempfile
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from .buffer import EditBuffer
11
+ from .errors import EditBufferError, InvalidOperationError
12
+
13
+
14
+ def main(argv: list[str] | None = None) -> int:
15
+ parser = _parser()
16
+ args = parser.parse_args(argv)
17
+ state_path = Path(args.state)
18
+
19
+ try:
20
+ if args.command == "new":
21
+ if state_path.exists():
22
+ raise InvalidOperationError(f"state already exists: {state_path}")
23
+ _save(state_path, {"initial": args.text, "operations": [], "committed": False})
24
+ return 0
25
+
26
+ state = _load(state_path)
27
+ buffer = _restore(state)
28
+ if args.command == "view":
29
+ print(buffer.view())
30
+ elif args.command == "history":
31
+ print(json.dumps(state["operations"], indent=2, ensure_ascii=False))
32
+ elif args.command == "commit":
33
+ result = buffer.commit()
34
+ state["committed"] = True
35
+ _save(state_path, state)
36
+ print(result)
37
+ elif args.command == "rollback":
38
+ operation = {"op": "rollback", "version": args.version}
39
+ buffer.apply(operation)
40
+ state["operations"].append(operation)
41
+ _save(state_path, state)
42
+ else:
43
+ operation = _operation(args)
44
+ buffer.apply(operation)
45
+ state["operations"].append(operation)
46
+ _save(state_path, state)
47
+ return 0
48
+ except (EditBufferError, OSError, ValueError, json.JSONDecodeError) as error:
49
+ parser.exit(2, f"editbuffer: {error}\n")
50
+
51
+
52
+ def _parser() -> argparse.ArgumentParser:
53
+ parser = argparse.ArgumentParser(prog="editbuffer")
54
+ subparsers = parser.add_subparsers(dest="command", required=True)
55
+
56
+ new = subparsers.add_parser("new")
57
+ new.add_argument("state")
58
+ new.add_argument("--text", default="")
59
+
60
+ append = subparsers.add_parser("append")
61
+ append.add_argument("state")
62
+ append.add_argument("text")
63
+
64
+ replace = subparsers.add_parser("replace")
65
+ replace.add_argument("state")
66
+ replace.add_argument("target", help="selection as JSON")
67
+ replace.add_argument("text")
68
+
69
+ apply = subparsers.add_parser("apply")
70
+ apply.add_argument("state")
71
+ apply.add_argument("operation", help="operation as JSON")
72
+
73
+ rollback = subparsers.add_parser("rollback")
74
+ rollback.add_argument("state")
75
+ rollback.add_argument("version", type=int)
76
+
77
+ for name in ("view", "history", "commit"):
78
+ command = subparsers.add_parser(name)
79
+ command.add_argument("state")
80
+ return parser
81
+
82
+
83
+ def _operation(args: argparse.Namespace) -> dict[str, Any]:
84
+ if args.command == "append":
85
+ return {"op": "append", "text": args.text}
86
+ if args.command == "replace":
87
+ target = json.loads(args.target)
88
+ if not isinstance(target, dict):
89
+ raise InvalidOperationError("target must be a JSON object")
90
+ return {"op": "replace", "target": target, "text": args.text}
91
+ operation = json.loads(args.operation)
92
+ if not isinstance(operation, dict):
93
+ raise InvalidOperationError("operation must be a JSON object")
94
+ return operation
95
+
96
+
97
+ def _load(path: Path) -> dict[str, Any]:
98
+ state = json.loads(path.read_text(encoding="utf-8"))
99
+ if not isinstance(state, dict):
100
+ raise InvalidOperationError("state must be a JSON object")
101
+ return state
102
+
103
+
104
+ def _restore(state: dict[str, Any]) -> EditBuffer:
105
+ initial = state.get("initial")
106
+ operations = state.get("operations")
107
+ if not isinstance(initial, str) or not isinstance(operations, list):
108
+ raise InvalidOperationError("invalid state file")
109
+ buffer = EditBuffer(initial)
110
+ for operation in operations:
111
+ if not isinstance(operation, dict):
112
+ raise InvalidOperationError("invalid operation in state file")
113
+ buffer.apply(operation)
114
+ if state.get("committed"):
115
+ buffer.commit()
116
+ return buffer
117
+
118
+
119
+ def _save(path: Path, state: dict[str, Any]) -> None:
120
+ path.parent.mkdir(parents=True, exist_ok=True)
121
+ handle = tempfile.NamedTemporaryFile(
122
+ "w",
123
+ encoding="utf-8",
124
+ dir=path.parent,
125
+ delete=False,
126
+ )
127
+ try:
128
+ with handle:
129
+ json.dump(state, handle, indent=2, ensure_ascii=False)
130
+ handle.write("\n")
131
+ os.replace(handle.name, path)
132
+ except BaseException:
133
+ Path(handle.name).unlink(missing_ok=True)
134
+ raise
135
+
136
+
137
+ if __name__ == "__main__":
138
+ raise SystemExit(main())
editbuffer/errors.py ADDED
@@ -0,0 +1,35 @@
1
+ class EditBufferError(Exception):
2
+ """Base error for editbuffer."""
3
+
4
+
5
+ class TargetNotFoundError(EditBufferError):
6
+ pass
7
+
8
+
9
+ class AmbiguousTargetError(EditBufferError):
10
+ def __init__(self, candidates: tuple[tuple[int, int], ...]) -> None:
11
+ self.candidates = candidates
12
+ super().__init__(f"selection matched {len(candidates)} targets: {candidates}")
13
+
14
+
15
+ class FuzzyMatchError(EditBufferError):
16
+ def __init__(
17
+ self,
18
+ reason: str,
19
+ candidates: tuple[tuple[int, int, float], ...],
20
+ ) -> None:
21
+ self.reason = reason
22
+ self.candidates = candidates
23
+ super().__init__(f"fuzzy selection {reason}: {candidates}")
24
+
25
+
26
+ class InvalidOperationError(EditBufferError):
27
+ pass
28
+
29
+
30
+ class StaleVersionError(EditBufferError):
31
+ pass
32
+
33
+
34
+ class ValidationError(EditBufferError):
35
+ pass
editbuffer/history.py ADDED
@@ -0,0 +1,33 @@
1
+ from dataclasses import dataclass
2
+ from collections.abc import Iterator
3
+
4
+ from .operations import EditOperation
5
+
6
+
7
+ @dataclass(frozen=True, slots=True)
8
+ class EditRecord:
9
+ operation: EditOperation
10
+ start: int
11
+ end: int
12
+ before: str
13
+ after: str
14
+ version_before: int
15
+ version_after: int
16
+ confidence: float = 1.0
17
+
18
+
19
+ class EditHistory:
20
+ def __init__(self) -> None:
21
+ self._records: list[EditRecord] = []
22
+
23
+ def append(self, record: EditRecord) -> None:
24
+ self._records.append(record)
25
+
26
+ def __len__(self) -> int:
27
+ return len(self._records)
28
+
29
+ def __getitem__(self, index: int) -> EditRecord:
30
+ return self._records[index]
31
+
32
+ def __iter__(self) -> Iterator[EditRecord]:
33
+ return iter(self._records)
@@ -0,0 +1,205 @@
1
+ from __future__ import annotations
2
+
3
+ from importlib import import_module
4
+ from typing import Any
5
+ from uuid import uuid4
6
+
7
+ from .buffer import EditBuffer
8
+ from .history import EditRecord
9
+
10
+
11
+ class BufferRegistry:
12
+ def __init__(self) -> None:
13
+ self._buffers: dict[str, EditBuffer] = {}
14
+ self._commands: list[dict[str, Any]] = []
15
+ self._next_command_number = 1
16
+
17
+ def create(
18
+ self,
19
+ content: str = "",
20
+ *,
21
+ buffer_id: str | None = None,
22
+ ) -> dict[str, Any]:
23
+ identifier = buffer_id or uuid4().hex
24
+ if identifier in self._buffers:
25
+ raise ValueError(f"buffer already exists: {identifier}")
26
+ self._buffers[identifier] = EditBuffer(content)
27
+ return self.view(identifier)
28
+
29
+ def list_buffers(self) -> list[dict[str, Any]]:
30
+ return [
31
+ self._state(identifier, buffer)
32
+ for identifier, buffer in self._buffers.items()
33
+ ]
34
+
35
+ def view(self, buffer_id: str) -> dict[str, Any]:
36
+ return self._state(buffer_id, self._get(buffer_id))
37
+
38
+ def edit(self, buffer_id: str, operation: dict[str, Any]) -> dict[str, Any]:
39
+ buffer = self._get(buffer_id)
40
+ record = buffer.apply(operation)
41
+ result = self._state(buffer_id, buffer)
42
+ result["applied"] = _record(record)
43
+ return result
44
+
45
+ def history(self, buffer_id: str) -> list[dict[str, Any]]:
46
+ return [_record(record) for record in self._get(buffer_id).history]
47
+
48
+ def rollback(self, buffer_id: str, version: int) -> dict[str, Any]:
49
+ buffer = self._get(buffer_id)
50
+ record = buffer.rollback(version)
51
+ result = self._state(buffer_id, buffer)
52
+ result["applied"] = _record(record)
53
+ return result
54
+
55
+ def commit(self, buffer_id: str) -> dict[str, Any]:
56
+ buffer = self._get(buffer_id)
57
+ buffer.commit()
58
+ self._remember_command(buffer.view())
59
+ return self._state(buffer_id, buffer)
60
+
61
+ def command_history(self) -> list[dict[str, Any]]:
62
+ return list(self._commands)
63
+
64
+ def select_command(
65
+ self,
66
+ command_id: str,
67
+ *,
68
+ buffer_id: str | None = None,
69
+ ) -> dict[str, Any]:
70
+ for item in self._commands:
71
+ if item["command_id"] == command_id:
72
+ return self.create(item["command"], buffer_id=buffer_id)
73
+ raise KeyError(f"unknown command: {command_id}")
74
+
75
+ def _get(self, buffer_id: str) -> EditBuffer:
76
+ try:
77
+ return self._buffers[buffer_id]
78
+ except KeyError as error:
79
+ raise KeyError(f"unknown buffer: {buffer_id}") from error
80
+
81
+ def _state(self, buffer_id: str, buffer: EditBuffer) -> dict[str, Any]:
82
+ return {
83
+ "buffer_id": buffer_id,
84
+ "content": buffer.view(),
85
+ "version": buffer.version,
86
+ "versions": list(buffer.versions),
87
+ "committed": buffer.committed,
88
+ }
89
+
90
+ def _remember_command(self, command: str) -> None:
91
+ if not command.strip():
92
+ return
93
+ self._commands.insert(
94
+ 0,
95
+ {
96
+ "command_id": f"cmd-{self._next_command_number}",
97
+ "command": command,
98
+ },
99
+ )
100
+ self._next_command_number += 1
101
+ del self._commands[10:]
102
+
103
+
104
+ def _record(record: EditRecord) -> dict[str, Any]:
105
+ return {
106
+ "operation": record.operation.as_dict(),
107
+ "start": record.start,
108
+ "end": record.end,
109
+ "before": record.before,
110
+ "after": record.after,
111
+ "version_before": record.version_before,
112
+ "version_after": record.version_after,
113
+ "confidence": record.confidence,
114
+ }
115
+
116
+
117
+ def create_server() -> Any:
118
+ try:
119
+ FastMCP = import_module("mcp.server.fastmcp").FastMCP
120
+ except ImportError as error:
121
+ raise RuntimeError(
122
+ "MCP support is not installed; run: pip install 'editbuffer[mcp]'"
123
+ ) from error
124
+
125
+ registry = BufferRegistry()
126
+ server = FastMCP(
127
+ "editbuffer",
128
+ instructions=(
129
+ "Use editbuffer for pending output that may need local corrections before "
130
+ "commit. Create one buffer, apply small selection-based edits, view when "
131
+ "needed, and commit only when final. Never retry ambiguous fuzzy edits "
132
+ "without narrowing the selection."
133
+ ),
134
+ json_response=True,
135
+ )
136
+
137
+ @server.tool()
138
+ def buffer_create(
139
+ content: str = "",
140
+ buffer_id: str | None = None,
141
+ ) -> dict[str, Any]:
142
+ """Create an in-memory pending output buffer."""
143
+ return registry.create(content, buffer_id=buffer_id)
144
+
145
+ @server.tool()
146
+ def buffer_list() -> list[dict[str, Any]]:
147
+ """List active pending output buffers."""
148
+ return registry.list_buffers()
149
+
150
+ @server.tool()
151
+ def buffer_view(buffer_id: str) -> dict[str, Any]:
152
+ """View current content, version, snapshots, and commit state."""
153
+ return registry.view(buffer_id)
154
+
155
+ @server.tool()
156
+ def buffer_edit(
157
+ buffer_id: str,
158
+ operation: dict[str, Any],
159
+ ) -> dict[str, Any]:
160
+ """Apply one JSON edit operation.
161
+
162
+ Example replace operation:
163
+ {"op": "replace", "target": {"type": "exact", "text": "old"}, "text": "new"}
164
+ Operations: append, replace, insert_before, insert_after, delete, rollback.
165
+ Targets: exact/context/range/fuzzy/block. Ambiguous edits fail without mutation.
166
+ """
167
+ return registry.edit(buffer_id, operation)
168
+
169
+ @server.tool()
170
+ def buffer_history(buffer_id: str) -> list[dict[str, Any]]:
171
+ """Return the audit trail for successful edits."""
172
+ return registry.history(buffer_id)
173
+
174
+ @server.tool()
175
+ def buffer_rollback(buffer_id: str, version: int) -> dict[str, Any]:
176
+ """Restore a prior snapshot as a new audited version."""
177
+ return registry.rollback(buffer_id, version)
178
+
179
+ @server.tool()
180
+ def buffer_commit(buffer_id: str) -> dict[str, Any]:
181
+ """Commit final output, close the buffer, and remember it as a reusable command."""
182
+ return registry.commit(buffer_id)
183
+
184
+ @server.tool()
185
+ def command_history() -> list[dict[str, Any]]:
186
+ """Return up to 10 most recently committed commands, newest first."""
187
+ return registry.command_history()
188
+
189
+ @server.tool()
190
+ def command_select(
191
+ command_id: str,
192
+ buffer_id: str | None = None,
193
+ ) -> dict[str, Any]:
194
+ """Create a new pending buffer from a previous command instead of regenerating it."""
195
+ return registry.select_command(command_id, buffer_id=buffer_id)
196
+
197
+ return server
198
+
199
+
200
+ def main() -> None:
201
+ create_server().run(transport="stdio")
202
+
203
+
204
+ if __name__ == "__main__":
205
+ main()