Repository: dbcli/mycli Branch: main Commit: 93ec867cf488 Files: 115 Total size: 685.8 KB Directory structure: gitextract_8u893jm8/ ├── .coveragerc ├── .git-blame-ignore-revs ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ └── feature_request.md │ ├── PULL_REQUEST_TEMPLATE.md │ ├── dependabot.yml │ └── workflows/ │ ├── ci.yml │ ├── codex-review.yml │ ├── lint.yml │ ├── publish.yml │ └── typecheck.yml ├── .gitignore ├── AUTHORS.rst ├── CONTRIBUTING.md ├── LICENSE.txt ├── MANIFEST.in ├── README.md ├── SPONSORS.rst ├── changelog.md ├── doc/ │ ├── key_bindings.rst │ └── llm.md ├── mycli/ │ ├── AUTHORS │ ├── SPONSORS │ ├── TIPS │ ├── __init__.py │ ├── clibuffer.py │ ├── clistyle.py │ ├── clitoolbar.py │ ├── compat.py │ ├── completion_refresher.py │ ├── config.py │ ├── constants.py │ ├── key_bindings.py │ ├── lexer.py │ ├── magic.py │ ├── main.py │ ├── myclirc │ ├── packages/ │ │ ├── __init__.py │ │ ├── checkup.py │ │ ├── completion_engine.py │ │ ├── filepaths.py │ │ ├── hybrid_redirection.py │ │ ├── paramiko_stub/ │ │ │ └── __init__.py │ │ ├── parseutils.py │ │ ├── prompt_utils.py │ │ ├── shortcuts.py │ │ ├── special/ │ │ │ ├── __init__.py │ │ │ ├── dbcommands.py │ │ │ ├── delimitercommand.py │ │ │ ├── favoritequeries.py │ │ │ ├── iocommands.py │ │ │ ├── llm.py │ │ │ ├── main.py │ │ │ └── utils.py │ │ ├── sqlresult.py │ │ ├── string_utils.py │ │ ├── tabular_output/ │ │ │ ├── __init__.py │ │ │ └── sql_format.py │ │ └── toolkit/ │ │ ├── __init__.py │ │ ├── fzf.py │ │ ├── history.py │ │ └── utils.py │ ├── sqlcompleter.py │ └── sqlexecute.py ├── pyproject.toml ├── pytest.ini ├── test/ │ ├── __init__.py │ ├── conftest.py │ ├── features/ │ │ ├── __init__.py │ │ ├── auto_vertical.feature │ │ ├── basic_commands.feature │ │ ├── connection.feature │ │ ├── crud_database.feature │ │ ├── crud_table.feature │ │ ├── db_utils.py │ │ ├── environment.py │ │ ├── fixture_data/ │ │ │ ├── help.txt │ │ │ └── help_commands.txt │ │ ├── fixture_utils.py │ │ ├── iocommands.feature │ │ ├── named_queries.feature │ │ ├── specials.feature │ │ ├── steps/ │ │ │ ├── __init__.py │ │ │ ├── auto_vertical.py │ │ │ ├── basic_commands.py │ │ │ ├── connection.py │ │ │ ├── crud_database.py │ │ │ ├── crud_table.py │ │ │ ├── iocommands.py │ │ │ ├── named_queries.py │ │ │ ├── specials.py │ │ │ ├── utils.py │ │ │ └── wrappers.py │ │ └── wrappager.py │ ├── myclirc │ ├── mylogin.cnf │ ├── test.txt │ ├── test_clistyle.py │ ├── test_clitoolbar.py │ ├── test_completion_engine.py │ ├── test_completion_refresher.py │ ├── test_config.py │ ├── test_dbspecial.py │ ├── test_llm_special.py │ ├── test_main.py │ ├── test_naive_completion.py │ ├── test_parseutils.py │ ├── test_plan.wiki │ ├── test_prompt_utils.py │ ├── test_smart_completion_public_schema_only.py │ ├── test_special_iocommands.py │ ├── test_sqlexecute.py │ ├── test_tabular_output.py │ └── utils.py └── tox.ini ================================================ FILE CONTENTS ================================================ ================================================ FILE: .coveragerc ================================================ [run] source = mycli ================================================ FILE: .git-blame-ignore-revs ================================================ ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Create a report to help us improve title: '' labels: '' assignees: '' --- ### Suggested troubleshooting steps for bug reports * [ ] Upgraded to the latest mycli if possible. * [ ] Ran `mycli --checkup`, if supported. ### Expected Behavior ### Actual Behavior ### Steps to Reproduce ### System * mycli version: * OS/version: ### Discussion ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Suggest an idea for this project title: '' labels: '' assignees: '' --- ================================================ FILE: .github/PULL_REQUEST_TEMPLATE.md ================================================ ## Description ## Checklist - [ ] I added this contribution to the `changelog.md` file. - [ ] I added my name to the `AUTHORS` file (or it's already there). - [ ] To lint and format the code, I ran ```bash uv run ruff check && uv run ruff format && uv run mypy --install-types . ``` ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: "github-actions" directory: "/" schedule: interval: "daily" ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI on: pull_request: paths-ignore: - '**.md' - '**.rst' - 'LICENSE.txt' - 'doc/**/*.txt' - '**/AUTHORS' - '**/SPONSORS' - '**/TIPS' jobs: tests: name: Tests runs-on: ubuntu-latest strategy: fail-fast: false matrix: python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 with: version: "latest" - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: ${{ matrix.python-version }} - name: Start MySQL run: | sudo /etc/init.d/mysql start - name: Install dependencies run: uv sync --all-extras -p ${{ matrix.python-version }} - name: Wait for MySQL connection run: | while ! mysqladmin ping --host=localhost --port=3306 --user=root --password=root --silent; do sleep 5 done - name: Pytest / behave env: PYTEST_PASSWORD: root PYTEST_HOST: 127.0.0.1 TERM: xterm run: | uv run tox -e py${{ matrix.python-version }} test-no-extras: name: Tests Without Extras runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 with: version: "latest" - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.13' - name: Start MySQL run: | sudo /etc/init.d/mysql start - name: Install dependencies run: uv sync --extra dev -p python3.13 - name: Wait for MySQL connection run: | while ! mysqladmin ping --host=localhost --port=3306 --user=root --password=root --silent; do sleep 5 done - name: Pytest / behave env: PYTEST_PASSWORD: root PYTEST_HOST: 127.0.0.1 TERM: xterm run: | uv run tox -e py3.13 ================================================ FILE: .github/workflows/codex-review.yml ================================================ name: Codex Review on: pull_request_target: types: [opened, labeled, reopened, ready_for_review] paths-ignore: - '**.md' - '**.rst' - 'LICENSE.txt' - 'doc/**/*.txt' - '**/AUTHORS' - '**/SPONSORS' - '**/TIPS' jobs: codex-review: if: github.event.pull_request.draft == false || (github.event.action == 'labeled' && contains(github.event.pull_request.labels.*.name, 'codex')) runs-on: ubuntu-latest permissions: contents: read outputs: final_message: ${{ steps.run_codex.outputs.final-message }} steps: - name: Check out PR merge commit uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: refs/pull/${{ github.event.pull_request.number }}/merge - name: Fetch base and head refs run: | git fetch --no-tags origin \ ${{ github.event.pull_request.base.ref }} \ +refs/pull/${{ github.event.pull_request.number }}/head - name: Run Codex review id: run_codex uses: openai/codex-action@v1 env: # Use env variables to handle untrusted metadata safely PR_TITLE: ${{ github.event.pull_request.title }} PR_BODY: ${{ github.event.pull_request.body }} with: openai-api-key: ${{ secrets.OPENAI_API_KEY }} prompt: | You are reviewing PR #${{ github.event.pull_request.number }} for ${{ github.repository }}. Only review changes introduced by this PR: git log --oneline ${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }} Focus on: - correctness bugs and regressions - security concerns - missing tests or edge cases Keep feedback concise and actionable. Pull request title and body: ---- $PR_TITLE $PR_BODY post-feedback: runs-on: ubuntu-latest needs: codex-review if: needs.codex-review.outputs.final_message != '' permissions: issues: write pull-requests: write steps: - name: Post Codex review as PR comment uses: actions/github-script@v8 env: CODEX_FINAL_MESSAGE: ${{ needs.codex-review.outputs.final_message }} with: github-token: ${{ github.token }} script: | await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.payload.pull_request.number, body: process.env.CODEX_FINAL_MESSAGE, }); ================================================ FILE: .github/workflows/lint.yml ================================================ name: Lint on: pull_request: paths-ignore: - '**.md' - '**.rst' - 'LICENSE.txt' - 'doc/**/*.txt' - '**/AUTHORS' - '**/SPONSORS' - '**/TIPS' jobs: linters: name: Linters runs-on: ubuntu-latest steps: - name: Check out Git repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Run ruff check uses: astral-sh/ruff-action@4919ec5cf1f49eff0871dbcea0da843445b837e6 # v3.6.1 - name: Run ruff format uses: astral-sh/ruff-action@4919ec5cf1f49eff0871dbcea0da843445b837e6 # v3.6.1 with: args: 'format --check' ================================================ FILE: .github/workflows/publish.yml ================================================ name: Publish Python Package on: release: types: [created] permissions: contents: read jobs: docs: runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Require release changelog form run: | if grep -q TBD changelog.md; then false; fi test: runs-on: ubuntu-latest needs: [docs] continue-on-error: true strategy: matrix: python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 with: version: "latest" - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: ${{ matrix.python-version }} - name: Start MySQL run: | sudo /etc/init.d/mysql start - name: Install dependencies run: uv sync --all-extras -p ${{ matrix.python-version }} - name: Wait for MySQL connection run: | while ! mysqladmin ping --host=localhost --port=3306 --user=root --password=root --silent; do sleep 5 done - name: Pytest / behave env: PYTEST_PASSWORD: root PYTEST_HOST: 127.0.0.1 run: | uv run tox -e py${{ matrix.python-version }} # arguably this should be made identical to CI for PRs - name: Run Style Checks run: uv run tox -e style build: runs-on: ubuntu-latest needs: [test] steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 with: version: "latest" - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.13' - name: Install dependencies run: uv sync --all-extras -p 3.13 - name: Build run: uv build - name: Store the distribution packages uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: python-packages path: dist/ publish: name: Publish to PyPI runs-on: ubuntu-latest if: startsWith(github.ref, 'refs/tags/') needs: [build] environment: release permissions: id-token: write steps: - name: Download distribution packages uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: python-packages path: dist/ - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 ================================================ FILE: .github/workflows/typecheck.yml ================================================ name: Typecheck on: pull_request: paths-ignore: - '**.md' - '**.rst' - 'LICENSE.txt' - 'doc/**/*.txt' - '**/AUTHORS' - '**/SPONSORS' - '**/TIPS' jobs: typecheck: name: Typecheck runs-on: ubuntu-latest steps: - name: Check out Git repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.13' - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 with: version: 'latest' - name: Install dependencies run: uv sync --all-extras - name: Run mypy run: | uv run --no-sync --frozen -- python -m ensurepip uv run --no-sync --frozen -- python -m mypy --no-pretty --install-types --non-interactive . ================================================ FILE: .gitignore ================================================ .idea/ .vscode/ /build /dist /mycli.egg-info /src /test/behave.ini .vagrant *.pyc *.deb .cache/ .coverage .coverage.* .venv/ venv/ .myclirc uv.lock ================================================ FILE: AUTHORS.rst ================================================ Check out our `AUTHORS`_. .. _AUTHORS: mycli/AUTHORS ================================================ FILE: CONTRIBUTING.md ================================================ # Development Guide This is a guide for developers who would like to contribute to this project. If you're interested in contributing to mycli, thank you. We'd love your help! You'll always get credit for your work. ## GitHub Workflow 1. [Fork the repository](https://github.com/dbcli/mycli) on GitHub. 2. Clone your fork locally: ```bash $ git clone ``` 3. Add the official repository (`upstream`) as a remote repository: ```bash $ git remote add upstream git@github.com:dbcli/mycli.git ``` 4. Set up [uv](https://docs.astral.sh/uv/getting-started/installation/) for development: ```bash $ cd mycli $ uv sync --extra dev ``` We've just created a virtual environment and installed all the dependencies and tools we need to work on mycli. 5. Create a branch for your bugfix or feature based off the `main` branch: ```bash $ git checkout -b main ``` 6. While you work on your bugfix or feature, be sure to pull the latest changes from `upstream`. This ensures that your local codebase is up-to-date: ```bash $ git pull upstream main ``` 7. When your work is ready for the mycli team to review it, push your branch to your fork: ```bash $ git push origin ``` 8. [Create a pull request](https://help.github.com/articles/creating-a-pull-request-from-a-fork/) on GitHub. ## Running mycli To run mycli with your local changes: ```bash $ uv run mycli ``` ## Running the Tests While you work on mycli, it's important to run the tests to make sure your code hasn't broken any existing functionality. To run the tests, just type in: ```bash $ uv run tox ``` ### Test Database Credentials Some tests require a database connection to work. You can tell the tests which credentials to use by setting the applicable environment variables: ```bash $ export PYTEST_HOST=localhost $ export PYTEST_USER=mycli $ export PYTEST_PASSWORD=myclirocks $ export PYTEST_PORT=3306 $ export PYTEST_CHARSET=utf8mb4 ``` The default values are `localhost`, `root`, no password, `3306`, and `utf8mb4`. You only need to set the values that differ from the defaults. If you would like to run the tests as a user with only the necessary privileges, create a `mycli` user and run the following grant statements. ```sql GRANT ALL PRIVILEGES ON `mycli_%`.* TO 'mycli'@'localhost'; GRANT SELECT ON mysql.* TO 'mycli'@'localhost'; GRANT SELECT ON performance_schema.* TO 'mycli'@'localhost'; ``` ### CLI Tests Some CLI tests expect the program `ex` to be a symbolic link to `vim`. In some systems (e.g. Arch Linux) `ex` is a symbolic link to `vi`, which will change the output and therefore make some tests fail. You can check this by running: ```bash $ readlink -f $(which ex) ``` # Github PR checklist - add the contribution to the `changelog.md` - add your name to the `AUTHORS` file (or it's already there). - run `uv run ruff check && uv run ruff format && uv run mypy --install-types .` ## Releasing a new version of mycli Create a new [release](https://github.com/dbcli/mycli/releases) in Github. This will trigger a Github action which will run all the tests, build the wheel and upload it to PyPI. ================================================ FILE: LICENSE.txt ================================================ Copyright (c) 2015-2026, mycli maintainers All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of mycli nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ================================================ FILE: MANIFEST.in ================================================ include LICENSE.txt *.md *.rst screenshots/* include tasks.py .coveragerc tox.ini recursive-include test *.cnf recursive-include test *.feature recursive-include test *.py recursive-include test *.txt ================================================ FILE: README.md ================================================ # mycli [![Build Status](https://github.com/dbcli/mycli/workflows/mycli/badge.svg)](https://github.com/dbcli/mycli/actions?query=workflow%3Amycli) A command line client for MySQL that can do auto-completion and syntax highlighting. Homepage: [https://mycli.net](https://mycli.net) Documentation: [https://mycli.net/docs](https://mycli.net/docs) ![Completion](screenshots/tables.png) ![CompletionGif](screenshots/main.gif) Postgres Equivalent: [https://pgcli.com](https://pgcli.com) Quick Start ----------- If you already know how to install Python packages, then you can install it via `pip`: You might need sudo on Linux. ```bash pip install -U 'mycli[all]' ``` or ```bash brew update && brew install mycli # Only on macOS ``` or ```bash sudo apt-get install mycli # Only on Debian or Ubuntu ``` ### Usage See ```bash mycli --help ``` Features -------- `mycli` is written using [prompt_toolkit](https://github.com/jonathanslenders/python-prompt-toolkit/). * Auto-completion as you type for SQL keywords as well as tables, views and columns in the database. * Fuzzy history search using [fzf](https://github.com/junegunn/fzf). * Syntax highlighting using Pygments. * Smart-completion (enabled by default) will suggest context-sensitive completion. - `SELECT * FROM ` will only show table names. - `SELECT * FROM users WHERE ` will only show column names. * Support for multiline queries. * Favorite queries with optional positional parameters. Save a query using `\fs ` and execute it with `\f `. * Timing of sql statements and table rendering. * Log every query and its results to a file (disabled by default). * Pretty print tabular data (with colors!). * Support for SSL connections * Shell-style trailing redirects with `$>`, `$>>` and `$|` operators. * Support for querying LLMs with context derived from your schema. * Support for storing passwords in the system keyring. Mycli creates a config file `~/.myclirc` on first run; you can use the options in that file to configure the above features, and more. Some features are only exposed as [key bindings](doc/key_bindings.rst). Contributions: -------------- If you're interested in contributing to this project, first of all I would like to extend my heartfelt gratitude. I've written a small doc to describe how to get this running in a development setup. https://github.com/dbcli/mycli/blob/main/CONTRIBUTING.md ## Additional Install Instructions: These are some alternative ways to install mycli that are not managed by our team but provided by OS package maintainers. These packages could be slightly out of date and take time to release the latest version. ### Arch, Manjaro You can install the mycli package available in the AUR: ``` yay -S mycli ``` ### Debian, Ubuntu On Debian, Ubuntu distributions, you can easily install the mycli package using apt: ``` sudo apt-get install mycli ``` ### Fedora Fedora has a package available for mycli, install it using dnf: ``` sudo dnf install mycli ``` ### Windows #### Option 1: Native Windows Install the `less` pager, for example by `scoop install less`. Follow the instructions on this blogpost: https://web.archive.org/web/20221006045208/https://www.codewall.co.uk/installing-using-mycli-on-windows/ **Mycli is not tested on Windows**, but the libraries used in the app are Windows-compatible. This means it should work without any modifications, but isn't supported. PRs to add native Windows testing to Mycli CI would be welcome! #### Option 2: WSL Everything should work as expected in WSL. This is a good option for using Mycli on Windows. ### Thanks: This project was funded through kickstarter. My thanks to the [backers](https://mycli.net/sponsors) who supported the project. A special thanks to [Jonathan Slenders](https://twitter.com/jonathan_s) for creating [Python Prompt Toolkit](https://github.com/jonathanslenders/python-prompt-toolkit), which is quite literally the backbone library, that made this app possible. Jonathan has also provided valuable feedback and support during the development of this app. [Click](https://palletsprojects.com/projects/click) is used for command line option parsing and printing error messages. Thanks to [PyMysql](https://github.com/PyMySQL/PyMySQL) for a pure python adapter to MySQL database. ### Compatibility Mycli is tested on macOS and Linux, and requires Python 3.10 or better. To connect to MySQL versions earlier than 5.5, you may need to set the following in `~/.myclirc`: ``` # character set for connections without --charset being set at the CLI default_character_set = utf8 ``` or set `--charset=utf8` when invoking MyCLI. ### Configuration and Usage For more information on using and configuring mycli, [check out our documentation](https://mycli.net/docs). Common topics include: - [Configuring mycli](https://mycli.net/config) - [Using/Disabling the pager](https://mycli.net/pager) - [Syntax colors](https://mycli.net/syntax) ================================================ FILE: SPONSORS.rst ================================================ Check out our `SPONSORS`_. .. _SPONSORS: mycli/SPONSORS ================================================ FILE: changelog.md ================================================ 1.65.2 (2026/03/19) ============== Security -------- * Harden `codex-review` workflow against script injection from untrusted PR metadata. 1.65.1 (2026/03/18) ============== Bug Fixes --------- * Require `sqlglot` 30.x. 1.65.0 (2026/03/16) ============== Features --------- * Add prompt format string for literal backslash. * Add collation completions, and complete charsets in more positions. Bug Fixes --------- * Suppress warnings when `sqlglotrs` is installed. * Improve completions after operators, by recognizing more operators. 1.64.0 (2026/03/13) ============== Features --------- * Add `-r` raw mode to `system` command. * Set timeouts, show exit codes, and improve formatting for `system` commands. * Add a dependencies section to `--checkup`. Bug Fixes --------- * Require `sqlglot` 29.x, suppressing a deprecation warning. 1.63.0 (2026/03/12) ============== Features --------- * Make short toolbar message show after one prompt. Internal --------- * Migrate more repeated values to `constants.py`. * Support `sqlglot` 28 and 29. 1.62.0 (2026/03/07) ============== Features --------- * Dynamic terminal titles based on prompt format strings. * Ability to turn off the toolbar. * Add completions for introducers on literals. * Load whole-line autosuggest candidates in a background thread for speed. Bug Fixes --------- * Improve query cancellation on control-c. * Improve refresh of some format strings in the toolbar. * Improve keyring storage, requiring re-entering most keyring passwords. * Improve sentinel value for `--password` without argument. Internal --------- * Require a more recent version of the `wcwidth` library. * Make `safe_invalidate_display` function safer. 1.61.0 (2026/03/07) ============== Features --------- * Allow shorter timeout lengths after pressing Esc, for vi-mode. * Let tab and control-space behaviors be configurable. * Add short hostname prompt format string. 1.60.0 (2026/03/05) ============== Features --------- * Prioritize common functions in the "value" position. * Improve value-position keywords. * Allow warning-count in status output to be styled. Bug Fixes --------- * Fix crash for completion edge case (#1668). * Update to a `cli_helpers` version with a `tabulate` bugfix. 1.59.0 (2026/03/03) ============== Features --------- * Offer filename completions on more special commands, such as `\edit`. * Allow styling of status and timings text. * Set up customization of prompt/continuation colors in `~/.myclirc`. * Allow customization of the toolbar with prompt format strings. * Add warnings-count prompt format strings: `\w` and `\W`. * Handle/document more attributes in the `[colors]` section of `~/.myclirc`. * Enable customization of table border color/attributes in `~/.myclirc`. * Complete much more precisely in the "value" position. Bug Fixes --------- * Make toolbar widths consistent on toggle actions. * Don't write ANSI prompt escapes to `tee` output. Internal --------- * Use prompt_toolkit's `bell()`. * Refactor `SQLResult` dataclass. * Avoid depending on string matches into host info. * Add more URL constants. * Set `$VISUAL` whenever `$EDITOR` is set. * Fix tempfile leak in test suite. * Avoid refreshing the prompt unless needed. 1.58.0 (2026/02/28) ============== Features --------- * Add `\bug` command. * Let the `F1` key open a browser to mycli.net/docs and emit help text. * Add documentation index URL to inline help. * Rewrite bottom toolbar, showing more statuses, but staying compact. * Let `help ` list similar keywords when not found. * Optionally highlight fuzzy search previews. * Make `\edit` synonymous with the `\e` command. * Add environment variable section to `--checkup`. Bug Fixes --------- * Force a prompt_toolkit refresh after fzf history search to avoid display glitches. * Include `status` footer in paged output. * Ensure fullscreen in fuzzy history search. Documentation --------- * Add `help ` to TIPS. * Refine inline help descriptions. * Add `$VISUAL` environment variable hint to TIPS. Internal --------- * Better tests for `null_string` configuration option. * Better cleanup of resources in the test suite. * Simplify prettify/unprettify handlers. * Make prettify/unprettify logic more robust. 1.57.0 (2026/02/25) ============== Features --------- * Add extra error output on connection failure for possible SSL mismatch (#1584). * Bind alternate terminal sequences for function keys F2 - F4. * Add `llm help` subcommand. * Rewrite `help` table. * Remove "info" counter from fzf history-search UI. Bug Fixes --------- * Let interactive changes to the prompt format respect dynamically-computed values. * Better handle arguments to `system cd`. * Fix missing keepalives in `\e` prompt loop. * Always strip trailing newlines with `\e`. * Fix `\llm` without arguments, and remove debug output. Documentation --------- * Startup tips: add right-arrow key binding. * Startup tips: add control-space and the `min_completion_trigger` setting. * Startup tips: add history-search bindings. * Prefer `https` protocol over `http` in documentation. Internal --------- * Remove outdated email address in `pyproject.toml`. * Set well-known URL values in `pyproject.toml`. 1.56.0 (2026/02/23) ============== Features --------- * Let the `--dsn` argument accept literal DSNs as well as aliases. * Accept `--character-set` as an alias for `--charset` at the CLI. * Add SSL/TLS version to `status` output. * Accept `socket` as a DSN query parameter. * Accept new-style `ssl_mode` in DSN URI query parameters, to match CLI argument. * Fully deprecate the built-in SSH functionality. * Let `--keepalive-ticks` be set per-connection, as a CLI option or DSN parameter. * Accept `character_set` as a DSN query parameter. * Don't attempt SSL for local socket connections when in "auto" SSL mode. * Add prompt format string for SSL/TLS version of the connection. * Add prompt format strings for displaying uptime. * Add batch mode to startup tips. * Update startup tips with new options. Bug Fixes --------- * Make `--ssl-capath` argument a directory. * Allow users to use empty passwords without prompting or any configuration (#1584). * Check the existence of a socket more directly in `status`. * Allow multi-line SQL statements in batch mode on the standard input. * Fix extraneous prompt refresh on every keystroke. 1.55.0 (2026/02/20) ============== Features --------- * `--checkup` now checks for external executables. Bug Fixes --------- * Improve completion suggestions within backticks. * Watch command now returns correct time when run as part of a multi-part query (#1565). * Don't diagnose free-entry sections such as `[favorite_queries]` in `--checkup`. * When accepting a filename completion, fill in leading `./` if given. Internal -------- * Bump `cli_helpers` to non-yanked version. 1.54.1 (2026/02/17) ============== Bug Fixes -------- * Don't offer autocomplete suggestions when the cursor is within a string. * Catch `getpwuid` error on unknown socket owner. Internal -------- * Tune Codex reviews. * Refactor `is_inside_quotes()` detection. 1.54.0 (2026/02/16) ============== Features -------- * Add many CLI flags to startup tips. * Accept all special commands without trailing semicolons in multi-line mode. * Add prompt format strings for socket connections. * Optionally defer auto-completions until a minimum number of characters is typed. * Make the completion interface more responsive using a background thread. * Option to suppress control-d exit behavior. * Better support Truecolor terminals. * Ability to send app-layer keepalive pings to the server. * Add `WITH`, `EXPLAIN`, and `LEFT JOIN` to favorite keyword suggestions. * Let the Escape key cancel completion popups. Bug Fixes --------- * Correct parameterization for completion queries. * Grammar nits in help display. Internal -------- * Prefer `yield from` over yielding in a loop. * Update `ruff` linter and CI. * Update `LICENSE.txt` for dates and GitHub detection. * Update key feature list in `README.md`, syncing with web. * Sync prompt format string commentary with web. * Add a GitHub Actions workflow to run Codex review on pull requests. * Remove vim-style exit sequence which had no effect. * Pin dependencies more tightly in `pyproject.toml`. * Exclude more documentation files from CI. 1.53.0 (2026/02/12) ============== Features -------- * Add all `~/.myclirc` entries/sections to startup tips. Bug Fixes --------- * Fix `\dt+ table_name` returning empty results. * Further bulletproof generating completions on stored procedures. Internal -------- * Add GitHub Issue templates. 1.52.0 (2026/02/11) ============== Features -------- * Suggest tables/views that contain the given columns first when provided in a SELECT query. Bug Fixes -------- * Reduce duplicated `--checkup` output. * Handle errors generating completions on stored procedures. * Fix whitespace/inline comments breaking destructive `UPDATE … WHERE` statement detection. Internal -------- * Let CI ignore additional documentation files. * Upgrade `cli_helpers` library to v2.10.0. * Organize startup tips. 1.51.1 (2026/02/09) ============== Features -------- * Options to limit size of LLM prompts; cache LLM prompt data. * Add startup usage tips. * Move `main.ssl_mode` config option to `connection.default_ssl_mode`. * Add "unsupported" and "deprecated" `--checkup` sections. Bug Fixes -------- * Correct mangled schema info sent in LLM prompts. * Give destructive warning on multi-table `UPDATE`s. 1.50.0 (2026/02/07) ============== Features -------- * Deprecate reading configuration values from `my.cnf` files. * Add `--checkup` mode to show unconfigured new features. * Add `binary_display` configuration option. Bug Fixes -------- * Link to `--ssl`/`--no-ssl` GitHub issue in deprecation warning. * Don't emit keyring-updated message unless needed. * Include port and socket in keyring identifier. 1.49.0 (2026/02/02) ============== Features -------- * "Eager" completions for the `source` command, limited to `*.sql` files. * Suggest column names from all tables in the current database after SELECT (#212). * Put fuzzy completions more often to the bottom of the suggestion list. * Store and retrieve passwords using the system keyring. Bug Fixes -------- * Refactor completions for special commands, with minor casing fixes. * Raise `--password-file` higher in the precedence of password specification. * Fix regression: show username in password prompt. Internal -------- * Remove `align_decimals` preprocessor, which had no effect. * Fix TLS deprecation warning in test suite. * Convert importlib read_text and open_text uses to newer files() syntax. * Update Pull Request template. 1.48.0 (2026/01/27) ============== Features -------- * Right-align numeric columns, and make the behavior configurable. * Add completions for stored procedures. * Escape database completions. * Offer completions on `CREATE TABLE ... LIKE`. * Use 0x-style hex literals for binaries in SQL output formats. Bug Fixes -------- * Better respect case when `keyword_casing` is `auto`. * Fix error when selecting from an empty table. * Let favorite queries contain special commands. * Render binary values more consistently as hex literals. * Offer format completions on special command `\Tr`/`redirectformat`. 1.47.0 (2026/01/24) ============== Features -------- * Add a `--checkpoint=` argument to log successful queries in batch mode. * Add `--throttle` option for batch mode. Bug Fixes -------- * Fix timediff output when the result is a negative value (#1113). * Don't offer completions for numeric text. 1.46.0 (2026/01/22) ============== Features -------- * Add `--unbuffered` mode which fetches rows as needed, to save memory. * Default to standards-compliant `utf8mb4` character set. * Stream input from STDIN to consume less memory, adding `--noninteractive` and `--format=` CLI arguments. * Remove suggested quoting on completions for identifiers with uppercase. * Allow table names to be completed with leading schema names. * Soft deprecate the built-in SSH features. * Add true fuzzy-match completions with rapidfuzz. Bug Fixes -------- * Fix CamelCase fuzzy matching. * Place special commands first in the list of completion candidates, and remove duplicates. 1.45.0 (2026/01/20) ============== Features -------- * Make password options also function as flags. Reworked password logic to prompt user as early as possible (#341). * More complete and up-to-date set of MySQL reserved words for completions. * Place exact-leading completions first. * Allow history file location to be configured. * Make destructive-warning keywords configurable. * Smarter fuzzy completion matches. Bug Fixes -------- * Respect `--logfile` when using `--execute` or standard input at the shell CLI. * Gracefully catch Paramiko parsing errors on `--list-ssh-config`. * Downgrade to Paramiko 3.5.1 to avoid crashing on DSA SSH keys. * Offer schema name completions in `GRANT ... ON` forms. 1.44.2 (2026/01/13) ============== Bug Fixes -------- * Update watch query output to display the correct execution time on all iterations (#763). * Use correct database (if applicable) when reconnecting after a connection loss (#1437). Internal -------- * Create new data class to handle SQL/command results to make further code improvements easier. 1.44.1 (2026/01/10) ============== Bug Fixes -------- * Let `sqlparse` accept arbitrarily-large queries. 1.44.0 (2026/01/08) ============== Features -------- * Add enum value completions for WHERE/HAVING clauses. (#790) * Add `show_favorite_query` config option to control query printing when running favorite queries. (#1118) 1.43.1 (2026/01/03) ============== Bug Fixes -------- * Prompt for password within SSL-auto retry flow. 1.43.0 (2026/01/02) ============== Features -------- * Update query processing functions to allow automatic show_warnings to work for more code paths like DDL. * Add new ssl_mode config / --ssl-mode CLI option to control SSL connection behavior. This setting will supercede the existing --ssl/--no-ssl CLI options, which are deprecated and will be removed in a future release. * Rework reconnect logic to actually reconnect or create a new connection instead of simply changing the database (#746). * Configurable string for missing values (NULLs) in outputs. Bug Fixes -------- * Update the prompt display logic to handle an edge case where a socket is used without a host being parsed from any other method (#707). Internal -------- * Refine documentation for Windows. * Target Python 3.10 for linting. * Use fully-qualified pymysql exception classes. 1.42.0 (2025/12/20) ============== Features -------- * Add support for the automatic displaying of warnings after a SQL statement is executed. May be set with the commands \W and \w, in the config file with show_warnings, or with --show-warnings/--no-show-warnings on the command line. Internal -------- * Improve robustness for flaky tests when publishing. * Improve type annotations for latest mypy/type stubs. * Set mypy version more strictly. 1.41.2 (2025/11/24) ============== Bug Fixes -------- * Close connection to server properly to avoid "Aborted connection" warnings in server logs. Internal -------- * Add ruff to developement dependencies. * Update contributing guidelines to match GitHub pull request checklist. 1.41.1 (2025/11/15) ============== Bug Fixes -------- * Upgrade `click` to v8.3.1, resolving a longstanding pager bug. Internal -------- * Include LLM dependencies in tox configuration. 1.41.0 (2025/11/01) ============== Features -------- * Make LLM dependencies an optional extra. Bug Fixes -------- * Let LLM commands respect show-timing configuration. Internal -------- * Add mypy to Pull Request template. * Enable flake8-bugbear lint rules. * Fix flaky editor-command tests in CI. * Require release format of `changelog.md` when making a release. * Improve type annotations on LLM driver. 1.40.0 (2025/10/14) ============== Features -------- * Support reconnecting to mysql server when the server restarts. Internal -------- * Test on Python 3.14. * Switch from pyaes to pycryptodomex as it seems to be more actively maintained. 1.39.1 (2025/10/06) ============== Bug Fixes -------- * Don't require `--ssl` argument when other SSL arguments are given. 1.39.0 (2025/09/30) ============== Features -------- * Support only Python 3.10+. Bug Fixes -------- * Fixes use of incorrect ssl config after retrying connection with prompted password. * Fix ssl_context always created. Internal -------- Typing fix for `pymysql.connect()`. 1.38.4 (2025/09/06) ============== Bug Fixes -------- * Limit Alt-R bindings to Emacs mode. * Fix timing being printed twice. Internal -------- * Only read "my" configuration files once, rather than once per call to read_my_cnf_files. 1.38.3 (2025/08/21) ============== Bug Fixes -------- * Fix the infinite looping when `\llm` is called without args. 1.38.2 (2025/08/19) ====================== Bug Fixes -------- * Fix failure to save Favorite Queries. 1.38.1 (2025/08/19) ====================== Bug Fixes -------- * Partially fix Favorite Query completion crash. Internal -------- * Improve CI workflow naming. 1.38.0 (2025/08/16) ====================== Features -------- * Add LLM support. Bug Fixes -------- * Improve missing ssh-extras message. * Fix repeated control-r in traditional reverse isearch. * Fix spelling of `ssl-verify-server-cert` option. * Improve handling of `ssl-verify-server-cert` False values. * Guard against missing contributors file on startup. * Friendlier errors on password-file failures. * Better handle empty-string passwords. * Permit empty-string passwords at the interactive prompt. Internal -------- * Improve pull request template lint commands. * Complete typehinting the non-test codebase. * Modernization: conversion to f-strings. * Modernization: remove more Python 2 compatibility logic. 1.37.1 (2025/07/28) ====================== Internal -------- * Align LICENSE with SPDX format. * Fix deprecated `license` specification format in `pyproject.toml`. 1.37.0 (2025/07/28) ====================== Features -------- * Show username in password prompt. * Add `mysql` and `mysql_unicode` table formats. Bug Fixes -------- * Help Windows installations find a working default pager. Internal -------- * Support only Python 3.9+ in `pyproject.toml`. * Add linting suggestion to pull request template. * Make CI names and properties more consistent. * Enable typechecking for most of the non-test codebase. * CI: turn off fail-fast matrix strategy. * Remove unused Python 2 compatibility code. * Also run CI tests without installing SSH extra dependencies. * Update `cli_helpers` dependency, and list of table formats. 1.36.0 (2025/07/19) ====================== Features -------- * Make control-r reverse search style configurable. * Make fzf search key bindings more compatible with traditional isearch. Bug Fixes -------- * Better reset after pipe command failures. Internal -------- * Add limited typechecking to CI. 1.35.0 (2025/07/18) ====================== Features -------- * Support chained pipe operators such as `select first_name from users $| grep '^J' $| head -10`. * Support trailing file redirects after pipe operators, such as `select 10 $| tail -1 $> ten.txt`. 1.34.4 (2025/07/15) ====================== Bug Fixes -------- * Fix old-style `\pipe_once`. 1.34.3 (2025/07/14) ====================== Bug Fixes -------- * Use only `communicate()` to communicate with subprocess. 1.34.2 (2025/07/12) ====================== Bug Fixes -------- * Use plain `print()` to communicate with subprocess. 1.34.1 (2025/07/12) ====================== Internal -------- * Bump cli_helpers dependency for corrected output formats. 1.34.0 (2025/07/11) ====================== Features -------- * Post-save command hook for redirected output. Internal -------- * Documentation cleanup. * Bump cli_helpers dependency for more output formats. 1.33.0 (2025/07/07) ====================== Features -------- * Keybindings to insert current date/datetime. * Improve feedback when running external commands. * Independent format for redirected output. * Trailing shell-style redirect syntax. Internal -------- * Remove `requirements-dev.txt` in favor of uv/`pyproject.toml`. 1.32.0 (2025/07/04) ====================== Features -------- * Support SSL query parameters on DSNs. * More information and care on KeyboardInterrupt. Internal -------- * Work on passing `ruff check` linting. * Relax expectation for unreliable test. * Bump sqlglot version to v26 and add rs extras. 1.31.2 (2025/05/01) =================== Bug Fixes --------- * Let table-name extraction work on multi-statement inputs. Internal -------- * Work on passing `ruff check` linting. * Remove backward-compatibility hacks. * Pin more GitHub Actions and add Dependabot support. * Enable xpassing test. 1.31.1 (2025/04/25) =================== Internal -------- * skip style checks on Publish action 1.31.0 (NEVER RELEASED) =================== Features -------- * Added explicit error handle to get_password_from_file with EAFP. * Use the "history" scheme for fzf searches. * Deduplicate history in fzf searches. * Add a preview window to fzf history searches. Internal -------- * New Project Lead: [Roland Walker](https://github.com/rolandwalker) * Update sqlparse to <=0.6.0 * Typing/lint fixes. 1.30.0 (2025/04/19) =================== Features -------- * DSN specific init-command in myclirc. Fixes (#1195) * Add `\\g` to force the horizontal output. 1.29.2 (2024/12/11) =================== Internal -------- * Exclude tests from the python package. 1.29.1 (2024/12/11) =================== Internal -------- * Fix the GH actions to publish a new version. 1.29.0 (NEVER RELEASED) ======================= Bug Fixes ---------- * fix SSL through SSH jump host by using a true python socket for a tunnel * Fix mycli crash when connecting to Vitess Internal --------- * Modernize to use PEP-621. Use `uv` instead of `pip` in GH actions. * Remove Python 3.8 and add Python 3.13 in test matrix. 1.28.0 (2024/11/10) ====================== Features --------- * Added fzf history search functionality. The feature can switch between the old implementation and the new one based on the presence of the fzf binary. Bug Fixes ---------- * Fixes `Database connection failed: error('unpack requires a buffer of 4 bytes')` * Only show keyword completions after * * Enable fuzzy matching for keywords 1.27.2 (2024/04/03) =================== Bug Fixes ---------- * Don't use default prompt when one is not supplied to the --prompt option. 1.27.1 (2024/03/28) =================== Bug Fixes ---------- * Don't install tests. * Do not ignore the socket passed with the -S option, even when no port is passed * Fix unexpected exception when using dsn without username & password (Thanks: [Will Wang]) * Let the `--prompt` option act normally with its predefined default value Internal --------- * paramiko is newer than 2.11.0 now, remove version pinning `cryptography`. * Drop support for Python 3.7 1.27.0 (2023/08/11) =================== Features --------- * Detect TiDB instance, show in the prompt, and use additional keywords. * Fix the completion order to show more commonly-used keywords at the top. Bug Fixes ---------- * Better handle empty statements in un/prettify * Remove vi-mode bindings for prettify/unprettify. * Honor `\G` when executing from commandline with `-e`. * Correctly report the version of TiDB. * Revised `botton` spelling mistakes with `bottom` in `mycli/clitoolbar.py` 1.26.1 (2022/09/01) =================== Bug Fixes ---------- * Require Python 3.7 in `setup.py` 1.26.0 (2022/09/01) =================== Features --------- * Add `--ssl` flag to enable ssl/tls. * Add `pager` option to `~/.myclirc`, for instance `pager = 'pspg --csv'` (Thanks: [BuonOmo]) * Add prettify/unprettify keybindings to format the current statement using `sqlglot`. Features --------- * Add `--tls-version` option to control the tls version used. Internal --------- * Pin `cryptography` to suppress `paramiko` warning, helping CI complete and presumably affecting some users. * Upgrade some dev requirements * Change tests to always use databases prefixed with 'mycli_' for better security Bug Fixes ---------- * Support for some MySQL compatible databases, which may not implement connection_id(). * Fix the status command to work with missing 'Flush_commands' (mariadb) * Ignore the user of the system [myslqd] config. 1.25.0 (2022/04/02) =================== Features --------- * Add `beep_after_seconds` option to `~/.myclirc`, to ring the terminal bell after long queries. 1.24.4 (2022/03/30) =================== Internal --------- * Upgrade Ubuntu VM for runners as Github has deprecated it Bug Fixes ---------- * Change in main.py - Replace the `click.get_terminal_size()` with `shutil.get_terminal_size()` 1.24.3 (2022/01/20) =================== Bug Fixes ---------- * Upgrade cli_helpers to workaround Pygments regression. 1.24.2 (2022/01/11) =================== Bug Fixes ---------- * Fix autocompletion for more than one JOIN * Fix the status command when connected to TiDB or other servers that don't implement 'Threads\_connected' * Pin pygments version to avoid a breaking change 1.24.1 ======= Bug Fixes --------- * Restore dependency on cryptography for the interactive password prompt Internal --------- * Deprecate Python mock 1.24.0 ====== Bug Fixes ---------- * Allow `FileNotFound` exception for SSH config files. * Fix startup error on MySQL < 5.0.22 * Check error code rather than message for Access Denied error * Fix login with ~/.my.cnf files Features --------- * Add `-g` shortcut to option `--login-path`. * Alt-Enter dispatches the command in multi-line mode. * Allow to pass a file or FIFO path with --password-file when password is not specified or is failing (as suggested in this best-practice ) Internal --------- * Remove unused function is_open_quote() * Use importlib, instead of file links, to locate resources * Test various host-port combinations in command line arguments * Switched from Cryptography to pyaes for decrypting mylogin.cnf 1.23.2 ====== Bug Fixes ---------- * Ensure `--port` is always an int. 1.23.1 ====== Bug Fixes ---------- * Allow `--host` without `--port` to make a TCP connection. 1.23.0 ====== Bug Fixes ---------- * Fix config file include logic Features --------- * Add an option `--init-command` to execute SQL after connecting (Thanks: [KITAGAWA Yasutaka]). * Use InputMode.REPLACE_SINGLE * Add support for ANSI escape sequences for coloring the prompt. * Allow customization of Pygments SQL syntax-highlighting styles. * Add a `\clip` special command to copy queries to the system clipboard. * Add a special command `\pipe_once` to pipe output to a subprocess. * Add an option `--charset` to set the default charset when connect database. Bug Fixes ---------- * Fixed compatibility with sqlparse 0.4 (Thanks: [mtorromeo]). * Fixed iPython magic (Thanks: [mwcm]). * Send "Connecting to socket" message to the standard error. * Respect empty string for prompt_continuation via `prompt_continuation = ''` in `.myclirc` * Fix \once -o to overwrite output whole, instead of line-by-line. * Dispatch lines ending with `\e` or `\clip` on return, even in multiline mode. * Restore working local `--socket=` (Thanks: [xeron]). * Allow backtick quoting around the database argument to the `use` command. * Avoid opening `/dev/tty` when `--no-warn` is given. * Fixed some typo errors in `README.md`. 1.22.2 ====== Bug Fixes ---------- * Make the `pwd` module optional. 1.22.1 ====== Bug Fixes ---------- * Fix the breaking change introduced in PyMySQL 0.10.0. (Thanks: [Amjith]). Features --------- * Add an option `--ssh-config-host` to read ssh configuration from OpenSSH configuration file. * Add an option `--list-ssh-config` to list ssh configurations. * Add an option `--ssh-config-path` to choose ssh configuration path. Bug Fixes ---------- * Fix specifying empty password with `--password=''` when config file has a password set (Thanks: [Zach DeCook]). 1.21.1 ====== Bug Fixes ---------- * Fix broken auto-completion for favorite queries (Thanks: [Amjith]). * Fix undefined variable exception when running with --no-warn (Thanks: [Georgy Frolov]) * Support setting color for null value (Thanks: [laixintao]) 1.21.0 ====== Features --------- * Added DSN alias name as a format specifier to the prompt (Thanks: [Georgy Frolov]). * Mark `update` without `where`-clause as destructive query (Thanks: [Klaus Wünschel]). * Added DELIMITER command (Thanks: [Georgy Frolov]) * Added clearer error message when failing to connect to the default socket. * Extend main.is_dropping_database check with create after delete statement. * Search `${XDG_CONFIG_HOME}/mycli/myclirc` after `${HOME}/.myclirc` and before `/etc/myclirc` (Thanks: [Takeshi D. Itoh]) Bug Fixes ---------- * Allow \o command more than once per session (Thanks: [Georgy Frolov]) * Fixed crash when the query dropping the current database starts with a comment (Thanks: [Georgy Frolov]) Internal --------- * deprecate python versions 2.7, 3.4, 3.5; support python 3.8 1.20.1 ====== Bug Fixes ---------- * Fix an error when using login paths with an explicit database name (Thanks: [Thomas Roten]). 1.20.0 ====== Features ---------- * Auto find alias dsn when `://` not in `database` (Thanks: [QiaoHou Peng]). * Mention URL encoding as escaping technique for special characters in connection DSN (Thanks: [Aljosha Papsch]). * Pressing Alt-Enter will introduce a line break. This is a way to break up the query into multiple lines without switching to multi-line mode. (Thanks: [Amjith Ramanujam]). * Use a generator to stream the output to the pager (Thanks: [Dick Marinus]). Bug Fixes ---------- * Fix the missing completion for special commands (Thanks: [Amjith Ramanujam]). * Fix favorites queries being loaded/stored only from/in default config file and not --myclirc (Thanks: [Matheus Rosa]) * Fix automatic vertical output with native syntax style (Thanks: [Thomas Roten]). * Update `cli_helpers` version, this will remove quotes from batch output like the official client (Thanks: [Dick Marinus]) * Update `setup.py` to no longer require `sqlparse` to be less than 0.3.0 as that just came out and there are no notable changes. ([VVelox]) * workaround for ConfigObj parsing strings containing "," as lists (Thanks: [Mike Palandra]) Internal --------- * fix unhashable FormattedText from prompt toolkit in unit tests (Thanks: [Dick Marinus]). 1.19.0 ====== Internal --------- * Add Python 3.7 trove classifier (Thanks: [Thomas Roten]). * Fix pytest in Fedora mock (Thanks: [Dick Marinus]). * Require `prompt_toolkit>=2.0.6` (Thanks: [Dick Marinus]). Features --------- * Add Token.Prompt/Continuation (Thanks: [Dick Marinus]). * Don't reconnect when switching databases using use (Thanks: [Angelo Lupo]). * Handle MemoryErrors while trying to pipe in large files and exit gracefully with an error (Thanks: [Amjith Ramanujam]) Bug Fixes ---------- * Enable Ctrl-Z to suspend the app (Thanks: [Amjith Ramanujam]). 1.18.2 ====== Bug Fixes ---------- * Fixes database reconnecting feature (Thanks: [Yang Zou]). Internal --------- * Update Twine version to 1.12.1 (Thanks: [Thomas Roten]). * Fix warnings for running tests on Python 3.7 (Thanks: [Dick Marinus]). * Clean up and add behave logging (Thanks: [Dick Marinus]). 1.18.1 ====== Features --------- * Add Keywords: TINYINT, SMALLINT, MEDIUMINT, INT, BIGINT (Thanks: [QiaoHou Peng]). Internal --------- * Update prompt toolkit (Thanks: [Jonathan Slenders], [Irina Truong], [Dick Marinus]). 1.18.0 ====== Features --------- * Display server version in welcome message (Thanks: [Irina Truong]). * Set `program_name` connection attribute (Thanks: [Dick Marinus]). * Use `return` to terminate a generator for better Python 3.7 support (Thanks: [Zhongyang Guan]). * Add `SAVEPOINT` to SQLCompleter (Thanks: [Huachao Mao]). * Connect using a SSH transport (Thanks: [Dick Marinus]). * Add `FROM_UNIXTIME` and `UNIX_TIMESTAMP` to SQLCompleter (Thanks: [QiaoHou Peng]) * Search `${PWD}/.myclirc`, then `${HOME}/.myclirc`, lastly `/etc/myclirc` (Thanks: [QiaoHao Peng]) Bug Fixes ---------- * When DSN is used, allow overrides from mycli arguments (Thanks: [Dick Marinus]). * A DSN without password should be allowed (Thanks: [Dick Marinus]) Bug Fixes ---------- * Convert `sql_format` to unicode strings for py27 compatibility (Thanks: [Dick Marinus]). * Fixes mycli compatibility with pbr (Thanks: [Thomas Roten]). * Don't align decimals for `sql_format` (Thanks: [Dick Marinus]). Internal --------- * Use fileinput (Thanks: [Dick Marinus]). * Enable tests for Python 3.7 (Thanks: [Thomas Roten]). * Remove `*.swp` from gitignore (Thanks: [Dick Marinus]). 1.17.0 ======= Features ---------- * Add `CONCAT` to SQLCompleter and remove unused code (Thanks: [caitinggui]) * Do not quit when aborting a confirmation prompt (Thanks: [Thomas Roten]). * Add option list-dsn (Thanks: [Frederic Aoustin]). * Add verbose option for list-dsn, add tests and clean up code (Thanks: [Dick Marinus]). Bug Fixes ---------- * Add enable_pager to the config file (Thanks: [Frederic Aoustin]). * Mark `test_sql_output` as a dbtest (Thanks: [Dick Marinus]). * Don't crash if the log/history file directories don't exist (Thanks: [Thomas Roten]). * Unquote dsn username and password (Thanks: [Dick Marinus]). * Output `Password:` prompt to stderr (Thanks: [ushuz]). * Mark `alter` as a destructive query (Thanks: [Dick Marinus]). * Quote CSV fields (Thanks: [Thomas Roten]). * Fix `thanks_picker` (Thanks: [Dick Marinus]). Internal --------- * Refactor Destructive Warning behave tests (Thanks: [Dick Marinus]). 1.16.0 ======= Features --------- * Add DSN aliases to the config file (Thanks: [Frederic Aoustin]). Bug Fixes ---------- * Do not try to connect to a unix socket on Windows (Thanks: [Thomas Roten]). 1.15.0 ======= Features --------- * Add sql-update/insert output format. (Thanks: [Dick Marinus]). * Also complete aliases in WHERE. (Thanks: [Dick Marinus]). 1.14.0 ======= Features --------- * Add `watch [seconds] query` command to repeat a query every [seconds] seconds (by default 5). (Thanks: [David Caro](https://github.com/Terseus)) * Default to unix socket connection if host and port are unspecified. This simplifies authentication on some systems and matches mysql behaviour. * Add support for positional parameters to favorite queries. (Thanks: [Scrappy Soft](https://github.com/scrappysoft)) Bug Fixes ---------- * Fix source command for script in current working directory. (Thanks: [Dick Marinus]). * Fix issue where the `tee` command did not work on Python 2.7 (Thanks: [Thomas Roten]). Internal Changes ----------------- * Drop support for Python 3.3 (Thanks: [Thomas Roten]). * Make tests more compatible between different build environments. (Thanks: [David Caro]) * Merge `_on_completions_refreshed` and `_swap_completer_objects` functions (Thanks: [Dick Marinus]). 1.13.1 ======= Bug Fixes ---------- * Fix keyword completion suggestion for `SHOW` (Thanks: [Thomas Roten]). * Prevent mycli from crashing when failing to read login path file (Thanks: [Thomas Roten]). Internal Changes ----------------- * Make tests ignore user config files (Thanks: [Thomas Roten]). 1.13.0 ======= Features --------- * Add file name completion for source command (issue #500). (Thanks: [Irina Truong]). Bug Fixes ---------- * Fix UnicodeEncodeError when editing sql command in external editor (Thanks: Klaus Wünschel). * Fix MySQL4 version comment retrieval (Thanks: [François Pietka]) * Fix error that occurred when outputting JSON and NULL data (Thanks: [Thomas Roten]). 1.12.1 ======= Bug Fixes ---------- * Prevent missing MySQL help database from causing errors in completions (Thanks: [Thomas Roten]). * Fix mycli from crashing with small terminal windows under Python 2 (Thanks: [Thomas Roten]). * Prevent an error from displaying when you drop the current database (Thanks: [Thomas Roten]). Internal Changes ----------------- * Use less memory when formatting results for display (Thanks: [Dick Marinus]). * Preliminary work for a future change in outputting results that uses less memory (Thanks: [Dick Marinus]). 1.12.0 ======= Features --------- * Add fish-style auto-suggestion from history. (Thanks: [Amjith Ramanujam]) 1.11.0 ======= Features --------- * Handle reserved space for completion menu better in small windows. (Thanks: [Thomas Roten]). * Display current vi mode in toolbar. (Thanks: [Thomas Roten]). * Opening an external editor will edit the last-run query. (Thanks: [Thomas Roten]). * Output once special command. (Thanks: [Dick Marinus]). * Add special command to show create table statement. (Thanks: [Ryan Smith]) * Display all result sets returned by stored procedures (Thanks: [Thomas Roten]). * Add current time to prompt options (Thanks: [Thomas Roten]). * Output status text in a more intuitive way (Thanks: [Thomas Roten]). * Add colored/styled headers and odd/even rows (Thanks: [Thomas Roten]). * Keyword completion casing (upper/lower/auto) (Thanks: [Irina Truong]). Bug Fixes ---------- * Fixed incorrect timekeeping when running queries from a file. (Thanks: [Thomas Roten]). * Do not display time and empty line for blank queries (Thanks: [Thomas Roten]). * Fixed issue where quit command would sometimes not work (Thanks: [Thomas Roten]). * Remove shebang from main.py (Thanks: [Dick Marinus]). * Only use pager if output doesn't fit. (Thanks: [Dick Marinus]). * Support tilde user directory for output file names (Thanks: [Thomas Roten]). * Auto vertical output is a little bit better at its calculations (Thanks: [Thomas Roten]). Internal Changes ----------------- * Rename tests/ to test/. (Thanks: [Dick Marinus]). * Move AUTHORS and SPONSORS to mycli directory. (Thanks: [Terje Røsten] []). * Switch from pycryptodome to cryptography (Thanks: [Thomas Roten]). * Add pager wrapper for behave tests (Thanks: [Dick Marinus]). * Behave test source command (Thanks: [Dick Marinus]). * Test using behave the tee command (Thanks: [Dick Marinus]). * Behave fix clean up. (Thanks: [Dick Marinus]). * Remove output formatter code in favor of CLI Helpers dependency (Thanks: [Thomas Roten]). * Better handle common before/after scenarios in behave. (Thanks: [Dick Marinus]) * Added a regression test for sqlparse >= 0.2.3 (Thanks: [Dick Marinus]). * Reverted removal of temporary hack for sqlparse (Thanks: [Dick Marinus]). * Add setup.py commands to simplify development tasks (Thanks: [Thomas Roten]). * Add behave tests to tox (Thanks: [Dick Marinus]). * Add missing @dbtest to tests (Thanks: [Dick Marinus]). * Standardizes punctuation/grammar for help strings (Thanks: [Thomas Roten]). 1.10.0 ======= Features --------- * Add ability to specify alternative myclirc file. (Thanks: [Dick Marinus]). * Add new display formats for pretty printing query results. (Thanks: [Amjith Ramanujam], [Dick Marinus], [Thomas Roten]). * Add logic to shorten the default prompt if it becomes too long once generated. (Thanks: [John Sterling]). Bug Fixes ---------- * Fix external editor bug (issue #377). (Thanks: [Irina Truong]). * Fixed bug so that favorite queries can include unicode characters. (Thanks: [Thomas Roten]). * Fix requirements and remove old compatibility code (Thanks: [Dick Marinus]) * Fix bug where mycli would not start due to the thanks/credit intro text. (Thanks: [Thomas Roten]). * Use pymysql default conversions (issue #375). (Thanks: [Dick Marinus]). Internal Changes ----------------- * Upload mycli distributions in a safer manner (using twine). (Thanks: [Thomas Roten]). * Test mycli using pexpect/python-behave (Thanks: [Dick Marinus]). * Run pep8 checks in travis (Thanks: [Irina Truong]). * Remove temporary hack for sqlparse (Thanks: [Dick Marinus]). 1.9.0 ====== Features --------- * Add tee/notee commands for outputing results to a file. (Thanks: [Dick Marinus]). * Add date, port, and whitespace options to prompt configuration. (Thanks: [Matheus Rosa]). * Allow user to specify LESS pager flags. (Thanks: [John Sterling]). * Add support for auto-reconnect. (Thanks: [Jialong Liu]). * Add CSV batch output. (Thanks: [Matheus Rosa]). * Add `auto_vertical_output` config to myclirc. (Thanks: [Matheus Rosa]). * Improve Fedora install instructions. (Thanks: [Dick Marinus]). Bug Fixes ---------- * Fix crashes occuring from commands starting with #. (Thanks: [Zhidong]). * Fix broken PyMySQL link in README. (Thanks: [Daniël van Eeden]). * Add various missing keywords for highlighting and autocompletion. (Thanks: [zer09]). * Add the missing REGEXP keyword for highlighting and autocompletion. (Thanks: [cxbig]). * Fix duplicate username entries in completion list. (Thanks: [John Sterling]). * Remove extra spaces in TSV table format output. (Thanks: [Dick Marinus]). * Kill running query when interrupted via Ctrl-C. (Thanks: [chainkite]). * Read the `smart_completion` config from myclirc. (Thanks: [Thomas Roten]). Internal Changes ----------------- * Improve handling of test database credentials. (Thanks: [Dick Marinus]). * Add Python 3.6 to test environments and PyPI metadata. (Thanks: [Thomas Roten]). * Drop Python 2.6 support. (Thanks: [Thomas Roten]). * Swap pycrypto dependency for pycryptodome. (Thanks: [Michał Górny]). * Bump sqlparse version so pgcli and mycli can be installed together. (Thanks: [darikg]). 1.8.1 ====== Bug Fixes ---------- * Remove duplicate listing of DISTINCT keyword. (Thanks: [Amjith Ramanujam]). * Add an try/except for AS keyword crash. (Thanks: [Amjith Ramanujam]). * Support python-sqlparse 0.2. (Thanks: [Dick Marinus]). * Fallback to the raw object for invalid time values. (Thanks: [Amjith Ramanujam]). * Reset the show items when completion is refreshed. (Thanks: [Amjith Ramanujam]). Internal Changes ----------------- * Make the dependency of sqlparse slightly more liberal. (Thanks: [Amjith Ramanujam]). 1.8.0 ====== Features --------- * Add support for --execute/-e commandline arg. (Thanks: [Matheus Rosa]). * Add `less_chatty` config option to skip the intro messages. (Thanks: [Scrappy Soft]). * Support `MYCLI_HISTFILE` environment variable to specify where to write the history file. (Thanks: [Scrappy Soft]). * Add `prompt_continuation` config option to allow configuring the continuation prompt for multi-line queries. (Thanks: [Scrappy Soft]). * Display login-path instead of host in prompt. (Thanks: [Irina Truong]). Bug Fixes ---------- * Pin sqlparse to version 0.1.19 since the new version is breaking completion. (Thanks: [Amjith Ramanujam]). * Remove unsupported keywords. (Thanks: [Matheus Rosa]). * Fix completion suggestion inside functions with operands. (Thanks: [Irina Truong]). 1.7.0 ====== Features --------- * Add stdin batch mode. (Thanks: [Thomas Roten]). * Add warn/no-warn command-line options. (Thanks: [Thomas Roten]). * Upgrade sqlparse dependency to 0.1.19. (Thanks: [Amjith Ramanujam]). * Update features list in README.md. (Thanks: [Matheus Rosa]). * Remove extra \n in features list in README.md. (Thanks: [Matheus Rosa]). Bug Fixes ---------- * Enable history search via . (Thanks: [Amjith Ramanujam]). Internal Changes ----------------- * Upgrade `prompt_toolkit` to 1.0.0. (Thanks: [Jonathan Slenders]) 1.6.0 ====== Features --------- * Change continuation prompt for multi-line mode to match default mysql. * Add `status` command to match mysql's `status` command. (Thanks: [Thomas Roten]). * Add SSL support for `mycli`. (Thanks: [Artem Bezsmertnyi]). * Add auto-completion and highlight support for OFFSET keyword. (Thanks: [Matheus Rosa]). * Add support for `MYSQL_TEST_LOGIN_FILE` env variable to specify alternate login file. (Thanks: [Thomas Roten]). * Add support for `--auto-vertical-output` to automatically switch to vertical output if the output doesn't fit in the table format. * Add support for system-wide config. Now /etc/myclirc will be honored. (Thanks: [Thomas Roten]). * Add support for `nopager` and `\n` to turn off the pager. (Thanks: [Thomas Roten]). * Add support for `--local-infile` command-line option. (Thanks: [Thomas Roten]). Bug Fixes ---------- * Remove -S from `less` option which was clobbering the scroll back in history. (Thanks: [Thomas Roten]). * Make system command work with Python 3. (Thanks: [Thomas Roten]). * Support \G terminator for \f queries. (Thanks: [Terseus]). Internal Changes ----------------- * Upgrade `prompt_toolkit` to 0.60. * Add Python 3.5 to test environments. (Thanks: [Thomas Roten]). * Remove license meta-data. (Thanks: [Thomas Roten]). * Skip binary tests if PyMySQL version does not support it. (Thanks: [Thomas Roten]). * Refactor pager handling. (Thanks: [Thomas Roten]) * Capture warnings to log file. (Thanks: [Mikhail Borisov]). * Make `syntax_style` a tiny bit more intuitive. (Thanks: [Phil Cohen]). 1.5.2 ====== Bug Fixes ---------- * Protect against port number being None when no port is specified in command line. 1.5.1 ====== Bug Fixes ---------- * Cast the value of port read from my.cnf to int. 1.5.0 ====== Features --------- * Make a config option to enable `audit_log`. (Thanks: [Matheus Rosa]). * Add support for reading .mylogin.cnf to get user credentials. (Thanks: [Thomas Roten]). This feature is only available when `pycrypto` package is installed. * Register the special command `prompt` with the `\R` as alias. (Thanks: [Matheus Rosa]). Users can now change the mysql prompt at runtime using `prompt` command. eg: ``` mycli> prompt \u@\h> Changed prompt format to \u@\h> Time: 0.001s amjith@localhost> ``` * Perform completion refresh in a background thread. Now mycli can handle databases with thousands of tables without blocking. * Add support for `system` command. (Thanks: [Matheus Rosa]). Users can now run a system command from within mycli as follows: ``` amjith@localhost:(none)>system cat tmp.sql select 1; select * from django_migrations; ``` * Caught and hexed binary fields in MySQL. (Thanks: [Daniel West]). Geometric fields stored in a database will be displayed as hexed strings. * Treat enter key as tab when the suggestion menu is open. (Thanks: [Matheus Rosa]) * Add "delete" and "truncate" as destructive commands. (Thanks: [Martijn Engler]). * Change \dt syntax to add an optional table name. (Thanks: [Shoma Suzuki]). `\dt [tablename]` will describe the columns in a table. * Add TRANSACTION related keywords. * Treat DESC and EXPLAIN as DESCRIBE. (Thanks: [spacewander]). Bug Fixes ---------- * Fix the removal of whitespace from table output. * Add ability to make suggestions for compound join clauses. (Thanks: [Matheus Rosa]). * Fix the incorrect reporting of command time. * Add type validation for port argument. (Thanks [Matheus Rosa]) Internal Changes ----------------- * Make pycrypto optional and only install it in \*nix systems. (Thanks: [Irina Truong]). * Add badge for PyPI version to README. (Thanks: [Shoma Suzuki]). * Updated release script with a --dry-run and --confirm-steps option. (Thanks: [Irina Truong]). * Adds support for PyMySQL 0.6.2 and above. This is useful for debian package builders. (Thanks: [Thomas Roten]). * Disable click warning. 1.4.0 ====== Features --------- * Add `source` command. This allows running sql statement from a file. eg: ``` mycli> source filename.sql ``` * Added a config option to make the warning before destructive commands optional. (Thanks: [Daniel West](https://github.com/danieljwest)) In the config file ~/.myclirc set `destructive_warning = False` which will disable the warning before running `DROP` commands. * Add completion support for CHANGE TO and other master/slave commands. This is still preliminary and it will be enhanced in the future. * Add custom styles to color the menus and toolbars. * Upgrade `prompt_toolkit` to 0.46. (Thanks: [Jonathan Slenders]) Multi-line queries are automatically indented. Bug Fixes ---------- * Fix keyword completion after the `WHERE` clause. * Add `\g` and `\G` as valid query terminators. Previously in multi-line mode ending a query with a `\G` wouldn't run the query. This is now fixed. 1.3.0 ====== Features --------- * Add a new special command (\T) to change the table format on the fly. (Thanks: [Jonathan Bruno](https://github.com/brewneaux)) eg: ``` mycli> \T tsv ``` * Add `--defaults-group-suffix` to the command line. This lets the user specify a group to use in the my.cnf files. (Thanks: [Irina Truong](https://github.com/j-bennet)) In the my.cnf file a user can specify credentials for different databases and invoke mycli with the group name to use the appropriate credentials. eg: ``` # my.cnf [client] user = 'root' socket = '/tmp/mysql.sock' pager = 'less -RXSF' database = 'account' [clientamjith] user = 'amjith' database = 'user_management' $ mycli --defaults-group-suffix=amjith # uses the [clientamjith] section in my.cnf ``` * Add `--defaults-file` option to the command line. This allows specifying a `my.cnf` to use at launch. This also makes it play nice with mysql sandbox. * Make `-p` and `--password` take the password in commandline. This makes mycli a drop in replacement for mysql. 1.2.0 ====== Features --------- * Add support for wider completion menus in the config file. Add `wider_completion_menu = True` in the config file (~/.myclirc) to enable this feature. Bug Fixes --------- * Prevent Ctrl-C from quitting mycli while the pager is active. * Refresh auto-completions after the database is changed via a CONNECT command. Internal Changes ----------------- * Upgrade `prompt_toolkit` dependency version to 0.45. * Added Travis CI to run the tests automatically. 1.1.1 ====== Bug Fixes ---------- * Change dictonary comprehension used in mycnf reader to list comprehension to make it compatible with Python 2.6. 1.1.0 ====== Features --------- * Fuzzy completion is now case-insensitive. (Thanks: [bjarnagin](https://github.com/bjarnagin)) * Added new-line (`\n`) to the list of special characters to use in prompt. (Thanks: [brewneaux](https://github.com/brewneaux)) * Honor the `pager` setting in my.cnf files. (Thanks: [Irina Truong](https://github.com/j-bennet)) Bug Fixes ---------- * Fix a crashing bug in completion engine for cross joins. * Make `` value consistent between tabular and vertical output. Internal Changes ----------------- * Changed pymysql version to be greater than 0.6.6. * Upgrade `prompt_toolkit` version to 0.42. (Thanks: [Yasuhiro Matsumoto](https://github.com/mattn)) * Removed the explicit dependency on six. 2015/06/10 =========== Features --------- * Customizable prompt. (Thanks [Steve Robbins](https://github.com/steverobbins)) * Make `\G` formatting to behave more like mysql. Bug Fixes ---------- * Formatting issue in \G for really long column values. 2015/06/07 =========== Features --------- * Upgrade `prompt_toolkit` to 0.38. This improves the performance of pasting long queries. * Add support for reading my.cnf files. * Add editor command \e. * Replace ConfigParser with ConfigObj. * Add \dt to show all tables. * Add fuzzy completion for table names and column names. * Automatically reconnect when connection is lost to the database. Bug Fixes ---------- * Fix a bug with reconnect failure. * Fix the issue with `use` command not changing the prompt. * Fix the issue where `\\r` shortcut was not recognized. 2015/05/24 ========== Features --------- * Add support for connecting via socket. * Add completion for SQL functions. * Add completion support for SHOW statements. * Made the timing of sql statements human friendly. * Automatically prompt for a password if needed. Bug Fixes ---------- * Fixed the installation issues with PyMySQL dependency on case-sensitive file systems. [Amjith Ramanujam]: https://blog.amjith.com [Artem Bezsmertnyi]: https://github.com/mrdeathless [BuonOmo]: https://github.com/BuonOmo [Daniel West]: https://github.com/danieljwest [Dick Marinus]: https://github.com/meeuw [François Pietka]: https://github.com/fpietka [Frederic Aoustin]: https://github.com/fraoustin [Georgy Frolov]: https://github.com/pasenor [Irina Truong]: https://github.com/j-bennet [Jonathan Slenders]: https://github.com/jonathanslenders [laixintao]: https://github.com/laixintao [Martijn Engler]: https://github.com/martijnengler [Matheus Rosa]: https://github.com/mdsrosa [Mikhail Borisov]: https://github.com/borman [mtorromeo]: https://github.com/mtorromeo [mwcm]: https://github.com/mwcm [Phil Cohen]: https://github.com/phlipper [Scrappy Soft]: https://github.com/scrappysoft [Shoma Suzuki]: https://github.com/shoma [spacewander]: https://github.com/spacewander [Terseus]: https://github.com/Terseus [Thomas Roten]: https://github.com/tsroten [xeron]: https://github.com/xeron [Zach DeCook]: https://zachdecook.com [Will Wang]: https://github.com/willww64 ================================================ FILE: doc/key_bindings.rst ================================================ ************* Key Bindings: ************* Most key bindings are simply inherited from `prompt-toolkit `_ . The following key bindings are special to mycli: ### F1 ### Open documentation index in a browser tab. ### F2 ### Enable/Disable SmartCompletion Mode. ### F3 ### Enable/Disable Multiline Mode. ### F4 ### Toggle between Vi and Emacs mode. ### Tab ### Force autocompletion at cursor. ####### C-space ####### Initialize autocompletion at cursor. If the autocompletion menu is not showing, display it with the appropriate completions for the context. If the menu is showing, select the next completion. ######### ESC Enter ######### Introduce a line break in multi-line mode, or dispatch the command in single-line mode. The sequence ESC-Enter is often sent by Alt-Enter. ################## C-x p (Emacs-mode) ################## Prettify and indent current statement, usually into multiple lines. Only accepts buffers containing single SQL statements. ################## C-x u (Emacs-mode) ################## Unprettify and dedent current statement, usually into one line. Only accepts buffers containing single SQL statements. ################## C-o d (Emacs-mode) ################## Insert the current date at cursor, defined by NOW() on the server. #################### C-o C-d (Emacs-mode) #################### Insert the quoted current date at cursor. ################## C-o t (Emacs-mode) ################## Insert the current datetime at cursor. #################### C-o C-t (Emacs-mode) #################### Insert the quoted current datetime at cursor. ================================================ FILE: doc/llm.md ================================================ # Using the \llm Command (AI-assisted SQL) The `\llm` special command lets you ask natural-language questions and get SQL proposed for you. It uses the open‑source `llm` CLI under the hood and enriches your prompt with database context (schema and one sample row per table) so answers can include runnable SQL. Alias: `\ai` works the same as `\llm`. --- ## Quick Start 1) Make sure mycli is installed with the `[llm]` extras, like ```bash pip install 'mycli[llm]' ``` or that the `llm` dependency is installed separately: ```bash pip install llm ``` 2) From the mycli prompt, configure your API key (only needed for remote providers like OpenAI): ```text \llm keys set openai ``` 3) Ask a question. The response’s SQL (inside a ```sql fenced block) is extracted and pre-filled at the prompt: ```text World> \llm "Capital of India?" -- Answer text from the model... -- ```sql -- SELECT ...; -- ``` -- Your prompt is prefilled with the SQL above. ``` You can now hit Enter to run, or edit the query first. --- ## What Context Is Sent When you ask a plain question via `\llm "..."`, mycli: - Sends your question. - Adds your current database schema: table names with column types. - Adds one sample row (if available) from each table. This helps the model propose SQL that fits your schema. Follow‑ups using `-c` continue the same conversation and do not re-send the DB context (see “Continue Conversation (-c)”). Note: Context is gathered from the current connection. If you are not connected, using contextual mode will fail — connect first. --- ## Using `llm` Subcommands from mycli You can run any `llm` CLI subcommand by prefixing it with `\llm` inside mycli. Examples: - List models: ```text \llm models ``` - Set the default model: ```text \llm models default gpt-5 ``` - Set provider API key: ```text \llm keys set openai ``` - Install a plugin (e.g., local models via Ollama): ```text \llm install llm-ollama ``` After installing or uninstalling plugins, mycli will restart to pick up new commands. Tab completion works for `\llm` subcommands, and even for model IDs under `models default`. Aside: for using local models. --- ## Ask Questions With DB Context (default) Ask your question in quotes. mycli sends database context and extracts a SQL block if present. ```text World> \llm "Most visited urls?" ``` Behavior: - Response is printed in the output pane. - If the response contains a ```sql fenced block, mycli extracts the SQL and pre-fills it at your prompt. --- ## Continue Conversation (-c) Use `-c` to ask a follow‑up that continues the previous conversation with the model. This does not re-send the DB context; it relies on the ongoing thread. ```text World> \llm "Top 10 customers by spend" -- model returns analysis and a ```sql block; SQL is prefilled World> \llm -c "Now include each customer's email and order count" ``` Behavior: - Continues the last conversation in the `llm` history. - Database context is not re-sent on follow‑ups. - If the response includes a ```sql block, the SQL is pre-filled at your prompt. --- ## Examples - List available models: ```text World> \llm models ``` - Change default model: ```text World> \llm models default llama3 ``` - Set API key (for providers that require it): ```text World> \llm keys set openai ``` - Ask a question with context: ```text World> \llm "Capital of India?" ``` - Use a local model (after installing a plugin such as `llm-ollama`): ```text World> \llm install llm-ollama World> \llm models default llama3 World> \llm "Top 10 customers by spend" ``` See: for details. --- ## Customize the Prompt Template mycli uses a saved `llm` template named `mycli-llm-template` for contextual questions. You can view or edit it: ```text World> \llm templates edit mycli-llm-template ``` Tip: After first use, mycli ensures this template exists. To just view it without editing, use: ```text World> \llm templates show mycli-llm-template ``` --- ## Troubleshooting - No SQL pre-fill: Ensure the model’s response includes a ```sql fenced block. The built‑in prompt encourages this, but some models may omit it; try asking the model to include SQL in a ```sql block. - Not connected to a database: Contextual questions require a live connection. Connect first. Follow‑ups with `-c` only help after a successful contextual call. - Plugin changes not recognized: After `\llm install` or `\llm uninstall`, mycli restarts automatically to load new commands. - Provider/API issues: Use `\llm keys list` and `\llm keys set ` to check credentials. Use `\llm models` to confirm available models. --- ## Notes and Safety - Data sent: Contextual questions send schema (table/column names and types) and a single sample row per table. Review your data sensitivity policies before using remote models; prefer local models (such as ollama) if needed. - Help: Running `\llm` with no arguments shows a short usage message. ## Turning Off LLM Support To turn off LLM support even when the `llm` dependency is installed, set the `MYCLI_LLM_OFF` environment variable: ```bash export MYCLI_LLM_OFF=1 ``` This may be desirable for faster startup times. --- ## Learn More - `llm` project docs: https://llm.datasette.io/ - `llm` plugin directory: https://llm.datasette.io/en/stable/plugins/directory.html ================================================ FILE: mycli/AUTHORS ================================================ Project Lead: ------------- * Roland Walker Core Developers: ---------------- * Thomas Roten * Irina Truong * Matheus Rosa * Darik Gamble * Dick Marinus * Amjith Ramanujam Contributors: ------------- * 0xflotus * Abirami P * Adam Chainz * Aljosha Papsch * Allrob * Andy Teijelo Pérez * Angelo Lupo * Artem Bezsmertnyi * bitkeen * bjarnagin * BuonOmo * caitinggui * Carlos Afonso * Casper Langemeijer * chainkite * Claude Becker * Colin Caine * cxbig * Daniel Black * Daniel West * Daniël van Eeden * Fabrizio Gennari * FatBoyXPC * François Pietka * Frederic Aoustin * Georgy Frolov * Heath Naylor * Huachao Mao * Ishaan Bhimwal * Jakub Boukal * jbruno * Jerome Provensal * Jialong Liu * Johannes Hoff * John Sterling * Jonathan Bruno * Jonathan Lloyd * Jonathan Slenders * Kacper Kwapisz * Karthikeyan Singaravelan * kevinhwang91 * KITAGAWA Yasutaka * Klaus Wünschel * laixintao * Lennart Weller * Martijn Engler * Massimiliano Torromeo * Michał Górny * Mike Palandra * Mikhail Borisov * Miodrag Tokić * Morgan Mitchell * mrdeathless * Nathan Huang * Nicolas Palumbo * Phil Cohen * QiaoHou Peng * Roland Walker * Ryan Smith * Scrappy Soft * Seamile * Shoma Suzuki * spacewander * Steve Robbins * Takeshi D. Itoh * Terje Røsten * Terseus * Tyler Kuipers * ushuz * William GARCIA * xeron * Yang Zou * Yasuhiro Matsumoto * Yuanchun Shang * Zach DeCook * Zane C. Bowers-Hadley * zer09 * Zhaolong Zhu * Zhidong * Zhongyang Guan * Arvind Mishra * Kevin Schmeichel * Mel Dafert * Thomas Copper * Will Wang * Alfred Wingate * Zhanze Wang * Houston Wong * Mohamed Rezk * Ryosuke Kazami * Cornel Cruceru * Sherlock Holo * keltaklo * 924060929 * tmijieux * Scott Nemes * Angelino Storm * Abhay Kumar Created by: ----------- Amjith Ramanujam ================================================ FILE: mycli/SPONSORS ================================================ Many thanks to the following Kickstarter backers. * Tech Blue Software * jweiland.net # Silver Sponsors * Whitane Tech * Open Query Pty Ltd * Prathap Ramamurthy * Lincoln Loop # Sponsors * Nathan Taggart * Iryna Cherniavska * Sudaraka Wijesinghe * www.mysqlfanboy.com * Steve Robbins * Norbert Spichtig * orpharion bestheneme * Daniel Black * Anonymous * Magnus udd * Anonymous * Lewis Peckover * Cyrille Tabary * Heath Naylor * Ted Pennings * Chris Anderton * Jonathan Slenders # Other Donors * OpenAI ================================================ FILE: mycli/TIPS ================================================ ### ### CLI arguments ### check your ~/.myclirc settings using the --checkup flag! list your aliased DSNs with the --list-dsn flag! log every query and result with the --logfile option! the --checkpoint option helps track successful queries in batch mode! the --format option helps set the output format in batch mode! the --throttle option helps slow down queries in batch mode! the --password-file option can be used with a FIFO to avoid saving creds to a file! the --character-set option sets the character set for a single session! the --unbuffered flag can save memory when in batch mode! --use-keyring=true lets you access the system keyring for passwords! --use-keyring=reset resets a password saved to the system keyring! the --myclirc option can change the config file location for a single session! the --execute option lets you execute a single line of SQL! the --auto-vertical-output flag lets you automatically switch to vertical output! the --show-warnings flag turns on warnings from the MySQL server! the --no-warn flag turns off warnings befor running a destructive query! the --init-command option lets you execute initialization SQL before a session! the --login-path option lets you work with login-path files! --keepalive-ticks= sets keepalive pings for a single session! ### ### commands ### interact with an LLM using the \llm command! copy a query to the clipboard using \clip at the end of the query! \dt lists tables; \dt describes
! edit a query in an external editor using \edit! edit a query in an external editor using \edit ! set "export VISUAL='code --wait'" in your shell to `\edit` queries using VS Code! \f lists favorite queries; \f executes a favorite! \fs saves a favorite query! \fd deletes a saved favorite query! \l lists databases! \once appends the next result to ! \| sends the next result to a subprocess! \t toggles timing of commands! \r or "connect" reconnects to the server! \delimiter changes the SQL delimiter! \q, "quit", or "exit" exits from the prompt! \? or "help" for help! "help " for help on SQL keywords! \n or "nopager" to disable the pager! use "tee"/"notee" to write/stop-writing results to a output file! \W or "warnings" enables automatic warnings display! \w or "nowarnings" disables automatic warnings display! \P or "pager" sets the pager. Try "pager less"! \R or "prompt" changes the prompt format! \Tr or "redirectformat" changes the table format for redirects! \# or "rehash" refreshes autocompletions! \. or "source" executes queries from a file! \s or "status" requests status information from the server! use "system " to execute a shell command! \T or "tableformat" changes the interactive table format! \u or "use" changes to a new database! the "watch" command executes a query every N seconds! use \bug to file a bug on GitHub! ### ### general ### display query output vertically using \G at the end of a query! run SQL scripts in batch mode using the standard input! ### ### keystrokes ### edit a query in an external editor using keystrokes control-x + control-e! open a documentation browser using keystroke F1! toggle smart completion using keystroke F2! toggle multi-line mode using keystroke F3! toggle vi mode using keystroke F4! complete at cursor using the tab key! summon completion candidates using control-space! control-space works well with "min_completion_trigger" in ~/.myclirc! prettify a query using keystrokes control-x + p! un-prettify a query using keystrokes control-x + u! insert the current date using keystrokes control-o + d! insert the quoted current date using keystrokes control-o + control-d! insert the current datetime using keystrokes control-o + t! insert the quoted current date using keystrokes control-o + control-t! search query history using keystroke control-r! use keystroke control-g to cancel completion popups! use keystroke right-arrow to accept a full-line suggestion from your history! cancel history search using keystrokes Escape or control-g! ### ### myclirc options ### set "less_chatty = True" in ~/.myclirc to turn off these tips! set a fancy table format like "table_format = psql_unicode" in ~/.myclirc! change the string for NULLs with "null_string = " in ~/.myclirc! choose a color theme with "syntax_style" in ~/.myclirc! design a prompt with the "prompt" option in ~/.myclirc! turn off multi-line prompt indentation with "prompt_continuation = ''" in ~/.myclirc! save passwords in the system keyring with "use_keyring" in ~/.myclirc! enable SHOW WARNINGS with "show warnings" in ~/.myclirc! turn off smart completions with "smart_completion" in ~/.myclirc! turn on multi-line mode with "multi_line" in ~/.myclirc! turn off destructive warnings with "destructive_warning" in ~/.myclirc! control destructive warnings with "destructive_keywords" in ~/.myclirc! move the history file locattion with "history_file" in ~/.myclirc! enable an audit log with "audit_log" in ~/.myclirc! disable timing of SQL statements with "timiing" in ~/.myclirc! disable display of SQL when running a favorite with "show_favorite_query" in ~/.myclirc! notify after a long query by setting "beep_after_seconds" in ~/.myclirc! control alignment with "numeric_alignment" in ~/.myclirc! control binary value display with "binary_display" in ~/.myclirc! set vi key bindings with "key_bindings" in ~/.myclirc! show more suggestions with "wider_completion_menu" in ~/.myclirc! use the host alias in the prompt with "login_path_as_host" in ~/.myclirc! auto-display wide results vertically with "auto_vertical_output" in ~/.myclirc! control keyword casing in completions using "keyword_casing" in ~/.myclirc! disable pager on startup using "enable_pager" in ~/.myclirc! choose a pager command with "pager" in ~/.myclirc! customize colors using the "[colors]" section in ~/.myclirc! customize LLM commands using the "[llm]" section in ~/.myclirc! customize history search using "control_r" in ~/.myclirc! edit favorite queries directly using the "[favorite_queries]" section in ~/.myclirc! set up initial commands using the "[init-commands]" section in ~/.myclirc! create DSN shortcuts using the "[alias_dsn]" section in ~/.myclirc! set up per-DSN initial commands using the "[alias_dsn.init-commands]" section in ~/.myclirc! set up connection defaults using the "[connection]" section in ~/.myclirc! use "min_completion_trigger" in ~/.myclirc to defer completions! colorize search previews with "highlight_preview" in ~/.myclirc! ### ### redirection ### redirect query output to a shell command with "$| "! redirect query output to a file with "$> "! append query output to a file with "$>> "! run a command after shell redirects with "post_redirect_command" in ~/.myclirc! ================================================ FILE: mycli/__init__.py ================================================ import importlib.metadata __version__: str = importlib.metadata.version("mycli") ================================================ FILE: mycli/clibuffer.py ================================================ from prompt_toolkit.application import get_app from prompt_toolkit.enums import DEFAULT_BUFFER from prompt_toolkit.filters import Condition, Filter from mycli.packages.special import iocommands from mycli.packages.special.main import COMMANDS as SPECIAL_COMMANDS def cli_is_multiline(mycli) -> Filter: @Condition def cond(): doc = get_app().layout.get_buffer_by_name(DEFAULT_BUFFER).document if not mycli.multi_line: return False else: return not _multiline_exception(doc.text) return cond def _multiline_exception(text: str) -> bool: orig = text text = text.strip() first_word = text.split(' ')[0] # Multi-statement favorite query is a special case. Because there will # be a semicolon separating statements, we can't consider semicolon an # EOL. Let's consider an empty line an EOL instead. if first_word.startswith("\\fs"): return orig.endswith("\n") return ( # Special Command first_word.startswith("\\") or text.endswith(( # Ended with the current delimiter (usually a semi-column) iocommands.get_current_delimiter(), # or ended with certain commands "\\g", "\\G", r"\e", r"\edit", r"\clip", )) or # non-backslashed special commands such as "exit" or "help" don't need semicolon first_word in SPECIAL_COMMANDS or # uppercase variants accepted first_word.lower() in SPECIAL_COMMANDS or # just a plain enter without any text (first_word == "") ) ================================================ FILE: mycli/clistyle.py ================================================ import logging from prompt_toolkit.styles import Style, merge_styles from prompt_toolkit.styles.pygments import style_from_pygments_cls from prompt_toolkit.styles.style import _MergedStyle from pygments.style import Style as PygmentsStyle import pygments.styles from pygments.token import Token, string_to_tokentype from pygments.util import ClassNotFound logger = logging.getLogger(__name__) # map Pygments tokens (ptk 1.0) to class names (ptk 2.0). TOKEN_TO_PROMPT_STYLE: dict[Token, str] = { Token.Menu.Completions.Completion.Current: "completion-menu.completion.current", Token.Menu.Completions.Completion: "completion-menu.completion", Token.Menu.Completions.Meta.Current: "completion-menu.meta.completion.current", Token.Menu.Completions.Meta: "completion-menu.meta.completion", Token.Menu.Completions.MultiColumnMeta: "completion-menu.multi-column-meta", Token.Menu.Completions.ProgressButton: "scrollbar.arrow", # best guess Token.Menu.Completions.ProgressBar: "scrollbar", # best guess Token.SelectedText: "selected", Token.SearchMatch: "search", Token.SearchMatch.Current: "search.current", Token.Toolbar: "bottom-toolbar", Token.Toolbar.Off: "bottom-toolbar.off", Token.Toolbar.On: "bottom-toolbar.on", Token.Toolbar.Search: "search-toolbar", Token.Toolbar.Search.Text: "search-toolbar.text", Token.Toolbar.System: "system-toolbar", Token.Toolbar.Arg: "arg-toolbar", Token.Toolbar.Arg.Text: "arg-toolbar.text", Token.Toolbar.Transaction.Valid: "bottom-toolbar.transaction.valid", Token.Toolbar.Transaction.Failed: "bottom-toolbar.transaction.failed", Token.Output.TableSeparator: "output.table-separator", Token.Output.Header: "output.header", Token.Output.OddRow: "output.odd-row", Token.Output.EvenRow: "output.even-row", Token.Output.Null: "output.null", Token.Output.Status: "output.status", Token.Output.Status.WarningCount: "output.status.warning-count", Token.Output.Timing: "output.timing", Token.Warnings.TableSeparator: "warnings.table-separator", Token.Warnings.Header: "warnings.header", Token.Warnings.OddRow: "warnings.odd-row", Token.Warnings.EvenRow: "warnings.even-row", Token.Warnings.Null: "warnings.null", Token.Warnings.Status: "warnings.status", Token.Warnings.Status.WarningCount: "warnings.status.warning-count", Token.Warnings.Timing: "warnings.timing", Token.Prompt: "prompt", Token.Continuation: "continuation", } # reverse dict for cli_helpers, because they still expect Pygments tokens. PROMPT_STYLE_TO_TOKEN: dict[str, Token] = {v: k for k, v in TOKEN_TO_PROMPT_STYLE.items()} # all tokens that the Pygments MySQL lexer can produce OVERRIDE_STYLE_TO_TOKEN: dict[str, Token] = { "sql.comment": Token.Comment, "sql.comment.multi-line": Token.Comment.Multiline, "sql.comment.single-line": Token.Comment.Single, "sql.comment.optimizer-hint": Token.Comment.Special, "sql.escape": Token.Error, "sql.keyword": Token.Keyword, "sql.datatype": Token.Keyword.Type, "sql.literal": Token.Literal, "sql.literal.date": Token.Literal.Date, "sql.symbol": Token.Name, "sql.quoted-schema-object": Token.Name.Quoted, "sql.quoted-schema-object.escape": Token.Name.Quoted.Escape, "sql.constant": Token.Name.Constant, "sql.function": Token.Name.Function, "sql.variable": Token.Name.Variable, "sql.number": Token.Number, "sql.number.binary": Token.Number.Bin, "sql.number.float": Token.Number.Float, "sql.number.hex": Token.Number.Hex, "sql.number.integer": Token.Number.Integer, "sql.operator": Token.Operator, "sql.punctuation": Token.Punctuation, "sql.string": Token.String, "sql.string.double-quouted": Token.String.Double, "sql.string.escape": Token.String.Escape, "sql.string.single-quoted": Token.String.Single, "sql.whitespace": Token.Text, } def parse_pygments_style( token_name: str, style_object: PygmentsStyle | str, style_dict: dict[str, str], ) -> tuple[Token, str]: """Parse token type and style string. :param token_name: str name of Pygments token. Example: "Token.String" :param style_object: pygments.style.Style instance to use as base :param style_dict: dict of token names and their styles, customized to this cli """ token_type = string_to_tokentype(token_name) if isinstance(style_object, PygmentsStyle): # When a Pygments Style class is passed, use its "styles" mapping. other_token_type = string_to_tokentype(style_dict[token_name]) return token_type, style_object.styles[other_token_type] else: return token_type, style_dict[token_name] def is_valid_pygments(name: str) -> bool: try: class TestStyle(PygmentsStyle): default_style = '' styles = {Token.Default: name} return True except AssertionError: # can't emit error because some styles are valid pygments and not valid ptoolkit return False def is_valid_ptoolkit(name: str) -> bool: try: _s = Style([("default", name)]) return True except ValueError: # can't emit error because some styles are valid pygments and not valid ptoolkit return False def style_factory_toolkit(name: str, cli_style: dict[str, str]) -> _MergedStyle: try: style: PygmentsStyle = pygments.styles.get_style_by_name(name) except ClassNotFound: style = pygments.styles.get_style_by_name("native") prompt_styles: list[tuple[str, str]] = [] # prompt-toolkit used pygments tokens for styling before, switched to style # names in 2.0. Convert old token types to new style names, for backwards compatibility. for token in cli_style: if token.startswith("Token."): # treat as pygments token (1.0) token_type, style_value = parse_pygments_style(token, style, cli_style) if token_type in TOKEN_TO_PROMPT_STYLE: prompt_style = TOKEN_TO_PROMPT_STYLE[token_type] if is_valid_ptoolkit(style_value): prompt_styles.append((prompt_style, style_value)) else: # we don't want to support tokens anymore logger.error("Unhandled style / class name: %s", token) else: # treat as prompt style name (2.0). See default style names here: # https://github.com/jonathanslenders/python-prompt-toolkit/blob/master/prompt_toolkit/styles/defaults.py if is_valid_ptoolkit(cli_style[token]): prompt_styles.append((token, cli_style[token])) override_style: Style = Style([("bottom-toolbar", "noreverse")]) return merge_styles([style_from_pygments_cls(style), override_style, Style(prompt_styles)]) def style_factory_helpers( name: str, cli_style: dict[str, str], warnings: bool = False, ) -> PygmentsStyle: try: style: dict[PygmentsStyle | str, str] = pygments.styles.get_style_by_name(name).styles except ClassNotFound: style = pygments.styles.get_style_by_name("native").styles for token in cli_style: if token.startswith("Token."): token_type, style_value = parse_pygments_style(token, style, cli_style) if is_valid_pygments(style_value): style.update({token_type: style_value}) elif token in PROMPT_STYLE_TO_TOKEN: token_type = PROMPT_STYLE_TO_TOKEN[token] if is_valid_pygments(cli_style[token]): style.update({token_type: cli_style[token]}) elif token in OVERRIDE_STYLE_TO_TOKEN: token_type = OVERRIDE_STYLE_TO_TOKEN[token] if is_valid_pygments(cli_style[token]): style.update({token_type: cli_style[token]}) else: # TODO: cli helpers will have to switch to ptk.Style logger.error("Unhandled style / class name: %s", token) if warnings: for warning_token in list(style.keys()): if 'Warnings' not in str(warning_token): continue warning_str = str(warning_token) output_str = warning_str.replace('Warnings', 'Output') output_token = string_to_tokentype(output_str) style[output_token] = style[warning_token] class OutputStyle(PygmentsStyle): default_style = "" styles = style return OutputStyle ================================================ FILE: mycli/clitoolbar.py ================================================ from typing import Callable from prompt_toolkit.application import get_app from prompt_toolkit.enums import EditingMode from prompt_toolkit.formatted_text import to_formatted_text from prompt_toolkit.key_binding.vi_state import InputMode from mycli.packages import special def create_toolbar_tokens_func(mycli, show_initial_toolbar_help: Callable, format_string: str | None) -> Callable: """Return a function that generates the toolbar tokens.""" def get_toolbar_tokens() -> list[tuple[str, str]]: divider = ('class:bottom-toolbar', ' │ ') result = [("class:bottom-toolbar", "[Tab] Complete")] dynamic = [] result.append(divider) result.append(("class:bottom-toolbar", "[F1] Help")) if mycli.completer.smart_completion: result.append(divider) result.append(("class:bottom-toolbar", "[F2] Smart-complete:")) result.append(("class:bottom-toolbar.on", "ON ")) else: result.append(divider) result.append(("class:bottom-toolbar", "[F2] Smart-complete:")) result.append(("class:bottom-toolbar.off", "OFF")) if mycli.multi_line: result.append(divider) result.append(("class:bottom-toolbar", "[F3] Multiline:")) result.append(("class:bottom-toolbar.on", "ON ")) else: result.append(divider) result.append(("class:bottom-toolbar", "[F3] Multiline:")) result.append(("class:bottom-toolbar.off", "OFF")) if mycli.prompt_app.editing_mode == EditingMode.VI: result.append(divider) result.append(("class:bottom-toolbar", "Vi:")) result.append(("class:bottom-toolbar.on", _get_vi_mode())) if mycli.toolbar_error_message: dynamic.append(divider) dynamic.append(("class:bottom-toolbar.transaction.failed", mycli.toolbar_error_message)) mycli.toolbar_error_message = None if mycli.multi_line: delimiter = special.get_current_delimiter() if delimiter != ';' or show_initial_toolbar_help(): dynamic.append(divider) dynamic.append(('class:bottom-toolbar', '"')) dynamic.append(('class:bottom-toolbar.on', delimiter)) dynamic.append(('class:bottom-toolbar', '" ends a statement')) if show_initial_toolbar_help(): dynamic.append(divider) dynamic.append(("class:bottom-toolbar", "right-arrow accepts full-line suggestion")) if mycli.completion_refresher.is_refreshing(): dynamic.append(divider) dynamic.append(("class:bottom-toolbar", "Refreshing completions…")) if format_string and format_string != r'\B': if format_string.startswith(r'\B'): amended_format = format_string[2:] result.extend(dynamic) dynamic = [] result.append(('class:bottom-toolbar', '\n')) else: amended_format = format_string result = [] formatted = to_formatted_text(mycli.get_custom_toolbar(amended_format), style='class:bottom-toolbar') result.extend([*formatted]) # coerce to list for mypy result.extend(dynamic) return result return get_toolbar_tokens def _get_vi_mode() -> str: """Get the current vi mode for display.""" return { InputMode.INSERT: "I", InputMode.NAVIGATION: "N", InputMode.REPLACE: "R", InputMode.REPLACE_SINGLE: "R", InputMode.INSERT_MULTIPLE: "M", }[get_app().vi_state.input_mode] ================================================ FILE: mycli/compat.py ================================================ """Platform and Python version compatibility support.""" import sys WIN: bool = sys.platform in ("win32", "cygwin") ================================================ FILE: mycli/completion_refresher.py ================================================ import threading from typing import Callable from mycli.packages.special.main import COMMANDS from mycli.packages.sqlresult import SQLResult from mycli.sqlcompleter import SQLCompleter from mycli.sqlexecute import ServerSpecies, SQLExecute class CompletionRefresher: refreshers: dict = {} def __init__(self) -> None: self._completer_thread: threading.Thread | None = None self._restart_refresh = threading.Event() def refresh( self, executor: SQLExecute, callbacks: Callable | list[Callable], completer_options: dict | None = None, ) -> list[SQLResult]: """Creates a SQLCompleter object and populates it with the relevant completion suggestions in a background thread. executor - SQLExecute object, used to extract the credentials to connect to the database. callbacks - A function or a list of functions to call after the thread has completed the refresh. The newly created completion object will be passed in as an argument to each callback. completer_options - dict of options to pass to SQLCompleter. """ if completer_options is None: completer_options = {} if self.is_refreshing(): self._restart_refresh.set() return [SQLResult(status="Auto-completion refresh restarted.")] else: self._completer_thread = threading.Thread( target=self._bg_refresh, args=(executor, callbacks, completer_options), name="completion_refresh" ) self._completer_thread.daemon = True self._completer_thread.start() return [SQLResult(status="Auto-completion refresh started in the background.")] def is_refreshing(self) -> bool: return bool(self._completer_thread and self._completer_thread.is_alive()) def _bg_refresh( self, sqlexecute: SQLExecute, callbacks: Callable | list[Callable], completer_options: dict, ) -> None: completer = SQLCompleter(**completer_options) # Create a new sqlexecute method to populate the completions. e = sqlexecute executor = SQLExecute( e.dbname, e.user, e.password, e.host, e.port, e.socket, e.character_set, e.local_infile, e.ssl, e.ssh_user, e.ssh_host, e.ssh_port, e.ssh_password, e.ssh_key_filename, ) # If callbacks is a single function then push it into a list. if callable(callbacks): callbacks = [callbacks] while 1: for refresher in self.refreshers.values(): refresher(completer, executor) if self._restart_refresh.is_set(): self._restart_refresh.clear() break else: # Break out of while loop if the for loop finishes natually # without hitting the break statement. break # Start over the refresh from the beginning if the for loop hit the # break statement. continue for callback in callbacks: callback(completer) executor.close() def refresher(name: str, refreshers: dict = CompletionRefresher.refreshers) -> Callable: """Decorator to add the decorated function to the dictionary of refreshers. Any function decorated with a @refresher will be executed as part of the completion refresh routine.""" def wrapper(wrapped): refreshers[name] = wrapped return wrapped return wrapper @refresher("databases") def refresh_databases(completer: SQLCompleter, executor: SQLExecute) -> None: completer.extend_database_names(executor.databases()) @refresher("schemata") def refresh_schemata(completer: SQLCompleter, executor: SQLExecute) -> None: # schemata - In MySQL Schema is the same as database. But for mycli # schemata will be the name of the current database. completer.extend_schemata(executor.dbname) completer.set_dbname(executor.dbname) @refresher("tables") def refresh_tables(completer: SQLCompleter, executor: SQLExecute) -> None: table_columns_dbresult = list(executor.table_columns()) completer.extend_relations(table_columns_dbresult, kind="tables") completer.extend_columns(table_columns_dbresult, kind="tables") @refresher("enum_values") def refresh_enum_values(completer: SQLCompleter, executor: SQLExecute) -> None: completer.extend_enum_values(executor.enum_values()) @refresher("users") def refresh_users(completer: SQLCompleter, executor: SQLExecute) -> None: completer.extend_users(executor.users()) # @refresher('views') # def refresh_views(completer: SQLCompleter, executor: SQLExecute) -> None: # completer.extend_relations(executor.views(), kind='views') # completer.extend_columns(executor.view_columns(), kind='views') @refresher("functions") def refresh_functions(completer: SQLCompleter, executor: SQLExecute) -> None: completer.extend_functions(executor.functions()) if executor.server_info and executor.server_info.species == ServerSpecies.TiDB: completer.extend_functions(completer.tidb_functions, builtin=True) @refresher("procedures") def refresh_procedures(completer: SQLCompleter, executor: SQLExecute) -> None: completer.extend_procedures(executor.procedures()) @refresher("character_sets") def refresh_character_sets(completer: SQLCompleter, executor: SQLExecute) -> None: completer.extend_character_sets(executor.character_sets()) @refresher("collations") def refresh_collations(completer: SQLCompleter, executor: SQLExecute) -> None: completer.extend_collations(executor.collations()) @refresher("special_commands") def refresh_special(completer: SQLCompleter, executor: SQLExecute) -> None: completer.extend_special_commands(list(COMMANDS.keys())) @refresher("show_commands") def refresh_show_commands(completer: SQLCompleter, executor: SQLExecute) -> None: completer.extend_show_items(executor.show_candidates()) @refresher("keywords") def refresh_keywords(completer: SQLCompleter, executor: SQLExecute) -> None: if executor.server_info and executor.server_info.species == ServerSpecies.TiDB: completer.extend_keywords(completer.tidb_keywords, replace=True) ================================================ FILE: mycli/config.py ================================================ from copy import copy from importlib import resources from io import BytesIO, TextIOWrapper import logging import os from os.path import exists import struct import sys from typing import IO, BinaryIO, Literal from configobj import ConfigObj, ConfigObjError from Cryptodome.Cipher import AES logger = logging.getLogger(__name__) def log(logger: logging.Logger, level: int, message: str) -> None: """Logs message to stderr if logging isn't initialized.""" if logger.parent and logger.parent.name == "root": print(message, file=sys.stderr) logger.log(level, message) def read_config_file(f: str | IO[str], list_values: bool = True) -> ConfigObj | None: """Read a config file. *list_values* set to `True` is the default behavior of ConfigObj. Disabling it causes values to not be parsed for lists, (e.g. 'a,b,c' -> ['a', 'b', 'c']. Additionally, the config values are not unquoted. We are disabling list_values when reading MySQL config files so we can correctly interpret commas in passwords. """ if isinstance(f, str): f = os.path.expanduser(f) try: config = ConfigObj(f, interpolation=False, encoding="utf8", list_values=list_values) except ConfigObjError as e: log(logger, logging.WARNING, "Unable to parse line {0} of config file '{1}'.".format(e.line_number, f)) log(logger, logging.WARNING, "Using successfully parsed config values.") return e.config except (IOError, OSError) as e: log(logger, logging.WARNING, "You don't have permission to read config file '{0}'.".format(e.filename)) return None return config def get_included_configs(config_file: str | IO[str]) -> list[str | IO[str]]: """Get a list of configuration files that are included into config_path with !includedir directive. "Normal" configs should be passed as file paths. The only exception is .mylogin which is decoded into a stream. However, it never contains include directives and so will be ignored by this function. """ if not isinstance(config_file, str) or not os.path.isfile(config_file): return [] included_configs: list[str | IO[str]] = [] try: with open(config_file) as f: include_directives = filter(lambda s: s.startswith("!includedir"), f) dirs_split = (s.strip().split()[-1] for s in include_directives) dirs = filter(os.path.isdir, dirs_split) for dir_ in dirs: for filename in os.listdir(dir_): if filename.endswith(".cnf"): included_configs.append(os.path.join(dir_, filename)) except (PermissionError, UnicodeDecodeError): pass return included_configs def read_config_files( files: list[str | IO[str]], list_values: bool = True, ignore_package_defaults: bool = False, ignore_user_options: bool = False, ) -> ConfigObj: """Read and merge a list of config files.""" if ignore_package_defaults: config = ConfigObj() else: config = create_default_config(list_values=list_values) if ignore_user_options: return config _files = copy(files) while _files: _file = _files.pop(0) _config = read_config_file(_file, list_values=list_values) # expand includes only if we were able to parse config # (otherwise we'll just encounter the same errors again) if config is not None: _files = get_included_configs(_file) + _files if _config is not None: config.merge(_config) config.filename = _config.filename return config def create_default_config(list_values: bool = True) -> ConfigObj: import mycli default_config_file = resources.files(mycli).joinpath("myclirc").open('r') return read_config_file(default_config_file, list_values=list_values) def write_default_config(destination: str, overwrite: bool = False) -> None: import mycli with resources.files(mycli).joinpath("myclirc").open('r') as f: default_config = f.read() destination = os.path.expanduser(destination) if not overwrite and exists(destination): return with open(destination, "w") as f: f.write(default_config) def get_mylogin_cnf_path() -> str | None: """Return the path to the login path file or None if it doesn't exist.""" mylogin_cnf_path = os.getenv("MYSQL_TEST_LOGIN_FILE") if mylogin_cnf_path is None: app_data = os.getenv("APPDATA") default_dir = os.path.join(app_data, "MySQL") if app_data else "~" mylogin_cnf_path = os.path.join(default_dir, ".mylogin.cnf") mylogin_cnf_path = os.path.expanduser(mylogin_cnf_path) if exists(mylogin_cnf_path): logger.debug("Found login path file at '{0}'".format(mylogin_cnf_path)) return mylogin_cnf_path return None def open_mylogin_cnf(name: str) -> TextIOWrapper | None: """Open a readable version of .mylogin.cnf. Returns the file contents as a TextIOWrapper object. :param str name: The pathname of the file to be opened. :return: the login path file or None """ try: with open(name, "rb") as f: plaintext = read_and_decrypt_mylogin_cnf(f) except (OSError, IOError, ValueError): logger.error("Unable to open login path file.") return None if not isinstance(plaintext, BytesIO): logger.error("Unable to read login path file.") return None return TextIOWrapper(plaintext) # TODO reuse code between encryption an decryption def encrypt_mylogin_cnf(plaintext: IO[str]) -> BytesIO: """Encryption of .mylogin.cnf file, analogous to calling mysql_config_editor. Code is based on the python implementation by Kristian Koehntopp https://github.com/isotopp/mysql-config-coder """ def realkey(key: bytes) -> bytes: """Create the AES key from the login key.""" rkey = bytearray(16) for i in range(len(key)): rkey[i % 16] ^= key[i] return bytes(rkey) def encode_line(plaintext: str, real_key: bytes, buf_len: int) -> bytes: aes = AES.new(real_key, AES.MODE_ECB) text_len = len(plaintext) pad_len = buf_len - text_len pad_chr = bytes(chr(pad_len), "utf8") plaintext_b = plaintext.encode() + pad_chr * pad_len encrypted_text = b"".join([aes.encrypt(plaintext_b[i : i + 16]) for i in range(0, len(plaintext_b), 16)]) return encrypted_text LOGIN_KEY_LENGTH = 20 key = os.urandom(LOGIN_KEY_LENGTH) real_key = realkey(key) outfile = BytesIO() outfile.write(struct.pack("i", 0)) outfile.write(key) while True: line = plaintext.readline() if not line: break real_len = len(line) pad_len = (int(real_len / 16) + 1) * 16 outfile.write(struct.pack("i", pad_len)) x = encode_line(line, real_key, pad_len) outfile.write(x) outfile.seek(0) return outfile def read_and_decrypt_mylogin_cnf(f: BinaryIO) -> BytesIO | None: """Read and decrypt the contents of .mylogin.cnf. This decryption algorithm mimics the code in MySQL's mysql_config_editor.cc. The login key is 20-bytes of random non-printable ASCII. It is written to the actual login path file. It is used to generate the real key used in the AES cipher. :param f: an I/O object opened in binary mode :return: the decrypted login path file :rtype: io.BytesIO or None """ # Number of bytes used to store the length of ciphertext. MAX_CIPHER_STORE_LEN = 4 LOGIN_KEY_LEN = 20 # Move past the unused buffer. buf = f.read(4) if not buf or len(buf) != 4: logger.error("Login path file is blank or incomplete.") return None # Read the login key. key = f.read(LOGIN_KEY_LEN) # Generate the real key. rkey = [0] * 16 for i in range(LOGIN_KEY_LEN): try: rkey[i % 16] ^= ord(key[i : i + 1]) except TypeError: # ord() was unable to get the value of the byte. logger.error("Unable to generate login path AES key.") return None rkey_b = struct.pack("16B", *rkey) # Create a bytes buffer to hold the plaintext. plaintext = BytesIO() aes = AES.new(rkey_b, AES.MODE_ECB) while True: # Read the length of the ciphertext. len_buf = f.read(MAX_CIPHER_STORE_LEN) if len(len_buf) < MAX_CIPHER_STORE_LEN: break (cipher_len,) = struct.unpack(" bool: """Convert a string value to its corresponding boolean value.""" if isinstance(s, bool): return s elif not isinstance(s, str): raise TypeError("argument must be a string") true_values = ("true", "on", "1") false_values = ("false", "off", "0") if s.lower() in true_values: return True elif s.lower() in false_values: return False else: raise ValueError(f'not a recognized boolean value: {s}') def strip_matching_quotes(s: str) -> str: """Remove matching, surrounding quotes from a string. This is the same logic that ConfigObj uses when parsing config values. """ if isinstance(s, str) and len(s) >= 2 and s[0] == s[-1] and s[0] in ('"', "'"): s = s[1:-1] return s def _remove_pad(line: bytes) -> bytes | Literal[False]: """Remove the pad from the *line*.""" try: # Determine pad length. pad_length = ord(line[-1:]) except TypeError: # ord() was unable to get the value of the byte. logger.warning("Unable to remove pad.") return False if pad_length > len(line) or len(set(line[-pad_length:])) != 1: # Pad length should be less than or equal to the length of the # plaintext. The pad should have a single unique byte. logger.warning("Invalid pad found in login path file.") return False return line[:-pad_length] ================================================ FILE: mycli/constants.py ================================================ HOME_URL = 'https://mycli.net' REPO_URL = 'https://github.com/dbcli/mycli' DOCS_URL = f'{HOME_URL}/docs' ISSUES_URL = f'{REPO_URL}/issues' DEFAULT_CHARSET = 'utf8mb4' DEFAULT_DATABASE = 'mysql' DEFAULT_HOST = 'localhost' DEFAULT_PORT = 3306 DEFAULT_USER = 'root' TEST_DATABASE = 'mycli_test_db' ================================================ FILE: mycli/key_bindings.py ================================================ import logging import webbrowser import prompt_toolkit from prompt_toolkit.application.current import get_app from prompt_toolkit.enums import EditingMode from prompt_toolkit.filters import ( Condition, completion_is_selected, control_is_searchable, emacs_mode, ) from prompt_toolkit.key_binding import KeyBindings from prompt_toolkit.key_binding.key_processor import KeyPressEvent from prompt_toolkit.selection import SelectionType from mycli.constants import DOCS_URL from mycli.packages import shortcuts from mycli.packages.toolkit.fzf import search_history from mycli.packages.toolkit.utils import safe_invalidate_display _logger = logging.getLogger(__name__) @Condition def ctrl_d_condition() -> bool: """Ctrl-D exit binding is only active when the buffer is empty.""" app = get_app() return not app.current_buffer.text @Condition def in_completion() -> bool: app = get_app() return bool(app.current_buffer.complete_state) def print_f1_help(): app = get_app() app.print_text('\n') app.print_text([ ('', 'Inline help — type "'), ('bold', 'help'), ('', '" or "'), ('bold', r'\?'), ('', '"\n'), ]) app.print_text([ ('', 'Docs index — '), ('bold', DOCS_URL), ('', '\n'), ]) app.print_text('\n') def mycli_bindings(mycli) -> KeyBindings: """Custom key bindings for mycli.""" kb = KeyBindings() @kb.add('f1') def _(event: KeyPressEvent) -> None: """Open browser to documentation index.""" _logger.debug('Detected F1 key.') webbrowser.open_new_tab(DOCS_URL) prompt_toolkit.application.run_in_terminal(print_f1_help) safe_invalidate_display(event.app) @kb.add('escape', '[', 'P') def _(event: KeyPressEvent) -> None: """Open browser to documentation index.""" _logger.debug("Detected alternate F1 key sequence.") webbrowser.open_new_tab(DOCS_URL) prompt_toolkit.application.run_in_terminal(print_f1_help) safe_invalidate_display(event.app) @kb.add("f2") def _(_event: KeyPressEvent) -> None: """Enable/Disable SmartCompletion Mode.""" _logger.debug("Detected F2 key.") mycli.completer.smart_completion = not mycli.completer.smart_completion @kb.add('escape', '[', 'Q') def _(_event: KeyPressEvent) -> None: """Enable/Disable SmartCompletion Mode.""" _logger.debug("Detected alternate F2 key sequence.") mycli.completer.smart_completion = not mycli.completer.smart_completion @kb.add("f3") def _(_event: KeyPressEvent) -> None: """Enable/Disable Multiline Mode.""" _logger.debug("Detected F3 key.") mycli.multi_line = not mycli.multi_line @kb.add('escape', '[', 'R') def _(_event: KeyPressEvent) -> None: """Enable/Disable Multiline Mode.""" _logger.debug('Detected alternate F3 key sequence.') mycli.multi_line = not mycli.multi_line @kb.add("f4") def _(event: KeyPressEvent) -> None: """Toggle between Vi and Emacs mode.""" _logger.debug("Detected F4 key.") if mycli.key_bindings == "vi": event.app.editing_mode = EditingMode.EMACS mycli.key_bindings = "emacs" event.app.ttimeoutlen = mycli.emacs_ttimeoutlen else: event.app.editing_mode = EditingMode.VI mycli.key_bindings = "vi" event.app.ttimeoutlen = mycli.vi_ttimeoutlen @kb.add('escape', '[', 'S') def _(event: KeyPressEvent) -> None: """Toggle between Vi and Emacs mode.""" _logger.debug('Detected alternate F4 key sequence.') if mycli.key_bindings == 'vi': event.app.editing_mode = EditingMode.EMACS mycli.key_bindings = 'emacs' event.app.ttimeoutlen = mycli.emacs_ttimeoutlen else: event.app.editing_mode = EditingMode.VI mycli.key_bindings = 'vi' event.app.ttimeoutlen = mycli.vi_ttimeoutlen @kb.add("tab") def _(event: KeyPressEvent) -> None: """Complete action at cursor.""" _logger.debug("Detected key.") b = event.app.current_buffer behaviors = mycli.config['keys'].as_list('tab') if 'toolkit_default' in behaviors: if b.complete_state: b.complete_next() else: b.start_completion(select_first=True) if b.complete_state: if 'advance' in behaviors: b.complete_next() elif 'cancel' in behaviors: b.cancel_completion() return if 'advancing_summon' in behaviors: b.start_completion(select_first=True) elif 'prefixing_summon' in behaviors: b.start_completion(insert_common_part=True) elif 'summon' in behaviors: b.start_completion(select_first=False) @kb.add("escape", eager=True, filter=in_completion) def _(event: KeyPressEvent) -> None: """Cancel completion menu. There will be a lag when canceling Escape due to the processing of Alt- keystrokes as Escape- sequences. There will be no lag when using control-g to cancel.""" event.app.current_buffer.cancel_completion() @kb.add("c-space") def _(event: KeyPressEvent) -> None: """ Complete action at cursor. By default, if the autocompletion menu is not showing, display it with the appropriate completions for the context. If the menu is showing, select the next completion. """ _logger.debug("Detected key.") b = event.app.current_buffer behaviors = mycli.config['keys'].as_list('control_space') if 'toolkit_default' in behaviors: if b.text: b.start_selection(selection_type=SelectionType.CHARACTERS) return if b.complete_state: if 'advance' in behaviors: b.complete_next() elif 'cancel' in behaviors: b.cancel_completion() return if 'advancing_summon' in behaviors: b.start_completion(select_first=True) elif 'prefixing_summon' in behaviors: b.start_completion(insert_common_part=True) elif 'summon' in behaviors: b.start_completion(select_first=False) @kb.add("c-x", "p", filter=emacs_mode) def _(event: KeyPressEvent) -> None: """ Prettify and indent current statement, usually into multiple lines. Only accepts buffers containing single SQL statements. """ _logger.debug("Detected /> key.") b = event.app.current_buffer if b.text: b.transform_region(0, len(b.text), mycli.handle_prettify_binding) @kb.add("c-x", "u", filter=emacs_mode) def _(event: KeyPressEvent) -> None: """ Unprettify and dedent current statement, usually into one line. Only accepts buffers containing single SQL statements. """ _logger.debug("Detected /< key.") b = event.app.current_buffer if b.text: b.transform_region(0, len(b.text), mycli.handle_unprettify_binding) @kb.add("c-o", "d", filter=emacs_mode) def _(event: KeyPressEvent) -> None: """ Insert the current date. """ _logger.debug("Detected key.") event.app.current_buffer.insert_text(shortcuts.server_date(mycli.sqlexecute)) @kb.add("c-o", "c-d", filter=emacs_mode) def _(event: KeyPressEvent) -> None: """ Insert the quoted current date. """ _logger.debug("Detected key.") event.app.current_buffer.insert_text(shortcuts.server_date(mycli.sqlexecute, quoted=True)) @kb.add("c-o", "t", filter=emacs_mode) def _(event: KeyPressEvent) -> None: """ Insert the current datetime. """ _logger.debug("Detected key.") event.app.current_buffer.insert_text(shortcuts.server_datetime(mycli.sqlexecute)) @kb.add("c-o", "c-t", filter=emacs_mode) def _(event: KeyPressEvent) -> None: """ Insert the quoted current datetime. """ _logger.debug("Detected key.") event.app.current_buffer.insert_text(shortcuts.server_datetime(mycli.sqlexecute, quoted=True)) @kb.add("c-r", filter=control_is_searchable) def _(event: KeyPressEvent) -> None: """Search history using fzf or reverse incremental search.""" _logger.debug("Detected key.") mode = mycli.config.get('keys', {}).get('control_r', 'auto') if mode == 'reverse_isearch': search_history(event, incremental=True) else: search_history( event, highlight_preview=mycli.highlight_preview, highlight_style=mycli.syntax_style, ) @kb.add("escape", "r", filter=control_is_searchable & emacs_mode) def _(event: KeyPressEvent) -> None: """Search history using fzf when available.""" _logger.debug("Detected key.") search_history( event, highlight_preview=mycli.highlight_preview, highlight_style=mycli.syntax_style, ) @kb.add('c-d', filter=ctrl_d_condition) def _(event: KeyPressEvent) -> None: """Exit mycli or ignore keypress.""" _logger.debug('Detected key on empty line.') mode = mycli.config.get('keys', {}).get('control_d', 'exit') if mode == 'exit': event.app.exit(exception=EOFError, style='class:exiting') else: event.app.output.bell() @kb.add("enter", filter=completion_is_selected) def _(event: KeyPressEvent) -> None: """Makes the enter key work as the tab key only when showing the menu. In other words, don't execute query when enter is pressed in the completion dropdown menu, instead close the dropdown menu (accept current selection). """ _logger.debug("Detected enter key.") event.current_buffer.complete_state = None b = event.app.current_buffer b.complete_state = None @kb.add("escape", "enter") def _(event: KeyPressEvent) -> None: """Introduces a line break in multi-line mode, or dispatches the command in single-line mode.""" _logger.debug("Detected alt-enter key.") if mycli.multi_line: event.app.current_buffer.validate_and_handle() else: event.app.current_buffer.insert_text("\n") return kb ================================================ FILE: mycli/lexer.py ================================================ from pygments.lexer import inherit from pygments.lexers.sql import MySqlLexer from pygments.token import Keyword class MyCliLexer(MySqlLexer): """Extends MySQL lexer to add keywords.""" tokens = { "root": [(r"\brepair\b", Keyword), (r"\boffset\b", Keyword), inherit], } ================================================ FILE: mycli/magic.py ================================================ import logging from typing import Any import sql.connection import sql.parse from mycli.main import MyCli, Query _logger: logging.Logger = logging.getLogger(__name__) def load_ipython_extension(ipython) -> None: # This is called via the ipython command '%load_ext mycli.magic'. # First, load the sql magic if it isn't already loaded. if not ipython.find_line_magic("sql"): ipython.run_line_magic("load_ext", "sql") # Register our own magic. ipython.register_magic_function(mycli_line_magic, "line", "mycli") def mycli_line_magic(line: str): _logger.debug("mycli magic called: %r", line) parsed: dict[str, Any] = sql.parse.parse(line, {}) # "get" was renamed to "set" in ipython-sql: # https://github.com/catherinedevlin/ipython-sql/commit/f4283c65aaf68f961e84019e8b939e4a3c501d43 if hasattr(sql.connection.Connection, "get"): conn = sql.connection.Connection.get(parsed["connection"]) else: try: conn = sql.connection.Connection.set(parsed["connection"]) # a new positional argument was added to Connection.set in version 0.4.0 of ipython-sql except TypeError: conn = sql.connection.Connection.set(parsed["connection"], False) try: # A corresponding mycli object already exists mycli: MyCli = conn._mycli _logger.debug("Reusing existing mycli") except AttributeError: mycli = MyCli() u = conn.session.engine.url _logger.debug("New mycli: %r", str(u)) mycli.connect(host=u.host, port=u.port, passwd=u.password, database=u.database, user=u.username, init_command=None) conn._mycli = mycli # For convenience, print the connection alias print(f'Connected: {conn.name}') try: mycli.run_cli() except SystemExit: pass if not mycli.query_history: return q: Query = mycli.query_history[-1] if q.mutating: _logger.debug("Mutating query detected -- ignoring") return if q.successful: ipython = get_ipython() # type: ignore # noqa: F821 return ipython.run_cell_magic("sql", line, q.query) ================================================ FILE: mycli/main.py ================================================ from __future__ import annotations from collections import defaultdict, namedtuple from decimal import Decimal import functools from io import TextIOWrapper import logging import os import random import re import shutil import subprocess import sys import threading import traceback from typing import IO, Any, Callable, Generator, Iterable, Literal try: from pwd import getpwuid except ImportError: pass from datetime import datetime from importlib import resources import itertools from random import choice from textwrap import dedent from time import sleep, time from urllib.parse import parse_qs, unquote, urlparse import warnings from cli_helpers.tabular_output import TabularOutputFormatter, preprocessors from cli_helpers.tabular_output.output_formatter import MISSING_VALUE as DEFAULT_MISSING_VALUE from cli_helpers.utils import strip_ansi import click from configobj import ConfigObj import keyring from prompt_toolkit import print_formatted_text from prompt_toolkit.application.current import get_app from prompt_toolkit.auto_suggest import AutoSuggestFromHistory, ThreadedAutoSuggest from prompt_toolkit.completion import Completion, DynamicCompleter from prompt_toolkit.document import Document from prompt_toolkit.enums import DEFAULT_BUFFER, EditingMode from prompt_toolkit.filters import Condition, HasFocus, IsDone from prompt_toolkit.formatted_text import ( ANSI, HTML, AnyFormattedText, FormattedText, to_formatted_text, to_plain_text, ) from prompt_toolkit.key_binding.bindings.named_commands import register as prompt_register from prompt_toolkit.key_binding.key_processor import KeyPressEvent from prompt_toolkit.layout.processors import ConditionalProcessor, HighlightMatchingBracketProcessor from prompt_toolkit.lexers import PygmentsLexer from prompt_toolkit.output import ColorDepth from prompt_toolkit.shortcuts import CompleteStyle, PromptSession import pymysql from pymysql.constants.CR import CR_SERVER_LOST from pymysql.constants.ER import ACCESS_DENIED_ERROR, HANDSHAKE_ERROR from pymysql.cursors import Cursor import sqlparse with warnings.catch_warnings(): # for sqlglot v29.0.1 warnings.filterwarnings( 'ignore', message=r'sqlglot\[rs\] is deprecated', category=UserWarning, module='sqlglot', ) import sqlglot from mycli import __version__ from mycli.clibuffer import cli_is_multiline from mycli.clistyle import style_factory_helpers, style_factory_toolkit from mycli.clitoolbar import create_toolbar_tokens_func from mycli.compat import WIN from mycli.completion_refresher import CompletionRefresher from mycli.config import get_mylogin_cnf_path, open_mylogin_cnf, read_config_files, str_to_bool, strip_matching_quotes, write_default_config from mycli.constants import ( DEFAULT_CHARSET, DEFAULT_HOST, DEFAULT_PORT, HOME_URL, ISSUES_URL, REPO_URL, ) from mycli.key_bindings import mycli_bindings from mycli.lexer import MyCliLexer from mycli.packages import special from mycli.packages.checkup import do_checkup from mycli.packages.filepaths import dir_path_exists, guess_socket_location from mycli.packages.hybrid_redirection import get_redirect_components, is_redirect_command from mycli.packages.parseutils import is_destructive, is_dropping_database, is_valid_connection_scheme from mycli.packages.prompt_utils import confirm, confirm_destructive_query from mycli.packages.special.favoritequeries import FavoriteQueries from mycli.packages.special.main import ArgType from mycli.packages.special.utils import format_uptime, get_ssl_version, get_uptime, get_warning_count from mycli.packages.sqlresult import SQLResult from mycli.packages.string_utils import sanitize_terminal_title from mycli.packages.tabular_output import sql_format from mycli.packages.toolkit.history import FileHistoryWithTimestamp from mycli.sqlcompleter import SQLCompleter from mycli.sqlexecute import FIELD_TYPES, SQLExecute try: import paramiko except ImportError: from mycli.packages.paramiko_stub import paramiko # type: ignore[no-redef] sqlparse.engine.grouping.MAX_GROUPING_DEPTH = None # type: ignore[assignment] sqlparse.engine.grouping.MAX_GROUPING_TOKENS = None # type: ignore[assignment] # Query tuples are used for maintaining history Query = namedtuple("Query", ["query", "successful", "mutating"]) SUPPORT_INFO = f"Home: {HOME_URL}\nBug tracker: {ISSUES_URL}" DEFAULT_WIDTH = 80 DEFAULT_HEIGHT = 25 MIN_COMPLETION_TRIGGER = 1 MAX_MULTILINE_BATCH_STATEMENT = 5000 EMPTY_PASSWORD_FLAG_SENTINEL = -1 @Condition def complete_while_typing_filter() -> bool: """Whether enough characters have been typed to trigger completion. Written in a verbose way, with a string slice, for efficiency.""" if MIN_COMPLETION_TRIGGER <= 1: return True app = get_app() text = app.current_buffer.text.lstrip() text_len = len(text) if text_len < MIN_COMPLETION_TRIGGER: return False last_word = text[-MIN_COMPLETION_TRIGGER:] if len(last_word) == text_len: return text_len >= MIN_COMPLETION_TRIGGER if text[:6].lower() in ['source', r'\.']: # Different word characters for paths; see comment below. # In fact, it might be nice if paths had a different threshold. return not bool(re.search(r'[\s!-,:-@\[-^\{\}-]', last_word)) else: # This is "whitespace and all punctuation except underscore and backtick" # acting as word breaks, but it would be neat if we could complete differently # when inside a backtick, accepting all legal characters towards the trigger # limit. We would have to parse the statement, or at least go back more # characters, costing performance. This still works within a backtick! So # long as there are three trailing non-punctuation characters. return not bool(re.search(r'[\s!-/:-@\[-^\{-~]', last_word)) class IntOrStringClickParamType(click.ParamType): name = 'string' # display as STRING in helpdoc def convert(self, value, param, ctx): if isinstance(value, int): return value elif isinstance(value, str): return value elif value is None: return value else: self.fail('Not a valid password string', param, ctx) INT_OR_STRING_CLICK_TYPE = IntOrStringClickParamType() class MyCli: default_prompt = "\\t \\u@\\h:\\d> " default_prompt_splitln = "\\u@\\h\\n(\\t):\\d>" max_len_prompt = 45 defaults_suffix = None # In order of being loaded. Files lower in list override earlier ones. cnf_files: list[str | IO[str]] = [ "/etc/my.cnf", "/etc/mysql/my.cnf", "/usr/local/etc/my.cnf", os.path.expanduser("~/.my.cnf"), ] # check XDG_CONFIG_HOME exists and not an empty string xdg_config_home = os.environ.get("XDG_CONFIG_HOME", "~/.config") system_config_files: list[str | IO[str]] = [ "/etc/myclirc", os.path.join(os.path.expanduser(xdg_config_home), "mycli", "myclirc"), ] pwd_config_file = os.path.join(os.getcwd(), ".myclirc") def __init__( self, sqlexecute: SQLExecute | None = None, prompt: str | None = None, toolbar_format: str | None = None, logfile: TextIOWrapper | Literal[False] | None = None, defaults_suffix: str | None = None, defaults_file: str | None = None, login_path: str | None = None, auto_vertical_output: bool = False, show_warnings: bool = False, warn: bool | None = None, myclirc: str = "~/.myclirc", ) -> None: global MIN_COMPLETION_TRIGGER self.sqlexecute = sqlexecute self.logfile = logfile self.defaults_suffix = defaults_suffix self.login_path = login_path self.toolbar_error_message: str | None = None self.prompt_app: PromptSession | None = None self._keepalive_counter = 0 self.keepalive_ticks: int | None = 0 # self.cnf_files is a class variable that stores the list of mysql # config files to read in at launch. # If defaults_file is specified then override the class variable with # defaults_file. if defaults_file: self.cnf_files = [defaults_file] # Load config. config_files: list[str | IO[str]] = self.system_config_files + [myclirc] + [self.pwd_config_file] c = self.config = read_config_files(config_files) # this parallel config exists to # * compare with my.cnf # * support the --checkup feature # todo: after removing my.cnf, create the parallel configs only when --checkup is set self.config_without_package_defaults = read_config_files(config_files, ignore_package_defaults=True) # this parallel config exists to compare with my.cnf support the --checkup feature self.config_without_user_options = read_config_files(config_files, ignore_user_options=True) self.multi_line = c["main"].as_bool("multi_line") self.key_bindings = c["main"]["key_bindings"] self.emacs_ttimeoutlen = c['keys'].as_float('emacs_ttimeoutlen') self.vi_ttimeoutlen = c['keys'].as_float('vi_ttimeoutlen') special.set_timing_enabled(c["main"].as_bool("timing")) special.set_show_favorite_query(c["main"].as_bool("show_favorite_query")) self.beep_after_seconds = float(c["main"]["beep_after_seconds"] or 0) self.default_keepalive_ticks = c['connection'].as_int('default_keepalive_ticks') FavoriteQueries.instance = FavoriteQueries.from_config(self.config) self.dsn_alias: str | None = None self.main_formatter = TabularOutputFormatter(format_name=c["main"]["table_format"]) self.redirect_formatter = TabularOutputFormatter(format_name=c["main"].get("redirect_format", "csv")) sql_format.register_new_formatter(self.main_formatter) sql_format.register_new_formatter(self.redirect_formatter) self.main_formatter.mycli = self self.redirect_formatter.mycli = self self.syntax_style = c["main"]["syntax_style"] self.less_chatty = c["main"].as_bool("less_chatty") self.cli_style = c["colors"] self.toolkit_style = style_factory_toolkit(self.syntax_style, self.cli_style) self.helpers_style = style_factory_helpers(self.syntax_style, self.cli_style) self.helpers_warnings_style = style_factory_helpers(self.syntax_style, self.cli_style, warnings=True) self.wider_completion_menu = c["main"].as_bool("wider_completion_menu") c_dest_warning = c["main"].as_bool("destructive_warning") self.destructive_warning = c_dest_warning if warn is None else warn self.login_path_as_host = c["main"].as_bool("login_path_as_host") self.post_redirect_command = c['main'].get('post_redirect_command') self.null_string = c['main'].get('null_string') self.numeric_alignment = c['main'].get('numeric_alignment', 'right') self.binary_display = c['main'].get('binary_display') if 'llm' in c and re.match(r'^\d+$', c['llm'].get('prompt_field_truncate', '')): self.llm_prompt_field_truncate = int(c['llm'].get('prompt_field_truncate')) else: self.llm_prompt_field_truncate = 0 if 'llm' in c and re.match(r'^\d+$', c['llm'].get('prompt_section_truncate', '')): self.llm_prompt_section_truncate = int(c['llm'].get('prompt_section_truncate')) else: self.llm_prompt_section_truncate = 0 # set ssl_mode if a valid option is provided in a config file, otherwise None ssl_mode = c["main"].get("ssl_mode", None) or c["connection"].get("default_ssl_mode", None) if ssl_mode not in ("auto", "on", "off", None): self.echo(f"Invalid config option provided for ssl_mode ({ssl_mode}); ignoring.", err=True, fg="red") self.ssl_mode = None else: self.ssl_mode = ssl_mode # read from cli argument or user config file self.auto_vertical_output = auto_vertical_output or c["main"].as_bool("auto_vertical_output") self.show_warnings = show_warnings or c["main"].as_bool("show_warnings") # Write user config if system config wasn't the last config loaded. if c.filename not in self.system_config_files and not os.path.exists(myclirc): write_default_config(myclirc) # audit log if self.logfile is None and "audit_log" in c["main"]: try: self.logfile = open(os.path.expanduser(c["main"]["audit_log"]), "a") except (IOError, OSError): self.echo("Error: Unable to open the audit log file. Your queries will not be logged.", err=True, fg="red") self.logfile = False self.completion_refresher = CompletionRefresher() self.logger = logging.getLogger(__name__) self.initialize_logging() keyword_casing = c["main"].get("keyword_casing", "auto") self.highlight_preview = c['search'].as_bool('highlight_preview') self.query_history: list[Query] = [] # Initialize completer. self.smart_completion = c["main"].as_bool("smart_completion") self.completer = SQLCompleter( self.smart_completion, supported_formats=self.main_formatter.supported_formats, keyword_casing=keyword_casing ) self._completer_lock = threading.Lock() self.min_completion_trigger = c["main"].as_int("min_completion_trigger") MIN_COMPLETION_TRIGGER = self.min_completion_trigger self.last_prompt_message = ANSI('') self.last_custom_toolbar_message = ANSI('') # Register custom special commands. self.register_special_commands() # Load .mylogin.cnf if it exists. mylogin_cnf_path = get_mylogin_cnf_path() if mylogin_cnf_path: mylogin_cnf = open_mylogin_cnf(mylogin_cnf_path) if mylogin_cnf_path and mylogin_cnf: # .mylogin.cnf gets read last, even if defaults_file is specified. self.cnf_files.append(mylogin_cnf) elif mylogin_cnf_path and not mylogin_cnf: # There was an error reading the login path file. print("Error: Unable to read login path file.") self.my_cnf = read_config_files(self.cnf_files, list_values=False) if not self.my_cnf.get('client'): self.my_cnf['client'] = {} if not self.my_cnf.get('mysqld'): self.my_cnf['mysqld'] = {} prompt_cnf = self.read_my_cnf(self.my_cnf, ["prompt"])["prompt"] self.prompt_format = prompt or prompt_cnf or c["main"]["prompt"] or self.default_prompt self.prompt_lines = 0 self.multiline_continuation_char = c["main"]["prompt_continuation"] self.toolbar_format = toolbar_format or c['main']['toolbar'] self.terminal_tab_title_format = c['main']['terminal_tab_title'] self.terminal_window_title_format = c['main']['terminal_window_title'] self.multiplex_window_title_format = c['main']['multiplex_window_title'] self.multiplex_pane_title_format = c['main']['multiplex_pane_title'] self.prompt_app = None self.destructive_keywords = [ keyword for keyword in c["main"].get("destructive_keywords", "DROP SHUTDOWN DELETE TRUNCATE ALTER UPDATE").split(' ') if keyword ] special.set_destructive_keywords(self.destructive_keywords) def close(self) -> None: if self.sqlexecute is not None: self.sqlexecute.close() def register_special_commands(self) -> None: special.register_special_command(self.change_db, "use", "use ", "Change to a new database.", aliases=["\\u"]) special.register_special_command( self.manual_reconnect, "connect", "connect [database]", "Reconnect to the server, optionally switching databases.", aliases=["\\r"], case_sensitive=True, ) special.register_special_command( self.refresh_completions, "rehash", "rehash", "Refresh auto-completions.", arg_type=ArgType.NO_QUERY, aliases=["\\#"] ) special.register_special_command( self.change_table_format, "tableformat", "tableformat ", "Change the table format used to output interactive results.", aliases=["\\T"], case_sensitive=True, ) special.register_special_command( self.change_redirect_format, "redirectformat", "redirectformat ", "Change the table format used to output redirected results.", aliases=["\\Tr"], case_sensitive=True, ) special.register_special_command( self.disable_show_warnings, "nowarnings", "nowarnings", "Disable automatic warnings display.", aliases=["\\w"], case_sensitive=True, ) special.register_special_command( self.enable_show_warnings, "warnings", "warnings", "Enable automatic warnings display.", aliases=["\\W"], case_sensitive=True, ) special.register_special_command( self.execute_from_file, "source", "source ", "Execute queries from a file.", aliases=["\\."] ) special.register_special_command( self.change_prompt_format, "prompt", "prompt ", "Change prompt format.", aliases=["\\R"], case_sensitive=True ) def manual_reconnect(self, arg: str = "", **_) -> Generator[SQLResult, None, None]: """ Interactive method to use for the \r command, so that the utility method may be cleanly used elsewhere. """ if not self.reconnect(database=arg): yield SQLResult(status="Not connected") elif not arg or arg == '``': yield SQLResult() else: yield self.change_db(arg).send(None) def enable_show_warnings(self, **_) -> Generator[SQLResult, None, None]: self.show_warnings = True msg = "Show warnings enabled." yield SQLResult(status=msg) def disable_show_warnings(self, **_) -> Generator[SQLResult, None, None]: self.show_warnings = False msg = "Show warnings disabled." yield SQLResult(status=msg) def change_table_format(self, arg: str, **_) -> Generator[SQLResult, None, None]: try: self.main_formatter.format_name = arg yield SQLResult(status=f"Changed table format to {arg}") except ValueError: msg = f"Table format {arg} not recognized. Allowed formats:" for table_type in self.main_formatter.supported_formats: msg += f"\n\t{table_type}" yield SQLResult(status=msg) def change_redirect_format(self, arg: str, **_) -> Generator[SQLResult, None, None]: try: self.redirect_formatter.format_name = arg yield SQLResult(status=f"Changed redirect format to {arg}") except ValueError: msg = f"Redirect format {arg} not recognized. Allowed formats:" for table_type in self.redirect_formatter.supported_formats: msg += f"\n\t{table_type}" yield SQLResult(status=msg) def change_db(self, arg: str, **_) -> Generator[SQLResult, None, None]: if arg.startswith("`") and arg.endswith("`"): arg = re.sub(r"^`(.*)`$", r"\1", arg) arg = re.sub(r"``", r"`", arg) if not arg: click.secho("No database selected", err=True, fg="red") return assert isinstance(self.sqlexecute, SQLExecute) if self.sqlexecute.dbname == arg: msg = f'You are already connected to database "{self.sqlexecute.dbname}" as user "{self.sqlexecute.user}"' else: self.sqlexecute.change_db(arg) msg = f'You are now connected to database "{self.sqlexecute.dbname}" as user "{self.sqlexecute.user}"' self.set_all_external_titles() yield SQLResult(status=msg) def execute_from_file(self, arg: str, **_) -> Iterable[SQLResult]: if not arg: message = "Missing required argument: filename." return [SQLResult(status=message)] try: with open(os.path.expanduser(arg)) as f: query = f.read() except IOError as e: return [SQLResult(status=str(e))] if self.destructive_warning and confirm_destructive_query(self.destructive_keywords, query) is False: message = "Wise choice. Command execution stopped." return [SQLResult(status=message)] assert isinstance(self.sqlexecute, SQLExecute) return self.sqlexecute.run(query) def change_prompt_format(self, arg: str, **_) -> list[SQLResult]: """ Change the prompt format. """ if not arg: message = "Missing required argument, format." return [SQLResult(status=message)] self.prompt_format = arg return [SQLResult(status=f"Changed prompt format to {arg}")] def initialize_logging(self) -> None: log_file = os.path.expanduser(self.config["main"]["log_file"]) log_level = self.config["main"]["log_level"] level_map = { "CRITICAL": logging.CRITICAL, "ERROR": logging.ERROR, "WARNING": logging.WARNING, "INFO": logging.INFO, "DEBUG": logging.DEBUG, } # Disable logging if value is NONE by switching to a no-op handler # Set log level to a high value so it doesn't even waste cycles getting called. if log_level.upper() == "NONE": handler: logging.Handler = logging.NullHandler() log_level = "CRITICAL" elif dir_path_exists(log_file): handler = logging.FileHandler(log_file) else: self.echo(f'Error: Unable to open the log file "{log_file}".', err=True, fg="red") return formatter = logging.Formatter("%(asctime)s (%(process)d/%(threadName)s) %(name)s %(levelname)s - %(message)s") handler.setFormatter(formatter) root_logger = logging.getLogger("mycli") root_logger.addHandler(handler) root_logger.setLevel(level_map[log_level.upper()]) logging.captureWarnings(True) root_logger.debug("Initializing mycli logging.") root_logger.debug("Log file %r.", log_file) def read_my_cnf(self, cnf: ConfigObj, keys: list[str]) -> dict[str, Any]: """ Retrieves some keys from a configuration, applies transformations, returns a new configuration. :param cnf: configuration to read :param keys: list of keys to retrieve :returns: tuple, with None for missing keys. """ sections = ["client", "mysqld"] key_transformations = { "mysqld": { "socket": "default_socket", "port": "default_port", "user": "default_user", }, } if self.login_path and self.login_path != "client": sections.append(self.login_path) if self.defaults_suffix: sections.extend([sect + self.defaults_suffix for sect in sections]) configuration: dict[str, Any] = defaultdict(lambda: None) for key in keys: for section in cnf: if section not in sections or key not in cnf[section]: continue new_key = key_transformations.get(section, {}).get(key) or key configuration[new_key] = strip_matching_quotes(cnf[section][key]) return configuration def merge_ssl_with_cnf(self, ssl: dict[str, Any], cnf: dict[str, Any]) -> dict[str, Any]: """Merge SSL configuration dict with cnf dict""" merged = {} merged.update(ssl) prefix = "ssl-" for k, v in cnf.items(): # skip unrelated options if not k.startswith(prefix): continue if v is None: continue # special case because PyMySQL argument is significantly different # from commandline if k == "ssl-verify-server-cert": merged["check_hostname"] = str_to_bool(v) else: # use argument name just strip "ssl-" prefix arg = k[len(prefix) :] merged[arg] = v return merged def connect( self, database: str | None = "", user: str | None = "", passwd: str | int | None = None, host: str | None = "", port: str | int | None = "", socket: str | None = "", character_set: str | None = "", local_infile: bool = False, ssl: dict[str, Any] | None = None, ssh_user: str | None = "", ssh_host: str | None = "", ssh_port: int = 22, ssh_password: str | None = "", ssh_key_filename: str | None = "", init_command: str | None = "", unbuffered: bool | None = None, use_keyring: bool | None = None, reset_keyring: bool | None = None, keepalive_ticks: int | None = None, ) -> None: cnf = { "database": None, "user": None, "password": None, "host": None, "port": None, "socket": None, "default_socket": None, "default-character-set": None, "local-infile": None, "loose-local-infile": None, "ssl-ca": None, "ssl-cert": None, "ssl-key": None, "ssl-cipher": None, "ssl-verify-server-cert": None, } cnf = self.read_my_cnf(self.my_cnf, list(cnf.keys())) # Fall back to config values only if user did not specify a value. database = database or cnf["database"] user = user or cnf["user"] or os.getenv("USER") host = host or cnf["host"] port = port or cnf["port"] ssl_config: dict[str, Any] = ssl or {} user_connection_config = self.config_without_package_defaults.get('connection', {}) self.keepalive_ticks = keepalive_ticks int_port = port and int(port) if not int_port: int_port = DEFAULT_PORT if not host or host == DEFAULT_HOST: socket = ( socket or user_connection_config.get("default_socket") or cnf["socket"] or cnf["default_socket"] or guess_socket_location() ) passwd = passwd if isinstance(passwd, (str, int)) else cnf["password"] # default_character_set doesn't check in self.config_without_package_defaults, because the # option already existed before the my.cnf deprecation. For the same reason, # default_character_set can be in [connection] or [main]. if not character_set: if 'default_character_set' in self.config['connection']: character_set = self.config['connection']['default_character_set'] elif 'default_character_set' in self.config['main']: character_set = self.config['main']['default_character_set'] elif 'default_character_set' in cnf: character_set = cnf['default_character_set'] elif 'default-character-set' in cnf: character_set = cnf['default-character-set'] if not character_set: character_set = DEFAULT_CHARSET # Favor whichever local_infile option is set. use_local_infile = False for local_infile_option in ( local_infile, user_connection_config.get('default_local_infile'), cnf['local_infile'], cnf['local-infile'], cnf['loose_local_infile'], cnf['loose-local-infile'], False, ): try: use_local_infile = str_to_bool(local_infile_option or '') break except (TypeError, ValueError): pass # temporary my.cnf override mappings if 'default_ssl_ca' in user_connection_config: cnf['ssl-ca'] = user_connection_config.get('default_ssl_ca') or None if 'default_ssl_cert' in user_connection_config: cnf['ssl-cert'] = user_connection_config.get('default_ssl_cert') or None if 'default_ssl_key' in user_connection_config: cnf['ssl-key'] = user_connection_config.get('default_ssl_key') or None if 'default_ssl_cipher' in user_connection_config: cnf['ssl-cipher'] = user_connection_config.get('default_ssl_cipher') or None if 'default_ssl_verify_server_cert' in user_connection_config: cnf['ssl-verify-server-cert'] = user_connection_config.get('default_ssl_verify_server_cert') or None # todo: rewrite the merge method using self.config['connection'] instead of cnf, after removing my.cnf support ssl_config_or_none: dict[str, Any] | None = self.merge_ssl_with_cnf(ssl_config, cnf) # default_ssl_ca_path is not represented in my.cnf if 'default_ssl_ca_path' in self.config['connection'] and (not ssl_config_or_none or not ssl_config_or_none.get('capath')): if ssl_config_or_none is None: ssl_config_or_none = {} ssl_config_or_none['capath'] = self.config['connection']['default_ssl_ca_path'] or False # prune lone check_hostname=False if not any(v for v in ssl_config.values()): ssl_config_or_none = None # password hierarchy # 1. -p / --pass/--password CLI options # 2. --password-file CLI option # 3. envvar (MYSQL_PWD) # 4. DSN (mysql://user:password) # 5. cnf (.my.cnf / etc) # 6. keyring keyring_identifier = f'{user}@{host}:{"" if socket else int_port}:{socket or ""}' keyring_domain = 'mycli.net' keyring_retrieved_cleanly = False if passwd is None and use_keyring and not reset_keyring: passwd = keyring.get_password(keyring_domain, keyring_identifier) if passwd is not None: keyring_retrieved_cleanly = True # prompt for password if requested by user if passwd == EMPTY_PASSWORD_FLAG_SENTINEL: passwd = click.prompt(f"Enter password for {user}", hide_input=True, show_default=False, default='', type=str, err=True) keyring_retrieved_cleanly = False # should not fail, but will help the typechecker assert not isinstance(passwd, int) connection_info: dict[Any, Any] = { "database": database, "user": user, "password": passwd, "host": host, "port": int_port, "socket": socket, "character_set": character_set, "local_infile": use_local_infile, "ssl": ssl_config_or_none, "ssh_user": ssh_user, "ssh_host": ssh_host, "ssh_port": int(ssh_port) if ssh_port else None, "ssh_password": ssh_password, "ssh_key_filename": ssh_key_filename, "init_command": init_command, "unbuffered": unbuffered, } def _update_keyring(password: str | None, keyring_retrieved_cleanly: bool): if not password: return if reset_keyring or (use_keyring and not keyring_retrieved_cleanly): try: saved_pw = keyring.get_password(keyring_domain, keyring_identifier) if password != saved_pw or reset_keyring: keyring.set_password(keyring_domain, keyring_identifier, password) click.secho(f'Password saved to the system keyring at {keyring_domain}/{keyring_identifier}', err=True) except Exception as e: click.secho(f'Password not saved to the system keyring: {e}', err=True, fg='red') def _connect( retry_ssl: bool = False, retry_password: bool = False, keyring_save_eligible: bool = True, keyring_retrieved_cleanly: bool = False, ) -> None: try: if keyring_save_eligible: _update_keyring(connection_info["password"], keyring_retrieved_cleanly=keyring_retrieved_cleanly) self.sqlexecute = SQLExecute(**connection_info) except pymysql.OperationalError as e1: if e1.args[0] == HANDSHAKE_ERROR and ssl is not None and ssl.get("mode", None) == "auto": # if we already tried and failed to connect without SSL, raise the error if retry_ssl: raise e1 # disable SSL and try to connect again connection_info["ssl"] = None _connect( retry_ssl=True, keyring_retrieved_cleanly=keyring_retrieved_cleanly, keyring_save_eligible=keyring_save_eligible ) elif e1.args[0] == ACCESS_DENIED_ERROR and connection_info["password"] is None: # if we already tried and failed to connect with a new password, raise the error if retry_password: raise e1 # ask the user for a new password and try to connect again new_password = click.prompt( f"Enter password for {user}", hide_input=True, show_default=False, default='', type=str, err=True ) connection_info["password"] = new_password keyring_retrieved_cleanly = False _connect( retry_password=True, keyring_retrieved_cleanly=keyring_retrieved_cleanly, keyring_save_eligible=keyring_save_eligible, ) elif e1.args[0] == CR_SERVER_LOST: self.echo( ( "Connection to server lost. If this error persists, it may be a mismatch between the server and " "client SSL configuration. To troubleshoot the issue, try --ssl-mode=off or --ssl-mode=on." ), err=True, fg='red', ) raise e1 else: raise e1 try: if not WIN and socket: try: socket_owner = getpwuid(os.stat(socket).st_uid).pw_name except KeyError: socket_owner = '' self.echo(f"Connecting to socket {socket}, owned by user {socket_owner}", err=True) try: _connect(keyring_retrieved_cleanly=keyring_retrieved_cleanly) except pymysql.OperationalError as e: # These are "Can't open socket" and 2x "Can't connect" if [code for code in (2001, 2002, 2003) if code == e.args[0]]: self.logger.debug("Database connection failed: %r.", e) self.logger.error("traceback: %r", traceback.format_exc()) self.logger.debug("Retrying over TCP/IP") self.echo(f"Failed to connect to local MySQL server through socket '{socket}':") self.echo(str(e), err=True) self.echo("Retrying over TCP/IP", err=True) # Else fall back to TCP/IP localhost socket = "" host = DEFAULT_HOST port = DEFAULT_PORT # todo should reload the keyring identifier here instead of invalidating _connect(keyring_save_eligible=False) else: raise e else: host = host or DEFAULT_HOST port = port or DEFAULT_PORT # could try loading the keyring again here instead of assuming nothing important changed # Bad ports give particularly daft error messages try: port = int(port) except ValueError: self.echo(f"Error: Invalid port number: '{port}'.", err=True, fg="red") sys.exit(1) _connect(keyring_retrieved_cleanly=keyring_retrieved_cleanly) except Exception as e: # Connecting to a database could fail. self.logger.debug("Database connection failed: %r.", e) self.logger.error("traceback: %r", traceback.format_exc()) self.echo(str(e), err=True, fg="red") sys.exit(1) def handle_editor_command( self, text: str, inputhook: Callable | None, loaded_message_fn: Callable, ) -> str: r"""Editor command is any query that is prefixed or suffixed by a '\e'. The reason for a while loop is because a user might edit a query multiple times. For eg: "select * from \e" to edit it in vim, then come back to the prompt with the edited query "select * from blah where q = 'abc'\e" to edit it again. :param text: Document :return: Document """ while special.editor_command(text): filename = special.get_filename(text) query = special.get_editor_query(text) or self.get_last_query() sql, message = special.open_external_editor(filename=filename, sql=query) if message: # Something went wrong. Raise an exception and bail. raise RuntimeError(message) while True: try: assert isinstance(self.prompt_app, PromptSession) text = self.prompt_app.prompt( default=sql, inputhook=inputhook, message=loaded_message_fn, ) break except KeyboardInterrupt: sql = "" continue return text def handle_clip_command(self, text: str) -> bool: r"""A clip command is any query that is prefixed or suffixed by a '\clip'. :param text: Document :return: Boolean """ if special.clip_command(text): query = special.get_clip_query(text) or self.get_last_query() message = special.copy_query_to_clipboard(sql=query) if message: raise RuntimeError(message) return True return False def handle_prettify_binding(self, text: str) -> str: if not text: return '' try: statements = sqlglot.parse(text, read='mysql') except Exception: statements = [] if len(statements) == 1 and statements[0]: parse_succeeded = True pretty_text = statements[0].sql(pretty=True, pad=4, dialect='mysql') else: parse_succeeded = False pretty_text = text.rstrip(';') self.toolbar_error_message = 'Prettify failed to parse single statement' if pretty_text and parse_succeeded: pretty_text = pretty_text + ';' return pretty_text def handle_unprettify_binding(self, text: str) -> str: if not text: return '' try: statements = sqlglot.parse(text, read='mysql') except Exception: statements = [] if len(statements) == 1 and statements[0]: parse_succeeded = True unpretty_text = statements[0].sql(pretty=False, dialect='mysql') else: parse_succeeded = False unpretty_text = text.rstrip(';') self.toolbar_error_message = 'Unprettify failed to parse single statement' if unpretty_text and parse_succeeded: unpretty_text = unpretty_text + ';' return unpretty_text def output_timing(self, timing: str, is_warnings_style: bool = False) -> None: self.log_output(timing) add_style = 'class:warnings.timing' if is_warnings_style else 'class:output.timing' formatted_timing = FormattedText([('', timing)]) styled_timing = to_formatted_text(formatted_timing, style=add_style) print_formatted_text(styled_timing, style=self.toolkit_style) def run_cli(self) -> None: iterations = 0 sqlexecute = self.sqlexecute assert isinstance(sqlexecute, SQLExecute) logger = self.logger self.configure_pager() if self.smart_completion: self.refresh_completions() history_file = os.path.expanduser(os.environ.get("MYCLI_HISTFILE", self.config.get("history_file", "~/.mycli-history"))) if dir_path_exists(history_file): history = FileHistoryWithTimestamp(history_file) else: history = None self.echo( f'Error: Unable to open the history file "{history_file}". Your query history will not be saved.', err=True, fg="red", ) key_bindings = mycli_bindings(self) if not self.less_chatty: print(sqlexecute.server_info) print("mycli", __version__) print(SUPPORT_INFO) if random.random() <= 0.5: print("Thanks to the contributor —", thanks_picker()) else: print("Tip —", tips_picker()) def get_prompt_message(app) -> ANSI: if app.current_buffer.text: return self.last_prompt_message prompt = self.get_prompt(self.prompt_format, app.render_counter) if self.prompt_format == self.default_prompt and len(prompt) > self.max_len_prompt: prompt = self.get_prompt(self.default_prompt_splitln, app.render_counter) self.prompt_lines = prompt.count('\n') + 1 prompt = prompt.replace("\\x1b", "\x1b") if not self.prompt_lines: self.prompt_lines = prompt.count('\n') + 1 self.last_prompt_message = ANSI(prompt) return self.last_prompt_message def get_continuation(width: int, _two: int, _three: int) -> AnyFormattedText: if self.multiline_continuation_char == "": continuation = "" elif self.multiline_continuation_char: left_padding = width - len(self.multiline_continuation_char) continuation = " " * max((left_padding - 1), 0) + self.multiline_continuation_char + " " else: continuation = " " return [("class:continuation", continuation)] def show_initial_toolbar_help() -> bool: return iterations == 0 # Keep track of whether or not the query is mutating. In case # of a multi-statement query, the overall query is considered # mutating if any one of the component statements is mutating mutating = False def output_res(results: Generator[SQLResult], start: float) -> None: nonlocal mutating result_count = watch_count = 0 for result in results: logger.debug("preamble: %r", result.preamble) logger.debug("header: %r", result.header) logger.debug("rows: %r", result.rows) logger.debug("status: %r", result.status) logger.debug("command: %r", result.command) threshold = 1000 # If this is a watch query, offset the start time on the 2nd+ iteration # to account for the sleep duration if result.command is not None and result.command["name"] == "watch": if watch_count > 0: try: watch_seconds = float(result.command["seconds"]) start += watch_seconds except ValueError as e: self.echo(f"Invalid watch sleep time provided ({e}).", err=True, fg="red") sys.exit(1) else: watch_count += 1 if is_select(result.status_plain) and isinstance(result.rows, Cursor) and result.rows.rowcount > threshold: self.echo( f"The result set has more than {threshold} rows.", fg="red", ) if not confirm("Do you want to continue?"): self.echo("Aborted!", err=True, fg="red") break if self.auto_vertical_output: if self.prompt_app is not None: max_width = self.prompt_app.output.get_size().columns else: max_width = DEFAULT_WIDTH else: max_width = None formatted = self.format_sqlresult( result, is_expanded=special.is_expanded_output(), is_redirected=special.is_redirected(), null_string=self.null_string, numeric_alignment=self.numeric_alignment, binary_display=self.binary_display, max_width=max_width, ) t = time() - start try: if result_count > 0: self.echo("") try: self.output(formatted, result) except KeyboardInterrupt: pass if self.beep_after_seconds > 0 and t >= self.beep_after_seconds: assert self.prompt_app is not None self.prompt_app.output.bell() if special.is_timing_enabled(): self.output_timing(f"Time: {t:0.03f}s") except KeyboardInterrupt: pass start = time() result_count += 1 mutating = mutating or is_mutating(result.status_plain) # get and display warnings if enabled if self.show_warnings and isinstance(result.rows, Cursor) and result.rows.warning_count > 0: warnings = sqlexecute.run("SHOW WARNINGS") t = time() - start saw_warning = False for warning in warnings: saw_warning = True formatted = self.format_sqlresult( warning, is_expanded=special.is_expanded_output(), is_redirected=special.is_redirected(), null_string=self.null_string, numeric_alignment=self.numeric_alignment, binary_display=self.binary_display, max_width=max_width, is_warnings_style=True, ) self.echo("") self.output(formatted, warning, is_warnings_style=True) if saw_warning and special.is_timing_enabled(): self.output_timing(f"Time: {t:0.03f}s", is_warnings_style=True) def keepalive_hook(_context): """ prompt_toolkit shares the event loop with this hook, which seems to get called a bit faster than once/second on one machine. It would be nice to reset the counter whenever user input is made, but was not clear how to do that with context.input_is_ready(). Example at https://github.com/prompt-toolkit/python-prompt-toolkit/blob/main/examples/prompts/inputhook.py """ if self.keepalive_ticks is None: return if self.keepalive_ticks < 1: return self._keepalive_counter += 1 if self._keepalive_counter > self.keepalive_ticks: self._keepalive_counter = 0 self.logger.debug('keepalive ping') try: assert self.sqlexecute is not None assert self.sqlexecute.conn is not None self.sqlexecute.conn.ping(reconnect=False) except Exception as e: self.logger.debug('keepalive ping error %r', e) def one_iteration(text: str | None = None) -> None: inputhook = keepalive_hook if self.keepalive_ticks and self.keepalive_ticks >= 1 else None if text is None: try: assert self.prompt_app is not None loaded_message_fn = functools.partial(get_prompt_message, self.prompt_app.app) text = self.prompt_app.prompt( inputhook=inputhook, message=loaded_message_fn, ) except KeyboardInterrupt: return special.set_expanded_output(False) special.set_forced_horizontal_output(False) try: text = self.handle_editor_command( text, inputhook, loaded_message_fn, ) except RuntimeError as e: logger.error("sql: %r, error: %r", text, e) logger.error("traceback: %r", traceback.format_exc()) self.echo(str(e), err=True, fg="red") return try: if self.handle_clip_command(text): return except RuntimeError as e: logger.error("sql: %r, error: %r", text, e) logger.error("traceback: %r", traceback.format_exc()) self.echo(str(e), err=True, fg="red") return # LLM command support while special.is_llm_command(text): start = time() try: assert isinstance(self.sqlexecute, SQLExecute) assert sqlexecute.conn is not None cur = sqlexecute.conn.cursor() context, sql, duration = special.handle_llm( text, cur, sqlexecute.dbname or '', self.llm_prompt_field_truncate, self.llm_prompt_section_truncate, ) if context: click.echo("LLM Response:") click.echo(context) click.echo("---") if special.is_timing_enabled(): self.output_timing(f"Time: {duration:.2f} seconds") text = self.prompt_app.prompt( default=sql or '', inputhook=inputhook, message=loaded_message_fn, ) except KeyboardInterrupt: return except special.FinishIteration as e: if e.results: return output_res(e.results, start) else: return None except RuntimeError as e: logger.error("sql: %r, error: %r", text, e) logger.error("traceback: %r", traceback.format_exc()) self.echo(str(e), err=True, fg="red") return text = text.strip() if not text: return if is_redirect_command(text): sql_part, command_part, file_operator_part, file_part = get_redirect_components(text) text = sql_part or '' try: special.set_redirect(command_part, file_operator_part, file_part) except (FileNotFoundError, OSError, RuntimeError) as e: logger.error("sql: %r, error: %r", text, e) logger.error("traceback: %r", traceback.format_exc()) self.echo(str(e), err=True, fg="red") return if self.destructive_warning: destroy = confirm_destructive_query(self.destructive_keywords, text) if destroy is None: pass # Query was not destructive. Nothing to do here. elif destroy is True: self.echo("Your call!") else: self.echo("Wise choice!") return else: destroy = True try: logger.debug("sql: %r", text) special.write_tee(self.last_prompt_message, nl=False) special.write_tee(text) self.log_query(text) successful = False start = time() res = sqlexecute.run(text) self.main_formatter.query = text self.redirect_formatter.query = text successful = True output_res(res, start) special.unset_once_if_written(self.post_redirect_command) special.flush_pipe_once_if_written(self.post_redirect_command) except pymysql.err.InterfaceError: # attempt to reconnect if not self.reconnect(): return one_iteration(text) return # OK to just return, cuz the recursion call runs to the end. except EOFError as e: raise e except KeyboardInterrupt: # get last connection id connection_id_to_kill = sqlexecute.connection_id or 0 # some mysql-compatible databases may not implement connection_id() if connection_id_to_kill > 0: logger.debug("connection id to kill: %r", connection_id_to_kill) try: sqlexecute.connect() for kill_result in sqlexecute.run(f"kill {connection_id_to_kill}"): status_str = str(kill_result.status_plain).lower() if status_str.find("ok") > -1: logger.debug("cancelled query, connection id: %r, sql: %r", connection_id_to_kill, text) self.echo(f"Cancelled query id: {connection_id_to_kill}", err=True, fg="blue") else: logger.debug( "Failed to confirm query cancellation, connection id: %r, sql: %r", connection_id_to_kill, text, ) self.echo(f"Failed to confirm query cancellation, id: {connection_id_to_kill}", err=True, fg="red") except Exception as e2: self.echo(f"Encountered error while cancelling query: {e2}", err=True, fg="red") else: logger.debug("Did not get a connection id, skip cancelling query") self.echo("Did not get a connection id, skip cancelling query", err=True, fg="red") except NotImplementedError: self.echo("Not Yet Implemented.", fg="yellow") except pymysql.OperationalError as e1: logger.debug("Exception: %r", e1) if e1.args[0] in (2003, 2006, 2013): # attempt to reconnect if not self.reconnect(): return one_iteration(text) return # OK to just return, cuz the recursion call runs to the end. else: logger.error("sql: %r, error: %r", text, e1) logger.error("traceback: %r", traceback.format_exc()) self.echo(str(e1), err=True, fg="red") except Exception as e: logger.error("sql: %r, error: %r", text, e) logger.error("traceback: %r", traceback.format_exc()) self.echo(str(e), err=True, fg="red") else: if is_dropping_database(text, sqlexecute.dbname): sqlexecute.dbname = None sqlexecute.connect() # Refresh the table names and column names if necessary. if need_completion_refresh(text): self.refresh_completions(reset=need_completion_reset(text)) finally: if self.logfile is False: self.echo("Warning: This query was not logged.", err=True, fg="red") query = Query(text, successful, mutating) self.query_history.append(query) if self.toolbar_format.lower() == 'none': get_toolbar_tokens = None else: get_toolbar_tokens = create_toolbar_tokens_func( self, show_initial_toolbar_help, self.toolbar_format, ) if self.wider_completion_menu: complete_style = CompleteStyle.MULTI_COLUMN else: complete_style = CompleteStyle.COLUMN with self._completer_lock: if self.key_bindings == "vi": editing_mode = EditingMode.VI else: editing_mode = EditingMode.EMACS self.prompt_app = PromptSession( color_depth=ColorDepth.DEPTH_24_BIT if 'truecolor' in os.getenv('COLORTERM', '').lower() else None, lexer=PygmentsLexer(MyCliLexer), reserve_space_for_menu=self.get_reserved_space(), prompt_continuation=get_continuation, bottom_toolbar=get_toolbar_tokens, complete_style=complete_style, input_processors=[ ConditionalProcessor( processor=HighlightMatchingBracketProcessor(chars="[](){}"), filter=HasFocus(DEFAULT_BUFFER) & ~IsDone() ) ], tempfile_suffix=".sql", completer=DynamicCompleter(lambda: self.completer), complete_in_thread=True, history=history, auto_suggest=ThreadedAutoSuggest(AutoSuggestFromHistory()), complete_while_typing=complete_while_typing_filter, multiline=cli_is_multiline(self), # why not self.toolkit_style here? style=style_factory_toolkit(self.syntax_style, self.cli_style), include_default_pygments_style=False, key_bindings=key_bindings, enable_open_in_editor=True, enable_system_prompt=True, enable_suspend=True, editing_mode=editing_mode, search_ignore_case=True, ) if self.key_bindings == 'vi': self.prompt_app.app.ttimeoutlen = self.vi_ttimeoutlen else: self.prompt_app.app.ttimeoutlen = self.emacs_ttimeoutlen self.set_all_external_titles() try: while True: one_iteration() iterations += 1 except EOFError: special.close_tee() if not self.less_chatty: self.echo("Goodbye!") def reconnect(self, database: str = "") -> bool: """ Attempt to reconnect to the server. Return True if successful, False if unsuccessful. The "database" argument is used only to improve messages. """ assert self.sqlexecute is not None assert self.sqlexecute.conn is not None # First pass with ping(reconnect=False) and minimal feedback levels. This definitely # works as expected, and is a good idea especially when "connect" was used as a # synonym for "use". try: self.sqlexecute.conn.ping(reconnect=False) if not database: self.echo("Already connected.", fg="yellow") return True except pymysql.err.Error: pass # Second pass with ping(reconnect=True). It is not demonstrated that this pass ever # gives the benefit it is looking for, _ie_ preserves session state. We need to test # this with connection pooling. try: old_connection_id = self.sqlexecute.connection_id self.logger.debug("Attempting to reconnect.") self.echo("Reconnecting...", fg="yellow") self.sqlexecute.conn.ping(reconnect=True) # if a database is currently selected, set it on the conn again if self.sqlexecute.dbname: self.sqlexecute.conn.select_db(self.sqlexecute.dbname) self.logger.debug("Reconnected successfully.") self.echo("Reconnected successfully.", fg="yellow") self.sqlexecute.reset_connection_id() if old_connection_id != self.sqlexecute.connection_id: self.echo("Any session state was reset.", fg="red") return True except pymysql.err.Error: pass # Third pass with sqlexecute.connect() should always work, but always resets session state. try: self.logger.debug("Creating new connection") self.echo("Creating new connection...", fg="yellow") self.sqlexecute.connect() self.logger.debug("New connection created successfully.") self.echo("New connection created successfully.", fg="yellow") self.echo("Any session state was reset.", fg="red") return True except pymysql.OperationalError as e: self.logger.debug("Reconnect failed. e: %r", e) self.echo(str(e), err=True, fg="red") return False def log_query(self, query: str) -> None: if isinstance(self.logfile, TextIOWrapper): self.logfile.write(f"\n# {datetime.now()}\n") self.logfile.write(query) self.logfile.write("\n") def log_output(self, output: str | AnyFormattedText) -> None: """Log the output in the audit log, if it's enabled.""" if isinstance(output, (ANSI, HTML, FormattedText)): output = to_plain_text(output) if isinstance(self.logfile, TextIOWrapper): click.echo(output, file=self.logfile) def echo(self, s: str, **kwargs) -> None: """Print a message to stdout. The message will be logged in the audit log, if enabled. All keyword arguments are passed to click.echo(). """ self.log_output(s) click.secho(s, **kwargs) def get_output_margin(self, status: str | None = None) -> int: """Get the output margin (number of rows for the prompt, footer and timing message.""" if not self.prompt_lines: if self.prompt_app and self.prompt_app.app: render_counter = self.prompt_app.app.render_counter else: render_counter = 0 self.prompt_lines = self.get_prompt(self.prompt_format, render_counter).count('\n') + 1 margin = self.get_reserved_space() + self.prompt_lines if special.is_timing_enabled(): margin += 1 if status: margin += 1 + status.count("\n") return margin def output( self, output: itertools.chain[str], result: SQLResult, is_warnings_style: bool = False, ) -> None: """Output text to stdout or a pager command. The status text is not outputted to pager or files. The message will be logged in the audit log, if enabled. The message will be written to the tee file, if enabled. The message will be written to the output file, if enabled. """ if output: if self.prompt_app is not None: size = self.prompt_app.output.get_size() size_columns = size.columns size_rows = size.rows else: size_columns = DEFAULT_WIDTH size_rows = DEFAULT_HEIGHT margin = self.get_output_margin(result.status_plain) fits = True buf = [] output_via_pager = self.explicit_pager and special.is_pager_enabled() for i, line in enumerate(output, 1): self.log_output(line) special.write_tee(line) special.write_once(line) special.write_pipe_once(line) if special.is_redirected(): pass elif fits or output_via_pager: # buffering buf.append(line) if len(line) > size_columns or i > (size_rows - margin): fits = False if not self.explicit_pager and special.is_pager_enabled(): # doesn't fit, use pager output_via_pager = True if not output_via_pager: # doesn't fit, flush buffer for buf_line in buf: click.secho(buf_line) buf = [] else: click.secho(line) if buf: if output_via_pager: def newlinewrapper(text: list[str]) -> Generator[str, None, None]: for line in text: yield line + "\n" click.echo_via_pager(newlinewrapper(buf)) else: for line in buf: click.secho(line) if result.status: self.log_output(result.status_plain) add_style = 'class:warnings.status' if is_warnings_style else 'class:output.status' if isinstance(result.status, FormattedText): status = result.status else: status = FormattedText([('', result.status_plain)]) styled_status = to_formatted_text(status, style=add_style) print_formatted_text(styled_status, style=self.toolkit_style) def configure_pager(self) -> None: # Provide sane defaults for less if they are empty. if not os.environ.get("LESS"): os.environ["LESS"] = "-RXF" cnf = self.read_my_cnf(self.my_cnf, ["pager", "skip-pager"]) cnf_pager = cnf["pager"] or self.config["main"]["pager"] # help Windows users who haven't edited the default myclirc if WIN and cnf_pager == 'less' and not shutil.which(cnf_pager): cnf_pager = 'more' if cnf_pager: special.set_pager(cnf_pager) self.explicit_pager = True else: self.explicit_pager = False if cnf["skip-pager"] or not self.config["main"].as_bool("enable_pager"): special.disable_pager() def refresh_completions(self, reset: bool = False) -> list[SQLResult]: if reset: with self._completer_lock: self.completer.reset_completions() assert self.sqlexecute is not None self.completion_refresher.refresh( self.sqlexecute, self._on_completions_refreshed, { "smart_completion": self.smart_completion, "supported_formats": self.main_formatter.supported_formats, "keyword_casing": self.completer.keyword_casing, }, ) return [SQLResult(status="Auto-completion refresh started in the background.")] def _on_completions_refreshed(self, new_completer: SQLCompleter) -> None: """Swap the completer object in cli with the newly created completer.""" with self._completer_lock: self.completer = new_completer if self.prompt_app: # After refreshing, redraw the CLI to clear the statusbar # "Refreshing completions..." indicator self.prompt_app.app.invalidate() def get_completions(self, text: str, cursor_position: int) -> Iterable[Completion]: with self._completer_lock: return self.completer.get_completions(Document(text=text, cursor_position=cursor_position), None) def set_all_external_titles(self) -> None: self.set_external_terminal_tab_title() self.set_external_terminal_window_title() self.set_external_multiplex_window_title() self.set_external_multiplex_pane_title() def set_external_terminal_tab_title(self) -> None: if not self.terminal_tab_title_format: return if not self.prompt_app: return if not sys.stderr.isatty(): return title = sanitize_terminal_title(self.get_prompt(self.terminal_tab_title_format, self.prompt_app.app.render_counter)) print(f'\x1b]1;{title}\a', file=sys.stderr, end='') sys.stderr.flush() def set_external_terminal_window_title(self) -> None: if not self.terminal_window_title_format: return if not self.prompt_app: return if not sys.stderr.isatty(): return title = sanitize_terminal_title(self.get_prompt(self.terminal_window_title_format, self.prompt_app.app.render_counter)) print(f'\x1b]2;{title}\a', file=sys.stderr, end='') sys.stderr.flush() def set_external_multiplex_window_title(self) -> None: if not self.multiplex_window_title_format: return if not os.getenv('TMUX'): return if not self.prompt_app: return title = sanitize_terminal_title(self.get_prompt(self.multiplex_window_title_format, self.prompt_app.app.render_counter)) try: subprocess.run( ['tmux', 'rename-window', title], check=False, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) except FileNotFoundError: pass def set_external_multiplex_pane_title(self) -> None: if not self.multiplex_pane_title_format: return if not os.getenv('TMUX'): return if not self.prompt_app: return if not sys.stderr.isatty(): return title = sanitize_terminal_title(self.get_prompt(self.multiplex_pane_title_format, self.prompt_app.app.render_counter)) print(f'\x1b]2;{title}\x1b\\', file=sys.stderr, end='') sys.stderr.flush() def get_custom_toolbar(self, toolbar_format: str) -> ANSI: if not self.prompt_app: return ANSI('') if not self.prompt_app.app: return ANSI('') if self.prompt_app.app.current_buffer.text: return self.last_custom_toolbar_message toolbar = self.get_prompt(toolbar_format, self.prompt_app.app.render_counter) toolbar = toolbar.replace("\\x1b", "\x1b") self.last_custom_toolbar_message = ANSI(toolbar) return self.last_custom_toolbar_message # Memoizing a method leaks the instance, but we only expect one MyCli instance. # Before memoizing, get_prompt() was called dozens of times per prompt. # Even after memoizing, get_prompt's logic gets called twice per prompt, which # should be addressed, because some format strings take a trip to the server. @functools.lru_cache(maxsize=256) # noqa: B019 def get_prompt(self, string: str, _render_counter: int) -> str: sqlexecute = self.sqlexecute assert sqlexecute is not None assert sqlexecute.server_info is not None assert sqlexecute.server_info.species is not None if self.login_path and self.login_path_as_host: prompt_host = self.login_path elif sqlexecute.host is not None: prompt_host = sqlexecute.host else: prompt_host = DEFAULT_HOST short_prompt_host, _, _ = prompt_host.partition('.') if re.match(r'^[\d\.]+$', short_prompt_host): short_prompt_host = prompt_host now = datetime.now() backslash_placeholder = '\ufffc_backslash' string = string.replace('\\\\', backslash_placeholder) string = string.replace("\\u", sqlexecute.user or "(none)") string = string.replace("\\h", prompt_host or "(none)") string = string.replace("\\H", short_prompt_host or "(none)") string = string.replace("\\d", sqlexecute.dbname or "(none)") string = string.replace("\\t", sqlexecute.server_info.species.name) string = string.replace("\\n", "\n") string = string.replace("\\D", now.strftime("%a %b %d %H:%M:%S %Y")) string = string.replace("\\m", now.strftime("%M")) string = string.replace("\\P", now.strftime("%p")) string = string.replace("\\R", now.strftime("%H")) string = string.replace("\\r", now.strftime("%I")) string = string.replace("\\s", now.strftime("%S")) string = string.replace("\\p", str(sqlexecute.port)) string = string.replace("\\j", os.path.basename(sqlexecute.socket or '(none)')) string = string.replace("\\J", sqlexecute.socket or '(none)') string = string.replace("\\k", os.path.basename(sqlexecute.socket or str(sqlexecute.port))) string = string.replace("\\K", sqlexecute.socket or str(sqlexecute.port)) string = string.replace("\\A", self.dsn_alias or "(none)") string = string.replace("\\_", " ") string = string.replace(backslash_placeholder, '\\') # jump through hoops for the test environment, and for efficiency if hasattr(sqlexecute, 'conn') and sqlexecute.conn is not None: if '\\y' in string: with sqlexecute.conn.cursor() as cur: string = string.replace('\\y', str(get_uptime(cur)) or '(none)') if '\\Y' in string: with sqlexecute.conn.cursor() as cur: string = string.replace('\\Y', format_uptime(str(get_uptime(cur))) or '(none)') else: string = string.replace('\\y', '(none)') string = string.replace('\\Y', '(none)') if hasattr(sqlexecute, 'conn') and sqlexecute.conn is not None: if '\\T' in string: with sqlexecute.conn.cursor() as cur: string = string.replace('\\T', get_ssl_version(cur) or '(none)') else: string = string.replace('\\T', '(none)') if hasattr(sqlexecute, 'conn') and sqlexecute.conn is not None: if '\\w' in string: with sqlexecute.conn.cursor() as cur: string = string.replace('\\w', str(get_warning_count(cur) or '(none)')) else: string = string.replace('\\w', '(none)') if hasattr(sqlexecute, 'conn') and sqlexecute.conn is not None: if '\\W' in string: with sqlexecute.conn.cursor() as cur: string = string.replace('\\W', str(get_warning_count(cur) or '')) else: string = string.replace('\\W', '') return string def run_query( self, query: str, checkpoint: TextIOWrapper | None = None, new_line: bool = True, ) -> None: """Runs *query*.""" assert self.sqlexecute is not None self.log_query(query) results = self.sqlexecute.run(query) for result in results: self.main_formatter.query = query self.redirect_formatter.query = query output = self.format_sqlresult( result, is_expanded=special.is_expanded_output(), is_redirected=special.is_redirected(), null_string=self.null_string, numeric_alignment=self.numeric_alignment, binary_display=self.binary_display, ) for line in output: self.log_output(line) click.echo(line, nl=new_line) # get and display warnings if enabled if self.show_warnings and isinstance(result.rows, Cursor) and result.rows.warning_count > 0: warnings = self.sqlexecute.run("SHOW WARNINGS") for warning in warnings: output = self.format_sqlresult( warning, is_expanded=special.is_expanded_output(), is_redirected=special.is_redirected(), null_string=self.null_string, numeric_alignment=self.numeric_alignment, binary_display=self.binary_display, is_warnings_style=True, ) for line in output: click.echo(line, nl=new_line) if checkpoint: checkpoint.write(query.rstrip('\n') + '\n') checkpoint.flush() def format_sqlresult( self, result, is_expanded: bool = False, is_redirected: bool = False, null_string: str | None = None, numeric_alignment: str = 'right', binary_display: str | None = None, max_width: int | None = None, is_warnings_style: bool = False, ) -> itertools.chain[str]: if is_redirected: use_formatter = self.redirect_formatter else: use_formatter = self.main_formatter is_expanded = is_expanded or use_formatter.format_name == "vertical" output: itertools.chain[str] = itertools.chain() output_kwargs = { "dialect": "unix", "disable_numparse": True, "preserve_whitespace": True, "style": self.helpers_warnings_style if is_warnings_style else self.helpers_style, } default_kwargs = use_formatter._output_formats[use_formatter.format_name].formatter_args if null_string is not None and default_kwargs.get('missing_value') == DEFAULT_MISSING_VALUE: output_kwargs['missing_value'] = null_string if use_formatter.format_name not in sql_format.supported_formats and binary_display != 'utf8': # will run before preprocessors defined as part of the format in cli_helpers output_kwargs["preprocessors"] = (preprocessors.convert_to_undecoded_string,) if result.preamble: output = itertools.chain(output, [result.preamble]) if result.header or (result.rows and result.preamble): column_types = None colalign = None if isinstance(result.rows, Cursor): def get_col_type(col) -> type: col_type = FIELD_TYPES.get(col[1], str) return col_type if type(col_type) is type else str if result.rows.rowcount > 0: column_types = [get_col_type(tup) for tup in result.rows.description] colalign = [numeric_alignment if x in (int, float, Decimal) else 'left' for x in column_types] else: column_types, colalign = [], [] if max_width is not None and isinstance(result.rows, Cursor): result_rows = list(result.rows) else: result_rows = result.rows formatted = use_formatter.format_output( result_rows, result.header or [], format_name="vertical" if is_expanded else None, column_types=column_types, colalign=colalign, **output_kwargs, ) if isinstance(formatted, str): formatted = formatted.splitlines() formatted = iter(formatted) if not is_expanded and max_width and result.header and result_rows: first_line = next(formatted) if len(strip_ansi(first_line)) > max_width: formatted = use_formatter.format_output( result_rows, result.header, format_name="vertical", column_types=column_types, **output_kwargs, ) if isinstance(formatted, str): formatted = iter(formatted.splitlines()) else: formatted = itertools.chain([first_line], formatted) output = itertools.chain(output, formatted) if result.postamble: output = itertools.chain(output, [result.postamble]) return output def get_reserved_space(self) -> int: """Get the number of lines to reserve for the completion menu.""" reserved_space_ratio = 0.45 max_reserved_space = 8 _, height = shutil.get_terminal_size() return min(int(round(height * reserved_space_ratio)), max_reserved_space) def get_last_query(self) -> str | None: """Get the last query executed or None.""" return self.query_history[-1][0] if self.query_history else None @click.command() @click.option("-h", "--host", envvar="MYSQL_HOST", help="Host address of the database.") @click.option("-P", "--port", envvar="MYSQL_TCP_PORT", type=int, help="Port number to use for connection. Honors $MYSQL_TCP_PORT.") @click.option("-u", "--user", help="User name to connect to the database.") @click.option("-S", "--socket", envvar="MYSQL_UNIX_PORT", help="The socket file to use for connection.") @click.option( "-p", "--pass", "--password", "password", is_flag=False, flag_value=EMPTY_PASSWORD_FLAG_SENTINEL, type=INT_OR_STRING_CLICK_TYPE, help="Prompt for (or pass in cleartext) the password to connect to the database.", ) @click.option("--ssh-user", help="User name to connect to ssh server.") @click.option("--ssh-host", help="Host name to connect to ssh server.") @click.option("--ssh-port", default=22, help="Port to connect to ssh server.") @click.option("--ssh-password", help="Password to connect to ssh server.") @click.option("--ssh-key-filename", help="Private key filename (identify file) for the ssh connection.") @click.option("--ssh-config-path", help="Path to ssh configuration.", default=os.path.expanduser("~") + "/.ssh/config") @click.option("--ssh-config-host", help="Host to connect to ssh server reading from ssh configuration.") @click.option( "--ssl-mode", "ssl_mode", help="Set desired SSL behavior. auto=preferred if TCP/IP, on=required, off=off.", type=click.Choice(["auto", "on", "off"]), ) @click.option("--ssl/--no-ssl", "ssl_enable", default=None, help="Enable SSL for connection (automatically enabled with other flags).") @click.option("--ssl-ca", help="CA file in PEM format.", type=click.Path(exists=True)) @click.option("--ssl-capath", help="CA directory.", type=click.Path(exists=True, file_okay=False, dir_okay=True)) @click.option("--ssl-cert", help="X509 cert in PEM format.", type=click.Path(exists=True)) @click.option("--ssl-key", help="X509 key in PEM format.", type=click.Path(exists=True)) @click.option("--ssl-cipher", help="SSL cipher to use.") @click.option( "--tls-version", type=click.Choice(["TLSv1", "TLSv1.1", "TLSv1.2", "TLSv1.3"], case_sensitive=False), help="TLS protocol version for secure connection.", ) @click.option( "--ssl-verify-server-cert", is_flag=True, help=("""Verify server's "Common Name" in its cert against hostname used when connecting. This option is disabled by default."""), ) @click.version_option(__version__, "-V", "--version", help="Output mycli's version.") @click.option("-v", "--verbose", is_flag=True, help="Verbose output.") @click.option("-D", "--database", "dbname", help="Database or DSN to use for the connection.") @click.option("-d", "--dsn", 'dsn_alias', default="", envvar="DSN", help="DSN alias configured in the ~/.myclirc file, or a full DSN.") @click.option( "--list-dsn", "list_dsn", is_flag=True, help="list of DSN aliases configured in the [alias_dsn] section of the ~/.myclirc file." ) @click.option("--list-ssh-config", "list_ssh_config", is_flag=True, help="list ssh configurations in the ssh config (requires paramiko).") @click.option("--ssh-warning-off", is_flag=True, help="Suppress the SSH deprecation notice.") @click.option("-R", "--prompt", "prompt", help=f'Prompt format (Default: "{MyCli.default_prompt}").') @click.option('--toolbar', 'toolbar_format', help='Toolbar format.') @click.option("-l", "--logfile", type=click.File(mode="a", encoding="utf-8"), help="Log every query and its results to a file.") @click.option( "--checkpoint", type=click.File(mode="a", encoding="utf-8"), help="In batch or --execute mode, log successful queries to a file." ) @click.option("--defaults-group-suffix", type=str, help="Read MySQL config groups with the specified suffix.") @click.option("--defaults-file", type=click.Path(), help="Only read MySQL options from the given file.") @click.option("--myclirc", type=click.Path(), default="~/.myclirc", help="Location of myclirc file.") @click.option( "--auto-vertical-output", is_flag=True, help="Automatically switch to vertical output mode if the result is wider than the terminal width.", ) @click.option( "--show-warnings/--no-show-warnings", "show_warnings", is_flag=True, help="Automatically show warnings after executing a SQL statement." ) @click.option("-t", "--table", is_flag=True, help="Shorthand for --format=table.") @click.option("--csv", is_flag=True, help="Shorthand for --format=csv.") @click.option("--warn/--no-warn", default=None, help="Warn before running a destructive query.") @click.option("--local-infile", type=bool, help="Enable/disable LOAD DATA LOCAL INFILE.") @click.option("-g", "--login-path", type=str, help="Read this path from the login file.") @click.option("-e", "--execute", type=str, help="Execute command and quit.") @click.option("--init-command", type=str, help="SQL statement to execute after connecting.") @click.option( "--unbuffered", is_flag=True, help="Instead of copying every row of data into a buffer, fetch rows as needed, to save memory." ) @click.option("--character-set", "--charset", type=str, help="Character set for MySQL session.") @click.option( "--password-file", type=click.Path(), help="File or FIFO path containing the password to connect to the db if not specified otherwise." ) @click.argument("database", default=None, nargs=1) @click.option("--noninteractive", is_flag=True, help="Don't prompt during batch input. Recommended.") @click.option( '--format', 'batch_format', type=click.Choice(['default', 'csv', 'tsv', 'table']), help='Format for batch or --execute output.' ) @click.option('--throttle', type=float, default=0.0, help='Pause in seconds between queries in batch mode.') @click.option( '--use-keyring', 'use_keyring_cli_opt', type=click.Choice(['true', 'false', 'reset']), default=None, help='Store and retrieve passwords from the system keyring: true/false/reset.', ) @click.option( '--keepalive-ticks', type=int, help='Send regular keepalive pings to the connection, roughly every seconds.', ) @click.option("--checkup", is_flag=True, help="Run a checkup on your config file.") @click.pass_context def cli( ctx: click.Context, database: str | None, user: str | None, host: str | None, port: int | None, socket: str | None, password: str | int | None, dbname: str | None, verbose: bool, prompt: str | None, toolbar_format: str | None, logfile: TextIOWrapper | None, checkpoint: TextIOWrapper | None, defaults_group_suffix: str | None, defaults_file: str | None, login_path: str | None, auto_vertical_output: bool, show_warnings: bool, local_infile: bool, ssl_mode: str | None, ssl_enable: bool, ssl_ca: str | None, ssl_capath: str | None, ssl_cert: str | None, ssl_key: str | None, ssl_cipher: str | None, tls_version: str | None, ssl_verify_server_cert: bool, table: bool, csv: bool, warn: bool | None, execute: str | None, myclirc: str, dsn_alias: str, list_dsn: str | None, ssh_user: str | None, ssh_host: str | None, ssh_port: int, ssh_password: str | None, ssh_key_filename: str | None, list_ssh_config: bool, ssh_config_path: str, ssh_config_host: str | None, ssh_warning_off: bool | None, init_command: str | None, unbuffered: bool | None, character_set: str | None, password_file: str | None, noninteractive: bool, batch_format: str | None, throttle: float, use_keyring_cli_opt: str | None, checkup: bool, keepalive_ticks: int | None, ) -> None: """A MySQL terminal client with auto-completion and syntax highlighting. \b Examples: - mycli my_database - mycli -u my_user -h my_host.com my_database - mycli mysql://my_user@my_host.com:3306/my_database """ def get_password_from_file(password_file: str | None) -> str | None: if not password_file: return None try: with open(password_file) as fp: password = fp.readline().strip() return password except FileNotFoundError: click.secho(f"Password file '{password_file}' not found", err=True, fg="red") sys.exit(1) except PermissionError: click.secho(f"Permission denied reading password file '{password_file}'", err=True, fg="red") sys.exit(1) except IsADirectoryError: click.secho(f"Path '{password_file}' is a directory, not a file", err=True, fg="red") sys.exit(1) except Exception as e: click.secho(f"Error reading password file '{password_file}': {str(e)}", err=True, fg="red") sys.exit(1) # if the password value looks like a DSN, treat it as such and # prompt for password if database is None and isinstance(password, str) and "://" in password: # check if the scheme is valid. We do not actually have any logic for these, but # it will most usefully catch the case where we erroneously catch someone's # password, and give them an easy error message to follow / report is_valid_scheme, scheme = is_valid_connection_scheme(password) if not is_valid_scheme: click.secho(f"Error: Unknown connection scheme provided for DSN URI ({scheme}://)", err=True, fg="red") sys.exit(1) database = password password = EMPTY_PASSWORD_FLAG_SENTINEL # if the password is not specified try to set it using the password_file option if password is None and password_file: password_from_file = get_password_from_file(password_file) if password_from_file is not None: password = password_from_file # getting the envvar ourselves because the envvar from a click # option cannot be an empty string, but a password can be if password is None and os.environ.get("MYSQL_PWD") is not None: password = os.environ.get("MYSQL_PWD") mycli = MyCli( prompt=prompt, toolbar_format=toolbar_format, logfile=logfile, defaults_suffix=defaults_group_suffix, defaults_file=defaults_file, login_path=login_path, auto_vertical_output=auto_vertical_output, warn=warn, myclirc=myclirc, ) if checkup: do_checkup(mycli) sys.exit(0) if csv and batch_format not in [None, 'csv']: click.secho("Conflicting --csv and --format arguments.", err=True, fg="red") sys.exit(1) if table and batch_format not in [None, 'table']: click.secho("Conflicting --table and --format arguments.", err=True, fg="red") sys.exit(1) if not batch_format: batch_format = 'default' if csv: batch_format = 'csv' if table: batch_format = 'table' if ssl_enable is not None: click.secho( "Warning: The --ssl/--no-ssl CLI options are deprecated and will be removed in a future release. " "Please use the \"default_ssl_mode\" config option or --ssl-mode CLI flag instead. " f"See issue {ISSUES_URL}/1507", err=True, fg="yellow", ) # ssh_port and ssh_config_path have truthy defaults and are not included if any([ssh_user, ssh_host, ssh_password, ssh_key_filename, list_ssh_config, ssh_config_host]) and not ssh_warning_off: click.secho( f"Warning: The built-in SSH functionality is deprecated and will be removed in a future release. See issue {ISSUES_URL}/1464", err=True, fg="red", ) if list_dsn: try: alias_dsn = mycli.config["alias_dsn"] except KeyError: click.secho("Invalid DSNs found in the config file. Please check the \"[alias_dsn]\" section in myclirc.", err=True, fg="red") sys.exit(1) except Exception as e: click.secho(str(e), err=True, fg="red") sys.exit(1) for alias, value in alias_dsn.items(): if verbose: click.secho(f"{alias} : {value}") else: click.secho(alias) sys.exit(0) if list_ssh_config: ssh_config = read_ssh_config(ssh_config_path) try: host_entries = ssh_config.get_hostnames() except KeyError: click.secho('Error reading ssh config', err=True, fg="red") sys.exit(1) for host_entry in host_entries: if verbose: host_config = ssh_config.lookup(host_entry) click.secho(f"{host_entry} : {host_config.get('hostname')}") else: click.secho(host_entry) sys.exit(0) # Choose which ever one has a valid value. database = dbname or database dsn_uri = None # Treat the database argument as a DSN alias only if it matches a configured alias # todo why is port tested but not socket? truthy_password = password not in (None, EMPTY_PASSWORD_FLAG_SENTINEL) if ( database and "://" not in database and not any([user, truthy_password, host, port, login_path]) and database in mycli.config.get("alias_dsn", {}) ): dsn_alias, database = database, "" if database and "://" in database: dsn_uri, database = database, "" if dsn_alias: try: dsn_uri = mycli.config["alias_dsn"][dsn_alias] except KeyError: is_valid_scheme, scheme = is_valid_connection_scheme(dsn_alias) if is_valid_scheme: dsn_uri = dsn_alias else: click.secho( "Could not find the specified DSN in the config file. Please check the \"[alias_dsn]\" section in your myclirc.", err=True, fg="red", ) sys.exit(1) else: mycli.dsn_alias = dsn_alias if dsn_uri: uri = urlparse(dsn_uri) if not database: database = uri.path[1:] # ignore the leading fwd slash if not user and uri.username is not None: user = unquote(uri.username) # todo: rationalize the behavior of empty-string passwords here if not password and uri.password is not None: password = unquote(uri.password) if not host: host = uri.hostname if not port: port = uri.port if uri.query: dsn_params = parse_qs(uri.query) else: dsn_params = {} if params := dsn_params.get('ssl'): click.secho( 'Warning: The "ssl" DSN URI parameter is deprecated and will be removed in a future release. ' 'Please use the "ssl_mode" parameter instead. ' f'See issue {ISSUES_URL}/1507', err=True, fg='yellow', ) if params[0].lower() == 'true': ssl_mode = 'on' if params := dsn_params.get('ssl_mode'): ssl_mode = ssl_mode or params[0] if params := dsn_params.get('ssl_ca'): ssl_ca = ssl_ca or params[0] ssl_mode = ssl_mode or 'on' if params := dsn_params.get('ssl_capath'): ssl_capath = ssl_capath or params[0] ssl_mode = ssl_mode or 'on' if params := dsn_params.get('ssl_cert'): ssl_cert = ssl_cert or params[0] ssl_mode = ssl_mode or 'on' if params := dsn_params.get('ssl_key'): ssl_key = ssl_key or params[0] ssl_mode = ssl_mode or 'on' if params := dsn_params.get('ssl_cipher'): ssl_cipher = ssl_cipher or params[0] ssl_mode = ssl_mode or 'on' if params := dsn_params.get('tls_version'): tls_version = tls_version or params[0] ssl_mode = ssl_mode or 'on' if params := dsn_params.get('ssl_verify_server_cert'): ssl_verify_server_cert = ssl_verify_server_cert or (params[0].lower() == 'true') ssl_mode = ssl_mode or 'on' if params := dsn_params.get('socket'): socket = socket or params[0] if params := dsn_params.get('keepalive_ticks'): if keepalive_ticks is None: keepalive_ticks = int(params[0]) if params := dsn_params.get('character_set'): character_set = character_set or params[0] keepalive_ticks = keepalive_ticks if keepalive_ticks is not None else mycli.default_keepalive_ticks ssl_mode = ssl_mode or mycli.ssl_mode # cli option or config option # if there is a mismatch between the ssl_mode value and other sources of ssl config, show a warning # specifically using "is False" to not pickup the case where ssl_enable is None (not set by the user) if ssl_enable and ssl_mode == "off" or ssl_enable is False and ssl_mode in ("auto", "on"): click.secho( f"Warning: The current ssl_mode value of '{ssl_mode}' is overriding the value provided by " f"either the --ssl/--no-ssl CLI options or a DSN URI parameter (ssl={ssl_enable}).", err=True, fg="yellow", ) # configure SSL if ssl_mode is auto/on or if # ssl_enable = True (from --ssl or a DSN URI) and ssl_mode is None if ssl_mode in ("auto", "on") or (ssl_enable and ssl_mode is None): if socket and ssl_mode == 'auto': ssl = None else: ssl = { "mode": ssl_mode, "enable": ssl_enable, "ca": ssl_ca and os.path.expanduser(ssl_ca), "cert": ssl_cert and os.path.expanduser(ssl_cert), "key": ssl_key and os.path.expanduser(ssl_key), "capath": ssl_capath, "cipher": ssl_cipher, "tls_version": tls_version, "check_hostname": ssl_verify_server_cert, } # remove empty ssl options ssl = {k: v for k, v in ssl.items() if v is not None} else: ssl = None if ssh_config_host: ssh_config = read_ssh_config(ssh_config_path).lookup(ssh_config_host) ssh_host = ssh_host if ssh_host else ssh_config.get("hostname") ssh_user = ssh_user if ssh_user else ssh_config.get("user") if ssh_config.get("port") and ssh_port == 22: # port has a default value, overwrite it if it's in the config ssh_port = int(ssh_config.get("port")) ssh_key_filename = ssh_key_filename if ssh_key_filename else ssh_config.get("identityfile", [None])[0] ssh_key_filename = ssh_key_filename and os.path.expanduser(ssh_key_filename) # Merge init-commands: global, DSN-specific, then CLI init_cmds: list[str] = [] # 1) Global init-commands global_section = mycli.config.get("init-commands", {}) for _, val in global_section.items(): if isinstance(val, (list, tuple)): init_cmds.extend(val) elif val: init_cmds.append(val) # 2) DSN-specific init-commands if dsn_alias: alias_section = mycli.config.get("alias_dsn.init-commands", {}) if dsn_alias in alias_section: val = alias_section.get(dsn_alias) if isinstance(val, (list, tuple)): init_cmds.extend(val) elif val: init_cmds.append(val) # 3) CLI-provided init_command if init_command: init_cmds.append(init_command) combined_init_cmd = "; ".join(cmd.strip() for cmd in init_cmds if cmd) # --show-warnings / --no-show-warnings if show_warnings: mycli.show_warnings = show_warnings if use_keyring_cli_opt is not None and use_keyring_cli_opt.lower() == 'reset': use_keyring = True reset_keyring = True elif use_keyring_cli_opt is None: use_keyring = str_to_bool(mycli.config['main'].get('use_keyring', 'False')) reset_keyring = False else: use_keyring = str_to_bool(use_keyring_cli_opt) reset_keyring = False # todo: removeme after a period of transition for tup in [ ('client', 'prompt', 'prompt', 'main', 'prompt'), ('client', 'pager', 'pager', 'main', 'pager'), ('client', 'skip-pager', 'skip-pager', 'main', 'enable_pager'), # this is a white lie, because default_character_set can actually be read from the package config ('client', 'default-character-set', 'default-character-set', 'connection', 'default_character_set'), # local-infile can be read from both sections ('mysqld', 'local-infile', 'local-infile', 'connection', 'default_local_infile'), ('client', 'local-infile', 'local-infile', 'connection', 'default_local_infile'), ('mysqld', 'loose-local-infile', 'loose-local-infile', 'connection', 'default_local_infile'), ('client', 'loose-local-infile', 'loose-local-infile', 'connection', 'default_local_infile'), # todo: in the future we should add default_port, etc, but only in .myclirc # they are currently ignored in my.cnf ('mysqld', 'default_socket', 'socket', 'connection', 'default_socket'), ('client', 'ssl-ca', 'ssl-ca', 'connection', 'default_ssl_ca'), ('client', 'ssl-cert', 'ssl-cert', 'connection', 'default_ssl_cert'), ('client', 'ssl-key', 'ssl-key', 'connection', 'default_ssl_key'), ('client', 'ssl-cipher', 'ssl-cipher', 'connection', 'default_ssl_cipher'), ('client', 'ssl-verify-server-cert', 'ssl-verify-server-cert', 'connection', 'default_ssl_verify_server_cert'), ]: ( mycnf_section_name, mycnf_item_name, printable_mycnf_item_name, myclirc_section_name, myclirc_item_name, ) = tup if str_to_bool(mycli.config['main'].get('my_cnf_transition_done', 'False')): break if ( mycli.my_cnf[mycnf_section_name].get(mycnf_item_name) is None and mycli.my_cnf[mycnf_section_name].get(mycnf_item_name.replace('-', '_')) is None ): continue user_section = mycli.config_without_package_defaults.get(myclirc_section_name, {}) if user_section.get(myclirc_item_name) is None: cnf_value = mycli.my_cnf[mycnf_section_name].get(mycnf_item_name) if cnf_value is None: cnf_value = mycli.my_cnf[mycnf_section_name].get(mycnf_item_name.replace('-', '_')) click.secho( dedent( f""" Reading configuration from my.cnf files is deprecated. See {ISSUES_URL}/1490 . The cause of this message is the following in a my.cnf file without a corresponding ~/.myclirc entry: [{mycnf_section_name}] {printable_mycnf_item_name} = {cnf_value} To suppress this message, remove the my.cnf item add or the following to ~/.myclirc: [{myclirc_section_name}] {myclirc_item_name} = The ~/.myclirc setting will take precedence. In the future, the my.cnf will be ignored. Values are documented at {REPO_URL}/blob/main/mycli/myclirc . An empty is generally accepted. To ignore all of this, set [main] my_cnf_transition_done = True in ~/.myclirc. -------- """ ), err=True, fg='yellow', ) mycli.connect( database=database, user=user, passwd=password, host=host, port=port, socket=socket, local_infile=local_infile, ssl=ssl, ssh_user=ssh_user, ssh_host=ssh_host, ssh_port=ssh_port, ssh_password=ssh_password, ssh_key_filename=ssh_key_filename, init_command=combined_init_cmd, unbuffered=unbuffered, character_set=character_set, use_keyring=use_keyring, reset_keyring=reset_keyring, keepalive_ticks=keepalive_ticks, ) if combined_init_cmd: click.echo(f"Executing init-command: {combined_init_cmd}", err=True) mycli.logger.debug("Launch Params: \n\tdatabase: %r\tuser: %r\thost: %r\tport: %r", database, user, host, port) # --execute argument if execute: try: if batch_format == 'csv': mycli.main_formatter.format_name = 'csv' if execute.endswith(r'\G'): execute = execute[:-2] elif batch_format == 'tsv': mycli.main_formatter.format_name = 'tsv' if execute.endswith(r'\G'): execute = execute[:-2] elif batch_format == 'table': mycli.main_formatter.format_name = 'ascii' if execute.endswith(r'\G'): execute = execute[:-2] else: mycli.main_formatter.format_name = 'tsv' mycli.run_query(execute, checkpoint=checkpoint) sys.exit(0) except Exception as e: click.secho(str(e), err=True, fg="red") sys.exit(1) def dispatch_batch_statements(statements: str, batch_counter: int) -> None: if batch_counter: # this is imperfect if the first line of input has multiple statements if batch_format == 'csv': mycli.main_formatter.format_name = 'csv-noheader' elif batch_format == 'tsv': mycli.main_formatter.format_name = 'tsv_noheader' elif batch_format == 'table': mycli.main_formatter.format_name = 'ascii' else: mycli.main_formatter.format_name = 'tsv' else: if batch_format == 'csv': mycli.main_formatter.format_name = 'csv' elif batch_format == 'tsv': mycli.main_formatter.format_name = 'tsv' elif batch_format == 'table': mycli.main_formatter.format_name = 'ascii' else: mycli.main_formatter.format_name = 'tsv' warn_confirmed: bool | None = True if not noninteractive and mycli.destructive_warning and is_destructive(mycli.destructive_keywords, statements): try: # this seems to work, even though we are reading from stdin above sys.stdin = open("/dev/tty") # bug: the prompt will not be visible if stdout is redirected warn_confirmed = confirm_destructive_query(mycli.destructive_keywords, statements) except (IOError, OSError): mycli.logger.warning("Unable to open TTY as stdin.") sys.exit(1) try: if warn_confirmed: if throttle and batch_counter >= 1: sleep(throttle) mycli.run_query(statements, checkpoint=checkpoint, new_line=True) except Exception as e: click.secho(str(e), err=True, fg="red") sys.exit(1) if sys.stdin.isatty(): mycli.run_cli() else: stdin = click.get_text_stream("stdin") statements = '' line_counter = 0 batch_counter = 0 for stdin_text in stdin: line_counter += 1 if line_counter > MAX_MULTILINE_BATCH_STATEMENT: click.secho( f'Saw single input statement greater than {MAX_MULTILINE_BATCH_STATEMENT} lines; assuming a parsing error.', err=True, fg="red", ) sys.exit(1) statements += stdin_text try: tokens = sqlglot.tokenize(statements, read='mysql') if not tokens: continue # we don't handle changing the delimiter within the batch input if tokens[-1].text == ';': dispatch_batch_statements(statements, batch_counter) batch_counter += 1 statements = '' line_counter = 0 except sqlglot.errors.TokenError: continue if statements: dispatch_batch_statements(statements, batch_counter) sys.exit(0) mycli.close() def need_completion_refresh(queries: str) -> bool: """Determines if the completion needs a refresh by checking if the sql statement is an alter, create, drop or change db.""" for query in sqlparse.split(queries): try: first_token = query.split()[0] if first_token.lower() in ("alter", "create", "use", "\\r", "\\u", "connect", "drop", "rename"): return True except Exception: return False return False def need_completion_reset(queries: str) -> bool: """Determines if the statement is a database switch such as 'use' or '\\u'. When a database is changed the existing completions must be reset before we start the completion refresh for the new database. """ for query in sqlparse.split(queries): try: first_token = query.split()[0] if first_token.lower() in ("use", "\\u"): return True except Exception: return False return False def is_mutating(status_plain: str | None) -> bool: """Determines if the statement is mutating based on the status.""" if not status_plain: return False mutating = {"insert", "update", "delete", "alter", "create", "drop", "replace", "truncate", "load", "rename"} return status_plain.split(None, 1)[0].lower() in mutating def is_select(status_plain: str | None) -> bool: """Returns true if the first word in status is 'select'.""" if not status_plain: return False return status_plain.split(None, 1)[0].lower() == "select" def thanks_picker() -> str: import mycli lines: str = "" try: with resources.files(mycli).joinpath("AUTHORS").open('r') as f: lines += f.read() except FileNotFoundError: pass try: with resources.files(mycli).joinpath("SPONSORS").open('r') as f: lines += f.read() except FileNotFoundError: pass contents = [] for line in lines.split("\n"): if m := re.match(r"^ *\* (.*)", line): contents.append(m.group(1)) return choice(contents) if contents else 'our sponsors' def tips_picker() -> str: import mycli tips = [] try: with resources.files(mycli).joinpath('TIPS').open('r') as f: for line in f: if line.startswith("#"): continue if tip := line.strip(): tips.append(tip) except FileNotFoundError: pass return choice(tips) if tips else r'\? or "help" for help!' @prompt_register("edit-and-execute-command") def edit_and_execute(event: KeyPressEvent) -> None: """Different from the prompt-toolkit default, we want to have a choice not to execute a query after editing, hence validate_and_handle=False.""" buff = event.current_buffer buff.open_in_editor(validate_and_handle=False) def read_ssh_config(ssh_config_path: str): ssh_config = paramiko.config.SSHConfig() try: with open(ssh_config_path) as f: ssh_config.parse(f) except FileNotFoundError as e: click.secho(str(e), err=True, fg="red") sys.exit(1) # Paramiko prior to version 2.7 raises Exception on parse errors. # In 2.7 it has become paramiko.ssh_exception.SSHException, # but let's catch everything for compatibility except Exception as err: click.secho(f"Could not parse SSH configuration file {ssh_config_path}:\n{err} ", err=True, fg="red") sys.exit(1) else: return ssh_config if __name__ == "__main__": cli() ================================================ FILE: mycli/myclirc ================================================ # vi: ft=dosini [main] # Enable or disable the automatic displaying of warnings ("SHOW WARNINGS") # after executing a SQL statement when applicable. show_warnings = False # Enables context sensitive auto-completion. If this is disabled the all # possible completions will be listed. smart_completion = True # Minimum characters typed before offering completion suggestions. # Suggestion: 3. min_completion_trigger = 1 # Multi-line mode allows breaking up the sql statements into multiple lines. If # this is set to True, then the end of the statements must have a semi-colon. # If this is set to False then sql statements can't be split into multiple # lines. End of line (return) is considered as the end of the statement. multi_line = False # Destructive warning mode will alert you before executing a sql statement # that may cause harm to the database such as "drop table", "drop database" # or "shutdown". destructive_warning = True # Queries starting with these keywords will activate the destructive warning. # UPDATE will not activate the warning if the statement includes a WHERE # clause. destructive_keywords = DROP SHUTDOWN DELETE TRUNCATE ALTER UPDATE # interactive query history location. history_file = ~/.mycli-history # log_file location. log_file = ~/.mycli.log # Default log level. Possible values: "CRITICAL", "ERROR", "WARNING", "INFO" # and "DEBUG". "NONE" disables logging. log_level = INFO # Log every query and its results to a file. Enable this by uncommenting the # line below. # audit_log = ~/.mycli-audit.log # Timing of SQL statements and table rendering, or LLM commands. timing = True # Show the full SQL when running a favorite query. Set to False to hide. show_favorite_query = True # Beep after long-running queries are completed; 0 to disable. beep_after_seconds = 0 # Table format. Possible values: ascii, ascii_escaped, csv, csv-noheader, # csv-tab, csv-tab-noheader, double, fancy_grid, github, grid, html, jira, # jsonl, jsonl_escaped, latex, latex_booktabs, mediawiki, minimal, moinmoin, # mysql, mysql_unicode, orgtbl, pipe, plain, psql, psql_unicode, rst, simple, # sql-insert, sql-update, sql-update-1, sql-update-2, textile, tsv, # tsv_noheader, vertical. # Recommended: ascii. table_format = ascii # Redirected otuput format # Recommended: csv. redirect_format = csv # How to display the missing value (ie NULL). Only certain table formats # support configuring the missing value. CSV for example always uses the # empty string, and JSON formats use native nulls. null_string = # How to align numeric data in tabular output: right or left. numeric_alignment = right # How to display binary values in tabular output: "hex", or "utf8". "utf8" # means attempt to render valid UTF-8 sequences as strings, then fall back # to hex rendering if not possible. binary_display = hex # A command to run after a successful output redirect, with {} to be replaced # with the escaped filename. Mac example: echo {} | pbcopy. Escaping is not # reliable/safe on Windows. post_redirect_command = # Syntax coloring style. Possible values (many support the "-dark" suffix): # manni, igor, xcode, vim, autumn, vs, rrt, native, perldoc, borland, tango, emacs, # friendly, monokai, paraiso, colorful, murphy, bw, pastie, paraiso, trac, default, # fruity. # Screenshots at https://mycli.net/syntax # Can be further modified in [colors] syntax_style = default # Keybindings: Possible values: emacs, vi. # Emacs mode: Ctrl-A is home, Ctrl-E is end. All emacs keybindings are available in the REPL. # When Vi mode is enabled you can use modal editing features offered by Vi in the REPL. key_bindings = emacs # Enabling this option will show the suggestions in a wider menu. Thus more items are suggested. wider_completion_menu = False # MySQL prompt # * \D - full current date, e.g. Sat Feb 14 15:55:48 2026 # * \R - current hour in 24-hour time (00–23) # * \r - current hour in 12-hour time (01–12) # * \m - minutes of the current time # * \s - seconds of the current time # * \P - AM/PM # * \d - selected database/schema # * \h - hostname of the server # * \H - shortened hostname of the server # * \p - connection port # * \j - connection socket basename # * \J - full connection socket path # * \k - connection socket basename OR the port # * \K - full connection socket path OR the port # * \T - connection SSL/TLS version # * \t - database vendor (Percona, MySQL, MariaDB, TiDB) # * \u - username # * \w - number of warnings, or "(none)" (requires frequent trips to the server) # * \W - number of warnings, or the empty string (requires frequent trips to the server) # * \y - uptime in seconds (requires frequent trips to the server) # * \Y - uptime in words (requires frequent trips to the server) # * \A - DSN alias # * \n - a newline # * \_ - a space # * \\ - a literal backslash # * \x1b[...m - an ANSI escape sequence (can style with color) prompt = '\t \u@\h:\d> ' prompt_continuation = '->' # Use the same prompt format strings to construct a status line in the toolbar, # where \B in the first position refers to the default toolbar showing keystrokes # and state. Example: # # toolbar = '\B\d \D' # # If \B is included, the additional content will begin on the next line. More # lines can be added with \n. If \B is not included, the customized toolbar # can be a single line. An empty value is the same as the default "\B". The # special literal value "None" will suppress the toolbar from appearing. toolbar = '' # Use the same prompt format strings to construct a terminal tab title. # The original XTerm docs call this title the "window title", but it now # probably refers to a terminal tab. This title is only updated as frequently # as the database is changed. terminal_tab_title = '' # Use the same prompt format strings to construct a terminal window title. # The original XTerm docs call this title the "icon title", but it now # probably refers to a terminal window which contains tabs. This title is # only updated as frequently as the database is changed. terminal_window_title = '' # Use the same prompt format strings to construct a window title in a terminal # multiplexer. Currently only tmux is supported. This title is only updated # as frequently as the database is changed. multiplex_window_title = '' # Use the same prompt format strings to construct a pane title in a terminal # multiplexer. Currently only tmux is supported. This title is only updated # as frequently as the database is changed. multiplex_pane_title = '' # Skip intro info on startup and outro info on exit less_chatty = False # Use alias from --login-path instead of host name in prompt login_path_as_host = False # Cause result sets to be displayed vertically if they are too wide for the current window, # and using normal tabular format otherwise. (This applies to statements terminated by ; or \G.) auto_vertical_output = False # keyword casing preference. Possible values "lower", "upper", "auto" keyword_casing = auto # disabled pager on startup enable_pager = True # Choose a specific pager pager = 'less' # whether to show verbose warnings about the transition away from reading my.cnf my_cnf_transition_done = False # Whether to store and retrieve passwords from the system keyring. # See the documentation for https://pypi.org/project/keyring/ for your OS. # Note that the hostname is considered to be different if short or qualified. # This can be overridden with --use-keyring= at the CLI. # A password can be reset with --use-keyring=reset at the CLI. use_keyring = False [search] # Whether to apply syntax highlighting to the preview window in fuzzy history # search. There is a small performance penalty to enabling this. The "pygmentize" # CLI tool must also be available. The syntax style from the "syntax_style" # option will be respected, though additional customizations from [colors] will # not be applied. highlight_preview = False [connection] # character set for connections without --character-set being set default_character_set = utf8mb4 # whether to enable LOAD DATA LOCAL INFILE for connections without --local-infile being set default_local_infile = False # How often to send periodic background pings to the server when input is idle. Ticks are # roughly in seconds, but may be faster. Set to zero to disable. Suggestion: 300. default_keepalive_ticks = 0 # Sets the desired behavior for handling secure connections to the database server. # Possible values: # auto = SSL is preferred for TCP/IP connections. Will attempt to connect via SSL, but will fall # back to cleartext as needed. Will not attempt to connect with SSL over local sockets. # on = SSL is required. Will attempt to connect via SSL even on a local socket, and will fail if # a secure connection is not established. # off = do not use SSL. Will fail if the server requires a secure connection. default_ssl_mode = auto # SSL CA file for connections without --ssl-ca being set default_ssl_ca = # SSL CA directory for connections without --ssl-capath being set default_ssl_capath = # SSL X509 cert path for connections without --ssl-cert being set default_ssl_cert = # SSL X509 key for connections without --ssl-key being set default_ssl_key = # SSL cipher to use for connections without --ssl-cipher being set default_ssl_cipher = # whether to verify server's "Common Name" in its cert, for connections without # --ssl-verify-server-cert being set default_ssl_verify_server_cert = False [llm] # If set to a positive integer, truncate text/binary fields to that width # in bytes when sending sample data, to conserve tokens. Suggestion: 1024. prompt_field_truncate = None # If set to a positive integer, attempt to truncate various sections of LLM # prompt input to that number in bytes, to conserve tokens. Suggestion: # 1000000. prompt_section_truncate = None [keys] # possible values: exit, none control_d = exit # possible values: auto, fzf, reverse_isearch control_r = auto # comma-separated list: toolkit_default, summon, advancing_summon, prefixing_summon, advance, cancel # # * toolkit_default - ignore other behaviors and use prompt_toolkit's default bindings # * summon - when completions are not visible, summon them # * advancing_summon - when completions are not visible, summon them _and_ advance in the list # * prefixing_summon - when completions are not visible, summon them _and_ insert the common prefix # * advance - when completions are visible, advance in the list # * cancel - when completions are visible, toggle the list off control_space = summon, advance # comma-separated list: toolkit_default, summon, advancing_summon, prefixing_summon, advance, cancel tab = advancing_summon, advance # How long to wait for an Escape key sequence in vi mode. # 0.5 seconds is the prompt_toolkit default, but vi users may find that too long. # Shorter values mean that "Escape" alone is recognized more quickly. vi_ttimeoutlen = 0.1 # How long to wait for an Escape key sequence in Emacs mode. emacs_ttimeoutlen = 0.5 # Custom colors for the completion menu, toolbar, etc, with actual support # depending on the terminal, and the property being set. # Colors: #ffffff, bg:#ffffff, border:#ffffff. # Attributes: (no)blink, bold, dim, hidden, inherit, italic, reverse, strike, underline. [colors] completion-menu.completion.current = 'bg:#ffffff #000000' completion-menu.completion = 'bg:#008888 #ffffff' completion-menu.meta.completion.current = 'bg:#44aaaa #000000' completion-menu.meta.completion = 'bg:#448888 #ffffff' completion-menu.multi-column-meta = 'bg:#aaffff #000000' scrollbar.arrow = 'bg:#003333' scrollbar = 'bg:#00aaaa' selected = '#ffffff bg:#6666aa' search = '#ffffff bg:#4444aa' search.current = '#ffffff bg:#44aa44' bottom-toolbar = 'bg:#222222 #aaaaaa' bottom-toolbar.off = 'bg:#222222 #888888' bottom-toolbar.on = 'bg:#222222 #ffffff' search-toolbar = 'noinherit bold' search-toolbar.text = 'nobold' system-toolbar = 'noinherit bold' arg-toolbar = 'noinherit bold' arg-toolbar.text = 'nobold' bottom-toolbar.transaction.valid = 'bg:#222222 #00ff5f bold' bottom-toolbar.transaction.failed = 'bg:#222222 #ff005f bold' prompt = '' continuation = '' # style classes for colored table output output.table-separator = "" output.header = "#00ff5f bold" output.odd-row = "" output.even-row = "" output.null = "#808080" output.status = "" output.status.warning-count = "" output.timing = "" # SQL syntax highlighting overrides # sql.comment = 'italic #408080' # sql.comment.multi-line = '' # sql.comment.single-line = '' # sql.comment.optimizer-hint = '' # sql.escape = 'border:#FF0000' # sql.keyword = 'bold #008000' # sql.datatype = 'nobold #B00040' # sql.literal = '' # sql.literal.date = '' # sql.symbol = '' # sql.quoted-schema-object = '' # sql.quoted-schema-object.escape = '' # sql.constant = '#880000' # sql.function = '#0000FF' # sql.variable = '#19177C' # sql.number = '#666666' # sql.number.binary = '' # sql.number.float = '' # sql.number.hex = '' # sql.number.integer = '' # sql.operator = '#666666' # sql.punctuation = '' # sql.string = '#BA2121' # sql.string.double-quouted = '' # sql.string.escape = 'bold #BB6622' # sql.string.single-quoted = '' # sql.whitespace = '' # Favorite queries. # You can add your favorite queries here. They will be available in the # REPL when you type `\f` or `\f `. [favorite_queries] # example = "SELECT * FROM example_table WHERE id = 1" # Initial commands to execute when connecting to any database. [init-commands] # read_only = "SET SESSION TRANSACTION READ ONLY" # Use the -d option to reference a DSN. # Special characters in passwords and other strings can be escaped with URL encoding. [alias_dsn] # example_dsn = mysql://[user[:password]@][host][:port][/dbname] # Initial commands to execute when connecting to a DSN alias. [alias_dsn.init-commands] # Define one or more SQL statements per alias (semicolon-separated). # example_dsn = "SET sql_select_limit=1000; SET time_zone='+00:00'" ================================================ FILE: mycli/packages/__init__.py ================================================ ================================================ FILE: mycli/packages/checkup.py ================================================ import importlib.metadata import json import os import shutil import sys import urllib.error import urllib.request from mycli.constants import REPO_URL PYPI_API_BASE = 'https://pypi.org/pypi' def pypi_api_fetch(fragment: str) -> dict: fragment = fragment.lstrip('/') url = f'{PYPI_API_BASE}/{fragment}' try: with urllib.request.urlopen(url, timeout=5) as response: return json.loads(response.read().decode('utf8')) except urllib.error.URLError: print(f'Failed to connect to PyPi on {url}', file=sys.stderr) return {} def _dependencies_checkup() -> None: print('\n### Key Python dependencies:\n') for dependency in [ 'cli_helpers', 'click', 'prompt_toolkit', 'pymysql', 'tabulate', ]: try: installed_version = importlib.metadata.version(dependency) except importlib.metadata.PackageNotFoundError: installed_version = None pypi_profile = pypi_api_fetch(f'/{dependency}/json') latest_version = pypi_profile.get('info', {}).get('version', None) print(f'{dependency} version {installed_version} (latest {latest_version})') def _executables_checkup() -> None: print('\n### External executables:\n') for executable in [ 'less', 'fzf', 'pygmentize', ]: if shutil.which(executable): print(f'The "{executable}" executable was found — good!') else: print(f'The recommended "{executable}" executable was not found — some functionality will suffer.') def _environment_checkup() -> None: print('\n### Environment variables:\n') for variable in [ 'EDITOR', 'VISUAL', ]: if value := os.environ.get(variable): print(f'The ${variable} environment variable was set to "{value}" — good!') else: print(f'The ${variable} environment variable was not set — some functionality will suffer.') def _configuration_checkup(mycli) -> None: did_output_missing = False did_output_unsupported = False did_output_deprecated = False indent = ' ' transitions = { f'{indent}[main]\n{indent}default_character_set': f'{indent}[connection]\n{indent}default_character_set', f'{indent}[main]\n{indent}ssl_mode': f'{indent}[connection]\n{indent}default_ssl_mode', } reverse_transitions = {v: k for k, v in transitions.items()} if not list(mycli.config.keys()): print('\n### Missing file:\n') print('The local ~/,myclirc is missing or empty.\n') did_output_missing = True else: for section_name in mycli.config: if section_name not in mycli.config_without_package_defaults: if not did_output_missing: print('\n### Missing in user ~/.myclirc:\n') print(f'The entire section:\n\n{indent}[{section_name}]\n') did_output_missing = True continue for item_name in mycli.config[section_name]: transition_key = f'{indent}[{section_name}]\n{indent}{item_name}' if transition_key in reverse_transitions: continue if item_name not in mycli.config_without_package_defaults[section_name]: if not did_output_missing: print('\n### Missing in user ~/.myclirc:\n') print(f'The item:\n\n{indent}[{section_name}]\n{indent}{item_name} =\n') did_output_missing = True for section_name in mycli.config_without_package_defaults: if section_name not in mycli.config_without_user_options: if not did_output_unsupported: print('\n### Unsupported in user ~/.myclirc:\n') did_output_unsupported = True print(f'The entire section:\n\n{indent}[{section_name}]\n') continue for item_name in mycli.config_without_package_defaults[section_name]: if section_name == 'colors' and item_name.startswith('sql.'): # these are commented out in the package myclirc continue if section_name in [ 'favorite_queries', 'init-commands', 'alias_dsn', 'alias_dsn.init-commands', ]: # these are free-entry sections, so a comparison per item is not meaningful continue transition_key = f'{indent}[{section_name}]\n{indent}{item_name}' if transition_key in transitions: continue if item_name not in mycli.config_without_user_options[section_name]: if not did_output_unsupported: print('\n### Unsupported in user ~/.myclirc:\n') print(f'The item:\n\n{indent}[{section_name}]\n{indent}{item_name} =\n') did_output_unsupported = True for section_name in mycli.config_without_package_defaults: if section_name not in mycli.config_without_user_options: continue for item_name in mycli.config_without_package_defaults[section_name]: if section_name == 'colors' and item_name.startswith('sql.'): # these are commented out in the package myclirc continue transition_key = f'{indent}[{section_name}]\n{indent}{item_name}' if transition_key in transitions: if not did_output_deprecated: print('\n### Deprecated in user ~/.myclirc:\n') transition_value = transitions[transition_key] print(f'It is recommended to transition:\n\n{transition_key}\n\nto\n\n{transition_value}\n') did_output_deprecated = True if did_output_missing or did_output_unsupported or did_output_deprecated: print(f'For more info on supported features, see the commentary and defaults at:\n\n * {REPO_URL}/blob/main/mycli/myclirc\n') else: print('\n### Configuration:\n') print('User configuration all up to date!\n') def do_checkup(mycli) -> None: _dependencies_checkup() _executables_checkup() _environment_checkup() _configuration_checkup(mycli) ================================================ FILE: mycli/packages/completion_engine.py ================================================ import functools import re from typing import Any, Literal import sqlparse from sqlparse.sql import Comparison, Identifier, Token, Where from mycli.packages.parseutils import extract_tables, find_prev_keyword, last_word from mycli.packages.special.main import COMMANDS as SPECIAL_COMMANDS from mycli.packages.special.main import parse_special_command sqlparse.engine.grouping.MAX_GROUPING_DEPTH = None # type: ignore[assignment] sqlparse.engine.grouping.MAX_GROUPING_TOKENS = None # type: ignore[assignment] _ENUM_VALUE_RE = re.compile( r"(?P(?:`[^`]+`|[\w$]+)(?:\.(?:`[^`]+`|[\w$]+))?)\s*=\s*$", re.IGNORECASE, ) # missing because not binary # BETWEEN # CASE # missing because parens are used # IN(), and others # unary operands might need to have another set # not, !, ~ # arrow operators only take a literal on the right # and so might need different treatment # := might also need a different context # sqlparse would call these identifiers, so they are excluded # xor # these are hitting the recursion guard, and so not completing after # so we might as well leave them out: # is, 'is not', mod # sqlparse might also parse "not null" together # should also verify how sqlparse parses every space-containing case BINARY_OPERANDS = { '&', '>', '>>', '>=', '<', '<>', '!=', '<<', '<=', '<=>', '%', '*', '+', '-', '->', '->>', '/', ':=', '=', '^', 'and', '&&', 'div', 'like', 'not like', 'not regexp', 'or', '||', 'regexp', 'rlike', 'sounds like', '|', } # fmt: skip def _enum_value_suggestion(text_before_cursor: str, full_text: str) -> dict[str, Any] | None: match = _ENUM_VALUE_RE.search(text_before_cursor) if not match: return None if is_inside_quotes(text_before_cursor, match.start("lhs")): return None lhs = match.group("lhs") if "." in lhs: parent, column = lhs.split(".", 1) else: parent, column = None, lhs return { "type": "enum_value", "tables": extract_tables(full_text), "column": column, "parent": parent, } def _charset_suggestion(tokens: list[Token]) -> list[dict[str, str]] | None: token_values = [token.value.lower() for token in tokens if token.value] if len(token_values) >= 2 and token_values[-1] == 'set' and token_values[-2] == 'character': return [{'type': 'character_set'}] if len(token_values) >= 3 and token_values[-2] == 'set' and token_values[-3] == 'character': return [{'type': 'character_set'}] if len(token_values) >= 5 and token_values[-1] == 'using' and token_values[-4] == 'convert': return [{'type': 'character_set'}] if len(token_values) >= 6 and token_values[-2] == 'using' and token_values[-5] == 'convert': return [{'type': 'character_set'}] if len(token_values) >= 1 and token_values[-1] == 'collate': return [{'type': 'collation'}] return None def _is_where_or_having(token: Token | None) -> bool: return bool(token and token.value and token.value.lower() in ("where", "having")) def _find_doubled_backticks(text: str) -> list[int]: length = len(text) doubled_backtick_positions: list[int] = [] backtick = '`' two_backticks = backtick + backtick if two_backticks not in text: return doubled_backtick_positions for index in range(0, length): ch = text[index] if ch != backtick: index += 1 continue if index + 1 < length and text[index + 1] == backtick: doubled_backtick_positions.append(index) doubled_backtick_positions.append(index + 1) index += 2 continue index += 1 return doubled_backtick_positions @functools.lru_cache(maxsize=128) def is_inside_quotes(text: str, pos: int) -> Literal[False, 'single', 'double', 'backtick']: in_single = False in_double = False in_backticks = False escaped = False doubled_backtick_positions = [] single_quote = "'" double_quote = '"' backtick = '`' backslash = '\\' # scanning the string twice seems to be needed to handle doubled backticks doubled_backtick_positions = _find_doubled_backticks(text) length = len(text) if pos < 0: pos = length + pos pos = max(pos, 0) pos = min(length, pos) # optimization up_to_pos = text[:pos] if backtick not in up_to_pos and single_quote not in up_to_pos and double_quote not in up_to_pos: return False for index in range(0, pos): ch = text[index] if index in doubled_backtick_positions: index += 1 continue if escaped and (in_double or in_single): escaped = False index += 1 continue if ch == backslash and (in_double or in_single): escaped = True index += 1 continue if ch == backtick and not in_double and not in_single: in_backticks = not in_backticks elif ch == single_quote and not in_double and not in_backticks: in_single = not in_single elif ch == double_quote and not in_single and not in_backticks: in_double = not in_double index += 1 if in_single: return 'single' elif in_double: return 'double' elif in_backticks: return 'backtick' else: return False def suggest_type(full_text: str, text_before_cursor: str) -> list[dict[str, Any]]: """Takes the full_text that is typed so far and also the text before the cursor to suggest completion type and scope. Returns a tuple with a type of entity ('table', 'column' etc) and a scope. A scope for a column category will be a list of tables. """ word_before_cursor = last_word(text_before_cursor, include="many_punctuations") identifier: Identifier | None = None # here should be removed once sqlparse has been fixed try: # If we've partially typed a word then word_before_cursor won't be an empty # string. In that case we want to remove the partially typed string before # sending it to the sqlparser. Otherwise the last token will always be the # partially typed string which renders the smart completion useless because # it will always return the list of keywords as completion. if word_before_cursor: if word_before_cursor.endswith("(") or word_before_cursor.startswith("\\"): parsed = sqlparse.parse(text_before_cursor) else: parsed = sqlparse.parse(text_before_cursor[: -len(word_before_cursor)]) # word_before_cursor may include a schema qualification, like # "schema_name.partial_name" or "schema_name.", so parse it # separately p = sqlparse.parse(word_before_cursor)[0] if p.tokens and isinstance(p.tokens[0], Identifier): identifier = p.tokens[0] else: parsed = sqlparse.parse(text_before_cursor) except (TypeError, AttributeError): return [{"type": "keyword"}] if len(parsed) > 1: # Multiple statements being edited -- isolate the current one by # cumulatively summing statement lengths to find the one that bounds the # current position current_pos = len(text_before_cursor) stmt_start, stmt_end = 0, 0 for statement in parsed: stmt_len = len(str(statement)) stmt_start, stmt_end = stmt_end, stmt_end + stmt_len if stmt_end >= current_pos: text_before_cursor = full_text[stmt_start:current_pos] full_text = full_text[stmt_start:] break elif parsed: # A single statement statement = parsed[0] else: # The empty string statement = None # Check for special commands and handle those separately if statement: # Be careful here because trivial whitespace is parsed as a statement, # but the statement won't have a first token tok1 = statement.token_first() # lenient because \. will parse as two tokens if tok1 and tok1.value.startswith('\\'): return suggest_special(text_before_cursor) elif tok1: if tok1.value.lower() in SPECIAL_COMMANDS: return suggest_special(text_before_cursor) last_token = statement and statement.token_prev(len(statement.tokens))[1] or "" # todo: unsure about empty string as identifier return suggest_based_on_last_token(last_token, text_before_cursor, word_before_cursor, full_text, identifier or Identifier('')) def suggest_special(text: str) -> list[dict[str, Any]]: text = text.lstrip() cmd, _separator, _arg = parse_special_command(text) if cmd == text: # Trying to complete the special command itself return [{"type": "special"}] if cmd in ("\\u", "\\r"): return [{"type": "database"}] if cmd.lower() in ('use', 'connect'): return [{'type': 'database'}] if cmd in (r'\T', r'\Tr'): return [{"type": "table_format"}] if cmd.lower() in ('tableformat', 'redirectformat'): return [{"type": "table_format"}] if cmd in ["\\f", "\\fs", "\\fd"]: return [{"type": "favoritequery"}] if cmd in ["\\dt", "\\dt+"]: return [ {"type": "table", "schema": []}, {"type": "view", "schema": []}, {"type": "schema"}, ] elif cmd.lower() in [ r'\.', 'source', r'\o', r'\once', r'tee', ]: return [{"type": "file_name"}] # todo: why is \edit case-sensitive? elif cmd in [ r'\e', r'\edit', ]: return [{"type": "file_name"}] if cmd in ["\\llm", "\\ai"]: return [{"type": "llm"}] return [{"type": "keyword"}, {"type": "special"}] def suggest_based_on_last_token( token: str | Token | None, text_before_cursor: str, word_before_cursor: str | None, full_text: str, identifier: Identifier, ) -> list[dict[str, Any]]: # don't suggest anything inside a string or number if word_before_cursor: # todo: example where this fails: completing on COLLATE with string "0900" if re.match(r'^[\d\.]', word_before_cursor[0]): return [] # more efficient if no space was typed yet in the string if word_before_cursor[0] in ('"', "'"): return [] # less efficient, but handles all cases # in fact, this is quite slow, but not as slow as offering completions! # faster would be to peek inside the Pygments lexer run by prompt_toolkit -- how? if is_inside_quotes(text_before_cursor, -1) in ['single', 'double']: return [] try: # todo: pass in the complete list of tokens to avoid multiple parsing passes parsed = sqlparse.parse(text_before_cursor)[0] tokens_wo_space = [x for x in parsed.tokens if x.ttype != sqlparse.tokens.Token.Text.Whitespace] except (AttributeError, IndexError, ValueError, sqlparse.exceptions.SQLParseError): parsed = sqlparse.sql.Statement() tokens_wo_space = [] if isinstance(token, str): token_v = token.lower() elif isinstance(token, Comparison): # If 'token' is a Comparison type such as # 'select * FROM abc a JOIN def d ON a.id = d.'. Then calling # token.value on the comparison type will only return the lhs of the # comparison. In this case a.id. So we need to do token.tokens to get # both sides of the comparison and pick the last token out of that # list. token_v = token.tokens[-1].value.lower() elif isinstance(token, Where): # sqlparse groups all tokens from the where clause into a single token # list. This means that token.value may be something like # 'where foo > 5 and '. We need to look "inside" token.tokens to handle # suggestions in complicated where clauses correctly. # # This logic also needs to look even deeper in to the WHERE clause. # We recapitulate some transcoding suggestions here, but cannot # recapitulate the entire logic of this function. where_tokens = [x for x in token.tokens if x.ttype != sqlparse.tokens.Token.Text.Whitespace] if transcoding_suggestion := _charset_suggestion(where_tokens): return transcoding_suggestion original_text = text_before_cursor prev_keyword, text_before_cursor = find_prev_keyword(text_before_cursor) enum_suggestion = _enum_value_suggestion(original_text, full_text) fallback = suggest_based_on_last_token(prev_keyword, text_before_cursor, None, full_text, identifier) if enum_suggestion and _is_where_or_having(prev_keyword): return [enum_suggestion] + fallback return fallback elif token is None: return [{"type": "keyword"}] else: token_v = token.value.lower() if not token: return [{"type": "keyword"}, {"type": "special"}] if token_v == "*": return [{"type": "keyword"}] if token_v.endswith("("): if parsed.tokens and isinstance(parsed.tokens[-1], Where): # Four possibilities: # 1 - Parenthesized clause like "WHERE foo AND (" # Suggest columns/functions # 2 - Function call like "WHERE foo(" # Suggest columns/functions # 3 - Subquery expression like "WHERE EXISTS (" # Suggest keywords, in order to do a subquery # 4 - Subquery OR array comparison like "WHERE foo = ANY(" # Suggest columns/functions AND keywords. (If we wanted to be # really fancy, we could suggest only array-typed columns) column_suggestions = suggest_based_on_last_token("where", text_before_cursor, None, full_text, identifier) # Check for a subquery expression (cases 3 & 4) where = parsed.tokens[-1] _idx, prev_tok = where.token_prev(len(where.tokens) - 1) if isinstance(prev_tok, Comparison): # e.g. "SELECT foo FROM bar WHERE foo = ANY(" prev_tok = prev_tok.tokens[-1] prev_tok = prev_tok.value.lower() if prev_tok == "exists": return [{"type": "keyword"}] else: return column_suggestions # Get the token before the parens idx, prev_tok = parsed.token_prev(len(parsed.tokens) - 1) if prev_tok and prev_tok.value and prev_tok.value.lower() == "using": # tbl1 INNER JOIN tbl2 USING (col1, col2) tables = extract_tables(full_text) # suggest columns that are present in more than one table return [{"type": "column", "tables": tables, "drop_unique": True}] elif parsed.tokens and parsed.token_first().value.lower() == "select": # If the lparen is preceeded by a space chances are we're about to # do a sub-select. if last_word(text_before_cursor, "all_punctuations").startswith("("): return [{"type": "keyword"}] elif parsed.tokens and parsed.token_first().value.lower() == "show": return [{"type": "show"}] # We're probably in a function argument list return [{"type": "column", "tables": extract_tables(full_text)}] elif token_v in ("call"): return [{"type": "procedure", "schema": []}] elif token_v in ('set') and len(tokens_wo_space) >= 3 and tokens_wo_space[-3].value.lower() == 'character': return [{'type': 'character_set'}] elif token_v in ('set') and len(tokens_wo_space) >= 2 and tokens_wo_space[-2].value.lower() == 'character': return [{'type': 'character_set'}] elif token_v in ("set", "order by", "distinct"): return [{"type": "column", "tables": extract_tables(full_text)}] elif token_v == "as": # Don't suggest anything for an alias return [] elif token_v in ("show"): return [{"type": "show"}] elif token_v in ("to",): if parsed.tokens and parsed.token_first().value.lower() == "change": return [{"type": "change"}] else: return [{"type": "user"}] elif token_v in ("user", "for"): return [{"type": "user"}] elif token_v in ('collate'): return [{'type': 'collation'}] # some duplication with _charset_suggestion() elif token_v in ('using') and len(tokens_wo_space) >= 5 and tokens_wo_space[-5].value.lower() == 'convert': return [{'type': 'character_set'}] elif token_v in ('using') and len(tokens_wo_space) >= 4 and tokens_wo_space[-4].value.lower() == 'convert': return [{'type': 'character_set'}] elif token_v in ("select", "where", "having"): # Check for a table alias or schema qualification parent = (identifier and identifier.get_parent_name()) or [] tables = extract_tables(full_text) if parent: tables = [t for t in tables if identifies(parent, *t)] return [ {"type": "column", "tables": tables}, {"type": "table", "schema": parent}, {"type": "view", "schema": parent}, {"type": "function", "schema": parent}, ] elif is_inside_quotes(text_before_cursor, -1) == 'backtick': # todo: this should be revised, since we complete too exuberantly within # backticks, including keywords aliases = [alias or table for (schema, table, alias) in tables] return [ {"type": "column", "tables": tables}, {"type": "function", "schema": []}, {"type": "alias", "aliases": aliases}, {"type": "keyword"}, ] else: aliases = [alias or table for (schema, table, alias) in tables] return [ {"type": "column", "tables": tables}, {"type": "function", "schema": []}, {"type": "introducer"}, {"type": "alias", "aliases": aliases}, ] elif ( (token_v.endswith("join") and isinstance(token, Token) and token.is_keyword) or (token_v in ("copy", "from", "update", "into", "describe", "truncate", "desc", "explain")) # todo: the create table regex fails to match on multi-statement queries, which # suggests a bug above in suggest_type() or (token_v == "like" and re.match(r'^\s*create\s+table\s', full_text, re.IGNORECASE)) ): schema = (identifier and identifier.get_parent_name()) or [] # Suggest tables from either the currently-selected schema or the # public schema if no schema has been specified suggest = [{"type": "table", "schema": schema}] if not schema: # Suggest schemas suggest.append({"type": "database"}) # Only tables can be TRUNCATED, otherwise suggest views if token_v != "truncate": suggest.append({"type": "view", "schema": schema}) return suggest elif token_v in ("table", "view", "function"): # E.g. 'DROP FUNCTION ', 'ALTER TABLE ' rel_type = token_v schema = (identifier and identifier.get_parent_name()) or [] if schema: return [{"type": rel_type, "schema": schema}] else: return [{"type": "schema"}, {"type": rel_type, "schema": []}] elif token_v == "on": tables = extract_tables(full_text) # [(schema, table, alias), ...] parent = (identifier and identifier.get_parent_name()) or [] if parent: # "ON parent." # parent can be either a schema name or table alias tables = [t for t in tables if identifies(parent, *t)] return [ {"type": "column", "tables": tables}, {"type": "table", "schema": parent}, {"type": "view", "schema": parent}, {"type": "function", "schema": parent}, ] else: # ON # Use table alias if there is one, otherwise the table name aliases = [alias or table for (schema, table, alias) in tables] suggest = [{"type": "alias", "aliases": aliases}] # The lists of 'aliases' could be empty if we're trying to complete # a GRANT query. eg: GRANT SELECT, INSERT ON # In that case we just suggest all schemata and all tables. if not aliases: suggest.append({"type": "database"}) suggest.append({"type": "table", "schema": parent}) return suggest elif token_v in ("database", "template"): # "\c ", "DROP DATABASE ", # "CREATE DATABASE WITH TEMPLATE " return [{"type": "database"}] elif is_inside_quotes(text_before_cursor, -1) in ['single', 'double']: return [] elif token_v.endswith(",") or token_v in BINARY_OPERANDS: original_text = text_before_cursor prev_keyword, text_before_cursor = find_prev_keyword(text_before_cursor) enum_suggestion = _enum_value_suggestion(original_text, full_text) # guard against non-progressing parser rewinds, which can otherwise # recurse forever on some operator shapes. if prev_keyword and text_before_cursor.rstrip() != original_text.rstrip(): fallback = suggest_based_on_last_token(prev_keyword, text_before_cursor, None, full_text, identifier) else: # perhaps this fallback should include columns fallback = [{"type": "keyword"}] if enum_suggestion and _is_where_or_having(prev_keyword): return [enum_suggestion] + fallback return fallback else: return [{"type": "keyword"}] def identifies( identifier: Any, schema: str | None, table: str, alias: str, ) -> bool: if identifier == alias: return True if identifier == table: return True if schema and identifier == (schema + "." + table): return True return False ================================================ FILE: mycli/packages/filepaths.py ================================================ import os import platform DEFAULT_SOCKET_DIRS: list[str] = [] if os.name == "posix": if platform.system() == "Darwin": DEFAULT_SOCKET_DIRS = ["/tmp"] else: DEFAULT_SOCKET_DIRS = ["/var/run", "/var/lib"] def list_path(root_dir: str) -> list[str]: """List directory if exists. :param root_dir: str :return: list """ files = [] dirs = [] if not os.path.isdir(root_dir): return [] for name in sorted(os.listdir(root_dir)): if name.startswith('.'): continue elif os.path.isdir(name): dirs.append(f'{name}/') # if .sql is too restrictive it can be made configurable with some effort elif name.lower().endswith('.sql'): files.append(name) return files + dirs def complete_path(curr_dir: str, last_dir: str) -> str: """Return the path to complete that matches the last entered component. If the last entered component is ~, expanded path would not match, so return all of the available paths. :param curr_dir: str :param last_dir: str :return: str """ if not last_dir or curr_dir.startswith(last_dir): return curr_dir elif last_dir == "~": return os.path.join(last_dir, curr_dir) else: return '' def parse_path(root_dir: str) -> tuple[str, str, int]: """Split path into head and last component for the completer. Also return position where last component starts. :param root_dir: str path :return: tuple of (string, string, int) """ base_dir, last_dir, position = "", "", 0 if root_dir: base_dir, last_dir = os.path.split(root_dir) position = -len(last_dir) if last_dir else 0 return base_dir, last_dir, position def suggest_path(root_dir: str) -> list[str]: """List all files and subdirectories in a directory. If the directory is not specified, suggest root directory, user directory, current and parent directory. :param root_dir: string: directory to list :return: list """ if not root_dir: return [ os.path.abspath(os.sep), "~", os.curdir, os.pardir, *list_path(os.curdir), ] if root_dir[0] not in ('/', '~') and root_dir[0:1] != './': return list_path(os.curdir) if "~" in root_dir: root_dir = os.path.expanduser(root_dir) if not os.path.exists(root_dir): root_dir, _ = os.path.split(root_dir) return list_path(root_dir) def dir_path_exists(path: str) -> bool: """Check if the directory path exists for a given file. For example, for a file /home/user/.cache/mycli/log, check if /home/user/.cache/mycli exists. :param str path: The file path. :return: Whether or not the directory path exists. """ return os.path.exists(os.path.dirname(path)) def guess_socket_location() -> str | None: """Try to guess the location of the default mysql socket file.""" socket_dirs = filter(os.path.exists, DEFAULT_SOCKET_DIRS) for directory in socket_dirs: for r, dirs, files in os.walk(directory, topdown=True): for filename in files: name, ext = os.path.splitext(filename) if name.startswith("mysql") and name != "mysqlx" and ext in (".socket", ".sock"): return os.path.join(r, filename) dirs[:] = [d for d in dirs if d.startswith("mysql")] return None ================================================ FILE: mycli/packages/hybrid_redirection.py ================================================ import functools import logging import warnings with warnings.catch_warnings(): # for sqlglot v29.0.1 warnings.filterwarnings( 'ignore', message=r'sqlglot\[rs\] is deprecated', category=UserWarning, module='sqlglot', ) import sqlglot from mycli.compat import WIN from mycli.packages.special.delimitercommand import DelimiterCommand logger = logging.getLogger(__name__) delimiter_command = DelimiterCommand() def find_token_indices(tokens: list[sqlglot.Token]) -> dict[str, list[int]]: token_indices: dict[str, list[int]] = { 'raw_dollar': [], 'true_dollar': [], 'angle_bracket': [], 'pipe': [], } for i, tok in enumerate(tokens): if tok.token_type == sqlglot.TokenType.VAR and tok.text == '$': token_indices['raw_dollar'].append(i) continue if tok.token_type == sqlglot.TokenType.GT and (i - 1) in token_indices['raw_dollar']: token_indices['angle_bracket'].append(i) continue if tok.token_type == sqlglot.TokenType.PIPE and (i - 1) in token_indices['raw_dollar']: token_indices['pipe'].append(i) continue for i in token_indices['raw_dollar']: if (i + 1) in token_indices['angle_bracket'] or (i + 1) in token_indices['pipe']: token_indices['true_dollar'].append(i) return token_indices def find_sql_part( command: str, tokens: list[sqlglot.Token], true_dollar_indices: list[int], ): leftmost_dollar_pos = tokens[true_dollar_indices[0]].start sql_part = command[0:leftmost_dollar_pos].strip().removesuffix(delimiter_command.current).rstrip() try: statements = sqlglot.parse(sql_part, read='mysql') except sqlglot.errors.ParseError: return '' if len(statements) != 1: # buglet: the statement count doesn't respect a custom delimiter return '' return sql_part def find_command_tokens( tokens: list[sqlglot.Token], true_dollar_indices: list[int], ) -> list[sqlglot.Token]: command_part_tokens = [] for i, tok in enumerate(tokens): if i < true_dollar_indices[0]: continue if i in true_dollar_indices: continue command_part_tokens.append(tok) if command_part_tokens: _operator = command_part_tokens.pop(0) return command_part_tokens def find_file_tokens( tokens: list[sqlglot.Token], angle_bracket_indices: list[int], ) -> tuple[list[sqlglot.Token], int, str | None]: file_part_tokens: list[sqlglot.Token] = [] file_part_index = len(tokens) if not angle_bracket_indices: return file_part_tokens, file_part_index, None file_part_tokens = tokens[angle_bracket_indices[-1] :] file_part_index = angle_bracket_indices[-1] file_operator_part = file_part_tokens.pop(0).text if file_operator_part == '>' and file_part_tokens[0].token_type == sqlglot.TokenType.GT: file_part_tokens.pop(0) file_operator_part = '>>' return file_part_tokens, file_part_index, file_operator_part def assemble_tokens(tokens: list[sqlglot.Token]) -> str: assembled_string = ' ' * (tokens[-1].end + 10) for tok in tokens: if tok.token_type == sqlglot.TokenType.IDENTIFIER: text = f'"{tok.text}"' offset = 2 elif tok.token_type == sqlglot.TokenType.STRING: text = f"'{tok.text}'" offset = 2 else: text = tok.text offset = 0 assembled_string = assembled_string[0 : tok.start] + text + assembled_string[tok.end + offset :] return assembled_string.strip().removesuffix(delimiter_command.current).rstrip() def invalid_shell_part( file_part: str | None, command_part: str | None, ) -> bool: if file_part and ' ' in file_part: return True if file_part and '>' in file_part: return True if not file_part and not command_part: return True return False @functools.lru_cache(maxsize=1) def get_redirect_components(command: str) -> tuple[str | None, str | None, str | None, str | None]: """Get the parts of a hybrid shell-style redirect command.""" try: tokens = sqlglot.tokenize(command) except sqlglot.errors.TokenError: return None, None, None, None token_indices = find_token_indices(tokens) if not token_indices['true_dollar']: return None, None, None, None if len(token_indices['angle_bracket']) > 1: return None, None, None, None if WIN and len(token_indices['pipe']) > 1: # how to give better feedback here? return None, None, None, None if token_indices['angle_bracket'] and token_indices['pipe']: if token_indices['pipe'][-1] > token_indices['angle_bracket'][-1]: return None, None, None, None sql_part = find_sql_part( command, tokens, token_indices['true_dollar'], ) if not sql_part: return None, None, None, None ( file_part_tokens, file_part_index, file_operator_part, ) = find_file_tokens( tokens, token_indices['angle_bracket'], ) command_part_tokens = find_command_tokens( tokens[0:file_part_index], token_indices['true_dollar'], ) if file_part_tokens: file_part = assemble_tokens(file_part_tokens) else: file_part = None if command_part_tokens: command_part = assemble_tokens(command_part_tokens) else: command_part = None if invalid_shell_part(file_part, command_part): return None, None, None, None logger.debug('redirect parse sql_part: "{}"'.format(sql_part)) logger.debug('redirect parse command_part: "{}"'.format(command_part)) logger.debug('redirect parse file_operator_part: "{}"'.format(file_operator_part)) logger.debug('redirect parse file_part: "{}"'.format(file_part)) return sql_part, command_part, file_operator_part, file_part def is_redirect_command(command: str) -> bool: """Is this a shell-style redirect to command or file? :param command: string """ sql_part, _command_part, _file_operator_part, _file_part = get_redirect_components(command) return bool(sql_part) ================================================ FILE: mycli/packages/paramiko_stub/__init__.py ================================================ """A module to import instead of paramiko when it is not available (to avoid checking for paramiko all over the place). When paramiko is first invoked, this simply shuts down mycli, telling the user they either have to install paramiko or should not use SSH features. """ class Paramiko: def __getattr__(self, name: str) -> None: import sys from textwrap import dedent print( dedent(""" To enable certain SSH features you need to install ssh extras: pip install 'mycli[ssh]' or pip install paramiko sshtunnel This is required for the following command-line arguments: --list-ssh-config --ssh-config-host --ssh-host """), file=sys.stderr, ) sys.exit(1) paramiko = Paramiko() ================================================ FILE: mycli/packages/parseutils.py ================================================ from __future__ import annotations import re from typing import Any, Generator, Literal import warnings import sqlparse from sqlparse.sql import Function, Identifier, IdentifierList, Token, TokenList from sqlparse.tokens import DML, Keyword, Punctuation with warnings.catch_warnings(): # for sqlglot v29.0.1 warnings.filterwarnings( 'ignore', message=r'sqlglot\[rs\] is deprecated', category=UserWarning, module='sqlglot', ) import sqlglot sqlparse.engine.grouping.MAX_GROUPING_DEPTH = None # type: ignore[assignment] sqlparse.engine.grouping.MAX_GROUPING_TOKENS = None # type: ignore[assignment] cleanup_regex: dict[str, re.Pattern] = { # This matches only alphanumerics and underscores. "alphanum_underscore": re.compile(r"(\w+)$"), # This matches everything except spaces, parens, colon, and comma "many_punctuations": re.compile(r"([^():,\s]+)$"), # This matches everything except spaces, parens, colon, comma, and period "most_punctuations": re.compile(r"([^\.():,\s]+)$"), # This matches everything except a space. "all_punctuations": re.compile(r"([^\s]+)$"), } def is_valid_connection_scheme(text: str) -> tuple[bool, str | None]: # exit early if the text does not resemble a DSN URI if "://" not in text: return False, None scheme = text.split("://")[0] if scheme not in ("mysql", "mysqlx", "tcp", "socket", "ssh"): return False, scheme else: return True, None def last_word( text: str, include: Literal[ 'alphanum_underscore', 'many_punctuations', 'most_punctuations', 'all_punctuations', ] = 'alphanum_underscore', ) -> str: r""" Find the last word in a sentence. >>> last_word('abc') 'abc' >>> last_word(' abc') 'abc' >>> last_word('') '' >>> last_word(' ') '' >>> last_word('abc ') '' >>> last_word('abc def') 'def' >>> last_word('abc def ') '' >>> last_word('abc def;') '' >>> last_word('bac $def') 'def' >>> last_word('bac $def', include='most_punctuations') '$def' >>> last_word('bac \def', include='most_punctuations') '\\\\def' >>> last_word('bac \def;', include='most_punctuations') '\\\\def;' >>> last_word('bac::def', include='most_punctuations') 'def' """ if not text: # Empty string return "" if text[-1].isspace(): return "" else: regex = cleanup_regex[include] matches = regex.search(text) if matches: return matches.group(0) else: return "" # This code is borrowed from sqlparse example script. # def is_subselect(parsed: TokenList) -> bool: if not parsed.is_group: return False for item in parsed.tokens: if item.ttype is DML and item.value.upper() in ("SELECT", "INSERT", "UPDATE", "CREATE", "DELETE"): return True return False def get_last_select(parsed: TokenList) -> TokenList: """ Takes a parsed sql statement and returns the last select query where applicable. The intended use case is for when giving table suggestions based on columns, where we only want to look at the columns from the most recent select. This works for a single select query, or one or more sub queries (the useful part). The custom logic is necessary because the typical sqlparse logic for things like finding sub selects (i.e. is_subselect) only works on complete statements, such as: * select c1 from t1; However when suggesting tables based on columns, we only have partial select statements, i.e.: * select c1 * select c1 from (select c2) So given the above, we must parse them ourselves as they are not viewed as complete statements. Returns a TokenList of the last select statement's tokens. """ select_indexes: list[int] = [] for token in parsed: if token.match(DML, "select"): # match is case insensitive select_indexes.append(parsed.token_index(token)) last_select = TokenList() if select_indexes: last_select = TokenList(parsed[select_indexes[-1] :]) return last_select def extract_from_part(parsed: TokenList, stop_at_punctuation: bool = True) -> Generator[Any, None, None]: tbl_prefix_seen = False for item in parsed.tokens: if tbl_prefix_seen: if is_subselect(item): yield from extract_from_part(item, stop_at_punctuation) elif stop_at_punctuation and item.ttype is Punctuation: return None # Multiple JOINs in the same query won't work properly since # "ON" is a keyword and will trigger the next elif condition. # So instead of stooping the loop when finding an "ON" skip it # eg: 'SELECT * FROM abc JOIN def ON abc.id = def.abc_id JOIN ghi' elif item.ttype is Keyword and item.value.upper() == "ON": tbl_prefix_seen = False continue # An incomplete nested select won't be recognized correctly as a # sub-select. eg: 'SELECT * FROM (SELECT id FROM user'. This causes # the second FROM to trigger this elif condition resulting in a # StopIteration. So we need to ignore the keyword if the keyword # FROM. # Also 'SELECT * FROM abc JOIN def' will trigger this elif # condition. So we need to ignore the keyword JOIN and its variants # INNER JOIN, FULL OUTER JOIN, etc. elif item.ttype is Keyword and (not item.value.upper() == "FROM") and (not item.value.upper().endswith("JOIN")): return None else: yield item elif (item.ttype is Keyword or item.ttype is Keyword.DML) and item.value.upper() in ( "COPY", "FROM", "INTO", "UPDATE", "TABLE", "JOIN", ): tbl_prefix_seen = True # 'SELECT a, FROM abc' will detect FROM as part of the column list. # So this check here is necessary. elif isinstance(item, IdentifierList): for identifier in item.get_identifiers(): if identifier.ttype is Keyword and identifier.value.upper() == "FROM": tbl_prefix_seen = True break def extract_table_identifiers(token_stream: Generator[Any, None, None]) -> Generator[tuple[str | None, str, str], None, None]: """yields tuples of (schema_name, table_name, table_alias)""" for item in token_stream: if isinstance(item, IdentifierList): for identifier in item.get_identifiers(): # Sometimes Keywords (such as FROM ) are classified as # identifiers which don't have the get_real_name() method. try: schema_name = identifier.get_parent_name() real_name = identifier.get_real_name() except AttributeError: continue if real_name: yield (schema_name, real_name, identifier.get_alias()) elif isinstance(item, Identifier): real_name = item.get_real_name() schema_name = item.get_parent_name() if real_name: yield (schema_name, real_name, item.get_alias()) else: name = item.get_name() yield (None, name, item.get_alias() or name) elif isinstance(item, Function): yield (None, item.get_name(), item.get_name()) # extract_tables is inspired from examples in the sqlparse lib. def extract_tables(sql: str) -> list[tuple[str | None, str, str]]: """Extract the table names from an SQL statement. Returns a list of (schema, table, alias) tuples """ parsed = sqlparse.parse(sql) if not parsed: return [] # INSERT statements must stop looking for tables at the sign of first # Punctuation. eg: INSERT INTO abc (col1, col2) VALUES (1, 2) # abc is the table name, but if we don't stop at the first lparen, then # we'll identify abc, col1 and col2 as table names. insert_stmt = parsed[0].token_first().value.lower() == "insert" stream = extract_from_part(parsed[0], stop_at_punctuation=insert_stmt) return list(extract_table_identifiers(stream)) def extract_columns_from_select(sql: str) -> list[str]: """ Extract the column names from a select SQL statement. Returns a list of columns. """ parsed = sqlparse.parse(sql) if not parsed: return [] statement = get_last_select(parsed[0]) # if there is no select, skip checking for columns if not statement: return [] columns = [] # Loops through the tokens (pieces) of the SQL statement. # Once it finds the SELECT token (generally first), it # will then start looking for columns from that point on. # The get_real_name() function returns the real column name # even if an alias is used. found_select = False for token in statement.tokens: if token.ttype is DML and token.value.upper() == 'SELECT': found_select = True elif found_select: if isinstance(token, IdentifierList): # multiple columns for identifier in token.get_identifiers(): if isinstance(identifier, Identifier): column = identifier.get_real_name() elif isinstance(identifier, Token): column = identifier.value else: continue columns.append(column) elif isinstance(token, Identifier): # single column column = token.get_real_name() columns.append(column) elif token.ttype is Keyword: break if columns: break return columns def extract_tables_from_complete_statements(sql: str) -> list[tuple[str | None, str, str | None]]: """Extract the table names from a complete and valid series of SQL statements. Returns a list of (schema, table, alias) tuples """ # sqlglot chokes entirely on things like "\T" that it doesn't know about, # but is much better at extracting table names from complete statements. # sqlparse can extract the series of statements, though it also doesn't # understand "\T". roughly_parsed = sqlparse.parse(sql) if not roughly_parsed: return [] finely_parsed = [] for rough_statement in roughly_parsed: try: finely_parsed.append(sqlglot.parse_one(str(rough_statement), read='mysql')) except sqlglot.errors.ParseError: pass tables = [] for fine_statement in finely_parsed: for identifier in fine_statement.find_all(sqlglot.exp.Table): if identifier.parent_select and identifier.parent_select.sql().startswith('WITH'): continue tables.append(( None if identifier.db == '' else identifier.db, identifier.name, None if identifier.alias == '' else identifier.alias, )) return tables def find_prev_keyword(sql: str) -> tuple[Token | None, str]: """Find the last sql keyword in an SQL statement Returns the value of the last keyword, and the text of the query with everything after the last keyword stripped """ if not sql.strip(): return None, "" parsed = sqlparse.parse(sql)[0] flattened = list(parsed.flatten()) logical_operators = ("AND", "OR", "NOT", "BETWEEN") for t in reversed(flattened): if t.value == "(" or (t.is_keyword and (t.value.upper() not in logical_operators)): # Find the location of token t in the original parsed statement # We can't use parsed.token_index(t) because t may be a child token # inside a TokenList, in which case token_index thows an error # Minimal example: # p = sqlparse.parse('select * from foo where bar') # t = list(p.flatten())[-3] # The "Where" token # p.token_index(t) # Throws ValueError: not in list idx = flattened.index(t) # Combine the string values of all tokens in the original list # up to and including the target keyword token t, to produce a # query string with everything after the keyword token removed text = "".join(tok.value for tok in flattened[: idx + 1]) return t, text return None, "" def query_starts_with(query: str, prefixes: list[str]) -> bool: """Check if the query starts with any item from *prefixes*.""" prefixes = [prefix.lower() for prefix in prefixes] formatted_sql = sqlparse.format(query.lower(), strip_comments=True) return bool(formatted_sql) and formatted_sql.split()[0] in prefixes def queries_start_with(queries: str, prefixes: list[str]) -> bool: """Check if any queries start with any item from *prefixes*.""" for query in sqlparse.split(queries): if query and query_starts_with(query, prefixes) is True: return True return False def query_has_where_clause(query: str) -> bool: """Check if the query contains a where-clause.""" return any(isinstance(token, sqlparse.sql.Where) for token_list in sqlparse.parse(query) for token in token_list) # todo: handle "UPDATE LOW_PRIORITY" and "UPDATE IGNORE" def query_is_single_table_update(query: str) -> bool: """Check if a query is a simple single-table UPDATE.""" cleaned_query_for_parsing_only = sqlparse.format(query, strip_comments=True) cleaned_query_for_parsing_only = re.sub(r'\s+', ' ', cleaned_query_for_parsing_only) if not cleaned_query_for_parsing_only: return False parsed = sqlparse.parse(cleaned_query_for_parsing_only) if not parsed: return False statement = parsed[0] return ( statement[0].value.lower() == 'update' and statement[1].is_whitespace and ',' not in statement[2].value # multiple tables and statement[3].is_whitespace and statement[4].value.lower() == 'set' ) def is_destructive(keywords: list[str], queries: str) -> bool: """Returns True if any of the queries in *queries* is destructive.""" for query in sqlparse.split(queries): if not query: continue # subtle: if "UPDATE" is one of our keywords AND "query" starts with "UPDATE" if query_starts_with(query, keywords) and query_starts_with(query, ["update"]): if query_has_where_clause(query) and query_is_single_table_update(query): return False else: return True if query_starts_with(query, keywords): return True return False def is_dropping_database(queries: str, dbname: str | None) -> bool: """Determine if the query is dropping a specific database.""" result = False if dbname is None: return False def normalize_db_name(db: str) -> str: return db.lower().strip('`"') dbname = normalize_db_name(dbname) for query in sqlparse.parse(queries): keywords = [t for t in query.tokens if t.is_keyword] if len(keywords) < 2: continue if keywords[0].normalized in ("DROP", "CREATE") and keywords[1].value.lower() in ( "database", "schema", ): database_token = next((t for t in query.tokens if isinstance(t, Identifier)), None) if database_token is not None and normalize_db_name(database_token.get_name()) == dbname: result = keywords[0].normalized == "DROP" return result if __name__ == "__main__": sql = "select * from (select t. from tabl t" print(extract_tables(sql)) ================================================ FILE: mycli/packages/prompt_utils.py ================================================ import sys import click from mycli.packages.parseutils import is_destructive class ConfirmBoolParamType(click.ParamType): name = "confirmation" def convert(self, value: bool | str, param: click.Parameter | None, ctx: click.Context | None) -> bool: if isinstance(value, bool): return bool(value) value = value.lower() if value in ("yes", "y"): return True if value in ("no", "n"): return False self.fail(f'{value} is not a valid boolean', param, ctx) def __repr__(self): return "BOOL" BOOLEAN_TYPE = ConfirmBoolParamType() def confirm_destructive_query(keywords: list[str], queries: str) -> bool | None: """Check if the query is destructive and prompts the user to confirm. Returns: * None if the query is non-destructive or we can't prompt the user. * True if the query is destructive and the user wants to proceed. * False if the query is destructive and the user doesn't want to proceed. """ prompt_text = "You're about to run a destructive command.\nDo you want to proceed? (y/n)" if is_destructive(keywords, queries) and sys.stdin.isatty(): return prompt(prompt_text, type=BOOLEAN_TYPE) else: return None def confirm(*args, **kwargs) -> bool: """Prompt for confirmation (yes/no) and handle any abort exceptions.""" try: return click.confirm(*args, **kwargs) except click.Abort: return False def prompt(*args, **kwargs) -> bool: """Prompt the user for input and handle any abort exceptions.""" try: return click.prompt(*args, **kwargs) except click.Abort: return False ================================================ FILE: mycli/packages/shortcuts.py ================================================ from mycli.sqlexecute import SQLExecute def server_date(sqlexecute: SQLExecute, quoted: bool = False) -> str: server_date_str = sqlexecute.now().strftime('%Y-%m-%d') if quoted: return f"'{server_date_str}'" else: return server_date_str def server_datetime(sqlexecute: SQLExecute, quoted: bool = False) -> str: server_datetime_str = sqlexecute.now().strftime('%Y-%m-%d %H:%M:%S') if quoted: return f"'{server_datetime_str}'" else: return server_datetime_str ================================================ FILE: mycli/packages/special/__init__.py ================================================ from mycli.packages.special.dbcommands import ( list_databases, list_tables, status, ) from mycli.packages.special.iocommands import ( clip_command, close_tee, copy_query_to_clipboard, disable_pager, editor_command, flush_pipe_once_if_written, forced_horizontal, get_clip_query, get_current_delimiter, get_editor_query, get_filename, is_expanded_output, is_pager_enabled, is_redirected, is_show_favorite_query, is_timing_enabled, open_external_editor, set_delimiter, set_destructive_keywords, set_expanded_output, set_favorite_queries, set_forced_horizontal_output, set_pager, set_pager_enabled, set_redirect, set_show_favorite_query, set_timing_enabled, split_queries, unset_once_if_written, write_once, write_pipe_once, write_tee, ) from mycli.packages.special.llm import ( FinishIteration, handle_llm, is_llm_command, sql_using_llm, ) from mycli.packages.special.main import ( CommandNotFound, execute, parse_special_command, register_special_command, special_command, ) __all__: list[str] = [ 'CommandNotFound', 'FinishIteration', 'clip_command', 'close_tee', 'copy_query_to_clipboard', 'disable_pager', 'editor_command', 'execute', 'flush_pipe_once_if_written', 'forced_horizontal', 'get_clip_query', 'get_current_delimiter', 'get_editor_query', 'get_filename', 'handle_llm', 'is_expanded_output', 'is_llm_command', 'is_pager_enabled', 'is_redirected', 'is_timing_enabled', 'list_databases', 'list_tables', 'open_external_editor', 'parse_special_command', 'register_special_command', 'set_delimiter', 'set_destructive_keywords', 'set_expanded_output', 'set_favorite_queries', 'set_forced_horizontal_output', 'set_pager', 'set_pager_enabled', 'set_redirect', 'set_timing_enabled', 'set_show_favorite_query', 'is_show_favorite_query', 'special_command', 'split_queries', 'sql_using_llm', 'status', 'unset_once_if_written', 'write_once', 'write_pipe_once', 'write_tee', ] ================================================ FILE: mycli/packages/special/dbcommands.py ================================================ import logging import os import platform from pymysql import ProgrammingError from pymysql.cursors import Cursor from mycli import __version__ from mycli.packages.special import iocommands from mycli.packages.special.main import ArgType, special_command from mycli.packages.special.utils import format_uptime, get_ssl_version from mycli.packages.sqlresult import SQLResult logger = logging.getLogger(__name__) @special_command("\\dt", "\\dt[+] [table]", "List or describe tables.", arg_type=ArgType.PARSED_QUERY, case_sensitive=True) def list_tables( cur: Cursor, arg: str | None = None, _arg_type: ArgType = ArgType.PARSED_QUERY, verbose: bool = False, ) -> list[SQLResult]: if arg: query = f'SHOW FIELDS FROM {arg}' else: query = "SHOW TABLES" logger.debug(query) cur.execute(query) if cur.description: header = [x[0] for x in cur.description] else: return [SQLResult()] # Fetch results before potentially executing another query results = list(cur.fetchall()) if verbose and arg else cur postamble = '' if verbose and arg: query = f'SHOW CREATE TABLE {arg}' logger.debug(query) cur.execute(query) if one := cur.fetchone(): postamble = one[1] # todo missing a status line because sqlexecute.get_result was not used return [SQLResult(header=header, rows=results, postamble=postamble)] @special_command("\\l", "\\l", "List databases.", arg_type=ArgType.RAW_QUERY, case_sensitive=True) def list_databases(cur: Cursor, **_) -> list[SQLResult]: query = "SHOW DATABASES" logger.debug(query) cur.execute(query) if cur.description: header = [x[0] for x in cur.description] # todo missing a status line because sqlexecute.get_result was not used return [SQLResult(header=header, rows=cur)] else: return [SQLResult()] @special_command( "status", "status", "Get status information from the server.", arg_type=ArgType.RAW_QUERY, aliases=["\\s"], case_sensitive=True ) def status(cur: Cursor, **_) -> list[SQLResult]: query = "SHOW GLOBAL STATUS;" logger.debug(query) try: cur.execute(query) except ProgrammingError: # Fallback in case query fail, as it does with Mysql 4 query = "SHOW STATUS;" logger.debug(query) cur.execute(query) status = dict(cur.fetchall()) query = "SHOW GLOBAL VARIABLES;" logger.debug(query) cur.execute(query) variables = dict(cur.fetchall()) # prepare in case keys are bytes, as with Python 3 and Mysql 4 if isinstance(list(variables)[0], bytes) and isinstance(list(status)[0], bytes): variables = {k.decode("utf-8"): v.decode("utf-8") for k, v in variables.items()} status = {k.decode("utf-8"): v.decode("utf-8") for k, v in status.items()} # Create output buffers. preamble = [] output = [] footer = [] preamble.append("--------------") # Output the mycli client information. implementation = platform.python_implementation() version = platform.python_version() client_info = [] client_info.append(f'mycli {__version__}') client_info.append(f'running on {implementation} {version}') preamble.append(" ".join(client_info) + "\n") # Build the output that will be displayed as a table. output.append(("Connection id:", cur.connection.thread_id())) query = "SELECT DATABASE(), USER();" logger.debug(query) cur.execute(query) if one := cur.fetchone(): db, user = one else: db = "" user = "" output.append(("Current database:", db)) output.append(("Current user:", user)) if iocommands.is_pager_enabled(): if "PAGER" in os.environ: pager = os.environ["PAGER"] else: pager = "System default" else: pager = "stdout" output.append(("Current pager:", pager)) output.append(("Server version:", f'{variables["version"]} {variables["version_comment"]}')) output.append(("Protocol version:", variables["protocol_version"])) output.append(('SSL/TLS version:', get_ssl_version(cur))) if getattr(cur.connection, 'unix_socket', None): host_info = cur.connection.host_info else: host_info = f'{cur.connection.host} via TCP/IP' output.append(("Connection:", host_info)) query = "SELECT @@character_set_server, @@character_set_database, @@character_set_client, @@character_set_connection LIMIT 1;" logger.debug(query) cur.execute(query) if one := cur.fetchone(): charset = one else: charset = ("", "", "", "") output.append(("Server characterset:", charset[0])) output.append(("Db characterset:", charset[1])) output.append(("Client characterset:", charset[2])) output.append(("Conn. characterset:", charset[3])) if getattr(cur.connection, 'unix_socket', None): output.append(('UNIX socket:', variables['socket'])) else: output.append(('TCP port:', cur.connection.port)) if "Uptime" in status: output.append(("Uptime:", format_uptime(status["Uptime"]))) if "Threads_connected" in status: # Print the current server statistics. stats = [] stats.append(f'Connections: {status["Threads_connected"]}') if "Queries" in status: stats.append(f'Queries: {status["Queries"]}') stats.append(f'Slow queries: {status["Slow_queries"]}') stats.append(f'Opens: {status["Opened_tables"]}') if "Flush_commands" in status: stats.append(f'Flush tables: {status["Flush_commands"]}') stats.append(f'Open tables: {status["Open_tables"]}') if "Queries" in status: queries_per_second = int(status["Queries"]) / int(status["Uptime"]) stats.append(f'Queries per second avg: {queries_per_second:.3f}') stats_str = " ".join(stats) footer.append("\n" + stats_str) footer.append("--------------") return [SQLResult(preamble="\n".join(preamble), rows=output, postamble="\n".join(footer))] ================================================ FILE: mycli/packages/special/delimitercommand.py ================================================ from __future__ import annotations import re from typing import Generator import sqlparse from mycli.packages.sqlresult import SQLResult sqlparse.engine.grouping.MAX_GROUPING_DEPTH = None # type: ignore[assignment] sqlparse.engine.grouping.MAX_GROUPING_TOKENS = None # type: ignore[assignment] class DelimiterCommand: def __init__(self) -> None: self._delimiter = ";" def _split(self, sql: str) -> list[str]: """Temporary workaround until sqlparse.split() learns about custom delimiters.""" placeholder = "\ufffc" # unicode object replacement character if self._delimiter == ";": return sqlparse.split(sql) # We must find a string that original sql does not contain. # Most likely, our placeholder is enough, but if not, keep looking while placeholder in sql: placeholder += placeholder[0] sql = sql.replace(";", placeholder) sql = sql.replace(self._delimiter, ";") split = sqlparse.split(sql) return [stmt.replace(";", self._delimiter).replace(placeholder, ";") for stmt in split] def queries_iter(self, input_str: str) -> Generator[str, None, None]: """Iterate over queries in the input string.""" queries = self._split(input_str) while queries: for sql in queries: delimiter = self._delimiter sql = queries.pop(0) if sql.endswith(delimiter): trailing_delimiter = True sql = sql.strip(delimiter) else: trailing_delimiter = False yield sql # if the delimiter was changed by the last command, # re-split everything, and if we previously stripped # the delimiter, append it to the end if self._delimiter != delimiter: combined_statement = " ".join([sql] + queries) if trailing_delimiter: combined_statement += delimiter queries = self._split(combined_statement)[1:] def set(self, arg: str, **_) -> list[SQLResult]: """Change delimiter. Since `arg` is everything that follows the DELIMITER token after sqlparse (it may include other statements separated by the new delimiter), we want to set the delimiter to the first word of it. """ match = arg and re.search(r"[^\s]+", arg) if not match: message = "Missing required argument, delimiter" return [SQLResult(status=message)] delimiter = match.group() if delimiter.lower() == "delimiter": return [SQLResult(status='Invalid delimiter "delimiter"')] self._delimiter = delimiter return [SQLResult(status=f'Changed delimiter to {delimiter}')] @property def current(self) -> str: return self._delimiter ================================================ FILE: mycli/packages/special/favoritequeries.py ================================================ from __future__ import annotations class FavoriteQueries: section_name: str = "favorite_queries" usage = """ Favorite Queries are a way to save frequently used queries with a short name. Examples: # Save a new favorite query. > \\fs simple select * from abc where a is not Null; # List all favorite queries. > \\f ╒════════╤═══════════════════════════════════════╕ │ Name │ Query │ ╞════════╪═══════════════════════════════════════╡ │ simple │ SELECT * FROM abc where a is not NULL │ ╘════════╧═══════════════════════════════════════╛ # Run a favorite query. > \\f simple ╒════════╤════════╕ │ a │ b │ ╞════════╪════════╡ │ 日本語 │ 日本語 │ ╘════════╧════════╛ # Delete a favorite query. > \\fd simple simple: Deleted. """ # Class-level variable, for convenience to use as a singleton. instance: FavoriteQueries def __init__(self, config) -> None: self.config = config @classmethod def from_config(cls, config): return FavoriteQueries(config) def list(self) -> list[str | None]: return self.config.get(self.section_name, []) def get(self, name) -> str | None: return self.config.get(self.section_name, {}).get(name, None) def save(self, name: str, query: str) -> None: self.config.encoding = "utf-8" if self.section_name not in self.config: self.config[self.section_name] = {} self.config[self.section_name][name] = query self.config.write() def delete(self, name: str) -> str: try: del self.config[self.section_name][name] except KeyError: return f'{name}: Not Found.' self.config.write() return f'{name}: Deleted.' ================================================ FILE: mycli/packages/special/iocommands.py ================================================ from __future__ import annotations import locale import logging import os import re import shlex import subprocess from time import sleep from typing import Any, Generator import click from configobj import ConfigObj from prompt_toolkit.formatted_text import ANSI, FormattedText, to_plain_text from pymysql.cursors import Cursor import pyperclip import sqlparse from mycli.compat import WIN from mycli.packages.prompt_utils import confirm_destructive_query from mycli.packages.special.delimitercommand import DelimiterCommand from mycli.packages.special.favoritequeries import FavoriteQueries from mycli.packages.special.main import COMMANDS as SPECIAL_COMMANDS from mycli.packages.special.main import ArgType, special_command from mycli.packages.special.main import execute as special_execute from mycli.packages.special.utils import handle_cd_command from mycli.packages.sqlresult import SQLResult sqlparse.engine.grouping.MAX_GROUPING_DEPTH = None # type: ignore[assignment] sqlparse.engine.grouping.MAX_GROUPING_TOKENS = None # type: ignore[assignment] TIMING_ENABLED = False use_expanded_output = False force_horizontal_output = False PAGER_ENABLED = True SHOW_FAVORITE_QUERY = True tee_file = None once_file = None written_to_once_file = False PIPE_ONCE: dict[str, Any] = { 'process': None, 'stdin': [], 'stdout_file': None, 'stdout_mode': None, } delimiter_command = DelimiterCommand() favoritequeries = FavoriteQueries(ConfigObj()) DESTRUCTIVE_KEYWORDS: list[str] = [] def set_favorite_queries(config): global favoritequeries favoritequeries = FavoriteQueries(config) def set_timing_enabled(val: bool) -> None: global TIMING_ENABLED TIMING_ENABLED = val def set_pager_enabled(val: bool) -> None: global PAGER_ENABLED PAGER_ENABLED = val def is_pager_enabled() -> bool: return PAGER_ENABLED def set_show_favorite_query(val: bool) -> None: global SHOW_FAVORITE_QUERY SHOW_FAVORITE_QUERY = val def is_show_favorite_query() -> bool: return SHOW_FAVORITE_QUERY def set_destructive_keywords(val: list[str]) -> None: global DESTRUCTIVE_KEYWORDS DESTRUCTIVE_KEYWORDS = val @special_command( "pager", "pager [command]", "Set pager to [command]. Print query results via pager.", arg_type=ArgType.PARSED_QUERY, aliases=["\\P"], case_sensitive=True, ) def set_pager(arg: str, **_) -> list[SQLResult]: if arg: os.environ["PAGER"] = arg msg = f"PAGER set to {arg}." set_pager_enabled(True) else: if "PAGER" in os.environ: msg = f"PAGER set to {os.environ['PAGER']}." else: # This uses click's default per echo_via_pager. msg = "Pager enabled." set_pager_enabled(True) return [SQLResult(status=msg)] @special_command("nopager", "nopager", "Disable pager; print to stdout.", arg_type=ArgType.NO_QUERY, aliases=["\\n"], case_sensitive=True) def disable_pager() -> list[SQLResult]: set_pager_enabled(False) return [SQLResult(status="Pager disabled.")] @special_command("\\timing", "\\timing", "Toggle timing of queries.", arg_type=ArgType.NO_QUERY, aliases=["\\t"], case_sensitive=True) def toggle_timing() -> list[SQLResult]: global TIMING_ENABLED TIMING_ENABLED = not TIMING_ENABLED message = "Timing is " message += "on." if TIMING_ENABLED else "off." return [SQLResult(status=message)] def is_timing_enabled() -> bool: return TIMING_ENABLED def set_expanded_output(val: bool) -> None: global use_expanded_output use_expanded_output = val def is_expanded_output() -> bool: return use_expanded_output def set_forced_horizontal_output(val: bool) -> None: global force_horizontal_output force_horizontal_output = val def forced_horizontal() -> bool: return force_horizontal_output _logger = logging.getLogger(__name__) def editor_command(command: str) -> bool: """ Is this an external editor command? :param command: string """ # It is possible to have `\e filename` or `SELECT * FROM \e`. So we check # for both conditions. return ( command.strip().endswith("\\e") or command.strip().startswith("\\e ") or command.strip().endswith("\\edit") or command.strip().startswith("\\edit ") ) def get_filename(sql: str) -> str | None: if sql.strip().startswith("\\e ") or sql.strip().startswith("\\edit "): command, _, filename = sql.partition(" ") return filename.strip() or None else: return None def get_editor_query(sql: str) -> str: """Get the query part of an editor command.""" sql = sql.strip() # The reason we can't simply do .strip('\e') is that it strips characters, # not a substring. So it'll strip "e" in the end of the sql also! # Ex: "select * from style\e" -> "select * from styl". pattern = re.compile(r"(\\e$|\\edit$)") while pattern.search(sql): sql = pattern.sub("", sql) return sql def open_external_editor(filename: str | None = None, sql: str | None = None) -> tuple[str, str | None]: """Open external editor, wait for the user to type in their query, return the query. """ filename = filename.strip().split(" ", 1)[0] if filename else None sql = sql or "" MARKER = "# Type your query above this line.\n" if filename: query = '' message = None click.edit(filename=filename) try: with open(filename, 'r') as f: query = f.read() except IOError: message = f'Error reading file: {filename}' return (query.rstrip('\n'), message) # Populate the editor buffer with the partial sql (if available) and a # placeholder comment. query = click.edit(f"{sql}\n\n{MARKER}", extension=".sql") or '' if query: query = query.split(MARKER, 1)[0].rstrip("\n") else: # Don't return None for the caller to deal with. # Empty string is ok. query = sql return (query, None) def clip_command(command: str) -> bool: """Is this a clip command? :param command: string """ # It is possible to have `\clip` or `SELECT * FROM \clip`. So we check # for both conditions. return command.strip().endswith("\\clip") or command.strip().startswith("\\clip") def get_clip_query(sql: str) -> str: """Get the query part of a clip command.""" sql = sql.strip() # The reason we can't simply do .strip('\clip') is that it strips characters, # not a substring. So it'll strip "c" in the end of the sql also! pattern = re.compile(r"(^\\clip|\\clip$)") while pattern.search(sql): sql = pattern.sub("", sql) return sql def copy_query_to_clipboard(sql: str | None = None) -> str | None: """Send query to the clipboard.""" sql = sql or "" message = None try: pyperclip.copy(f"{sql}") except RuntimeError as e: message = f"Error clipping query: {e}." return message def set_redirect(command_part: str | None, file_operator_part: str | None, file_part: str | None) -> list[tuple]: if command_part: if file_part: PIPE_ONCE['stdout_file'] = file_part PIPE_ONCE['stdout_mode'] = 'w' if file_operator_part == '>' else 'a' return set_pipe_once(command_part) elif file_operator_part == '>': return set_once(f'-o {file_part}') else: return set_once(file_part) @special_command("\\f", "\\f [name [args..]]", "List or execute favorite queries.", arg_type=ArgType.PARSED_QUERY, case_sensitive=True) def execute_favorite_query(cur: Cursor, arg: str, **_) -> Generator[SQLResult, None, None]: if arg == "": yield from list_favorite_queries() # Parse out favorite name and optional substitution parameters name, _separator, arg_str = arg.partition(" ") args = shlex.split(arg_str) query = FavoriteQueries.instance.get(name) if query is None: message = f"No favorite query: {name}" yield SQLResult(status=message) else: query, arg_error = subst_favorite_query_args(query, args) if query is None: yield SQLResult(status=arg_error) else: for sql in sqlparse.split(query): sql = sql.rstrip(";") preamble = f"> {sql}" if is_show_favorite_query() else None is_special = False for special in SPECIAL_COMMANDS: if sql.lower().startswith(special.lower()): is_special = True break if is_special: for result in special_execute(cur, sql): result.preamble = preamble # special_execute() already returns a SQLResult yield result else: cur.execute(sql) if cur.description: header = [x[0] for x in cur.description] yield SQLResult(preamble=preamble, header=header, rows=cur) else: yield SQLResult(preamble=preamble) def list_favorite_queries() -> list[SQLResult]: """List of all favorite queries.""" header = ["Name", "Query"] rows = [(r, FavoriteQueries.instance.get(r)) for r in FavoriteQueries.instance.list()] if not rows: status = "\nNo favorite queries found." + FavoriteQueries.instance.usage else: status = "" return [SQLResult(header=header, rows=rows, status=status)] def subst_favorite_query_args(query: str, args: list[str]) -> list[str | None]: """replace positional parameters ($1...$N) in query.""" for idx, val in enumerate(args): subst_var = "$" + str(idx + 1) if subst_var not in query: return [None, "query does not have substitution parameter " + subst_var + ":\n " + query] query = query.replace(subst_var, val) match = re.search(r"\$\d+", query) if match: return [None, "missing substitution for " + match.group(0) + " in query:\n " + query] return [query, None] @special_command("\\fs", "\\fs ", "Save a favorite query.") def save_favorite_query(arg: str, **_) -> list[SQLResult]: """Save a new favorite query.""" usage = "Syntax: \\fs name query.\n\n" + FavoriteQueries.instance.usage if not arg: return [SQLResult(status=usage)] name, _separator, query = arg.partition(" ") # If either name or query is missing then print the usage and complain. if (not name) or (not query): return [SQLResult(status=f"{usage} Err: Both name and query are required.")] FavoriteQueries.instance.save(name, query) return [SQLResult(status="Saved.")] @special_command("\\fd", "\\fd ", "Delete a favorite query.") def delete_favorite_query(arg: str, **_) -> list[SQLResult]: """Delete an existing favorite query.""" usage = "Syntax: \\fd name.\n\n" + FavoriteQueries.instance.usage if not arg: return [SQLResult(status=usage)] status = FavoriteQueries.instance.delete(arg) return [SQLResult(status=status)] @special_command("system", "system [-r] ", "Execute a system shell command (raw mode with -r).") def execute_system_command(arg: str, **_) -> list[SQLResult]: """Execute a system shell command.""" usage = "Syntax: system [-r] [command].\n-r denotes \"raw\" mode, in which output is passed through without formatting." IMPLICIT_RAW_MODE_COMMANDS = { 'clear', 'vim', 'vi', 'bash', 'zsh', } if not arg.strip(): return [SQLResult(status=usage)] try: command = shlex.split(arg.strip(), posix=not WIN) except ValueError as e: return [SQLResult(status=f"Cannot parse system command: {e}")] raw = False if command[0] == '-r': command.pop(0) raw = True elif command[0].lower() in IMPLICIT_RAW_MODE_COMMANDS: raw = True if not command: return [SQLResult(status=usage)] if command[0].lower() == 'cd': ok, error_message = handle_cd_command(command) if not ok: return [SQLResult(status=error_message)] return [SQLResult()] try: if raw: completed_process = subprocess.run(command, check=False) if completed_process.returncode: return [SQLResult(status=f'Command exited with return code {completed_process.returncode}')] else: return [SQLResult()] else: process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE) try: output, error = process.communicate(timeout=60) except subprocess.TimeoutExpired: process.kill() output, error = process.communicate() response = output if not error else error encoding = locale.getpreferredencoding(False) response_str = response.decode(encoding) if process.returncode: status = f'Command exited with return code {process.returncode}' else: status = None return [SQLResult(preamble=response_str, status=status)] except OSError as e: return [SQLResult(status=f"OSError: {e.strerror}")] def parseargfile(arg: str) -> tuple[str, str]: if arg.startswith("-o "): mode = "w" filename = arg[3:] else: mode = "a" filename = arg if not filename: raise TypeError("You must provide a filename.") return (os.path.expanduser(filename), mode) @special_command("tee", "tee [-o] ", "Append all results to an output file (overwrite using -o).") def set_tee(arg: str, **_) -> list[SQLResult]: global tee_file try: tee_file = open(*parseargfile(arg)) except (IOError, OSError) as e: raise OSError(f"Cannot write to file '{e.filename}': {e.strerror}") from e return [SQLResult(status="")] def close_tee() -> None: global tee_file if tee_file: tee_file.close() tee_file = None @special_command("notee", "notee", "Stop writing results to an output file.") def no_tee(arg: str, **_) -> list[SQLResult]: close_tee() return [SQLResult(status="")] def write_tee(output: str | ANSI | FormattedText, nl: bool = True) -> None: global tee_file if not tee_file: return click.echo(to_plain_text(output), file=tee_file, nl=False) if nl: click.echo('\n', file=tee_file, nl=False) tee_file.flush() @special_command("\\once", "\\once [-o] ", "Append next result to an output file (overwrite using -o).", aliases=["\\o"]) def set_once(arg: str, **_) -> list[SQLResult]: global once_file, written_to_once_file try: once_file = open(*parseargfile(arg)) except (IOError, OSError) as e: raise OSError(f"Cannot write to file '{e.filename}': {e.strerror}") from e written_to_once_file = False return [SQLResult(status="")] def is_redirected() -> bool: return bool(once_file or PIPE_ONCE['process']) def write_once(output: str) -> None: global once_file, written_to_once_file if output and once_file: click.echo(output, file=once_file, nl=False) click.echo("\n", file=once_file, nl=False) once_file.flush() written_to_once_file = True def unset_once_if_written(post_redirect_command: str) -> None: """Unset the once file, if it has been written to.""" global once_file, written_to_once_file if written_to_once_file and once_file: once_filename = once_file.name once_file.close() once_file = None _run_post_redirect_hook(post_redirect_command, once_filename) def _run_post_redirect_hook(post_redirect_command: str, filename: str) -> None: if not post_redirect_command: return post_cmd = post_redirect_command.format(shlex.quote(filename)) try: subprocess.run( post_cmd, shell=True, check=True, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) except Exception as e: raise OSError(f"Redirect post hook failed: {e}") from e @special_command("\\pipe_once", "\\pipe_once ", "Send next result to a subprocess.", aliases=["\\|"]) def set_pipe_once(arg: str, **_) -> list[SQLResult]: if not arg: raise OSError("pipe_once requires a command") if WIN: # best effort, no chaining pipe_once_cmd = shlex.split(arg) else: # to support chaining pipe_once_cmd = ['sh', '-c', arg] PIPE_ONCE['stdin'] = [] PIPE_ONCE['process'] = subprocess.Popen( pipe_once_cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding="UTF-8", universal_newlines=True, ) return [SQLResult(status="")] def write_pipe_once(line: str) -> None: if line and PIPE_ONCE['process']: PIPE_ONCE['stdin'].append(line) def flush_pipe_once_if_written(post_redirect_command: str) -> None: """Flush the pipe_once cmd, if lines have been written.""" if not PIPE_ONCE['process']: return if not PIPE_ONCE['stdin']: return try: (stdout_data, stderr_data) = PIPE_ONCE['process'].communicate(input='\n'.join(PIPE_ONCE['stdin']) + '\n', timeout=60) except subprocess.TimeoutExpired: PIPE_ONCE['process'].kill() (stdout_data, stderr_data) = PIPE_ONCE['process'].communicate() if stdout_data: if PIPE_ONCE['stdout_file']: with open(PIPE_ONCE['stdout_file'], PIPE_ONCE['stdout_mode']) as f: print(stdout_data, file=f) _run_post_redirect_hook(post_redirect_command, PIPE_ONCE['stdout_file']) else: click.secho(stdout_data.rstrip('\n')) if stderr_data: click.secho(stderr_data.rstrip('\n'), err=True, fg='red') if returncode := PIPE_ONCE['process'].returncode: PIPE_ONCE['process'] = None PIPE_ONCE['stdin'] = [] PIPE_ONCE['stdout_file'] = None PIPE_ONCE['stdout_mode'] = None raise OSError(f'process exited with nonzero code {returncode}') PIPE_ONCE['process'] = None PIPE_ONCE['stdin'] = [] PIPE_ONCE['stdout_file'] = None PIPE_ONCE['stdout_mode'] = None @special_command("watch", "watch [seconds] [-c] ", "Execute query every [seconds] seconds (5 by default).") def watch_query(arg: str, **kwargs) -> Generator[SQLResult, None, None]: usage = """Syntax: watch [seconds] [-c] query. * seconds: The interval at the query will be repeated, in seconds. By default 5. * -c: Clears the screen between every iteration. """ if not arg: yield SQLResult(status=usage) return seconds = 5.0 clear_screen = False statement = None while statement is None: arg = arg.strip() if not arg: # Oops, we parsed all the arguments without finding a statement yield SQLResult(status=usage) return (left_arg, _, right_arg) = arg.partition(" ") arg = right_arg try: seconds = float(left_arg) continue except ValueError: pass if left_arg == "-c": clear_screen = True continue statement = f"{left_arg} {arg}" destructive_prompt = confirm_destructive_query(DESTRUCTIVE_KEYWORDS, statement) if destructive_prompt is False: click.secho("Wise choice!") return elif destructive_prompt is True: click.secho("Your call!") cur = kwargs["cur"] sql_list = [(sql.rstrip(";"), f"> {sql}") for sql in sqlparse.split(statement)] old_pager_enabled = is_pager_enabled() while True: if clear_screen: click.clear() try: # Somewhere in the code the pager its activated after every yield, # so we disable it in every iteration set_pager_enabled(False) for sql, preamble in sql_list: cur.execute(sql) command: dict[str, str | float] = { "name": "watch", "seconds": seconds, } if cur.description: header = [x[0] for x in cur.description] yield SQLResult(preamble=preamble, header=header, rows=cur, command=command) else: yield SQLResult(preamble=preamble, command=command) sleep(seconds) except KeyboardInterrupt: # This prints the Ctrl-C character in its own line, which prevents # to print a line with the cursor positioned behind the prompt click.secho("", nl=True) return finally: set_pager_enabled(old_pager_enabled) @special_command("delimiter", "delimiter ", "Change end-of-statement delimiter.") def set_delimiter(arg: str, **_) -> list[SQLResult]: return delimiter_command.set(arg) def get_current_delimiter() -> str: return delimiter_command.current def split_queries(input_str: str) -> Generator[str, None, None]: yield from delimiter_command.queries_iter(input_str) ================================================ FILE: mycli/packages/special/llm.py ================================================ import contextlib import functools import io import logging import os import re from runpy import run_module import shlex import sys from time import time from typing import Any import click try: if not os.environ.get('MYCLI_LLM_OFF'): import llm LLM_IMPORTED = True else: LLM_IMPORTED = False except ImportError: LLM_IMPORTED = False try: if not os.environ.get('MYCLI_LLM_OFF'): from llm.cli import cli LLM_CLI_IMPORTED = True else: LLM_CLI_IMPORTED = False except ImportError: LLM_CLI_IMPORTED = False from pymysql.cursors import Cursor from mycli.packages.special.main import Verbosity, parse_special_command from mycli.packages.sqlresult import SQLResult log = logging.getLogger(__name__) LLM_TEMPLATE_NAME = "mycli-llm-template" SCHEMA_DATA_CACHE: dict[str, str] = {} SAMPLE_DATA_CACHE: dict[str, dict] = {} def run_external_cmd( cmd: str, *args, capture_output=False, restart_cli=False, raise_exception=True, ) -> tuple[int, str]: original_exe = sys.executable original_args = sys.argv try: sys.argv = [cmd] + list(args) code = 0 if capture_output: buffer = io.StringIO() redirect: contextlib.ExitStack[bool | None] | contextlib.nullcontext[None] = contextlib.ExitStack() assert isinstance(redirect, contextlib.ExitStack) redirect.enter_context(contextlib.redirect_stdout(buffer)) redirect.enter_context(contextlib.redirect_stderr(buffer)) else: redirect = contextlib.nullcontext() with redirect: try: run_module(cmd, run_name="__main__") except SystemExit as e: code = int(e.code or 0) if code != 0 and raise_exception: if capture_output: raise RuntimeError(buffer.getvalue()) from e raise RuntimeError(f"Command {cmd} failed with exit code {code}.") from e except Exception as e: code = 1 if raise_exception: if capture_output: raise RuntimeError(buffer.getvalue()) from e raise RuntimeError(f"Command {cmd} failed: {e}") from e if restart_cli and code == 0: os.execv(original_exe, [original_exe] + original_args) if capture_output: return code, buffer.getvalue() else: return code, "" finally: sys.argv = original_args def _build_command_tree(cmd) -> dict[str, Any] | None: tree: dict[str, Any] | None = {} assert isinstance(tree, dict) if isinstance(cmd, click.Group): for name, subcmd in cmd.commands.items(): if cmd.name == "models" and name == "default": tree[name] = {x.model_id: None for x in llm.get_models()} else: tree[name] = _build_command_tree(subcmd) else: tree = None return tree def build_command_tree(cmd) -> dict[str, Any]: return _build_command_tree(cmd) or {} # Generate the command tree for autocompletion COMMAND_TREE = build_command_tree(cli) if LLM_CLI_IMPORTED is True else {} def get_completions( tokens: list[str], tree: dict[str, Any] | None = None, ) -> list[str]: tree = tree or COMMAND_TREE for token in tokens: if token.startswith("-"): continue if tree and token in tree: tree = tree[token] else: return [] return list(tree.keys()) if tree else [] class FinishIteration(Exception): def __init__(self, results=None): self.results = results USAGE = """ Use an LLM to create SQL queries to answer questions from your database. Examples: # Ask a question. > \\llm 'Most visited urls?' # List available models > \\llm models > gpt-4o > gpt-3.5-turbo # Change default model > \\llm models default llama3 # Set api key (not required for local models) > \\llm keys set openai # Install a model plugin > \\llm install llm-ollama > llm-ollama installed. # Plugins directory # https://llm.datasette.io/en/stable/plugins/directory.html """ NEED_DEPENDENCIES = """ To enable LLM features you need to install mycli with LLM support: pip install 'mycli[llm]' or pip install 'mycli[all]' or install LLM libraries separately pip install llm This is required to use the \\llm command. """ _SQL_CODE_FENCE = r"```sql\n(.*?)\n```" PROMPT = """ You are a helpful assistant who is a MySQL expert. You are embedded in a mysql cli tool called mycli. Answer this question: $question Use the following context if it is relevant to answering the question. If the question is not about the current database then ignore the context. You are connected to a MySQL database with the following schema: $db_schema Here is a sample row of data from each table: $sample_data If the answer can be found using a SQL query, include a sql query in a code fence such as this one: ```sql SELECT count(*) FROM table_name; ``` Keep your explanation concise and focused on the question asked. """ def ensure_mycli_template(replace: bool = False) -> None: if not replace: code, _ = run_external_cmd("llm", "templates", "show", LLM_TEMPLATE_NAME, capture_output=True, raise_exception=False) if code == 0: return run_external_cmd("llm", PROMPT, "--save", LLM_TEMPLATE_NAME) @functools.cache def cli_commands() -> list[str]: return list(cli.commands.keys()) def handle_llm( text: str, cur: Cursor, dbname: str, prompt_field_truncate: int, prompt_section_truncate: int, ) -> tuple[str, str | None, float]: _, verbosity, arg = parse_special_command(text) if not LLM_IMPORTED: raise FinishIteration(results=[SQLResult(preamble=NEED_DEPENDENCIES)]) if arg.strip().lower() in ['', 'help', '?', r'\?']: raise FinishIteration(results=[SQLResult(preamble=USAGE)]) parts = shlex.split(arg) restart = False if "-c" in parts: capture_output = True use_context = False elif "prompt" in parts: capture_output = True use_context = True elif "install" in parts or "uninstall" in parts: capture_output = False use_context = False restart = True elif parts and parts[0] in cli_commands(): capture_output = False use_context = False elif parts and parts[0] == "--help": capture_output = False use_context = False else: capture_output = True use_context = True if not use_context: args = parts if capture_output: click.echo("Calling llm command") start = time() _, output = run_external_cmd("llm", *args, capture_output=capture_output) end = time() match = re.search(_SQL_CODE_FENCE, output, re.DOTALL) if match: sql = match.group(1).strip() else: raise FinishIteration(results=[SQLResult(preamble=output)]) return (output if verbosity == Verbosity.SUCCINCT else "", sql, end - start) else: run_external_cmd("llm", *args, restart_cli=restart) raise FinishIteration(results=None) try: ensure_mycli_template() start = time() context, sql = sql_using_llm( cur=cur, question=arg, dbname=dbname, prompt_field_truncate=prompt_field_truncate, prompt_section_truncate=prompt_section_truncate, ) end = time() if verbosity == Verbosity.SUCCINCT: context = "" return (context, sql, end - start) except Exception as e: raise RuntimeError(e) from e def is_llm_command(command: str) -> bool: cmd, _, _ = parse_special_command(command) return cmd in ("\\llm", "\\ai") def truncate_list_elements(row: list, prompt_field_truncate: int, prompt_section_truncate: int) -> list: if not prompt_section_truncate and not prompt_field_truncate: return row width = prompt_field_truncate while width >= 0: truncated_row = [x[:width] if isinstance(x, (str, bytes)) else x for x in row] if prompt_section_truncate: if sum(sys.getsizeof(x) for x in truncated_row) <= prompt_section_truncate: break width -= 100 else: break return truncated_row def truncate_table_lines(table: list[str], prompt_section_truncate: int) -> list[str]: if not prompt_section_truncate: return table truncated_table = [] running_sum = 0 while table and running_sum <= prompt_section_truncate: line = table.pop(0) running_sum += sys.getsizeof(line) truncated_table.append(line) return truncated_table def get_schema(cur: Cursor, dbname: str, prompt_section_truncate: int) -> str: if dbname in SCHEMA_DATA_CACHE: return SCHEMA_DATA_CACHE[dbname] click.echo("Preparing schema information to feed the LLM") schema_query = f""" SELECT CONCAT(table_name, '(', GROUP_CONCAT(column_name, ' ', COLUMN_TYPE SEPARATOR ', '),')') AS `schema` FROM information_schema.columns WHERE table_schema = '{dbname}' GROUP BY table_name ORDER BY table_name """ cur.execute(schema_query) db_schema = [row for (row,) in cur.fetchall()] summary = '\n'.join(truncate_table_lines(db_schema, prompt_section_truncate)) SCHEMA_DATA_CACHE[dbname] = summary return summary def get_sample_data( cur: Cursor, dbname: str, prompt_field_truncate: int, prompt_section_truncate: int, ) -> dict[str, Any]: if dbname in SAMPLE_DATA_CACHE: return SAMPLE_DATA_CACHE[dbname] click.echo("Preparing sample data to feed the LLM") tables_query = "SHOW TABLES" sample_row_query = "SELECT * FROM `{dbname}`.`{table}` LIMIT 1" cur.execute(tables_query) sample_data = {} for (table_name,) in cur.fetchall(): try: cur.execute(sample_row_query.format(dbname=dbname, table=table_name)) except Exception: continue cols = [desc[0] for desc in cur.description] row = cur.fetchone() if row is None: continue sample_data[table_name] = list( zip(cols, truncate_list_elements(list(row), prompt_field_truncate, prompt_section_truncate), strict=False) ) SAMPLE_DATA_CACHE[dbname] = sample_data return sample_data def sql_using_llm( cur: Cursor | None, question: str | None, dbname: str = '', prompt_field_truncate: int = 0, prompt_section_truncate: int = 0, ) -> tuple[str, str | None]: if cur is None: raise RuntimeError("Connect to a database and try again.") if dbname == '': raise RuntimeError("Choose a schema and try again.") args = [ "--template", LLM_TEMPLATE_NAME, "--param", "db_schema", get_schema(cur, dbname, prompt_section_truncate), "--param", "sample_data", get_sample_data(cur, dbname, prompt_field_truncate, prompt_section_truncate), "--param", "question", question, " ", ] click.echo("Invoking llm command with schema information and sample data") _, result = run_external_cmd("llm", *args, capture_output=True) click.echo("Received response from the llm command") match = re.search(_SQL_CODE_FENCE, result, re.DOTALL) if match: sql = match.group(1).strip() else: sql = "" return (result, sql) ================================================ FILE: mycli/packages/special/main.py ================================================ from collections import namedtuple from enum import Enum import logging import os from typing import Callable import webbrowser from mycli.constants import DOCS_URL, ISSUES_URL from mycli.packages.sqlresult import SQLResult try: if not os.environ.get('MYCLI_LLM_OFF'): import llm # noqa: F401 LLM_IMPORTED = True else: LLM_IMPORTED = False except ImportError: LLM_IMPORTED = False from pymysql.cursors import Cursor logger = logging.getLogger(__name__) COMMANDS = {} SpecialCommand = namedtuple( "SpecialCommand", [ "handler", "command", "usage", "description", "arg_type", "hidden", "case_sensitive", "shortcut", ], ) class ArgType(Enum): NO_QUERY = 0 PARSED_QUERY = 1 RAW_QUERY = 2 class CommandNotFound(Exception): pass class Verbosity(Enum): SUCCINCT = "succinct" NORMAL = "normal" VERBOSE = "verbose" def parse_special_command(sql: str) -> tuple[str, Verbosity, str]: command, _, arg = sql.partition(" ") verbosity = Verbosity.NORMAL if "+" in command: verbosity = Verbosity.VERBOSE elif "-" in command: verbosity = Verbosity.SUCCINCT command = command.strip().strip("+-") return (command, verbosity, arg.strip()) def special_command( command: str, usage: str | None, description: str, arg_type: ArgType = ArgType.PARSED_QUERY, hidden: bool = False, case_sensitive: bool = False, aliases: list[str] | None = None, ) -> Callable: def wrapper(wrapped): register_special_command( wrapped, command, usage, description, arg_type=arg_type, hidden=hidden, case_sensitive=case_sensitive, aliases=aliases, ) return wrapped return wrapper def register_special_command( handler: Callable, command: str, usage: str | None, description: str, arg_type: ArgType = ArgType.PARSED_QUERY, hidden: bool = False, case_sensitive: bool = False, aliases: list[str] | None = None, ) -> None: cmd = command.lower() if not case_sensitive else command COMMANDS[cmd] = SpecialCommand( handler, command, usage, description, arg_type=arg_type, hidden=hidden, case_sensitive=case_sensitive, shortcut=aliases[0] if aliases else None, ) aliases = [] if aliases is None else aliases for alias in aliases: cmd = alias.lower() if not case_sensitive else alias COMMANDS[cmd] = SpecialCommand( handler, command, usage, description, arg_type=arg_type, case_sensitive=case_sensitive, hidden=True, shortcut=None, ) def execute(cur: Cursor, sql: str) -> list[SQLResult]: """Execute a special command and return the results. If the special command is not supported a CommandNotFound will be raised. """ command, verbosity, arg = parse_special_command(sql) if (command not in COMMANDS) and (command.lower() not in COMMANDS): raise CommandNotFound(f'Command not found: {command}') try: special_cmd = COMMANDS[command] except KeyError as exc: special_cmd = COMMANDS[command.lower()] if special_cmd.case_sensitive: raise CommandNotFound(f'Command not found: {command}') from exc # "help is a special case. We want built-in help, not # mycli help here. if command == "help" and arg: return show_keyword_help(cur=cur, arg=arg) if special_cmd.arg_type == ArgType.NO_QUERY: return special_cmd.handler() elif special_cmd.arg_type == ArgType.PARSED_QUERY: return special_cmd.handler(cur=cur, arg=arg, verbose=(verbosity == Verbosity.VERBOSE)) elif special_cmd.arg_type == ArgType.RAW_QUERY: return special_cmd.handler(cur=cur, query=sql) raise CommandNotFound(f"Command type not found: {command}") @special_command( "help", "help [term]", "Show this help, or search for a term on the server.", arg_type=ArgType.NO_QUERY, aliases=["\\?", "?"] ) def show_help(*_args) -> list[SQLResult]: header = ["Command", "Shortcut", "Usage", "Description"] result = [] for _, value in sorted(COMMANDS.items()): if not value.hidden: result.append((value.command, value.shortcut, value.usage, value.description)) return [SQLResult(header=header, rows=result, postamble=f'Docs index — {DOCS_URL}')] def show_keyword_help(cur: Cursor, arg: str) -> list[SQLResult]: """ Call the built-in "show ", to display help for an SQL keyword. :param cur: cursor :param arg: string :return: list """ keyword = arg.strip().strip('"\'') query = 'help %s' logger.debug(query) cur.execute(query, keyword) if cur.description and cur.rowcount > 0: header = [x[0] for x in cur.description] return [SQLResult(header=header, rows=cur)] logger.debug(query) cur.execute(query, (f'%{keyword}%',)) if cur.description and cur.rowcount > 0: header = [x[0] for x in cur.description] return [SQLResult(preamble='Similar terms:', header=header, rows=cur)] else: return [SQLResult(status=f'No help found for "{keyword}".')] @special_command('\\bug', '\\bug', 'File a bug on GitHub.', arg_type=ArgType.NO_QUERY) def file_bug(*_args) -> list[SQLResult]: webbrowser.open_new_tab(ISSUES_URL) return [SQLResult(status=f'{ISSUES_URL} — press "New Issue"')] @special_command("exit", "exit", "Exit.", arg_type=ArgType.NO_QUERY, aliases=["\\q"]) @special_command("quit", "quit", "Quit.", arg_type=ArgType.NO_QUERY, aliases=["\\q"]) def quit_(*_args): raise EOFError @special_command( "\\edit", "\\edit | \\edit ", "Edit query with editor (uses $VISUAL or $EDITOR).", arg_type=ArgType.NO_QUERY, case_sensitive=True, aliases=['\\e'], ) @special_command("\\clip", "\\clip", "Copy query to the system clipboard.", arg_type=ArgType.NO_QUERY, case_sensitive=True) @special_command("\\G", "\\G", "Display query results vertically.", arg_type=ArgType.NO_QUERY, case_sensitive=True) def stub(): raise NotImplementedError if LLM_IMPORTED: @special_command( "\\llm", "\\llm [arguments]", "Interrogate an LLM. See \"\\llm help\".", arg_type=ArgType.RAW_QUERY, case_sensitive=True, aliases=["\\ai"], ) def llm_stub(): raise NotImplementedError ================================================ FILE: mycli/packages/special/utils.py ================================================ import logging import os import click import pymysql from pymysql.cursors import Cursor logger = logging.getLogger(__name__) CACHED_SSL_VERSION: dict[tuple, str | None] = {} def handle_cd_command(command: list[str]) -> tuple[bool, str | None]: """Handles a `cd` shell command by calling python's os.chdir.""" if not command[0].lower() == 'cd': return False, 'Not a cd command.' if len(command) != 2: return False, 'Exactly one directory name must be provided.' directory = command[1] try: os.chdir(directory) click.echo(os.getcwd(), err=True) return True, None except OSError as e: return False, e.strerror def format_uptime(uptime_in_seconds: str) -> str: """Format number of seconds into human-readable string. :param uptime_in_seconds: The server uptime in seconds. :returns: A human-readable string representing the uptime. >>> uptime = format_uptime('56892') >>> print(uptime) 15 hours 48 min 12 sec """ m, s = divmod(int(uptime_in_seconds), 60) h, m = divmod(m, 60) d, h = divmod(h, 24) uptime_values: list[str] = [] for value, unit in ((d, "days"), (h, "hours"), (m, "min"), (s, "sec")): if value == 0 and not uptime_values: # Don't include a value/unit if the unit isn't applicable to # the uptime. E.g. don't do 0 days 0 hours 1 min 30 sec. continue if value == 1 and unit.endswith("s"): # Remove the "s" if the unit is singular. unit = unit[:-1] uptime_values.append(f'{value} {unit}') uptime = " ".join(uptime_values) return uptime def get_uptime(cur: Cursor) -> int: query = 'SHOW STATUS LIKE "Uptime"' logger.debug(query) uptime = 0 try: cur.execute(query) if one := cur.fetchone(): uptime = int(one[1] or 0) except pymysql.err.OperationalError: pass return uptime def get_warning_count(cur: Cursor) -> int: query = 'SHOW COUNT(*) WARNINGS' logger.debug(query) warning_count = 0 try: cur.execute(query) if one := cur.fetchone(): warning_count = int(one[0] or 0) except pymysql.err.OperationalError: pass return warning_count def get_ssl_version(cur: Cursor) -> str | None: cache_key = (id(cur.connection), cur.connection.thread_id()) if cache_key in CACHED_SSL_VERSION: return CACHED_SSL_VERSION[cache_key] or None query = 'SHOW STATUS LIKE "Ssl_version"' logger.debug(query) ssl_version = None try: cur.execute(query) if one := cur.fetchone(): CACHED_SSL_VERSION[cache_key] = one[1] ssl_version = one[1] or None else: CACHED_SSL_VERSION[cache_key] = '' except pymysql.err.OperationalError: pass return ssl_version ================================================ FILE: mycli/packages/sqlresult.py ================================================ from dataclasses import dataclass from functools import cached_property from prompt_toolkit.formatted_text import FormattedText, to_plain_text from pymysql.cursors import Cursor @dataclass class SQLResult: preamble: str | None = None header: list[str] | str | None = None rows: Cursor | list[tuple] | None = None postamble: str | None = None status: str | FormattedText | None = None command: dict[str, str | float] | None = None def __iter__(self): return self def __str__(self): return f"{self.preamble}, {self.header}, {self.rows}, {self.postamble}, {self.status}, {self.command}" @cached_property def status_plain(self): if self.status is None: return None return to_plain_text(self.status) ================================================ FILE: mycli/packages/string_utils.py ================================================ import re from cli_helpers.utils import strip_ansi def sanitize_terminal_title(title: str) -> str: sanitized = strip_ansi(title) sanitized = sanitized.replace('\n', ' ') sanitized = re.sub('[\x00-\x1f\x7f]', '', sanitized) return sanitized ================================================ FILE: mycli/packages/tabular_output/__init__.py ================================================ ================================================ FILE: mycli/packages/tabular_output/sql_format.py ================================================ """Format adapter for sql.""" from __future__ import annotations from typing import Generator, Union from cli_helpers.tabular_output import TabularOutputFormatter from mycli.packages.parseutils import extract_tables_from_complete_statements supported_formats = ( "sql-insert", "sql-update", "sql-update-1", "sql-update-2", ) preprocessors = () formatter: TabularOutputFormatter def escape_for_sql_statement(value: Union[bytes, str]) -> str: if isinstance(value, bytes): return f"0x{value.hex()}" else: return formatter.mycli.sqlexecute.conn.escape(value) def adapter(data: list[str], headers: list[str], table_format: Union[str, None] = None, **kwargs) -> Generator[str, None, None]: tables = extract_tables_from_complete_statements(formatter.query) if len(tables) > 0: table = tables[0] if table[0]: table_name = f'{table[0]}.{table[1]}' else: table_name = table[1] else: table_name = "`DUAL`" if table_format == "sql-insert": h = "`, `".join(headers) yield f'INSERT INTO {table_name} (`{h}`) VALUES' prefix = " " for d in data: values = ", ".join(escape_for_sql_statement(v) for i, v in enumerate(d)) yield f'{prefix}({values})' if prefix == " ": prefix = ", " yield ";" if table_format and table_format.startswith("sql-update"): s = table_format.split("-") keys = 1 if len(s) > 2: keys = int(s[-1]) for d in data: yield f'UPDATE {table_name} SET' prefix = " " for i, v in enumerate(d[keys:], keys): yield f'{prefix}`{headers[i]}` = {escape_for_sql_statement(v)}' if prefix == " ": prefix = ", " f = "`{}` = {}" where = (f.format(headers[i], escape_for_sql_statement(d[i])) for i in range(keys)) yield f'WHERE {" AND ".join(where)};' def register_new_formatter(tof: TabularOutputFormatter): global formatter formatter = tof for sql_format in supported_formats: tof.register_new_formatter(sql_format, adapter, preprocessors, {"table_format": sql_format}) ================================================ FILE: mycli/packages/toolkit/__init__.py ================================================ ================================================ FILE: mycli/packages/toolkit/fzf.py ================================================ import re import shlex from shutil import which from prompt_toolkit import search from prompt_toolkit.key_binding.key_processor import KeyPressEvent from pyfzf import FzfPrompt from mycli.packages.toolkit.history import FileHistoryWithTimestamp from mycli.packages.toolkit.utils import safe_invalidate_display class Fzf(FzfPrompt): def __init__(self): self.executable = which("fzf") if self.executable: super().__init__() def is_available(self) -> bool: return self.executable is not None def search_history( event: KeyPressEvent, highlight_preview: bool = False, highlight_style: str = 'default', incremental: bool = False, ) -> None: buffer = event.current_buffer history = buffer.history fzf = Fzf() if incremental or not fzf.is_available() or not isinstance(history, FileHistoryWithTimestamp): # Fallback to default reverse incremental search search.start_search(direction=search.SearchDirection.BACKWARD) return history_items_with_timestamp = history.load_history_with_timestamp() formatted_history_items = [] original_history_items = [] seen = {} for item, timestamp in history_items_with_timestamp: formatted_item = re.sub(r'\s+', ' ', item) timestamp = timestamp.split(".")[0] if "." in timestamp else timestamp if formatted_item in seen: continue seen[formatted_item] = True formatted_history_items.append(f"{timestamp} {formatted_item}") original_history_items.append(item) options = [ '--info=hidden', '--scheme=history', '--tiebreak=index', '--bind=ctrl-r:up,alt-r:up', '--preview-window=down:wrap:nohidden', '--no-height', ] if highlight_preview and which('pygmentize'): options.append(f'--preview="printf \'%s\' {{}} | pygmentize -l mysql -P style={shlex.quote(highlight_style)}"') else: options.append('--preview="printf \'%s\' {}"') result = fzf.prompt( formatted_history_items, fzf_options=' '.join(options), ) safe_invalidate_display(event.app) if result: selected_index = formatted_history_items.index(result[0]) buffer.text = original_history_items[selected_index] buffer.cursor_position = len(buffer.text) ================================================ FILE: mycli/packages/toolkit/history.py ================================================ import os from typing import Union from prompt_toolkit.history import FileHistory _StrOrBytesPath = Union[str, bytes, os.PathLike] class FileHistoryWithTimestamp(FileHistory): """ :class:`.FileHistory` class that stores all strings in a file with timestamp. """ def __init__(self, filename: _StrOrBytesPath) -> None: self.filename = filename super().__init__(filename) def load_history_with_timestamp(self) -> list[tuple[str, str]]: """ Load history entries along with their timestamps. Returns: list[tuple[str, str]]: A list of tuples where each tuple contains a history entry and its corresponding timestamp. """ history_with_timestamp: list[tuple[str, str]] = [] lines: list[str] = [] timestamp: str = "" def add() -> None: if lines: # Join and drop trailing newline. string = "".join(lines)[:-1] history_with_timestamp.append((string, timestamp)) if os.path.exists(self.filename): with open(self.filename, 'r', encoding='utf-8') as f: for line in f: if line.startswith("#"): # Extract timestamp timestamp = line[2:].strip() elif line.startswith("+"): lines.append(line[1:]) else: add() lines = [] add() return list(reversed(history_with_timestamp)) ================================================ FILE: mycli/packages/toolkit/utils.py ================================================ from prompt_toolkit.application import Application, run_in_terminal def safe_invalidate_display(app: Application) -> None: """ fzf can confuse the terminal/app when certain values are set in environment variable FZF_DEFAULT_OPTS. The same could happen after running other external programs. This function invalidates the prompt_toolkit display, causing a refresh of the prompt message and pending user input, without leading to exceptions at exit time, as the built-in app.invalidate() does. """ def print_empty_string(): app.print_text('') try: run_in_terminal(print_empty_string) except RuntimeError: pass ================================================ FILE: mycli/sqlcompleter.py ================================================ from __future__ import annotations from collections import Counter from enum import IntEnum import logging import re from typing import Any, Collection, Generator, Iterable, Literal from prompt_toolkit.completion import CompleteEvent, Completer, Completion from prompt_toolkit.completion.base import Document from pygments.lexers._mysql_builtins import MYSQL_DATATYPES, MYSQL_FUNCTIONS, MYSQL_KEYWORDS import rapidfuzz from mycli.packages.completion_engine import is_inside_quotes, suggest_type from mycli.packages.filepaths import complete_path, parse_path, suggest_path from mycli.packages.parseutils import extract_columns_from_select, last_word from mycli.packages.special import llm from mycli.packages.special.favoritequeries import FavoriteQueries from mycli.packages.special.main import COMMANDS as SPECIAL_COMMANDS _logger = logging.getLogger(__name__) class Fuzziness(IntEnum): PERFECT = 0 REGEX = 1 UNDER_WORDS = 2 CAMEL_CASE = 3 RAPIDFUZZ = 4 class SQLCompleter(Completer): favorite_keywords = [ 'SELECT', 'FROM', 'WHERE', 'UPDATE', 'DELETE FROM', 'GROUP BY', 'ORDER BY', 'JOIN', 'LEFT JOIN', 'INSERT INTO', 'LIKE', 'LIMIT', 'WITH', 'EXPLAIN', ] keywords_raw = [ x.upper() for x in favorite_keywords + list(MYSQL_DATATYPES) + list(MYSQL_KEYWORDS) + ['ALTER TABLE', 'CHANGE MASTER TO', 'CHARACTER SET', 'FOREIGN KEY'] ] keywords_d = dict.fromkeys(keywords_raw) for x in SPECIAL_COMMANDS: if x.upper() in keywords_d: del keywords_d[x.upper()] keywords = list(keywords_d) tidb_keywords = [ "SELECT", "FROM", "WHERE", "DELETE FROM", "UPDATE", "GROUP BY", "JOIN", "INSERT INTO", "LIKE", "LIMIT", "ACCOUNT", "ACTION", "ADD", "ADDDATE", "ADMIN", "ADVISE", "AFTER", "AGAINST", "AGO", "ALGORITHM", "ALL", "ALTER", "ALWAYS", "ANALYZE", "AND", "ANY", "APPROX_COUNT_DISTINCT", "APPROX_PERCENTILE", "AS", "ASC", "ASCII", "ATTRIBUTES", "AUTO_ID_CACHE", "AUTO_INCREMENT", "AUTO_RANDOM", "AUTO_RANDOM_BASE", "AVG", "AVG_ROW_LENGTH", "BACKEND", "BACKUP", "BACKUPS", "BATCH", "BEGIN", "BERNOULLI", "BETWEEN", "BIGINT", "BINARY", "BINDING", "BINDINGS", "BINDING_CACHE", "BINLOG", "BIT", "BIT_AND", "BIT_OR", "BIT_XOR", "BLOB", "BLOCK", "BOOL", "BOOLEAN", "BOTH", "BOUND", "BRIEF", "BTREE", "BUCKETS", "BUILTINS", "BY", "BYTE", "CACHE", "CALL", "CANCEL", "CAPTURE", "CARDINALITY", "CASCADE", "CASCADED", "CASE", "CAST", "CAUSAL", "CHAIN", "CHANGE", "CHAR", "CHARACTER", "CHARSET", "CHECK", "CHECKPOINT", "CHECKSUM", "CIPHER", "CLEANUP", "CLIENT", "CLIENT_ERRORS_SUMMARY", "CLUSTERED", "CMSKETCH", "COALESCE", "COLLATE", "COLLATION", "COLUMN", "COLUMNS", "COLUMN_FORMAT", "COLUMN_STATS_USAGE", "COMMENT", "COMMIT", "COMMITTED", "COMPACT", "COMPRESSED", "COMPRESSION", "CONCURRENCY", "CONFIG", "CONNECTION", "CONSISTENCY", "CONSISTENT", "CONSTRAINT", "CONSTRAINTS", "CONTEXT", "CONVERT", "COPY", "CORRELATION", "CPU", "CREATE", "CROSS", "CSV_BACKSLASH_ESCAPE", "CSV_DELIMITER", "CSV_HEADER", "CSV_NOT_NULL", "CSV_NULL", "CSV_SEPARATOR", "CSV_TRIM_LAST_SEPARATORS", "CUME_DIST", "CURRENT", "CURRENT_DATE", "CURRENT_ROLE", "CURRENT_TIME", "CURRENT_TIMESTAMP", "CURRENT_USER", "CURTIME", "CYCLE", "DATA", "DATABASE", "DATABASES", "DATE", "DATETIME", "DATE_ADD", "DATE_SUB", "DAY", "DAY_HOUR", "DAY_MICROSECOND", "DAY_MINUTE", "DAY_SECOND", "DDL", "DEALLOCATE", "DECIMAL", "DEFAULT", "DEFINER", "DELAYED", "DELAY_KEY_WRITE", "DENSE_RANK", "DEPENDENCY", "DEPTH", "DESC", "DESCRIBE", "DIRECTORY", "DISABLE", "DISABLED", "DISCARD", "DISK", "DISTINCT", "DISTINCTROW", "DIV", "DO", "DOT", "DOUBLE", "DRAINER", "DROP", "DRY", "DUAL", "DUMP", "DUPLICATE", "DYNAMIC", "ELSE", "ENABLE", "ENABLED", "ENCLOSED", "ENCRYPTION", "END", "ENFORCED", "ENGINE", "ENGINES", "ENUM", "ERROR", "ERRORS", "ESCAPE", "ESCAPED", "EVENT", "EVENTS", "EVOLVE", "EXACT", "EXCEPT", "EXCHANGE", "EXCLUSIVE", "EXECUTE", "EXISTS", "EXPANSION", "EXPIRE", "EXPLAIN", "EXPR_PUSHDOWN_BLACKLIST", "EXTENDED", "EXTRACT", "FALSE", "FAST", "FAULTS", "FETCH", "FIELDS", "FILE", "FIRST", "FIRST_VALUE", "FIXED", "FLASHBACK", "FLOAT", "FLUSH", "FOLLOWER", "FOLLOWERS", "FOLLOWER_CONSTRAINTS", "FOLLOWING", "FOR", "FORCE", "FOREIGN", "FORMAT", "FULL", "FULLTEXT", "FUNCTION", "GENERAL", "GENERATED", "GET_FORMAT", "GLOBAL", "GRANT", "GRANTS", "GROUPS", "GROUP_CONCAT", "HASH", "HAVING", "HELP", "HIGH_PRIORITY", "HISTOGRAM", "HISTOGRAMS_IN_FLIGHT", "HISTORY", "HOSTS", "HOUR", "HOUR_MICROSECOND", "HOUR_MINUTE", "HOUR_SECOND", "IDENTIFIED", "IF", "IGNORE", "IMPORT", "IMPORTS", "IN", "INCREMENT", "INCREMENTAL", "INDEX", "INDEXES", "INFILE", "INNER", "INPLACE", "INSERT_METHOD", "INSTANCE", "INSTANT", "INT", "INT1", "INT2", "INT3", "INT4", "INT8", "INTEGER", "INTERNAL", "INTERSECT", "INTERVAL", "INTO", "INVISIBLE", "INVOKER", "IO", "IPC", "IS", "ISOLATION", "ISSUER", "JOB", "JOBS", "JSON", "JSON_ARRAYAGG", "JSON_OBJECTAGG", "KEY", "KEYS", "KEY_BLOCK_SIZE", "KILL", "LABELS", "LAG", "LANGUAGE", "LAST", "LASTVAL", "LAST_BACKUP", "LAST_VALUE", "LEAD", "LEADER", "LEADER_CONSTRAINTS", "LEADING", "LEARNER", "LEARNERS", "LEARNER_CONSTRAINTS", "LEFT", "LESS", "LEVEL", "LINEAR", "LINES", "LIST", "LOAD", "LOCAL", "LOCALTIME", "LOCALTIMESTAMP", "LOCATION", "LOCK", "LOCKED", "LOGS", "LONG", "LONGBLOB", "LONGTEXT", "LOW_PRIORITY", "MASTER", "MATCH", "MAX", "MAXVALUE", "MAX_CONNECTIONS_PER_HOUR", "MAX_IDXNUM", "MAX_MINUTES", "MAX_QUERIES_PER_HOUR", "MAX_ROWS", "MAX_UPDATES_PER_HOUR", "MAX_USER_CONNECTIONS", "MB", "MEDIUMBLOB", "MEDIUMINT", "MEDIUMTEXT", "MEMORY", "MERGE", "MICROSECOND", "MIN", "MINUTE", "MINUTE_MICROSECOND", "MINUTE_SECOND", "MINVALUE", "MIN_ROWS", "MOD", "MODE", "MODIFY", "MONTH", "NAMES", "NATIONAL", "NATURAL", "NCHAR", "NEVER", "NEXT", "NEXTVAL", "NEXT_ROW_ID", "NO", "NOCACHE", "NOCYCLE", "NODEGROUP", "NODE_ID", "NODE_STATE", "NOMAXVALUE", "NOMINVALUE", "NONCLUSTERED", "NONE", "NORMAL", "NOT", "NOW", "NOWAIT", "NO_WRITE_TO_BINLOG", "NTH_VALUE", "NTILE", "NULL", "NULLS", "NUMERIC", "NVARCHAR", "OF", "OFF", "OFFSET", "ON", "ONLINE", "ONLY", "ON_DUPLICATE", "OPEN", "OPTIMISTIC", "OPTIMIZE", "OPTION", "OPTIONAL", "OPTIONALLY", "OPT_RULE_BLACKLIST", "OR", "ORDER", "OUTER", "OUTFILE", "OVER", "PACK_KEYS", "PAGE", "PARSER", "PARTIAL", "PARTITION", "PARTITIONING", "PARTITIONS", "PASSWORD", "PERCENT", "PERCENT_RANK", "PER_DB", "PER_TABLE", "PESSIMISTIC", "PLACEMENT", "PLAN", "PLAN_CACHE", "PLUGINS", "POLICY", "POSITION", "PRECEDING", "PRECISION", "PREDICATE", "PREPARE", "PRESERVE", "PRE_SPLIT_REGIONS", "PRIMARY", "PRIMARY_REGION", "PRIVILEGES", "PROCEDURE", "PROCESS", "PROCESSLIST", "PROFILE", "PROFILES", "PROXY", "PUMP", "PURGE", "QUARTER", "QUERIES", "QUERY", "QUICK", "RANGE", "RANK", "RATE_LIMIT", "READ", "REAL", "REBUILD", "RECENT", "RECOVER", "RECURSIVE", "REDUNDANT", "REFERENCES", "REGEXP", "REGION", "REGIONS", "RELEASE", "RELOAD", "REMOVE", "RENAME", "REORGANIZE", "REPAIR", "REPEAT", "REPEATABLE", "REPLACE", "REPLAYER", "REPLICA", "REPLICAS", "REPLICATION", "REQUIRE", "REQUIRED", "RESET", "RESPECT", "RESTART", "RESTORE", "RESTORES", "RESTRICT", "RESUME", "REVERSE", "REVOKE", "RIGHT", "RLIKE", "ROLE", "ROLLBACK", "ROUTINE", "ROW", "ROWS", "ROW_COUNT", "ROW_FORMAT", "ROW_NUMBER", "RTREE", "RUN", "RUNNING", "S3", "SAMPLERATE", "SAMPLES", "SAN", "SAVEPOINT", "SCHEDULE", "SECOND", "SECONDARY_ENGINE", "SECONDARY_LOAD", "SECONDARY_UNLOAD", "SECOND_MICROSECOND", "SECURITY", "SEND_CREDENTIALS_TO_TIKV", "SEPARATOR", "SEQUENCE", "SERIAL", "SERIALIZABLE", "SESSION", "SESSION_STATES", "SET", "SETVAL", "SHARD_ROW_ID_BITS", "SHARE", "SHARED", "SHOW", "SHUTDOWN", "SIGNED", "SIMPLE", "SKIP", "SKIP_SCHEMA_FILES", "SLAVE", "SLOW", "SMALLINT", "SNAPSHOT", "SOME", "SOURCE", "SPATIAL", "SPLIT", "SQL", "SQL_BIG_RESULT", "SQL_BUFFER_RESULT", "SQL_CACHE", "SQL_CALC_FOUND_ROWS", "SQL_NO_CACHE", "SQL_SMALL_RESULT", "SQL_TSI_DAY", "SQL_TSI_HOUR", "SQL_TSI_MINUTE", "SQL_TSI_MONTH", "SQL_TSI_QUARTER", "SQL_TSI_SECOND", "SQL_TSI_WEEK", "SQL_TSI_YEAR", "SSL", "STALENESS", "START", "STARTING", "STATISTICS", "STATS", "STATS_AUTO_RECALC", "STATS_BUCKETS", "STATS_COL_CHOICE", "STATS_COL_LIST", "STATS_EXTENDED", "STATS_HEALTHY", "STATS_HISTOGRAMS", "STATS_META", "STATS_OPTIONS", "STATS_PERSISTENT", "STATS_SAMPLE_PAGES", "STATS_SAMPLE_RATE", "STATS_TOPN", "STATUS", "STD", "STDDEV", "STDDEV_POP", "STDDEV_SAMP", "STOP", "STORAGE", "STORED", "STRAIGHT_JOIN", "STRICT", "STRICT_FORMAT", "STRONG", "SUBDATE", "SUBJECT", "SUBPARTITION", "SUBPARTITIONS", "SUBSTRING", "SUM", "SUPER", "SWAPS", "SWITCHES", "SYSTEM", "SYSTEM_TIME", "TABLE", "TABLES", "TABLESAMPLE", "TABLESPACE", "TABLE_CHECKSUM", "TARGET", "TELEMETRY", "TELEMETRY_ID", "TEMPORARY", "TEMPTABLE", "TERMINATED", "TEXT", "THAN", "THEN", "TIDB", "TIFLASH", "TIKV_IMPORTER", "TIME", "TIMESTAMP", "TIMESTAMPADD", "TIMESTAMPDIFF", "TINYBLOB", "TINYINT", "TINYTEXT", "TLS", "TO", "TOKUDB_DEFAULT", "TOKUDB_FAST", "TOKUDB_LZMA", "TOKUDB_QUICKLZ", "TOKUDB_SMALL", "TOKUDB_SNAPPY", "TOKUDB_UNCOMPRESSED", "TOKUDB_ZLIB", "TOP", "TOPN", "TRACE", "TRADITIONAL", "TRAILING", "TRANSACTION", "TRIGGER", "TRIGGERS", "TRIM", "TRUE", "TRUE_CARD_COST", "TRUNCATE", "TYPE", "UNBOUNDED", "UNCOMMITTED", "UNDEFINED", "UNICODE", "UNION", "UNIQUE", "UNKNOWN", "UNLOCK", "UNSIGNED", "USAGE", "USE", "USER", "USING", "UTC_DATE", "UTC_TIME", "UTC_TIMESTAMP", "VALIDATION", "VALUE", "VALUES", "VARBINARY", "VARCHAR", "VARCHARACTER", "VARIABLES", "VARIANCE", "VARYING", "VAR_POP", "VAR_SAMP", "VERBOSE", "VIEW", "VIRTUAL", "VISIBLE", "VOTER", "VOTERS", "VOTER_CONSTRAINTS", "WAIT", "WARNINGS", "WEEK", "WEIGHT_STRING", "WHEN", "WIDTH", "WINDOW", "WITH", "WITHOUT", "WRITE", "X509", "XOR", "YEAR", "YEAR_MONTH", "ZEROFILL", ] # misclassified as keywords # do they need to also be subtracted from keywords? pygments_misclassified_functions = [ 'ASCII', 'AVG', 'CHARSET', 'COALESCE', 'COLLATION', 'CONVERT', 'CUME_DIST', 'CURRENT_DATE', 'CURRENT_TIME', 'CURRENT_TIMESTAMP', 'CURRENT_USER', 'DATABASE', 'DAY', 'DEFAULT', 'DENSE_RANK', 'EXISTS', 'FIRST_VALUE', 'FORMAT', 'GEOMCOLLECTION', 'GET_FORMAT', 'GROUPING', 'HOUR', 'IF', 'INSERT', 'INTERVAL', 'JSON_TABLE', 'JSON_VALUE', 'LAG', 'LAST_VALUE', 'LEAD', 'LEFT', 'LOCALTIME', 'LOCALTIMESTAMP', 'MATCH', 'MICROSECOND', 'MINUTE', 'MOD', 'MONTH', 'NTH_VALUE', 'NTILE', 'PERCENT_RANK', 'QUARTER', 'RANK', 'REPEAT', 'REPLACE', 'REVERSE', 'RIGHT', 'ROW_COUNT', 'ROW_NUMBER', 'SCHEMA', 'SECOND', 'TIMESTAMPADD', 'TIMESTAMPDIFF', 'TRUNCATE', 'USER', 'UTC_DATE', 'UTC_TIME', 'UTC_TIMESTAMP', 'VALUES', 'WEEK', 'WEIGHT_STRING', ] # should case be respected for functions styled as CamelCase? pygments_missing_functions = [ 'BINARY', # deprecated function, but available everywhere 'CHAR', 'DATE', 'DISTANCE', 'ETAG', 'GeometryCollection', 'JSON_DUALITY_OBJECT', 'LineString', 'MultiLineString', 'MultiPoint', 'MultiPolygon', 'Point', 'Polygon', 'STRING_TO_VECTOR', 'TIME', 'TIMESTAMP', 'VECTOR_DIM', 'VECTOR_TO_STRING', 'YEAR', ] # so far an incomplete list # these should be spun out and completed independently from functions in the value position pygments_value_position_nonfunction_keywords = [ 'BETWEEN', 'CASE', 'DISTINCT', 'FALSE', 'NOT', 'NULL', 'TRUE', ] # should https://dev.mysql.com/doc/refman/9.6/en/loadable-function-reference.html also be added? pygments_functions_supplemented = sorted( [x.upper() for x in MYSQL_FUNCTIONS] + [x.upper() for x in pygments_misclassified_functions] + [x.upper() for x in pygments_missing_functions] + [x.upper() for x in pygments_value_position_nonfunction_keywords] ) favorite_functions = [ 'COUNT', 'CONVERT', 'BINARY', 'CAST', 'COALESCE', 'MAX', 'MIN', 'SUM', 'AVG', 'JSON_EXTRACT', 'JSON_VALUE', 'JSON_REMOVE', 'JSON_SET', 'CONCAT', 'GROUP_CONCAT', 'CHAR_LENGTH', 'ROUND', 'FLOOR', 'CEIL', 'IF', 'IFNULL', 'SUBSTR', 'SUBSTRING_INDEX', 'REPLACE', 'RIGHT', 'LEFT', 'UNIX_TIMESTAMP', 'FROM_UNIXTIME', 'RAND', 'DATEDIFF', 'DATE_SUB', ] functions_raw = favorite_functions + pygments_functions_supplemented functions = list(dict.fromkeys(functions_raw)) # https://docs.pingcap.com/tidb/dev/tidb-functions tidb_functions = [ "TIDB_BOUNDED_STALENESS", "TIDB_DECODE_KEY", "TIDB_DECODE_PLAN", "TIDB_IS_DDL_OWNER", "TIDB_PARSE_TSO", "TIDB_VERSION", "TIDB_DECODE_SQL_DIGESTS", "VITESS_HASH", "TIDB_SHARD", ] show_items: list[Completion] = [] change_items = [ "MASTER_BIND", "MASTER_HOST", "MASTER_USER", "MASTER_PASSWORD", "MASTER_PORT", "MASTER_CONNECT_RETRY", "MASTER_HEARTBEAT_PERIOD", "MASTER_LOG_FILE", "MASTER_LOG_POS", "RELAY_LOG_FILE", "RELAY_LOG_POS", "MASTER_SSL", "MASTER_SSL_CA", "MASTER_SSL_CAPATH", "MASTER_SSL_CERT", "MASTER_SSL_KEY", "MASTER_SSL_CIPHER", "MASTER_SSL_VERIFY_SERVER_CERT", "IGNORE_SERVER_IDS", ] users: list[str] = [] character_sets: list[str] = [] collations: list[str] = [] def __init__( self, smart_completion: bool = True, supported_formats: tuple = (), keyword_casing: str = "auto", ) -> None: super(self.__class__, self).__init__() self.smart_completion = smart_completion self.reserved_words = set() for x in self.keywords: self.reserved_words.update(x.split()) self.name_pattern = re.compile(r"^[_a-zA-Z][_a-zA-Z0-9\$]*$") self.special_commands: list[str] = [] self.table_formats = supported_formats if keyword_casing not in ("upper", "lower", "auto"): keyword_casing = "auto" self.keyword_casing = keyword_casing self.reset_completions() def escape_name(self, name: str) -> str: if name and ((not self.name_pattern.match(name)) or (name.upper() in self.reserved_words) or (name.upper() in self.functions)): name = f'`{name}`' return name def escaped_names(self, names: Collection[str]) -> list[str]: return [self.escape_name(name) for name in names] def extend_special_commands(self, special_commands: list[str]) -> None: # Special commands are not part of all_completions since they can only # be at the beginning of a line. self.special_commands.extend(special_commands) def extend_database_names(self, databases: list[str]) -> None: self.databases.extend([self.escape_name(db) for db in databases]) def extend_keywords(self, keywords: list[str], replace: bool = False) -> None: if replace: self.keywords = keywords else: self.keywords.extend(keywords) self.all_completions.update(keywords) def extend_show_items(self, show_items: Iterable[tuple]) -> None: for show_item in show_items: self.show_items.extend(show_item) self.all_completions.update(show_item) def extend_change_items(self, change_items: Iterable[tuple]) -> None: for change_item in change_items: self.change_items.extend(change_item) self.all_completions.update(change_item) def extend_users(self, users: Iterable[tuple]) -> None: for user in users: self.users.extend(user) self.all_completions.update(user) def extend_schemata(self, schema: str | None) -> None: if schema is None: return metadata = self.dbmetadata["tables"] metadata[schema] = {} # dbmetadata.values() are the 'tables' and 'functions' dicts for metadata in self.dbmetadata.values(): metadata[schema] = {} self.all_completions.update(schema) def extend_relations(self, data: list[tuple[str, str]], kind: Literal['tables', 'views']) -> None: """Extend metadata for tables or views :param data: list of (rel_name, ) tuples :param kind: either 'tables' or 'views' :return: """ data_ll = [self.escaped_names(d) for d in data] # dbmetadata['tables'][$schema_name][$table_name] should be a list of # column names. Default to an asterisk metadata = self.dbmetadata[kind] for relname in data_ll: try: metadata[self.dbname][relname[0]] = ["*"] except KeyError: _logger.error("%r %r listed in unrecognized schema %r", kind, relname[0], self.dbname) self.all_completions.add(relname[0]) def extend_columns(self, column_data: list[tuple[str, str]], kind: Literal['tables', 'views']) -> None: """Extend column metadata :param column_data: list of (rel_name, column_name) tuples :param kind: either 'tables' or 'views' :return: """ column_data_ll = [self.escaped_names(d) for d in column_data] metadata = self.dbmetadata[kind] for relname, column in column_data_ll: if relname not in metadata[self.dbname]: _logger.error("relname '%s' was not found in db '%s'", relname, self.dbname) # this could happen back when the completer populated via two calls: # SHOW TABLES then SELECT table_name, column_name from information_schema.columns # it's a slight race, but much more likely on Vitess picking random shards for each. # see discussion in https://github.com/dbcli/mycli/pull/1182 (tl;dr - let's keep it) continue metadata[self.dbname][relname].append(column) self.all_completions.add(column) def extend_enum_values(self, enum_data: Iterable[tuple[str, str, list[str]]]) -> None: metadata = self.dbmetadata["enum_values"] if self.dbname not in metadata: metadata[self.dbname] = {} for relname, column, values in enum_data: relname_escaped = self.escape_name(relname) column_escaped = self.escape_name(column) table_meta = metadata[self.dbname].setdefault(relname_escaped, {}) table_meta[column_escaped] = values def extend_functions(self, func_data: list[str] | Generator[tuple[str, str]], builtin: bool = False) -> None: # if 'builtin' is set this is extending the list of builtin functions if builtin: if isinstance(func_data, list): self.functions.extend(func_data) return # 'func_data' is a generator object. It can throw an exception while # being consumed. This could happen if the user has launched the app # without specifying a database name. This exception must be handled to # prevent crashing. try: func_data_ll = [self.escaped_names(d) for d in func_data] except Exception: func_data_ll = [] # dbmetadata['functions'][$schema_name][$function_name] should return # function metadata. metadata = self.dbmetadata["functions"] for func in func_data_ll: metadata[self.dbname][func[0]] = None self.all_completions.add(func[0]) def extend_procedures(self, procedure_data: Generator[tuple]) -> None: metadata = self.dbmetadata["procedures"] if self.dbname not in metadata: metadata[self.dbname] = {} for elt in procedure_data: # not sure why this happens on MariaDB in some cases # see https://github.com/dbcli/mycli/issues/1531 if not elt: continue if not elt[0]: continue metadata[self.dbname][elt[0]] = None def extend_character_sets(self, character_set_data: Generator[tuple]) -> None: for elt in character_set_data: if not elt: continue if not elt[0]: continue self.character_sets.append(elt[0]) self.all_completions.update(elt[0]) def extend_collations(self, collation_data: Generator[tuple]) -> None: for elt in collation_data: if not elt: continue if not elt[0]: continue self.collations.append(elt[0]) self.all_completions.update(elt[0]) def set_dbname(self, dbname: str | None) -> None: self.dbname = dbname or '' def reset_completions(self) -> None: self.databases: list[str] = [] self.users: list[str] = [] self.character_sets: list[str] = [] self.collations: list[str] = [] self.show_items: list[Completion] = [] self.dbname = "" self.dbmetadata: dict[str, Any] = { "tables": {}, "views": {}, "functions": {}, "procedures": {}, "enum_values": {}, } self.all_completions = set(self.keywords + self.functions) @staticmethod def find_matches( orig_text: str, collection: Collection, start_only: bool = False, fuzzy: bool = True, casing: str | None = None, text_before_cursor: str = '', ) -> Generator[tuple[str, int], None, None]: """Find completion matches for the given text. Given the user's input text and a collection of available completions, find completions matching the last word of the text. If `start_only` is True, the text will match an available completion only at the beginning. Otherwise, a completion is considered a match if the text appears anywhere within it. yields prompt_toolkit Completion instances for any matches found in the collection of available completions. """ last = last_word(orig_text, include="most_punctuations") text = last.lower() # unicode support not possible without adding the regex dependency case_change_pat = re.compile("(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])") completions: list[tuple[str, int]] = [] def maybe_quote_identifier(item: str) -> str: if item.startswith('`'): return item if item == '*': return item return '`' + item + '`' # checking text.startswith() first is an optimization; is_inside_quotes() covers more cases if text.startswith('`') or is_inside_quotes(text_before_cursor, len(text_before_cursor)) == 'backtick': quoted_collection: Collection[Any] = [maybe_quote_identifier(x) if isinstance(x, str) else x for x in collection] else: quoted_collection = collection if fuzzy: regex = ".{0,3}?".join(map(re.escape, text)) pat = re.compile(f'({regex})') under_words_text = [x for x in text.split('_') if x] case_words_text = re.split(case_change_pat, last) for item in quoted_collection: r = pat.search(item.lower()) if r: completions.append((item, Fuzziness.REGEX)) continue under_words_item = [x for x in item.lower().split('_') if x] occurrences = 0 for elt_word in under_words_text: for elt_item in under_words_item: if elt_item.startswith(elt_word): occurrences += 1 break if occurrences >= len(under_words_text): completions.append((item, Fuzziness.UNDER_WORDS)) continue case_words_item = re.split(case_change_pat, item) occurrences = 0 for elt_word in case_words_text: for elt_item in case_words_item: if elt_item.startswith(elt_word): occurrences += 1 break if occurrences >= len(case_words_text): completions.append((item, Fuzziness.CAMEL_CASE)) continue if len(text) >= 4: rapidfuzz_matches = rapidfuzz.process.extract( text, quoted_collection, scorer=rapidfuzz.fuzz.WRatio, # todo: maybe make our own processor which only does case-folding # because underscores are valuable info processor=rapidfuzz.utils.default_process, limit=20, score_cutoff=75, ) for elt in rapidfuzz_matches: item, _score, _type = elt if len(item) < len(text) / 1.5: continue if item in completions: continue completions.append((item, Fuzziness.RAPIDFUZZ)) else: match_end_limit = len(text) if start_only else None for item in quoted_collection: match_point = item.lower().find(text, 0, match_end_limit) if match_point >= 0: completions.append((item, Fuzziness.PERFECT)) if casing == "auto": casing = "lower" if last and (last[0].islower() or last[-1].islower()) else "upper" def apply_case(tup: tuple[str, int]) -> tuple[str, int]: kw, fuzziness = tup if casing == "upper": return (kw.upper(), fuzziness) return (kw.lower(), fuzziness) return (x if casing is None else apply_case(x) for x in completions) def get_completions( self, document: Document, complete_event: CompleteEvent | None, smart_completion: bool | None = None, ) -> Iterable[Completion]: word_before_cursor = document.get_word_before_cursor(WORD=True) last_for_len = last_word(word_before_cursor, include="most_punctuations") text_for_len = last_for_len.lower() last_for_len_paths = last_word(word_before_cursor, include='alphanum_underscore') if smart_completion is None: smart_completion = self.smart_completion # If smart_completion is off then match any word that starts with # 'word_before_cursor'. if not smart_completion: matches = self.find_matches( word_before_cursor, self.all_completions, start_only=True, fuzzy=False, text_before_cursor=document.text_before_cursor, ) return (Completion(x[0], -len(text_for_len)) for x in matches) completions: list[tuple[str, int, int]] = [] suggestions = suggest_type(document.text, document.text_before_cursor) rigid_sort = False length_based_on_path = False rank = 0 for suggestion in suggestions: _logger.debug("Suggestion type: %r", suggestion["type"]) rank += 1 if suggestion["type"] == "column": tables = suggestion["tables"] _logger.debug("Completion column scope: %r", tables) scoped_cols = self.populate_scoped_cols(tables) if suggestion.get("drop_unique"): # drop_unique is used for 'tb11 JOIN tbl2 USING (...' # which should suggest only columns that appear in more than # one table scoped_cols = [col for (col, count) in Counter(scoped_cols).items() if count > 1 and col != "*"] elif not tables: # if tables was empty, this is a naked SELECT and we are # showing all columns. So make them unique and sort them. scoped_cols = sorted(set(scoped_cols), key=lambda s: s.strip('`')) cols = self.find_matches( word_before_cursor, scoped_cols, text_before_cursor=document.text_before_cursor, ) completions.extend([(*x, rank) for x in cols]) elif suggestion["type"] == "function": # suggest user-defined functions using substring matching funcs = self.populate_schema_objects(suggestion["schema"], "functions") user_funcs = self.find_matches( word_before_cursor, funcs, text_before_cursor=document.text_before_cursor, ) completions.extend([(*x, rank) for x in user_funcs]) # suggest hardcoded functions using startswith matching only if # there is no schema qualifier. If a schema qualifier is # present it probably denotes a table. # eg: SELECT * FROM users u WHERE u. if not suggestion["schema"]: predefined_funcs = self.find_matches( word_before_cursor, self.functions, start_only=True, fuzzy=False, casing=self.keyword_casing, text_before_cursor=document.text_before_cursor, ) completions.extend([(*x, rank) for x in predefined_funcs]) elif suggestion["type"] == "procedure": procs = self.populate_schema_objects(suggestion["schema"], "procedures") procs_m = self.find_matches( word_before_cursor, procs, text_before_cursor=document.text_before_cursor, ) completions.extend([(*x, rank) for x in procs_m]) elif suggestion['type'] == 'introducer': introducers = [f'_{x}' for x in self.character_sets] introducers_m = self.find_matches( word_before_cursor, introducers, text_before_cursor=document.text_before_cursor, ) completions.extend([(*x, rank) for x in introducers_m]) elif suggestion['type'] == 'character_set': charsets_m = self.find_matches( word_before_cursor, self.character_sets, text_before_cursor=document.text_before_cursor, ) completions.extend([(*x, rank) for x in charsets_m]) elif suggestion['type'] == 'collation': collations_m = self.find_matches( word_before_cursor, self.collations, text_before_cursor=document.text_before_cursor, ) completions.extend([(*x, rank) for x in collations_m]) elif suggestion["type"] == "table": # If this is a select and columns are given, parse the columns and # then only return tables that have one or more of the given columns. # If no columns are given (or able to be parsed), return all tables # as usual. columns = extract_columns_from_select(document.text) if columns: tables = self.populate_schema_objects(suggestion["schema"], "tables", columns) else: tables = self.populate_schema_objects(suggestion["schema"], "tables") tables_m = self.find_matches( word_before_cursor, tables, text_before_cursor=document.text_before_cursor, ) completions.extend([(*x, rank) for x in tables_m]) elif suggestion["type"] == "view": views = self.populate_schema_objects(suggestion["schema"], "views") views_m = self.find_matches( word_before_cursor, views, text_before_cursor=document.text_before_cursor, ) completions.extend([(*x, rank) for x in views_m]) elif suggestion["type"] == "alias": aliases = suggestion["aliases"] aliases_m = self.find_matches( word_before_cursor, aliases, text_before_cursor=document.text_before_cursor, ) completions.extend([(*x, rank) for x in aliases_m]) elif suggestion["type"] == "database": dbs_m = self.find_matches( word_before_cursor, self.databases, text_before_cursor=document.text_before_cursor, ) completions.extend([(*x, rank) for x in dbs_m]) elif suggestion["type"] == "keyword": keywords_m = self.find_matches( word_before_cursor, self.keywords, casing=self.keyword_casing, text_before_cursor=document.text_before_cursor, ) completions.extend([(*x, rank) for x in keywords_m]) elif suggestion["type"] == "show": show_items_m = self.find_matches( word_before_cursor, self.show_items, start_only=False, fuzzy=True, casing=self.keyword_casing, text_before_cursor=document.text_before_cursor, ) completions.extend([(*x, rank) for x in show_items_m]) elif suggestion["type"] == "change": change_items_m = self.find_matches( word_before_cursor, self.change_items, start_only=False, fuzzy=True, text_before_cursor=document.text_before_cursor, ) completions.extend([(*x, rank) for x in change_items_m]) elif suggestion["type"] == "user": users_m = self.find_matches( word_before_cursor, self.users, start_only=False, fuzzy=True, text_before_cursor=document.text_before_cursor, ) completions.extend([(*x, rank) for x in users_m]) elif suggestion["type"] == "special": special_m = self.find_matches( word_before_cursor, self.special_commands, start_only=True, fuzzy=False, text_before_cursor=document.text_before_cursor, ) # specials are special, and go early in the candidates, first if possible completions.extend([(*x, 0) for x in special_m]) elif suggestion["type"] == "favoritequery": if hasattr(FavoriteQueries, 'instance') and hasattr(FavoriteQueries.instance, 'list'): queries_m = self.find_matches( word_before_cursor, FavoriteQueries.instance.list(), start_only=False, fuzzy=True, text_before_cursor=document.text_before_cursor, ) completions.extend([(*x, rank) for x in queries_m]) elif suggestion["type"] == "table_format": formats_m = self.find_matches( word_before_cursor, self.table_formats, text_before_cursor=document.text_before_cursor, ) completions.extend([(*x, rank) for x in formats_m]) elif suggestion["type"] == "file_name": file_names_m = self.find_files(word_before_cursor) completions.extend([(*x, rank) for x in file_names_m]) # for filenames we _really_ want directories to go last rigid_sort = True length_based_on_path = True elif suggestion["type"] == "llm": if not word_before_cursor: tokens = document.text.split()[1:] else: tokens = document.text.split()[1:-1] possible_entries = llm.get_completions(tokens) subcommands_m = self.find_matches( word_before_cursor, possible_entries, start_only=False, fuzzy=True, text_before_cursor=document.text_before_cursor, ) completions.extend([(*x, rank) for x in subcommands_m]) elif suggestion["type"] == "enum_value": enum_values = self.populate_enum_values( suggestion["tables"], suggestion["column"], suggestion.get("parent"), ) if enum_values: quoted_values = [self._quote_sql_string(value) for value in enum_values] completions = [ (*x, rank) for x in self.find_matches( word_before_cursor, quoted_values, text_before_cursor=document.text_before_cursor, ) ] break def completion_sort_key(item: tuple[str, int, int], text_for_len: str): candidate, fuzziness, rank = item if not text_for_len: # sort only by the rank (the order of the completion type) return (0, rank, 0) elif candidate.lower().startswith(text_for_len): # sort only by the length of the candidate return (0, 0, -1000 + len(candidate)) # sort by fuzziness and rank # todo add alpha here, or original order? return (fuzziness, rank, 0) if rigid_sort: uniq_completions_str = dict.fromkeys(x[0] for x in completions) else: sorted_completions = sorted(completions, key=lambda item: completion_sort_key(item, text_for_len.lower())) uniq_completions_str = dict.fromkeys(x[0] for x in sorted_completions) if length_based_on_path: return (Completion(x, -len(last_for_len_paths)) for x in uniq_completions_str) else: return (Completion(x, -len(text_for_len)) for x in uniq_completions_str) def find_files(self, word: str) -> Generator[tuple[str, int], None, None]: """Yield matching directory or file names. :param word: :return: iterable """ # todo position is ignored, but may need to be used # todo fuzzy matches for filenames base_path, last_path, position = parse_path(word) paths = suggest_path(word) for name in paths: suggestion = complete_path(name, last_path) if suggestion: yield (suggestion, Fuzziness.PERFECT) def populate_scoped_cols(self, scoped_tbls: list[tuple[str | None, str, str | None]]) -> list[str]: """Find all columns in a set of scoped_tables :param scoped_tbls: list of (schema, table, alias) tuples :return: list of column names """ columns = [] meta = self.dbmetadata # if scoped tables is empty, this is just after a SELECT so we # show all columns for all tables in the schema. if len(scoped_tbls) == 0 and self.dbname: for table in meta["tables"][self.dbname]: columns.extend(meta["tables"][self.dbname][table]) return columns or ['*'] # query includes tables, so use those to populate columns for tbl in scoped_tbls: # A fully qualified schema.relname reference or default_schema # DO NOT escape schema names. schema = tbl[0] or self.dbname relname = tbl[1] escaped_relname = self.escape_name(tbl[1]) # We don't know if schema.relname is a table or view. Since # tables and views cannot share the same name, we can check one # at a time try: columns.extend(meta["tables"][schema][relname]) # Table exists, so don't bother checking for a view continue except KeyError: try: columns.extend(meta["tables"][schema][escaped_relname]) # Table exists, so don't bother checking for a view continue except KeyError: pass try: columns.extend(meta["views"][schema][relname]) except KeyError: pass return columns def populate_enum_values( self, scoped_tbls: list[tuple[str | None, str, str | None]], column: str, parent: str | None = None, ) -> list[str]: values: list[str] = [] meta = self.dbmetadata["enum_values"] column_key = self._escape_identifier(column) parent_key = self._strip_backticks(parent) if parent else None for schema, relname, alias in scoped_tbls: if parent_key and not self._matches_parent(parent_key, schema, relname, alias): continue schema = schema or self.dbname table_meta = meta.get(schema, {}) escaped_relname = self.escape_name(relname) for rel_key in {relname, escaped_relname}: columns = table_meta.get(rel_key) if columns and column_key in columns: values.extend(columns[column_key]) return list(dict.fromkeys(values)) def _escape_identifier(self, name: str) -> str: return self.escape_name(self._strip_backticks(name)) @staticmethod def _strip_backticks(name: str | None) -> str: if name and name[0] == "`" and name[-1] == "`": return name[1:-1] return name or "" @staticmethod def _matches_parent(parent: str, schema: str | None, relname: str, alias: str | None) -> bool: if alias and parent == alias: return True if parent == relname: return True if schema and parent == f"{schema}.{relname}": return True return False @staticmethod def _quote_sql_string(value: str) -> str: return "'" + value.replace("'", "''") + "'" def populate_schema_objects(self, schema: str | None, obj_type: str, columns: list[str] | None = None) -> list[str]: """Returns list of tables or functions for a (optional) schema""" metadata = self.dbmetadata[obj_type] schema = schema or self.dbname try: objects = list(metadata[schema].keys()) except KeyError: # schema doesn't exist objects = [] filtered_objects: list[str] = [] remaining_objects: list[str] = [] # If the requested object type is tables and the user already entered # columns, return a filtered list of tables (or views) that contain # one or more of the given columns. If a table does not contain the # given columns, add it to a separate list to add to the end of the # filtered suggestions. if obj_type == "tables" and columns and objects: for obj in objects: matched = False for column in metadata[schema][obj]: if column in columns: filtered_objects.append(obj) matched = True break if not matched: remaining_objects.append(obj) else: filtered_objects = objects return filtered_objects + remaining_objects ================================================ FILE: mycli/sqlexecute.py ================================================ from __future__ import annotations import datetime import enum import logging import re import ssl from typing import Any, Generator, Iterable from prompt_toolkit.formatted_text import FormattedText import pymysql from pymysql.connections import Connection from pymysql.constants import FIELD_TYPE from pymysql.converters import conversions, convert_date, convert_datetime, convert_time, decoders from pymysql.cursors import Cursor from mycli.packages.special import iocommands from mycli.packages.special.main import CommandNotFound, execute from mycli.packages.sqlresult import SQLResult try: import paramiko # noqa: F401 import sshtunnel except ImportError: pass _logger = logging.getLogger(__name__) FIELD_TYPES = decoders.copy() FIELD_TYPES.update({FIELD_TYPE.NULL: type(None)}) ERROR_CODE_ACCESS_DENIED = 1045 class ServerSpecies(enum.Enum): MySQL = "MySQL" MariaDB = "MariaDB" Percona = "Percona" TiDB = "TiDB" Unknown = "Unknown" class ServerInfo: def __init__(self, species: ServerSpecies | None, version_str: str) -> None: self.species = species self.version_str = version_str self.version = self.calc_mysql_version_value(version_str) @staticmethod def calc_mysql_version_value(version_str: str) -> int: if not version_str or not isinstance(version_str, str): return 0 try: major, minor, patch = version_str.split(".") except ValueError: return 0 else: return int(major) * 10_000 + int(minor) * 100 + int(patch) @classmethod def from_version_string(cls, version_string: str) -> ServerInfo: if not version_string: return cls(ServerSpecies.MySQL, "") re_species = ( (r"(?P[0-9\.]+)-MariaDB", ServerSpecies.MariaDB), (r"[0-9\.]*-TiDB-v(?P[0-9\.]+)-?(?P[a-z0-9\-]*)", ServerSpecies.TiDB), (r"(?P[0-9\.]+)[a-z0-9]*-(?P[0-9]+$)", ServerSpecies.Percona), (r"(?P[0-9\.]+)[a-z0-9]*-(?P[A-Za-z0-9_]+)", ServerSpecies.MySQL), ) for regexp, species in re_species: match = re.search(regexp, version_string) if match is not None: parsed_version = match.group("version") detected_species = species break else: detected_species = ServerSpecies.MySQL parsed_version = "" return cls(detected_species, parsed_version) def __str__(self) -> str: if self.species: return f"{self.species.value} {self.version_str}" else: return self.version_str class SQLExecute: databases_query = """SHOW DATABASES""" tables_query = """SHOW TABLES""" show_candidates_query = '''SELECT name from mysql.help_topic WHERE name like "SHOW %"''' users_query = """SELECT CONCAT("'", user, "'@'",host,"'") FROM mysql.user""" functions_query = '''SELECT ROUTINE_NAME FROM INFORMATION_SCHEMA.ROUTINES WHERE ROUTINE_TYPE="FUNCTION" AND ROUTINE_SCHEMA = %s''' procedures_query = '''SELECT ROUTINE_NAME FROM INFORMATION_SCHEMA.ROUTINES WHERE ROUTINE_TYPE="PROCEDURE" AND ROUTINE_SCHEMA = %s''' character_sets_query = '''SHOW CHARACTER SET''' collations_query = '''SHOW COLLATION''' table_columns_query = """select TABLE_NAME, COLUMN_NAME from information_schema.columns where table_schema = %s order by table_name,ordinal_position""" enum_values_query = """select TABLE_NAME, COLUMN_NAME, COLUMN_TYPE from information_schema.columns where table_schema = %s and data_type = 'enum' order by table_name,ordinal_position""" now_query = """SELECT NOW()""" @staticmethod def _parse_enum_values(column_type: str) -> list[str]: if not column_type or not column_type.lower().startswith("enum("): return [] values: list[str] = [] current: list[str] = [] in_quote = False i = column_type.find("(") + 1 while i < len(column_type): ch = column_type[i] if not in_quote: if ch == "'": in_quote = True current = [] elif ch == ")": break else: if ch == "\\" and i + 1 < len(column_type): current.append(column_type[i + 1]) i += 1 elif ch == "'": if i + 1 < len(column_type) and column_type[i + 1] == "'": current.append("'") i += 1 else: values.append("".join(current)) in_quote = False else: current.append(ch) i += 1 return values def __init__( self, database: str | None, user: str | None, password: str | None, host: str | None, port: int | None, socket: str | None, character_set: str | None, local_infile: bool | None, ssl: dict[str, Any] | None, ssh_user: str | None, ssh_host: str | None, ssh_port: int | None, ssh_password: str | None, ssh_key_filename: str | None, init_command: str | None = None, unbuffered: bool | None = None, ) -> None: self.dbname = database self.user = user self.password = password self.host = host self.port = port self.socket = socket self.character_set = character_set self.local_infile = local_infile self.ssl = ssl self.server_info: ServerInfo | None = None self.connection_id: int | None = None self.ssh_user = ssh_user self.ssh_host = ssh_host self.ssh_port = ssh_port self.ssh_password = ssh_password self.ssh_key_filename = ssh_key_filename self.init_command = init_command self.unbuffered = unbuffered self.conn: Connection | None = None self.connect() def connect( self, database: str | None = None, user: str | None = None, password: str | None = None, host: str | None = None, port: int | None = None, socket: str | None = None, character_set: str | None = None, local_infile: bool | None = None, ssl: dict[str, Any] | None = None, ssh_host: str | None = None, ssh_port: int | None = None, ssh_user: str | None = None, ssh_password: str | None = None, ssh_key_filename: str | None = None, init_command: str | None = None, unbuffered: bool | None = None, ): db = database if database is not None else self.dbname user = user if user is not None else self.user password = password if password is not None else self.password host = host if host is not None else self.host port = port if port is not None else self.port socket = socket if socket is not None else self.socket character_set = character_set if character_set is not None else self.character_set local_infile = local_infile if local_infile is not None else self.local_infile ssl = ssl if ssl is not None else self.ssl ssh_user = ssh_user if ssh_user is not None else self.ssh_user ssh_host = ssh_host if ssh_host is not None else self.ssh_host ssh_port = ssh_port if ssh_port is not None else self.ssh_port ssh_password = ssh_password if ssh_password is not None else self.ssh_password ssh_key_filename = ssh_key_filename if ssh_key_filename is not None else self.ssh_key_filename init_command = init_command if init_command is not None else self.init_command unbuffered = unbuffered if unbuffered is not None else self.unbuffered _logger.debug( "Connection DB Params: \n" "\tdatabase: %r" "\tuser: %r" "\thost: %r" "\tport: %r" "\tsocket: %r" "\tcharacter_set: %r" "\tlocal_infile: %r" "\tssl: %r" "\tssh_user: %r" "\tssh_host: %r" "\tssh_port: %r" "\tssh_password: %r" "\tssh_key_filename: %r" "\tinit_command: %r" "\tunbuffered: %r", db, user, host, port, socket, character_set, local_infile, ssl, ssh_user, ssh_host, ssh_port, ssh_password, ssh_key_filename, init_command, unbuffered, ) conv = conversions.copy() conv.update({ FIELD_TYPE.TIMESTAMP: lambda obj: convert_datetime(obj) or obj, FIELD_TYPE.DATETIME: lambda obj: convert_datetime(obj) or obj, FIELD_TYPE.TIME: lambda obj: convert_time(obj) or obj, FIELD_TYPE.DATE: lambda obj: convert_date(obj) or obj, }) defer_connect = False if ssh_host: defer_connect = True client_flag = pymysql.constants.CLIENT.INTERACTIVE if init_command and len(list(iocommands.split_queries(init_command))) > 1: client_flag |= pymysql.constants.CLIENT.MULTI_STATEMENTS ssl_context = None if ssl: ssl_context = self._create_ssl_ctx(ssl) conn = pymysql.connect( database=db, user=user, password=password or '', host=host, port=port or 0, unix_socket=socket, use_unicode=True, charset=character_set or '', autocommit=True, client_flag=client_flag, local_infile=local_infile or False, conv=conv, ssl=ssl_context, # type: ignore[arg-type] program_name="mycli", defer_connect=defer_connect, init_command=init_command or None, cursorclass=pymysql.cursors.SSCursor if unbuffered else pymysql.cursors.Cursor, ) # type: ignore[misc] if ssh_host: ##### paramiko.Channel is a bad socket implementation overall if you want SSL through an SSH tunnel ##### # instead let's open a tunnel and rewrite host:port to local bind try: chan = sshtunnel.SSHTunnelForwarder( (ssh_host, ssh_port), ssh_username=ssh_user, ssh_pkey=ssh_key_filename, ssh_password=ssh_password, remote_bind_address=(host, port), ) chan.start() conn.host = chan.local_bind_host conn.port = chan.local_bind_port conn.connect() except Exception as e: raise e if self.conn is not None: try: self.conn.close() except pymysql.err.Error: pass self.conn = conn # Update them after the connection is made to ensure that it was a # successful connection. self.dbname = db self.user = user self.password = password self.host = host self.port = port self.socket = socket self.character_set = character_set self.ssl = ssl self.init_command = init_command self.unbuffered = unbuffered # retrieve connection id self.reset_connection_id() self.server_info = ServerInfo.from_version_string(conn.server_version) # type: ignore[attr-defined] def run(self, statement: str) -> Generator[SQLResult, None, None]: """Execute the sql in the database and return the results.""" # Remove spaces and EOL statement = statement.strip() if not statement: # Empty string yield SQLResult() # Split the sql into separate queries and run each one. # Unless it's saving a favorite query, in which case we # want to save them all together. if statement.startswith("\\fs"): components: Iterable[str] = [statement] else: components = iocommands.split_queries(statement) for sql in components: # \G is treated specially since we have to set the expanded output. if sql.endswith("\\G"): iocommands.set_expanded_output(True) sql = sql[:-2].strip() # \g is treated specially since we might want collapsed output when # auto vertical output is enabled elif sql.endswith('\\g'): iocommands.set_expanded_output(False) iocommands.set_forced_horizontal_output(True) sql = sql[:-2].strip() assert isinstance(self.conn, Connection) cur = self.conn.cursor() try: # Special command _logger.debug("Trying a dbspecial command. sql: %r", sql) yield from execute(cur, sql) except CommandNotFound: # Regular SQL _logger.debug("Regular sql statement. sql: %r", sql) cur.execute(sql) while True: yield self.get_result(cur) # PyMySQL returns an extra, empty result set with stored # procedures. We skip it (rowcount is zero and no # description). if not cur.nextset() or (not cur.rowcount and cur.description is None): break def get_result(self, cursor: Cursor) -> SQLResult: """Get the current result's data from the cursor.""" preamble = header = None # cursor.description is not None for queries that return result sets, # e.g. SELECT or SHOW. plural = '' if cursor.rowcount == 1 else 's' if cursor.description: header = [x[0] for x in cursor.description] status = FormattedText([('', f'{cursor.rowcount} row{plural} in set')]) else: _logger.debug("No rows in result.") status = FormattedText([('', f'Query OK, {cursor.rowcount} row{plural} affected')]) if cursor.warning_count > 0: plural = '' if cursor.warning_count == 1 else 's' comma = FormattedText([('', ', ')]) warning_count = FormattedText([('class:output.status.warning-count', f'{cursor.warning_count} warning{plural}')]) status.extend(comma) status.extend(warning_count) return SQLResult(preamble=preamble, header=header, rows=cursor, status=status) def tables(self) -> Generator[tuple[str], None, None]: """Yields table names""" assert isinstance(self.conn, Connection) with self.conn.cursor() as cur: _logger.debug("Tables Query. sql: %r", self.tables_query) cur.execute(self.tables_query) yield from cur def table_columns(self) -> Generator[tuple[str, str], None, None]: """Yields (table name, column name) pairs""" assert isinstance(self.conn, Connection) with self.conn.cursor() as cur: _logger.debug("Columns Query. sql: %r", self.table_columns_query) cur.execute(self.table_columns_query, (self.dbname,)) yield from cur def enum_values(self) -> Generator[tuple[str, str, list[str]], None, None]: """Yields (table name, column name, enum values) tuples""" assert isinstance(self.conn, Connection) with self.conn.cursor() as cur: _logger.debug("Enum Values Query. sql: %r", self.enum_values_query) cur.execute(self.enum_values_query, (self.dbname,)) for table_name, column_name, column_type in cur: values = self._parse_enum_values(column_type) if values: yield (table_name, column_name, values) def databases(self) -> list[str]: assert isinstance(self.conn, Connection) with self.conn.cursor() as cur: _logger.debug("Databases Query. sql: %r", self.databases_query) cur.execute(self.databases_query) return [x[0] for x in cur.fetchall()] def functions(self) -> Generator[tuple[str, str], None, None]: """Yields tuples of (schema_name, function_name)""" assert isinstance(self.conn, Connection) with self.conn.cursor() as cur: _logger.debug("Functions Query. sql: %r", self.functions_query) cur.execute(self.functions_query, (self.dbname,)) yield from cur def procedures(self) -> Generator[tuple, None, None]: """Yields tuples of (procedure_name, )""" assert isinstance(self.conn, Connection) with self.conn.cursor() as cur: _logger.debug("Procedures Query. sql: %r", self.procedures_query) try: cur.execute(self.procedures_query, (self.dbname,)) except pymysql.DatabaseError as e: _logger.error('No procedure completions due to %r', e) yield () else: yield from cur def character_sets(self) -> Generator[tuple, None, None]: """Yields tuples of (character_set_name, )""" assert isinstance(self.conn, Connection) with self.conn.cursor() as cur: _logger.debug("Character sets Query. sql: %r", self.character_sets_query) try: cur.execute(self.character_sets_query) except pymysql.DatabaseError as e: _logger.error('No character_set completions due to %r', e) yield () else: yield from cur def collations(self) -> Generator[tuple, None, None]: """Yields tuples of (collation_name, )""" assert isinstance(self.conn, Connection) with self.conn.cursor() as cur: _logger.debug("Collations Query. sql: %r", self.collations_query) try: cur.execute(self.collations_query) except pymysql.DatabaseError as e: _logger.error('No collations completions due to %r', e) yield () else: yield from cur def show_candidates(self) -> Generator[tuple, None, None]: assert isinstance(self.conn, Connection) with self.conn.cursor() as cur: _logger.debug("Show Query. sql: %r", self.show_candidates_query) try: cur.execute(self.show_candidates_query) except pymysql.DatabaseError as e: _logger.error("No show completions due to %r", e) yield () else: for row in cur: yield (row[0].split(None, 1)[-1],) def users(self) -> Generator[tuple, None, None]: assert isinstance(self.conn, Connection) with self.conn.cursor() as cur: _logger.debug("Users Query. sql: %r", self.users_query) try: cur.execute(self.users_query) except pymysql.DatabaseError as e: _logger.error("No user completions due to %r", e) yield () else: yield from cur def now(self) -> datetime.datetime: assert isinstance(self.conn, Connection) with self.conn.cursor() as cur: _logger.debug("Now Query. sql: %r", self.now_query) cur.execute(self.now_query) if one := cur.fetchone(): return one[0] else: return datetime.datetime.now() def get_connection_id(self) -> int | None: if not self.connection_id: self.reset_connection_id() return self.connection_id def reset_connection_id(self) -> None: # Remember current connection id _logger.debug("Get current connection id") try: results = self.run("select connection_id()") for result in results: cur = result.rows if isinstance(cur, Cursor): v = cur.fetchone() self.connection_id = v[0] if v is not None else -1 else: raise ValueError except Exception as e: # See #1054 self.connection_id = -1 _logger.error("Failed to get connection id: %s", e) else: _logger.debug("Current connection id: %s", self.connection_id) def change_db(self, db: str) -> None: assert isinstance(self.conn, Connection) self.conn.select_db(db) self.dbname = db def _create_ssl_ctx(self, sslp: dict) -> ssl.SSLContext: ca = sslp.get("ca") capath = sslp.get("capath") hasnoca = ca is None and capath is None ctx = ssl.create_default_context(cafile=ca, capath=capath) ctx.check_hostname = not hasnoca and sslp.get("check_hostname", True) ctx.verify_mode = ssl.CERT_NONE if hasnoca else ssl.CERT_REQUIRED if "cert" in sslp: ctx.load_cert_chain(sslp["cert"], keyfile=sslp.get("key")) if "cipher" in sslp: ctx.set_ciphers(sslp["cipher"]) ctx.minimum_version = ssl.TLSVersion.TLSv1_2 if "tls_version" in sslp: tls_version = sslp["tls_version"] if tls_version == "TLSv1": ctx.minimum_version = ssl.TLSVersion.TLSv1 ctx.maximum_version = ssl.TLSVersion.TLSv1 elif tls_version == "TLSv1.1": ctx.minimum_version = ssl.TLSVersion.TLSv1_1 ctx.maximum_version = ssl.TLSVersion.TLSv1_1 elif tls_version == "TLSv1.2": ctx.minimum_version = ssl.TLSVersion.TLSv1_2 ctx.maximum_version = ssl.TLSVersion.TLSv1_2 elif tls_version == "TLSv1.3": ctx.minimum_version = ssl.TLSVersion.TLSv1_3 ctx.maximum_version = ssl.TLSVersion.TLSv1_3 else: _logger.error("Invalid tls version: %s", tls_version) return ctx def close(self) -> None: if self.conn is not None: try: self.conn.close() except pymysql.err.Error: pass ================================================ FILE: pyproject.toml ================================================ [project] name = "mycli" dynamic = ["version"] description = "CLI for MySQL Database. With auto-completion and syntax highlighting." readme = "README.md" requires-python = ">=3.10" license = "BSD-3-Clause" authors = [{ name = "Mycli Core Team" }] dependencies = [ "click ~= 8.3.1", "cryptography ~= 46.0.5", "Pygments ~= 2.19.2", "prompt_toolkit>=3.0.6,<4.0.0", "PyMySQL ~= 1.1.2", "sqlparse>=0.3.0,<0.6.0", "sqlglot[c] ~= 30.0.0", "configobj ~= 5.0.9", "cli_helpers[styles] ~= 2.11.0", "wcwidth ~= 0.6.0", "pyperclip ~= 1.11.0", "pycryptodomex ~= 3.23.0", "pyfzf ~= 0.3.1", "rapidfuzz ~= 3.14.3", "keyring ~= 25.7.0", ] [project.urls] Homepage = 'https://mycli.net' Documentation = 'https://mycli.net/docs' Source = 'https://github.com/dbcli/mycli' Issues = 'https://github.com/dbcli/mycli/issues' Changelog = 'https://github.com/dbcli/mycli/blob/main/changelog.md' [build-system] requires = ["setuptools>=64.0", "setuptools-scm>=8"] build-backend = "setuptools.build_meta" [tool.setuptools_scm] [project.optional-dependencies] ssh = [ "paramiko ~= 3.5.1", "sshtunnel ~= 0.4.0", ] llm = [ "llm ~= 0.28.0", "setuptools == 82.*", # Required by llm commands to install models "pip == 26.*", ] all = [ "mycli[ssh]", "mycli[llm]", ] dev = [ "behave ~= 1.3.3", "coverage ~= 7.13.4", "mypy ~= 1.19.1", "pexpect ~= 4.9.0", "pytest ~= 9.0.2", "pytest-cov ~= 7.0.0", "tox ~= 4.35.0", "pdbpp ~= 0.11.7", "paramiko ~= 3.5.1", "sshtunnel ~= 0.4.0", "llm ~= 0.28.0", "setuptools == 82.*", # Required by llm commands to install models "pip == 26.*", "ruff ~= 0.15.0", ] [project.scripts] mycli = "mycli.main:cli" [tool.setuptools.package-data] mycli = ["myclirc", "AUTHORS", "SPONSORS", "TIPS"] [tool.setuptools.packages.find] include = ["mycli*"] [tool.ruff] target-version = 'py310' line-length = 140 [tool.ruff.lint] select = ['A', 'B', 'I', 'E', 'W', 'F', 'C4', 'PIE', 'TID'] ignore = [ 'B005', # Multi-character strip() 'E401', # Multiple imports on one line 'E402', # Module level import not at top of file 'PIE808', # range() starting with 0 # https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules 'E111', # indentation-with-invalid-multiple 'E114', # indentation-with-invalid-multiple-comment 'E117', # over-indented 'W191', # tab-indentation ] [tool.ruff.lint.isort] force-sort-within-sections = true known-first-party = ['mycli', 'test', 'steps'] [tool.ruff.lint.flake8-tidy-imports] ban-relative-imports = 'all' [tool.ruff.format] preview = true quote-style = 'preserve' exclude = ['build', 'mycli_dev'] [tool.mypy] pretty = true strict_equality = true ignore_missing_imports = true warn_unreachable = true warn_redundant_casts = true warn_no_return = true warn_unused_configs = true show_column_numbers = true exclude = ['^build/', '^dist/'] ================================================ FILE: pytest.ini ================================================ [pytest] addopts = --ignore=mycli/packages/paramiko_stub/__init__.py ================================================ FILE: test/__init__.py ================================================ ================================================ FILE: test/conftest.py ================================================ # type: ignore import pytest import mycli.sqlexecute from test.utils import CHARACTER_SET, DATABASE, HOST, PASSWORD, PORT, SSH_HOST, SSH_PORT, SSH_USER, USER, create_db, db_connection @pytest.fixture(scope="function") def connection(): create_db(DATABASE) connection = db_connection(DATABASE) yield connection connection.close() @pytest.fixture def cursor(connection): with connection.cursor() as cur: return cur @pytest.fixture def executor(connection): return mycli.sqlexecute.SQLExecute( database=DATABASE, user=USER, host=HOST, password=PASSWORD, port=PORT, socket=None, character_set=CHARACTER_SET, local_infile=False, ssl=None, ssh_user=SSH_USER, ssh_host=SSH_HOST, ssh_port=SSH_PORT, ssh_password=None, ssh_key_filename=None, ) ================================================ FILE: test/features/__init__.py ================================================ ================================================ FILE: test/features/auto_vertical.feature ================================================ Feature: auto_vertical mode: on, off Scenario: auto_vertical on with small query When we run dbcli with --auto-vertical-output and we execute a small query then we see small results in horizontal format Scenario: auto_vertical on with large query When we run dbcli with --auto-vertical-output and we execute a large query then we see large results in vertical format ================================================ FILE: test/features/basic_commands.feature ================================================ Feature: run the cli, call the help command, check our application name, insert the date, exit the cli Scenario: run "\?" command When we send "\?" command then we see help output Scenario: run source command When we send source command then we see help output Scenario: check our application_name When we run query to check application_name then we see found Scenario: insert the date When we send "ctrl + o, ctrl + d" then we see the date Scenario: run the cli and exit When we send "ctrl + d" then dbcli exits ================================================ FILE: test/features/connection.feature ================================================ Feature: connect to a database: @requires_local_db Scenario: run mycli on localhost without port When we run mycli with arguments "host=localhost" without arguments "port" When we query "status" Then status contains "via UNIX socket" Scenario: run mycli on TCP host without port When we run mycli without arguments "port" When we query "status" Then status contains "via TCP/IP" Scenario: run mycli with port but without host When we run mycli without arguments "host" When we query "status" Then status contains "via TCP/IP" @requires_local_db Scenario: run mycli without host and port When we run mycli without arguments "host port" When we query "status" Then status contains "via UNIX socket" Scenario: run mycli with my.cnf configuration When we create my.cnf file When we run mycli without arguments "host port user pass defaults_file" Then we are logged in Scenario: run mycli with mylogin.cnf configuration When we create mylogin.cnf file When we run mycli with arguments "login_path=test_login_path" without arguments "host port user pass defaults_file" Then we are logged in ================================================ FILE: test/features/crud_database.feature ================================================ Feature: manipulate databases: create, drop, connect, disconnect Scenario: create and drop temporary database When we create database then we see database created when we drop database then we confirm the destructive warning then we see database dropped when we connect to dbserver then we see database connected Scenario: connect and disconnect from test database When we connect to test database then we see database connected when we connect to dbserver then we see database connected Scenario: connect and disconnect from quoted test database When we connect to quoted test database then we see database connected Scenario: create and drop default database When we create database then we see database created when we connect to tmp database then we see database connected when we drop database then we confirm the destructive warning then we see database dropped and no default database ================================================ FILE: test/features/crud_table.feature ================================================ Feature: manipulate tables: create, insert, update, select, delete from, drop Scenario: create, insert, select from, update, drop table When we connect to test database then we see database connected when we create table then we see table created when we insert into table then we see record inserted when we update table then we see record updated when we select from table then we see data selected when we delete from table then we confirm the destructive warning then we see record deleted when we drop table then we confirm the destructive warning then we see table dropped when we connect to dbserver then we see database connected Scenario: select null values When we connect to test database then we see database connected when we select null then we see null selected Scenario: confirm destructive query When we query "create table foo(x integer);" and we query "delete from foo;" and we answer the destructive warning with "y" then we see text "Your call!" Scenario: decline destructive query When we query "delete from foo;" and we answer the destructive warning with "n" then we see text "Wise choice!" # TODO (amjith). This scenario fails in GH actions but only in 3.12. Unable # to reproduce locally. @skip_py312 Scenario: no destructive warning if disabled in config When we run dbcli with --no-warn and we query "create table blabla(x integer);" and we query "delete from blabla;" Then we see text "Query OK" Scenario: confirm destructive query with invalid response When we query "delete from foo;" then we answer the destructive warning with invalid "1" and see text "is not a valid boolean" ================================================ FILE: test/features/db_utils.py ================================================ # type: ignore import pymysql from mycli.constants import DEFAULT_CHARSET, DEFAULT_HOST, DEFAULT_PORT def create_db(hostname=DEFAULT_HOST, port=DEFAULT_PORT, username=None, password=None, dbname=None): """Create test database. :param hostname: string :param port: int :param username: string :param password: string :param dbname: string :return: """ cn = pymysql.connect( host=hostname, port=port, user=username, password=password, charset=DEFAULT_CHARSET, cursorclass=pymysql.cursors.DictCursor ) with cn.cursor() as cr: cr.execute("drop database if exists " + dbname) cr.execute("create database " + dbname) cn.close() cn = create_cn(hostname, port, password, username, dbname) return cn def create_cn(hostname, port, password, username, dbname): """Open connection to database. :param hostname: :param port: :param password: :param username: :param dbname: string :return: psycopg2.connection """ cn = pymysql.connect( host=hostname, port=port, user=username, password=password, db=dbname, charset=DEFAULT_CHARSET, cursorclass=pymysql.cursors.DictCursor, ) return cn def drop_db(hostname=DEFAULT_HOST, port=DEFAULT_PORT, username=None, password=None, dbname=None): """Drop database. :param hostname: string :param port: int :param username: string :param password: string :param dbname: string """ cn = pymysql.connect( host=hostname, port=port, user=username, password=password, db=dbname, charset=DEFAULT_CHARSET, cursorclass=pymysql.cursors.DictCursor, ) with cn.cursor() as cr: cr.execute("drop database if exists " + dbname) close_cn(cn) def close_cn(cn=None): """Close connection. :param connection: pymysql.connection """ if cn: cn.close() ================================================ FILE: test/features/environment.py ================================================ # type: ignore import os import shutil import sys from tempfile import NamedTemporaryFile import db_utils as dbutils import fixture_utils as fixutils import pexpect from mycli.constants import DEFAULT_HOST, DEFAULT_PORT, DEFAULT_USER from steps.wrappers import run_cli, wait_prompt from test.utils import TEMPFILE_PREFIX test_log_file = os.path.join(os.environ["HOME"], ".mycli.test.log") SELF_CONNECTING_FEATURES = ("test/features/connection.feature",) MY_CNF_PATH = os.path.expanduser("~/.my.cnf") MY_CNF_BACKUP_PATH = f"{MY_CNF_PATH}.backup" MYLOGIN_CNF_PATH = os.path.expanduser("~/.mylogin.cnf") MYLOGIN_CNF_BACKUP_PATH = f"{MYLOGIN_CNF_PATH}.backup" def get_db_name_from_context(context): return context.config.userdata.get("my_test_db", None) or "mycli_behave_tests" def before_all(context): """Set env parameters.""" os.environ["LINES"] = "100" os.environ["COLUMNS"] = "100" os.environ["VISUAL"] = "ex" os.environ["EDITOR"] = "ex" os.environ["LC_ALL"] = "en_US.UTF-8" os.environ["PROMPT_TOOLKIT_NO_CPR"] = "1" os.environ["MYCLI_HISTFILE"] = os.devnull # test_dir = os.path.abspath(os.path.dirname(os.path.dirname(__file__))) # login_path_file = os.path.join(test_dir, "mylogin.cnf") # os.environ['MYSQL_TEST_LOGIN_FILE'] = login_path_file context.package_root = os.path.abspath(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))) os.environ["COVERAGE_PROCESS_START"] = os.path.join(context.package_root, ".coveragerc") context.exit_sent = False vi = "_".join([str(x) for x in sys.version_info[:3]]) db_name = get_db_name_from_context(context) db_name_full = f"{db_name}_{vi}" # Store get params from config/environment variables context.conf = { "host": context.config.userdata.get("my_test_host", os.getenv("PYTEST_HOST", DEFAULT_HOST)), "port": context.config.userdata.get("my_test_port", int(os.getenv("PYTEST_PORT", DEFAULT_PORT))), "user": context.config.userdata.get("my_test_user", os.getenv("PYTEST_USER", DEFAULT_USER)), "pass": context.config.userdata.get("my_test_pass", os.getenv("PYTEST_PASSWORD", None)), "cli_command": context.config.userdata.get("my_cli_command", None) or sys.executable + ' -c "import coverage ; coverage.process_startup(); import mycli.main; mycli.main.cli()"', "dbname": db_name, "dbname_tmp": db_name_full + "_tmp", "vi": vi, "pager_boundary": "---boundary---", } with NamedTemporaryFile(prefix=TEMPFILE_PREFIX, mode='w', delete=False) as my_cnf: my_cnf.write( f'[client]\npager={sys.executable} ' f'{os.path.join(context.package_root, "test/features/wrappager.py")} {context.conf["pager_boundary"]}\n' ) context.conf["defaults-file"] = my_cnf.name context.conf["myclirc"] = os.path.join(context.package_root, "test", "myclirc") context.cn = dbutils.create_db( context.conf["host"], context.conf["port"], context.conf["user"], context.conf["pass"], context.conf["dbname"] ) context.fixture_data = fixutils.read_fixture_files() def after_all(context): """Unset env parameters.""" dbutils.close_cn(context.cn) dbutils.drop_db(context.conf["host"], context.conf["port"], context.conf["user"], context.conf["pass"], context.conf["dbname"]) try: if os.path.exists(context.conf["defaults-file"]): os.remove(context.conf["defaults-file"]) except Exception: pass # Restore env vars. # for k, v in context.pgenv.items(): # if k in os.environ and v is None: # del os.environ[k] # elif v: # os.environ[k] = v def before_step(context, _): context.atprompt = False def before_scenario(context, arg): # Skip scenarios marked skip_py312 when running on Python 3.12 if sys.version_info[:2] == (3, 12) and "skip_py312" in arg.tags: arg.skip("Skipped on Python 3.12") with open(test_log_file, "w") as f: f.write("") if arg.location.filename not in SELF_CONNECTING_FEATURES: run_cli(context) wait_prompt(context) if os.path.exists(MY_CNF_PATH): shutil.move(MY_CNF_PATH, MY_CNF_BACKUP_PATH) if os.path.exists(MYLOGIN_CNF_PATH): shutil.move(MYLOGIN_CNF_PATH, MYLOGIN_CNF_BACKUP_PATH) def after_scenario(context, _): """Cleans up after each test complete.""" with open(test_log_file) as f: for line in f: if "error" in line.lower(): raise RuntimeError(f"Error in log file: {line}") if hasattr(context, "cli") and not context.exit_sent: # Quit nicely. if not context.atprompt: user = context.conf["user"] host = context.conf["host"] dbname = context.currentdb context.cli.expect_exact(f"{user}@{host}:{dbname}>", timeout=5) context.cli.sendcontrol("c") context.cli.sendcontrol("d") context.cli.expect_exact(pexpect.EOF, timeout=5) if os.path.exists(MY_CNF_BACKUP_PATH): shutil.move(MY_CNF_BACKUP_PATH, MY_CNF_PATH) if os.path.exists(MYLOGIN_CNF_BACKUP_PATH): shutil.move(MYLOGIN_CNF_BACKUP_PATH, MYLOGIN_CNF_PATH) elif os.path.exists(MYLOGIN_CNF_PATH): # This file was moved in `before_scenario`. # If it exists now, it has been created during a test os.remove(MYLOGIN_CNF_PATH) # TODO: uncomment to debug a failure # def after_step(context, step): # if step.status == "failed": # import ipdb; ipdb.set_trace() ================================================ FILE: test/features/fixture_data/help.txt ================================================ +--------------------------+-----------------------------------------------+ | Command | Description | |--------------------------+-----------------------------------------------| | \# | Refresh auto-completions. | | \? | Show Help. | | \c[onnect] database_name | Change to a new database. | | \d [pattern] | List or describe tables, views and sequences. | | \dT[S+] [pattern] | List data types | | \df[+] [pattern] | List functions. | | \di[+] [pattern] | List indexes. | | \dn[+] [pattern] | List schemas. | | \ds[+] [pattern] | List sequences. | | \dt[+] [pattern] | List tables. | | \du[+] [pattern] | List roles. | | \dv[+] [pattern] | List views. | | \e [file] | Edit the query with external editor. | | \l | List databases. | | \n[+] [name] | List or execute named queries. | | \nd [name [query]] | Delete a named query. | | \ns name query | Save a named query. | | \refresh | Refresh auto-completions. | | \timing | Toggle timing of commands. | | \x | Toggle expanded output. | +--------------------------+-----------------------------------------------+ ================================================ FILE: test/features/fixture_data/help_commands.txt ================================================ +----------------+----------+---------------------------------+-------------------------------------------------------------+ | Command | Shortcut | Usage | Description | +----------------+----------+---------------------------------+-------------------------------------------------------------+ | \G | | \G | Display query results vertically. | | \bug | | \bug | File a bug on GitHub. | | \clip | | \clip | Copy query to the system clipboard. | | \dt | | \dt[+] [table] | List or describe tables. | | \edit | \e | \edit | \edit | Edit query with editor (uses $VISUAL or $EDITOR). | | \f | | \f [name [args..]] | List or execute favorite queries. | | \fd | | \fd | Delete a favorite query. | | \fs | | \fs | Save a favorite query. | | \l | | \l | List databases. | | \llm | \ai | \llm [arguments] | Interrogate an LLM. See "\llm help". | | \once | \o | \once [-o] | Append next result to an output file (overwrite using -o). | | \pipe_once | \| | \pipe_once | Send next result to a subprocess. | | \timing | \t | \timing | Toggle timing of queries. | | connect | \r | connect [database] | Reconnect to the server, optionally switching databases. | | delimiter | | delimiter | Change end-of-statement delimiter. | | exit | \q | exit | Exit. | | help | \? | help [term] | Show this help, or search for a term on the server. | | nopager | \n | nopager | Disable pager; print to stdout. | | notee | | notee | Stop writing results to an output file. | | nowarnings | \w | nowarnings | Disable automatic warnings display. | | pager | \P | pager [command] | Set pager to [command]. Print query results via pager. | | prompt | \R | prompt | Change prompt format. | | quit | \q | quit | Quit. | | redirectformat | \Tr | redirectformat | Change the table format used to output redirected results. | | rehash | \# | rehash | Refresh auto-completions. | | source | \. | source | Execute queries from a file. | | status | \s | status | Get status information from the server. | | system | | system [-r] | Execute a system shell command (raw mode with -r). | | tableformat | \T | tableformat | Change the table format used to output interactive results. | | tee | | tee [-o] | Append all results to an output file (overwrite using -o). | | use | \u | use | Change to a new database. | | warnings | \W | warnings | Enable automatic warnings display. | | watch | | watch [seconds] [-c] | Execute query every [seconds] seconds (5 by default). | +----------------+----------+---------------------------------+-------------------------------------------------------------+ ================================================ FILE: test/features/fixture_utils.py ================================================ # type: ignore import os def read_fixture_lines(filename): """Read lines of text from file. :param filename: string name :return: list of strings """ lines = [] for line in open(filename): lines.append(line.strip()) return lines def read_fixture_files(): """Read all files inside fixture_data directory.""" fixture_dict = {} current_dir = os.path.dirname(__file__) fixture_dir = os.path.join(current_dir, "fixture_data/") for filename in os.listdir(fixture_dir): if filename not in [".", ".."]: fullname = os.path.join(fixture_dir, filename) fixture_dict[filename] = read_fixture_lines(fullname) return fixture_dict ================================================ FILE: test/features/iocommands.feature ================================================ Feature: I/O commands Scenario: edit sql in file with external editor When we start external editor providing a file name and we type "select * from abc" in the editor and we exit the editor then we see dbcli prompt and we see "select * from abc" in prompt Scenario: tee output from query When we tee output and we wait for prompt and we select "select 123456" and we wait for prompt and we notee output and we wait for prompt then we see 123456 in tee output Scenario: set delimiter When we query "delimiter $" then delimiter is set to "$" Scenario: set delimiter twice When we query "delimiter $" and we query "delimiter ]]" then delimiter is set to "]]" Scenario: set delimiter and query on same line When we query "select 123; delimiter $ select 456 $ delimiter %" then we see tabular result "123" and we see tabular result "456" and delimiter is set to "%" Scenario: send output to file When we query "\o /tmp/output1.sql" and we query "select 123" and we query "system cat /tmp/output1.sql" then we see csv result "123" Scenario: send output to file two times When we query "\o /tmp/output1.sql" and we query "select 123" and we query "\o /tmp/output2.sql" and we query "select 456" and we query "system cat /tmp/output2.sql" then we see csv result "456" Scenario: shell style redirect to file When we query "select 123 as constant $> /tmp/output1.csv" and we query "system cat /tmp/output1.csv" then we see csv 123 in file output Scenario: shell style redirect to command When we query "select 100 $| wc" then we see space 12 in command output Scenario: shell style redirect to multiple commands When we query "select 100 $| head -1 $| wc" then we see space 6 in command output Scenario: shell style redirect to multiple commands with minimal spaces When we query "select 100$|head -1$|wc" then we see space 6 in command output Scenario: shell style redirect to multiple commands containing single quotes When we query "select 100 $| head '-1' $| wc" then we see space 6 in command output Scenario: shell style redirect to multiple commands containing single quotes and minimal spaces When we query "select 100$|head '-1'$|wc" then we see space 6 in command output Scenario: shell style redirect to multiple commands containing mixed quoted and unquoted arg When we query "select 100 $| head -'1' $| wc" then we see space 6 in command output Scenario: shell style redirect to multiple commands containing double quotes When we query "select 100 $| head ""-1"" $| wc" then we see space 6 in command output Scenario: shell style redirect with commands and capture to file When we query "select 100 $| head -1 $| wc $> /tmp/output1.txt" and we query "system cat /tmp/output1.txt" then we see text 6 in file output Scenario: shell style redirect with append to file When we query "select 100 $> /tmp/output1.csv" and we query "select 200 $>> /tmp/output1.csv" and we query "system cat /tmp/output1.csv" then we see csv 100 in file output and we see csv 200 in file output Scenario: shell style redirect with command and append to file When we query "select 300 $| grep 0 $> /tmp/output1.csv" and we query "select 400 $| grep 0 $>> /tmp/output1.csv" and we query "system cat /tmp/output1.csv" then we see csv 300 in file output and we see csv 400 in file output ================================================ FILE: test/features/named_queries.feature ================================================ Feature: named queries: save, use and delete named queries Scenario: save, use and delete named queries When we connect to test database then we see database connected when we save a named query then we see the named query saved when we use a named query then we see the named query executed when we delete a named query then we see the named query deleted Scenario: save, use and delete named queries with parameters When we connect to test database then we see database connected when we save a named query with parameters then we see the named query saved when we use named query with parameters then we see the named query with parameters executed when we use named query with too few parameters then we see the named query with parameters fail with missing parameters when we use named query with too many parameters then we see the named query with parameters fail with extra parameters ================================================ FILE: test/features/specials.feature ================================================ Feature: Special commands @wip Scenario: run refresh command When we refresh completions and we wait for prompt then we see completions refresh started ================================================ FILE: test/features/steps/__init__.py ================================================ ================================================ FILE: test/features/steps/auto_vertical.py ================================================ # type: ignore from textwrap import dedent from behave import then, when from utils import parse_cli_args_to_dict import wrappers @when("we run dbcli with {arg}") def step_run_cli_with_arg(context, arg): wrappers.run_cli(context, run_args=parse_cli_args_to_dict(arg)) @when("we execute a small query") def step_execute_small_query(context): context.cli.sendline("select 1") @when("we execute a large query") def step_execute_large_query(context): context.cli.sendline(f"select {','.join([str(n) for n in range(1, 50)])}") @then("we see small results in horizontal format") def step_see_small_results(context): expected = ( dedent( """ +---+\r | 1 |\r +---+\r | 1 |\r +---+ """ ).strip() + '\r\n\r\n' ) wrappers.expect_pager( context, expected, timeout=5, ) wrappers.expect_exact(context, "1 row in set", timeout=2) @then("we see large results in vertical format") def step_see_large_results(context): rows = [f"{str(n):3}| {n}" for n in range(1, 50)] delimited_rows = '\r\n'.join(rows) + '\r\n' expected = "***************************[ 1. row ]***************************\r\n" + delimited_rows + "\r\n" wrappers.expect_pager(context, expected, timeout=10) wrappers.expect_exact(context, "1 row in set", timeout=2) ================================================ FILE: test/features/steps/basic_commands.py ================================================ # type: ignore """Steps for behavioral style tests are defined in this module. Each step is defined by the string decorating it. This string is used to call the step in "*.feature" file. """ import datetime import tempfile from textwrap import dedent from behave import then, when import wrappers from test.utils import TEMPFILE_PREFIX @when("we run dbcli") def step_run_cli(context): wrappers.run_cli(context) @when("we wait for prompt") def step_wait_prompt(context): wrappers.wait_prompt(context) @when('we send "ctrl + d"') def step_ctrl_d(context): """Send Ctrl + D to hopefully exit.""" context.cli.sendcontrol("d") context.exit_sent = True @when('we send "ctrl + o, ctrl + d"') def step_ctrl_o_ctrl_d(context): """Send ctrl + o, ctrl + d to insert the quoted date.""" context.cli.send("SELECT ") context.cli.sendcontrol("o") context.cli.sendcontrol("d") context.cli.send(" AS dt") context.cli.sendline("") @when(r'we send "\?" command') def step_send_help(context): r"""Send \? to see help. """ context.cli.sendline("\\?") wrappers.expect_exact(context, context.conf["pager_boundary"] + "\r\n", timeout=5) @when("we send source command") def step_send_source_command(context): with tempfile.NamedTemporaryFile(prefix=TEMPFILE_PREFIX) as f: f.write(b"\\?") f.flush() context.cli.sendline(f"\\. {f.name}") wrappers.expect_exact(context, context.conf["pager_boundary"] + "\r\n", timeout=5) @when("we run query to check application_name") def step_check_application_name(context): context.cli.sendline( "SELECT 'found' FROM performance_schema.session_connect_attrs WHERE attr_name = 'program_name' AND attr_value = 'mycli'" ) @then("we see found") def step_see_found(context): expected = ( dedent( """ +-------+\r | found |\r +-------+\r | found |\r +-------+ """ ).strip() + '\r\n\r\n' ) wrappers.expect_exact( context, context.conf["pager_boundary"] + '\r\n' + expected + context.conf["pager_boundary"], timeout=5, ) @then("we see the date") def step_see_date(context): # There are some edge cases in which this test could fail, # such as running near midnight when the test database has # a different TZ setting than the system. date_str = datetime.datetime.now().strftime("%Y-%m-%d") expected = ( dedent( f""" +------------+\r | dt |\r +------------+\r | {date_str} |\r +------------+ """ ).strip() + '\r\n\r\n' ) wrappers.expect_exact( context, context.conf["pager_boundary"] + '\r\n' + expected + context.conf["pager_boundary"], timeout=5, ) @then("we confirm the destructive warning") def step_confirm_destructive_command(context): # noqa """Confirm destructive command.""" wrappers.expect_exact(context, "You're about to run a destructive command.\r\nDo you want to proceed? (y/n):", timeout=2) context.cli.sendline("y") @when('we answer the destructive warning with "{confirmation}"') def step_confirm_destructive_command(context, confirmation): # noqa """Confirm destructive command.""" wrappers.expect_exact(context, "You're about to run a destructive command.\r\nDo you want to proceed? (y/n):", timeout=2) context.cli.sendline(confirmation) @then('we answer the destructive warning with invalid "{confirmation}" and see text "{text}"') def step_confirm_destructive_command(context, confirmation, text): # noqa """Confirm destructive command.""" wrappers.expect_exact(context, "You're about to run a destructive command.\r\nDo you want to proceed? (y/n):", timeout=2) context.cli.sendline(confirmation) wrappers.expect_exact(context, text, timeout=2) # we must exit the Click loop, or the feature will hang context.cli.sendline("n") ================================================ FILE: test/features/steps/connection.py ================================================ # type: ignore import io import os from behave import then, when import wrappers from mycli.config import encrypt_mylogin_cnf from test.features.environment import MY_CNF_PATH, MYLOGIN_CNF_PATH, get_db_name_from_context from test.features.steps.utils import parse_cli_args_to_dict from test.utils import HOST, PASSWORD, PORT, USER TEST_LOGIN_PATH = "test_login_path" @when('we run mycli with arguments "{exact_args}" without arguments "{excluded_args}"') @when('we run mycli without arguments "{excluded_args}"') def step_run_cli_without_args(context, excluded_args, exact_args=""): wrappers.run_cli(context, run_args=parse_cli_args_to_dict(exact_args), exclude_args=parse_cli_args_to_dict(excluded_args).keys()) @then('status contains "{expression}"') def status_contains(context, expression): wrappers.expect_exact(context, f"{expression}", timeout=5) # Normally, the shutdown after scenario waits for the prompt. # But we may have changed the prompt, depending on parameters, # so let's wait for its last character context.cli.expect_exact(">") context.atprompt = True @when("we create my.cnf file") def step_create_my_cnf_file(context): my_cnf = f"[client]\nhost = {HOST}\nport = {PORT}\nuser = {USER}\npassword = {PASSWORD}\n" with open(MY_CNF_PATH, "w") as f: f.write(my_cnf) @when("we create mylogin.cnf file") def step_create_mylogin_cnf_file(context): os.environ.pop("MYSQL_TEST_LOGIN_FILE", None) mylogin_cnf = f"[{TEST_LOGIN_PATH}]\nhost = {HOST}\nport = {PORT}\nuser = {USER}\npassword = {PASSWORD}\n" with open(MYLOGIN_CNF_PATH, "wb") as f: input_file = io.StringIO(mylogin_cnf) f.write(encrypt_mylogin_cnf(input_file).read()) @then("we are logged in") def we_are_logged_in(context): db_name = get_db_name_from_context(context) context.cli.expect_exact(f"{db_name}>", timeout=5) context.atprompt = True ================================================ FILE: test/features/steps/crud_database.py ================================================ # type: ignore """Steps for behavioral style tests are defined in this module. Each step is defined by the string decorating it. This string is used to call the step in "*.feature" file. """ from behave import then, when import pexpect import wrappers from mycli.constants import DEFAULT_DATABASE @when("we create database") def step_db_create(context): """Send create database.""" context.cli.sendline(f"create database {context.conf['dbname_tmp']};") context.response = {"database_name": context.conf["dbname_tmp"]} @when("we drop database") def step_db_drop(context): """Send drop database.""" context.cli.sendline(f"drop database {context.conf['dbname_tmp']};") @when("we connect to test database") def step_db_connect_test(context): """Send connect to database.""" db_name = context.conf["dbname"] context.currentdb = db_name context.cli.sendline(f"use {db_name};") @when("we connect to quoted test database") def step_db_connect_quoted_tmp(context): """Send connect to database.""" db_name = context.conf["dbname"] context.currentdb = db_name context.cli.sendline(f"use `{db_name}`;") @when("we connect to tmp database") def step_db_connect_tmp(context): """Send connect to database.""" db_name = context.conf["dbname_tmp"] context.currentdb = db_name context.cli.sendline(f"use {db_name}") @when("we connect to dbserver") def step_db_connect_dbserver(context): """Send connect to database.""" context.currentdb = DEFAULT_DATABASE context.cli.sendline(f"use {DEFAULT_DATABASE}") @then("dbcli exits") def step_wait_exit(context): """Make sure the cli exits.""" wrappers.expect_exact(context, pexpect.EOF, timeout=5) @then("we see dbcli prompt") def step_see_prompt(context): """Wait to see the prompt.""" user = context.conf["user"] host = context.conf["host"] dbname = context.currentdb wrappers.wait_prompt(context, f"{user}@{host}:{dbname}> ") @then("we see help output") def step_see_help(context): for expected_line in context.fixture_data["help_commands.txt"]: # in case tests are run without extras if 'LLM' in expected_line: continue wrappers.expect_exact(context, expected_line, timeout=1) @then("we see database created") def step_see_db_created(context): """Wait to see create database output.""" wrappers.expect_exact(context, "Query OK, 1 row affected", timeout=2) @then("we see database dropped") def step_see_db_dropped(context): """Wait to see drop database output.""" wrappers.expect_exact(context, "Query OK, 0 rows affected", timeout=2) @then("we see database dropped and no default database") def step_see_db_dropped_no_default(context): """Wait to see drop database output.""" user = context.conf["user"] host = context.conf["host"] database = "(none)" context.currentdb = None wrappers.expect_exact(context, "Query OK, 0 rows affected", timeout=2) wrappers.wait_prompt(context, f"{user}@{host}:{database}>") @then("we see database connected") def step_see_db_connected(context): """Wait to see drop database output.""" wrappers.expect_exact(context, 'connected to database "', timeout=2) wrappers.expect_exact(context, '"', timeout=2) wrappers.expect_exact(context, f' as user "{context.conf["user"]}"', timeout=2) ================================================ FILE: test/features/steps/crud_table.py ================================================ # type: ignore """Steps for behavioral style tests are defined in this module. Each step is defined by the string decorating it. This string is used to call the step in "*.feature" file. """ from textwrap import dedent from behave import then, when import wrappers @when("we create table") def step_create_table(context): """Send create table.""" context.cli.sendline("create table a(x text);") @when("we insert into table") def step_insert_into_table(context): """Send insert into table.""" context.cli.sendline("""insert into a(x) values('xxx');""") @when("we update table") def step_update_table(context): """Send insert into table.""" context.cli.sendline("""update a set x = 'yyy' where x = 'xxx';""") @when("we select from table") def step_select_from_table(context): """Send select from table.""" context.cli.sendline("select * from a;") @when("we delete from table") def step_delete_from_table(context): """Send deete from table.""" context.cli.sendline("""delete from a where x = 'yyy';""") @when("we drop table") def step_drop_table(context): """Send drop table.""" context.cli.sendline("drop table a;") @then("we see table created") def step_see_table_created(context): """Wait to see create table output.""" wrappers.expect_exact(context, "Query OK, 0 rows affected", timeout=2) @then("we see record inserted") def step_see_record_inserted(context): """Wait to see insert output.""" wrappers.expect_exact(context, "Query OK, 1 row affected", timeout=2) @then("we see record updated") def step_see_record_updated(context): """Wait to see update output.""" wrappers.expect_exact(context, "Query OK, 1 row affected", timeout=2) @then("we see data selected") def step_see_data_selected(context): """Wait to see select output.""" expected = ( dedent( """ +-----+\r | x |\r +-----+\r | yyy |\r +-----+ """ ).strip() + '\r\n\r\n' ) wrappers.expect_pager( context, expected, timeout=2, ) wrappers.expect_exact(context, "1 row in set", timeout=2) @then("we see record deleted") def step_see_data_deleted(context): """Wait to see delete output.""" wrappers.expect_exact(context, "Query OK, 1 row affected", timeout=2) @then("we see table dropped") def step_see_table_dropped(context): """Wait to see drop output.""" wrappers.expect_exact(context, "Query OK, 0 rows affected", timeout=2) @when("we select null") def step_select_null(context): """Send select null.""" context.cli.sendline("select null;") @then("we see null selected") def step_see_null_selected(context): """Wait to see null output.""" expected = ( dedent( """ +--------+\r | NULL |\r +--------+\r | |\r +--------+ """ ).strip() + '\r\n\r\n' ) wrappers.expect_pager( context, expected, timeout=2, ) wrappers.expect_exact(context, "1 row in set", timeout=2) ================================================ FILE: test/features/steps/iocommands.py ================================================ # type: ignore import os from textwrap import dedent from behave import then, when import wrappers @when("we start external editor providing a file name") def step_edit_file(context): """Edit file with external editor.""" context.editor_file_name = os.path.join(context.package_root, f"test_file_{context.conf['vi']}.sql") if os.path.exists(context.editor_file_name): os.remove(context.editor_file_name) context.cli.sendline(f"\\e {os.path.basename(context.editor_file_name)}") wrappers.expect_exact(context, 'Entering Ex mode. Type "visual" to go to Normal mode.', timeout=4) wrappers.expect_exact(context, "\r\n:", timeout=4) @when('we type "{query}" in the editor') def step_edit_type_sql(context, query): context.cli.sendline("i") context.cli.sendline(query) context.cli.sendline(".") wrappers.expect_exact(context, "\r\n:", timeout=4) @when("we exit the editor") def step_edit_quit(context): context.cli.sendline("x") wrappers.expect_exact(context, "written", timeout=4) @then('we see "{query}" in prompt') def step_edit_done_sql(context, query): for match in query.split(" "): wrappers.expect_exact(context, match, timeout=5) # Cleanup the command line. context.cli.sendcontrol("c") # Cleanup the edited file. if context.editor_file_name and os.path.exists(context.editor_file_name): os.remove(context.editor_file_name) @when("we tee output") def step_tee_ouptut(context): context.tee_file_name = os.path.join(context.package_root, f"tee_file_{context.conf['vi']}.sql") if os.path.exists(context.tee_file_name): os.remove(context.tee_file_name) context.cli.sendline(f"tee {os.path.basename(context.tee_file_name)}") @when('we select "select {param}"') def step_query_select_number(context, param): context.cli.sendline(f"select {param}") expected = ( dedent( f""" +{'-' * (len(param) + 2)}+\r | {param} |\r +{'-' * (len(param) + 2)}+\r | {param} |\r +{'-' * (len(param) + 2)}+ """ ).strip() + '\r\n\r\n' ) wrappers.expect_pager( context, expected, timeout=5, ) wrappers.expect_exact(context, "1 row in set", timeout=2) @then('we see tabular result "{result}"') def step_see_tabular_result(context, result): wrappers.expect_exact(context, f'| {result} |', timeout=2) @then('we see csv result "{result}"') def step_see_csv_result(context, result): wrappers.expect_exact(context, f'"{result}"', timeout=2) @when('we query "{query}"') def step_query(context, query): context.cli.sendline(query) @when("we notee output") def step_notee_output(context): context.cli.sendline("notee") @then("we see 123456 in tee output") def step_see_123456_in_ouput(context): with open(context.tee_file_name) as f: assert "123456" in f.read() if os.path.exists(context.tee_file_name): os.remove(context.tee_file_name) @then('we see csv {result} in file output') def step_see_csv_result_in_redirected_ouput(context, result): wrappers.expect_exact(context, f'"{result}"', timeout=2) temp_filename = "/tmp/output1.csv" if os.path.exists(temp_filename): os.remove(temp_filename) @then('we see text {result} in file output') def step_see_text_result_in_redirected_ouput(context, result): wrappers.expect_exact(context, f' {result}', timeout=2) temp_filename = "/tmp/output1.txt" if os.path.exists(temp_filename): os.remove(temp_filename) @then("we see space 12 in command output") def step_see_space_12_in_command_ouput(context): wrappers.expect_exact(context, ' 12', timeout=2) @then("we see space 6 in command output") def step_see_space_6_in_command_ouput(context): wrappers.expect_exact(context, ' 6', timeout=2) @then('delimiter is set to "{delimiter}"') def delimiter_is_set(context, delimiter): wrappers.expect_exact(context, f"Changed delimiter to {delimiter}", timeout=2) ================================================ FILE: test/features/steps/named_queries.py ================================================ # type: ignore """Steps for behavioral style tests are defined in this module. Each step is defined by the string decorating it. This string is used to call the step in "*.feature" file. """ from behave import then, when import wrappers @when("we save a named query") def step_save_named_query(context): """Send \fs command.""" context.cli.sendline("\\fs foo SELECT 12345") @when("we use a named query") def step_use_named_query(context): """Send \f command.""" context.cli.sendline("\\f foo") @when("we delete a named query") def step_delete_named_query(context): """Send \fd command.""" context.cli.sendline("\\fd foo") @then("we see the named query saved") def step_see_named_query_saved(context): """Wait to see query saved.""" wrappers.expect_exact(context, "Saved.", timeout=2) @then("we see the named query executed") def step_see_named_query_executed(context): """Wait to see select output.""" wrappers.expect_exact(context, "SELECT 12345", timeout=2) @then("we see the named query deleted") def step_see_named_query_deleted(context): """Wait to see query deleted.""" wrappers.expect_exact(context, "foo: Deleted", timeout=2) @when("we save a named query with parameters") def step_save_named_query_with_parameters(context): """Send \fs command for query with parameters.""" context.cli.sendline('\\fs foo_args SELECT $1, "$2", "$3"') @when("we use named query with parameters") def step_use_named_query_with_parameters(context): """Send \f command with parameters.""" context.cli.sendline('\\f foo_args 101 second "third value"') @then("we see the named query with parameters executed") def step_see_named_query_with_parameters_executed(context): """Wait to see select output.""" wrappers.expect_exact(context, 'SELECT 101, "second", "third value"', timeout=2) @when("we use named query with too few parameters") def step_use_named_query_with_too_few_parameters(context): """Send \f command with missing parameters.""" context.cli.sendline("\\f foo_args 101") @then("we see the named query with parameters fail with missing parameters") def step_see_named_query_with_parameters_fail_with_missing_parameters(context): """Wait to see select output.""" wrappers.expect_exact(context, "missing substitution for $2 in query:", timeout=2) @when("we use named query with too many parameters") def step_use_named_query_with_too_many_parameters(context): """Send \f command with extra parameters.""" context.cli.sendline("\\f foo_args 101 102 103 104") @then("we see the named query with parameters fail with extra parameters") def step_see_named_query_with_parameters_fail_with_extra_parameters(context): """Wait to see select output.""" wrappers.expect_exact(context, "query does not have substitution parameter $4:", timeout=2) ================================================ FILE: test/features/steps/specials.py ================================================ # type: ignore """Steps for behavioral style tests are defined in this module. Each step is defined by the string decorating it. This string is used to call the step in "*.feature" file. """ from behave import then, when import wrappers @when("we refresh completions") def step_refresh_completions(context): """Send refresh command.""" context.cli.sendline("rehash") @then('we see text "{text}"') def step_see_text(context, text): """Wait to see given text message.""" wrappers.expect_exact(context, text, timeout=2) @then("we see completions refresh started") def step_see_refresh_started(context): """Wait to see refresh output.""" wrappers.expect_exact(context, "Auto-completion refresh started in the background.", timeout=2) ================================================ FILE: test/features/steps/utils.py ================================================ # type: ignore import shlex def parse_cli_args_to_dict(cli_args: str): args_dict = {} for arg in shlex.split(cli_args): if "=" in arg: key, value = arg.split("=") args_dict[key] = value else: args_dict[arg] = None return args_dict ================================================ FILE: test/features/steps/wrappers.py ================================================ # type: ignore from io import StringIO import re import sys import textwrap import pexpect def expect_exact(context, expected, timeout): timedout = False try: context.cli.expect_exact(expected, timeout=timeout) except pexpect.TIMEOUT: timedout = True if timedout: # Strip color codes out of the output. actual = re.sub(r"\x1b\[([0-9A-Za-z;?])+[m|K]?", "", context.cli.before) raise Exception( textwrap.dedent( f"""\ Expected: --- {expected!r} --- Actual: --- {actual!r} --- Full log: --- {context.logfile.getvalue()!r} --- """ ) ) def expect_pager(context, expected, timeout): expect_exact(context, f"{context.conf['pager_boundary']}\r\n{expected}{context.conf['pager_boundary']}\r\n", timeout=timeout) def run_cli(context, run_args=None, exclude_args=None): """Run the process using pexpect.""" run_args = run_args or {} rendered_args = [] exclude_args = set(exclude_args) if exclude_args else set() conf = dict(**context.conf) conf.update(run_args) def add_arg(name, key, value): if name not in exclude_args: if value is not None: rendered_args.extend((key, value)) else: rendered_args.append(key) if conf.get("host", None): add_arg("host", "-h", conf["host"]) if conf.get("user", None): add_arg("user", "-u", conf["user"]) if conf.get("pass", None): add_arg("pass", "-p", conf["pass"]) if conf.get("port", None): add_arg("port", "-P", str(conf["port"])) if conf.get("dbname", None): add_arg("dbname", "-D", conf["dbname"]) if conf.get("defaults-file", None): add_arg("defaults_file", "--defaults-file", conf["defaults-file"]) if conf.get("myclirc", None): add_arg("myclirc", "--myclirc", conf["myclirc"]) if conf.get("login_path"): add_arg("login_path", "--login-path", conf["login_path"]) for arg_name, arg_value in conf.items(): if arg_name.startswith("-"): add_arg(arg_name, arg_name, arg_value) try: cli_cmd = context.conf["cli_command"] except KeyError: cli_cmd = f'{sys.executable} -c "import coverage ; coverage.process_startup(); import mycli.main; mycli.main.cli()"' cmd_parts = [cli_cmd] + rendered_args cmd = " ".join(cmd_parts) context.cli = pexpect.spawnu(cmd, cwd=context.package_root) context.logfile = StringIO() context.cli.logfile = context.logfile context.exit_sent = False context.currentdb = context.conf["dbname"] def wait_prompt(context, prompt=None): """Make sure prompt is displayed.""" if prompt is None: user = context.conf["user"] host = context.conf["host"] dbname = context.currentdb prompt = (f"{user}@{host}:{dbname}>",) expect_exact(context, prompt, timeout=5) context.atprompt = True ================================================ FILE: test/features/wrappager.py ================================================ #!/usr/bin/env python import sys def wrappager(boundary: str) -> None: print(boundary) while 1: buf = sys.stdin.read(2048) if not buf: break sys.stdout.write(buf) print(boundary) if __name__ == "__main__": wrappager(sys.argv[1]) ================================================ FILE: test/myclirc ================================================ # vi: ft=dosini [main] # Enable or disable the automatic displaying of warnings ("SHOW WARNINGS") # after executing a SQL statement when applicable. show_warnings = False # Enables context sensitive auto-completion. If this is disabled the all # possible completions will be listed. smart_completion = True # Minimum characters typed before offering completion suggestions. # Suggestion: 3. min_completion_trigger = 1 # Multi-line mode allows breaking up the sql statements into multiple lines. If # this is set to True, then the end of the statements must have a semi-colon. # If this is set to False then sql statements can't be split into multiple # lines. End of line (return) is considered as the end of the statement. multi_line = False # Destructive warning mode will alert you before executing a sql statement # that may cause harm to the database such as "drop table", "drop database" # or "shutdown". destructive_warning = True # Queries starting with these keywords will activate the destructive warning. # UPDATE will not activate the warning if the statement includes a WHERE # clause. destructive_keywords = DROP SHUTDOWN DELETE TRUNCATE ALTER UPDATE # interactive query history location. history_file = ~/.mycli-history # log_file location. log_file = ~/.mycli.test.log # Default log level. Possible values: "CRITICAL", "ERROR", "WARNING", "INFO" # and "DEBUG". "NONE" disables logging. log_level = DEBUG # Log every query and its results to a file. Enable this by uncommenting the # line below. # audit_log = ~/.mycli-audit.log # Timing of sql statements and table rendering. timing = True # Show the full SQL when running a favorite query. Set to False to hide. show_favorite_query = True # Beep after long-running queries are completed; 0 to disable. beep_after_seconds = 0 # Table format. Possible values: ascii, double, github, # psql, plain, simple, grid, fancy_grid, pipe, orgtbl, rst, mediawiki, html, # latex, latex_booktabs, textile, moinmoin, jira, vertical, tsv, tsv_noheader, # csv, csv-noheader, jsonl, jsonl_unescaped. # Recommended: ascii table_format = ascii # Redirected otuput format # Recommended: csv. redirect_format = csv # How to display the missing value (ie NULL). Only certain table formats # support configuring the missing value. CSV for example always uses the # empty string, and JSON formats use native nulls. null_string = # How to align numeric data in tabular output: right or left. numeric_alignment = right # How to display binary values in tabular output: "hex", or "utf8". "utf8" # means attempt to render valid UTF-8 sequences as strings, then fall back # to hex rendering if not possible. binary_display = hex # A command to run after a successful output redirect, with {} to be replaced # with the escaped filename. Mac example: echo {} | pbcopy. Escaping is not # reliable/safe on Windows. post_redirect_command = "" # Syntax coloring style. Possible values (many support the "-dark" suffix): # manni, igor, xcode, vim, autumn, vs, rrt, native, perldoc, borland, tango, emacs, # friendly, monokai, paraiso, colorful, murphy, bw, pastie, paraiso, trac, default, # fruity. # Screenshots at https://mycli.net/syntax # Can be further modified in [colors] syntax_style = default # Keybindings: Possible values: emacs, vi. # Emacs mode: Ctrl-A is home, Ctrl-E is end. All emacs keybindings are available in the REPL. # When Vi mode is enabled you can use modal editing features offered by Vi in the REPL. key_bindings = emacs # Enabling this option will show the suggestions in a wider menu. Thus more items are suggested. wider_completion_menu = False # MySQL prompt # * \D - full current date, e.g. Sat Feb 14 15:55:48 2026 # * \R - current hour in 24-hour time (00–23) # * \r - current hour in 12-hour time (01–12) # * \m - minutes of the current time # * \s - seconds of the current time # * \P - AM/PM # * \d - selected database/schema # * \h - hostname of the server # * \H - shortened hostname of the server # * \p - connection port # * \j - connection socket basename # * \J - full connection socket path # * \k - connection socket basename OR the port # * \K - full connection socket path OR the port # * \T - connection SSL/TLS version # * \t - database vendor (Percona, MySQL, MariaDB, TiDB) # * \w - number of warnings, or "(none)" (requires frequent trips to the server) # * \W - number of warnings, or the empty string (requires frequent trips to the server) # * \y - uptime in seconds (requires frequent trips to the server) # * \Y - uptime in words (requires frequent trips to the server) # * \u - username # * \A - DSN alias # * \n - a newline # * \_ - a space # * \\ - a literal backslash # * \x1b[...m - an ANSI escape sequence (can style with color) prompt = "\t \u@\h:\d> " prompt_continuation = -> # Use the same prompt format strings to construct a status line in the toolbar, # where \B in the first position refers to the default toolbar showing keystrokes # and state. Example: # # toolbar = '\B\d \D' # # If \B is included, the additional content will begin on the next line. More # lines can be added with \n. If \B is not included, the customized toolbar # can be a single line. An empty value is the same as the default "\B". The # special literal value "None" will suppress the toolbar from appearing. toolbar = '' # Use the same prompt format strings to construct a terminal tab title. # The original XTerm docs call this title the "window title", but it now # probably refers to a terminal tab. This title is only updated as frequently # as the database is changed. terminal_tab_title = '' # Use the same prompt format strings to construct a terminal window title. # The original XTerm docs call this title the "icon title", but it now # probably refers to a terminal window which contains tabs. This title is # only updated as frequently as the database is changed. terminal_window_title = '' # Use the same prompt format strings to construct a window title in a terminal # multiplexer. Currently only tmux is supported. This title is only updated # as frequently as the database is changed. multiplex_window_title = '' # Use the same prompt format strings to construct a pane title in a terminal # multiplexer. Currently only tmux is supported. This title is only updated # as frequently as the database is changed. multiplex_pane_title = '' # Skip intro info on startup and outro info on exit less_chatty = True # Use alias from --login-path instead of host name in prompt login_path_as_host = False # Cause result sets to be displayed vertically if they are too wide for the current window, # and using normal tabular format otherwise. (This applies to statements terminated by ; or \G.) auto_vertical_output = False # keyword casing preference. Possible values "lower", "upper", "auto" keyword_casing = auto # disabled pager on startup enable_pager = True # Choose a specific pager pager = less # whether to show verbose warnings about the transition away from reading my.cnf my_cnf_transition_done = False # Whether to store and retrieve passwords from the system keyring. # See the documentation for https://pypi.org/project/keyring/ for your OS. # Note that the hostname is considered to be different if short or qualified. # This can be overridden with --use-keyring= at the CLI. # A password can be reset with --use-keyring=reset at the CLI. use_keyring = False [search] # Whether to apply syntax highlighting to the preview window in fuzzy history # search. There is a small performance penalty to enabling this. The "pygmentize" # CLI tool must also be available. The syntax style from the "syntax_style" # option will be respected, though additional customizations from [colors] will # not be applied. highlight_preview = False [connection] # character set for connections without --character-set being set default_character_set = utf8mb4 # whether to enable LOAD DATA LOCAL INFILE for connections without --local-infile being set default_local_infile = False # How often to send periodic background pings to the server when input is idle. Ticks are # roughly in seconds, but may be faster. Set to zero to disable. Suggestion: 300. default_keepalive_ticks = 0 # Sets the desired behavior for handling secure connections to the database server. # Possible values: # auto = SSL is preferred for TCP/IP connections. Will attempt to connect via SSL, but will fall # back to cleartext as needed. Will not attempt to connect with SSL over local sockets. # on = SSL is required. Will attempt to connect via SSL even on a local socket, and will fail if # a secure connection is not established. # off = do not use SSL. Will fail if the server requires a secure connection. default_ssl_mode = auto # SSL CA file for connections without --ssl-ca being set default_ssl_ca = # SSL CA directory for connections without --ssl-capath being set default_ssl_capath = # SSL X509 cert path for connections without --ssl-cert being set default_ssl_cert = # SSL X509 key for connections without --ssl-key being set default_ssl_key = # SSL cipher to use for connections without --ssl-cipher being set default_ssl_cipher = # whether to verify server's "Common Name" in its cert, for connections without # --ssl-verify-server-cert being set default_ssl_verify_server_cert = False [llm] # If set to a positive integer, truncate text/binary fields to that width # in bytes when sending sample data, to conserve tokens. Suggestion: 1024. prompt_field_truncate = None # If set to a positive integer, attempt to truncate various sections of LLM # prompt input to that number in bytes, to conserve tokens. Suggestion: # 1000000. prompt_section_truncate = None [keys] # possible values: exit, none control_d = exit # possible values: auto, fzf, reverse_isearch control_r = auto # comma-separated list: toolkit_default, summon, advancing_summon, prefixing_summon, advance, cancel # # * toolkit_default - ignore other behaviors and use prompt_toolkit's default bindings # * summon - when completions are not visible, summon them # * advancing_summon - when completions are not visible, summon them _and_ advance in the list # * prefixing_summon - when completions are not visible, summon them _and_ insert the common prefix # * advance - when completions are visible, advance in the list # * cancel - when completions are visible, toggle the list off control_space = summon, advance # comma-separated list: toolkit_default, summon, advancing_summon, prefixing_summon, advance, cancel tab = advancing_summon, advance # How long to wait for an Escape key sequence in vi mode. # 0.5 seconds is the prompt_toolkit default, but vi users may find that too long. # Shorter values mean that "Escape" alone is recognized more quickly. vi_ttimeoutlen = 0.1 # How long to wait for an Escape key sequence in Emacs mode. emacs_ttimeoutlen = 0.5 # Custom colors for the completion menu, toolbar, etc, with actual support # depending on the terminal, and the property being set. # Colors: #ffffff, bg:#ffffff, border:#ffffff. # Attributes: (no)blink, bold, dim, hidden, inherit, italic, reverse, strike, underline. [colors] completion-menu.completion.current = "bg:#ffffff #000000" completion-menu.completion = "bg:#008888 #ffffff" completion-menu.meta.completion.current = "bg:#44aaaa #000000" completion-menu.meta.completion = "bg:#448888 #ffffff" completion-menu.multi-column-meta = "bg:#aaffff #000000" scrollbar.arrow = "bg:#003333" scrollbar = "bg:#00aaaa" selected = "#ffffff bg:#6666aa" search = "#ffffff bg:#4444aa" search.current = "#ffffff bg:#44aa44" bottom-toolbar = "bg:#222222 #aaaaaa" bottom-toolbar.off = "bg:#222222 #888888" bottom-toolbar.on = "bg:#222222 #ffffff" search-toolbar = noinherit bold search-toolbar.text = nobold system-toolbar = noinherit bold arg-toolbar = noinherit bold arg-toolbar.text = nobold bottom-toolbar.transaction.valid = "bg:#222222 #00ff5f bold" bottom-toolbar.transaction.failed = "bg:#222222 #ff005f bold" prompt = '' continuation = '' # style classes for colored table output output.table-separator = "" output.header = "#00ff5f bold" output.odd-row = "" output.even-row = "" output.null = "#808080" output.status = "" output.status.warning-count = "" output.timing = "" # SQL syntax highlighting overrides # sql.comment = 'italic #408080' # sql.comment.multi-line = '' # sql.comment.single-line = '' # sql.comment.optimizer-hint = '' # sql.escape = 'border:#FF0000' # sql.keyword = 'bold #008000' # sql.datatype = 'nobold #B00040' # sql.literal = '' # sql.literal.date = '' # sql.symbol = '' # sql.quoted-schema-object = '' # sql.quoted-schema-object.escape = '' # sql.constant = '#880000' # sql.function = '#0000FF' # sql.variable = '#19177C' # sql.number = '#666666' # sql.number.binary = '' # sql.number.float = '' # sql.number.hex = '' # sql.number.integer = '' # sql.operator = '#666666' # sql.punctuation = '' # sql.string = '#BA2121' # sql.string.double-quouted = '' # sql.string.escape = 'bold #BB6622' # sql.string.single-quoted = '' # sql.whitespace = '' # Favorite queries. # You can add your favorite queries here. They will be available in the # REPL when you type `\f` or `\f `. [favorite_queries] check = 'select "✔"' foo_args = 'SELECT $1, "$2", "$3"' # example = "SELECT * FROM example_table WHERE id = 1" # Initial commands to execute when connecting to any database. [init-commands] global_limit = set sql_select_limit=9999 # read_only = "SET SESSION TRANSACTION READ ONLY" # Use the -d option to reference a DSN. # Special characters in passwords and other strings can be escaped with URL encoding. [alias_dsn] # example_dsn = mysql://[user[:password]@][host][:port][/dbname] # Initial commands to execute when connecting to a DSN alias. [alias_dsn.init-commands] # Define one or more SQL statements per alias (semicolon-separated). # example_dsn = "SET sql_select_limit=1000; SET time_zone='+00:00'" ================================================ FILE: test/test.txt ================================================ mycli rocks! ================================================ FILE: test/test_clistyle.py ================================================ # type: ignore """Test the mycli.clistyle module.""" from pygments.style import Style from pygments.token import Token import pytest from mycli.clistyle import style_factory_toolkit @pytest.mark.skip(reason="incompatible with new prompt toolkit") def test_style_factory_toolkit(): """Test that a Pygments Style class is created.""" header = "bold underline #ansired" cli_style = {"Token.Output.Header": header} style = style_factory_toolkit("default", cli_style) assert isinstance(style(), Style) assert Token.Output.Header in style.styles assert header == style.styles[Token.Output.Header] @pytest.mark.skip(reason="incompatible with new prompt toolkit") def test_style_factory_toolkit_unknown_name(): """Test that an unrecognized name will not throw an error.""" style = style_factory_toolkit("foobar", {}) assert isinstance(style(), Style) ================================================ FILE: test/test_clitoolbar.py ================================================ from prompt_toolkit.shortcuts import PromptSession from mycli.clitoolbar import create_toolbar_tokens_func from mycli.main import MyCli def test_create_toolbar_tokens_func_initial(): m = MyCli() m.prompt_app = PromptSession() iteration = 0 f = create_toolbar_tokens_func(m, lambda: iteration == 0, m.toolbar_format) result = f() assert any("right-arrow accepts full-line suggestion" in token for token in result) def test_create_toolbar_tokens_func_short(): m = MyCli() m.prompt_app = PromptSession() iteration = 1 f = create_toolbar_tokens_func(m, lambda: iteration == 0, m.toolbar_format) result = f() assert not any("right-arrow accepts full-line suggestion" in token for token in result) ================================================ FILE: test/test_completion_engine.py ================================================ # type: ignore import pytest from mycli.packages import special from mycli.packages.completion_engine import ( _find_doubled_backticks, is_inside_quotes, suggest_type, ) def sorted_dicts(dicts): """input is a list of dicts.""" return sorted(tuple(x.items()) for x in dicts) def test_select_suggests_cols_with_visible_table_scope(): suggestions = suggest_type("SELECT FROM tabl", "SELECT ") assert sorted_dicts(suggestions) == sorted_dicts([ {"type": "alias", "aliases": ["tabl"]}, {"type": "column", "tables": [(None, "tabl", None)]}, {"type": "function", "schema": []}, {"type": "introducer"}, ]) def test_select_suggests_cols_with_qualified_table_scope(): suggestions = suggest_type("SELECT FROM sch.tabl", "SELECT ") assert sorted_dicts(suggestions) == sorted_dicts([ {"type": "alias", "aliases": ["tabl"]}, {"type": "column", "tables": [("sch", "tabl", None)]}, {"type": "function", "schema": []}, {"type": "introducer"}, ]) @pytest.mark.parametrize( "expression", [ "SELECT * FROM tabl WHERE ", "SELECT * FROM tabl WHERE (", "SELECT * FROM tabl WHERE bar OR ", "SELECT * FROM tabl WHERE foo = 1 AND ", "SELECT * FROM tabl WHERE (bar > 10 AND ", "SELECT * FROM tabl WHERE (bar AND (baz OR (qux AND (", "SELECT * FROM tabl WHERE 10 < ", "SELECT * FROM tabl WHERE foo BETWEEN ", "SELECT * FROM tabl WHERE foo BETWEEN foo AND ", ], ) def test_where_suggests_columns_functions(expression): suggestions = suggest_type(expression, expression) assert sorted_dicts(suggestions) == sorted_dicts([ {"type": "alias", "aliases": ["tabl"]}, {"type": "column", "tables": [(None, "tabl", None)]}, {"type": "function", "schema": []}, {"type": "introducer"}, ]) def test_where_equals_suggests_enum_values_first(): expression = "SELECT * FROM tabl WHERE foo = " suggestions = suggest_type(expression, expression) assert sorted_dicts(suggestions) == sorted_dicts([ {"type": "enum_value", "tables": [(None, "tabl", None)], "column": "foo", "parent": None}, {"type": "alias", "aliases": ["tabl"]}, {"type": "column", "tables": [(None, "tabl", None)]}, {"type": "function", "schema": []}, {"type": "introducer"}, ]) @pytest.mark.parametrize( "expression", [ "SELECT * FROM tabl WHERE foo IN (", "SELECT * FROM tabl WHERE foo IN (bar, ", ], ) def test_where_in_suggests_columns(expression): suggestions = suggest_type(expression, expression) assert sorted_dicts(suggestions) == sorted_dicts([ {"type": "alias", "aliases": ["tabl"]}, {"type": "column", "tables": [(None, "tabl", None)]}, {"type": "function", "schema": []}, {"type": "introducer"}, ]) def test_where_equals_any_suggests_columns_or_keywords(): text = "SELECT * FROM tabl WHERE foo = ANY(" suggestions = suggest_type(text, text) assert sorted_dicts(suggestions) == sorted_dicts([ {"type": "alias", "aliases": ["tabl"]}, {"type": "column", "tables": [(None, "tabl", None)]}, {"type": "function", "schema": []}, {"type": "introducer"}, ]) def test_where_convert_using_suggests_character_set(): text = 'SELECT * FROM tabl WHERE CONVERT(foo USING ' suggestions = suggest_type(text, text) assert suggestions == [{"type": "character_set"}] def test_where_cast_character_set_suggests_character_set(): text = 'SELECT * FROM tabl WHERE CAST(foo AS CHAR CHARACTER SET ' suggestions = suggest_type(text, text) assert suggestions == [{"type": "character_set"}] def test_lparen_suggests_cols(): suggestion = suggest_type("SELECT MAX( FROM tbl", "SELECT MAX(") assert suggestion == [{"type": "column", "tables": [(None, "tbl", None)]}] def test_operand_inside_function_suggests_cols1(): suggestion = suggest_type("SELECT MAX(col1 + FROM tbl", "SELECT MAX(col1 + ") assert suggestion == [{"type": "column", "tables": [(None, "tbl", None)]}] def test_operand_inside_function_suggests_cols2(): suggestion = suggest_type("SELECT MAX(col1 + col2 + FROM tbl", "SELECT MAX(col1 + col2 + ") assert suggestion == [{"type": "column", "tables": [(None, "tbl", None)]}] def test_operand_inside_function_suggests_cols3(): suggestion = suggest_type("SELECT MAX(col1 || FROM tbl", "SELECT MAX(col1 || ") assert suggestion == [{"type": "column", "tables": [(None, "tbl", None)]}] def test_operand_inside_function_suggests_cols4(): suggestion = suggest_type("SELECT MAX(col1 LIKE FROM tbl", "SELECT MAX(col1 LIKE ") assert suggestion == [{"type": "column", "tables": [(None, "tbl", None)]}] def test_operand_inside_function_suggests_cols5(): suggestion = suggest_type("SELECT MAX(col1 DIV FROM tbl", "SELECT MAX(col1 DIV ") assert suggestion == [{"type": "column", "tables": [(None, "tbl", None)]}] @pytest.mark.xfail def test_arrow_op_inside_function_suggests_nothing(): suggestion = suggest_type("SELECT MAX(col1-> FROM tbl", "SELECT MAX(col1->") assert suggestion == [] def test_select_suggests_cols_and_funcs(): suggestions = suggest_type("SELECT ", "SELECT ") assert sorted_dicts(suggestions) == sorted_dicts([ {"type": "alias", "aliases": []}, {"type": "column", "tables": []}, {"type": "function", "schema": []}, {"type": "introducer"}, ]) @pytest.mark.parametrize( "expression", [ "SELECT * FROM ", "INSERT INTO ", "COPY ", "UPDATE ", "DESCRIBE ", "DESC ", "EXPLAIN ", "SELECT * FROM foo JOIN ", ], ) def test_expression_suggests_tables_views_and_schemas(expression): suggestions = suggest_type(expression, expression) assert sorted_dicts(suggestions) == sorted_dicts([ {"type": "table", "schema": []}, {"type": "view", "schema": []}, {"type": "database"}, ]) @pytest.mark.parametrize( "expression", [ "SELECT * FROM sch.", "INSERT INTO sch.", "COPY sch.", "UPDATE sch.", "DESCRIBE sch.", "DESC sch.", "EXPLAIN sch.", "SELECT * FROM foo JOIN sch.", ], ) def test_expression_suggests_qualified_tables_views_and_schemas(expression): suggestions = suggest_type(expression, expression) assert sorted_dicts(suggestions) == sorted_dicts([ {"type": "table", "schema": "sch"}, {"type": "view", "schema": "sch"}, ]) def test_truncate_suggests_tables_and_schemas(): suggestions = suggest_type("TRUNCATE ", "TRUNCATE ") assert sorted_dicts(suggestions) == sorted_dicts([ {"type": "table", "schema": []}, {"type": "database"}, ]) def test_truncate_suggests_qualified_tables(): suggestions = suggest_type("TRUNCATE sch.", "TRUNCATE sch.") assert sorted_dicts(suggestions) == sorted_dicts([ {"type": "table", "schema": "sch"}, ]) def test_distinct_suggests_cols(): suggestions = suggest_type("SELECT DISTINCT ", "SELECT DISTINCT ") assert suggestions == [{"type": "column", "tables": []}] def test_col_comma_suggests_cols(): suggestions = suggest_type("SELECT a, b, FROM tbl", "SELECT a, b,") assert sorted_dicts(suggestions) == sorted_dicts([ {"type": "alias", "aliases": ["tbl"]}, {"type": "column", "tables": [(None, "tbl", None)]}, {"type": "function", "schema": []}, {"type": "introducer"}, ]) def test_table_comma_suggests_tables_and_schemas(): suggestions = suggest_type("SELECT a, b FROM tbl1, ", "SELECT a, b FROM tbl1, ") assert sorted_dicts(suggestions) == sorted_dicts([ {"type": "database"}, {"type": "table", "schema": []}, {"type": "view", "schema": []}, ]) def test_into_suggests_tables_and_schemas(): suggestion = suggest_type("INSERT INTO ", "INSERT INTO ") assert sorted_dicts(suggestion) == sorted_dicts([ {"type": "database"}, {"type": "table", "schema": []}, {"type": "view", "schema": []}, ]) def test_insert_into_lparen_suggests_cols(): suggestions = suggest_type("INSERT INTO abc (", "INSERT INTO abc (") assert suggestions == [{"type": "column", "tables": [(None, "abc", None)]}] def test_insert_into_lparen_partial_text_suggests_cols(): suggestions = suggest_type("INSERT INTO abc (i", "INSERT INTO abc (i") assert suggestions == [{"type": "column", "tables": [(None, "abc", None)]}] def test_insert_into_lparen_comma_suggests_cols(): suggestions = suggest_type("INSERT INTO abc (id,", "INSERT INTO abc (id,") assert suggestions == [{"type": "column", "tables": [(None, "abc", None)]}] def test_partially_typed_col_name_suggests_col_names(): suggestions = suggest_type("SELECT * FROM tabl WHERE col_n", "SELECT * FROM tabl WHERE col_n") assert sorted_dicts(suggestions) == sorted_dicts([ {"type": "alias", "aliases": ["tabl"]}, {"type": "column", "tables": [(None, "tabl", None)]}, {"type": "function", "schema": []}, {"type": "introducer"}, ]) def test_dot_suggests_cols_of_a_table_or_schema_qualified_table(): suggestions = suggest_type("SELECT tabl. FROM tabl", "SELECT tabl.") assert sorted_dicts(suggestions) == sorted_dicts([ {"type": "column", "tables": [(None, "tabl", None)]}, {"type": "table", "schema": "tabl"}, {"type": "view", "schema": "tabl"}, {"type": "function", "schema": "tabl"}, ]) def test_dot_suggests_cols_of_an_alias(): suggestions = suggest_type("SELECT t1. FROM tabl1 t1, tabl2 t2", "SELECT t1.") assert sorted_dicts(suggestions) == sorted_dicts([ {"type": "table", "schema": "t1"}, {"type": "view", "schema": "t1"}, {"type": "column", "tables": [(None, "tabl1", "t1")]}, {"type": "function", "schema": "t1"}, ]) def test_dot_col_comma_suggests_cols_or_schema_qualified_table(): suggestions = suggest_type("SELECT t1.a, t2. FROM tabl1 t1, tabl2 t2", "SELECT t1.a, t2.") assert sorted_dicts(suggestions) == sorted_dicts([ {"type": "column", "tables": [(None, "tabl2", "t2")]}, {"type": "table", "schema": "t2"}, {"type": "view", "schema": "t2"}, {"type": "function", "schema": "t2"}, ]) @pytest.mark.parametrize( "expression", [ "SELECT * FROM (", "SELECT * FROM foo WHERE EXISTS (", "SELECT * FROM foo WHERE bar AND NOT EXISTS (", "SELECT 1 AS", ], ) def test_sub_select_suggests_keyword(expression): suggestion = suggest_type(expression, expression) assert suggestion == [{"type": "keyword"}] @pytest.mark.parametrize( "expression", [ "SELECT * FROM (S", "SELECT * FROM foo WHERE EXISTS (S", "SELECT * FROM foo WHERE bar AND NOT EXISTS (S", ], ) def test_sub_select_partial_text_suggests_keyword(expression): suggestion = suggest_type(expression, expression) assert suggestion == [{"type": "keyword"}] def test_outer_table_reference_in_exists_subquery_suggests_columns(): q = "SELECT * FROM foo f WHERE EXISTS (SELECT 1 FROM bar WHERE f." suggestions = suggest_type(q, q) assert suggestions == [ {"type": "column", "tables": [(None, "foo", "f")]}, {"type": "table", "schema": "f"}, {"type": "view", "schema": "f"}, {"type": "function", "schema": "f"}, ] @pytest.mark.parametrize( "expression", [ "SELECT * FROM (SELECT * FROM ", "SELECT * FROM foo WHERE EXISTS (SELECT * FROM ", "SELECT * FROM foo WHERE bar AND NOT EXISTS (SELECT * FROM ", ], ) def test_sub_select_table_name_completion(expression): suggestion = suggest_type(expression, expression) assert sorted_dicts(suggestion) == sorted_dicts([ {"type": "database"}, {"type": "table", "schema": []}, {"type": "view", "schema": []}, ]) def test_sub_select_col_name_completion(): suggestions = suggest_type("SELECT * FROM (SELECT FROM abc", "SELECT * FROM (SELECT ") assert sorted_dicts(suggestions) == sorted_dicts([ {"type": "alias", "aliases": ["abc"]}, {"type": "column", "tables": [(None, "abc", None)]}, {"type": "function", "schema": []}, {"type": "introducer"}, ]) @pytest.mark.xfail def test_sub_select_multiple_col_name_completion(): suggestions = suggest_type("SELECT * FROM (SELECT a, FROM abc", "SELECT * FROM (SELECT a, ") assert sorted_dicts(suggestions) == sorted_dicts([ {"type": "column", "tables": [(None, "abc", None)]}, {"type": "function", "schema": []}, {"type": "introducer"}, ]) def test_sub_select_dot_col_name_completion(): suggestions = suggest_type("SELECT * FROM (SELECT t. FROM tabl t", "SELECT * FROM (SELECT t.") assert sorted_dicts(suggestions) == sorted_dicts([ {"type": "column", "tables": [(None, "tabl", "t")]}, {"type": "table", "schema": "t"}, {"type": "view", "schema": "t"}, {"type": "function", "schema": "t"}, ]) @pytest.mark.parametrize("join_type", ["", "INNER", "LEFT", "RIGHT OUTER"]) @pytest.mark.parametrize("tbl_alias", ["", "foo"]) def test_join_suggests_tables_and_schemas(tbl_alias, join_type): text = f"SELECT * FROM abc {tbl_alias} {join_type} JOIN " suggestion = suggest_type(text, text) assert sorted_dicts(suggestion) == sorted_dicts([ {"type": "database"}, {"type": "table", "schema": []}, {"type": "view", "schema": []}, ]) @pytest.mark.parametrize( "sql", [ "SELECT * FROM abc a JOIN def d ON a.", "SELECT * FROM abc a JOIN def d ON a.id = d.id AND a.", ], ) def test_join_alias_dot_suggests_cols1(sql): suggestions = suggest_type(sql, sql) assert sorted_dicts(suggestions) == sorted_dicts([ {"type": "column", "tables": [(None, "abc", "a")]}, {"type": "table", "schema": "a"}, {"type": "view", "schema": "a"}, {"type": "function", "schema": "a"}, ]) @pytest.mark.parametrize( "sql", [ "SELECT * FROM abc a JOIN def d ON a.id = d.", "SELECT * FROM abc a JOIN def d ON a.id = d.id AND a.id2 = d.", ], ) def test_join_alias_dot_suggests_cols2(sql): suggestions = suggest_type(sql, sql) assert sorted_dicts(suggestions) == sorted_dicts([ {"type": "column", "tables": [(None, "def", "d")]}, {"type": "table", "schema": "d"}, {"type": "view", "schema": "d"}, {"type": "function", "schema": "d"}, ]) @pytest.mark.parametrize( "sql", [ "select a.x, b.y from abc a join bcd b on ", "select a.x, b.y from abc a join bcd b on a.id = b.id OR ", "select a.x, b.y from abc a join bcd b on a.id = b.id + ", "select a.x, b.y from abc a join bcd b on a.id = b.id < ", ], ) def test_on_suggests_aliases(sql): suggestions = suggest_type(sql, sql) assert suggestions == [{"type": "alias", "aliases": ["a", "b"]}] @pytest.mark.parametrize( "sql", [ "select abc.x, bcd.y from abc join bcd on ", "select abc.x, bcd.y from abc join bcd on abc.id = bcd.id AND ", ], ) def test_on_suggests_tables(sql): suggestions = suggest_type(sql, sql) assert suggestions == [{"type": "alias", "aliases": ["abc", "bcd"]}] @pytest.mark.parametrize( "sql", [ "select a.x, b.y from abc a join bcd b on a.id = ", "select a.x, b.y from abc a join bcd b on a.id = b.id AND a.id2 = ", ], ) def test_on_suggests_aliases_right_side(sql): suggestions = suggest_type(sql, sql) assert suggestions == [{"type": "alias", "aliases": ["a", "b"]}] @pytest.mark.parametrize( "sql", [ "select abc.x, bcd.y from abc join bcd on ", "select abc.x, bcd.y from abc join bcd on abc.id = bcd.id and ", ], ) def test_on_suggests_tables_right_side(sql): suggestions = suggest_type(sql, sql) assert suggestions == [{"type": "alias", "aliases": ["abc", "bcd"]}] @pytest.mark.parametrize("col_list", ["", "col1, "]) def test_join_using_suggests_common_columns(col_list): text = "select * from abc inner join def using (" + col_list assert suggest_type(text, text) == [{"type": "column", "tables": [(None, "abc", None), (None, "def", None)], "drop_unique": True}] @pytest.mark.parametrize( "sql", [ "SELECT * FROM abc a JOIN def d ON a.id = d.id JOIN ghi g ON g.", "SELECT * FROM abc a JOIN def d ON a.id = d.id AND a.id2 = d.id2 JOIN ghi g ON d.id = g.id AND g.", ], ) def test_two_join_alias_dot_suggests_cols1(sql): suggestions = suggest_type(sql, sql) assert sorted_dicts(suggestions) == sorted_dicts([ {"type": "column", "tables": [(None, "ghi", "g")]}, {"type": "table", "schema": "g"}, {"type": "view", "schema": "g"}, {"type": "function", "schema": "g"}, ]) def test_2_statements_2nd_current(): suggestions = suggest_type("select * from a; select * from ", "select * from a; select * from ") assert sorted_dicts(suggestions) == sorted_dicts([ {"type": "table", "schema": []}, {"type": "view", "schema": []}, {"type": "database"}, ]) suggestions = suggest_type("select * from a; select from b", "select * from a; select ") assert sorted_dicts(suggestions) == sorted_dicts([ {"type": "alias", "aliases": ["b"]}, {"type": "column", "tables": [(None, "b", None)]}, {"type": "function", "schema": []}, {"type": "introducer"}, ]) # Should work even if first statement is invalid suggestions = suggest_type("select * from; select * from ", "select * from; select * from ") assert sorted_dicts(suggestions) == sorted_dicts([ {"type": "table", "schema": []}, {"type": "view", "schema": []}, {"type": "database"}, ]) def test_2_statements_1st_current(): suggestions = suggest_type("select * from ; select * from b", "select * from ") assert sorted_dicts(suggestions) == sorted_dicts([ {"type": "database"}, {"type": "table", "schema": []}, {"type": "view", "schema": []}, ]) suggestions = suggest_type("select from a; select * from b", "select ") assert sorted_dicts(suggestions) == sorted_dicts([ {"type": "alias", "aliases": ["a"]}, {"type": "column", "tables": [(None, "a", None)]}, {"type": "function", "schema": []}, {"type": "introducer"}, ]) def test_3_statements_2nd_current(): suggestions = suggest_type("select * from a; select * from ; select * from c", "select * from a; select * from ") assert sorted_dicts(suggestions) == sorted_dicts([ {"type": "database"}, {"type": "table", "schema": []}, {"type": "view", "schema": []}, ]) suggestions = suggest_type("select * from a; select from b; select * from c", "select * from a; select ") assert sorted_dicts(suggestions) == sorted_dicts([ {"type": "alias", "aliases": ["b"]}, {"type": "column", "tables": [(None, "b", None)]}, {"type": "function", "schema": []}, {"type": "introducer"}, ]) def test_create_db_with_template(): suggestions = suggest_type("create database foo with template ", "create database foo with template ") assert sorted_dicts(suggestions) == sorted_dicts([{"type": "database"}]) @pytest.mark.parametrize("initial_text", ["", " ", "\t \t"]) def test_specials_included_for_initial_completion(initial_text): suggestions = suggest_type(initial_text, initial_text) assert sorted_dicts(suggestions) == sorted_dicts([{"type": "keyword"}, {"type": "special"}]) @pytest.mark.parametrize('initial_text', ['REDIRECT']) def test_specials_included_with_caps(initial_text): suggestions = suggest_type(initial_text, initial_text) assert sorted_dicts(suggestions) == sorted_dicts([{'type': 'keyword'}, {'type': 'special'}]) def test_specials_not_included_after_initial_token(): suggestions = suggest_type("create table foo (dt d", "create table foo (dt d") assert sorted_dicts(suggestions) == sorted_dicts([{"type": "keyword"}]) def test_drop_schema_qualified_table_suggests_only_tables(): text = "DROP TABLE schema_name.table_name" suggestions = suggest_type(text, text) assert suggestions == [{"type": "table", "schema": "schema_name"}] @pytest.mark.parametrize("text", [",", " ,", "sel ,"]) def test_handle_pre_completion_comma_gracefully(text): suggestions = suggest_type(text, text) assert iter(suggestions) def test_cross_join(): text = "select * from v1 cross join v2 JOIN v1.id, " suggestions = suggest_type(text, text) assert sorted_dicts(suggestions) == sorted_dicts([ {"type": "database"}, {"type": "table", "schema": []}, {"type": "view", "schema": []}, ]) @pytest.mark.parametrize( "expression", [ "SELECT 1 AS ", "SELECT 1 FROM tabl AS ", ], ) def test_after_as(expression): suggestions = suggest_type(expression, expression) assert set(suggestions) == set() @pytest.mark.parametrize( "expression", [ "\\. ", "select 1; \\. ", "select 1;\\. ", "select 1 ; \\. ", "source ", "truncate table test; source ", "truncate table test ; source ", "truncate table test;source ", ], ) def test_source_is_file(expression): # "source" has to be registered by hand because that usually happens inside MyCLI in mycli/main.py special.register_special_command(..., 'source', '\\. ', 'Execute commands from file.', aliases=['\\.']) suggestions = suggest_type(expression, expression) assert suggestions == [{"type": "file_name"}] @pytest.mark.parametrize( "expression", [ "\\f ", ], ) def test_favorite_name_suggestion(expression): suggestions = suggest_type(expression, expression) assert suggestions == [{"type": "favoritequery"}] def test_order_by(): text = "select * from foo order by " suggestions = suggest_type(text, text) assert suggestions == [{"tables": [(None, "foo", None)], "type": "column"}] def test_quoted_where(): text = "'where i=';" suggestions = suggest_type(text, text) assert suggestions == [{"type": "keyword"}] def test_find_doubled_backticks_none(): text = 'select `ab`' assert _find_doubled_backticks(text) == [] def test_find_doubled_backticks_some(): text = 'select `a``b`' assert _find_doubled_backticks(text) == [9, 10] def test_inside_quotes_01(): text = "select '" assert is_inside_quotes(text, len(text)) == 'single' def test_inside_quotes_02(): text = "select '\\'" assert is_inside_quotes(text, len(text)) == 'single' def test_inside_quotes_03(): text = "select '`" assert is_inside_quotes(text, len(text)) == 'single' def test_inside_quotes_04(): text = 'select "' assert is_inside_quotes(text, len(text)) == 'double' def test_inside_quotes_05(): text = 'select "\\"\'' assert is_inside_quotes(text, len(text)) == 'double' def test_inside_quotes_06(): text = 'select ""' assert is_inside_quotes(text, len(text)) is False @pytest.mark.parametrize( ["text", "position", "expected"], [ ("select `'", len("select `'"), 'backtick'), ("select `' ", len("select `' "), 'backtick'), ("select `'", -1, 'backtick'), ("select `'", -2, False), ('select `ab` ', -1, False), ('select `ab` ', -2, 'backtick'), ('select `a``b` ', -1, False), ('select `a``b` ', -2, 'backtick'), ('select `a``b` ', -3, 'backtick'), ('select `a``b` ', -4, 'backtick'), ('select `a``b` ', -5, 'backtick'), ('select `a``b` ', -6, 'backtick'), ('select `a``b` ', -7, False), ] ) # fmt: skip def test_inside_quotes_backtick_01(text, position, expected): assert is_inside_quotes(text, position) == expected def test_inside_quotes_backtick_02(): """Empty backtick pairs are treated as a doubled (escaped) backtick. This is okay because it is invalid SQL, and we don't have to complete on it. """ text = 'select ``' assert is_inside_quotes(text, -1) is False def test_inside_quotes_backtick_03(): """Empty backtick pairs are treated as a doubled (escaped) backtick. This is okay because it is invalid SQL, and we don't have to complete on it. """ text = 'select ``' assert is_inside_quotes(text, -2) is False ================================================ FILE: test/test_completion_refresher.py ================================================ # type: ignore import time from unittest.mock import Mock, patch import pytest @pytest.fixture def refresher(): from mycli.completion_refresher import CompletionRefresher return CompletionRefresher() def test_ctor(refresher): """Refresher object should contain a few handlers. :param refresher: :return: """ assert len(refresher.refreshers) > 0 actual_handlers = list(refresher.refreshers.keys()) expected_handlers = [ "databases", "schemata", "tables", "enum_values", "users", "functions", "procedures", 'character_sets', 'collations', "special_commands", "show_commands", "keywords", ] assert expected_handlers == actual_handlers def test_refresh_called_once(refresher): """ :param refresher: :return: """ callbacks = Mock() sqlexecute = Mock() with patch.object(refresher, "_bg_refresh") as bg_refresh: actual = refresher.refresh(sqlexecute, callbacks) time.sleep(1) # Wait for the thread to work. assert actual[0].preamble is None assert actual[0].header is None assert actual[0].rows is None assert actual[0].status == "Auto-completion refresh started in the background." bg_refresh.assert_called_with(sqlexecute, callbacks, {}) def test_refresh_called_twice(refresher): """If refresh is called a second time, it should be restarted. :param refresher: :return: """ callbacks = Mock() sqlexecute = Mock() def dummy_bg_refresh(*args): time.sleep(3) # seconds refresher._bg_refresh = dummy_bg_refresh actual1 = refresher.refresh(sqlexecute, callbacks) time.sleep(1) # Wait for the thread to work. assert actual1[0].preamble is None assert actual1[0].header is None assert actual1[0].rows is None assert actual1[0].status == "Auto-completion refresh started in the background." actual2 = refresher.refresh(sqlexecute, callbacks) time.sleep(1) # Wait for the thread to work. assert actual2[0].preamble is None assert actual2[0].header is None assert actual2[0].rows is None assert actual2[0].status == "Auto-completion refresh restarted." def test_refresh_with_callbacks(refresher): """Callbacks must be called. :param refresher: """ callbacks = [Mock()] sqlexecute_class = Mock() sqlexecute = Mock() with patch("mycli.completion_refresher.SQLExecute", sqlexecute_class): # Set refreshers to 0: we're not testing refresh logic here refresher.refreshers = {} refresher.refresh(sqlexecute, callbacks) time.sleep(1) # Wait for the thread to work. assert callbacks[0].call_count == 1 ================================================ FILE: test/test_config.py ================================================ # type: ignore """Unit tests for the mycli.config module.""" from io import BytesIO, StringIO, TextIOWrapper import os import struct import sys from tempfile import NamedTemporaryFile import pytest from mycli.config import ( get_mylogin_cnf_path, open_mylogin_cnf, read_and_decrypt_mylogin_cnf, read_config_file, str_to_bool, strip_matching_quotes, ) from test.utils import TEMPFILE_PREFIX LOGIN_PATH_FILE = os.path.abspath(os.path.join(os.path.dirname(__file__), "mylogin.cnf")) def open_bmylogin_cnf(name): """Open contents of *name* in a BytesIO buffer.""" with open(name, "rb") as f: buf = BytesIO() buf.write(f.read()) return buf def test_read_mylogin_cnf(): """Tests that a login path file can be read and decrypted.""" mylogin_cnf = open_mylogin_cnf(LOGIN_PATH_FILE) assert isinstance(mylogin_cnf, TextIOWrapper) contents = mylogin_cnf.read() for word in ("[test]", "user", "password", "host", "port"): assert word in contents def test_decrypt_blank_mylogin_cnf(): """Test that a blank login path file is handled correctly.""" mylogin_cnf = read_and_decrypt_mylogin_cnf(BytesIO()) assert mylogin_cnf is None def test_corrupted_login_key(): """Test that a corrupted login path key is handled correctly.""" buf = open_bmylogin_cnf(LOGIN_PATH_FILE) # Skip past the unused bytes buf.seek(4) # Write null bytes over half the login key buf.write(b"\0\0\0\0\0\0\0\0\0\0") buf.seek(0) mylogin_cnf = read_and_decrypt_mylogin_cnf(buf) assert mylogin_cnf is None def test_corrupted_pad(): """Tests that a login path file with a corrupted pad is partially read.""" buf = open_bmylogin_cnf(LOGIN_PATH_FILE) # Skip past the login key buf.seek(24) # Skip option group len_buf = buf.read(4) (cipher_len,) = struct.unpack(" " @dbtest def test_prompt_socket_overrides_port(executor): mycli = MyCli() mycli.prompt_format = "\\t \\u@\\h:\\k \\d> " mycli.sqlexecute = SQLExecute mycli.sqlexecute.server_info = ServerInfo.from_version_string("8.0.44-0ubuntu0.24.04.1") mycli.sqlexecute.host = None mycli.sqlexecute.socket = "/var/run/mysqld/mysqld.sock" mycli.sqlexecute.user = DEFAULT_USER mycli.sqlexecute.dbname = DEFAULT_DATABASE mycli.sqlexecute.port = DEFAULT_PORT prompt = mycli.get_prompt(mycli.prompt_format, 0) assert prompt == f"MySQL {DEFAULT_USER}@{DEFAULT_HOST}:mysqld.sock {DEFAULT_DATABASE}> " @dbtest def test_prompt_socket_short_host(executor): mycli = MyCli() mycli.prompt_format = "\\t \\u@\\H:\\k \\d> " mycli.sqlexecute = SQLExecute mycli.sqlexecute.server_info = ServerInfo.from_version_string("8.0.44-0ubuntu0.24.04.1") mycli.sqlexecute.host = f'{DEFAULT_HOST}.localdomain' mycli.sqlexecute.socket = None mycli.sqlexecute.user = DEFAULT_USER mycli.sqlexecute.dbname = DEFAULT_DATABASE mycli.sqlexecute.port = DEFAULT_PORT prompt = mycli.get_prompt(mycli.prompt_format, 0) assert prompt == f"MySQL {DEFAULT_USER}@{DEFAULT_HOST}:{DEFAULT_PORT} {DEFAULT_DATABASE}> " @dbtest def test_enable_show_warnings(executor): mycli = MyCli() mycli.register_special_commands() sql = "\\W" result = run(executor, sql) assert result[0]["status"] == "Show warnings enabled." @dbtest def test_disable_show_warnings(executor): mycli = MyCli() mycli.register_special_commands() sql = "\\w" result = run(executor, sql) assert result[0]["status"] == "Show warnings disabled." @dbtest def test_output_ddl_with_warning_and_show_warnings_enabled(executor): runner = CliRunner() db = TEST_DATABASE table = "table_that_definitely_does_not_exist_1234" sql = f"DROP TABLE IF EXISTS {db}.{table}" result = runner.invoke(cli, args=CLI_ARGS + ["--show-warnings", "--no-warn"], input=sql) expected = f"Level\tCode\tMessage\nNote\t1051\tUnknown table '{db}.table_that_definitely_does_not_exist_1234'\n" assert expected in result.output @dbtest def test_output_with_warning_and_show_warnings_enabled(executor): runner = CliRunner() sql = "SELECT 1 + '0 foo'" result = runner.invoke(cli, args=CLI_ARGS + ["--show-warnings"], input=sql) expected = "1 + '0 foo'\n1.0\nLevel\tCode\tMessage\nWarning\t1292\tTruncated incorrect DOUBLE value: '0 foo'\n" assert expected in result.output @dbtest def test_output_with_warning_and_show_warnings_disabled(executor): runner = CliRunner() sql = "SELECT 1 + '0 foo'" result = runner.invoke(cli, args=CLI_ARGS + ["--no-show-warnings"], input=sql) expected = "1 + '0 foo'\n1.0\nLevel\tCode\tMessage\nWarning\t1292\tTruncated incorrect DOUBLE value: '0 foo'\n" assert expected not in result.output @dbtest def test_output_with_multiple_warnings_in_single_statement(executor): runner = CliRunner() sql = "SELECT 1 + '0 foo', 2 + '0 foo'" result = runner.invoke(cli, args=CLI_ARGS + ["--show-warnings"], input=sql) expected = ( "1 + '0 foo'\t2 + '0 foo'\n" "1.0\t2.0\n" "Level\tCode\tMessage\n" "Warning\t1292\tTruncated incorrect DOUBLE value: '0 foo'\n" "Warning\t1292\tTruncated incorrect DOUBLE value: '0 foo'\n" ) assert expected in result.output @dbtest def test_output_with_multiple_warnings_in_multiple_statements(executor): runner = CliRunner() sql = "SELECT 1 + '0 foo'; SELECT 2 + '0 foo'" result = runner.invoke(cli, args=CLI_ARGS + ["--show-warnings"], input=sql) expected = ( "1 + '0 foo'\n" "1.0\n" "Level\tCode\tMessage\n" "Warning\t1292\tTruncated incorrect DOUBLE value: '0 foo'\n" "2 + '0 foo'\n" "2.0\n" "Level\tCode\tMessage\n" "Warning\t1292\tTruncated incorrect DOUBLE value: '0 foo'\n" ) assert expected in result.output @dbtest def test_execute_arg(executor): run(executor, "create table test (a text)") run(executor, 'insert into test values("abc")') sql = "select * from test;" runner = CliRunner() result = runner.invoke(cli, args=CLI_ARGS + ["-e", sql]) assert result.exit_code == 0 assert "abc" in result.output result = runner.invoke(cli, args=CLI_ARGS + ["--execute", sql]) assert result.exit_code == 0 assert "abc" in result.output expected = "a\nabc\n" assert expected in result.output @dbtest def test_execute_arg_with_checkpoint(executor): run(executor, "create table test (a text)") run(executor, 'insert into test values("abc")') sql = "select * from test;" runner = CliRunner() with NamedTemporaryFile(prefix=TEMPFILE_PREFIX, mode="w", delete=False) as checkpoint: checkpoint.close() result = runner.invoke(cli, args=CLI_ARGS + ["--execute", sql, f"--checkpoint={checkpoint.name}"]) assert result.exit_code == 0 with open(checkpoint.name, 'r') as f: contents = f.read() assert sql in contents os.remove(checkpoint.name) sql = 'select 10 from nonexistent_table;' result = runner.invoke(cli, args=CLI_ARGS + ["--execute", sql, f"--checkpoint={checkpoint.name}"]) assert result.exit_code != 0 with open(checkpoint.name, 'r') as f: contents = f.read() assert sql not in contents # delete=False means we should try to clean up # we don't really need "try" here as open() would have already failed try: if os.path.exists(checkpoint.name): os.remove(checkpoint.name) except Exception as e: print(f"An error occurred while attempting to delete the file: {e}") @dbtest def test_execute_arg_with_table(executor): run(executor, "create table test (a text)") run(executor, 'insert into test values("abc")') sql = "select * from test;" runner = CliRunner() result = runner.invoke(cli, args=CLI_ARGS + ["-e", sql] + ["--table"]) expected = "+-----+\n| a |\n+-----+\n| abc |\n+-----+\n" assert result.exit_code == 0 assert expected in result.output @dbtest def test_execute_arg_with_csv(executor): run(executor, "create table test (a text)") run(executor, 'insert into test values("abc")') sql = "select * from test;" runner = CliRunner() result = runner.invoke(cli, args=CLI_ARGS + ["-e", sql] + ["--csv"]) expected = '"a"\n"abc"\n' assert result.exit_code == 0 assert expected in "".join(result.output) @dbtest def test_batch_mode(executor): run(executor, """create table test(a text)""") run(executor, """insert into test values('abc'), ('def'), ('ghi')""") sql = "select count(*) from test;\nselect * from test limit 1;" runner = CliRunner() result = runner.invoke(cli, args=CLI_ARGS, input=sql) assert result.exit_code == 0 assert "count(*)\n3\na\nabc\n" in "".join(result.output) @dbtest def test_batch_mode_multiline_statement(executor): run(executor, """create table test(a text)""") run(executor, """insert into test values('abc'), ('def'), ('ghi')""") sql = "select count(*)\nfrom test;\nselect * from test limit 1;" runner = CliRunner() result = runner.invoke(cli, args=CLI_ARGS, input=sql) assert result.exit_code == 0 assert "count(*)\n3\na\nabc\n" in "".join(result.output) @dbtest def test_batch_mode_table(executor): run(executor, """create table test(a text)""") run(executor, """insert into test values('abc'), ('def'), ('ghi')""") sql = "select count(*) from test;\nselect * from test limit 1;" runner = CliRunner() result = runner.invoke(cli, args=CLI_ARGS + ["-t"], input=sql) expected = dedent("""\ +----------+ | count(*) | +----------+ | 3 | +----------+ +-----+ | a | +-----+ | abc | +-----+""") assert result.exit_code == 0 assert expected in result.output @dbtest def test_batch_mode_csv(executor): run(executor, """create table test(a text, b text)""") run(executor, """insert into test (a, b) values('abc', 'de\nf'), ('ghi', 'jkl')""") sql = "select * from test;" runner = CliRunner() result = runner.invoke(cli, args=CLI_ARGS + ["--csv"], input=sql) expected = '"a","b"\n"abc","de\nf"\n"ghi","jkl"\n' assert result.exit_code == 0 assert expected in "".join(result.output) def test_thanks_picker_utf8(): name = thanks_picker() assert name and isinstance(name, str) def test_help_strings_end_with_periods(): """Make sure click options have help text that end with a period.""" for param in cli.params: if isinstance(param, click.core.Option): assert hasattr(param, "help") assert param.help.endswith(".") def test_command_descriptions_end_with_periods(): """Make sure that mycli commands' descriptions end with a period.""" MyCli() for _, command in SPECIAL_COMMANDS.items(): assert command[3].endswith(".") def output(monkeypatch, terminal_size, testdata, explicit_pager, expect_pager): global clickoutput clickoutput = "" m = MyCli(myclirc=default_config_file) class TestOutput: def get_size(self): size = namedtuple("Size", "rows columns") size.columns, size.rows = terminal_size return size class TestExecute: host = "test" user = "test" dbname = "test" server_info = ServerInfo.from_version_string("unknown") port = 0 socket = '' def server_type(self): return ["test"] class PromptBuffer: output = TestOutput() app = None m.prompt_app = PromptBuffer() m.sqlexecute = TestExecute() m.explicit_pager = explicit_pager def echo_via_pager(s): assert expect_pager global clickoutput clickoutput += "".join(s) def secho(s): assert not expect_pager global clickoutput clickoutput += s + "\n" monkeypatch.setattr(click, "echo_via_pager", echo_via_pager) monkeypatch.setattr(click, "secho", secho) m.output(testdata, SQLResult()) if clickoutput.endswith("\n"): clickoutput = clickoutput[:-1] assert clickoutput == "\n".join(testdata) def test_conditional_pager(monkeypatch): testdata = "Lorem ipsum dolor sit amet consectetur adipiscing elit sed do".split(" ") # User didn't set pager, output doesn't fit screen -> pager output(monkeypatch, terminal_size=(5, 10), testdata=testdata, explicit_pager=False, expect_pager=True) # User didn't set pager, output fits screen -> no pager output(monkeypatch, terminal_size=(20, 20), testdata=testdata, explicit_pager=False, expect_pager=False) # User manually configured pager, output doesn't fit screen -> pager output(monkeypatch, terminal_size=(5, 10), testdata=testdata, explicit_pager=True, expect_pager=True) # User manually configured pager, output fit screen -> pager output(monkeypatch, terminal_size=(20, 20), testdata=testdata, explicit_pager=True, expect_pager=True) SPECIAL_COMMANDS["nopager"].handler() output(monkeypatch, terminal_size=(5, 10), testdata=testdata, explicit_pager=False, expect_pager=False) SPECIAL_COMMANDS["pager"].handler("") def test_reserved_space_is_integer(monkeypatch): """Make sure that reserved space is returned as an integer.""" def stub_terminal_size(): return (5, 5) with monkeypatch.context() as m: m.setattr(shutil, "get_terminal_size", stub_terminal_size) mycli = MyCli() assert isinstance(mycli.get_reserved_space(), int) def test_list_dsn(monkeypatch): monkeypatch.setattr(MyCli, "system_config_files", []) monkeypatch.setattr(MyCli, "pwd_config_file", os.devnull) runner = CliRunner() # keep Windows from locking the file with delete=False with NamedTemporaryFile(prefix=TEMPFILE_PREFIX, mode="w", delete=False) as myclirc: myclirc.write( dedent("""\ [alias_dsn] test = mysql://test/test """) ) myclirc.flush() args = ["--list-dsn", "--myclirc", myclirc.name] result = runner.invoke(cli, args=args) assert result.output == "test\n" result = runner.invoke(cli, args=args + ["--verbose"]) assert result.output == "test : mysql://test/test\n" # delete=False means we should try to clean up try: if os.path.exists(myclirc.name): os.remove(myclirc.name) except Exception as e: print(f"An error occurred while attempting to delete the file: {e}") def test_prettify_statement(): statement = "SELECT 1" m = MyCli() pretty_statement = m.handle_prettify_binding(statement) assert pretty_statement == "SELECT\n 1;" def test_unprettify_statement(): statement = "SELECT\n 1" m = MyCli() unpretty_statement = m.handle_unprettify_binding(statement) assert unpretty_statement == "SELECT 1;" def test_list_ssh_config(): runner = CliRunner() # keep Windows from locking the file with delete=False with NamedTemporaryFile(prefix=TEMPFILE_PREFIX, mode="w", delete=False) as ssh_config: ssh_config.write( dedent("""\ Host test Hostname test.example.com User joe Port 22222 IdentityFile ~/.ssh/gateway """) ) ssh_config.flush() args = ["--list-ssh-config", "--ssh-config-path", ssh_config.name] result = runner.invoke(cli, args=args) assert "test\n" in result.output result = runner.invoke(cli, args=args + ["--verbose"]) assert "test : test.example.com\n" in result.output # delete=False means we should try to clean up try: if os.path.exists(ssh_config.name): os.remove(ssh_config.name) except Exception as e: print(f"An error occurred while attempting to delete the file: {e}") def test_dsn(monkeypatch): # Setup classes to mock mycli.main.MyCli class Formatter: format_name = None class Logger: def debug(self, *args, **args_dict): pass def warning(self, *args, **args_dict): pass class MockMyCli: config = { "main": {}, "alias_dsn": {}, "connection": { "default_keepalive_ticks": 0, }, } def __init__(self, **args): self.logger = Logger() self.destructive_warning = False self.main_formatter = Formatter() self.redirect_formatter = Formatter() self.ssl_mode = "auto" self.my_cnf = {"client": {}, "mysqld": {}} self.default_keepalive_ticks = 0 def connect(self, **args): MockMyCli.connect_args = args def run_query(self, query, new_line=True): pass import mycli.main monkeypatch.setattr(mycli.main, "MyCli", MockMyCli) runner = CliRunner() # When a user supplies a DSN as database argument to mycli, # use these values. result = runner.invoke(mycli.main.cli, args=["mysql://dsn_user:dsn_passwd@dsn_host:1/dsn_database"]) assert result.exit_code == 0, result.output + " " + str(result.exception) assert ( MockMyCli.connect_args["user"] == "dsn_user" and MockMyCli.connect_args["passwd"] == "dsn_passwd" and MockMyCli.connect_args["host"] == "dsn_host" and MockMyCli.connect_args["port"] == 1 and MockMyCli.connect_args["database"] == "dsn_database" ) MockMyCli.connect_args = None # When a use supplies a DSN as database argument to mycli, # and used command line arguments, use the command line # arguments. result = runner.invoke( mycli.main.cli, args=[ "mysql://dsn_user:dsn_passwd@dsn_host:2/dsn_database", "--user", "arg_user", "--password", "arg_password", "--host", "arg_host", "--port", "3", "--database", "arg_database", ], ) assert result.exit_code == 0, result.output + " " + str(result.exception) assert ( MockMyCli.connect_args["user"] == "arg_user" and MockMyCli.connect_args["passwd"] == "arg_password" and MockMyCli.connect_args["host"] == "arg_host" and MockMyCli.connect_args["port"] == 3 and MockMyCli.connect_args["database"] == "arg_database" ) MockMyCli.config = { "main": {}, "alias_dsn": {"test": "mysql://alias_dsn_user:alias_dsn_passwd@alias_dsn_host:4/alias_dsn_database"}, "connection": { "default_keepalive_ticks": 0, }, } MockMyCli.connect_args = None # When a user uses a DSN from the configuration file (alias_dsn), # use these values. result = runner.invoke(cli, args=["--dsn", "test"]) assert result.exit_code == 0, result.output + " " + str(result.exception) assert ( MockMyCli.connect_args["user"] == "alias_dsn_user" and MockMyCli.connect_args["passwd"] == "alias_dsn_passwd" and MockMyCli.connect_args["host"] == "alias_dsn_host" and MockMyCli.connect_args["port"] == 4 and MockMyCli.connect_args["database"] == "alias_dsn_database" ) MockMyCli.config = { "main": {}, "alias_dsn": {"test": "mysql://alias_dsn_user:alias_dsn_passwd@alias_dsn_host:4/alias_dsn_database"}, "connection": { "default_keepalive_ticks": 0, }, } MockMyCli.connect_args = None # When a user uses a DSN from the configuration file (alias_dsn) # and used command line arguments, use the command line arguments. result = runner.invoke( cli, args=[ "--dsn", "test", "", "--user", "arg_user", "--password", "arg_password", "--host", "arg_host", "--port", "5", "--database", "arg_database", ], ) assert result.exit_code == 0, result.output + " " + str(result.exception) assert ( MockMyCli.connect_args["user"] == "arg_user" and MockMyCli.connect_args["passwd"] == "arg_password" and MockMyCli.connect_args["host"] == "arg_host" and MockMyCli.connect_args["port"] == 5 and MockMyCli.connect_args["database"] == "arg_database" ) # Use a DSN without password result = runner.invoke(mycli.main.cli, args=["mysql://dsn_user@dsn_host:6/dsn_database"]) assert result.exit_code == 0, result.output + " " + str(result.exception) assert ( MockMyCli.connect_args["user"] == "dsn_user" and MockMyCli.connect_args["passwd"] is None and MockMyCli.connect_args["host"] == "dsn_host" and MockMyCli.connect_args["port"] == 6 and MockMyCli.connect_args["database"] == "dsn_database" ) # Use a DSN with query parameters result = runner.invoke(mycli.main.cli, args=["mysql://dsn_user:dsn_passwd@dsn_host:6/dsn_database?ssl_mode=off"]) assert result.exit_code == 0, result.output + " " + str(result.exception) assert ( MockMyCli.connect_args["user"] == "dsn_user" and MockMyCli.connect_args["passwd"] == "dsn_passwd" and MockMyCli.connect_args["host"] == "dsn_host" and MockMyCli.connect_args["port"] == 6 and MockMyCli.connect_args["database"] == "dsn_database" and MockMyCli.connect_args["ssl"] is None ) # When a user uses a DSN with query parameters, and also used command line # arguments, prefer the command line arguments. MockMyCli.connect_args = None MockMyCli.config = { "main": {}, "alias_dsn": {}, "connection": { "default_keepalive_ticks": 0, }, } # keepalive_ticks as a query parameter result = runner.invoke(mycli.main.cli, args=["mysql://dsn_user:dsn_passwd@dsn_host:6/dsn_database?keepalive_ticks=30"]) assert result.exit_code == 0, result.output + " " + str(result.exception) assert MockMyCli.connect_args["keepalive_ticks"] == 30 MockMyCli.connect_args = None # When a user uses a DSN with query parameters, and also used command line # arguments, use the command line arguments. result = runner.invoke( mycli.main.cli, args=[ 'mysql://dsn_user:dsn_passwd@dsn_host:6/dsn_database?ssl_mode=off', '--ssl-mode=on', ], ) assert result.exit_code == 0, result.output + ' ' + str(result.exception) assert MockMyCli.connect_args['user'] == 'dsn_user' assert MockMyCli.connect_args['passwd'] == 'dsn_passwd' assert MockMyCli.connect_args['host'] == 'dsn_host' assert MockMyCli.connect_args['port'] == 6 assert MockMyCli.connect_args['database'] == 'dsn_database' assert MockMyCli.connect_args['ssl']['mode'] == 'on' # Accept a literal DSN with the --dsn flag (not only an alias) result = runner.invoke( mycli.main.cli, args=[ '--dsn', 'mysql://dsn_user:dsn_passwd@dsn_host:6/dsn_database', ], ) assert result.exit_code == 0, result.output + ' ' + str(result.exception) assert ( MockMyCli.connect_args['user'] == 'dsn_user' and MockMyCli.connect_args['passwd'] == 'dsn_passwd' and MockMyCli.connect_args['host'] == 'dsn_host' and MockMyCli.connect_args['port'] == 6 and MockMyCli.connect_args['database'] == 'dsn_database' ) # accept socket as a query parameter result = runner.invoke( mycli.main.cli, args=[ f'mysql://dsn_user:dsn_passwd@{DEFAULT_HOST}/dsn_database?socket=mysql.sock', ], ) assert result.exit_code == 0, result.output + ' ' + str(result.exception) assert MockMyCli.connect_args['user'] == 'dsn_user' assert MockMyCli.connect_args['passwd'] == 'dsn_passwd' assert MockMyCli.connect_args['host'] == DEFAULT_HOST assert MockMyCli.connect_args['database'] == 'dsn_database' assert MockMyCli.connect_args['socket'] == 'mysql.sock' # accept character_set as a query parameter result = runner.invoke( mycli.main.cli, args=[ f'mysql://dsn_user:dsn_passwd@{DEFAULT_HOST}/dsn_database?character_set=latin1', ], ) assert result.exit_code == 0, result.output + ' ' + str(result.exception) assert MockMyCli.connect_args['user'] == 'dsn_user' assert MockMyCli.connect_args['passwd'] == 'dsn_passwd' assert MockMyCli.connect_args['host'] == DEFAULT_HOST assert MockMyCli.connect_args['database'] == 'dsn_database' assert MockMyCli.connect_args['character_set'] == 'latin1' # --character_set overrides character_set as a query parameter result = runner.invoke( mycli.main.cli, args=[ f'mysql://dsn_user:dsn_passwd@{DEFAULT_HOST}/dsn_database?character_set=latin1', '--character-set=utf8mb3', ], ) assert result.exit_code == 0, result.output + ' ' + str(result.exception) assert MockMyCli.connect_args['user'] == 'dsn_user' assert MockMyCli.connect_args['passwd'] == 'dsn_passwd' assert MockMyCli.connect_args['host'] == DEFAULT_HOST assert MockMyCli.connect_args['database'] == 'dsn_database' assert MockMyCli.connect_args['character_set'] == 'utf8mb3' def test_password_flag_uses_sentinel(monkeypatch): class Formatter: format_name = None class Logger: def debug(self, *args, **args_dict): pass def warning(self, *args, **args_dict): pass class MockMyCli: config = { 'main': {}, 'alias_dsn': {}, 'connection': { 'default_keepalive_ticks': 0, }, } def __init__(self, **_args): self.logger = Logger() self.destructive_warning = False self.main_formatter = Formatter() self.redirect_formatter = Formatter() self.ssl_mode = 'auto' self.my_cnf = {'client': {}, 'mysqld': {}} self.default_keepalive_ticks = 0 def connect(self, **args): MockMyCli.connect_args = args def run_query(self, query, new_line=True): pass import mycli.main monkeypatch.setattr(mycli.main, 'MyCli', MockMyCli) runner = CliRunner() result = runner.invoke( mycli.main.cli, args=[ '--user', 'user', '--host', DEFAULT_HOST, '--port', f'{DEFAULT_PORT}', '--database', 'database', '--password', ], ) assert result.exit_code == 0, result.output + ' ' + str(result.exception) assert MockMyCli.connect_args['passwd'] == EMPTY_PASSWORD_FLAG_SENTINEL def test_ssh_config(monkeypatch): # Setup classes to mock mycli.main.MyCli class Formatter: format_name = None class Logger: def debug(self, *args, **args_dict): pass def warning(self, *args, **args_dict): pass class MockMyCli: config = { "main": {}, "alias_dsn": {}, "connection": { "default_keepalive_ticks": 0, }, } def __init__(self, **args): self.logger = Logger() self.destructive_warning = False self.main_formatter = Formatter() self.redirect_formatter = Formatter() self.ssl_mode = "auto" self.my_cnf = {"client": {}, "mysqld": {}} self.default_keepalive_ticks = 0 def connect(self, **args): MockMyCli.connect_args = args def run_query(self, query, new_line=True): pass import mycli.main monkeypatch.setattr(mycli.main, "MyCli", MockMyCli) runner = CliRunner() # Setup temporary configuration # keep Windows from locking the file with delete=False with NamedTemporaryFile(prefix=TEMPFILE_PREFIX, mode="w", delete=False) as ssh_config: ssh_config.write( dedent("""\ Host test Hostname test.example.com User joe Port 22222 IdentityFile ~/.ssh/gateway """) ) ssh_config.flush() # When a user supplies a ssh config. result = runner.invoke(mycli.main.cli, args=["--ssh-config-path", ssh_config.name, "--ssh-config-host", "test"]) assert result.exit_code == 0, result.output + " " + str(result.exception) assert ( MockMyCli.connect_args["ssh_user"] == "joe" and MockMyCli.connect_args["ssh_host"] == "test.example.com" and MockMyCli.connect_args["ssh_port"] == 22222 and MockMyCli.connect_args["ssh_key_filename"] == os.path.expanduser("~") + "/.ssh/gateway" ) # When a user supplies a ssh config host as argument to mycli, # and used command line arguments, use the command line # arguments. result = runner.invoke( mycli.main.cli, args=[ "--ssh-config-path", ssh_config.name, "--ssh-config-host", "test", "--ssh-user", "arg_user", "--ssh-host", "arg_host", "--ssh-port", "3", "--ssh-key-filename", "/path/to/key", ], ) assert result.exit_code == 0, result.output + " " + str(result.exception) assert ( MockMyCli.connect_args["ssh_user"] == "arg_user" and MockMyCli.connect_args["ssh_host"] == "arg_host" and MockMyCli.connect_args["ssh_port"] == 3 and MockMyCli.connect_args["ssh_key_filename"] == "/path/to/key" ) # delete=False means we should try to clean up try: if os.path.exists(ssh_config.name): os.remove(ssh_config.name) except Exception as e: print(f"An error occurred while attempting to delete the file: {e}") @dbtest def test_init_command_arg(executor): init_command = "set sql_select_limit=1000" sql = 'show variables like "sql_select_limit";' runner = CliRunner() result = runner.invoke(cli, args=CLI_ARGS + ["--init-command", init_command], input=sql) expected = "sql_select_limit\t1000\n" assert result.exit_code == 0 assert expected in result.output @dbtest def test_init_command_multiple_arg(executor): init_command = "set sql_select_limit=2000; set max_join_size=20000" sql = 'show variables like "sql_select_limit";\nshow variables like "max_join_size"' runner = CliRunner() result = runner.invoke(cli, args=CLI_ARGS + ["--init-command", init_command], input=sql) expected_sql_select_limit = "sql_select_limit\t2000\n" expected_max_join_size = "max_join_size\t20000\n" assert result.exit_code == 0 assert expected_sql_select_limit in result.output assert expected_max_join_size in result.output @dbtest def test_global_init_commands(executor): """Tests that global init-commands from config are executed by default.""" # The global init-commands section in test/myclirc sets sql_select_limit=9999 sql = 'show variables like "sql_select_limit";' runner = CliRunner() result = runner.invoke(cli, args=CLI_ARGS, input=sql) expected = "sql_select_limit\t9999\n" assert result.exit_code == 0 assert expected in result.output @dbtest def test_execute_with_logfile(executor): """Test that --execute combines with --logfile""" sql = 'select 1' runner = CliRunner() with NamedTemporaryFile(prefix=TEMPFILE_PREFIX, mode="w", delete=False) as logfile: result = runner.invoke(mycli.main.cli, args=CLI_ARGS + ["--logfile", logfile.name, "--execute", sql]) assert result.exit_code == 0 assert os.path.getsize(logfile.name) > 0 try: if os.path.exists(logfile.name): os.remove(logfile.name) except Exception as e: print(f"An error occurred while attempting to delete the file: {e}") def test_null_string_config(monkeypatch): monkeypatch.setattr(MyCli, 'system_config_files', []) monkeypatch.setattr(MyCli, 'pwd_config_file', os.devnull) runner = CliRunner() # keep Windows from locking the file with delete=False with NamedTemporaryFile(mode='w', delete=False) as myclirc: myclirc.write( dedent("""\ [main] null_string = """) ) myclirc.flush() args = CLI_ARGS + ['--myclirc', myclirc.name, '--format=table', '--execute', 'SELECT NULL'] result = runner.invoke(mycli.main.cli, args=args) assert '' in result.output assert '' not in result.output # delete=False means we should try to clean up try: if os.path.exists(myclirc.name): os.remove(myclirc.name) except Exception as e: print(f'An error occurred while attempting to delete the file: {e}') ================================================ FILE: test/test_naive_completion.py ================================================ # type: ignore from prompt_toolkit.completion import Completion from prompt_toolkit.document import Document import pytest @pytest.fixture def completer(): import mycli.sqlcompleter as sqlcompleter return sqlcompleter.SQLCompleter(smart_completion=False) @pytest.fixture def complete_event(): from unittest.mock import Mock return Mock() def test_empty_string_completion(completer, complete_event): text = "" position = 0 result = list(completer.get_completions(Document(text=text, cursor_position=position), complete_event)) assert result == list(map(Completion, completer.all_completions)) def test_select_keyword_completion(completer, complete_event): text = "SEL" position = len("SEL") result = list(completer.get_completions(Document(text=text, cursor_position=position), complete_event)) assert result == [Completion(text="SELECT", start_position=-3)] def test_function_name_completion(completer, complete_event): text = "SELECT MA" position = len("SELECT MA") result = list(completer.get_completions(Document(text=text, cursor_position=position), complete_event)) assert sorted(x.text for x in result) == [ 'MAKEDATE', 'MAKETIME', 'MAKE_SET', 'MASTER', 'MASTER_AUTO_POSITION', 'MASTER_BIND', 'MASTER_COMPRESSION_ALGORITHMS', 'MASTER_CONNECT_RETRY', 'MASTER_DELAY', 'MASTER_HEARTBEAT_PERIOD', 'MASTER_HOST', 'MASTER_LOG_FILE', 'MASTER_LOG_POS', 'MASTER_PASSWORD', 'MASTER_PORT', 'MASTER_POS_WAIT', 'MASTER_PUBLIC_KEY_PATH', 'MASTER_RETRY_COUNT', 'MASTER_SSL', 'MASTER_SSL_CA', 'MASTER_SSL_CAPATH', 'MASTER_SSL_CERT', 'MASTER_SSL_CIPHER', 'MASTER_SSL_CRL', 'MASTER_SSL_CRLPATH', 'MASTER_SSL_KEY', 'MASTER_SSL_VERIFY_SERVER_CERT', 'MASTER_TLS_CIPHERSUITES', 'MASTER_TLS_VERSION', 'MASTER_USER', 'MASTER_ZSTD_COMPRESSION_LEVEL', 'MATCH', 'MAX', 'MAXVALUE', 'MAX_CONNECTIONS_PER_HOUR', 'MAX_QUERIES_PER_HOUR', 'MAX_ROWS', 'MAX_SIZE', 'MAX_UPDATES_PER_HOUR', 'MAX_USER_CONNECTIONS', ] def test_column_name_completion(completer, complete_event): text = "SELECT FROM users" position = len("SELECT ") result = list(completer.get_completions(Document(text=text, cursor_position=position), complete_event)) assert result == list(map(Completion, completer.all_completions)) def test_special_name_completion(completer, complete_event): text = "\\" position = len("\\") result = set(completer.get_completions(Document(text=text, cursor_position=position), complete_event)) # Special commands will NOT be suggested during naive completion mode. assert result == set() ================================================ FILE: test/test_parseutils.py ================================================ # type: ignore import pytest from mycli.packages.parseutils import ( extract_columns_from_select, extract_tables, extract_tables_from_complete_statements, is_destructive, is_dropping_database, queries_start_with, query_has_where_clause, query_starts_with, ) def test_extract_columns_from_select(): try: columns = extract_columns_from_select("SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT FROM INFORMATION_SCHEMA.COLUMNS") except Exception: columns = [] assert columns == ["COLUMN_NAME", "DATA_TYPE", "IS_NULLABLE", "COLUMN_DEFAULT"] def test_empty_string(): tables = extract_tables("") assert tables == [] def test_simple_select_single_table(): tables = extract_tables("select * from abc") assert tables == [(None, "abc", None)] def test_simple_select_single_table_schema_qualified(): tables = extract_tables("select * from abc.def") assert tables == [("abc", "def", None)] def test_simple_select_multiple_tables(): tables = extract_tables("select * from abc, def") assert sorted(tables) == [(None, "abc", None), (None, "def", None)] def test_simple_select_multiple_tables_schema_qualified(): tables = extract_tables("select * from abc.def, ghi.jkl") assert sorted(tables) == [("abc", "def", None), ("ghi", "jkl", None)] def test_simple_select_with_cols_single_table(): tables = extract_tables("select a,b from abc") assert tables == [(None, "abc", None)] def test_simple_select_with_cols_single_table_schema_qualified(): tables = extract_tables("select a,b from abc.def") assert tables == [("abc", "def", None)] def test_simple_select_with_cols_multiple_tables(): tables = extract_tables("select a,b from abc, def") assert sorted(tables) == [(None, "abc", None), (None, "def", None)] def test_simple_select_with_cols_multiple_tables_with_schema(): tables = extract_tables("select a,b from abc.def, def.ghi") assert sorted(tables) == [("abc", "def", None), ("def", "ghi", None)] def test_select_with_hanging_comma_single_table(): tables = extract_tables("select a, from abc") assert tables == [(None, "abc", None)] def test_select_with_hanging_comma_multiple_tables(): tables = extract_tables("select a, from abc, def") assert sorted(tables) == [(None, "abc", None), (None, "def", None)] def test_select_with_hanging_period_multiple_tables(): tables = extract_tables("SELECT t1. FROM tabl1 t1, tabl2 t2") assert sorted(tables) == [(None, "tabl1", "t1"), (None, "tabl2", "t2")] def test_simple_insert_single_table(): tables = extract_tables('insert into abc (id, name) values (1, "def")') # sqlparse mistakenly assigns an alias to the table # assert tables == [(None, 'abc', None)] assert tables == [(None, "abc", "abc")] def test_simple_insert_single_table_schema_qualified(): tables = extract_tables('insert into abc.def (id, name) values (1, "def")') assert tables == [("abc", "def", None)] def test_simple_update_table(): tables = extract_tables("update abc set id = 1") assert tables == [(None, "abc", None)] def test_simple_update_table_with_schema(): tables = extract_tables("update abc.def set id = 1") assert tables == [("abc", "def", None)] def test_join_table(): tables = extract_tables("SELECT * FROM abc a JOIN def d ON a.id = d.num") assert sorted(tables) == [(None, "abc", "a"), (None, "def", "d")] def test_join_table_schema_qualified(): tables = extract_tables("SELECT * FROM abc.def x JOIN ghi.jkl y ON x.id = y.num") assert tables == [("abc", "def", "x"), ("ghi", "jkl", "y")] def test_join_as_table(): tables = extract_tables("SELECT * FROM my_table AS m WHERE m.a > 5") assert tables == [(None, "my_table", "m")] def test_extract_tables_from_complete_statements(): tables = extract_tables_from_complete_statements("SELECT * FROM my_table AS m WHERE m.a > 5") assert tables == [(None, "my_table", "m")] def test_extract_tables_from_complete_statements_cte(): tables = extract_tables_from_complete_statements("WITH my_cte (id, num) AS ( SELECT id, COUNT(1) FROM my_table GROUP BY id ) SELECT *") assert tables == [(None, "my_table", None)] # this would confuse plain extract_tables() per #1122 def test_extract_tables_from_multiple_complete_statements(): tables = extract_tables_from_complete_statements(r'\T sql-insert; SELECT * FROM my_table AS m WHERE m.a > 5') assert tables == [(None, "my_table", "m")] def test_query_starts_with(): query = "USE test;" assert query_starts_with(query, ("use",)) is True query = "DROP DATABASE test;" assert query_starts_with(query, ("use",)) is False def test_query_starts_with_comment(): query = "# comment\nUSE test;" assert query_starts_with(query, ("use",)) is True def test_queries_start_with(): sql = "# comment\nshow databases;use foo;" assert queries_start_with(sql, ["show", "select"]) is True assert queries_start_with(sql, ["use", "drop"]) is True assert queries_start_with(sql, ["delete", "update"]) is False def test_is_destructive(): sql = "use test;\nshow databases;\ndrop database foo;" assert is_destructive(["drop"], sql) is True def test_is_destructive_update_with_where_clause(): sql = "use test;\nshow databases;\nUPDATE test SET x = 1 WHERE id = 1;" assert is_destructive(["update"], sql) is False def test_is_destructive_update_with_where_clause_and_comment(): sql = "use test;\nshow databases;\nUPDATE /* inline comment */ test SET x = 1 WHERE id = 1;" assert is_destructive(["update"], sql) is False def test_is_destructive_update_multiple_tables_with_where_clause(): sql = "use test;\nshow databases;\nUPDATE test, foo SET x = 1 WHERE id = 1;" assert is_destructive(["update"], sql) is True def test_is_destructive_update_without_where_clause(): sql = "use test;\nshow databases;\nUPDATE test SET x = 1;" assert is_destructive(["update"], sql) is True @pytest.mark.parametrize( ("sql", "has_where_clause"), [ ("update test set dummy = 1;", False), ("update test set dummy = 1 where id = 1);", True), ], ) def test_query_has_where_clause(sql, has_where_clause): assert query_has_where_clause(sql) is has_where_clause @pytest.mark.parametrize( ("sql", "dbname", "is_dropping"), [ ("select bar from foo", "foo", False), ('drop database "foo";', "`foo`", True), ("drop schema foo", "foo", True), ("drop schema foo", "bar", False), ("drop database bar", "foo", False), ("drop database foo", None, False), ("drop database foo; create database foo", "foo", False), ("drop database foo; create database bar", "foo", True), ("select bar from foo; drop database bazz", "foo", False), ("select bar from foo; drop database bazz", "bazz", True), ("-- dropping database \n drop -- really dropping \n schema abc -- now it is dropped", "abc", True), ], ) def test_is_dropping_database(sql, dbname, is_dropping): assert is_dropping_database(sql, dbname) == is_dropping ================================================ FILE: test/test_plan.wiki ================================================ = Gross Checks = * [ ] Check connecting to a local database. * [ ] Check connecting to a remote database. * [ ] Check connecting to a database with a user/password. * [ ] Check connecting to a non-existent database. * [ ] Test changing the database. == PGExecute == * [ ] Test successful execution given a cursor. * [ ] Test unsuccessful execution with a syntax error. * [ ] Test a series of executions with the same cursor without failure. * [ ] Test a series of executions with the same cursor with failure. * [ ] Test passing in a special command. == Naive Autocompletion == * [ ] Input empty string, ask for completions - Everything. * [ ] Input partial prefix, ask for completions - Stars with prefix. * [ ] Input fully autocompleted string, ask for completions - Only full match * [ ] Input non-existent prefix, ask for completions - nothing * [ ] Input lowercase prefix - case insensitive completions == Smart Autocompletion == * [ ] Input empty string and check if only keywords are returned. * [ ] Input SELECT prefix and check if only columns and '*' are returned. * [ ] Input SELECT blah - only keywords are returned. * [ ] Input SELECT * FROM - Table names only == PGSpecial == * [ ] Test \d * [ ] Test \d tablename * [ ] Test \d tablena* * [ ] Test \d non-existent-tablename * [ ] Test \d index * [ ] Test \d sequence * [ ] Test \d view == Exceptionals == * [ ] Test the 'use' command to change db. ================================================ FILE: test/test_prompt_utils.py ================================================ # type: ignore import click from mycli.packages.prompt_utils import confirm_destructive_query def test_confirm_destructive_query_notty() -> None: stdin = click.get_text_stream("stdin") assert stdin.isatty() is False sql = "drop database foo;" assert confirm_destructive_query(["drop"], sql) is None ================================================ FILE: test/test_smart_completion_public_schema_only.py ================================================ # type: ignore import os.path from unittest.mock import patch from prompt_toolkit.completion import Completion from prompt_toolkit.document import Document import pytest import mycli.packages.special.main as special metadata = { "users": ["id", "email", "first_name", "last_name"], "orders": ["id", "ordered_date", "status"], "select": ["id", "insert", "ABC"], "réveillé": ["id", "insert", "ABC"], "time_zone": ["Time_zone_id"], "time_zone_leap_second": ["Time_zone_id"], "time_zone_name": ["Time_zone_id"], "time_zone_transition": ["Time_zone_id"], "time_zone_transition_type": ["Time_zone_id"], } @pytest.fixture def completer(): import mycli.sqlcompleter as sqlcompleter comp = sqlcompleter.SQLCompleter(smart_completion=True) tables, columns = [], [] for table, cols in metadata.items(): tables.append((table,)) columns.extend([(table, col) for col in cols]) databases = ["test", "test 2"] for db in databases: comp.extend_schemata(db) comp.extend_database_names(databases) comp.set_dbname("test") comp.extend_relations(tables, kind="tables") comp.extend_columns(columns, kind="tables") comp.extend_enum_values([("orders", "status", ["pending", "shipped"])]) comp.extend_special_commands(special.COMMANDS) return comp @pytest.fixture def empty_completer(): import mycli.sqlcompleter as sqlcompleter comp = sqlcompleter.SQLCompleter(smart_completion=True) tables, columns = [], [] for table, cols in metadata.items(): tables.append((table,)) columns.extend([(table, col) for col in cols]) db = 'empty' comp.extend_schemata(db) comp.extend_database_names([db]) comp.set_dbname(db) comp.extend_special_commands(special.COMMANDS) return comp @pytest.fixture def complete_event(): from unittest.mock import Mock return Mock() def test_use_database_completion(completer, complete_event): text = "USE " position = len(text) special.register_special_command(..., 'use', '\\u [database]', 'Change to a new database.', aliases=['\\u']) result = completer.get_completions(Document(text=text, cursor_position=position), complete_event) assert list(result) == [ Completion(text="test", start_position=0), Completion(text="`test 2`", start_position=0), ] def test_special_name_completion(completer, complete_event): text = "\\d" position = len("\\d") result = completer.get_completions(Document(text=text, cursor_position=position), complete_event) assert list(result) == [Completion(text="\\dt", start_position=-2)] def test_empty_string_completion(completer, complete_event): text = "" position = 0 result = list(completer.get_completions(Document(text=text, cursor_position=position), complete_event)) assert list(map(Completion, completer.special_commands + completer.keywords)) == result def test_select_keyword_completion(completer, complete_event): text = "SEL" position = len("SEL") result = completer.get_completions(Document(text=text, cursor_position=position), complete_event) assert list(result) == [ Completion(text='SELECT', start_position=-3), Completion(text='SERIAL', start_position=-3), Completion(text='MASTER_LOG_FILE', start_position=-3), Completion(text='MASTER_LOG_POS', start_position=-3), Completion(text='MASTER_TLS_CIPHERSUITES', start_position=-3), Completion(text='MASTER_TLS_VERSION', start_position=-3), Completion(text='SCHEDULE', start_position=-3), Completion(text='SERIALIZABLE', start_position=-3), ] def test_select_star(completer, complete_event): text = "SELECT * " position = len(text) result = completer.get_completions(Document(text=text, cursor_position=position), complete_event) assert list(result) == list(map(Completion, completer.keywords)) def test_introducer_completion(completer, complete_event): completer.extend_character_sets([('latin1',), ('utf8mb4',)]) text = 'SELECT _' position = len(text) result = list(completer.get_completions(Document(text=text, cursor_position=position), complete_event)) result_text = [item.text for item in result] assert '_latin1' in result_text assert '_utf8mb4' in result_text def test_collation_completion(completer, complete_event): completer.extend_collations([('utf16le_bin',), ('utf8mb4_unicode_ci',)]) text = 'SELECT "text" COLLATE ' position = len(text) result = list(completer.get_completions(Document(text=text, cursor_position=position), complete_event)) result_text = [item.text for item in result] assert 'utf16le_bin' in result_text assert 'utf8mb4_unicode_ci' in result_text def test_transcoding_completion_1(completer, complete_event): completer.extend_character_sets([('latin1',), ('utf8mb4',)]) text = 'SELECT CONVERT("text" USING ' position = len(text) result = list(completer.get_completions(Document(text=text, cursor_position=position), complete_event)) result_text = [item.text for item in result] assert 'latin1' in result_text assert 'utf8mb4' in result_text def test_transcoding_completion_2(completer, complete_event): completer.extend_character_sets([('utf8mb3',), ('utf8mb4',)]) text = 'SELECT CONVERT("text" USING u' position = len(text) result = list(completer.get_completions(Document(text=text, cursor_position=position), complete_event)) result_text = [item.text for item in result] assert 'utf8mb3' in result_text assert 'utf8mb4' in result_text def test_transcoding_completion_3(completer, complete_event): completer.extend_character_sets([('latin1',), ('utf8mb4',)]) text = 'SELECT CAST("text" AS CHAR CHARACTER SET ' position = len(text) result = list(completer.get_completions(Document(text=text, cursor_position=position), complete_event)) result_text = [item.text for item in result] assert 'latin1' in result_text assert 'utf8mb4' in result_text def test_transcoding_completion_4(completer, complete_event): completer.extend_character_sets([('utf8mb3',), ('utf8mb4',)]) text = 'SELECT CAST("text" AS CHAR CHARACTER SET u' position = len(text) result = list(completer.get_completions(Document(text=text, cursor_position=position), complete_event)) result_text = [item.text for item in result] assert 'utf8mb3' in result_text assert 'utf8mb4' in result_text def test_where_transcoding_completion_1(completer, complete_event): completer.extend_character_sets([('latin1',), ('utf8mb4',)]) text = 'SELECT * FROM users WHERE CONVERT(email USING ' position = len(text) result = list(completer.get_completions(Document(text=text, cursor_position=position), complete_event)) result_text = [item.text for item in result] assert 'latin1' in result_text assert 'utf8mb4' in result_text def test_where_transcoding_completion_2(completer, complete_event): completer.extend_character_sets([('latin1',), ('utf8mb4',)]) text = 'SELECT * FROM users WHERE CAST(email AS CHAR CHARACTER SET ' position = len(text) result = list(completer.get_completions(Document(text=text, cursor_position=position), complete_event)) result_text = [item.text for item in result] assert 'latin1' in result_text assert 'utf8mb4' in result_text def test_table_completion(completer, complete_event): text = "SELECT * FROM " position = len(text) result = completer.get_completions(Document(text=text, cursor_position=position), complete_event) assert list(result) == [ Completion(text="users", start_position=0), Completion(text="orders", start_position=0), Completion(text="`select`", start_position=0), Completion(text="`réveillé`", start_position=0), Completion(text="time_zone", start_position=0), Completion(text="time_zone_leap_second", start_position=0), Completion(text="time_zone_name", start_position=0), Completion(text="time_zone_transition", start_position=0), Completion(text="time_zone_transition_type", start_position=0), Completion(text="test", start_position=0), Completion(text="`test 2`", start_position=0), ] def test_select_filtered_table_completion(completer, complete_event): text = "SELECT ABC FROM " position = len(text) result = completer.get_completions(Document(text=text, cursor_position=position), complete_event) assert list(result) == [ Completion(text="`select`", start_position=0), Completion(text="`réveillé`", start_position=0), Completion(text="users", start_position=0), Completion(text="orders", start_position=0), Completion(text="time_zone", start_position=0), Completion(text="time_zone_leap_second", start_position=0), Completion(text="time_zone_name", start_position=0), Completion(text="time_zone_transition", start_position=0), Completion(text="time_zone_transition_type", start_position=0), Completion(text="test", start_position=0), Completion(text="`test 2`", start_position=0), ] def test_sub_select_filtered_table_completion(completer, complete_event): text = "SELECT * FROM (SELECT ordered_date FROM " position = len(text) result = completer.get_completions(Document(text=text, cursor_position=position), complete_event) assert list(result) == [ Completion(text="orders", start_position=0), Completion(text="users", start_position=0), Completion(text="`select`", start_position=0), Completion(text="`réveillé`", start_position=0), Completion(text="time_zone", start_position=0), Completion(text="time_zone_leap_second", start_position=0), Completion(text="time_zone_name", start_position=0), Completion(text="time_zone_transition", start_position=0), Completion(text="time_zone_transition_type", start_position=0), Completion(text="test", start_position=0), Completion(text="`test 2`", start_position=0), ] def test_enum_value_completion(completer, complete_event): text = "SELECT * FROM orders WHERE status = " position = len(text) result = list(completer.get_completions(Document(text=text, cursor_position=position), complete_event)) assert result == [ Completion(text="'pending'", start_position=0), Completion(text="'shipped'", start_position=0), ] def test_function_name_completion(completer, complete_event): text = "SELECT MA" position = len("SELECT MA") result = completer.get_completions(Document(text=text, cursor_position=position), complete_event) assert list(result) == [ Completion(text='MAX', start_position=-2), Completion(text='MATCH', start_position=-2), Completion(text='MAKEDATE', start_position=-2), Completion(text='MAKETIME', start_position=-2), Completion(text='MAKE_SET', start_position=-2), Completion(text='MASTER_POS_WAIT', start_position=-2), Completion(text='email', start_position=-2), ] def test_suggested_column_names(completer, complete_event): """Suggest column and function names when selecting from table. :param completer: :param complete_event: :return: """ text = "SELECT from users" position = len("SELECT ") result = list(completer.get_completions(Document(text=text, cursor_position=position), complete_event)) assert result == list( [ Completion(text="*", start_position=0), Completion(text="id", start_position=0), Completion(text="email", start_position=0), Completion(text="first_name", start_position=0), Completion(text="last_name", start_position=0), ] + list(map(Completion, completer.functions)) + [Completion(text="users", start_position=0)] ) def test_suggested_column_names_empty_db(empty_completer, complete_event): """Suggest * and function when selecting from no-table db. :param empty_completer: :param complete_event: :return: """ text = "SELECT " position = len("SELECT ") result = list(empty_completer.get_completions(Document(text=text, cursor_position=position), complete_event)) assert result == list( [ Completion(text="*", start_position=0), ] + list(map(Completion, empty_completer.functions)) ) def test_suggested_column_names_in_function(completer, complete_event): """Suggest column and function names when selecting multiple columns from table. :param completer: :param complete_event: :return: """ text = "SELECT MAX( from users" position = len("SELECT MAX(") result = completer.get_completions(Document(text=text, cursor_position=position), complete_event) assert list(result) == [ Completion(text="*", start_position=0), Completion(text="id", start_position=0), Completion(text="email", start_position=0), Completion(text="first_name", start_position=0), Completion(text="last_name", start_position=0), ] def test_suggested_column_names_with_table_dot(completer, complete_event): """Suggest column names on table name and dot. :param completer: :param complete_event: :return: """ text = "SELECT users. from users" position = len("SELECT users.") result = list(completer.get_completions(Document(text=text, cursor_position=position), complete_event)) assert result == [ Completion(text="*", start_position=0), Completion(text="id", start_position=0), Completion(text="email", start_position=0), Completion(text="first_name", start_position=0), Completion(text="last_name", start_position=0), ] def test_suggested_column_names_with_alias(completer, complete_event): """Suggest column names on table alias and dot. :param completer: :param complete_event: :return: """ text = "SELECT u. from users u" position = len("SELECT u.") result = list(completer.get_completions(Document(text=text, cursor_position=position), complete_event)) assert result == [ Completion(text="*", start_position=0), Completion(text="id", start_position=0), Completion(text="email", start_position=0), Completion(text="first_name", start_position=0), Completion(text="last_name", start_position=0), ] def test_suggested_multiple_column_names(completer, complete_event): """Suggest column and function names when selecting multiple columns from table. :param completer: :param complete_event: :return: """ text = "SELECT id, from users u" position = len("SELECT id, ") result = list(completer.get_completions(Document(text=text, cursor_position=position), complete_event)) assert result == list( [ Completion(text="*", start_position=0), Completion(text="id", start_position=0), Completion(text="email", start_position=0), Completion(text="first_name", start_position=0), Completion(text="last_name", start_position=0), ] + list(map(Completion, completer.functions)) + [Completion(text="u", start_position=0)] ) def test_suggested_multiple_column_names_with_alias(completer, complete_event): """Suggest column names on table alias and dot when selecting multiple columns from table. :param completer: :param complete_event: :return: """ text = "SELECT u.id, u. from users u" position = len("SELECT u.id, u.") result = list(completer.get_completions(Document(text=text, cursor_position=position), complete_event)) assert result == [ Completion(text="*", start_position=0), Completion(text="id", start_position=0), Completion(text="email", start_position=0), Completion(text="first_name", start_position=0), Completion(text="last_name", start_position=0), ] def test_suggested_multiple_column_names_with_dot(completer, complete_event): """Suggest column names on table names and dot when selecting multiple columns from table. :param completer: :param complete_event: :return: """ text = "SELECT users.id, users. from users u" position = len("SELECT users.id, users.") result = list(completer.get_completions(Document(text=text, cursor_position=position), complete_event)) assert result == [ Completion(text="*", start_position=0), Completion(text="id", start_position=0), Completion(text="email", start_position=0), Completion(text="first_name", start_position=0), Completion(text="last_name", start_position=0), ] def test_suggested_aliases_after_on(completer, complete_event): text = "SELECT u.name, o.id FROM users u JOIN orders o ON " position = len("SELECT u.name, o.id FROM users u JOIN orders o ON ") result = list(completer.get_completions(Document(text=text, cursor_position=position), complete_event)) assert result == [ Completion(text="u", start_position=0), Completion(text="o", start_position=0), ] def test_suggested_aliases_after_on_right_side(completer, complete_event): text = "SELECT u.name, o.id FROM users u JOIN orders o ON o.user_id = " position = len("SELECT u.name, o.id FROM users u JOIN orders o ON o.user_id = ") result = list(completer.get_completions(Document(text=text, cursor_position=position), complete_event)) assert result == [ Completion(text="u", start_position=0), Completion(text="o", start_position=0), ] def test_suggested_tables_after_on(completer, complete_event): text = "SELECT users.name, orders.id FROM users JOIN orders ON " position = len("SELECT users.name, orders.id FROM users JOIN orders ON ") result = list(completer.get_completions(Document(text=text, cursor_position=position), complete_event)) assert result == [ Completion(text="users", start_position=0), Completion(text="orders", start_position=0), ] def test_suggested_tables_after_on_right_side(completer, complete_event): text = "SELECT users.name, orders.id FROM users JOIN orders ON orders.user_id = " position = len("SELECT users.name, orders.id FROM users JOIN orders ON orders.user_id = ") result = list(completer.get_completions(Document(text=text, cursor_position=position), complete_event)) assert result == [ Completion(text="users", start_position=0), Completion(text="orders", start_position=0), ] def test_table_names_after_from(completer, complete_event): text = "SELECT * FROM " position = len("SELECT * FROM ") result = list(completer.get_completions(Document(text=text, cursor_position=position), complete_event)) assert result == [ Completion(text="users", start_position=0), Completion(text="orders", start_position=0), Completion(text="`select`", start_position=0), Completion(text="`réveillé`", start_position=0), Completion(text="time_zone", start_position=0), Completion(text="time_zone_leap_second", start_position=0), Completion(text="time_zone_name", start_position=0), Completion(text="time_zone_transition", start_position=0), Completion(text="time_zone_transition_type", start_position=0), Completion(text="test", start_position=0), Completion(text="`test 2`", start_position=0), ] def test_table_names_leading_partial(completer, complete_event): text = "SELECT * FROM time_zone" position = len("SELECT * FROM time_zone") result = list(completer.get_completions(Document(text=text, cursor_position=position), complete_event)) assert result == [ Completion(text="time_zone", start_position=-9), Completion(text="time_zone_name", start_position=-9), Completion(text="time_zone_transition", start_position=-9), Completion(text="time_zone_leap_second", start_position=-9), Completion(text="time_zone_transition_type", start_position=-9), ] def test_table_names_inter_partial(completer, complete_event): text = "SELECT * FROM time_leap" position = len("SELECT * FROM time_leap") result = list(completer.get_completions(Document(text=text, cursor_position=position), complete_event)) assert result == [ Completion(text="time_zone_leap_second", start_position=-9), Completion(text='time_zone_name', start_position=-9), Completion(text='time_zone_transition', start_position=-9), Completion(text='time_zone_transition_type', start_position=-9), ] def test_table_names_fuzzy(completer, complete_event): text = "SELECT * FROM tim_leap" position = len("SELECT * FROM tim_leap") result = list(completer.get_completions(Document(text=text, cursor_position=position), complete_event)) assert result == [ Completion(text="time_zone_leap_second", start_position=-8), ] def test_auto_escaped_col_names(completer, complete_event): text = "SELECT from `select`" position = len("SELECT ") result = [x.text for x in completer.get_completions(Document(text=text, cursor_position=position), complete_event)] expected = ( [ "*", "id", "`insert`", "ABC", ] + completer.functions + ["select"] ) assert result == expected def test_un_escaped_table_names(completer, complete_event): text = "SELECT from réveillé" position = len("SELECT ") result = [x.text for x in completer.get_completions(Document(text=text, cursor_position=position), complete_event)] assert result == [ "*", "id", "`insert`", "ABC", ] + completer.functions + ["réveillé"] # todo: the fixtures are insufficient; the database name should also appear in the result def test_grant_on_suggets_tables_and_schemata(completer, complete_event): text = "GRANT ALL ON " position = len(text) result = list(completer.get_completions(Document(text=text, cursor_position=position), complete_event)) assert result == [ Completion(text="test", start_position=0), Completion(text="`test 2`", start_position=0), Completion(text='users', start_position=0), Completion(text='orders', start_position=0), Completion(text='`select`', start_position=0), Completion(text='`réveillé`', start_position=0), Completion(text='time_zone', start_position=0), Completion(text='time_zone_leap_second', start_position=0), Completion(text='time_zone_name', start_position=0), Completion(text='time_zone_transition', start_position=0), Completion(text='time_zone_transition_type', start_position=0), ] # todo: this test belongs more logically in test_naive_completion.py, but it didn't work there: # multiple completion candidates were not suggested. def test_deleted_keyword_completion(completer, complete_event): text = "exi" position = len("exi") result = list(completer.get_completions(Document(text=text, cursor_position=position), complete_event)) assert result == [ Completion(text="exit", start_position=-3), Completion(text='exists', start_position=-3), Completion(text='explain', start_position=-3), Completion(text='expire', start_position=-3), ] def test_numbers_no_completion(completer, complete_event): text = "SELECT COUNT(1) FROM time_zone WHERE Time_zone_id = 1" position = len("SELECT COUNT(1) FROM time_zone WHERE Time_zone_id = 1") result = list(completer.get_completions(Document(text=text, cursor_position=position), complete_event)) assert result == [] # ie not INT1 def dummy_list_path(dir_name): dirs = { "/": [ "dir1", "file1.sql", "file2.sql", ], "/dir1": [ "subdir1", "subfile1.sql", "subfile2.sql", ], "/dir1/subdir1": [ "lastfile.sql", ], } return dirs.get(dir_name, []) @patch("mycli.packages.filepaths.list_path", new=dummy_list_path) @pytest.mark.parametrize( "text,expected", [ ('source ', [('/', 0), ('~', 0), ('.', 0), ('..', 0)]), ("source /", [("dir1", 0), ("file1.sql", 0), ("file2.sql", 0)]), ("source /dir1/", [("subdir1", 0), ("subfile1.sql", 0), ("subfile2.sql", 0)]), ("source /dir1/subdir1/", [("lastfile.sql", 0)]), ], ) def test_file_name_completion(completer, complete_event, text, expected): position = len(text) special.register_special_command(..., 'source', '\\. ', 'Execute commands from file.', aliases=['\\.']) result = list(completer.get_completions(Document(text=text, cursor_position=position), complete_event)) expected = [Completion(txt, pos) for txt, pos in expected] assert result == expected def test_auto_case_heuristic(completer, complete_event): text = "select json_v" position = len("select json_v") result = list(completer.get_completions(Document(text=text, cursor_position=position), complete_event)) assert [x.text for x in result] == [ 'json_value', 'json_valid', ] def test_create_table_like_completion(completer, complete_event): text = "CREATE TABLE foo LIKE ti" position = len(text) result = list(completer.get_completions(Document(text=text, cursor_position=position), complete_event)) assert [x.text for x in result] == [ 'time_zone', 'time_zone_name', 'time_zone_transition', 'time_zone_leap_second', 'time_zone_transition_type', ] def test_source_eager_completion(completer, complete_event): text = "source sc" position = len(text) script_filename = 'script_for_test_suite.sql' f = open(script_filename, 'w') f.close() special.register_special_command(..., 'source', '\\. ', 'Execute commands from file.', aliases=['\\.']) result = list(completer.get_completions(Document(text=text, cursor_position=position), complete_event)) success = True error = 'unknown' try: assert [x.text for x in result] == [ script_filename, 'screenshots/', ] except AssertionError as e: success = False error = e if os.path.exists(script_filename): os.remove(script_filename) if not success: raise AssertionError(error) def test_source_leading_dot_suggestions_completion(completer, complete_event): text = "source ./sc" position = len(text) script_filename = 'script_for_test_suite.sql' f = open(script_filename, 'w') f.close() special.register_special_command(..., 'source', '\\. ', 'Execute commands from file.', aliases=['\\.']) result = list(completer.get_completions(Document(text=text, cursor_position=position), complete_event)) success = True error = 'unknown' try: assert [x.text for x in result] == [ script_filename, 'screenshots/', ] except AssertionError as e: success = False error = e if os.path.exists(script_filename): os.remove(script_filename) if not success: raise AssertionError(error) def test_string_no_completion(completer, complete_event): text = 'select "json' position = len(text) result = list(completer.get_completions(Document(text=text, cursor_position=position), complete_event)) assert result == [] def test_string_no_completion_single_quote(completer, complete_event): text = "select 'json" position = len(text) result = list(completer.get_completions(Document(text=text, cursor_position=position), complete_event)) assert result == [] def test_string_no_completion_spaces(completer, complete_event): text = 'select "nocomplete json' position = len(text) result = list(completer.get_completions(Document(text=text, cursor_position=position), complete_event)) assert result == [] def test_string_no_completion_spaces_inner_1(completer, complete_event): text = 'select "json nocomplete' position = len('select "json') result = list(completer.get_completions(Document(text=text, cursor_position=position), complete_event)) assert result == [] def test_string_no_completion_spaces_inner_2(completer, complete_event): text = 'select "json nocomplete' position = len('select "json ') result = list(completer.get_completions(Document(text=text, cursor_position=position), complete_event)) assert result == [] def test_backticked_column_completion(completer, complete_event): text = 'select `Tim' position = len(text) result = list(completer.get_completions(Document(text=text, cursor_position=position), complete_event)) assert result == [ # todo it would be nicer if the column names sorted to the top Completion(text='`time`', start_position=-4), Completion(text='`timediff`', start_position=-4), Completion(text='`timestamp`', start_position=-4), Completion(text='`time_format`', start_position=-4), Completion(text='`time_to_sec`', start_position=-4), Completion(text='`Time_zone_id`', start_position=-4), Completion(text='`timestampadd`', start_position=-4), Completion(text='`timestampdiff`', start_position=-4), Completion(text='`datetime`', start_position=-4), Completion(text='`optimize`', start_position=-4), Completion(text='`optimizer_costs`', start_position=-4), Completion(text='`utc_time`', start_position=-4), Completion(text='`utc_timestamp`', start_position=-4), Completion(text='`current_time`', start_position=-4), Completion(text='`current_timestamp`', start_position=-4), Completion(text='`localtime`', start_position=-4), Completion(text='`localtimestamp`', start_position=-4), Completion(text='`password_lock_time`', start_position=-4), ] def test_backticked_column_completion_component(completer, complete_event): text = 'select `com' position = len(text) result = list(completer.get_completions(Document(text=text, cursor_position=position), complete_event)) assert result == [ # todo it would be nicer if "comment" sorted to the top because it is a column name, # and because it is a reserved word Completion(text='`commit`', start_position=-4), Completion(text='`comment`', start_position=-4), Completion(text='`compact`', start_position=-4), Completion(text='`compress`', start_position=-4), Completion(text='`committed`', start_position=-4), Completion(text='`component`', start_position=-4), Completion(text='`completion`', start_position=-4), Completion(text='`compressed`', start_position=-4), Completion(text='`compression`', start_position=-4), Completion(text='`column`', start_position=-4), Completion(text='`column_format`', start_position=-4), Completion(text='`column_name`', start_position=-4), Completion(text='`columns`', start_position=-4), Completion(text='`second_microsecond`', start_position=-4), Completion(text='`uncommitted`', start_position=-4), ] def test_backticked_column_completion_two_character(completer, complete_event): text = 'select `f' position = len(text) result = list(completer.get_completions(Document(text=text, cursor_position=position), complete_event)) assert result == [ # todo it would be nicer if the column name "first_name" sorted to the top Completion(text='`for`', start_position=-2), Completion(text='`from`', start_position=-2), Completion(text='`fast`', start_position=-2), Completion(text='`file`', start_position=-2), Completion(text='`full`', start_position=-2), Completion(text='`floor`', start_position=-2), Completion(text='`false`', start_position=-2), Completion(text='`field`', start_position=-2), Completion(text='`fixed`', start_position=-2), Completion(text='`float`', start_position=-2), Completion(text='`fetch`', start_position=-2), Completion(text='`first`', start_position=-2), Completion(text='`flush`', start_position=-2), Completion(text='`force`', start_position=-2), Completion(text='`found`', start_position=-2), Completion(text='`format`', start_position=-2), Completion(text='`float4`', start_position=-2), Completion(text='`float8`', start_position=-2), Completion(text='`factor`', start_position=-2), Completion(text='`faults`', start_position=-2), Completion(text='`fields`', start_position=-2), Completion(text='`filter`', start_position=-2), Completion(text='`finish`', start_position=-2), Completion(text='`follows`', start_position=-2), Completion(text='`foreign`', start_position=-2), Completion(text='`fulltext`', start_position=-2), Completion(text='`function`', start_position=-2), Completion(text='`from_days`', start_position=-2), Completion(text='`following`', start_position=-2), Completion(text='`first_name`', start_position=-2), Completion(text='`found_rows`', start_position=-2), Completion(text='`find_in_set`', start_position=-2), Completion(text='`first_value`', start_position=-2), Completion(text='`from_base64`', start_position=-2), Completion(text='`foreign key`', start_position=-2), Completion(text='`format_bytes`', start_position=-2), Completion(text='`from_unixtime`', start_position=-2), Completion(text='`file_block_size`', start_position=-2), Completion(text='`format_pico_time`', start_position=-2), Completion(text='`failed_login_attempts`', start_position=-2), Completion(text='`left join`', start_position=-2), Completion(text='`after`', start_position=-2), Completion(text='`before`', start_position=-2), Completion(text='`default`', start_position=-2), Completion(text='`default_auth`', start_position=-2), Completion(text='`definer`', start_position=-2), Completion(text='`definition`', start_position=-2), Completion(text='`enforced`', start_position=-2), Completion(text='`if`', start_position=-2), Completion(text='`infile`', start_position=-2), Completion(text='`left`', start_position=-2), Completion(text='`logfile`', start_position=-2), Completion(text='`of`', start_position=-2), Completion(text='`off`', start_position=-2), Completion(text='`offset`', start_position=-2), Completion(text='`outfile`', start_position=-2), Completion(text='`profile`', start_position=-2), Completion(text='`profiles`', start_position=-2), Completion(text='`reference`', start_position=-2), Completion(text='`references`', start_position=-2), ] def test_backticked_column_completion_three_character(completer, complete_event): text = 'select `fi' position = len(text) result = list(completer.get_completions(Document(text=text, cursor_position=position), complete_event)) assert result == [ # todo it would be nicer if the column name "first_name" sorted to the top Completion(text='`file`', start_position=-3), Completion(text='`field`', start_position=-3), Completion(text='`fixed`', start_position=-3), Completion(text='`first`', start_position=-3), Completion(text='`fields`', start_position=-3), Completion(text='`filter`', start_position=-3), Completion(text='`finish`', start_position=-3), Completion(text='`first_name`', start_position=-3), Completion(text='`find_in_set`', start_position=-3), Completion(text='`first_value`', start_position=-3), Completion(text='`file_block_size`', start_position=-3), Completion(text='`definer`', start_position=-3), Completion(text='`definition`', start_position=-3), Completion(text='`failed_login_attempts`', start_position=-3), Completion(text='`foreign`', start_position=-3), Completion(text='`infile`', start_position=-3), Completion(text='`logfile`', start_position=-3), Completion(text='`outfile`', start_position=-3), Completion(text='`profile`', start_position=-3), Completion(text='`profiles`', start_position=-3), Completion(text='`foreign key`', start_position=-3), ] def test_backticked_column_completion_four_character(completer, complete_event): text = 'select `fir' position = len(text) result = list(completer.get_completions(Document(text=text, cursor_position=position), complete_event)) assert result == [ # todo it would be nicer if the column name "first_name" sorted to the top Completion(text='`first`', start_position=-4), Completion(text='`first_name`', start_position=-4), Completion(text='`first_value`', start_position=-4), Completion(text='`definer`', start_position=-4), Completion(text='`filter`', start_position=-4), ] def test_backticked_table_completion_required(completer, complete_event): text = 'select ABC from `rév' position = len(text) result = list(completer.get_completions(Document(text=text, cursor_position=position), complete_event)) assert result == [ Completion(text='`réveillé`', start_position=-4), ] def test_backticked_table_completion_not_required(completer, complete_event): text = 'select * from `t' position = len(text) result = list(completer.get_completions(Document(text=text, cursor_position=position), complete_event)) assert result == [ Completion(text='`test`', start_position=-2), Completion(text='`test 2`', start_position=-2), Completion(text='`time_zone`', start_position=-2), Completion(text='`time_zone_name`', start_position=-2), Completion(text='`time_zone_transition`', start_position=-2), Completion(text='`time_zone_leap_second`', start_position=-2), Completion(text='`time_zone_transition_type`', start_position=-2), ] def test_string_no_completion_backtick(completer, complete_event): text = 'select * from "`t' position = len(text) result = list(completer.get_completions(Document(text=text, cursor_position=position), complete_event)) assert result == [] # todo this shouldn't suggest anything but the space resets the logic # and it completes on "bar" alone @pytest.mark.xfail def test_backticked_no_completion_spaces(completer, complete_event): text = 'select * from `nocomplete bar' position = len(text) result = list(completer.get_completions(Document(text=text, cursor_position=position), complete_event)) assert result == [] ================================================ FILE: test/test_special_iocommands.py ================================================ # type: ignore import os import stat import tempfile from time import time from unittest.mock import patch from pymysql import ProgrammingError import pytest import mycli.packages.special from test.utils import TEMPFILE_PREFIX, db_connection, dbtest, send_ctrl_c def test_set_get_pager(monkeypatch): monkeypatch.setenv('PAGER', '') mycli.packages.special.set_pager_enabled(True) assert mycli.packages.special.is_pager_enabled() mycli.packages.special.set_pager_enabled(False) assert not mycli.packages.special.is_pager_enabled() mycli.packages.special.set_pager("less") assert os.environ["PAGER"] == "less" mycli.packages.special.set_pager(False) assert os.environ["PAGER"] == "less" del os.environ["PAGER"] mycli.packages.special.set_pager(False) mycli.packages.special.disable_pager() assert not mycli.packages.special.is_pager_enabled() def test_set_get_timing(): mycli.packages.special.set_timing_enabled(True) assert mycli.packages.special.is_timing_enabled() mycli.packages.special.set_timing_enabled(False) assert not mycli.packages.special.is_timing_enabled() def test_set_get_expanded_output(): mycli.packages.special.set_expanded_output(True) assert mycli.packages.special.is_expanded_output() mycli.packages.special.set_expanded_output(False) assert not mycli.packages.special.is_expanded_output() def test_editor_command(monkeypatch): monkeypatch.setenv('EDITOR', 'true') monkeypatch.setenv('VISUAL', 'true') assert mycli.packages.special.editor_command(r"hello\e") assert mycli.packages.special.editor_command(r"hello\edit") assert mycli.packages.special.editor_command(r"\e hello") assert mycli.packages.special.editor_command(r"\edit hello") assert not mycli.packages.special.editor_command(r"hello") assert not mycli.packages.special.editor_command(r"\ehello") assert not mycli.packages.special.editor_command(r"\edithello") assert mycli.packages.special.get_filename(r"\e filename") == "filename" if os.name != "nt": assert mycli.packages.special.open_external_editor(sql=r"select 1") == ('select 1', None) else: pytest.skip("Skipping on Windows platform.") def test_tee_command(): mycli.packages.special.write_tee("hello world") # write without file set # keep Windows from locking the file with delete=False with tempfile.NamedTemporaryFile(prefix=TEMPFILE_PREFIX, delete=False) as f: mycli.packages.special.execute(None, "tee " + f.name) mycli.packages.special.write_tee("hello world") if os.name == "nt": assert f.read() == b"hello world\r\n" else: assert f.read() == b"hello world\n" mycli.packages.special.execute(None, "tee -o " + f.name) mycli.packages.special.write_tee("hello world") f.seek(0) if os.name == "nt": assert f.read() == b"hello world\r\n" else: assert f.read() == b"hello world\n" mycli.packages.special.execute(None, "notee") mycli.packages.special.write_tee("hello world") f.seek(0) if os.name == "nt": assert f.read() == b"hello world\r\n" else: assert f.read() == b"hello world\n" # remove temp file # delete=False means we should try to clean up try: if os.path.exists(f.name): os.remove(f.name) except Exception as e: print(f"An error occurred while attempting to delete the file: {e}") def test_tee_command_error(): with pytest.raises(TypeError): mycli.packages.special.execute(None, "tee") with pytest.raises(OSError): with tempfile.NamedTemporaryFile(prefix=TEMPFILE_PREFIX) as f: os.chmod(f.name, stat.S_IRUSR | stat.S_IRGRP | stat.S_IROTH) mycli.packages.special.execute(None, f"tee {f.name}") @dbtest @pytest.mark.skipif(os.name == "nt", reason="Bug: fails on Windows, needs fixing, singleton of FQ not working right") def test_favorite_query(): with db_connection().cursor() as cur: query = 'select "✔"' mycli.packages.special.execute(cur, f"\\fs check {query}") assert next(mycli.packages.special.execute(cur, "\\f check")).preamble == "> " + query @dbtest @pytest.mark.skipif(os.name == "nt", reason="Bug: fails on Windows, needs fixing, singleton of FQ not working right") def test_special_favorite_query(): with db_connection().cursor() as cur: query = r'\?' mycli.packages.special.execute(cur, rf"\fs special {query}") assert (r'\G', None, r'\G', 'Display query results vertically.') in next( mycli.packages.special.execute(cur, r'\f special') ).rows def test_once_command(): with pytest.raises(TypeError): mycli.packages.special.execute(None, "\\once") with pytest.raises(OSError): mycli.packages.special.execute(None, "\\once /proc/access-denied") mycli.packages.special.write_once("hello world") # write without file set # keep Windows from locking the file with delete=False with tempfile.NamedTemporaryFile(prefix=TEMPFILE_PREFIX, delete=False) as f: mycli.packages.special.execute(None, "\\once " + f.name) mycli.packages.special.write_once("hello world") if os.name == "nt": assert f.read() == b"hello world\r\n" else: assert f.read() == b"hello world\n" mycli.packages.special.execute(None, "\\once -o " + f.name) mycli.packages.special.write_once("hello world line 1") mycli.packages.special.write_once("hello world line 2") f.seek(0) if os.name == "nt": assert f.read() == b"hello world line 1\r\nhello world line 2\r\n" else: assert f.read() == b"hello world line 1\nhello world line 2\n" # delete=False means we should try to clean up try: if os.path.exists(f.name): os.remove(f.name) except Exception as e: print(f"An error occurred while attempting to delete the file: {e}") def test_pipe_once_command(): with pytest.raises(IOError): mycli.packages.special.execute(None, "\\pipe_once") with pytest.raises(OSError): mycli.packages.special.execute(None, "\\pipe_once /proc/access-denied") mycli.packages.special.write_pipe_once("select 1") mycli.packages.special.flush_pipe_once_if_written(None) if os.name == "nt": mycli.packages.special.execute(None, '\\pipe_once python -c "import sys; print(len(sys.stdin.read().strip()))"') mycli.packages.special.write_once("hello world") mycli.packages.special.flush_pipe_once_if_written(None) else: with tempfile.NamedTemporaryFile(prefix=TEMPFILE_PREFIX) as f: mycli.packages.special.execute(None, "\\pipe_once tee " + f.name) mycli.packages.special.write_pipe_once("hello world") mycli.packages.special.flush_pipe_once_if_written(None) f.seek(0) assert f.read() == b"hello world\n" def test_parseargfile(): """Test that parseargfile expands the user directory.""" expected = (os.path.join(os.path.expanduser("~"), "filename"), "a") if os.name == "nt": assert expected == mycli.packages.special.iocommands.parseargfile("~\\filename") else: assert expected == mycli.packages.special.iocommands.parseargfile("~/filename") expected = (os.path.join(os.path.expanduser("~"), "filename"), "w") if os.name == "nt": assert expected == mycli.packages.special.iocommands.parseargfile("-o ~\\filename") else: assert expected == mycli.packages.special.iocommands.parseargfile("-o ~/filename") def test_parseargfile_no_file(): """Test that parseargfile raises a TypeError if there is no filename.""" with pytest.raises(TypeError): mycli.packages.special.iocommands.parseargfile("") with pytest.raises(TypeError): mycli.packages.special.iocommands.parseargfile("-o ") @dbtest def test_watch_query_iteration(): """Test that a single iteration of the result of `watch_query` executes the desired query and returns the given results.""" expected_value = "1" query = f"SELECT {expected_value}" expected_preamble = f"> {query}" with db_connection().cursor() as cur: result = next(mycli.packages.special.iocommands.watch_query(arg=query, cur=cur)) assert result.preamble == expected_preamble assert result.header[0] == expected_value @dbtest @pytest.mark.skipif(os.name == "nt", reason="Bug: Win handles this differently. May need to refactor watch_query to work for Win") def test_watch_query_full(): """Test that `watch_query`: * Returns the expected results. * Executes the defined times inside the given interval, in this case with a 0.3 seconds wait, it should execute 4 times inside a 1 seconds interval. * Stops at Ctrl-C """ watch_seconds = 0.3 wait_interval = 1 expected_value = "1" query = f"SELECT {expected_value}" expected_preamble = f"> {query}" expected_results = [4, 5, 6, 7] # Python 3.14 is skipping ahead to 6 or 7 ctrl_c_process = send_ctrl_c(wait_interval) with db_connection().cursor() as cur: results = list(mycli.packages.special.iocommands.watch_query(arg=f"{watch_seconds} {query}", cur=cur)) ctrl_c_process.join(1) assert len(results) in expected_results for result in results: assert result.preamble == expected_preamble assert result.header[0] == expected_value @dbtest @patch("click.clear") def test_watch_query_clear(clear_mock): """Test that the screen is cleared with the -c flag of `watch` command before execute the query.""" with db_connection().cursor() as cur: watch_gen = mycli.packages.special.iocommands.watch_query(arg="0.1 -c select 1;", cur=cur) assert not clear_mock.called next(watch_gen) assert clear_mock.called clear_mock.reset_mock() next(watch_gen) assert clear_mock.called clear_mock.reset_mock() @dbtest def test_watch_query_bad_arguments(): """Test different incorrect combinations of arguments for `watch` command.""" watch_query = mycli.packages.special.iocommands.watch_query with db_connection().cursor() as cur: with pytest.raises(ProgrammingError): next(watch_query("a select 1;", cur=cur)) with pytest.raises(ProgrammingError): next(watch_query("-a select 1;", cur=cur)) with pytest.raises(ProgrammingError): next(watch_query("1 -a select 1;", cur=cur)) with pytest.raises(ProgrammingError): next(watch_query("-c -a select 1;", cur=cur)) @dbtest @patch("click.clear") def test_watch_query_interval_clear(clear_mock): """Test `watch` command with interval and clear flag.""" def test_asserts(gen): clear_mock.reset_mock() start = time() next(gen) assert clear_mock.called next(gen) exec_time = time() - start assert exec_time > seconds and exec_time < (seconds + seconds) seconds = 1.0 watch_query = mycli.packages.special.iocommands.watch_query with db_connection().cursor() as cur: test_asserts(watch_query(f"{seconds} -c select 1;", cur=cur)) test_asserts(watch_query(f"-c {seconds} select 1;", cur=cur)) def test_split_sql_by_delimiter(): for delimiter_str in (";", "$", "😀"): mycli.packages.special.set_delimiter(delimiter_str) sql_input = f"select 1{delimiter_str} select \ufffc2" queries = ("select 1", "select \ufffc2") for query, parsed_query in zip(queries, mycli.packages.special.split_queries(sql_input), strict=True): assert query == parsed_query def test_switch_delimiter_within_query(): mycli.packages.special.set_delimiter(";") sql_input = "select 1; delimiter $$ select 2 $$ select 3 $$" queries = ("select 1", "delimiter $$ select 2 $$ select 3 $$") for query, parsed_query in zip(queries, mycli.packages.special.split_queries(sql_input), strict=True): assert query == parsed_query def test_set_delimiter(): for delim in ("foo", "bar"): mycli.packages.special.set_delimiter(delim) assert mycli.packages.special.get_current_delimiter() == delim def teardown_function(): mycli.packages.special.set_delimiter(";") ================================================ FILE: test/test_sqlexecute.py ================================================ # type: ignore from datetime import time import os from prompt_toolkit.formatted_text import FormattedText import pymysql import pytest from mycli.constants import TEST_DATABASE from mycli.sqlexecute import ServerInfo, ServerSpecies from test.utils import dbtest, is_expanded_output, run, set_expanded_output def assert_result_equal( result, preamble=None, header=None, rows=None, status=None, status_plain=None, postamble=None, auto_status=True, assert_contains=False, ): """Assert that an sqlexecute.run() result matches the expected values.""" if status_plain is None and auto_status and rows: status_plain = f"{len(rows)} row{'s' if len(rows) > 1 else ''} in set" status = FormattedText([('', status_plain)]) fields = { "preamble": preamble, "header": header, "rows": rows, "postamble": postamble, "status": status, "status_plain": status_plain, } if assert_contains: # Do a loose match on the results using the *in* operator. for key, field in fields.items(): if field: assert field in result[0][key] else: # Do an exact match on the fields. assert result == [fields] @dbtest def test_timediff_negative_value(executor): sql = "select timediff('2020-11-11 01:01:01', '2020-11-11 01:02:01')" result = run(executor, sql) # negative value comes back as str assert result[0]["rows"][0][0] == "-00:01:00" @dbtest def test_timediff_positive_value(executor): sql = "select timediff('2020-11-11 01:02:01', '2020-11-11 01:01:01')" result = run(executor, sql) # positive value comes back as datetime.time assert result[0]["rows"][0][0] == time(0, 1) @dbtest def test_get_result_status_without_warning(executor): sql = "select 1" result = run(executor, sql) assert result[0]["status_plain"] == "1 row in set" @dbtest def test_get_result_status_with_warning(executor): sql = "SELECT 1 + '0 foo'" result = run(executor, sql) assert result[0]["status"] == FormattedText([ ('', '1 row in set'), ('', ', '), ('class:output.status.warning-count', '1 warning'), ]) assert result[0]["status_plain"] == "1 row in set, 1 warning" @dbtest def test_conn(executor): run(executor, """create table test(a text)""") run(executor, """insert into test values('abc')""") results = run(executor, """select * from test""") assert_result_equal(results, header=["a"], rows=[("abc",)]) @dbtest def test_bools(executor): run(executor, """create table test(a boolean)""") run(executor, """insert into test values(True)""") results = run(executor, """select * from test""") assert_result_equal(results, header=["a"], rows=[(1,)]) @dbtest def test_binary(executor): run(executor, """create table bt(geom linestring NOT NULL)""") run(executor, "INSERT INTO bt VALUES (ST_GeomFromText('LINESTRING(116.37604 39.73979,116.375 39.73965)'));") results = run(executor, """select * from bt""") geom = ( b"\x00\x00\x00\x00\x01\x02\x00\x00\x00\x02\x00\x00\x009\x7f\x13\n" b"\x11\x18]@4\xf4Op\xb1\xdeC@\x00\x00\x00\x00\x00\x18]@B>\xe8\xd9" b"\xac\xdeC@" ) assert_result_equal(results, header=["geom"], rows=[(geom,)]) @dbtest def test_table_and_columns_query(executor): run(executor, "create table a(x text, y text)") run(executor, "create table b(z text)") assert set(executor.tables()) == {("a",), ("b",)} assert set(executor.table_columns()) == {("a", "x"), ("a", "y"), ("b", "z")} @dbtest def test_database_list(executor): databases = executor.databases() assert TEST_DATABASE in databases @dbtest def test_invalid_syntax(executor): with pytest.raises(pymysql.ProgrammingError) as excinfo: run(executor, "invalid syntax!") assert "You have an error in your SQL syntax;" in str(excinfo.value) @dbtest def test_invalid_column_name(executor): with pytest.raises(pymysql.err.OperationalError) as excinfo: run(executor, "select invalid command") assert "Unknown column 'invalid' in 'field list'" in str(excinfo.value) @dbtest def test_unicode_support_in_output(executor): run(executor, "create table unicodechars(t text)") run(executor, "insert into unicodechars (t) values ('é')") # See issue #24, this raises an exception without proper handling results = run(executor, "select * from unicodechars") assert_result_equal(results, header=["t"], rows=[("é",)]) @dbtest def test_multiple_queries_same_line(executor): results = run(executor, "select 'foo'; select 'bar'") expected = [ { "preamble": None, "header": ["foo"], "rows": [("foo",)], "postamble": None, "status_plain": "1 row in set", 'status': FormattedText([('', '1 row in set')]), }, { "preamble": None, "header": ["bar"], "rows": [("bar",)], "postamble": None, "status_plain": "1 row in set", 'status': FormattedText([('', '1 row in set')]), }, ] assert expected == results @dbtest def test_multiple_queries_same_line_syntaxerror(executor): with pytest.raises(pymysql.ProgrammingError) as excinfo: run(executor, "select 'foo'; invalid syntax") assert "You have an error in your SQL syntax;" in str(excinfo.value) @dbtest @pytest.mark.skipif(os.name == "nt", reason="Bug: fails on Windows, needs fixing, singleton of FQ not working right") def test_favorite_query(executor): set_expanded_output(False) run(executor, "create table test(a text)") run(executor, "insert into test values('abc')") run(executor, "insert into test values('def')") results = run(executor, "\\fs test-a select * from test where a like 'a%'") assert_result_equal(results, status="Saved.", status_plain="Saved.") results = run(executor, "\\f test-a") assert_result_equal(results, preamble="> select * from test where a like 'a%'", header=["a"], rows=[("abc",)], auto_status=False) results = run(executor, "\\fd test-a") assert_result_equal(results, status="test-a: Deleted.", status_plain="test-a: Deleted.") @dbtest @pytest.mark.skipif(os.name == "nt", reason="Bug: fails on Windows, needs fixing, singleton of FQ not working right") def test_favorite_query_multiple_statement(executor): set_expanded_output(False) run(executor, "create table test(a text)") run(executor, "insert into test values('abc')") run(executor, "insert into test values('def')") results = run(executor, "\\fs test-ad select * from test where a like 'a%'; select * from test where a like 'd%'") assert_result_equal(results, status="Saved.", status_plain="Saved.") results = run(executor, "\\f test-ad") expected = [ { "preamble": "> select * from test where a like 'a%'", "header": ["a"], "rows": [("abc",)], "postamble": None, "status": None, "status_plain": None, }, { "preamble": "> select * from test where a like 'd%'", "header": ["a"], "rows": [("def",)], "postamble": None, "status": None, "status_plain": None, }, ] assert expected == results results = run(executor, "\\fd test-ad") assert_result_equal(results, status="test-ad: Deleted.", status_plain="test-ad: Deleted.") @dbtest @pytest.mark.skipif(os.name == "nt", reason="Bug: fails on Windows, needs fixing, singleton of FQ not working right") def test_favorite_query_expanded_output(executor): set_expanded_output(False) run(executor, """create table test(a text)""") run(executor, """insert into test values('abc')""") results = run(executor, "\\fs test-ae select * from test") assert_result_equal(results, status="Saved.", status_plain="Saved.") results = run(executor, "\\f test-ae \\G") assert is_expanded_output() is True assert_result_equal(results, preamble="> select * from test", header=["a"], rows=[("abc",)], auto_status=False) set_expanded_output(False) results = run(executor, "\\fd test-ae") assert_result_equal(results, status="test-ae: Deleted.", status_plain="test-ae: Deleted.") @dbtest def test_collapsed_output_special_command(executor): set_expanded_output(True) run(executor, "select 1\\g") assert is_expanded_output() is False @dbtest def test_special_command(executor): results = run(executor, "\\?") assert_result_equal(results, rows=("quit", "\\q", "quit", "Quit."), header="Command", assert_contains=True, auto_status=False) @dbtest def test_cd_command_without_a_folder_name(executor): results = run(executor, "system cd") assert_result_equal( results, status="Exactly one directory name must be provided.", status_plain="Exactly one directory name must be provided." ) @dbtest def test_cd_command_with_one_nonexistent_folder_name(executor): results = run(executor, 'system cd nonexistent_folder_name') assert_result_equal(results, status='No such file or directory', status_plain='No such file or directory') @dbtest def test_cd_command_with_one_real_folder_name(executor): results = run(executor, 'system cd screenshots') # todo would be better to capture stderr but there was a problem with capsys assert results[0]['status_plain'] is None @dbtest def test_cd_command_with_two_folder_names(executor): results = run(executor, "system cd one two") assert_result_equal( results, status='Exactly one directory name must be provided.', status_plain='Exactly one directory name must be provided.' ) @dbtest def test_cd_command_unbalanced(executor): results = run(executor, "system cd 'one") assert_result_equal( results, status='Cannot parse system command: No closing quotation', status_plain='Cannot parse system command: No closing quotation', ) @dbtest def test_system_command_not_found(executor): results = run(executor, "system xyz") if os.name == "nt": assert_result_equal(results, status_plain="OSError: The system cannot find the file specified", assert_contains=True) else: assert_result_equal(results, status_plain="OSError: No such file or directory", assert_contains=True) @dbtest def test_system_command_output(executor): eol = os.linesep test_dir = os.path.abspath(os.path.dirname(__file__)) test_file_path = os.path.join(test_dir, "test.txt") results = run(executor, f"system cat {test_file_path}") assert_result_equal(results, preamble=f"mycli rocks!{eol}") @dbtest def test_cd_command_current_dir(executor): test_path = os.path.abspath(os.path.dirname(__file__)) run(executor, f"system cd {test_path}") assert os.getcwd() == test_path @dbtest def test_unicode_support(executor): results = run(executor, "SELECT '日本語' AS japanese;") assert_result_equal(results, header=["japanese"], rows=[("日本語",)]) @dbtest def test_timestamp_null(executor): run(executor, """create table ts_null(a timestamp null)""") run(executor, """insert into ts_null values(null)""") results = run(executor, """select * from ts_null""") assert_result_equal(results, header=["a"], rows=[(None,)]) @dbtest def test_datetime_null(executor): run(executor, """create table dt_null(a datetime null)""") run(executor, """insert into dt_null values(null)""") results = run(executor, """select * from dt_null""") assert_result_equal(results, header=["a"], rows=[(None,)]) @dbtest def test_date_null(executor): run(executor, """create table date_null(a date null)""") run(executor, """insert into date_null values(null)""") results = run(executor, """select * from date_null""") assert_result_equal(results, header=["a"], rows=[(None,)]) @dbtest def test_time_null(executor): run(executor, """create table time_null(a time null)""") run(executor, """insert into time_null values(null)""") results = run(executor, """select * from time_null""") assert_result_equal(results, header=["a"], rows=[(None,)]) @dbtest def test_multiple_results(executor): query = """CREATE PROCEDURE dmtest() BEGIN SELECT 1; SELECT 2; END""" executor.conn.cursor().execute(query) results = run(executor, "call dmtest;") expected = [ { "preamble": None, "header": ["1"], "rows": [(1,)], "postamble": None, "status_plain": "1 row in set", 'status': FormattedText([('', '1 row in set')]), }, { "preamble": None, "header": ["2"], "rows": [(2,)], "postamble": None, "status_plain": "1 row in set", 'status': FormattedText([('', '1 row in set')]), }, ] assert results == expected @pytest.mark.parametrize( "version_string, species, parsed_version_string, version", ( ("5.7.25-TiDB-v6.1.0", "TiDB", "6.1.0", 60100), ("8.0.11-TiDB-v7.2.0-alpha-69-g96e9e68daa", "TiDB", "7.2.0", 70200), ("5.7.32-35", "Percona", "5.7.32", 50732), ("5.7.32-0ubuntu0.18.04.1", "MySQL", "5.7.32", 50732), ("10.5.8-MariaDB-1:10.5.8+maria~focal", "MariaDB", "10.5.8", 100508), ("5.5.5-10.5.8-MariaDB-1:10.5.8+maria~focal", "MariaDB", "10.5.8", 100508), ("5.0.16-pro-nt-log", "MySQL", "5.0.16", 50016), ("5.1.5a-alpha", "MySQL", "5.1.5", 50105), ("unexpected version string", None, "", 0), ("", None, "", 0), (None, None, "", 0), ), ) def test_version_parsing(version_string, species, parsed_version_string, version): server_info = ServerInfo.from_version_string(version_string) assert (server_info.species and server_info.species.name) == species or ServerSpecies.MySQL assert server_info.version_str == parsed_version_string assert server_info.version == version ================================================ FILE: test/test_tabular_output.py ================================================ # type: ignore """Test the sql output adapter.""" from textwrap import dedent from pymysql.constants import FIELD_TYPE import pytest from mycli.main import MyCli from mycli.packages.sqlresult import SQLResult from test.utils import HOST, PASSWORD, PORT, USER, dbtest @pytest.fixture def mycli(): cli = MyCli() cli.connect(None, USER, PASSWORD, HOST, PORT, None, init_command=None) yield cli cli.sqlexecute.conn.close() @dbtest def test_sql_output(mycli): """Test the sql output adapter.""" header = ["letters", "number", "optional", "float", "binary"] class FakeCursor: def __init__(self): self.data = [("abc", 1, None, 10.0, b"\xaa"), ("d", 456, "1", 0.5, b"\xaa\xbb")] self.description = [ (None, FIELD_TYPE.VARCHAR), (None, FIELD_TYPE.LONG), (None, FIELD_TYPE.LONG), (None, FIELD_TYPE.FLOAT), (None, FIELD_TYPE.BLOB), ] def __iter__(self): return self def __next__(self): if self.data: return self.data.pop(0) else: raise StopIteration() def description(self): return self.description # Test sql-update output format assert list(mycli.change_table_format("sql-update")) == [SQLResult(status="Changed table format to sql-update")] mycli.main_formatter.query = "" mycli.redirect_formatter.query = "" output = mycli.format_sqlresult(SQLResult(header=header, rows=FakeCursor())) actual = "\n".join(output) assert actual == dedent("""\ UPDATE `DUAL` SET `number` = 1 , `optional` = NULL , `float` = 10.0e0 , `binary` = 0xaa WHERE `letters` = 'abc'; UPDATE `DUAL` SET `number` = 456 , `optional` = '1' , `float` = 0.5e0 , `binary` = 0xaabb WHERE `letters` = 'd';""") # Test sql-update-2 output format assert list(mycli.change_table_format("sql-update-2")) == [SQLResult(status="Changed table format to sql-update-2")] mycli.main_formatter.query = "" mycli.redirect_formatter.query = "" output = mycli.format_sqlresult(SQLResult(header=header, rows=FakeCursor())) assert "\n".join(output) == dedent("""\ UPDATE `DUAL` SET `optional` = NULL , `float` = 10.0e0 , `binary` = 0xaa WHERE `letters` = 'abc' AND `number` = 1; UPDATE `DUAL` SET `optional` = '1' , `float` = 0.5e0 , `binary` = 0xaabb WHERE `letters` = 'd' AND `number` = 456;""") # Test sql-insert output format (without table name) assert list(mycli.change_table_format("sql-insert")) == [SQLResult(status="Changed table format to sql-insert")] mycli.main_formatter.query = "" mycli.redirect_formatter.query = "" output = mycli.format_sqlresult(SQLResult(header=header, rows=FakeCursor())) assert "\n".join(output) == dedent("""\ INSERT INTO `DUAL` (`letters`, `number`, `optional`, `float`, `binary`) VALUES ('abc', 1, NULL, 10.0e0, 0xaa) , ('d', 456, '1', 0.5e0, 0xaabb) ;""") # Test sql-insert output format (with table name) assert list(mycli.change_table_format("sql-insert")) == [SQLResult(status="Changed table format to sql-insert")] mycli.main_formatter.query = "SELECT * FROM `table`" mycli.redirect_formatter.query = "SELECT * FROM `table`" output = mycli.format_sqlresult(SQLResult(header=header, rows=FakeCursor())) assert "\n".join(output) == dedent("""\ INSERT INTO table (`letters`, `number`, `optional`, `float`, `binary`) VALUES ('abc', 1, NULL, 10.0e0, 0xaa) , ('d', 456, '1', 0.5e0, 0xaabb) ;""") # Test sql-insert output format (with database + table name) assert list(mycli.change_table_format("sql-insert")) == [SQLResult(status="Changed table format to sql-insert")] mycli.main_formatter.query = "SELECT * FROM `database`.`table`" mycli.redirect_formatter.query = "SELECT * FROM `database`.`table`" output = mycli.format_sqlresult(SQLResult(header=header, rows=FakeCursor())) assert "\n".join(output) == dedent("""\ INSERT INTO database.table (`letters`, `number`, `optional`, `float`, `binary`) VALUES ('abc', 1, NULL, 10.0e0, 0xaa) , ('d', 456, '1', 0.5e0, 0xaabb) ;""") # Test binary output format is a hex string assert list(mycli.change_table_format("psql")) == [SQLResult(status="Changed table format to psql")] output = mycli.format_sqlresult(SQLResult(header=header, rows=FakeCursor())) assert '0xaabb' in '\n'.join(output) @dbtest def test_postamble_output(mycli): """Test the postamble output property.""" header = ['letters', 'number', 'optional', 'float'] class FakeCursor: def __init__(self): self.data = [('abc', 1, None, 10.0)] self.description = [ (None, FIELD_TYPE.VARCHAR), (None, FIELD_TYPE.LONG), (None, FIELD_TYPE.LONG), (None, FIELD_TYPE.FLOAT), ] def __iter__(self): return self def __next__(self): if self.data: return self.data.pop(0) else: raise StopIteration() def description(self): return self.description postamble = 'postamble:\nfooter content' mycli.change_table_format('ascii') mycli.main_formatter.query = '' output = mycli.format_sqlresult(SQLResult(header=header, rows=FakeCursor(), postamble=postamble)) actual = "\n".join(output) assert actual.endswith(postamble) ================================================ FILE: test/utils.py ================================================ # type: ignore import multiprocessing import os import platform import signal import time import pymysql import pytest from mycli.constants import ( DEFAULT_CHARSET, DEFAULT_HOST, DEFAULT_PORT, DEFAULT_USER, TEST_DATABASE, ) from mycli.main import special DATABASE = TEST_DATABASE PASSWORD = os.getenv("PYTEST_PASSWORD") USER = os.getenv("PYTEST_USER", DEFAULT_USER) HOST = os.getenv("PYTEST_HOST", DEFAULT_HOST) PORT = int(os.getenv("PYTEST_PORT", DEFAULT_PORT)) CHARACTER_SET = os.getenv("PYTEST_CHARSET", DEFAULT_CHARSET) SSH_USER = os.getenv("PYTEST_SSH_USER", None) SSH_HOST = os.getenv("PYTEST_SSH_HOST", None) SSH_PORT = int(os.getenv("PYTEST_SSH_PORT", "22")) TEMPFILE_PREFIX = 'mycli_test_suite_' def db_connection(dbname=None): conn = pymysql.connect(user=USER, host=HOST, port=PORT, database=dbname, password=PASSWORD, charset=CHARACTER_SET, local_infile=False) conn.autocommit = True return conn try: db_connection() CAN_CONNECT_TO_DB = True except Exception: CAN_CONNECT_TO_DB = False dbtest = pytest.mark.skipif(not CAN_CONNECT_TO_DB, reason=f"Need a mysql instance at {DEFAULT_HOST} accessible by user '{DEFAULT_USER}'") def create_db(dbname): with db_connection().cursor() as cur: try: cur.execute(f"DROP DATABASE IF EXISTS {TEST_DATABASE}") cur.execute(f"CREATE DATABASE {TEST_DATABASE}") except Exception: pass def run(executor, sql, rows_as_list=True): """Return string output for the sql to be run.""" results = [] for result in executor.run(sql): rows = list(result.rows) if (rows_as_list and result.rows) else result.rows results.append({ "preamble": result.preamble, "header": result.header, "rows": rows, "postamble": result.postamble, "status": result.status, "status_plain": result.status_plain, }) return results def set_expanded_output(is_expanded): """Pass-through for the tests.""" return special.set_expanded_output(is_expanded) def is_expanded_output(): """Pass-through for the tests.""" return special.is_expanded_output() def send_ctrl_c_to_pid(pid, wait_seconds): """Sends a Ctrl-C like signal to the given `pid` after `wait_seconds` seconds.""" time.sleep(wait_seconds) system_name = platform.system() if system_name == "Windows": os.kill(pid, signal.CTRL_C_EVENT) else: os.kill(pid, signal.SIGINT) def send_ctrl_c(wait_seconds): """Create a process that sends a Ctrl-C like signal to the current process after `wait_seconds` seconds. Returns the `multiprocessing.Process` created. """ ctrl_c_process = multiprocessing.Process(target=send_ctrl_c_to_pid, args=(os.getpid(), wait_seconds)) ctrl_c_process.start() return ctrl_c_process ================================================ FILE: tox.ini ================================================ [tox] envlist = py [testenv] skip_install = true deps = uv passenv = PYTEST_HOST PYTEST_USER PYTEST_PASSWORD PYTEST_PORT PYTEST_CHARSET commands = uv pip install -e .[dev,ssh,llm] coverage run -m pytest -v test coverage report -m behave test/features [testenv:style] skip_install = true deps = ruff commands = ruff check ruff format --diff