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
jstdata/cli.py
ADDED
|
@@ -0,0 +1,682 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import click
|
|
3
|
+
from typing import List, Union, Optional
|
|
4
|
+
|
|
5
|
+
from .client import ApiKeyNotSetError, JSTDataClient, InvalidApiKeyError
|
|
6
|
+
from .utils import common_params, common_search_params, format_and_print
|
|
7
|
+
|
|
8
|
+
client = JSTDataClient()
|
|
9
|
+
|
|
10
|
+
def resolve_id(value: str, search_func) -> str:
|
|
11
|
+
"""
|
|
12
|
+
Helper to resolve a potential ID or search for it.
|
|
13
|
+
Emphasizes 'Intent over IDs'.
|
|
14
|
+
"""
|
|
15
|
+
# Simple heuristic: if it looks like an ID (no spaces, all lowercase/dashes),
|
|
16
|
+
# we might still want to check if it's valid, but for now let's just use search
|
|
17
|
+
# if it's not a perfect match.
|
|
18
|
+
# In a real scenario, we might try a direct lookup first.
|
|
19
|
+
try:
|
|
20
|
+
results = search_func(value, limit=1)
|
|
21
|
+
if results:
|
|
22
|
+
return results[0].id
|
|
23
|
+
except Exception:
|
|
24
|
+
pass
|
|
25
|
+
return value
|
|
26
|
+
|
|
27
|
+
@click.group()
|
|
28
|
+
def cli():
|
|
29
|
+
"""
|
|
30
|
+
Jefferson Street CLI - A Research OS for Financial Data.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
@cli.command()
|
|
34
|
+
@click.option("--api-key", prompt="Enter your Jefferson Street API Key", hide_input=True, help="Your API Key")
|
|
35
|
+
def login(api_key):
|
|
36
|
+
"""
|
|
37
|
+
Authenticate with the Jefferson Street API.
|
|
38
|
+
"""
|
|
39
|
+
click.echo("Validating API key...")
|
|
40
|
+
try:
|
|
41
|
+
if client.validate_key(api_key):
|
|
42
|
+
click.secho("Success! Authenticated.", fg="green")
|
|
43
|
+
client._cfg.write(api_key=api_key)
|
|
44
|
+
from .client import CONFIG_FILE
|
|
45
|
+
click.echo(f"Configuration saved to {CONFIG_FILE.absolute()}")
|
|
46
|
+
else:
|
|
47
|
+
click.secho("Error: Invalid API key.", fg="red", err=True)
|
|
48
|
+
sys.exit(1)
|
|
49
|
+
except Exception as e:
|
|
50
|
+
click.secho(f"Error during validation: {e}", fg="red", err=True)
|
|
51
|
+
sys.exit(1)
|
|
52
|
+
|
|
53
|
+
@cli.group()
|
|
54
|
+
def config():
|
|
55
|
+
"""
|
|
56
|
+
Manage CLI configuration.
|
|
57
|
+
"""
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
@config.command("show")
|
|
61
|
+
@click.option("--verbose", "-v", is_flag=True, help="Show advanced configuration like base URL")
|
|
62
|
+
def config_show(verbose):
|
|
63
|
+
"""
|
|
64
|
+
Display current configuration.
|
|
65
|
+
"""
|
|
66
|
+
if verbose:
|
|
67
|
+
click.echo(f"Base URL: {client.base_url}")
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
key = client.api_key
|
|
71
|
+
masked_key = f"{key[:4]}...{key[-4:]}"
|
|
72
|
+
click.echo(f"API Key: {masked_key}")
|
|
73
|
+
except ApiKeyNotSetError:
|
|
74
|
+
click.echo("API Key: Not set")
|
|
75
|
+
|
|
76
|
+
@config.command("set")
|
|
77
|
+
@click.argument("key", type=click.Choice(["api_key", "base_url"]))
|
|
78
|
+
@click.argument("value")
|
|
79
|
+
def config_set(key, value):
|
|
80
|
+
"""
|
|
81
|
+
Update a configuration value. Valid keys are "api_key" and "base_url".
|
|
82
|
+
"""
|
|
83
|
+
client._cfg.write(**{key: value})
|
|
84
|
+
click.echo(f"Set {key} to {value}")
|
|
85
|
+
|
|
86
|
+
# --- Metric Commands ---
|
|
87
|
+
|
|
88
|
+
@cli.group()
|
|
89
|
+
def metric():
|
|
90
|
+
"""Commands for interacting with Metrics (themes)."""
|
|
91
|
+
|
|
92
|
+
@metric.command("ls")
|
|
93
|
+
@common_params
|
|
94
|
+
def list_metrics(limit, offset, format):
|
|
95
|
+
"""List all available metrics."""
|
|
96
|
+
results = client.list_metrics(limit=limit, offset=offset)
|
|
97
|
+
format_and_print(results, format)
|
|
98
|
+
|
|
99
|
+
@metric.command("show")
|
|
100
|
+
@click.argument("id")
|
|
101
|
+
@click.option("--format", default="pretty")
|
|
102
|
+
def show_metric(id, format):
|
|
103
|
+
"""Show details for a specific metric."""
|
|
104
|
+
results = client.get_metric(id)
|
|
105
|
+
format_and_print(results, format)
|
|
106
|
+
|
|
107
|
+
@metric.command("search")
|
|
108
|
+
@click.argument("query", required=False, default=None)
|
|
109
|
+
@click.option("--taxonomy", help="Restrict search to metrics with series in a taxonomy")
|
|
110
|
+
@click.option(
|
|
111
|
+
"--entity",
|
|
112
|
+
multiple=True,
|
|
113
|
+
help="Restrict to metrics associated with these entities. Repeatable.",
|
|
114
|
+
)
|
|
115
|
+
@click.option(
|
|
116
|
+
"--mode",
|
|
117
|
+
type=click.Choice(["union", "intersect"]),
|
|
118
|
+
default="union",
|
|
119
|
+
help="How to combine multiple --entity values",
|
|
120
|
+
)
|
|
121
|
+
@common_search_params
|
|
122
|
+
def search_metrics(query, taxonomy, entity, mode, limit, offset, format):
|
|
123
|
+
"""Search for metrics by intent. Omit QUERY to list the matching set."""
|
|
124
|
+
results = client.search_metrics(
|
|
125
|
+
query,
|
|
126
|
+
entity=list(entity) or None,
|
|
127
|
+
taxonomy=taxonomy,
|
|
128
|
+
mode=mode,
|
|
129
|
+
limit=limit,
|
|
130
|
+
offset=offset,
|
|
131
|
+
)
|
|
132
|
+
format_and_print(results, format)
|
|
133
|
+
|
|
134
|
+
@metric.command("series")
|
|
135
|
+
@click.argument("id")
|
|
136
|
+
@common_params
|
|
137
|
+
def metric_series(id, limit, offset, format):
|
|
138
|
+
"""List all series associated with a metric."""
|
|
139
|
+
results = client.get_metric_series(id, limit=limit, offset=offset)
|
|
140
|
+
format_and_print(results, format)
|
|
141
|
+
|
|
142
|
+
# --- Entity Commands ---
|
|
143
|
+
|
|
144
|
+
@cli.group()
|
|
145
|
+
def entity():
|
|
146
|
+
"""Commands for interacting with Entities (contexts)."""
|
|
147
|
+
|
|
148
|
+
@entity.command("show")
|
|
149
|
+
@click.argument("id")
|
|
150
|
+
@click.option("--format", default="pretty")
|
|
151
|
+
def show_entity(id, format):
|
|
152
|
+
"""Show details for a specific entity."""
|
|
153
|
+
results = client.get_entity(id)
|
|
154
|
+
format_and_print(results, format)
|
|
155
|
+
|
|
156
|
+
@entity.command("search")
|
|
157
|
+
@click.argument("query", required=False, default=None)
|
|
158
|
+
@click.option("--taxonomy", help="Restrict search to entities in a taxonomy")
|
|
159
|
+
@click.option(
|
|
160
|
+
"--relation",
|
|
161
|
+
multiple=True,
|
|
162
|
+
help="Filter by relationship anchor (<relationship_type>:<to_entity_id>). Repeatable; OR'd.",
|
|
163
|
+
)
|
|
164
|
+
@click.option(
|
|
165
|
+
"--metric",
|
|
166
|
+
multiple=True,
|
|
167
|
+
help="Restrict to entities associated with these metrics. Repeatable.",
|
|
168
|
+
)
|
|
169
|
+
@click.option(
|
|
170
|
+
"--mode",
|
|
171
|
+
type=click.Choice(["union", "intersect"]),
|
|
172
|
+
default="union",
|
|
173
|
+
help="How to combine multiple --metric values",
|
|
174
|
+
)
|
|
175
|
+
@common_search_params
|
|
176
|
+
def search_entities(query, taxonomy, relation, metric, mode, limit, offset, format):
|
|
177
|
+
"""Search for entities by intent. Omit QUERY to list the matching set."""
|
|
178
|
+
results = client.search_entities(
|
|
179
|
+
query,
|
|
180
|
+
metric=list(metric) or None,
|
|
181
|
+
taxonomy=taxonomy,
|
|
182
|
+
relation=list(relation) or None,
|
|
183
|
+
mode=mode,
|
|
184
|
+
limit=limit,
|
|
185
|
+
offset=offset,
|
|
186
|
+
)
|
|
187
|
+
format_and_print(results, format)
|
|
188
|
+
|
|
189
|
+
@entity.command("series")
|
|
190
|
+
@click.argument("id")
|
|
191
|
+
@common_params
|
|
192
|
+
def entity_series(id, limit, offset, format):
|
|
193
|
+
"""List all series associated with an entity."""
|
|
194
|
+
results = client.get_entity_series(id, limit=limit, offset=offset)
|
|
195
|
+
format_and_print(results, format)
|
|
196
|
+
|
|
197
|
+
@entity.command("relations")
|
|
198
|
+
@click.argument("id")
|
|
199
|
+
@common_params
|
|
200
|
+
def entity_relations(id, limit, offset, format):
|
|
201
|
+
"""Walk the entity graph."""
|
|
202
|
+
results = client.get_entity_relations(id, limit=limit, offset=offset)
|
|
203
|
+
format_and_print(results, format)
|
|
204
|
+
|
|
205
|
+
# --- Taxonomy Commands ---
|
|
206
|
+
|
|
207
|
+
@cli.group()
|
|
208
|
+
def taxonomy():
|
|
209
|
+
"""Commands for interacting with Taxonomies (membership catalogs)."""
|
|
210
|
+
|
|
211
|
+
@taxonomy.command("ls")
|
|
212
|
+
@common_params
|
|
213
|
+
def list_taxonomies(limit, offset, format):
|
|
214
|
+
"""List available taxonomies."""
|
|
215
|
+
results = client.list_taxonomies(limit=limit, offset=offset)
|
|
216
|
+
format_and_print(results, format)
|
|
217
|
+
|
|
218
|
+
@taxonomy.command("show")
|
|
219
|
+
@click.argument("id")
|
|
220
|
+
@click.option("--format", default="pretty")
|
|
221
|
+
def show_taxonomy(id, format):
|
|
222
|
+
"""Show details for a specific taxonomy."""
|
|
223
|
+
results = client.get_taxonomy(id)
|
|
224
|
+
format_and_print(results, format)
|
|
225
|
+
|
|
226
|
+
@taxonomy.command("entities")
|
|
227
|
+
@click.argument("id")
|
|
228
|
+
@common_params
|
|
229
|
+
def taxonomy_entities(id, limit, offset, format):
|
|
230
|
+
"""List entities that participate in a taxonomy."""
|
|
231
|
+
results = client.get_taxonomy_entities(id, limit=limit, offset=offset)
|
|
232
|
+
format_and_print(results, format)
|
|
233
|
+
|
|
234
|
+
@taxonomy.command("metrics")
|
|
235
|
+
@click.argument("id")
|
|
236
|
+
@common_params
|
|
237
|
+
def taxonomy_metrics(id, limit, offset, format):
|
|
238
|
+
"""List metrics with series on entities in a taxonomy."""
|
|
239
|
+
results = client.get_taxonomy_metrics(id, limit=limit, offset=offset)
|
|
240
|
+
format_and_print(results, format)
|
|
241
|
+
|
|
242
|
+
# --- Series Commands ---
|
|
243
|
+
|
|
244
|
+
@cli.group()
|
|
245
|
+
def series():
|
|
246
|
+
"""Commands for interacting with Series (data points)."""
|
|
247
|
+
|
|
248
|
+
@series.command("ls")
|
|
249
|
+
@common_params
|
|
250
|
+
def list_series(limit, offset, format):
|
|
251
|
+
"""List all available series."""
|
|
252
|
+
results = client.list_series(limit=limit, offset=offset)
|
|
253
|
+
format_and_print(results, format)
|
|
254
|
+
|
|
255
|
+
@series.command("show")
|
|
256
|
+
@click.argument("id")
|
|
257
|
+
@click.option("--format", default="pretty")
|
|
258
|
+
def show_series(id, format):
|
|
259
|
+
"""Show details for a specific series."""
|
|
260
|
+
results = client.get_series(id)
|
|
261
|
+
format_and_print(results, format)
|
|
262
|
+
|
|
263
|
+
@series.command("search")
|
|
264
|
+
@click.argument("query")
|
|
265
|
+
@common_search_params
|
|
266
|
+
def search_series(query, limit, offset, format):
|
|
267
|
+
"""Search for series by intent."""
|
|
268
|
+
results = client.search_series(query, limit=limit, offset=offset)
|
|
269
|
+
format_and_print(results, format)
|
|
270
|
+
|
|
271
|
+
@series.command("observations")
|
|
272
|
+
@click.argument("id")
|
|
273
|
+
@click.option("--start-date", help="Start date (YYYY-MM-DD)")
|
|
274
|
+
@click.option("--end-date", help="End date (YYYY-MM-DD)")
|
|
275
|
+
@click.option("--start-time", type=int, help="Start time (unix timestamp)")
|
|
276
|
+
@click.option("--end-time", type=int, help="End time (unix timestamp)")
|
|
277
|
+
@click.option(
|
|
278
|
+
"--order-by",
|
|
279
|
+
type=click.Choice(["asc", "desc"]),
|
|
280
|
+
default="asc",
|
|
281
|
+
help="Sort observations by timestamp",
|
|
282
|
+
)
|
|
283
|
+
@common_params
|
|
284
|
+
def series_observations(
|
|
285
|
+
id, start_date, end_date, start_time, end_time, order_by, limit, offset, format
|
|
286
|
+
):
|
|
287
|
+
"""Paginated history for one series."""
|
|
288
|
+
results = client.get_series_observations(
|
|
289
|
+
id,
|
|
290
|
+
start_date=start_date,
|
|
291
|
+
end_date=end_date,
|
|
292
|
+
start_time=start_time,
|
|
293
|
+
end_time=end_time,
|
|
294
|
+
order_by=order_by,
|
|
295
|
+
limit=limit,
|
|
296
|
+
offset=offset,
|
|
297
|
+
)
|
|
298
|
+
format_and_print(results, format)
|
|
299
|
+
|
|
300
|
+
# --- Query Command ---
|
|
301
|
+
|
|
302
|
+
@cli.command()
|
|
303
|
+
@click.option("--metric", multiple=True, help="Metric ID(s) or keywords")
|
|
304
|
+
@click.option("--entity", multiple=True, help="Entity ID(s) or keywords")
|
|
305
|
+
@click.option("--series", multiple=True, help="Series ID(s) or keywords")
|
|
306
|
+
@click.option("--frequency", type=click.Choice(["Annual", "Quarterly", "Monthly", "Daily", "Intraday"]))
|
|
307
|
+
@click.option(
|
|
308
|
+
"--taxonomy",
|
|
309
|
+
help="Restrict to series whose entities have an identity relation to this taxonomy",
|
|
310
|
+
)
|
|
311
|
+
@click.option("--head", type=int, help="Earliest N observations per series")
|
|
312
|
+
@click.option("--tail", type=int, help="Latest N observations per series (default 20)")
|
|
313
|
+
@click.option("--as-of", "as_of", help="Timezone-aware ISO-8601 cutoff (release_timestamp)")
|
|
314
|
+
@click.option(
|
|
315
|
+
"--sort-by",
|
|
316
|
+
"sort_by",
|
|
317
|
+
type=click.Choice(["id", "value"]),
|
|
318
|
+
default="id",
|
|
319
|
+
help="Order series by id (default) or by last value in the window (desc)",
|
|
320
|
+
)
|
|
321
|
+
@click.option("--fuzzy", is_flag=True, default=True, help="Try to resolve keywords to IDs automatically")
|
|
322
|
+
@click.option(
|
|
323
|
+
"--limit",
|
|
324
|
+
default=50,
|
|
325
|
+
help="Maximum number of series to return (default: 50, max: 50)",
|
|
326
|
+
)
|
|
327
|
+
@click.option("--offset", default=0, help="Number of series to skip (default: 0)")
|
|
328
|
+
@click.option(
|
|
329
|
+
"--format",
|
|
330
|
+
default="pretty",
|
|
331
|
+
help="Output format. Valid formats are: json, csv, pretty.",
|
|
332
|
+
)
|
|
333
|
+
def query(
|
|
334
|
+
metric,
|
|
335
|
+
entity,
|
|
336
|
+
series,
|
|
337
|
+
frequency,
|
|
338
|
+
taxonomy,
|
|
339
|
+
head,
|
|
340
|
+
tail,
|
|
341
|
+
as_of,
|
|
342
|
+
sort_by,
|
|
343
|
+
fuzzy,
|
|
344
|
+
limit,
|
|
345
|
+
offset,
|
|
346
|
+
format,
|
|
347
|
+
):
|
|
348
|
+
"""
|
|
349
|
+
Bounded cross-sectional query. Mix metrics, entities, and series.
|
|
350
|
+
|
|
351
|
+
Uses head/tail per series (not a date window). For deep history of one
|
|
352
|
+
series, use `jst series observations`. Use --sort-by value with
|
|
353
|
+
--taxonomy to rank a population (e.g. country GDP).
|
|
354
|
+
"""
|
|
355
|
+
m_ids = list(metric)
|
|
356
|
+
e_ids = list(entity)
|
|
357
|
+
s_ids = list(series)
|
|
358
|
+
|
|
359
|
+
if fuzzy:
|
|
360
|
+
m_ids = [resolve_id(m, client.search_metrics) for m in m_ids]
|
|
361
|
+
e_ids = [resolve_id(e, client.search_entities) for e in e_ids]
|
|
362
|
+
s_ids = [resolve_id(s, client.search_series) for s in s_ids]
|
|
363
|
+
|
|
364
|
+
results = client.query(
|
|
365
|
+
metric=m_ids or None,
|
|
366
|
+
entity=e_ids or None,
|
|
367
|
+
series=s_ids or None,
|
|
368
|
+
frequency=frequency,
|
|
369
|
+
taxonomy=taxonomy,
|
|
370
|
+
head=head,
|
|
371
|
+
tail=tail,
|
|
372
|
+
as_of=as_of,
|
|
373
|
+
sort_by=sort_by,
|
|
374
|
+
limit=limit,
|
|
375
|
+
offset=offset,
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
format_and_print(results, format)
|
|
379
|
+
|
|
380
|
+
@cli.command("tutorial")
|
|
381
|
+
@click.option(
|
|
382
|
+
"--session",
|
|
383
|
+
type=click.Path(exists=True, dir_okay=False, path_type=str),
|
|
384
|
+
help="Preload a session JSON into the pipeline",
|
|
385
|
+
)
|
|
386
|
+
@click.option(
|
|
387
|
+
"--output",
|
|
388
|
+
type=click.Path(dir_okay=False, path_type=str),
|
|
389
|
+
help="Default path for export session writes",
|
|
390
|
+
)
|
|
391
|
+
def tutorial_cmd(session: str | None, output: str | None) -> None:
|
|
392
|
+
"""Run the built-in interactive tutorial (alias for workflow run tutorial)."""
|
|
393
|
+
from .workflows import run_tutorial
|
|
394
|
+
|
|
395
|
+
run_tutorial(client, session_path=session, output_path=output)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
@cli.command("agent-guide")
|
|
399
|
+
def agent_guide_cmd() -> None:
|
|
400
|
+
"""Print a markdown bootstrap guide for agents (version-tied)."""
|
|
401
|
+
from .agent_guide import render_agent_guide
|
|
402
|
+
|
|
403
|
+
click.echo(render_agent_guide(cli), nl=False)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
@cli.command("steps")
|
|
407
|
+
@click.option("--json", "as_json", is_flag=True, help="Machine-readable catalog")
|
|
408
|
+
def steps_cmd(as_json: bool) -> None:
|
|
409
|
+
"""List available investigation steps."""
|
|
410
|
+
import json as json_lib
|
|
411
|
+
|
|
412
|
+
from .workflows import list_steps
|
|
413
|
+
|
|
414
|
+
specs = list_steps()
|
|
415
|
+
if as_json:
|
|
416
|
+
click.echo(json_lib.dumps([s.to_dict() for s in specs], indent=2))
|
|
417
|
+
return
|
|
418
|
+
if not specs:
|
|
419
|
+
click.echo("No steps registered.")
|
|
420
|
+
return
|
|
421
|
+
for spec in specs:
|
|
422
|
+
click.echo(f"{spec.id:20} {spec.name} — {spec.description}")
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
@cli.command("step")
|
|
426
|
+
@click.argument("step_id")
|
|
427
|
+
@click.option("--json", "as_json", is_flag=True, help="Machine-readable step metadata")
|
|
428
|
+
def step_cmd(step_id: str, as_json: bool) -> None:
|
|
429
|
+
"""Describe a step: arguments, requirements, example."""
|
|
430
|
+
import json as json_lib
|
|
431
|
+
|
|
432
|
+
from .workflows import format_step_help, get_step
|
|
433
|
+
|
|
434
|
+
try:
|
|
435
|
+
spec = get_step(step_id)
|
|
436
|
+
except KeyError as e:
|
|
437
|
+
click.echo(str(e), err=True)
|
|
438
|
+
sys.exit(1)
|
|
439
|
+
if as_json:
|
|
440
|
+
click.echo(json_lib.dumps(spec.to_dict(), indent=2))
|
|
441
|
+
return
|
|
442
|
+
click.echo(format_step_help(spec), nl=False)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
@cli.command(
|
|
446
|
+
"run",
|
|
447
|
+
context_settings={
|
|
448
|
+
"ignore_unknown_options": True,
|
|
449
|
+
"allow_extra_args": True,
|
|
450
|
+
"allow_interspersed_args": False,
|
|
451
|
+
},
|
|
452
|
+
)
|
|
453
|
+
@click.option(
|
|
454
|
+
"--session",
|
|
455
|
+
type=click.Path(exists=True, dir_okay=False, path_type=str),
|
|
456
|
+
help="Preload a session JSON into the pipeline",
|
|
457
|
+
)
|
|
458
|
+
@click.option(
|
|
459
|
+
"--output",
|
|
460
|
+
type=click.Path(dir_okay=False, path_type=str),
|
|
461
|
+
help="Default path for export session writes",
|
|
462
|
+
)
|
|
463
|
+
@click.pass_context
|
|
464
|
+
def run_cmd(ctx: click.Context, session: str | None, output: str | None) -> None:
|
|
465
|
+
"""Run one or more steps, daisy-chained with ':'
|
|
466
|
+
|
|
467
|
+
\b
|
|
468
|
+
jst run console
|
|
469
|
+
jst run --session in.json console : console
|
|
470
|
+
jst run company-selector --industry semiconductors : console
|
|
471
|
+
"""
|
|
472
|
+
from .workflows import PipelineError, run_pipeline
|
|
473
|
+
|
|
474
|
+
if not ctx.args:
|
|
475
|
+
click.echo(ctx.get_help())
|
|
476
|
+
raise SystemExit(2)
|
|
477
|
+
try:
|
|
478
|
+
run_pipeline(client, ctx.args, session_path=session, output_path=output)
|
|
479
|
+
except KeyError as e:
|
|
480
|
+
click.echo(str(e), err=True)
|
|
481
|
+
sys.exit(1)
|
|
482
|
+
except PipelineError as e:
|
|
483
|
+
click.echo(f"Error: {e}", err=True)
|
|
484
|
+
sys.exit(2)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
@cli.group("workflow")
|
|
488
|
+
def workflow_group() -> None:
|
|
489
|
+
"""Save and run named step pipelines."""
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
# Backward-compatible plural alias (same group object).
|
|
493
|
+
cli.add_command(workflow_group, "workflows")
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
@workflow_group.command("ls")
|
|
497
|
+
def workflow_ls() -> None:
|
|
498
|
+
"""List saved workflows."""
|
|
499
|
+
from .workflows import (
|
|
500
|
+
format_pipeline,
|
|
501
|
+
is_bundled_workflow,
|
|
502
|
+
list_saved_workflows,
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
workflows = list_saved_workflows()
|
|
506
|
+
if not workflows:
|
|
507
|
+
click.echo("No saved workflows.")
|
|
508
|
+
return
|
|
509
|
+
for wf in workflows:
|
|
510
|
+
desc = wf.description or "-"
|
|
511
|
+
tag = " (built-in)" if is_bundled_workflow(wf.id) else ""
|
|
512
|
+
click.echo(
|
|
513
|
+
f"{wf.id:24}{tag:11} {desc:40} {format_pipeline(wf)}"
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
@workflow_group.command("rm")
|
|
518
|
+
@click.argument("workflow_id")
|
|
519
|
+
def workflow_rm(workflow_id: str) -> None:
|
|
520
|
+
"""Delete a saved workflow."""
|
|
521
|
+
from .workflows import WorkflowStoreError, delete_workflow
|
|
522
|
+
|
|
523
|
+
try:
|
|
524
|
+
delete_workflow(workflow_id)
|
|
525
|
+
except WorkflowStoreError as e:
|
|
526
|
+
click.echo(f"Error: {e}", err=True)
|
|
527
|
+
sys.exit(1)
|
|
528
|
+
click.echo(f"Removed workflow {workflow_id!r}.")
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
@workflow_group.command(
|
|
532
|
+
"create",
|
|
533
|
+
context_settings={
|
|
534
|
+
"ignore_unknown_options": True,
|
|
535
|
+
"allow_extra_args": True,
|
|
536
|
+
"allow_interspersed_args": False,
|
|
537
|
+
},
|
|
538
|
+
)
|
|
539
|
+
@click.option(
|
|
540
|
+
"--id",
|
|
541
|
+
"workflow_id",
|
|
542
|
+
required=True,
|
|
543
|
+
help="Slug id for the workflow (e.g. gdp-rank)",
|
|
544
|
+
)
|
|
545
|
+
@click.option(
|
|
546
|
+
"--description",
|
|
547
|
+
default="",
|
|
548
|
+
help="Optional short description",
|
|
549
|
+
)
|
|
550
|
+
@click.pass_context
|
|
551
|
+
def workflow_create(
|
|
552
|
+
ctx: click.Context, workflow_id: str, description: str
|
|
553
|
+
) -> None:
|
|
554
|
+
"""Save a step pipeline as a named workflow.
|
|
555
|
+
|
|
556
|
+
\b
|
|
557
|
+
jst workflow create --id gdp-rank -- console : rank --taxonomy country
|
|
558
|
+
jst workflow create --id gdp --description "GDP leaders" -- console : rank
|
|
559
|
+
"""
|
|
560
|
+
import sys as _sys
|
|
561
|
+
|
|
562
|
+
from .workflows import (
|
|
563
|
+
PipelineError,
|
|
564
|
+
WorkflowStoreError,
|
|
565
|
+
create_workflow_from_tokens,
|
|
566
|
+
save_workflow,
|
|
567
|
+
workflow_exists,
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
# Hard boundary: pipeline must follow '--'. Enforce when this process
|
|
571
|
+
# looks like a real ``jst workflow create`` (CliRunner leaves sys.argv alone).
|
|
572
|
+
try:
|
|
573
|
+
create_at = _sys.argv.index("create")
|
|
574
|
+
via_workflow = any(
|
|
575
|
+
name in _sys.argv[:create_at] for name in ("workflow", "workflows")
|
|
576
|
+
)
|
|
577
|
+
except ValueError:
|
|
578
|
+
via_workflow = False
|
|
579
|
+
if via_workflow and "--" not in _sys.argv[create_at:]:
|
|
580
|
+
click.echo(
|
|
581
|
+
"Error: pass the pipeline after '--'.\n"
|
|
582
|
+
"Example: jst workflow create --id gdp-rank -- console : rank",
|
|
583
|
+
err=True,
|
|
584
|
+
)
|
|
585
|
+
sys.exit(2)
|
|
586
|
+
if not ctx.args:
|
|
587
|
+
click.echo(ctx.get_help())
|
|
588
|
+
raise SystemExit(2)
|
|
589
|
+
|
|
590
|
+
try:
|
|
591
|
+
if workflow_exists(workflow_id):
|
|
592
|
+
if not click.confirm(
|
|
593
|
+
f"Workflow {workflow_id!r} already exists. Overwrite?",
|
|
594
|
+
default=False,
|
|
595
|
+
):
|
|
596
|
+
click.echo("Aborted.")
|
|
597
|
+
return
|
|
598
|
+
workflow = create_workflow_from_tokens(workflow_id, description, list(ctx.args))
|
|
599
|
+
path = save_workflow(workflow)
|
|
600
|
+
except WorkflowStoreError as e:
|
|
601
|
+
click.echo(f"Error: {e}", err=True)
|
|
602
|
+
sys.exit(2)
|
|
603
|
+
except KeyError as e:
|
|
604
|
+
click.echo(str(e), err=True)
|
|
605
|
+
sys.exit(1)
|
|
606
|
+
except PipelineError as e:
|
|
607
|
+
click.echo(f"Error: {e}", err=True)
|
|
608
|
+
sys.exit(2)
|
|
609
|
+
|
|
610
|
+
click.echo(f"Saved workflow {workflow.id!r} → {path}")
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
@workflow_group.command("run")
|
|
614
|
+
@click.argument("workflow_id")
|
|
615
|
+
@click.option(
|
|
616
|
+
"--session",
|
|
617
|
+
type=click.Path(exists=True, dir_okay=False, path_type=str),
|
|
618
|
+
help="Preload a session JSON into the pipeline",
|
|
619
|
+
)
|
|
620
|
+
@click.option(
|
|
621
|
+
"--output",
|
|
622
|
+
type=click.Path(dir_okay=False, path_type=str),
|
|
623
|
+
help="Default path for export session writes",
|
|
624
|
+
)
|
|
625
|
+
def workflow_run(
|
|
626
|
+
workflow_id: str, session: str | None, output: str | None
|
|
627
|
+
) -> None:
|
|
628
|
+
"""Run a saved workflow (validates before launching the UI).
|
|
629
|
+
|
|
630
|
+
\b
|
|
631
|
+
jst workflow run gdp-rank
|
|
632
|
+
jst workflow run gdp-rank --session in.json
|
|
633
|
+
"""
|
|
634
|
+
from .workflows import (
|
|
635
|
+
PipelineError,
|
|
636
|
+
TUTORIAL_WORKFLOW_ID,
|
|
637
|
+
WorkflowStoreError,
|
|
638
|
+
load_workflow,
|
|
639
|
+
resolve_saved_workflow,
|
|
640
|
+
run_resolved_pipeline,
|
|
641
|
+
run_tutorial,
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
try:
|
|
645
|
+
workflow = load_workflow(workflow_id)
|
|
646
|
+
if workflow_id == TUTORIAL_WORKFLOW_ID:
|
|
647
|
+
run_tutorial(client, session_path=session, output_path=output)
|
|
648
|
+
return
|
|
649
|
+
resolved = resolve_saved_workflow(workflow)
|
|
650
|
+
except WorkflowStoreError as e:
|
|
651
|
+
click.echo(f"Error: {e}", err=True)
|
|
652
|
+
sys.exit(1)
|
|
653
|
+
except KeyError as e:
|
|
654
|
+
click.echo(str(e), err=True)
|
|
655
|
+
sys.exit(1)
|
|
656
|
+
except PipelineError as e:
|
|
657
|
+
click.echo(f"Error: {e}", err=True)
|
|
658
|
+
sys.exit(2)
|
|
659
|
+
|
|
660
|
+
run_resolved_pipeline(
|
|
661
|
+
client, resolved, session_path=session, output_path=output
|
|
662
|
+
)
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
if __name__ == "__main__":
|
|
666
|
+
try:
|
|
667
|
+
cli()
|
|
668
|
+
except ApiKeyNotSetError as e:
|
|
669
|
+
click.secho(f"Error: {e}", fg="red", err=True)
|
|
670
|
+
if sys.stdin.isatty():
|
|
671
|
+
if click.confirm("Would you like to run 'jstdata login' now?"):
|
|
672
|
+
# Use click.Context to invoke the login command
|
|
673
|
+
ctx = cli.make_context("login", [])
|
|
674
|
+
cli.invoke(ctx)
|
|
675
|
+
else:
|
|
676
|
+
sys.exit(1)
|
|
677
|
+
except InvalidApiKeyError as e:
|
|
678
|
+
click.echo(f"Error: {e}", err=True)
|
|
679
|
+
sys.exit(1)
|
|
680
|
+
except Exception as e:
|
|
681
|
+
click.echo(f"Unexpected error: {e}", err=True)
|
|
682
|
+
sys.exit(1)
|