kash-shell 0.3.25__py3-none-any.whl → 0.3.27__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 (46) hide show
  1. kash/actions/__init__.py +51 -6
  2. kash/actions/core/minify_html.py +2 -2
  3. kash/commands/base/general_commands.py +4 -2
  4. kash/commands/help/assistant_commands.py +4 -3
  5. kash/commands/help/welcome.py +1 -1
  6. kash/config/colors.py +7 -3
  7. kash/config/logger.py +4 -0
  8. kash/config/text_styles.py +1 -0
  9. kash/config/unified_live.py +249 -0
  10. kash/docs/markdown/assistant_instructions_template.md +3 -3
  11. kash/docs/markdown/topics/a1_what_is_kash.md +22 -20
  12. kash/docs/markdown/topics/a2_installation.md +10 -10
  13. kash/docs/markdown/topics/a3_getting_started.md +8 -8
  14. kash/docs/markdown/topics/a4_elements.md +3 -3
  15. kash/docs/markdown/topics/a5_tips_for_use_with_other_tools.md +12 -12
  16. kash/docs/markdown/topics/b0_philosophy_of_kash.md +17 -17
  17. kash/docs/markdown/topics/b1_kash_overview.md +7 -7
  18. kash/docs/markdown/topics/b2_workspace_and_file_formats.md +1 -1
  19. kash/docs/markdown/topics/b3_modern_shell_tool_recommendations.md +1 -1
  20. kash/docs/markdown/topics/b4_faq.md +7 -7
  21. kash/docs/markdown/welcome.md +1 -1
  22. kash/embeddings/embeddings.py +110 -39
  23. kash/embeddings/text_similarity.py +2 -2
  24. kash/exec/shell_callable_action.py +4 -3
  25. kash/help/help_embeddings.py +5 -2
  26. kash/mcp/mcp_server_sse.py +0 -5
  27. kash/model/graph_model.py +2 -0
  28. kash/model/items_model.py +4 -4
  29. kash/shell/output/shell_output.py +2 -2
  30. kash/shell/shell_main.py +64 -6
  31. kash/shell/version.py +18 -2
  32. kash/utils/file_utils/csv_utils.py +105 -0
  33. kash/utils/rich_custom/multitask_status.py +19 -5
  34. kash/web_gen/templates/base_styles.css.jinja +384 -31
  35. kash/web_gen/templates/base_webpage.html.jinja +43 -0
  36. kash/web_gen/templates/components/toc_styles.css.jinja +25 -4
  37. kash/web_gen/templates/components/tooltip_styles.css.jinja +2 -0
  38. kash/web_gen/templates/content_styles.css.jinja +23 -9
  39. kash/web_gen/templates/item_view.html.jinja +12 -4
  40. kash/web_gen/templates/simple_webpage.html.jinja +2 -2
  41. kash/xonsh_custom/custom_shell.py +6 -6
  42. {kash_shell-0.3.25.dist-info → kash_shell-0.3.27.dist-info}/METADATA +59 -56
  43. {kash_shell-0.3.25.dist-info → kash_shell-0.3.27.dist-info}/RECORD +46 -44
  44. {kash_shell-0.3.25.dist-info → kash_shell-0.3.27.dist-info}/WHEEL +0 -0
  45. {kash_shell-0.3.25.dist-info → kash_shell-0.3.27.dist-info}/entry_points.txt +0 -0
  46. {kash_shell-0.3.25.dist-info → kash_shell-0.3.27.dist-info}/licenses/LICENSE +0 -0
@@ -1,8 +1,9 @@
1
1
  from funlog import log_tallies
2
2
 
3
3
  from kash.config.env_settings import KashEnv
4
- from kash.config.logger import get_console, get_logger
5
- from kash.config.text_styles import COLOR_ERROR, SPINNER
4
+ from kash.config.logger import get_logger
5
+ from kash.config.text_styles import COLOR_ERROR
6
+ from kash.config.unified_live import get_unified_live
6
7
  from kash.exec.action_exec import run_action_with_shell_context
7
8
  from kash.exec.history import record_command
8
9
  from kash.exec_model.commands_model import Command
@@ -57,7 +58,7 @@ class ShellCallableAction:
57
58
  log.info("Action shell args: %s", shell_args)
58
59
  explicit_values = RawParamValues(shell_args.options)
59
60
  if not action.interactive_input and not action.live_output:
