sift-cli 1.0.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.
- sift/__init__.py +18 -0
- sift/answers.py +175 -0
- sift/background.py +444 -0
- sift/capture.py +240 -0
- sift/cli.py +820 -0
- sift/digest.py +101 -0
- sift/distill.py +670 -0
- sift/fallback.py +98 -0
- sift/hook.py +275 -0
- sift/lines.py +37 -0
- sift/many.py +51 -0
- sift/memory.py +94 -0
- sift/model.py +433 -0
- sift/outline.py +117 -0
- sift/peek.py +161 -0
- sift/privacy.py +145 -0
- sift/records.py +95 -0
- sift/server.py +552 -0
- sift/store.py +499 -0
- sift/tools.py +76 -0
- sift/view.py +317 -0
- sift/watch.py +166 -0
- sift_cli-1.0.0.dist-info/METADATA +326 -0
- sift_cli-1.0.0.dist-info/RECORD +27 -0
- sift_cli-1.0.0.dist-info/WHEEL +4 -0
- sift_cli-1.0.0.dist-info/entry_points.txt +3 -0
- sift_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
sift/peek.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Getting back exactly what was there.
|
|
2
|
+
|
|
3
|
+
Every view `sift` produces names the capture it came from, and every gap in it
|
|
4
|
+
says how many lines it covers. `peek` is the other half of that promise: the way
|
|
5
|
+
back. Without it, a view would be a claim you have to take on trust; with it, a
|
|
6
|
+
view is a starting point and the original is one call away.
|
|
7
|
+
|
|
8
|
+
It returns bytes turned to text and nothing else -- no re-wrapping, no trimming,
|
|
9
|
+
no highlighting. A caller asking to see line 412 is usually asking because
|
|
10
|
+
something did not add up, and at that moment anything helpful is in the way.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
import time
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from sift import lines as text_lines
|
|
21
|
+
from sift import store
|
|
22
|
+
from sift.distill import render
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class Peek:
|
|
27
|
+
handle: str
|
|
28
|
+
text: str
|
|
29
|
+
first_line: int
|
|
30
|
+
last_line: int
|
|
31
|
+
total_lines: int
|
|
32
|
+
byte_count: int
|
|
33
|
+
# How many lines the caller's pattern matched, when there was one. Not the
|
|
34
|
+
# number shown: context is added around each match and the whole answer is
|
|
35
|
+
# capped, so a reader needs both numbers to know whether they saw them all.
|
|
36
|
+
matched: int = 0
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def is_whole(self) -> bool:
|
|
40
|
+
return self.first_line == 1 and self.last_line == self.total_lines
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def bytes_of(handle: str) -> bytes:
|
|
44
|
+
"""The bytes behind a handle: a capture's if there is one, otherwise a file's.
|
|
45
|
+
|
|
46
|
+
`sift run` writes a handle into its gap marker and `sift outline` writes a
|
|
47
|
+
path, and both markers end the same way -- *sift peek X for any of them*. So
|
|
48
|
+
the way back takes either. The store is asked first, so that a file sitting
|
|
49
|
+
in the working directory under some capture's name can never answer for that
|
|
50
|
+
capture; only a handle that was never captured falls through to disk.
|
|
51
|
+
"""
|
|
52
|
+
try:
|
|
53
|
+
return store.read_raw(handle)
|
|
54
|
+
except FileNotFoundError:
|
|
55
|
+
removed = store.gone(handle)
|
|
56
|
+
if removed is not None:
|
|
57
|
+
when = time.strftime("%Y-%m-%d", time.localtime(removed.removed_at))
|
|
58
|
+
raise FileNotFoundError(
|
|
59
|
+
f"capture {handle!r} was removed by sift gc on {when}"
|
|
60
|
+
f" ({removed.byte_count:,} bytes)"
|
|
61
|
+
) from None
|
|
62
|
+
found = Path(handle)
|
|
63
|
+
if not found.is_file():
|
|
64
|
+
raise
|
|
65
|
+
return found.read_bytes()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# How many lines a search may return before it stops being a way back into a
|
|
69
|
+
# capture and becomes a second copy of it. The caller can raise it; the default
|
|
70
|
+
# is here so that a pattern matching everything cannot answer with everything.
|
|
71
|
+
FOUND_CAP = 200
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def peek(
|
|
75
|
+
handle: str,
|
|
76
|
+
start: int | None = None,
|
|
77
|
+
end: int | None = None,
|
|
78
|
+
grep: str | None = None,
|
|
79
|
+
around: int = 3,
|
|
80
|
+
cap: int = FOUND_CAP,
|
|
81
|
+
) -> Peek:
|
|
82
|
+
"""Lines `start`..`end` of a capture or a file, 1-based and inclusive.
|
|
83
|
+
|
|
84
|
+
Out-of-range asks are clamped rather than refused. Someone reading a view saw
|
|
85
|
+
"3,914 lines not shown" and guessed at a number; answering with the nearest
|
|
86
|
+
real lines is more use than an error about the guess.
|
|
87
|
+
|
|
88
|
+
With `grep`, the range becomes the window to search rather than the answer:
|
|
89
|
+
every line matching the pattern comes back, with `around` lines on each side
|
|
90
|
+
of it so the match can be read. Nothing here judges -- the pattern is the
|
|
91
|
+
caller's, the matching is exact, and the lines are still the file's own. This
|
|
92
|
+
is the search a view sends you to when its gap says "3,914 lines not shown"
|
|
93
|
+
and you already know the word you are looking for.
|
|
94
|
+
"""
|
|
95
|
+
raw = bytes_of(handle)
|
|
96
|
+
text = raw.decode("utf-8", errors="replace")
|
|
97
|
+
lines = text_lines.of(text)
|
|
98
|
+
total = len(lines)
|
|
99
|
+
|
|
100
|
+
if total == 0:
|
|
101
|
+
return Peek(handle, "", 0, 0, 0, len(raw))
|
|
102
|
+
|
|
103
|
+
first = 1 if start is None else max(1, min(start, total))
|
|
104
|
+
last = total if end is None else max(first, min(end, total))
|
|
105
|
+
|
|
106
|
+
if grep is None:
|
|
107
|
+
return Peek(
|
|
108
|
+
handle=handle,
|
|
109
|
+
text="\n".join(lines[first - 1 : last]),
|
|
110
|
+
first_line=first,
|
|
111
|
+
last_line=last,
|
|
112
|
+
total_lines=total,
|
|
113
|
+
byte_count=len(raw),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
hits = _found(lines, grep, first, last)
|
|
117
|
+
chosen = _with_context(hits, around, cap, first, last)
|
|
118
|
+
if not chosen:
|
|
119
|
+
return Peek(handle, "", first, first, total, len(raw), matched=0)
|
|
120
|
+
|
|
121
|
+
return Peek(
|
|
122
|
+
handle=handle,
|
|
123
|
+
text=render(lines, chosen, handle),
|
|
124
|
+
first_line=min(chosen),
|
|
125
|
+
last_line=max(chosen),
|
|
126
|
+
total_lines=total,
|
|
127
|
+
byte_count=len(raw),
|
|
128
|
+
matched=len(hits),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _found(lines: list[str], grep: str, first: int, last: int) -> list[int]:
|
|
133
|
+
"""Every line in the window that the caller's pattern matches.
|
|
134
|
+
|
|
135
|
+
A pattern that will not compile is searched for as plain text, the same rule
|
|
136
|
+
`keep` follows and for the same reason: someone who typed `main()` meant
|
|
137
|
+
those characters, and answering a mistyped group with an empty result would
|
|
138
|
+
look exactly like a word that is not there.
|
|
139
|
+
"""
|
|
140
|
+
try:
|
|
141
|
+
found = re.compile(grep)
|
|
142
|
+
except re.error:
|
|
143
|
+
return [n for n in range(first, last + 1) if grep in lines[n - 1]]
|
|
144
|
+
return [n for n in range(first, last + 1) if found.search(lines[n - 1])]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _with_context(hits: list[int], around: int, cap: int, first: int, last: int) -> set[int]:
|
|
148
|
+
"""The matches, the lines around them, and no more than `cap` of anything.
|
|
149
|
+
|
|
150
|
+
The cap is applied match by match rather than to the finished set, so what
|
|
151
|
+
comes back is the *first* matches complete with their context instead of
|
|
152
|
+
every match with its context cut off. A truncated context is worse than a
|
|
153
|
+
missing match: the reader cannot tell it was truncated.
|
|
154
|
+
"""
|
|
155
|
+
chosen: set[int] = set()
|
|
156
|
+
for hit in hits:
|
|
157
|
+
nearby = set(range(max(first, hit - around), min(last, hit + around) + 1))
|
|
158
|
+
if len(chosen | nearby) > cap and chosen:
|
|
159
|
+
break
|
|
160
|
+
chosen |= nearby
|
|
161
|
+
return chosen
|
sift/privacy.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""What leaves this machine, and what is held back.
|
|
2
|
+
|
|
3
|
+
Everything else here is about what a reader is shown. This module is about the
|
|
4
|
+
other direction. Exactly one thing ever leaves: the text of a question. This
|
|
5
|
+
decides what that text may contain, and whether it is sent at all.
|
|
6
|
+
|
|
7
|
+
Two switches and one rule.
|
|
8
|
+
|
|
9
|
+
**`SIFT_NO_MODEL`** turns sending off entirely. Nothing is asked and every view
|
|
10
|
+
falls back to the deterministic one -- worse, and offered honestly as worse. A
|
|
11
|
+
tool that cannot be told "not from this machine" is a tool that cannot be used
|
|
12
|
+
on the machines where it would help most.
|
|
13
|
+
|
|
14
|
+
**`SIFT_MASK=0`** turns masking off, for someone who has decided their output
|
|
15
|
+
holds nothing worth hiding and would rather a model saw all of it.
|
|
16
|
+
|
|
17
|
+
And the rule: **a credential is replaced on the way out, and never in what is
|
|
18
|
+
shown.** The second half is what makes this cheap. A model is only ever asked
|
|
19
|
+
for line numbers; the lines are printed from the local file, byte for byte. So a
|
|
20
|
+
line masked on the way out is still shown to the reader in full. Masking costs
|
|
21
|
+
the model a little context and costs the reader nothing, which is why it is on
|
|
22
|
+
by default.
|
|
23
|
+
|
|
24
|
+
This is the one place in this project where patterns are allowed, and `fallback`
|
|
25
|
+
refuses them in the strongest terms, so the difference is worth stating. There a
|
|
26
|
+
pattern would be *judging* -- deciding which lines matter, in languages it half
|
|
27
|
+
knows, and being confidently wrong. Here nothing is judged. A pattern that fires
|
|
28
|
+
wrongly costs a masked token in a prompt; a pattern that fails to fire leaks a
|
|
29
|
+
key. Those two mistakes are not comparable, so the rule is: when in doubt, mask.
|
|
30
|
+
|
|
31
|
+
What is **not** masked matters just as much. Long is not the same as secret. A
|
|
32
|
+
commit hash, a checksum, a base64 payload in a build log are all long, all high
|
|
33
|
+
entropy, and all harmless; a tool that redacted every one of them would hand the
|
|
34
|
+
model a page of `[redacted]` and call it privacy. So what is matched here is the
|
|
35
|
+
shape credentials actually have, and a bare secret shaped like nothing in
|
|
36
|
+
particular will get through. That is a real limit, stated rather than papered
|
|
37
|
+
over: masking reduces what leaks by accident, and `SIFT_NO_MODEL` is the switch
|
|
38
|
+
that guarantees nothing leaves at all.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import os
|
|
44
|
+
import re
|
|
45
|
+
|
|
46
|
+
# Short, plain, and obviously not a value. It travels in a prompt, so it costs
|
|
47
|
+
# the model a token and tells it that something was there.
|
|
48
|
+
REDACTED = "[redacted]"
|
|
49
|
+
|
|
50
|
+
_NO = {"0", "false", "no", "off", ""}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def sending_on() -> bool:
|
|
54
|
+
"""Whether a model may be asked anything at all.
|
|
55
|
+
|
|
56
|
+
Off is the easy state to reach: `SIFT_NO_MODEL` set to anything except a
|
|
57
|
+
plain no means no sending. A privacy switch that only works when spelled
|
|
58
|
+
exactly right is a switch that fails open, and this one has to fail closed.
|
|
59
|
+
"""
|
|
60
|
+
return (os.environ.get("SIFT_NO_MODEL") or "").strip().lower() in _NO
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def masking_on() -> bool:
|
|
64
|
+
"""Whether credentials are replaced before a question is sent.
|
|
65
|
+
|
|
66
|
+
On unless switched off, and here a value nobody recognises leaves it on.
|
|
67
|
+
Both defaults point the same way: towards less leaving the machine.
|
|
68
|
+
"""
|
|
69
|
+
return (os.environ.get("SIFT_MASK") or "1").strip().lower() not in _NO
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# Each rule is a pattern and which group of it holds the secret. Group 0 means
|
|
73
|
+
# the whole match is the secret; a numbered group keeps the surrounding text,
|
|
74
|
+
# which is what tells a reader -- and a model -- what was hidden and where.
|
|
75
|
+
_RULES: list[tuple[re.Pattern[str], int]] = [
|
|
76
|
+
# A JSON Web Token. The first segment always begins this way, because it is
|
|
77
|
+
# base64 of a JSON object that opens with a quoted key.
|
|
78
|
+
(re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}"), 0),
|
|
79
|
+
# Tokens that carry their own prefix. Nothing else looks like these, so
|
|
80
|
+
# matching them costs nothing and missing them costs a key.
|
|
81
|
+
(re.compile(r"\b(?:AKIA|ASIA|ABIA|ACCA|A3T[A-Z0-9])[A-Z0-9]{16}\b"), 0),
|
|
82
|
+
(re.compile(r"\bgh[pousr]_[A-Za-z0-9]{20,}\b"), 0),
|
|
83
|
+
(re.compile(r"\bxox[abprs]-[A-Za-z0-9-]{10,}"), 0),
|
|
84
|
+
(re.compile(r"\bsk-[A-Za-z0-9_-]{20,}\b"), 0),
|
|
85
|
+
(re.compile(r"\bnvapi-[A-Za-z0-9_-]{20,}\b"), 0),
|
|
86
|
+
(re.compile(r"\bAIza[0-9A-Za-z_-]{35,}"), 0),
|
|
87
|
+
(re.compile(r"\bglpat-[A-Za-z0-9_-]{20,}\b"), 0),
|
|
88
|
+
(re.compile(r"\bhf_[A-Za-z0-9]{20,}\b"), 0),
|
|
89
|
+
# An authorization header, whatever scheme it names.
|
|
90
|
+
(re.compile(r"(?i)\b(?:bearer|basic|token)\s+([A-Za-z0-9+/=._~-]{12,})"), 1),
|
|
91
|
+
# A password inside a connection string: scheme://user:secret@host
|
|
92
|
+
(re.compile(r"\b[a-zA-Z][a-zA-Z0-9+.-]*://[^\s:/@]+:([^\s@/]{3,})@"), 1),
|
|
93
|
+
# An assignment whose name says what it holds. The names are English because
|
|
94
|
+
# configuration and protocols are, whatever language the project around them
|
|
95
|
+
# is written in -- but this rule is a bonus rather than the floor, and cannot
|
|
96
|
+
# be complete. The shapes above are what this module actually rests on.
|
|
97
|
+
(
|
|
98
|
+
re.compile(
|
|
99
|
+
r"(?i)\b(?:password|passwd|pwd|secret|token|api[_-]?key|access[_-]?key"
|
|
100
|
+
r"|client[_-]?secret|auth[_-]?token|credential)s?[\"']?\s*[:=]\s*"
|
|
101
|
+
r"[\"']?([^\s\"',;)]{4,})"
|
|
102
|
+
),
|
|
103
|
+
1,
|
|
104
|
+
),
|
|
105
|
+
# A line that is nothing but base64: the body of a PEM block. Three things
|
|
106
|
+
# narrow it, and each was earned. Anchored to the whole line, because a long
|
|
107
|
+
# base64 run *inside* a sentence is a payload someone is debugging and
|
|
108
|
+
# masking it would help nobody. And mixed case with a digit, because
|
|
109
|
+
# "x" * 5000 is a line of x's -- a test in this project produces exactly
|
|
110
|
+
# that, and an earlier version of this rule redacted it. Long is not the
|
|
111
|
+
# same as dense, and neither is the same as secret.
|
|
112
|
+
(
|
|
113
|
+
re.compile(
|
|
114
|
+
r"^(?=[A-Za-z0-9+/]*[a-z])(?=[A-Za-z0-9+/]*[A-Z])(?=[A-Za-z0-9+/]*[0-9])"
|
|
115
|
+
r"[A-Za-z0-9+/]{40,}={0,2}$"
|
|
116
|
+
),
|
|
117
|
+
0,
|
|
118
|
+
),
|
|
119
|
+
]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def mask(text: str) -> str:
|
|
123
|
+
"""The same text with anything credential-shaped replaced.
|
|
124
|
+
|
|
125
|
+
Applied to one line at a time by its caller, and that is deliberate: a
|
|
126
|
+
replacement that spanned lines would take the line numbers with it, and the
|
|
127
|
+
numbers are the only thing a model is ever asked to give back.
|
|
128
|
+
"""
|
|
129
|
+
if not masking_on():
|
|
130
|
+
return text
|
|
131
|
+
for pattern, group in _RULES:
|
|
132
|
+
text = pattern.sub(lambda found, g=group: _hidden(found, g), text)
|
|
133
|
+
return text
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _hidden(found: re.Match[str], group: int) -> str:
|
|
137
|
+
"""The match with its secret part replaced and the rest of it left alone."""
|
|
138
|
+
whole = found.group(0)
|
|
139
|
+
if group == 0:
|
|
140
|
+
return REDACTED
|
|
141
|
+
value = found.group(group)
|
|
142
|
+
if not value:
|
|
143
|
+
return whole
|
|
144
|
+
cut = found.start(group) - found.start(0)
|
|
145
|
+
return whole[:cut] + REDACTED + whole[cut + len(value) :]
|
sift/records.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""When a line is not a unit anybody can choose.
|
|
2
|
+
|
|
3
|
+
Everything else here counts in lines, and for command output that is right: a
|
|
4
|
+
line is what the writer meant as one thing and what a reader's eye stops on.
|
|
5
|
+
|
|
6
|
+
A JSON array is the case where it is simply false. Written by a machine for
|
|
7
|
+
another machine, it very often has no newlines at all -- four hundred kilobytes
|
|
8
|
+
on line 1 -- and then there is exactly one unit to choose from and choosing it
|
|
9
|
+
means showing all of it. Written out with indentation it is worse in a quieter
|
|
10
|
+
way: the lines exist, but a line of one is `"id": 3,`, which is true, chosen,
|
|
11
|
+
byte for byte from the file, and of no use to anybody. Neither shape is helped
|
|
12
|
+
by a better question. The unit is wrong.
|
|
13
|
+
|
|
14
|
+
So when the text is a JSON array, the record is the unit and everything else
|
|
15
|
+
stays exactly as it was. The model still answers with numbers and nothing else,
|
|
16
|
+
the text still comes out of the source unchanged, and a gap still says how many
|
|
17
|
+
of them it covers. Only what is being counted changes.
|
|
18
|
+
|
|
19
|
+
**Nothing is re-serialised.** A record is a slice of the original text, offset
|
|
20
|
+
to offset, so the whitespace, key order and number formatting a reader sees are
|
|
21
|
+
the ones in the file. Handing back `json.dumps` of a parsed value would be this
|
|
22
|
+
tool writing a line, which is the one thing it does not do -- and it would be
|
|
23
|
+
wrong quietly: `1.0` comes back `1.0`, but `1E2` comes back `100.0`.
|
|
24
|
+
|
|
25
|
+
**Only a top-level array.** An object wrapping one (`{"results": [...]}`) is
|
|
26
|
+
left as lines, and that is a decision rather than an omission: picking which of
|
|
27
|
+
an object's arrays holds "the records" is a guess about somebody else's schema,
|
|
28
|
+
and a guess that decides what a reader sees is the thing this project was
|
|
29
|
+
rewritten to be rid of. A caller who knows better has `--keep`.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import json
|
|
35
|
+
|
|
36
|
+
# One record is not a choice, and a view of it is the text itself. Below two,
|
|
37
|
+
# lines are no worse and the machinery is not worth entering.
|
|
38
|
+
FEWEST = 2
|
|
39
|
+
|
|
40
|
+
_SPACE = " \t\n\r"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def of(text: str) -> list[str] | None:
|
|
44
|
+
"""The records of a top-level JSON array, or None if that is not what this is.
|
|
45
|
+
|
|
46
|
+
Each record is `text[start:end]` of the source. The scan is a single pass
|
|
47
|
+
with the standard decoder, which is what makes it exact rather than a
|
|
48
|
+
bracket-counter that a brace inside a string would fool.
|
|
49
|
+
|
|
50
|
+
Returns None rather than raising, everywhere: this is asked of every capture
|
|
51
|
+
and almost every one of them is not JSON. "No" is the ordinary answer here,
|
|
52
|
+
not a failure, and it costs one character to reach.
|
|
53
|
+
"""
|
|
54
|
+
at = _past_space(text, 0)
|
|
55
|
+
if at >= len(text) or text[at] != "[":
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
decoder = json.JSONDecoder()
|
|
59
|
+
found: list[str] = []
|
|
60
|
+
at = _past_space(text, at + 1)
|
|
61
|
+
|
|
62
|
+
while True:
|
|
63
|
+
if at >= len(text):
|
|
64
|
+
return None # the array never closed; this is not a whole document
|
|
65
|
+
if text[at] == "]" and not found:
|
|
66
|
+
return None # an empty array has nothing to choose between
|
|
67
|
+
try:
|
|
68
|
+
_, end = decoder.raw_decode(text, at)
|
|
69
|
+
except ValueError:
|
|
70
|
+
return None
|
|
71
|
+
found.append(text[at:end])
|
|
72
|
+
|
|
73
|
+
at = _past_space(text, end)
|
|
74
|
+
if at >= len(text):
|
|
75
|
+
return None
|
|
76
|
+
if text[at] == "]":
|
|
77
|
+
break
|
|
78
|
+
if text[at] != ",":
|
|
79
|
+
return None
|
|
80
|
+
at = _past_space(text, at + 1)
|
|
81
|
+
|
|
82
|
+
# A document that goes on after the array is not an array. Refusing it keeps
|
|
83
|
+
# the promise the rest of this file rests on: every byte of the source is in
|
|
84
|
+
# exactly one record, so a count of records folded away is a count of the
|
|
85
|
+
# whole thing.
|
|
86
|
+
if _past_space(text, at + 1) != len(text):
|
|
87
|
+
return None
|
|
88
|
+
return found if len(found) >= FEWEST else None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _past_space(text: str, at: int) -> int:
|
|
92
|
+
"""The next index at or after `at` that is not JSON whitespace."""
|
|
93
|
+
while at < len(text) and text[at] in _SPACE:
|
|
94
|
+
at += 1
|
|
95
|
+
return at
|