kash-shell 0.3.9__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 (151) hide show
  1. kash/actions/__init__.py +4 -4
  2. kash/actions/core/format_markdown_template.py +2 -5
  3. kash/actions/core/markdownify.py +7 -6
  4. kash/actions/core/readability.py +7 -6
  5. kash/actions/core/render_as_html.py +37 -0
  6. kash/actions/core/show_webpage.py +6 -11
  7. kash/actions/core/strip_html.py +2 -6
  8. kash/actions/core/tabbed_webpage_config.py +31 -0
  9. kash/actions/core/{webpage_generate.py → tabbed_webpage_generate.py} +5 -4
  10. kash/commands/__init__.py +8 -20
  11. kash/commands/base/basic_file_commands.py +15 -0
  12. kash/commands/base/debug_commands.py +13 -0
  13. kash/commands/base/files_command.py +28 -10
  14. kash/commands/base/general_commands.py +21 -16
  15. kash/commands/base/logs_commands.py +4 -2
  16. kash/commands/base/model_commands.py +8 -8
  17. kash/commands/base/search_command.py +3 -2
  18. kash/commands/base/show_command.py +5 -3
  19. kash/commands/extras/parse_uv_lock.py +186 -0
  20. kash/commands/help/doc_commands.py +2 -31
  21. kash/commands/help/welcome.py +33 -0
  22. kash/commands/workspace/selection_commands.py +11 -6
  23. kash/commands/workspace/workspace_commands.py +19 -17
  24. kash/config/colors.py +3 -1
  25. kash/config/env_settings.py +14 -1
  26. kash/config/init.py +2 -2
  27. kash/config/logger.py +59 -56
  28. kash/config/logger_basic.py +3 -3
  29. kash/config/settings.py +116 -57
  30. kash/config/setup.py +28 -12
  31. kash/config/text_styles.py +3 -13
  32. kash/docs/load_api_docs.py +2 -1
  33. kash/docs/markdown/topics/a3_getting_started.md +3 -2
  34. kash/{concepts → embeddings}/text_similarity.py +2 -2
  35. kash/exec/__init__.py +20 -3
  36. kash/exec/action_decorators.py +24 -10
  37. kash/exec/action_exec.py +41 -23
  38. kash/exec/action_registry.py +13 -48
  39. kash/exec/command_registry.py +2 -1
  40. kash/exec/fetch_url_metadata.py +4 -6
  41. kash/exec/importing.py +56 -0
  42. kash/exec/llm_transforms.py +12 -10
  43. kash/exec/precondition_registry.py +2 -1
  44. kash/exec/preconditions.py +22 -1
  45. kash/exec/resolve_args.py +4 -0
  46. kash/exec/shell_callable_action.py +33 -19
  47. kash/file_storage/file_store.py +42 -27
  48. kash/file_storage/item_file_format.py +5 -2
  49. kash/file_storage/metadata_dirs.py +11 -2
  50. kash/help/assistant.py +1 -1
  51. kash/help/assistant_instructions.py +2 -1
  52. kash/help/function_param_info.py +1 -1
  53. kash/help/help_embeddings.py +2 -2
  54. kash/help/help_printing.py +7 -11
  55. kash/llm_utils/clean_headings.py +1 -1
  56. kash/llm_utils/llm_api_keys.py +4 -4
  57. kash/llm_utils/llm_features.py +68 -0
  58. kash/llm_utils/llm_messages.py +1 -2
  59. kash/llm_utils/llm_names.py +1 -1
  60. kash/llm_utils/llms.py +8 -3
  61. kash/local_server/__init__.py +5 -2
  62. kash/local_server/local_server.py +8 -5
  63. kash/local_server/local_server_commands.py +2 -2
  64. kash/local_server/local_server_routes.py +1 -7
  65. kash/local_server/local_url_formatters.py +1 -1
  66. kash/mcp/__init__.py +5 -2
  67. kash/mcp/mcp_cli.py +5 -5
  68. kash/mcp/mcp_server_commands.py +5 -5
  69. kash/mcp/mcp_server_routes.py +5 -5
  70. kash/mcp/mcp_server_sse.py +4 -2
  71. kash/media_base/media_cache.py +8 -8
  72. kash/media_base/media_services.py +1 -1
  73. kash/media_base/media_tools.py +6 -6
  74. kash/media_base/services/local_file_media.py +2 -2
  75. kash/media_base/{speech_transcription.py → transcription_deepgram.py} +25 -110
  76. kash/media_base/transcription_format.py +73 -0
  77. kash/media_base/transcription_whisper.py +38 -0
  78. kash/model/__init__.py +73 -5
  79. kash/model/actions_model.py +38 -4
  80. kash/model/concept_model.py +30 -0
  81. kash/model/items_model.py +115 -32
  82. kash/model/params_model.py +24 -0
  83. kash/shell/completions/completion_scoring.py +37 -5
  84. kash/shell/output/kerm_codes.py +1 -2
  85. kash/shell/output/shell_formatting.py +14 -4
  86. kash/shell/shell_main.py +2 -2
  87. kash/shell/utils/exception_printing.py +6 -0
  88. kash/shell/utils/native_utils.py +26 -20
  89. kash/shell/utils/shell_function_wrapper.py +15 -15
  90. kash/text_handling/custom_sliding_transforms.py +12 -4
  91. kash/text_handling/doc_normalization.py +6 -2
  92. kash/text_handling/markdown_render.py +118 -0
  93. kash/text_handling/markdown_utils.py +226 -0
  94. kash/utils/common/function_inspect.py +360 -110
  95. kash/utils/common/import_utils.py +12 -3
  96. kash/utils/common/type_utils.py +0 -29
  97. kash/utils/common/url.py +27 -3
  98. kash/utils/errors.py +6 -0
  99. kash/utils/file_utils/file_ext.py +4 -0
  100. kash/utils/file_utils/file_formats.py +2 -2
  101. kash/utils/file_utils/file_formats_model.py +20 -1
  102. kash/web_content/dir_store.py +1 -2
  103. kash/web_content/file_cache_utils.py +37 -10
  104. kash/web_content/file_processing.py +68 -0
  105. kash/web_content/local_file_cache.py +12 -9
  106. kash/web_content/web_extract.py +8 -3
  107. kash/web_content/web_fetch.py +12 -4
  108. kash/web_gen/__init__.py +0 -4
  109. kash/web_gen/simple_webpage.py +52 -0
  110. kash/web_gen/tabbed_webpage.py +24 -14
  111. kash/web_gen/template_render.py +37 -2
  112. kash/web_gen/templates/base_styles.css.jinja +169 -43
  113. kash/web_gen/templates/base_webpage.html.jinja +110 -45
  114. kash/web_gen/templates/content_styles.css.jinja +4 -2
  115. kash/web_gen/templates/item_view.html.jinja +49 -39
  116. kash/web_gen/templates/simple_webpage.html.jinja +24 -0
  117. kash/web_gen/templates/tabbed_webpage.html.jinja +42 -33
  118. kash/workspaces/__init__.py +15 -2
  119. kash/workspaces/selections.py +18 -3
  120. kash/workspaces/source_items.py +0 -1
  121. kash/workspaces/workspaces.py +5 -11
  122. kash/xonsh_custom/command_nl_utils.py +40 -19
  123. kash/xonsh_custom/custom_shell.py +43 -11
  124. kash/xonsh_custom/customize_prompt.py +39 -21
  125. kash/xonsh_custom/load_into_xonsh.py +22 -25
  126. kash/xonsh_custom/shell_load_commands.py +2 -2
  127. kash/xonsh_custom/xonsh_completers.py +2 -249
  128. kash/xonsh_custom/xonsh_keybindings.py +282 -0
  129. kash/xonsh_custom/xonsh_modern_tools.py +3 -3
  130. kash/xontrib/kash_extension.py +5 -6
  131. {kash_shell-0.3.9.dist-info → kash_shell-0.3.11.dist-info}/METADATA +10 -8
  132. {kash_shell-0.3.9.dist-info → kash_shell-0.3.11.dist-info}/RECORD +137 -136
  133. kash/actions/core/webpage_config.py +0 -21
  134. kash/concepts/concept_formats.py +0 -23
  135. kash/shell/clideps/api_keys.py +0 -100
  136. kash/shell/clideps/dotenv_setup.py +0 -115
  137. kash/shell/clideps/dotenv_utils.py +0 -98
  138. kash/shell/clideps/pkg_deps.py +0 -257
  139. kash/shell/clideps/platforms.py +0 -11
  140. kash/shell/clideps/terminal_features.py +0 -56
  141. kash/shell/utils/osc_utils.py +0 -95
  142. kash/shell/utils/terminal_images.py +0 -133
  143. kash/text_handling/markdown_util.py +0 -167
  144. kash/utils/common/atomic_var.py +0 -171
  145. kash/utils/common/string_replace.py +0 -93
  146. kash/utils/common/string_template.py +0 -101
  147. /kash/{concepts → embeddings}/cosine.py +0 -0
  148. /kash/{concepts → embeddings}/embeddings.py +0 -0
  149. {kash_shell-0.3.9.dist-info → kash_shell-0.3.11.dist-info}/WHEEL +0 -0
  150. {kash_shell-0.3.9.dist-info → kash_shell-0.3.11.dist-info}/entry_points.txt +0 -0
  151. {kash_shell-0.3.9.dist-info → kash_shell-0.3.11.dist-info}/licenses/LICENSE +0 -0
