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.
Files changed (83) hide show
  1. kash/actions/core/format_markdown_template.py +2 -5
  2. kash/actions/core/markdownify.py +2 -4
  3. kash/actions/core/readability.py +2 -4
  4. kash/actions/core/render_as_html.py +30 -11
  5. kash/actions/core/show_webpage.py +6 -11
  6. kash/actions/core/strip_html.py +4 -8
  7. kash/actions/core/{webpage_config.py → tabbed_webpage_config.py} +5 -3
  8. kash/actions/core/{webpage_generate.py → tabbed_webpage_generate.py} +5 -4
  9. kash/commands/base/basic_file_commands.py +21 -3
  10. kash/commands/base/files_command.py +29 -10
  11. kash/commands/extras/parse_uv_lock.py +12 -3
  12. kash/commands/workspace/selection_commands.py +1 -1
  13. kash/commands/workspace/workspace_commands.py +2 -3
  14. kash/config/colors.py +2 -2
  15. kash/config/env_settings.py +2 -42
  16. kash/config/logger.py +30 -25
  17. kash/config/logger_basic.py +6 -6
  18. kash/config/settings.py +23 -7
  19. kash/config/setup.py +33 -5
  20. kash/config/text_styles.py +25 -22
  21. kash/embeddings/cosine.py +12 -4
  22. kash/embeddings/embeddings.py +16 -6
  23. kash/embeddings/text_similarity.py +10 -4
  24. kash/exec/__init__.py +3 -0
  25. kash/exec/action_decorators.py +10 -25
  26. kash/exec/action_exec.py +43 -23
  27. kash/exec/llm_transforms.py +6 -3
  28. kash/exec/preconditions.py +10 -12
  29. kash/exec/resolve_args.py +4 -0
  30. kash/exec/runtime_settings.py +134 -0
  31. kash/exec/shell_callable_action.py +5 -3
  32. kash/file_storage/file_store.py +37 -38
  33. kash/file_storage/item_file_format.py +6 -3
  34. kash/file_storage/store_filenames.py +6 -3
  35. kash/help/function_param_info.py +1 -1
  36. kash/llm_utils/init_litellm.py +16 -0
  37. kash/llm_utils/llm_api_keys.py +6 -2
  38. kash/llm_utils/llm_completion.py +11 -4
  39. kash/local_server/local_server_routes.py +1 -7
  40. kash/mcp/mcp_cli.py +3 -2
  41. kash/mcp/mcp_server_routes.py +11 -12
  42. kash/media_base/transcription_deepgram.py +15 -2
  43. kash/model/__init__.py +1 -1
  44. kash/model/actions_model.py +6 -54
  45. kash/model/exec_model.py +79 -0
  46. kash/model/items_model.py +102 -35
  47. kash/model/operations_model.py +38 -15
  48. kash/model/paths_model.py +2 -0
  49. kash/shell/output/shell_output.py +10 -8
  50. kash/shell/shell_main.py +2 -2
  51. kash/shell/utils/exception_printing.py +2 -2
  52. kash/shell/utils/shell_function_wrapper.py +15 -15
  53. kash/text_handling/doc_normalization.py +16 -8
  54. kash/text_handling/markdown_render.py +1 -0
  55. kash/text_handling/markdown_utils.py +105 -2
  56. kash/utils/common/format_utils.py +2 -8
  57. kash/utils/common/function_inspect.py +360 -110
  58. kash/utils/common/inflection.py +22 -0
  59. kash/utils/common/task_stack.py +4 -15
  60. kash/utils/errors.py +14 -9
  61. kash/utils/file_utils/file_ext.py +4 -0
  62. kash/utils/file_utils/file_formats_model.py +32 -1
  63. kash/utils/file_utils/file_sort_filter.py +10 -3
  64. kash/web_gen/__init__.py +0 -4
  65. kash/web_gen/simple_webpage.py +52 -0
  66. kash/web_gen/tabbed_webpage.py +23 -16
  67. kash/web_gen/template_render.py +37 -2
  68. kash/web_gen/templates/base_styles.css.jinja +84 -59
  69. kash/web_gen/templates/base_webpage.html.jinja +85 -67
  70. kash/web_gen/templates/item_view.html.jinja +47 -37
  71. kash/web_gen/templates/simple_webpage.html.jinja +24 -0
  72. kash/web_gen/templates/tabbed_webpage.html.jinja +42 -32
  73. kash/workspaces/__init__.py +12 -3
  74. kash/workspaces/workspace_dirs.py +58 -0
  75. kash/workspaces/workspace_importing.py +1 -1
  76. kash/workspaces/workspaces.py +26 -90
  77. {kash_shell-0.3.10.dist-info → kash_shell-0.3.12.dist-info}/METADATA +7 -7
  78. {kash_shell-0.3.10.dist-info → kash_shell-0.3.12.dist-info}/RECORD +81 -76
  79. kash/shell/utils/argparse_utils.py +0 -20
  80. kash/utils/lang_utils/inflection.py +0 -18
  81. {kash_shell-0.3.10.dist-info → kash_shell-0.3.12.dist-info}/WHEEL +0 -0
  82. {kash_shell-0.3.10.dist-info → kash_shell-0.3.12.dist-info}/entry_points.txt +0 -0
  83. {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.actions_model import ExecContext
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
- f"{fmt_store_path(item_dict['store_path'])}: " if "store_path" in item_dict else ""
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: "Workspace | None" = None) -> Path: # noqa: UP037
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(self, max_len: int = 100, add_ops_suffix: bool = True) -> str:
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
- # Special case for URLs with no title..
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
- path_stem = (
511
- (self.store_path and Path(self.store_path).stem)
512
- or (self.external_path and Path(self.external_path).stem)
513
- or (self.original_filename and Path(self.original_filename).stem)
514
- )
515
- if not self.title and path_stem:
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
- # Otherwise, use the title, description, or body text.
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
- if add_ops_suffix and self.type not in [ItemType.concept, ItemType.resource]:
528
- # For notes, exports, etc but not for concepts, add a parenthical note
529
- # indicating the last operation, if there was one. This makes filename slugs
530
- # more readable.
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 a cut off version of the body text. Must not be a binary Item.
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, other: Item | None = None, update_timestamp: bool = False, **other_updates
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(self, update_timestamp: bool = True, **other_updates) -> Item:
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. Updates
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, type: ItemType, **other_updates) -> Item:
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
- "its derived_from relation: %s on %s",
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 = other_updates.copy()
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 other_updates and "format" in other_updates:
714
- updates["file_ext"] = other_updates["format"].file_ext
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 other_updates:
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 and store path.
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()
@@ -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
- # TODO: May want to support Locators or other inputs besides StorePaths.
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 `some/path/filename.ext@sha1:hash` or
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
- parts = input_str.rsplit("@", 1)
41
- if len(parts) == 2:
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
- return cls(path=StorePath(input_str), hash=None)
46
-
47
- def path_and_hash(self):
48
- return f"{fmt_loc(self.path)}@{self.hash}"
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.path_and_hash()
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.path_and_hash() for arg in self.arguments]
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.path_and_hash() for arg in self.arguments]
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
@@ -264,6 +264,8 @@ def fmt_store_path(store_path: str | Path | StorePath) -> str:
264
264
  """
265
265
  Format a store path as a string.
266
266
  """
