kash-shell 0.3.9__py3-none-any.whl → 0.3.11__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/__init__.py +4 -4
- kash/actions/core/format_markdown_template.py +2 -5
- kash/actions/core/markdownify.py +7 -6
- kash/actions/core/readability.py +7 -6
- kash/actions/core/render_as_html.py +37 -0
- kash/actions/core/show_webpage.py +6 -11
- kash/actions/core/strip_html.py +2 -6
- kash/actions/core/tabbed_webpage_config.py +31 -0
- kash/actions/core/{webpage_generate.py → tabbed_webpage_generate.py} +5 -4
- kash/commands/__init__.py +8 -20
- kash/commands/base/basic_file_commands.py +15 -0
- kash/commands/base/debug_commands.py +13 -0
- kash/commands/base/files_command.py +28 -10
- kash/commands/base/general_commands.py +21 -16
- kash/commands/base/logs_commands.py +4 -2
- kash/commands/base/model_commands.py +8 -8
- kash/commands/base/search_command.py +3 -2
- kash/commands/base/show_command.py +5 -3
- kash/commands/extras/parse_uv_lock.py +186 -0
- kash/commands/help/doc_commands.py +2 -31
- kash/commands/help/welcome.py +33 -0
- kash/commands/workspace/selection_commands.py +11 -6
- kash/commands/workspace/workspace_commands.py +19 -17
- kash/config/colors.py +3 -1
- kash/config/env_settings.py +14 -1
- kash/config/init.py +2 -2
- kash/config/logger.py +59 -56
- kash/config/logger_basic.py +3 -3
- kash/config/settings.py +116 -57
- kash/config/setup.py +28 -12
- kash/config/text_styles.py +3 -13
- kash/docs/load_api_docs.py +2 -1
- kash/docs/markdown/topics/a3_getting_started.md +3 -2
- kash/{concepts → embeddings}/text_similarity.py +2 -2
- kash/exec/__init__.py +20 -3
- kash/exec/action_decorators.py +24 -10
- kash/exec/action_exec.py +41 -23
- kash/exec/action_registry.py +13 -48
- kash/exec/command_registry.py +2 -1
- kash/exec/fetch_url_metadata.py +4 -6
- kash/exec/importing.py +56 -0
- kash/exec/llm_transforms.py +12 -10
- kash/exec/precondition_registry.py +2 -1
- kash/exec/preconditions.py +22 -1
- kash/exec/resolve_args.py +4 -0
- kash/exec/shell_callable_action.py +33 -19
- kash/file_storage/file_store.py +42 -27
- kash/file_storage/item_file_format.py +5 -2
- kash/file_storage/metadata_dirs.py +11 -2
- kash/help/assistant.py +1 -1
- kash/help/assistant_instructions.py +2 -1
- kash/help/function_param_info.py +1 -1
- kash/help/help_embeddings.py +2 -2
- kash/help/help_printing.py +7 -11
- kash/llm_utils/clean_headings.py +1 -1
- kash/llm_utils/llm_api_keys.py +4 -4
- kash/llm_utils/llm_features.py +68 -0
- kash/llm_utils/llm_messages.py +1 -2
- kash/llm_utils/llm_names.py +1 -1
- kash/llm_utils/llms.py +8 -3
- kash/local_server/__init__.py +5 -2
- kash/local_server/local_server.py +8 -5
- kash/local_server/local_server_commands.py +2 -2
- kash/local_server/local_server_routes.py +1 -7
- kash/local_server/local_url_formatters.py +1 -1
- kash/mcp/__init__.py +5 -2
- kash/mcp/mcp_cli.py +5 -5
- kash/mcp/mcp_server_commands.py +5 -5
- kash/mcp/mcp_server_routes.py +5 -5
- kash/mcp/mcp_server_sse.py +4 -2
- kash/media_base/media_cache.py +8 -8
- kash/media_base/media_services.py +1 -1
- kash/media_base/media_tools.py +6 -6
- kash/media_base/services/local_file_media.py +2 -2
- kash/media_base/{speech_transcription.py → transcription_deepgram.py} +25 -110
- kash/media_base/transcription_format.py +73 -0
- kash/media_base/transcription_whisper.py +38 -0
- kash/model/__init__.py +73 -5
- kash/model/actions_model.py +38 -4
- kash/model/concept_model.py +30 -0
- kash/model/items_model.py +115 -32
- kash/model/params_model.py +24 -0
- kash/shell/completions/completion_scoring.py +37 -5
- kash/shell/output/kerm_codes.py +1 -2
- kash/shell/output/shell_formatting.py +14 -4
- kash/shell/shell_main.py +2 -2
- kash/shell/utils/exception_printing.py +6 -0
- kash/shell/utils/native_utils.py +26 -20
- kash/shell/utils/shell_function_wrapper.py +15 -15
- kash/text_handling/custom_sliding_transforms.py +12 -4
- kash/text_handling/doc_normalization.py +6 -2
- kash/text_handling/markdown_render.py +118 -0
- kash/text_handling/markdown_utils.py +226 -0
- kash/utils/common/function_inspect.py +360 -110
- kash/utils/common/import_utils.py +12 -3
- kash/utils/common/type_utils.py +0 -29
- kash/utils/common/url.py +27 -3
- kash/utils/errors.py +6 -0
- kash/utils/file_utils/file_ext.py +4 -0
- kash/utils/file_utils/file_formats.py +2 -2
- kash/utils/file_utils/file_formats_model.py +20 -1
- kash/web_content/dir_store.py +1 -2
- kash/web_content/file_cache_utils.py +37 -10
- kash/web_content/file_processing.py +68 -0
- kash/web_content/local_file_cache.py +12 -9
- kash/web_content/web_extract.py +8 -3
- kash/web_content/web_fetch.py +12 -4
- kash/web_gen/__init__.py +0 -4
- kash/web_gen/simple_webpage.py +52 -0
- kash/web_gen/tabbed_webpage.py +24 -14
- kash/web_gen/template_render.py +37 -2
- kash/web_gen/templates/base_styles.css.jinja +169 -43
- kash/web_gen/templates/base_webpage.html.jinja +110 -45
- kash/web_gen/templates/content_styles.css.jinja +4 -2
- kash/web_gen/templates/item_view.html.jinja +49 -39
- kash/web_gen/templates/simple_webpage.html.jinja +24 -0
- kash/web_gen/templates/tabbed_webpage.html.jinja +42 -33
- kash/workspaces/__init__.py +15 -2
- kash/workspaces/selections.py +18 -3
- kash/workspaces/source_items.py +0 -1
- kash/workspaces/workspaces.py +5 -11
- kash/xonsh_custom/command_nl_utils.py +40 -19
- kash/xonsh_custom/custom_shell.py +43 -11
- kash/xonsh_custom/customize_prompt.py +39 -21
- kash/xonsh_custom/load_into_xonsh.py +22 -25
- kash/xonsh_custom/shell_load_commands.py +2 -2
- kash/xonsh_custom/xonsh_completers.py +2 -249
- kash/xonsh_custom/xonsh_keybindings.py +282 -0
- kash/xonsh_custom/xonsh_modern_tools.py +3 -3
- kash/xontrib/kash_extension.py +5 -6
- {kash_shell-0.3.9.dist-info → kash_shell-0.3.11.dist-info}/METADATA +10 -8
- {kash_shell-0.3.9.dist-info → kash_shell-0.3.11.dist-info}/RECORD +137 -136
- kash/actions/core/webpage_config.py +0 -21
- kash/concepts/concept_formats.py +0 -23
- kash/shell/clideps/api_keys.py +0 -100
- kash/shell/clideps/dotenv_setup.py +0 -115
- kash/shell/clideps/dotenv_utils.py +0 -98
- kash/shell/clideps/pkg_deps.py +0 -257
- kash/shell/clideps/platforms.py +0 -11
- kash/shell/clideps/terminal_features.py +0 -56
- kash/shell/utils/osc_utils.py +0 -95
- kash/shell/utils/terminal_images.py +0 -133
- kash/text_handling/markdown_util.py +0 -167
- kash/utils/common/atomic_var.py +0 -171
- kash/utils/common/string_replace.py +0 -93
- kash/utils/common/string_template.py +0 -101
- /kash/{concepts → embeddings}/cosine.py +0 -0
- /kash/{concepts → embeddings}/embeddings.py +0 -0
- {kash_shell-0.3.9.dist-info → kash_shell-0.3.11.dist-info}/WHEEL +0 -0
- {kash_shell-0.3.9.dist-info → kash_shell-0.3.11.dist-info}/entry_points.txt +0 -0
- {kash_shell-0.3.9.dist-info → kash_shell-0.3.11.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,133 +0,0 @@
|
|
|
1
|
-
import fcntl
|
|
2
|
-
import os
|
|
3
|
-
import select
|
|
4
|
-
import shutil
|
|
5
|
-
import subprocess
|
|
6
|
-
import sys
|
|
7
|
-
import termios
|
|
8
|
-
from functools import cache
|
|
9
|
-
from pathlib import Path
|
|
10
|
-
|
|
11
|
-
from kash.config.text_styles import STYLE_HINT
|
|
12
|
-
from kash.shell.output.shell_output import cprint
|
|
13
|
-
from kash.utils.errors import SetupError
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
@cache
|
|
17
|
-
def terminal_supports_sixel() -> bool:
|
|
18
|
-
"""
|
|
19
|
-
Modern terminals that support Sixel should respond with a sequence containing '4'
|
|
20
|
-
in their list of supported features. This is more reliable than checking terminal
|
|
21
|
-
names. Some terminals (like Hyper 4+ with xterm.js 5+) might require explicit
|
|
22
|
-
configuration to enable Sixel support.
|
|
23
|
-
|
|
24
|
-
See:
|
|
25
|
-
https://vt100.net/docs/vt510-rm/DA1.html
|
|
26
|
-
https://www.arewesixelyet.com/
|
|
27
|
-
"""
|
|
28
|
-
# If not connected to a real terminal the answer is no (and in fact
|
|
29
|
-
# the test below may throw UnsupportedOperation).
|
|
30
|
-
if not (sys.stdin.isatty() and sys.stdout.isatty()):
|
|
31
|
-
return False
|
|
32
|
-
|
|
33
|
-
# Save the current terminal settings.
|
|
34
|
-
fd = sys.stdin.fileno()
|
|
35
|
-
old_settings = termios.tcgetattr(fd)
|
|
36
|
-
new_settings = termios.tcgetattr(fd)
|
|
37
|
-
new_settings[3] &= ~(termios.ICANON | termios.ECHO)
|
|
38
|
-
termios.tcsetattr(fd, termios.TCSANOW, new_settings)
|
|
39
|
-
|
|
40
|
-
# Set stdin to non-blocking.
|
|
41
|
-
old_flags = fcntl.fcntl(fd, fcntl.F_GETFL)
|
|
42
|
-
fcntl.fcntl(fd, fcntl.F_SETFL, old_flags | os.O_NONBLOCK)
|
|
43
|
-
|
|
44
|
-
try:
|
|
45
|
-
# Send the DA control sequence.
|
|
46
|
-
sys.stdout.write("\x1b[c")
|
|
47
|
-
sys.stdout.flush()
|
|
48
|
-
|
|
49
|
-
# Wait for the response.
|
|
50
|
-
ready, _, _ = select.select([fd], [], [], 1)
|
|
51
|
-
if ready:
|
|
52
|
-
response = os.read(fd, 1024).decode()
|
|
53
|
-
# Check if the response indicates SIXEL support.
|
|
54
|
-
return "4" in response
|
|
55
|
-
else:
|
|
56
|
-
return False
|
|
57
|
-
finally:
|
|
58
|
-
# Restore the terminal settings.
|
|
59
|
-
termios.tcsetattr(fd, termios.TCSANOW, old_settings)
|
|
60
|
-
fcntl.fcntl(fd, fcntl.F_SETFL, old_flags)
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
# Direct detection method. Shouldn't be needed as better method above seems to work.
|
|
64
|
-
# @cache
|
|
65
|
-
# def terminal_supports_sixel() -> bool:
|
|
66
|
-
# term = os.environ.get("TERM", "")
|
|
67
|
-
# term_program = os.environ.get("TERM_PROGRAM", "")
|
|
68
|
-
# supported_terms = [
|
|
69
|
-
# "xterm",
|
|
70
|
-
# "xterm-256color",
|
|
71
|
-
# "screen.xterm-256color",
|
|
72
|
-
# "kitty",
|
|
73
|
-
# "iTerm.app",
|
|
74
|
-
# "wezterm",
|
|
75
|
-
# "foot",
|
|
76
|
-
# "mlterm",
|
|
77
|
-
# ]
|
|
78
|
-
# term_supports = any(supported_term in term for supported_term in supported_terms)
|
|
79
|
-
# # Old Hyper 3 does not support Sixel, new Hyper 4 does.
|
|
80
|
-
# term_program_supports = term_program not in ["Hyper"]
|
|
81
|
-
# return term_supports and term_program_supports
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
@cache
|
|
85
|
-
def terminal_is_kitty() -> bool:
|
|
86
|
-
return os.environ.get("TERM") == "xterm-kitty"
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
def _terminal_show_image_sixel(image_path: str | Path, width: int = 600, height: int = 400) -> None:
|
|
90
|
-
if shutil.which("magick") is None:
|
|
91
|
-
raise SetupError("ImageMagick `magick` not found in path; check it is installed?")
|
|
92
|
-
|
|
93
|
-
try:
|
|
94
|
-
cmd = [
|
|
95
|
-
"magick",
|
|
96
|
-
str(image_path),
|
|
97
|
-
"-depth",
|
|
98
|
-
"8",
|
|
99
|
-
"-resize",
|
|
100
|
-
f"{width}x{height}",
|
|
101
|
-
"sixel:-",
|
|
102
|
-
]
|
|
103
|
-
subprocess.run(cmd, check=True)
|
|
104
|
-
except subprocess.CalledProcessError as e:
|
|
105
|
-
raise SetupError(f"Failed to display image: {e}")
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
def _terminal_show_image_kitty(filename: str | Path):
|
|
109
|
-
filename = str(filename)
|
|
110
|
-
try:
|
|
111
|
-
subprocess.run(["kitty", "+kitten", "icat", filename])
|
|
112
|
-
except subprocess.CalledProcessError as e:
|
|
113
|
-
raise SetupError(f"Failed to display image with kitty: {e}")
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
def terminal_show_image(filename: str | Path):
|
|
117
|
-
"""
|
|
118
|
-
Try to display an image in the terminal, using kitty or sixel.
|
|
119
|
-
Raise `SetupError` if not supported.
|
|
120
|
-
"""
|
|
121
|
-
if terminal_is_kitty():
|
|
122
|
-
_terminal_show_image_kitty(filename)
|
|
123
|
-
elif terminal_supports_sixel():
|
|
124
|
-
_terminal_show_image_sixel(filename)
|
|
125
|
-
else:
|
|
126
|
-
raise SetupError("Image display in this terminal doesn't seem to be supported")
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
def terminal_show_image_graceful(filename: str | Path):
|
|
130
|
-
try:
|
|
131
|
-
terminal_show_image(filename)
|
|
132
|
-
except SetupError:
|
|
133
|
-
cprint(f"[Image: {filename}]", style=STYLE_HINT)
|
|
@@ -1,167 +0,0 @@
|
|
|
1
|
-
from textwrap import dedent
|
|
2
|
-
from typing import Any
|
|
3
|
-
|
|
4
|
-
import marko
|
|
5
|
-
import regex
|
|
6
|
-
from marko.block import Heading, HTMLBlock, ListItem
|
|
7
|
-
from marko.inline import Link
|
|
8
|
-
|
|
9
|
-
from kash.config.logger import get_logger
|
|
10
|
-
|
|
11
|
-
log = get_logger(__name__)
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
def as_bullet_points(values: list[str]) -> str:
|
|
15
|
-
"""
|
|
16
|
-
Convert a list of strings to a Markdown bullet-point list.
|
|
17
|
-
"""
|
|
18
|
-
return "\n\n".join([f"- {point}" for point in values])
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
class CustomHTMLRenderer(marko.HTMLRenderer):
|
|
22
|
-
"""
|
|
23
|
-
When wrapping paragraphs as divs in Markdown we usually want them to be paragraphs.
|
|
24
|
-
This handles that.
|
|
25
|
-
"""
|
|
26
|
-
|
|
27
|
-
div_pattern = regex.compile(r"^\s*<div\b", regex.IGNORECASE)
|
|
28
|
-
|
|
29
|
-
def render_html_block(self, element: HTMLBlock) -> str:
|
|
30
|
-
if self.div_pattern.match(element.body.strip()):
|
|
31
|
-
return f"\n{element.body.strip()}\n"
|
|
32
|
-
else:
|
|
33
|
-
return element.body
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
standard_markdown = marko.Markdown()
|
|
37
|
-
|
|
38
|
-
custom_markdown = marko.Markdown(renderer=CustomHTMLRenderer)
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
def markdown_to_html(markdown: str, converter: marko.Markdown = custom_markdown) -> str:
|
|
42
|
-
"""
|
|
43
|
-
Convert Markdown to HTML. Markdown may contain embedded HTML.
|
|
44
|
-
"""
|
|
45
|
-
return converter.convert(markdown)
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def is_markdown_header(markdown: str) -> bool:
|
|
49
|
-
"""
|
|
50
|
-
Is the start of this content a Markdown header?
|
|
51
|
-
"""
|
|
52
|
-
return regex.match(r"^#+ ", markdown) is not None
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
def _tree_links(element, include_internal=False):
|
|
56
|
-
links = []
|
|
57
|
-
|
|
58
|
-
def _find_links(element):
|
|
59
|
-
match element:
|
|
60
|
-
case Link():
|
|
61
|
-
if include_internal or not element.dest.startswith("#"):
|
|
62
|
-
links.append(element.dest)
|
|
63
|
-
case _:
|
|
64
|
-
if hasattr(element, "children"):
|
|
65
|
-
for child in element.children:
|
|
66
|
-
_find_links(child)
|
|
67
|
-
|
|
68
|
-
_find_links(element)
|
|
69
|
-
return links
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
def extract_links(file_path: str, include_internal=False) -> list[str]:
|
|
73
|
-
"""
|
|
74
|
-
Extract all links from a Markdown file. Future: Include textual and section context.
|
|
75
|
-
"""
|
|
76
|
-
|
|
77
|
-
with open(file_path) as file:
|
|
78
|
-
content = file.read()
|
|
79
|
-
document = marko.parse(content)
|
|
80
|
-
return _tree_links(document, include_internal)
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
def _extract_text(element: Any) -> str:
|
|
84
|
-
if isinstance(element, str):
|
|
85
|
-
return element
|
|
86
|
-
elif hasattr(element, "children"):
|
|
87
|
-
return "".join(_extract_text(child) for child in element.children)
|
|
88
|
-
else:
|
|
89
|
-
return ""
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
def _tree_bullet_points(element: marko.block.Document) -> list[str]:
|
|
93
|
-
bullet_points: list[str] = []
|
|
94
|
-
|
|
95
|
-
def _find_bullet_points(element):
|
|
96
|
-
if isinstance(element, ListItem):
|
|
97
|
-
bullet_points.append(_extract_text(element).strip())
|
|
98
|
-
elif hasattr(element, "children"):
|
|
99
|
-
for child in element.children:
|
|
100
|
-
_find_bullet_points(child)
|
|
101
|
-
|
|
102
|
-
_find_bullet_points(element)
|
|
103
|
-
return bullet_points
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
def extract_bullet_points(content: str) -> list[str]:
|
|
107
|
-
"""
|
|
108
|
-
Extract list item values from a Markdown file.
|
|
109
|
-
"""
|
|
110
|
-
|
|
111
|
-
document = marko.parse(content)
|
|
112
|
-
return _tree_bullet_points(document)
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
def _type_from_heading(heading: Heading) -> str:
|
|
116
|
-
if heading.level in [1, 2, 3, 4, 5, 6]:
|
|
117
|
-
return f"h{heading.level}"
|
|
118
|
-
else:
|
|
119
|
-
raise ValueError(f"Unsupported heading: {heading}: level {heading.level}")
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
## Tests
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
def test_markdown_to_html():
|
|
126
|
-
markdown = dedent(
|
|
127
|
-
"""
|
|
128
|
-
# Heading
|
|
129
|
-
|
|
130
|
-
This is a paragraph and a [link](https://example.com).
|
|
131
|
-
|
|
132
|
-
- Item 1
|
|
133
|
-
- Item 2
|
|
134
|
-
|
|
135
|
-
## Subheading
|
|
136
|
-
|
|
137
|
-
This is a paragraph with a <span>span</span> tag.
|
|
138
|
-
This is a paragraph with a <div>div</div> tag.
|
|
139
|
-
This is a paragraph with an <a href='https://example.com'>example link</a>.
|
|
140
|
-
|
|
141
|
-
<div class="div1">This is a div.</div>
|
|
142
|
-
|
|
143
|
-
<div class="div2">This is a second div.</div>
|
|
144
|
-
"""
|
|
145
|
-
)
|
|
146
|
-
print(markdown_to_html(markdown))
|
|
147
|
-
|
|
148
|
-
expected_html = dedent(
|
|
149
|
-
"""
|
|
150
|
-
<h1>Heading</h1>
|
|
151
|
-
<p>This is a paragraph and a <a href="https://example.com">link</a>.</p>
|
|
152
|
-
<ul>
|
|
153
|
-
<li>Item 1</li>
|
|
154
|
-
<li>Item 2</li>
|
|
155
|
-
</ul>
|
|
156
|
-
<h2>Subheading</h2>
|
|
157
|
-
<p>This is a paragraph with a <span>span</span> tag.
|
|
158
|
-
This is a paragraph with a <div>div</div> tag.
|
|
159
|
-
This is a paragraph with an <a href='https://example.com'>example link</a>.</p>
|
|
160
|
-
|
|
161
|
-
<div class="div1">This is a div.</div>
|
|
162
|
-
|
|
163
|
-
<div class="div2">This is a second div.</div>
|
|
164
|
-
"""
|
|
165
|
-
)
|
|
166
|
-
|
|
167
|
-
assert markdown_to_html(markdown).strip() == expected_html.strip()
|
kash/utils/common/atomic_var.py
DELETED
|
@@ -1,171 +0,0 @@
|
|
|
1
|
-
import copy as cpy
|
|
2
|
-
import threading
|
|
3
|
-
from collections.abc import Callable
|
|
4
|
-
from contextlib import contextmanager
|
|
5
|
-
from typing import Any, Generic, TypeVar
|
|
6
|
-
|
|
7
|
-
T = TypeVar("T")
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
def value_is_immutable(obj: Any) -> bool:
|
|
11
|
-
"""
|
|
12
|
-
Check if a value is of a known immutable type. Just a heuristic for common
|
|
13
|
-
cases and not perfect.
|
|
14
|
-
"""
|
|
15
|
-
immutable_types = (int, float, bool, str, tuple, frozenset, type(None), bytes, complex)
|
|
16
|
-
if isinstance(obj, immutable_types):
|
|
17
|
-
return True
|
|
18
|
-
if hasattr(obj, "__dataclass_params__") and getattr(obj.__dataclass_params__, "frozen", False):
|
|
19
|
-
return True
|
|
20
|
-
return False
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
class AtomicVar(Generic[T]):
|
|
24
|
-
"""
|
|
25
|
-
`AtomicVar` is a simple zero-dependency thread-safe variable that works
|
|
26
|
-
for any type.
|
|
27
|
-
|
|
28
|
-
Often the standard "Pythonic" approach is to use locks directly, but for
|
|
29
|
-
some common use cases, `AtomicVar` may be simpler and more readable.
|
|
30
|
-
Works on any type, including lists and dicts.
|
|
31
|
-
|
|
32
|
-
Other options include `threading.Event` (for shared booleans),
|
|
33
|
-
`threading.Queue` (for producer-consumer queues), and `multiprocessing.Value`
|
|
34
|
-
(for process-safe primitives).
|
|
35
|
-
|
|
36
|
-
Examples:
|
|
37
|
-
|
|
38
|
-
```python
|
|
39
|
-
# Immutable types are always safe:
|
|
40
|
-
count = AtomicVar(0)
|
|
41
|
-
count.update(lambda x: x + 5) # In any thread.
|
|
42
|
-
count.set(0) # In any thread.
|
|
43
|
-
current_count = count.value # In any thread.
|
|
44
|
-
|
|
45
|
-
# Useful for flags:
|
|
46
|
-
global_flag = AtomicVar(False)
|
|
47
|
-
global_flag.set(True) # In any thread.
|
|
48
|
-
if global_flag: # In any thread.
|
|
49
|
-
print("Flag is set")
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
# For mutable types,consider using `copy` or `deepcopy` to access the value:
|
|
53
|
-
my_list = AtomicVar([1, 2, 3])
|
|
54
|
-
my_list_copy = my_list.copy() # In any thread.
|
|
55
|
-
my_list_deepcopy = my_list.deepcopy() # In any thread.
|
|
56
|
-
|
|
57
|
-
# For mutable types, the `updates()` context manager gives a simple way to
|
|
58
|
-
# lock on updates:
|
|
59
|
-
with my_list.updates() as value:
|
|
60
|
-
value.append(5)
|
|
61
|
-
|
|
62
|
-
# Or if you prefer, via a function:
|
|
63
|
-
my_list.update(lambda x: x.append(4)) # In any thread.
|
|
64
|
-
|
|
65
|
-
# You can also use the var's lock directly. In particular, this encapsulates
|
|
66
|
-
# locked one-time initialization:
|
|
67
|
-
initialized = AtomicVar(False)
|
|
68
|
-
with initialized.lock:
|
|
69
|
-
if not initialized: # checks truthiness of underlying value
|
|
70
|
-
expensive_setup()
|
|
71
|
-
initialized.set(True)
|
|
72
|
-
|
|
73
|
-
# Or:
|
|
74
|
-
lazy_var: AtomicVar[list[str] | None] = AtomicVar(None)
|
|
75
|
-
with lazy_var.lock:
|
|
76
|
-
if not lazy_var:
|
|
77
|
-
lazy_var.set(expensive_calculation())
|
|
78
|
-
```
|
|
79
|
-
"""
|
|
80
|
-
|
|
81
|
-
def __init__(self, initial_value: T, is_immutable: bool | None = None):
|
|
82
|
-
self._value: T = initial_value
|
|
83
|
-
# Use an RLock just in case we read from the var while in an update().
|
|
84
|
-
self.lock = threading.RLock()
|
|
85
|
-
if is_immutable is None:
|
|
86
|
-
self.is_immutable = value_is_immutable(initial_value)
|
|
87
|
-
else:
|
|
88
|
-
self.is_immutable = is_immutable
|
|
89
|
-
|
|
90
|
-
@property
|
|
91
|
-
def value(self) -> T:
|
|
92
|
-
"""
|
|
93
|
-
Current value. For immutable types, this is thread safe. For mutable types,
|
|
94
|
-
this gives direct access to the value, so you should consider using `copy` or
|
|
95
|
-
`deepcopy` instead.
|
|
96
|
-
"""
|
|
97
|
-
with self.lock:
|
|
98
|
-
return self._value
|
|
99
|
-
|
|
100
|
-
def copy(self) -> T:
|
|
101
|
-
"""
|
|
102
|
-
Shallow copy of the current value.
|
|
103
|
-
"""
|
|
104
|
-
with self.lock:
|
|
105
|
-
return cpy.copy(self._value)
|
|
106
|
-
|
|
107
|
-
def deepcopy(self) -> T:
|
|
108
|
-
"""
|
|
109
|
-
Deep copy of the current value.
|
|
110
|
-
"""
|
|
111
|
-
with self.lock:
|
|
112
|
-
return cpy.deepcopy(self._value)
|
|
113
|
-
|
|
114
|
-
def set(self, new_value: T) -> None:
|
|
115
|
-
with self.lock:
|
|
116
|
-
self._value = new_value
|
|
117
|
-
|
|
118
|
-
def swap(self, new_value: T) -> T:
|
|
119
|
-
"""
|
|
120
|
-
Set to new value and return the old value.
|
|
121
|
-
"""
|
|
122
|
-
with self.lock:
|
|
123
|
-
old_value = self._value
|
|
124
|
-
self._value = new_value
|
|
125
|
-
return old_value
|
|
126
|
-
|
|
127
|
-
def update(self, update_func: Callable[[T], T | None]) -> T:
|
|
128
|
-
"""
|
|
129
|
-
Update value with a function and return the new value.
|
|
130
|
-
|
|
131
|
-
The `update_func` can either return a new value or update a mutable type in place,
|
|
132
|
-
in which case it should return None. Always returns the final value of the
|
|
133
|
-
variable after the update.
|
|
134
|
-
"""
|
|
135
|
-
with self.lock:
|
|
136
|
-
result = update_func(self._value)
|
|
137
|
-
if result is not None:
|
|
138
|
-
self._value = result
|
|
139
|
-
# Always return the potentially updated self._value
|
|
140
|
-
return self._value
|
|
141
|
-
|
|
142
|
-
@contextmanager
|
|
143
|
-
def updates(self):
|
|
144
|
-
"""
|
|
145
|
-
Context manager for convenient thread-safe updates. Only applicable to
|
|
146
|
-
mutable types.
|
|
147
|
-
|
|
148
|
-
Example usage:
|
|
149
|
-
```
|
|
150
|
-
my_list = AtomicVar([1, 2, 3])
|
|
151
|
-
with my_list.updates() as value:
|
|
152
|
-
value.append(4)
|
|
153
|
-
```
|
|
154
|
-
"""
|
|
155
|
-
# Sanity check to avoid accidental use with atomic/immutable types.
|
|
156
|
-
if self.is_immutable:
|
|
157
|
-
raise ValueError("Cannot use AtomicVar.updates() context manager on an immutable value")
|
|
158
|
-
with self.lock:
|
|
159
|
-
yield self._value
|
|
160
|
-
|
|
161
|
-
def __bool__(self) -> bool:
|
|
162
|
-
"""
|
|
163
|
-
Truthiness matches that of the underlying value.
|
|
164
|
-
"""
|
|
165
|
-
return bool(self.value)
|
|
166
|
-
|
|
167
|
-
def __repr__(self) -> str:
|
|
168
|
-
return f"{self.__class__.__name__}({self.value!r})"
|
|
169
|
-
|
|
170
|
-
def __str__(self) -> str:
|
|
171
|
-
return str(self.value)
|
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
from typing import TypeAlias
|
|
2
|
-
|
|
3
|
-
Insertion = tuple[int, str]
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
def insert_multiple(text: str, insertions: list[Insertion]) -> str:
|
|
7
|
-
"""
|
|
8
|
-
Insert multiple strings into `text` at the given offsets, at once.
|
|
9
|
-
"""
|
|
10
|
-
chunks = []
|
|
11
|
-
last_end = 0
|
|
12
|
-
for offset, insertion in sorted(insertions, key=lambda x: x[0]):
|
|
13
|
-
chunks.append(text[last_end:offset])
|
|
14
|
-
chunks.append(insertion)
|
|
15
|
-
last_end = offset
|
|
16
|
-
chunks.append(text[last_end:])
|
|
17
|
-
return "".join(chunks)
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
Replacement: TypeAlias = tuple[int, int, str]
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def replace_multiple(text: str, replacements: list[Replacement]) -> str:
|
|
24
|
-
"""
|
|
25
|
-
Replace multiple substrings in `text` with new strings, simultaneously.
|
|
26
|
-
The replacements are a list of tuples (start_offset, end_offset, new_string).
|
|
27
|
-
"""
|
|
28
|
-
replacements = sorted(replacements, key=lambda x: x[0])
|
|
29
|
-
chunks = []
|
|
30
|
-
last_end = 0
|
|
31
|
-
for start, end, new_text in replacements:
|
|
32
|
-
if start < last_end:
|
|
33
|
-
raise ValueError("Overlapping replacements are not allowed.")
|
|
34
|
-
chunks.append(text[last_end:start])
|
|
35
|
-
chunks.append(new_text)
|
|
36
|
-
last_end = end
|
|
37
|
-
chunks.append(text[last_end:])
|
|
38
|
-
return "".join(chunks)
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
## Tests
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def test_insert_multiple():
|
|
45
|
-
text = "hello world"
|
|
46
|
-
insertions = [(5, ",")]
|
|
47
|
-
expected = "hello, world"
|
|
48
|
-
assert insert_multiple(text, insertions) == expected, "Single insertion failed"
|
|
49
|
-
|
|
50
|
-
text = "hello world"
|
|
51
|
-
insertions = [(0, "Start "), (11, " End")]
|
|
52
|
-
expected = "Start hello world End"
|
|
53
|
-
assert insert_multiple(text, insertions) == expected, "Multiple insertions failed"
|
|
54
|
-
|
|
55
|
-
text = "short"
|
|
56
|
-
insertions = [(10, " end")]
|
|
57
|
-
expected = "short end"
|
|
58
|
-
assert insert_multiple(text, insertions) == expected, "Out of bounds insertion failed"
|
|
59
|
-
|
|
60
|
-
text = "negative test"
|
|
61
|
-
insertions = [(-1, "ss")]
|
|
62
|
-
expected = "negative tessst"
|
|
63
|
-
assert insert_multiple(text, insertions) == expected, "Negative offset insertion failed"
|
|
64
|
-
|
|
65
|
-
text = "no change"
|
|
66
|
-
insertions = []
|
|
67
|
-
expected = "no change"
|
|
68
|
-
assert insert_multiple(text, insertions) == expected, "Empty insertions failed"
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
def test_replace_multiple():
|
|
72
|
-
text = "The quick brown fox"
|
|
73
|
-
replacements = [(4, 9, "slow"), (16, 19, "dog")]
|
|
74
|
-
expected = "The slow brown dog"
|
|
75
|
-
assert replace_multiple(text, replacements) == expected, "Multiple replacements failed"
|
|
76
|
-
|
|
77
|
-
text = "overlap test"
|
|
78
|
-
replacements = [(0, 6, "start"), (5, 10, "end")]
|
|
79
|
-
try:
|
|
80
|
-
replace_multiple(text, replacements)
|
|
81
|
-
raise AssertionError("Overlapping replacements did not raise ValueError")
|
|
82
|
-
except ValueError:
|
|
83
|
-
pass # Expected exception
|
|
84
|
-
|
|
85
|
-
text = "short text"
|
|
86
|
-
replacements = [(5, 10, " longer text")]
|
|
87
|
-
expected = "short longer text"
|
|
88
|
-
assert replace_multiple(text, replacements) == expected, "Out of bounds replacement failed"
|
|
89
|
-
|
|
90
|
-
text = "no change"
|
|
91
|
-
replacements = []
|
|
92
|
-
expected = "no change"
|
|
93
|
-
assert replace_multiple(text, replacements) == expected, "Empty replacements failed"
|
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
from collections.abc import Sequence
|
|
2
|
-
from dataclasses import dataclass
|
|
3
|
-
from typing import Any
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
@dataclass(frozen=True)
|
|
7
|
-
class StringTemplate:
|
|
8
|
-
"""
|
|
9
|
-
A validated template string that supports only specified fields.
|
|
10
|
-
Can subclass to have a type with a given set of `allowed_fields`.
|
|
11
|
-
Provide a type with a field name to allow validation of int/float format strings.
|
|
12
|
-
|
|
13
|
-
Examples:
|
|
14
|
-
>>> t = StringTemplate("{name} is {age} years old", ["name", "age"])
|
|
15
|
-
>>> t.format(name="Alice", age=30)
|
|
16
|
-
'Alice is 30 years old'
|
|
17
|
-
|
|
18
|
-
>>> t = StringTemplate("{count:3d}@{price:.2f}", [("count", int), ("price", float)])
|
|
19
|
-
>>> t.format(count=10, price=19.99)
|
|
20
|
-
' 10@19.99'
|
|
21
|
-
"""
|
|
22
|
-
|
|
23
|
-
template: str
|
|
24
|
-
|
|
25
|
-
allowed_fields: Sequence[str | tuple[str, type | None]]
|
|
26
|
-
"""List of allowed field names. If `d` or `f` formats are used, give tuple with the type."""
|
|
27
|
-
# Sequence is covariant so compatible with List[str]
|
|
28
|
-
|
|
29
|
-
strict: bool = False
|
|
30
|
-
"""If True, raise a ValueError if the template is missing an allowed field."""
|
|
31
|
-
|
|
32
|
-
def __post_init__(self):
|
|
33
|
-
if not isinstance(self.template, str):
|
|
34
|
-
raise ValueError("Template must be a string")
|
|
35
|
-
|
|
36
|
-
# Confirm only the allowed fields are in the template.
|
|
37
|
-
field_types = self._field_types()
|
|
38
|
-
try:
|
|
39
|
-
placeholder_values = {field: (type or str)(123) for field, type in field_types.items()}
|
|
40
|
-
self.template.format(**placeholder_values)
|
|
41
|
-
except KeyError as e:
|
|
42
|
-
raise ValueError(f"Template contains unsupported variable: {e}")
|
|
43
|
-
except ValueError as e:
|
|
44
|
-
raise ValueError(
|
|
45
|
-
f"Invalid template (forgot to provide a type when using non-str format strings?): {e}"
|
|
46
|
-
)
|
|
47
|
-
|
|
48
|
-
def _field_types(self) -> dict[str, type | None]:
|
|
49
|
-
return {
|
|
50
|
-
field[0] if isinstance(field, tuple) else field: (
|
|
51
|
-
field[1] if isinstance(field, tuple) else None
|
|
52
|
-
)
|
|
53
|
-
for field in self.allowed_fields
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
def format(self, **kwargs: Any) -> str:
|
|
57
|
-
field_types = self._field_types()
|
|
58
|
-
allowed_keys = field_types.keys()
|
|
59
|
-
unexpected_keys = set(kwargs.keys()) - allowed_keys
|
|
60
|
-
if self.strict and unexpected_keys:
|
|
61
|
-
raise ValueError(f"Unexpected keyword arguments: {', '.join(unexpected_keys)}")
|
|
62
|
-
|
|
63
|
-
# Type check the values, if types were provided.
|
|
64
|
-
for f, expected_type in field_types.items():
|
|
65
|
-
if f in kwargs and expected_type:
|
|
66
|
-
if not isinstance(kwargs[f], expected_type):
|
|
67
|
-
raise ValueError(
|
|
68
|
-
f"Invalid type for '{f}': expected {expected_type.__name__} but got {repr(kwargs[f])} ({type(kwargs[f]).__name__})"
|
|
69
|
-
)
|
|
70
|
-
|
|
71
|
-
return self.template.format(**kwargs)
|
|
72
|
-
|
|
73
|
-
def __bool__(self) -> bool:
|
|
74
|
-
return bool(self.template)
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
## Tests
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
def test_string_template():
|
|
81
|
-
t = StringTemplate("{name} is {age} years old", ["name", "age"])
|
|
82
|
-
assert t.format(name="Alice", age=30) == "Alice is 30 years old"
|
|
83
|
-
|
|
84
|
-
t = StringTemplate("{count:3d}@{price:.2f}", [("count", int), ("price", float)])
|
|
85
|
-
assert t.format(count=10, price=19.99) == " 10@19.99"
|
|
86
|
-
|
|
87
|
-
try:
|
|
88
|
-
StringTemplate("{name} {age}", ["name"])
|
|
89
|
-
raise AssertionError("Should have raised ValueError")
|
|
90
|
-
except ValueError as e:
|
|
91
|
-
assert "Template contains unsupported variable: 'age'" in str(e)
|
|
92
|
-
|
|
93
|
-
t = StringTemplate("{count:d}", [("count", int)])
|
|
94
|
-
try:
|
|
95
|
-
t.format(count="not an int")
|
|
96
|
-
raise AssertionError("Should have raised ValueError")
|
|
97
|
-
except ValueError as e:
|
|
98
|
-
assert "Invalid type for 'count': expected int but got 'not an int' (str)" in str(e)
|
|
99
|
-
|
|
100
|
-
assert not StringTemplate("", [])
|
|
101
|
-
assert StringTemplate("not empty", [])
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|