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/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