kash-shell 0.3.9__py3-none-any.whl → 0.3.10__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 (135) hide show
  1. kash/actions/__init__.py +4 -4
  2. kash/actions/core/markdownify.py +5 -2
  3. kash/actions/core/readability.py +5 -2
  4. kash/actions/core/render_as_html.py +18 -0
  5. kash/actions/core/webpage_config.py +12 -4
  6. kash/commands/__init__.py +8 -20
  7. kash/commands/base/basic_file_commands.py +15 -0
  8. kash/commands/base/debug_commands.py +13 -0
  9. kash/commands/base/general_commands.py +21 -16
  10. kash/commands/base/logs_commands.py +4 -2
  11. kash/commands/base/model_commands.py +8 -8
  12. kash/commands/base/search_command.py +3 -2
  13. kash/commands/base/show_command.py +5 -3
  14. kash/commands/extras/parse_uv_lock.py +186 -0
  15. kash/commands/help/doc_commands.py +2 -31
  16. kash/commands/help/welcome.py +33 -0
  17. kash/commands/workspace/selection_commands.py +11 -6
  18. kash/commands/workspace/workspace_commands.py +18 -15
  19. kash/config/colors.py +2 -0
  20. kash/config/env_settings.py +14 -1
  21. kash/config/init.py +2 -2
  22. kash/config/logger.py +59 -56
  23. kash/config/logger_basic.py +3 -3
  24. kash/config/settings.py +116 -57
  25. kash/config/setup.py +28 -12
  26. kash/config/text_styles.py +3 -13
  27. kash/docs/load_api_docs.py +2 -1
  28. kash/docs/markdown/topics/a3_getting_started.md +3 -2
  29. kash/{concepts → embeddings}/text_similarity.py +2 -2
  30. kash/exec/__init__.py +20 -3
  31. kash/exec/action_decorators.py +18 -4
  32. kash/exec/action_exec.py +41 -23
  33. kash/exec/action_registry.py +13 -48
  34. kash/exec/command_registry.py +2 -1
  35. kash/exec/fetch_url_metadata.py +4 -6
  36. kash/exec/importing.py +56 -0
  37. kash/exec/llm_transforms.py +6 -7
  38. kash/exec/precondition_registry.py +2 -1
  39. kash/exec/preconditions.py +16 -1
  40. kash/exec/shell_callable_action.py +33 -19
  41. kash/file_storage/file_store.py +23 -10
  42. kash/file_storage/item_file_format.py +5 -2
  43. kash/file_storage/metadata_dirs.py +11 -2
  44. kash/help/assistant.py +1 -1
  45. kash/help/assistant_instructions.py +2 -1
  46. kash/help/help_embeddings.py +2 -2
  47. kash/help/help_printing.py +7 -11
  48. kash/llm_utils/clean_headings.py +1 -1
  49. kash/llm_utils/llm_api_keys.py +4 -4
  50. kash/llm_utils/llm_features.py +68 -0
  51. kash/llm_utils/llm_messages.py +1 -2
  52. kash/llm_utils/llm_names.py +1 -1
  53. kash/llm_utils/llms.py +8 -3
  54. kash/local_server/__init__.py +5 -2
  55. kash/local_server/local_server.py +8 -5
  56. kash/local_server/local_server_commands.py +2 -2
  57. kash/local_server/local_url_formatters.py +1 -1
  58. kash/mcp/__init__.py +5 -2
  59. kash/mcp/mcp_cli.py +5 -5
  60. kash/mcp/mcp_server_commands.py +5 -5
  61. kash/mcp/mcp_server_routes.py +5 -5
  62. kash/mcp/mcp_server_sse.py +4 -2
  63. kash/media_base/media_cache.py +8 -8
  64. kash/media_base/media_services.py +1 -1
  65. kash/media_base/media_tools.py +6 -6
  66. kash/media_base/services/local_file_media.py +2 -2
  67. kash/media_base/{speech_transcription.py → transcription_deepgram.py} +25 -110
  68. kash/media_base/transcription_format.py +73 -0
  69. kash/media_base/transcription_whisper.py +38 -0
  70. kash/model/__init__.py +73 -5
  71. kash/model/actions_model.py +38 -4
  72. kash/model/concept_model.py +30 -0
  73. kash/model/items_model.py +44 -7
  74. kash/model/params_model.py +24 -0
  75. kash/shell/completions/completion_scoring.py +37 -5
  76. kash/shell/output/kerm_codes.py +1 -2
  77. kash/shell/output/shell_formatting.py +14 -4
  78. kash/shell/shell_main.py +2 -2
  79. kash/shell/utils/exception_printing.py +6 -0
  80. kash/shell/utils/native_utils.py +26 -20
  81. kash/text_handling/custom_sliding_transforms.py +12 -4
  82. kash/text_handling/doc_normalization.py +6 -2
  83. kash/text_handling/markdown_render.py +117 -0
  84. kash/text_handling/markdown_utils.py +204 -0
  85. kash/utils/common/import_utils.py +12 -3
  86. kash/utils/common/type_utils.py +0 -29
  87. kash/utils/common/url.py +27 -3
  88. kash/utils/errors.py +6 -0
  89. kash/utils/file_utils/file_formats.py +2 -2
  90. kash/utils/file_utils/file_formats_model.py +3 -0
  91. kash/web_content/dir_store.py +1 -2
  92. kash/web_content/file_cache_utils.py +37 -10
  93. kash/web_content/file_processing.py +68 -0
  94. kash/web_content/local_file_cache.py +12 -9
  95. kash/web_content/web_extract.py +8 -3
  96. kash/web_content/web_fetch.py +12 -4
  97. kash/web_gen/tabbed_webpage.py +5 -2
  98. kash/web_gen/templates/base_styles.css.jinja +120 -14
  99. kash/web_gen/templates/base_webpage.html.jinja +60 -13
  100. kash/web_gen/templates/content_styles.css.jinja +4 -2
  101. kash/web_gen/templates/item_view.html.jinja +2 -2
  102. kash/web_gen/templates/tabbed_webpage.html.jinja +1 -2
  103. kash/workspaces/__init__.py +15 -2
  104. kash/workspaces/selections.py +18 -3
  105. kash/workspaces/source_items.py +0 -1
  106. kash/workspaces/workspaces.py +5 -11
  107. kash/xonsh_custom/command_nl_utils.py +40 -19
  108. kash/xonsh_custom/custom_shell.py +43 -11
  109. kash/xonsh_custom/customize_prompt.py +39 -21
  110. kash/xonsh_custom/load_into_xonsh.py +22 -25
  111. kash/xonsh_custom/shell_load_commands.py +2 -2
  112. kash/xonsh_custom/xonsh_completers.py +2 -249
  113. kash/xonsh_custom/xonsh_keybindings.py +282 -0
  114. kash/xonsh_custom/xonsh_modern_tools.py +3 -3
  115. kash/xontrib/kash_extension.py +5 -6
  116. {kash_shell-0.3.9.dist-info → kash_shell-0.3.10.dist-info}/METADATA +8 -6
  117. {kash_shell-0.3.9.dist-info → kash_shell-0.3.10.dist-info}/RECORD +122 -123
  118. kash/concepts/concept_formats.py +0 -23
  119. kash/shell/clideps/api_keys.py +0 -100
  120. kash/shell/clideps/dotenv_setup.py +0 -115
  121. kash/shell/clideps/dotenv_utils.py +0 -98
  122. kash/shell/clideps/pkg_deps.py +0 -257
  123. kash/shell/clideps/platforms.py +0 -11
  124. kash/shell/clideps/terminal_features.py +0 -56
  125. kash/shell/utils/osc_utils.py +0 -95
  126. kash/shell/utils/terminal_images.py +0 -133
  127. kash/text_handling/markdown_util.py +0 -167
  128. kash/utils/common/atomic_var.py +0 -171
  129. kash/utils/common/string_replace.py +0 -93
  130. kash/utils/common/string_template.py +0 -101
  131. /kash/{concepts → embeddings}/cosine.py +0 -0
  132. /kash/{concepts → embeddings}/embeddings.py +0 -0
  133. {kash_shell-0.3.9.dist-info → kash_shell-0.3.10.dist-info}/WHEEL +0 -0
  134. {kash_shell-0.3.9.dist-info → kash_shell-0.3.10.dist-info}/entry_points.txt +0 -0
  135. {kash_shell-0.3.9.dist-info → kash_shell-0.3.10.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
@@ -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
 
@@ -310,7 +311,11 @@ class Item:
310
311
  key: value for key, value in item_dict.items() if key not in all_fields
311
312
  }
