kash-shell 0.3.28__py3-none-any.whl → 0.3.33__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.
- kash/actions/core/chat.py +1 -0
- kash/actions/core/markdownify_html.py +4 -5
- kash/actions/core/minify_html.py +4 -5
- kash/actions/core/readability.py +1 -4
- kash/actions/core/render_as_html.py +10 -7
- kash/actions/core/save_sidematter_meta.py +47 -0
- kash/actions/core/show_webpage.py +2 -0
- kash/actions/core/zip_sidematter.py +47 -0
- kash/commands/base/basic_file_commands.py +7 -4
- kash/commands/base/diff_commands.py +6 -4
- kash/commands/base/files_command.py +31 -30
- kash/commands/base/general_commands.py +3 -2
- kash/commands/base/logs_commands.py +6 -4
- kash/commands/base/reformat_command.py +3 -2
- kash/commands/base/search_command.py +4 -3
- kash/commands/base/show_command.py +9 -7
- kash/commands/help/assistant_commands.py +6 -4
- kash/commands/help/help_commands.py +7 -4
- kash/commands/workspace/selection_commands.py +18 -16
- kash/commands/workspace/workspace_commands.py +39 -26
- kash/config/logger.py +1 -1
- kash/config/setup.py +2 -27
- kash/config/text_styles.py +1 -1
- kash/docs/markdown/topics/a1_what_is_kash.md +26 -18
- kash/docs/markdown/topics/a2_installation.md +3 -2
- kash/exec/action_decorators.py +7 -5
- kash/exec/action_exec.py +104 -53
- kash/exec/fetch_url_items.py +40 -11
- kash/exec/llm_transforms.py +14 -5
- kash/exec/preconditions.py +2 -2
- kash/exec/resolve_args.py +4 -1
- kash/exec/runtime_settings.py +3 -0
- kash/file_storage/file_store.py +108 -114
- kash/file_storage/item_file_format.py +91 -26
- kash/file_storage/item_id_index.py +128 -0
- kash/help/help_types.py +1 -1
- kash/llm_utils/llms.py +6 -1
- kash/local_server/local_server_commands.py +2 -1
- kash/mcp/mcp_server_commands.py +3 -2
- kash/mcp/mcp_server_routes.py +42 -12
- kash/model/actions_model.py +44 -32
- kash/model/compound_actions_model.py +4 -3
- kash/model/exec_model.py +33 -3
- kash/model/items_model.py +150 -60
- kash/model/params_model.py +4 -4
- kash/shell/output/shell_output.py +1 -2
- kash/utils/api_utils/gather_limited.py +2 -0
- kash/utils/api_utils/multitask_gather.py +74 -0
- kash/utils/common/s3_utils.py +108 -0
- kash/utils/common/url.py +16 -4
- kash/utils/file_formats/chat_format.py +7 -4
- kash/utils/file_utils/file_ext.py +1 -0
- kash/utils/file_utils/file_formats.py +4 -2
- kash/utils/file_utils/file_formats_model.py +12 -0
- kash/utils/text_handling/doc_normalization.py +1 -1
- kash/utils/text_handling/markdown_footnotes.py +224 -0
- kash/utils/text_handling/markdown_utils.py +532 -41
- kash/utils/text_handling/markdownify_utils.py +2 -1
- kash/web_content/web_fetch.py +2 -1
- kash/web_gen/templates/components/tooltip_scripts.js.jinja +186 -1
- kash/web_gen/templates/components/youtube_popover_scripts.js.jinja +223 -0
- kash/web_gen/templates/components/youtube_popover_styles.css.jinja +150 -0
- kash/web_gen/templates/content_styles.css.jinja +53 -1
- kash/web_gen/templates/youtube_webpage.html.jinja +47 -0
- kash/web_gen/webpage_render.py +103 -0
- kash/workspaces/workspaces.py +0 -5
- kash/xonsh_custom/custom_shell.py +4 -3
- {kash_shell-0.3.28.dist-info → kash_shell-0.3.33.dist-info}/METADATA +35 -26
- {kash_shell-0.3.28.dist-info → kash_shell-0.3.33.dist-info}/RECORD +72 -64
- kash/llm_utils/llm_features.py +0 -72
- kash/web_gen/simple_webpage.py +0 -55
- {kash_shell-0.3.28.dist-info → kash_shell-0.3.33.dist-info}/WHEEL +0 -0
- {kash_shell-0.3.28.dist-info → kash_shell-0.3.33.dist-info}/entry_points.txt +0 -0
- {kash_shell-0.3.28.dist-info → kash_shell-0.3.33.dist-info}/licenses/LICENSE +0 -0
|
@@ -5,7 +5,8 @@ from pydantic.dataclasses import dataclass
|
|
|
5
5
|
|
|
6
6
|
from kash.config.logger import get_logger
|
|
7
7
|
from kash.exec.combiners import Combiner
|
|
8
|
-
from kash.model.actions_model import Action, ActionInput, ActionResult
|
|
8
|
+
from kash.model.actions_model import Action, ActionInput, ActionResult
|
|
9
|
+
from kash.model.exec_model import ActionContext
|
|
9
10
|
from kash.model.items_model import Item, State
|
|
10
11
|
from kash.model.params_model import RawParamValues
|
|
11
12
|
from kash.model.paths_model import StorePath
|
|
@@ -46,7 +47,7 @@ class SequenceAction(Action):
|
|
|
46
47
|
)
|
|
47
48
|
self.description = seq_description
|
|
48
49
|
|
|
49
|
-
def run(self, input: ActionInput, context:
|
|
50
|
+
def run(self, input: ActionInput, context: ActionContext) -> ActionResult:
|
|
50
51
|
from kash.exec.action_exec import run_action_with_shell_context
|
|
51
52
|
from kash.workspaces import current_ws
|
|
52
53
|
|
|
@@ -140,7 +141,7 @@ class ComboAction(Action):
|
|
|
140
141
|
|
|
141
142
|
self.description = combo_description
|
|
142
143
|
|
|
143
|
-
def run(self, input: ActionInput, context:
|
|
144
|
+
def run(self, input: ActionInput, context: ActionContext) -> ActionResult:
|
|
144
145
|
from kash.exec.action_exec import run_action_with_shell_context
|
|
145
146
|
from kash.exec.combiners import combine_as_paragraphs
|
|
146
147
|
|
kash/model/exec_model.py
CHANGED
|
@@ -8,10 +8,11 @@ from pydantic.dataclasses import dataclass
|
|
|
8
8
|
|
|
9
9
|
from kash.config.logger import get_logger
|
|
10
10
|
from kash.model.items_model import State
|
|
11
|
+
from kash.model.operations_model import Operation
|
|
11
12
|
|
|
12
13
|
if TYPE_CHECKING:
|
|
13
14
|
from kash.file_storage.file_store import FileStore
|
|
14
|
-
from kash.model.actions_model import Action
|
|
15
|
+
from kash.model.actions_model import Action, ActionInput
|
|
15
16
|
|
|
16
17
|
|
|
17
18
|
log = get_logger(__name__)
|
|
@@ -42,6 +43,9 @@ class RuntimeSettings:
|
|
|
42
43
|
no_format: bool = False
|
|
43
44
|
"""If True, will not normalize the output item's body text formatting (for Markdown)."""
|
|
44
45
|
|
|
46
|
+
sync_to_s3: bool = True
|
|
47
|
+
"""If True, will sync output items to S3 if input was from S3."""
|
|
48
|
+
|
|
45
49
|
@property
|
|
46
50
|
def workspace(self) -> FileStore:
|
|
47
51
|
from kash.workspaces.workspaces import get_ws
|
|
@@ -68,8 +72,8 @@ class RuntimeSettings:
|
|
|
68
72
|
@dataclass(frozen=True)
|
|
69
73
|
class ExecContext:
|
|
70
74
|
"""
|
|
71
|
-
An action and its context for execution. This is a good place for
|
|
72
|
-
that apply to any action and are bothersome to pass as parameters.
|
|
75
|
+
An action and its general context for execution. This is a good place for general
|
|
76
|
+
settings that apply to any action and are bothersome to pass as parameters.
|
|
73
77
|
"""
|
|
74
78
|
|
|
75
79
|
action: Action
|
|
@@ -77,3 +81,29 @@ class ExecContext:
|
|
|
77
81
|
|
|
78
82
|
settings: RuntimeSettings
|
|
79
83
|
"""The workspace and other run-time settings for the action."""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True)
|
|
87
|
+
class ActionContext:
|
|
88
|
+
"""
|
|
89
|
+
All context for the currently executing action, with all inputs and options.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
exec_context: ExecContext
|
|
93
|
+
"""The context of the current execution."""
|
|
94
|
+
|
|
95
|
+
action_input: ActionInput
|
|
96
|
+
"""The assembled input to the current action."""
|
|
97
|
+
|
|
98
|
+
operation: Operation
|
|
99
|
+
"""The operation in full detail, including inputs and options."""
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def action(self) -> Action:
|
|
103
|
+
"""The action being executed."""
|
|
104
|
+
return self.exec_context.action
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def settings(self) -> RuntimeSettings:
|
|
108
|
+
"""The workspace and other run-time settings for the action."""
|
|
109
|
+
return self.exec_context.settings
|
kash/model/items_model.py
CHANGED
|
@@ -17,7 +17,7 @@ from prettyfmt import (
|
|
|
17
17
|
slugify_snake,
|
|
18
18
|
)
|
|
19
19
|
from pydantic.dataclasses import dataclass
|
|
20
|
-
from strif import abbrev_str, format_iso_timestamp
|
|
20
|
+
from strif import abbrev_str, format_iso_timestamp, single_line
|
|
21
21
|
|
|
22
22
|
from kash.config.logger import get_logger
|
|
23
23
|
from kash.model.concept_model import canonicalize_concept
|
|
@@ -34,7 +34,9 @@ from kash.utils.text_handling.markdown_render import markdown_to_html
|
|
|
34
34
|
from kash.utils.text_handling.markdown_utils import first_heading
|
|
35
35
|
|
|
36
36
|
if TYPE_CHECKING:
|
|
37
|
-
from
|
|
37
|
+
from sidematter_format import ResolvedSidematter
|
|
38
|
+
|
|
39
|
+
from kash.model.exec_model import ActionContext
|
|
38
40
|
from kash.workspaces import Workspace
|
|
39
41
|
|
|
40
42
|
log = get_logger(__name__)
|
|
@@ -68,7 +70,15 @@ class ItemType(Enum):
|
|
|
68
70
|
"""
|
|
69
71
|
Resources don't have a body. On concepts it's optional.
|
|
70
72
|
"""
|
|
71
|
-
return self.value not in
|
|
73
|
+
return self.value not in (ItemType.resource.value, ItemType.concept.value)
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def allows_op_suffix(self) -> bool:
|
|
77
|
+
"""
|
|
78
|
+
Whether it makes sense to have an operation suffix for this item type
|
|
79
|
+
(docs often should, but concepts or resources can have cleaner naming conventions).
|
|
80
|
+
"""
|
|
81
|
+
return self not in (ItemType.concept, ItemType.resource, ItemType.export)
|
|
72
82
|
|
|
73
83
|
@staticmethod
|
|
74
84
|
def for_format(format: Format) -> ItemType:
|
|
@@ -100,6 +110,7 @@ class ItemType(Enum):
|
|
|
100
110
|
Format.mp3: ItemType.resource,
|
|
101
111
|
Format.m4a: ItemType.resource,
|
|
102
112
|
Format.mp4: ItemType.resource,
|
|
113
|
+
Format.zip: ItemType.resource,
|
|
103
114
|
}
|
|
104
115
|
return format_to_item_type.get(format, ItemType.resource)
|
|
105
116
|
|
|
@@ -192,6 +203,15 @@ class ItemId:
|
|
|
192
203
|
# If we got here, the item has no identity.
|
|
193
204
|
item_id = None
|
|
194
205
|
|
|
206
|
+
log.debug(
|
|
207
|
+
"item_id is %s for type=%s, format=%s, url=%s, title=%s, source=%s",
|
|
208
|
+
item_id,
|
|
209
|
+
item.type,
|
|
210
|
+
item.format,
|
|
211
|
+
item.url,
|
|
212
|
+
item.title,
|
|
213
|
+
item.source,
|
|
214
|
+
)
|
|
195
215
|
return item_id
|
|
196
216
|
|
|
197
217
|
|
|
@@ -267,7 +287,7 @@ class Item:
|
|
|
267
287
|
|
|
268
288
|
# Optional execution context. Useful for letting functions that take only an Item
|
|
269
289
|
# arg get access to context.
|
|
270
|
-
context:
|
|
290
|
+
context: ActionContext | None = field(default=None, metadata={"exclude": True})
|
|
271
291
|
|
|
272
292
|
# These fields we don't want in YAML frontmatter.
|
|
273
293
|
# We don't include store_path as it's redundant with the filename.
|
|
@@ -369,9 +389,9 @@ class Item:
|
|
|
369
389
|
item_type: ItemType | None = None,
|
|
370
390
|
*,
|
|
371
391
|
title: str | None = None,
|
|
372
|
-
original_filename: str | None = None,
|
|
373
392
|
url: Url | None = None,
|
|
374
393
|
mime_type: MimeType | None = None,
|
|
394
|
+
preserve_filename: bool = True,
|
|
375
395
|
) -> Item:
|
|
376
396
|
"""
|
|
377
397
|
Create a resource Item for a file with a format inferred from the file extension
|
|
@@ -400,9 +420,10 @@ class Item:
|
|
|
400
420
|
if not file_ext:
|
|
401
421
|
file_ext = format_info.suggested_file_ext
|
|
402
422
|
|
|
423
|
+
original_filename = Path(path).name if preserve_filename else None
|
|
403
424
|
item = cls(
|
|
404
425
|
type=item_type,
|
|
405
|
-
title=title,
|
|
426
|
+
title=single_line(title) if title else None, # Avoid multiline titles.
|
|
406
427
|
file_ext=file_ext,
|
|
407
428
|
format=format,
|
|
408
429
|
external_path=str(path),
|
|
@@ -428,7 +449,7 @@ class Item:
|
|
|
428
449
|
return cls(
|
|
429
450
|
type=ItemType.resource,
|
|
430
451
|
format=Format.url,
|
|
431
|
-
title=media_metadata.title,
|
|
452
|
+
title=single_line(media_metadata.title), # Avoid multiline titles.
|
|
432
453
|
url=media_metadata.url,
|
|
433
454
|
description=media_metadata.description,
|
|
434
455
|
thumbnail_url=media_metadata.thumbnail_url,
|
|
@@ -453,7 +474,7 @@ class Item:
|
|
|
453
474
|
if self.type.expects_body and self.format.has_body and not self.body:
|
|
454
475
|
raise ValueError(f"Item type `{self.type.value}` is text but has no body: {self}")
|
|
455
476
|
|
|
456
|
-
def absolute_path(self, ws: Workspace | None = None) -> Path:
|
|
477
|
+
def absolute_path(self, ws: Path | Workspace | None = None) -> Path:
|
|
457
478
|
"""
|
|
458
479
|
Get the absolute path to the item. Throws `ValueError` if the item has no
|
|
459
480
|
store path. If no workspace is provided, uses the current workspace.
|
|
@@ -462,8 +483,11 @@ class Item:
|
|
|
462
483
|
|
|
463
484
|
if not self.store_path:
|
|
464
485
|
raise ValueError("Item has no store path")
|
|
465
|
-
|
|
486
|
+
elif isinstance(ws, Path):
|
|
487
|
+
return ws / self.store_path
|
|
488
|
+
elif not ws:
|
|
466
489
|
ws = current_ws()
|
|
490
|
+
|
|
467
491
|
return ws.base_dir / self.store_path
|
|
468
492
|
|
|
469
493
|
@property
|
|
@@ -485,7 +509,7 @@ class Item:
|
|
|
485
509
|
raise ValueError("Cannot get doc id for an item that has not been saved")
|
|
486
510
|
return str(self.store_path)
|
|
487
511
|
|
|
488
|
-
def metadata(self, datetime_as_str: bool = False) -> dict[str, Any]:
|
|
512
|
+
def metadata(self, *, datetime_as_str: bool = False) -> dict[str, Any]:
|
|
489
513
|
"""
|
|
490
514
|
Metadata is all relevant non-None fields in easy-to-serialize form.
|
|
491
515
|
Optional fields are omitted unless they are set.
|
|
@@ -529,6 +553,15 @@ class Item:
|
|
|
529
553
|
|
|
530
554
|
return item_dict
|
|
531
555
|
|
|
556
|
+
def sidematter(self, ws: Path | Workspace | None = None) -> ResolvedSidematter:
|
|
557
|
+
"""
|
|
558
|
+
Get the sidematter for this item, if present, by looking at the files
|
|
559
|
+
in the specified workspace (or the current workspace if not specified).
|
|
560
|
+
"""
|
|
561
|
+
from sidematter_format import Sidematter
|
|
562
|
+
|
|
563
|
+
return Sidematter(self.absolute_path(ws)).resolve()
|
|
564
|
+
|
|
532
565
|
def filename_stem(self) -> str | None:
|
|
533
566
|
"""
|
|
534
567
|
If the item has an existing or previous filename, return its stem,
|
|
@@ -545,16 +578,25 @@ class Item:
|
|
|
545
578
|
path_name = None
|
|
546
579
|
return path_name
|
|
547
580
|
|
|
548
|
-
def slug_name(
|
|
581
|
+
def slug_name(
|
|
582
|
+
self,
|
|
583
|
+
max_len: int = SLUG_MAX_LEN,
|
|
584
|
+
prefer_title: bool = False,
|
|
585
|
+
add_ops_suffix: bool = True,
|
|
586
|
+
) -> str:
|
|
549
587
|
"""
|
|
550
588
|
Get a readable slugified name for this item, either from a previous filename
|
|
551
|
-
or from slugifying the title or content.
|
|
589
|
+
or from slugifying the title or content. Adds the last operation suffix if requested
|
|
590
|
+
and available in item history.
|
|
552
591
|
"""
|
|
553
|
-
|
|
554
|
-
if
|
|
555
|
-
|
|
592
|
+
base_title = self.filename_stem()
|
|
593
|
+
if prefer_title or not base_title:
|
|
594
|
+
base_title = self.pick_title(max_len=max_len, add_ops_suffix=add_ops_suffix)
|
|
556
595
|
else:
|
|
557
|
-
|
|
596
|
+
base_title = self.pick_title(
|
|
597
|
+
base_title=base_title, max_len=max_len, add_ops_suffix=add_ops_suffix
|
|
598
|
+
)
|
|
599
|
+
return slugify_snake(base_title)
|
|
558
600
|
|
|
559
601
|
def default_filename(self) -> str:
|
|
560
602
|
"""
|
|
@@ -578,6 +620,7 @@ class Item:
|
|
|
578
620
|
|
|
579
621
|
def pick_title(
|
|
580
622
|
self,
|
|
623
|
+
base_title: str | None = None,
|
|
581
624
|
*,
|
|
582
625
|
max_len: int = 100,
|
|
583
626
|
add_ops_suffix: bool = False,
|
|
@@ -590,35 +633,31 @@ class Item:
|
|
|
590
633
|
"""
|
|
591
634
|
# First special case: if we are pulling the title from the body header, check
|
|
592
635
|
# that.
|
|
593
|
-
if not
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
636
|
+
if not base_title:
|
|
637
|
+
if not base_title and pull_body_heading:
|
|
638
|
+
heading = self.body_heading()
|
|
639
|
+
base_title = heading
|
|
640
|
+
|
|
641
|
+
# Next special case: URLs with no title use the url itself.
|
|
642
|
+
if not self.title and self.url:
|
|
643
|
+
return abbrev_str(self.url, max_len)
|
|
644
|
+
|
|
645
|
+
filename_stem = self.filename_stem()
|
|
646
|
+
|
|
647
|
+
# Use the title or the path if possible, falling back to description or even body text.
|
|
648
|
+
base_title = (
|
|
649
|
+
self.title
|
|
650
|
+
or filename_stem
|
|
651
|
+
or self.description
|
|
652
|
+
or (not self.is_binary and self.abbrev_body(max_len))
|
|
653
|
+
or UNTITLED
|
|
654
|
+
)
|
|
612
655
|
|
|
613
|
-
suffix = ""
|
|
614
656
|
# For docs, etc but not for concepts/resources/exports, add a parenthical note
|
|
615
657
|
# indicating the last operation, if there was one. This makes filename slugs
|
|
616
658
|
# more readable.
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
ItemType.resource,
|
|
620
|
-
ItemType.export,
|
|
621
|
-
]:
|
|
659
|
+
suffix = ""
|
|
660
|
+
if add_ops_suffix and self.type.allows_op_suffix:
|
|
622
661
|
last_op = self.history and self.history[-1].action_name
|
|
623
662
|
if last_op:
|
|
624
663
|
step_num = len(self.history) + 1 if self.history else 1
|
|
@@ -626,7 +665,7 @@ class Item:
|
|
|
626
665
|
|
|
627
666
|
shorter_len = min(max_len, max(max_len - len(suffix), 20))
|
|
628
667
|
clean_text = sanitize_title(
|
|
629
|
-
abbrev_phrase_in_middle(html_to_plaintext(
|
|
668
|
+
abbrev_phrase_in_middle(html_to_plaintext(base_title), shorter_len)
|
|
630
669
|
)
|
|
631
670
|
|
|
632
671
|
final_text = clean_text
|
|
@@ -670,8 +709,6 @@ class Item:
|
|
|
670
709
|
"""
|
|
671
710
|
If it is a data Item, return the parsed YAML.
|
|
672
711
|
"""
|
|
673
|
-
if not self.type == ItemType.data:
|
|
674
|
-
raise FileFormatError(f"Item is not a data item: {self}")
|
|
675
712
|
if not self.body:
|
|
676
713
|
raise FileFormatError(f"Data item has no body: {self}")
|
|
677
714
|
if self.format != Format.yaml:
|
|
@@ -795,15 +832,24 @@ class Item:
|
|
|
795
832
|
merged_fields = self._copy_and_update(other, update_timestamp=False)
|
|
796
833
|
return Item(**merged_fields)
|
|
797
834
|
|
|
798
|
-
def derived_copy(
|
|
835
|
+
def derived_copy(
|
|
836
|
+
self,
|
|
837
|
+
action_context: ActionContext | None = None,
|
|
838
|
+
output_num: int = 0,
|
|
839
|
+
**updates: Unpack[ItemUpdateOptions],
|
|
840
|
+
) -> Item:
|
|
799
841
|
"""
|
|
800
842
|
Copy item with the given field updates. Resets `store_path` and `source` to None
|
|
801
843
|
since those should be set explicitly later. Preserves other fields, including
|
|
802
|
-
the body.
|
|
844
|
+
the type and the body.
|
|
803
845
|
|
|
804
846
|
Same as `new_copy_with` but also updates the `derived_from` relation. If we also
|
|
805
|
-
have an action context, then use
|
|
847
|
+
have an action context, then use that to fill some fields, in particular `title_template`
|
|
848
|
+
to derive a new title and `output_type` and `output_format` to set the output type
|
|
849
|
+
and format
|
|
806
850
|
"""
|
|
851
|
+
|
|
852
|
+
# Get derived_from relation if possible.
|
|
807
853
|
if not self.store_path:
|
|
808
854
|
if self.relations.derived_from:
|
|
809
855
|
log.message(
|
|
@@ -830,30 +876,58 @@ class Item:
|
|
|
830
876
|
assert updates["format"] is not None
|
|
831
877
|
updates["file_ext"] = updates["format"].file_ext
|
|
832
878
|
|
|
833
|
-
# External resource paths
|
|
834
|
-
|
|
835
|
-
new_type = updates.get("type") or self.type
|
|
836
|
-
if "external_path" not in updates and new_type != ItemType.resource:
|
|
879
|
+
# External resource paths should not be preserved.
|
|
880
|
+
if "external_path" not in updates:
|
|
837
881
|
updates["external_path"] = None
|
|
838
882
|
|
|
883
|
+
action_context = action_context or self.context
|
|
884
|
+
|
|
885
|
+
if action_context:
|
|
886
|
+
# Default the output item type and format to the action's declared output_type
|
|
887
|
+
# and format if not explicitly set.
|
|
888
|
+
if "type" not in updates:
|
|
889
|
+
updates["type"] = action_context.action.output_type
|
|
890
|
+
# If we were not given a format override, we leave the output type the same.
|
|
891
|
+
elif action_context.action.output_format:
|
|
892
|
+
# Check an overridden format and then our own format.
|
|
893
|
+
new_output_format = updates.get("format", self.format)
|
|
894
|
+
if new_output_format and action_context.action.output_format != new_output_format:
|
|
895
|
+
log.warning(
|
|
896
|
+
"Output item format `%s` does not match declared output format `%s` for action `%s`",
|
|
897
|
+
new_output_format,
|
|
898
|
+
action_context.action.output_format,
|
|
899
|
+
action_context.action.name,
|
|
900
|
+
)
|
|
901
|
+
|
|
839
902
|
new_item = self.new_copy_with(update_timestamp=True, **updates)
|
|
840
903
|
if derived_from:
|
|
841
904
|
new_item.update_relations(derived_from=derived_from)
|
|
842
905
|
|
|
906
|
+
# Record the history.
|
|
907
|
+
if action_context:
|
|
908
|
+
new_item.update_source(
|
|
909
|
+
Source(
|
|
910
|
+
operation=action_context.operation,
|
|
911
|
+
output_num=output_num,
|
|
912
|
+
cacheable=action_context.action.cacheable,
|
|
913
|
+
)
|
|
914
|
+
)
|
|
915
|
+
action = action_context.action
|
|
916
|
+
else:
|
|
917
|
+
action = None
|
|
918
|
+
|
|
843
919
|
# Fall back to action title template if we have it and title wasn't explicitly set.
|
|
844
920
|
if "title" not in updates:
|
|
845
921
|
prev_title = self.title or (Path(self.store_path).stem if self.store_path else UNTITLED)
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
new_item.title = action.
|
|
849
|
-
title=prev_title, action_name=action.name
|
|
850
|
-
)
|
|
922
|
+
|
|
923
|
+
if action:
|
|
924
|
+
new_item.title = action.format_title(prev_title)
|
|
851
925
|
else:
|
|
852
|
-
log.
|
|
926
|
+
log.info(
|
|
853
927
|
"Deriving an item without action context so keeping previous title: %s",
|
|
854
928
|
self,
|
|
855
929
|
)
|
|
856
|
-
new_item.title =
|
|
930
|
+
new_item.title = prev_title
|
|
857
931
|
|
|
858
932
|
return new_item
|
|
859
933
|
|
|
@@ -866,9 +940,10 @@ class Item:
|
|
|
866
940
|
setattr(self.relations, key, list(value))
|
|
867
941
|
return self.relations
|
|
868
942
|
|
|
869
|
-
def
|
|
943
|
+
def update_source(self, source: Source) -> None:
|
|
870
944
|
"""
|
|
871
|
-
Update the history of the item
|
|
945
|
+
Update the source and the history of the item to indicate it was created
|
|
946
|
+
by the given operation. For convenience, this is idempotent.
|
|
872
947
|
"""
|
|
873
948
|
self.source = source
|
|
874
949
|
self.add_to_history(source.operation.summary())
|
|
@@ -900,12 +975,26 @@ class Item:
|
|
|
900
975
|
return metadata_matches and body_matches
|
|
901
976
|
|
|
902
977
|
def add_to_history(self, operation_summary: OperationSummary):
|
|
978
|
+
"""
|
|
979
|
+
For convenience, this is idempotent.
|
|
980
|
+
"""
|
|
903
981
|
if not self.history:
|
|
904
982
|
self.history = []
|
|
905
983
|
# Don't add duplicates to the history.
|
|
906
984
|
if not self.history or self.history[-1] != operation_summary:
|
|
907
985
|
self.history.append(operation_summary)
|
|
908
986
|
|
|
987
|
+
def mark_as_saved(self, external_path: Path) -> None:
|
|
988
|
+
"""
|
|
989
|
+
Mark the item as saved at an external or internal path. If this item is saved to a
|
|
990
|
+
workspace and the path is inside the workspace, the save will be short-circuited.
|
|
991
|
+
If it's outside the workspace, the item will be copied to the workspace.
|
|
992
|
+
Having this method makes it quick to catch bugs where the file is missing.
|
|
993
|
+
"""
|
|
994
|
+
if not external_path.exists():
|
|
995
|
+
raise FileNotFoundError(f"Provided path not found: {fmt_loc(external_path)}")
|
|
996
|
+
self.external_path = str(external_path)
|
|
997
|
+
|
|
909
998
|
def fmt_loc(self) -> str:
|
|
910
999
|
"""
|
|
911
1000
|
Formatted store path, external path, URL, or title. Use for logging etc.
|
|
@@ -949,6 +1038,7 @@ class Item:
|
|
|
949
1038
|
key_filter={
|
|
950
1039
|
"store_path": 0,
|
|
951
1040
|
"external_path": 64,
|
|
1041
|
+
"original_filename": 64,
|
|
952
1042
|
"type": 64,
|
|
953
1043
|
"format": 64,
|
|
954
1044
|
"state": 64,
|
kash/model/params_model.py
CHANGED
|
@@ -206,10 +206,10 @@ A list of parameter declarations, possibly with default values.
|
|
|
206
206
|
|
|
207
207
|
# These are the default models for typical use cases.
|
|
208
208
|
# The user may override them with parameters.
|
|
209
|
-
DEFAULT_CAREFUL_LLM = LLM.
|
|
210
|
-
DEFAULT_STRUCTURED_LLM = LLM.
|
|
211
|
-
DEFAULT_STANDARD_LLM = LLM.
|
|
212
|
-
DEFAULT_FAST_LLM = LLM.
|
|
209
|
+
DEFAULT_CAREFUL_LLM = LLM.gpt_5
|
|
210
|
+
DEFAULT_STRUCTURED_LLM = LLM.gpt_5
|
|
211
|
+
DEFAULT_STANDARD_LLM = LLM.gpt_5
|
|
212
|
+
DEFAULT_FAST_LLM = LLM.gpt_5_mini
|
|
213
213
|
|
|
214
214
|
|
|
215
215
|
# Parameters set globally such as in the workspace.
|
|
@@ -10,7 +10,6 @@ from enum import Enum, auto
|
|
|
10
10
|
import rich
|
|
11
11
|
import rich.style
|
|
12
12
|
from flowmark import Wrap, fill_text
|
|
13
|
-
from flowmark.text_filling import DEFAULT_INDENT
|
|
14
13
|
from rich.console import Group, OverflowMethod, RenderableType
|
|
15
14
|
from rich.rule import Rule
|
|
16
15
|
from rich.style import Style
|
|
@@ -50,7 +49,7 @@ def print_style(pad_style: PadStyle):
|
|
|
50
49
|
Context manager for print styles.
|
|
51
50
|
"""
|
|
52
51
|
if pad_style == PadStyle.INDENT:
|
|
53
|
-
token = print_context_var.set(
|
|
52
|
+
token = print_context_var.set(" ")
|
|
54
53
|
try:
|
|
55
54
|
yield
|
|
56
55
|
finally:
|
|
@@ -542,6 +542,8 @@ async def gather_limited_sync(
|
|
|
542
542
|
# Mark as failed
|
|
543
543
|
if status and task_id is not None:
|
|
544
544
|
await status.finish(task_id, TaskState.FAILED, str(e))
|
|
545
|
+
|
|
546
|
+
log.warning("Task failed: %s: %s", label, e, exc_info=True)
|
|
545
547
|
raise
|
|
546
548
|
|
|
547
549
|
return await _gather_with_interrupt_handling(
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Iterable, Sequence
|
|
4
|
+
from typing import Any, TypeVar
|
|
5
|
+
|
|
6
|
+
from kash.config.logger import get_logger
|
|
7
|
+
from kash.config.settings import global_settings
|
|
8
|
+
from kash.shell.output.shell_output import multitask_status
|
|
9
|
+
from kash.utils.api_utils.api_retries import RetrySettings
|
|
10
|
+
from kash.utils.api_utils.gather_limited import FuncTask, Limit, gather_limited_sync
|
|
11
|
+
|
|
12
|
+
T = TypeVar("T")
|
|
13
|
+
|
|
14
|
+
log = get_logger(name=__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _default_labeler(total: int) -> Callable[[int, Any], str]:
|
|
18
|
+
def labeler(i: int, _spec: Any) -> str: # pyright: ignore[reportUnusedParameter]
|
|
19
|
+
return f"Task {i + 1}/{total}"
|
|
20
|
+
|
|
21
|
+
return labeler
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def multitask_gather(
|
|
25
|
+
tasks: Iterable[FuncTask[T]] | Sequence[FuncTask[T]],
|
|
26
|
+
*,
|
|
27
|
+
labeler: Callable[[int, Any], str] | None = None,
|
|
28
|
+
limit: Limit | None = None,
|
|
29
|
+
bucket_limits: dict[str, Limit] | None = None,
|
|
30
|
+
retry_settings: RetrySettings | None = None,
|
|
31
|
+
show_progress: bool = True,
|
|
32
|
+
) -> list[T]:
|
|
33
|
+
"""
|
|
34
|
+
Run many `FuncTask`s concurrently with shared progress UI and rate limits.
|
|
35
|
+
|
|
36
|
+
This wraps the standard pattern of creating a status context, providing a labeler,
|
|
37
|
+
and calling `gather_limited_sync` with common options.
|
|
38
|
+
|
|
39
|
+
- `labeler` can be omitted; a simple "Task X/Y" label will be used.
|
|
40
|
+
- If `limit` is not provided, defaults are taken from `global_settings()`.
|
|
41
|
+
- If `show_progress` is False, tasks are run without the status context.
|
|
42
|
+
- By default, exceptions are returned as results rather than raised (return_exceptions=True).
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
# Normalize tasks to a list for length and stable iteration
|
|
46
|
+
task_list: list[FuncTask[T]] = list(tasks)
|
|
47
|
+
|
|
48
|
+
# Provide a default labeler if none is supplied
|
|
49
|
+
effective_labeler: Callable[[int, Any], str] = (
|
|
50
|
+
labeler if labeler is not None else _default_labeler(len(task_list))
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Provide sensible default rate limits if none are supplied
|
|
54
|
+
effective_limit: Limit = (
|
|
55
|
+
limit
|
|
56
|
+
if limit is not None
|
|
57
|
+
else Limit(
|
|
58
|
+
rps=global_settings().limit_rps,
|
|
59
|
+
concurrency=global_settings().limit_concurrency,
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
if not show_progress:
|
|
64
|
+
log.warning("Running %d tasks (progress disabled)…", len(task_list))
|
|
65
|
+
|
|
66
|
+
async with multitask_status(enabled=show_progress) as status:
|
|
67
|
+
return await gather_limited_sync(
|
|
68
|
+
*task_list,
|
|
69
|
+
limit=effective_limit,
|
|
70
|
+
bucket_limits=bucket_limits,
|
|
71
|
+
status=status,
|
|
72
|
+
labeler=effective_labeler,
|
|
73
|
+
retry_settings=retry_settings,
|
|
74
|
+
)
|