kash-shell 0.3.11__py3-none-any.whl → 0.3.13__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 (95) hide show
  1. kash/actions/core/markdownify.py +5 -4
  2. kash/actions/core/readability.py +4 -4
  3. kash/actions/core/render_as_html.py +8 -6
  4. kash/actions/core/show_webpage.py +2 -2
  5. kash/actions/core/strip_html.py +2 -2
  6. kash/commands/base/basic_file_commands.py +24 -3
  7. kash/commands/base/diff_commands.py +38 -3
  8. kash/commands/base/files_command.py +5 -4
  9. kash/commands/base/reformat_command.py +1 -1
  10. kash/commands/base/show_command.py +1 -1
  11. kash/commands/extras/parse_uv_lock.py +12 -3
  12. kash/commands/workspace/selection_commands.py +1 -1
  13. kash/commands/workspace/workspace_commands.py +62 -16
  14. kash/config/env_settings.py +2 -42
  15. kash/config/logger.py +30 -25
  16. kash/config/logger_basic.py +6 -6
  17. kash/config/settings.py +23 -7
  18. kash/config/setup.py +33 -5
  19. kash/config/text_styles.py +25 -22
  20. kash/docs/load_source_code.py +1 -1
  21. kash/embeddings/cosine.py +12 -4
  22. kash/embeddings/embeddings.py +16 -6
  23. kash/embeddings/text_similarity.py +10 -4
  24. kash/exec/__init__.py +3 -0
  25. kash/exec/action_decorators.py +4 -19
  26. kash/exec/action_exec.py +46 -27
  27. kash/exec/fetch_url_metadata.py +8 -5
  28. kash/exec/importing.py +4 -4
  29. kash/exec/llm_transforms.py +2 -2
  30. kash/exec/preconditions.py +11 -19
  31. kash/exec/runtime_settings.py +134 -0
  32. kash/exec/shell_callable_action.py +5 -3
  33. kash/file_storage/file_store.py +91 -53
  34. kash/file_storage/item_file_format.py +6 -3
  35. kash/file_storage/store_filenames.py +7 -3
  36. kash/help/help_embeddings.py +2 -2
  37. kash/llm_utils/clean_headings.py +1 -1
  38. kash/{text_handling → llm_utils}/custom_sliding_transforms.py +0 -3
  39. kash/llm_utils/init_litellm.py +16 -0
  40. kash/llm_utils/llm_api_keys.py +6 -2
  41. kash/llm_utils/llm_completion.py +12 -5
  42. kash/local_server/__init__.py +1 -1
  43. kash/local_server/local_server_commands.py +2 -1
  44. kash/mcp/__init__.py +1 -1
  45. kash/mcp/mcp_cli.py +3 -2
  46. kash/mcp/mcp_server_commands.py +8 -2
  47. kash/mcp/mcp_server_routes.py +11 -12
  48. kash/media_base/media_cache.py +10 -3
  49. kash/media_base/transcription_deepgram.py +15 -2
  50. kash/model/__init__.py +1 -1
  51. kash/model/actions_model.py +9 -54
  52. kash/model/exec_model.py +79 -0
  53. kash/model/items_model.py +131 -81
  54. kash/model/operations_model.py +38 -15
  55. kash/model/paths_model.py +2 -0
  56. kash/shell/output/shell_output.py +10 -8
  57. kash/shell/shell_main.py +2 -2
  58. kash/shell/ui/shell_results.py +2 -1
  59. kash/shell/utils/exception_printing.py +2 -2
  60. kash/utils/common/format_utils.py +0 -14
  61. kash/utils/common/import_utils.py +46 -18
  62. kash/utils/common/task_stack.py +4 -15
  63. kash/utils/errors.py +14 -9
  64. kash/utils/file_utils/file_formats_model.py +61 -26
  65. kash/utils/file_utils/file_sort_filter.py +10 -3
  66. kash/utils/file_utils/filename_parsing.py +41 -16
  67. kash/{text_handling → utils/text_handling}/doc_normalization.py +23 -13
  68. kash/utils/text_handling/escape_html_tags.py +156 -0
  69. kash/{text_handling → utils/text_handling}/markdown_utils.py +82 -4
  70. kash/utils/text_handling/markdownify_utils.py +87 -0
  71. kash/{text_handling → utils/text_handling}/unified_diffs.py +1 -44
  72. kash/web_content/file_cache_utils.py +42 -34
  73. kash/web_content/local_file_cache.py +29 -12
  74. kash/web_content/web_extract.py +1 -1
  75. kash/web_content/web_extract_readabilipy.py +4 -2
  76. kash/web_content/web_fetch.py +42 -7
  77. kash/web_content/web_page_model.py +2 -1
  78. kash/web_gen/simple_webpage.py +1 -1
  79. kash/web_gen/templates/base_styles.css.jinja +139 -16
  80. kash/web_gen/templates/simple_webpage.html.jinja +1 -1
  81. kash/workspaces/__init__.py +12 -3
  82. kash/workspaces/selections.py +2 -2
  83. kash/workspaces/workspace_dirs.py +58 -0
  84. kash/workspaces/workspace_importing.py +2 -2
  85. kash/workspaces/workspace_output.py +2 -2
  86. kash/workspaces/workspaces.py +26 -90
  87. kash/xonsh_custom/load_into_xonsh.py +4 -2
  88. {kash_shell-0.3.11.dist-info → kash_shell-0.3.13.dist-info}/METADATA +4 -4
  89. {kash_shell-0.3.11.dist-info → kash_shell-0.3.13.dist-info}/RECORD +93 -89
  90. kash/shell/utils/argparse_utils.py +0 -20
  91. kash/utils/lang_utils/inflection.py +0 -18
  92. /kash/{text_handling → utils/text_handling}/markdown_render.py +0 -0
  93. {kash_shell-0.3.11.dist-info → kash_shell-0.3.13.dist-info}/WHEEL +0 -0
  94. {kash_shell-0.3.11.dist-info → kash_shell-0.3.13.dist-info}/entry_points.txt +0 -0
  95. {kash_shell-0.3.11.dist-info → kash_shell-0.3.13.dist-info}/licenses/LICENSE +0 -0
