kash-shell 0.3.11__py3-none-any.whl → 0.3.12__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/render_as_html.py +2 -2
- kash/actions/core/show_webpage.py +2 -2
- kash/actions/core/strip_html.py +2 -2
- kash/commands/base/basic_file_commands.py +21 -3
- kash/commands/base/files_command.py +5 -4
- kash/commands/extras/parse_uv_lock.py +12 -3
- kash/commands/workspace/selection_commands.py +1 -1
- kash/commands/workspace/workspace_commands.py +1 -1
- kash/config/env_settings.py +2 -42
- kash/config/logger.py +30 -25
- kash/config/logger_basic.py +6 -6
- kash/config/settings.py +23 -7
- kash/config/setup.py +33 -5
- kash/config/text_styles.py +25 -22
- kash/embeddings/cosine.py +12 -4
- kash/embeddings/embeddings.py +16 -6
- kash/embeddings/text_similarity.py +10 -4
- kash/exec/__init__.py +3 -0
- kash/exec/action_decorators.py +4 -19
- kash/exec/action_exec.py +43 -23
- kash/exec/llm_transforms.py +2 -2
- kash/exec/preconditions.py +4 -12
- kash/exec/runtime_settings.py +134 -0
- kash/exec/shell_callable_action.py +5 -3
- kash/file_storage/file_store.py +18 -21
- kash/file_storage/item_file_format.py +6 -3
- kash/file_storage/store_filenames.py +6 -3
- kash/llm_utils/init_litellm.py +16 -0
- kash/llm_utils/llm_api_keys.py +6 -2
- kash/llm_utils/llm_completion.py +11 -4
- kash/mcp/mcp_cli.py +3 -2
- kash/mcp/mcp_server_routes.py +11 -12
- kash/media_base/transcription_deepgram.py +15 -2
- kash/model/__init__.py +1 -1
- kash/model/actions_model.py +6 -54
- kash/model/exec_model.py +79 -0
- kash/model/items_model.py +71 -50
- kash/model/operations_model.py +38 -15
- kash/model/paths_model.py +2 -0
- kash/shell/output/shell_output.py +10 -8
- kash/shell/shell_main.py +2 -2
- kash/shell/utils/exception_printing.py +2 -2
- kash/text_handling/doc_normalization.py +16 -8
- kash/text_handling/markdown_utils.py +83 -2
- kash/utils/common/format_utils.py +2 -8
- kash/utils/common/inflection.py +22 -0
- kash/utils/common/task_stack.py +4 -15
- kash/utils/errors.py +14 -9
- kash/utils/file_utils/file_formats_model.py +15 -0
- kash/utils/file_utils/file_sort_filter.py +10 -3
- kash/web_gen/templates/base_styles.css.jinja +8 -3
- kash/workspaces/__init__.py +12 -3
- kash/workspaces/workspace_dirs.py +58 -0
- kash/workspaces/workspace_importing.py +1 -1
- kash/workspaces/workspaces.py +26 -90
- {kash_shell-0.3.11.dist-info → kash_shell-0.3.12.dist-info}/METADATA +4 -4
- {kash_shell-0.3.11.dist-info → kash_shell-0.3.12.dist-info}/RECORD +60 -57
- kash/shell/utils/argparse_utils.py +0 -20
- kash/utils/lang_utils/inflection.py +0 -18
- {kash_shell-0.3.11.dist-info → kash_shell-0.3.12.dist-info}/WHEEL +0 -0
- {kash_shell-0.3.11.dist-info → kash_shell-0.3.12.dist-info}/entry_points.txt +0 -0
- {kash_shell-0.3.11.dist-info → kash_shell-0.3.12.dist-info}/licenses/LICENSE +0 -0
kash/model/exec_model.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
from prettyfmt import abbrev_obj
|
|
7
|
+
from pydantic.dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
from kash.config.logger import get_logger
|
|
10
|
+
from kash.model.items_model import State
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from kash.file_storage.file_store import FileStore
|
|
14
|
+
from kash.model.actions_model import Action
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
log = get_logger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class RuntimeSettings:
|
|
22
|
+
"""
|
|
23
|
+
Workspace and other runtime settings that may be set across runs of
|
|
24
|
+
one or more actions.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
workspace_dir: Path
|
|
28
|
+
"""The workspace directory in which the action is being executed."""
|
|
29
|
+
|
|
30
|
+
rerun: bool = False
|
|
31
|
+
"""If True, always run actions, even cacheable ones that have results."""
|
|
32
|
+
|
|
33
|
+
refetch: bool = False
|
|
34
|
+
"""If True, will refetch items even if they are already in the content caches."""
|
|
35
|
+
|
|
36
|
+
override_state: State | None = None
|
|
37
|
+
"""If specified, override the state of result items. Useful to mark items as transient."""
|
|
38
|
+
|
|
39
|
+
tmp_output: bool = False
|
|
40
|
+
"""If True, will save output items to a temporary file."""
|
|
41
|
+
|
|
42
|
+
no_format: bool = False
|
|
43
|
+
"""If True, will not normalize the output item's body text formatting (for Markdown)."""
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def workspace(self) -> FileStore:
|
|
47
|
+
from kash.workspaces.workspaces import get_ws
|
|
48
|
+
|
|
49
|
+
return get_ws(self.workspace_dir)
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def non_default_options(self) -> dict[str, str]:
|
|
53
|
+
"""
|
|
54
|
+
Summarize non-default runtime options as a dict.
|
|
55
|
+
"""
|
|
56
|
+
opts: dict[str, str] = {}
|
|
57
|
+
# Only these two settings directly affect the output:
|
|
58
|
+
if self.no_format:
|
|
59
|
+
opts["no_format"] = "true"
|
|
60
|
+
if self.override_state:
|
|
61
|
+
opts["override_state"] = self.override_state.name
|
|
62
|
+
return opts
|
|
63
|
+
|
|
64
|
+
def __repr__(self):
|
|
65
|
+
return abbrev_obj(self, field_max_len=80)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class ExecContext:
|
|
70
|
+
"""
|
|
71
|
+
An action and its context for execution. This is a good place for settings
|
|
72
|
+
that apply to any action and are bothersome to pass as parameters.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
action: Action
|
|
76
|
+
"""The action being executed."""
|
|
77
|
+
|
|
78
|
+
settings: RuntimeSettings
|
|
79
|
+
"""The workspace and other run-time settings for the action."""
|
kash/model/items_model.py
CHANGED
|
@@ -25,6 +25,7 @@ from kash.model.media_model import MediaMetadata
|
|
|
25
25
|
from kash.model.operations_model import OperationSummary, Source
|
|
26
26
|
from kash.model.paths_model import StorePath, fmt_store_path
|
|
27
27
|
from kash.text_handling.markdown_render import markdown_to_html
|
|
28
|
+
from kash.text_handling.markdown_utils import first_heading
|
|
28
29
|
from kash.utils.common.format_utils import fmt_loc, html_to_plaintext, plaintext_to_html
|
|
29
30
|
from kash.utils.common.url import Locator, Url
|
|
30
31
|
from kash.utils.errors import FileFormatError
|
|
@@ -32,7 +33,7 @@ from kash.utils.file_formats.chat_format import ChatHistory
|
|
|
32
33
|
from kash.utils.file_utils.file_formats_model import FileExt, Format
|
|
33
34
|
|
|
34
35
|
if TYPE_CHECKING:
|
|
35
|
-
from kash.model.
|
|
36
|
+
from kash.model.exec_model import ExecContext
|
|
36
37
|
from kash.workspaces import Workspace
|
|
37
38
|
|
|
38
39
|
log = get_logger(__name__)
|
|
@@ -178,9 +179,7 @@ class ItemId:
|
|
|
178
179
|
if item.type == ItemType.resource and item.format == Format.url and item.url:
|
|
179
180
|
item_id = ItemId(item.type, IdType.url, canonicalize_url(item.url))
|
|
180
181
|
elif item.type == ItemType.concept and item.title:
|
|
181
|
-
item_id = ItemId(
|
|
182
|
-
item.type, IdType.concept, canonicalize_concept(item.title)
|
|
183
|
-
)
|
|
182
|
+
item_id = ItemId(item.type, IdType.concept, canonicalize_concept(item.title))
|
|
184
183
|
elif item.source and item.source.cacheable:
|
|
185
184
|
# We know the source of this and if the action was cacheable, we can create
|
|
186
185
|
# an identity based on the source.
|
|
@@ -281,11 +280,9 @@ class Item:
|
|
|
281
280
|
"""
|
|
282
281
|
item_dict = {**item_dict, **kwargs}
|
|
283
282
|
|
|
284
|
-
info_prefix =
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
else ""
|
|
288
|
-
)
|
|
283
|
+
info_prefix = ""
|
|
284
|
+
if "store_path" in item_dict and item_dict["store_path"]:
|
|
285
|
+
info_prefix = f"{fmt_store_path(item_dict['store_path'])}: "
|
|
289
286
|
|
|
290
287
|
# Metadata formats might change over time so it's important to gracefully handle issues.
|
|
291
288
|
def set_field(key: str, default: Any, cls_: type[T]) -> T:
|
|
@@ -314,9 +311,7 @@ class Item:
|
|
|
314
311
|
body = item_dict.get("body")
|
|
315
312
|
history = [OperationSummary(**op) for op in item_dict.get("history", [])]
|
|
316
313
|
relations = (
|
|
317
|
-
ItemRelations(**item_dict["relations"])
|
|
318
|
-
if "relations" in item_dict
|
|
319
|
-
else ItemRelations()
|
|
314
|
+
ItemRelations(**item_dict["relations"]) if "relations" in item_dict else ItemRelations()
|
|
320
315
|
)
|
|
321
316
|
store_path = item_dict.get("store_path")
|
|
322
317
|
|
|
@@ -334,9 +329,7 @@ class Item:
|
|
|
334
329
|
]
|
|
335
330
|
all_fields = [f.name for f in cls.__dataclass_fields__.values()]
|
|
336
331
|
allowed_fields = [f for f in all_fields if f not in excluded_fields]
|
|
337
|
-
other_metadata = {
|
|
338
|
-
key: value for key, value in item_dict.items() if key in allowed_fields
|
|
339
|
-
}
|
|
332
|
+
other_metadata = {key: value for key, value in item_dict.items() if key in allowed_fields}
|
|
340
333
|
unexpected_metadata = {
|
|
341
334
|
key: value for key, value in item_dict.items() if key not in all_fields
|
|
342
335
|
}
|
|
@@ -385,9 +378,7 @@ class Item:
|
|
|
385
378
|
if not item_type:
|
|
386
379
|
# Default to doc for general text files and resource for everything else.
|
|
387
380
|
item_type = (
|
|
388
|
-
ItemType.doc
|
|
389
|
-
if format and format.supports_frontmatter
|
|
390
|
-
else ItemType.resource
|
|
381
|
+
ItemType.doc if format and format.supports_frontmatter else ItemType.resource
|
|
391
382
|
)
|
|
392
383
|
item = cls(
|
|
393
384
|
type=item_type,
|
|
@@ -438,11 +429,9 @@ class Item:
|
|
|
438
429
|
if not self.format:
|
|
439
430
|
raise ValueError(f"Item has no format: {self}")
|
|
440
431
|
if self.type.expects_body and self.format.has_body and not self.body:
|
|
441
|
-
raise ValueError(
|
|
442
|
-
f"Item type `{self.type.value}` is text but has no body: {self}"
|
|
443
|
-
)
|
|
432
|
+
raise ValueError(f"Item type `{self.type.value}` is text but has no body: {self}")
|
|
444
433
|
|
|
445
|
-
def absolute_path(self, ws:
|
|
434
|
+
def absolute_path(self, ws: Workspace | None = None) -> Path:
|
|
446
435
|
"""
|
|
447
436
|
Get the absolute path to the item. Throws `ValueError` if the item has no
|
|
448
437
|
store path. If no workspace is provided, uses the current workspace.
|
|
@@ -493,9 +482,7 @@ class Item:
|
|
|
493
482
|
return {k: serialize(v) for k, v in v.items()}
|
|
494
483
|
elif isinstance(v, Enum):
|
|
495
484
|
return v.value
|
|
496
|
-
elif hasattr(
|
|
497
|
-
v, "as_dict"
|
|
498
|
-
): # Handle Operation or any object with as_dict method.
|
|
485
|
+
elif hasattr(v, "as_dict"): # Handle Operation or any object with as_dict method.
|
|
499
486
|
return v.as_dict()
|
|
500
487
|
elif is_dataclass(v) and not isinstance(v, type):
|
|
501
488
|
# Handle Python and Pydantic dataclasses.
|
|
@@ -532,22 +519,38 @@ class Item:
|
|
|
532
519
|
display_title = self.abbrev_title()
|
|
533
520
|
return display_title
|
|
534
521
|
|
|
535
|
-
def abbrev_title(
|
|
522
|
+
def abbrev_title(
|
|
523
|
+
self,
|
|
524
|
+
*,
|
|
525
|
+
max_len: int = 100,
|
|
526
|
+
add_ops_suffix: bool = False,
|
|
527
|
+
pull_body_heading: bool = False,
|
|
528
|
+
) -> str:
|
|
536
529
|
"""
|
|
537
530
|
Get or infer a title for this item, falling back to the filename, URL,
|
|
538
531
|
description, or finally body text.
|
|
539
532
|
Optionally, include the last operation as a parenthetical at the end of the title.
|
|
540
533
|
"""
|
|
541
|
-
|
|
534
|
+
from kash.file_storage.store_filenames import parse_item_filename
|
|
535
|
+
|
|
536
|
+
# First special case: if we are pulling the title from the body header, check
|
|
537
|
+
# that.
|
|
538
|
+
if not self.title and pull_body_heading:
|
|
539
|
+
heading = self.body_heading()
|
|
540
|
+
if heading:
|
|
541
|
+
return heading
|
|
542
|
+
|
|
543
|
+
# Next special case: URLs with no title use the url itself.
|
|
542
544
|
if not self.title and self.url:
|
|
543
545
|
return abbrev_str(self.url, max_len)
|
|
544
546
|
|
|
545
547
|
# Special case for filenames with no title.
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
548
|
+
# Use stem to drop suffix like .resource.docx etc in a title.
|
|
549
|
+
path = self.store_path or self.external_path or self.original_filename
|
|
550
|
+
if path:
|
|
551
|
+
path_name, _item_type, _format, _file_ext = parse_item_filename(Path(path).name)
|
|
552
|
+
else:
|
|
553
|
+
path_name = None
|
|
551
554
|
|
|
552
555
|
# Use the title or the path if possible, falling back to description or even body text.
|
|
553
556
|
title_raw_text = (
|
|
@@ -559,10 +562,14 @@ class Item:
|
|
|
559
562
|
)
|
|
560
563
|
|
|
561
564
|
suffix = ""
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
565
|
+
# For docs, etc but not for concepts/resources/exports, add a parenthical note
|
|
566
|
+
# indicating the last operation, if there was one. This makes filename slugs
|
|
567
|
+
# more readable.
|
|
568
|
+
if add_ops_suffix and self.type not in [
|
|
569
|
+
ItemType.concept,
|
|
570
|
+
ItemType.resource,
|
|
571
|
+
ItemType.export,
|
|
572
|
+
]:
|
|
566
573
|
last_op = self.history and self.history[-1].action_name
|
|
567
574
|
if last_op:
|
|
568
575
|
step_num = len(self.history) + 1 if self.history else 1
|
|
@@ -579,9 +586,18 @@ class Item:
|
|
|
579
586
|
|
|
580
587
|
return final_text
|
|
581
588
|
|
|
589
|
+
def body_heading(self) -> str | None:
|
|
590
|
+
"""
|
|
591
|
+
Get the first h1 or h2 heading from the body text, if present.
|
|
592
|
+
"""
|
|
593
|
+
if self.format in [Format.markdown, Format.md_html]:
|
|
594
|
+
return first_heading(self.body_text(), allowed_tags=("h1", "h2"))
|
|
595
|
+
# TODO: Support HTML <h1> and <h2> as well.
|
|
596
|
+
return None
|
|
597
|
+
|
|
582
598
|
def abbrev_body(self, max_len: int) -> str:
|
|
583
599
|
"""
|
|
584
|
-
Get
|
|
600
|
+
Get an abbreviated version of the body text. Must not be a binary Item.
|
|
585
601
|
Abbreviates YAML bodies like {"role": "user", "content": "Hello"} to "user Hello".
|
|
586
602
|
"""
|
|
587
603
|
body_text = self.body_text()[:max_len]
|
|
@@ -609,7 +625,7 @@ class Item:
|
|
|
609
625
|
Get a readable slugified version of the title or filename or content
|
|
610
626
|
appropriate for this item. May not be unique.
|
|
611
627
|
"""
|
|
612
|
-
title = self.abbrev_title(max_len=max_len)
|
|
628
|
+
title = self.abbrev_title(max_len=max_len, add_ops_suffix=True)
|
|
613
629
|
slug = slugify_snake(title)
|
|
614
630
|
return slug
|
|
615
631
|
|
|
@@ -617,9 +633,7 @@ class Item:
|
|
|
617
633
|
"""
|
|
618
634
|
Get or infer description.
|
|
619
635
|
"""
|
|
620
|
-
return abbrev_on_words(
|
|
621
|
-
html_to_plaintext(self.description or self.body or ""), max_len
|
|
622
|
-
)
|
|
636
|
+
return abbrev_on_words(html_to_plaintext(self.description or self.body or ""), max_len)
|
|
623
637
|
|
|
624
638
|
def read_as_config(self) -> Any:
|
|
625
639
|
"""
|
|
@@ -656,6 +670,9 @@ class Item:
|
|
|
656
670
|
elif self.type == ItemType.script:
|
|
657
671
|
# Same for kash/xonsh scripts.
|
|
658
672
|
return f"{self.type.value}.{FileExt.xsh.value}"
|
|
673
|
+
elif self.type == ItemType.export:
|
|
674
|
+
# For exports, skip the item type to keep it maximally compatible for external tools.
|
|
675
|
+
return f"{self.get_file_ext().value}"
|
|
659
676
|
else:
|
|
660
677
|
return f"{self.type.value}.{self.get_file_ext().value}"
|
|
661
678
|
|
|
@@ -668,11 +685,19 @@ class Item:
|
|
|
668
685
|
return "\n\n".join(part for part in parts if part)
|
|
669
686
|
|
|
670
687
|
def body_text(self) -> str:
|
|
688
|
+
"""
|
|
689
|
+
Body text of the item, also validating that the item is not binary.
|
|
690
|
+
"""
|
|
671
691
|
if self.is_binary:
|
|
672
692
|
raise ValueError("Cannot get text content of a binary Item")
|
|
673
693
|
return self.body or ""
|
|
674
694
|
|
|
675
695
|
def body_as_html(self) -> str:
|
|
696
|
+
"""
|
|
697
|
+
Body of the item, converted to HTML format. Validates that the body format can be
|
|
698
|
+
converted and then converts plaintext or Markdown to HTML. Simply returns the body
|
|
699
|
+
if it is already HTML.
|
|
700
|
+
"""
|
|
676
701
|
if self.format == Format.html:
|
|
677
702
|
return self.body_text()
|
|
678
703
|
elif self.format == Format.plaintext:
|
|
@@ -708,12 +733,10 @@ class Item:
|
|
|
708
733
|
self, update_timestamp: bool = True, **other_updates: Unpack[ItemUpdateOptions]
|
|
709
734
|
) -> Item:
|
|
710
735
|
"""
|
|
711
|
-
Copy item with the given field updates. Resets store_path to None
|
|
712
|
-
created time if requested.
|
|
736
|
+
Copy item with the given field updates. Resets `store_path` to None but preserves
|
|
737
|
+
other fields, including the body. Updates created time if requested.
|
|
713
738
|
"""
|
|
714
|
-
new_fields = self._copy_and_update(
|
|
715
|
-
update_timestamp=update_timestamp, **other_updates
|
|
716
|
-
)
|
|
739
|
+
new_fields = self._copy_and_update(update_timestamp=update_timestamp, **other_updates)
|
|
717
740
|
return Item(**new_fields)
|
|
718
741
|
|
|
719
742
|
def merged_copy(self, other: Item) -> Item:
|
|
@@ -734,7 +757,7 @@ class Item:
|
|
|
734
757
|
if self.relations.derived_from:
|
|
735
758
|
log.message(
|
|
736
759
|
"Deriving from an item that has not been saved so using "
|
|
737
|
-
"
|
|
760
|
+
"upstream derived_from relation: %s on %s",
|
|
738
761
|
self.relations.derived_from,
|
|
739
762
|
self,
|
|
740
763
|
)
|
|
@@ -768,9 +791,7 @@ class Item:
|
|
|
768
791
|
|
|
769
792
|
# Fall back to action title template if we have it and title wasn't explicitly set.
|
|
770
793
|
if "title" not in updates:
|
|
771
|
-
prev_title = self.title or (
|
|
772
|
-
Path(self.store_path).stem if self.store_path else UNTITLED
|
|
773
|
-
)
|
|
794
|
+
prev_title = self.title or (Path(self.store_path).stem if self.store_path else UNTITLED)
|
|
774
795
|
if self.context:
|
|
775
796
|
action = self.context.action
|
|
776
797
|
new_item.title = action.title_template.format(
|
kash/model/operations_model.py
CHANGED
|
@@ -25,27 +25,46 @@ class OperationSummary:
|
|
|
25
25
|
class Input:
|
|
26
26
|
"""
|
|
27
27
|
An input to an operation, which may include a hash fingerprint.
|
|
28
|
+
Typically an input is a StorePath, but it could be something else like an in-memory
|
|
29
|
+
item that hasn't been saved yet.
|
|
28
30
|
"""
|
|
29
31
|
|
|
30
|
-
|
|
31
|
-
path: StorePath
|
|
32
|
+
path: StorePath | None
|
|
32
33
|
hash: str | None = None
|
|
34
|
+
source_info: str | None = None
|
|
33
35
|
|
|
34
36
|
@classmethod
|
|
35
37
|
def parse(cls, input_str: str) -> Input:
|
|
36
38
|
"""
|
|
37
|
-
Parse an Input string in the format `
|
|
38
|
-
`@some/path/filename.ext@sha1:hash`, with a store path and a hash.
|
|
39
|
+
Parse an Input string in the format printed by `Input.parseable_str()`.
|
|
39
40
|
"""
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
path, hash = parts
|
|
43
|
-
return cls(path=StorePath(path), hash=hash)
|
|
41
|
+
if input_str.startswith("[") and input_str.endswith("]"):
|
|
42
|
+
return cls(path=None, hash=None, source_info=input_str[1:-1])
|
|
44
43
|
else:
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
44
|
+
parts = input_str.rsplit("@", 1)
|
|
45
|
+
if len(parts) == 2:
|
|
46
|
+
path, hash = parts
|
|
47
|
+
return cls(path=StorePath(path), hash=hash)
|
|
48
|
+
else:
|
|
49
|
+
return cls(path=StorePath(input_str), hash=None)
|
|
50
|
+
|
|
51
|
+
def parseable_str(self):
|
|
52
|
+
"""
|
|
53
|
+
A readable and parseable string describing the input, typically a hash and a path but
|
|
54
|
+
could be a path without a hash or another info in brackets. Paths may have an `@` at the
|
|
55
|
+
front.
|
|
56
|
+
|
|
57
|
+
some/path.txt@sha1:1234567890
|
|
58
|
+
@some/path.txt@sha1:1234567890
|
|
59
|
+
some/path.txt
|
|
60
|
+
[unsaved]
|
|
61
|
+
"""
|
|
62
|
+
if self.path and self.hash:
|
|
63
|
+
return f"{fmt_loc(self.path)}@{self.hash}"
|
|
64
|
+
elif self.source_info:
|
|
65
|
+
return f"[{self.source_info}]"
|
|
66
|
+
else:
|
|
67
|
+
return "[input info missing]"
|
|
49
68
|
|
|
50
69
|
# Inputs are equal if the hashes match (even if the paths have changed).
|
|
51
70
|
|
|
@@ -53,6 +72,10 @@ class Input:
|
|
|
53
72
|
return hash(self.hash) if self.hash else object.__hash__(self)
|
|
54
73
|
|
|
55
74
|
def __eq__(self, other: Any) -> bool:
|
|
75
|
+
"""
|
|
76
|
+
Inputs are equal if the hashes match (even if the paths have changed) or if the paths
|
|
77
|
+
are the same. They are *not* equal otherwise, even if the source_info is the same.
|
|
78
|
+
"""
|
|
56
79
|
if not isinstance(other, Input):
|
|
57
80
|
return NotImplemented
|
|
58
81
|
if self.hash and other.hash:
|
|
@@ -62,7 +85,7 @@ class Input:
|
|
|
62
85
|
return False
|
|
63
86
|
|
|
64
87
|
def __str__(self):
|
|
65
|
-
return self.
|
|
88
|
+
return self.parseable_str()
|
|
66
89
|
|
|
67
90
|
|
|
68
91
|
@dataclass(frozen=True)
|
|
@@ -88,7 +111,7 @@ class Operation:
|
|
|
88
111
|
}
|
|
89
112
|
|
|
90
113
|
if self.arguments:
|
|
91
|
-
d["arguments"] = [arg.
|
|
114
|
+
d["arguments"] = [arg.parseable_str() for arg in self.arguments]
|
|
92
115
|
if self.options:
|
|
93
116
|
d["options"] = self.options
|
|
94
117
|
|
|
@@ -101,7 +124,7 @@ class Operation:
|
|
|
101
124
|
return [shell_quote(str(arg.path)) for arg in self.arguments]
|
|
102
125
|
|
|
103
126
|
def hashed_args(self):
|
|
104
|
-
return [arg.
|
|
127
|
+
return [arg.parseable_str() for arg in self.arguments]
|
|
105
128
|
|
|
106
129
|
def quoted_options(self):
|
|
107
130
|
return [f"--{k}={shell_quote(str(v))}" for k, v in self.options.items()]
|
kash/model/paths_model.py
CHANGED
|
@@ -16,7 +16,7 @@ from rich.rule import Rule
|
|
|
16
16
|
from rich.style import Style
|
|
17
17
|
from rich.text import Text
|
|
18
18
|
|
|
19
|
-
from kash.config.logger import get_console
|
|
19
|
+
from kash.config.logger import get_console, is_console_quiet
|
|
20
20
|
from kash.config.text_styles import (
|
|
21
21
|
COLOR_HINT_DIM,
|
|
22
22
|
COLOR_RESPONSE,
|
|
@@ -31,9 +31,6 @@ from kash.shell.output.kmarkdown import KMarkdown
|
|
|
31
31
|
from kash.utils.rich_custom.rich_indent import Indent
|
|
32
32
|
from kash.utils.rich_custom.rich_markdown_fork import Markdown
|
|
33
33
|
|
|
34
|
-
console = get_console()
|
|
35
|
-
|
|
36
|
-
|
|
37
34
|
print_context_var: contextvars.ContextVar[str] = contextvars.ContextVar("print_prefix", default="")
|
|
38
35
|
"""
|
|
39
36
|
Context variable override for print prefix.
|
|
@@ -99,7 +96,12 @@ def rich_print(
|
|
|
99
96
|
Print to the Rich console, either the global console or a thread-local
|
|
100
97
|
override, if one is active. With `raw` true, we bypass rich formatting
|
|
101
98
|
entirely and simply write to the console stream.
|
|
99
|
+
|
|
100
|
+
Output is suppressed by the global `console_quiet` setting.
|
|
102
101
|
"""
|
|
102
|
+
if is_console_quiet():
|
|
103
|
+
return
|
|
104
|
+
|
|
103
105
|
console = get_console()
|
|
104
106
|
if raw:
|
|
105
107
|
# TODO: Indent not supported in raw mode.
|
|
@@ -136,7 +138,7 @@ def cprint(
|
|
|
136
138
|
raw: bool = False,
|
|
137
139
|
):
|
|
138
140
|
"""
|
|
139
|
-
Main way to print to the shell. Wraps `
|
|
141
|
+
Main way to print to the shell. Wraps `rich_print` with our additional
|
|
140
142
|
formatting options for text fill and prefix.
|
|
141
143
|
"""
|
|
142
144
|
empty_indent = extra_indent.strip()
|
|
@@ -323,8 +325,8 @@ class PrintHooks(Enum):
|
|
|
323
325
|
after_command_run = "after_command_run"
|
|
324
326
|
before_status = "before_status"
|
|
325
327
|
after_status = "after_status"
|
|
326
|
-
before_shell_action_run = "
|
|
327
|
-
after_shell_action_run = "
|
|
328
|
+
before_shell_action_run = "before_shell_action_run"
|
|
329
|
+
after_shell_action_run = "after_shell_action_run"
|
|
328
330
|
before_log_action_run = "before_log_action_run"
|
|
329
331
|
before_assistance = "before_assistance"
|
|
330
332
|
after_assistance = "after_assistance"
|
|
@@ -376,7 +378,7 @@ class PrintHooks(Enum):
|
|
|
376
378
|
elif self == PrintHooks.nonfatal_exception:
|
|
377
379
|
self.nl()
|
|
378
380
|
elif self == PrintHooks.before_done_message:
|
|
379
|
-
|
|
381
|
+
pass
|
|
380
382
|
elif self == PrintHooks.before_output:
|
|
381
383
|
self.nl()
|
|
382
384
|
elif self == PrintHooks.after_output:
|
kash/shell/shell_main.py
CHANGED
|
@@ -14,10 +14,10 @@ import argparse
|
|
|
14
14
|
import threading
|
|
15
15
|
|
|
16
16
|
import xonsh.main
|
|
17
|
+
from clideps.utils.readable_argparse import ReadableColorFormatter
|
|
17
18
|
from strif import quote_if_needed
|
|
18
19
|
|
|
19
20
|
from kash.config.setup import kash_setup
|
|
20
|
-
from kash.shell.utils.argparse_utils import WrappedColorFormatter
|
|
21
21
|
from kash.shell.version import get_full_version_name, get_version
|
|
22
22
|
from kash.xonsh_custom.custom_shell import install_to_xonshrc, start_shell
|
|
23
23
|
|
|
@@ -51,7 +51,7 @@ def run_shell(single_command: str | None = None):
|
|
|
51
51
|
|
|
52
52
|
|
|
53
53
|
def build_parser() -> argparse.ArgumentParser:
|
|
54
|
-
parser = argparse.ArgumentParser(description=__doc__, formatter_class=
|
|
54
|
+
parser = argparse.ArgumentParser(description=__doc__, formatter_class=ReadableColorFormatter)
|
|
55
55
|
|
|
56
56
|
parser.add_argument("--version", action="version", version=get_full_version_name())
|
|
57
57
|
|
|
@@ -5,7 +5,7 @@ from typing import TypeVar
|
|
|
5
5
|
from kash.config.logger import get_logger
|
|
6
6
|
from kash.config.text_styles import COLOR_ERROR
|
|
7
7
|
from kash.shell.output.shell_output import PrintHooks
|
|
8
|
-
from kash.utils.errors import
|
|
8
|
+
from kash.utils.errors import get_nonfatal_exceptions
|
|
9
9
|
|
|
10
10
|
log = get_logger(__name__)
|
|
11
11
|
|
|
@@ -41,7 +41,7 @@ def wrap_with_exception_printing(func: Callable[..., R]) -> Callable[[list[str]]
|
|
|
41
41
|
(", ".join(str(arg) for arg in args)),
|
|
42
42
|
)
|
|
43
43
|
return func(*args)
|
|
44
|
-
except
|
|
44
|
+
except get_nonfatal_exceptions() as e:
|
|
45
45
|
PrintHooks.nonfatal_exception()
|
|
46
46
|
log.error(f"[{COLOR_ERROR}]Command error:[/{COLOR_ERROR}] %s", summarize_traceback(e))
|
|
47
47
|
log.info("Command error details: %s", e, exc_info=True)
|
|
@@ -2,7 +2,7 @@ from pathlib import Path
|
|
|
2
2
|
|
|
3
3
|
from flowmark import fill_markdown, fill_text, line_wrap_by_sentence
|
|
4
4
|
from flowmark.text_filling import DEFAULT_WRAP_WIDTH
|
|
5
|
-
from flowmark.text_wrapping import simple_word_splitter
|
|
5
|
+
from flowmark.text_wrapping import simple_word_splitter
|
|
6
6
|
from frontmatter_format import fmf_read, fmf_write
|
|
7
7
|
|
|
8
8
|
from kash.utils.common.format_utils import fmt_loc
|
|
@@ -11,20 +11,26 @@ from kash.utils.file_utils.file_formats_model import Format, detect_file_format
|
|
|
11
11
|
from kash.utils.rich_custom.ansi_cell_len import ansi_cell_len
|
|
12
12
|
|
|
13
13
|
|
|
14
|
-
def
|
|
14
|
+
def normalize_formatting(
|
|
15
|
+
text: str,
|
|
16
|
+
format: Format | None,
|
|
17
|
+
width=DEFAULT_WRAP_WIDTH,
|
|
18
|
+
support_ansi: bool = True,
|
|
19
|
+
cleanups: bool = True,
|
|
20
|
+
) -> str:
|
|
15
21
|
"""
|
|
16
22
|
Normalize text formatting by wrapping lines and normalizing Markdown.
|
|
23
|
+
This only does "safe" normalizations that cannot break the text.
|
|
17
24
|
Enables ANSI support so ANSI codes and OSC-8 links are correctly handled.
|
|
18
25
|
"""
|
|
26
|
+
len_fn = ansi_cell_len if support_ansi else len
|
|
19
27
|
if format == Format.plaintext:
|
|
20
|
-
return fill_text(
|
|
21
|
-
text, width=width, word_splitter=simple_word_splitter, len_fn=ansi_cell_len
|
|
22
|
-
)
|
|
28
|
+
return fill_text(text, width=width, word_splitter=simple_word_splitter, len_fn=len_fn)
|
|
23
29
|
elif format == Format.markdown or format == Format.md_html:
|
|
24
30
|
return fill_markdown(
|
|
25
31
|
text,
|
|
26
|
-
line_wrapper=line_wrap_by_sentence(len_fn=
|
|
27
|
-
cleanups=
|
|
32
|
+
line_wrapper=line_wrap_by_sentence(len_fn=len_fn, is_markdown=True),
|
|
33
|
+
cleanups=cleanups,
|
|
28
34
|
)
|
|
29
35
|
elif format == Format.html:
|
|
30
36
|
# We don't currently auto-format HTML as we sometimes use HTML with specifically chosen line breaks.
|
|
@@ -37,6 +43,7 @@ def normalize_text_file(
|
|
|
37
43
|
path: str | Path,
|
|
38
44
|
target_path: Path,
|
|
39
45
|
format: Format | None = None,
|
|
46
|
+
support_ansi: bool = True,
|
|
40
47
|
) -> None:
|
|
41
48
|
"""
|
|
42
49
|
Normalize formatting on a text file, handling Markdown, HTML, or text, as well as
|
|
@@ -48,7 +55,7 @@ def normalize_text_file(
|
|
|
48
55
|
raise ValueError(f"Cannot format non-text files: {fmt_loc(path)}")
|
|
49
56
|
|
|
50
57
|
content, metadata = fmf_read(path)
|
|
51
|
-
norm_content =
|
|
58
|
+
norm_content = normalize_formatting(content, format=format, support_ansi=support_ansi)
|
|
52
59
|
fmf_write(not_none(target_path), norm_content, metadata)
|
|
53
60
|
|
|
54
61
|
|
|
@@ -57,6 +64,7 @@ def normalize_text_file(
|
|
|
57
64
|
|
|
58
65
|
def test_osc8_link():
|
|
59
66
|
from clideps.terminal.osc_utils import osc8_link
|
|
67
|
+
from flowmark.text_wrapping import wrap_paragraph
|
|
60
68
|
|
|
61
69
|
link = osc8_link("https://example.com/" + "x" * 50, "Example")
|
|
62
70
|
assert ansi_cell_len(link) == 7
|