@@ -9,12 +9,12 @@ from textwrap import dedent
9
9
  from typing import Any, TypeVar, cast
10
10
 
11
11
  from chopdiff.docs import DiffFilter
12
- from chopdiff.docs.token_diffs import DIFF_FILTER_NONE
13
12
  from chopdiff.transforms import WINDOW_NONE, WindowSettings
14
13
  from flowmark import fill_text
15
14
  from prettyfmt import abbrev_obj, fmt_lines
16
15
  from pydantic.dataclasses import dataclass, rebuild_dataclass
17
16
  from pydantic.json_schema import JsonSchemaValue
17
+ from strif import StringTemplate
18
18
  from typing_extensions import override
19
19
 
20
20
  from kash.config.logger import get_logger
@@ -27,13 +27,15 @@ from kash.model.items_model import UNTITLED, Item, ItemType, State
27
27
  from kash.model.operations_model import Operation, Source
28
28
  from kash.model.params_model import (
29
29
  ALL_COMMON_PARAMS,
30
+ COMMON_SHELL_PARAMS,
31
+ RUNTIME_ACTION_PARAMS,
32
+ Param,
30
33
  ParamDeclarations,
31
34
  TypedParamValues,
32
35
  )
33
36
  from kash.model.paths_model import StorePath
34
37
  from kash.model.preconditions_model import Precondition
