margin 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.
margin/outline.py ADDED
@@ -0,0 +1,282 @@
1
+ """A small client for Outline's RPC-style API (POST /api/<method>, JSON in and out).
2
+
3
+ Only what margin needs: collections, documents, comments and webhook subscriptions. Standard library
4
+ only. Errors raise OutlineError with the HTTP status and Outline's own error code, so a caller can
5
+ tell "the text to patch was not found" (404) from "not allowed" (403) from "the server is down".
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import socket
11
+ import time
12
+ import urllib.error
13
+ import urllib.parse
14
+ import urllib.request
15
+
16
+
17
+ class OutlineError(Exception):
18
+ def __init__(self, method: str, status: int, code: str, message: str):
19
+ super().__init__(f"{method}: {status} {code} {message}")
20
+ self.method, self.status, self.code, self.message = method, status, code, message
21
+
22
+
23
+ def pin_host(host: str, ip: str) -> None:
24
+ """Resolve `host` to `ip` for this process. TLS still checks the certificate against `host`.
25
+
26
+ Needed on a machine that serves Outline over Tailscale but cannot resolve its own MagicDNS name.
27
+ """
28
+ real = socket.getaddrinfo
29
+
30
+ def gai(h, *a, **k):
31
+ return real(ip if h == host else h, *a, **k)
32
+
33
+ socket.getaddrinfo = gai
34
+
35
+
36
+ #: what a proxy answers while Outline itself is down or restarting: worth waiting for, not a fault
37
+ GATEWAY = (502, 503, 504)
38
+
39
+
40
+ class Outline:
41
+ def __init__(self, url: str, api_key: str, timeout: float = 30.0):
42
+ self.url = url.rstrip("/")
43
+ self.api_key = api_key
44
+ self.timeout = timeout
45
+ self._me: dict | None = None
46
+
47
+ def call(self, method: str, body: dict | None = None, retries: int = 3,
48
+ headers: dict | None = None) -> dict:
49
+ data = json.dumps(body or {}).encode()
50
+ req = urllib.request.Request(
51
+ f"{self.url}/api/{method}", data,
52
+ {"Content-Type": "application/json", "Authorization": f"Bearer {self.api_key}",
53
+ **(headers or {})})
54
+ for attempt in range(retries + 1):
55
+ try:
56
+ with urllib.request.urlopen(req, timeout=self.timeout) as r:
57
+ return json.loads(r.read())
58
+ except urllib.error.HTTPError as e:
59
+ raw = e.read().decode(errors="replace")
60
+ try:
61
+ j = json.loads(raw)
62
+ except ValueError:
63
+ j = {}
64
+ if e.code == 429 and attempt < retries:
65
+ time.sleep(float(e.headers.get("Retry-After") or 2 ** attempt))
66
+ continue
67
+ if e.code in GATEWAY and attempt < retries:
68
+ time.sleep(2 ** attempt)
69
+ continue
70
+ raise OutlineError(method, e.code, j.get("error", ""), j.get("message", raw[:200]))
71
+ except urllib.error.URLError:
72
+ if attempt < retries:
73
+ time.sleep(2 ** attempt)
74
+ continue
75
+ raise
76
+ raise AssertionError("unreachable")
77
+
78
+ def data(self, method: str, body: dict | None = None):
79
+ return self.call(method, body).get("data")
80
+
81
+ def structure(self, doc_id: str) -> dict | None:
82
+ """A document as Outline's editor holds it (ProseMirror JSON), comment marks included.
83
+ API version 3 returns it in place of the markdown; the markdown has no comment marks."""
84
+ got = self.call("documents.info", {"id": doc_id}, headers={"x-api-version": "3"})
85
+ doc = (got.get("data") or {})
86
+ return (doc.get("document") or doc).get("data")
87
+
88
+ def paged(self, method: str, body: dict | None = None, limit: int = 100):
89
+ """Every row of a paginated list method."""
90
+ offset = 0
91
+ while True:
92
+ page = self.data(method, {**(body or {}), "limit": limit, "offset": offset}) or []
93
+ yield from page
94
+ if len(page) < limit:
95
+ return
96
+ offset += limit
97
+
98
+ def wait_ready(self, timeout: float = 600.0, every: float = 10.0, log=None) -> dict:
99
+ """Who the key belongs to, once Outline answers: a server that is down or restarting (a
100
+ gateway error, or no connection) is waited for until `timeout` seconds, then raised. Any
101
+ other error (a bad key, say) is raised at once."""
102
+ deadline = time.monotonic() + timeout
103
+ while True:
104
+ try:
105
+ return self.me()
106
+ except (OutlineError, urllib.error.URLError) as e:
107
+ if isinstance(e, OutlineError) and e.status not in GATEWAY:
108
+ raise
109
+ if time.monotonic() >= deadline:
110
+ raise
111
+ if log:
112
+ log(f"waiting for Outline: {e}")
113
+ time.sleep(every)
114
+
115
+ # ---- who am I
116
+ def me(self) -> dict:
117
+ if self._me is None:
118
+ self._me = self.data("auth.info")["user"]
119
+ return self._me
120
+
121
+ # ---- collections
122
+ def collection_by_name(self, name: str) -> dict | None:
123
+ for c in self.paged("collections.list"):
124
+ if c["name"] == name:
125
+ return c
126
+ return None
127
+
128
+ def ensure_collection(self, name: str, description: str = "") -> dict:
129
+ return self.collection_by_name(name) or self.data(
130
+ "collections.create", {"name": name, "description": description,
131
+ "permission": "read_write", "sharing": False})
132
+
133
+ # ---- documents
134
+ def create_document(self, collection_id: str, title: str, text: str,
135
+ parent_id: str | None = None) -> dict:
136
+ body = {"collectionId": collection_id, "title": title, "text": text, "publish": True}
137
+ if parent_id:
138
+ body["parentDocumentId"] = parent_id
139
+ return self.data("documents.create", body)
140
+
141
+ def document(self, doc_id: str) -> dict:
142
+ return self.data("documents.info", {"id": doc_id})
143
+
144
+ def patch(self, doc_id: str, find: str, text: str) -> dict:
145
+ """Replace the first occurrence of `find` (markdown) with `text`. Keeps comments elsewhere."""
146
+ return self.data("documents.update",
147
+ {"id": doc_id, "editMode": "patch", "findText": find, "text": text})
148
+
149
+ def append(self, doc_id: str, text: str) -> dict:
150
+ """Add `text` after the last block. Touches no existing block, so every comment stays."""
151
+ return self.data("documents.update", {"id": doc_id, "editMode": "append", "text": text})
152
+
153
+ def replace(self, doc_id: str, text: str) -> dict:
154
+ """Replace the whole body. Removes every comment anchor, so only for docs margin owns
155
+ outright (the scratch document), never for a document someone may have commented on."""
156
+ return self.data("documents.update", {"id": doc_id, "editMode": "replace", "text": text})
157
+
158
+ def revisions(self, doc_id: str) -> list[dict]:
159
+ """Every saved revision of a document, each with `createdBy`."""
160
+ return list(self.paged("revisions.list", {"documentId": doc_id}))
161
+
162
+ def set_title(self, doc_id: str, title: str) -> dict:
163
+ return self.data("documents.update", {"id": doc_id, "title": title})
164
+
165
+ # ---- attachments
166
+ def upload(self, name: str, content_type: str, data: bytes, document_id: str) -> str:
167
+ """Upload a file as an attachment of `document_id` and return the address a document uses
168
+ for it (`/api/attachments.redirect?id=...`).
169
+
170
+ Outline answers attachments.create with where to send the bytes: its own files.create when
171
+ it stores files itself, or a storage service's pre-signed address. The API key goes only to
172
+ Outline's own address, never to a storage service."""
173
+ import uuid
174
+ d = self.data("attachments.create", {"name": name, "contentType": content_type,
175
+ "size": len(data), "documentId": document_id,
176
+ "preset": "documentAttachment"})
177
+ up = d["uploadUrl"]
178
+ own = up.startswith("/") or up.startswith(self.url + "/")
179
+ if up.startswith("/"):
180
+ up = self.url + up
181
+ b = "----margin" + uuid.uuid4().hex
182
+ parts = [f'--{b}\r\nContent-Disposition: form-data; name="{k}"\r\n\r\n{v}\r\n'.encode()
183
+ for k, v in (d.get("form") or {}).items()]
184
+ parts.append(f'--{b}\r\nContent-Disposition: form-data; name="file"; filename="{name}"\r\n'
185
+ f"Content-Type: {content_type}\r\n\r\n".encode() + data + b"\r\n")
186
+ parts.append(f"--{b}--\r\n".encode())
187
+ headers = {"Content-Type": f"multipart/form-data; boundary={b}"}
188
+ if own:
189
+ headers["Authorization"] = f"Bearer {self.api_key}"
190
+ req = urllib.request.Request(up, b"".join(parts), headers)
191
+ try:
192
+ with urllib.request.urlopen(req, timeout=max(self.timeout, 120)) as r:
193
+ r.read()
194
+ except urllib.error.HTTPError as e:
195
+ raise OutlineError("upload", e.code, "", e.read().decode(errors="replace")[:200])
196
+ return d["attachment"]["url"]
197
+
198
+ # ---- comments
199
+ def comments(self, collection_id: str | None = None, document_id: str | None = None):
200
+ body: dict = {"includeAnchorText": True, "sort": "createdAt", "direction": "DESC"}
201
+ if collection_id:
202
+ body["collectionId"] = collection_id
203
+ if document_id:
204
+ body["documentId"] = document_id
205
+ return self.paged("comments.list", body)
206
+
207
+ def comment(self, comment_id: str) -> dict:
208
+ return self.data("comments.info", {"id": comment_id, "includeAnchorText": True})
209
+
210
+ def reply(self, document_id: str, parent_id: str | None, text: str,
211
+ anchor: str | None = None) -> dict:
212
+ body: dict = {"documentId": document_id, "text": text}
213
+ if parent_id:
214
+ body["parentCommentId"] = parent_id
215
+ if anchor:
216
+ body["anchorText"] = anchor
217
+ return self.data("comments.create", body)
218
+
219
+ def resolve(self, comment_id: str) -> dict:
220
+ return self.data("comments.resolve", {"id": comment_id})
221
+
222
+ # ---- webhooks
223
+ def webhooks(self) -> list[dict]:
224
+ return list(self.paged("webhookSubscriptions.list"))
225
+
226
+ def ensure_webhook(self, name: str, url: str, secret: str, events: list[str]) -> dict:
227
+ """Create the subscription, or repair it. Outline switches a subscription off after
228
+ repeated failed deliveries, and an update switches it back on."""
229
+ for w in self.webhooks():
230
+ if w.get("url") == url:
231
+ if w.get("enabled") is False or sorted(w.get("events") or []) != sorted(events):
232
+ w = self.data("webhookSubscriptions.update",
233
+ {"id": w["id"], "name": name, "url": url, "secret": secret,
234
+ "events": events})
235
+ return w
236
+ return self.data("webhookSubscriptions.create",
237
+ {"name": name, "url": url, "secret": secret, "events": events})
238
+
239
+
240
+ def doc_url(base: str, doc: dict) -> str:
241
+ return f"{base.rstrip('/')}{doc.get('url') or '/doc/' + doc['id']}"
242
+
243
+
244
+ def comment_text(data: dict | None) -> str:
245
+ """Plain text of a ProseMirror comment body. Mentions render as @Label."""
246
+ out: list[str] = []
247
+
248
+ def walk(node: dict) -> None:
249
+ t = node.get("type")
250
+ if t == "text":
251
+ out.append(node.get("text", ""))
252
+ elif t == "mention":
253
+ out.append("@" + (node.get("attrs", {}).get("label") or ""))
254
+ elif t == "hard_break":
255
+ out.append("\n")
256
+ for child in node.get("content", []) or []:
257
+ walk(child)
258
+ if t in ("paragraph", "heading", "list_item", "blockquote"):
259
+ out.append("\n")
260
+
261
+ walk(data or {})
262
+ return "".join(out).strip()
263
+
264
+
265
+ def mentions(data: dict | None) -> set[str]:
266
+ """User ids mentioned in a ProseMirror comment body."""
267
+ found: set[str] = set()
268
+
269
+ def walk(node: dict) -> None:
270
+ if node.get("type") == "mention":
271
+ a = node.get("attrs", {})
272
+ if a.get("type", "user") == "user" and a.get("modelId"):
273
+ found.add(a["modelId"])
274
+ for child in node.get("content", []) or []:
275
+ walk(child)
276
+
277
+ walk(data or {})
278
+ return found
279
+
280
+
281
+ def quote(s: str) -> str:
282
+ return urllib.parse.quote(s, safe="")
margin/patches.py ADDED
@@ -0,0 +1,243 @@
1
+ """Turn "the document is X, it should be Y" into the smallest patches Outline can apply.
2
+
3
+ Outline's patch edit replaces the FIRST occurrence of a piece of markdown and keeps every comment
4
+ anchored outside it. A comment anchored inside the replaced span loses its anchor (the thread stays,
5
+ pointing at nothing). So the patches must be as small as possible, and each one must be unique in
6
+ the document at the moment it is applied, or it lands on the wrong occurrence.
7
+
8
+ The planner works on lines. Hunks are applied bottom-up, so everything below a hunk already
9
+ matches the target and can always serve as context.
10
+
11
+ ⭐ A CHANGED LINE IS PATCHED WORD BY WORD (2026-10-13). A paragraph is one line, so a line-sized
12
+ patch replaced a whole paragraph, and a comment anchored on words the edit had kept lost its
13
+ highlight (the anchor "Between 23:00 and 08:00" went when only a bracket after it was removed).
14
+ Outline patches a match inside one paragraph in place and keeps every mark outside the match, so
15
+ a line that changed into one other line is patched as the runs of words that changed, each with
16
+ just enough words around it to be unique. Only when that cannot be done does the whole line go. Context above a hunk is taken only from the
17
+ unchanged run directly above it. `apply` replays a plan locally, and `plan` checks its own result
18
+ with it before returning, so a plan that would not produce the target is never returned.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import re
23
+ from difflib import SequenceMatcher
24
+
25
+ Patch = tuple[str, str] # (find, replace)
26
+
27
+
28
+ class CannotPatch(Exception):
29
+ """No sequence of unique patches reaches the target. The caller must not fall back to a full
30
+ replace on a document that may carry comments; it reports the document instead."""
31
+
32
+
33
+ #: a run of asterisks that starts with an escaped one: Outline writes two literal asterisks as `\**`
34
+ #: when it saves a whole body and as `\*\*` when it saves a patch
35
+ _ESCAPED_RUN = re.compile(r"\\\*(?:\\?\*)*")
36
+
37
+
38
+ #: a run of spaces after text and before an inline picture
39
+ _BEFORE_PICTURE = re.compile(r"(?<=\S) +(?=!\[)")
40
+
41
+
42
+ def spelled(line: str) -> str:
43
+ r"""A line with the spellings Outline itself varies made one: an escaped run of asterisks
44
+ (`\**` and `\*\*`), a hard line break (a backslash at the end of the line), which a patch
45
+ writes where a whole-body save wrote a plain newline, and the spaces before an inline picture. Nothing else: `\*a\*` and `*a*` stay apart,
46
+ because they mean different things."""
47
+ line = _ESCAPED_RUN.sub(lambda m: "\\" + "*" * m.group(0).count("*"), line)
48
+ # spaces between text and an inline picture: a patch writes one more (`[ ![`, three between two
49
+ # pictures), and markdown shows a run of spaces as one; indentation at a line's start is kept
50
+ line = _BEFORE_PICTURE.sub(" ", line)
51
+ return line[:-1] if line.endswith("\\") and not line.endswith("\\\\") else line
52
+
53
+
54
+ def spelled_alike(a: str, b: str) -> bool:
55
+ """Whether two texts differ only in the spellings `spelled` makes one."""
56
+ return a == b or [spelled(x) for x in a.split("\n")] == [spelled(x) for x in b.split("\n")]
57
+
58
+
59
+ def adopt(current: str, target: str) -> str:
60
+ """The target with every line that is spelled alike in `current` written as `current` writes
61
+ it, so a plan changes only what really differs and never chases Outline's own spelling."""
62
+ a, b = current.split("\n"), target.split("\n")
63
+ out = b[:]
64
+ ops = SequenceMatcher(None, [spelled(x) for x in a], [spelled(x) for x in b], autojunk=False).get_opcodes()
65
+ for tag, i1, i2, j1, j2 in ops:
66
+ if tag == "equal":
67
+ out[j1:j2] = a[i1:i2]
68
+ return "\n".join(out)
69
+
70
+
71
+ def apply(text: str, patches: list[Patch]) -> str:
72
+ for find, repl in patches:
73
+ if find not in text:
74
+ raise CannotPatch(f"find text not present: {find[:60]!r}")
75
+ text = text.replace(find, repl, 1)
76
+ return text
77
+
78
+
79
+ def _only_at(lines: list[str], lo: int, find: str) -> bool:
80
+ """`find` occurs exactly once in the joined lines, starting at line `lo`. Overlapping matches
81
+ count, because Outline replaces the first occurrence wherever it starts."""
82
+ joined = "\n".join(lines)
83
+ at = sum(len(x) + 1 for x in lines[:lo])
84
+ return joined.find(find) == at and joined.find(find, at + 1) == -1
85
+
86
+
87
+ _TOKEN = re.compile(r"\s+|\w+|[^\w\s]")
88
+
89
+
90
+ def _offsets(tokens: list[str]) -> list[int]:
91
+ out = [0]
92
+ for t in tokens:
93
+ out.append(out[-1] + len(t))
94
+ return out
95
+
96
+
97
+ #: the markers that come in pairs: bold, bold, strike-through and a code span
98
+ _PAIRED = re.compile(r"(?<!\\)(\*\*|__|~~|`)")
99
+
100
+
101
+ def _cuts_markup(piece: str) -> bool:
102
+ r"""Whether a piece of a line holds one half of a paired marker. ⛔ Outline parses a patch's text
103
+ on its own, so a lone `**` comes back escaped: a patch that changed the words after `**Sweep`
104
+ left `\**Sweep ...:\**` in the document, the bold gone (2026-10-16, fifteen documents at
105
+ cannot-patch). Such a change is patched as its whole line."""
106
+ counts: dict[str, int] = {}
107
+ for m in _PAIRED.finditer(piece):
108
+ counts[m.group(1)] = counts.get(m.group(1), 0) + 1
109
+ return any(n % 2 for n in counts.values())
110
+
111
+
112
+ def _is_row(line: str) -> bool:
113
+ """A line of a markdown table."""
114
+ return line.lstrip().startswith("|")
115
+
116
+
117
+ def _words(lines: list[str], i: int, new: str) -> list[Patch] | None:
118
+ """Patches that turn line `i` of `lines` into `new`, touching only the words that changed. Each
119
+ is unique in the whole document when it is applied, and they go right to left so the rest of
120
+ the line still reads as it did. None when a change cannot be made unique inside its line.
121
+ `lines` is updated as the patches are planned."""
122
+ if _is_row(lines[i]) or _is_row(new):
123
+ return None # a table is patched whole (plan)
124
+ old_t, new_t = _TOKEN.findall(lines[i]), _TOKEN.findall(new)
125
+ if not old_t or not new_t or "".join(old_t) != lines[i]:
126
+ return None
127
+ out: list[Patch] = []
128
+ ops: list[list] = []
129
+ for op in SequenceMatcher(None, old_t, new_t, autojunk=False).get_opcodes():
130
+ # two changes with only spaces between them are one change ("an hour" to "a day")
131
+ if (len(ops) >= 2 and op[0] != "equal" and ops[-1][0] == "equal" and ops[-2][0] != "equal"
132
+ and not "".join(old_t[ops[-1][1]:ops[-1][2]]).strip()):
133
+ ops.pop()
134
+ ops[-1] = ["replace", ops[-1][1], op[2], ops[-1][3], op[4]]
135
+ else:
136
+ ops.append(list(op))
137
+ for tag, s1, s2, t1, t2 in reversed(ops):
138
+ if tag == "equal":
139
+ continue
140
+ at = _offsets(old_t)
141
+ left, right = s1, s2 # the span of old tokens the patch covers
142
+ while True:
143
+ find = lines[i][at[left]:at[right]]
144
+ if find.strip() and _only_at_col(lines, i, at[left], find):
145
+ break
146
+ # one more word of context, on the side with more room, never past the line
147
+ if left > 0 and (right >= len(old_t) or s1 - left <= right - s2):
148
+ left -= 1
149
+ elif right < len(old_t):
150
+ right += 1
151
+ else:
152
+ return None
153
+ repl = "".join(old_t[left:s1]) + "".join(new_t[t1:t2]) + "".join(old_t[s2:right])
154
+ if _cuts_markup(find) or _cuts_markup(repl):
155
+ return None # the whole line, so Outline reads both halves of every marker
156
+ lines[i] = lines[i][:at[left]] + repl + lines[i][at[right]:]
157
+ out.append((find, repl))
158
+ old_t = old_t[:s1] + new_t[t1:t2] + old_t[s2:] # right of here now reads as the target
159
+ return out if lines[i] == new else None
160
+
161
+
162
+ def _only_at_col(lines: list[str], i: int, col: int, find: str) -> bool:
163
+ """`find` occurs exactly once in the joined lines, starting at column `col` of line `i`."""
164
+ joined = "\n".join(lines)
165
+ at = sum(len(x) + 1 for x in lines[:i]) + col
166
+ return joined.find(find) == at and joined.find(find, at + 1) == -1
167
+
168
+
169
+ def plan(current: str, target: str, widen: int = 0) -> list[Patch]:
170
+ """Patches from `current` to `target`. `widen` > 0 makes every patch also cross that many
171
+ blank lines (block boundaries). Outline patches a match inside ONE top-level block by
172
+ re-parsing just that block, and drops anything the replacement adds outside it; a match that
173
+ spans two blocks takes its region path instead, which keeps everything. The syncer widens
174
+ only after it sees a patch come back different from what it expected."""
175
+ if current == target:
176
+ return []
177
+ a, b = current.split("\n"), target.split("\n")
178
+ ops = SequenceMatcher(None, a, b, autojunk=False).get_opcodes()
179
+ work = a[:]
180
+ patches: list[Patch] = []
181
+ k = len(ops) - 1
182
+ while k >= 0:
183
+ tag, i1, i2, j1, j2 = ops[k]
184
+ if tag == "equal":
185
+ k -= 1
186
+ continue
187
+ if widen == 0 and tag == "replace" and i2 - i1 == j2 - j1:
188
+ trial, fine = work[:], []
189
+ for li in range(i2 - 1, i1 - 1, -1): # bottom-up, as the hunks are
190
+ if trial[li] == b[j1 + li - i1]:
191
+ continue
192
+ got = _words(trial, li, b[j1 + li - i1])
193
+ if got is None:
194
+ fine = None
195
+ break
196
+ fine += got
197
+ if fine is not None:
198
+ work = trial
199
+ patches += fine
200
+ k -= 1
201
+ continue
202
+ lo, hi, blo, bhi = i1, i2, j1, j2
203
+ # ⛔ A TABLE IS PATCHED WHOLE (2026-10-16). Outline took no patch that began inside a table:
204
+ # rows added at its end came back as a paragraph after it, the second row lost, and every
205
+ # later pass failed on the same table. A hunk that touches a table line, or sits right
206
+ # against one, covers the whole table and the blank line on each side, so it crosses blocks
207
+ # and Outline takes the path that keeps everything.
208
+ up_limit = ops[k - 1][1] if k > 0 and ops[k - 1][0] == "equal" else lo
209
+ near = work[lo:hi] + b[blo:bhi] + work[max(lo - 1, 0):lo] + work[hi:hi + 1]
210
+ if any(_is_row(x) for x in near):
211
+ # lines above the hunk, down to the run left unchanged, are the current's and the
212
+ # target's alike; lines below it already read as the target (the hunks go bottom-up)
213
+ while lo > up_limit and _is_row(work[lo - 1]):
214
+ lo, blo = lo - 1, blo - 1
215
+ if lo > up_limit and not work[lo - 1].strip():
216
+ lo, blo = lo - 1, blo - 1
217
+ while hi < len(work) and _is_row(work[hi]):
218
+ hi, bhi = hi + 1, bhi + 1
219
+ if hi < len(work) and not work[hi].strip():
220
+ hi, bhi = hi + 1, bhi + 1
221
+ while True:
222
+ # how far up we may borrow context: the unchanged run directly above the hunk
223
+ up_limit = ops[k - 1][1] if k > 0 and ops[k - 1][0] == "equal" else lo
224
+ find = "\n".join(work[lo:hi])
225
+ repl = "\n".join(b[blo:bhi])
226
+ if (find.strip() and (repl or bhi > blo) and _only_at(work, lo, find)
227
+ and sum(1 for x in work[lo:hi] if not x.strip()) >= widen):
228
+ break
229
+ if hi < len(work): # below the hunk already matches the target
230
+ hi, bhi = hi + 1, bhi + 1
231
+ elif lo > up_limit:
232
+ lo, blo = lo - 1, blo - 1
233
+ elif k >= 2: # swallow the run above and the hunk above it
234
+ k -= 2
235
+ lo, blo = ops[k][1], ops[k][3]
236
+ else:
237
+ raise CannotPatch("no unique context around a change")
238
+ work[lo:hi] = b[blo:bhi]
239
+ patches.append((find, repl))
240
+ k -= 1
241
+ if apply(current, patches) != target:
242
+ raise CannotPatch("plan does not reproduce the target")
243
+ return patches