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 ADDED
@@ -0,0 +1,6 @@
1
+ """exform — reshape text by example (deterministic, offline, no LLM)."""
2
+
3
+ from .synth import Program, SynthesisError, synthesize
4
+
5
+ __version__ = "0.1.0"
6
+ __all__ = ["synthesize", "Program", "SynthesisError", "__version__"]
exform/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
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
+ ![exform demo: two examples in, the whole stream reshaped](assets/demo.svg)
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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ exform = exform.cli:main
@@ -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.