35
38
  from kash.utils.common.parse_key_vals import format_key_value
36
- from kash.utils.common.string_template import StringTemplate
37
39
  from kash.utils.common.type_utils import not_none
38
40
  from kash.utils.errors import InvalidDefinition, InvalidInput
39
41
  from kash.workspaces.workspaces import get_ws
@@ -65,7 +67,8 @@ class ActionInput:
65
67
  @dataclass(frozen=True)
66
68
  class ExecContext:
67
69
  """
68
- An action and its context for execution.
70
+ An action and its context for execution. This is a good place for settings
71
+ that apply to any action and are bothersome to pass as parameters.
69
72
  """
70
73
 
71
74
  action: Action
@@ -77,13 +80,33 @@ class ExecContext:
77
80
  rerun: bool = False
78
81
  """If True, always run actions, even cacheable ones that have results."""
79
82
 
83
+ refetch: bool = False
84
+ """If True, will refetch items even if they are already in the content caches."""
85
+
80
86
  override_state: State | None = None
81
87
  """If specified, override the state of result items. Useful to mark items as transient."""
82
88
 
89
+ tmp_output: bool = False
90
+ """If True, will save output items to a temporary file."""
91
+
92
+ no_format: bool = False
93
+ """If True, will not normalize the output item's body text formatting (for Markdown)."""
94
+
83
95
  @property
84
96
  def workspace(self) -> FileStore:
85
97
  return get_ws(self.workspace_dir)