312
313
  if unexpected_metadata:
313
- log.info("Skipping unexpected metadata on item: %s%s", info_prefix, unexpected_metadata)
314
+ log.info(
315
+ "Skipping unexpected metadata on item: %s%s",
316
+ info_prefix,
317
+ unexpected_metadata,
318
+ )
314
319
 
315
320
  result = cls(
316
321
  type=type_,
@@ -328,7 +333,10 @@ class Item:
328
333
 
329
334
  @classmethod
330
335
  def from_external_path(
331
- cls, path: Path | str, item_type: ItemType | None = None, title: str | None = None
336
+ cls,
337
+ path: Path | str,
338
+ item_type: ItemType | None = None,
339
+ title: str | None = None,
332
340
  ) -> Item:
333
341
  """
334
342
  Create a resource Item for a file with a format inferred from the file extension
@@ -400,6 +408,19 @@ class Item:
400
408
  if self.type.expects_body and self.format.has_body and not self.body:
401
409
  raise ValueError(f"Item type `{self.type.value}` is text but has no body: {self}")
402
410
 
411
+ def absolute_path(self, ws: "Workspace | None" = None) -> Path: # noqa: UP037
412
+ """
413
+ Get the absolute path to the item. Throws `ValueError` if the item has no
414
+ store path. If no workspace is provided, uses the current workspace.
415
+ """
416
+ from kash.workspaces import current_ws
417
+
418
+ if not self.store_path:
419
+ raise ValueError("Item has no store path")
420
+ if not ws:
421
+ ws = current_ws()
422
+ return ws.base_dir / self.store_path
423
+
403
424
  @property
404
425
  def is_binary(self) -> bool:
405
426
  return bool(self.format and self.format.is_binary)
@@ -541,6 +562,13 @@ class Item:
541
562
 
542
563
  return body_text[:max_len]
543
564
 
565
+ @property
566
+ def has_body(self) -> bool:
567
+ """
568
+ True if the item has a non-empty body.
569
+ """
570
+ return bool(self.body and self.body.strip())
571
+
544
572
  def slug_name(self, max_len: int = SLUG_MAX_LEN) -> str:
545
573
  """
546
574
  Get a readable slugified version of the title or filename or content
@@ -681,6 +709,10 @@ class Item:
681
709
  updates = other_updates.copy()
682
710
  updates["type"] = type
683
711
 
712
+ # 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
715
+
684
716
  # External resource paths only make sense for resources, so clear them out if new item
685
717
  # is not a resource.
686
718
  new_type = updates.get("type") or self.type
@@ -691,15 +723,20 @@ class Item:
691
723
  if derived_from:
692
724
  new_item.update_relations(derived_from=derived_from)
693
725
 
694
- # Fall back to action title template if we have it and it wasn't explicitly set.
726
+ # Fall back to action title template if we have it and title wasn't explicitly set.
695
727
  if "title" not in other_updates:
728
+ prev_title = self.title or (Path(self.store_path).stem if self.store_path else UNTITLED)
696
729
  if self.context:
697
730
  action = self.context.action
698
731
  new_item.title = action.title_template.format(
699
- title=self.title or UNTITLED, action_name=action.name
732
+ title=prev_title, action_name=action.name
700
733
  )
701
734
  else:
702
- log.warning("Deriving an item without action context, will omit title: %s", self)
735
+ log.warning(
736
+ "Deriving an item without action context so keeping previous title: %s",
737
+ self,
738
+ )
739
+ new_item.title = f"{prev_title} (derived copy)"
703
740
 
704
741
  return new_item
705
742
 
@@ -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
@@ -11,15 +11,15 @@ import webbrowser
11
11
  from enum import Enum
12
12
  from pathlib import Path
13
13
 
14
+ from clideps.pkgs.pkg_check import pkg_check
15
+ from clideps.pkgs.platform_checks import Platform, get_platform
16
+ from clideps.terminal.terminal_images import terminal_show_image
14
17
  from flowmark import Wrap
15
18
  from funlog import log_calls
16
19
 
17
20
  from kash.config.logger import get_logger
18
- from kash.config.text_styles import BAT_STYLE, BAT_THEME, COLOR_ERROR
19
- from kash.shell.clideps.pkg_deps import Pkg, pkg_check
20
- from kash.shell.clideps.platforms import PLATFORM, Platform
21
+ from kash.config.text_styles import BAT_STYLE, BAT_STYLE_PLAIN, BAT_THEME, COLOR_ERROR
21
22
  from kash.shell.output.shell_output import cprint
22
- from kash.shell.utils.terminal_images import terminal_show_image
23
23
  from kash.utils.common.format_utils import fmt_loc
24
24
  from kash.utils.common.url import as_file_url, is_file_url, is_url
25
25
  from kash.utils.errors import FileNotFound, SetupError
@@ -49,11 +49,11 @@ def file_size_check(
49
49
  def native_open(filename: str | Path):
50
50
  filename = str(filename)
51
51
  log.message("Opening file: %s", filename)
52
- if PLATFORM == Platform.Darwin:
52
+ if get_platform() == Platform.Darwin:
53
53
  subprocess.run(["open", filename])
54
- elif PLATFORM == Platform.Linux:
54
+ elif get_platform() == Platform.Linux:
55
55
  subprocess.run(["xdg-open", filename])
56
- elif PLATFORM == Platform.Windows:
56
+ elif get_platform() == Platform.Windows:
57
57
  subprocess.run(["start", shlex.quote(filename)], shell=True)
58
58
  else:
59
59
  raise NotImplementedError("Unsupported platform")
@@ -110,12 +110,14 @@ def _detect_view_mode(file_or_url: str) -> ViewMode:
110
110
  def view_file_native(
111
111
  file_or_url: str | Path,
112
112
  view_mode: ViewMode = ViewMode.auto,
113
+ plain: bool = False,
113
114
  ):
114
115
  """
115
116
  Open a file or URL in the console or a native app. If `view_mode` is auto,
116
117
  automatically determine whether to use console, web browser, or the user's
117
118
  preferred native application. For images, also tries terminal-based image
118
- display.
119
+ display. The `--plain` flag will disable line numbers, grid, etc. in `bat`
120
+ and force `ViewMode.console`.
119
121
  """
120
122
  file_or_url = str(file_or_url)
121
123
  path = None
@@ -124,6 +126,9 @@ def view_file_native(
124
126
  if not path.exists():
125
127
  raise FileNotFound(fmt_loc(path))
126
128
 
129
+ if plain:
130
+ view_mode = ViewMode.console
131
+
127
132
  if view_mode == ViewMode.auto:
128
133
  view_mode = _detect_view_mode(file_or_url)
129
134
 
@@ -133,7 +138,7 @@ def view_file_native(
133
138
  webbrowser.open(url)
134
139
  elif view_mode == ViewMode.console and path:
135
140
  file_size, min_lines = file_size_check(path)
136
- view_file_console(path, use_pager=min_lines > 40 or file_size > 20 * 1024)
141
+ view_file_console(path, use_pager=min_lines > 40 or file_size > 20 * 1024, plain=plain)
137
142
  elif view_mode == ViewMode.terminal_image and path:
138
143
  try:
139
144
  terminal_show_image(path)
@@ -187,11 +192,11 @@ def tail_file(
187
192
  if follow:
188
193
  max_lines = follow_max_lines
189
194
 
190
- pkg_check().require(Pkg.tail)
191
- pkg_check().warn_if_missing(Pkg.bat)
195
+ pkg_check().require("tail")
196
+ pkg_check().warn_if_missing("bat")
192
197
 
193
198
  if follow:
194
- if pkg_check().has(Pkg.bat):
199
+ if pkg_check().is_found("bat"):
195
200
  # Follow the file in real-time.
196
201
  command = (
197
202
  f"tail -{max_lines} -f {all_paths_str} | "
@@ -202,8 +207,8 @@ def tail_file(
202
207
  command = f"tail -f {all_paths_str}"
203
208
  cprint("Following file: `%s`", command, text_wrap=Wrap.NONE)
204
209
  else:
205
- pkg_check().require(Pkg.less)
206
- if pkg_check().has(Pkg.bat, Pkg.less):
210
+ pkg_check().require("less")
211
+ if pkg_check().is_found("bat"):
207
212
  command = (
208
213
  f"tail -{max_lines} {all_paths_str} | "
209
214
  f"bat --paging=never --color=always --style=plain --theme={BAT_THEME} -l log | "
@@ -216,7 +221,7 @@ def tail_file(
216
221
  subprocess.run(command, shell=True, check=True)
217
222
 
218
223
 
219
- def view_file_console(filename: str | Path, use_pager: bool = True):
224
+ def view_file_console(filename: str | Path, use_pager: bool = True, plain: bool = False):
220
225
  """
221
226
  Displays a file in the console with pagination and syntax highlighting.
222
227
  """
@@ -226,18 +231,19 @@ def view_file_console(filename: str | Path, use_pager: bool = True):
226
231
  # TODO: Visualize YAML frontmatter with different syntax/style than Markdown content.
227
232
 
228
233
  is_text = file_format_info(filename).is_text
234
+ bat_style = BAT_STYLE_PLAIN if plain else BAT_STYLE
229
235
  if is_text:
230
- pkg_check().require(Pkg.less)
231
- if pkg_check().has(Pkg.bat):
236
+ pkg_check().require("less")
237
+ if pkg_check().is_found("bat"):
232
238
  pager_str = "--pager=always --pager=less " if use_pager else ""
233
- command = f"bat {pager_str}--color=always --style={BAT_STYLE} --theme={BAT_THEME} {quoted_filename}"
239
+ command = f"bat {pager_str}--color=always --style={bat_style} --theme={BAT_THEME} {quoted_filename}"
234
240
  else:
235
- pkg_check().require(Pkg.pygmentize)
241
+ pkg_check().require("pygmentize")
236
242
  command = f"pygmentize -g {quoted_filename}"
237
243
  if use_pager:
238
244
  command = f"{command} | less -R"
239
245
  else:
240
- pkg_check().require(Pkg.hexyl)
246
+ pkg_check().require("hexyl")
241
247
  command = f"hexyl {quoted_filename}"
242
248
  if use_pager:
243
249
  command = f"{command} | less -R"
@@ -1,10 +1,17 @@
1
1
  from collections.abc import Callable
2
2
  from math import ceil
3
3
 
4
- from chopdiff.docs import DiffFilter, Paragraph, TextDoc, TextUnit, diff_docs, join_wordtoks
4
+ from chopdiff.docs import (
5
+ DIFF_FILTER_NONE,
6
+ DiffFilter,
7
+ Paragraph,
8
+ TextDoc,
9
+ TextUnit,
10
+ diff_docs,
11
+ join_wordtoks,
12
+ )
5
13
  from chopdiff.transforms import (
6
14
  WindowSettings,
7
- accept_all,
8
15
  remove_window_br,
9
16
  sliding_para_window,
10
17
  sliding_window_transform,
@@ -31,7 +38,7 @@ def filtered_transform(
31
38
  doc: TextDoc,
32
39
  transform_func: TextDocTransform,
33
40
  windowing: WindowSettings | None,
34
- diff_filter: DiffFilter = accept_all,
41
+ diff_filter: DiffFilter | None = None,
35
42
  ) -> TextDoc:
36
43
  """
37
44
  Apply a transform with sliding window across the input doc, enforcing the changes it's
@@ -39,7 +46,7 @@ def filtered_transform(
39
46
 
40
47
  If windowing is None, apply the transform to the entire document at once.
41
48
  """
42
- has_filter = diff_filter != accept_all
49
+ has_filter = bool(diff_filter and diff_filter != DIFF_FILTER_NONE)
43
50
 
44
51
  if not windowing or not windowing.size:
45
52
  transformed_doc = transform_func(doc)
@@ -52,6 +59,7 @@ def filtered_transform(
52
59
  transformed_doc = transform_func(input_doc)
53
60
 
54
61
  if has_filter:
62
+ assert diff_filter
55
63
  # Check the transform did what it should have.
56
64
  diff = diff_docs(input_doc, transformed_doc)
57
65
  accepted_diff, rejected_diff = diff.filter(diff_filter)
@@ -21,7 +21,11 @@ def normalize_formatting_ansi(text: str, format: Format | None, width=DEFAULT_WR
21
21
  text, width=width, word_splitter=simple_word_splitter, len_fn=ansi_cell_len
22
22
  )
23
23
  elif format == Format.markdown or format == Format.md_html:
24
- return fill_markdown(text, line_wrapper=line_wrap_by_sentence(len_fn=ansi_cell_len))
24
+ return fill_markdown(
25
+ text,
26
+ line_wrapper=line_wrap_by_sentence(len_fn=ansi_cell_len),
27
+ cleanups=True, # Safe cleanups like unbolding section headers.
28
+ )
25
29
  elif format == Format.html:
26
30
  # We don't currently auto-format HTML as we sometimes use HTML with specifically chosen line breaks.
27
31
  return text
@@ -52,7 +56,7 @@ def normalize_text_file(
52
56
 
53
57
 
54
58
  def test_osc8_link():
55
- from kash.shell.utils.osc_utils import osc8_link
59
+ from clideps.terminal.osc_utils import osc8_link
56
60
 
57
61
  link = osc8_link("https://example.com/" + "x" * 50, "Example")
58
62
  assert ansi_cell_len(link) == 7