60
- with get_console().status(f"Running action {action.name}…", spinner=SPINNER):
61
+ with get_unified_live().status(f"Running action {action.name}…"):
61
62
  result = run_action_with_shell_context(
62
63
  action_cls,
63
64
  explicit_values,
@@ -6,7 +6,7 @@ from pathlib import Path
6
6
  from typing_extensions import override
7
7
 
8
8
  from kash.config.logger import get_logger
9
- from kash.embeddings.embeddings import Embeddings
9
+ from kash.embeddings.embeddings import Embeddings, EmbValue, KeyVal
10
10
  from kash.embeddings.text_similarity import rank_by_relatedness
11
11
  from kash.help.help_types import HelpDoc, HelpDocType
12
12
  from kash.web_content.local_file_cache import Loadable
@@ -59,7 +59,10 @@ class HelpIndex:
59
59
  from kash.web_content.file_cache_utils import cache_file
60
60
 
61
61
  def calculate_and_save_help_embeddings(target_path: Path) -> None:
62
- keyvals = [(str(key), doc.embedding_text()) for key, doc in self._docs_by_key()]
62
+ keyvals = [
63
+ KeyVal(key=str(key), value=EmbValue(emb_text=doc.embedding_text()))
64
+ for key, doc in self._docs_by_key()
65
+ ]
63
66
  embeddings = Embeddings.embed(keyvals)
64
67
  log.info("Embedded %d help documents, cached at: %s", len(embeddings.data), target_path)
65
68
  embeddings.to_npz(target_path)
@@ -8,7 +8,6 @@ from typing import TYPE_CHECKING
8
8
 
9
9
  from mcp.server.sse import SseServerTransport
10
10
  from prettyfmt import fmt_path
11
- from sse_starlette.sse import AppStatus
12
11
  from starlette.applications import Starlette
13
12
  from starlette.routing import Mount, Route
14
13
 
@@ -72,10 +71,6 @@ class MCPServerSSE:
72
71
  def _setup_server(self):
73
72
  import uvicorn
74
73
 
75
- # Reset AppStatus.should_exit_event to None to ensure it's created
76
- # in the correct event loop when needed.
77
- AppStatus.should_exit_event = None
78
-
79
74
  port = global_settings().mcp_server_port
80
75
 
81
76
  # Check if the port is available.
kash/model/graph_model.py CHANGED
@@ -1,5 +1,6 @@
1
1
  from collections.abc import Iterable
2
2
  from dataclasses import asdict, field
3
+ from typing import Any
3
4
 
4
5
  from pydantic.dataclasses import dataclass
5
6
  from strif import abbrev_list
@@ -18,6 +19,7 @@ class Node:
18
19
  body: str | None = None
19
20
  url: str | None = None
20
21
  thumbnail_url: str | None = None
22
+ data: dict[str, Any] | None = None
21
23
 
22
24
 
23
25
  @dataclass(frozen=True)
kash/model/items_model.py CHANGED
@@ -263,7 +263,7 @@ class Item:
263
263
  thumbnail_url: Url | None = None
264
264
 
265
265
  # Optional additional metadata.
266
- extra: dict | None = None
266
+ extra: dict[str, Any] | None = None
267
267
 
268
268
  # Optional execution context. Useful for letting functions that take only an Item
269
269
  # arg get access to context.
@@ -935,8 +935,8 @@ class Item:
935
935
  "type": 64,
936
936
  "format": 64,
937
937
  "title": 40,
938
- "url": 64,
939
- "external_path": 64,
938
+ "url": 128,
939
+ "external_path": 0,
940
940
  },
941
941
  )
942
942
  + f"[{len(self.body) if self.body else 0} body chars]"
@@ -961,7 +961,7 @@ class Item:
961
961
  + f"[{len(self.body) if self.body else 0} body chars]"
962
962
  )
963
963
 
964
- def __repr__(self):
964
+ def __repr__(self) -> str:
965
965
  return self.as_str_brief()
966
966
 
967
967
 
@@ -86,8 +86,8 @@ def multitask_status(
86
86
  ) -> MultiTaskStatus | nullcontext:
87
87
  """
88
88
  Create a `MultiTaskStatus` context manager for displaying multiple task progress
89
- using the global shell console. If disabled, returns a null context, so it's convenient
90
- to disable status display.
89
+ using the global shell console with live display conflict prevention. If disabled,
90
+ returns a null context, so it's convenient to disable status display.
91
91
  """
92
92
  if not enabled:
93
93
  return nullcontext()