@@ -25,27 +25,46 @@ class OperationSummary:
25
25
  class Input:
26
26
  """
27
27
  An input to an operation, which may include a hash fingerprint.
28
+ Typically an input is a StorePath, but it could be something else like an in-memory
29
+ item that hasn't been saved yet.
28
30
  """
29
31
 
30
- # TODO: May want to support Locators or other inputs besides StorePaths.
31
- path: StorePath
32
+ path: StorePath | None
32
33
  hash: str | None = None
34
+ source_info: str | None = None
33
35
 
34
36
  @classmethod
35
37
  def parse(cls, input_str: str) -> Input:
36
38
  """
37
- Parse an Input string in the format `some/path/filename.ext@sha1:hash` or
38
- `@some/path/filename.ext@sha1:hash`, with a store path and a hash.
39
+ Parse an Input string in the format printed by `Input.parseable_str()`.
39
40
  """
40
- parts = input_str.rsplit("@", 1)
41
- if len(parts) == 2:
42
- path, hash = parts
43
- return cls(path=StorePath(path), hash=hash)
41
+ if input_str.startswith("[") and input_str.endswith("]"):
42
+ return cls(path=None, hash=None, source_info=input_str[1:-1])
44
43
  else:
45
- return cls(path=StorePath(input_str), hash=None)
46
-
47
- def path_and_hash(self):
48
- return f"{fmt_loc(self.path)}@{self.hash}"
44
+ parts = input_str.rsplit("@", 1)
45
+ if len(parts) == 2:
46
+ path, hash = parts
47
+ return cls(path=StorePath(path), hash=hash)
48
+ else:
49
+ return cls(path=StorePath(input_str), hash=None)
50
+
51
+ def parseable_str(self):
52
+ """
53
+ A readable and parseable string describing the input, typically a hash and a path but
54
+ could be a path without a hash or another info in brackets. Paths may have an `@` at the
55
+ front.
56
+
57
+ some/path.txt@sha1:1234567890
58
+ @some/path.txt@sha1:1234567890
59
+ some/path.txt
60
+ [unsaved]
61
+ """
62
+ if self.path and self.hash:
63
+ return f"{fmt_loc(self.path)}@{self.hash}"
64
+ elif self.source_info:
65
+ return f"[{self.source_info}]"
66
+ else:
67
+ return "[input info missing]"
49
68
 
