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.
@@ -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}