contextattr 0.1.0__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.
- contextattr/__init__.py +22 -0
- contextattr/__main__.py +7 -0
- contextattr/cache.py +64 -0
- contextattr/cli.py +181 -0
- contextattr/evaluate.py +137 -0
- contextattr/methods/__init__.py +27 -0
- contextattr/methods/base.py +86 -0
- contextattr/methods/indices.py +59 -0
- contextattr/methods/interactions.py +215 -0
- contextattr/methods/kernelshap.py +114 -0
- contextattr/methods/linear.py +104 -0
- contextattr/methods/loo.py +31 -0
- contextattr/prompt/__init__.py +9 -0
- contextattr/prompt/io.py +222 -0
- contextattr/prompt/render.py +70 -0
- contextattr/prompt/split.py +153 -0
- contextattr/prompt/units.py +271 -0
- contextattr/report.py +120 -0
- contextattr/responses/__init__.py +6 -0
- contextattr/responses/checks.py +156 -0
- contextattr/responses/sample.py +36 -0
- contextattr/result.py +120 -0
- contextattr/run.py +306 -0
- contextattr/targets/__init__.py +7 -0
- contextattr/targets/sampled.py +58 -0
- contextattr/targets/scored.py +177 -0
- contextattr/value.py +201 -0
- contextattr/wrapper/__init__.py +9 -0
- contextattr/wrapper/check.py +163 -0
- contextattr/wrapper/contract.py +166 -0
- contextattr/wrapper/template.py +56 -0
- contextattr/wrapper/toy.py +129 -0
- contextattr-0.1.0.dist-info/METADATA +129 -0
- contextattr-0.1.0.dist-info/RECORD +38 -0
- contextattr-0.1.0.dist-info/WHEEL +5 -0
- contextattr-0.1.0.dist-info/entry_points.txt +2 -0
- contextattr-0.1.0.dist-info/licenses/LICENSE +21 -0
- contextattr-0.1.0.dist-info/top_level.txt +1 -0
contextattr/prompt/io.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Reading and writing prompts.jsonl, one prompt per line (docs/interface.md §2).
|
|
2
|
+
|
|
3
|
+
Reading checks every field and names the line and the place of any error. Writing gives each group its units
|
|
4
|
+
explicitly, so a file read back gives equal prompts whatever splitter made the groups.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from collections.abc import Iterable
|
|
12
|
+
|
|
13
|
+
from .split import KINDS, split
|
|
14
|
+
from .units import Group, Message, Prompt, Response, Unit
|
|
15
|
+
|
|
16
|
+
_PROMPT_FIELDS = {"id", "messages", "text", "reference", "responses"}
|
|
17
|
+
_MESSAGE_FIELDS = {"role", "content"}
|
|
18
|
+
_UNIT_FIELDS = {"unit", "text", "sep", "tag", "replace", "split"}
|
|
19
|
+
_GROUP_FIELDS = {"group", "units", "lead", "sep", "tag", "replace"}
|
|
20
|
+
_RESPONSE_FIELDS = {"id", "text", "label", "span"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _FormatError(ValueError):
|
|
24
|
+
"""An error already prefixed with its place in the prompt."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def read_prompts(path) -> list[Prompt]:
|
|
28
|
+
"""Every prompt of a JSON Lines file; blank lines are skipped and prompt ids must be unique."""
|
|
29
|
+
prompts, lines = [], {}
|
|
30
|
+
with open(path, encoding="utf-8-sig") as f: # -sig: a byte-order mark some editors add is skipped
|
|
31
|
+
for number, line in enumerate(f, 1):
|
|
32
|
+
if not line.strip():
|
|
33
|
+
continue
|
|
34
|
+
where = f"{os.fspath(path)} line {number}"
|
|
35
|
+
try:
|
|
36
|
+
data = json.loads(line)
|
|
37
|
+
except json.JSONDecodeError as e:
|
|
38
|
+
raise ValueError(f"{where}: not valid JSON ({e.msg}, column {e.colno})") from None
|
|
39
|
+
prompt = prompt_from_dict(data, where)
|
|
40
|
+
if prompt.id in lines:
|
|
41
|
+
raise ValueError(f"{where}: prompt id {prompt.id!r} is already used on line {lines[prompt.id]}")
|
|
42
|
+
lines[prompt.id] = number
|
|
43
|
+
prompts.append(prompt)
|
|
44
|
+
return prompts
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def write_prompts(prompts: Iterable[Prompt], path) -> None:
|
|
48
|
+
"""Write prompts as JSON Lines, replacing the file only once every line is written."""
|
|
49
|
+
rows, seen = [], set()
|
|
50
|
+
for p in prompts:
|
|
51
|
+
if not isinstance(p, Prompt):
|
|
52
|
+
raise TypeError(f"write_prompts takes Prompt objects, not {type(p).__name__}")
|
|
53
|
+
if p.id in seen:
|
|
54
|
+
raise ValueError(f"prompt id {p.id!r} is used twice")
|
|
55
|
+
seen.add(p.id)
|
|
56
|
+
rows.append(json.dumps(prompt_to_dict(p), ensure_ascii=False))
|
|
57
|
+
tmp = f"{os.fspath(path)}.tmp"
|
|
58
|
+
with open(tmp, "w", encoding="utf-8", newline="\n") as f:
|
|
59
|
+
f.writelines(row + "\n" for row in rows)
|
|
60
|
+
os.replace(tmp, path)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def prompt_from_dict(data, where: str = "prompt") -> Prompt:
|
|
64
|
+
"""One prompt from its JSON object, every field checked."""
|
|
65
|
+
try:
|
|
66
|
+
_object(data, "the prompt")
|
|
67
|
+
_fields(data, _PROMPT_FIELDS, {"id"}, "the prompt")
|
|
68
|
+
messages = text = None
|
|
69
|
+
if "messages" in data:
|
|
70
|
+
if not isinstance(data["messages"], list):
|
|
71
|
+
raise _FormatError("messages must be a list")
|
|
72
|
+
messages = [_at(f"messages[{k}]", _message, m, f"messages[{k}]") for k, m in enumerate(data["messages"])]
|
|
73
|
+
if "text" in data:
|
|
74
|
+
text = _content(data["text"], "text")
|
|
75
|
+
responses = data.get("responses", [])
|
|
76
|
+
if not isinstance(responses, list):
|
|
77
|
+
raise _FormatError("responses must be a list")
|
|
78
|
+
return Prompt(data["id"], messages=messages, text=text, reference=data.get("reference"),
|
|
79
|
+
responses=tuple(_at(f"responses[{k}]", _response, r, f"responses[{k}]")
|
|
80
|
+
for k, r in enumerate(responses)))
|
|
81
|
+
except (TypeError, ValueError) as e:
|
|
82
|
+
raise ValueError(f"{where}: {e}") from None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def prompt_to_dict(prompt: Prompt) -> dict:
|
|
86
|
+
"""The JSON object of a prompt; prompt_from_dict gives it back equal."""
|
|
87
|
+
d: dict = {"id": prompt.id}
|
|
88
|
+
if prompt.messages is not None:
|
|
89
|
+
d["messages"] = [{"role": m.role, "content": _content_out(m.content)} for m in prompt.messages]
|
|
90
|
+
else:
|
|
91
|
+
d["text"] = _content_out(prompt.text)
|
|
92
|
+
if prompt.reference is not None:
|
|
93
|
+
d["reference"] = prompt.reference
|
|
94
|
+
if prompt.responses:
|
|
95
|
+
d["responses"] = [_response_out(r) for r in prompt.responses]
|
|
96
|
+
return d
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# -- reading ----------------------------------------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
def _at(where: str, fn, *args):
|
|
102
|
+
try:
|
|
103
|
+
return fn(*args)
|
|
104
|
+
except _FormatError:
|
|
105
|
+
raise
|
|
106
|
+
except (TypeError, ValueError) as e:
|
|
107
|
+
raise _FormatError(f"{where}: {e}") from None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _object(value, where: str) -> None:
|
|
111
|
+
if not isinstance(value, dict):
|
|
112
|
+
raise _FormatError(f"{where} must be a JSON object, not {type(value).__name__}")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _fields(obj: dict, allowed: set, required: set, where: str) -> None:
|
|
116
|
+
unknown = sorted(set(obj) - allowed)
|
|
117
|
+
if unknown:
|
|
118
|
+
raise _FormatError(f"{where}: unknown field {', '.join(map(repr, unknown))} "
|
|
119
|
+
f"(allowed: {', '.join(sorted(allowed))})")
|
|
120
|
+
missing = sorted(required - set(obj))
|
|
121
|
+
if missing:
|
|
122
|
+
raise _FormatError(f"{where}: missing field {', '.join(map(repr, missing))}")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _message(value, where: str) -> Message:
|
|
126
|
+
_object(value, where)
|
|
127
|
+
_fields(value, _MESSAGE_FIELDS, _MESSAGE_FIELDS, where)
|
|
128
|
+
return Message(value["role"], _content(value["content"], f"{where}.content"))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _content(value, where: str):
|
|
132
|
+
if isinstance(value, str):
|
|
133
|
+
return value
|
|
134
|
+
if not isinstance(value, list):
|
|
135
|
+
raise _FormatError(f"{where} must be a string or a list, not {type(value).__name__}")
|
|
136
|
+
return [_at(f"{where}[{k}]", _piece, p, f"{where}[{k}]") for k, p in enumerate(value)]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _piece(value, where: str):
|
|
140
|
+
if isinstance(value, str):
|
|
141
|
+
return value
|
|
142
|
+
_object(value, where)
|
|
143
|
+
if "unit" in value and "group" in value:
|
|
144
|
+
raise _FormatError(f"{where}: a piece is a unit or a group, not both")
|
|
145
|
+
if "unit" in value:
|
|
146
|
+
_fields(value, _UNIT_FIELDS, {"unit", "text"}, where)
|
|
147
|
+
if "split" in value:
|
|
148
|
+
if value["split"] not in KINDS:
|
|
149
|
+
raise _FormatError(f"{where}: split must be one of {', '.join(KINDS)}, not {value['split']!r}")
|
|
150
|
+
return split(value["text"], value["split"], id=value["unit"], sep=value.get("sep", ""),
|
|
151
|
+
tag=value.get("tag"), replace=value.get("replace"))
|
|
152
|
+
return _unit(value)
|
|
153
|
+
if "group" in value:
|
|
154
|
+
_fields(value, _GROUP_FIELDS, {"group", "units"}, where)
|
|
155
|
+
if not isinstance(value["units"], list):
|
|
156
|
+
raise _FormatError(f"{where}: units must be a list")
|
|
157
|
+
units = []
|
|
158
|
+
for k, u in enumerate(value["units"]):
|
|
159
|
+
_object(u, f"{where}.units[{k}]")
|
|
160
|
+
_fields(u, _UNIT_FIELDS - {"split"}, {"unit", "text"}, f"{where}.units[{k}]")
|
|
161
|
+
units.append(_at(f"{where}.units[{k}]", _unit, u))
|
|
162
|
+
return Group(value["group"], tuple(units), lead=value.get("lead", ""), sep=value.get("sep", ""),
|
|
163
|
+
tag=value.get("tag"), replace=value.get("replace"))
|
|
164
|
+
raise _FormatError(f"{where}: an object piece needs a 'unit' or a 'group' field")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _unit(value: dict) -> Unit:
|
|
168
|
+
return Unit(value["unit"], value["text"], sep=value.get("sep", ""), tag=value.get("tag"),
|
|
169
|
+
replace=value.get("replace"))
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _response(value, where: str) -> Response:
|
|
173
|
+
_object(value, where)
|
|
174
|
+
_fields(value, _RESPONSE_FIELDS, {"id", "text"}, where)
|
|
175
|
+
span = value.get("span")
|
|
176
|
+
if span is not None and not isinstance(span, list):
|
|
177
|
+
raise _FormatError(f"{where}: span must be a list [start, end]")
|
|
178
|
+
return Response(value["id"], value["text"], label=value.get("label"),
|
|
179
|
+
span=tuple(span) if span is not None else None)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# -- writing ----------------------------------------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
def _content_out(pieces):
|
|
185
|
+
if len(pieces) == 1 and isinstance(pieces[0], str):
|
|
186
|
+
return pieces[0]
|
|
187
|
+
return [_piece_out(p) for p in pieces]
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _piece_out(p):
|
|
191
|
+
if isinstance(p, str):
|
|
192
|
+
return p
|
|
193
|
+
if isinstance(p, Unit):
|
|
194
|
+
return _unit_out(p, None, None)
|
|
195
|
+
d = {"group": p.id, "units": [_unit_out(u, p.tag, p.replace) for u in p.units]}
|
|
196
|
+
for name in ("lead", "sep"):
|
|
197
|
+
if getattr(p, name):
|
|
198
|
+
d[name] = getattr(p, name)
|
|
199
|
+
for name in ("tag", "replace"):
|
|
200
|
+
if getattr(p, name) is not None:
|
|
201
|
+
d[name] = getattr(p, name)
|
|
202
|
+
return d
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _unit_out(u: Unit, group_tag, group_replace) -> dict:
|
|
206
|
+
d = {"unit": u.id, "text": u.text}
|
|
207
|
+
if u.sep:
|
|
208
|
+
d["sep"] = u.sep
|
|
209
|
+
if u.tag is not None and u.tag != group_tag:
|
|
210
|
+
d["tag"] = u.tag
|
|
211
|
+
if u.replace is not None and u.replace != group_replace:
|
|
212
|
+
d["replace"] = u.replace
|
|
213
|
+
return d
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _response_out(r: Response) -> dict:
|
|
217
|
+
d = {"id": r.id, "text": r.text}
|
|
218
|
+
if r.label is not None:
|
|
219
|
+
d["label"] = r.label
|
|
220
|
+
if r.span is not None:
|
|
221
|
+
d["span"] = list(r.span)
|
|
222
|
+
return d
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""A mask → the prompt as the model sees it: chat messages or text, removed units deleted or replaced (§2, §6)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .units import Group, Prompt, Unit
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def as_mask(mask, n: int) -> tuple[bool, ...]:
|
|
9
|
+
"""Check a mask: n entries, each 1 or True (keep) or 0 or False (remove). None keeps every unit."""
|
|
10
|
+
if mask is None:
|
|
11
|
+
return (True,) * n
|
|
12
|
+
if isinstance(mask, (str, bytes)):
|
|
13
|
+
raise TypeError("a mask is a sequence of 0/1 or True/False, not a string")
|
|
14
|
+
try:
|
|
15
|
+
values = list(mask)
|
|
16
|
+
except TypeError:
|
|
17
|
+
raise TypeError(f"a mask is a sequence of 0/1 or True/False, not {type(mask).__name__}") from None
|
|
18
|
+
if len(values) != n:
|
|
19
|
+
raise ValueError(f"the mask has {len(values)} entries, the prompt {n} units")
|
|
20
|
+
out = []
|
|
21
|
+
for k, v in enumerate(values):
|
|
22
|
+
if v is True or v is False:
|
|
23
|
+
out.append(v)
|
|
24
|
+
continue
|
|
25
|
+
try:
|
|
26
|
+
ok = not isinstance(v, (str, bytes)) and bool(v == 0 or v == 1)
|
|
27
|
+
except (TypeError, ValueError): # e.g. a row of a 2-D array
|
|
28
|
+
ok = False
|
|
29
|
+
if not ok:
|
|
30
|
+
raise ValueError(f"mask entry {k} is {v!r}: use 0/1 or True/False")
|
|
31
|
+
out.append(bool(v == 1))
|
|
32
|
+
return tuple(out)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def render(prompt: Prompt, mask=None, replace: str | None = None):
|
|
36
|
+
"""The prompt with only the units `mask` keeps. A removed unit is deleted together with its `sep`, or, when it
|
|
37
|
+
has its own `replace` text or `replace` is given, becomes that text followed by its `sep`. A group's `lead` and
|
|
38
|
+
`sep` stay while any of its units shows. Chat prompts give [{"role", "content"}] dicts, text prompts a string."""
|
|
39
|
+
if replace is not None and not isinstance(replace, str):
|
|
40
|
+
raise TypeError(f"replace must be a string or None, not {type(replace).__name__}")
|
|
41
|
+
keep = iter(as_mask(mask, prompt.n))
|
|
42
|
+
if prompt.messages is not None:
|
|
43
|
+
return [{"role": m.role, "content": _join(m.content, keep, replace)} for m in prompt.messages]
|
|
44
|
+
return _join(prompt.text, keep, replace)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _join(pieces, keep, replace: str | None) -> str:
|
|
48
|
+
parts: list[str] = []
|
|
49
|
+
for p in pieces:
|
|
50
|
+
if isinstance(p, str):
|
|
51
|
+
parts.append(p)
|
|
52
|
+
elif isinstance(p, Unit):
|
|
53
|
+
parts.append(_unit(p, next(keep), replace)[0])
|
|
54
|
+
elif isinstance(p, Group):
|
|
55
|
+
shown = [_unit(u, next(keep), replace) for u in p.units]
|
|
56
|
+
if any(visible for _, visible in shown):
|
|
57
|
+
parts.append(p.lead)
|
|
58
|
+
parts.extend(text for text, _ in shown)
|
|
59
|
+
parts.append(p.sep)
|
|
60
|
+
return "".join(parts)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _unit(u: Unit, kept: bool, replace: str | None) -> tuple[str, bool]:
|
|
64
|
+
"""The unit's text in the rendered prompt, and whether it shows (kept, or replaced)."""
|
|
65
|
+
if kept:
|
|
66
|
+
return u.text + u.sep, True
|
|
67
|
+
r = u.replace if u.replace is not None else replace
|
|
68
|
+
if r is None:
|
|
69
|
+
return "", False
|
|
70
|
+
return r + u.sep, True
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Cutting a text into units that join back to it exactly (docs/interface.md §2).
|
|
2
|
+
|
|
3
|
+
Every splitter only chooses where units start and end: the text before the first unit becomes the group's `lead`,
|
|
4
|
+
and the text after each unit, up to the next one, becomes that unit's `sep`, so nothing is ever lost or added.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import operator
|
|
10
|
+
import re
|
|
11
|
+
from collections.abc import Callable, Iterable
|
|
12
|
+
|
|
13
|
+
from .units import Group, Unit
|
|
14
|
+
|
|
15
|
+
KINDS = ("word", "sentence", "line", "paragraph")
|
|
16
|
+
|
|
17
|
+
_LINE_BREAK = re.compile(r"\r\n|\r|\n")
|
|
18
|
+
_WORD = re.compile(r"\S+")
|
|
19
|
+
_CLOSERS = "\"'”’»)]"
|
|
20
|
+
_OPENERS = "\"'“‘«([{"
|
|
21
|
+
_LATIN_END = re.compile(r"[.!?…]+[\"'”’»)\]]*(?=\s)") # terminal punctuation, closing quotes or brackets, a space
|
|
22
|
+
_CJK_END = re.compile(r"[。!?]+[」』”’))]*") # full-width terminals need no space after them
|
|
23
|
+
_NUMBER_NEXT = re.compile(r"\(?\d")
|
|
24
|
+
_DOTTED = re.compile(r"(?:[^\W\d_]\.)+[^\W\d_]") # U.S, e.g, i.e, a.m: what precedes the final period
|
|
25
|
+
# A period after these never ends a sentence: titles and short forms that precede a name or a word.
|
|
26
|
+
_ABBREV = frozenset("mr mrs ms mx dr prof st mt gen gov sen rep rev lt col sgt capt cmdr adm maj vs cf al".split())
|
|
27
|
+
# A period after these does not end a sentence when a number follows: "Fig. 3", "No. 5", "Eq. (2)", "Jan. 5".
|
|
28
|
+
_ABBREV_NUMBER = frozenset("no nos fig figs eq eqs sec secs vol vols pp p ch art ref tab approx "
|
|
29
|
+
"jan feb mar apr jun jul aug sep sept oct nov dec".split())
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def split(text: str, by: str | Callable[[str], Iterable[tuple[int, int]]] = "sentence", *, id: str,
|
|
33
|
+
sep: str = "", tag: str | None = None, replace: str | None = None) -> Group:
|
|
34
|
+
"""Cut `text` into units `<id>.0`, `<id>.1`, … held in a Group with `sep` after it. The units with their
|
|
35
|
+
separators give back `text` exactly. `by`: "word", "sentence", "line", "paragraph", or a function returning the
|
|
36
|
+
units' (start, end) character spans, sorted and not overlapping."""
|
|
37
|
+
if not isinstance(text, str):
|
|
38
|
+
raise TypeError(f"split: text must be a string, not {type(text).__name__}")
|
|
39
|
+
if callable(by):
|
|
40
|
+
spans = _custom_spans(text, by)
|
|
41
|
+
elif by in _SPLITTERS:
|
|
42
|
+
spans = _SPLITTERS[by](text)
|
|
43
|
+
else:
|
|
44
|
+
raise ValueError(f"split: by must be one of {', '.join(KINDS)} or a function, not {by!r}")
|
|
45
|
+
if not spans:
|
|
46
|
+
raise ValueError(f"split: no units in {text[:40]!r} (by {by if isinstance(by, str) else 'the function'})")
|
|
47
|
+
units = []
|
|
48
|
+
for k, (start, end) in enumerate(spans):
|
|
49
|
+
nxt = spans[k + 1][0] if k + 1 < len(spans) else len(text)
|
|
50
|
+
units.append(Unit(f"{id}.{k}", text[start:end], sep=text[end:nxt]))
|
|
51
|
+
return Group(id, tuple(units), lead=text[: spans[0][0]], sep=sep, tag=tag, replace=replace)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _word_spans(text: str) -> list[tuple[int, int]]:
|
|
55
|
+
"""Runs of non-space characters."""
|
|
56
|
+
return [(m.start(), m.end()) for m in _WORD.finditer(text)]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _line_spans(text: str) -> list[tuple[int, int]]:
|
|
60
|
+
"""Lines with text, each from the line's start (its indentation included) to its last non-space character."""
|
|
61
|
+
spans = []
|
|
62
|
+
start = 0
|
|
63
|
+
for m in [*_LINE_BREAK.finditer(text), None]:
|
|
64
|
+
end = m.start() if m else len(text)
|
|
65
|
+
content = text[start:end].rstrip()
|
|
66
|
+
if content.strip():
|
|
67
|
+
spans.append((start, start + len(content)))
|
|
68
|
+
if m:
|
|
69
|
+
start = m.end()
|
|
70
|
+
return spans
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _paragraph_spans(text: str) -> list[tuple[int, int]]:
|
|
74
|
+
"""Runs of lines with text that no blank line separates, from the first line's start to the last's last
|
|
75
|
+
non-space character."""
|
|
76
|
+
spans: list[tuple[int, int]] = []
|
|
77
|
+
for a, b in _line_spans(text):
|
|
78
|
+
if spans and len(_LINE_BREAK.findall(text, spans[-1][1], a)) == 1:
|
|
79
|
+
spans[-1] = (spans[-1][0], b)
|
|
80
|
+
else:
|
|
81
|
+
spans.append((a, b))
|
|
82
|
+
return spans
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _sentence_spans(text: str) -> list[tuple[int, int]]:
|
|
86
|
+
"""Sentences within each line (a line break always ends one), without surrounding spaces."""
|
|
87
|
+
spans = []
|
|
88
|
+
for a, b in _line_spans(text):
|
|
89
|
+
a = _skip_space(text, a, b)
|
|
90
|
+
start = a
|
|
91
|
+
for end in _sentence_ends(text, a, b):
|
|
92
|
+
spans.append((start, end))
|
|
93
|
+
start = _skip_space(text, end, b)
|
|
94
|
+
spans.append((start, b))
|
|
95
|
+
return spans
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _sentence_ends(text: str, a: int, b: int) -> list[int]:
|
|
99
|
+
"""Positions inside text[a:b] where a sentence ends and another begins on the same line."""
|
|
100
|
+
ends = set()
|
|
101
|
+
for m in _LATIN_END.finditer(text, a, b):
|
|
102
|
+
nxt = _skip_space(text, m.end(), b)
|
|
103
|
+
if nxt < b and not text[nxt].islower() and not _abbreviation(text, a, m.start(), m.group(), nxt, b):
|
|
104
|
+
ends.add(m.end())
|
|
105
|
+
for m in _CJK_END.finditer(text, a, b):
|
|
106
|
+
if _skip_space(text, m.end(), b) < b:
|
|
107
|
+
ends.add(m.end())
|
|
108
|
+
return sorted(ends)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _abbreviation(text: str, line_start: int, p: int, run: str, nxt: int, b: int) -> bool:
|
|
112
|
+
"""Whether the single period at p closes an abbreviation rather than a sentence."""
|
|
113
|
+
if run.rstrip(_CLOSERS) != ".":
|
|
114
|
+
return False
|
|
115
|
+
q = p
|
|
116
|
+
while q > line_start and not text[q - 1].isspace():
|
|
117
|
+
q -= 1
|
|
118
|
+
word = text[q:p].lstrip(_OPENERS)
|
|
119
|
+
if not word:
|
|
120
|
+
return False
|
|
121
|
+
return ((len(word) == 1 and word.isupper()) # an initial: "J. K. Rowling"
|
|
122
|
+
or _DOTTED.fullmatch(word) is not None # "U.S.", "e.g.", "a.m."
|
|
123
|
+
or word.lower() in _ABBREV # "Dr. Smith", "et al. (2020)"
|
|
124
|
+
or (word.lower() in _ABBREV_NUMBER and _NUMBER_NEXT.match(text, nxt, b) is not None) # "Fig. 3"
|
|
125
|
+
or ((word.isdigit() or (len(word) == 1 and word.isalpha())) and q == line_start)) # "1. Mix", "a. Add"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _skip_space(text: str, i: int, b: int) -> int:
|
|
129
|
+
while i < b and text[i].isspace():
|
|
130
|
+
i += 1
|
|
131
|
+
return i
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _custom_spans(text: str, fn: Callable[[str], Iterable[tuple[int, int]]]) -> list[tuple[int, int]]:
|
|
135
|
+
spans = []
|
|
136
|
+
prev = 0
|
|
137
|
+
for k, span in enumerate(fn(text)):
|
|
138
|
+
try:
|
|
139
|
+
start, end = span
|
|
140
|
+
if isinstance(start, bool) or isinstance(end, bool):
|
|
141
|
+
raise TypeError
|
|
142
|
+
start, end = operator.index(start), operator.index(end)
|
|
143
|
+
except (TypeError, ValueError):
|
|
144
|
+
raise TypeError(f"split: the function gave {span!r} as span {k}; a span is (start, end) integers") from None
|
|
145
|
+
if not prev <= start < end <= len(text):
|
|
146
|
+
raise ValueError(f"split: span {k} is ({start}, {end}); spans must be sorted, not overlapping and not "
|
|
147
|
+
f"empty, within 0..{len(text)}")
|
|
148
|
+
spans.append((start, end))
|
|
149
|
+
prev = end
|
|
150
|
+
return spans
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
_SPLITTERS = {"word": _word_spans, "sentence": _sentence_spans, "line": _line_spans, "paragraph": _paragraph_spans}
|