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.
- kash/actions/core/render_as_html.py +2 -2
- kash/actions/core/show_webpage.py +2 -2
- kash/actions/core/strip_html.py +2 -2
- kash/commands/base/basic_file_commands.py +21 -3
- kash/commands/base/files_command.py +5 -4
- kash/commands/extras/parse_uv_lock.py +12 -3
- kash/commands/workspace/selection_commands.py +1 -1
- kash/commands/workspace/workspace_commands.py +1 -1
- kash/config/env_settings.py +2 -42
- kash/config/logger.py +30 -25
- kash/config/logger_basic.py +6 -6
- kash/config/settings.py +23 -7
- kash/config/setup.py +33 -5
- kash/config/text_styles.py +25 -22
- kash/embeddings/cosine.py +12 -4
- kash/embeddings/embeddings.py +16 -6
- kash/embeddings/text_similarity.py +10 -4
- kash/exec/__init__.py +3 -0
- kash/exec/action_decorators.py +4 -19
- kash/exec/action_exec.py +43 -23
- kash/exec/llm_transforms.py +2 -2
- kash/exec/preconditions.py +4 -12
- kash/exec/runtime_settings.py +134 -0
- kash/exec/shell_callable_action.py +5 -3
- kash/file_storage/file_store.py +18 -21
- kash/file_storage/item_file_format.py +6 -3
- kash/file_storage/store_filenames.py +6 -3
- kash/llm_utils/init_litellm.py +16 -0
- kash/llm_utils/llm_api_keys.py +6 -2
- kash/llm_utils/llm_completion.py +11 -4
- kash/mcp/mcp_cli.py +3 -2
- kash/mcp/mcp_server_routes.py +11 -12
- kash/media_base/transcription_deepgram.py +15 -2
- kash/model/__init__.py +1 -1
- kash/model/actions_model.py +6 -54
- kash/model/exec_model.py +79 -0
- kash/model/items_model.py +71 -50
- kash/model/operations_model.py +38 -15
- kash/model/paths_model.py +2 -0
- kash/shell/output/shell_output.py +10 -8
- kash/shell/shell_main.py +2 -2
- kash/shell/utils/exception_printing.py +2 -2
- kash/text_handling/doc_normalization.py +16 -8
- kash/text_handling/markdown_utils.py +83 -2
- kash/utils/common/format_utils.py +2 -8
- kash/utils/common/inflection.py +22 -0
- kash/utils/common/task_stack.py +4 -15
- kash/utils/errors.py +14 -9
- kash/utils/file_utils/file_formats_model.py +15 -0
- kash/utils/file_utils/file_sort_filter.py +10 -3
- kash/web_gen/templates/base_styles.css.jinja +8 -3
- kash/workspaces/__init__.py +12 -3
- kash/workspaces/workspace_dirs.py +58 -0
- kash/workspaces/workspace_importing.py +1 -1
- kash/workspaces/workspaces.py +26 -90
- {kash_shell-0.3.11.dist-info → kash_shell-0.3.12.dist-info}/METADATA +4 -4
- {kash_shell-0.3.11.dist-info → kash_shell-0.3.12.dist-info}/RECORD +60 -57
- kash/shell/utils/argparse_utils.py +0 -20
- kash/utils/lang_utils/inflection.py +0 -18
- {kash_shell-0.3.11.dist-info → kash_shell-0.3.12.dist-info}/WHEEL +0 -0
- {kash_shell-0.3.11.dist-info → kash_shell-0.3.12.dist-info}/entry_points.txt +0 -0
- {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
|
|
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) ->
|
|
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.
|
|
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} {
|
|
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)
|
kash/utils/common/task_stack.py
CHANGED
|
@@ -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
|
-
|
|
97
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
166
|
+
import openai
|
|
159
167
|
|
|
160
|
-
exceptions.
|
|
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
|
|
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) ->
|
|
126
|
-
|
|
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:
|
|
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.
|
|
124
|
-
margin-bottom: 0.
|
|
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 {
|
kash/workspaces/__init__.py
CHANGED
|
@@ -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.
|
|
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}")
|
kash/workspaces/workspaces.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
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,
|
|
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) ->
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
148
|
+
|
|
149
|
+
As a convenience, this call also auto-updates logging and cache directories
|
|
150
|
+
if this has changed.
|
|
225
151
|
"""
|
|
226
|
-
|
|
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 =
|
|
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
|
|