kash-shell 0.3.8__py3-none-any.whl → 0.3.10__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 (154) hide show
  1. kash/actions/__init__.py +4 -4
  2. kash/actions/core/markdownify.py +5 -2
  3. kash/actions/core/readability.py +5 -2
  4. kash/actions/core/render_as_html.py +18 -0
  5. kash/actions/core/webpage_config.py +12 -4
  6. kash/commands/__init__.py +8 -20
  7. kash/commands/base/basic_file_commands.py +15 -0
  8. kash/commands/base/debug_commands.py +15 -2
  9. kash/commands/base/general_commands.py +27 -18
  10. kash/commands/base/logs_commands.py +1 -4
  11. kash/commands/base/model_commands.py +8 -8
  12. kash/commands/base/search_command.py +3 -2
  13. kash/commands/base/show_command.py +5 -3
  14. kash/commands/extras/parse_uv_lock.py +186 -0
  15. kash/commands/help/doc_commands.py +2 -31
  16. kash/commands/help/welcome.py +33 -0
  17. kash/commands/workspace/selection_commands.py +11 -6
  18. kash/commands/workspace/workspace_commands.py +19 -16
  19. kash/config/colors.py +2 -0
  20. kash/config/env_settings.py +72 -0
  21. kash/config/init.py +2 -2
  22. kash/config/logger.py +61 -59
  23. kash/config/logger_basic.py +12 -5
  24. kash/config/server_config.py +6 -6
  25. kash/config/settings.py +117 -67
  26. kash/config/setup.py +35 -9
  27. kash/config/suppress_warnings.py +30 -12
  28. kash/config/text_styles.py +3 -13
  29. kash/docs/load_api_docs.py +2 -1
  30. kash/docs/markdown/topics/a2_installation.md +7 -3
  31. kash/docs/markdown/topics/a3_getting_started.md +3 -2
  32. kash/docs/markdown/warning.md +3 -8
  33. kash/docs/markdown/welcome.md +4 -0
  34. kash/docs_base/load_recipe_snippets.py +1 -1
  35. kash/docs_base/recipes/{general_system_commands.ksh → general_system_commands.sh} +1 -1
  36. kash/{concepts → embeddings}/cosine.py +2 -1
  37. kash/embeddings/text_similarity.py +57 -0
  38. kash/exec/__init__.py +20 -3
  39. kash/exec/action_decorators.py +18 -4
  40. kash/exec/action_exec.py +41 -23
  41. kash/exec/action_registry.py +13 -48
  42. kash/exec/command_registry.py +2 -1
  43. kash/exec/fetch_url_metadata.py +4 -6
  44. kash/exec/importing.py +56 -0
  45. kash/exec/llm_transforms.py +6 -6
  46. kash/exec/precondition_registry.py +2 -1
  47. kash/exec/preconditions.py +16 -1
  48. kash/exec/shell_callable_action.py +33 -19
  49. kash/file_storage/file_store.py +23 -14
  50. kash/file_storage/item_file_format.py +13 -3
  51. kash/file_storage/metadata_dirs.py +11 -2
  52. kash/help/assistant.py +2 -2
  53. kash/help/assistant_instructions.py +2 -1
  54. kash/help/help_embeddings.py +2 -2
  55. kash/help/help_printing.py +14 -10
  56. kash/help/tldr_help.py +5 -3
  57. kash/llm_utils/clean_headings.py +1 -1
  58. kash/llm_utils/llm_api_keys.py +4 -4
  59. kash/llm_utils/llm_completion.py +2 -2
  60. kash/llm_utils/llm_features.py +68 -0
  61. kash/llm_utils/llm_messages.py +1 -2
  62. kash/llm_utils/llm_names.py +1 -1
  63. kash/llm_utils/llms.py +17 -12
  64. kash/local_server/__init__.py +5 -2
  65. kash/local_server/local_server.py +56 -46
  66. kash/local_server/local_server_commands.py +15 -15
  67. kash/local_server/local_server_routes.py +2 -2
  68. kash/local_server/local_url_formatters.py +1 -1
  69. kash/mcp/__init__.py +5 -2
  70. kash/mcp/mcp_cli.py +54 -17
  71. kash/mcp/mcp_server_commands.py +5 -6
  72. kash/mcp/mcp_server_routes.py +14 -11
  73. kash/mcp/mcp_server_sse.py +61 -34
  74. kash/mcp/mcp_server_stdio.py +0 -8
  75. kash/media_base/audio_processing.py +81 -7
  76. kash/media_base/media_cache.py +18 -18
  77. kash/media_base/media_services.py +1 -1
  78. kash/media_base/media_tools.py +6 -6
  79. kash/media_base/services/local_file_media.py +2 -2
  80. kash/media_base/{speech_transcription.py → transcription_deepgram.py} +25 -109
  81. kash/media_base/transcription_format.py +73 -0
  82. kash/media_base/transcription_whisper.py +38 -0
  83. kash/model/__init__.py +73 -5
  84. kash/model/actions_model.py +38 -4
  85. kash/model/concept_model.py +30 -0
  86. kash/model/items_model.py +56 -13
  87. kash/model/params_model.py +24 -0
  88. kash/shell/completions/completion_scoring.py +37 -5
  89. kash/shell/output/kerm_codes.py +1 -2
  90. kash/shell/output/shell_formatting.py +14 -4
  91. kash/shell/shell_main.py +2 -2
  92. kash/shell/utils/exception_printing.py +6 -0
  93. kash/shell/utils/native_utils.py +26 -20
  94. kash/text_handling/custom_sliding_transforms.py +12 -4
  95. kash/text_handling/doc_normalization.py +6 -2
  96. kash/text_handling/markdown_render.py +117 -0
  97. kash/text_handling/markdown_utils.py +204 -0
  98. kash/utils/common/import_utils.py +12 -3
  99. kash/utils/common/type_utils.py +0 -29
  100. kash/utils/common/url.py +80 -28
  101. kash/utils/errors.py +6 -0
  102. kash/utils/file_utils/{dir_size.py → dir_info.py} +25 -4
  103. kash/utils/file_utils/file_ext.py +2 -3
  104. kash/utils/file_utils/file_formats.py +28 -2
  105. kash/utils/file_utils/file_formats_model.py +50 -19
  106. kash/utils/file_utils/filename_parsing.py +10 -4
  107. kash/web_content/dir_store.py +1 -2
  108. kash/web_content/file_cache_utils.py +37 -10
  109. kash/web_content/file_processing.py +68 -0
  110. kash/web_content/local_file_cache.py +12 -9
  111. kash/web_content/web_extract.py +8 -3
  112. kash/web_content/web_fetch.py +12 -4
  113. kash/web_gen/tabbed_webpage.py +5 -2
  114. kash/web_gen/templates/base_styles.css.jinja +120 -14
  115. kash/web_gen/templates/base_webpage.html.jinja +60 -13
  116. kash/web_gen/templates/content_styles.css.jinja +4 -2
  117. kash/web_gen/templates/item_view.html.jinja +2 -2
  118. kash/web_gen/templates/tabbed_webpage.html.jinja +1 -2
  119. kash/workspaces/__init__.py +15 -2
  120. kash/workspaces/selections.py +18 -3
  121. kash/workspaces/source_items.py +4 -2
  122. kash/workspaces/workspace_output.py +11 -4
  123. kash/workspaces/workspaces.py +5 -11
  124. kash/xonsh_custom/command_nl_utils.py +40 -19
  125. kash/xonsh_custom/custom_shell.py +44 -12
  126. kash/xonsh_custom/customize_prompt.py +39 -21
  127. kash/xonsh_custom/load_into_xonsh.py +26 -27
  128. kash/xonsh_custom/shell_load_commands.py +2 -2
  129. kash/xonsh_custom/xonsh_completers.py +2 -249
  130. kash/xonsh_custom/xonsh_keybindings.py +282 -0
  131. kash/xonsh_custom/xonsh_modern_tools.py +3 -3
  132. kash/xontrib/kash_extension.py +5 -6
  133. {kash_shell-0.3.8.dist-info → kash_shell-0.3.10.dist-info}/METADATA +26 -12
  134. {kash_shell-0.3.8.dist-info → kash_shell-0.3.10.dist-info}/RECORD +140 -140
  135. {kash_shell-0.3.8.dist-info → kash_shell-0.3.10.dist-info}/entry_points.txt +1 -1
  136. kash/concepts/concept_formats.py +0 -23
  137. kash/concepts/text_similarity.py +0 -112
  138. kash/shell/clideps/api_keys.py +0 -99
  139. kash/shell/clideps/dotenv_setup.py +0 -114
  140. kash/shell/clideps/dotenv_utils.py +0 -89
  141. kash/shell/clideps/pkg_deps.py +0 -232
  142. kash/shell/clideps/platforms.py +0 -11
  143. kash/shell/clideps/terminal_features.py +0 -56
  144. kash/shell/utils/osc_utils.py +0 -95
  145. kash/shell/utils/terminal_images.py +0 -133
  146. kash/text_handling/markdown_util.py +0 -167
  147. kash/utils/common/atomic_var.py +0 -158
  148. kash/utils/common/string_replace.py +0 -93
  149. kash/utils/common/string_template.py +0 -101
  150. /kash/docs_base/recipes/{python_dev_commands.ksh → python_dev_commands.sh} +0 -0
  151. /kash/docs_base/recipes/{tldr_standard_commands.ksh → tldr_standard_commands.sh} +0 -0
  152. /kash/{concepts → embeddings}/embeddings.py +0 -0
  153. {kash_shell-0.3.8.dist-info → kash_shell-0.3.10.dist-info}/WHEEL +0 -0
  154. {kash_shell-0.3.8.dist-info → kash_shell-0.3.10.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()
@@ -1,158 +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
-
31
- Other options include `threading.Event` (for shared booleans),
32
- `threading.Queue` (for producer-consumer queues), and `multiprocessing.Value`
33
- (for process-safe primitives).
34
-
35
- Examples:
36
-
37
- ```python
38
- # Immutable types are always safe:
39
- count = AtomicVar(0)
40
- count.update(lambda x: x + 5) # In any thread.
41
- count.set(0) # In any thread.
42
- current_count = count.value # In any thread.
43
-
44
- # Useful for flags:
45
- global_flag = AtomicVar(False)
46
- global_flag.set(True) # In any thread.
47
- if global_flag: # In any thread.
48
- print("Flag is set")
49
-
50
- # Works on any type, including lists and dicts.
51
-
52
- # For mutable types,consider using `copy` or `deepcopy` to access the value:
53
- my_list = AtomicVar([1, 2, 3])
54
-
55
- my_list_copy = my_list.copy() # In any thread.
56
- my_list_deepcopy = my_list.deepcopy() # In any thread.
57
-
58
- # For mutable types, the `updates()` context manager gives a simple way to
59
- # lock on updates:
60
- with my_list.updates() as value:
61
- value.append(5)
62
-
63
- # Or if you prefer, via a function:
64
- my_list.update(lambda x: x.append(4)) # In any thread.
65
- ```
66
- """
67
-
68
- def __init__(self, initial_value: T, is_immutable: bool | None = None):
69
- self._value: T = initial_value
70
- # Use an RLock just in case we read from the var while in an update().
71
- self.lock = threading.RLock()
72
- if is_immutable is None:
73
- self.is_immutable = value_is_immutable(initial_value)
74
- else:
75
- self.is_immutable = is_immutable
76
-
77
- @property
78
- def value(self) -> T:
79
- """
80
- Current value. For immutable types, this is thread safe. For mutable types,
81
- this gives direct access to the value, so you should consider using `copy` or
82
- `deepcopy` instead.
83
- """
84
- with self.lock:
85
- return self._value
86
-
87
- def copy(self) -> T:
88
- """
89
- Shallow copy of the current value.
90
- """
91
- with self.lock:
92
- return cpy.copy(self._value)
93
-
94
- def deepcopy(self) -> T:
95
- """
96
- Deep copy of the current value.
97
- """
98
- with self.lock:
99
- return cpy.deepcopy(self._value)
100
-
101
- def set(self, new_value: T) -> None:
102
- with self.lock:
103
- self._value = new_value
104
-
105
- def swap(self, new_value: T) -> T:
106
- """
107
- Set to new value and return the old value.
108
- """
109
- with self.lock:
110
- old_value = self._value
111
- self._value = new_value
112
- return old_value
113
-
114
- def update(self, update_func: Callable[[T], T | None]) -> T:
115
- """
116
- Update value with a function and return the new value.
117
-
118
- The `update_func` can either return a new value or update a mutable type in place,
119
- in which case it should return None. Always returns the final value of the
120
- variable after the update.
121
- """
122
- with self.lock:
123
- result = update_func(self._value)
124
- if result is not None:
125
- self._value = result
126
- # Always return the potentially updated self._value
127
- return self._value
128
-
129
- @contextmanager
130
- def updates(self):
131
- """
132
- Context manager for convenient thread-safe updates. Only applicable to
133
- mutable types.
134
-
135
- Usage:
136
- ```
137
- my_list = AtomicVar([1, 2, 3])
138
- with my_list.updates() as value:
139
- value.append(4)
140
- ```
141
- """
142
- # Sanity check to avoid accidental use with atomic/immutable types.
143
- if self.is_immutable:
144
- raise ValueError("Cannot use AtomicVar.updates() context manager on an immutable value")
145
- with self.lock:
146
- yield self._value
147
-
148
- def __bool__(self) -> bool:
149
- """
150
- Truthiness matches that of the underlying value.
151
- """
152
- return bool(self.value)
153
-
154
- def __repr__(self) -> str:
155
- return f"{self.__class__.__name__}({self.value!r})"
156
-
157
- def __str__(self) -> str:
158
- 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