50
69
  # Inputs are equal if the hashes match (even if the paths have changed).
51
70
 
@@ -53,6 +72,10 @@ class Input:
53
72
  return hash(self.hash) if self.hash else object.__hash__(self)
54
73
 
55
74
  def __eq__(self, other: Any) -> bool:
75
+ """
76
+ Inputs are equal if the hashes match (even if the paths have changed) or if the paths
77
+ are the same. They are *not* equal otherwise, even if the source_info is the same.
78
+ """
56
79
  if not isinstance(other, Input):
57
80
  return NotImplemented
58
81
  if self.hash and other.hash:
@@ -62,7 +85,7 @@ class Input:
62
85
  return False
63
86
 
64
87
  def __str__(self):
65
- return self.path_and_hash()
88
+ return self.parseable_str()
66
89
 
67
90
 
68
91
  @dataclass(frozen=True)
@@ -88,7 +111,7 @@ class Operation:
88
111
  }
89
112
 
90
113
  if self.arguments:
91
- d["arguments"] = [arg.path_and_hash() for arg in self.arguments]
114
+ d["arguments"] = [arg.parseable_str() for arg in self.arguments]
92
115
  if self.options:
93
116
  d["options"] = self.options
94
117
 
@@ -101,7 +124,7 @@ class Operation:
101
124
  return [shell_quote(str(arg.path)) for arg in self.arguments]
102
125
 
103
126
  def hashed_args(self):
104
- return [arg.path_and_hash() for arg in self.arguments]
127
+ return [arg.parseable_str() for arg in self.arguments]
105
128
 
106
129
  def quoted_options(self):
107
130
  return [f"--{k}={shell_quote(str(v))}" for k, v in self.options.items()]
kash/model/paths_model.py CHANGED
@@ -264,6 +264,8 @@ def fmt_store_path(store_path: str | Path | StorePath) -> str:
264
264
  """
265
265
  Format a store path as a string.
266
266
  """
267
+ if not store_path:
268
+ raise ValueError("Cannot format empty store path")
267
269
  return StorePath(store_path).display_str()
268
270
 
269
271
 
@@ -16,7 +16,7 @@ from rich.rule import Rule
16
16
  from rich.style import Style
17
17
  from rich.text import Text
18
18
 
