kash-shell 0.3.10__py3-none-any.whl → 0.3.11__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 (40) 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 +2 -6
  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/files_command.py +28 -10
  10. kash/commands/workspace/workspace_commands.py +1 -2
  11. kash/config/colors.py +2 -2
  12. kash/exec/action_decorators.py +6 -6
  13. kash/exec/llm_transforms.py +6 -3
  14. kash/exec/preconditions.py +6 -0
  15. kash/exec/resolve_args.py +4 -0
  16. kash/file_storage/file_store.py +20 -18
  17. kash/help/function_param_info.py +1 -1
  18. kash/local_server/local_server_routes.py +1 -7
  19. kash/model/items_model.py +74 -28
  20. kash/shell/utils/shell_function_wrapper.py +15 -15
  21. kash/text_handling/doc_normalization.py +1 -1
  22. kash/text_handling/markdown_render.py +1 -0
  23. kash/text_handling/markdown_utils.py +22 -0
  24. kash/utils/common/function_inspect.py +360 -110
  25. kash/utils/file_utils/file_ext.py +4 -0
  26. kash/utils/file_utils/file_formats_model.py +17 -1
  27. kash/web_gen/__init__.py +0 -4
  28. kash/web_gen/simple_webpage.py +52 -0
  29. kash/web_gen/tabbed_webpage.py +23 -16
  30. kash/web_gen/template_render.py +37 -2
  31. kash/web_gen/templates/base_styles.css.jinja +76 -56
  32. kash/web_gen/templates/base_webpage.html.jinja +85 -67
  33. kash/web_gen/templates/item_view.html.jinja +47 -37
  34. kash/web_gen/templates/simple_webpage.html.jinja +24 -0
  35. kash/web_gen/templates/tabbed_webpage.html.jinja +42 -32
  36. {kash_shell-0.3.10.dist-info → kash_shell-0.3.11.dist-info}/METADATA +5 -5
  37. {kash_shell-0.3.10.dist-info → kash_shell-0.3.11.dist-info}/RECORD +40 -38
  38. {kash_shell-0.3.10.dist-info → kash_shell-0.3.11.dist-info}/WHEEL +0 -0
  39. {kash_shell-0.3.10.dist-info → kash_shell-0.3.11.dist-info}/entry_points.txt +0 -0
  40. {kash_shell-0.3.10.dist-info → kash_shell-0.3.11.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 (
@@ -120,6 +120,28 @@ class IdType(Enum):
120
120
  source = "source"
121
121
 
122
122
 
123
+ class ItemUpdateOptions(TypedDict, total=False):
124
+ """
125
+ Keyword arguments that can be passed to update an Item.
126
+ """
127
+
128
+ type: NotRequired[ItemType]
129
+ state: NotRequired[State]
130
+ title: NotRequired[str | None]
131
+ url: NotRequired[Url | None]
132
+ description: NotRequired[str | None]
133
+ format: NotRequired[Format | None]
134
+ file_ext: NotRequired[FileExt | None]
135
+ body: NotRequired[str | None]
136
+ external_path: NotRequired[str | None]
137
+ original_filename: NotRequired[str | None]
138
+ relations: NotRequired[ItemRelations]
139
+ source: NotRequired[Source | None]
140
+ history: NotRequired[list[OperationSummary] | None]
141
+ thumbnail_url: NotRequired[Url | None]
142
+ extra: NotRequired[dict | None]
143
+
144
+
123
145
  @dataclass(frozen=True)
124
146
  class ItemId:
125
147
  """
@@ -156,7 +178,9 @@ class ItemId:
156
178
  if item.type == ItemType.resource and item.format == Format.url and item.url:
157
179
  item_id = ItemId(item.type, IdType.url, canonicalize_url(item.url))
158
180
  elif item.type == ItemType.concept and item.title:
159
- item_id = ItemId(item.type, IdType.concept, canonicalize_concept(item.title))
181
+ item_id = ItemId(
182
+ item.type, IdType.concept, canonicalize_concept(item.title)
183
+ )
160
184
  elif item.source and item.source.cacheable:
161
185
  # We know the source of this and if the action was cacheable, we can create
162
186
  # an identity based on the source.
@@ -258,7 +282,9 @@ class Item:
258
282
  item_dict = {**item_dict, **kwargs}
259
283
 
260
284
  info_prefix = (
261
- f"{fmt_store_path(item_dict['store_path'])}: " if "store_path" in item_dict else ""
285
+ f"{fmt_store_path(item_dict['store_path'])}: "
286
+ if "store_path" in item_dict
287
+ else ""
262
288
  )
263
289
 
264
290
  # Metadata formats might change over time so it's important to gracefully handle issues.
@@ -288,7 +314,9 @@ class Item:
288
314
  body = item_dict.get("body")
289
315
  history = [OperationSummary(**op) for op in item_dict.get("history", [])]
290
316
  relations = (
291
- ItemRelations(**item_dict["relations"]) if "relations" in item_dict else ItemRelations()
317
+ ItemRelations(**item_dict["relations"])
318
+ if "relations" in item_dict
319
+ else ItemRelations()
292
320
  )
293
321
  store_path = item_dict.get("store_path")
294
322
 
@@ -306,7 +334,9 @@ class Item:
306
334
  ]
307
335
  all_fields = [f.name for f in cls.__dataclass_fields__.values()]
308
336
  allowed_fields = [f for f in all_fields if f not in excluded_fields]
309
- other_metadata = {key: value for key, value in item_dict.items() if key in allowed_fields}
337
+ other_metadata = {
338
+ key: value for key, value in item_dict.items() if key in allowed_fields
339
+ }
310
340
  unexpected_metadata = {
311
341
  key: value for key, value in item_dict.items() if key not in all_fields
312
342
  }
@@ -355,7 +385,9 @@ class Item:
355
385
  if not item_type:
356
386
  # Default to doc for general text files and resource for everything else.
357
387
  item_type = (
358
- ItemType.doc if format and format.supports_frontmatter else ItemType.resource
388
+ ItemType.doc
389
+ if format and format.supports_frontmatter
390
+ else ItemType.resource
359
391
  )
360
392
  item = cls(
361
393
  type=item_type,
@@ -406,7 +438,9 @@ class Item:
406
438
  if not self.format:
407
439
  raise ValueError(f"Item has no format: {self}")
408
440
  if self.type.expects_body and self.format.has_body and not self.body:
409
- raise ValueError(f"Item type `{self.type.value}` is text but has no body: {self}")
441
+ raise ValueError(
442
+ f"Item type `{self.type.value}` is text but has no body: {self}"
443
+ )
410
444
 
411
445
  def absolute_path(self, ws: "Workspace | None" = None) -> Path: # noqa: UP037
412
446
  """
@@ -459,7 +493,9 @@ class Item:
459
493
  return {k: serialize(v) for k, v in v.items()}
460
494
  elif isinstance(v, Enum):
461
495
  return v.value
462
- elif hasattr(v, "as_dict"): # Handle Operation or any object with as_dict method.
496
+ elif hasattr(
497
+ v, "as_dict"
498
+ ): # Handle Operation or any object with as_dict method.
463
499
  return v.as_dict()
464
500
  elif is_dataclass(v) and not isinstance(v, type):
465
501
  # Handle Python and Pydantic dataclasses.
@@ -507,17 +543,16 @@ class Item:
507
543
  return abbrev_str(self.url, max_len)
508
544
 
509
545
  # 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)
546
+ path_name = (
547
+ (self.store_path and Path(self.store_path).name)
548
+ or (self.external_path and Path(self.external_path).name)
549
+ or (self.original_filename and Path(self.original_filename).name)
514
550
  )
515
- if not self.title and path_stem:
516
- return abbrev_str(path_stem, max_len)
517
551
 
518
- # Otherwise, use the title, description, or body text.
552
+ # Use the title or the path if possible, falling back to description or even body text.
519
553
  title_raw_text = (
520
554
  self.title
555
+ or path_name
521
556
  or self.description
522
557
  or (not self.is_binary and self.abbrev_body(max_len))
523
558
  or UNTITLED
@@ -582,7 +617,9 @@ class Item:
582
617
  """
583
618
  Get or infer description.
584
619
  """
585
- return abbrev_on_words(html_to_plaintext(self.description or self.body or ""), max_len)
620
+ return abbrev_on_words(
621
+ html_to_plaintext(self.description or self.body or ""), max_len
622
+ )
586
623
 
587
624
  def read_as_config(self) -> Any:
588
625
  """
@@ -613,7 +650,6 @@ class Item:
613
650
  """
614
651
  Get the full file extension suffix (e.g. "note.md") for this item.
615
652
  """
616
-
617
653
  if self.type == ItemType.extension:
618
654
  # Python files cannot have more than one . in them.
619
655
  return f"{FileExt.py.value}"
@@ -647,7 +683,10 @@ class Item:
647
683
  raise ValueError(f"Cannot convert item of type {self.format} to HTML: {self}")
648
684
 
649
685
  def _copy_and_update(
650
- self, other: Item | None = None, update_timestamp: bool = False, **other_updates
686
+ self,
687
+ other: Item | None = None,
688
+ update_timestamp: bool = False,
689
+ **other_updates: Unpack[ItemUpdateOptions],
651
690
  ) -> dict[str, Any]:
652
691
  overrides: dict[str, Any] = {"store_path": None, "modified_at": None}
653
692
  if update_timestamp:
@@ -665,12 +704,16 @@ class Item:
665
704
 
666
705
  return fields
667
706
 
668
- def new_copy_with(self, update_timestamp: bool = True, **other_updates) -> Item:
707
+ def new_copy_with(
708
+ self, update_timestamp: bool = True, **other_updates: Unpack[ItemUpdateOptions]
709
+ ) -> Item:
669
710
  """
670
711
  Copy item with the given field updates. Resets store_path to None. Updates
671
712
  created time if requested.
672
713
  """
673
- new_fields = self._copy_and_update(update_timestamp=update_timestamp, **other_updates)
714
+ new_fields = self._copy_and_update(
715
+ update_timestamp=update_timestamp, **other_updates
716
+ )
674
717
  return Item(**new_fields)
675
718
 
676
719
  def merged_copy(self, other: Item) -> Item:
@@ -681,7 +724,7 @@ class Item:
681
724
  merged_fields = self._copy_and_update(other, update_timestamp=False)
682
725
  return Item(**merged_fields)
683
726
 
684
- def derived_copy(self, type: ItemType, **other_updates) -> Item:
727
+ def derived_copy(self, **updates: Unpack[ItemUpdateOptions]) -> Item:
685
728
  """
686
729
  Same as `new_copy_with()`, but also makes any other updates and updates the
687
730
  `derived_from` relation. If we also have an action context, then use the
@@ -706,12 +749,12 @@ class Item:
706
749
  else:
707
750
  derived_from = [StorePath(self.store_path)]
708
751
 
709
- updates = other_updates.copy()
710
- updates["type"] = type
752
+ updates = updates.copy()
711
753
 
712
754
  # 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
755
+ if "file_ext" not in updates and "format" in updates:
756
+ assert updates["format"] is not None
757
+ updates["file_ext"] = updates["format"].file_ext
715
758
 
716
759
  # External resource paths only make sense for resources, so clear them out if new item
717
760
  # is not a resource.
@@ -724,8 +767,10 @@ class Item:
724
767
  new_item.update_relations(derived_from=derived_from)
725
768
 
726
769
  # Fall back to action title template if we have it and title wasn't explicitly set.
727
- if "title" not in other_updates:
728
- prev_title = self.title or (Path(self.store_path).stem if self.store_path else UNTITLED)
770
+ if "title" not in updates:
771
+ prev_title = self.title or (
772
+ Path(self.store_path).stem if self.store_path else UNTITLED
773
+ )
729
774
  if self.context:
730
775
  action = self.context.action
731
776
  new_item.title = action.title_template.format(
@@ -764,7 +809,8 @@ class Item:
764
809
 
765
810
  def content_equals(self, other: Item) -> bool:
766
811
  """
767
- Check if two items have identical content, ignoring timestamps and store path.
812
+ Check if two items have identical content, ignoring timestamps, store path,
813
+ and any trailing newlines or whitespace.
768
814
  """
769
815
  # Check relevant metadata fields.
770
816
  self_fields = self.__dict__.copy()
@@ -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)
@@ -23,7 +23,7 @@ def normalize_formatting_ansi(text: str, format: Format | None, width=DEFAULT_WR
23
23
  elif format == Format.markdown or format == Format.md_html:
24
24
  return fill_markdown(
25
25
  text,
26
- line_wrapper=line_wrap_by_sentence(len_fn=ansi_cell_len),
26
+ line_wrapper=line_wrap_by_sentence(len_fn=ansi_cell_len, is_markdown=True),
27
27
  cleanups=True, # Safe cleanups like unbolding section headers.
28
28
  )
29
29
  elif format == Format.html:
@@ -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
  )
@@ -83,6 +83,19 @@ def extract_links(file_path: str, include_internal=False) -> list[str]:
83
83
  return _tree_links(document, include_internal)
84
84
 
85
85
 
86
+ def extract_first_header(content: str) -> str | None:
87
+ """
88
+ Extract the first header from markdown content if present.
89
+ Also drops any formatting, so the result can be used as a document title.
90
+ """
91
+ document = marko.parse(content)
92
+
93
+ if document.children and isinstance(document.children[0], Heading):
94
+ return _extract_text(document.children[0]).strip()
95
+
96
+ return None
97
+
98
+
86
99
  def _extract_text(element: Any) -> str:
87
100
  if isinstance(element, str):
88
101
  return element
@@ -182,6 +195,15 @@ def test_escape_markdown() -> None:
182
195
  )
183
196
 
184
197
 
198
+ def test_extract_first_header() -> None:
199
+ assert extract_first_header("# Header 1") == "Header 1"
200
+ assert extract_first_header("Not a header\n# Header later") is None
201
+ assert extract_first_header("") is None
202
+ assert (
203
+ extract_first_header("## *Formatted* _Header_ [link](#anchor)") == "Formatted Header link"
204
+ )
205
+
206
+
185
207
  def test_find_markdown_text() -> None: # pragma: no cover
186
208
  # Match is returned when the term is not inside a link.
187
209
  text = "Foo bar baz"