kash/shell/shell_main.py CHANGED
@@ -1,5 +1,5 @@
1
1
  """
2
- Welcome to kash! Main way to run the kash shell.
2
+ Welcome to Kash! This command is the main way to run the kash shell.
3
3
 
4
4
  Usually this is used to start the kash interactively but you can also pass a single
5
5
  command to run non-interactively.
@@ -13,16 +13,16 @@ More information at: github.com/jlevy/kash
13
13
  import argparse
14
14
  import threading
15
15
 
16
- import xonsh.main
17
- from clideps.utils.readable_argparse import ReadableColorFormatter
18
16
  from strif import quote_if_needed
19
17
 
18
+ from kash.config.logger import get_console, get_logger
20
19
  from kash.config.setup import kash_setup
21
20
  from kash.shell.version import get_full_version_name, get_version
22
- from kash.xonsh_custom.custom_shell import install_to_xonshrc, start_shell
23
21
 
24
22
  kash_setup(rich_logging=True) # Set up logging first.
25
23
 
24
+ log = get_logger(__name__)
25
+
26
26
 
27
27
  __version__ = get_version()
28
28
 
@@ -35,6 +35,10 @@ def run_plain_xonsh():
35
35
  xontrib only (in ~/.xonshrc), but the full customizations of prompts, tab
36
36
  completion, etc are not available.
37
37
  """
38
+ import xonsh.main
39
+
40
+ from kash.xonsh_custom.custom_shell import install_to_xonshrc
41
+
38
42
  install_to_xonshrc()
39
43
  xonsh.main.main()
40
44
 
@@ -42,25 +46,77 @@ def run_plain_xonsh():
42
46
  # Event to monitor loading.
43
47
  shell_ready_event = threading.Event()
44
48
 
49
+ imports_done_event = threading.Event()
50
+
45
51
 
46
52
  def run_shell(single_command: str | None = None):
47
53
  """
48
54
  Run the kash shell interactively or non-interactively with a single command.
49
55
  """
56
+ from kash.xonsh_custom.custom_shell import start_shell
57
+
50
58
  start_shell(single_command, shell_ready_event)
51
59
 
52
60
 
53
61
  def build_parser() -> argparse.ArgumentParser:
62
+ from clideps.utils.readable_argparse import ReadableColorFormatter
63
+
54
64
  parser = argparse.ArgumentParser(description=__doc__, formatter_class=ReadableColorFormatter)
55
65
 
56
- parser.add_argument("--version", action="version", version=get_full_version_name())
66
+ # Don't call get_full_version_name() here, as it's slow.
67
+ parser.add_argument("--version", action="store_true", help="show version and exit")
57
68
 
58
69
  return parser
59
70
 
60
71
 
72
+ def _import_packages():
73
+ try:
74
+ # Slowest packages:
75
+ import uvicorn.protocols # noqa: F401
76
+ import uvicorn.protocols.http.h11_impl # noqa: F401
77
+ import uvicorn.protocols.websockets.websockets_impl # noqa: F401
78
+ import xonsh.completers.init # noqa: F401
79
+ import xonsh.pyghooks # noqa: F401
80
+
81
+ import kash.actions # noqa: F401
82
+ import kash.local_server # noqa: F401
83
+ import kash.local_server.local_server # noqa: F401
84
+ import kash.mcp.mcp_server_sse # noqa: F401
85
+ except ImportError as e:
86
+ log.warning(f"Error pre-importing packages: {e}")
87
+
88
+ imports_done_event.set()
89
+
90
+
91
+ def import_with_status_if_slow(min_time: float = 1.0):
92
+ """
93
+ Not required, but imports can be remarkably slow the first time, so this shows a status message.
94
+ """
95
+
96
+ # Start imports in background thread
97
+ import_thread = threading.Thread(target=_import_packages, daemon=True)
98
+ import_thread.start()
99
+
100
+ # Wait for imports to complete, with a short timeout
101
+ if not imports_done_event.wait(timeout=min_time):
102
+ # If imports aren't done quickly, show status message
103
+ if get_console().is_terminal:
104
+ with get_console().status(
105
+ "Importing packages (this is a bit slow the first time) …", spinner="line"
106
+ ):
107
+ import_thread.join()
108
+ else:
109
+ import_thread.join()
110
+
111
+
61
112
  def main():
62
113
  parser = build_parser()
63
- _args, unknown = parser.parse_known_args()
114
+
115
+ args, unknown = parser.parse_known_args()
116
+
117
+ if args.version:
118
+ print(get_full_version_name(with_kits=True))
119
+ return
64
120
 
65
121
  # Join remaining arguments to pass as a single command to kash.
