beans-picker 0.3.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.
- beans_picker/__init__.py +1 -0
- beans_picker/__main__.py +6 -0
- beans_picker/_aio.py +32 -0
- beans_picker/_json.py +101 -0
- beans_picker/_numbers.py +116 -0
- beans_picker/_proc.py +92 -0
- beans_picker/_stdin.py +66 -0
- beans_picker/_text.py +97 -0
- beans_picker/act/__init__.py +0 -0
- beans_picker/act/execute.py +487 -0
- beans_picker/act/pixel.py +204 -0
- beans_picker/candidates/__init__.py +0 -0
- beans_picker/candidates/build.py +340 -0
- beans_picker/candidates/describe.py +91 -0
- beans_picker/candidates/keypad.py +117 -0
- beans_picker/candidates/menu.py +61 -0
- beans_picker/candidates/prune.py +77 -0
- beans_picker/candidates/safety.py +161 -0
- beans_picker/candidates/types.py +68 -0
- beans_picker/cli.py +83 -0
- beans_picker/config.py +170 -0
- beans_picker/driver/__init__.py +0 -0
- beans_picker/driver/app.py +143 -0
- beans_picker/driver/lock.py +22 -0
- beans_picker/driver/markdown.py +108 -0
- beans_picker/driver/mcp.py +374 -0
- beans_picker/driver/sentinel.py +140 -0
- beans_picker/driver/types.py +366 -0
- beans_picker/errors.py +96 -0
- beans_picker/jev/__init__.py +0 -0
- beans_picker/jev/client.py +213 -0
- beans_picker/jev/questions.py +55 -0
- beans_picker/jev/rank.py +143 -0
- beans_picker/jev/state.py +225 -0
- beans_picker/log.py +48 -0
- beans_picker/menus/__init__.py +0 -0
- beans_picker/menus/keyequiv.py +194 -0
- beans_picker/menus/menukeys.py +288 -0
- beans_picker/native/__init__.py +0 -0
- beans_picker/native/axtext.m +81 -0
- beans_picker/native/menukeys.m +94 -0
- beans_picker/observe/__init__.py +0 -0
- beans_picker/observe/exacttext.py +210 -0
- beans_picker/observe/helpers.py +179 -0
- beans_picker/observe/identity.py +32 -0
- beans_picker/observe/menudiff.py +105 -0
- beans_picker/observe/normalize.py +58 -0
- beans_picker/observe/png.py +164 -0
- beans_picker/observe/signature.py +29 -0
- beans_picker/observe/snapshot.py +394 -0
- beans_picker/observe/types.py +107 -0
- beans_picker/observe/visual.py +179 -0
- beans_picker/paths.py +31 -0
- beans_picker/py.typed +0 -0
- beans_picker/server.py +192 -0
- beans_picker/tools/__init__.py +0 -0
- beans_picker/tools/act.py +371 -0
- beans_picker/tools/args.py +339 -0
- beans_picker/tools/extract.py +230 -0
- beans_picker/tools/observe.py +65 -0
- beans_picker/tools/present.py +69 -0
- beans_picker/tools/session.py +250 -0
- beans_picker/verify/__init__.py +0 -0
- beans_picker/verify/effect.py +168 -0
- beans_picker-0.3.0.dist-info/METADATA +227 -0
- beans_picker-0.3.0.dist-info/RECORD +69 -0
- beans_picker-0.3.0.dist-info/WHEEL +4 -0
- beans_picker-0.3.0.dist-info/entry_points.txt +2 -0
- beans_picker-0.3.0.dist-info/licenses/LICENSE +21 -0
beans_picker/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.0"
|
beans_picker/__main__.py
ADDED
beans_picker/_aio.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Small asyncio helpers and the time seams (`Sleep`, `Clock`) that waiting code takes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections.abc import Awaitable, Callable
|
|
7
|
+
|
|
8
|
+
type Sleep = Callable[[float], Awaitable[None]]
|
|
9
|
+
type Clock = Callable[[], float]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
async def gather_settled[T](*aws: Awaitable[T]) -> list[T]:
|
|
13
|
+
"""Wait for every awaitable, then return their results in order, or raise the first failure by position."""
|
|
14
|
+
tasks = [asyncio.ensure_future(a) for a in aws]
|
|
15
|
+
try:
|
|
16
|
+
if tasks:
|
|
17
|
+
await asyncio.wait(tasks)
|
|
18
|
+
except BaseException:
|
|
19
|
+
for t in tasks:
|
|
20
|
+
t.cancel()
|
|
21
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
22
|
+
raise
|
|
23
|
+
# Retrieve every exception first so none is reported as never retrieved.
|
|
24
|
+
failures = [None if t.cancelled() else t.exception() for t in tasks]
|
|
25
|
+
results: list[T] = []
|
|
26
|
+
for t, exc in zip(tasks, failures, strict=True):
|
|
27
|
+
if t.cancelled():
|
|
28
|
+
raise asyncio.CancelledError
|
|
29
|
+
if exc is not None:
|
|
30
|
+
raise exc
|
|
31
|
+
results.append(t.result())
|
|
32
|
+
return results
|
beans_picker/_json.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Compact JSON output and JSON string literals, written by hand so every byte is fixed."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from typing import Final
|
|
7
|
+
|
|
8
|
+
from beans_picker._numbers import number_text
|
|
9
|
+
|
|
10
|
+
type JsonValue = str | int | float | bool | list[JsonValue] | dict[str, JsonValue] | None
|
|
11
|
+
type JsonObject = dict[str, JsonValue]
|
|
12
|
+
|
|
13
|
+
_SHORT_ESCAPES: Final[dict[str, str]] = {
|
|
14
|
+
'"': '\\"',
|
|
15
|
+
"\\": "\\\\",
|
|
16
|
+
"\b": "\\b",
|
|
17
|
+
"\f": "\\f",
|
|
18
|
+
"\n": "\\n",
|
|
19
|
+
"\r": "\\r",
|
|
20
|
+
"\t": "\\t",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _needs_work(s: str) -> bool:
|
|
25
|
+
for ch in s:
|
|
26
|
+
c = ord(ch)
|
|
27
|
+
if c < 0x20 or ch in ('"', "\\") or 0xD800 <= c <= 0xDFFF:
|
|
28
|
+
return True
|
|
29
|
+
return False
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def quote(s: str) -> str:
|
|
33
|
+
"""`s` as a JSON string literal, quotes included."""
|
|
34
|
+
if not _needs_work(s):
|
|
35
|
+
return f'"{s}"'
|
|
36
|
+
out: list[str] = ['"']
|
|
37
|
+
i = 0
|
|
38
|
+
n = len(s)
|
|
39
|
+
while i < n:
|
|
40
|
+
ch = s[i]
|
|
41
|
+
c = ord(ch)
|
|
42
|
+
short = _SHORT_ESCAPES.get(ch)
|
|
43
|
+
if short is not None:
|
|
44
|
+
out.append(short)
|
|
45
|
+
elif c < 0x20:
|
|
46
|
+
out.append(f"\\u{c:04x}")
|
|
47
|
+
elif 0xD800 <= c <= 0xDBFF and i + 1 < n and 0xDC00 <= ord(s[i + 1]) <= 0xDFFF:
|
|
48
|
+
# A pair held as two code points is one character.
|
|
49
|
+
out.append(chr(0x10000 + ((c - 0xD800) << 10) + (ord(s[i + 1]) - 0xDC00)))
|
|
50
|
+
i += 1
|
|
51
|
+
elif 0xD800 <= c <= 0xDFFF:
|
|
52
|
+
out.append(f"\\u{c:04x}")
|
|
53
|
+
else:
|
|
54
|
+
out.append(ch)
|
|
55
|
+
i += 1
|
|
56
|
+
out.append('"')
|
|
57
|
+
return "".join(out)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def dumps(obj: object) -> str:
|
|
61
|
+
"""`obj` as compact JSON (no spaces)."""
|
|
62
|
+
parts: list[str] = []
|
|
63
|
+
_write(obj, parts)
|
|
64
|
+
return "".join(parts)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _write(obj: object, out: list[str]) -> None:
|
|
68
|
+
if obj is None:
|
|
69
|
+
out.append("null")
|
|
70
|
+
elif isinstance(obj, bool):
|
|
71
|
+
out.append("true" if obj else "false")
|
|
72
|
+
elif isinstance(obj, int):
|
|
73
|
+
text = number_text(obj)
|
|
74
|
+
# An integer too large for a float has no finite JSON number.
|
|
75
|
+
out.append("null" if text.endswith("Infinity") else text)
|
|
76
|
+
elif isinstance(obj, float):
|
|
77
|
+
out.append(number_text(obj) if math.isfinite(obj) else "null")
|
|
78
|
+
elif isinstance(obj, str):
|
|
79
|
+
out.append(quote(obj))
|
|
80
|
+
elif isinstance(obj, dict):
|
|
81
|
+
out.append("{")
|
|
82
|
+
first = True
|
|
83
|
+
for key, value in obj.items():
|
|
84
|
+
if not isinstance(key, str):
|
|
85
|
+
raise TypeError(f"JSON object keys must be str, not {type(key).__name__}")
|
|
86
|
+
if not first:
|
|
87
|
+
out.append(",")
|
|
88
|
+
first = False
|
|
89
|
+
out.append(quote(key))
|
|
90
|
+
out.append(":")
|
|
91
|
+
_write(value, out)
|
|
92
|
+
out.append("}")
|
|
93
|
+
elif isinstance(obj, list | tuple):
|
|
94
|
+
out.append("[")
|
|
95
|
+
for i, item in enumerate(obj):
|
|
96
|
+
if i:
|
|
97
|
+
out.append(",")
|
|
98
|
+
_write(item, out)
|
|
99
|
+
out.append("]")
|
|
100
|
+
else:
|
|
101
|
+
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
|
beans_picker/_numbers.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Number rounding, formatting and parsing with the exact rules the tool outputs use."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import re
|
|
7
|
+
from decimal import ROUND_HALF_UP, Decimal
|
|
8
|
+
from typing import Final
|
|
9
|
+
|
|
10
|
+
from beans_picker._text import trim
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def round_half_up(x: float) -> int:
|
|
14
|
+
"""The integer nearest to `x`; a tie goes up (2.5 -> 3, -2.5 -> -2)."""
|
|
15
|
+
f = math.floor(x)
|
|
16
|
+
return f + 1 if x - f >= 0.5 else f
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def round3(p: float) -> float:
|
|
20
|
+
return round_half_up(p * 1000) / 1000
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def fixed(p: float, digits: int) -> str:
|
|
24
|
+
"""`p` with exactly `digits` decimals, rounded half away from zero on its exact binary value."""
|
|
25
|
+
if not math.isfinite(p) or abs(p) >= 1e21:
|
|
26
|
+
return number_text(p)
|
|
27
|
+
if p == 0:
|
|
28
|
+
p = 0.0
|
|
29
|
+
q = Decimal(p).quantize(Decimal(10) ** -digits, rounding=ROUND_HALF_UP)
|
|
30
|
+
return f"{q:f}"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
_EXP_LIMIT_HIGH: Final = 21
|
|
34
|
+
_EXP_LIMIT_LOW: Final = -6
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def number_text(x: float) -> str:
|
|
38
|
+
"""`x` written as in JSON output."""
|
|
39
|
+
if isinstance(x, bool):
|
|
40
|
+
return "1" if x else "0"
|
|
41
|
+
if isinstance(x, int):
|
|
42
|
+
try:
|
|
43
|
+
x = float(x)
|
|
44
|
+
except OverflowError:
|
|
45
|
+
x = math.inf if x > 0 else -math.inf
|
|
46
|
+
if math.isnan(x):
|
|
47
|
+
return "NaN"
|
|
48
|
+
if math.isinf(x):
|
|
49
|
+
return "Infinity" if x > 0 else "-Infinity"
|
|
50
|
+
if x == 0:
|
|
51
|
+
return "0"
|
|
52
|
+
sign = "-" if x < 0 else ""
|
|
53
|
+
digits, n = _shortest_digits(abs(x))
|
|
54
|
+
k = len(digits)
|
|
55
|
+
if k <= n <= _EXP_LIMIT_HIGH:
|
|
56
|
+
body = digits + "0" * (n - k)
|
|
57
|
+
elif 0 < n <= _EXP_LIMIT_HIGH:
|
|
58
|
+
body = digits[:n] + "." + digits[n:]
|
|
59
|
+
elif _EXP_LIMIT_LOW < n <= 0:
|
|
60
|
+
body = "0." + "0" * (-n) + digits
|
|
61
|
+
else:
|
|
62
|
+
e = n - 1
|
|
63
|
+
exp = f"e+{e}" if e >= 0 else f"e-{-e}"
|
|
64
|
+
body = (digits[0] + "." + digits[1:] if k > 1 else digits) + exp
|
|
65
|
+
return sign + body
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _shortest_digits(x: float) -> tuple[str, int]:
|
|
69
|
+
text = repr(x)
|
|
70
|
+
mantissa, _, exp_text = text.partition("e")
|
|
71
|
+
exp = int(exp_text) if exp_text else 0
|
|
72
|
+
int_part, _, frac_part = mantissa.partition(".")
|
|
73
|
+
all_digits = int_part + frac_part
|
|
74
|
+
point = len(int_part) + exp
|
|
75
|
+
stripped = all_digits.lstrip("0")
|
|
76
|
+
point -= len(all_digits) - len(stripped)
|
|
77
|
+
stripped = stripped.rstrip("0")
|
|
78
|
+
return stripped, point
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
_DECIMAL_RE: Final = re.compile(r"[+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?")
|
|
82
|
+
_INFINITY_RE: Final = re.compile(r"[+-]?Infinity")
|
|
83
|
+
_PREFIXED_RE: Final = re.compile(r"0(?:[xX][0-9a-fA-F]+|[oO][0-7]+|[bB][01]+)")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def parse_number(text: str) -> float:
|
|
87
|
+
"""The number a text denotes, or NaN."""
|
|
88
|
+
s = trim(text)
|
|
89
|
+
if s == "":
|
|
90
|
+
return 0.0
|
|
91
|
+
if _DECIMAL_RE.fullmatch(s):
|
|
92
|
+
return float(s)
|
|
93
|
+
if _INFINITY_RE.fullmatch(s):
|
|
94
|
+
return -math.inf if s[0] == "-" else math.inf
|
|
95
|
+
if _PREFIXED_RE.fullmatch(s):
|
|
96
|
+
base = {"x": 16, "o": 8, "b": 2}[s[1].lower()]
|
|
97
|
+
try:
|
|
98
|
+
return float(int(s[2:], base))
|
|
99
|
+
except OverflowError:
|
|
100
|
+
return math.inf
|
|
101
|
+
return math.nan
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def scalar_text(v: object) -> str:
|
|
105
|
+
"""A received value as text: `null`, `true`/`false`, a number in its shortest form, a list joined by commas."""
|
|
106
|
+
if v is None:
|
|
107
|
+
return "null"
|
|
108
|
+
if isinstance(v, bool):
|
|
109
|
+
return "true" if v else "false"
|
|
110
|
+
if isinstance(v, int | float):
|
|
111
|
+
return number_text(v)
|
|
112
|
+
if isinstance(v, str):
|
|
113
|
+
return v
|
|
114
|
+
if isinstance(v, list | tuple):
|
|
115
|
+
return ",".join("" if item is None else scalar_text(item) for item in v)
|
|
116
|
+
return "[object Object]"
|
beans_picker/_proc.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""The one place subprocesses are started."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Final, Protocol
|
|
11
|
+
|
|
12
|
+
from beans_picker.errors import ProcessError
|
|
13
|
+
|
|
14
|
+
DEFAULT_MAX_BYTES: Final = 1 << 20
|
|
15
|
+
_CHUNK: Final = 64 * 1024
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class Completed:
|
|
20
|
+
stdout: str
|
|
21
|
+
returncode: int
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Runner(Protocol):
|
|
25
|
+
async def __call__(
|
|
26
|
+
self,
|
|
27
|
+
argv: Sequence[str],
|
|
28
|
+
/,
|
|
29
|
+
*,
|
|
30
|
+
max_bytes: int = DEFAULT_MAX_BYTES,
|
|
31
|
+
check: bool = True,
|
|
32
|
+
cwd: Path | None = None,
|
|
33
|
+
) -> Completed: ...
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def run(
|
|
37
|
+
argv: Sequence[str],
|
|
38
|
+
/,
|
|
39
|
+
*,
|
|
40
|
+
max_bytes: int = DEFAULT_MAX_BYTES,
|
|
41
|
+
check: bool = True,
|
|
42
|
+
cwd: Path | None = None,
|
|
43
|
+
) -> Completed:
|
|
44
|
+
"""Run `argv` with stdin and stderr on the null device; return stdout and the exit code."""
|
|
45
|
+
if not argv:
|
|
46
|
+
raise ProcessError("empty command")
|
|
47
|
+
try:
|
|
48
|
+
proc = await asyncio.create_subprocess_exec(
|
|
49
|
+
*argv,
|
|
50
|
+
stdin=asyncio.subprocess.DEVNULL,
|
|
51
|
+
stdout=asyncio.subprocess.PIPE,
|
|
52
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
53
|
+
cwd=cwd,
|
|
54
|
+
)
|
|
55
|
+
except OSError as err:
|
|
56
|
+
raise ProcessError(f"{argv[0]}: could not start: {err}") from err
|
|
57
|
+
try:
|
|
58
|
+
stdout = await _read_limited(proc, max_bytes)
|
|
59
|
+
if stdout is not None:
|
|
60
|
+
returncode = await proc.wait()
|
|
61
|
+
except BaseException:
|
|
62
|
+
await _kill(proc)
|
|
63
|
+
raise
|
|
64
|
+
if stdout is None:
|
|
65
|
+
await _kill(proc)
|
|
66
|
+
raise ProcessError(f"{argv[0]}: output exceeded {max_bytes} bytes")
|
|
67
|
+
text = stdout.decode("utf-8", errors="replace")
|
|
68
|
+
if check and returncode != 0:
|
|
69
|
+
raise ProcessError(f"{argv[0]} exited with code {returncode}", returncode=returncode, stdout=text)
|
|
70
|
+
return Completed(stdout=text, returncode=returncode)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def _read_limited(proc: asyncio.subprocess.Process, max_bytes: int) -> bytes | None:
|
|
74
|
+
stream = proc.stdout
|
|
75
|
+
if stream is None: # pragma: no cover - stdout is always a pipe here
|
|
76
|
+
return b""
|
|
77
|
+
buf = bytearray()
|
|
78
|
+
while True:
|
|
79
|
+
chunk = await stream.read(_CHUNK)
|
|
80
|
+
if not chunk:
|
|
81
|
+
return bytes(buf)
|
|
82
|
+
buf += chunk
|
|
83
|
+
if len(buf) > max_bytes:
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
async def _kill(proc: asyncio.subprocess.Process) -> None:
|
|
88
|
+
if proc.returncode is None:
|
|
89
|
+
with contextlib.suppress(ProcessLookupError):
|
|
90
|
+
proc.kill()
|
|
91
|
+
# Reap the child even when the caller is being cancelled.
|
|
92
|
+
await asyncio.shield(proc.wait())
|
beans_picker/_stdin.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Standard input for the MCP transport, read so that a signal can end serving at once."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import fcntl
|
|
7
|
+
import os
|
|
8
|
+
import threading
|
|
9
|
+
from typing import Final
|
|
10
|
+
|
|
11
|
+
import anyio
|
|
12
|
+
|
|
13
|
+
_CHUNK: Final = 65536
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class StdinLines(anyio.AsyncFile[str]):
|
|
17
|
+
"""Lines of a descriptor as UTF-8 text; an empty string at end of input, as from a file."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, fd: int = 0) -> None:
|
|
20
|
+
own = fcntl.fcntl(fd, fcntl.F_DUPFD_CLOEXEC, 3)
|
|
21
|
+
# The wrapped file is only there for the `AsyncFile` interface; lines come from the thread.
|
|
22
|
+
super().__init__(os.fdopen(own, "r", encoding="utf-8", errors="replace", closefd=False))
|
|
23
|
+
self._fd = own
|
|
24
|
+
self._lines: asyncio.Queue[str] | None = None
|
|
25
|
+
|
|
26
|
+
async def readline(self) -> str:
|
|
27
|
+
if self._lines is None:
|
|
28
|
+
self._lines = asyncio.Queue()
|
|
29
|
+
pump = threading.Thread(
|
|
30
|
+
target=_pump,
|
|
31
|
+
args=(self._fd, asyncio.get_running_loop(), self._lines),
|
|
32
|
+
name="beans-picker stdin",
|
|
33
|
+
daemon=True,
|
|
34
|
+
)
|
|
35
|
+
pump.start()
|
|
36
|
+
line = await self._lines.get()
|
|
37
|
+
if not line:
|
|
38
|
+
self._lines.put_nowait(line)
|
|
39
|
+
return line
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _pump(fd: int, loop: asyncio.AbstractEventLoop, lines: asyncio.Queue[str]) -> None:
|
|
43
|
+
pending = bytearray()
|
|
44
|
+
try:
|
|
45
|
+
while chunk := os.read(fd, _CHUNK):
|
|
46
|
+
pending += chunk
|
|
47
|
+
while (end := pending.find(b"\n")) >= 0:
|
|
48
|
+
line = bytes(pending[: end + 1])
|
|
49
|
+
del pending[: end + 1]
|
|
50
|
+
if not _deliver(loop, lines, line.decode("utf-8", "replace")):
|
|
51
|
+
return
|
|
52
|
+
if pending:
|
|
53
|
+
_deliver(loop, lines, pending.decode("utf-8", "replace"))
|
|
54
|
+
except OSError:
|
|
55
|
+
pass # the descriptor failed: treat it as the end of input
|
|
56
|
+
finally:
|
|
57
|
+
_deliver(loop, lines, "")
|
|
58
|
+
os.close(fd)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _deliver(loop: asyncio.AbstractEventLoop, lines: asyncio.Queue[str], line: str) -> bool:
|
|
62
|
+
try:
|
|
63
|
+
loop.call_soon_threadsafe(lines.put_nowait, line)
|
|
64
|
+
except RuntimeError:
|
|
65
|
+
return False
|
|
66
|
+
return True
|
beans_picker/_text.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Text primitives shared by every layer: UTF-16 lengths, the whitespace set and regex classes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Final
|
|
6
|
+
|
|
7
|
+
# Candidate keys and state signatures depend on this exact set; changing it changes every key.
|
|
8
|
+
# U+0085 and U+200E are deliberately not in it.
|
|
9
|
+
WS_CODE_POINTS: Final[tuple[int, ...]] = (
|
|
10
|
+
0x0009,
|
|
11
|
+
0x000A,
|
|
12
|
+
0x000B,
|
|
13
|
+
0x000C,
|
|
14
|
+
0x000D,
|
|
15
|
+
0x0020,
|
|
16
|
+
0x00A0,
|
|
17
|
+
0x1680,
|
|
18
|
+
*range(0x2000, 0x200B),
|
|
19
|
+
0x2028,
|
|
20
|
+
0x2029,
|
|
21
|
+
0x202F,
|
|
22
|
+
0x205F,
|
|
23
|
+
0x3000,
|
|
24
|
+
0xFEFF,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
WS_CHARS: Final[frozenset[str]] = frozenset(chr(c) for c in WS_CODE_POINTS)
|
|
28
|
+
|
|
29
|
+
_WS_STR: Final[str] = "".join(chr(c) for c in WS_CODE_POINTS)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _escape_class(code_points: tuple[int, ...]) -> str:
|
|
33
|
+
return "".join(f"\\u{c:04x}" for c in code_points)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
WS_CLASS_BODY: Final[str] = _escape_class(WS_CODE_POINTS)
|
|
37
|
+
WS: Final[str] = f"[{WS_CLASS_BODY}]"
|
|
38
|
+
NOT_WS: Final[str] = f"[^{WS_CLASS_BODY}]"
|
|
39
|
+
WORD_CLASS_BODY: Final[str] = "A-Za-z0-9_"
|
|
40
|
+
WORD: Final[str] = f"[{WORD_CLASS_BODY}]"
|
|
41
|
+
NOT_WORD: Final[str] = f"[^{WORD_CLASS_BODY}]"
|
|
42
|
+
DIGIT: Final[str] = "[0-9]"
|
|
43
|
+
# Never use inside a pattern compiled with re.ASCII.
|
|
44
|
+
LETTER_OR_NUMBER: Final[str] = "[^\\W_]"
|
|
45
|
+
NOT_LETTER_OR_NUMBER: Final[str] = "[\\W_]"
|
|
46
|
+
NOT_LINE_END: Final[str] = "[^\\n\\r\\u2028\\u2029]"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def utf16_units(s: str) -> bytes:
|
|
50
|
+
return s.encode("utf-16-le", "surrogatepass")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def utf16_len(s: str) -> int:
|
|
54
|
+
"""The length of `s` in UTF-16 code units (an astral character counts 2)."""
|
|
55
|
+
return len(utf16_units(s)) // 2
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _from_units(units: bytes) -> str:
|
|
59
|
+
return units.decode("utf-16-le", "surrogatepass")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def utf16_slice(s: str, stop: int, *, start: int = 0) -> str:
|
|
63
|
+
"""`s` cut to the UTF-16 unit range [start, stop)."""
|
|
64
|
+
units = utf16_units(s)
|
|
65
|
+
n = len(units) // 2
|
|
66
|
+
lo = _clamp(start, n)
|
|
67
|
+
hi = _clamp(stop, n)
|
|
68
|
+
if hi <= lo:
|
|
69
|
+
return ""
|
|
70
|
+
return _from_units(units[2 * lo : 2 * hi])
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _clamp(i: int, n: int) -> int:
|
|
74
|
+
if i < 0:
|
|
75
|
+
return max(n + i, 0)
|
|
76
|
+
return min(i, n)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def hash_bytes(s: str) -> bytes:
|
|
80
|
+
"""The bytes that are hashed for `s`: UTF-8, with every lone surrogate replaced by U+FFFD."""
|
|
81
|
+
return s.encode("utf-16-le", "surrogatepass").decode("utf-16-le", "replace").encode("utf-8")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def trim(s: str) -> str:
|
|
85
|
+
return s.strip(_WS_STR)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def lstrip_ws(s: str) -> str:
|
|
89
|
+
return s.lstrip(_WS_STR)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def rstrip_ws(s: str) -> str:
|
|
93
|
+
return s.rstrip(_WS_STR)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def is_ws(ch: str) -> bool:
|
|
97
|
+
return ch in WS_CHARS
|
|
File without changes
|