kash-shell 0.3.10__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/format_markdown_template.py +2 -5
- kash/actions/core/markdownify.py +2 -4
- kash/actions/core/readability.py +2 -4
- kash/actions/core/render_as_html.py +30 -11
- kash/actions/core/show_webpage.py +6 -11
- kash/actions/core/strip_html.py +4 -8
- kash/actions/core/{webpage_config.py → tabbed_webpage_config.py} +5 -3
- kash/actions/core/{webpage_generate.py → tabbed_webpage_generate.py} +5 -4
- kash/commands/base/basic_file_commands.py +21 -3
- kash/commands/base/files_command.py +29 -10
- kash/commands/extras/parse_uv_lock.py +12 -3
- kash/commands/workspace/selection_commands.py +1 -1
- kash/commands/workspace/workspace_commands.py +2 -3
- kash/config/colors.py +2 -2
- 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 +10 -25
- kash/exec/action_exec.py +43 -23
- kash/exec/llm_transforms.py +6 -3
- kash/exec/preconditions.py +10 -12
- kash/exec/resolve_args.py +4 -0
- kash/exec/runtime_settings.py +134 -0
- kash/exec/shell_callable_action.py +5 -3
- kash/file_storage/file_store.py +37 -38
- kash/file_storage/item_file_format.py +6 -3
- kash/file_storage/store_filenames.py +6 -3
- kash/help/function_param_info.py +1 -1
- 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/local_server/local_server_routes.py +1 -7
- 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 +102 -35
- 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/shell/utils/shell_function_wrapper.py +15 -15
- kash/text_handling/doc_normalization.py +16 -8
- kash/text_handling/markdown_render.py +1 -0
- kash/text_handling/markdown_utils.py +105 -2
- kash/utils/common/format_utils.py +2 -8
- kash/utils/common/function_inspect.py +360 -110
- 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_ext.py +4 -0
- kash/utils/file_utils/file_formats_model.py +32 -1
- kash/utils/file_utils/file_sort_filter.py +10 -3
- kash/web_gen/__init__.py +0 -4
- kash/web_gen/simple_webpage.py +52 -0
- kash/web_gen/tabbed_webpage.py +23 -16
- kash/web_gen/template_render.py +37 -2
- kash/web_gen/templates/base_styles.css.jinja +84 -59
- kash/web_gen/templates/base_webpage.html.jinja +85 -67
- kash/web_gen/templates/item_view.html.jinja +47 -37
- kash/web_gen/templates/simple_webpage.html.jinja +24 -0
- kash/web_gen/templates/tabbed_webpage.html.jinja +42 -32
- 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.10.dist-info → kash_shell-0.3.12.dist-info}/METADATA +7 -7
- {kash_shell-0.3.10.dist-info → kash_shell-0.3.12.dist-info}/RECORD +81 -76
- kash/shell/utils/argparse_utils.py +0 -20
- kash/utils/lang_utils/inflection.py +0 -18
- {kash_shell-0.3.10.dist-info → kash_shell-0.3.12.dist-info}/WHEEL +0 -0
- {kash_shell-0.3.10.dist-info → kash_shell-0.3.12.dist-info}/entry_points.txt +0 -0
- {kash_shell-0.3.10.dist-info → kash_shell-0.3.12.dist-info}/licenses/LICENSE +0 -0
kash/model/items_model.py
CHANGED
|
@@ -6,7 +6,7 @@ from dataclasses import asdict, field, is_dataclass
|
|
|
6
6
|
from datetime import UTC, datetime
|
|
7
7
|
from enum import Enum
|
|
8
8
|
from pathlib import Path
|
|
9
|
-
from typing import TYPE_CHECKING, Any, TypeVar
|
|
9
|
+
from typing import TYPE_CHECKING, Any, NotRequired, TypedDict, TypeVar, Unpack
|
|
10
10
|
|
|
11
11
|
from frontmatter_format import from_yaml_string, new_yaml
|
|
12
12
|
from prettyfmt import (
|
|
@@ -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__)
|
|
@@ -120,6 +121,28 @@ class IdType(Enum):
|
|
|
120
121
|
source = "source"
|
|
121
122
|
|
|
122
123
|
|
|
124
|
+
class ItemUpdateOptions(TypedDict, total=False):
|
|
125
|
+
"""
|
|
126
|
+
Keyword arguments that can be passed to update an Item.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
type: NotRequired[ItemType]
|
|
130
|
+
state: NotRequired[State]
|
|
131
|
+
title: NotRequired[str | None]
|
|
132
|
+
url: NotRequired[Url | None]
|
|
133
|
+
description: NotRequired[str | None]
|
|
134
|
+
format: NotRequired[Format | None]
|
|
135
|
+
file_ext: NotRequired[FileExt | None]
|
|
136
|
+
body: NotRequired[str | None]
|
|
137
|
+
external_path: NotRequired[str | None]
|
|
138
|
+
original_filename: NotRequired[str | None]
|
|
139
|
+
relations: NotRequired[ItemRelations]
|
|
140
|
+
source: NotRequired[Source | None]
|
|
141
|
+
history: NotRequired[list[OperationSummary] | None]
|
|
142
|
+
thumbnail_url: NotRequired[Url | None]
|
|
143
|
+
extra: NotRequired[dict | None]
|
|
144
|
+
|
|
145
|
+
|
|
123
146
|
@dataclass(frozen=True)
|
|
124
147
|
class ItemId:
|
|
125
148
|
"""
|
|
@@ -257,9 +280,9 @@ class Item:
|
|
|
257
280
|
"""
|
|
258
281
|
item_dict = {**item_dict, **kwargs}
|
|
259
282
|
|
|
260
|
-
info_prefix =
|
|
261
|
-
|
|
262
|
-
|
|
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'])}: "
|
|
263
286
|
|
|
264
287
|
# Metadata formats might change over time so it's important to gracefully handle issues.
|
|
265
288
|
def set_field(key: str, default: Any, cls_: type[T]) -> T:
|
|
@@ -408,7 +431,7 @@ class Item:
|
|
|
408
431
|
if self.type.expects_body and self.format.has_body and not self.body:
|
|
409
432
|
raise ValueError(f"Item type `{self.type.value}` is text but has no body: {self}")
|
|
410
433
|
|
|
411
|
-
def absolute_path(self, ws:
|
|
434
|
+
def absolute_path(self, ws: Workspace | None = None) -> Path:
|
|
412
435
|
"""
|
|
413
436
|
Get the absolute path to the item. Throws `ValueError` if the item has no
|
|
414
437
|
store path. If no workspace is provided, uses the current workspace.
|
|
@@ -496,38 +519,57 @@ class Item:
|
|
|
496
519
|
display_title = self.abbrev_title()
|
|
497
520
|
return display_title
|
|
498
521
|
|
|
499
|
-
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:
|
|
500
529
|
"""
|
|
501
530
|
Get or infer a title for this item, falling back to the filename, URL,
|
|
502
531
|
description, or finally body text.
|
|
503
532
|
Optionally, include the last operation as a parenthetical at the end of the title.
|
|
504
533
|
"""
|
|
505
|
-
|
|
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.
|
|
506
544
|
if not self.title and self.url:
|
|
507
545
|
return abbrev_str(self.url, max_len)
|
|
508
546
|
|
|
509
547
|
# Special case for filenames with no title.
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
return abbrev_str(path_stem, max_len)
|
|
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
|
|
517
554
|
|
|
518
|
-
#
|
|
555
|
+
# Use the title or the path if possible, falling back to description or even body text.
|
|
519
556
|
title_raw_text = (
|
|
520
557
|
self.title
|
|
558
|
+
or path_name
|
|
521
559
|
or self.description
|
|
522
560
|
or (not self.is_binary and self.abbrev_body(max_len))
|
|
523
561
|
or UNTITLED
|
|
524
562
|
)
|
|
525
563
|
|
|
526
564
|
suffix = ""
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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
|
+
]:
|
|
531
573
|
last_op = self.history and self.history[-1].action_name
|
|
532
574
|
if last_op:
|
|
533
575
|
step_num = len(self.history) + 1 if self.history else 1
|
|
@@ -544,9 +586,18 @@ class Item:
|
|
|
544
586
|
|
|
545
587
|
return final_text
|
|
546
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
|
+
|
|
547
598
|
def abbrev_body(self, max_len: int) -> str:
|
|
548
599
|
"""
|
|
549
|
-
Get
|
|
600
|
+
Get an abbreviated version of the body text. Must not be a binary Item.
|
|
550
601
|
Abbreviates YAML bodies like {"role": "user", "content": "Hello"} to "user Hello".
|
|
551
602
|
"""
|
|
552
603
|
body_text = self.body_text()[:max_len]
|
|
@@ -574,7 +625,7 @@ class Item:
|
|
|
574
625
|
Get a readable slugified version of the title or filename or content
|
|
575
626
|
appropriate for this item. May not be unique.
|
|
576
627
|
"""
|
|
577
|
-
title = self.abbrev_title(max_len=max_len)
|
|
628
|
+
title = self.abbrev_title(max_len=max_len, add_ops_suffix=True)
|
|
578
629
|
slug = slugify_snake(title)
|
|
579
630
|
return slug
|
|
580
631
|
|
|
@@ -613,13 +664,15 @@ class Item:
|
|
|
613
664
|
"""
|
|
614
665
|
Get the full file extension suffix (e.g. "note.md") for this item.
|
|
615
666
|
"""
|
|
616
|
-
|
|
617
667
|
if self.type == ItemType.extension:
|
|
618
668
|
# Python files cannot have more than one . in them.
|
|
619
669
|
return f"{FileExt.py.value}"
|
|
620
670
|
elif self.type == ItemType.script:
|
|
621
671
|
# Same for kash/xonsh scripts.
|
|
622
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}"
|
|
623
676
|
else:
|
|
624
677
|
return f"{self.type.value}.{self.get_file_ext().value}"
|
|
625
678
|
|
|
@@ -632,11 +685,19 @@ class Item:
|
|
|
632
685
|
return "\n\n".join(part for part in parts if part)
|
|
633
686
|
|
|
634
687
|
def body_text(self) -> str:
|
|
688
|
+
"""
|
|
689
|
+
Body text of the item, also validating that the item is not binary.
|
|
690
|
+
"""
|
|
635
691
|
if self.is_binary:
|
|
636
692
|
raise ValueError("Cannot get text content of a binary Item")
|
|
637
693
|
return self.body or ""
|
|
638
694
|
|
|
639
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
|
+
"""
|
|
640
701
|
if self.format == Format.html:
|
|
641
702
|
return self.body_text()
|
|
642
703
|
elif self.format == Format.plaintext:
|
|
@@ -647,7 +708,10 @@ class Item:
|
|
|
647
708
|
raise ValueError(f"Cannot convert item of type {self.format} to HTML: {self}")
|
|
648
709
|
|
|
649
710
|
def _copy_and_update(
|
|
650
|
-
self,
|
|
711
|
+
self,
|
|
712
|
+
other: Item | None = None,
|
|
713
|
+
update_timestamp: bool = False,
|
|
714
|
+
**other_updates: Unpack[ItemUpdateOptions],
|
|
651
715
|
) -> dict[str, Any]:
|
|
652
716
|
overrides: dict[str, Any] = {"store_path": None, "modified_at": None}
|
|
653
717
|
if update_timestamp:
|
|
@@ -665,10 +729,12 @@ class Item:
|
|
|
665
729
|
|
|
666
730
|
return fields
|
|
667
731
|
|
|
668
|
-
def new_copy_with(
|
|
732
|
+
def new_copy_with(
|
|
733
|
+
self, update_timestamp: bool = True, **other_updates: Unpack[ItemUpdateOptions]
|
|
734
|
+
) -> Item:
|
|
669
735
|
"""
|
|
670
|
-
Copy item with the given field updates. Resets store_path to None
|
|
671
|
-
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.
|
|
672
738
|
"""
|
|
673
739
|
new_fields = self._copy_and_update(update_timestamp=update_timestamp, **other_updates)
|
|
674
740
|
return Item(**new_fields)
|
|
@@ -681,7 +747,7 @@ class Item:
|
|
|
681
747
|
merged_fields = self._copy_and_update(other, update_timestamp=False)
|
|
682
748
|
return Item(**merged_fields)
|
|
683
749
|
|
|
684
|
-
def derived_copy(self,
|
|
750
|
+
def derived_copy(self, **updates: Unpack[ItemUpdateOptions]) -> Item:
|
|
685
751
|
"""
|
|
686
752
|
Same as `new_copy_with()`, but also makes any other updates and updates the
|
|
687
753
|
`derived_from` relation. If we also have an action context, then use the
|
|
@@ -691,7 +757,7 @@ class Item:
|
|
|
691
757
|
if self.relations.derived_from:
|
|
692
758
|
log.message(
|
|
693
759
|
"Deriving from an item that has not been saved so using "
|
|
694
|
-
"
|
|
760
|
+
"upstream derived_from relation: %s on %s",
|
|
695
761
|
self.relations.derived_from,
|
|
696
762
|
self,
|
|
697
763
|
)
|
|
@@ -706,12 +772,12 @@ class Item:
|
|
|
706
772
|
else:
|
|
707
773
|
derived_from = [StorePath(self.store_path)]
|
|
708
774
|
|
|
709
|
-
updates =
|
|
710
|
-
updates["type"] = type
|
|
775
|
+
updates = updates.copy()
|
|
711
776
|
|
|
712
777
|
# If format was specified and user didn't specify file_ext, then infer it.
|
|
713
|
-
if "file_ext" not in
|
|
714
|
-
updates["
|
|
778
|
+
if "file_ext" not in updates and "format" in updates:
|
|
779
|
+
assert updates["format"] is not None
|
|
780
|
+
updates["file_ext"] = updates["format"].file_ext
|
|
715
781
|
|
|
716
782
|
# External resource paths only make sense for resources, so clear them out if new item
|
|
717
783
|
# is not a resource.
|
|
@@ -724,7 +790,7 @@ class Item:
|
|
|
724
790
|
new_item.update_relations(derived_from=derived_from)
|
|
725
791
|
|
|
726
792
|
# Fall back to action title template if we have it and title wasn't explicitly set.
|
|
727
|
-
if "title" not in
|
|
793
|
+
if "title" not in updates:
|
|
728
794
|
prev_title = self.title or (Path(self.store_path).stem if self.store_path else UNTITLED)
|
|
729
795
|
if self.context:
|
|
730
796
|
action = self.context.action
|
|
@@ -764,7 +830,8 @@ class Item:
|
|
|
764
830
|
|
|
765
831
|
def content_equals(self, other: Item) -> bool:
|
|
766
832
|
"""
|
|
767
|
-
Check if two items have identical content, ignoring timestamps
|
|
833
|
+
Check if two items have identical content, ignoring timestamps, store path,
|
|
834
|
+
and any trailing newlines or whitespace.
|
|
768
835
|
"""
|
|
769
836
|
# Check relevant metadata fields.
|
|
770
837
|
self_fields = self.__dict__.copy()
|
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)
|
|
@@ -27,7 +27,7 @@ def _map_positional(
|
|
|
27
27
|
keywords_consumed = 0
|
|
28
28
|
|
|
29
29
|
for param in pos_params:
|
|
30
|
-
param_type = param.
|
|
30
|
+
param_type = param.effective_type or str
|
|
31
31
|
if param.is_varargs:
|
|
32
32
|
pos_values.extend([param_type(arg) for arg in pos_args[i:]])
|
|
33
33
|
return pos_values, 0 # All remaining args are consumed, so we can return early.
|
|
@@ -39,7 +39,7 @@ def _map_positional(
|
|
|
39
39
|
|
|
40
40
|
# If there are remaining positional arguments, they will go toward keyword arguments.
|
|
41
41
|
for param in kw_params:
|
|
42
|
-
param_type = param.
|
|
42
|
+
param_type = param.effective_type or str
|
|
43
43
|
if not param.is_varargs and i < len(pos_args):
|
|
44
44
|
pos_values.append(param_type(pos_args[i]))
|
|
45
45
|
i += 1
|
|
@@ -70,30 +70,30 @@ def _map_keyword(kw_args: Mapping[str, str | bool], kw_params: list[FuncParam])
|
|
|
70
70
|
for key, value in kw_args.items():
|
|
71
71
|
matching_param = next((param for param in kw_params if param.name == key), None)
|
|
72
72
|
if matching_param:
|
|
73
|
-
|
|
73
|
+
param_type = matching_param.effective_type or str
|
|
74
74
|
|
|
75
75
|
# Handle UnionType (str | None) specially
|
|
76
|
-
if hasattr(types, "UnionType") and isinstance(
|
|
77
|
-
args = get_args(
|
|
76
|
+
if hasattr(types, "UnionType") and isinstance(param_type, types.UnionType):
|
|
77
|
+
args = get_args(param_type)
|
|
78
78
|
non_none_args = [arg for arg in args if arg is not type(None)]
|
|
79
79
|
if len(non_none_args) == 1 and isinstance(non_none_args[0], type):
|
|
80
|
-
|
|
80
|
+
param_type = non_none_args[0]
|
|
81
81
|
|
|
82
|
-
if isinstance(value, bool) and not issubclass(
|
|
82
|
+
if isinstance(value, bool) and not issubclass(param_type, bool):
|
|
83
83
|
raise InvalidCommand(f"Option `--{key}` expects a value")
|
|
84
|
-
if not isinstance(value, bool) and issubclass(
|
|
84
|
+
if not isinstance(value, bool) and issubclass(param_type, bool):
|
|
85
85
|
raise InvalidCommand(f"Option `--{key}` is boolean and does not take a value")
|
|
86
86
|
|
|
87
87
|
try:
|
|
88
|
-
kw_values[key] = instantiate_as_type(
|
|
89
|
-
value, matching_param_type, accept_enum_names=True
|
|
90
|
-
)
|
|
88
|
+
kw_values[key] = instantiate_as_type(value, param_type, accept_enum_names=True)
|
|
91
89
|
except Exception as e:
|
|
92
90
|
valid_values = ""
|
|
93
|
-
if isinstance(
|
|
94
|
-
valid_values =
|
|
91
|
+
if isinstance(param_type, type) and issubclass(param_type, Enum):
|
|
92
|
+
valid_values = (
|
|
93
|
+
f" (valid values are: {', '.join('`' + v.name + '`' for v in param_type)})"
|
|
94
|
+
)
|
|
95
95
|
raise InvalidCommand(
|
|
96
|
-
f"Invalid value for parameter `{key}` of type {
|
|
96
|
+
f"Invalid value for parameter `{key}` of type {param_type}: {value!r}{valid_values}"
|
|
97
97
|
) from e
|
|
98
98
|
elif var_kw_param:
|
|
99
99
|
var_kw_values[key] = value
|
|
@@ -117,7 +117,7 @@ def wrap_for_shell_args(func: Callable[..., R]) -> Callable[[list[str]], R | Non
|
|
|
117
117
|
from kash.commands.help import help_commands
|
|
118
118
|
|
|
119
119
|
params = inspect_function_params(func)
|
|
120
|
-
pos_params = [p for p in params if p.
|
|
120
|
+
pos_params = [p for p in params if p.is_pure_positional]
|
|
121
121
|
kw_params = [p for p in params if p not in pos_params]
|
|
122
122
|
|
|
123
123
|
@wraps(func)
|
|
@@ -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
|
|
@@ -39,6 +39,7 @@ def html_postprocess(html: str) -> str:
|
|
|
39
39
|
"""
|
|
40
40
|
Final tweaks to the HTML.
|
|
41
41
|
"""
|
|
42
|
+
# TODO: Improve rendering of footnote defs to put the up arrow next to the number instead?
|
|
42
43
|
html = html.replace(
|
|
43
44
|
"""class="footnote">↩</a>""", f"""class="footnote">{FOOTNOTE_UP_ARROW}</a>"""
|
|
44
45
|
)
|