267
+ if not store_path:
268
+ raise ValueError("Cannot format empty store path")
267
269
  return StorePath(store_path).display_str()
268
270
 
269
271
 
@@ -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 `rprint` with our additional
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 = "before_action_run"
327
- after_shell_action_run = "after_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
- self.nl()
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=WrappedColorFormatter)
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 NONFATAL_EXCEPTIONS
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 NONFATAL_EXCEPTIONS as e:
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.type or str
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.type or str
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
- matching_param_type = matching_param.type or str
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(matching_param_type, types.UnionType):
77
- args = get_args(matching_param_type)
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
- matching_param_type = non_none_args[0]
80
+ param_type = non_none_args[0]
81
81
 
82
- if isinstance(value, bool) and not issubclass(matching_param_type, bool):
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(matching_param_type, bool):
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(matching_param.type, type) and issubclass(matching_param.type, Enum):
94
- valid_values = f" (valid values are: {', '.join('`' + v.name + '`' for v in matching_param.type)})"
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 {matching_param.type}: {value!r}{valid_values}"
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.is_positional]
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, wrap_paragraph
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 normalize_formatting_ansi(text: str, format: Format | None, width=DEFAULT_WRAP_WIDTH) -> str:
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=ansi_cell_len),
27
- cleanups=True, # Safe cleanups like unbolding section headers.
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 = normalize_formatting_ansi(content, format=format)
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">&#8617;</a>""", f"""class="footnote">{FOOTNOTE_UP_ARROW}</a>"""
44
45
  )