86
98
 
99
+ @property
100
+ def runtime_options(self) -> dict[str, str]:
101
+ """Return non-default runtime options."""
102
+ opts: dict[str, str] = {}
103
+ # Only these two settings directly affect the output:
104
+ if self.no_format:
105
+ opts["no_format"] = "true"
106
+ if self.override_state:
107
+ opts["override_state"] = self.override_state.name
108
+ return opts
109
+
87
110
  def __repr__(self):
88
111
  return abbrev_obj(self, field_max_len=80)
89
112
 
@@ -175,7 +198,7 @@ class LLMOptions:
175
198
  system_message: Message = Message("")
176
199
  body_template: MessageTemplate = MessageTemplate("{body}")
177
200
  windowing: WindowSettings = WINDOW_NONE
178
- diff_filter: DiffFilter = DIFF_FILTER_NONE
201
+ diff_filter: DiffFilter | None = None
179
202
 
180
203
  def updated_with(self, param_name: str, value: Any) -> LLMOptions:
181
204
  """Update option from an action parameter."""
@@ -409,6 +432,17 @@ class Action(ABC):
409
432
  # Update corresponding LLM option if appropriate.
410
433
  self.llm_options = self.llm_options.updated_with(param_name, value)
411
434
 
435
+ @property
436
+ def shell_params(self) -> list[Param]:
437
+ """
438
+ List of parameters that are relevant to shell usage.
439
+ """
440
+ return (
441
+ list(self.params)
442
+ + list(RUNTIME_ACTION_PARAMS.values())
443
+ + list(COMMON_SHELL_PARAMS.values())
444
+ )
445
+
412
446
  def param_value_summary(self) -> dict[str, str]:
413
447
  """
414
448
  Readable, serializable summary of the action's non-default parameters, to include in
@@ -0,0 +1,30 @@
1
+ from typing import NewType
2
+
3
+ from kash.utils.lang_utils.capitalization import capitalize_cms
4
+
5
+ Concept = NewType("Concept", str)
6
+
7
+
8
+ def canonicalize_concept(concept: str, capitalize: bool = True) -> Concept:
9
+ """
10
+ Convert a concept string (general name, person, etc.) to a canonical form.
11
+ Drop any extraneous Markdown bullets. Drop any quoted phrases (e.g. book titles etc)
12
+ for consistency.
13
+ """
14
+ concept = concept.strip("-* ")
15
+ for quote in ['"', "'"]:
16
+ if concept.startswith(quote) and concept.endswith(quote):
17
+ concept = concept[1:-1]
18
+ if capitalize:
19
+ return Concept(capitalize_cms(concept))
20
+ else:
21
+ return Concept(concept)
22
+
23
+
24
+ def normalize_concepts(
25
+ concepts: list[str], sort_dedup: bool = True, capitalize: bool = True
26
+ ) -> list[Concept]:
27
+ if sort_dedup:
28
+ return sorted(set(canonicalize_concept(concept, capitalize) for concept in concepts))
29
+ else:
30
+ return [canonicalize_concept(concept, capitalize) for concept in concepts]
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 (
@@ -19,12 +19,12 @@ from prettyfmt import (
19
19
  from pydantic.dataclasses import dataclass
20
20
  from strif import abbrev_str, format_iso_timestamp
21
21
 
22
- from kash.concepts.concept_formats import canonicalize_concept
23
22
  from kash.config.logger import get_logger
23
+ from kash.model.concept_model import canonicalize_concept
24
24
  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
- from kash.text_handling.markdown_util import markdown_to_html
27
+ from kash.text_handling.markdown_render import markdown_to_html
28
28
  from kash.utils.common.format_utils import fmt_loc, html_to_plaintext, plaintext_to_html
29
29
  from kash.utils.common.url import Locator, Url
30
30
  from kash.utils.errors import FileFormatError
@@ -33,6 +33,7 @@ from kash.utils.file_utils.file_formats_model import FileExt, Format
33
33
 
34
34
  if TYPE_CHECKING:
35
35
  from kash.model.actions_model import ExecContext
36
+ from kash.workspaces import Workspace
36
37
 
37
38
  log = get_logger(__name__)
38
39
 
@@ -119,6 +120,28 @@ class IdType(Enum):
119
120
  source = "source"
120
121
 
121
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
+
122
145
  @dataclass(frozen=True)
123
146
  class ItemId:
124
147
  """
