mycli 2.26.0__tar.gz → 2.27.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 (206) hide show
  1. {mycli-2.26.0 → mycli-2.27.0}/.github/workflows/ci.yml +4 -4
  2. {mycli-2.26.0 → mycli-2.27.0}/.github/workflows/publish.yml +4 -4
  3. {mycli-2.26.0 → mycli-2.27.0}/.github/workflows/typecheck.yml +1 -1
  4. {mycli-2.26.0 → mycli-2.27.0}/AGENTS.md +1 -0
  5. {mycli-2.26.0/mycli.egg-info → mycli-2.27.0}/PKG-INFO +1 -1
  6. {mycli-2.26.0 → mycli-2.27.0}/changelog.md +15 -0
  7. {mycli-2.26.0 → mycli-2.27.0}/doc/transforms.md +0 -1
  8. {mycli-2.26.0 → mycli-2.27.0}/docker/resources/test_db-1.0.7.tar.gz +1 -1
  9. {mycli-2.26.0 → mycli-2.27.0}/mycli/constants.py +11 -0
  10. {mycli-2.26.0 → mycli-2.27.0}/mycli/main_modes/repl.py +66 -26
  11. {mycli-2.26.0 → mycli-2.27.0}/mycli/myclirc +6 -0
  12. {mycli-2.26.0 → mycli-2.27.0}/mycli/output.py +14 -0
  13. {mycli-2.26.0 → mycli-2.27.0}/mycli/packages/hybrid_redirection.py +75 -27
  14. {mycli-2.26.0 → mycli-2.27.0}/mycli/packages/polars_completion.py +3 -0
  15. {mycli-2.26.0 → mycli-2.27.0}/mycli/packages/polars_transform.py +36 -1
  16. {mycli-2.26.0 → mycli-2.27.0}/mycli/packages/special/iocommands.py +106 -22
  17. mycli-2.27.0/mycli/query_runner.py +355 -0
  18. {mycli-2.26.0 → mycli-2.27.0}/mycli/sqlexecute.py +32 -1
  19. {mycli-2.26.0 → mycli-2.27.0/mycli.egg-info}/PKG-INFO +1 -1
  20. {mycli-2.26.0 → mycli-2.27.0}/mycli.egg-info/SOURCES.txt +2 -0
  21. {mycli-2.26.0 → mycli-2.27.0}/mycli.egg-info/scm_file_list.json +2 -0
  22. mycli-2.27.0/mycli.egg-info/scm_version.json +8 -0
  23. {mycli-2.26.0 → mycli-2.27.0}/test/myclirc +6 -0
  24. {mycli-2.26.0 → mycli-2.27.0}/test/pytests/test_hybrid_redirection.py +74 -0
  25. {mycli-2.26.0 → mycli-2.27.0}/test/pytests/test_main_modes_repl.py +256 -3
  26. {mycli-2.26.0 → mycli-2.27.0}/test/pytests/test_output.py +38 -0
  27. {mycli-2.26.0 → mycli-2.27.0}/test/pytests/test_polars_completion.py +5 -0
  28. {mycli-2.26.0 → mycli-2.27.0}/test/pytests/test_polars_transform.py +80 -0
  29. mycli-2.27.0/test/pytests/test_query_runner.py +855 -0
  30. {mycli-2.26.0 → mycli-2.27.0}/test/pytests/test_special_iocommands.py +294 -11
  31. {mycli-2.26.0 → mycli-2.27.0}/test/pytests/test_sqlexecute.py +143 -0
  32. {mycli-2.26.0 → mycli-2.27.0}/test/utils.py +1 -1
  33. mycli-2.26.0/mycli.egg-info/scm_version.json +0 -8
  34. {mycli-2.26.0 → mycli-2.27.0}/.git-blame-ignore-revs +0 -0
  35. {mycli-2.26.0 → mycli-2.27.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  36. {mycli-2.26.0 → mycli-2.27.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  37. {mycli-2.26.0 → mycli-2.27.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  38. {mycli-2.26.0 → mycli-2.27.0}/.github/dependabot.yml +0 -0
  39. {mycli-2.26.0 → mycli-2.27.0}/.github/workflows/lint.yml +0 -0
  40. {mycli-2.26.0 → mycli-2.27.0}/.gitignore +0 -0
  41. {mycli-2.26.0 → mycli-2.27.0}/AUTHORS.md +0 -0
  42. {mycli-2.26.0 → mycli-2.27.0}/CONTRIBUTING.md +0 -0
  43. {mycli-2.26.0 → mycli-2.27.0}/LICENSE.txt +0 -0
  44. {mycli-2.26.0 → mycli-2.27.0}/MANIFEST.in +0 -0
  45. {mycli-2.26.0 → mycli-2.27.0}/README.md +0 -0
  46. {mycli-2.26.0 → mycli-2.27.0}/SPONSORS.md +0 -0
  47. {mycli-2.26.0 → mycli-2.27.0}/doc/key_bindings.md +0 -0
  48. {mycli-2.26.0 → mycli-2.27.0}/doc/llm.md +0 -0
  49. {mycli-2.26.0 → mycli-2.27.0}/doc/screenshots/main.gif +0 -0
  50. {mycli-2.26.0 → mycli-2.27.0}/doc/screenshots/polars_describe.png +0 -0
  51. {mycli-2.26.0 → mycli-2.27.0}/doc/screenshots/polars_group_by.png +0 -0
  52. {mycli-2.26.0 → mycli-2.27.0}/doc/screenshots/polars_histogram.png +0 -0
  53. {mycli-2.26.0 → mycli-2.27.0}/doc/screenshots/polars_pivot.png +0 -0
  54. {mycli-2.26.0 → mycli-2.27.0}/doc/screenshots/tables.png +0 -0
  55. {mycli-2.26.0 → mycli-2.27.0}/docker/Dockerfile +0 -0
  56. {mycli-2.26.0 → mycli-2.27.0}/docker/resources/test_db_license.md +0 -0
  57. {mycli-2.26.0 → mycli-2.27.0}/mycli/AUTHORS +0 -0
  58. {mycli-2.26.0 → mycli-2.27.0}/mycli/SPONSORS +0 -0
  59. {mycli-2.26.0 → mycli-2.27.0}/mycli/TIPS +0 -0
  60. {mycli-2.26.0 → mycli-2.27.0}/mycli/__init__.py +0 -0
  61. {mycli-2.26.0 → mycli-2.27.0}/mycli/app_state.py +0 -0
  62. {mycli-2.26.0 → mycli-2.27.0}/mycli/boundary_tunnel.py +0 -0
  63. {mycli-2.26.0 → mycli-2.27.0}/mycli/cli_runner.py +0 -0
  64. {mycli-2.26.0 → mycli-2.27.0}/mycli/clibuffer.py +0 -0
  65. {mycli-2.26.0 → mycli-2.27.0}/mycli/client.py +0 -0
  66. {mycli-2.26.0 → mycli-2.27.0}/mycli/client_commands.py +0 -0
  67. {mycli-2.26.0 → mycli-2.27.0}/mycli/client_connection.py +0 -0
  68. {mycli-2.26.0 → mycli-2.27.0}/mycli/client_query.py +0 -0
  69. {mycli-2.26.0 → mycli-2.27.0}/mycli/clistyle.py +0 -0
  70. {mycli-2.26.0 → mycli-2.27.0}/mycli/clitoolbar.py +0 -0
  71. {mycli-2.26.0 → mycli-2.27.0}/mycli/compat.py +0 -0
  72. {mycli-2.26.0 → mycli-2.27.0}/mycli/completion_refresher.py +0 -0
  73. {mycli-2.26.0 → mycli-2.27.0}/mycli/config.py +0 -0
  74. {mycli-2.26.0 → mycli-2.27.0}/mycli/key_bindings.py +0 -0
  75. {mycli-2.26.0 → mycli-2.27.0}/mycli/keyring_utils.py +0 -0
  76. {mycli-2.26.0 → mycli-2.27.0}/mycli/kubectl_tunnel.py +0 -0
  77. {mycli-2.26.0 → mycli-2.27.0}/mycli/lexer.py +0 -0
  78. {mycli-2.26.0 → mycli-2.27.0}/mycli/macos_keychain.py +0 -0
  79. {mycli-2.26.0 → mycli-2.27.0}/mycli/main.py +0 -0
  80. {mycli-2.26.0 → mycli-2.27.0}/mycli/main_modes/batch.py +0 -0
  81. {mycli-2.26.0 → mycli-2.27.0}/mycli/main_modes/checkup.py +0 -0
  82. {mycli-2.26.0 → mycli-2.27.0}/mycli/main_modes/completions.py +0 -0
  83. {mycli-2.26.0 → mycli-2.27.0}/mycli/main_modes/execute.py +0 -0
  84. {mycli-2.26.0 → mycli-2.27.0}/mycli/main_modes/list_dsn.py +0 -0
  85. {mycli-2.26.0 → mycli-2.27.0}/mycli/packages/__init__.py +0 -0
  86. {mycli-2.26.0 → mycli-2.27.0}/mycli/packages/batch_utils.py +0 -0
  87. {mycli-2.26.0 → mycli-2.27.0}/mycli/packages/cli_utils.py +0 -0
  88. {mycli-2.26.0 → mycli-2.27.0}/mycli/packages/completion_engine.py +0 -0
  89. {mycli-2.26.0 → mycli-2.27.0}/mycli/packages/filepaths.py +0 -0
  90. {mycli-2.26.0 → mycli-2.27.0}/mycli/packages/interactive_utils.py +0 -0
  91. {mycli-2.26.0 → mycli-2.27.0}/mycli/packages/key_binding_utils.py +0 -0
  92. {mycli-2.26.0 → mycli-2.27.0}/mycli/packages/ptoolkit/__init__.py +0 -0
  93. {mycli-2.26.0 → mycli-2.27.0}/mycli/packages/ptoolkit/fzf.py +0 -0
  94. {mycli-2.26.0 → mycli-2.27.0}/mycli/packages/ptoolkit/history.py +0 -0
  95. {mycli-2.26.0 → mycli-2.27.0}/mycli/packages/ptoolkit/utils.py +0 -0
  96. {mycli-2.26.0 → mycli-2.27.0}/mycli/packages/special/__init__.py +0 -0
  97. {mycli-2.26.0 → mycli-2.27.0}/mycli/packages/special/dbcommands.py +0 -0
  98. {mycli-2.26.0 → mycli-2.27.0}/mycli/packages/special/delimitercommand.py +0 -0
  99. {mycli-2.26.0 → mycli-2.27.0}/mycli/packages/special/dsn_aliases.py +0 -0
  100. {mycli-2.26.0 → mycli-2.27.0}/mycli/packages/special/favoritequeries.py +0 -0
  101. {mycli-2.26.0 → mycli-2.27.0}/mycli/packages/special/llm.py +0 -0
  102. {mycli-2.26.0 → mycli-2.27.0}/mycli/packages/special/main.py +0 -0
  103. {mycli-2.26.0 → mycli-2.27.0}/mycli/packages/special/source.py +0 -0
  104. {mycli-2.26.0 → mycli-2.27.0}/mycli/packages/special/utils.py +0 -0
  105. {mycli-2.26.0 → mycli-2.27.0}/mycli/packages/sql_utils.py +0 -0
  106. {mycli-2.26.0 → mycli-2.27.0}/mycli/packages/sqlresult.py +0 -0
  107. {mycli-2.26.0 → mycli-2.27.0}/mycli/packages/string_utils.py +0 -0
  108. {mycli-2.26.0 → mycli-2.27.0}/mycli/packages/tabular_output/__init__.py +0 -0
  109. {mycli-2.26.0 → mycli-2.27.0}/mycli/packages/tabular_output/sql_format.py +0 -0
  110. {mycli-2.26.0 → mycli-2.27.0}/mycli/password_sources.py +0 -0
  111. {mycli-2.26.0 → mycli-2.27.0}/mycli/resources/completions/bash/mycli +0 -0
  112. {mycli-2.26.0 → mycli-2.27.0}/mycli/resources/completions/fish/mycli.fish +0 -0
  113. {mycli-2.26.0 → mycli-2.27.0}/mycli/resources/completions/zsh/_mycli +0 -0
  114. {mycli-2.26.0 → mycli-2.27.0}/mycli/schema_prefetcher.py +0 -0
  115. {mycli-2.26.0 → mycli-2.27.0}/mycli/sqlcompleter.py +0 -0
  116. {mycli-2.26.0 → mycli-2.27.0}/mycli/ssh_tunnel.py +0 -0
  117. {mycli-2.26.0 → mycli-2.27.0}/mycli/types.py +0 -0
  118. {mycli-2.26.0 → mycli-2.27.0}/mycli/vault.py +0 -0
  119. {mycli-2.26.0 → mycli-2.27.0}/mycli.egg-info/dependency_links.txt +0 -0
  120. {mycli-2.26.0 → mycli-2.27.0}/mycli.egg-info/entry_points.txt +0 -0
  121. {mycli-2.26.0 → mycli-2.27.0}/mycli.egg-info/requires.txt +0 -0
  122. {mycli-2.26.0 → mycli-2.27.0}/mycli.egg-info/top_level.txt +0 -0
  123. {mycli-2.26.0 → mycli-2.27.0}/pyproject.toml +0 -0
  124. {mycli-2.26.0 → mycli-2.27.0}/setup.cfg +0 -0
  125. {mycli-2.26.0 → mycli-2.27.0}/test/__init__.py +0 -0
  126. {mycli-2.26.0 → mycli-2.27.0}/test/features/__init__.py +0 -0
  127. {mycli-2.26.0 → mycli-2.27.0}/test/features/auto_vertical.feature +0 -0
  128. {mycli-2.26.0 → mycli-2.27.0}/test/features/basic_commands.feature +0 -0
  129. {mycli-2.26.0 → mycli-2.27.0}/test/features/connection.feature +0 -0
  130. {mycli-2.26.0 → mycli-2.27.0}/test/features/crud_database.feature +0 -0
  131. {mycli-2.26.0 → mycli-2.27.0}/test/features/crud_table.feature +0 -0
  132. {mycli-2.26.0 → mycli-2.27.0}/test/features/db_utils.py +0 -0
  133. {mycli-2.26.0 → mycli-2.27.0}/test/features/environment.py +0 -0
  134. {mycli-2.26.0 → mycli-2.27.0}/test/features/fixture_data/help_commands.txt +0 -0
  135. {mycli-2.26.0 → mycli-2.27.0}/test/features/fixture_utils.py +0 -0
  136. {mycli-2.26.0 → mycli-2.27.0}/test/features/iocommands.feature +0 -0
  137. {mycli-2.26.0 → mycli-2.27.0}/test/features/named_queries.feature +0 -0
  138. {mycli-2.26.0 → mycli-2.27.0}/test/features/specials.feature +0 -0
  139. {mycli-2.26.0 → mycli-2.27.0}/test/features/steps/__init__.py +0 -0
  140. {mycli-2.26.0 → mycli-2.27.0}/test/features/steps/auto_vertical.py +0 -0
  141. {mycli-2.26.0 → mycli-2.27.0}/test/features/steps/basic_commands.py +0 -0
  142. {mycli-2.26.0 → mycli-2.27.0}/test/features/steps/connection.py +0 -0
  143. {mycli-2.26.0 → mycli-2.27.0}/test/features/steps/crud_database.py +0 -0
  144. {mycli-2.26.0 → mycli-2.27.0}/test/features/steps/crud_table.py +0 -0
  145. {mycli-2.26.0 → mycli-2.27.0}/test/features/steps/iocommands.py +0 -0
  146. {mycli-2.26.0 → mycli-2.27.0}/test/features/steps/named_queries.py +0 -0
  147. {mycli-2.26.0 → mycli-2.27.0}/test/features/steps/specials.py +0 -0
  148. {mycli-2.26.0 → mycli-2.27.0}/test/features/steps/utils.py +0 -0
  149. {mycli-2.26.0 → mycli-2.27.0}/test/features/steps/wrappers.py +0 -0
  150. {mycli-2.26.0 → mycli-2.27.0}/test/features/wrappager.py +0 -0
  151. {mycli-2.26.0 → mycli-2.27.0}/test/mylogin.cnf +0 -0
  152. {mycli-2.26.0 → mycli-2.27.0}/test/pytests/conftest.py +0 -0
  153. {mycli-2.26.0 → mycli-2.27.0}/test/pytests/test_app_state.py +0 -0
  154. {mycli-2.26.0 → mycli-2.27.0}/test/pytests/test_bash_completion.py +0 -0
  155. {mycli-2.26.0 → mycli-2.27.0}/test/pytests/test_batch_utils.py +0 -0
  156. {mycli-2.26.0 → mycli-2.27.0}/test/pytests/test_boundary_tunnel.py +0 -0
  157. {mycli-2.26.0 → mycli-2.27.0}/test/pytests/test_checkup.py +0 -0
  158. {mycli-2.26.0 → mycli-2.27.0}/test/pytests/test_cli_runner.py +0 -0
  159. {mycli-2.26.0 → mycli-2.27.0}/test/pytests/test_cli_utils.py +0 -0
  160. {mycli-2.26.0 → mycli-2.27.0}/test/pytests/test_clibuffer.py +0 -0
  161. {mycli-2.26.0 → mycli-2.27.0}/test/pytests/test_client.py +0 -0
  162. {mycli-2.26.0 → mycli-2.27.0}/test/pytests/test_client_commands.py +0 -0
  163. {mycli-2.26.0 → mycli-2.27.0}/test/pytests/test_client_connection.py +0 -0
  164. {mycli-2.26.0 → mycli-2.27.0}/test/pytests/test_client_query.py +0 -0
  165. {mycli-2.26.0 → mycli-2.27.0}/test/pytests/test_clistyle.py +0 -0
  166. {mycli-2.26.0 → mycli-2.27.0}/test/pytests/test_clitoolbar.py +0 -0
  167. {mycli-2.26.0 → mycli-2.27.0}/test/pytests/test_completer_use_switch.py +0 -0
  168. {mycli-2.26.0 → mycli-2.27.0}/test/pytests/test_completion_engine.py +0 -0
  169. {mycli-2.26.0 → mycli-2.27.0}/test/pytests/test_completion_refresher.py +0 -0
  170. {mycli-2.26.0 → mycli-2.27.0}/test/pytests/test_config.py +0 -0
  171. {mycli-2.26.0 → mycli-2.27.0}/test/pytests/test_delimitercommand.py +0 -0
  172. {mycli-2.26.0 → mycli-2.27.0}/test/pytests/test_dsn_aliases.py +0 -0
  173. {mycli-2.26.0 → mycli-2.27.0}/test/pytests/test_favoritequeries.py +0 -0
  174. {mycli-2.26.0 → mycli-2.27.0}/test/pytests/test_filepaths.py +0 -0
  175. {mycli-2.26.0 → mycli-2.27.0}/test/pytests/test_fish_completion.py +0 -0
  176. {mycli-2.26.0 → mycli-2.27.0}/test/pytests/test_interactive_utils.py +0 -0
  177. {mycli-2.26.0 → mycli-2.27.0}/test/pytests/test_key_binding_utils.py +0 -0
  178. {mycli-2.26.0 → mycli-2.27.0}/test/pytests/test_key_bindings.py +0 -0
  179. {mycli-2.26.0 → mycli-2.27.0}/test/pytests/test_keyring_utils.py +0 -0
  180. {mycli-2.26.0 → mycli-2.27.0}/test/pytests/test_kubectl_tunnel.py +0 -0
  181. {mycli-2.26.0 → mycli-2.27.0}/test/pytests/test_macos_keychain.py +0 -0
  182. {mycli-2.26.0 → mycli-2.27.0}/test/pytests/test_main.py +0 -0
  183. {mycli-2.26.0 → mycli-2.27.0}/test/pytests/test_main_modes_batch.py +0 -0
  184. {mycli-2.26.0 → mycli-2.27.0}/test/pytests/test_main_modes_execute.py +0 -0
  185. {mycli-2.26.0 → mycli-2.27.0}/test/pytests/test_main_modes_list_dsn.py +0 -0
  186. {mycli-2.26.0 → mycli-2.27.0}/test/pytests/test_naive_completion.py +0 -0
  187. {mycli-2.26.0 → mycli-2.27.0}/test/pytests/test_password_sources.py +0 -0
  188. {mycli-2.26.0 → mycli-2.27.0}/test/pytests/test_ptoolkit_fzf.py +0 -0
  189. {mycli-2.26.0 → mycli-2.27.0}/test/pytests/test_ptoolkit_history.py +0 -0
  190. {mycli-2.26.0 → mycli-2.27.0}/test/pytests/test_ptoolkit_utils.py +0 -0
  191. {mycli-2.26.0 → mycli-2.27.0}/test/pytests/test_schema_prefetcher.py +0 -0
  192. {mycli-2.26.0 → mycli-2.27.0}/test/pytests/test_smart_completion_public_schema_only.py +0 -0
  193. {mycli-2.26.0 → mycli-2.27.0}/test/pytests/test_special_dbcommands.py +0 -0
  194. {mycli-2.26.0 → mycli-2.27.0}/test/pytests/test_special_init.py +0 -0
  195. {mycli-2.26.0 → mycli-2.27.0}/test/pytests/test_special_llm.py +0 -0
  196. {mycli-2.26.0 → mycli-2.27.0}/test/pytests/test_special_main.py +0 -0
  197. {mycli-2.26.0 → mycli-2.27.0}/test/pytests/test_special_source.py +0 -0
  198. {mycli-2.26.0 → mycli-2.27.0}/test/pytests/test_special_utils.py +0 -0
  199. {mycli-2.26.0 → mycli-2.27.0}/test/pytests/test_sql_utils.py +0 -0
  200. {mycli-2.26.0 → mycli-2.27.0}/test/pytests/test_sqlcompleter.py +0 -0
  201. {mycli-2.26.0 → mycli-2.27.0}/test/pytests/test_sqlresult.py +0 -0
  202. {mycli-2.26.0 → mycli-2.27.0}/test/pytests/test_ssh_tunnel.py +0 -0
  203. {mycli-2.26.0 → mycli-2.27.0}/test/pytests/test_string_utils.py +0 -0
  204. {mycli-2.26.0 → mycli-2.27.0}/test/pytests/test_tabular_output.py +0 -0
  205. {mycli-2.26.0 → mycli-2.27.0}/test/pytests/test_vault.py +0 -0
  206. {mycli-2.26.0 → mycli-2.27.0}/test/pytests/test_zsh_completion.py +0 -0
@@ -24,7 +24,7 @@ jobs:
24
24
  steps:
25
25
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
26
26
 
27
- - uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
27
+ - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
28
28
  with:
29
29
  version: "latest"
30
30
 
@@ -66,7 +66,7 @@ jobs:
66
66
  steps:
67
67
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
68
68
 
69
- - uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
69
+ - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
70
70
  with:
71
71
  version: "latest"
72
72
 
@@ -116,7 +116,7 @@ jobs:
116
116
  steps:
117
117
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
118
118
 
119
- - uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
119
+ - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
120
120
  with:
121
121
  version: 'latest'
122
122
 
@@ -220,7 +220,7 @@ jobs:
220
220
  steps:
221
221
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
222
222
 
223
- - uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
223
+ - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
224
224
  with:
225
225
  version: "latest"
226
226
 
@@ -32,7 +32,7 @@ jobs:
32
32
 
33
33
  steps:
34
34
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
35
- - uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
35
+ - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
36
36
  with:
37
37
  version: "latest"
38
38
 
@@ -77,7 +77,7 @@ jobs:
77
77
  steps:
78
78
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
79
79
 
80
- - uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
80
+ - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
81
81
  with:
82
82
  version: "latest"
83
83
 
@@ -127,7 +127,7 @@ jobs:
127
127
  steps:
128
128
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
129
129
 
130
- - uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
130
+ - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
131
131
  with:
132
132
  version: 'latest'
133
133
 
@@ -230,7 +230,7 @@ jobs:
230
230
 
231
231
  steps:
232
232
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
233
- - uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
233
+ - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
234
234
  with:
235
235
  version: "latest"
236
236
 
@@ -25,7 +25,7 @@ jobs:
25
25
  with:
26
26
  python-version: '3.14'
27
27
 
28
- - uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
28
+ - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
29
29
  with:
30
30
  version: 'latest'
31
31
 
@@ -56,6 +56,7 @@ A command line client for MySQL with auto-completion and syntax highlighting.
56
56
  ├── mycli/packages/string_utils.py # generic string utilities
57
57
  ├── mycli/packages/tabular_output/ # extends cli_helper with additional output formats
58
58
  ├── mycli/password_sources.py # password sources and precedence
59
+ ├── mycli/query_runner.py # run queries with query state and timing updates
59
60
  ├── mycli/resources/completions/ # shell completions for CLI interface
60
61
  ├── mycli/ssh_tunnel.py # connecting over a tunnel with `--ssh-jump`
61
62
  ├── mycli/schema_prefetcher.py # background prefetcher for multi-schema auto-completion
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mycli
3
- Version: 2.26.0
3
+ Version: 2.27.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
@@ -1,3 +1,18 @@
1
+ v2.27.0 (2026/09/29)
2
+ ==============
3
+
4
+ Features
5
+ --------
6
+ * Show query state and timings while waiting.
7
+ * Allow composing trailing shell redirections with `.|` transforms.
8
+
9
+
10
+ Bug Fixes
11
+ --------
12
+ * Don't treat `-o` as a flag after `$>` redirects.
13
+ * Timed-out shell pipelines no longer report success.
14
+
15
+
1
16
  v2.26.0 (2026/09/26)
2
17
  ==============
3
18
 
@@ -21,7 +21,6 @@ functionality may still change.
21
21
 
22
22
  Here are some known limitations:
23
23
 
24
- * transforms can't be composed with `$|` shell redirection
25
24
  * composing multiple transform operations is not supported
26
25
  * results from `UNION`s may be unable to be transformed
27
26
 
@@ -1,4 +1,4 @@
1
1
  [diffend] Oversized file quarantined before diffing.
2
- name: mycli-2.26.0/docker/resources/test_db-1.0.7.tar.gz
2
+ name: mycli-2.27.0/docker/resources/test_db-1.0.7.tar.gz
3
3
  size: 35607473 bytes
4
4
  sha256: c44c140f352f35d47fdb65df60f52b779ef552822fad6c4efcfa7b134c3faf84
@@ -1,3 +1,5 @@
1
+ import enum
2
+
1
3
  HOME_URL = 'https://mycli.net'
2
4
  REPO_URL = 'https://github.com/dbcli/mycli'
3
5
  DOCS_URL = f'{HOME_URL}/docs'
@@ -51,3 +53,12 @@ MYSQL_ESCAPES = {
51
53
  't': '\t',
52
54
  'Z': '\x1a',
53
55
  }
56
+
57
+ TTY_ERASE_LINE = '\r\x1b[2K'
58
+
59
+
60
+ class QueryState(enum.StrEnum):
61
+ RENDERING = 'rendering'
62
+ TRANSFORMING = 'transforming'
63
+ UNKNOWN = 'unknown'
64
+ INITIAL = 'finding state'
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from collections.abc import Generator, Iterable
4
+ from contextlib import nullcontext
4
5
  from dataclasses import dataclass
5
6
  from datetime import datetime
6
7
  import functools
@@ -57,6 +58,7 @@ from mycli.constants import (
57
58
  ER_MUST_CHANGE_PASSWORD,
58
59
  HOME_URL,
59
60
  ISSUES_URL,
61
+ QueryState,
60
62
  )
61
63
  from mycli.key_bindings import mycli_bindings
62
64
  from mycli.lexer import MyCliLexer
@@ -76,6 +78,7 @@ from mycli.packages.polars_transform import (
76
78
  run_polars_transform,
77
79
  )
78
80
  from mycli.packages.ptoolkit.history import FRECENCY_HISTORY_ENTRIES, FRECENCY_REFRESH_INTERVAL, FileHistoryWithTimestamp
81
+ from mycli.packages.special.iocommands import temporary_redirect
79
82
  from mycli.packages.special.utils import format_uptime, get_ssl_version, get_uptime, get_warning_count
80
83
  from mycli.packages.sql_utils import (
81
84
  extract_new_password,
@@ -89,6 +92,7 @@ from mycli.packages.sql_utils import (
89
92
  )
90
93
  from mycli.packages.sqlresult import SQLResult
91
94
  from mycli.packages.string_utils import sanitize_terminal_title
95
+ from mycli.query_runner import QueryCancelled, QueryRunner, runner_for
92
96
  from mycli.sqlexecute import SQLExecute
93
97
  from mycli.types import Query
94
98
 
@@ -452,6 +456,8 @@ def _output_results(
452
456
  state: ReplState,
453
457
  results: Iterable[SQLResult],
454
458
  start: float,
459
+ *,
460
+ raise_interrupts: bool = False,
455
461
  ) -> None:
456
462
  sqlexecute = mycli.sqlexecute
457
463
  assert sqlexecute is not None
@@ -468,7 +474,9 @@ def _output_results(
468
474
  return
469
475
  paged_output = _single_paged_output_results(mycli, state, result_iterator, start)
470
476
  try:
471
- click.echo_via_pager(paged_output)
477
+ runner = runner_for(mycli)
478
+ with runner.suspend_display() if runner else nullcontext():
479
+ click.echo_via_pager(paged_output)
472
480
  except KeyboardInterrupt:
473
481
  pass
474
482
  finally:
@@ -504,6 +512,8 @@ def _output_results(
504
512
  watch_count += 1
505
513
 
506
514
  if is_select(result.status_plain) and isinstance(result.rows, Cursor) and result.rows.rowcount > threshold:
515
+ if runner := runner_for(mycli):
516
+ runner.reset_progress()
507
517
  mycli.echo(
508
518
  f'The result set has more than {threshold} rows.',
509
519
  fg='red',
@@ -537,14 +547,16 @@ def _output_results(
537
547
  try:
538
548
  mycli.output(formatted, result)
539
549
  except KeyboardInterrupt:
540
- pass
550
+ if raise_interrupts:
551
+ raise
541
552
  if mycli.beep_after_seconds > 0 and duration >= mycli.beep_after_seconds:
542
553
  assert mycli.prompt_session is not None
543
554
  mycli.prompt_session.output.bell()
544
555
  if special.is_timing_enabled():
545
556
  mycli.output_timing(f'Time: {duration:0.03f}s')
546
557
  except KeyboardInterrupt:
547
- pass
558
+ if raise_interrupts:
559
+ raise
548
560
 
549
561
  start = time.time()
550
562
  result_count += 1
@@ -928,38 +940,52 @@ def _one_iteration(
928
940
  mycli.log_query(query_history_text)
929
941
 
930
942
  start = time.time()
943
+ if runner := runner_for(mycli):
944
+ runner.reset_progress()
931
945
  results = sqlexecute.run(text)
932
946
  mycli.main_formatter.query = text
933
947
  mycli.redirect_formatter.query = text
934
948
  mycli.explorer_formatter.query = text
935
949
  if polars_transform is not None:
936
950
  assert polars_pipeline is not None
937
- if polars_pipeline.output_path is None:
938
- polars_result = run_polars_transform(
939
- polars_transform,
940
- results,
941
- image_protocol=mycli.image_protocol,
942
- plot_scale_factor=mycli.plot_scale_factor,
943
- plot_ppi=mycli.plot_ppi,
944
- plot_theme=mycli.plot_theme,
945
- )
946
- else:
947
- polars_result = run_polars_transform(
948
- polars_transform,
949
- results,
950
- polars_pipeline.output_path,
951
- original_query=original_text,
952
- image_protocol=mycli.image_protocol,
953
- plot_scale_factor=mycli.plot_scale_factor,
954
- plot_ppi=mycli.plot_ppi,
955
- plot_theme=mycli.plot_theme,
956
- )
951
+ runner = runner_for(mycli)
952
+ with runner.rendering(QueryState.TRANSFORMING) if runner else nullcontext():
953
+ if polars_pipeline.output_path is None:
954
+ polars_result = run_polars_transform(
955
+ polars_transform,
956
+ results,
957
+ image_protocol=mycli.image_protocol,
958
+ plot_scale_factor=mycli.plot_scale_factor,
959
+ plot_ppi=mycli.plot_ppi,
960
+ plot_theme=mycli.plot_theme,
961
+ allow_plots=polars_pipeline.shell_redirect is None,
962
+ )
963
+ else:
964
+ polars_result = run_polars_transform(
965
+ polars_transform,
966
+ results,
967
+ polars_pipeline.output_path,
968
+ original_query=original_text,
969
+ image_protocol=mycli.image_protocol,
970
+ plot_scale_factor=mycli.plot_scale_factor,
971
+ plot_ppi=mycli.plot_ppi,
972
+ plot_theme=mycli.plot_theme,
973
+ )
957
974
  if polars_pipeline.output_path is None:
958
975
  if polars_pipeline.output_mode == 'explorer':
959
976
  special.set_explorer_output(True)
960
977
  elif polars_pipeline.output_mode == 'expanded':
961
978
  special.set_expanded_output(True)
962
- _output_results(mycli, state, iter([polars_result]), start)
979
+ redirect = polars_pipeline.shell_redirect
980
+ try:
981
+ with (
982
+ temporary_redirect(redirect.command, redirect.file_operator, redirect.filename, mycli.post_redirect_command)
983
+ if redirect is not None
984
+ else nullcontext()
985
+ ):
986
+ _output_results(mycli, state, iter([polars_result]), start, raise_interrupts=redirect is not None)
987
+ except KeyboardInterrupt:
988
+ raise QueryCancelled(False) from None
963
989
  if polars_pipeline.output_path is not None:
964
990
  special.run_post_redirect_hook(
965
991
  mycli.post_redirect_command,
@@ -970,10 +996,15 @@ def _one_iteration(
970
996
  special.unset_once_if_written(mycli.post_redirect_command)
971
997
  special.flush_pipe_once_if_written(mycli.post_redirect_command)
972
998
  successful = True
999
+ except QueryCancelled as exc:
1000
+ mycli.echo('Query cancelled.', err=True, fg='blue')
1001
+ if exc.disconnected:
1002
+ mycli.echo('Connection closed. Session state was lost.', err=True, fg='yellow')
1003
+ mycli.reconnect()
973
1004
  except pymysql.err.InterfaceError:
974
1005
  if not mycli.reconnect():
975
1006
  return
976
- _one_iteration(mycli, state, text)
1007
+ _one_iteration(mycli, state, original_text if polars_pipeline is not None else text)
977
1008
  return
978
1009
  except EOFError as e:
979
1010
  raise e
@@ -1017,7 +1048,7 @@ def _one_iteration(
1017
1048
  elif e1.args[0] in (2003, 2006, 2013):
1018
1049
  if not mycli.reconnect():
1019
1050
  return
1020
- _one_iteration(mycli, state, text)
1051
+ _one_iteration(mycli, state, original_text if polars_pipeline is not None else text)
1021
1052
  return
1022
1053
  else:
1023
1054
  mycli.logger.error('sql: %r, error: %r', text, e1)
@@ -1139,6 +1170,11 @@ def main_repl(mycli: 'MyCli') -> None:
1139
1170
  _build_prompt_session(mycli, state, history, key_bindings)
1140
1171
  set_all_external_titles(mycli)
1141
1172
 
1173
+ config = mycli.config['main']
1174
+ show_state_interval = config.as_float('show_query_state_interval')
1175
+ runner = QueryRunner(show_state_interval)
1176
+ if isinstance(sqlexecute, SQLExecute):
1177
+ sqlexecute.set_query_runner(runner)
1142
1178
  try:
1143
1179
  while True:
1144
1180
  _one_iteration(mycli, state)
@@ -1147,3 +1183,7 @@ def main_repl(mycli: 'MyCli') -> None:
1147
1183
  special.close_tee()
1148
1184
  if mycli.verbosity >= 0:
1149
1185
  mycli.echo('Goodbye!')
1186
+ finally:
1187
+ if isinstance(sqlexecute, SQLExecute):
1188
+ sqlexecute.set_query_runner(None)
1189
+ runner.close()
@@ -111,6 +111,12 @@ log_level = INFO
111
111
  # Timing of SQL statements and table rendering, or LLM commands.
112
112
  timing = True
113
113
 
114
+ # Seconds between query state and elapsed time updates, off if zero. Disabling
115
+ # this saves a second connection to the server. If set to a very low positive
116
+ # value, would lead to excessive SHOW PROCESSLIST queries. 0.5 is probably a
117
+ # good lower bound.
118
+ show_query_state_interval = 0.5
119
+
114
120
  # Show the full SQL when running a favorite query. Set to False to hide.
115
121
  show_favorite_query = True
116
122
 
@@ -35,6 +35,7 @@ import mycli.main_modes.repl as repl_mode
35
35
  from mycli.packages import special
36
36
  from mycli.packages.sqlresult import SQLResult
37
37
  from mycli.packages.tabular_output import sql_format
38
+ from mycli.query_runner import rendering_output, runner_for
38
39
  from mycli.sqlexecute import FIELD_TYPES
39
40
 
40
41
 
@@ -109,6 +110,7 @@ class OutputMixin(MyCliState):
109
110
 
110
111
  return margin
111
112
 
113
+ @rendering_output
112
114
  def output(
113
115
  self,
114
116
  output: itertools.chain[str],
@@ -116,8 +118,15 @@ class OutputMixin(MyCliState):
116
118
  is_warnings_style: bool = False,
117
119
  ) -> None:
118
120
  """Output text to stdout or a pager command."""
121
+ runner = runner_for(self)
122
+
123
+ def before_output() -> None:
124
+ if runner:
125
+ runner.stop_rendering()
126
+
119
127
  prompt_output = self.prompt_session.output if self.prompt_session is not None else None
120
128
  if result.image is not None and not is_windows_console(prompt_output):
129
+ before_output()
121
130
  if result.image_protocol == 'iterm2':
122
131
  click.secho('')
123
132
  self.output_iterm2_image(result.image)
@@ -156,13 +165,16 @@ class OutputMixin(MyCliState):
156
165
  output_via_pager = True
157
166
 
158
167
  if not output_via_pager:
168
+ before_output()
159
169
  for buf_line in buf:
160
170
  click.secho(buf_line)
161
171
  buf = []
162
172
  else:
173
+ before_output()
163
174
  click.secho(line)
164
175
 
165
176
  if buf:
177
+ before_output()
166
178
 
167
179
  def newlinewrapper(text: list[str]) -> Generator[str, None, None]:
168
180
  for line in text:
@@ -186,6 +198,7 @@ class OutputMixin(MyCliState):
186
198
  click.secho(line)
187
199
 
188
200
  if result.status:
201
+ before_output()
189
202
  self.log_output(result.status_plain)
190
203
  add_style = 'class:warnings.status' if is_warnings_style else 'class:output.status'
191
204
  if isinstance(result.status, FormattedText):
@@ -234,6 +247,7 @@ class OutputMixin(MyCliState):
234
247
  return bool(shutil.which(cmd[0]))
235
248
  return False
236
249
 
250
+ @rendering_output
237
251
  def format_sqlresult(
238
252
  self,
239
253
  result,
@@ -1,3 +1,4 @@
1
+ from dataclasses import dataclass
1
2
  import functools
2
3
  import logging
3
4
  import re
@@ -19,6 +20,76 @@ SOURCE_OPTION_TERMINATOR_PATTERN = re.compile(r'(?<!\S)--(?=\s|$)')
19
20
  HYBRID_OPERATOR_PATTERN = re.compile(r'\$(?:>>?|\|)')
20
21
 
21
22
 
23
+ @dataclass(frozen=True, slots=True)
24
+ class ShellRedirect:
25
+ command: str | None
26
+ file_operator: str | None
27
+ filename: str | None
28
+
29
+
30
+ def tokenize_shell_suffix(suffix: str) -> list[sqlglot.Token]:
31
+ # Shell long options are not SQL comments; preserve offsets into the original text.
32
+ for match in re.finditer(r'\$\s*>', suffix):
33
+ prefix = re.sub(r'--(?=\S)', '__', suffix[: match.end()])
34
+ try:
35
+ tokens = sqlglot.tokenize(prefix)
36
+ except sqlglot.errors.TokenError:
37
+ continue
38
+ files = find_token_indices(tokens)['angle_bracket']
39
+ if files and tokens[files[-1]].end == match.end() - 1:
40
+ # Only an unquoted file operator switches back to SQL comment handling.
41
+ operand = re.sub(r'(?<=\S)--', '__', suffix[match.end() :])
42
+ return sqlglot.tokenize(prefix + operand)
43
+ return sqlglot.tokenize(re.sub(r'--(?=\S)', '__', suffix))
44
+
45
+
46
+ def parse_shell_redirect(suffix: str) -> ShellRedirect:
47
+ """Parse a final shell suffix independently of the SQL or transform preceding it."""
48
+ suffix = suffix.strip().removesuffix(delimiter_command.current).rstrip()
49
+ tokens = tokenize_shell_suffix(suffix)
50
+ indices = find_token_indices(tokens)
51
+ dollars = indices['true_dollar']
52
+ pipes = indices['pipe']
53
+ files = indices['angle_bracket']
54
+ if not dollars or tokens[dollars[0]].start != 0:
55
+ raise ValueError('Shell redirection requires an operator.')
56
+ if len(files) > 1 or (files and pipes and pipes[-1] > files[0]):
57
+ raise ValueError('Shell file redirection must be last and occur only once.')
58
+ if WIN and len(pipes) > 1:
59
+ raise ValueError('Multiple shell pipes are not supported on Windows.')
60
+
61
+ command_parts: list[str] = []
62
+ for position, dollar in enumerate(dollars):
63
+ operator = tokens[dollar + 1]
64
+ next_dollar = dollars[position + 1] if position + 1 < len(dollars) else len(tokens)
65
+ if operator.token_type == sqlglot.TokenType.PIPE:
66
+ end = len(suffix[: tokens[next_dollar - 1].end + 1].removesuffix(delimiter_command.current).rstrip())
67
+ last_token = next(token for token in reversed(tokens[:next_dollar]) if token.start < end)
68
+ part = suffix[operator.end + 1 : min(last_token.end + 1, end)].strip()
69
+ if not part:
70
+ raise ValueError('Shell pipes require a command.')
71
+ command_parts.append(part)
72
+
73
+ filename = None
74
+ file_operator = None
75
+ if files:
76
+ operator = tokens[files[0]]
77
+ end = len(suffix[: tokens[-1].end + 1].removesuffix(delimiter_command.current).rstrip())
78
+ last_token = next(token for token in reversed(tokens) if token.start < end)
79
+ operand = suffix[operator.end + 1 : min(last_token.end + 1, end)].strip()
80
+ file_operator = '>'
81
+ if operand.startswith('>'):
82
+ file_operator = '>>'
83
+ operand = operand[1:].strip()
84
+ filename = parse_redirect_filename(operand)
85
+ if not filename:
86
+ raise ValueError('Shell file redirection requires one filename; quote filenames containing spaces.')
87
+ command = ' | '.join(command_parts) or None
88
+ if invalid_shell_part(filename, command):
89
+ raise ValueError('Invalid shell redirection.')
90
+ return ShellRedirect(command, file_operator, filename)
91
+
92
+
22
93
  def tokenize_command(command: str) -> list[sqlglot.Token]:
23
94
  """Tokenize a command without treating source options as SQL comments."""
24
95
  if SOURCE_COMMAND_PATTERN.match(command):
@@ -200,34 +271,11 @@ def get_redirect_components(command: str) -> tuple[str | None, str | None, str |
200
271
  if not sql_part:
201
272
  return None, None, None, None
202
273
 
203
- (
204
- file_part_tokens,
205
- file_part_index,
206
- file_operator_part,
207
- ) = find_file_tokens(
208
- tokens,
209
- token_indices['angle_bracket'],
210
- )
211
-
212
- command_part_tokens = find_command_tokens(
213
- tokens[0:file_part_index],
214
- token_indices['true_dollar'],
215
- )
216
-
217
- if file_part_tokens:
218
- file_part = parse_redirect_filename(assemble_tokens(file_part_tokens))
219
- if file_part is None:
220
- return None, None, None, None
221
- else:
222
- file_part = None
223
-
224
- if command_part_tokens:
225
- command_part = assemble_tokens(command_part_tokens)
226
- else:
227
- command_part = None
228
-
229
- if invalid_shell_part(file_part, command_part):
274
+ try:
275
+ redirect = parse_shell_redirect(command[tokens[token_indices['true_dollar'][0]].start :])
276
+ except (ValueError, sqlglot.errors.TokenError):
230
277
  return None, None, None, None
278
+ command_part, file_operator_part, file_part = redirect.command, redirect.file_operator, redirect.filename
231
279
 
232
280
  logger.debug('redirect parse sql_part: "{}"'.format(sql_part))
233
281
  logger.debug('redirect parse command_part: "{}"'.format(command_part))
@@ -14,6 +14,7 @@ from typing import Any, Union, cast, get_args, get_origin
14
14
 
15
15
  import sqlglot
16
16
 
17
+ from mycli.packages.hybrid_redirection import find_token_indices
17
18
  from mycli.packages.polars_transform import _pipeline_operator_indexes
18
19
 
19
20
  _ATTRIBUTE_PATTERN = re.compile(r'(?s)(.*)\.([A-Za-z_][A-Za-z0-9_]*)?\s*$')
@@ -40,6 +41,8 @@ def complete_polars_transform(command: str) -> list[PolarsCompletion] | None:
40
41
  return None
41
42
  if pipe_index is None:
42
43
  return None
44
+ if find_token_indices(tokens)['true_dollar']:
45
+ return []
43
46
  if output_index is not None:
44
47
  return []
45
48
 
@@ -11,6 +11,7 @@ from typing import Any, Iterable, Sequence
11
11
 
12
12
  import sqlglot
13
13
 
14
+ from mycli.packages.hybrid_redirection import ShellRedirect, find_token_indices, parse_shell_redirect, tokenize_shell_suffix
14
15
  from mycli.packages.special.delimitercommand import DelimiterCommand
15
16
  from mycli.packages.sqlresult import SQLResult
16
17
  from mycli.types import ImageProtocol, OutputMode
@@ -30,6 +31,7 @@ class PolarsPipeline:
30
31
  expression: str | None
31
32
  output_path: str | None
32
33
  output_mode: OutputMode
34
+ shell_redirect: ShellRedirect | None = None
33
35
 
34
36
 
35
37
  @dataclass(frozen=True, slots=True)
@@ -69,8 +71,29 @@ def parse_polars_transform(command: str) -> PolarsPipeline | None:
69
71
 
70
72
  pipe_index, parquet_index = _pipeline_operator_indexes(command, tokens)
71
73
 
74
+ shell_indexes = find_token_indices(tokens)['true_dollar']
75
+ if shell_indexes:
76
+ suffix = command[tokens[shell_indexes[0]].start :]
77
+ try:
78
+ shell_tokens = tokenize_shell_suffix(suffix)
79
+ except sqlglot.errors.TokenError as exc:
80
+ raise PolarsTransformError(f'Unable to parse shell redirection: {exc}') from exc
81
+ shell_operators = _pipeline_operator_indexes(suffix, shell_tokens)
82
+ if any(index is not None for index in shell_operators):
83
+ raise PolarsTransformError('Shell redirections must follow all transform stages.')
72
84
  if pipe_index is None and parquet_index is None:
73
85
  return None
86
+ shell_redirect = None
87
+ if shell_indexes:
88
+ shell_index = shell_indexes[0]
89
+ if parquet_index is not None:
90
+ raise PolarsTransformError('The ".>" operator cannot be combined with shell redirection.')
91
+ shell_start = tokens[shell_index].start
92
+ try:
93
+ shell_redirect = parse_shell_redirect(command[shell_start:])
94
+ except (ValueError, sqlglot.errors.TokenError) as exc:
95
+ raise PolarsTransformError(str(exc)) from exc
96
+ command = command[:shell_start].rstrip()
74
97
  if parquet_index is not None and pipe_index is not None and parquet_index < pipe_index:
75
98
  raise PolarsTransformError('The ".>" operator must follow the ".|" operator.')
76
99
 
@@ -99,6 +122,9 @@ def parse_polars_transform(command: str) -> PolarsPipeline | None:
99
122
  else:
100
123
  output_mode = 'tabular'
101
124
 
125
+ if shell_redirect is not None and output_mode == 'explorer':
126
+ raise PolarsTransformError('Explorer output cannot be combined with shell redirection.')
127
+
102
128
  for delimiter in (delimiter_command.current, r'\G', r'\g', r'\x'):
103
129
  sql = sql.removesuffix(delimiter).rstrip()
104
130
  if expression is not None:
@@ -113,7 +139,13 @@ def parse_polars_transform(command: str) -> PolarsPipeline | None:
113
139
  raise PolarsTransformError('File saves require a destination path.')
114
140
  output_path = _parse_output_path(output_path)
115
141
  _validate_sql(sql)
116
- return PolarsPipeline(sql=sql, expression=expression, output_path=output_path, output_mode=output_mode)
142
+ return PolarsPipeline(
143
+ sql=sql,
144
+ expression=expression,
145
+ output_path=output_path,
146
+ output_mode=output_mode,
147
+ shell_redirect=shell_redirect,
148
+ )
117
149
 
118
150
 
119
151
  def _pipeline_operator_indexes(
@@ -229,6 +261,7 @@ def run_polars_transform(
229
261
  plot_scale_factor: float = 1.0,
230
262
  plot_ppi: int = 200,
231
263
  plot_theme: str = 'carbong90',
264
+ allow_plots: bool = True,
232
265
  ) -> SQLResult:
233
266
  iterator = iter(results)
234
267
  try:
@@ -298,6 +331,8 @@ def run_polars_transform(
298
331
  return SQLResult(status=f'Wrote {len(series_dataframe)} rows to {output_path}.')
299
332
  return SQLResult(header=[column_name], rows=[(item,) for item in value])
300
333
  elif transform.altair is not None and isinstance(value, transform.altair.TopLevelMixin):
334
+ if not allow_plots:
335
+ raise PolarsTransformError('Altair plots cannot use shell redirection. Use ".>" to save a plot file.')
301
336
  plot_format = _plot_format_for_path(output_path) if output_path is not None else None
302
337
  if output_path is not None and plot_format is None:
303
338
  raise PolarsTransformError('Altair plots can only be written to ".png", ".pdf", ".svg", or ".html" files.')