jstdata 0.2.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.
- jstdata/__init__.py +13 -0
- jstdata/agent_guide.py +530 -0
- jstdata/cli.py +682 -0
- jstdata/client.py +543 -0
- jstdata/models.py +161 -0
- jstdata/session.py +251 -0
- jstdata/utils.py +103 -0
- jstdata/workflows/__init__.py +88 -0
- jstdata/workflows/base.py +356 -0
- jstdata/workflows/bundled/__init__.py +1 -0
- jstdata/workflows/bundled/tutorial.yaml +14 -0
- jstdata/workflows/bundled/tutorial_script.yaml +45 -0
- jstdata/workflows/chart.py +357 -0
- jstdata/workflows/console.py +560 -0
- jstdata/workflows/discover.py +502 -0
- jstdata/workflows/export.py +171 -0
- jstdata/workflows/find.py +386 -0
- jstdata/workflows/host.py +255 -0
- jstdata/workflows/rank.py +658 -0
- jstdata/workflows/session_manager.py +277 -0
- jstdata/workflows/store.py +308 -0
- jstdata/workflows/tutorial.py +163 -0
- jstdata/workflows/tutorial_host.py +178 -0
- jstdata-0.2.0.dist-info/METADATA +227 -0
- jstdata-0.2.0.dist-info/RECORD +28 -0
- jstdata-0.2.0.dist-info/WHEEL +4 -0
- jstdata-0.2.0.dist-info/entry_points.txt +3 -0
- jstdata-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
"""Step catalog, pipeline parsing, and shared session helpers.
|
|
2
|
+
|
|
3
|
+
The shell is the composition language::
|
|
4
|
+
|
|
5
|
+
jst run STEP [ARGS...] : STEP [ARGS...] : ...
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from typing import Any, Callable, Dict, Optional, Sequence
|
|
14
|
+
|
|
15
|
+
from ..client import JSTDataClient
|
|
16
|
+
from ..session import Session
|
|
17
|
+
|
|
18
|
+
# (client, session, **kwargs) -> textual.screen.Screen
|
|
19
|
+
CreateScreenFn = Callable[..., Any]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class StepArgument:
|
|
24
|
+
"""One CLI flag that seeds a step's TUI (does not reproduce the UI)."""
|
|
25
|
+
|
|
26
|
+
name: str
|
|
27
|
+
type: str
|
|
28
|
+
description: str
|
|
29
|
+
required: bool = False
|
|
30
|
+
default: Any = None
|
|
31
|
+
choices: Optional[tuple[str, ...]] = None
|
|
32
|
+
multiple: bool = False
|
|
33
|
+
|
|
34
|
+
def flag(self) -> str:
|
|
35
|
+
return "--" + self.name.replace("_", "-")
|
|
36
|
+
|
|
37
|
+
def to_dict(self) -> dict[str, Any]:
|
|
38
|
+
data = {
|
|
39
|
+
"name": self.name,
|
|
40
|
+
"flag": self.flag(),
|
|
41
|
+
"type": self.type,
|
|
42
|
+
"description": self.description,
|
|
43
|
+
"required": self.required,
|
|
44
|
+
"default": self.default,
|
|
45
|
+
"multiple": self.multiple,
|
|
46
|
+
}
|
|
47
|
+
if self.choices is not None:
|
|
48
|
+
data["choices"] = list(self.choices)
|
|
49
|
+
return data
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class StepBinding:
|
|
54
|
+
"""One keybinding a step exposes to the host (help + introspection)."""
|
|
55
|
+
|
|
56
|
+
key: str
|
|
57
|
+
action: str
|
|
58
|
+
description: str
|
|
59
|
+
show_in_help: bool = True
|
|
60
|
+
|
|
61
|
+
def to_dict(self) -> dict[str, Any]:
|
|
62
|
+
return {
|
|
63
|
+
"key": self.key,
|
|
64
|
+
"action": self.action,
|
|
65
|
+
"description": self.description,
|
|
66
|
+
"show_in_help": self.show_in_help,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class StepSpec:
|
|
72
|
+
"""Self-describing interactive step. Catalog + host plug into this."""
|
|
73
|
+
|
|
74
|
+
id: str
|
|
75
|
+
name: str
|
|
76
|
+
description: str
|
|
77
|
+
create_screen: CreateScreenFn
|
|
78
|
+
arguments: tuple[StepArgument, ...] = ()
|
|
79
|
+
bindings: tuple[StepBinding, ...] = ()
|
|
80
|
+
example: str = ""
|
|
81
|
+
|
|
82
|
+
def to_dict(self) -> dict[str, Any]:
|
|
83
|
+
return {
|
|
84
|
+
"id": self.id,
|
|
85
|
+
"name": self.name,
|
|
86
|
+
"description": self.description,
|
|
87
|
+
"arguments": [a.to_dict() for a in self.arguments],
|
|
88
|
+
"bindings": [b.to_dict() for b in self.bindings if b.show_in_help],
|
|
89
|
+
"example": self.example or f"jst run {self.id}",
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(frozen=True)
|
|
94
|
+
class ResolvedStep:
|
|
95
|
+
"""A catalog step plus parsed kwargs from the shell pipeline."""
|
|
96
|
+
|
|
97
|
+
spec: StepSpec
|
|
98
|
+
kwargs: dict[str, Any]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def default_session_path(prefix: str = "session") -> str:
|
|
102
|
+
"""Unique-by-default session filename to avoid write conflicts."""
|
|
103
|
+
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
104
|
+
safe = prefix.replace(":", "-") or "session"
|
|
105
|
+
return f"{safe}-{stamp}.json"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def apply_loaded_session(target: Session, loaded: Session) -> None:
|
|
109
|
+
"""Copy resource selectors and filters from ``loaded`` into ``target``."""
|
|
110
|
+
target.metric = list(loaded.metric)
|
|
111
|
+
target.entity = list(loaded.entity)
|
|
112
|
+
target.series = list(loaded.series)
|
|
113
|
+
target.frequency = loaded.frequency
|
|
114
|
+
target.taxonomy = loaded.taxonomy
|
|
115
|
+
target.head = loaded.head
|
|
116
|
+
target.tail = loaded.tail
|
|
117
|
+
target.as_of = loaded.as_of
|
|
118
|
+
target.sort_by = loaded.sort_by
|
|
119
|
+
target.start_date = loaded.start_date
|
|
120
|
+
target.end_date = loaded.end_date
|
|
121
|
+
target.start_time = loaded.start_time
|
|
122
|
+
target.end_time = loaded.end_time
|
|
123
|
+
target.order_by = loaded.order_by
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def load_session_or_empty(path: Optional[str]) -> Session:
|
|
127
|
+
"""Load a session from disk, or return an empty session."""
|
|
128
|
+
if not path:
|
|
129
|
+
return Session()
|
|
130
|
+
return Session.load(path)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def resource_type_for_id(session: Session, resource_id: str) -> str:
|
|
134
|
+
"""Return metric|entity|series for an id present in the session."""
|
|
135
|
+
if resource_id in session.metric:
|
|
136
|
+
return "metric"
|
|
137
|
+
if resource_id in session.entity:
|
|
138
|
+
return "entity"
|
|
139
|
+
if resource_id in session.series:
|
|
140
|
+
return "series"
|
|
141
|
+
return "series"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def hydrate_labels(
|
|
145
|
+
client: JSTDataClient,
|
|
146
|
+
session: Session,
|
|
147
|
+
labels: dict[str, str],
|
|
148
|
+
) -> None:
|
|
149
|
+
"""Fill missing UI labels for session resource IDs (in-place)."""
|
|
150
|
+
missing = [rid for rid in session.resource_ids() if rid not in labels]
|
|
151
|
+
if not missing:
|
|
152
|
+
return
|
|
153
|
+
for resource in client.get_resources(missing):
|
|
154
|
+
labels[resource.id] = resource.label or resource.id
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def label_for(labels: dict[str, str], resource_id: str) -> str:
|
|
158
|
+
"""Display name for a resource id; falls back to the id itself."""
|
|
159
|
+
return labels.get(resource_id, resource_id)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
_REGISTRY: Dict[str, StepSpec] = {}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def register(spec: StepSpec) -> StepSpec:
|
|
166
|
+
"""Register a step (idempotent by id)."""
|
|
167
|
+
_REGISTRY[spec.id] = spec
|
|
168
|
+
return spec
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def get_step(step_id: str) -> StepSpec:
|
|
172
|
+
try:
|
|
173
|
+
return _REGISTRY[step_id]
|
|
174
|
+
except KeyError as exc:
|
|
175
|
+
known = ", ".join(sorted(_REGISTRY)) or "(none)"
|
|
176
|
+
raise KeyError(f"Unknown step {step_id!r}. Available: {known}") from exc
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def list_steps() -> list[StepSpec]:
|
|
180
|
+
return [_REGISTRY[k] for k in sorted(_REGISTRY)]
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class PipelineError(ValueError):
|
|
184
|
+
"""Invalid ``jst run`` pipeline."""
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def split_pipeline(tokens: Sequence[str]) -> list[list[str]]:
|
|
188
|
+
"""Split argv on standalone ``:`` into per-step token groups."""
|
|
189
|
+
if not tokens:
|
|
190
|
+
raise PipelineError("No steps given. Example: jst run console")
|
|
191
|
+
|
|
192
|
+
chunks: list[list[str]] = []
|
|
193
|
+
current: list[str] = []
|
|
194
|
+
for tok in tokens:
|
|
195
|
+
if tok == ":":
|
|
196
|
+
if not current:
|
|
197
|
+
raise PipelineError("Empty step in pipeline (stray ':').")
|
|
198
|
+
chunks.append(current)
|
|
199
|
+
current = []
|
|
200
|
+
else:
|
|
201
|
+
current.append(tok)
|
|
202
|
+
if not current:
|
|
203
|
+
raise PipelineError("Trailing ':' with no step.")
|
|
204
|
+
chunks.append(current)
|
|
205
|
+
return chunks
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _coerce(arg: StepArgument, raw: str) -> Any:
|
|
209
|
+
if arg.choices is not None and raw not in arg.choices:
|
|
210
|
+
allowed = ", ".join(arg.choices)
|
|
211
|
+
raise PipelineError(
|
|
212
|
+
f"Invalid value {raw!r} for {arg.flag()}. Choices: {allowed}"
|
|
213
|
+
)
|
|
214
|
+
if arg.type in ("integer", "int"):
|
|
215
|
+
try:
|
|
216
|
+
return int(raw)
|
|
217
|
+
except ValueError as exc:
|
|
218
|
+
raise PipelineError(f"{arg.flag()} expects an integer, got {raw!r}") from exc
|
|
219
|
+
if arg.type in ("boolean", "bool"):
|
|
220
|
+
lowered = raw.lower()
|
|
221
|
+
if lowered in ("1", "true", "yes"):
|
|
222
|
+
return True
|
|
223
|
+
if lowered in ("0", "false", "no"):
|
|
224
|
+
return False
|
|
225
|
+
raise PipelineError(f"{arg.flag()} expects a boolean, got {raw!r}")
|
|
226
|
+
return raw
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def parse_step_kwargs(spec: StepSpec, tokens: Sequence[str]) -> dict[str, Any]:
|
|
230
|
+
"""Parse a step's flag tokens against its declared arguments."""
|
|
231
|
+
by_flag = {a.flag(): a for a in spec.arguments}
|
|
232
|
+
kwargs: dict[str, Any] = {}
|
|
233
|
+
i = 0
|
|
234
|
+
while i < len(tokens):
|
|
235
|
+
tok = tokens[i]
|
|
236
|
+
flag = tok
|
|
237
|
+
inline: Optional[str] = None
|
|
238
|
+
if tok.startswith("--") and "=" in tok:
|
|
239
|
+
flag, inline = tok.split("=", 1)
|
|
240
|
+
arg = by_flag.get(flag)
|
|
241
|
+
if arg is None:
|
|
242
|
+
raise PipelineError(
|
|
243
|
+
f"Unknown option {flag} for step {spec.id!r}. See: jst step {spec.id}"
|
|
244
|
+
)
|
|
245
|
+
if arg.type in ("boolean", "bool") and inline is None:
|
|
246
|
+
kwargs[arg.name] = True
|
|
247
|
+
i += 1
|
|
248
|
+
continue
|
|
249
|
+
if inline is not None:
|
|
250
|
+
value = _coerce(arg, inline)
|
|
251
|
+
i += 1
|
|
252
|
+
else:
|
|
253
|
+
if i + 1 >= len(tokens):
|
|
254
|
+
raise PipelineError(f"{flag} requires a value.")
|
|
255
|
+
value = _coerce(arg, tokens[i + 1])
|
|
256
|
+
i += 2
|
|
257
|
+
if arg.multiple:
|
|
258
|
+
kwargs.setdefault(arg.name, []).append(value)
|
|
259
|
+
else:
|
|
260
|
+
kwargs[arg.name] = value
|
|
261
|
+
|
|
262
|
+
for arg in spec.arguments:
|
|
263
|
+
if arg.name in kwargs:
|
|
264
|
+
continue
|
|
265
|
+
if arg.required:
|
|
266
|
+
raise PipelineError(
|
|
267
|
+
f"Step {spec.id!r} requires {arg.flag()}. See: jst step {spec.id}"
|
|
268
|
+
)
|
|
269
|
+
if arg.default is not None:
|
|
270
|
+
kwargs[arg.name] = arg.default
|
|
271
|
+
return kwargs
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def resolve_pipeline(tokens: Sequence[str]) -> list[ResolvedStep]:
|
|
275
|
+
"""Turn raw ``jst run`` tokens into catalog steps + kwargs."""
|
|
276
|
+
resolved: list[ResolvedStep] = []
|
|
277
|
+
for chunk in split_pipeline(tokens):
|
|
278
|
+
step_id, *rest = chunk
|
|
279
|
+
spec = get_step(step_id)
|
|
280
|
+
kwargs = parse_step_kwargs(spec, rest)
|
|
281
|
+
resolved.append(ResolvedStep(spec=spec, kwargs=kwargs))
|
|
282
|
+
return resolved
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def format_step_help(spec: StepSpec) -> str:
|
|
286
|
+
"""Human-readable ``jst step <id>`` text."""
|
|
287
|
+
lines = [
|
|
288
|
+
spec.name,
|
|
289
|
+
"",
|
|
290
|
+
spec.description,
|
|
291
|
+
"",
|
|
292
|
+
"Options:",
|
|
293
|
+
]
|
|
294
|
+
if spec.arguments:
|
|
295
|
+
for arg in spec.arguments:
|
|
296
|
+
req = " (required)" if arg.required else ""
|
|
297
|
+
default = f" [default: {arg.default}]" if arg.default is not None else ""
|
|
298
|
+
lines.append(f" {arg.flag()} {arg.type.upper()}{req}{default}")
|
|
299
|
+
lines.append(f" {arg.description}")
|
|
300
|
+
if arg.multiple:
|
|
301
|
+
lines.append(" Repeatable.")
|
|
302
|
+
if arg.choices:
|
|
303
|
+
lines.append(f" Choices: {', '.join(arg.choices)}")
|
|
304
|
+
lines.append("")
|
|
305
|
+
else:
|
|
306
|
+
lines.append(" (none)")
|
|
307
|
+
lines.append("")
|
|
308
|
+
|
|
309
|
+
lines.append("Keybindings:")
|
|
310
|
+
visible = [b for b in spec.bindings if b.show_in_help]
|
|
311
|
+
if visible:
|
|
312
|
+
for b in visible:
|
|
313
|
+
lines.append(f" {b.key:<16} {b.description}")
|
|
314
|
+
lines.append("")
|
|
315
|
+
else:
|
|
316
|
+
lines.append(" (none)")
|
|
317
|
+
lines.append("")
|
|
318
|
+
|
|
319
|
+
lines.append("Example:")
|
|
320
|
+
lines.append(f" {spec.example or f'jst run {spec.id}'}")
|
|
321
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def run_pipeline(
|
|
325
|
+
client: JSTDataClient,
|
|
326
|
+
tokens: Sequence[str],
|
|
327
|
+
*,
|
|
328
|
+
session_path: Optional[str] = None,
|
|
329
|
+
output_path: Optional[str] = None,
|
|
330
|
+
) -> None:
|
|
331
|
+
"""Launch the host over a resolved step pipeline."""
|
|
332
|
+
run_resolved_pipeline(
|
|
333
|
+
client,
|
|
334
|
+
resolve_pipeline(tokens),
|
|
335
|
+
session_path=session_path,
|
|
336
|
+
output_path=output_path,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def run_resolved_pipeline(
|
|
341
|
+
client: JSTDataClient,
|
|
342
|
+
steps: list[ResolvedStep],
|
|
343
|
+
*,
|
|
344
|
+
session_path: Optional[str] = None,
|
|
345
|
+
output_path: Optional[str] = None,
|
|
346
|
+
) -> None:
|
|
347
|
+
"""Launch the host over an already-resolved step list (no re-parse)."""
|
|
348
|
+
from .host import WorkflowHost
|
|
349
|
+
|
|
350
|
+
app = WorkflowHost(
|
|
351
|
+
client,
|
|
352
|
+
steps,
|
|
353
|
+
session_path=session_path,
|
|
354
|
+
output_path=output_path,
|
|
355
|
+
)
|
|
356
|
+
app.run()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Built-in workflow definitions shipped with jstdata."""
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
id: tutorial
|
|
2
|
+
description: Compare Apple, Microsoft, and Amazon — your first JST investigation.
|
|
3
|
+
steps:
|
|
4
|
+
- id: console
|
|
5
|
+
args:
|
|
6
|
+
taxonomy: sec-central-index-key
|
|
7
|
+
resource_type: entity
|
|
8
|
+
relation:
|
|
9
|
+
- classified_as:sic:7372
|
|
10
|
+
- classified_as:sic:5961
|
|
11
|
+
- classified_as:sic:3571
|
|
12
|
+
- id: discover
|
|
13
|
+
args:
|
|
14
|
+
mode: union
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
phases:
|
|
2
|
+
- text: |
|
|
3
|
+
HELLO, WORLD — your first investigation.
|
|
4
|
+
You'll compare Apple, Microsoft, and Amazon.
|
|
5
|
+
until:
|
|
6
|
+
auto: true
|
|
7
|
+
|
|
8
|
+
- text: |
|
|
9
|
+
Search for "Apple" and press Enter to add it.
|
|
10
|
+
step_index: 0
|
|
11
|
+
until:
|
|
12
|
+
session_entity_count: 1
|
|
13
|
+
|
|
14
|
+
- text: |
|
|
15
|
+
Add Microsoft and Amazon the same way.
|
|
16
|
+
step_index: 0
|
|
17
|
+
until:
|
|
18
|
+
session_entity_count: 3
|
|
19
|
+
|
|
20
|
+
- text: |
|
|
21
|
+
Continue to metric discovery. Press n.
|
|
22
|
+
step_index: 0
|
|
23
|
+
until:
|
|
24
|
+
action: next_step
|
|
25
|
+
|
|
26
|
+
- text: |
|
|
27
|
+
Pick a metric for these companies. Press Space to add it to your session.
|
|
28
|
+
step_index: 1
|
|
29
|
+
until:
|
|
30
|
+
session_metric_count: 1
|
|
31
|
+
|
|
32
|
+
- text: |
|
|
33
|
+
Open your session. Press s.
|
|
34
|
+
until:
|
|
35
|
+
action: open_session
|
|
36
|
+
|
|
37
|
+
- text: |
|
|
38
|
+
Export your session. Press e, then click Write.
|
|
39
|
+
until:
|
|
40
|
+
action: session_written
|
|
41
|
+
|
|
42
|
+
- text: |
|
|
43
|
+
Tutorial complete. This is your session — keep exploring.
|
|
44
|
+
until:
|
|
45
|
+
done: true
|