kash-shell 0.3.11__py3-none-any.whl → 0.3.12__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 (62) hide show
  1. kash/actions/core/render_as_html.py +2 -2
  2. kash/actions/core/show_webpage.py +2 -2
  3. kash/actions/core/strip_html.py +2 -2
  4. kash/commands/base/basic_file_commands.py +21 -3
  5. kash/commands/base/files_command.py +5 -4
  6. kash/commands/extras/parse_uv_lock.py +12 -3
  7. kash/commands/workspace/selection_commands.py +1 -1
  8. kash/commands/workspace/workspace_commands.py +1 -1
  9. kash/config/env_settings.py +2 -42
  10. kash/config/logger.py +30 -25
  11. kash/config/logger_basic.py +6 -6
  12. kash/config/settings.py +23 -7
  13. kash/config/setup.py +33 -5
  14. kash/config/text_styles.py +25 -22
  15. kash/embeddings/cosine.py +12 -4
  16. kash/embeddings/embeddings.py +16 -6
  17. kash/embeddings/text_similarity.py +10 -4
  18. kash/exec/__init__.py +3 -0
  19. kash/exec/action_decorators.py +4 -19
  20. kash/exec/action_exec.py +43 -23
  21. kash/exec/llm_transforms.py +2 -2
  22. kash/exec/preconditions.py +4 -12
  23. kash/exec/runtime_settings.py +134 -0
  24. kash/exec/shell_callable_action.py +5 -3
  25. kash/file_storage/file_store.py +18 -21
  26. kash/file_storage/item_file_format.py +6 -3
  27. kash/file_storage/store_filenames.py +6 -3
  28. kash/llm_utils/init_litellm.py +16 -0
  29. kash/llm_utils/llm_api_keys.py +6 -2
  30. kash/llm_utils/llm_completion.py +11 -4
  31. kash/mcp/mcp_cli.py +3 -2
  32. kash/mcp/mcp_server_routes.py +11 -12
  33. kash/media_base/transcription_deepgram.py +15 -2
  34. kash/model/__init__.py +1 -1
  35. kash/model/actions_model.py +6 -54
  36. kash/model/exec_model.py +79 -0
  37. kash/model/items_model.py +71 -50
  38. kash/model/operations_model.py +38 -15
  39. kash/model/paths_model.py +2 -0
  40. kash/shell/output/shell_output.py +10 -8
  41. kash/shell/shell_main.py +2 -2
  42. kash/shell/utils/exception_printing.py +2 -2
  43. kash/text_handling/doc_normalization.py +16 -8
  44. kash/text_handling/markdown_utils.py +83 -2
  45. kash/utils/common/format_utils.py +2 -8
  46. kash/utils/common/inflection.py +22 -0
  47. kash/utils/common/task_stack.py +4 -15
  48. kash/utils/errors.py +14 -9
  49. kash/utils/file_utils/file_formats_model.py +15 -0
  50. kash/utils/file_utils/file_sort_filter.py +10 -3
  51. kash/web_gen/templates/base_styles.css.jinja +8 -3
  52. kash/workspaces/__init__.py +12 -3
  53. kash/workspaces/workspace_dirs.py +58 -0
  54. kash/workspaces/workspace_importing.py +1 -1
  55. kash/workspaces/workspaces.py +26 -90
  56. {kash_shell-0.3.11.dist-info → kash_shell-0.3.12.dist-info}/METADATA +4 -4
  57. {kash_shell-0.3.11.dist-info → kash_shell-0.3.12.dist-info}/RECORD +60 -57
  58. kash/shell/utils/argparse_utils.py +0 -20
  59. kash/utils/lang_utils/inflection.py +0 -18
  60. {kash_shell-0.3.11.dist-info → kash_shell-0.3.12.dist-info}/WHEEL +0 -0
  61. {kash_shell-0.3.11.dist-info → kash_shell-0.3.12.dist-info}/entry_points.txt +0 -0
  62. {kash_shell-0.3.11.dist-info → kash_shell-0.3.12.dist-info}/licenses/LICENSE +0 -0
