typeling 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.
- typeling/__init__.py +6 -0
- typeling/__main__.py +4 -0
- typeling/demo.py +300 -0
- typeling/en.bin +0 -0
- typeling/keyboard.py +43 -0
- typeling/lm.py +370 -0
- typeling/model.py +209 -0
- typeling/predictor.py +158 -0
- typeling/text.py +66 -0
- typeling/user.py +87 -0
- typeling-0.1.0.dist-info/METADATA +281 -0
- typeling-0.1.0.dist-info/RECORD +17 -0
- typeling-0.1.0.dist-info/WHEEL +5 -0
- typeling-0.1.0.dist-info/entry_points.txt +2 -0
- typeling-0.1.0.dist-info/licenses/LICENSE +202 -0
- typeling-0.1.0.dist-info/licenses/NOTICE +46 -0
- typeling-0.1.0.dist-info/top_level.txt +1 -0
typeling/__init__.py
ADDED
typeling/__main__.py
ADDED
typeling/demo.py
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Terminal autocomplete demo: completes the word being typed and predicts the next word after a space, as grey ghost
|
|
3
|
+
text and in a candidate bar below the line.
|
|
4
|
+
|
|
5
|
+
python -m typeling # start typing; the grey text is the suggestion
|
|
6
|
+
python -m typeling --no-learn # do not learn or save your words
|
|
7
|
+
python -m typeling --lm # use Typeling-25M (pip install "typeling[lm]"); --lm DIR for another model
|
|
8
|
+
python -m typeling --bench # measure per-keystroke latency
|
|
9
|
+
echo I wan | python -m typeling # non-interactive: prints "I wan[t]"
|
|
10
|
+
|
|
11
|
+
Keys: Tab / Right accept the highlighted candidate (the first one, shown as ghost text when it completes the typed
|
|
12
|
+
letters; with the language model it may also correct them), Up / Down move the highlight, Esc hide, Backspace delete
|
|
13
|
+
a character, Ctrl+W delete a word, Enter new line, Ctrl+C quit.
|
|
14
|
+
Each line submitted with Enter is learned by the user model, which is stored only on this machine
|
|
15
|
+
(~/.typeling/user.json, shared by both models) and saved on exit.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
import shutil
|
|
22
|
+
import sys
|
|
23
|
+
import time
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from .predictor import Predictor
|
|
27
|
+
from .text import normalize, split_input
|
|
28
|
+
|
|
29
|
+
USER_FILE = Path.home() / ".typeling" / "user.json"
|
|
30
|
+
|
|
31
|
+
GREY, INVERT, RESET = "\x1b[90m", "\x1b[7m", "\x1b[0m"
|
|
32
|
+
PROMPT = "> "
|
|
33
|
+
ACCEPT_ADDS_SPACE = False # append a space after an accepted suggestion
|
|
34
|
+
CANDIDATES = 5 # shown in the bar (the first 3 are the ones the benchmark measures)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class LineEditor:
|
|
38
|
+
"""Pure state machine: consumes keys and returns the strings to draw; never touches the terminal."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, completer, candidates=CANDIDATES):
|
|
41
|
+
self.c = completer
|
|
42
|
+
self.n = candidates
|
|
43
|
+
self.text = ""
|
|
44
|
+
self.hidden = False # set by Esc; cleared by the next edit
|
|
45
|
+
self.sel = 0 # highlighted candidate
|
|
46
|
+
self._cache = (None, "", []) # (text, ghost, candidates): one inference per distinct text
|
|
47
|
+
self.latencies = []
|
|
48
|
+
|
|
49
|
+
def view(self):
|
|
50
|
+
"""-> (ghost text, [candidate words]) for the current text."""
|
|
51
|
+
if self.hidden:
|
|
52
|
+
return "", []
|
|
53
|
+
if self._cache[0] != self.text:
|
|
54
|
+
t0 = time.perf_counter()
|
|
55
|
+
cands = [w for w, _ in self.c.topk(self.text, self.n)] if self.n else []
|
|
56
|
+
g = self.c.suggest(self.text)
|
|
57
|
+
self.latencies.append(time.perf_counter() - t0)
|
|
58
|
+
self._cache = (self.text, g, cands)
|
|
59
|
+
return self._cache[1], self._cache[2]
|
|
60
|
+
|
|
61
|
+
def accept(self):
|
|
62
|
+
"""Replace the partly typed word with the highlighted candidate (or append the ghost text)."""
|
|
63
|
+
g, cands = self.view()
|
|
64
|
+
if self.sel == 0 and g:
|
|
65
|
+
self.text += g
|
|
66
|
+
elif cands:
|
|
67
|
+
sp = split_input(normalize(self.text))
|
|
68
|
+
partial = sp[1] if sp else ""
|
|
69
|
+
self.text = self.text[:len(self.text) - len(partial)] + cands[self.sel]
|
|
70
|
+
else:
|
|
71
|
+
return
|
|
72
|
+
self.text += " " if ACCEPT_ADDS_SPACE else ""
|
|
73
|
+
|
|
74
|
+
def feed(self, key):
|
|
75
|
+
"""Handle one key; return "enter", "quit" or None."""
|
|
76
|
+
if key is None:
|
|
77
|
+
return None
|
|
78
|
+
if key in ("UP", "DOWN"):
|
|
79
|
+
n = len(self.view()[1])
|
|
80
|
+
if n:
|
|
81
|
+
self.sel = (self.sel + (1 if key == "DOWN" else -1)) % n
|
|
82
|
+
return None
|
|
83
|
+
if key in ("TAB", "RIGHT"):
|
|
84
|
+
self.accept()
|
|
85
|
+
elif key == "BS":
|
|
86
|
+
self.text = self.text[:-1]
|
|
87
|
+
elif key == "DELWORD":
|
|
88
|
+
self.text = re.sub(r"\S*$", "", self.text.rstrip())
|
|
89
|
+
elif key == "ESC":
|
|
90
|
+
self.hidden = True
|
|
91
|
+
return None
|
|
92
|
+
elif key == "ENTER":
|
|
93
|
+
return "enter"
|
|
94
|
+
elif key == "QUIT":
|
|
95
|
+
return "quit"
|
|
96
|
+
elif len(key) == 1 and key.isprintable():
|
|
97
|
+
self.text += key
|
|
98
|
+
else:
|
|
99
|
+
return None
|
|
100
|
+
self.hidden = False
|
|
101
|
+
self.sel = 0
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
def render(self, width, show=True):
|
|
105
|
+
"""Two lines, the text with its ghost and the candidate bar; leaves the cursor after the text."""
|
|
106
|
+
g, cands = self.view() if show else ("", [])
|
|
107
|
+
avail = max(10, width - len(PROMPT) - 1)
|
|
108
|
+
g = g[: avail - 1]
|
|
109
|
+
room = avail - len(g)
|
|
110
|
+
vis = self.text if len(self.text) <= room else self.text[-room:]
|
|
111
|
+
s = "\r" + PROMPT + vis + "\x1b[K" + (f"{GREY}{g}{RESET}" if g else "")
|
|
112
|
+
bar, used = [], len(PROMPT)
|
|
113
|
+
for i, w in enumerate(cands):
|
|
114
|
+
item = f"{i + 1} {w}"
|
|
115
|
+
if used + len(item) + 2 > width - 1:
|
|
116
|
+
break
|
|
117
|
+
bar.append(f"{INVERT}{item}{RESET}" if i == self.sel else f"{GREY}{item}{RESET}")
|
|
118
|
+
used += len(item) + 2
|
|
119
|
+
return s + "\r\n\x1b[K" + " " * len(PROMPT) + " ".join(bar) + f"\x1b[1A\r\x1b[{len(PROMPT) + len(vis)}C"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# Raw keyboard input, per platform.
|
|
123
|
+
|
|
124
|
+
if os.name == "nt":
|
|
125
|
+
import ctypes
|
|
126
|
+
import msvcrt
|
|
127
|
+
|
|
128
|
+
_SPECIAL = {"M": "RIGHT", "K": "LEFT", "H": "UP", "P": "DOWN"}
|
|
129
|
+
_KEYS = {"\x08": "BS", "\x7f": "DELWORD", "\x17": "DELWORD", "\r": "ENTER",
|
|
130
|
+
"\t": "TAB", "\x1b": "ESC", "\x03": "QUIT", "\x04": "QUIT", "\x1a": "QUIT"}
|
|
131
|
+
|
|
132
|
+
def _read_one():
|
|
133
|
+
ch = msvcrt.getwch()
|
|
134
|
+
if ch in ("\x00", "\xe0"):
|
|
135
|
+
return _SPECIAL.get(msvcrt.getwch())
|
|
136
|
+
return _KEYS.get(ch, ch)
|
|
137
|
+
|
|
138
|
+
def read_keys():
|
|
139
|
+
"""Block for one key, then drain any queued keys so fast typing triggers a single redraw."""
|
|
140
|
+
keys = [_read_one()]
|
|
141
|
+
while msvcrt.kbhit():
|
|
142
|
+
keys.append(_read_one())
|
|
143
|
+
return keys
|
|
144
|
+
|
|
145
|
+
class raw_mode:
|
|
146
|
+
def __enter__(self):
|
|
147
|
+
# Enable ANSI escape sequences (colors, cursor movement) in this console.
|
|
148
|
+
k32 = ctypes.windll.kernel32
|
|
149
|
+
h = k32.GetStdHandle(-11)
|
|
150
|
+
mode = ctypes.c_uint32()
|
|
151
|
+
if k32.GetConsoleMode(h, ctypes.byref(mode)):
|
|
152
|
+
k32.SetConsoleMode(h, mode.value | 0x0004)
|
|
153
|
+
return self
|
|
154
|
+
|
|
155
|
+
def __exit__(self, *exc):
|
|
156
|
+
return False
|
|
157
|
+
|
|
158
|
+
else:
|
|
159
|
+
import termios
|
|
160
|
+
import tty
|
|
161
|
+
|
|
162
|
+
_ARROWS = {"A": "UP", "B": "DOWN", "C": "RIGHT", "D": "LEFT"}
|
|
163
|
+
_KEYS = {"\x7f": "BS", "\x08": "DELWORD", "\x17": "DELWORD", "\r": "ENTER",
|
|
164
|
+
"\n": "ENTER", "\t": "TAB", "\x03": "QUIT", "\x04": "QUIT"}
|
|
165
|
+
|
|
166
|
+
def read_keys():
|
|
167
|
+
data = os.read(sys.stdin.fileno(), 1024).decode("utf-8", "ignore")
|
|
168
|
+
keys, i = [], 0
|
|
169
|
+
while i < len(data):
|
|
170
|
+
if data[i] == "\x1b":
|
|
171
|
+
if data[i + 1 : i + 2] in ("[", "O") and i + 2 < len(data):
|
|
172
|
+
keys.append(_ARROWS.get(data[i + 2]))
|
|
173
|
+
i += 3
|
|
174
|
+
continue
|
|
175
|
+
keys.append("ESC")
|
|
176
|
+
else:
|
|
177
|
+
keys.append(_KEYS.get(data[i], data[i]))
|
|
178
|
+
i += 1
|
|
179
|
+
return keys
|
|
180
|
+
|
|
181
|
+
class raw_mode:
|
|
182
|
+
def __enter__(self):
|
|
183
|
+
self.fd = sys.stdin.fileno()
|
|
184
|
+
self.old = termios.tcgetattr(self.fd)
|
|
185
|
+
tty.setcbreak(self.fd)
|
|
186
|
+
return self
|
|
187
|
+
|
|
188
|
+
def __exit__(self, *exc):
|
|
189
|
+
termios.tcsetattr(self.fd, termios.TCSADRAIN, self.old)
|
|
190
|
+
return False
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _stats(latencies):
|
|
194
|
+
if not latencies:
|
|
195
|
+
return ""
|
|
196
|
+
us = sorted(x * 1e6 for x in latencies)
|
|
197
|
+
p99 = us[min(len(us) - 1, int(len(us) * 0.99))]
|
|
198
|
+
return f"{len(us)} suggestions: mean {sum(us) / len(us):.0f} µs, p99 {p99:.0f} µs, max {us[-1]:.0f} µs"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def load(args, learn=False):
|
|
202
|
+
"""-> (predictor, description) for the command-line options."""
|
|
203
|
+
user = USER_FILE if learn else None
|
|
204
|
+
note = f"learning your words ({USER_FILE})" if learn else "learning off"
|
|
205
|
+
if args.lm:
|
|
206
|
+
from .lm import LMPredictor # needs typeling[lm]
|
|
207
|
+
if args.threads:
|
|
208
|
+
import torch
|
|
209
|
+
torch.set_num_threads(args.threads)
|
|
210
|
+
return LMPredictor(args.lm, user, device=args.device), f"language model {args.lm} · {note}"
|
|
211
|
+
return Predictor(user=user), note
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def interactive(args):
|
|
215
|
+
learn = not args.no_learn
|
|
216
|
+
t0 = time.perf_counter()
|
|
217
|
+
p, note = load(args, learn)
|
|
218
|
+
ed = LineEditor(p, args.candidates)
|
|
219
|
+
load_ms = (time.perf_counter() - t0) * 1000
|
|
220
|
+
out = sys.stdout
|
|
221
|
+
width = lambda: shutil.get_terminal_size().columns
|
|
222
|
+
with raw_mode():
|
|
223
|
+
out.write(f"{GREY}Model loaded in {load_ms:.0f} ms · {note} · Tab/→ accept · ↑/↓ choose · Esc hide · "
|
|
224
|
+
f"Ctrl+W delete word · Enter new line · Ctrl+C quit{RESET}\r\n")
|
|
225
|
+
out.write(ed.render(width()))
|
|
226
|
+
out.flush()
|
|
227
|
+
try:
|
|
228
|
+
while True:
|
|
229
|
+
for k in read_keys():
|
|
230
|
+
r = ed.feed(k)
|
|
231
|
+
if r == "quit":
|
|
232
|
+
raise KeyboardInterrupt
|
|
233
|
+
if r == "enter":
|
|
234
|
+
out.write(ed.render(width(), show=False) + "\r\n")
|
|
235
|
+
if learn and ed.text.strip():
|
|
236
|
+
p.learn(ed.text)
|
|
237
|
+
ed.text = ""
|
|
238
|
+
out.write(ed.render(width()))
|
|
239
|
+
out.flush()
|
|
240
|
+
except KeyboardInterrupt:
|
|
241
|
+
out.write(ed.render(width(), show=False) + "\r\n")
|
|
242
|
+
if learn:
|
|
243
|
+
p.save()
|
|
244
|
+
print(_stats(ed.latencies))
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def pipe_mode(args):
|
|
248
|
+
c, _ = load(args)
|
|
249
|
+
for line in sys.stdin:
|
|
250
|
+
line = line.rstrip("\r\n")
|
|
251
|
+
g = c.suggest(line)
|
|
252
|
+
print(f"{line}[{g}]" if g else line)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def bench(args):
|
|
256
|
+
t0 = time.perf_counter()
|
|
257
|
+
c, note = load(args)
|
|
258
|
+
print(f"Model loaded in {(time.perf_counter() - t0) * 1000:.0f} ms ({note})")
|
|
259
|
+
text = ("I want to know whether this little model can keep up with really fast "
|
|
260
|
+
"typing because everyone expects instant suggestions while writing ") * 30
|
|
261
|
+
ed = LineEditor(c, args.candidates)
|
|
262
|
+
for ch in text: # type one key at a time
|
|
263
|
+
ed.feed(ch)
|
|
264
|
+
ed.view()
|
|
265
|
+
for _ in range(200): # then backspace one key at a time
|
|
266
|
+
ed.feed("BS")
|
|
267
|
+
ed.view()
|
|
268
|
+
print(_stats(ed.latencies))
|
|
269
|
+
for s in ["I wan", "th", "Hel", "WOR", "encyc", "(beaut", "I don", "xylo", "Thank you so ", "how are ",
|
|
270
|
+
"See you tomm"]:
|
|
271
|
+
g = c.suggest(s)
|
|
272
|
+
cands = ", ".join(w for w, _ in c.topk(s, args.candidates))
|
|
273
|
+
print(f" {s!r:>15} → {s}{GREY}{g}{RESET}" + ("" if g else " (no suggestion)") + f" [{cands}]")
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def main():
|
|
277
|
+
try:
|
|
278
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
279
|
+
except Exception:
|
|
280
|
+
pass
|
|
281
|
+
ap = argparse.ArgumentParser(prog="typeling", description="Terminal autocomplete demo")
|
|
282
|
+
ap.add_argument("--no-learn", action="store_true", help="do not learn or save your words")
|
|
283
|
+
ap.add_argument("--lm", metavar="MODEL", nargs="?", const="ScarletKc/Typeling-25M",
|
|
284
|
+
help='use the language model (pip install "typeling[lm]"): Typeling-25M from Hugging Face, or '
|
|
285
|
+
"another repo id or directory")
|
|
286
|
+
ap.add_argument("--device", default=None, help="language model device: cpu or cuda (default: cuda if available)")
|
|
287
|
+
ap.add_argument("--threads", type=int, default=None, help="CPU threads for the language model")
|
|
288
|
+
ap.add_argument("--candidates", type=int, default=CANDIDATES, help=f"candidates in the bar (default {CANDIDATES})")
|
|
289
|
+
ap.add_argument("--bench", action="store_true", help="measure per-keystroke latency")
|
|
290
|
+
args = ap.parse_args()
|
|
291
|
+
if args.bench:
|
|
292
|
+
bench(args)
|
|
293
|
+
elif not sys.stdin.isatty():
|
|
294
|
+
pipe_mode(args)
|
|
295
|
+
else:
|
|
296
|
+
interactive(args)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
if __name__ == "__main__":
|
|
300
|
+
main()
|
typeling/en.bin
ADDED
|
Binary file
|
typeling/keyboard.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""
|
|
2
|
+
QWERTY keyboard error model: the strings that a single typing error turns into the typed letters.
|
|
3
|
+
|
|
4
|
+
from typeling.keyboard import corrections
|
|
5
|
+
{q for q, kind in corrections("tomm")} # contains "tom" (extra key), "tpmm" (adjacent key), ...
|
|
6
|
+
|
|
7
|
+
Used by the language model decoder's typo repair (typeling.lm) and by the typo benchmark (lm/typos.py).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
ROWS, OFFSETS = ("qwertyuiop", "asdfghjkl", "zxcvbnm"), (0.0, 0.25, 0.75)
|
|
11
|
+
ALPHABET = "abcdefghijklmnopqrstuvwxyz'"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _neighbors():
|
|
15
|
+
pos = {c: (r, i + OFFSETS[r]) for r, row in enumerate(ROWS) for i, c in enumerate(row)}
|
|
16
|
+
return {c: "".join(d for d, (r2, x2) in pos.items() if d != c and (
|
|
17
|
+
(r2 == r and abs(x2 - x) <= 1) or (abs(r2 - r) == 1 and abs(x2 - x) < 1))) for c, (r, x) in pos.items()}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
NEIGHBORS = _neighbors() # g -> tyfhvb
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def corrections(t):
|
|
24
|
+
"""Typed letters -> (intended letters, kind) for each single error that turns the intended letters into t:
|
|
25
|
+
sub an adjacent key was hit instead
|
|
26
|
+
del an extra key was hit, doubling or next to a neighboring letter
|
|
27
|
+
ins a letter or apostrophe was left out (not at the end, where it is simply not typed yet)
|
|
28
|
+
swap two neighboring letters were swapped
|
|
29
|
+
case the first letter was not capitalized
|
|
30
|
+
Results may repeat and need not be words."""
|
|
31
|
+
low = t.lower()
|
|
32
|
+
for i, c in enumerate(t):
|
|
33
|
+
nb = NEIGHBORS.get(low[i], "")
|
|
34
|
+
for d in nb:
|
|
35
|
+
yield t[:i] + (d.upper() if c.isupper() else d) + t[i + 1:], "sub"
|
|
36
|
+
if len(t) > 1 and any(x == low[i] or x in nb for x in low[max(0, i - 1):i] + low[i + 1:i + 2]):
|
|
37
|
+
yield t[:i] + t[i + 1:], "del"
|
|
38
|
+
for d in ALPHABET[:-1] if i == 0 else ALPHABET:
|
|
39
|
+
yield t[:i] + d + t[i:], "ins"
|
|
40
|
+
if i + 1 < len(t) and low[i] != low[i + 1]:
|
|
41
|
+
yield t[:i] + t[i + 1] + c + t[i + 2:], "swap"
|
|
42
|
+
if t[:1].islower():
|
|
43
|
+
yield t[0].upper() + t[1:], "case"
|