harlequin 2.5.2__tar.gz → 2.7.0__tar.gz

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 (74) hide show
  1. {harlequin-2.5.2 → harlequin-2.7.0}/.gitignore +4 -0
  2. {harlequin-2.5.2 → harlequin-2.7.0}/CHANGELOG.md +35 -1
  3. {harlequin-2.5.2 → harlequin-2.7.0}/PKG-INFO +8 -7
  4. {harlequin-2.5.2 → harlequin-2.7.0}/pyproject.toml +17 -9
  5. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin/app.py +52 -40
  6. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin/autocomplete/completers.py +20 -13
  7. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin/cli.py +29 -5
  8. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin/colors.py +3 -1
  9. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin/components/code_editor.py +2 -0
  10. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin/components/data_catalog/tree.py +1 -16
  11. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin/components/error_modal.py +5 -3
  12. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin/config_wizard.py +2 -3
  13. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin/exception.py +4 -2
  14. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin/export.py +2 -2
  15. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin_duckdb/adapter.py +1 -1
  16. {harlequin-2.5.2 → harlequin-2.7.0}/LICENSE +0 -0
  17. {harlequin-2.5.2 → harlequin-2.7.0}/README.md +0 -0
  18. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin/__init__.py +0 -0
  19. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin/__main__.py +0 -0
  20. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin/actions.py +0 -0
  21. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin/adapter.py +0 -0
  22. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin/app.tcss +0 -0
  23. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin/app_base.py +0 -0
  24. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin/autocomplete/__init__.py +0 -0
  25. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin/autocomplete/completion.py +0 -0
  26. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin/autocomplete/constants.py +0 -0
  27. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin/bindings.py +0 -0
  28. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin/cache.py +0 -0
  29. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin/catalog.py +0 -0
  30. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin/catalog_cache.py +0 -0
  31. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin/components/__init__.py +0 -0
  32. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin/components/confirm_modal.py +0 -0
  33. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin/components/data_catalog/__init__.py +0 -0
  34. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin/components/data_catalog/database_tree.py +0 -0
  35. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin/components/data_catalog/file_tree.py +0 -0
  36. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin/components/data_catalog/s3_tree.py +0 -0
  37. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin/components/debug_info.py +0 -0
  38. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin/components/export_screen.py +0 -0
  39. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin/components/help_screen.md +0 -0
  40. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin/components/help_screen.py +0 -0
  41. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin/components/history_screen.py +0 -0
  42. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin/components/results_viewer.py +0 -0
  43. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin/components/run_query_bar.py +0 -0
  44. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin/config.py +0 -0
  45. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin/copy_formats.py +0 -0
  46. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin/copy_widgets.py +0 -0
  47. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin/driver.py +0 -0
  48. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin/editor_cache.py +0 -0
  49. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin/global.tcss +0 -0
  50. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin/history.py +0 -0
  51. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin/keymap.py +0 -0
  52. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin/keys_app.py +0 -0
  53. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin/keys_app.tcss +0 -0
  54. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin/locale_manager.py +0 -0
  55. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin/messages.py +0 -0
  56. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin/options.py +0 -0
  57. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin/plugins.py +0 -0
  58. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin/py.typed +0 -0
  59. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin/transaction_mode.py +0 -0
  60. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin/windows_timezone.py +0 -0
  61. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin_duckdb/__init__.py +0 -0
  62. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin_duckdb/catalog.py +0 -0
  63. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin_duckdb/cli_options.py +0 -0
  64. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin_duckdb/completions.py +0 -0
  65. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin_duckdb/interactions.py +0 -0
  66. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin_duckdb/py.typed +0 -0
  67. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin_sqlite/__init__.py +0 -0
  68. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin_sqlite/adapter.py +0 -0
  69. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin_sqlite/catalog.py +0 -0
  70. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin_sqlite/cli_options.py +0 -0
  71. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin_sqlite/completions.py +0 -0
  72. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin_sqlite/interactions.py +0 -0
  73. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin_sqlite/py.typed +0 -0
  74. {harlequin-2.5.2 → harlequin-2.7.0}/src/harlequin_vscode/__init__.py +0 -0