19
- from kash.config.logger import get_console
19
+ from kash.config.logger import get_console, is_console_quiet
20
20
  from kash.config.text_styles import (
21
21
  COLOR_HINT_DIM,
22
22
  COLOR_RESPONSE,
@@ -31,9 +31,6 @@ from kash.shell.output.kmarkdown import KMarkdown
31
31
  from kash.utils.rich_custom.rich_indent import Indent
32
32
  from kash.utils.rich_custom.rich_markdown_fork import Markdown
33
33
 
34
- console = get_console()
35
-
36
-
37
34
  print_context_var: contextvars.ContextVar[str] = contextvars.ContextVar("print_prefix", default="")
38
35
  """
39
36
  Context variable override for print prefix.
@@ -99,7 +96,12 @@ def rich_print(
99
96
  Print to the Rich console, either the global console or a thread-local
100
97
  override, if one is active. With `raw` true, we bypass rich formatting
101
98
  entirely and simply write to the console stream.
99
+
100
+ Output is suppressed by the global `console_quiet` setting.
102
101
  """
102
+ if is_console_quiet():
103
+ return
104
+
103
105
  console = get_console()
104
106
  if raw:
105
107
  # TODO: Indent not supported in raw mode.
@@ -136,7 +138,7 @@ def cprint(
136
138
  raw: bool = False,
137
139
  ):
138
140
  """
139
- Main way to print to the shell. Wraps `rprint` with our additional
141
+ Main way to print to the shell. Wraps `rich_print` with our additional
140
142
  formatting options for text fill and prefix.
141
143
  """
142
144
  empty_indent = extra_indent.strip()
@@ -323,8 +325,8 @@ class PrintHooks(Enum):
323
325
  after_command_run = "after_command_run"
324
326
  before_status = "before_status"
325
327
  after_status = "after_status"
326
- before_shell_action_run = "before_action_run"
327
- after_shell_action_run = "after_action_run"
328
+ before_shell_action_run = "before_shell_action_run"
329
+ after_shell_action_run = "after_shell_action_run"
328
330
  before_log_action_run = "before_log_action_run"
329
331
  before_assistance = "before_assistance"
330
332
  after_assistance = "after_assistance"
@@ -376,7 +378,7 @@ class PrintHooks(Enum):
376
378
  elif self == PrintHooks.nonfatal_exception:
377
379
  self.nl()
378
380
  elif self == PrintHooks.before_done_message:
379
- self.nl()
381
+ pass
380
382
  elif self == PrintHooks.before_output:
381
383
  self.nl()
382
384
  elif self == PrintHooks.after_output:
kash/shell/shell_main.py CHANGED
@@ -14,10 +14,10 @@ import argparse
14
14
  import threading
15
15
 
16
16
  import xonsh.main
17
+ from clideps.utils.readable_argparse import ReadableColorFormatter
17
18
  from strif import quote_if_needed
18
19
 
19
20
  from kash.config.setup import kash_setup
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
 
@@ -51,7 +51,7 @@ def run_shell(single_command: str | None = None):
51
51
 
52
52
 
53
53
  def build_parser() -> argparse.ArgumentParser:
54
- parser = argparse.ArgumentParser(description=__doc__, formatter_class=WrappedColorFormatter)
54
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=ReadableColorFormatter)
55
55
 
56
56
  parser.add_argument("--version", action="version", version=get_full_version_name())
57
57
 
@@ -1,5 +1,6 @@
1
1
  from typing import Any
2
2
 
3
+ from prettyfmt import fmt_count_items
3
4
  from rich.box import SQUARE
4
5
  from rich.panel import Panel
5
6
  from rich.table import Table
@@ -10,7 +11,7 @@ from kash.config.text_styles import COLOR_SELECTION, STYLE_HINT
10
11
  from kash.exec.command_exec import run_command_or_action
11
12
  from kash.exec_model.shell_model import ShellResult
12
13
  from kash.shell.output.shell_output import PrintHooks, console_pager, cprint, print_result
13
- from kash.utils.common.format_utils import fmt_count_items, fmt_loc
14
+ from kash.utils.common.format_utils import fmt_loc
14
15
  from kash.utils.errors import is_fatal
15
16
  from kash.workspaces import SelectionHistory
16
17
 
@@ -5,7 +5,7 @@ from typing import TypeVar
5
5
  from kash.config.logger import get_logger
6
6
  from kash.config.text_styles import COLOR_ERROR
7
7
  from kash.shell.output.shell_output import PrintHooks
8
- from kash.utils.errors import NONFATAL_EXCEPTIONS
8
+ from kash.utils.errors import get_nonfatal_exceptions
9
9
 
10
10
  log = get_logger(__name__)
11
11
 
@@ -41,7 +41,7 @@ def wrap_with_exception_printing(func: Callable[..., R]) -> Callable[[list[str]]
41
41
  (", ".join(str(arg) for arg in args)),
42
42
  )
43
43
  return func(*args)
44
- except NONFATAL_EXCEPTIONS as e:
44
+ except get_nonfatal_exceptions() as e:
45
45
  PrintHooks.nonfatal_exception()
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)
@@ -2,10 +2,8 @@ import html
2
2
  import re
3
3
  from pathlib import Path
4
4
 
5
- from inflect import engine
6
5
  from prettyfmt import fmt_path
7
6
 
8
- from kash.utils.common.lazyobject import lazyobject
9
7
  from kash.utils.common.url import Locator, is_url
