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.
@@ -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