@@ -155,7 +178,9 @@ class ItemId:
155
178
  if item.type == ItemType.resource and item.format == Format.url and item.url:
156
179
  item_id = ItemId(item.type, IdType.url, canonicalize_url(item.url))
157
180
  elif item.type == ItemType.concept and item.title:
158
- 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
+ )
159
184
  elif item.source and item.source.cacheable:
160
185
  # We know the source of this and if the action was cacheable, we can create
161
186
  # an identity based on the source.
@@ -257,7 +282,9 @@ class Item:
257
282
  item_dict = {**item_dict, **kwargs}
258
283
 
259
284
  info_prefix = (
260
- 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 ""
261
288
  )
262
289
 
263
290
  # Metadata formats might change over time so it's important to gracefully handle issues.
@@ -287,7 +314,9 @@ class Item:
287
314
  body = item_dict.get("body")
288
315
  history = [OperationSummary(**op) for op in item_dict.get("history", [])]
289
316
  relations = (
290
- 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()
291
320
  )
292
321
  store_path = item_dict.get("store_path")
293
322
 
@@ -305,12 +334,18 @@ class Item:
305
334
  ]
306
335
  all_fields = [f.name for f in cls.__dataclass_fields__.values()]
307
336
  allowed_fields = [f for f in all_fields if f not in excluded_fields]
308
- 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
+ }
309
340
  unexpected_metadata = {
310
341
  key: value for key, value in item_dict.items() if key not in all_fields
311
342
  }
312
343
  if unexpected_metadata:
313
- log.info("Skipping unexpected metadata on item: %s%s", info_prefix, unexpected_metadata)
344
+ log.info(
345
+ "Skipping unexpected metadata on item: %s%s",
346
+ info_prefix,
347
+ unexpected_metadata,
348
+ )
314
349
 
315
350
  result = cls(
316
351
  type=type_,
@@ -328,7 +363,10 @@ class Item:
328
363
 
329
364
  @classmethod
330
365
  def from_external_path(
331
- cls, path: Path | str, item_type: ItemType | None = None, title: str | None = None
366
+ cls,
367
+ path: Path | str,
368
+ item_type: ItemType | None = None,
369
+ title: str | None = None,
332
370
  ) -> Item:
333
371
  """
334
372
  Create a resource Item for a file with a format inferred from the file extension
@@ -347,7 +385,9 @@ class Item:
347
385
  if not item_type:
348
386
  # Default to doc for general text files and resource for everything else.
349
387
  item_type = (
350
- 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
351
391
  )
352
392
  item = cls(
353
393
  type=item_type,
@@ -398,7 +438,22 @@ class Item:
398
438
  if not self.format:
399
439
  raise ValueError(f"Item has no format: {self}")
400
440
  if self.type.expects_body and self.format.has_body and not self.body:
401
- 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
+ )
444
+
445
+ def absolute_path(self, ws: "Workspace | None" = None) -> Path: # noqa: UP037
446
+ """
447
+ Get the absolute path to the item. Throws `ValueError` if the item has no
448
+ store path. If no workspace is provided, uses the current workspace.
449
+ """
450
+ from kash.workspaces import current_ws
451
+
452
+ if not self.store_path:
453
+ raise ValueError("Item has no store path")
454
+ if not ws:
455
+ ws = current_ws()
456
+ return ws.base_dir / self.store_path
402
457
 
403
458
  @property
404
459
  def is_binary(self) -> bool:
@@ -438,7 +493,9 @@ class Item:
438
493
  return {k: serialize(v) for k, v in v.items()}
439
494
  elif isinstance(v, Enum):
440
495
  return v.value
441
- 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.
442
499
  return v.as_dict()
443
500
  elif is_dataclass(v) and not isinstance(v, type):
444
501
  # Handle Python and Pydantic dataclasses.
@@ -486,17 +543,16 @@ class Item:
486
543
  return abbrev_str(self.url, max_len)
487
544
 
488
545
  # Special case for filenames with no title.
489
- path_stem = (
490
- (self.store_path and Path(self.store_path).stem)
491
- or (self.external_path and Path(self.external_path).stem)
492
- 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)
493
550
  )
494
- if not self.title and path_stem:
495
- return abbrev_str(path_stem, max_len)
496
551
 
497
- # 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.
498
553
  title_raw_text = (
499
554
  self.title
555
+ or path_name
500
556
  or self.description
501
557
  or (not self.is_binary and self.abbrev_body(max_len))
502
558
  or UNTITLED
@@ -541,6 +597,13 @@ class Item:
541
597
 
542
598
  return body_text[:max_len]
543
599
 
600
+ @property
601
+ def has_body(self) -> bool:
602
+ """
603
+ True if the item has a non-empty body.
604
+ """
605
+ return bool(self.body and self.body.strip())
606
+
544
607
  def slug_name(self, max_len: int = SLUG_MAX_LEN) -> str:
545
608
  """
546
609
  Get a readable slugified version of the title or filename or content
@@ -554,7 +617,9 @@ class Item:
554
617
  """
555
618
  Get or infer description.
556
619
  """
557
- 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
+ )
558
623
 