10
8
 
11
9
 
@@ -45,18 +43,6 @@ def fmt_loc(locator: str | Locator, resolve: bool = True) -> str:
45
43
  return fmt_path(locator, resolve=resolve)
46
44
 
47
45
 
48
- @lazyobject
49
- def inflect():
50
- return engine()
51
-
52
-
53
- def fmt_count_items(count: int, name: str = "item") -> str:
54
- """
55
- Format a count and a name as a pluralized phrase, e.g. "1 item" or "2 items".
56
- """
57
- return f"{count} {inflect.plural(name, count)}" # pyright: ignore
58
-
59
-
60
46
  ## Tests
61
47
 
62
48
 
@@ -12,36 +12,64 @@ log = logging.getLogger(__name__)
12
12
  Tallies: TypeAlias = dict[str, int]
13
13
 
14
14
 
15
- def import_subdirs(
15
+ def import_recursive(
16
16
  parent_package_name: str,
17
17
  parent_dir: Path,
18
- subdir_names: list[str] | None = None,
18
+ resource_names: list[str] | None = None,
19
19
  tallies: Tallies | None = None,
20
20
  ):
21
21
  """
22
- Import all files in the given subdirectories of a single parent directory.
23
- Wraps `pkgutil.iter_modules` to iterate over all modules in the subdirectories.
24
- If `subdir_names` is `None`, will import all subdirectories.
22
+ Import modules from subdirectories or individual Python modules within a parent package.
23
+
24
+ Each resource in `resource_names` can be:
25
+ - A directory name (all modules within it will be imported)
26
+ - A module name with or without '.py' extension (a single module will be imported)
27
+ - "." to import all modules in the parent_dir
28
+
29
+ If `resource_names` is `None`, imports all modules directly in parent_dir.
30
+
31
+ Simply a convenience wrapper for `importlib.import_module` and
32
+ `pkgutil.iter_modules` to iterate over all modules in the subdirectories.
33
+
34
+ If `tallies` is provided, it will be updated with the number of modules imported
35
+ for each package.
25
36
  """
26
37
  if tallies is None:
27
38
  tallies = {}
28
- if not subdir_names:
29
- subdir_names = ["."]
39
+ if not resource_names:
40
+ resource_names = ["."]
30
41
 
31
- for subdir_name in subdir_names:
32
- if subdir_name == ".":
42
+ for name in resource_names:
43
+ if name == ".":
33
44
  full_path = parent_dir
34
45
  package_name = parent_package_name
35
46
  else:
36
- full_path = parent_dir / subdir_name
37
- package_name = f"{parent_package_name}.{subdir_name}"
38
-
39
- if not full_path.is_dir():
40
- raise FileNotFoundError(f"Subdirectory not found: {full_path}")
41
-
42
- for _module_finder, module_name, _is_pkg in pkgutil.iter_modules(path=[str(full_path)]):
43
- importlib.import_module(f"{package_name}.{module_name}") # Propagate import errors
44
- tallies[package_name] = tallies.get(package_name, 0) + 1
47
+ full_path = parent_dir / name
48
+ package_name = f"{parent_package_name}.{name}"
49
+
50
+ # Check if it's a directory
51
+ if full_path.is_dir():
52
+ # Import all modules in the directory
53
+ for _, module_name, _ in pkgutil.iter_modules(path=[str(full_path)]):
54
+ importlib.import_module(f"{package_name}.{module_name}")
55
+ tallies[package_name] = tallies.get(package_name, 0) + 1
56
+ else:
57
+ # Not a directory, try as a module file
58
+ module_path = full_path
59
+ module_name = name
60
+
61
+ # Handle with or without .py extension
62
+ if not module_path.is_file() and module_path.suffix != ".py":
63
+ module_path = parent_dir / f"{name}.py"
64
+ module_name = name
65
+ elif module_path.suffix == ".py":
66
+ module_name = module_path.stem
67
+
68
+ if module_path.is_file() and module_name != "__init__":
69
+ importlib.import_module(f"{parent_package_name}.{module_name}")
70
+ tallies[parent_package_name] = tallies.get(parent_package_name, 0) + 1
71
+ else:
72
+ raise FileNotFoundError(f"Path not found or not importable: {full_path}")
45
73
 
46
74
  return tallies
47
75
 
@@ -3,7 +3,6 @@ from contextlib import contextmanager
3
3
  from dataclasses import dataclass
4
4
 
5
5
  from kash.config.text_styles import (
6
- EMOJI_ACTION,
7
6
  EMOJI_BREADCRUMB_SEP,
8
7
  EMOJI_MSG_INDENT,
9
8
  TASK_STACK_HEADER,
@@ -93,9 +92,8 @@ class TaskStack:
93
92
  if not self.stack:
94
93
  return ""
95
94
  else:
96
- prefix = f"{self.prefix_str()} {EMOJI_BREADCRUMB_SEP} "
97
- sep = f"\n{prefix}"
98
- return prefix + sep.join(state.full_str() for state in self.stack)
95
+ sep = f" {EMOJI_BREADCRUMB_SEP} "
96
+ return f"{EMOJI_BREADCRUMB_SEP} " + sep.join(state.full_str() for state in self.stack)
99
97
 
100
98
  def prefix_str(self) -> str:
101
99
  if not self.stack:
@@ -107,8 +105,7 @@ class TaskStack:
107
105
  return f"TaskStack({self.full_str()})"
108
106
 
109
107
  def log_stack(self):
110
- self._print()
111
- self._log.message(f"{EMOJI_ACTION} {TASK_STACK_HEADER}\n%s", self.full_str())
108
+ self._log.message(f"{TASK_STACK_HEADER} %s", self.full_str())
112
109
 
113
110
  @contextmanager
114
111
  def context(self, name: str, total_parts: int = 1, unit: str = ""):
@@ -123,9 +120,7 @@ class TaskStack:
123
120
  except Exception as e:
124
121
  # Log immediately where the exception occurred, but don't double-log.
125
122
  if e not in self.exceptions_logged:
126
- self._log.warning(
127
- "Exception in task context: %s: %s", type(e).__name__, e, exc_info=True
128
- )
123
+ self._log.info("Exception in task context: %s: %s", type(e).__name__, e)
129
124
  self.exceptions_logged.add(e)
130
125
  self.next(last_had_error=True)
131
126
  raise
@@ -139,12 +134,6 @@ class TaskStack:
139
134
 
140
135
  return get_logger(__name__)
141
136
 
142
- @property
143
- def _print(self):
144
- from kash.shell.output.shell_output import cprint
145
-
146
- return cprint
147
-
148
137
 
149
138
  task_stack_var: contextvars.ContextVar[TaskStack | None] = contextvars.ContextVar(
150
139
  "task_stack", default=None
kash/utils/errors.py CHANGED
@@ -3,6 +3,8 @@ Common hierarchy of error types. These inherit from standard errors like
3
3
  ValueError and FileExistsError but are more fine-grained.
4
4
  """
5
5
 
6
+ from functools import cache
7
+
6
8
 
7
9
  class KashRuntimeError(ValueError):
8
10
  """Base class for kash runtime errors."""
@@ -145,8 +147,14 @@ class ApiError(KashRuntimeError):
145
147
  pass
146
148
 
147
149
 
148
- def _nonfatal_exceptions() -> tuple[type[Exception], ...]:
150
+ @cache
151
+ def get_nonfatal_exceptions() -> tuple[type[Exception], ...]:
152
+ """
153
+ Exceptions that are not fatal and usually don't merit a full stack trace.
154
+ """
149
155
  exceptions: list[type[Exception]] = [SelfExplanatoryError, FileNotFoundError, IOError]
156
+
157
+ # Slow imports, do lazily.
150
158
  try:
151
159
  from xonsh.tools import XonshError
152
160
 
@@ -155,14 +163,15 @@ def _nonfatal_exceptions() -> tuple[type[Exception], ...]:
155
163
  pass
156
164
 
157
165
  try:
158
- import litellm
166
+ import openai
159
167
 
160
- exceptions.append(litellm.exceptions.APIError)
168
+ # LiteLLM exceptions subclass openai.APIError
169
+ exceptions.append(openai.APIError)
161
170
  except ImportError:
162
171
  pass
163
172
 
164
173
  try:
165
- import yt_dlp
174
+ import yt_dlp.utils
166
175
 
167
176
  exceptions.append(yt_dlp.utils.DownloadError)
168
177
  except ImportError:
@@ -171,12 +180,8 @@ def _nonfatal_exceptions() -> tuple[type[Exception], ...]:
171
180
  return tuple(exceptions)
172
181
 
173
182
 
174
- NONFATAL_EXCEPTIONS = _nonfatal_exceptions()
175
- """Exceptions that are not fatal and usually don't merit a full stack trace."""
176
-
177
-
178
183
  def is_fatal(exception: Exception) -> bool:
179
- for e in NONFATAL_EXCEPTIONS:
184
+ for e in get_nonfatal_exceptions():
180
185
  if isinstance(exception, e):
181
186
  return False
182
187
  return True
@@ -4,7 +4,7 @@ from dataclasses import dataclass
4
4
  from enum import Enum
5
5
  from pathlib import Path
6
6
 
7
- from kash.utils.common.url import Url, is_file_url, parse_file_url
7
+ from kash.utils.common.url import Url, is_file_url, is_url, parse_file_url
8
8
  from kash.utils.file_utils.file_ext import FileExt
9
9
  from kash.utils.file_utils.file_formats import (
10
10
  MIME_EMPTY,
@@ -103,8 +103,18 @@ class Format(Enum):
103
103
  self.log,
104
104
  ]
105
105
 
106
+ @property
107
+ def is_simple_text(self) -> bool:
108
+ """
109
+ Is this plaintext or close to it, like Markdown?
110
+ """
111
+ return self in [self.plaintext, self.markdown, self.md_html]
112
+
106
113
  @property
107
114
  def is_doc(self) -> bool:
115
+ """
116
+ Is this a textual document of some kind?
117
+ """
108
118
  return self in [
109
119
  self.markdown,
110
120
  self.md_html,
@@ -112,6 +122,7 @@ class Format(Enum):
112
122
  self.pdf,
113
123
  self.docx,
114
124
  self.pptx,
125
+ self.epub,
115
126
  ]
116
127
 
117
128
  @property
@@ -130,6 +141,14 @@ class Format(Enum):
130
141
  def is_code(self) -> bool:
131
142
  return self in [self.python, self.shellscript, self.xonsh, self.json, self.yaml]
132
143
 
144
+ @property
145
+ def is_markdown(self) -> bool:
146
+ return self in [self.markdown, self.md_html]
147
+
148
+ @property
149
+ def is_html(self) -> bool:
150
+ return self in [self.html, self.md_html]
151
+
133
152
  @property
134
153
  def is_data(self) -> bool:
135
154
  return self in [self.csv, self.xlsx, self.npz]
@@ -325,8 +344,8 @@ Format._init_mime_type_map()
325
344
 
326
345
  @dataclass(frozen=True)
327
346
  class FileFormatInfo:
328
- file_ext: FileExt | None
329
- """File extension, if recognized."""
347
+ current_file_ext: FileExt | None
348
+ """File extension, if recognized and in the current filename."""
330
349
 
331
350
  format: Format | None
332
351
  """Format, if recognized."""
@@ -334,11 +353,18 @@ class FileFormatInfo:
334
353
  mime_type: MimeType | None
335
354
  """Raw mime type, which may include more formats than the ones above."""
336
355
 
356
+ @property
357
+ def suggested_file_ext(self) -> FileExt | None:
358
+ """
359
+ Suggested file extension based on detected format.
360
+ """
361
+ return self.format.file_ext if self.format else self.current_file_ext
362
+
337
363
  @property
338
364
  def is_text(self) -> bool:
339
365
  return bool(
340
- self.file_ext
341
- and self.file_ext.is_text
366
+ self.current_file_ext
367
+ and self.current_file_ext.is_text
342
368
  or self.format
343
369
  and self.format.is_text
344
370
  or self.mime_type
@@ -358,8 +384,8 @@ class FileFormatInfo:
358
384
  @property
359
385
  def is_image(self) -> bool:
360
386
  return bool(
361
- self.file_ext
362
- and self.file_ext.is_image
387
+ self.current_file_ext
388
+ and self.current_file_ext.is_image
363
389
  or self.format
364
390
  and self.format.is_image
365
391
  or self.mime_type
@@ -432,24 +458,33 @@ def detect_media_type(filename: str | Path) -> MediaType:
432
458
  return media_type
433
459
 
434
460
 
435
- def choose_file_ext(url_or_path: Url | Path | str) -> FileExt | None:
461
+ def choose_file_ext(
462
+ url_or_path: Url | Path | str, mime_type: MimeType | None = None
463
+ ) -> FileExt | None:
436
464
  """
437
- Pick a suffix to reflect the type of the content. Recognizes known file
438
- extensions, then tries libmagic, then gives up.
465
+ Pick a file extension to reflect the type of the content. First tries from any
466
+ provided content type (e.g. if this item was just downloaded). Then
467
+ recognizes known file extensions on the filename or URL, then tries looking
468
+ at the content with libmagic and heuristics, then gives up.
439
469
  """
440
-
441
- def file_ext_for(path: Path) -> FileExt | None:
442
- fmt = detect_file_format(path)
443
- return fmt.file_ext if fmt else None
444
-
445
- ext = None
446
- if isinstance(url_or_path, Path):
447
- ext = parse_file_ext(url_or_path) or file_ext_for(url_or_path)
448
- elif is_file_url(url_or_path):
449
- path = parse_file_url(url_or_path)
450
- if path:
451
- ext = parse_file_ext(path) or file_ext_for(path)
452
- else:
453
- ext = parse_file_ext(url_or_path)
454
-
455
- return ext
470
+ if mime_type:
471
+ fmt = Format.from_mime_type(mime_type)
472
+ if fmt:
473
+ return fmt.file_ext
474
+
475
+ # First check if it's a known standard extension.
476
+ filename_ext = parse_file_ext(url_or_path)
477
+ if filename_ext:
478
+ return filename_ext
479
+
480
+ local_path = None
481
+ if isinstance(url_or_path, str) and is_file_url(url_or_path):
482
+ local_path = parse_file_url(url_or_path)
483
+ elif not is_url(url_or_path):
484
+ local_path = Path(url_or_path)
485
+
486
+ # If it's local based the extension on the file content.
487
+ if local_path:
488
+ return file_format_info(local_path).suggested_file_ext
489
+
490
+ return None
@@ -1,9 +1,11 @@
1
+ from __future__ import annotations
2
+
1
3
  from datetime import UTC, datetime
2
4
  from enum import Enum
3
5
  from pathlib import Path
6
+ from typing import TYPE_CHECKING
4
7
 
5
8
  import humanfriendly
6
- import pandas as pd
7
9
  from funlog import log_calls
8
10
  from prettyfmt import fmt_path
9
11
  from pydantic.dataclasses import dataclass
@@ -12,6 +14,9 @@ from kash.config.logger import get_logger
12
14
  from kash.utils.errors import FileNotFound, InvalidInput
13
15
  from kash.utils.file_utils.file_walk import IgnoreFilter, walk_by_dir
14
16
 
17
+ if TYPE_CHECKING:
18
+ from pandas import DataFrame
19
+
15
20
  log = get_logger(__name__)
16
21
 
17
22
 
@@ -122,8 +127,10 @@ class FileListing:
122
127
  size_matching: int
123
128
  since_timestamp: float
124
129
 
125
- def as_dataframe(self) -> pd.DataFrame:
126
- df = pd.DataFrame([file.__dict__ for file in self.files])
130
+ def as_dataframe(self) -> DataFrame:
131
+ from pandas import DataFrame
132
+
133
+ df = DataFrame([file.__dict__ for file in self.files])
127
134
  return df
128
135
 
129
136
  @property