mycli 2.16.0__tar.gz → 2.18.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 (195) hide show
  1. {mycli-2.16.0/mycli.egg-info → mycli-2.18.0}/PKG-INFO +5 -6
  2. {mycli-2.16.0 → mycli-2.18.0}/changelog.md +37 -0
  3. mycli-2.18.0/doc/screenshots/main.gif +0 -0
  4. mycli-2.18.0/doc/screenshots/tables.png +0 -0
  5. {mycli-2.16.0 → mycli-2.18.0}/mycli/client.py +1 -1
  6. {mycli-2.16.0 → mycli-2.18.0}/mycli/client_commands.py +18 -1
  7. {mycli-2.16.0 → mycli-2.18.0}/mycli/client_query.py +1 -0
  8. {mycli-2.16.0 → mycli-2.18.0}/mycli/completion_refresher.py +1 -1
  9. {mycli-2.16.0 → mycli-2.18.0}/mycli/lexer.py +1 -3
  10. {mycli-2.16.0 → mycli-2.18.0}/mycli/main.py +1 -1
  11. {mycli-2.16.0 → mycli-2.18.0}/mycli/main_modes/repl.py +11 -3
  12. {mycli-2.16.0 → mycli-2.18.0}/mycli/myclirc +9 -1
  13. mycli-2.18.0/mycli/packages/polars_completion.py +293 -0
  14. {mycli-2.16.0 → mycli-2.18.0}/mycli/packages/polars_transform.py +36 -24
  15. mycli-2.18.0/mycli/packages/ptoolkit/history.py +198 -0
  16. {mycli-2.16.0 → mycli-2.18.0}/mycli/packages/special/dbcommands.py +3 -0
  17. {mycli-2.16.0 → mycli-2.18.0}/mycli/packages/special/iocommands.py +17 -0
  18. {mycli-2.16.0 → mycli-2.18.0}/mycli/packages/special/main.py +21 -1
  19. {mycli-2.16.0 → mycli-2.18.0}/mycli/sqlcompleter.py +43 -14
  20. {mycli-2.16.0 → mycli-2.18.0/mycli.egg-info}/PKG-INFO +5 -6
  21. {mycli-2.16.0 → mycli-2.18.0}/mycli.egg-info/SOURCES.txt +2 -1
  22. {mycli-2.16.0 → mycli-2.18.0}/mycli.egg-info/requires.txt +4 -5
  23. {mycli-2.16.0 → mycli-2.18.0}/mycli.egg-info/scm_file_list.json +2 -1
  24. mycli-2.18.0/mycli.egg-info/scm_version.json +8 -0
  25. {mycli-2.16.0 → mycli-2.18.0}/pyproject.toml +10 -8
  26. {mycli-2.16.0 → mycli-2.18.0}/test/myclirc +9 -1
  27. {mycli-2.16.0 → mycli-2.18.0}/test/pytests/test_client_commands.py +26 -1
  28. {mycli-2.16.0 → mycli-2.18.0}/test/pytests/test_client_query.py +4 -0
  29. {mycli-2.16.0 → mycli-2.18.0}/test/pytests/test_completion_refresher.py +6 -2
  30. {mycli-2.16.0 → mycli-2.18.0}/test/pytests/test_main_modes_repl.py +42 -4
  31. mycli-2.18.0/test/pytests/test_polars_completion.py +259 -0
  32. mycli-2.18.0/test/pytests/test_ptoolkit_history.py +410 -0
  33. {mycli-2.16.0 → mycli-2.18.0}/test/pytests/test_smart_completion_public_schema_only.py +14 -8
  34. {mycli-2.16.0 → mycli-2.18.0}/test/pytests/test_special_main.py +14 -1
  35. {mycli-2.16.0 → mycli-2.18.0}/test/pytests/test_sqlcompleter.py +86 -0
  36. mycli-2.16.0/doc/screenshots/main.gif +0 -0
  37. mycli-2.16.0/doc/screenshots/tables.png +0 -0
  38. mycli-2.16.0/mycli/packages/ptoolkit/history.py +0 -59
  39. mycli-2.16.0/mycli.egg-info/scm_version.json +0 -8
  40. mycli-2.16.0/test/pytests/test_lexer.py +0 -10
  41. mycli-2.16.0/test/pytests/test_ptoolkit_history.py +0 -72
  42. {mycli-2.16.0 → mycli-2.18.0}/.git-blame-ignore-revs +0 -0
  43. {mycli-2.16.0 → mycli-2.18.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  44. {mycli-2.16.0 → mycli-2.18.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  45. {mycli-2.16.0 → mycli-2.18.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  46. {mycli-2.16.0 → mycli-2.18.0}/.github/dependabot.yml +0 -0
  47. {mycli-2.16.0 → mycli-2.18.0}/.github/workflows/ci.yml +0 -0
  48. {mycli-2.16.0 → mycli-2.18.0}/.github/workflows/lint.yml +0 -0
  49. {mycli-2.16.0 → mycli-2.18.0}/.github/workflows/publish.yml +0 -0
  50. {mycli-2.16.0 → mycli-2.18.0}/.github/workflows/typecheck.yml +0 -0
  51. {mycli-2.16.0 → mycli-2.18.0}/.gitignore +0 -0
  52. {mycli-2.16.0 → mycli-2.18.0}/AGENTS.md +0 -0
  53. {mycli-2.16.0 → mycli-2.18.0}/AUTHORS.rst +0 -0
  54. {mycli-2.16.0 → mycli-2.18.0}/CONTRIBUTING.md +0 -0
  55. {mycli-2.16.0 → mycli-2.18.0}/LICENSE.txt +0 -0
  56. {mycli-2.16.0 → mycli-2.18.0}/MANIFEST.in +0 -0
  57. {mycli-2.16.0 → mycli-2.18.0}/README.md +0 -0
  58. {mycli-2.16.0 → mycli-2.18.0}/SPONSORS.rst +0 -0
  59. {mycli-2.16.0 → mycli-2.18.0}/doc/key_bindings.rst +0 -0
  60. {mycli-2.16.0 → mycli-2.18.0}/doc/llm.md +0 -0
  61. {mycli-2.16.0 → mycli-2.18.0}/doc/screenshots/total_histogram.png +0 -0
  62. {mycli-2.16.0 → mycli-2.18.0}/doc/transforms.md +0 -0
  63. {mycli-2.16.0 → mycli-2.18.0}/mycli/AUTHORS +0 -0
  64. {mycli-2.16.0 → mycli-2.18.0}/mycli/SPONSORS +0 -0
  65. {mycli-2.16.0 → mycli-2.18.0}/mycli/TIPS +0 -0
  66. {mycli-2.16.0 → mycli-2.18.0}/mycli/__init__.py +0 -0
  67. {mycli-2.16.0 → mycli-2.18.0}/mycli/app_state.py +0 -0
  68. {mycli-2.16.0 → mycli-2.18.0}/mycli/boundary_tunnel.py +0 -0
  69. {mycli-2.16.0 → mycli-2.18.0}/mycli/cli_runner.py +0 -0
  70. {mycli-2.16.0 → mycli-2.18.0}/mycli/clibuffer.py +0 -0
  71. {mycli-2.16.0 → mycli-2.18.0}/mycli/client_connection.py +0 -0
  72. {mycli-2.16.0 → mycli-2.18.0}/mycli/clistyle.py +0 -0
  73. {mycli-2.16.0 → mycli-2.18.0}/mycli/clitoolbar.py +0 -0
  74. {mycli-2.16.0 → mycli-2.18.0}/mycli/compat.py +0 -0
  75. {mycli-2.16.0 → mycli-2.18.0}/mycli/config.py +0 -0
  76. {mycli-2.16.0 → mycli-2.18.0}/mycli/constants.py +0 -0
  77. {mycli-2.16.0 → mycli-2.18.0}/mycli/key_bindings.py +0 -0
  78. {mycli-2.16.0 → mycli-2.18.0}/mycli/main_modes/batch.py +0 -0
  79. {mycli-2.16.0 → mycli-2.18.0}/mycli/main_modes/checkup.py +0 -0
  80. {mycli-2.16.0 → mycli-2.18.0}/mycli/main_modes/completions.py +0 -0
  81. {mycli-2.16.0 → mycli-2.18.0}/mycli/main_modes/execute.py +0 -0
  82. {mycli-2.16.0 → mycli-2.18.0}/mycli/main_modes/list_dsn.py +0 -0
  83. {mycli-2.16.0 → mycli-2.18.0}/mycli/output.py +0 -0
  84. {mycli-2.16.0 → mycli-2.18.0}/mycli/packages/__init__.py +0 -0
  85. {mycli-2.16.0 → mycli-2.18.0}/mycli/packages/batch_utils.py +0 -0
  86. {mycli-2.16.0 → mycli-2.18.0}/mycli/packages/cli_utils.py +0 -0
  87. {mycli-2.16.0 → mycli-2.18.0}/mycli/packages/completion_engine.py +0 -0
  88. {mycli-2.16.0 → mycli-2.18.0}/mycli/packages/filepaths.py +0 -0
  89. {mycli-2.16.0 → mycli-2.18.0}/mycli/packages/hybrid_redirection.py +0 -0
  90. {mycli-2.16.0 → mycli-2.18.0}/mycli/packages/interactive_utils.py +0 -0
  91. {mycli-2.16.0 → mycli-2.18.0}/mycli/packages/key_binding_utils.py +0 -0
  92. {mycli-2.16.0 → mycli-2.18.0}/mycli/packages/ptoolkit/__init__.py +0 -0
  93. {mycli-2.16.0 → mycli-2.18.0}/mycli/packages/ptoolkit/fzf.py +0 -0
  94. {mycli-2.16.0 → mycli-2.18.0}/mycli/packages/ptoolkit/utils.py +0 -0
  95. {mycli-2.16.0 → mycli-2.18.0}/mycli/packages/special/__init__.py +0 -0
  96. {mycli-2.16.0 → mycli-2.18.0}/mycli/packages/special/delimitercommand.py +0 -0
  97. {mycli-2.16.0 → mycli-2.18.0}/mycli/packages/special/dsn_aliases.py +0 -0
  98. {mycli-2.16.0 → mycli-2.18.0}/mycli/packages/special/favoritequeries.py +0 -0
  99. {mycli-2.16.0 → mycli-2.18.0}/mycli/packages/special/llm.py +0 -0
  100. {mycli-2.16.0 → mycli-2.18.0}/mycli/packages/special/utils.py +0 -0
  101. {mycli-2.16.0 → mycli-2.18.0}/mycli/packages/sql_utils.py +0 -0
  102. {mycli-2.16.0 → mycli-2.18.0}/mycli/packages/sqlresult.py +0 -0
  103. {mycli-2.16.0 → mycli-2.18.0}/mycli/packages/string_utils.py +0 -0
  104. {mycli-2.16.0 → mycli-2.18.0}/mycli/packages/tabular_output/__init__.py +0 -0
  105. {mycli-2.16.0 → mycli-2.18.0}/mycli/packages/tabular_output/sql_format.py +0 -0
  106. {mycli-2.16.0 → mycli-2.18.0}/mycli/password_sources.py +0 -0
  107. {mycli-2.16.0 → mycli-2.18.0}/mycli/resources/completions/bash/mycli +0 -0
  108. {mycli-2.16.0 → mycli-2.18.0}/mycli/resources/completions/fish/mycli.fish +0 -0
  109. {mycli-2.16.0 → mycli-2.18.0}/mycli/resources/completions/zsh/_mycli +0 -0
  110. {mycli-2.16.0 → mycli-2.18.0}/mycli/schema_prefetcher.py +0 -0
  111. {mycli-2.16.0 → mycli-2.18.0}/mycli/sqlexecute.py +0 -0
  112. {mycli-2.16.0 → mycli-2.18.0}/mycli/ssh_tunnel.py +0 -0
  113. {mycli-2.16.0 → mycli-2.18.0}/mycli/types.py +0 -0
  114. {mycli-2.16.0 → mycli-2.18.0}/mycli/vault.py +0 -0
  115. {mycli-2.16.0 → mycli-2.18.0}/mycli.egg-info/dependency_links.txt +0 -0
  116. {mycli-2.16.0 → mycli-2.18.0}/mycli.egg-info/entry_points.txt +0 -0
  117. {mycli-2.16.0 → mycli-2.18.0}/mycli.egg-info/top_level.txt +0 -0
  118. {mycli-2.16.0 → mycli-2.18.0}/setup.cfg +0 -0
  119. {mycli-2.16.0 → mycli-2.18.0}/test/__init__.py +0 -0
  120. {mycli-2.16.0 → mycli-2.18.0}/test/features/__init__.py +0 -0
  121. {mycli-2.16.0 → mycli-2.18.0}/test/features/auto_vertical.feature +0 -0
  122. {mycli-2.16.0 → mycli-2.18.0}/test/features/basic_commands.feature +0 -0
  123. {mycli-2.16.0 → mycli-2.18.0}/test/features/connection.feature +0 -0
  124. {mycli-2.16.0 → mycli-2.18.0}/test/features/crud_database.feature +0 -0
  125. {mycli-2.16.0 → mycli-2.18.0}/test/features/crud_table.feature +0 -0
  126. {mycli-2.16.0 → mycli-2.18.0}/test/features/db_utils.py +0 -0
  127. {mycli-2.16.0 → mycli-2.18.0}/test/features/environment.py +0 -0
  128. {mycli-2.16.0 → mycli-2.18.0}/test/features/fixture_data/help_commands.txt +0 -0
  129. {mycli-2.16.0 → mycli-2.18.0}/test/features/fixture_utils.py +0 -0
  130. {mycli-2.16.0 → mycli-2.18.0}/test/features/iocommands.feature +0 -0
  131. {mycli-2.16.0 → mycli-2.18.0}/test/features/named_queries.feature +0 -0
  132. {mycli-2.16.0 → mycli-2.18.0}/test/features/specials.feature +0 -0
  133. {mycli-2.16.0 → mycli-2.18.0}/test/features/steps/__init__.py +0 -0
  134. {mycli-2.16.0 → mycli-2.18.0}/test/features/steps/auto_vertical.py +0 -0
  135. {mycli-2.16.0 → mycli-2.18.0}/test/features/steps/basic_commands.py +0 -0
  136. {mycli-2.16.0 → mycli-2.18.0}/test/features/steps/connection.py +0 -0
  137. {mycli-2.16.0 → mycli-2.18.0}/test/features/steps/crud_database.py +0 -0
  138. {mycli-2.16.0 → mycli-2.18.0}/test/features/steps/crud_table.py +0 -0
  139. {mycli-2.16.0 → mycli-2.18.0}/test/features/steps/iocommands.py +0 -0
  140. {mycli-2.16.0 → mycli-2.18.0}/test/features/steps/named_queries.py +0 -0
  141. {mycli-2.16.0 → mycli-2.18.0}/test/features/steps/specials.py +0 -0
  142. {mycli-2.16.0 → mycli-2.18.0}/test/features/steps/utils.py +0 -0
  143. {mycli-2.16.0 → mycli-2.18.0}/test/features/steps/wrappers.py +0 -0
  144. {mycli-2.16.0 → mycli-2.18.0}/test/features/wrappager.py +0 -0
  145. {mycli-2.16.0 → mycli-2.18.0}/test/mylogin.cnf +0 -0
  146. {mycli-2.16.0 → mycli-2.18.0}/test/pytests/conftest.py +0 -0
  147. {mycli-2.16.0 → mycli-2.18.0}/test/pytests/test_app_state.py +0 -0
  148. {mycli-2.16.0 → mycli-2.18.0}/test/pytests/test_bash_completion.py +0 -0
  149. {mycli-2.16.0 → mycli-2.18.0}/test/pytests/test_batch_utils.py +0 -0
  150. {mycli-2.16.0 → mycli-2.18.0}/test/pytests/test_boundary_tunnel.py +0 -0
  151. {mycli-2.16.0 → mycli-2.18.0}/test/pytests/test_checkup.py +0 -0
  152. {mycli-2.16.0 → mycli-2.18.0}/test/pytests/test_cli_runner.py +0 -0
  153. {mycli-2.16.0 → mycli-2.18.0}/test/pytests/test_cli_utils.py +0 -0
  154. {mycli-2.16.0 → mycli-2.18.0}/test/pytests/test_clibuffer.py +0 -0
  155. {mycli-2.16.0 → mycli-2.18.0}/test/pytests/test_client.py +0 -0
  156. {mycli-2.16.0 → mycli-2.18.0}/test/pytests/test_client_connection.py +0 -0
  157. {mycli-2.16.0 → mycli-2.18.0}/test/pytests/test_clistyle.py +0 -0
  158. {mycli-2.16.0 → mycli-2.18.0}/test/pytests/test_clitoolbar.py +0 -0
  159. {mycli-2.16.0 → mycli-2.18.0}/test/pytests/test_completer_use_switch.py +0 -0
  160. {mycli-2.16.0 → mycli-2.18.0}/test/pytests/test_completion_engine.py +0 -0
  161. {mycli-2.16.0 → mycli-2.18.0}/test/pytests/test_config.py +0 -0
  162. {mycli-2.16.0 → mycli-2.18.0}/test/pytests/test_delimitercommand.py +0 -0
  163. {mycli-2.16.0 → mycli-2.18.0}/test/pytests/test_dsn_aliases.py +0 -0
  164. {mycli-2.16.0 → mycli-2.18.0}/test/pytests/test_favoritequeries.py +0 -0
  165. {mycli-2.16.0 → mycli-2.18.0}/test/pytests/test_filepaths.py +0 -0
  166. {mycli-2.16.0 → mycli-2.18.0}/test/pytests/test_fish_completion.py +0 -0
  167. {mycli-2.16.0 → mycli-2.18.0}/test/pytests/test_hybrid_redirection.py +0 -0
  168. {mycli-2.16.0 → mycli-2.18.0}/test/pytests/test_interactive_utils.py +0 -0
  169. {mycli-2.16.0 → mycli-2.18.0}/test/pytests/test_key_binding_utils.py +0 -0
  170. {mycli-2.16.0 → mycli-2.18.0}/test/pytests/test_key_bindings.py +0 -0
  171. {mycli-2.16.0 → mycli-2.18.0}/test/pytests/test_main.py +0 -0
  172. {mycli-2.16.0 → mycli-2.18.0}/test/pytests/test_main_modes_batch.py +0 -0
  173. {mycli-2.16.0 → mycli-2.18.0}/test/pytests/test_main_modes_execute.py +0 -0
  174. {mycli-2.16.0 → mycli-2.18.0}/test/pytests/test_main_modes_list_dsn.py +0 -0
  175. {mycli-2.16.0 → mycli-2.18.0}/test/pytests/test_naive_completion.py +0 -0
  176. {mycli-2.16.0 → mycli-2.18.0}/test/pytests/test_output.py +0 -0
  177. {mycli-2.16.0 → mycli-2.18.0}/test/pytests/test_password_sources.py +0 -0
  178. {mycli-2.16.0 → mycli-2.18.0}/test/pytests/test_polars_transform.py +0 -0
  179. {mycli-2.16.0 → mycli-2.18.0}/test/pytests/test_ptoolkit_fzf.py +0 -0
  180. {mycli-2.16.0 → mycli-2.18.0}/test/pytests/test_ptoolkit_utils.py +0 -0
  181. {mycli-2.16.0 → mycli-2.18.0}/test/pytests/test_schema_prefetcher.py +0 -0
  182. {mycli-2.16.0 → mycli-2.18.0}/test/pytests/test_special_dbcommands.py +0 -0
  183. {mycli-2.16.0 → mycli-2.18.0}/test/pytests/test_special_init.py +0 -0
  184. {mycli-2.16.0 → mycli-2.18.0}/test/pytests/test_special_iocommands.py +0 -0
  185. {mycli-2.16.0 → mycli-2.18.0}/test/pytests/test_special_llm.py +0 -0
  186. {mycli-2.16.0 → mycli-2.18.0}/test/pytests/test_special_utils.py +0 -0
  187. {mycli-2.16.0 → mycli-2.18.0}/test/pytests/test_sql_utils.py +0 -0
  188. {mycli-2.16.0 → mycli-2.18.0}/test/pytests/test_sqlexecute.py +0 -0
  189. {mycli-2.16.0 → mycli-2.18.0}/test/pytests/test_sqlresult.py +0 -0
  190. {mycli-2.16.0 → mycli-2.18.0}/test/pytests/test_ssh_tunnel.py +0 -0
  191. {mycli-2.16.0 → mycli-2.18.0}/test/pytests/test_string_utils.py +0 -0
  192. {mycli-2.16.0 → mycli-2.18.0}/test/pytests/test_tabular_output.py +0 -0
  193. {mycli-2.16.0 → mycli-2.18.0}/test/pytests/test_vault.py +0 -0
  194. {mycli-2.16.0 → mycli-2.18.0}/test/pytests/test_zsh_completion.py +0 -0
  195. {mycli-2.16.0 → mycli-2.18.0}/test/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mycli
3
- Version: 2.16.0
3
+ Version: 2.18.0
4
4
  Summary: Rich MySQL terminal client with autocompletion, syntax highlighting, and dataframes
5
5
  Author: Mycli Core Team
6
6
  License-Expression: BSD-3-Clause
@@ -31,7 +31,7 @@ License-File: AUTHORS.rst
31
31
  Requires-Dist: click~=8.3.3
32
32
  Requires-Dist: clickdc~=0.1.1
33
33
  Requires-Dist: cryptography~=50.0.0
34
- Requires-Dist: Pygments~=2.20.0
34
+ Requires-Dist: Pygments~=2.21.0
35
35
  Requires-Dist: prompt_toolkit<4.0.0,>=3.0.41
36
36
  Requires-Dist: PyMySQL~=1.1.2
37
37
  Requires-Dist: sqlparse<0.6.0,>=0.3.0
@@ -48,10 +48,9 @@ Requires-Dist: keyring~=25.7.0
48
48
  Requires-Dist: jinja2~=3.1.6
49
49
  Requires-Dist: yaspin~=3.4.0
50
50
  Provides-Extra: llm
51
- Requires-Dist: llm~=0.31.1; extra == "llm"
51
+ Requires-Dist: llm~=0.33; extra == "llm"
52
52
  Requires-Dist: setuptools==83.*; extra == "llm"
53
53
  Requires-Dist: pip~=26.1.2; extra == "llm"
54
- Requires-Dist: openai~=2.48.0; extra == "llm"
55
54
  Provides-Extra: dataframe
56
55
  Requires-Dist: polars~=1.42.1; extra == "dataframe"
57
56
  Requires-Dist: altair~=6.2.2; extra == "dataframe"
@@ -61,14 +60,14 @@ Requires-Dist: mycli[dataframe,llm]; extra == "all"
61
60
  Provides-Extra: dev
62
61
  Requires-Dist: behave~=1.3.3; extra == "dev"
63
62
  Requires-Dist: coverage~=7.13.4; extra == "dev"
64
- Requires-Dist: mypy~=2.3.0; extra == "dev"
63
+ Requires-Dist: mypy~=2.3.1; extra == "dev"
65
64
  Requires-Dist: pexpect~=4.9.0; extra == "dev"
66
65
  Requires-Dist: pytest~=9.1.1; extra == "dev"
67
66
  Requires-Dist: pytest-cov~=7.0.0; extra == "dev"
68
67
  Requires-Dist: pytest-random-order~=1.2.0; extra == "dev"
69
68
  Requires-Dist: tox~=4.35.0; extra == "dev"
70
69
  Requires-Dist: pdbpp~=0.11.7; extra == "dev"
71
- Requires-Dist: ruff~=0.15.0; extra == "dev"
70
+ Requires-Dist: ruff~=0.16.3; extra == "dev"
72
71
  Dynamic: license-file
73
72
 
74
73
  # mycli
@@ -1,3 +1,40 @@
1
+ 2.18.0 (2026/08/29)
2
+ ==============
3
+
4
+ Features
5
+ --------
6
+ * Promote Boundary tunnel feature out of beta.
7
+
8
+
9
+ Bug Fixes
10
+ --------
11
+ * Default split-line prompt was missing trailing space.
12
+
13
+
14
+ Documentation
15
+ ---------
16
+ * Modernize `README.md` screenshots.
17
+
18
+
19
+ 2.17.0 (2026/08/25)
20
+ ==============
21
+
22
+ Features
23
+ --------
24
+ * Sort completion candidates by frecency from history.
25
+ * Add help snippets in special /command completions.
26
+ * Add completions on Polars methods in dataframe transforms.
27
+
28
+
29
+ Internal
30
+ ---------
31
+ * Upgrade `pygments` to v2.21.0, removing hacks for `set*` identifiers.
32
+ * Upgrade `llm` dependency to v3.33, removing pin for `openai`.
33
+ * Remove `pygments` cooldown exception as no longer needed.
34
+ * Upgrade `mypy` dev dependency to v2.3.1.
35
+ * Upgrade `ruff` dev dependency to v0.16.3.
36
+
37
+
1
38
  2.16.0 (2026/08/22)
2
39
  ==============
3
40
 
Binary file
Binary file
@@ -52,7 +52,7 @@ sqlparse.engine.grouping.MAX_GROUPING_TOKENS = None # type: ignore[assignment]
52
52
 
53
53
  class MyCli(AppStateMixin, OutputMixin, ClientCommandsMixin, ClientConnectionMixin, ClientQueryMixin):
54
54
  default_prompt = DEFAULT_PROMPT
55
- default_prompt_splitln = "\\u@\\h\\n(\\t):\\d>"
55
+ default_prompt_splitln = r'\u@\h\n(\t):\d>\_'
56
56
  max_len_prompt = 45
57
57
  prompt_lines: int
58
58
  sqlexecute: SQLExecute | None
@@ -17,6 +17,7 @@ from mycli.packages import special
17
17
  from mycli.packages.batch_utils import statements_from_filehandle
18
18
  from mycli.packages.filepaths import dir_path_exists
19
19
  from mycli.packages.interactive_utils import confirm_destructive_query
20
+ from mycli.packages.ptoolkit.history import FileHistoryWithTimestamp
20
21
  from mycli.packages.special import main as special_main
21
22
  from mycli.packages.special.iocommands import expand_favorite_query
22
23
  from mycli.packages.special.main import ArgType, SpecialCommandAlias
@@ -222,6 +223,7 @@ class ClientCommandsMixin:
222
223
  destructive_keywords: Any
223
224
  config: Any
224
225
  myclirc_path: str
226
+ prompt_session: Any
225
227
  prompt_format: str
226
228
 
227
229
  def refresh_completions(self, reset: bool = False) -> list[SQLResult]: ...
@@ -235,6 +237,7 @@ class ClientCommandsMixin:
235
237
  "/use <database>",
236
238
  "Change to a new database.",
237
239
  aliases=[SpecialCommandAlias("\\u", case_sensitive=False)],
240
+ completion_snippet='change databases',
238
241
  )
239
242
  special.register_special_command(
240
243
  self.manual_reconnect,
@@ -243,14 +246,16 @@ class ClientCommandsMixin:
243
246
  "Reconnect to the server, optionally switching databases.",
244
247
  case_sensitive=True,
245
248
  aliases=[SpecialCommandAlias("\\r", case_sensitive=True)],
249
+ completion_snippet='reconnect to server',
246
250
  )
247
251
  special.register_special_command(
248
- self.refresh_completions,
252
+ self.rehash,
249
253
  "rehash",
250
254
  "/rehash",
251
255
  "Refresh auto-completions.",
252
256
  arg_type=ArgType.NO_ARGUMENT,
253
257
  aliases=[SpecialCommandAlias("\\#", case_sensitive=False)],
258
+ completion_snippet='refresh completions',
254
259
  )
255
260
  special.register_special_command(
256
261
  self.change_table_format,
@@ -259,6 +264,7 @@ class ClientCommandsMixin:
259
264
  "Change the table format used to output interactive results.",
260
265
  case_sensitive=True,
261
266
  aliases=[SpecialCommandAlias("\\T", case_sensitive=True)],
267
+ completion_snippet='change interactive output format',
262
268
  )
263
269
  special.register_special_command(
264
270
  self.change_redirect_format,
@@ -267,6 +273,7 @@ class ClientCommandsMixin:
267
273
  "Change the table format used to output redirected results.",
268
274
  case_sensitive=True,
269
275
  aliases=[SpecialCommandAlias("\\Tr", case_sensitive=True)],
276
+ completion_snippet='change redirected output format',
270
277
  )
271
278
  special.register_special_command(
272
279
  self.execute_from_file,
@@ -274,6 +281,7 @@ class ClientCommandsMixin:
274
281
  "/source [--special|--show|--page] <file>",
275
282
  "Execute queries from a file.",
276
283
  aliases=[SpecialCommandAlias("\\.", case_sensitive=False)],
284
+ completion_snippet='execute queries from file',
277
285
  )
278
286
  special.register_special_command(
279
287
  self.change_prompt_format,
@@ -282,12 +290,14 @@ class ClientCommandsMixin:
282
290
  "Show or change prompt format.",
283
291
  case_sensitive=True,
284
292
  aliases=[SpecialCommandAlias("\\R", case_sensitive=True)],
293
+ completion_snippet='show or change prompt format',
285
294
  )
286
295
  special.register_special_command(
287
296
  self.config_command,
288
297
  r'\config',
289
298
  '/config <help|get|search|edit> [key]',
290
299
  'Inspect settings from config files.',
300
+ completion_snippet='inspect config file settings',
291
301
  )
292
302
 
293
303
  def manual_reconnect(self, arg: str = "", **_) -> Generator[SQLResult, None, None]:
@@ -302,6 +312,13 @@ class ClientCommandsMixin:
302
312
  else:
303
313
  yield self.change_db(arg).send(None)
304
314
 
315
+ def rehash(self) -> list[SQLResult]:
316
+ prompt_session = getattr(self, 'prompt_session', None)
317
+ history = getattr(prompt_session, 'history', None)
318
+ if isinstance(history, FileHistoryWithTimestamp):
319
+ history.refresh_frecency()
320
+ return self.refresh_completions()
321
+
305
322
  def change_table_format(self, arg: str, **_) -> Generator[SQLResult, None, None]:
306
323
  try:
307
324
  self.main_formatter.format_name = arg
@@ -59,6 +59,7 @@ class ClientQueryMixin:
59
59
  "keyword_casing": self.completer.keyword_casing,
60
60
  "indexed_column_suffix": self.completer.indexed_column_suffix,
61
61
  "config_property_names": self.completer.config_property_names,
62
+ 'frecency_provider': self.completer.frecency_provider,
62
63
  },
63
64
  )
