holt-cli 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.
- holt/__init__.py +0 -0
- holt/agent/__init__.py +0 -0
- holt/agent/entry.py +86 -0
- holt/agent/findings.py +49 -0
- holt/agent/landing.py +154 -0
- holt/agent/pipeline.py +244 -0
- holt/agent/progression.py +408 -0
- holt/agent/signals.py +220 -0
- holt/agent/stages.py +533 -0
- holt/agent/verdict.py +226 -0
- holt/agent/verify.py +140 -0
- holt/baseline.py +89 -0
- holt/baseline_matched.py +116 -0
- holt/cli.py +616 -0
- holt/discover.py +497 -0
- holt/evidence/__init__.py +3 -0
- holt/evidence/fixtures.py +154 -0
- holt/evidence/github_graphql.py +538 -0
- holt/evidence/provider.py +77 -0
- holt/evidence/redact.py +79 -0
- holt/issues.py +41 -0
- holt/model.py +516 -0
- holt/profile.py +126 -0
- holt/report.py +157 -0
- holt/tui/__init__.py +0 -0
- holt/tui/animation.py +84 -0
- holt/tui/app.py +294 -0
- holt/tui/clipboard.py +89 -0
- holt/tui/commands.py +134 -0
- holt/tui/discovery.py +305 -0
- holt/tui/env.py +49 -0
- holt/tui/events.py +245 -0
- holt/tui/mascot.py +121 -0
- holt/tui/models.py +590 -0
- holt/tui/observe.py +297 -0
- holt/tui/screens/__init__.py +35 -0
- holt/tui/screens/assessment.py +337 -0
- holt/tui/screens/confirm.py +62 -0
- holt/tui/screens/discover.py +444 -0
- holt/tui/screens/home.py +519 -0
- holt/tui/screens/inspector.py +106 -0
- holt/tui/screens/live.py +335 -0
- holt/tui/screens/models.py +393 -0
- holt/tui/screens/next_steps.py +425 -0
- holt/tui/screens/profile.py +129 -0
- holt/tui/session.py +711 -0
- holt/tui/store.py +458 -0
- holt/tui/theme.py +479 -0
- holt/tui/visual.py +33 -0
- holt/tui/widgets/__init__.py +0 -0
- holt/tui/widgets/candidates.py +78 -0
- holt/tui/widgets/claims.py +59 -0
- holt/tui/widgets/disclosure.py +121 -0
- holt/tui/widgets/evidence.py +121 -0
- holt/tui/widgets/masthead.py +122 -0
- holt/tui/widgets/recent.py +167 -0
- holt/tui/widgets/scrolling.py +38 -0
- holt/tui/widgets/stages.py +232 -0
- holt/types.py +48 -0
- holt_cli-0.1.0.dist-info/METADATA +198 -0
- holt_cli-0.1.0.dist-info/RECORD +65 -0
- holt_cli-0.1.0.dist-info/WHEEL +4 -0
- holt_cli-0.1.0.dist-info/entry_points.txt +2 -0
- holt_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
- holt_cli-0.1.0.dist-info/licenses/NOTICE +4 -0
holt/tui/commands.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""What ctrl+p offers.
|
|
2
|
+
|
|
3
|
+
Textual gives every app a command palette for free, and out of the box it is
|
|
4
|
+
entirely the framework's own: Keys, Maximize, Screenshot, Theme. Useful while
|
|
5
|
+
building a terminal app, and nothing to do with assessing a repository — someone
|
|
6
|
+
who opened it looking for holt's features found a menu about the toolkit holt
|
|
7
|
+
happens to be written in.
|
|
8
|
+
|
|
9
|
+
So holt puts its own commands in front of those. Two kinds, and the second is
|
|
10
|
+
the reason this file is worth having:
|
|
11
|
+
|
|
12
|
+
* **The things the keybindings already do** — assess, discover, models, profile.
|
|
13
|
+
A palette that lists them is how you find a feature whose key you have not
|
|
14
|
+
memorised, which is every feature until you have used it a few times.
|
|
15
|
+
* **The things that are true right now** — the runs in flight, and the
|
|
16
|
+
assessments already stored. Those cannot be keybindings, because they are
|
|
17
|
+
data. `stop home-assistant/core` is only offerable while that run exists, and
|
|
18
|
+
the palette is the only surface in the interface that can grow a command for
|
|
19
|
+
it and then lose it again.
|
|
20
|
+
|
|
21
|
+
The framework's commands are kept, appended after holt's rather than removed:
|
|
22
|
+
they cost a line each and someone who wants a screenshot of a verdict should be
|
|
23
|
+
able to take one.
|
|
24
|
+
|
|
25
|
+
They are yielded from *this* provider rather than registered alongside it.
|
|
26
|
+
Textual runs every registered provider concurrently and collects hits as they
|
|
27
|
+
arrive, and `App.COMMANDS` is a set, so two providers produce an order that
|
|
28
|
+
changes between launches — holt's commands on top one time and Textual's the
|
|
29
|
+
next. One provider, yielding in a written order, is the only way to say which
|
|
30
|
+
comes first and mean it.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
from typing import Callable, Iterable
|
|
36
|
+
|
|
37
|
+
from textual.command import DiscoveryHit, Hit, Hits, Provider
|
|
38
|
+
|
|
39
|
+
from holt.tui import store
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class HoltCommands(Provider):
|
|
43
|
+
"""holt's own entries in the command palette."""
|
|
44
|
+
|
|
45
|
+
async def discover(self) -> Hits:
|
|
46
|
+
"""What the palette shows before anything is typed."""
|
|
47
|
+
for name, help_text, callback in self._commands():
|
|
48
|
+
yield DiscoveryHit(name, callback, help=help_text)
|
|
49
|
+
|
|
50
|
+
async def search(self, query: str) -> Hits:
|
|
51
|
+
matcher = self.matcher(query)
|
|
52
|
+
for name, help_text, callback in self._commands():
|
|
53
|
+
score = matcher.match(name)
|
|
54
|
+
if score > 0:
|
|
55
|
+
yield Hit(score, matcher.highlight(name), callback, help=help_text)
|
|
56
|
+
|
|
57
|
+
# ─── the commands themselves ────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
def _commands(self) -> Iterable[tuple[str, str, Callable[[], None]]]:
|
|
60
|
+
"""Name, one-line help, and what it does.
|
|
61
|
+
|
|
62
|
+
Rebuilt on every open rather than cached, because half of these are
|
|
63
|
+
about state — a run that has finished must stop being offered as one
|
|
64
|
+
you can stop.
|
|
65
|
+
"""
|
|
66
|
+
app = self.app
|
|
67
|
+
yield from self._runs(app)
|
|
68
|
+
yield from self._stored(app)
|
|
69
|
+
yield (
|
|
70
|
+
"assess a repository",
|
|
71
|
+
"type an owner/name and press enter",
|
|
72
|
+
app.go_home,
|
|
73
|
+
)
|
|
74
|
+
yield (
|
|
75
|
+
"find a repository",
|
|
76
|
+
"screen candidates you have not named yet",
|
|
77
|
+
lambda: app.push_screen("discover"),
|
|
78
|
+
)
|
|
79
|
+
yield (
|
|
80
|
+
"models",
|
|
81
|
+
"which model answers each stage, and whether it can be reached",
|
|
82
|
+
lambda: app.push_screen("models"),
|
|
83
|
+
)
|
|
84
|
+
yield (
|
|
85
|
+
"profile",
|
|
86
|
+
"the languages and the time you have, used to rank what to read",
|
|
87
|
+
lambda: app.push_screen("profile"),
|
|
88
|
+
)
|
|
89
|
+
yield ("quit holt", "asks first if a run is still in flight", app.action_quit)
|
|
90
|
+
yield from self._framework(app)
|
|
91
|
+
|
|
92
|
+
def _framework(self, app) -> Iterable[tuple[str, str, Callable[[], None]]]:
|
|
93
|
+
"""Textual's own commands, last, in the framework's own order."""
|
|
94
|
+
try:
|
|
95
|
+
system = list(app.get_system_commands(self.screen))
|
|
96
|
+
except Exception: # noqa: BLE001 - the palette opens even if these do not
|
|
97
|
+
return
|
|
98
|
+
for entry in system:
|
|
99
|
+
name, help_text, callback = entry[0], entry[1], entry[2]
|
|
100
|
+
# `discover` is the framework's own flag for "list this before
|
|
101
|
+
# anything is typed". Honoured rather than overridden.
|
|
102
|
+
if len(entry) > 3 and not entry[3]:
|
|
103
|
+
continue
|
|
104
|
+
yield (name, help_text, callback)
|
|
105
|
+
|
|
106
|
+
def _runs(self, app) -> Iterable[tuple[str, str, Callable[[], None]]]:
|
|
107
|
+
"""One pair of commands per run in flight. First, because they are live."""
|
|
108
|
+
for session in getattr(app, "in_flight", []):
|
|
109
|
+
repo = session.options.repo
|
|
110
|
+
yield (
|
|
111
|
+
f"watch {repo}",
|
|
112
|
+
f"rejoin the assessment in progress · {session.options.mode}",
|
|
113
|
+
lambda s=session: app.watch_run(s),
|
|
114
|
+
)
|
|
115
|
+
yield (
|
|
116
|
+
f"stop {repo}",
|
|
117
|
+
"ends the run and keeps nothing it had computed",
|
|
118
|
+
lambda s=session: app.confirm_stop(s),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
def _stored(self, app) -> Iterable[tuple[str, str, Callable[[], None]]]:
|
|
122
|
+
"""Assessments already produced, newest first. Reading one costs nothing."""
|
|
123
|
+
try:
|
|
124
|
+
entries = app.store.all()
|
|
125
|
+
except Exception: # noqa: BLE001 - a palette must not raise over a bad store
|
|
126
|
+
return
|
|
127
|
+
for entry in entries:
|
|
128
|
+
verdict = getattr(entry.assessment.verdict, "value", "")
|
|
129
|
+
age = store.describe_age(entry.age_seconds)
|
|
130
|
+
yield (
|
|
131
|
+
f"open {entry.repo}",
|
|
132
|
+
f"{verdict.replace('_', ' ')} · assessed {age} · {entry.mode}",
|
|
133
|
+
lambda e=entry: app.open_stored(e),
|
|
134
|
+
)
|
holt/tui/discovery.py
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""Finding candidates, live or from a recording, as rows rather than markdown.
|
|
2
|
+
|
|
3
|
+
Two ways in, and the default matters. **Live** is what the feature is for: a
|
|
4
|
+
GitHub search over what you said you wanted, screened for free as each candidate
|
|
5
|
+
comes back. **Replaying a recording** is a demo of that — the same screening over
|
|
6
|
+
committed fixtures, no token, no network — and it is now something you ask for
|
|
7
|
+
rather than the thing the screen opens on. A page of repositories from a search
|
|
8
|
+
somebody else ran in June is not a result about you, and presenting it as the
|
|
9
|
+
first thing `ctrl+f` does made the feature look like a canned list.
|
|
10
|
+
|
|
11
|
+
Screening is free in both directions: `screen_records` runs no model, so a live
|
|
12
|
+
sweep spends GitHub API quota and a minute of waiting, and nothing else. The
|
|
13
|
+
model cost lives where it always did — pressing enter on a row starts an
|
|
14
|
+
ordinary assessment run.
|
|
15
|
+
|
|
16
|
+
The engine returns rendered markdown, which is the right output for a
|
|
17
|
+
file and the wrong one for a screen you navigate: the interface needs the slug
|
|
18
|
+
under the cursor so that pressing enter can assess it. Parsing the markdown back
|
|
19
|
+
into data would make the interface depend on how the report is worded, which is
|
|
20
|
+
exactly the coupling the whole TUI has avoided.
|
|
21
|
+
|
|
22
|
+
So the screening step is reassembled here from `discover`'s own public parts —
|
|
23
|
+
the session manifest, `screen_root`, and `screen_records`, which is where the
|
|
24
|
+
rule actually lives. Nothing about *what survives* is decided in this file. It
|
|
25
|
+
walks the same candidates in the same order and asks the same function.
|
|
26
|
+
|
|
27
|
+
The full analysis of survivors is deliberately not reproduced. Assessing a
|
|
28
|
+
repository already has a path through this interface, and a second one would be
|
|
29
|
+
the duplicate way of running a stage that this feature must not become: pressing
|
|
30
|
+
enter on a row starts an ordinary run.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import json
|
|
36
|
+
import os
|
|
37
|
+
import threading
|
|
38
|
+
from dataclasses import dataclass, field
|
|
39
|
+
from datetime import datetime
|
|
40
|
+
from typing import Any
|
|
41
|
+
|
|
42
|
+
from holt import discover
|
|
43
|
+
from holt.evidence.fixtures import FixtureProvider
|
|
44
|
+
from holt.profile import Profile
|
|
45
|
+
from holt.types import Window
|
|
46
|
+
|
|
47
|
+
#: The session that ships in the repository. Replays with no token and no key.
|
|
48
|
+
#: Reachable on request; never what the screen opens on.
|
|
49
|
+
DEFAULT_SESSION = "demo"
|
|
50
|
+
|
|
51
|
+
#: How many candidates a live search sources. The same default `holt discover`
|
|
52
|
+
#: uses, so the two produce comparable sweeps.
|
|
53
|
+
DEFAULT_LIMIT = 25
|
|
54
|
+
|
|
55
|
+
#: What a screening category means in words, since the engine's labels are
|
|
56
|
+
#: short. A category added later falls through to its own label rather than
|
|
57
|
+
#: being dropped, so a new reason still says something.
|
|
58
|
+
CATEGORY_WORDS = {
|
|
59
|
+
"registry": "a catalogue, not software",
|
|
60
|
+
"inactive": "not active enough",
|
|
61
|
+
"no_outsiders": "no outsider has landed work",
|
|
62
|
+
"unclear": "not enough evidence",
|
|
63
|
+
"archived": "archived",
|
|
64
|
+
"mirror": "a mirror of work done elsewhere",
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def cut_reason(category: str) -> str:
|
|
69
|
+
return CATEGORY_WORDS.get(category, category.replace("_", " "))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(slots=True)
|
|
73
|
+
class Row:
|
|
74
|
+
"""One candidate the recorded search turned up, and what screening said."""
|
|
75
|
+
|
|
76
|
+
slug: str
|
|
77
|
+
description: str
|
|
78
|
+
language: str
|
|
79
|
+
stars: int
|
|
80
|
+
verdict: str
|
|
81
|
+
#: `None` when the candidate survived screening; otherwise why it was cut.
|
|
82
|
+
category: str | None
|
|
83
|
+
reason: str
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def survived(self) -> bool:
|
|
87
|
+
return self.category is None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def row_for(candidate: Any, screened: Any) -> Row:
|
|
91
|
+
"""One screened candidate as a row. The only place a `Row` is built.
|
|
92
|
+
|
|
93
|
+
Live and replay reach screening by different routes and must not describe
|
|
94
|
+
it differently, so both come through here.
|
|
95
|
+
"""
|
|
96
|
+
return Row(
|
|
97
|
+
slug=candidate.slug,
|
|
98
|
+
description=(candidate.description or "").strip(),
|
|
99
|
+
language=candidate.language or "",
|
|
100
|
+
stars=candidate.stars,
|
|
101
|
+
verdict=getattr(screened.verdict, "value", str(screened.verdict)),
|
|
102
|
+
category=screened.category,
|
|
103
|
+
reason=screened.trace[0] if screened.trace else "",
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass(slots=True)
|
|
108
|
+
class Session:
|
|
109
|
+
name: str
|
|
110
|
+
profile_description: str
|
|
111
|
+
queries: list[str]
|
|
112
|
+
as_of: datetime
|
|
113
|
+
rows: list[Row]
|
|
114
|
+
#: Candidates in the manifest with no recorded evidence. Listed, never
|
|
115
|
+
#: silently dropped — a search that quietly loses candidates is a search
|
|
116
|
+
#: whose count means nothing.
|
|
117
|
+
skipped: list[str]
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def survivors(self) -> list[Row]:
|
|
121
|
+
return [r for r in self.rows if r.survived]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def missing_token() -> str | None:
|
|
125
|
+
"""What a live search needs, or None. Checked before a thread starts.
|
|
126
|
+
|
|
127
|
+
Only `GITHUB_TOKEN`: screening calls no model, so an OpenAI key is not
|
|
128
|
+
required to find candidates — only to assess one afterwards. Saying
|
|
129
|
+
otherwise would turn a free feature into one that looks paid.
|
|
130
|
+
"""
|
|
131
|
+
if os.environ.get("GITHUB_TOKEN"):
|
|
132
|
+
return None
|
|
133
|
+
return (
|
|
134
|
+
"GITHUB_TOKEN is not set. A live search reads GitHub directly; "
|
|
135
|
+
"export a token, or put it in .env, and try again."
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass
|
|
140
|
+
class Search:
|
|
141
|
+
"""A live search, running on a worker thread.
|
|
142
|
+
|
|
143
|
+
The same shape as `holt.tui.session.Session`, for the same reason: sourcing
|
|
144
|
+
twenty-five repositories and reading a page of pull-request threads from
|
|
145
|
+
each is a minute of network, and an interface that blocks on it looks
|
|
146
|
+
broken. The worker appends; the screen reads what it has not drawn yet.
|
|
147
|
+
There is no message schema and no lock — one writer, append-only, and a
|
|
148
|
+
cursor on the reading side, which is how the run stream already works.
|
|
149
|
+
|
|
150
|
+
Rows arrive in the order the search returned them, cut ones included. They
|
|
151
|
+
are deliberately not re-sorted as they land: screening says a candidate is
|
|
152
|
+
worth a closer look, not that it is better than another one, and a list
|
|
153
|
+
that rearranged itself under the cursor would be asserting an order the
|
|
154
|
+
engine never computed.
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
profile: Profile
|
|
158
|
+
limit: int = DEFAULT_LIMIT
|
|
159
|
+
rows: list[Row] = field(default_factory=list)
|
|
160
|
+
#: The searches GitHub was actually asked, filled in before any row lands.
|
|
161
|
+
queries: list[str] = field(default_factory=list)
|
|
162
|
+
as_of: datetime | None = None
|
|
163
|
+
#: How many candidates the search sourced. Zero until sourcing returns.
|
|
164
|
+
total: int = 0
|
|
165
|
+
#: Candidates GitHub would not let us read. Listed, never counted as
|
|
166
|
+
#: rejections — "we could not look" is not "we looked and it failed".
|
|
167
|
+
skipped: list[str] = field(default_factory=list)
|
|
168
|
+
error: str | None = None
|
|
169
|
+
finished: bool = False
|
|
170
|
+
cancelled: bool = False
|
|
171
|
+
_thread: threading.Thread | None = None
|
|
172
|
+
_cancel: threading.Event = field(default_factory=threading.Event)
|
|
173
|
+
|
|
174
|
+
# ─── lifecycle ──────────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
def start(self) -> None:
|
|
177
|
+
if self._thread is not None:
|
|
178
|
+
raise RuntimeError("search already started")
|
|
179
|
+
self._thread = threading.Thread(target=self._run, daemon=True)
|
|
180
|
+
self._thread.start()
|
|
181
|
+
|
|
182
|
+
def cancel(self) -> None:
|
|
183
|
+
"""Stop after the candidate in flight. Rows already screened are kept:
|
|
184
|
+
they were free, and they are still true."""
|
|
185
|
+
self._cancel.set()
|
|
186
|
+
|
|
187
|
+
@property
|
|
188
|
+
def running(self) -> bool:
|
|
189
|
+
return self._thread is not None and self._thread.is_alive()
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def survivors(self) -> list[Row]:
|
|
193
|
+
return [r for r in self.rows if r.survived]
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def screened(self) -> int:
|
|
197
|
+
return len(self.rows)
|
|
198
|
+
|
|
199
|
+
def wait(self, timeout: float | None = None) -> None:
|
|
200
|
+
"""Block until the worker exits. Used by tests."""
|
|
201
|
+
if self._thread is not None:
|
|
202
|
+
self._thread.join(timeout)
|
|
203
|
+
|
|
204
|
+
def describe(self) -> str:
|
|
205
|
+
"""One line of progress, true at every point in the sweep."""
|
|
206
|
+
if self.error:
|
|
207
|
+
return self.error
|
|
208
|
+
if not self.total:
|
|
209
|
+
return "searching GitHub for candidates…"
|
|
210
|
+
head = f"screened {self.screened} of {self.total} at no model cost"
|
|
211
|
+
head += f" · {len(self.survivors)} worth a closer look"
|
|
212
|
+
cut = self.screened - len(self.survivors)
|
|
213
|
+
if cut:
|
|
214
|
+
head += f" · {cut} cut"
|
|
215
|
+
if self.skipped:
|
|
216
|
+
head += f" · {len(self.skipped)} could not be read"
|
|
217
|
+
if self.cancelled:
|
|
218
|
+
head += " · stopped"
|
|
219
|
+
return head
|
|
220
|
+
|
|
221
|
+
# ─── the worker ─────────────────────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
def _run(self) -> None:
|
|
224
|
+
try:
|
|
225
|
+
search = discover.source_live(self.profile, self.limit)
|
|
226
|
+
# Set before the first row so a screen drawing progress can say
|
|
227
|
+
# what was searched for while the slow half is still running.
|
|
228
|
+
self.queries = list(search.queries)
|
|
229
|
+
self.as_of = search.as_of
|
|
230
|
+
self.total = len(search.candidates)
|
|
231
|
+
for step in search.screen(self._cancel.is_set):
|
|
232
|
+
if step.result is None:
|
|
233
|
+
self.skipped.append(step.candidate.slug)
|
|
234
|
+
continue
|
|
235
|
+
self.rows.append(row_for(step.candidate, step.result))
|
|
236
|
+
self.cancelled = self._cancel.is_set()
|
|
237
|
+
except Exception as exc: # noqa: BLE001 - reported, never a traceback
|
|
238
|
+
from holt.tui.session import readable
|
|
239
|
+
|
|
240
|
+
self.error = readable(exc)
|
|
241
|
+
finally:
|
|
242
|
+
self.finished = True
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def manifest_path_exists(name: str = DEFAULT_SESSION) -> bool:
|
|
246
|
+
"""Whether a recorded session is on disk. Used to skip, never to guess."""
|
|
247
|
+
try:
|
|
248
|
+
return discover.manifest_path(name).is_file()
|
|
249
|
+
except OSError:
|
|
250
|
+
return False
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def available() -> list[str]:
|
|
254
|
+
"""Recorded session names, newest first by name."""
|
|
255
|
+
try:
|
|
256
|
+
return sorted(p.stem for p in discover.DISCOVER_ROOT.glob("*.json"))
|
|
257
|
+
except OSError:
|
|
258
|
+
return []
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def load(name: str = DEFAULT_SESSION, days: int | None = None) -> Session:
|
|
262
|
+
"""Replay a recorded session's screening step. No model, no network.
|
|
263
|
+
|
|
264
|
+
Raises `FileNotFoundError` when the session is not on disk, which the screen
|
|
265
|
+
turns into a sentence rather than a traceback.
|
|
266
|
+
"""
|
|
267
|
+
manifest: dict[str, Any] = json.loads(discover.manifest_path(name).read_text())
|
|
268
|
+
as_of = datetime.fromisoformat(manifest["as_of"])
|
|
269
|
+
|
|
270
|
+
from holt.profile import Profile
|
|
271
|
+
|
|
272
|
+
profile = Profile(**manifest["profile"])
|
|
273
|
+
if days:
|
|
274
|
+
profile.days = days
|
|
275
|
+
|
|
276
|
+
provider = FixtureProvider(
|
|
277
|
+
Window.PRE_T, root=discover.screen_root(name), cutoff=as_of
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
rows: list[Row] = []
|
|
281
|
+
skipped: list[str] = []
|
|
282
|
+
for raw in manifest["candidates"]:
|
|
283
|
+
candidate = discover.Candidate(**raw)
|
|
284
|
+
try:
|
|
285
|
+
records = provider.fetch(candidate.slug)
|
|
286
|
+
except FileNotFoundError:
|
|
287
|
+
skipped.append(candidate.slug)
|
|
288
|
+
continue
|
|
289
|
+
# The rule lives in the engine. This asks it; it does not restate it.
|
|
290
|
+
screened = discover.screen_records(candidate, records, profile.days)
|
|
291
|
+
rows.append(row_for(candidate, screened))
|
|
292
|
+
|
|
293
|
+
# Survivors first: they are the ones you can act on. Within each group the
|
|
294
|
+
# search's own order is kept, because reordering would be a claim about
|
|
295
|
+
# which candidate is better and screening does not make one.
|
|
296
|
+
rows.sort(key=lambda r: not r.survived)
|
|
297
|
+
|
|
298
|
+
return Session(
|
|
299
|
+
name=name,
|
|
300
|
+
profile_description=profile.describe(),
|
|
301
|
+
queries=list(manifest.get("queries", [])),
|
|
302
|
+
as_of=as_of,
|
|
303
|
+
rows=rows,
|
|
304
|
+
skipped=skipped,
|
|
305
|
+
)
|
holt/tui/env.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Reading `.env`, for the interface only.
|
|
2
|
+
|
|
3
|
+
`holt analyze` deliberately does not do this: the reproduction path should need
|
|
4
|
+
nothing but `--replay`, and a command whose behaviour depends on an untracked
|
|
5
|
+
file in the working directory is harder to reason about, not easier. The
|
|
6
|
+
interface is the one place where a person is sitting down to do several live
|
|
7
|
+
runs in a row, and re-exporting two variables each time is friction with no
|
|
8
|
+
purpose.
|
|
9
|
+
|
|
10
|
+
Values already in the environment win. `.env` fills gaps; it never overrides
|
|
11
|
+
something the user set deliberately for one run.
|
|
12
|
+
|
|
13
|
+
The file is in `.gitignore`. Nothing here writes to it, prints its contents, or
|
|
14
|
+
puts a value in an event, a log line or the interface.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def load(path: str | Path = ".env") -> list[str]:
|
|
24
|
+
"""Set any variables `.env` defines that are not already set.
|
|
25
|
+
|
|
26
|
+
Returns the names it filled in — names only, never values — so a caller can
|
|
27
|
+
say where a credential came from without disclosing it.
|
|
28
|
+
"""
|
|
29
|
+
file = Path(path)
|
|
30
|
+
if not file.is_file():
|
|
31
|
+
return []
|
|
32
|
+
|
|
33
|
+
filled: list[str] = []
|
|
34
|
+
for raw in file.read_text().splitlines():
|
|
35
|
+
line = raw.strip()
|
|
36
|
+
if not line or line.startswith("#"):
|
|
37
|
+
continue
|
|
38
|
+
if line.startswith("export "):
|
|
39
|
+
line = line[len("export ") :].lstrip()
|
|
40
|
+
name, sep, value = line.partition("=")
|
|
41
|
+
if not sep:
|
|
42
|
+
continue
|
|
43
|
+
name = name.strip()
|
|
44
|
+
value = value.strip().strip('"').strip("'")
|
|
45
|
+
if not name or not value or os.environ.get(name):
|
|
46
|
+
continue
|
|
47
|
+
os.environ[name] = value
|
|
48
|
+
filled.append(name)
|
|
49
|
+
return filled
|