66
122
  # Use Python-style quoting only if needed for xonsh.
@@ -68,6 +124,8 @@ def main():
68
124
  if unknown:
69
125
  single_command = " ".join(quote_if_needed(arg) for arg in unknown)
70
126
 
127
+ import_with_status_if_slow()
128
+
71
129
  run_shell(single_command)
72
130
 
73
131
 
kash/shell/version.py CHANGED
@@ -28,8 +28,24 @@ def get_version_tag():
28
28
  return f"v{get_version()}"
29
29
 
30
30
 
31
- def get_full_version_name():
32
- return f"{PACKAGE_NAME} {get_version_tag()}"
31
+ def get_full_version_name(with_kits: bool = False):
32
+ """
33
+ Get the full version name, including the version number and any loaded kits.
34
+
35
+ If `with_kits` is True, will also import `kash.kits` packages and get those versions,
36
+ but be careful as this can be much slower due to imports!
37
+ """
38
+
39
+ version_items = [f"{PACKAGE_NAME} {get_version_tag()}"]
40
+ if with_kits:
41
+ from kash.actions import get_loaded_kits
42
+
43
+ kits = get_loaded_kits()
44
+ for kit in kits.values():
45
+ if kit.version:
46
+ version_items.append(f"{kit.distribution_name} v{kit.version}")
47
+
48
+ return ", ".join(version_items)
33
49
 
34
50
 
35
51
  if __name__ == "__main__":
@@ -0,0 +1,105 @@
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ from pathlib import Path
5
+ from typing import NamedTuple
6
+
7
+
8
+ class CsvMetadata(NamedTuple):
9
+ """
10
+ Result of CSV analysis, containing skip rows, metadata, and dialect.
11
+ """
12
+
13
+ skip_rows: int
14
+ metadata: dict[str, str]
15
+ dialect: type[csv.Dialect]
16
+
17
+
18
+ def sniff_csv_metadata(
19
+ file_path: Path,
20
+ *,
21
+ max_scan_lines: int = 500,
22
+ threshold_ratio: float = 0.8,
23
+ min_columns: int = 3,
24
+ sample_size: int = 32768,
25
+ ) -> CsvMetadata:
26
+ """
27
+ Detect CSV metadata and where the table data starts by finding the first row that looks
28
+ like proper headers.
29
+
30
+ This function handles various CSV formats:
31
+ - Normal CSV files: returns skip_rows=0 (no rows to skip)
32
+ - Files with metadata: detects the first row with multiple columns that looks like headers
33
+ - Survey exports: handles key-value metadata followed by proper CSV structure
34
+
35
+ Args:
36
+ file_path: Path to the CSV file to analyze
37
+ max_scan_lines: Maximum number of lines to scan before giving up
38
+ threshold_ratio: Minimum ratio of max columns a row must have to be considered headers
39
+ min_columns: Minimum number of columns required to be considered headers
40
+ sample_size: Number of bytes to read for dialect detection
41
+
42
+ Returns:
43
+ CsvMetadata with skip_rows, metadata dict, and detected dialect
44
+ """
45
+ # Read sample for dialect detection
46
+ sample_text = file_path.read_text(encoding="utf-8", errors="replace")[:sample_size]
47
+
48
+ # Detect CSV dialect
49
+ try:
50
+ dialect = csv.Sniffer().sniff(sample_text)
51
+ except csv.Error:
52
+ # Fall back to default dialect if detection fails
53
+ dialect = csv.excel
54
+
55
+ # Analyze file structure
56
+ with open(file_path, encoding="utf-8", errors="replace") as file:
57
+ reader = csv.reader(file, dialect=dialect)
58
+
59
+ max_columns = 0
60
+ header_candidates = []
61
+ metadata = {}
62
+
63
+ for line_num, row in enumerate(reader):
64
+ # Stop scanning if we've looked at too many lines
65
+ if line_num >= max_scan_lines:
66
+ break
67
+
68
+ # Skip completely empty rows
69
+ non_empty_cells = [cell.strip() for cell in row if cell.strip()]
70
+ if not non_empty_cells:
71
+ continue
72
+
73
+ column_count = len(non_empty_cells)
74
+
75
+ # Track the maximum number of columns seen
76
+ if column_count > max_columns:
77
+ max_columns = column_count
78
+
79
+ # Collect potential key-value metadata (exactly 2 columns)
80
+ # Only collect metadata before we find any header candidates with min_columns
81
+ if column_count == 2 and not any(hc[1] >= min_columns for hc in header_candidates):
82
+ key, value = non_empty_cells[0], non_empty_cells[1]
83
+ # Simple heuristic: if it looks like a key-value pair, store it
84
+ if not key.isdigit() and not value.replace(".", "").replace(",", "").isdigit():
85
+ metadata[key] = value
86
+
87
+ # Consider this a potential header if it has minimum required columns
88
+ if column_count >= min_columns:
89
+ header_candidates.append((line_num, column_count, row))
90
+
91
+ # If no multi-column rows found, assume it's a normal CSV starting at line 0
92
+ if not header_candidates:
93
+ return CsvMetadata(skip_rows=0, metadata=metadata, dialect=dialect)
94
+
95
+ # Look for the first row that has close to the maximum number of columns
96
+ # This helps distinguish metadata (usually fewer columns) from real headers (many columns)
97
+ threshold = max(min_columns, max_columns * threshold_ratio)
98
+
99
+ for line_num, column_count, _row in header_candidates:
100
+ if column_count >= threshold:
101
+ return CsvMetadata(skip_rows=line_num, metadata=metadata, dialect=dialect)
102
+
103
+ # If no clear header found but we have candidates, return the first multi-column row
104
+ first_candidate_line = header_candidates[0][0]
105
+ return CsvMetadata(skip_rows=first_candidate_line, metadata=metadata, dialect=dialect)
@@ -4,7 +4,7 @@ import asyncio
4
4
  from contextlib import AbstractAsyncContextManager