559
624
  def read_as_config(self) -> Any:
560
625
  """
@@ -585,7 +650,6 @@ class Item:
585
650
  """
586
651
  Get the full file extension suffix (e.g. "note.md") for this item.
587
652
  """
588
-
589
653
  if self.type == ItemType.extension:
590
654
  # Python files cannot have more than one . in them.
591
655
  return f"{FileExt.py.value}"
@@ -619,7 +683,10 @@ class Item:
619
683
  raise ValueError(f"Cannot convert item of type {self.format} to HTML: {self}")
620
684
 
621
685
  def _copy_and_update(
622
- 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],
623
690
  ) -> dict[str, Any]:
624
691
  overrides: dict[str, Any] = {"store_path": None, "modified_at": None}
625
692
  if update_timestamp:
@@ -637,12 +704,16 @@ class Item:
637
704
 
638
705
  return fields
639
706
 
640
- 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:
641
710
  """
642
711
  Copy item with the given field updates. Resets store_path to None. Updates
643
712
  created time if requested.
644
713
  """
645
- 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
+ )
646
717
  return Item(**new_fields)
647
718
 
648
719
  def merged_copy(self, other: Item) -> Item:
@@ -653,7 +724,7 @@ class Item:
653
724
  merged_fields = self._copy_and_update(other, update_timestamp=False)
654
725
  return Item(**merged_fields)
655
726
 
656
- def derived_copy(self, type: ItemType, **other_updates) -> Item:
727
+ def derived_copy(self, **updates: Unpack[ItemUpdateOptions]) -> Item:
657
728
  """
658
729
  Same as `new_copy_with()`, but also makes any other updates and updates the
659
730
  `derived_from` relation. If we also have an action context, then use the
@@ -678,8 +749,12 @@ class Item:
678
749
  else:
679
750
  derived_from = [StorePath(self.store_path)]
680
751
 
681
- updates = other_updates.copy()
682
- updates["type"] = type
752
+ updates = updates.copy()
753
+
754
+ # If format was specified and user didn't specify file_ext, then infer it.
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
683
758
 
684
759
  # External resource paths only make sense for resources, so clear them out if new item
685
760
  # is not a resource.
@@ -691,15 +766,22 @@ class Item:
691
766
  if derived_from:
692
767
  new_item.update_relations(derived_from=derived_from)
693
768
 
694
- # Fall back to action title template if we have it and it wasn't explicitly set.
695
- if "title" not in other_updates:
769
+ # Fall back to action title template if we have it and title wasn't explicitly set.
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
+ )
696
774
  if self.context:
697
775
  action = self.context.action
698
776
  new_item.title = action.title_template.format(
699
- title=self.title or UNTITLED, action_name=action.name
777
+ title=prev_title, action_name=action.name
700
778
  )
701
779
  else:
702
- log.warning("Deriving an item without action context, will omit title: %s", self)
780
+ log.warning(
781
+ "Deriving an item without action context so keeping previous title: %s",
782
+ self,
783
+ )
784
+ new_item.title = f"{prev_title} (derived copy)"
703
785
 
704
786
  return new_item
705
787
 
@@ -727,7 +809,8 @@ class Item:
727
809
 
728
810
  def content_equals(self, other: Item) -> bool:
729
811
  """
730
- 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.
731
814
  """
732
815
  # Check relevant metadata fields.
733
816
  self_fields = self.__dict__.copy()
@@ -294,6 +294,18 @@ COMMON_ACTION_PARAMS: dict[str, Param] = {
294
294
  type=DocSelection,
295
295
  default_value=DocSelection.full,
296
296
  ),
297
+ "s3_bucket": Param(
298
+ "s3_bucket",
299
+ "The S3 bucket to upload to.",
300
+ type=str,
301
+ default_value=None,
302
+ ),
303
+ "s3_prefix": Param(
304
+ "s3_prefix",
305
+ "The S3 prefix to upload to (with or without a trailing slash).",
306
+ type=str,
307
+ default_value=None,
308
+ ),
297
309
  }
298
310
 
299
311
  # Extra parameters that are available when an action is invoked from the shell.
@@ -305,6 +317,18 @@ RUNTIME_ACTION_PARAMS: dict[str, Param] = {
305
317
  "it produces an output item that already exists.",
306
318
  type=bool,
307
319
  ),
320
+ "refetch": Param(
321
+ "refetch",
322
+ "Forcing re-fetching of any content, not using media or content caches.",
323
+ type=bool,
324
+ default_value=False,
325
+ ),
326
+ "no_format": Param(
327
+ "no_format",
328
+ "Do not auto-format (normalize) Markdown outputs.",
329
+ type=bool,
330
+ default_value=False,
331
+ ),
308
332
  }
309
333
 
310
334
 
@@ -7,6 +7,7 @@ from datetime import UTC, datetime
7
7
  from pathlib import Path
8
8
  from typing import TypeVar
9
9
 
10
+ import rich
10
11
  from strif import abbrev_str
11
12
  from thefuzz import fuzz
12
13
 
@@ -22,7 +23,7 @@ log = get_logger(__name__)
22
23
  T = TypeVar("T")
23
24
 
24
25
  # Scores less than this can be dropped early.
25
- MIN_CUTOFF = Score(60)
26
+ MIN_CUTOFF = Score(70)
26
27
 
27
28
 
28
29
  def linear_boost(score: Score, min_score: Score) -> Score:
@@ -171,10 +172,29 @@ def score_phrase(prefix: str, text: str) -> Score:
171
172
  Could experiment with this more but it's a rough attempt to balance
172
173
  full matches and prefix matches.