64
65
 
@@ -267,7 +267,7 @@ def refresh_collations(completer: SQLCompleter, executor: SQLExecute) -> None:
267
267
 
268
268
  @refresher("special_commands")
269
269
  def refresh_special(completer: SQLCompleter, executor: SQLExecute) -> None:
270
- completer.extend_special_commands(list(COMMANDS.keys()))
270
+ completer.extend_special_commands({command: details.completion_snippet or details.description for command, details in COMMANDS.items()})
271
271
 
272
272
 
273
273
  @refresher("show_commands")
@@ -1,6 +1,6 @@
1
1
  from pygments.lexer import inherit
2
2
  from pygments.lexers.sql import MySqlLexer
3
- from pygments.token import Keyword, Name
3
+ from pygments.token import Keyword
4
4
 
5
5
 
6
6
  class MyCliLexer(MySqlLexer):
@@ -8,8 +8,6 @@ class MyCliLexer(MySqlLexer):
8
8
 
9
9
  tokens = {
10
10
  "root": [
11
- # TODO: Remove once Pygments is upgraded above v2.20.0.
12
- (r"\bset[\w$]+\b", Name),
13
11
  (r"\brepair\b", Keyword),
14
12
  (r"\boffset\b", Keyword),
15
13
  inherit,
@@ -323,7 +323,7 @@ class CliArgs:
323
323
  )
324
324
  boundary_id: str | None = clickdc.option(
325
325
  type=str,
326
- help='BETA: open a HashiCorp Boundary tunnel to TARGET_ID and connect through it.',
326
+ help='Open a HashiCorp Boundary tunnel to TARGET_ID and connect through it.',
327
327
  )
328
328
  checkup: bool = clickdc.option(
329
329
  is_flag=True,
@@ -74,7 +74,7 @@ from mycli.packages.polars_transform import (
74
74
  prepare_polars_transform,
75
75
  run_polars_transform,
76
76
  )
77
- from mycli.packages.ptoolkit.history import FileHistoryWithTimestamp
77
+ from mycli.packages.ptoolkit.history import FRECENCY_HISTORY_ENTRIES, FRECENCY_REFRESH_INTERVAL, FileHistoryWithTimestamp
78
78
  from mycli.packages.special.utils import format_uptime, get_ssl_version, get_uptime, get_warning_count
79
79
  from mycli.packages.sql_utils import (
80
80
  extract_new_password,
@@ -146,7 +146,13 @@ def complete_while_typing_filter() -> bool:
146
146
  def _create_history(mycli: 'MyCli') -> FileHistoryWithTimestamp | None:
147
147
  history_file = os.path.expanduser(os.environ.get('MYCLI_HISTFILE', mycli.config['main'].get('history_file', '~/.mycli-history')))
148
148
  if dir_path_exists(history_file):
149
- return FileHistoryWithTimestamp(history_file)
149
+ frecency_history_entries = int(mycli.config['main'].get('frecency_history_entries', FRECENCY_HISTORY_ENTRIES) or 0)
150
+ frecency_refresh_interval = int(mycli.config['main'].get('frecency_refresh_interval', FRECENCY_REFRESH_INTERVAL) or 0)
151
+ return FileHistoryWithTimestamp(
152
+ history_file,
153
+ frecency_history_entries=frecency_history_entries,
154
+ frecency_refresh_interval=frecency_refresh_interval,
155
+ )
150
156
 
151
157
  mycli.echo(
152
158
  f'Error: Unable to open the history file "{history_file}". Your query history will not be saved.',
@@ -1113,10 +1119,12 @@ def main_repl(mycli: 'MyCli') -> None:
1113
1119
 
1114
1120
  mycli.configure_pager()
1115
1121
  _configure_editor(mycli)
1122
+ history = _create_history(mycli)
1123
+ if history is not None:
1124
+ mycli.completer.frecency_provider = lambda: history.frecency
1116
1125
  if mycli.smart_completion and not mycli.sandbox_mode:
1117
1126
  mycli.refresh_completions()
1118
1127
 
1119
- history = _create_history(mycli)
1120
1128
  key_bindings = mycli_bindings(mycli)
1121
1129
  _show_startup_banner(mycli, sqlexecute)
1122
1130
  _build_prompt_session(mycli, state, history, key_bindings)
@@ -54,6 +54,14 @@ destructive_keywords = DROP SHUTDOWN DELETE TRUNCATE ALTER UPDATE
54
54
  # interactive query history location.
55
55
  history_file = ~/.mycli-history
56
56
 
57
+ # Number of recent history entries used to calculate frecency. Set to 0 or
58
+ # leave empty to disable frecency calculation entirely.
59
+ frecency_history_entries = 1000
60
+
61
+ # Number of REPL entries between frecency recalculation. Set to 0 to disable
62
+ # periodic recalculation.
63
+ frecency_refresh_interval = 50
64
+
57
65
  # log_file location.
58
66
  log_file = ~/.mycli.log
59
67
 
@@ -422,7 +430,7 @@ default_password_field = password
422
430
  # Field/property containing the username, if --vault-username-field is not provided.
423
431
  default_username_field = username
424
432
 
425
- [boundary_beta]
433
+ [boundary]
426
434
  # Path to the HashiCorp Boundary executable used by --boundary-id.
427
435
  boundary_executable = boundary
428
436
 
@@ -0,0 +1,293 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from collections.abc import Callable, Mapping
5
+ from dataclasses import dataclass
6
+ from functools import lru_cache
7
+ import inspect
8
+ from io import StringIO
9
+ import re
10
+ import sys
11
+ import tokenize
12
+ from types import ModuleType, UnionType
13
+ from typing import Any, Union, cast, get_args, get_origin
14
+
15
+ import sqlglot
16
+
17
+ from mycli.packages.polars_transform import _pipeline_operator_indexes
18
+
19
+ _ATTRIBUTE_PATTERN = re.compile(r'(?s)(.*)\.([A-Za-z_][A-Za-z0-9_]*)?\s*$')
20
+ _ANNOTATION_NAME_PATTERN = re.compile(r'[A-Za-z_][A-Za-z0-9_]*')
21
+ _SENTINEL = '__mycli_polars_completion__'
22
+ _OPEN_TO_CLOSE = {'(': ')', '[': ']', '{': '}'}
23
+ _CLOSE = frozenset(_OPEN_TO_CLOSE.values())
24
+
25
+
26
+ @dataclass(frozen=True, slots=True)
27
+ class PolarsCompletion:
28
+ text: str
29
+ display: str
30
+ display_meta: str
31
+ start_position: int
32
+
33
+
34
+ def complete_polars_transform(command: str) -> list[PolarsCompletion] | None:
35
+ """Return safe attribute completions for a Polars transform expression."""
36
+ try:
37
+ tokens = sqlglot.tokenize(command)
38
+ pipe_index, output_index = _pipeline_operator_indexes(command, tokens, require_operands=False)
39
+ except sqlglot.errors.TokenError:
40
+ return None
41
+ if pipe_index is None:
42
+ return None
43
+ if output_index is not None:
44
+ return []
45
+
46
+ pipe = tokens[pipe_index + 1]
47
+ expression = command[pipe.end + 1 :].strip()
48
+ match = _ATTRIBUTE_PATTERN.fullmatch(expression)
49
+ if match is None:
50
+ return []
51
+ prefix = match.group(2) or ''
52
+ source = f'{match.group(1)}.{_SENTINEL}'
53
+ closed_source = _close_open_delimiters(source)
54
+ if closed_source is None:
55
+ return []
56
+ try:
57
+ parsed = ast.parse(closed_source, mode='eval')
58
+ except SyntaxError:
59
+ return []
60
+ target = next(
61
+ (node for node in ast.walk(parsed) if isinstance(node, ast.Attribute) and node.attr == _SENTINEL),
62
+ None,
63
+ )
64
+ if target is None:
65
+ return []
66
+
67
+ polars = _load_polars()
68
+ if polars is None:
69
+ return []
70
+ owner = _infer_type(target.value, polars)
71
+ if owner is None:
72
+ return []
73
+ return [
74
+ PolarsCompletion(
75
+ text=f'{name}(' if is_callable else name,
76
+ display=name,
77
+ display_meta=metadata,
78
+ start_position=-len(prefix),
79
+ )
80
+ for name, is_callable, metadata in _members(owner, polars)
81
+ if name.startswith(prefix)
82
+ ]
83
+
84
+
85
+ def _close_open_delimiters(source: str) -> str | None:
86
+ stack: list[str] = []
87
+ try:
88
+ tokens = tokenize.generate_tokens(StringIO(source).readline)
89
+ for token in tokens:
90
+ if token.type != tokenize.OP:
91
+ continue
92
+ if token.string in _OPEN_TO_CLOSE:
93
+ stack.append(token.string)
94
+ elif token.string in _CLOSE:
95
+ if not stack or _OPEN_TO_CLOSE[stack.pop()] != token.string:
96
+ return None
97
+ except (IndentationError, tokenize.TokenError):
98
+ pass
99
+ return source + ''.join(_OPEN_TO_CLOSE[value] for value in reversed(stack))
100
+
101
+
102
+ @lru_cache(maxsize=1)
103
+ def _load_polars() -> Any | None:
104
+ try:
105
+ import polars as pl
106
+ except ImportError:
107
+ return None
108
+ return pl
109
+
110
+
111
+ def _infer_type(node: ast.AST, polars: Any) -> Any | None:
112
+ if isinstance(node, ast.Name):
113
+ if node.id == 'df':
114
+ return polars.DataFrame
115
+ if node.id == 'pl':
116
+ return polars
117
+ return None
118
+ if isinstance(node, ast.Subscript):
119
+ owner = _infer_type(node.value, polars)
120
+ if owner is polars.DataFrame:
121
+ return polars.Series
122
+ if owner is polars.Expr:
123
+ return polars.Expr
124
+ return None
125
+ if isinstance(node, ast.Attribute):
126
+ owner = _infer_type(node.value, polars)
127
+ member = _static_member(owner, node.attr)
128
+ if member is None:
129
+ return None
130
+ if isinstance(member, property):
131
+ return _return_type(member.fget, owner, polars)
132
+ if inspect.isclass(member) or isinstance(member, ModuleType):
133
+ return member
134
+ return None
135
+ if isinstance(node, ast.Call):
136
+ if isinstance(node.func, ast.Attribute):
137
+ owner = _infer_type(node.func.value, polars)
138
+ member = _static_member(owner, node.func.attr)
139
+ return _call_return_type(member, owner, polars)
140
+ return None
141
+ if isinstance(node, (ast.BinOp, ast.BoolOp, ast.Compare, ast.UnaryOp)):
142
+ inferred = [_infer_type(child, polars) for child in ast.iter_child_nodes(node)]
143
+ if polars.Expr in inferred:
144
+ return polars.Expr
145
+ if polars.Series in inferred:
146
+ return polars.Series
147
+ return None
148
+
149
+
150
+ def _static_member(owner: Any | None, name: str) -> Any | None:
151
+ if owner is None:
152
+ return None
153
+ try:
154
+ member = inspect.getattr_static(owner, name)
155
+ except AttributeError:
156
+ return None
157
+ if isinstance(member, (classmethod, staticmethod)):
158
+ return member.__func__
159
+ return member
160
+
161
+
162
+ def _call_return_type(member: Any | None, owner: Any, polars: Any) -> Any | None:
163
+ if member is None:
164
+ return None
165
+ if inspect.isclass(member):
166
+ return member
167
+ result = _return_type(member, owner, polars)
168
+ if result is not None:
169
+ return result
170
+ call = _static_member(type(member), '__call__')
171
+ return _return_type(call, owner, polars)
172
+
173
+
174
+ def _return_type(callable_object: Any | None, owner: Any, polars: Any) -> Any | None:
175
+ if callable_object is None:
176
+ return None
177
+ if inspect.isroutine(callable_object):
178
+ annotations = getattr(callable_object, '__annotations__', {})
179
+ else:
180
+ try:
181
+ annotations = inspect.getattr_static(callable_object, '__annotations__')
182
+ except AttributeError:
183
+ annotations = {}
184
+ if not isinstance(annotations, Mapping):
185
+ return None
186
+ annotation = annotations.get('return')
187
+ if annotation is None or annotation is inspect.Signature.empty:
188
+ return None
189
+ if annotation is Any:
190
+ return None
191
+ if inspect.isclass(annotation):
192
+ return annotation if _is_polars_type(annotation) else None
193
+ arguments = get_args(annotation)
194
+ if arguments:
195
+ origin = cast(Any, get_origin(annotation))
196
+ if origin not in (Union, UnionType):
197
+ return None
198
+ for argument in arguments:
199
+ resolved = _resolve_annotation(argument, callable_object, owner, polars)
200
+ if resolved is not None:
201
+ return resolved
202
+ return _resolve_annotation(annotation, callable_object, owner, polars)
203
+
204
+
205
+ def _resolve_annotation(annotation: Any, callable_object: Any, owner: Any, polars: Any) -> Any | None:
206
+ if inspect.isclass(annotation):
207
+ return annotation if _is_polars_type(annotation) else None
208
+ if not isinstance(annotation, str):
209
+ return None
210
+ if annotation in ('Self', 'typing.Self'):
211
+ return owner
212
+ if '[' in annotation:
213
+ return None
214
+ namespace = getattr(callable_object, '__globals__', {}) if inspect.isroutine(callable_object) else {}
215
+ for name in _ANNOTATION_NAME_PATTERN.findall(annotation):
216
+ candidate = namespace.get(name)
217
+ if not inspect.isclass(candidate):
218
+ candidate = _static_member(polars, name)
219
+ if not inspect.isclass(candidate):
220
+ candidate = _polars_types_by_name(polars).get(name)
221
+ if inspect.isclass(candidate) and _is_polars_type(candidate):
222
+ return candidate
223
+ return None
224
+
225
+
226
+ def _is_polars_type(value: type[Any]) -> bool:
227
+ return value.__module__ == 'polars' or value.__module__.startswith('polars.')
228
+
229
+
230
+ @lru_cache(maxsize=1)
231
+ def _polars_types_by_name(polars: Any) -> Mapping[str, type[Any]]:
232
+ """Index classes in loaded Polars modules without importing annotation paths."""
233
+ candidates: dict[str, type[Any] | None] = {}
234
+ modules = [
235
+ module
236
+ for module_name, module in tuple(sys.modules.items())
237
+ if module is not None and (module is polars or module_name.startswith('polars.'))
238
+ ]
239
+ for module in modules:
240
+ for value in vars(module).values():
241
+ if not inspect.isclass(value) or not _is_polars_type(value):
242
+ continue
243
+ name = value.__name__
244
+ existing = candidates.get(name, value)
245
+ candidates[name] = value if existing is value else None
246
+ return {name: value for name, value in candidates.items() if value is not None}
247
+
248
+
249
+ @lru_cache(maxsize=None)
250
+ def _members(owner: Any, polars: Any) -> tuple[tuple[str, bool, str], ...]:
251
+ members: list[tuple[str, bool, str]] = []
252
+ for name in dir(owner):
253
+ if name.startswith('_'):
254
+ continue
255
+ member = _static_member(owner, name)
256
+ if member is None:
257
+ continue
258
+ if isinstance(member, property):
259
+ members.append((name, False, _property_metadata(member, owner, polars)))
260
+ elif callable(member):
261
+ members.append((name, True, _signature_metadata(member)))
262
+ return tuple(members)
263
+
264
+
265
+ def _property_metadata(member: property, owner: Any, polars: Any) -> str:
266
+ result = _return_type(member.fget, owner, polars)
267
+ return result.__name__ if inspect.isclass(result) else 'property'
268
+
269
+
270
+ def _signature_metadata(member: Callable[..., Any]) -> str:
271
+ callable_object: Any = member
272
+ try:
273
+ signature = inspect.signature(callable_object)
274
+ except (TypeError, ValueError):
275
+ callable_object = _static_member(type(member), '__call__')
276
+ try:
277
+ signature = inspect.signature(callable_object)
278
+ except (TypeError, ValueError):
279
+ return '()'
280
+ parameters = [parameter for parameter in signature.parameters.values() if parameter.name not in ('self', 'cls')]
281
+ rendered: list[str] = []
282
+ for parameter in parameters[:4]:
283
+ text = parameter.name
284
+ if parameter.kind == inspect.Parameter.VAR_POSITIONAL:
285
+ text = f'*{text}'
286
+ elif parameter.kind == inspect.Parameter.VAR_KEYWORD:
287
+ text = f'**{text}'
288
+ elif parameter.default is not inspect.Parameter.empty:
289
+ text = f'{text}=...'
290
+ rendered.append(text)
291
+ if len(parameters) > 4:
292
+ rendered.append('...')
293
+ return f"({', '.join(rendered)})"
@@ -66,30 +66,7 @@ def parse_polars_transform(command: str) -> PolarsPipeline | None:
66
66
  except sqlglot.errors.TokenError as exc:
67
67
  raise PolarsTransformError(f'Unable to parse Polars transform: {exc}') from exc
68
68
 
69
- pipe_index: int | None = None
70
- parquet_index: int | None = None
71
- for index, token in enumerate(tokens[:-1]):
72
- following = tokens[index + 1]
73
- if token.token_type != sqlglot.TokenType.DOT:
74
- continue
75
- if token.start == 0 or not command[token.start - 1].isspace():
76
- continue
77
- if following.token_type not in (sqlglot.TokenType.PIPE, sqlglot.TokenType.GT):
78
- continue
79
- if following.end + 1 >= len(command):
80
- if following.token_type == sqlglot.TokenType.PIPE:
81
- raise PolarsTransformError('Polars transforms require a Python expression.')
82
- raise PolarsTransformError('File saves require a destination path.')
83
- if not command[following.end + 1].isspace():
84
- continue
85
- if following.token_type == sqlglot.TokenType.PIPE:
86
- if pipe_index is not None:
87
- raise PolarsTransformError('Polars transforms support only one ".|" operator.')
88
- pipe_index = index
89
- else:
90
- if parquet_index is not None:
91
- raise PolarsTransformError('File saves support only one ".>" operator.')
92
- parquet_index = index
69
+ pipe_index, parquet_index = _pipeline_operator_indexes(command, tokens)
93
70
 
94
71
  if pipe_index is None and parquet_index is None:
95
72
  return None
@@ -138,6 +115,41 @@ def parse_polars_transform(command: str) -> PolarsPipeline | None:
138
115
  return PolarsPipeline(sql=sql, expression=expression, output_path=output_path, output_mode=output_mode)
139
116
 
140
117
 
118
+ def _pipeline_operator_indexes(
119
+ command: str,
120
+ tokens: list[sqlglot.Token],
121
+ *,
122
+ require_operands: bool = True,
123
+ ) -> tuple[int | None, int | None]:
124
+ """Locate transform and redirect operators in tokenized command text."""
125
+
126
+ pipe_index: int | None = None
127
+ parquet_index: int | None = None
128
+ for index, token in enumerate(tokens[:-1]):
129
+ following = tokens[index + 1]
130
+ if token.token_type != sqlglot.TokenType.DOT:
131
+ continue
132
+ if token.start == 0 or not command[token.start - 1].isspace():
133
+ continue
134
+ if following.token_type not in (sqlglot.TokenType.PIPE, sqlglot.TokenType.GT):
135
+ continue
136
+ if following.end + 1 >= len(command) and require_operands:
137
+ if following.token_type == sqlglot.TokenType.PIPE:
138
+ raise PolarsTransformError('Polars transforms require a Python expression.')
139
+ raise PolarsTransformError('File saves require a destination path.')
140
+ if following.end + 1 < len(command) and not command[following.end + 1].isspace():
141
+ continue
142
+ if following.token_type == sqlglot.TokenType.PIPE:
143
+ if pipe_index is not None:
144
+ raise PolarsTransformError('Polars transforms support only one ".|" operator.')
145
+ pipe_index = index
146
+ else:
147
+ if parquet_index is not None:
148
+ raise PolarsTransformError('File saves support only one ".>" operator.')
149
+ parquet_index = index
150
+ return pipe_index, parquet_index
151
+
152
+
141
153
  def _parse_output_path(path: str) -> str:
142
154
  if path[0] in ('\'', '"'):
143
155
  if len(path) < 2 or path[-1] != path[0]: