patch-code 0.1.0__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 (153) hide show
  1. patch/__init__.py +3 -0
  2. patch/__main__.py +4 -0
  3. patch/analytics.py +268 -0
  4. patch/args.py +949 -0
  5. patch/args_formatter.py +227 -0
  6. patch/coders/__init__.py +34 -0
  7. patch/coders/architect_coder.py +48 -0
  8. patch/coders/architect_prompts.py +40 -0
  9. patch/coders/ask_coder.py +9 -0
  10. patch/coders/ask_prompts.py +41 -0
  11. patch/coders/base_coder.py +2485 -0
  12. patch/coders/base_prompts.py +60 -0
  13. patch/coders/chat_chunks.py +64 -0
  14. patch/coders/context_coder.py +53 -0
  15. patch/coders/context_prompts.py +75 -0
  16. patch/coders/editblock_coder.py +657 -0
  17. patch/coders/editblock_fenced_coder.py +10 -0
  18. patch/coders/editblock_fenced_prompts.py +143 -0
  19. patch/coders/editblock_func_coder.py +141 -0
  20. patch/coders/editblock_func_prompts.py +27 -0
  21. patch/coders/editblock_prompts.py +172 -0
  22. patch/coders/editor_diff_fenced_coder.py +9 -0
  23. patch/coders/editor_diff_fenced_prompts.py +11 -0
  24. patch/coders/editor_editblock_coder.py +8 -0
  25. patch/coders/editor_editblock_prompts.py +18 -0
  26. patch/coders/editor_whole_coder.py +8 -0
  27. patch/coders/editor_whole_prompts.py +10 -0
  28. patch/coders/help_coder.py +16 -0
  29. patch/coders/help_prompts.py +46 -0
  30. patch/coders/patch_coder.py +706 -0
  31. patch/coders/patch_prompts.py +159 -0
  32. patch/coders/search_replace.py +757 -0
  33. patch/coders/shell.py +37 -0
  34. patch/coders/single_wholefile_func_coder.py +102 -0
  35. patch/coders/single_wholefile_func_prompts.py +27 -0
  36. patch/coders/udiff_coder.py +429 -0
  37. patch/coders/udiff_prompts.py +113 -0
  38. patch/coders/udiff_simple.py +14 -0
  39. patch/coders/udiff_simple_prompts.py +25 -0
  40. patch/coders/wholefile_coder.py +144 -0
  41. patch/coders/wholefile_func_coder.py +134 -0
  42. patch/coders/wholefile_func_prompts.py +27 -0
  43. patch/coders/wholefile_prompts.py +64 -0
  44. patch/commands.py +1712 -0
  45. patch/copypaste.py +72 -0
  46. patch/deprecated.py +126 -0
  47. patch/diffs.py +128 -0
  48. patch/docs/__init__.py +1 -0
  49. patch/docs/analytics.md +28 -0
  50. patch/docs/config.md +43 -0
  51. patch/docs/git.md +22 -0
  52. patch/docs/install.md +54 -0
  53. patch/docs/models.md +59 -0
  54. patch/docs/troubleshooting.md +23 -0
  55. patch/docs/usage.md +47 -0
  56. patch/dump.py +29 -0
  57. patch/editor.py +147 -0
  58. patch/exceptions.py +113 -0
  59. patch/format_settings.py +26 -0
  60. patch/gui.py +545 -0
  61. patch/help.py +118 -0
  62. patch/history.py +143 -0
  63. patch/io.py +1191 -0
  64. patch/linter.py +304 -0
  65. patch/llm.py +47 -0
  66. patch/main.py +1274 -0
  67. patch/mdstream.py +243 -0
  68. patch/models.py +1338 -0
  69. patch/onboarding.py +428 -0
  70. patch/openrouter.py +128 -0
  71. patch/prompts.py +61 -0
  72. patch/queries/tree-sitter-language-pack/arduino-tags.scm +5 -0
  73. patch/queries/tree-sitter-language-pack/bash-tags.scm +8 -0
  74. patch/queries/tree-sitter-language-pack/c-tags.scm +9 -0
  75. patch/queries/tree-sitter-language-pack/chatito-tags.scm +16 -0
  76. patch/queries/tree-sitter-language-pack/clojure-tags.scm +7 -0
  77. patch/queries/tree-sitter-language-pack/commonlisp-tags.scm +122 -0
  78. patch/queries/tree-sitter-language-pack/cpp-tags.scm +15 -0
  79. patch/queries/tree-sitter-language-pack/csharp-tags.scm +26 -0
  80. patch/queries/tree-sitter-language-pack/d-tags.scm +26 -0
  81. patch/queries/tree-sitter-language-pack/dart-tags.scm +92 -0
  82. patch/queries/tree-sitter-language-pack/elisp-tags.scm +5 -0
  83. patch/queries/tree-sitter-language-pack/elixir-tags.scm +54 -0
  84. patch/queries/tree-sitter-language-pack/elm-tags.scm +19 -0
  85. patch/queries/tree-sitter-language-pack/gleam-tags.scm +41 -0
  86. patch/queries/tree-sitter-language-pack/go-tags.scm +42 -0
  87. patch/queries/tree-sitter-language-pack/java-tags.scm +20 -0
  88. patch/queries/tree-sitter-language-pack/javascript-tags.scm +88 -0
  89. patch/queries/tree-sitter-language-pack/lua-tags.scm +34 -0
  90. patch/queries/tree-sitter-language-pack/matlab-tags.scm +10 -0
  91. patch/queries/tree-sitter-language-pack/ocaml-tags.scm +115 -0
  92. patch/queries/tree-sitter-language-pack/ocaml_interface-tags.scm +98 -0
  93. patch/queries/tree-sitter-language-pack/pony-tags.scm +39 -0
  94. patch/queries/tree-sitter-language-pack/properties-tags.scm +5 -0
  95. patch/queries/tree-sitter-language-pack/python-tags.scm +14 -0
  96. patch/queries/tree-sitter-language-pack/r-tags.scm +21 -0
  97. patch/queries/tree-sitter-language-pack/racket-tags.scm +12 -0
  98. patch/queries/tree-sitter-language-pack/ruby-tags.scm +64 -0
  99. patch/queries/tree-sitter-language-pack/rust-tags.scm +60 -0
  100. patch/queries/tree-sitter-language-pack/solidity-tags.scm +43 -0
  101. patch/queries/tree-sitter-language-pack/swift-tags.scm +51 -0
  102. patch/queries/tree-sitter-language-pack/udev-tags.scm +20 -0
  103. patch/queries/tree-sitter-languages/bash-tags.scm +8 -0
  104. patch/queries/tree-sitter-languages/c-tags.scm +9 -0
  105. patch/queries/tree-sitter-languages/c_sharp-tags.scm +46 -0
  106. patch/queries/tree-sitter-languages/cpp-tags.scm +15 -0
  107. patch/queries/tree-sitter-languages/dart-tags.scm +91 -0
  108. patch/queries/tree-sitter-languages/elisp-tags.scm +8 -0
  109. patch/queries/tree-sitter-languages/elixir-tags.scm +54 -0
  110. patch/queries/tree-sitter-languages/elm-tags.scm +19 -0
  111. patch/queries/tree-sitter-languages/fortran-tags.scm +15 -0
  112. patch/queries/tree-sitter-languages/go-tags.scm +30 -0
  113. patch/queries/tree-sitter-languages/haskell-tags.scm +3 -0
  114. patch/queries/tree-sitter-languages/hcl-tags.scm +77 -0
  115. patch/queries/tree-sitter-languages/java-tags.scm +20 -0
  116. patch/queries/tree-sitter-languages/javascript-tags.scm +88 -0
  117. patch/queries/tree-sitter-languages/julia-tags.scm +60 -0
  118. patch/queries/tree-sitter-languages/kotlin-tags.scm +27 -0
  119. patch/queries/tree-sitter-languages/matlab-tags.scm +10 -0
  120. patch/queries/tree-sitter-languages/ocaml-tags.scm +115 -0
  121. patch/queries/tree-sitter-languages/ocaml_interface-tags.scm +98 -0
  122. patch/queries/tree-sitter-languages/php-tags.scm +26 -0
  123. patch/queries/tree-sitter-languages/python-tags.scm +12 -0
  124. patch/queries/tree-sitter-languages/ql-tags.scm +26 -0
  125. patch/queries/tree-sitter-languages/ruby-tags.scm +64 -0
  126. patch/queries/tree-sitter-languages/rust-tags.scm +60 -0
  127. patch/queries/tree-sitter-languages/scala-tags.scm +65 -0
  128. patch/queries/tree-sitter-languages/typescript-tags.scm +41 -0
  129. patch/queries/tree-sitter-languages/zig-tags.scm +3 -0
  130. patch/reasoning_tags.py +82 -0
  131. patch/repo.py +622 -0
  132. patch/repomap.py +867 -0
  133. patch/report.py +200 -0
  134. patch/resources/__init__.py +3 -0
  135. patch/resources/model-metadata.json +715 -0
  136. patch/resources/model-settings.yml +3128 -0
  137. patch/run_cmd.py +132 -0
  138. patch/scrape.py +284 -0
  139. patch/sendchat.py +61 -0
  140. patch/special.py +203 -0
  141. patch/urls.py +17 -0
  142. patch/utils.py +348 -0
  143. patch/versioncheck.py +130 -0
  144. patch/voice.py +187 -0
  145. patch/waiting.py +221 -0
  146. patch/watch.py +318 -0
  147. patch/watch_prompts.py +12 -0
  148. patch_code-0.1.0.dist-info/METADATA +467 -0
  149. patch_code-0.1.0.dist-info/RECORD +153 -0
  150. patch_code-0.1.0.dist-info/WHEEL +5 -0
  151. patch_code-0.1.0.dist-info/entry_points.txt +2 -0
  152. patch_code-0.1.0.dist-info/licenses/LICENSE.txt +202 -0
  153. patch_code-0.1.0.dist-info/top_level.txt +1 -0
