exform 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.
- exform/__init__.py +6 -0
- exform/__main__.py +4 -0
- exform/cli.py +305 -0
- exform/synth.py +623 -0
- exform-0.1.0.dist-info/METADATA +314 -0
- exform-0.1.0.dist-info/RECORD +9 -0
- exform-0.1.0.dist-info/WHEEL +4 -0
- exform-0.1.0.dist-info/entry_points.txt +2 -0
- exform-0.1.0.dist-info/licenses/LICENSE +21 -0
exform/__init__.py
ADDED
exform/__main__.py
ADDED
exform/cli.py
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""exform command-line interface.
|
|
2
|
+
|
|
3
|
+
Reshape text by example:
|
|
4
|
+
|
|
5
|
+
$ printf 'John Smith\\nJane Doe\\n' | exform -e 'John Smith => Smith, J.'
|
|
6
|
+
|
|
7
|
+
exform infers the transformation from the example(s) and applies it to every
|
|
8
|
+
input line.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import sys
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
from . import __version__
|
|
18
|
+
from .synth import Program, SynthesisError, synthesize
|
|
19
|
+
|
|
20
|
+
_ARROW = "=>"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _parse_example(raw: str, sep: str) -> tuple[str, str]:
|
|
24
|
+
if sep not in raw:
|
|
25
|
+
raise SystemExit(
|
|
26
|
+
f"exform: example {raw!r} does not contain the separator {sep!r}.\n"
|
|
27
|
+
f" write it as INPUT {sep} OUTPUT"
|
|
28
|
+
)
|
|
29
|
+
left, right = raw.split(sep, 1)
|
|
30
|
+
return left.strip(), right.strip()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _read_examples_file(path: str, sep: str) -> list[tuple[str, str]]:
|
|
34
|
+
exs = []
|
|
35
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
36
|
+
for line in fh:
|
|
37
|
+
line = line.rstrip("\n")
|
|
38
|
+
if not line.strip():
|
|
39
|
+
continue
|
|
40
|
+
exs.append(_parse_example(line, sep))
|
|
41
|
+
return exs
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
45
|
+
p = argparse.ArgumentParser(
|
|
46
|
+
prog="exform",
|
|
47
|
+
description="Reshape text by example. Give a couple of before=>after "
|
|
48
|
+
"examples; exform infers the transformation and applies it to every "
|
|
49
|
+
"line. Deterministic, offline, no regex, no LLM.",
|
|
50
|
+
epilog="examples:\n"
|
|
51
|
+
" exform -e 'John Smith => Smith, J.' names.txt\n"
|
|
52
|
+
" cat log.txt | exform -e '2021-05-01 ERROR boom => [ERROR] boom'\n"
|
|
53
|
+
" exform -E pairs.tsv --sep $'\\t' data.txt --explain\n",
|
|
54
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
55
|
+
)
|
|
56
|
+
p.add_argument(
|
|
57
|
+
"-e",
|
|
58
|
+
"--example",
|
|
59
|
+
action="append",
|
|
60
|
+
default=[],
|
|
61
|
+
metavar="'IN => OUT'",
|
|
62
|
+
help="an input=>output example (repeatable)",
|
|
63
|
+
)
|
|
64
|
+
p.add_argument(
|
|
65
|
+
"-E",
|
|
66
|
+
"--examples-file",
|
|
67
|
+
metavar="FILE",
|
|
68
|
+
help="read examples from FILE (one 'IN => OUT' per line)",
|
|
69
|
+
)
|
|
70
|
+
p.add_argument(
|
|
71
|
+
"file",
|
|
72
|
+
nargs="?",
|
|
73
|
+
help="input file to transform (default: stdin)",
|
|
74
|
+
)
|
|
75
|
+
p.add_argument(
|
|
76
|
+
"--sep",
|
|
77
|
+
default=_ARROW,
|
|
78
|
+
help=f"separator between input and output in examples (default: {_ARROW!r})",
|
|
79
|
+
)
|
|
80
|
+
p.add_argument(
|
|
81
|
+
"--fill",
|
|
82
|
+
action="store_true",
|
|
83
|
+
help="FlashFill mode: read a 2-column file (input<TAB>output). Rows "
|
|
84
|
+
"where you filled in the output become examples; rows with a blank "
|
|
85
|
+
"output are completed. Prints the finished table.",
|
|
86
|
+
)
|
|
87
|
+
p.add_argument(
|
|
88
|
+
"--col-sep",
|
|
89
|
+
default="\t",
|
|
90
|
+
metavar="SEP",
|
|
91
|
+
help="column separator for --fill mode (default: TAB)",
|
|
92
|
+
)
|
|
93
|
+
p.add_argument(
|
|
94
|
+
"--explain",
|
|
95
|
+
action="store_true",
|
|
96
|
+
help="print the inferred program to stderr",
|
|
97
|
+
)
|
|
98
|
+
p.add_argument(
|
|
99
|
+
"-q",
|
|
100
|
+
"--quiet",
|
|
101
|
+
action="store_true",
|
|
102
|
+
help="suppress non-fatal warnings (e.g. constant-program hint)",
|
|
103
|
+
)
|
|
104
|
+
p.add_argument(
|
|
105
|
+
"--dry-run",
|
|
106
|
+
action="store_true",
|
|
107
|
+
help="only infer and print the program; do not read/transform input",
|
|
108
|
+
)
|
|
109
|
+
p.add_argument(
|
|
110
|
+
"--no-slices",
|
|
111
|
+
action="store_true",
|
|
112
|
+
help="disable positional slice atoms (faster, more general)",
|
|
113
|
+
)
|
|
114
|
+
p.add_argument(
|
|
115
|
+
"--on-error",
|
|
116
|
+
choices=["keep", "empty", "skip", "fail"],
|
|
117
|
+
default="keep",
|
|
118
|
+
help="what to do with a line the program cannot transform "
|
|
119
|
+
"(default: keep the original line)",
|
|
120
|
+
)
|
|
121
|
+
p.add_argument("--version", action="version", version=f"exform {__version__}")
|
|
122
|
+
return p
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _iter_input(path: Optional[str]):
|
|
126
|
+
if path is None or path == "-":
|
|
127
|
+
for line in sys.stdin:
|
|
128
|
+
yield line.rstrip("\n")
|
|
129
|
+
else:
|
|
130
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
131
|
+
for line in fh:
|
|
132
|
+
yield line.rstrip("\n")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _run_fill(args) -> int:
|
|
136
|
+
"""FlashFill mode.
|
|
137
|
+
|
|
138
|
+
Read a 2-column table (input<COLSEP>output). Rows whose output cell is
|
|
139
|
+
non-empty are treated as examples; rows with a blank/absent output cell are
|
|
140
|
+
completed by the inferred program. The full finished table is written to
|
|
141
|
+
stdout in the same order.
|
|
142
|
+
"""
|
|
143
|
+
colsep = args.col_sep
|
|
144
|
+
rows: list[tuple[str, Optional[str]]] = []
|
|
145
|
+
examples: list[tuple[str, str]] = []
|
|
146
|
+
for line in _iter_input(args.file):
|
|
147
|
+
if colsep in line:
|
|
148
|
+
inp, out = line.split(colsep, 1)
|
|
149
|
+
else:
|
|
150
|
+
inp, out = line, ""
|
|
151
|
+
out_stripped = out.strip()
|
|
152
|
+
if out_stripped:
|
|
153
|
+
examples.append((inp, out_stripped))
|
|
154
|
+
rows.append((inp, out_stripped))
|
|
155
|
+
else:
|
|
156
|
+
rows.append((inp, None))
|
|
157
|
+
|
|
158
|
+
if not examples:
|
|
159
|
+
sys.stderr.write(
|
|
160
|
+
"exform: --fill needs at least one completed row "
|
|
161
|
+
f"(input{colsep!r}output). Fill in the output for the first row "
|
|
162
|
+
"or two, then re-run.\n"
|
|
163
|
+
)
|
|
164
|
+
return 2
|
|
165
|
+
|
|
166
|
+
n_blank = sum(1 for _, o in rows if o is None)
|
|
167
|
+
if n_blank == 0:
|
|
168
|
+
# Nothing to do; just echo back. Still infer so --explain works.
|
|
169
|
+
pass
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
program: Program = synthesize(examples, use_slices=not args.no_slices)
|
|
173
|
+
except SynthesisError as exc:
|
|
174
|
+
sys.stderr.write(f"exform: {exc}\n")
|
|
175
|
+
sys.stderr.write(
|
|
176
|
+
" try filling in another representative row.\n"
|
|
177
|
+
)
|
|
178
|
+
return 1
|
|
179
|
+
|
|
180
|
+
if args.explain:
|
|
181
|
+
sys.stderr.write(f"program: {program.explain()}\n")
|
|
182
|
+
|
|
183
|
+
if not args.quiet:
|
|
184
|
+
memo = program.memorized_literals(i for i, _ in examples)
|
|
185
|
+
if program.is_constant():
|
|
186
|
+
sys.stderr.write(
|
|
187
|
+
"exform: warning: the inferred rule is constant (every row "
|
|
188
|
+
"would get the same output). Fill in another, more varied "
|
|
189
|
+
"row.\n"
|
|
190
|
+
)
|
|
191
|
+
elif memo:
|
|
192
|
+
shown = ", ".join(repr(m) for m in memo[:3])
|
|
193
|
+
sys.stderr.write(
|
|
194
|
+
"exform: warning: the inferred rule hardcodes " + shown + " "
|
|
195
|
+
"copied from a completed row, so other rows will likely be "
|
|
196
|
+
"wrong. Fill in another varied row so exform can generalise.\n"
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
out = sys.stdout
|
|
200
|
+
for inp, given in rows:
|
|
201
|
+
if given is not None:
|
|
202
|
+
out.write(inp + colsep + given + "\n")
|
|
203
|
+
continue
|
|
204
|
+
result = program.apply(inp)
|
|
205
|
+
if result is None:
|
|
206
|
+
if args.on_error == "keep":
|
|
207
|
+
result = ""
|
|
208
|
+
elif args.on_error == "empty":
|
|
209
|
+
result = ""
|
|
210
|
+
elif args.on_error == "skip":
|
|
211
|
+
out.write(inp + "\n")
|
|
212
|
+
continue
|
|
213
|
+
else: # fail
|
|
214
|
+
sys.stderr.write(
|
|
215
|
+
f"exform: could not fill row: {inp!r}\n"
|
|
216
|
+
)
|
|
217
|
+
return 1
|
|
218
|
+
out.write(inp + colsep + result + "\n")
|
|
219
|
+
return 0
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def run(argv: Optional[list[str]] = None) -> int:
|
|
223
|
+
args = build_parser().parse_args(argv)
|
|
224
|
+
|
|
225
|
+
if args.fill:
|
|
226
|
+
return _run_fill(args)
|
|
227
|
+
|
|
228
|
+
examples: list[tuple[str, str]] = []
|
|
229
|
+
if args.examples_file:
|
|
230
|
+
examples.extend(_read_examples_file(args.examples_file, args.sep))
|
|
231
|
+
for raw in args.example:
|
|
232
|
+
examples.append(_parse_example(raw, args.sep))
|
|
233
|
+
|
|
234
|
+
if not examples:
|
|
235
|
+
sys.stderr.write("exform: no examples given (use -e 'IN => OUT')\n")
|
|
236
|
+
return 2
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
program: Program = synthesize(examples, use_slices=not args.no_slices)
|
|
240
|
+
except SynthesisError as exc:
|
|
241
|
+
sys.stderr.write(f"exform: {exc}\n")
|
|
242
|
+
sys.stderr.write(
|
|
243
|
+
" try adding another example, or a more representative one.\n"
|
|
244
|
+
)
|
|
245
|
+
return 1
|
|
246
|
+
|
|
247
|
+
if args.explain or args.dry_run:
|
|
248
|
+
sys.stderr.write(f"program: {program.explain()}\n")
|
|
249
|
+
|
|
250
|
+
if program.is_constant() and not args.quiet:
|
|
251
|
+
sys.stderr.write(
|
|
252
|
+
"exform: warning: the inferred program is a constant and ignores "
|
|
253
|
+
"the input\n"
|
|
254
|
+
" (every line would become the same text). This usually "
|
|
255
|
+
"means too few\n"
|
|
256
|
+
" or non-varied examples. Add another example, e.g. "
|
|
257
|
+
"-e 'OTHER_IN => OTHER_OUT',\n"
|
|
258
|
+
" or pass --quiet to silence this warning.\n"
|
|
259
|
+
)
|
|
260
|
+
else:
|
|
261
|
+
memo = program.memorized_literals(i for i, _ in examples)
|
|
262
|
+
if memo and not args.quiet:
|
|
263
|
+
shown = ", ".join(repr(m) for m in memo[:3])
|
|
264
|
+
sys.stderr.write(
|
|
265
|
+
"exform: warning: the inferred program hardcodes " + shown + " "
|
|
266
|
+
"copied from your\n"
|
|
267
|
+
" example's input, so it will likely be wrong on other "
|
|
268
|
+
"lines. This means the\n"
|
|
269
|
+
" example is ambiguous. Add another varied example, e.g. "
|
|
270
|
+
"-e 'OTHER_IN => OTHER_OUT',\n"
|
|
271
|
+
" so exform can generalise (pass --quiet to silence).\n"
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
if args.dry_run:
|
|
275
|
+
return 0
|
|
276
|
+
|
|
277
|
+
out = sys.stdout
|
|
278
|
+
exit_code = 0
|
|
279
|
+
for line in _iter_input(args.file):
|
|
280
|
+
result = program.apply(line)
|
|
281
|
+
if result is None:
|
|
282
|
+
if args.on_error == "keep":
|
|
283
|
+
result = line
|
|
284
|
+
elif args.on_error == "empty":
|
|
285
|
+
result = ""
|
|
286
|
+
elif args.on_error == "skip":
|
|
287
|
+
continue
|
|
288
|
+
else: # fail
|
|
289
|
+
sys.stderr.write(
|
|
290
|
+
f"exform: could not transform line: {line!r}\n"
|
|
291
|
+
)
|
|
292
|
+
return 1
|
|
293
|
+
out.write(result + "\n")
|
|
294
|
+
return exit_code
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def main() -> None: # console_scripts entry point
|
|
298
|
+
try:
|
|
299
|
+
raise SystemExit(run())
|
|
300
|
+
except BrokenPipeError:
|
|
301
|
+
pass
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
if __name__ == "__main__":
|
|
305
|
+
main()
|
exform/synth.py
ADDED
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
"""Deterministic program synthesis: infer a text transformation from
|
|
2
|
+
input -> output examples, then apply it to new lines.
|
|
3
|
+
|
|
4
|
+
The approach is a small version of programming-by-example (a la spreadsheet
|
|
5
|
+
"flash fill"), done with an explicit, inspectable DSL and a uniform-cost
|
|
6
|
+
search that finds the *simplest* program consistent with every example.
|
|
7
|
+
|
|
8
|
+
No LLM, no network, stdlib only.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import heapq
|
|
14
|
+
import re
|
|
15
|
+
import time
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import Callable, Iterable, Optional
|
|
18
|
+
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
# Transforms applied to an extracted piece of text.
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _title(s: str) -> str:
|
|
25
|
+
# Word-aware title-casing that does not mangle apostrophes the way
|
|
26
|
+
# str.title() does ("O'Brien" stays "O'Brien").
|
|
27
|
+
return re.sub(r"[A-Za-z]+", lambda m: m.group(0).capitalize(), s)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_NUMRE = re.compile(r"\s*([+-]?)(\d+)(\.\d+)?\s*\Z")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _group(s: str, sep: str) -> str:
|
|
34
|
+
"""Insert `sep` as a thousands separator in the integer part of a number.
|
|
35
|
+
|
|
36
|
+
Leaves non-numeric strings untouched, so on a line where the extracted
|
|
37
|
+
piece isn't a plain number it degrades to identity instead of erroring.
|
|
38
|
+
"""
|
|
39
|
+
m = _NUMRE.match(s)
|
|
40
|
+
if not m:
|
|
41
|
+
return s
|
|
42
|
+
sign, intpart, frac = m.group(1), m.group(2), m.group(3) or ""
|
|
43
|
+
grouped = format(int(intpart), ",").replace(",", sep)
|
|
44
|
+
return f"{sign}{grouped}{frac}"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
_SLUG_RE = re.compile(r"[^A-Za-z0-9]+")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _slug(s: str) -> str:
|
|
51
|
+
"""Canonical URL/anchor slug: lowercase, runs of non-alphanumeric collapse
|
|
52
|
+
to a single '-', leading/trailing '-' trimmed. "Hello, World!" ->
|
|
53
|
+
"hello-world". Handles a variable number of words (unlike field+glue),
|
|
54
|
+
which is what makes slugifying with a single example possible."""
|
|
55
|
+
return _SLUG_RE.sub("-", s).strip("-").lower()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _ws_to(s: str, sep: str) -> str:
|
|
59
|
+
"""Replace every run of whitespace with `sep` (case preserved), trimming
|
|
60
|
+
leading/trailing whitespace first. "my file name" -> "my_file_name"."""
|
|
61
|
+
return sep.join(s.split())
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _acronym(s: str, sep: str = "") -> str:
|
|
65
|
+
"""First letter of each whitespace-delimited word, upper-cased, joined by
|
|
66
|
+
`sep`. Handles a *variable* number of words, so "Ada King Lovelace" ->
|
|
67
|
+
"AKL" generalizes correctly from examples of different lengths (unlike a
|
|
68
|
+
fixed field+glue program). With sep="." gives "A.K.L.". Words that start
|
|
69
|
+
with a non-letter contribute their first character as-is."""
|
|
70
|
+
parts = [w[0] for w in s.split() if w]
|
|
71
|
+
joined = sep.join(parts).upper()
|
|
72
|
+
if sep and parts:
|
|
73
|
+
joined += sep
|
|
74
|
+
return joined
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
_WORD_SEP_RE = re.compile(r"[^A-Za-z0-9]+")
|
|
78
|
+
# Split a chunk into sub-words on camelCase / PascalCase / ACRONYM boundaries.
|
|
79
|
+
_CAMEL_RE = re.compile(r"[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z0-9]+|[A-Z]+|[0-9]+")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _words(s: str) -> list[str]:
|
|
83
|
+
"""Split an identifier or phrase into its component words, regardless of
|
|
84
|
+
the naming convention used. Handles separators (``_ - . space``) *and*
|
|
85
|
+
camelCase / PascalCase / ACRONYM boundaries, so "my_var_name",
|
|
86
|
+
"my-var-name", "myVarName" and "My Var Name" all yield
|
|
87
|
+
``["my", "var", "name"]`` (case preserved)."""
|
|
88
|
+
out: list[str] = []
|
|
89
|
+
for chunk in _WORD_SEP_RE.split(s):
|
|
90
|
+
if chunk:
|
|
91
|
+
out.extend(m.group(0) for m in _CAMEL_RE.finditer(chunk))
|
|
92
|
+
return out
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _camel(s: str, upper_first: bool) -> str:
|
|
96
|
+
"""Join the words of `s` in camelCase (``upper_first=False``) or
|
|
97
|
+
PascalCase (``upper_first=True``). Folds over a *variable* number of
|
|
98
|
+
words, so "my_var_name" -> "myVarName" generalizes from examples of
|
|
99
|
+
different word counts. Degrades to the original string if no words are
|
|
100
|
+
found (e.g. an all-symbol line) instead of erroring."""
|
|
101
|
+
words = _words(s)
|
|
102
|
+
if not words:
|
|
103
|
+
return s
|
|
104
|
+
parts = []
|
|
105
|
+
for i, w in enumerate(words):
|
|
106
|
+
if i == 0 and not upper_first:
|
|
107
|
+
parts.append(w.lower())
|
|
108
|
+
else:
|
|
109
|
+
parts.append(w[:1].upper() + w[1:].lower())
|
|
110
|
+
return "".join(parts)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
_INTRE = re.compile(r"([+-]?)(\d+)\Z")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _zpad(s: str, width: int) -> str:
|
|
117
|
+
"""Left-pad an integer string with zeros to `width` digits.
|
|
118
|
+
|
|
119
|
+
Only applies to plain integer strings (optionally signed); anything else
|
|
120
|
+
is returned untouched so it degrades to identity on non-numeric lines
|
|
121
|
+
instead of producing nonsense like ``00abc``. A sign is preserved in front
|
|
122
|
+
of the padding (``-7`` -> ``-007`` at width 3).
|
|
123
|
+
"""
|
|
124
|
+
m = _INTRE.match(s)
|
|
125
|
+
if not m:
|
|
126
|
+
return s
|
|
127
|
+
sign, digits = m.group(1), m.group(2)
|
|
128
|
+
return f"{sign}{digits.rjust(width, '0')}"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
TRANSFORMS: dict[str, Callable[[str], str]] = {
|
|
132
|
+
"": lambda s: s,
|
|
133
|
+
"lower": str.lower,
|
|
134
|
+
"upper": str.upper,
|
|
135
|
+
"cap": lambda s: s[:1].upper() + s[1:].lower() if s else s,
|
|
136
|
+
"title": _title,
|
|
137
|
+
"strip": str.strip,
|
|
138
|
+
"first": lambda s: s[:1],
|
|
139
|
+
"First": lambda s: s[:1].upper(),
|
|
140
|
+
# Lower-cased leading initial, the building block of username/email-local
|
|
141
|
+
# synthesis: "John" -> "j" so "first.jsmith@corp.com" is reachable.
|
|
142
|
+
"first_": lambda s: s[:1].lower(),
|
|
143
|
+
# Numeric thousands grouping (a la spreadsheet number formatting). The
|
|
144
|
+
# output separator is chosen from the example: comma (US), space (SI),
|
|
145
|
+
# or period (many European locales).
|
|
146
|
+
"group,": lambda s: _group(s, ","),
|
|
147
|
+
"group_": lambda s: _group(s, " "),
|
|
148
|
+
"group.": lambda s: _group(s, "."),
|
|
149
|
+
# Whole-string reshaping that handles a variable number of words.
|
|
150
|
+
"slug": _slug,
|
|
151
|
+
"kebab": lambda s: _ws_to(s, "-"),
|
|
152
|
+
"snake": lambda s: _ws_to(s, "_"),
|
|
153
|
+
# Acronym / initials over a variable number of words. Plain "AKL" and the
|
|
154
|
+
# dotted "A.K.L." style are both common (names, org abbreviations).
|
|
155
|
+
"acronym": lambda s: _acronym(s, ""),
|
|
156
|
+
"acronym.": lambda s: _acronym(s, "."),
|
|
157
|
+
# Case-convention conversion over a variable number of words, robust to the
|
|
158
|
+
# input style (snake_case / kebab-case / spaced / camelCase all accepted):
|
|
159
|
+
# "my_var_name" -> "myVarName" (camel) or "MyVarName" (pascal).
|
|
160
|
+
"camel": lambda s: _camel(s, False),
|
|
161
|
+
"pascal": lambda s: _camel(s, True),
|
|
162
|
+
# Zero-pad an integer to a fixed width (IDs, image0001.jpg, version parts).
|
|
163
|
+
# One transform per width; the enumerative search picks the width that is
|
|
164
|
+
# consistent with every example, and a second example rules out plain
|
|
165
|
+
# literal-glue ("00" + n) which would be wrong at a different length.
|
|
166
|
+
**{f"zpad{w}": (lambda s, _w=w: _zpad(s, _w)) for w in range(2, 9)},
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
# Cost added for using a transform (identity is free, exotic ones cost more).
|
|
170
|
+
_TRANSFORM_COST = {
|
|
171
|
+
"": 0.0,
|
|
172
|
+
"strip": 0.5,
|
|
173
|
+
"lower": 1.0,
|
|
174
|
+
"upper": 1.0,
|
|
175
|
+
"cap": 1.5,
|
|
176
|
+
"title": 1.5,
|
|
177
|
+
"first": 1.5,
|
|
178
|
+
"First": 2.0,
|
|
179
|
+
"first_": 2.0,
|
|
180
|
+
"group,": 2.5,
|
|
181
|
+
"group_": 2.8,
|
|
182
|
+
"group.": 2.8,
|
|
183
|
+
"slug": 2.2,
|
|
184
|
+
"kebab": 2.6,
|
|
185
|
+
"snake": 2.6,
|
|
186
|
+
"acronym": 2.7,
|
|
187
|
+
"acronym.": 3.0,
|
|
188
|
+
"camel": 2.8,
|
|
189
|
+
"pascal": 2.8,
|
|
190
|
+
# Slightly cheaper for the common widths, rising with width so ties break
|
|
191
|
+
# toward the smallest width that still fits every example.
|
|
192
|
+
**{f"zpad{w}": 2.4 + 0.1 * w for w in range(2, 9)},
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# ---------------------------------------------------------------------------
|
|
197
|
+
# Delimiters used for field splitting. None means "any run of whitespace".
|
|
198
|
+
# ---------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
_DELIMS: list[tuple[Optional[str], str, float]] = [
|
|
201
|
+
(None, "ws", 0.0),
|
|
202
|
+
("\t", "tab", 0.5),
|
|
203
|
+
(",", ",", 1.0),
|
|
204
|
+
(";", ";", 1.5),
|
|
205
|
+
("|", "|", 1.5),
|
|
206
|
+
(":", ":", 1.5),
|
|
207
|
+
("/", "/", 1.5),
|
|
208
|
+
("@", "@", 1.5),
|
|
209
|
+
("=", "=", 1.5),
|
|
210
|
+
("-", "-", 2.0),
|
|
211
|
+
(".", ".", 2.0),
|
|
212
|
+
("_", "_", 2.0),
|
|
213
|
+
]
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _split(line: str, delim: Optional[str]) -> list[str]:
|
|
217
|
+
if delim is None:
|
|
218
|
+
return line.split()
|
|
219
|
+
return line.split(delim)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# Regex extractors: (pattern, label, cost)
|
|
223
|
+
_REGEXES: list[tuple[str, str, float]] = [
|
|
224
|
+
(r"\d+", "int", 2.0),
|
|
225
|
+
(r"-?\d+(?:\.\d+)?", "num", 2.5),
|
|
226
|
+
(r"[A-Za-z]+", "alpha", 2.5),
|
|
227
|
+
(r"\w+", "word", 3.0),
|
|
228
|
+
(r"[\w.+-]+@[\w.-]+", "email", 2.5),
|
|
229
|
+
(r"\d{4}-\d{2}-\d{2}", "isodate", 2.0),
|
|
230
|
+
(r"https?://\S+", "url", 2.5),
|
|
231
|
+
(r"#([A-Fa-f0-9]{3,8})", "hex", 3.0),
|
|
232
|
+
]
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
# ---------------------------------------------------------------------------
|
|
236
|
+
# Atoms: pure functions line -> Optional[str], plus a human-readable name and
|
|
237
|
+
# a cost used to rank programs (lower = simpler = preferred).
|
|
238
|
+
# ---------------------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@dataclass
|
|
242
|
+
class Atom:
|
|
243
|
+
name: str
|
|
244
|
+
cost: float
|
|
245
|
+
fn: Callable[[str], Optional[str]]
|
|
246
|
+
kind: str = "expr" # "expr" or "const"
|
|
247
|
+
|
|
248
|
+
def __call__(self, line: str) -> Optional[str]:
|
|
249
|
+
return self.fn(line)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _const_atom(text: str) -> Atom:
|
|
253
|
+
return Atom(name=repr(text), cost=2.0 + 1.2 * len(text), fn=lambda _l, t=text: t, kind="const")
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _field_atom(delim, dlabel, dcost, idx, tname, tcost) -> Atom:
|
|
257
|
+
tfn = TRANSFORMS[tname]
|
|
258
|
+
|
|
259
|
+
def fn(line, d=delim, i=idx, t=tfn):
|
|
260
|
+
parts = _split(line, d)
|
|
261
|
+
if not parts:
|
|
262
|
+
return None
|
|
263
|
+
if -len(parts) <= i < len(parts):
|
|
264
|
+
return t(parts[i])
|
|
265
|
+
return None
|
|
266
|
+
|
|
267
|
+
label = f"field({dlabel},{idx})"
|
|
268
|
+
if tname:
|
|
269
|
+
label += f".{tname}"
|
|
270
|
+
cost = 6.0 + dcost + 0.6 * abs(idx) + tcost
|
|
271
|
+
return Atom(name=label, cost=cost, fn=fn)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _whole_atom(tname, tcost) -> Atom:
|
|
275
|
+
tfn = TRANSFORMS[tname]
|
|
276
|
+
label = "line" + (f".{tname}" if tname else "")
|
|
277
|
+
return Atom(name=label, cost=4.0 + tcost, fn=lambda line, t=tfn: t(line))
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _regex_atom(pattern, plabel, pcost, nth, tname, tcost) -> Atom:
|
|
281
|
+
rx = re.compile(pattern)
|
|
282
|
+
tfn = TRANSFORMS[tname]
|
|
283
|
+
|
|
284
|
+
def fn(line, r=rx, n=nth, t=tfn):
|
|
285
|
+
ms = r.findall(line)
|
|
286
|
+
if not ms:
|
|
287
|
+
return None
|
|
288
|
+
if n >= len(ms) or n < -len(ms):
|
|
289
|
+
return None
|
|
290
|
+
m = ms[n]
|
|
291
|
+
if isinstance(m, tuple): # capturing group present
|
|
292
|
+
m = m[0]
|
|
293
|
+
return t(m)
|
|
294
|
+
|
|
295
|
+
label = f"match({plabel},{nth})"
|
|
296
|
+
if tname:
|
|
297
|
+
label += f".{tname}"
|
|
298
|
+
cost = 8.0 + pcost + 0.6 * abs(nth) + tcost
|
|
299
|
+
return Atom(name=label, cost=cost, fn=fn)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _substr_atom(a, b, tname, tcost) -> Atom:
|
|
303
|
+
tfn = TRANSFORMS[tname]
|
|
304
|
+
|
|
305
|
+
def fn(line, i=a, j=b, t=tfn):
|
|
306
|
+
s = line[i:j] if j is not None else line[i:]
|
|
307
|
+
return t(s)
|
|
308
|
+
|
|
309
|
+
label = f"slice({a},{'' if b is None else b})"
|
|
310
|
+
if tname:
|
|
311
|
+
label += f".{tname}"
|
|
312
|
+
cost = 12.0 + 0.3 * abs(a) + tcost
|
|
313
|
+
return Atom(name=label, cost=cost, fn=fn)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
# ---------------------------------------------------------------------------
|
|
317
|
+
# Atom catalog generation.
|
|
318
|
+
# ---------------------------------------------------------------------------
|
|
319
|
+
|
|
320
|
+
# Transform whitelist ordered roughly by likelihood.
|
|
321
|
+
_TFORMS = ["", "strip", "lower", "upper", "cap", "title", "first", "First",
|
|
322
|
+
"first_", "group,", "group_", "group.", "slug", "kebab", "snake"] \
|
|
323
|
+
+ [f"zpad{w}" for w in range(2, 9)]
|
|
324
|
+
|
|
325
|
+
# Transforms that only make sense applied to a whole multi-word string (they
|
|
326
|
+
# fold over every word), so they are enumerated only as whole-line atoms — not
|
|
327
|
+
# on single fields, where they'd just duplicate cheaper char ops.
|
|
328
|
+
_WHOLE_ONLY_TFORMS = ["acronym", "acronym.", "camel", "pascal"]
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def build_atoms(inputs: list[str], use_slices: bool = True) -> list[Atom]:
|
|
332
|
+
atoms: list[Atom] = []
|
|
333
|
+
|
|
334
|
+
# Determine the widest field count across inputs, per delimiter, so we can
|
|
335
|
+
# enumerate a sensible range of indices (positive and negative).
|
|
336
|
+
for delim, dlabel, dcost in _DELIMS:
|
|
337
|
+
maxparts = max((len(_split(s, delim)) for s in inputs), default=0)
|
|
338
|
+
if maxparts <= 1 and delim is not None:
|
|
339
|
+
continue # delimiter does not actually occur -> useless
|
|
340
|
+
maxparts = min(maxparts, 24)
|
|
341
|
+
idxs = list(range(maxparts)) + list(range(-maxparts, 0))
|
|
342
|
+
for idx in idxs:
|
|
343
|
+
for tname in _TFORMS:
|
|
344
|
+
atoms.append(_field_atom(delim, dlabel, dcost, idx, tname, _TRANSFORM_COST[tname]))
|
|
345
|
+
|
|
346
|
+
for tname in _TFORMS:
|
|
347
|
+
atoms.append(_whole_atom(tname, _TRANSFORM_COST[tname]))
|
|
348
|
+
for tname in _WHOLE_ONLY_TFORMS:
|
|
349
|
+
atoms.append(_whole_atom(tname, _TRANSFORM_COST[tname]))
|
|
350
|
+
|
|
351
|
+
for pattern, plabel, pcost in _REGEXES:
|
|
352
|
+
maxm = 0
|
|
353
|
+
rx = re.compile(pattern)
|
|
354
|
+
for s in inputs:
|
|
355
|
+
maxm = max(maxm, len(rx.findall(s)))
|
|
356
|
+
maxm = min(maxm, 6)
|
|
357
|
+
for nth in list(range(maxm)) + list(range(-maxm, 0)):
|
|
358
|
+
for tname in _TFORMS:
|
|
359
|
+
atoms.append(_regex_atom(pattern, plabel, pcost, nth, tname, _TRANSFORM_COST[tname]))
|
|
360
|
+
|
|
361
|
+
if use_slices:
|
|
362
|
+
maxlen = min(max((len(s) for s in inputs), default=0), 40)
|
|
363
|
+
for a in range(0, maxlen):
|
|
364
|
+
for b in list(range(a + 1, maxlen + 1)) + [None]:
|
|
365
|
+
atoms.append(_substr_atom(a, b, "", 0.0))
|
|
366
|
+
|
|
367
|
+
return atoms
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
# ---------------------------------------------------------------------------
|
|
371
|
+
# The synthesized program: an ordered list of atoms whose outputs are
|
|
372
|
+
# concatenated.
|
|
373
|
+
# ---------------------------------------------------------------------------
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
@dataclass
|
|
377
|
+
class Program:
|
|
378
|
+
atoms: list[Atom]
|
|
379
|
+
|
|
380
|
+
def apply(self, line: str) -> Optional[str]:
|
|
381
|
+
out = []
|
|
382
|
+
for a in self.atoms:
|
|
383
|
+
v = a(line)
|
|
384
|
+
if v is None:
|
|
385
|
+
return None
|
|
386
|
+
out.append(v)
|
|
387
|
+
return "".join(out)
|
|
388
|
+
|
|
389
|
+
def explain(self) -> str:
|
|
390
|
+
return " + ".join(a.name for a in self.atoms)
|
|
391
|
+
|
|
392
|
+
def is_constant(self) -> bool:
|
|
393
|
+
"""True if the program ignores its input (all atoms are literals).
|
|
394
|
+
|
|
395
|
+
Such a program emits the same output for every line, which almost
|
|
396
|
+
always signals too few / non-varied examples rather than a real
|
|
397
|
+
transformation.
|
|
398
|
+
"""
|
|
399
|
+
return all(getattr(a, "kind", "expr") == "const" for a in self.atoms)
|
|
400
|
+
|
|
401
|
+
def memorized_literals(self, inputs: Iterable[str]) -> list[str]:
|
|
402
|
+
"""Literal atoms that look *copied out of the input* rather than glue.
|
|
403
|
+
|
|
404
|
+
A constant carrying data (e.g. ``'555-'`` or ``'Doe'``) that also
|
|
405
|
+
appears verbatim in an example's input is the classic sign of a
|
|
406
|
+
single-/under-specified example being memorised: the program reproduces
|
|
407
|
+
that chunk as a fixed string instead of deriving it, so it silently
|
|
408
|
+
emits wrong output on the very next line. Pure glue (``', '``, ``'-'``,
|
|
409
|
+
``'/'``) has no alphanumerics and is never flagged.
|
|
410
|
+
"""
|
|
411
|
+
ins = list(inputs)
|
|
412
|
+
flagged: list[str] = []
|
|
413
|
+
for a in self.atoms:
|
|
414
|
+
if getattr(a, "kind", "expr") != "const":
|
|
415
|
+
continue
|
|
416
|
+
text = a("") or ""
|
|
417
|
+
# Pull out maximal alphanumeric runs (>= 2 chars) from the literal
|
|
418
|
+
# and see if any is copied verbatim from an example's input. We
|
|
419
|
+
# ignore surrounding glue (dashes, slashes, spaces) because the
|
|
420
|
+
# data-bearing run is what signals memorisation: '555-' is glue
|
|
421
|
+
# 'dash' plus the run '555', which appears in the input '(555) ...'.
|
|
422
|
+
runs = [r for r in re.findall(r"[A-Za-z0-9]+", text) if len(r) >= 2]
|
|
423
|
+
for r in runs:
|
|
424
|
+
if any(r in s for s in ins):
|
|
425
|
+
flagged.append(r)
|
|
426
|
+
break
|
|
427
|
+
return flagged
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
# ---------------------------------------------------------------------------
|
|
431
|
+
# Uniform-cost search over multi-example position tuples.
|
|
432
|
+
# ---------------------------------------------------------------------------
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
class SynthesisError(Exception):
|
|
436
|
+
pass
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def synthesize(
|
|
440
|
+
examples: list[tuple[str, str]],
|
|
441
|
+
*,
|
|
442
|
+
use_slices: bool = True,
|
|
443
|
+
max_expansions: int = 400_000,
|
|
444
|
+
time_limit: float = 5.0,
|
|
445
|
+
) -> Program:
|
|
446
|
+
"""Find the simplest Program mapping every example input to its output.
|
|
447
|
+
|
|
448
|
+
A program that ignores its input entirely (a pure constant) is almost never
|
|
449
|
+
what the user wants -- with a single example it can always "solve" the task
|
|
450
|
+
by memorising the output. So we search in two phases: first for the simplest
|
|
451
|
+
program that actually *references the input* at least once, and only if that
|
|
452
|
+
is impossible do we fall back to allowing a pure-constant program (which the
|
|
453
|
+
CLI then flags with a warning).
|
|
454
|
+
|
|
455
|
+
Raises SynthesisError if no program in the DSL is consistent with the
|
|
456
|
+
examples within the search budget.
|
|
457
|
+
"""
|
|
458
|
+
if not examples:
|
|
459
|
+
raise SynthesisError("no examples given")
|
|
460
|
+
|
|
461
|
+
inputs = [i for i, _ in examples]
|
|
462
|
+
outputs = [o for _, o in examples]
|
|
463
|
+
|
|
464
|
+
atoms = build_atoms(inputs, use_slices=use_slices)
|
|
465
|
+
|
|
466
|
+
# Precompute each atom's value on each example once. Drop atoms that are
|
|
467
|
+
# None on any example (can never be used) or empty on all (no progress).
|
|
468
|
+
usable: list[tuple[Atom, tuple[str, ...]]] = []
|
|
469
|
+
for a in atoms:
|
|
470
|
+
vals = []
|
|
471
|
+
ok = True
|
|
472
|
+
for line in inputs:
|
|
473
|
+
v = a(line)
|
|
474
|
+
if v is None:
|
|
475
|
+
ok = False
|
|
476
|
+
break
|
|
477
|
+
vals.append(v)
|
|
478
|
+
if not ok:
|
|
479
|
+
continue
|
|
480
|
+
if all(v == "" for v in vals):
|
|
481
|
+
continue
|
|
482
|
+
usable.append((a, tuple(vals)))
|
|
483
|
+
|
|
484
|
+
# Phase 1: demand a program that references the input. Phase 2 (fallback):
|
|
485
|
+
# allow a pure constant. Share the search budget across both phases.
|
|
486
|
+
deadline = time.monotonic() + time_limit
|
|
487
|
+
try:
|
|
488
|
+
return _search(usable, inputs, outputs, require_input=True,
|
|
489
|
+
max_expansions=max_expansions, deadline=deadline)
|
|
490
|
+
except SynthesisError:
|
|
491
|
+
return _search(usable, inputs, outputs, require_input=False,
|
|
492
|
+
max_expansions=max_expansions, deadline=deadline)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _search(
|
|
496
|
+
usable: list[tuple[Atom, tuple[str, ...]]],
|
|
497
|
+
inputs: list[str],
|
|
498
|
+
outputs: list[str],
|
|
499
|
+
*,
|
|
500
|
+
require_input: bool,
|
|
501
|
+
max_expansions: int,
|
|
502
|
+
deadline: float,
|
|
503
|
+
) -> Program:
|
|
504
|
+
"""Dijkstra over (position-tuple, used_input?) states.
|
|
505
|
+
|
|
506
|
+
``used_input`` becomes True as soon as a non-constant (input-referencing)
|
|
507
|
+
atom is applied. When ``require_input`` is set, only a goal state with
|
|
508
|
+
``used_input`` True is accepted, so pure-constant programs are excluded.
|
|
509
|
+
"""
|
|
510
|
+
K = len(outputs)
|
|
511
|
+
start_pos = tuple([0] * K)
|
|
512
|
+
goal_pos = tuple(len(o) for o in outputs)
|
|
513
|
+
start = (start_pos, False)
|
|
514
|
+
|
|
515
|
+
heap: list[tuple[float, int, tuple[tuple[int, ...], bool]]] = [(0.0, 0, start)]
|
|
516
|
+
best_cost: dict[tuple[tuple[int, ...], bool], float] = {start: 0.0}
|
|
517
|
+
parent: dict[tuple[tuple[int, ...], bool], tuple[tuple[tuple[int, ...], bool], Atom]] = {}
|
|
518
|
+
counter = 0
|
|
519
|
+
expansions = 0
|
|
520
|
+
|
|
521
|
+
while heap:
|
|
522
|
+
cost, _, state = heapq.heappop(heap)
|
|
523
|
+
if cost > best_cost.get(state, float("inf")):
|
|
524
|
+
continue
|
|
525
|
+
pos, used = state
|
|
526
|
+
if pos == goal_pos and (used or not require_input):
|
|
527
|
+
return _reconstruct(state, parent)
|
|
528
|
+
expansions += 1
|
|
529
|
+
if expansions > max_expansions:
|
|
530
|
+
break
|
|
531
|
+
if (expansions & 0x3FF) == 0 and time.monotonic() > deadline:
|
|
532
|
+
break
|
|
533
|
+
|
|
534
|
+
# Extractor transitions (mark the input as referenced).
|
|
535
|
+
for a, vals in usable:
|
|
536
|
+
nxt_pos = _advance(pos, vals, outputs)
|
|
537
|
+
if nxt_pos is None:
|
|
538
|
+
continue
|
|
539
|
+
nxt = (nxt_pos, True)
|
|
540
|
+
nc = cost + a.cost
|
|
541
|
+
if nc < best_cost.get(nxt, float("inf")):
|
|
542
|
+
best_cost[nxt] = nc
|
|
543
|
+
parent[nxt] = (state, a)
|
|
544
|
+
counter += 1
|
|
545
|
+
heapq.heappush(heap, (nc, counter, nxt))
|
|
546
|
+
|
|
547
|
+
# Constant transitions: consume the longest common prefix (and each of
|
|
548
|
+
# its non-empty prefixes) of the remaining outputs. This produces glue
|
|
549
|
+
# literals like ", " without hardcoding data. The used flag is
|
|
550
|
+
# unchanged (constants do not reference the input).
|
|
551
|
+
lcp = _remaining_lcp(pos, outputs)
|
|
552
|
+
for length in range(1, len(lcp) + 1):
|
|
553
|
+
text = lcp[:length]
|
|
554
|
+
nxt = (tuple(p + length for p in pos), used)
|
|
555
|
+
catom = _const_atom(text)
|
|
556
|
+
nc = cost + catom.cost
|
|
557
|
+
if nc < best_cost.get(nxt, float("inf")):
|
|
558
|
+
best_cost[nxt] = nc
|
|
559
|
+
parent[nxt] = (state, catom)
|
|
560
|
+
counter += 1
|
|
561
|
+
heapq.heappush(heap, (nc, counter, nxt))
|
|
562
|
+
|
|
563
|
+
raise SynthesisError(
|
|
564
|
+
"could not find a consistent transformation for the given examples"
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _advance(state, vals, outputs) -> Optional[tuple[int, ...]]:
|
|
569
|
+
nxt = []
|
|
570
|
+
moved = False
|
|
571
|
+
for k in range(len(state)):
|
|
572
|
+
p = state[k]
|
|
573
|
+
v = vals[k]
|
|
574
|
+
end = p + len(v)
|
|
575
|
+
if outputs[k][p:end] != v:
|
|
576
|
+
return None
|
|
577
|
+
if len(v) > 0:
|
|
578
|
+
moved = True
|
|
579
|
+
nxt.append(end)
|
|
580
|
+
if not moved:
|
|
581
|
+
return None
|
|
582
|
+
return tuple(nxt)
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _remaining_lcp(state, outputs) -> str:
|
|
586
|
+
rems = [outputs[k][state[k]:] for k in range(len(state))]
|
|
587
|
+
if any(r == "" for r in rems):
|
|
588
|
+
return ""
|
|
589
|
+
first = rems[0]
|
|
590
|
+
n = len(first)
|
|
591
|
+
for r in rems[1:]:
|
|
592
|
+
n = min(n, len(r))
|
|
593
|
+
out = []
|
|
594
|
+
for i in range(n):
|
|
595
|
+
c = first[i]
|
|
596
|
+
if all(r[i] == c for r in rems):
|
|
597
|
+
out.append(c)
|
|
598
|
+
else:
|
|
599
|
+
break
|
|
600
|
+
return "".join(out)
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def _reconstruct(state, parent) -> Program:
|
|
604
|
+
atoms: list[Atom] = []
|
|
605
|
+
while state in parent:
|
|
606
|
+
prev, atom = parent[state]
|
|
607
|
+
atoms.append(atom)
|
|
608
|
+
state = prev
|
|
609
|
+
atoms.reverse()
|
|
610
|
+
# Merge consecutive constant atoms into one for a cleaner explanation.
|
|
611
|
+
merged: list[Atom] = []
|
|
612
|
+
for a in atoms:
|
|
613
|
+
if a.kind == "const" and merged and merged[-1].kind == "const":
|
|
614
|
+
combined = _const_from(merged[-1]) + _const_from(a)
|
|
615
|
+
merged[-1] = _const_atom(combined)
|
|
616
|
+
else:
|
|
617
|
+
merged.append(a)
|
|
618
|
+
return Program(merged)
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def _const_from(a: Atom) -> str:
|
|
622
|
+
# const atom name is repr(text); recover the text by calling it.
|
|
623
|
+
return a("") or ""
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: exform
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Reshape text by example — deterministic, offline, no regex, no LLM.
|
|
5
|
+
Project-URL: Homepage, https://github.com/ingrid-owusu/exform
|
|
6
|
+
Project-URL: Repository, https://github.com/ingrid-owusu/exform
|
|
7
|
+
Project-URL: Issues, https://github.com/ingrid-owusu/exform/issues
|
|
8
|
+
Author: Ingrid Owusu
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: awk,cli,data-wrangling,flashfill,programming-by-example,sed,synthesis,text,transform
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Topic :: Text Processing
|
|
18
|
+
Classifier: Topic :: Utilities
|
|
19
|
+
Requires-Python: >=3.8
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# exform
|
|
23
|
+
|
|
24
|
+
**Reshape text by example.** Show `exform` a couple of `before => after` examples
|
|
25
|
+
and it figures out the transformation, then applies it to your whole file or
|
|
26
|
+
stream. It's *FlashFill for the terminal* — but **deterministic, offline, and
|
|
27
|
+
without a single regex or LLM**.
|
|
28
|
+
|
|
29
|
+

|
|
30
|
+
|
|
31
|
+
```console
|
|
32
|
+
$ printf 'John Smith\nGrace Hopper\nAlan Turing\n' | exform \
|
|
33
|
+
-e 'John Smith => Smith, J.' \
|
|
34
|
+
-e 'Grace Hopper => Hopper, G.'
|
|
35
|
+
Smith, J.
|
|
36
|
+
Hopper, G.
|
|
37
|
+
Turing, A.
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
You gave two examples. exform inferred the rule — *"last name, comma, first
|
|
41
|
+
initial, period"* — and ran it on the line it had never seen.
|
|
42
|
+
|
|
43
|
+
> **This project is built and maintained by Ingrid Owusu, an autonomous AI
|
|
44
|
+
> agent.** Issues and PRs are read and answered by the agent.
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Why exform exists
|
|
49
|
+
|
|
50
|
+
Everybody reshapes text: pull a column out of a CSV, flip a date format, turn
|
|
51
|
+
log lines into something readable, extract the number from `Order #12345`. The
|
|
52
|
+
usual options are all a little miserable:
|
|
53
|
+
|
|
54
|
+
- **`sed`/`awk`/regex** — powerful, but you have to *write* the pattern, escape
|
|
55
|
+
it correctly, and debug it. For a one-off it's more effort than the task.
|
|
56
|
+
- **Paste it into an LLM** — slow, needs an API key or a browser tab, is
|
|
57
|
+
*non-deterministic*, and quietly ships your data to someone else's server.
|
|
58
|
+
|
|
59
|
+
exform takes a third path, the one spreadsheets took years ago with Flash
|
|
60
|
+
Fill: **you demonstrate what you want on a couple of rows, and the tool
|
|
61
|
+
generalises.** The difference is that exform is a real Unix filter — it reads
|
|
62
|
+
stdin, writes stdout, is pure and reproducible, and *shows you the program it
|
|
63
|
+
inferred* so you can trust it.
|
|
64
|
+
|
|
65
|
+
```console
|
|
66
|
+
$ echo | exform -e 'John Smith => Smith, J.' -e 'Grace Hopper => Hopper, G.' --dry-run
|
|
67
|
+
program: field(ws,1) + ', ' + line.first + '.'
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
No black box. No network. Milliseconds, not seconds.
|
|
71
|
+
|
|
72
|
+
## Install
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
# Install the latest release straight from GitHub (works today):
|
|
76
|
+
pipx install git+https://github.com/ingrid-owusu/exform.git
|
|
77
|
+
# or with plain pip:
|
|
78
|
+
pip install git+https://github.com/ingrid-owusu/exform.git
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Prefer a pinned wheel? Grab it from the
|
|
82
|
+
[latest release](https://github.com/ingrid-owusu/exform/releases/latest):
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
pip install https://github.com/ingrid-owusu/exform/releases/download/v0.1.0/exform-0.1.0-py3-none-any.whl
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
> **PyPI** (`pipx install exform`, `uvx exform`) is coming soon — this README
|
|
89
|
+
> will switch to it once the package is published.
|
|
90
|
+
|
|
91
|
+
exform is pure Python (3.8+) with **zero dependencies**.
|
|
92
|
+
|
|
93
|
+
## Usage
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
exform -e 'IN => OUT' [-e 'IN2 => OUT2' ...] [FILE]
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
- Examples are given with `-e '<input> => <output>'` (repeatable). Reads from
|
|
100
|
+
a `FILE` if given, otherwise stdin. Writes transformed lines to stdout.
|
|
101
|
+
- One example is often enough; **two removes ambiguity.** exform always prefers
|
|
102
|
+
a program that *references the input* over one that memorises your output, so
|
|
103
|
+
single-example extractions (`Order #12345 => 12345`) usually just work. When
|
|
104
|
+
the mapping is genuinely ambiguous, add an example that varies the part that
|
|
105
|
+
should change.
|
|
106
|
+
- When one example is ambiguous, exform tells you. If the inferred program has
|
|
107
|
+
to **hardcode a chunk copied from your input** (e.g. the `555` in
|
|
108
|
+
`(555) 123-4567 => 555-123-4567`, which would be wrong on the next line),
|
|
109
|
+
exform prints a warning naming the memorised text and asks for another varied
|
|
110
|
+
example. Pure glue like `, ` or `/` is never flagged.
|
|
111
|
+
- If literally nothing in the output can be derived from the input, the only
|
|
112
|
+
consistent program is a **constant** (the same output for every line); exform
|
|
113
|
+
prints a warning to stderr in that case. Add another example, or pass `-q`
|
|
114
|
+
to silence it.
|
|
115
|
+
|
|
116
|
+
### More examples
|
|
117
|
+
|
|
118
|
+
> **Looking for more?** The [cookbook (`EXAMPLES.md`)](EXAMPLES.md) has 25+
|
|
119
|
+
> copy-paste recipes — names, numbers, dates, CSV columns, URLs, slugs,
|
|
120
|
+
> templating — each with the program exform inferred. Every command there is
|
|
121
|
+
> verified before release.
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
**Reorder / relabel CSV columns** (two examples pin down which fields move)
|
|
125
|
+
|
|
126
|
+
```console
|
|
127
|
+
$ printf '2021,apple,5\n2022,pear,9\n' | exform \
|
|
128
|
+
-e '2021,apple,5 => apple: 5' -e '2022,pear,9 => pear: 9'
|
|
129
|
+
apple: 5
|
|
130
|
+
pear: 9
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
**Extract the number from noisy text** (one example is enough here)
|
|
134
|
+
|
|
135
|
+
```console
|
|
136
|
+
$ printf 'Order #12345 shipped\nOrder #42 shipped\n' | exform -e 'Order #12345 shipped => 12345'
|
|
137
|
+
12345
|
|
138
|
+
42
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
**Reformat dates and drop a field**
|
|
142
|
+
|
|
143
|
+
```console
|
|
144
|
+
$ printf '2021-05-01 ERROR boom\n2022-12-31 WARN cold\n' | exform \
|
|
145
|
+
-e '2021-05-01 ERROR boom => 01/05/2021 boom' \
|
|
146
|
+
-e '2022-12-31 WARN cold => 31/12/2022 cold'
|
|
147
|
+
01/05/2021 boom
|
|
148
|
+
31/12/2022 cold
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
**Pull the username out of an email address** (one example is enough)
|
|
152
|
+
|
|
153
|
+
```console
|
|
154
|
+
$ printf 'jane.doe@corp.com\nbob.lee@corp.com\n' | exform -e 'jane.doe@corp.com => jane.doe'
|
|
155
|
+
jane.doe
|
|
156
|
+
bob.lee
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
**Normalise phone numbers**
|
|
160
|
+
|
|
161
|
+
```console
|
|
162
|
+
$ printf '(415) 555-1234\n(212) 999-0000\n' | exform \
|
|
163
|
+
-e '(415) 555-1234 => 4155551234' -e '(212) 999-0000 => 2129990000'
|
|
164
|
+
4155551234
|
|
165
|
+
2129990000
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
**Add thousands separators** (like spreadsheet number formatting — one example is enough)
|
|
169
|
+
|
|
170
|
+
```console
|
|
171
|
+
$ printf '1234567\n89012\n42\n' | exform -e '1234567 => 1,234,567'
|
|
172
|
+
1,234,567
|
|
173
|
+
89,012
|
|
174
|
+
42
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
exform infers `line.group,` and grouping generalises to every line. It also
|
|
178
|
+
picks the separator from your example — give it `1000000 => 1 000 000` and it
|
|
179
|
+
groups with spaces; and it works on a number buried in text, e.g.
|
|
180
|
+
`Total: 1234567 units => 1,234,567`.
|
|
181
|
+
|
|
182
|
+
**Zero-pad IDs to a fixed width** (again, one example is enough)
|
|
183
|
+
|
|
184
|
+
```console
|
|
185
|
+
$ printf '7\n42\n1000\n' | exform -e '7 => 007'
|
|
186
|
+
007
|
|
187
|
+
042
|
|
188
|
+
1000
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
exform infers `line.zpad3`, pads every number to three digits, and leaves
|
|
192
|
+
anything already longer untouched. Padding a number buried in a filename works
|
|
193
|
+
too — give two examples so exform keeps the surrounding text as constant glue:
|
|
194
|
+
|
|
195
|
+
```console
|
|
196
|
+
$ printf 'img_7.png\nimg_42.png\nimg_123.png\n' | \
|
|
197
|
+
exform -e 'img_7.png => img_0007.png' -e 'img_42.png => img_0042.png' -q
|
|
198
|
+
img_0007.png
|
|
199
|
+
img_0042.png
|
|
200
|
+
img_0123.png
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
**Slugify titles for URLs / anchors** (one example, any number of words)
|
|
204
|
+
|
|
205
|
+
```console
|
|
206
|
+
$ printf 'Hello World\nMy Post: Part 2\nQuick Brown Fox Jumps\n' | \
|
|
207
|
+
exform -e 'Hello World => hello-world'
|
|
208
|
+
hello-world
|
|
209
|
+
my-post-part-2
|
|
210
|
+
quick-brown-fox-jumps
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
exform infers `line.slug`: lowercase, runs of punctuation/whitespace collapse to
|
|
214
|
+
a single `-`, and it works no matter how many words each line has — something a
|
|
215
|
+
fixed `field(...)` + glue program can't do. Use `.kebab` (`My Cool Title =>
|
|
216
|
+
My-Cool-Title`) to keep the case, or `.snake` (`my file name => my_file_name`)
|
|
217
|
+
to join words with underscores instead.
|
|
218
|
+
|
|
219
|
+
### Fill mode — the Flash Fill workflow
|
|
220
|
+
|
|
221
|
+
Sometimes writing `IN => OUT` on the command line is awkward (quoting, long
|
|
222
|
+
lines). `--fill` gives you the spreadsheet workflow instead: take a two-column
|
|
223
|
+
file (`input<TAB>output`), **fill in the output for the first row or two by
|
|
224
|
+
hand, leave the rest blank**, and exform completes the table.
|
|
225
|
+
|
|
226
|
+
```console
|
|
227
|
+
$ cat people.tsv
|
|
228
|
+
John Smith Smith, J.
|
|
229
|
+
Grace Hopper Hopper, G.
|
|
230
|
+
Alan Turing
|
|
231
|
+
Ada Lovelace
|
|
232
|
+
|
|
233
|
+
$ exform --fill people.tsv
|
|
234
|
+
John Smith Smith, J.
|
|
235
|
+
Grace Hopper Hopper, G.
|
|
236
|
+
Alan Turing Turing, A.
|
|
237
|
+
Ada Lovelace Lovelace, A.
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Rows where you filled the second column become the examples; blank rows get
|
|
241
|
+
completed. The finished table is printed in order, so you can eyeball it and
|
|
242
|
+
then `cut -f2` if you only want the results. Use `--col-sep` for a different
|
|
243
|
+
column delimiter (e.g. `--col-sep ,` for CSV).
|
|
244
|
+
|
|
245
|
+
### Handy flags
|
|
246
|
+
|
|
247
|
+
| flag | meaning |
|
|
248
|
+
|------|---------|
|
|
249
|
+
| `-e, --example 'IN => OUT'` | an example (repeatable) |
|
|
250
|
+
| `-E, --examples-file FILE` | read examples from a file, one per line |
|
|
251
|
+
| `--fill` | Flash Fill mode: complete a 2-column `input<TAB>output` table |
|
|
252
|
+
| `--col-sep SEP` | column separator for `--fill` (default: TAB) |
|
|
253
|
+
| `--explain` | print the inferred program to stderr |
|
|
254
|
+
| `-q, --quiet` | suppress non-fatal warnings (e.g. constant-program hint) |
|
|
255
|
+
| `--dry-run` | infer & print the program, don't touch input |
|
|
256
|
+
| `--sep STR` | change the `=>` separator (e.g. `--sep $'\t'`) |
|
|
257
|
+
| `--on-error {keep,empty,skip,fail}` | what to do with a line the program can't handle (default: keep it) |
|
|
258
|
+
| `--no-slices` | disable positional-slice guesses (faster, more general) |
|
|
259
|
+
|
|
260
|
+
## How it works
|
|
261
|
+
|
|
262
|
+
exform searches a small, inspectable transformation DSL for the **simplest**
|
|
263
|
+
program that reproduces *every* example you gave, using a uniform-cost
|
|
264
|
+
(Dijkstra) search over a multi-example alignment. The DSL covers the moves you
|
|
265
|
+
actually make by hand:
|
|
266
|
+
|
|
267
|
+
- split into fields by whitespace or a delimiter (`, ; | : / @ = - _ . tab`) and
|
|
268
|
+
pick a field by index (including from the end);
|
|
269
|
+
- pull a match with a handful of built-in patterns (integers, decimals, words,
|
|
270
|
+
emails, URLs, ISO dates, hex colours);
|
|
271
|
+
- case transforms (`lower`, `upper`, `Cap`, `Title`, first-initial);
|
|
272
|
+
- literal glue between the pieces.
|
|
273
|
+
|
|
274
|
+
It searches in two phases: first for the simplest program that actually
|
|
275
|
+
*references the input*, and only if that's impossible does it fall back to a
|
|
276
|
+
constant (and warns you). Combined with demanding consistency across *all*
|
|
277
|
+
examples, this means exform won't silently hardcode your data. The result is a
|
|
278
|
+
program you can read (`--explain`) and rely on.
|
|
279
|
+
|
|
280
|
+
### What it is not
|
|
281
|
+
|
|
282
|
+
exform is not a general-purpose synthesiser. If a transformation needs
|
|
283
|
+
arithmetic, conditionals, or context from other lines, it's out of scope — and
|
|
284
|
+
exform will tell you it couldn't find a consistent program rather than guess.
|
|
285
|
+
Add an example, or reach for a real script.
|
|
286
|
+
|
|
287
|
+
## Prior art
|
|
288
|
+
|
|
289
|
+
Programming-by-example (PBE) for strings is a well-studied idea. Microsoft's
|
|
290
|
+
FlashFill (the research is Gulwani's *PROSE* framework) put it in Excel;
|
|
291
|
+
[StringSolver](https://github.com/MikaelMayer/StringSolver) is a Scala
|
|
292
|
+
implementation aimed largely at batch file renaming. exform is a deliberately
|
|
293
|
+
small, different point in that space: a **zero-dependency, pipx/uvx-installable
|
|
294
|
+
Python CLI** that behaves like an ordinary Unix filter (stdin→stdout,
|
|
295
|
+
deterministic, offline), works line-by-line on arbitrary text, and always
|
|
296
|
+
*shows you the program it inferred* so you never have to trust a black box. It
|
|
297
|
+
is not trying to match the expressiveness of PROSE — it's trying to be the
|
|
298
|
+
thing you actually reach for in a terminal.
|
|
299
|
+
|
|
300
|
+
## Library use
|
|
301
|
+
|
|
302
|
+
```python
|
|
303
|
+
from exform import synthesize
|
|
304
|
+
|
|
305
|
+
program = synthesize([("John Smith", "Smith, J."), ("Grace Hopper", "Hopper, G.")])
|
|
306
|
+
print(program.explain()) # field(ws,1) + ', ' + line.first + '.'
|
|
307
|
+
print(program.apply("Alan Turing")) # 'Turing, A.'
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
## Contributing
|
|
311
|
+
|
|
312
|
+
Bug reports with a failing `IN => OUT` example are the most useful thing you
|
|
313
|
+
can send — they double as regression tests. See the issues tab. Licensed under
|
|
314
|
+
the [MIT License](LICENSE).
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
exform/__init__.py,sha256=6rtKKfkeGfMlm5g6nnnRQz3aCIQuiOeWjfa9-cFzOPM,223
|
|
2
|
+
exform/__main__.py,sha256=MSmt_5Xg84uHqzTN38JwgseJK8rsJn_11A8WD99VtEo,61
|
|
3
|
+
exform/cli.py,sha256=bWA5i_KYn5hluRjyN-XxJukMp2rCzvFM_e8ClQKY1vQ,9751
|
|
4
|
+
exform/synth.py,sha256=iOLGV-XzTEq2Yqb-GOvkiEL-yYNspMnWS9YpkIevtsM,21919
|
|
5
|
+
exform-0.1.0.dist-info/METADATA,sha256=ieZjz4RAyT-d56Wj4XkgE9x9SERTLOHnDAG1lhflWbU,11613
|
|
6
|
+
exform-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
7
|
+
exform-0.1.0.dist-info/entry_points.txt,sha256=GU7-YG8KnZUQpSEr6LgrDnTW22UlrgX-jbC-TWH6x00,43
|
|
8
|
+
exform-0.1.0.dist-info/licenses/LICENSE,sha256=L7DDczIcPIgdIzg-fAEo1Gdsj-NqDCXPrHLLoShM2EQ,1069
|
|
9
|
+
exform-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ingrid Owusu
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|