@@ -179,3 +179,7 @@ cython_debug/
179
179
  # and can be added to the global gitignore or merged into this file. For a more nuclear
180
180
  # option (not recommended) you can uncomment the following to ignore the entire idea folder.
181
181
  #.idea/
182
+
183
+ # Claude Code
184
+ CLAUDE.md
185
+ .claude/
@@ -4,6 +4,38 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [2.7.0] - 2026-08-05
8
+
9
+ ### Dependencies
10
+
11
+ - Upgrades Textual to 8.2.8 (from 6.4.0), with matching updates to textual-textarea and textual-fastdatatable.
12
+ - Raises the minimum DuckDB version to 1.1.1 (from 0.8.0) on Python < 3.14.
13
+ - Declares `pyperclip` as a direct dependency; it was previously installed only as a dependency of textual-textarea.
14
+
15
+ ### Bug Fixes
16
+
17
+ - Fixes `harlequin --help`, which printed the help text and then crashed.
18
+ - Harlequin no longer prints a `UserWarning` for each duplicated CLI flag (`The parameter -u is used more than once`) on every invocation.
19
+ - Copying the text of an error modal, a catalog label, or a data selection now works over ssh and in terminals where pyperclip has no backend, and reports a useful message when it fails.
20
+ - Fixes a crash when rendering tooltips for cells containing JSON or other markup-like values in the Results Viewer ([#933](https://github.com/tconbeer/harlequin/issues/933) - thank you [@crossi-dev](https://github.com/crossi-dev)!).
21
+ - Fixes a crash when rendering `bytes` values in the Results Viewer ([#974](https://github.com/tconbeer/harlequin/issues/974) - thank you [@Pawansingh3889](https://github.com/Pawansingh3889)!).
22
+ - Fixes a bug in the Query Editor where double-clicking a word shortly after double-clicking a different word would select the line or the entire query, instead of the second word ([#708](https://github.com/tconbeer/harlequin/issues/708)).
23
+
24
+ ### Refactoring
25
+
26
+ - The Data Catalog now uses Textual's `Click.chain` to detect double-clicks, instead of tracking the previously-clicked line with a timer ([#708](https://github.com/tconbeer/harlequin/issues/708)).
27
+ - The Query Editor now uses Textual's `Click.chain` for double-, triple-, and quadruple-click selection, instead of tracking consecutive clicks with a timer ([#708](https://github.com/tconbeer/harlequin/issues/708)).
28
+
29
+ ## [2.6.0] - 2026-08-03
30
+
31
+ ### Performance
32
+
33
+ - Autocomplete no longer re-sorts the full completion list every time the Data Catalog lazy-loads a node's children; new completions are staged and merged at most once per second. This keeps the UI responsive while the catalog loads in the background, especially against high-latency (remote) databases.
34
+
35
+ ### Bug Fixes
36
+
37
+ - Refreshes the file tree in the Data Catalog after Harlequin writes a file, either by saving from the editor or exporting data, so newly written files appear without a manual refresh ([#871](https://github.com/tconbeer/harlequin/issues/871)).
38
+
7
39
  ## [2.5.2] - 2026-03-25
8
40
 
9
41
  ### Bug Fixes
@@ -873,7 +905,9 @@ All notable changes to this project will be documented in this file.
873
905
 
874
906
  - Use the DuckDB CLI.
875
907
 
876
- [unreleased]: https://github.com/tconbeer/harlequin/compare/2.5.2...HEAD
908
+ [unreleased]: https://github.com/tconbeer/harlequin/compare/2.7.0...HEAD
909
+ [2.7.0]: https://github.com/tconbeer/harlequin/compare/2.6.0...2.7.0
910
+ [2.6.0]: https://github.com/tconbeer/harlequin/compare/2.5.2...2.6.0
877
911
  [2.5.2]: https://github.com/tconbeer/harlequin/compare/2.5.1...2.5.2
878
912
  [2.5.1]: https://github.com/tconbeer/harlequin/compare/2.5.0...2.5.1
879
913
  [2.5.0]: https://github.com/tconbeer/harlequin/compare/2.4.1...2.5.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: harlequin
3
- Version: 2.5.2
3
+ Version: 2.7.0
4
4
  Summary: The SQL IDE for Your Terminal.
5
5
  Project-URL: Homepage, https://harlequin.sh
6
6
  Project-URL: Documentation, https://harlequin.sh/docs/getting-started/index
@@ -21,17 +21,18 @@ Classifier: Programming Language :: Python :: 3.12
21
21
  Classifier: Programming Language :: Python :: 3.13
22
22
  Classifier: Programming Language :: Python :: 3.14
23
23
  Requires-Python: >=3.10
24
- Requires-Dist: click==8.1.8
25
- Requires-Dist: duckdb>=0.8.0; python_version < '3.14'
24
+ Requires-Dist: click==8.3.3
25
+ Requires-Dist: duckdb>=1.1.1; python_version < '3.14'
26
26
  Requires-Dist: duckdb>=1.4.2; python_version >= '3.14'
27
27
  Requires-Dist: pandas>=2.3; python_version >= '3.14'
28
28
  Requires-Dist: platformdirs<5.0,>=3.10
29
+ Requires-Dist: pyperclip<2,>=1.9
29
30
  Requires-Dist: questionary==2.0.1
30
- Requires-Dist: rich-click==1.8.5
31
+ Requires-Dist: rich-click==1.9.8
31
32
  Requires-Dist: shandy-sqlfmt>=0.28.2
32
- Requires-Dist: textual-fastdatatable==0.14.0
33
- Requires-Dist: textual-textarea==0.17.2
34
- Requires-Dist: textual==6.4.0
33
+ Requires-Dist: textual-fastdatatable==0.16.0
34
+ Requires-Dist: textual-textarea==0.18.1
35
+ Requires-Dist: textual==8.2.8
35
36
  Requires-Dist: tomlkit<0.14.0,>=0.12.5
36
37
  Provides-Extra: adbc
37
38
  Requires-Dist: harlequin-adbc; extra == 'adbc'
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
 
3
3
  name = "harlequin"
4
- version = "2.5.2"
4
+ version = "2.7.0"
5
5
  requires-python = ">=3.10"
6
6
  description = "The SQL IDE for Your Terminal."
7
7
  authors = [
@@ -23,20 +23,23 @@ classifiers = [
23
23
  ]
24
24
  dependencies = [
25
25
  # textual and component libraries
26
- "textual==6.4.0",
27
- "textual-fastdatatable==0.14.0",
28
- "textual-textarea==0.17.2",
26
+ "textual==8.2.8",
27
+ "textual-fastdatatable==0.16.0",
28
+ "textual-textarea==0.18.1",
29
29
 
30
30
  # click
31
- "click==8.1.8",
32
- "rich-click==1.8.5",
31
+ "click==8.3.3",
32
+ # 1.9 is the first release compatible with click 8.2's Parameter.make_metavar()
33
+ "rich-click==1.9.8",
33
34
 
34
35
  # other deps
35
- "duckdb>=0.8.0; python_version < '3.14'",
36
+ "duckdb>=1.1.1; python_version < '3.14'",
36
37
  "shandy-sqlfmt>=0.28.2",
37
38
  "platformdirs>=3.10,<5.0",
38
39
  "tomlkit>=0.12.5,<0.14.0",
39
40
  "questionary==2.0.1",
41
+ # imported directly by components/error_modal.py; also a textual-textarea dep
42
+ "pyperclip>=1.9,<2",
40
43
 
41
44
  # ensure we install from wheels
42
45
  "duckdb>=1.4.2; python_version >= '3.14'",
@@ -68,14 +71,15 @@ dev = [
68
71
  ]
69
72
  static = [
70
73
  "ruff>=0.14.1",
71
- "mypy==1.18.2,<2",
74
+ "mypy==2.0.0,<3",
72
75
  "pandas-stubs>=2,<3",
73
76
  "boto3-stubs>=1.34.23,<2",
74
77
  ]
75
78
  test = [
76
79
  "pytest>=9.0.2,<9.1.0",
77
80
  "pytest-asyncio==1.3.0",
78
- "pytest-textual-snapshot @ https://github.com/tconbeer/pytest-textual-snapshot.git#main",
81
+ "pytest-xdist>=3.6",
82
+ "pytest-textual-snapshot @ git+https://github.com/tconbeer/pytest-textual-snapshot.git@a2c6763b45283f76757a123342900accefcf04e8",
79
83
  "boto3==1.34.22",
80
84
  # extension tests require consistent version of duckdb.
81
85
  "duckdb==1.5.1",
@@ -186,6 +190,10 @@ strict_equality = true
186
190
 
187
191
  [tool.pytest.ini_options]
188
192
 
193
+ # Tests run in parallel, one worker per CPU. Pass -n0 to run them in a single
194
+ # process, e.g. when using a debugger or -s.
195
+ addopts = ["-n", "auto"]
196
+
189
197
  markers = [
190
198
  "online: requeries internet connection (deselect with '-m \"not online\"')",
191
199
  "use_cache: override the autouse fixture the disables the buffer cache",
@@ -27,6 +27,7 @@ from textual.lazy import Lazy
27
27
  from textual.message import Message
28
28
  from textual.reactive import reactive
29
29
  from textual.screen import Screen, ScreenResultCallbackType, ScreenResultType
30
+ from textual.timer import Timer
30
31
  from textual.types import CSSPathType
31
32
  from textual.widget import AwaitMount, Widget
32
33
  from textual.widgets import Button, Footer, Input
@@ -229,6 +230,7 @@ class Harlequin(AppBase):
229
230
  self.query_timer: Union[float, None] = None
230
231
  self.connection: HarlequinConnection | None = None
231
232
  self.harlequin_driver = HarlequinDriver(app=self)
233
+ self._completer_merge_timer: Timer | None = None
232
234
 
233
235
  if keymap_names is None:
234
236
  keymap_names = ("vscode",)
@@ -282,12 +284,10 @@ class Harlequin(AppBase):
282
284
  callback: ScreenResultCallbackType[ScreenResultType] | None = None,
283
285
  wait_for_dismiss: bool = False,
284
286
  ) -> AwaitMount | asyncio.Future[ScreenResultType]:
285
- if (
286
- self.editor is not None
287
- and self.editor.text_input is not None
288
- and self.editor._has_focus_within
289
- ):
290
- self.editor.text_input._pause_blink(visible=True)
287
+ # the editor keeps focus while a modal is up, so its cursor has to be
288
+ # frozen explicitly.
289
+ if self.editor is not None and self.editor._has_focus_within:
290
+ self.editor.pause_blink(visible=True)
291
291
 
292
292
  ## TODO: PREVENT DUPLICATE SCREENS HERE.
293
293
  return super().push_screen( # type: ignore[no-any-return,call-overload]
@@ -301,10 +301,9 @@ class Harlequin(AppBase):
301
301
  if (
302
302
  len(self.screen_stack) == 1
303
303
  and self.editor is not None
304
- and self.editor.text_input is not None
305
304
  and self.editor._has_focus_within
306
305
  ):
307
- self.editor.text_input._restart_blink()
306
+ self.editor.restart_blink()
308
307
  return new_screen
309
308
 
310
309
  def append_to_history(
@@ -410,25 +409,31 @@ class Harlequin(AppBase):
410
409
  self.editor.insert_text_at_selection(text=message.insert_name)
411
410
  self.editor.focus()
412
411
 
412
+ def _recycle_message(self, message: Message) -> None:
413
+ """Re-post a message we can't handle yet, while we wait for the editor."""
414
+ callback = partial(self.post_message, message)
415
+ self.set_timer(delay=0.1, callback=callback)
416
+
417
+ def _copy_to_clipboard(self, text: str, success_message: str) -> None:
418
+ """Copy text to the editor's clipboard, and to the system's if enabled.
419
+
420
+ The editor must be loaded; callers handle that by recycling their message.
421
+ textual-textarea also emits OSC 52, so this works over ssh, and reports
422
+ failures as TextAreaClipboardError, which CodeEditor turns into a notification.
423
+ """
424
+ assert self.editor is not None
425
+ self.editor.copy_to_clipboard(text)
426
+ self.notify(success_message)
427
+
413
428
  @on(HarlequinTree.NodeCopied)
414
429
  def copy_node_name(self, message: HarlequinTree.NodeCopied) -> None:
415
430
  message.stop()
416
431
  if self.editor is None or self.editor.text_input is None:
417
- # recycle message while we wait for the editor to load
418
- callback = partial(self.post_message, message)
419
- self.set_timer(delay=0.1, callback=callback)
432
+ self._recycle_message(message)
420
433
  return
421
- self.editor.text_input.clipboard = message.copy_name
422
- if (
423
- self.editor.use_system_clipboard
424
- and self.editor.text_input.system_copy is not None
425
- ):
426
- try:
427
- self.editor.text_input.system_copy(message.copy_name)
428
- except Exception:
429
- self.notify("Error copying data to system clipboard.", severity="error")
430
- else:
431
- self.notify("Selected label copied to clipboard.")
434
+ self._copy_to_clipboard(
435
+ message.copy_name, "Selected label copied to clipboard."
436
+ )
432
437
 
433
438
  @on(HarlequinDriver.InsertTextAtSelection)
434
439
  def driver_insert_text_into_editor(
@@ -541,23 +546,11 @@ class Harlequin(AppBase):
541
546
  def copy_data_to_clipboard(self, message: DataTable.SelectionCopied) -> None:
542
547
  message.stop()
543
548
  if self.editor is None or self.editor.text_input is None:
544
- # recycle the message while we wait for the editor to load
545
- callback = partial(self.post_message, message)
546
- self.set_timer(delay=0.1, callback=callback)
549
+ self._recycle_message(message)
547
550
  return
548
551
  # Excel, sheets, and Snowsight all use a TSV format for copying tabular data
549
552
  text = os.linesep.join("\t".join(map(str, row)) for row in message.values)
550
- self.editor.text_input.clipboard = text
551
- if (
552
- self.editor.use_system_clipboard
553
- and self.editor.text_input.system_copy is not None
554
- ):
555
- try:
556
- self.editor.text_input.system_copy(text)
557
- except Exception:
558
- self.notify("Error copying data to system clipboard.", severity="error")
559
- else:
560
- self.notify("Selected data copied to clipboard.")
553
+ self._copy_to_clipboard(text, "Selected data copied to clipboard.")
561
554
 
562
555
  @on(Worker.StateChanged)
563
556
  async def handle_worker_error(self, message: Worker.StateChanged) -> None:
@@ -889,11 +882,15 @@ class Harlequin(AppBase):
889
882
  if table is None:
890
883
  show_export_error(error=ValueError("You must execute a query first."))
891
884
  return
892
- notify = partial(self.notify, "Data exported successfully.")
885
+
886
+ def on_export_success() -> None:
887
+ self.notify("Data exported successfully.")
888
+ self.data_catalog.update_file_tree()
889
+
893
890
  callback = partial(
894
891
  export_callback,
895
892
  table=table,
896
- success_callback=notify,
893
+ success_callback=on_export_success,
897
894
  error_callback=show_export_error,
898
895
  )
899
896
  self.app.push_screen(
@@ -1168,12 +1165,27 @@ class Harlequin(AppBase):
1168
1165
  self.editor_collection.word_completer is not None
1169
1166
  and self.editor_collection.member_completer is not None
1170
1167
  ):
1168
+ # appending to the catalog is cheap, but merging the full
1169
+ # completion list is O(n log n), so when the Data Catalog's lazy
1170
+ # loader delivers a stream of items (one message per node), we
1171
+ # defer the merge and run it at most once per second.
1171
1172
  self.editor_collection.word_completer.extend_catalog(
1172
- parent=parent, items=items
1173
+ parent=parent, items=items, defer_merge=True
1173
1174
  )
1174
1175
  self.editor_collection.member_completer.extend_catalog(
1175
- parent=parent, items=items
1176
+ parent=parent, items=items, defer_merge=True
1176
1177
  )
1178
+ if self._completer_merge_timer is None:
1179
+ self._completer_merge_timer = self.set_timer(
1180
+ 1.0, self._merge_completers
1181
+ )
1182
+
1183
+ def _merge_completers(self) -> None:
1184
+ self._completer_merge_timer = None
1185
+ if self.editor_collection.word_completer is not None:
1186
+ self.editor_collection.word_completer.merge()
1187
+ if self.editor_collection.member_completer is not None:
1188
+ self.editor_collection.member_completer.merge()
1177
1189
 
1178
1190
  def update_completers(self, catalog: Catalog) -> None:
1179
1191
  if self.connection is None:
@@ -25,12 +25,8 @@ class WordCompleter:
25
25
  self._function_completions = function_completions
26
26
  self._catalog_completions = catalog_completions
27
27
  self._extra_completions = extra_completions or []
28
- self.completions: list[HarlequinCompletion] = self._merge_completions(
29
- self._keyword_completions,
30
- self._function_completions,
31
- self._catalog_completions,
32
- self._extra_completions,
33
- )
28
+ self.completions: list[HarlequinCompletion] = []
29
+ self.merge()
34
30
 
35
31
  def __call__(self, prefix: str) -> list[tuple[tuple[str, str], str]]:
36
32
  """
@@ -64,18 +60,29 @@ class WordCompleter:
64
60
 
65
61
  def update_catalog(self, catalog: Catalog) -> None:
66
62
  self._catalog_completions = build_catalog_completions(catalog=catalog)
67
- self.completions = self._merge_completions(
68
- self._keyword_completions,
69
- self._function_completions,
70
- self._catalog_completions,
71
- self._extra_completions,
72
- )
63
+ self.merge()
73
64
 
74
- def extend_catalog(self, parent: CatalogItem, items: list[CatalogItem]) -> None:
65
+ def extend_catalog(
66
+ self,
67
+ parent: CatalogItem,
68
+ items: list[CatalogItem],
69
+ defer_merge: bool = False,
70
+ ) -> None:
75
71
  # TODO: dedupe/merge on the parent's unique key, so we can load items from
76
72
  # a cache and update them later when they are lazy-loaded.
77
73
  new_completions = _build_children_completions(items=items, context=parent.label)
78
74
  self._catalog_completions.extend(new_completions)
75
+ if not defer_merge:
76
+ self.merge()
77
+
78
+ def merge(self) -> None:
79
+ """
80
+ Rebuild the merged, sorted completions list. This is O(n log n) in the
81
+ total number of completions, so callers that extend the catalog many
82
+ times in quick succession (e.g., the Data Catalog's lazy loader) should
83
+ pass defer_merge=True to extend_catalog and call this once, on a
84
+ debounce.
85
+ """
79
86
  self.completions = self._merge_completions(
80
87
  self._keyword_completions,
81
88
  self._function_completions,
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import sys
4
+ import warnings
4
5
  from importlib.metadata import entry_points, version
5
6
  from pathlib import Path
6
7
  from typing import Any, Sequence
@@ -36,7 +37,7 @@ DEFAULT_KEYMAP_NAMES = ["vscode"]
36
37
  DOCS_URL = "https://harlequin.sh/docs/getting-started"
37
38
 
38
39
  # general
39
- click.rich_click.USE_RICH_MARKUP = True
40
+ click.rich_click.TEXT_MARKUP = "rich"
40
41
  click.rich_click.COLOR_SYSTEM = "truecolor"
41
42
 
42
43
  click.rich_click.STYLE_OPTIONS_TABLE_LEADING = 1
@@ -50,9 +51,21 @@ click.rich_click.STYLE_ARGUMENT = PINK
50
51
  click.rich_click.STYLE_COMMAND = PINK
51
52
  click.rich_click.STYLE_SWITCH = GREEN
52
53
 
53
- # metavars
54
- click.rich_click.SHOW_METAVARS_COLUMN = False
55
- click.rich_click.APPEND_METAVARS_HELP = True
54
+ # metavars: drop the metavar column, and append the metavar to the help text instead
55
+ click.rich_click.OPTIONS_TABLE_COLUMN_TYPES = [
56
+ "required",
57
+ "opt_long",
58
+ "opt_short",
59
+ "help",
60
+ ]
61
+ click.rich_click.OPTIONS_TABLE_HELP_SECTIONS = [
62
+ "help",
63
+ "deprecated",
64
+ "envvar",
65
+ "default",
66
+ "required",
67
+ "metavar",
68
+ ]
56
69
  click.rich_click.STYLE_METAVAR_APPEND = PURPLE
57
70
  click.rich_click.STYLE_METAVAR_SEPARATOR = PURPLE
58
71
 
@@ -409,4 +422,15 @@ def harlequin() -> None:
409
422
  The main entrypoint for the Harlequin IDE. Builds and executes the click Command.
410
423
  """
411
424
  cli = build_cli()
412
- cli()
425
+ with warnings.catch_warnings():
426
+ # Two installed adapters can claim the same flag for different options --
427
+ # -u is duckdb's --allow-unsigned-extensions and postgres' --user, and -d
428
+ # is --database for one adapter and --dbname for another. Click warns about
429
+ # each collision on every invocation, but the flags belong to separate
430
+ # plugins, so there is nothing the user can do about it.
431
+ warnings.filterwarnings(
432
+ "ignore",
433
+ message="The parameter .* is used more than once",
434
+ category=UserWarning,
435
+ )
436
+ cli()
@@ -31,7 +31,9 @@ HARLEQUIN_TEXTUAL_THEME = TextualTheme(
31
31
  )
32
32
 
33
33
  VALID_THEMES = BUILTIN_THEMES
34
- VALID_THEMES.pop("textual-ansi")
34
+ # Harlequin doesn't support ANSI-passthrough themes.
35
+ VALID_THEMES.pop("ansi-dark", None)
36
+ VALID_THEMES.pop("ansi-light", None)
35
37
  VALID_THEMES.update({"harlequin": HARLEQUIN_TEXTUAL_THEME})
36
38
 
37
39
  HARLEQUIN_QUESTIONARY_STYLE = (
@@ -81,6 +81,8 @@ class CodeEditor(TextEditor, inherit_bindings=False):
81
81
 
82
82
  def on_text_area_saved(self, message: TextAreaSaved) -> None:
83
83
  self.app.notify(f"Editor contents saved to {message.path}")
84
+ if hasattr(self.app, "data_catalog"):
85
+ self.app.data_catalog.update_file_tree()
84
86
 
85
87
  def on_text_area_clipboard_error(self) -> None:
86
88
  if not self.has_shown_clipboard_error:
@@ -19,7 +19,6 @@ class HarlequinTree(Tree[TTreeNode], inherit_bindings=False):
19
19
  COMPONENT_CLASSES: ClassVar[set[str]] = {
20
20
  "harlequin-tree--type-label",
21
21
  }
22
- double_click: int | None = None
23
22
 
24
23
  class CatalogError(Message):
25
24
  def __init__(self, catalog_type: str, error: BaseException) -> None:
@@ -76,31 +75,17 @@ class HarlequinTree(Tree[TTreeNode], inherit_bindings=False):
76
75
  click_line: Union[int, None] = meta.get("line", None)
77
76
  if event.button == 1: # left button click
78
77
  self.post_message(self.HideContextMenu())
79
- if (
80
- self.double_click is not None
81
- and click_line is not None
82
- and self.double_click == click_line
83
- ):
78
+ if event.chain == 2 and click_line is not None: # double click
84
79
  event.prevent_default()
85
80
  node = self.get_node_at_line(click_line)
86
81
  if node is not None:
87
82
  self.post_message(self.NodeSubmitted(node=node))
88
83
  node.expand()
89
- else:
90
- self.double_click = click_line
91
- self.set_timer(
92
- delay=0.5,
93
- callback=self._clear_double_click,
94
- name="double_click_timer",
95
- )
96
84
  elif event.button == 3 and click_line is not None: # right click
97
85
  node = self.get_node_at_line(click_line)
98
86
  if node is not None and isinstance(node.data, CatalogItem):
99
87
  self.post_message(self.ShowContextMenu(node=node))
100
88
 
101
- def _clear_double_click(self) -> None:
102
- self.double_click = None
103
-
104
89
  def action_submit(self) -> None:
105
90
  if self.cursor_node is not None:
106
91
  self.post_message(self.NodeSubmitted(node=self.cursor_node))
@@ -12,12 +12,14 @@ from textual.widgets import Static
12
12
  class ClickableStatic(Static):
13
13
  def on_click(self, message: events.Click) -> None:
14
14
  message.stop()
15
+ text = str(self.content)
16
+ # OSC 52 works over ssh and where pyperclip has no backend
17
+ self.app.copy_to_clipboard(text)
15
18
  try:
16
- pyperclip.copy(str(self.content))
19
+ pyperclip.copy(text)
17
20
  except pyperclip.PyperclipException:
18
21
  pass
19
- else:
20
- self.app.notify("Error copied to clipboard.")
22
+ self.app.notify("Error copied to clipboard.")
21
23
 
22
24
 
23
25
  class ErrorModal(ModalScreen):
@@ -9,10 +9,9 @@ import tomlkit
9
9
  from rich import print as rich_print
10
10
  from rich.markup import escape
11
11
  from rich.panel import Panel
12
- from textual.theme import BUILTIN_THEMES
13
12
 
14
13
  from harlequin.adapter import HarlequinAdapter
15
- from harlequin.colors import HARLEQUIN_QUESTIONARY_STYLE, YELLOW
14
+ from harlequin.colors import HARLEQUIN_QUESTIONARY_STYLE, VALID_THEMES, YELLOW
16
15
  from harlequin.config import (
17
16
  Config,
18
17
  ConfigFile,
@@ -67,7 +66,7 @@ def _wizard(config_path: Path | None) -> None:
67
66
 
68
67
  theme = questionary.select(
69
68
  message="What theme should this profile use?",
70
- choices=sorted(BUILTIN_THEMES.keys()),
69
+ choices=sorted(VALID_THEMES.keys()),
71
70
  default=selected_profile.get("theme", "harlequin"),
72
71
  style=HARLEQUIN_QUESTIONARY_STYLE,
73
72
  ).unsafe_ask()
@@ -49,9 +49,11 @@ class HarlequinLocaleError(HarlequinError):
49
49
 
50
50
 
51
51
  def pretty_print_error(error: HarlequinError) -> None:
52
- from rich import print as rich_print
52
+ from rich.console import Console
53
53
 
54
- rich_print(pretty_error_message(error))
54
+ # errors are diagnostics on a failing exit path, so they belong on stderr;
55
+ # rich.print would put them on stdout and contaminate piped output.
56
+ Console(stderr=True).print(pretty_error_message(error))
55
57
 
56
58
 
57
59
  def pretty_error_message(error: HarlequinError) -> Panel:
@@ -64,7 +64,7 @@ def _export_csv(
64
64
  if "header" not in kwargs:
65
65
  kwargs["header"] = False
66
66
  try:
67
- relation = duckdb.arrow(data)
67
+ relation = duckdb.from_arrow(data)
68
68
  relation.write_csv(file_name=dest_path, **kwargs)
69
69
  except (duckdb.Error, OSError) as e:
70
70
  raise HarlequinCopyError(
@@ -81,7 +81,7 @@ def _export_parquet(
81
81
  import duckdb
82
82
 
83
83
  try:
84
- relation = duckdb.arrow(data)
84
+ relation = duckdb.from_arrow(data)
85
85
  relation.write_parquet(
86
86
  file_name=dest_path, compression=kwargs.get("compression")
87
87
  )
@@ -53,7 +53,7 @@ class DuckDbCursor(HarlequinCursor):
53
53
 
54
54
  def fetchall(self) -> AutoBackendType | None:
55
55
  try:
56
- result = self.relation.fetch_arrow_table()
56
+ result = self.relation.to_arrow_table()
57
57
  except duckdb.InterruptException:
58
58
  return None
59
59
  except duckdb.Error as e:
File without changes
File without changes