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