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.
- 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 +2 -6
- 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/files_command.py +28 -10
- kash/commands/workspace/workspace_commands.py +1 -2
- kash/config/colors.py +2 -2
- kash/exec/action_decorators.py +6 -6
- kash/exec/llm_transforms.py +6 -3
- kash/exec/preconditions.py +6 -0
- kash/exec/resolve_args.py +4 -0
- kash/file_storage/file_store.py +20 -18
- kash/help/function_param_info.py +1 -1
- kash/local_server/local_server_routes.py +1 -7
- kash/model/items_model.py +74 -28
- kash/shell/utils/shell_function_wrapper.py +15 -15
- kash/text_handling/doc_normalization.py +1 -1
- kash/text_handling/markdown_render.py +1 -0
- kash/text_handling/markdown_utils.py +22 -0
- kash/utils/common/function_inspect.py +360 -110
- kash/utils/file_utils/file_ext.py +4 -0
- kash/utils/file_utils/file_formats_model.py +17 -1
- 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 +76 -56
- 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_shell-0.3.10.dist-info → kash_shell-0.3.11.dist-info}/METADATA +5 -5
- {kash_shell-0.3.10.dist-info → kash_shell-0.3.11.dist-info}/RECORD +40 -38
- {kash_shell-0.3.10.dist-info → kash_shell-0.3.11.dist-info}/WHEEL +0 -0
- {kash_shell-0.3.10.dist-info → kash_shell-0.3.11.dist-info}/entry_points.txt +0 -0
- {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(
|
|
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'])}: "
|
|
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"])
|
|
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 = {
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
511
|
-
(self.store_path and Path(self.store_path).
|
|
512
|
-
or (self.external_path and Path(self.external_path).
|
|
513
|
-
or (self.original_filename and Path(self.original_filename).
|
|
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
|
-
#
|
|
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(
|
|
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,
|
|
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(
|
|
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(
|
|
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,
|
|
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 =
|
|
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
|
|
714
|
-
updates["
|
|
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
|
|
728
|
-
prev_title = self.title or (
|
|
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
|
|
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.
|
|
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)
|
|
@@ -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">↩</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"
|