patch/utils.py ADDED
@@ -0,0 +1,348 @@
1
+ import os
2
+ import platform
3
+ import subprocess
4
+ import sys
5
+ import tempfile
6
+ from pathlib import Path
7
+
8
+ import oslex
9
+
10
+ from patch.dump import dump # noqa: F401
11
+ from patch.waiting import Spinner
12
+
13
+ IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".tiff", ".webp", ".pdf"}
14
+
15
+
16
+ class IgnorantTemporaryDirectory:
17
+ def __init__(self):
18
+ if sys.version_info >= (3, 10):
19
+ self.temp_dir = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
20
+ else:
21
+ self.temp_dir = tempfile.TemporaryDirectory()
22
+
23
+ def __enter__(self):
24
+ return self.temp_dir.__enter__()
25
+
26
+ def __exit__(self, exc_type, exc_val, exc_tb):
27
+ self.cleanup()
28
+
29
+ def cleanup(self):
30
+ try:
31
+ self.temp_dir.cleanup()
32
+ except (OSError, PermissionError, RecursionError):
33
+ pass # Ignore errors (Windows and potential recursion)
34
+
35
+ def __getattr__(self, item):
36
+ return getattr(self.temp_dir, item)
37
+
38
+
39
+ class ChdirTemporaryDirectory(IgnorantTemporaryDirectory):
40
+ def __init__(self):
41
+ try:
42
+ self.cwd = os.getcwd()
43
+ except FileNotFoundError:
44
+ self.cwd = None
45
+
46
+ super().__init__()
47
+
48
+ def __enter__(self):
49
+ res = super().__enter__()
50
+ os.chdir(Path(self.temp_dir.name).resolve())
51
+ return res
52
+
53
+ def __exit__(self, exc_type, exc_val, exc_tb):
54
+ if self.cwd:
55
+ try:
56
+ os.chdir(self.cwd)
57
+ except FileNotFoundError:
58
+ pass
59
+ super().__exit__(exc_type, exc_val, exc_tb)
60
+
61
+
62
+ class GitTemporaryDirectory(ChdirTemporaryDirectory):
63
+ def __enter__(self):
64
+ dname = super().__enter__()
65
+ self.repo = make_repo(dname)
66
+ return dname
67
+
68
+ def __exit__(self, exc_type, exc_val, exc_tb):
69
+ del self.repo
70
+ super().__exit__(exc_type, exc_val, exc_tb)
71
+
72
+
73
+ def make_repo(path=None):
74
+ import git
75
+
76
+ if not path:
77
+ path = "."
78
+ repo = git.Repo.init(path)
79
+ repo.config_writer().set_value("user", "name", "Test User").release()
80
+ repo.config_writer().set_value("user", "email", "testuser@example.com").release()
81
+
82
+ return repo
83
+
84
+
85
+ def is_image_file(file_name):
86
+ """
87
+ Check if the given file name has an image file extension.
88
+
89
+ :param file_name: The name of the file to check.
90
+ :return: True if the file is an image, False otherwise.
91
+ """
92
+ file_name = str(file_name) # Convert file_name to string
93
+ return any(file_name.endswith(ext) for ext in IMAGE_EXTENSIONS)
94
+
95
+
96
+ def safe_abs_path(res):
97
+ "Gives an abs path, which safely returns a full (not 8.3) windows path"
98
+ try:
99
+ res = Path(res).resolve()
100
+ except (RuntimeError, OSError):
101
+ res = Path(res).absolute()
102
+ return str(res)
103
+
104
+
105
+ def format_content(role, content):
106
+ formatted_lines = []
107
+ for line in content.splitlines():
108
+ formatted_lines.append(f"{role} {line}")
109
+ return "\n".join(formatted_lines)
110
+
111
+
112
+ def format_messages(messages, title=None):
113
+ output = []
114
+ if title:
115
+ output.append(f"{title.upper()} {'*' * 50}")
116
+
117
+ for msg in messages:
118
+ output.append("-------")
119
+ role = msg["role"].upper()
120
+ content = msg.get("content")
121
+ if isinstance(content, list): # Handle list content (e.g., image messages)
122
+ for item in content:
123
+ if isinstance(item, dict):
124
+ for key, value in item.items():
125
+ if isinstance(value, dict) and "url" in value:
126
+ output.append(f"{role} {key.capitalize()} URL: {value['url']}")
127
+ else:
128
+ output.append(f"{role} {key}: {value}")
129
+ else:
130
+ output.append(f"{role} {item}")
131
+ elif isinstance(content, str): # Handle string content
132
+ output.append(format_content(role, content))
133
+ function_call = msg.get("function_call")
134
+ if function_call:
135
+ output.append(f"{role} Function Call: {function_call}")
136
+
137
+ return "\n".join(output)
138
+
139
+
140
+ def show_messages(messages, title=None, functions=None):
141
+ formatted_output = format_messages(messages, title)
142
+ print(formatted_output)
143
+
144
+ if functions:
145
+ dump(functions)
146
+
147
+
148
+ def split_chat_history_markdown(text, include_tool=False):
149
+ messages = []
150
+ user = []
151
+ assistant = []
152
+ tool = []
153
+ lines = text.splitlines(keepends=True)
154
+
155
+ def append_msg(role, lines):
156
+ lines = "".join(lines)
157
+ if lines.strip():
158
+ messages.append(dict(role=role, content=lines))
159
+
160
+ for line in lines:
161
+ if line.startswith("# "):
162
+ continue
163
+ if line.startswith("> "):
164
+ append_msg("assistant", assistant)
165
+ assistant = []
166
+ append_msg("user", user)
167
+ user = []
168
+ tool.append(line[2:])
169
+ continue
170
+ # if line.startswith("#### /"):
171
+ # continue
172
+
173
+ if line.startswith("#### "):
174
+ append_msg("assistant", assistant)
175
+ assistant = []
176
+ append_msg("tool", tool)
177
+ tool = []
178
+
179
+ content = line[5:]
180
+ user.append(content)
181
+ continue
182
+
183
+ append_msg("user", user)
184
+ user = []
185
+ append_msg("tool", tool)
186
+ tool = []
187
+
188
+ assistant.append(line)
189
+
190
+ append_msg("assistant", assistant)
191
+ append_msg("user", user)
192
+
193
+ if not include_tool:
194
+ messages = [m for m in messages if m["role"] != "tool"]
195
+
196
+ return messages
197
+
198
+
199
+ def get_pip_install(args):
200
+ cmd = [
201
+ sys.executable,
202
+ "-m",
203
+ "pip",
204
+ "install",
205
+ "--upgrade",
206
+ "--upgrade-strategy",
207
+ "only-if-needed",
208
+ ]
209
+ cmd += args
210
+ return cmd
211
+
212
+
213
+ def run_install(cmd):
214
+ print()
215
+ print("Installing:", printable_shell_command(cmd))
216
+
217
+ # First ensure pip is available
218
+ ensurepip_cmd = [sys.executable, "-m", "ensurepip", "--upgrade"]
219
+ try:
220
+ subprocess.run(ensurepip_cmd, capture_output=True, check=False)
221
+ except Exception:
222
+ pass # Continue even if ensurepip fails
223
+
224
+ try:
225
+ output = []
226
+ process = subprocess.Popen(
227
+ cmd,
228
+ stdout=subprocess.PIPE,
229
+ stderr=subprocess.STDOUT,
230
+ text=True,
231
+ bufsize=1,
232
+ universal_newlines=True,
233
+ encoding=sys.stdout.encoding,
234
+ errors="replace",
235
+ )
236
+ spinner = Spinner("Installing...")
237
+
238
+ while True:
239
+ char = process.stdout.read(1)
240
+ if not char:
241
+ break
242
+
243
+ output.append(char)
244
+ spinner.step()
245
+
246
+ spinner.end()
247
+ return_code = process.wait()
248
+ output = "".join(output)
249
+
250
+ if return_code == 0:
251
+ print("Installation complete.")
252
+ print()
253
+ return True, output
254
+
255
+ except subprocess.CalledProcessError as e:
256
+ print(f"\nError running pip install: {e}")
257
+
258
+ print("\nInstallation failed.\n")
259
+
260
+ return False, output
261
+
262
+
263
+ def find_common_root(abs_fnames):
264
+ try:
265
+ if len(abs_fnames) == 1:
266
+ return safe_abs_path(os.path.dirname(list(abs_fnames)[0]))
267
+ elif abs_fnames:
268
+ return safe_abs_path(os.path.commonpath(list(abs_fnames)))
269
+ except OSError:
270
+ pass
271
+
272
+ try:
273
+ return safe_abs_path(os.getcwd())
274
+ except FileNotFoundError:
275
+ # Fallback if cwd is deleted
276
+ return "."
277
+
278
+
279
+ def format_tokens(count):
280
+ if count < 1000:
281
+ return f"{count}"
282
+ elif count < 10000:
283
+ return f"{count / 1000:.1f}k"
284
+ else:
285
+ return f"{round(count / 1000)}k"
286
+
287
+
288
+ def touch_file(fname):
289
+ fname = Path(fname)
290
+ try:
291
+ fname.parent.mkdir(parents=True, exist_ok=True)
292
+ fname.touch()
293
+ return True
294
+ except OSError:
295
+ return False
296
+
297
+
298
+ def check_pip_install_extra(io, module, prompt, pip_install_cmd, self_update=False):
299
+ if module:
300
+ try:
301
+ __import__(module)
302
+ return True
303
+ except (ImportError, ModuleNotFoundError, RuntimeError):
304
+ pass
305
+
306
+ cmd = get_pip_install(pip_install_cmd)
307
+
308
+ if prompt:
309
+ io.tool_warning(prompt)
310
+
311
+ if self_update and platform.system() == "Windows":
312
+ io.tool_output("Run this command to update:")
313
+ print()
314
+ print(printable_shell_command(cmd)) # plain print so it doesn't line-wrap
315
+ return
316
+
317
+ if not io.confirm_ask("Run pip install?", default="y", subject=printable_shell_command(cmd)):
318
+ return
319
+
320
+ success, output = run_install(cmd)
321
+ if success:
322
+ if not module:
323
+ return True
324
+ try:
325
+ __import__(module)
326
+ return True
327
+ except (ImportError, ModuleNotFoundError, RuntimeError) as err:
328
+ io.tool_error(str(err))
329
+ pass
330
+
331
+ io.tool_error(output)
332
+
333
+ print()
334
+ print("Install failed, try running this command manually:")
335
+ print(printable_shell_command(cmd))
336
+
337
+
338
+ def printable_shell_command(cmd_list):
339
+ """
340
+ Convert a list of command arguments to a properly shell-escaped string.
341
+
342
+ Args:
343
+ cmd_list (list): List of command arguments.
344
+
345
+ Returns:
346
+ str: Shell-escaped command string.
347
+ """
348
+ return oslex.join(cmd_list)
patch/versioncheck.py ADDED
@@ -0,0 +1,130 @@
1
+ import os
2
+ import sys
3
+ import time
4
+ from importlib.metadata import PackageNotFoundError, version
5
+ from pathlib import Path
6
+
7
+ import packaging.version
8
+
9
+ import patch
10
+ from patch import utils
11
+ from patch.dump import dump # noqa: F401
12
+
13
+ VERSION_CHECK_FNAME = Path.home() / ".patch" / "caches" / "versioncheck"
14
+
15
+
16
+ def install_from_main_branch(io):
17
+ """
18
+ Install the latest version of patch from the main branch of the GitHub repository.
19
+ """
20
+
21
+ return utils.check_pip_install_extra(
22
+ io,
23
+ None,
24
+ "Install the development version of patch from the main branch?",
25
+ ["git+https://github.com/PierrunoYT/patch.git"],
26
+ self_update=True,
27
+ )
28
+
29
+
30
+ def install_upgrade(io):
31
+ """
32
+ Install the latest version of patch from PyPI.
33
+ """
34
+
35
+ new_ver_text = "Install latest version of Patch?"
36
+
37
+ docker_image = os.environ.get("PATCH_DOCKER_IMAGE")
38
+ if docker_image:
39
+ text = f"""
40
+ {new_ver_text} To upgrade, run:
41
+
42
+ docker pull {docker_image}
43
+ """
44
+ io.tool_warning(text)
45
+ return True
46
+
47
+ success = utils.check_pip_install_extra(
48
+ io,
49
+ None,
50
+ new_ver_text,
51
+ ["patch-code"],
52
+ self_update=True,
53
+ )
54
+
55
+ if success:
56
+ io.tool_output("Re-run patch to use new version.")
57
+ sys.exit()
58
+
59
+ return
60
+
61
+
62
+ def check_version(io, just_check=False, verbose=False):
63
+ latest_version = None
64
+ if not just_check:
65
+ try:
66
+ since = time.time() - VERSION_CHECK_FNAME.stat().st_mtime
67
+ if 0 <= since < 24 * 60 * 60:
68
+ latest_version = str(
69
+ packaging.version.Version(VERSION_CHECK_FNAME.read_text().strip())
70
+ )
71
+ except (OSError, ValueError):
72
+ # Also handles the empty timestamp-only cache used by older versions.
73
+ pass
74
+
75
+ if latest_version is None:
76
+ # To keep startup fast, avoid importing this unless needed.
77
+ import requests
78
+
79
+ try:
80
+ response = requests.get("https://pypi.org/pypi/patch-code/json", timeout=3)
81
+ response.raise_for_status()
82
+ latest_version = str(packaging.version.Version(response.json()["info"]["version"]))
83
+ except (requests.RequestException, ValueError, KeyError, TypeError) as err:
84
+ if just_check or verbose:
85
+ io.tool_error(f"Unable to check for Patch updates: {err}")
86
+ return False
87
+
88
+ try:
89
+ VERSION_CHECK_FNAME.parent.mkdir(parents=True, exist_ok=True)
90
+ VERSION_CHECK_FNAME.write_text(latest_version)
91
+ except OSError:
92
+ # A read-only cache must not prevent the notice or normal startup.
93
+ pass
94
+
95
+ # Compare the installed distribution, not the inherited upstream fallback version.
96
+ try:
97
+ current_version = version("patch-code")
98
+ except PackageNotFoundError:
99
+ current_version = patch.__version__
100
+
101
+ try:
102
+ is_update_available = packaging.version.parse(latest_version) > packaging.version.parse(
103
+ current_version
104
+ )
105
+ except ValueError as err:
106
+ if just_check or verbose:
107
+ io.tool_error(f"Unable to compare Patch versions: {err}")
108
+ return False
109
+
110
+ if just_check or verbose:
111
+ io.tool_output(f"Current version: {current_version}")
112
+ io.tool_output(f"Latest version: {latest_version}")
113
+ if is_update_available:
114
+ io.tool_output("Update available")
115
+ else:
116
+ io.tool_output("No update available")
117
+
118
+ if just_check:
119
+ return is_update_available
120
+
121
+ if not is_update_available:
122
+ return False
123
+
124
+ io.tool_warning(f"Patch update available: {current_version} → {latest_version}")
125
+ docker_image = os.environ.get("PATCH_DOCKER_IMAGE")
126
+ if docker_image:
127
+ io.tool_output(f"To update, run: docker pull {docker_image}")
128
+ else:
129
+ io.tool_output("To update, run: python -m patch --upgrade")
130
+ return True
patch/voice.py ADDED
@@ -0,0 +1,187 @@
1
+ import math
2
+ import os
3
+ import queue
4
+ import tempfile
5
+ import time
6
+ import warnings
7
+
8
+ from prompt_toolkit.shortcuts import prompt
9
+
10
+ from patch.llm import litellm
11
+
12
+ from .dump import dump # noqa: F401
13
+
14
+ warnings.filterwarnings(
15
+ "ignore", message="Couldn't find ffmpeg or avconv - defaulting to ffmpeg, but may not work"
16
+ )
17
+ warnings.filterwarnings("ignore", category=SyntaxWarning)
18
+
19
+
20
+ from pydub import AudioSegment # noqa
21
+ from pydub.exceptions import CouldntDecodeError, CouldntEncodeError # noqa
22
+
23
+ try:
24
+ import soundfile as sf
25
+ except (OSError, ModuleNotFoundError):
26
+ sf = None
27
+
28
+
29
+ class SoundDeviceError(Exception):
30
+ pass
31
+
32
+
33
+ class Voice:
34
+ max_rms = 0
35
+ min_rms = 1e5
36
+ pct = 0
37
+
38
+ threshold = 0.15
39
+
40
+ def __init__(self, audio_format="wav", device_name=None):
41
+ if sf is None:
42
+ raise SoundDeviceError
43
+ try:
44
+ print("Initializing sound device...")
45
+ import sounddevice as sd
46
+
47
+ self.sd = sd
48
+
49
+ devices = sd.query_devices()
50
+
51
+ if device_name:
52
+ # Find the device with matching name
53
+ device_id = None
54
+ for i, device in enumerate(devices):
55
+ if device_name in device["name"]:
56
+ device_id = i
57
+ break
58
+ if device_id is None:
59
+ available_inputs = [d["name"] for d in devices if d["max_input_channels"] > 0]
60
+ raise ValueError(
61
+ f"Device '{device_name}' not found. Available input devices:"
62
+ f" {available_inputs}"
63
+ )
64
+
65
+ print(f"Using input device: {device_name} (ID: {device_id})")
66
+
67
+ self.device_id = device_id
68
+ else:
69
+ self.device_id = None
70
+
71
+ except (OSError, ModuleNotFoundError):
72
+ raise SoundDeviceError
73
+ if audio_format not in ["wav", "mp3", "webm"]:
74
+ raise ValueError(f"Unsupported audio format: {audio_format}")
75
+ self.audio_format = audio_format
76
+
77
+ def callback(self, indata, frames, time, status):
78
+ """This is called (from a separate thread) for each audio block."""
79
+ import numpy as np
80
+
81
+ rms = np.sqrt(np.mean(indata**2))
82
+ self.max_rms = max(self.max_rms, rms)
83
+ self.min_rms = min(self.min_rms, rms)
84
+
85
+ rng = self.max_rms - self.min_rms
86
+ if rng > 0.001:
87
+ self.pct = (rms - self.min_rms) / rng
88
+ else:
89
+ self.pct = 0.5
90
+
91
+ self.q.put(indata.copy())
92
+
93
+ def get_prompt(self):
94
+ num = 10
95
+ if math.isnan(self.pct) or self.pct < self.threshold:
96
+ cnt = 0
97
+ else:
98
+ cnt = int(self.pct * 10)
99
+
100
+ bar = "░" * cnt + "█" * (num - cnt)
101
+ bar = bar[:num]
102
+
103
+ dur = time.time() - self.start_time
104
+ return f"Recording, press ENTER when done... {dur:.1f}sec {bar}"
105
+
106
+ def record_and_transcribe(self, history=None, language=None):
107
+ try:
108
+ return self.raw_record_and_transcribe(history, language)
109
+ except KeyboardInterrupt:
110
+ return
111
+ except SoundDeviceError as e:
112
+ print(f"Error: {e}")
113
+ print("Please ensure you have a working audio input device connected and try again.")
114
+ return
115
+
116
+ def raw_record_and_transcribe(self, history, language):
117
+ self.q = queue.Queue()
118
+
119
+ temp_wav = tempfile.mktemp(suffix=".wav")
120
+
121
+ try:
122
+ sample_rate = int(self.sd.query_devices(self.device_id, "input")["default_samplerate"])
123
+ except (TypeError, ValueError):
124
+ sample_rate = 16000 # fallback to 16kHz if unable to query device
125
+ except self.sd.PortAudioError:
126
+ raise SoundDeviceError(
127
+ "No audio input device detected. Please check your audio settings and try again."
128
+ )
129
+
130
+ self.start_time = time.time()
131
+
132
+ try:
133
+ with self.sd.InputStream(
134
+ samplerate=sample_rate, channels=1, callback=self.callback, device=self.device_id
135
+ ):
136
+ prompt(self.get_prompt, refresh_interval=0.1)
137
+ except self.sd.PortAudioError as err:
138
+ raise SoundDeviceError(f"Error accessing audio input device: {err}")
139
+
140
+ with sf.SoundFile(temp_wav, mode="x", samplerate=sample_rate, channels=1) as file:
141
+ while not self.q.empty():
142
+ file.write(self.q.get())
143
+
144
+ use_audio_format = self.audio_format
145
+
146
+ # Check file size and offer to convert to mp3 if too large
147
+ file_size = os.path.getsize(temp_wav)
148
+ if file_size > 24.9 * 1024 * 1024 and self.audio_format == "wav":
149
+ print("\nWarning: {temp_wav} is too large, switching to mp3 format.")
150
+ use_audio_format = "mp3"
151
+
152
+ filename = temp_wav
153
+ if use_audio_format != "wav":
154
+ try:
155
+ new_filename = tempfile.mktemp(suffix=f".{use_audio_format}")
156
+ audio = AudioSegment.from_wav(temp_wav)
157
+ audio.export(new_filename, format=use_audio_format)
158
+ os.remove(temp_wav)
159
+ filename = new_filename
160
+ except (CouldntDecodeError, CouldntEncodeError) as e:
161
+ print(f"Error converting audio: {e}")
162
+ except (OSError, FileNotFoundError) as e:
163
+ print(f"File system error during conversion: {e}")
164
+ except Exception as e:
165
+ print(f"Unexpected error during audio conversion: {e}")
166
+
167
+ with open(filename, "rb") as fh:
168
+ try:
169
+ transcript = litellm.transcription(
170
+ model="whisper-1", file=fh, prompt=history, language=language
171
+ )
172
+ except Exception as err:
173
+ print(f"Unable to transcribe {filename}: {err}")
174
+ return
175
+
176
+ if filename != temp_wav:
177
+ os.remove(filename)
178
+
179
+ text = transcript.text
180
+ return text
181
+
182
+
183
+ if __name__ == "__main__":
184
+ api_key = os.getenv("OPENAI_API_KEY")
185
+ if not api_key:
186
+ raise ValueError("Please set the OPENAI_API_KEY environment variable.")
187
+ print(Voice().record_and_transcribe())