5
5
  from dataclasses import dataclass
6
6
  from types import TracebackType
7
- from typing import TYPE_CHECKING, TypeVar
7
+ from typing import TYPE_CHECKING, Any, TypeVar
8
8
 
9
9
  from strif import abbrev_str, single_line
10
10
  from typing_extensions import override
@@ -17,6 +17,7 @@ from rich.progress import BarColumn, Progress, ProgressColumn, Task, TaskID
17
17
  from rich.spinner import Spinner
18
18
  from rich.text import Text
19
19
 
20
+ from kash.config.unified_live import get_unified_live
20
21
  from kash.utils.api_utils.progress_protocol import (
21
22
  EMOJI_FAILURE,
22
23
  EMOJI_RETRY,
@@ -229,7 +230,7 @@ class TruncatedLabelColumn(ProgressColumn):
229
230
  def __init__(self, console_width: int):
230
231
  super().__init__()
231
232
  # Reserve half the console width for labels/status messages
232
- self.max_label_width = console_width // 2
233
+ self.max_label_width: int = console_width // 2
233
234
 
234
235
  @override
235
236
  def render(self, task: Task) -> Text:
@@ -298,6 +299,9 @@ class MultiTaskStatus(AbstractAsyncContextManager):
298
299
  self._next_id: int = 1
299
300
  self._rich_task_ids: dict[int, TaskID] = {} # Map our IDs to Rich Progress IDs
300
301
 
302
+ # Unified live integration
303
+ self._unified_live: Any | None = None # Reference to the global unified live
304
+
301
305
  # Calculate spinner width for consistent spacing
302
306
  self._spinner_width = _get_spinner_width(SPINNER_NAME)
303
307
 
@@ -367,7 +371,13 @@ class MultiTaskStatus(AbstractAsyncContextManager):
367
371
  @override
368
372
  async def __aenter__(self) -> MultiTaskStatus:
369
373
  """Start the live display."""
370
- self._progress.__enter__()
374
+ # Try to integrate with unified live display
375
+
376
+ # Always integrate with unified live display (auto-initialized)
377
+ unified_live = get_unified_live()
378
+ self._unified_live = unified_live
379
+ # Register our progress display with the unified live
380
+ unified_live.set_multitask_display(self._progress)
371
381
  return self
372
382
 
373
383
  @override
@@ -378,9 +388,13 @@ class MultiTaskStatus(AbstractAsyncContextManager):
378
388
  exc_tb: TracebackType | None,
379
389
  ) -> None:
380
390
  """Stop the live display and show automatic summary if enabled."""
381
- self._progress.__exit__(exc_type, exc_val, exc_tb)
391
+ # Always clean up unified live integration
392
+ if self._unified_live is not None:
393
+ # Remove our display from the unified live
394
+ self._unified_live.set_multitask_display(None)
395
+ self._unified_live = None
382
396
 
383
- # Show automatic summary if enabled
397
+ # Show automatic summary if enabled (always print to console now)
384
398
  if self.auto_summary:
385
399
  summary = self.get_summary()
386
400
  self.console.print(summary)