@@ -1,5 +1,6 @@
1
1
  import re
2
- from typing import Any
2
+ from textwrap import dedent
3
+ from typing import Any, TypeAlias
3
4
 
4
5
  import marko
5
6
  import regex
@@ -11,6 +12,8 @@ from kash.utils.common.url import Url
11
12
 
12
13
  log = get_logger(__name__)
13
14
 
15
+ HTag: TypeAlias = str
16
+
14
17
  # Characters that commonly need escaping in Markdown inline text.
15
18
  MARKDOWN_ESCAPE_CHARS = r"([\\`*_{}\[\]()#+.!-])"
16
19
  MARKDOWN_ESCAPE_RE = re.compile(MARKDOWN_ESCAPE_CHARS)
@@ -128,7 +131,7 @@ def extract_bullet_points(content: str) -> list[str]:
128
131
  return _tree_bullet_points(document)
129
132
 
130
133
 
131
- def _type_from_heading(heading: Heading) -> str:
134
+ def _type_from_heading(heading: Heading) -> HTag:
132
135
  if heading.level in [1, 2, 3, 4, 5, 6]:
133
136
  return f"h{heading.level}"
134
137
  else:
@@ -174,6 +177,43 @@ def find_markdown_text(
174
177
  pos = match.end()
175
178
 
176
179
 
180
+ def extract_headings(text: str) -> list[tuple[HTag, str]]:
181
+ """
182
+ Extract all Markdown headings from the given content.
183
+ Returns a list of (tag, text) tuples:
184
+ [("h1", "Main Title"), ("h2", "Subtitle")]
185
+ where `#` corresponds to `h1`, `##` to `h2`, etc.
186
+ """
187
+ document = marko.parse(text)
188
+ headings_list: list[tuple[HTag, str]] = []
189
+
190
+ def _collect_headings_recursive(element: Any) -> None:
191
+ if isinstance(element, Heading):
192
+ tag = _type_from_heading(element)
193
+ content = _extract_text(element).strip()
194
+ headings_list.append((tag, content))
195
+
196
+ if hasattr(element, "children"):
197
+ for child in element.children:
198
+ _collect_headings_recursive(child)
199
+
200
+ _collect_headings_recursive(document)
201
+
202
+ return headings_list
203
+
204
+
205
+ def first_heading(text: str, *, allowed_tags: tuple[HTag, ...] = ("h1", "h2")) -> str | None:
206
+ """
207
+ Find the text of the first heading. Returns first h1 if present, otherwise first h2, etc.
208
+ """
209
+ headings = extract_headings(text)
210
+ for goal_tag in allowed_tags:
211
+ for h_tag, h_text in headings:
212
+ if h_tag == goal_tag:
213
+ return h_text
214
+ return None
215
+
216
+
177
217
  ## Tests
178
218
 
179
219
 
@@ -224,3 +264,44 @@ def test_find_markdown_text() -> None: # pragma: no cover
224
264
  pattern = re.compile("bar", re.IGNORECASE)
225
265
  match = find_markdown_text(pattern, text)
226
266
  assert match is None
267
+
268
+
269
+ def test_extract_headings_and_first_header() -> None:
270
+ markdown_content = dedent("""
271
+ # Title 1
272
+ Some text.
273
+ ## Subtitle 1.1
274
+ More text.
275
+ ### Sub-subtitle 1.1.1
276
+ Even more text.
277
+ # Title 2 *with formatting*
278
+ And final text.
279
+ ## Subtitle 2.1
280
+ """)
281
+ expected_headings = [
282
+ ("h1", "Title 1"),
283
+ ("h2", "Subtitle 1.1"),
284
+ ("h3", "Sub-subtitle 1.1.1"),
285
+ ("h1", "Title 2 with formatting"),
286
+ ("h2", "Subtitle 2.1"),
287
+ ]
288
+ assert extract_headings(markdown_content) == expected_headings
289
+
290
+ assert first_heading(markdown_content) == "Title 1"
291
+ assert first_heading(markdown_content) == "Title 1"
292
+ assert first_heading(markdown_content, allowed_tags=("h2",)) == "Subtitle 1.1"
293
+ assert first_heading(markdown_content, allowed_tags=("h3",)) == "Sub-subtitle 1.1.1"
294
+ assert first_heading(markdown_content, allowed_tags=("h4",)) is None
295
+
296
+ assert extract_headings("") == []
297
+ assert first_heading("") is None
298
+ assert first_heading("Just text, no headers.") is None
299
+
300
+ markdown_h2_only = "## Only H2 Here"
301
+ assert extract_headings(markdown_h2_only) == [("h2", "Only H2 Here")]
302
+ assert first_heading(markdown_h2_only) == "Only H2 Here"
303
+ assert first_heading(markdown_h2_only, allowed_tags=("h2",)) == "Only H2 Here"
304
+
305
+ formatted_header_md = "## *Formatted* _Header_ [link](#anchor)"
306
+ assert extract_headings(formatted_header_md) == [("h2", "Formatted Header link")]
307
+ assert first_heading(formatted_header_md, allowed_tags=("h2",)) == "Formatted Header link"
@@ -2,10 +2,9 @@ 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
7
+ from kash.utils.common.inflection import plural
9
8
  from kash.utils.common.url import Locator, is_url
10
9
 
11
10
 
@@ -45,16 +44,11 @@ def fmt_loc(locator: str | Locator, resolve: bool = True) -> str:
45
44
  return fmt_path(locator, resolve=resolve)
46
45
 
47
46
 
48
- @lazyobject
49
- def inflect():
50
- return engine()
51
-
52
-
53
47
  def fmt_count_items(count: int, name: str = "item") -> str:
54
48
  """
55
49
  Format a count and a name as a pluralized phrase, e.g. "1 item" or "2 items".
56
50
  """
57
- return f"{count} {inflect.plural(name, count)}" # pyright: ignore
51
+ return f"{count} {plural(name, count)}" # pyright: ignore
58
52
 
59
53
 
60
54
  ## Tests
@@ -0,0 +1,22 @@
1
+ from functools import cache
2
+
3
+ # Had been using the `inflect` package, but it takes over 1s to import.
4
+ # pluralizer seems simpler and fine for common English usage.
5
+
6
+
7
+ @cache
8
+ def _get_pluralizer():
9
+ from pluralizer import Pluralizer
10
+
11
+ return Pluralizer()
12
+
13
+
14
+ def plural(word: str, count: int | None = None) -> str:
15
+ """
16
+ Pluralize or singularize a word based on the count.
17
+ """
18
+ from chopdiff.docs import is_word
19
+
20
+ if not is_word(word):
21
+ return word
22
+ return _get_pluralizer().pluralize(word, count=count)
@@ -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
@@ -103,6 +103,13 @@ 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:
108
115
  return self in [
@@ -130,6 +137,14 @@ class Format(Enum):
130
137
  def is_code(self) -> bool:
131
138
  return self in [self.python, self.shellscript, self.xonsh, self.json, self.yaml]
132
139
 
140
+ @property
141
+ def is_markdown(self) -> bool:
142
+ return self in [self.markdown, self.md_html]
143
+
144
+ @property
145
+ def is_html(self) -> bool:
146
+ return self in [self.html, self.md_html]
147
+
133
148
  @property
134
149
  def is_data(self) -> bool:
135
150
  return self in [self.csv, self.xlsx, self.npz]
@@ -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
@@ -83,6 +83,9 @@ body {
83
83
  p {
84
84
  margin-bottom: 1rem;
85
85
  }
86
+ pre {
87
+ margin-bottom: 1rem;
88
+ }
86
89
 
87
90
  b, strong {
88
91
  font-weight: var(--font-weight-sans-bold);
@@ -114,14 +117,14 @@ h1 {
114
117
 
115
118
  h2 {
116
119
  font-size: 1.4rem;
117
- margin-top: 2.5rem;
120
+ margin-top: 1.2rem;
118
121
  margin-bottom: 1rem;
119
122
  }
120
123
 
121
124
  h3 {
122
125
  font-size: 1.09rem;
123
- margin-top: 1.7rem;
124
- margin-bottom: 0.7rem;
126
+ margin-top: 1.5rem;
127
+ margin-bottom: 0.5rem;
125
128
  }
126
129
 
127
130
  h4 {
@@ -289,6 +292,8 @@ sup {
289
292
  padding: 0 0.15rem;
290
293
  border-radius: 4px;
291
294
  transition: all 0.15s ease-in-out;
295
+ font-style: normal;
296
+ font-weight: normal;
292
297
  }
293
298
 
294
299
  .footnote-ref a:hover, .footnote:hover {
@@ -1,19 +1,28 @@
1
1
  from kash.workspaces.selections import Selection, SelectionHistory
2
+ from kash.workspaces.workspace_dirs import (
3
+ enclosing_ws_dir,
4
+ global_ws_dir,
5
+ is_global_ws_dir,
6
+ is_ws_dir,
7
+ )
2
8
  from kash.workspaces.workspaces import (
3
9
  Workspace,
10
+ _switch_ws_settings,
4
11
  current_ignore,
5
12
  current_ws,
6
13
  get_global_ws,
7
14
  get_ws,
8
- global_ws_dir,
9
15
  resolve_ws,
10
- switch_to_ws,
11
16
  ws_param_value,
12
17
  )
13
18
 
14
19
  __all__ = [
15
20
  "Selection",
16
21
  "SelectionHistory",
22
+ "enclosing_ws_dir",
23
+ "global_ws_dir",
24
+ "is_global_ws_dir",
25
+ "is_ws_dir",
17
26
  "Workspace",
18
27
  "current_ignore",
19
28
  "current_ws",
@@ -21,6 +30,6 @@ __all__ = [
21
30
  "get_ws",
22
31
  "global_ws_dir",
23
32
  "resolve_ws",
33
+ "_switch_ws_settings",
24
34
  "ws_param_value",
25
- "switch_to_ws",
26
35
  ]
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from functools import cache
5
+ from pathlib import Path
6
+
7
+ from kash.config.logger import get_logger
8
+ from kash.config.settings import global_settings, resolve_and_create_dirs
9
+ from kash.file_storage.metadata_dirs import MetadataDirs
10
+ from kash.utils.errors import InvalidInput
11
+
12
+ log = get_logger(__name__)
13
+
14
+
15
+ @cache
16
+ def global_ws_dir() -> Path:
17
+ kb_path = resolve_and_create_dirs(global_settings().global_ws_dir, is_dir=True)
18
+ log.debug("Global workspace path: %s", kb_path)
19
+ return kb_path
20
+
21
+
22
+ def is_global_ws_dir(path: Path) -> bool:
23
+ return path.resolve() == global_settings().global_ws_dir
24
+
25
+
26
+ def is_ws_dir(path: Path) -> bool:
27
+ dirs = MetadataDirs(path, False)
28
+ return dirs.is_initialized()
29
+
30
+
31
+ def enclosing_ws_dir(path: Path | None = None) -> Path | None:
32
+ """
33
+ Get the workspace directory enclosing the given path, or of the current
34
+ working directory if no path is given.
35
+ """
36
+ if not path:
37
+ path = Path(".")
38
+
39
+ path = path.absolute()
40
+ while path != Path("/"):
41
+ if is_ws_dir(path):
42
+ return path
43
+ path = path.parent
44
+
45
+ return None
46
+
47
+
48
+ def normalize_workspace_name(ws_name: str) -> str:
49
+ return str(ws_name).strip().rstrip("/")
50
+
51
+
52
+ def check_strict_workspace_name(ws_name: str) -> str:
53
+ ws_name = normalize_workspace_name(ws_name)
54
+ if not re.match(r"^[\w.-]+$", ws_name):
55
+ raise InvalidInput(
56
+ f"Use an alphanumeric name (- and . also allowed) for the workspace name: `{ws_name}`"
57
+ )
58
+ return ws_name
@@ -45,7 +45,7 @@ def import_and_load(ws: FileStore, locator: Locator | str) -> Item:
45
45
  # It's already a StorePath.
46
46
  item = ws.load(locator)
47
47
  else:
48
- log.message("Importing locator as local path: %r", locator)
48
+ log.info("Importing locator as local path: %r", locator)
49
49
  path = Path(locator)
50
50
  if not path.exists():
51
51
  raise InvalidInput(f"File not found: {path}")
@@ -1,5 +1,5 @@
1
- import contextvars
2
- import re
1
+ from __future__ import annotations
2
+
3
3
  from abc import ABC, abstractmethod
4
4
  from functools import cache
5
5
  from pathlib import Path
@@ -13,10 +13,13 @@ from kash.config.settings import (
13
13
  global_settings,
14
14
  resolve_and_create_dirs,
15
15
  )
16
+ from kash.config.text_styles import STYLE_HINT
16
17
  from kash.file_storage.metadata_dirs import MetadataDirs
17
18
  from kash.model.params_model import GLOBAL_PARAMS, RawParamValues
19
+ from kash.shell.output.shell_output import PrintHooks, cprint
18
20
  from kash.utils.errors import FileNotFound, InvalidInput, InvalidState
19
21
  from kash.utils.file_utils.ignore_files import IgnoreFilter, is_ignored_default
22
+ from kash.workspaces.workspace_dirs import check_strict_workspace_name, is_global_ws_dir, is_ws_dir
20
23
  from kash.workspaces.workspace_registry import WorkspaceInfo, get_ws_registry
21
24
 
22
25
  if TYPE_CHECKING:
@@ -25,19 +28,6 @@ if TYPE_CHECKING:
25
28
  log = get_logger(__name__)
26
29
 
27
30
 
28
- def normalize_workspace_name(ws_name: str) -> str:
29
- return str(ws_name).strip().rstrip("/")
30
-
31
-
32
- def check_strict_workspace_name(ws_name: str) -> str:
33
- ws_name = normalize_workspace_name(ws_name)
34
- if not re.match(r"^[\w.-]+$", ws_name):
35
- raise InvalidInput(
36
- f"Use an alphanumeric name (- and . also allowed) for the workspace name: `{ws_name}`"
37
- )
38
- return ws_name
39
-
40
-
41
31
  class Workspace(ABC):
42
32
  """
43
33
  A workspace is the context for actions and is tied to a folder on disk.
@@ -59,50 +49,6 @@ class Workspace(ABC):
59
49
  def base_dir(self) -> Path:
60
50
  """The base directory for this workspace."""
61
51
 
62
- def __enter__(self):
63
- """
64
- Context manager to set this workspace as the current workspace.
65
- """
66
- from kash.workspaces.workspaces import current_ws_context
67
-
68
- self._token = current_ws_context.set(self.base_dir)
69
- return self
70
-
71
- def __exit__(self, exc_type, exc_val, exc_tb):
72
- """
73
- Restore the previous workspace context.
74
- """
75
- from kash.workspaces.workspaces import current_ws_context
76
-
77
- current_ws_context.reset(self._token)
78
-
79
-
80
- current_ws_context: contextvars.ContextVar[Path | None] = contextvars.ContextVar(
81
- "current_ws_context", default=None
82
- )
83
- """
84
- Context variable that tracks the current workspace. Only used if it is
85
- explicitly set with a `with ws.as_current()` block.
86
- """
87
-
88
-
89
- def is_ws_dir(path: Path) -> bool:
90
- dirs = MetadataDirs(path, False)
91
- return dirs.is_initialized()
92
-
93
-
94
- def enclosing_ws_dir(path: Path) -> Path | None:
95
- """
96
- Get the workspace directory enclosing the given path (itself or a parent or None).
97
- """
98
- path = path.absolute()
99
- while path != Path("/"):
100
- if is_ws_dir(path):
101
- return path
102
- path = path.parent
103
-
104
- return None
105
-
106
52
 
107
53
  def resolve_ws(name: str | Path) -> WorkspaceInfo:
108
54
  """
@@ -137,10 +83,10 @@ def resolve_ws(name: str | Path) -> WorkspaceInfo:
137
83
 
138
84
  ws_name = check_strict_workspace_name(resolved.name)
139
85
 
140
- return WorkspaceInfo(ws_name, resolved, is_global_ws_path(resolved))
86
+ return WorkspaceInfo(ws_name, resolved, is_global_ws_dir(resolved))
141
87
 
142
88
 
143
- def get_ws(name_or_path: str | Path, auto_init: bool = True) -> "FileStore":
89
+ def get_ws(name_or_path: str | Path, auto_init: bool = True) -> FileStore:
144
90
  """
145
91
  Get a workspace by name or path. Adds to the in-memory registry so we reuse it.
146
92
  With `auto_init` true, will initialize the workspace if it is not already initialized.
@@ -162,18 +108,14 @@ def global_ws_dir() -> Path:
162
108
  return kb_path
163
109
 
164
110
 
165
- def is_global_ws_path(path: Path) -> bool:
166
- return path.name.lower() == GLOBAL_WS_NAME.lower()
167
-
168
-
169
- def get_global_ws() -> "FileStore":
111
+ def get_global_ws() -> FileStore:
170
112
  """
171
113
  Get the global_ws workspace.
172
114
  """
173
115
  return get_ws_registry().load(GLOBAL_WS_NAME, global_ws_dir(), True)
174
116
 
175
117
 
176
- def switch_to_ws(base_dir: Path) -> "FileStore":
118
+ def _switch_ws_settings(base_dir: Path) -> FileStore:
177
119
  """
178
120
  Switch the current workspace to the given directory.
179
121
  Updates logging and cache directories to be within that workspace.
@@ -199,41 +141,35 @@ def switch_to_ws(base_dir: Path) -> "FileStore":
199
141
  return get_ws_registry().load(info.name, info.base_dir, info.is_global_ws)
200
142
 
201
143
 
202
- def _current_ws_info() -> tuple[Path | None, bool]:
203
- """
204
- Infer the current workspace from context or the current working directory.
205
- Does not load the workspace.
206
- """
207
- # First check if we have an explicit workspace context.
208
- override_dir = current_ws_context.get()
209
- if override_dir:
210
- return override_dir, is_global_ws_path(override_dir)
211
-
212
- # Fall back to detecting from the current working directory.
213
- dir = enclosing_ws_dir(Path("."))
214
- is_global_ws = is_global_ws_path(dir) if dir else False
215
- if not dir or is_global_ws:
216
- dir = global_ws_dir()
217
- return dir, is_global_ws
218
-
219
-
220
- def current_ws(silent: bool = False) -> "FileStore":
144
+ def current_ws(silent: bool = False) -> FileStore:
221
145
  """
222
146
  Get the current workspace based on the current working directory.
223
147
  Loads and registers the workspace if it is not already loaded.
224
- Also updates logging and cache directories if this has changed.
148
+
149
+ As a convenience, this call also auto-updates logging and cache directories
150
+ if this has changed.
225
151
  """
226
- base_dir, _is_global_ws = _current_ws_info()
152
+ from kash.exec.runtime_settings import current_ws_context
153
+
154
+ ws_context = current_ws_context()
155
+ base_dir = ws_context.current_ws_dir
227
156
  if not base_dir:
228
157
  raise InvalidState(
229
158
  f"No workspace found in: {fmt_path(Path('.').absolute(), resolve=False)}\n"
230
159
  "Create one with the `workspace` command."
231
160
  )
232
161
 
233
- ws = switch_to_ws(base_dir)
162
+ ws = _switch_ws_settings(base_dir)
234
163
 
235
164
  if not silent:
236
- ws.log_workspace_info(once=True)
165
+ did_log = ws.log_workspace_info(once=True)
166
+ if did_log and ws.is_global_ws and not ws_context.override_dir:
167
+ PrintHooks.spacer()
168
+ log.warning("Note you are currently using the default global workspace.")
169
+ cprint(
170
+ "Create or switch to another workspace with the `workspace` command.",
171
+ style=STYLE_HINT,
172
+ )
237
173
 
238
174
  return ws
239
175