173
174
  """
174
- return Score(
175
- 0.4 * fuzz.token_set_ratio(prefix, text)
176
- + 0.4 * fuzz.partial_ratio(prefix, text)
177
- + 0.2 * fuzz.token_sort_ratio(prefix, text)
175
+ if len(prefix) > 5:
176
+ return Score(
177
+ 0.4 * fuzz.ratio(prefix, text)
178
+ + 0.3 * fuzz.token_set_ratio(prefix, text)
179
+ + 0.3 * fuzz.partial_ratio(prefix, text),
180
+ )
181
+ else:
182
+ return Score(0.6 * fuzz.ratio(prefix, text) + 0.4 * fuzz.token_set_ratio(prefix, text))
183
+
184
+
185
+ def print_all_scores(prefix: str, text: str):
186
+ rich.inspect(
187
+ {
188
+ "ratio": fuzz.ratio(prefix, text),
189
+ "partial_ratio": fuzz.partial_ratio(prefix, text),
190
+ "token_sort_ratio": fuzz.token_sort_ratio(prefix, text),
191
+ "token_set_ratio": fuzz.token_set_ratio(prefix, text),
192
+ "partial_token_set_ratio": fuzz.partial_token_set_ratio(prefix, text),
193
+ "final": score_phrase(prefix, text),
194
+ },
195
+ methods=False,
196
+ docs=False,
197
+ sort=False,
178
198
  )
179
199
 
180
200
 
@@ -281,3 +301,15 @@ def score_snippet(query: str, snippet: CommentedCommand) -> Score:
281
301
  )
282
302
  # Bias a little toward command matches.
283
303
  return Score(max(command_score, 0.7 * comment_score))
304
+
305
+
306
+ ## Tests
307
+
308
+
309
+ def test_score_phrase():
310
+ assert score_phrase("hello world", "hello world") == Score(100)
311
+ assert Score(90) >= score_phrase("hello world", "hello there world") >= Score(80)
312
+ assert Score(70) >= score_phrase("hello world", "hello there there") >= Score(60)
313
+ assert Score(85) >= score_phrase("duf", "df") >= Score(75)
314
+ assert Score(70) >= score_phrase("wik", "awk") >= Score(60)
315
+ assert Score(60) >= score_phrase("wiki", "awk") >= Score(50)
@@ -72,13 +72,12 @@ from html import escape
72
72
  from typing import Annotated, Literal, Self, TypeAlias
73
73
  from urllib.parse import parse_qs, quote, urlencode, urlparse
74
74
 
75
+ from clideps.terminal.osc_utils import OscStr, osc8_link, osc8_link_codes, osc8_link_rich, osc_code
75
76
  from prompt_toolkit.formatted_text import FormattedText
76
77
  from pydantic import BaseModel, Field, TypeAdapter, model_validator
77
78
  from rich.style import Style
78
79
  from rich.text import Text
79
80
 
80
- from kash.shell.utils.osc_utils import OscStr, osc8_link, osc8_link_codes, osc8_link_rich, osc_code
81
-
82
81
  KC_VERSION = 0
83
82
  """Version of the Kerm codes format. Update when we make breaking changes."""
84
83
 
@@ -8,10 +8,7 @@ from flowmark import Wrap, fill_text
8
8
  from rich.console import Group
9
9
  from rich.text import Text
10
10
 
11
- from kash.config.text_styles import (
12
- STYLE_HINT,
13
- format_success_emoji,
14
- )
11
+ from kash.config.text_styles import COLOR_FAILURE, COLOR_SUCCESS, STYLE_HINT
15
12
  from kash.shell.output.kmarkdown import KMarkdown
16
13
 
17
14
 
@@ -84,6 +81,19 @@ def format_paragraphs(*paragraphs: str | Text | Group) -> Group:
84
81
  return Group(*text)
85
82
 
86
83
 
84
+ EMOJI_TRUE = "✔︎"
85
+
86
+ EMOJI_FALSE = "✘"
87
+
88
+
89
+ def success_emoji(value: bool, success_only: bool = False) -> str:
90
+ return EMOJI_TRUE if value else " " if success_only else EMOJI_FALSE
91
+
92
+
93
+ def format_success_emoji(value: bool, success_only: bool = False) -> Text:
94
+ return Text(success_emoji(value, success_only), style=COLOR_SUCCESS if value else COLOR_FAILURE)
95
+
96
+
87
97
  def format_success(message: str | Text) -> Text:
88
98
  return Text.assemble(format_success_emoji(True), message)
89
99
 
kash/shell/shell_main.py CHANGED
@@ -16,12 +16,12 @@ import threading
16
16
  import xonsh.main
17
17
  from strif import quote_if_needed
18
18
 
19
- from kash.config.setup import setup
19
+ from kash.config.setup import kash_setup
20
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
 
24
- setup(rich_logging=True) # Set up logging first.
24
+ kash_setup(rich_logging=True) # Set up logging first.
25
25
 
26
26
 
27
27
  __version__ = get_version()
@@ -46,5 +46,11 @@ def wrap_with_exception_printing(func: Callable[..., R]) -> Callable[[list[str]]
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)
48
48
  return None
49
+ except Exception as e:
50
+ # Note xonsh can log exceptions but it will be in the xonsh call stack, which is
51
+ # useless for the user. Better to log all unexpected exception call stack
52
+ # here to our logs.
53
+ log.info("Command error details: %s", e, exc_info=True)
54
+ raise
49
55
 
50
56
  return command