diff options
49 files changed, 3244 insertions, 1245 deletions
@@ -25,3 +25,6 @@ htmlcov # mypy plugin for PyCharm dmypy.json dmypy.sock + +# cmd2 history file used in main.py +cmd2_history.txt diff --git a/.travis.yml b/.travis.yml index 30fdc8cf..63b73ecf 100644 --- a/.travis.yml +++ b/.travis.yml @@ -14,7 +14,9 @@ matrix: python: 3.6 env: TOXENV=py36 - os: linux - python: 3.7-dev + python: 3.7 + dist: xenial + sudo: true # Travis CI doesn't yet support official (non-development) Python 3.7 on container-based builds env: TOXENV=py37 - os: linux python: 3.5 diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d49d34e..d2d0adec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,37 @@ * Bug Fixes * Fixed bug where ``get_all_commands`` could return non-callable attributes * Fixed bug where **alias** command was dropping quotes around arguments + * Fixed bug where running help on argparse commands didn't work if they didn't support -h * Enhancements * Added ``exit_code`` attribute of ``cmd2.Cmd`` class * Enables applications to return a non-zero exit code when exiting from ``cmdloop`` * ``ACHelpFormatter`` now inherits from ``argparse.RawTextHelpFormatter`` to make it easier for formatting help/description text + * Aliases are now sorted alphabetically + * The **set** command now tab-completes settable parameter names + * Added ``async_alert``, ``async_update_prompt``, and ``set_window_title`` functions + * These allow you to provide feedback to the user in an asychronous fashion, meaning alerts can + display when the user is still entering text at the prompt. See [async_printing.py](https://github.com/python-cmd2/cmd2/blob/master/examples/async_printing.py) + for an example. + * Cross-platform colored output support + * ``colorama`` gets initialized properly in ``Cmd.__init()`` + * The ``Cmd.colors`` setting is no longer platform dependent and now has three values: + * Terminal (default) - output methods do not strip any ANSI escape sequences when output is a terminal, but + if the output is a pipe or a file the escape sequences are stripped + * Always - output methods **never** strip ANSI escape sequences, regardless of the output destination + * Never - output methods strip all ANSI escape sequences + * Added ``macro`` command to create macros, which are similar to aliases, but can take arguments when called + * All cmd2 command functions have been converted to use argparse. +* Deprecations + * Deprecated the built-in ``cmd2`` support for colors including ``Cmd.colorize()`` and ``Cmd._colorcodes`` +* Deletions (potentially breaking changes) + * The ``preparse``, ``postparsing_precmd``, and ``postparsing_postcmd`` methods *deprecated* in the previous release + have been deleted + * The new application lifecycle hook system allows for registration of callbacks to be called at various points + in the lifecycle and is more powerful and flexible than the previous system + * ``alias`` is now a command with subcommands to create, list, and delete aliases. Therefore its syntax + has changed. All current alias commands in startup scripts or transcripts will break with this release. + * `unalias` was deleted since ``alias delete`` replaced it ## 0.9.4 (August 21, 2018) * Bug Fixes @@ -118,7 +144,7 @@ * Fixed ``AttributeError`` on Windows when running a ``select`` command cause by **pyreadline** not implementing ``remove_history_item`` * Enhancements * Added warning about **libedit** variant of **readline** not being supported on macOS - * Added tab-completion of alias names in value filed of **alias** command + * Added tab-completion of alias names in value field of **alias** command * Enhanced the ``py`` console in the following ways * Added tab completion of Python identifiers instead of **cmd2** commands * Separated the ``py`` console history from the **cmd2** history diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0736d893..5ba66b14 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,39 +1,40 @@ -# Contributor's Guide +# Contributor's guide We welcome pull requests from cmd2 users and seasoned Python developers alike! Follow these steps to contribute: -1. Find an issue that needs assistance by searching for the [Help Wanted](https://github.com/python-cmd2/cmd2/labels/help%20wanted) tag. +1. Find an issue that needs assistance by searching for the [Help Wanted](https://github.com/python-cmd2/cmd2/labels/help%20wanted) tag -2. Let us know you are working on it by posting a comment on the issue. +2. Let us know you're working on it by posting a comment on the issue -3. Follow the [Contribution Guidelines](#contribution-guidelines) to start working on the issue. +3. Follow the [Contribution guidelines](#contribution-guidelines) to start working on the issue Remember to feel free to ask for help by leaving a comment within the Issue. -Working on your first Pull Request? You can learn how from this *free* series [How to Contribute to an Open Source Project on GitHub](https://egghead.io/series/how-to-contribute-to-an-open-source-project-on-github). +Working on your first pull request? You can learn how from this *free* series [How to Contribute to an Open Source Project on GitHub](https://egghead.io/series/how-to-contribute-to-an-open-source-project-on-github). -###### If you've found a bug that is not on the board, [follow these steps](#found-a-bug). +###### If you've found a bug that is not on the board, [follow these steps](README.md#found-a-bug). -------------------------------------------------------------------------------- -## Contribution Guidelines +## Contribution guidelines - [Prerequisites](#prerequisites) -- [Forking The Project](#forking-the-project) -- [Create A Branch](#create-a-branch) -- [Setup for cmd2 development](#setup-for-cmd2-development) -- [Make Changes](#make-changes) -- [Static Code Analysis](#static-code-analysis) -- [Run The Test Suite](#run-the-test-suite) -- [Squash Your Commits](#squash-your-commits) -- [Creating A Pull Request](#creating-a-pull-request) -- [Common Steps](#common-steps) -- [How We Review and Merge Pull Requests](#how-we-review-and-merge-pull-requests) -- [How We Close Stale Issues](#how-we-close-stale-issues) -- [Next Steps](#next-steps) +- [Forking the project](#forking-the-project) +- [Creating a branch](#creating-a-branch) +- [Setting up for cmd2 development](#setting-up-for-cmd2-development) +- [Making changes](#making-changes) +- [Static code analysis](#static-code-analysis) +- [Running the test suite](#running-the-test-suite) +- [Squashing your commits](#squashing-your-commits) +- [Creating a pull request](#creating-a-pull-request) +- [Common steps](#common-steps) +- [How we review and merge pull requests](#how-we-review-and-merge-pull-requests) +- [How we close stale issues](#how-we-close-stale-issues) +- [Next steps](#next-steps) - [Other resources](#other-resources) - [Advice](#advice) - [Developing in an IDE](#developing-in-an-ide) +- [Publishing a new release](#publishing-a-new-release) ### Prerequisites @@ -45,25 +46,29 @@ The tables below list all prerequisites along with the minimum required version | Prerequisite | Minimum Version | | --------------------------------------------------- | --------------- | -| [Python](https://www.python.org/downloads/) | `3.4` | -| [pyperclip](https://github.com/asweigart/pyperclip) | `1.6` | +| [python](https://www.python.org/downloads/) | `3.4` | +| [attrs](https://github.com/python-attrs/attrs) | `16.3` | +| [colorama](https://github.com/tartley/colorama) | `0.3.7` | +| [pyperclip](https://github.com/asweigart/pyperclip) | `1.5.27` | + #### Additional prerequisites to run cmd2 unit tests | Prerequisite | Minimum Version | | ------------------------------------------- | --------------- | -| [pytest](http://doc.pytest.org/en/latest/) | `2.6.3` | +| [pytest](http://doc.pytest.org/en/latest/) | `3.0.6` | +| [pytest-mock](https://pypi.org/project/pytest-mock/) | `1.3` | -### Additional prerequisites to build cmd2 documentation +#### Additional prerequisites to build cmd2 documentation | Prerequisite | Minimum Version | | ------------------------------------------- | --------------- | -| [sphinx](http://www.sphinx-doc.org) | `1.2.3` | -| [sphinx-rtd-theme](https://github.com/snide/sphinx_rtd_theme) | `0.1.6` | +| [sphinx](http://www.sphinx-doc.org) | `1.4.9` | +| [sphinx-rtd-theme](https://github.com/snide/sphinx_rtd_theme) | `0.1.9` | -### Optional prerequisites for enhanced unit test features +#### Optional prerequisites for enhanced unit test features | Prerequisite | Minimum Version | | ------------------------------------------- | --------------- | -| [pytest-cov](https://pypi.python.org/pypi/pytest-cov) | `1.8` | +| [pytest-cov](https://pypi.python.org/pypi/pytest-cov) | `2.4` | If Python is already installed in your machine, run the following commands to validate the versions: @@ -74,36 +79,32 @@ $ pip freeze | grep pyperclip If your versions are lower than the prerequisite versions, you should update. -If you do not already have Python installed on your machine, we recommend using the [Anaconda](https://www.continuum.io/downloads) distribution because it provides an excellent out-of-the-box install on all Platforms (Windows, Mac, or Linux) and because it supports having multiple Python environments (versions of Python) installed simultaneously. +If you do not already have Python installed on your machine, we recommend using the [Anaconda](https://www.continuum.io/downloads) distribution because it provides an excellent out-of-the-box install on all platforms (Windows, Mac, and Linux) and because it supports having multiple Python environments (versions of Python) installed simultaneously. -### Forking The Project +### Forking the project -#### Setting Up Your System +#### Setting up your system 1. Install [Git](https://git-scm.com/) or your favorite Git client. If you aren't comfortable with Git at the command-line, then both [SmartGit](http://www.syntevo.com/smartgit/) and [GitKraken](https://www.gitkraken.com) are excellent cross-platform graphical Git clients. -2. (Optional) [Setup an SSH Key](https://help.github.com/articles/generating-an-ssh-key/) for GitHub. -3. Create a parent projects directory on your system. For this guide, it will be assumed that it is `~/src` +2. (Optional) [Set up an SSH key](https://help.github.com/articles/generating-an-ssh-key/) for GitHub. +3. Create a parent projects directory on your system. For this guide, it will be assumed that it is `~/src`. #### Forking cmd2 -1. Go to the top level cmd2 repository: <https://github.com/python-cmd2/cmd2> -2. Click the "Fork" Button in the upper right hand corner of the interface ([More Details Here](https://help.github.com/articles/fork-a-repo/)) +1. Go to the top-level cmd2 repository: <https://github.com/python-cmd2/cmd2> +2. Click the "Fork" button in the upper right hand corner of the interface ([more details here](https://help.github.com/articles/fork-a-repo/)) 3. After the repository has been forked, you will be taken to your copy of the cmd2 repo at `yourUsername/cmd2` -#### Cloning Your Fork +#### Cloning your fork -1. Open a Terminal / Command Line / Bash Shell in your projects directory (_i.e.: `/yourprojectdirectory/`_) -2. Clone your fork of cmd2 +1. Open a terminal / command line / Bash shell in your projects directory (_e.g.: `~/src/`_) +2. Clone your fork of cmd2, making sure to replace `yourUsername` with your GitHub username. This will download the entire cmd2 repo to your projects directory. ```sh $ git clone https://github.com/yourUsername/cmd2.git ``` -##### (make sure to replace `yourUsername` with your GitHub Username) - -This will download the entire cmd2 repo to your projects directory. - -#### Setup Your Upstream +#### Set up your upstream 1. Change directory to the new cmd2 directory (`cd cmd2`) 2. Add a remote to the official cmd2 repo: @@ -114,11 +115,11 @@ $ git remote add upstream https://github.com/python-cmd2/cmd2.git Congratulations, you now have a local copy of the cmd2 repo! -#### Maintaining Your Fork +#### Maintaining your fork Now that you have a copy of your fork, there is work you will need to do to keep it current. -##### **Rebasing from Upstream** +##### **Rebasing from upstream** Do this prior to every time you create a branch for a PR: @@ -130,13 +131,13 @@ Do this prior to every time you create a branch for a PR: > Your branch is up-to-date with 'origin/master'. > ``` - > If your aren't on `master`, resolve outstanding files / commits and checkout the `master` branch + > If your aren't on `master`, resolve outstanding files and commits and checkout the `master` branch > ```sh > $ git checkout master > ``` -2. Do A Pull with Rebase Against `upstream` +2. Do a pull with rebase against `upstream` > ```sh > $ git pull --rebase upstream master @@ -152,15 +153,15 @@ Do this prior to every time you create a branch for a PR: > This will overwrite the master branch of your fork. -### Create A Branch +### Creating a branch -Before you start working, you will need to create a separate branch specific to the issue / feature you're working on. You will push your work to this branch. +Before you start working, you will need to create a separate branch specific to the issue or feature you're working on. You will push your work to this branch. -#### Naming Your Branch +#### Naming your branch Name the branch something like `fix/xxx` or `feature/xxx` where `xxx` is a short description of the changes or feature you are attempting to add. For example `fix/script-files` would be a branch where you fix something specific to script files. -#### Adding Your Branch +#### Adding your branch To create a branch on your local machine (and switch to this branch): @@ -177,7 +178,7 @@ $ git push origin [name_of_your_new_branch] ##### If you need more help with branching, take a look at _[this](https://github.com/Kunena/Kunena-Forum/wiki/Create-a-new-branch-with-git-and-manage-branches)_. -### Setup for cmd2 development +### Setting up for cmd2 development For doing cmd2 development, you actually do NOT want to have cmd2 installed as a Python package. So if you have previously installed cmd2, make sure to uninstall it: ```sh @@ -203,11 +204,11 @@ $ pip install -e .[dev] This project uses many python modules for various development tasks, including testing, rendering documentation, and building and distributing releases. These modules can be configured many different ways, which can make it difficult to -learn the specific incantations required for each project you are familiar with. +learn the specific incantations required for each project you're familiar with. -This project uses `invoke <http://www.pyinvoke.org>`_ to provide a clean, high -level interface for these development tasks. To see the full list of functions -available:: +This project uses `invoke <http://www.pyinvoke.org>` to provide a clean, +high-level interface for these development tasks. To see the full list of functions +available: ```sh $ invoke -l ``` @@ -232,21 +233,21 @@ $ python examples/example.py If the example app loads, you should see a prompt that says "(Cmd)". You can type `help` to get help or `quit` to quit. If you see that, then congratulations -– you're all set. Otherwise, refer to the cmd2 [Installation Instructions](https://cmd2.readthedocs.io/en/latest/install.html#installing). -There also might be an error in the console of your Bash / Terminal / Command Line +– you're all set. Otherwise, refer to the cmd2 [installation instructions](https://cmd2.readthedocs.io/en/latest/install.html#installing). +There also might be an error in the console of your Bash / terminal / command line that will help identify the problem. -### Make Changes +### Making changes This bit is up to you! -#### How to find the code in the cmd2 codebase to fix/edit? +#### How to find code in the cmd2 codebase to fix/edit The cmd2 project directory structure is pretty simple and straightforward. All actual code for cmd2 is located underneath the `cmd2` directory. The code to generate the documentation is in the `docs` directory. Unit tests are in the `tests` directory. The `examples` directory contains examples of how to use cmd2. There are various other files in the root directory, but these are -primarily related to continuous integration and to release deployment. +primarily related to continuous integration and release deployment. #### Changes to the documentation files @@ -255,7 +256,7 @@ Sphinx documentation and make sure your changes look good: ```sh $ invoke docs ``` -In order to see the changes, use your web browser of choice to open `<cmd2>/docs/_build/html/index.html`. +In order to see the changes, use your web browser of choice to open `~/cmd2/docs/_build/html/index.html`. If you would rather use a webserver to view the documentation, including automatic page refreshes as you edit the files, use: @@ -265,48 +266,46 @@ $ invoke livehtml ``` You will be shown the IP address and port number where the documents are now -served (usually [http://localhost:8000](http://localhost:8000). +served (usually [http://localhost:8000](http://localhost:8000)). -### Static Code Analysis +### Static code analysis -You should have some sort of [PEP8](https://www.python.org/dev/peps/pep-0008/)-based linting running in your editor or IDE or at the command-line before you commit code. [pylint](https://www.pylint.org) is a good Python linter which can be run at the command-line but also can integrate with many IDEs and editors. +You should have some sort of [PEP 8](https://www.python.org/dev/peps/pep-0008/)-based linting running in your editor or IDE or at the command line before you commit code. [pylint](https://www.pylint.org) is a good Python linter which can be run at the command line but also can integrate with many IDEs and editors. > Please do not ignore any linting errors in code you write or modify, as they are meant to **help** you and to ensure a clean and simple code base. Don't worry about linting errors in code you don't touch though - cleaning up the legacy code is a work in progress. -### Run The Test Suite +### Running the test suite When you're ready to share your code, run the test suite: ```sh -$ cd <cmd2> +$ cd ~/cmd2 $ invoke pytest ``` and ensure all tests pass. Running the test suite also calculates test code coverage. A summary of coverage -is shown on the screen. A full report is available in `<cmd2>/htmlcov/index.html`. +is shown on the screen. A full report is available in `~/cmd2/htmlcov/index.html`. -### Squash Your Commits +### Squashing your commits When you make a pull request, it is preferable for all of your changes to be in one commit. - If you have made more then one commit, then you can _squash_ your commits. +To do this, see [this article](http://forum.freecodecamp.com/t/how-to-squash-multiple-commits-into-one-with-git/13231). -To do this, see [Squashing Your Commits](http://forum.freecodecamp.com/t/how-to-squash-multiple-commits-into-one-with-git/13231). - -### Creating A Pull Request +### Creating a pull request -#### What is a Pull Request? +#### What is a pull request? A pull request (PR) is a method of submitting proposed changes to the cmd2 -Repo (or any Repo, for that matter). You will make changes to copies of the +repo (or any repo, for that matter). You will make changes to copies of the files which make up cmd2 in a personal fork, then apply to have them accepted by cmd2 proper. -#### Need Help? +#### Need help? GitHub has a good guide on how to contribute to open source [here](https://opensource.guide/how-to-contribute/). #### Important: ALWAYS EDIT ON A BRANCH -Take away only one thing from this document, it should be this: Never, **EVER** +If you take away only one thing from this document, it should be this: Never, **EVER** make edits to the `master` branch. ALWAYS make a new branch BEFORE you edit files. This is critical, because if your PR is not accepted, your copy of master will be forever sullied and the only way to fix it is to delete your @@ -319,13 +318,13 @@ There are two methods of creating a pull request for cmd2: - Editing files on a local clone (recommended) - Editing files via the GitHub Interface -##### Method 1: Editing via your Local Fork _(Recommended)_ +##### Method 1: Editing via your local fork _(recommended)_ -This is the recommended method. Read about [How to Setup and Maintain a Local -Instance of cmd2](#maintaining-your-fork). +This is the recommended method. Read about [how to set up and maintain a local +instance of cmd2](#maintaining-your-fork). -1. Perform the maintenance step of rebasing `master`. -2. Ensure you are on the `master` branch using `git status`: +1. Perform the maintenance step of rebasing `master` +2. Ensure you're on the `master` branch using `git status`: ```sh $ git status @@ -335,7 +334,7 @@ Your branch is up-to-date with 'origin/master'. nothing to commit, working directory clean ``` -1. If you are not on master or your working directory is not clean, resolve +1. If you're not on master or your working directory is not clean, resolve any outstanding files/commits and checkout master `git checkout master` 2. Create a branch off of `master` with git: `git checkout -B @@ -345,69 +344,69 @@ nothing to commit, working directory clean 3. Edit your file(s) locally with the editor of your choice -4. Check your `git status` to see unstaged files. +4. Check your `git status` to see unstaged files 5. Add your edited files: `git add path/to/filename.ext` You can also do: `git add .` to add all unstaged files. Take care, though, because you can accidentally add files you don't want added. Review your `git status` first. -6. Commit your edits: `git commit -m "Brief Description of Commit"`. Do not add the issue number in the commit message. +6. Commit your edits: `git commit -m "Brief description of commit"`. Do not add the issue number in the commit message. -7. Squash your commits, if there are more than one. +7. Squash your commits, if there are more than one 8. Push your commits to your GitHub Fork: `git push -u origin branch/name-here` -9. Go to [Common Steps](#common-steps) +9. Go to [Common steps](#common-steps) -##### Method 2: Editing via the GitHub Interface +##### Method 2: Editing via the GitHub interface Note: Editing via the GitHub Interface is not recommended, since it is not possible to update your fork via GitHub's interface without deleting and recreating your fork. -If you really want to go this route which isn't recommended, you can Google for more information on +If you really want to go this route (which isn't recommended), you can Google for more information on how to do it. -### Common Steps +### Common steps 1. Once the edits have been committed, you will be prompted to create a pull - request on your fork's GitHub Page. + request on your fork's GitHub page 2. By default, all pull requests should be against the cmd2 main repo, `master` - branch. + branch -3. Submit a pull request from your branch to cmd2's `master` branch. +3. Submit a pull request from your branch to cmd2's `master` branch 4. The title (also called the subject) of your PR should be descriptive of your - changes and succinctly indicates what is being fixed. + changes and succinctly indicate what is being fixed - - **Do not add the issue number in the PR title or commit message.** + - **Do not add the issue number in the PR title or commit message** - - Examples: `Add Test Cases for Unicode Support` `Correct typo in Overview documentation` + - Examples: `Add test cases for Unicode support`; `Correct typo in overview documentation` 5. In the body of your PR include a more detailed summary of the changes you - made and why. + made and why - If the PR is meant to fix an existing bug/issue, then, at the end of your PR's description, append the keyword `closes` and #xxxx (where xxxx is the issue number). Example: `closes #1337`. This tells GitHub to - close the existing issue, if the PR is merged. + close the existing issue if the PR is merged. 6. Indicate what local testing you have done (e.g. what OS and version(s) of Python did you run the unit test suite with) 7. Creating the PR causes our continuous integration (CI) systems to automatically run all of the - unit tests on all supported OSes and all supported versions of Python. You should watch your PR + unit tests on all supported OSes and all supported versions of Python. You should watch your PR to make sure that all unit tests pass on TravisCI (Linux), AppVeyor (Windows), and VSTS (macOS). -8. If any unit tests fail, you should look at the details and fix the failures. You can then push - the fix to the same branch in your fork and the PR will automatically get updated and the CI system +8. If any unit tests fail, you should look at the details and fix the failures. You can then push + the fix to the same branch in your fork. The PR will automatically get updated and the CI system will automatically run all of the unit tests again. -### How We Review and Merge Pull Requests +### How we review and merge pull requests -cmd2 has a team of volunteer Maintainers. These Maintainers routinely go through open pull requests in a process called [Quality Assurance](https://en.wikipedia.org/wiki/Quality_assurance) (QA). We also utilize multiple continuous +cmd2 has a team of volunteer Maintainers. These Maintainers routinely go through open pull requests in a process called [Quality Assurance](https://en.wikipedia.org/wiki/Quality_assurance) (QA). We also use multiple continuous integration (CI) providers to automatically run all of the unit tests on multiple operating systems and versions of Python. 1. If your changes can merge without conflicts and all unit tests pass for all OSes and supported versions of Python, @@ -422,15 +421,15 @@ and doesn't present any backward compatibility issues, they will merge the pull If you would like to apply to join our Maintainer team, message [@tleonhardt](https://github.com/tleonhardt) with links to 5 of your pull requests that have been accepted. -### How We Close Stale Issues +### How we close stale issues We will close any issues that have been inactive for more than 60 days or pull requests that have been -inactive for more than 30 days, except those that match the following criteria: +inactive for more than 30 days, except those that match any of the following criteria: - bugs that are confirmed - pull requests that are waiting on other pull requests to be merged - features that are part of a cmd2 GitHub Milestone or Project -### Next Steps +### Next steps #### If your PR is accepted @@ -445,7 +444,7 @@ delete the local copy of the branch with: `git branch -D branch/to-delete-name` Don't despair! You should receive solid feedback from the Maintainers as to why it was rejected and what changes are needed. -Many Pull Requests, especially first Pull Requests, require correction or +Many pull requests, especially first pull requests, require correction or updating. If you have used the GitHub interface to create your PR, you will need to close your PR, create a new branch, and re-submit. @@ -458,11 +457,11 @@ Be sure to post in the PR conversation that you have made the requested changes. ### Other resources -- [PEP8 Style Guide for Python Code](https://www.python.org/dev/peps/pep-0008/) +- [PEP 8 Style Guide for Python Code](https://www.python.org/dev/peps/pep-0008/) -- [Searching for Your Issue on GitHub](https://help.github.com/articles/searching-issues/) +- [Searching for your issue on GitHub](https://help.github.com/articles/searching-issues/) -- [Creating a New GitHub Issue](https://help.github.com/articles/creating-an-issue/) +- [Creating a new GitHub issue](https://help.github.com/articles/creating-an-issue/) ### Advice @@ -472,30 +471,30 @@ Here is some advice regarding what makes a good pull request (PR) from the persp - Good unit/functional tests are very important - Accurate documentation is also important - Adding new features is of the lowest importance, behind bug fixes, unit test additions/improvements, code cleanup, and documentation -- It's best to create a dedicated branch for a PR and use it only for that PR (and delete it once the PR has been merged) +- It's best to create a dedicated branch for a PR, use it only for that PR, and delete it once the PR has been merged - It's good if the branch name is related to the PR contents, even if it's just "fix123" or "add_more_tests" -- Code coverage of the unit tests matters, try not to decrease it -- Think twice before adding dependencies to 3rd party libraries (outside of the Python standard library) because it could affect a lot of users +- Code coverage of the unit tests matters, so try not to decrease it +- Think twice before adding dependencies to third-party libraries (outside of the Python standard library) because it could affect a lot of users ### Developing in an IDE -We recommend using [Visual Studio Code](https://code.visualstudio.com) with the [Python extension](https://code.visualstudio.com/docs/languages/python) and it's [Integrated Terminal](https://code.visualstudio.com/docs/python/debugging) debugger for debugging since it has +We recommend using [Visual Studio Code](https://code.visualstudio.com) with the [Python extension](https://code.visualstudio.com/docs/languages/python) and its [Integrated Terminal](https://code.visualstudio.com/docs/python/debugging) debugger for debugging since it has excellent support for debugging console applications. -[PyCharm](https://www.jetbrains.com/pycharm/) is also quite good and has very nice [Code Inspection](https://www.jetbrains.com/help/pycharm/code-inspection.html) capabilities. +[PyCharm](https://www.jetbrains.com/pycharm/) is also quite good and has very nice [code inspection](https://www.jetbrains.com/help/pycharm/code-inspection.html) capabilities. ## Publishing a new release Since 0.9.2, the process of publishing a new release of `cmd2` to [PyPi](https://pypi.org/) has been mostly automated. The manual steps are all git operations. Here's the checklist: -1. Make sure you are on the proper branch -2. Make sure the version strings in `cmd2.py`, `conf.py`, `setup.py`, and `test_cmd2.py` are correct. -3. Make sure all the unit tests pass. -4. Make sure `CHANGELOG.md` describes the version and has the correct release date. -5. Create and push a git tag that matches the version strings above. -6. (Optional) Run `invoke pypi_test` to clean, build, and upload a new release to [Test PyPi](https://test.pypi.org) -7. Run `invoke pypi` to clean, build, and upload a new release to [PyPi](https://pypi.org/) +1. Make sure you're on the proper branch (almost always **master**) +1. Make sure all the unit tests pass wih `invoke pypi-test` or `py.test` +1. Make sure `CHANGELOG.md` describes the version and has the correct release date +1. Add a git tag representing the version number using ``invoke tag x.y.z`` + * Where x, y, and z are all small non-negative integers +1. (Optional) Run `invoke pypi_test` to clean, build, and upload a new release to [Test PyPi](https://test.pypi.org) +1. Run `invoke pypi` to clean, build, and upload a new release to [PyPi](https://pypi.org/) ## Acknowledgement Thanks to the good folks at [freeCodeCamp](https://github.com/freeCodeCamp/freeCodeCamp) for creating @@ -14,8 +14,8 @@ applications. It provides a simple API which is an extension of Python's built- of cmd to make your life easier and eliminates much of the boilerplate code which would be necessary when using cmd. -[](https://github.com/python-cmd2/cmd2/blob/master/cmd2.png) - +Click on image below to watch a short video demonstrating the capabilities of cmd2: +[](https://youtu.be/DDU_JH6cFsA) Main Features ------------- @@ -31,6 +31,7 @@ Main Features - Multi-line commands - Special-character command shortcuts (beyond cmd's `?` and `!`) - Command aliasing similar to bash `alias` command +- Macros, which are similar to aliases, but can take arguments when called - Ability to load commands at startup from an initialization script - Settable environment parameters - Parsing commands with arguments using `argparse`, including support for sub-commands @@ -40,6 +41,7 @@ Main Features - Trivial to provide built-in help for all commands - Built-in regression testing framework for your applications (transcript-based testing) - Transcripts for use with built-in regression can be automatically generated from `history -t` +- Alerts that seamlessly print while user enters text at prompt Python 2.7 support is EOL ------------------------- @@ -259,3 +261,15 @@ timing: False Note how a regular expression `/(True|False)/` is used for output of the **show color** command since colored text is currently not available for cmd2 on Windows. Regular expressions can be used anywhere within a transcript file simply by enclosing them within forward slashes, `/`. + + +Found a bug? +------------ + +If you think you've found a bug, please first read through the open [Issues](https://github.com/python-cmd2/cmd2/issues). If you're confident it's a new bug, go ahead and create a new GitHub issue. Be sure to include as much information as possible so we can reproduce the bug. At a minimum, please state the following: + +* ``cmd2`` version +* Python version +* OS name and version +* What you did to cause the bug to occur +* Include any traceback or error message associated with the bug Binary files differdiff --git a/cmd2/argcomplete_bridge.py b/cmd2/argcomplete_bridge.py index 7bdb816f..51e856ef 100644 --- a/cmd2/argcomplete_bridge.py +++ b/cmd2/argcomplete_bridge.py @@ -23,16 +23,18 @@ else: import os import shlex import sys + from typing import List, Tuple, Union from . import constants from . import utils - def tokens_for_completion(line, endidx): + def tokens_for_completion(line: str, endidx: int) -> Union[Tuple[List[str], List[str], int, int], + Tuple[None, None, None, None]]: """ Used by tab completion functions to get all tokens through the one being completed - :param line: str - the current input line with leading whitespace removed - :param endidx: int - the ending index of the prefix text + :param line: the current input line with leading whitespace removed + :param endidx: the ending index of the prefix text :return: A 4 item tuple where the items are On Success tokens: list of unquoted tokens @@ -46,7 +48,7 @@ else: The last item in both lists is the token being tab completed On Failure - Both items are None + All 4 items are None """ unclosed_quote = '' quotes_to_try = copy.copy(constants.QUOTES) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 0e241cd9..ad2c520b 100755 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -510,6 +510,18 @@ class AutoCompleter(object): return self.basic_complete(text, line, begidx, endidx, completers.keys()) return [] + def format_help(self, tokens: List[str]) -> str: + """Supports the completion of sub-commands for commands through the cmd2 help command.""" + for idx, token in enumerate(tokens): + if idx >= self._token_start_index: + if self._positional_completers: + # For now argparse only allows 1 sub-command group per level + # so this will only loop once. + for completers in self._positional_completers.values(): + if token in completers: + return completers[token].format_help(tokens) + return self._parser.format_help() + @staticmethod def _process_action_nargs(action: argparse.Action, arg_state: _ArgumentState) -> None: if isinstance(action, _RangeAction): @@ -654,12 +666,19 @@ class AutoCompleter(object): else: prefix = '' + if action.help is None: + help_text = '' + else: + help_text = action.help + + # is there anything to print for this parameter? + if not prefix and not help_text: + return + prefix = ' {0: <{width}} '.format(prefix, width=20) pref_len = len(prefix) - if action.help is not None: - help_lines = action.help.splitlines() - else: - help_lines = [''] + help_lines = help_text.splitlines() + if len(help_lines) == 1: print('\nHint:\n{}{}\n'.format(prefix, help_lines[0])) else: @@ -676,12 +695,12 @@ class AutoCompleter(object): """ Performs tab completion against a list - :param text: str - the string prefix we are attempting to match (all returned matches must begin with it) - :param line: str - the current input line with leading whitespace removed - :param begidx: int - the beginning index of the prefix text - :param endidx: int - the ending index of the prefix text - :param match_against: Collection - the list being matched against - :return: List[str] - a list of possible tab completions + :param text: the string prefix we are attempting to match (all returned matches must begin with it) + :param line: the current input line with leading whitespace removed + :param begidx: the beginning index of the prefix text + :param endidx: the ending index of the prefix text + :param match_against: the list being matched against + :return: a list of possible tab completions """ return [cur_match for cur_match in match_against if cur_match.startswith(text)] @@ -731,7 +750,7 @@ class ACHelpFormatter(argparse.RawTextHelpFormatter): # build full usage string format = self._format_actions_usage - action_usage = format(positionals + required_options + optionals, groups) + action_usage = format(required_options + optionals + positionals, groups) usage = ' '.join([s for s in [prog, action_usage] if s]) # wrap the usage parts if it's too long @@ -742,15 +761,15 @@ class ACHelpFormatter(argparse.RawTextHelpFormatter): # break usage into wrappable parts part_regexp = r'\(.*?\)+|\[.*?\]+|\S+' + req_usage = format(required_options, groups) opt_usage = format(optionals, groups) pos_usage = format(positionals, groups) - req_usage = format(required_options, groups) + req_parts = _re.findall(part_regexp, req_usage) opt_parts = _re.findall(part_regexp, opt_usage) pos_parts = _re.findall(part_regexp, pos_usage) - req_parts = _re.findall(part_regexp, req_usage) + assert ' '.join(req_parts) == req_usage assert ' '.join(opt_parts) == opt_usage assert ' '.join(pos_parts) == pos_usage - assert ' '.join(req_parts) == req_usage # End cmd2 customization @@ -780,13 +799,15 @@ class ACHelpFormatter(argparse.RawTextHelpFormatter): if len(prefix) + len(prog) <= 0.75 * text_width: indent = ' ' * (len(prefix) + len(prog) + 1) # Begin cmd2 customization - if opt_parts: - lines = get_lines([prog] + pos_parts, indent, prefix) - lines.extend(get_lines(req_parts, indent)) + if req_parts: + lines = get_lines([prog] + req_parts, indent, prefix) lines.extend(get_lines(opt_parts, indent)) + lines.extend(get_lines(pos_parts, indent)) + elif opt_parts: + lines = get_lines([prog] + opt_parts, indent, prefix) + lines.extend(get_lines(pos_parts, indent)) elif pos_parts: lines = get_lines([prog] + pos_parts, indent, prefix) - lines.extend(get_lines(req_parts, indent)) else: lines = [prog] # End cmd2 customization @@ -795,13 +816,13 @@ class ACHelpFormatter(argparse.RawTextHelpFormatter): else: indent = ' ' * len(prefix) # Begin cmd2 customization - parts = pos_parts + req_parts + opt_parts + parts = req_parts + opt_parts + pos_parts lines = get_lines(parts, indent) if len(lines) > 1: lines = [] - lines.extend(get_lines(pos_parts, indent)) lines.extend(get_lines(req_parts, indent)) lines.extend(get_lines(opt_parts, indent)) + lines.extend(get_lines(pos_parts, indent)) # End cmd2 customization lines = [prog] + lines @@ -870,6 +891,9 @@ class ACHelpFormatter(argparse.RawTextHelpFormatter): result = super()._format_args(action, default_metavar) return result + def format_help(self): + return super().format_help() + '\n' + # noinspection PyCompatibility class ACArgumentParser(argparse.ArgumentParser): diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index fb929078..dec0a04d 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -32,25 +32,26 @@ Git repository on GitHub at https://github.com/python-cmd2/cmd2 import argparse import cmd import collections +import colorama from colorama import Fore import glob import inspect import os -import platform import re import shlex import sys -from typing import Any, Callable, Dict, List, Mapping, Optional, Tuple, Type, Union +import threading +from typing import Any, Callable, Dict, List, Mapping, Optional, Tuple, Type, Union, IO from . import constants from . import utils from . import plugin -from .argparse_completer import AutoCompleter, ACArgumentParser +from .argparse_completer import AutoCompleter, ACArgumentParser, ACTION_ARG_CHOICES from .clipboard import can_clip, get_paste_buffer, write_to_paste_buffer -from .parsing import StatementParser, Statement +from .parsing import StatementParser, Statement, Macro, MacroArg # Set up readline -from .rl_utils import rl_type, RlType +from .rl_utils import rl_type, RlType, rl_get_point, rl_set_prompt, vt100_support, rl_make_safe_prompt if rl_type == RlType.NONE: # pragma: no cover rl_warning = "Readline features including tab completion have been disabled since no \n" \ "supported version of readline was found. To resolve this, install \n" \ @@ -104,9 +105,9 @@ except ImportError: # Python 3.4 require contextlib2 for temporarily redirecting stderr and stdout if sys.version_info < (3, 5): - from contextlib2 import redirect_stdout, redirect_stderr + from contextlib2 import redirect_stdout else: - from contextlib import redirect_stdout, redirect_stderr + from contextlib import redirect_stdout # Detect whether IPython is installed to determine if the built-in "ipy" command should be included ipython_available = True @@ -121,6 +122,13 @@ except ImportError: # pragma: no cover HELP_CATEGORY = 'help_category' HELP_SUMMARY = 'help_summary' +INTERNAL_COMMAND_EPILOG = ("Notes:\n" + " This command is for internal use and is not intended to be called from the\n" + " command line.") + +# All command functions start with this +COMMAND_PREFIX = 'do_' + def categorize(func: Union[Callable, Iterable], category: str) -> None: """Categorize a function. @@ -137,19 +145,21 @@ def categorize(func: Union[Callable, Iterable], category: str) -> None: setattr(func, HELP_CATEGORY, category) -def parse_quoted_string(cmdline: str) -> List[str]: - """Parse a quoted string into a list of arguments.""" - if isinstance(cmdline, list): +def parse_quoted_string(string: str, preserve_quotes: bool) -> List[str]: + """ + Parse a quoted string into a list of arguments + :param string: the string being parsed + :param preserve_quotes: if True, then quotes will not be stripped + """ + if isinstance(string, list): # arguments are already a list, return the list we were passed - lexed_arglist = cmdline + lexed_arglist = string else: # Use shlex to split the command line into a list of arguments based on shell rules - lexed_arglist = shlex.split(cmdline, posix=False) - # strip off outer quotes for convenience - temp_arglist = [] - for arg in lexed_arglist: - temp_arglist.append(utils.strip_quotes(arg)) - lexed_arglist = temp_arglist + lexed_arglist = shlex.split(string, posix=False) + + if not preserve_quotes: + lexed_arglist = [utils.strip_quotes(arg) for arg in lexed_arglist] return lexed_arglist @@ -161,7 +171,7 @@ def with_category(category: str) -> Callable: return cat_decorator -def with_argument_list(func: Callable) -> Callable: +def with_argument_list(func: Callable, preserve_quotes: bool=False) -> Callable: """A decorator to alter the arguments passed to a do_* cmd2 method. Default passes a string of whatever the user typed. With this decorator, the decorated method will receive a list @@ -170,18 +180,19 @@ def with_argument_list(func: Callable) -> Callable: @functools.wraps(func) def cmd_wrapper(self, cmdline): - lexed_arglist = parse_quoted_string(cmdline) + lexed_arglist = parse_quoted_string(cmdline, preserve_quotes) return func(self, lexed_arglist) cmd_wrapper.__doc__ = func.__doc__ return cmd_wrapper -def with_argparser_and_unknown_args(argparser: argparse.ArgumentParser) -> Callable: +def with_argparser_and_unknown_args(argparser: argparse.ArgumentParser, preserve_quotes: bool=False) -> Callable: """A decorator to alter a cmd2 method to populate its ``args`` argument by parsing arguments with the given instance of argparse.ArgumentParser, but also returning unknown args as a list. - :param argparser: argparse.ArgumentParser - given instance of ArgumentParser + :param argparser: given instance of ArgumentParser + :param preserve_quotes: if True, then the arguments passed to arparse be maintain their quotes :return: function that gets passed parsed args and a list of unknown args """ import functools @@ -190,7 +201,7 @@ def with_argparser_and_unknown_args(argparser: argparse.ArgumentParser) -> Calla def arg_decorator(func: Callable): @functools.wraps(func) def cmd_wrapper(instance, cmdline): - lexed_arglist = parse_quoted_string(cmdline) + lexed_arglist = parse_quoted_string(cmdline, preserve_quotes) try: args, unknown = argparser.parse_known_args(lexed_arglist) except SystemExit: @@ -219,11 +230,12 @@ def with_argparser_and_unknown_args(argparser: argparse.ArgumentParser) -> Calla return arg_decorator -def with_argparser(argparser: argparse.ArgumentParser) -> Callable: +def with_argparser(argparser: argparse.ArgumentParser, preserve_quotes: bool=False) -> Callable: """A decorator to alter a cmd2 method to populate its ``args`` argument by parsing arguments with the given instance of argparse.ArgumentParser. - :param argparser: argparse.ArgumentParser - given instance of ArgumentParser + :param argparser: given instance of ArgumentParser + :param preserve_quotes: if True, then the arguments passed to arparse be maintain their quotes :return: function that gets passed parsed args """ import functools @@ -232,7 +244,7 @@ def with_argparser(argparser: argparse.ArgumentParser) -> Callable: def arg_decorator(func: Callable): @functools.wraps(func) def cmd_wrapper(instance, cmdline): - lexed_arglist = parse_quoted_string(cmdline) + lexed_arglist = parse_quoted_string(cmdline, preserve_quotes) try: args = argparser.parse_args(lexed_arglist) except SystemExit: @@ -306,7 +318,6 @@ class Cmd(cmd.Cmd): # Attributes used to configure the StatementParser, best not to change these at runtime multiline_commands = [] shortcuts = {'?': 'help', '!': 'shell', '@': 'load', '@@': '_relative_load'} - aliases = dict() terminators = [';'] # Attributes which are NOT dynamically settable at runtime @@ -317,7 +328,7 @@ class Cmd(cmd.Cmd): reserved_words = [] # Attributes which ARE dynamically settable at runtime - colors = (platform.system() != 'Windows') + colors = constants.COLORS_TERMINAL continuation_prompt = '> ' debug = False echo = False @@ -337,7 +348,7 @@ class Cmd(cmd.Cmd): # To make an attribute settable with the "do_set" command, add it to this ... # This starts out as a dictionary but gets converted to an OrderedDict sorted alphabetically by key - settable = {'colors': 'Colorized output (*nix only)', + settable = {'colors': 'Allow colorized output (valid values: Terminal, Always, Never)', 'continuation_prompt': 'On 2nd+ line of input', 'debug': 'Show full error stack on error', 'echo': 'Echo command issued into output', @@ -369,6 +380,9 @@ class Cmd(cmd.Cmd): except AttributeError: pass + # Override whether ansi codes should be stripped from the output since cmd2 has its own logic for doing this + colorama.init(strip=False) + # initialize plugin system # needs to be done before we call __init__(0) self._initialize_plugin_system() @@ -376,12 +390,20 @@ class Cmd(cmd.Cmd): # Call super class constructor super().__init__(completekey=completekey, stdin=stdin, stdout=stdout) + # Get rid of cmd's complete_help() functions so AutoCompleter will complete our help command + if getattr(cmd.Cmd, 'complete_help', None) is not None: + delattr(cmd.Cmd, 'complete_help') + # Commands to exclude from the help menu and tab completion self.hidden_commands = ['eof', 'eos', '_relative_load'] # Commands to exclude from the history command self.exclude_from_history = '''history edit eof eos'''.split() + # Command aliases and macros + self.aliases = dict() + self.macros = dict() + self._finalize_app_parameters() self.initial_stdout = sys.stdout @@ -389,7 +411,7 @@ class Cmd(cmd.Cmd): self.pystate = {} self.py_history = [] self.pyscript_name = 'app' - self.keywords = self.reserved_words + [fname[3:] for fname in dir(self) if fname.startswith('do_')] + self.keywords = self.reserved_words + self.get_all_commands() self.statement_parser = StatementParser( allow_redirection=self.allow_redirection, terminators=self.terminators, @@ -417,13 +439,13 @@ class Cmd(cmd.Cmd): self._STOP_AND_EXIT = True # cmd convention self._colorcodes = {'bold': {True: '\x1b[1m', False: '\x1b[22m'}, - 'cyan': {True: '\x1b[36m', False: '\x1b[39m'}, - 'blue': {True: '\x1b[34m', False: '\x1b[39m'}, - 'red': {True: '\x1b[31m', False: '\x1b[39m'}, - 'magenta': {True: '\x1b[35m', False: '\x1b[39m'}, - 'green': {True: '\x1b[32m', False: '\x1b[39m'}, - 'underline': {True: '\x1b[4m', False: '\x1b[24m'}, - 'yellow': {True: '\x1b[33m', False: '\x1b[39m'}} + 'cyan': {True: Fore.CYAN, False: Fore.RESET}, + 'blue': {True: Fore.BLUE, False: Fore.RESET}, + 'red': {True: Fore.RED, False: Fore.RESET}, + 'magenta': {True: Fore.MAGENTA, False: Fore.RESET}, + 'green': {True: Fore.GREEN, False: Fore.RESET}, + 'underline': {True: '\x1b[4m', False: Fore.RESET}, + 'yellow': {True: Fore.YELLOW, False: Fore.RESET}} # Used load command to store the current script dir as a LIFO queue to support _relative_load command self._script_dir = [] @@ -441,6 +463,7 @@ class Cmd(cmd.Cmd): self.broken_pipe_warning = '' # Check if history should persist + self.persistent_history_file = '' if persistent_history_file and rl_type != RlType.NONE: persistent_history_file = os.path.expanduser(persistent_history_file) read_err = False @@ -526,6 +549,11 @@ class Cmd(cmd.Cmd): # This determines if a non-zero exit code should be used when exiting the application self.exit_code = None + # This lock should be acquired before doing any asynchronous changes to the terminal to + # ensure the updates to the terminal don't interfere with the input being typed. It can be + # acquired any time there is a readline prompt on screen. + self.terminal_lock = threading.RLock() + # ----- Methods related to presenting output to the user ----- @property @@ -547,34 +575,53 @@ class Cmd(cmd.Cmd): # Make sure settable parameters are sorted alphabetically by key self.settable = collections.OrderedDict(sorted(self.settable.items(), key=lambda t: t[0])) - def poutput(self, msg: str, end: str='\n') -> None: - """Convenient shortcut for self.stdout.write(); by default adds newline to end if not already present. + def decolorized_write(self, fileobj: IO, msg: str) -> None: + """Write a string to a fileobject, stripping ANSI escape sequences if necessary - Also handles BrokenPipeError exceptions for when a commands's output has been piped to another process and - that process terminates before the cmd2 command is finished executing. + Honor the current colors setting, which requires us to check whether the + fileobject is a tty. + """ + if self.colors.lower() == constants.COLORS_NEVER.lower() or \ + (self.colors.lower() == constants.COLORS_TERMINAL.lower() and not fileobj.isatty()): + msg = utils.strip_ansi(msg) + fileobj.write(msg) - :param msg: message to print to current stdout - anything convertible to a str with '{}'.format() is OK - :param end: string appended after the end of the message if not already present, default a newline + def poutput(self, msg: Any, end: str='\n', color: str='') -> None: + """Smarter self.stdout.write(); color aware and adds newline of not present. + + Also handles BrokenPipeError exceptions for when a commands's output has + been piped to another process and that process terminates before the + cmd2 command is finished executing. + + :param msg: message to print to current stdout (anything convertible to a str with '{}'.format() is OK) + :param end: (optional) string appended after the end of the message if not already present, default a newline + :param color: (optional) color escape to output this message with """ if msg is not None and msg != '': try: msg_str = '{}'.format(msg) - self.stdout.write(msg_str) if not msg_str.endswith(end): - self.stdout.write(end) + msg_str += end + if color: + msg_str = color + msg_str + Fore.RESET + self.decolorized_write(self.stdout, msg_str) except BrokenPipeError: - # This occurs if a command's output is being piped to another process and that process closes before the - # command is finished. If you would like your application to print a warning message, then set the - # broken_pipe_warning attribute to the message you want printed. + # This occurs if a command's output is being piped to another + # process and that process closes before the command is + # finished. If you would like your application to print a + # warning message, then set the broken_pipe_warning attribute + # to the message you want printed. if self.broken_pipe_warning: sys.stderr.write(self.broken_pipe_warning) - def perror(self, err: Union[str, Exception], traceback_war: bool=True) -> None: + def perror(self, err: Union[str, Exception], traceback_war: bool=True, err_color: str=Fore.LIGHTRED_EX, + war_color: str=Fore.LIGHTYELLOW_EX) -> None: """ Print error message to sys.stderr and if debug is true, print an exception Traceback if one exists. :param err: an Exception or error message to print out :param traceback_war: (optional) if True, print a message to let user know they can enable debug - :return: + :param err_color: (optional) color escape to output error with + :param war_color: (optional) color escape to output warning with """ if self.debug: import traceback @@ -582,14 +629,15 @@ class Cmd(cmd.Cmd): if isinstance(err, Exception): err_msg = "EXCEPTION of type '{}' occurred with message: '{}'\n".format(type(err).__name__, err) - sys.stderr.write(self.colorize(err_msg, 'red')) else: - err_msg = self.colorize("ERROR: {}\n".format(err), 'red') - sys.stderr.write(err_msg) + err_msg = "ERROR: {}\n".format(err) + err_msg = err_color + err_msg + Fore.RESET + self.decolorized_write(sys.stderr, err_msg) if traceback_war: war = "To enable full traceback, run the following command: 'set debug true'\n" - sys.stderr.write(self.colorize(war, 'yellow')) + war = war_color + war + Fore.RESET + self.decolorized_write(sys.stderr, war) def pfeedback(self, msg: str) -> None: """For printing nonessential feedback. Can be silenced with `quiet`. @@ -598,7 +646,7 @@ class Cmd(cmd.Cmd): if self.feedback_to_output: self.poutput(msg) else: - sys.stderr.write("{}\n".format(msg)) + self.decolorized_write(sys.stderr, "{}\n".format(msg)) def ppaged(self, msg: str, end: str='\n', chop: bool=False) -> None: """Print output using a pager if it would go off screen and stdout isn't currently being redirected. @@ -606,7 +654,7 @@ class Cmd(cmd.Cmd): Never uses a pager inside of a script (Python or text) or when output is being redirected or piped or when stdout or stdin are not a fully functional terminal. - :param msg: message to print to current stdout - anything convertible to a str with '{}'.format() is OK + :param msg: message to print to current stdout (anything convertible to a str with '{}'.format() is OK) :param end: string appended after the end of the message if not already present, default a newline :param chop: True -> causes lines longer than the screen width to be chopped (truncated) rather than wrapped - truncated text is still accessible by scrolling with the right & left arrow keys @@ -634,6 +682,9 @@ class Cmd(cmd.Cmd): # Don't attempt to use a pager that can block if redirecting or running a script (either text or Python) # Also only attempt to use a pager if actually running in a real fully functional terminal if functional_terminal and not self.redirecting and not self._in_py and not self._script_dir: + if self.colors.lower() == constants.COLORS_NEVER.lower(): + msg_str = utils.strip_ansi(msg_str) + pager = self.pager if chop: pager = self.pager_chop @@ -658,7 +709,7 @@ class Cmd(cmd.Cmd): except BrokenPipeError: # This occurs if a command's output is being piped to another process and that process closes before the # command is finished. If you would like your application to print a warning message, then set the - # broken_pipe_warning attribute to the message you want printed. + # broken_pipe_warning attribute to the message you want printed.` if self.broken_pipe_warning: sys.stderr.write(self.broken_pipe_warning) @@ -669,7 +720,7 @@ class Cmd(cmd.Cmd): is running on Windows, will return ``val`` unchanged. ``color`` should be one of the supported strings (or styles): red/blue/green/cyan/magenta, bold, underline""" - if self.colors and (self.stdout == self.initial_stdout): + if self.colors.lower() != constants.COLORS_NEVER.lower() and (self.stdout == self.initial_stdout): return self._colorcodes[color][True] + val + self._colorcodes[color][False] return val @@ -897,14 +948,13 @@ class Cmd(cmd.Cmd): :param line: the current input line with leading whitespace removed :param begidx: the beginning index of the prefix text :param endidx: the ending index of the prefix text - :param flag_dict: dict - dictionary whose structure is the following: - keys - flags (ex: -c, --create) that result in tab completion for the next - argument in the command line - values - there are two types of values - 1. iterable list of strings to match against (dictionaries, lists, etc.) - 2. function that performs tab completion (ex: path_complete) - :param all_else: Collection or function - an optional parameter for tab completing any token that isn't preceded - by a flag in flag_dict + :param flag_dict: dictionary whose structure is the following: + keys - flags (ex: -c, --create) that result in tab completion for the next + argument in the command line + values - there are two types of values + 1. iterable list of strings to match against (dictionaries, lists, etc.) + 2. function that performs tab completion (ex: path_complete) + :param all_else: an optional parameter for tab completing any token that isn't preceded by a flag in flag_dict :return: a list of possible tab completions """ # Get all tokens through the one being completed @@ -940,14 +990,13 @@ class Cmd(cmd.Cmd): :param line: the current input line with leading whitespace removed :param begidx: the beginning index of the prefix text :param endidx: the ending index of the prefix text - :param index_dict: dict - dictionary whose structure is the following: - keys - 0-based token indexes into command line that determine which tokens - perform tab completion - values - there are two types of values - 1. iterable list of strings to match against (dictionaries, lists, etc.) - 2. function that performs tab completion (ex: path_complete) - :param all_else: Collection or function - an optional parameter for tab completing any token that isn't at an - index in index_dict + :param index_dict: dictionary whose structure is the following: + keys - 0-based token indexes into command line that determine which tokens + perform tab completion + values - there are two types of values + 1. iterable list of strings to match against (dictionaries, lists, etc.) + 2. function that performs tab completion (ex: path_complete) + :param all_else: an optional parameter for tab completing any token that isn't at an index in index_dict :return: a list of possible tab completions """ # Get all tokens through the one being completed @@ -1093,8 +1142,8 @@ class Cmd(cmd.Cmd): self.allow_appended_space = False self.allow_closing_quote = False - # Sort the matches before any trailing slashes are added - matches = utils.alphabetical_sort(matches) + # Sort the matches alphabetically before any trailing slashes are added + matches.sort(key=utils.norm_fold) self.matches_sorted = True # Build display_matches and add a slash to directories @@ -1433,18 +1482,21 @@ class Cmd(cmd.Cmd): # Check if a valid command was entered if command in self.get_all_commands(): # Get the completer function for this command - try: - compfunc = getattr(self, 'complete_' + command) - except AttributeError: + compfunc = getattr(self, 'complete_' + command, None) + + if compfunc is None: # There's no completer function, next see if the command uses argparser - try: - cmd_func = getattr(self, 'do_' + command) - argparser = getattr(cmd_func, 'argparser') - # Command uses argparser, switch to the default argparse completer - compfunc = functools.partial(self._autocomplete_default, argparser=argparser) - except AttributeError: + func = self.cmd_func(command) + if func and hasattr(func, 'argparser'): + compfunc = functools.partial(self._autocomplete_default, + argparser=getattr(func, 'argparser')) + else: compfunc = self.completedefault + # Check if a macro was entered + elif command in self.macros: + compfunc = self.path_complete + # A valid command was not entered else: # Check if this command should be run as a shell command @@ -1512,11 +1564,9 @@ class Cmd(cmd.Cmd): [shortcut_to_restore + match for match in self.completion_matches] else: - # Complete token against aliases and command names - alias_names = set(self.aliases.keys()) - visible_commands = set(self.get_visible_commands()) - strs_to_match = list(alias_names | visible_commands) - self.completion_matches = self.basic_complete(text, line, begidx, endidx, strs_to_match) + # Complete token against anything a user can run + self.completion_matches = self.basic_complete(text, line, begidx, endidx, + self.get_commands_aliases_and_macros_for_completion()) # Handle single result if len(self.completion_matches) == 1: @@ -1534,8 +1584,8 @@ class Cmd(cmd.Cmd): # Sort matches alphabetically if they haven't already been sorted if not self.matches_sorted: - self.completion_matches = utils.alphabetical_sort(self.completion_matches) - self.display_matches = utils.alphabetical_sort(self.display_matches) + self.completion_matches.sort(key=utils.norm_fold) + self.display_matches.sort(key=utils.norm_fold) self.matches_sorted = True try: @@ -1556,8 +1606,8 @@ class Cmd(cmd.Cmd): def get_all_commands(self) -> List[str]: """Returns a list of all commands.""" - return [name[3:] for name in self.get_names() - if name.startswith('do_') and isinstance(getattr(self, name), Callable)] + return [name[len(COMMAND_PREFIX):] for name in self.get_names() + if name.startswith(COMMAND_PREFIX) and callable(getattr(self, name))] def get_visible_commands(self) -> List[str]: """Returns a list of commands that have not been hidden.""" @@ -1570,53 +1620,25 @@ class Cmd(cmd.Cmd): return commands - def get_help_topics(self) -> List[str]: - """ Returns a list of help topics """ - return [name[5:] for name in self.get_names() - if name.startswith('help_') and isinstance(getattr(self, name), Callable)] - - def complete_help(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: - """ - Override of parent class method to handle tab completing subcommands and not showing hidden commands - Returns a list of possible tab completions - """ - - # The command is the token at index 1 in the command line - cmd_index = 1 - - # The subcommand is the token at index 2 in the command line - subcmd_index = 2 - - # Get all tokens through the one being completed - tokens, _ = self.tokens_for_completion(line, begidx, endidx) - if not tokens: - return [] - - matches = [] + def get_alias_names(self) -> List[str]: + """Return a list of alias names.""" + return list(self.aliases) - # Get the index of the token being completed - index = len(tokens) - 1 - - # Check if we are completing a command or help topic - if index == cmd_index: + def get_macro_names(self) -> List[str]: + """Return a list of macro names.""" + return list(self.macros) - # Complete token against topics and visible commands - topics = set(self.get_help_topics()) - visible_commands = set(self.get_visible_commands()) - strs_to_match = list(topics | visible_commands) - matches = self.basic_complete(text, line, begidx, endidx, strs_to_match) - - # check if the command uses argparser - elif index >= subcmd_index: - try: - cmd_func = getattr(self, 'do_' + tokens[cmd_index]) - parser = getattr(cmd_func, 'argparser') - completer = AutoCompleter(parser) - matches = completer.complete_command_help(tokens[1:], text, line, begidx, endidx) - except AttributeError: - pass + def get_commands_aliases_and_macros_for_completion(self) -> List[str]: + """Return a list of visible commands, aliases, and macros for tab completion""" + visible_commands = set(self.get_visible_commands()) + alias_names = set(self.get_alias_names()) + macro_names = set(self.get_macro_names()) + return list(visible_commands | alias_names | macro_names) - return matches + def get_help_topics(self) -> List[str]: + """ Returns a list of help topics """ + return [name[5:] for name in self.get_names() + if name.startswith('help_') and callable(getattr(self, name))] # noinspection PyUnusedLocal def sigint_handler(self, signum: int, frame) -> None: @@ -1637,12 +1659,6 @@ class Cmd(cmd.Cmd): # Re-raise a KeyboardInterrupt so other parts of the code can catch it raise KeyboardInterrupt("Got a keyboard interrupt") - def preloop(self) -> None: - """Hook method executed once when the cmdloop() method is called.""" - import signal - # Register a default SIGINT signal handler for Ctrl+C - signal.signal(signal.SIGINT, self.sigint_handler) - def precmd(self, statement: Statement) -> Statement: """Hook method executed just before the command is processed by ``onecmd()`` and after adding it to the history. @@ -1651,58 +1667,6 @@ class Cmd(cmd.Cmd): """ return statement - # ----- Methods which are cmd2-specific lifecycle hooks which are not present in cmd ----- - - # noinspection PyMethodMayBeStatic - def preparse(self, raw: str) -> str: - """Hook method executed before user input is parsed. - - WARNING: If it's a multiline command, `preparse()` may not get all the - user input. _complete_statement() really does two things: a) parse the - user input, and b) accept more input in case it's a multiline command - the passed string doesn't have a terminator. `preparse()` is currently - called before we know whether it's a multiline command, and before we - know whether the user input includes a termination character. - - If you want a reliable pre parsing hook method, register a postparsing - hook, modify the user input, and then reparse it. - - :param raw: raw command line input :return: potentially modified raw command line input - :return: a potentially modified version of the raw input string - """ - return raw - - # noinspection PyMethodMayBeStatic - def postparsing_precmd(self, statement: Statement) -> Tuple[bool, Statement]: - """This runs after parsing the command-line, but before anything else; even before adding cmd to history. - - NOTE: This runs before precmd() and prior to any potential output redirection or piping. - - If you wish to fatally fail this command and exit the application entirely, set stop = True. - - If you wish to just fail this command you can do so by raising an exception: - - - raise EmptyStatement - will silently fail and do nothing - - raise <AnyOtherException> - will fail and print an error message - - :param statement: - the parsed command-line statement as a Statement object - :return: (bool, statement) - (stop, statement) containing a potentially modified version of the statement object - """ - stop = False - return stop, statement - - # noinspection PyMethodMayBeStatic - def postparsing_postcmd(self, stop: bool) -> bool: - """This runs after everything else, including after postcmd(). - - It even runs when an empty line is entered. Thus, if you need to do something like update the prompt due - to notifications from a background thread, then this is the method you want to override to do it. - - :param stop: bool - True implies the entire application should exit. - :return: bool - True implies the entire application should exit. - """ - return stop - def parseline(self, line: str) -> Tuple[str, str, str]: """Parse the line into a command name and a string containing the arguments. @@ -1742,9 +1706,6 @@ class Cmd(cmd.Cmd): data = func(data) if data.stop: break - # postparsing_precmd is deprecated - if not data.stop: - (data.stop, data.statement) = self.postparsing_precmd(data.statement) # unpack the data object statement = data.statement stop = data.stop @@ -1809,9 +1770,7 @@ class Cmd(cmd.Cmd): data = func(data) # retrieve the final value of stop, ignoring any # modifications to the statement - stop = data.stop - # postparsing_postcmd is deprecated - return self.postparsing_postcmd(stop) + return data.stop except Exception as ex: self.perror(ex) @@ -1865,9 +1824,6 @@ class Cmd(cmd.Cmd): pipe runs out. We can't refactor it because we need to retain backwards compatibility with the standard library version of cmd. """ - # preparse() is deprecated, use self.register_postparsing_hook() instead - line = self.preparse(line) - while True: try: statement = self.statement_parser.parse(line) @@ -2014,49 +1970,91 @@ class Cmd(cmd.Cmd): self.redirecting = False - def _func_named(self, arg: str) -> str: - """Gets the method name associated with a given command. + def cmd_func(self, command: str) -> Optional[Callable]: + """ + Get the function for a command + :param command: the name of the command + """ + func_name = self.cmd_func_name(command) + if func_name: + return getattr(self, func_name) + + def cmd_func_name(self, command: str) -> str: + """Get the method name associated with a given command. - :param arg: command to look up method name which implements it + :param command: command to look up method name which implements it :return: method name which implements the given command """ - result = None - target = 'do_' + arg - if target in dir(self): - result = target - return result + target = COMMAND_PREFIX + command + return target if callable(getattr(self, target, None)) else '' - def onecmd(self, statement: Union[Statement, str]) -> Optional[bool]: + def onecmd(self, statement: Union[Statement, str]) -> bool: """ This executes the actual do_* method for a command. If the command provided doesn't exist, then it executes _default() instead. - :param statement: Command - intended to be a Statement instance parsed command from the input stream, - alternative acceptance of a str is present only for backward compatibility with cmd + :param statement: intended to be a Statement instance parsed command from the input stream, alternative + acceptance of a str is present only for backward compatibility with cmd :return: a flag indicating whether the interpretation of commands should stop """ # For backwards compatibility with cmd, allow a str to be passed in if not isinstance(statement, Statement): statement = self._complete_statement(statement) - funcname = self._func_named(statement.command) - if not funcname: - self.default(statement) - return + # Check if this is a macro + if statement.command in self.macros: + stop = self._run_macro(statement) + else: + func = self.cmd_func(statement.command) + if func: + stop = func(statement) - # Since we have a valid command store it in the history - if statement.command not in self.exclude_from_history: - self.history.append(statement.raw) + # Since we have a valid command store it in the history + if statement.command not in self.exclude_from_history: + self.history.append(statement.raw) - try: - func = getattr(self, funcname) - except AttributeError: - self.default(statement) - return + else: + self.default(statement) + stop = False - stop = func(statement) return stop + def _run_macro(self, statement: Statement) -> bool: + """ + Resolve a macro and run the resulting string + + :param statement: the parsed statement from the command line + :return: a flag indicating whether the interpretation of commands should stop + """ + if statement.command not in self.macros.keys(): + raise KeyError('{} is not a macro'.format(statement.command)) + + macro = self.macros[statement.command] + + # For macros, every argument must be provided and there can be no extra arguments. + if len(statement.arg_list) != macro.required_arg_count: + self.perror("The macro '{}' expects {} argument(s)".format(statement.command, macro.required_arg_count), + traceback_war=False) + return False + + # Resolve the arguments in reverse + resolved = macro.value + reverse_arg_list = sorted(macro.arg_list, key=lambda ma: ma.start_index, reverse=True) + + for arg in reverse_arg_list: + if arg.is_escaped: + to_replace = '{{' + arg.number_str + '}}' + replacement = '{' + arg.number_str + '}' + else: + to_replace = '{' + arg.number_str + '}' + replacement = statement.argv[int(arg.number_str)] + + parts = resolved.rsplit(to_replace, maxsplit=1) + resolved = parts[0] + replacement + parts[1] + + # Run the resolved command + return self.onecmd_plus_hooks(resolved) + def default(self, statement: Statement) -> None: """Executed when the command given isn't a recognized command implemented by a do_* method. @@ -2072,34 +2070,6 @@ class Cmd(cmd.Cmd): # Print out a message stating this is an unknown command self.poutput('*** Unknown syntax: {}\n'.format(arg)) - @staticmethod - def _surround_ansi_escapes(prompt: str, start: str="\x01", end: str="\x02") -> str: - """Overcome bug in GNU Readline in relation to calculation of prompt length in presence of ANSI escape codes. - - :param prompt: original prompt - :param start: start code to tell GNU Readline about beginning of invisible characters - :param end: end code to tell GNU Readline about end of invisible characters - :return: prompt safe to pass to GNU Readline - """ - # Windows terminals don't use ANSI escape codes and Windows readline isn't based on GNU Readline - if sys.platform == "win32": - return prompt - - escaped = False - result = "" - - for c in prompt: - if c == "\x1b" and not escaped: - result += start + c - escaped = True - elif c.isalpha() and escaped: - result += c + end - escaped = False - else: - result += c - - return result - def pseudo_raw_input(self, prompt: str) -> str: """Began life as a copy of cmd's cmdloop; like raw_input but @@ -2108,23 +2078,37 @@ class Cmd(cmd.Cmd): to decide whether to print the prompt and the input """ - # Deal with the vagaries of readline and ANSI escape codes - safe_prompt = self._surround_ansi_escapes(prompt) + # Temporarily save over self.prompt to reflect what will be on screen + orig_prompt = self.prompt + self.prompt = prompt if self.use_rawinput: try: if sys.stdin.isatty(): + # Wrap in try since terminal_lock may not be locked when this function is called from unit tests + try: + # A prompt is about to be drawn. Allow asynchronous changes to the terminal. + self.terminal_lock.release() + except RuntimeError: + pass + + # Deal with the vagaries of readline and ANSI escape codes + safe_prompt = rl_make_safe_prompt(prompt) line = input(safe_prompt) else: line = input() if self.echo: - sys.stdout.write('{}{}\n'.format(safe_prompt, line)) + sys.stdout.write('{}{}\n'.format(self.prompt, line)) except EOFError: line = 'eof' + finally: + if sys.stdin.isatty(): + # The prompt is gone. Do not allow asynchronous changes to the terminal. + self.terminal_lock.acquire() else: if self.stdin.isatty(): # on a tty, print the prompt first, then read the line - self.poutput(safe_prompt, end='') + self.poutput(self.prompt, end='') self.stdout.flush() line = self.stdin.readline() if len(line) == 0: @@ -2137,9 +2121,13 @@ class Cmd(cmd.Cmd): if len(line): # we read something, output the prompt and the something if self.echo: - self.poutput('{}{}'.format(safe_prompt, line)) + self.poutput('{}{}'.format(self.prompt, line)) else: line = 'eof' + + # Restore prompt + self.prompt = orig_prompt + return line.strip() def _cmdloop(self) -> bool: @@ -2219,147 +2207,423 @@ class Cmd(cmd.Cmd): return stop - def do_alias(self, statement: Statement) -> None: - """Define or display aliases + # ----- Alias subcommand functions ----- -Usage: Usage: alias [name] | [<name> <value>] - Where: - name - name of the alias being looked up, added, or replaced - value - what the alias will be resolved to (if adding or replacing) - this can contain spaces and does not need to be quoted + def alias_create(self, args: argparse.Namespace): + """ Creates or overwrites an alias """ - Without arguments, 'alias' prints a list of all aliases in a reusable form which - can be outputted to a startup_script to preserve aliases across sessions. + # Validate the alias name + args.name = utils.strip_quotes(args.name) + valid, errmsg = self.statement_parser.is_valid_command(args.name) + if not valid: + errmsg = "Invalid alias name: {}".format(errmsg) + self.perror(errmsg, traceback_war=False) + return - With one argument, 'alias' shows the value of the specified alias. - Example: alias ls (Prints the value of the alias called 'ls' if it exists) + if args.name in self.macros: + errmsg = "Alias cannot have the same name as a macro" + self.perror(errmsg, traceback_war=False) + return - With two or more arguments, 'alias' creates or replaces an alias. + utils.unquote_redirection_tokens(args.command_args) - Example: alias ls !ls -lF + # Build the alias value string + value = args.command + if args.command_args: + value += ' ' + ' '.join(args.command_args) - If you want to use redirection or pipes in the alias, then quote them to prevent - the alias command itself from being redirected + # Set the alias + result = "overwritten" if args.name in self.aliases else "created" + self.aliases[args.name] = value + self.poutput("Alias '{}' {}".format(args.name, result)) - Examples: - alias save_results print_results ">" out.txt - alias save_results print_results '>' out.txt -""" - # Get alias arguments as a list with quotes preserved - alias_arg_list = statement.arg_list + def alias_delete(self, args: argparse.Namespace): + """ Deletes aliases """ + if args.all: + self.aliases.clear() + self.poutput("All aliases deleted") + elif not args.name: + self.do_help('alias delete') + else: + # Get rid of duplicates and strip quotes since the argparse decorator for do_alias() preserves them + aliases_to_delete = [utils.strip_quotes(cur_name) for cur_name in utils.remove_duplicates(args.name)] + + for cur_name in aliases_to_delete: + if cur_name in self.aliases: + del self.aliases[cur_name] + self.poutput("Alias '{}' deleted".format(cur_name)) + else: + self.perror("Alias '{}' does not exist".format(cur_name), traceback_war=False) + + def alias_list(self, args: argparse.Namespace): + """ Lists some or all aliases """ + if args.name: + # Get rid of duplicates and strip quotes since the argparse decorator for do_alias() preserves them + names_to_view = [utils.strip_quotes(cur_name) for cur_name in utils.remove_duplicates(args.name)] + + for cur_name in names_to_view: + if cur_name in self.aliases: + self.poutput("alias create {} {}".format(cur_name, self.aliases[cur_name])) + else: + self.perror("Alias '{}' not found".format(cur_name), traceback_war=False) + else: + sorted_aliases = utils.alphabetical_sort(self.aliases) + for cur_alias in sorted_aliases: + self.poutput("alias create {} {}".format(cur_alias, self.aliases[cur_alias])) + + # Top-level parser for alias + alias_description = ("Manage aliases\n" + "\n" + "An alias is a command that enables replacement of a word by another string.") + alias_epilog = ("See also:\n" + " macro") + alias_parser = ACArgumentParser(description=alias_description, epilog=alias_epilog, prog='alias') + + # Add subcommands to alias + alias_subparsers = alias_parser.add_subparsers() + + # alias -> create + alias_create_help = "create or overwrite an alias" + alias_create_description = "Create or overwrite an alias" + + alias_create_epilog = ("Notes:\n" + " If you want to use redirection or pipes in the alias, then quote them to\n" + " prevent the 'alias create' command from being redirected.\n" + "\n" + " Since aliases are resolved during parsing, tab completion will function as it\n" + " would for the actual command the alias resolves to.\n" + "\n" + "Examples:\n" + " alias ls !ls -lF\n" + " alias create show_log !cat \"log file.txt\"\n" + " alias create save_results print_results \">\" out.txt\n") + + alias_create_parser = alias_subparsers.add_parser('create', help=alias_create_help, + description=alias_create_description, + epilog=alias_create_epilog) + alias_create_parser.add_argument('name', help='name of this alias') + setattr(alias_create_parser.add_argument('command', help='what the alias resolves to'), + ACTION_ARG_CHOICES, get_commands_aliases_and_macros_for_completion) + setattr(alias_create_parser.add_argument('command_args', nargs=argparse.REMAINDER, + help='arguments to pass to command'), + ACTION_ARG_CHOICES, ('path_complete',)) + alias_create_parser.set_defaults(func=alias_create) + + # alias -> delete + alias_delete_help = "delete aliases" + alias_delete_description = "Delete specified aliases or all aliases if --all is used" + alias_delete_parser = alias_subparsers.add_parser('delete', help=alias_delete_help, + description=alias_delete_description) + setattr(alias_delete_parser.add_argument('name', nargs='*', help='alias to delete'), + ACTION_ARG_CHOICES, get_alias_names) + alias_delete_parser.add_argument('-a', '--all', action='store_true', help="delete all aliases") + alias_delete_parser.set_defaults(func=alias_delete) + + # alias -> list + alias_list_help = "list aliases" + alias_list_description = ("List specified aliases in a reusable form that can be saved to a startup script\n" + "to preserve aliases across sessions\n" + "\n" + "Without arguments, all aliases will be listed.") + + alias_list_parser = alias_subparsers.add_parser('list', help=alias_list_help, + description=alias_list_description) + setattr(alias_list_parser.add_argument('name', nargs="*", help='alias to list'), + ACTION_ARG_CHOICES, get_alias_names) + alias_list_parser.set_defaults(func=alias_list) + + # Preserve quotes since we are passing strings to other commands + @with_argparser(alias_parser, preserve_quotes=True) + def do_alias(self, args: argparse.Namespace): + """Manage aliases""" + func = getattr(args, 'func', None) + if func is not None: + # Call whatever subcommand function was selected + func(self, args) + else: + # No subcommand was provided, so call help + self.do_help('alias') + + # ----- Macro subcommand functions ----- + + def macro_create(self, args: argparse.Namespace): + """ Creates or overwrites a macro """ - # If no args were given, then print a list of current aliases - if not alias_arg_list: - for cur_alias in self.aliases: - self.poutput("alias {} {}".format(cur_alias, self.aliases[cur_alias])) + # Validate the macro name + args.name = utils.strip_quotes(args.name) + valid, errmsg = self.statement_parser.is_valid_command(args.name) + if not valid: + errmsg = "Invalid macro name: {}".format(errmsg) + self.perror(errmsg, traceback_war=False) return - # Get the alias name - name = alias_arg_list[0] + if args.name in self.get_all_commands(): + errmsg = "Macro cannot have the same name as a command" + self.perror(errmsg, traceback_war=False) + return - # The user is looking up an alias - if len(alias_arg_list) == 1: - if name in self.aliases: - self.poutput("alias {} {}".format(name, self.aliases[name])) - else: - self.perror("Alias {!r} not found".format(name), traceback_war=False) + if args.name in self.aliases: + errmsg = "Macro cannot have the same name as an alias" + self.perror(errmsg, traceback_war=False) + return - # The user is creating an alias - else: - # Unquote redirection and pipes - index = 1 - while index < len(alias_arg_list): - unquoted_arg = utils.strip_quotes(alias_arg_list[index]) - if unquoted_arg in constants.REDIRECTION_TOKENS: - alias_arg_list[index] = unquoted_arg - index += 1 - - # Build the alias value string - value = ' '.join(alias_arg_list[1:]) - - # Validate the alias to ensure it doesn't include weird characters - # like terminators, output redirection, or whitespace - valid, invalidchars = self.statement_parser.is_valid_command(name) - if valid: - # Set the alias - self.aliases[name] = value - self.poutput("Alias {!r} created".format(name)) - else: - errmsg = "Aliases can not contain: {}".format(invalidchars) - self.perror(errmsg, traceback_war=False) + utils.unquote_redirection_tokens(args.command_args) - def complete_alias(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: - """ Tab completion for alias """ - alias_names = set(self.aliases.keys()) - visible_commands = set(self.get_visible_commands()) + # Build the macro value string + value = args.command + if args.command_args: + value += ' ' + ' '.join(args.command_args) - index_dict = \ - { - 1: alias_names, - 2: list(alias_names | visible_commands) - } - return self.index_based_complete(text, line, begidx, endidx, index_dict, self.path_complete) + # Find all normal arguments + arg_list = [] + normal_matches = re.finditer(MacroArg.macro_normal_arg_pattern, value) + max_arg_num = 0 + arg_nums = set() - @with_argument_list - def do_unalias(self, arglist: List[str]) -> None: - """Unsets aliases + while True: + try: + cur_match = normal_matches.__next__() -Usage: Usage: unalias [-a] name [name ...] - Where: - name - name of the alias being unset + # Get the number string between the braces + cur_num_str = (re.findall(MacroArg.digit_pattern, cur_match.group())[0]) + cur_num = int(cur_num_str) + if cur_num < 1: + self.perror("Argument numbers must be greater than 0", traceback_war=False) + return - Options: - -a remove all alias definitions -""" - if not arglist: - self.do_help(['unalias']) + arg_nums.add(cur_num) + if cur_num > max_arg_num: + max_arg_num = cur_num - if '-a' in arglist: - self.aliases.clear() - self.poutput("All aliases cleared") + arg_list.append(MacroArg(start_index=cur_match.start(), number_str=cur_num_str, is_escaped=False)) + + except StopIteration: + break + + # Make sure the argument numbers are continuous + if len(arg_nums) != max_arg_num: + self.perror("Not all numbers between 1 and {} are present " + "in the argument placeholders".format(max_arg_num), traceback_war=False) + return + + # Find all escaped arguments + escaped_matches = re.finditer(MacroArg.macro_escaped_arg_pattern, value) + + while True: + try: + cur_match = escaped_matches.__next__() + # Get the number string between the braces + cur_num_str = re.findall(MacroArg.digit_pattern, cur_match.group())[0] + + arg_list.append(MacroArg(start_index=cur_match.start(), number_str=cur_num_str, is_escaped=True)) + except StopIteration: + break + + # Set the macro + result = "overwritten" if args.name in self.macros else "created" + self.macros[args.name] = Macro(name=args.name, value=value, required_arg_count=max_arg_num, arg_list=arg_list) + self.poutput("Macro '{}' {}".format(args.name, result)) + + def macro_delete(self, args: argparse.Namespace): + """ Deletes macros """ + if args.all: + self.macros.clear() + self.poutput("All macros deleted") + elif not args.name: + self.do_help('macro delete') else: - # Get rid of duplicates - arglist = utils.remove_duplicates(arglist) + # Get rid of duplicates and strip quotes since the argparse decorator for do_macro() preserves them + macros_to_delete = [utils.strip_quotes(cur_name) for cur_name in utils.remove_duplicates(args.name)] + + for cur_name in macros_to_delete: + if cur_name in self.macros: + del self.macros[cur_name] + self.poutput("Macro '{}' deleted".format(cur_name)) + else: + self.perror("Macro '{}' does not exist".format(cur_name), traceback_war=False) + + def macro_list(self, args: argparse.Namespace): + """ Lists some or all macros """ + if args.name: + # Get rid of duplicates and strip quotes since the argparse decorator for do_macro() preserves them + names_to_view = [utils.strip_quotes(cur_name) for cur_name in utils.remove_duplicates(args.name)] - for cur_arg in arglist: - if cur_arg in self.aliases: - del self.aliases[cur_arg] - self.poutput("Alias {!r} cleared".format(cur_arg)) + for cur_name in names_to_view: + if cur_name in self.macros: + self.poutput("macro create {} {}".format(cur_name, self.macros[cur_name].value)) else: - self.perror("Alias {!r} does not exist".format(cur_arg), traceback_war=False) - - def complete_unalias(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: - """ Tab completion for unalias """ - return self.basic_complete(text, line, begidx, endidx, self.aliases) - - @with_argument_list - def do_help(self, arglist: List[str]) -> None: - """ List available commands with "help" or detailed help with "help cmd" """ - if not arglist or (len(arglist) == 1 and arglist[0] in ('--verbose', '-v')): - verbose = len(arglist) == 1 and arglist[0] in ('--verbose', '-v') - self._help_menu(verbose) + self.perror("Macro '{}' not found".format(cur_name), traceback_war=False) + else: + sorted_macros = utils.alphabetical_sort(self.macros) + for cur_macro in sorted_macros: + self.poutput("macro create {} {}".format(cur_macro, self.macros[cur_macro].value)) + + # Top-level parser for macro + macro_description = ("Manage macros\n" + "\n" + "A macro is similar to an alias, but it can take arguments when called.") + macro_epilog = ("See also:\n" + " alias") + macro_parser = ACArgumentParser(description=macro_description, epilog=macro_epilog, prog='macro') + + # Add subcommands to macro + macro_subparsers = macro_parser.add_subparsers() + + # macro -> create + macro_create_help = "create or overwrite a macro" + macro_create_description = "Create or overwrite a macro" + + macro_create_epilog = ("A macro is similar to an alias, but it can take arguments when called.\n" + "Arguments are expressed when creating a macro using {#} notation where {1}\n" + "means the first argument.\n" + "\n" + "The following creates a macro called my_macro that expects two arguments:\n" + "\n" + " macro create my_macro make_dinner -meat {1} -veggie {2}\n" + "\n" + "When the macro is called, the provided arguments are resolved and the assembled\n" + "command is run. For example:\n" + "\n" + " my_macro beef broccoli ---> make_dinner -meat beef -veggie broccoli\n" + "\n" + "Notes:\n" + " To use the literal string {1} in your command, escape it this way: {{1}}.\n" + "\n" + " An argument number can be repeated in a macro. In the following example the\n" + " first argument will populate both {1} instances.\n" + "\n" + " macro create ft file_taxes -p {1} -q {2} -r {1}\n" + "\n" + " To quote an argument in the resolved command, quote it during creation.\n" + "\n" + " macro create backup !cp \"{1}\" \"{1}.orig\"\n" + "\n" + " Be careful! Since macros can resolve into commands, aliases, and macros,\n" + " it is possible to create a macro that results in infinite recursion.\n" + "\n" + " If you want to use redirection or pipes in the macro, then quote them as in\n" + " this example to prevent the 'macro create' command from being redirected.\n" + "\n" + " macro create show_results print_results -type {1} \"|\" less\n" + "\n" + " Because macros do not resolve until after parsing (hitting Enter), tab\n" + " completion will only complete paths.") + + macro_create_parser = macro_subparsers.add_parser('create', help=macro_create_help, + description=macro_create_description, + epilog=macro_create_epilog) + macro_create_parser.add_argument('name', help='name of this macro') + setattr(macro_create_parser.add_argument('command', help='what the macro resolves to'), + ACTION_ARG_CHOICES, get_commands_aliases_and_macros_for_completion) + setattr(macro_create_parser.add_argument('command_args', nargs=argparse.REMAINDER, + help='arguments to pass to command'), + ACTION_ARG_CHOICES, ('path_complete',)) + macro_create_parser.set_defaults(func=macro_create) + + # macro -> delete + macro_delete_help = "delete macros" + macro_delete_description = "Delete specified macros or all macros if --all is used" + macro_delete_parser = macro_subparsers.add_parser('delete', help=macro_delete_help, + description=macro_delete_description) + setattr(macro_delete_parser.add_argument('name', nargs='*', help='macro to delete'), + ACTION_ARG_CHOICES, get_macro_names) + macro_delete_parser.add_argument('-a', '--all', action='store_true', help="delete all macros") + macro_delete_parser.set_defaults(func=macro_delete) + + # macro -> list + macro_list_help = "list macros" + macro_list_description = ("List specified macros in a reusable form that can be saved to a startup script\n" + "to preserve macros across sessions\n" + "\n" + "Without arguments, all macros will be listed.") + + macro_list_parser = macro_subparsers.add_parser('list', help=macro_list_help, description=macro_list_description) + setattr(macro_list_parser.add_argument('name', nargs="*", help='macro to list'), + ACTION_ARG_CHOICES, get_macro_names) + macro_list_parser.set_defaults(func=macro_list) + + # Preserve quotes since we are passing strings to other commands + @with_argparser(macro_parser, preserve_quotes=True) + def do_macro(self, args: argparse.Namespace): + """Manage macros""" + func = getattr(args, 'func', None) + if func is not None: + # Call whatever subcommand function was selected + func(self, args) + else: + # No subcommand was provided, so call help + self.do_help('macro') + + def complete_help_command(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: + """Completes the command argument of help""" + + # Complete token against topics and visible commands + topics = set(self.get_help_topics()) + visible_commands = set(self.get_visible_commands()) + strs_to_match = list(topics | visible_commands) + return self.basic_complete(text, line, begidx, endidx, strs_to_match) + + def complete_help_subcommand(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: + """Completes the subcommand argument of help""" + + # Get all tokens through the one being completed + tokens, _ = self.tokens_for_completion(line, begidx, endidx) + + if not tokens: + return [] + + # Must have at least 3 args for 'help command subcommand' + if len(tokens) < 3: + return [] + + # Find where the command is by skipping past any flags + cmd_index = 1 + for cur_token in tokens[cmd_index:]: + if not cur_token.startswith('-'): + break + cmd_index += 1 + + if cmd_index >= len(tokens): + return [] + + command = tokens[cmd_index] + matches = [] + + # Check if this is a command with an argparse function + func = self.cmd_func(command) + if func and hasattr(func, 'argparser'): + completer = AutoCompleter(getattr(func, 'argparser'), cmd2_app=self) + matches = completer.complete_command_help(tokens[cmd_index:], text, line, begidx, endidx) + + return matches + + help_parser = ACArgumentParser() + + setattr(help_parser.add_argument('command', help="command to retrieve help for", nargs="?"), + ACTION_ARG_CHOICES, ('complete_help_command',)) + setattr(help_parser.add_argument('subcommand', help="subcommand to retrieve help for", + nargs=argparse.REMAINDER), + ACTION_ARG_CHOICES, ('complete_help_subcommand',)) + help_parser.add_argument('-v', '--verbose', action='store_true', + help="print a list of all commands with descriptions of each") + + @with_argparser(help_parser) + def do_help(self, args: argparse.Namespace) -> None: + """List available commands or provide detailed help for a specific command""" + if not args.command or args.verbose: + self._help_menu(args.verbose) + else: # Getting help for a specific command - funcname = self._func_named(arglist[0]) - if funcname: - # Check to see if this function was decorated with an argparse ArgumentParser - func = getattr(self, funcname) - if hasattr(func, 'argparser'): - # Function has an argparser, so get help based on all the arguments in case there are sub-commands - new_arglist = arglist[1:] - new_arglist.append('-h') - - # Temporarily redirect all argparse output to both sys.stdout and sys.stderr to self.stdout - with redirect_stdout(self.stdout): - with redirect_stderr(self.stdout): - func(new_arglist) - else: - # No special behavior needed, delegate to cmd base class do_help() - cmd.Cmd.do_help(self, funcname[3:]) + func = self.cmd_func(args.command) + if func and hasattr(func, 'argparser'): + completer = AutoCompleter(getattr(func, 'argparser'), cmd2_app=self) + tokens = [args.command] + args.subcommand + self.poutput(completer.format_help(tokens)) else: - # This could be a help topic - cmd.Cmd.do_help(self, arglist[0]) + # No special behavior needed, delegate to cmd base class do_help() + super().do_help(args.command) def _help_menu(self, verbose: bool=False) -> None: """Show a list of commands which help can be displayed for. @@ -2375,11 +2639,12 @@ Usage: Usage: unalias [-a] name [name ...] cmds_cats = {} for command in visible_commands: - if command in help_topics or getattr(self, self._func_named(command)).__doc__: + func = self.cmd_func(command) + if command in help_topics or func.__doc__: if command in help_topics: help_topics.remove(command) - if hasattr(getattr(self, self._func_named(command)), HELP_CATEGORY): - category = getattr(getattr(self, self._func_named(command)), HELP_CATEGORY) + if hasattr(func, HELP_CATEGORY): + category = getattr(func, HELP_CATEGORY) cmds_cats.setdefault(category, []) cmds_cats[category].append(command) else: @@ -2432,12 +2697,13 @@ Usage: Usage: unalias [-a] name [name ...] func = getattr(self, 'help_' + command) except AttributeError: # Couldn't find a help function + func = self.cmd_func(command) try: # Now see if help_summary has been set - doc = getattr(self, self._func_named(command)).help_summary + doc = func.help_summary except AttributeError: # Last, try to directly access the function's doc-string - doc = getattr(self, self._func_named(command)).__doc__ + doc = func.__doc__ else: # we found the help function result = io.StringIO() @@ -2458,13 +2724,17 @@ Usage: Usage: unalias [-a] name [name ...] doc_block = [] found_first = False for doc_line in doc.splitlines(): - str(doc_line).strip() - if len(doc_line.strip()) > 0: - doc_block.append(doc_line.strip()) - found_first = True - else: + stripped_line = doc_line.strip() + + # Don't include :param type lines + if stripped_line.startswith(':'): if found_first: break + elif stripped_line: + doc_block.append(stripped_line) + found_first = True + elif found_first: + break for doc_line in doc_block: self.stdout.write('{: <{col_width}}{doc}\n'.format(command, @@ -2473,18 +2743,21 @@ Usage: Usage: unalias [-a] name [name ...] command = '' self.stdout.write("\n") - def do_shortcuts(self, _: str) -> None: - """Lists shortcuts available""" + @with_argparser(ACArgumentParser()) + def do_shortcuts(self, _: argparse.Namespace) -> None: + """List available shortcuts""" result = "\n".join('%s: %s' % (sc[0], sc[1]) for sc in sorted(self.shortcuts)) self.poutput("Shortcuts for other commands:\n{}\n".format(result)) - def do_eof(self, _: str) -> bool: + @with_argparser(ACArgumentParser(epilog=INTERNAL_COMMAND_EPILOG)) + def do_eof(self, _: argparse.Namespace) -> bool: """Called when <Ctrl>-D is pressed""" # End of script should not exit app, but <Ctrl>-D should. return self._STOP_AND_EXIT - def do_quit(self, _: str) -> bool: - """Exits this application""" + @with_argparser(ACArgumentParser()) + def do_quit(self, _: argparse.Namespace) -> bool: + """Exit this application""" self._should_quit = True return self._STOP_AND_EXIT @@ -2514,7 +2787,8 @@ Usage: Usage: unalias [-a] name [name ...] for (idx, (_, text)) in enumerate(fulloptions): self.poutput(' %2d. %s\n' % (idx + 1, text)) while True: - response = input(prompt) + safe_prompt = rl_make_safe_prompt(prompt) + response = input(safe_prompt) if rl_type != RlType.NONE: hlen = readline.get_current_history_length() @@ -2541,22 +2815,21 @@ Usage: Usage: unalias [-a] name [name ...] Output redirection and pipes allowed: {}""" return read_only_settings.format(str(self.terminators), self.allow_cli_args, self.allow_redirection) - def show(self, args: argparse.Namespace, parameter: str) -> None: + def show(self, args: argparse.Namespace, parameter: str='') -> None: """Shows current settings of parameters. :param args: argparse parsed arguments from the set command - :param parameter: - :return: + :param parameter: optional search parameter """ - param = '' - if parameter: - param = parameter.strip().lower() + param = parameter.strip().lower() result = {} maxlen = 0 + for p in self.settable: if (not param) or p.startswith(param): - result[p] = '%s: %s' % (p, str(getattr(self, p))) + result[p] = '{}: {}'.format(p, str(getattr(self, p))) maxlen = max(maxlen, len(result[p])) + if result: for p in sorted(result): if args.long: @@ -2568,58 +2841,69 @@ Usage: Usage: unalias [-a] name [name ...] if args.all: self.poutput('\nRead only settings:{}'.format(self.cmdenvironment())) else: - raise LookupError("Parameter '%s' not supported (type 'set' for list of parameters)." % param) + raise LookupError("Parameter '{}' not supported (type 'set' for list of parameters).".format(param)) - set_description = "Sets a settable parameter or shows current settings of parameters.\n" - set_description += "\n" - set_description += "Accepts abbreviated parameter names so long as there is no ambiguity.\n" - set_description += "Call without arguments for a list of settable parameters with their values." + set_description = ("Set a settable parameter or show current settings of parameters\n" + "\n" + "Accepts abbreviated parameter names so long as there is no ambiguity.\n" + "Call without arguments for a list of settable parameters with their values.") set_parser = ACArgumentParser(description=set_description) set_parser.add_argument('-a', '--all', action='store_true', help='display read-only settings as well') set_parser.add_argument('-l', '--long', action='store_true', help='describe function of parameter') - set_parser.add_argument('settable', nargs=(0, 2), help='[param_name] [value]') + setattr(set_parser.add_argument('param', nargs='?', help='parameter to set or view'), + ACTION_ARG_CHOICES, settable) + set_parser.add_argument('value', nargs='?', help='the new value for settable') @with_argparser(set_parser) def do_set(self, args: argparse.Namespace) -> None: - """Sets a settable parameter or shows current settings of parameters""" - try: - param_name, val = args.settable - val = val.strip() - param_name = param_name.strip().lower() - if param_name not in self.settable: - hits = [p for p in self.settable if p.startswith(param_name)] - if len(hits) == 1: - param_name = hits[0] - else: - return self.show(args, param_name) - current_val = getattr(self, param_name) - if (val[0] == val[-1]) and val[0] in ("'", '"'): - val = val[1:-1] + """Set a settable parameter or show current settings of parameters""" + + # Check if param was passed in + if not args.param: + return self.show(args) + param = args.param.strip().lower() + + # Check if value was passed in + if not args.value: + return self.show(args, param) + value = args.value + + # Check if param points to just one settable + if param not in self.settable: + hits = [p for p in self.settable if p.startswith(param)] + if len(hits) == 1: + param = hits[0] else: - val = utils.cast(current_val, val) - setattr(self, param_name, val) - self.poutput('%s - was: %s\nnow: %s\n' % (param_name, current_val, val)) - if current_val != val: - try: - onchange_hook = getattr(self, '_onchange_%s' % param_name) - onchange_hook(old=current_val, new=val) - except AttributeError: - pass - except (ValueError, AttributeError): - param = '' - if args.settable: - param = args.settable[0] - self.show(args, param) - - def do_shell(self, statement: Statement) -> None: - """Execute a command as if at the OS prompt - - Usage: shell <command> [arguments]""" + return self.show(args, param) + + # Update the settable's value + current_value = getattr(self, param) + value = utils.cast(current_value, value) + setattr(self, param, value) + + self.poutput('{} - was: {}\nnow: {}\n'.format(param, current_value, value)) + + # See if we need to call a change hook for this settable + if current_value != value: + onchange_hook = getattr(self, '_onchange_{}'.format(param), None) + if onchange_hook is not None: + onchange_hook(old=current_value, new=value) + + shell_parser = ACArgumentParser() + setattr(shell_parser.add_argument('command', help='the command to run'), + ACTION_ARG_CHOICES, ('shell_cmd_complete',)) + setattr(shell_parser.add_argument('command_args', nargs=argparse.REMAINDER, + help='arguments to pass to command'), + ACTION_ARG_CHOICES, ('path_complete',)) + + @with_argparser(shell_parser, preserve_quotes=True) + def do_shell(self, args: argparse.Namespace) -> None: + """Execute a command as if at the OS prompt""" import subprocess - # Get list of arguments to shell with quotes preserved - tokens = statement.arg_list + # Create a list of arguments to shell + tokens = [args.command] + args.command_args # Support expanding ~ in quoted paths for index, _ in enumerate(tokens): @@ -2640,18 +2924,6 @@ Usage: Usage: unalias [-a] name [name ...] proc = subprocess.Popen(expanded_command, stdout=self.stdout, shell=True) proc.communicate() - def complete_shell(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: - """Handles tab completion of executable commands and local file system paths for the shell command - - :param text: the string prefix we are attempting to match (all returned matches must begin with it) - :param line: the current input line with leading whitespace removed - :param begidx: the beginning index of the prefix text - :param endidx: the ending index of the prefix text - :return: a list of possible tab completions - """ - index_dict = {1: self.shell_cmd_complete} - return self.index_based_complete(text, line, begidx, endidx, index_dict, self.path_complete) - @staticmethod def _reset_py_display() -> None: """ @@ -2676,37 +2948,35 @@ Usage: Usage: unalias [-a] name [name ...] sys.displayhook = sys.__displayhook__ sys.excepthook = sys.__excepthook__ - def do_py(self, arg: str) -> bool: - """ - Invoke python command, shell, or script + py_parser = ACArgumentParser() + py_parser.add_argument('command', help="command to run", nargs='?') + py_parser.add_argument('remainder', help="remainder of command", nargs=argparse.REMAINDER) - py <command>: Executes a Python command. - py: Enters interactive Python mode. - End with ``Ctrl-D`` (Unix) / ``Ctrl-Z`` (Windows), ``quit()``, '`exit()``. - Non-python commands can be issued with ``pyscript_name("your command")``. - Run python code from external script files with ``run("script.py")`` - """ - from .pyscript_bridge import PyscriptBridge + @with_argparser(py_parser) + def do_py(self, args: argparse.Namespace) -> bool: + """Invoke Python command or shell""" + from .pyscript_bridge import PyscriptBridge, CommandResult if self._in_py: - self.perror("Recursively entering interactive Python consoles is not allowed.", traceback_war=False) + err = "Recursively entering interactive Python consoles is not allowed." + self.perror(err, traceback_war=False) + self._last_result = CommandResult('', err) return False self._in_py = True # noinspection PyBroadException try: - arg = arg.strip() - # Support the run command even if called prior to invoking an interactive interpreter - def run(filename): + def run(filename: str): """Run a Python script file in the interactive console. - :param filename: str - filename of *.py script file to run + :param filename: filename of *.py script file to run """ + expanded_filename = os.path.expanduser(filename) try: - with open(filename) as f: + with open(expanded_filename) as f: interp.runcode(f.read()) except OSError as ex: - error_msg = "Error opening script file '{}': {}".format(filename, ex) + error_msg = "Error opening script file '{}': {}".format(expanded_filename, ex) self.perror(error_msg, traceback_war=False) bridge = PyscriptBridge(self) @@ -2721,8 +2991,12 @@ Usage: Usage: unalias [-a] name [name ...] interp = InteractiveConsole(locals=localvars) interp.runcode('import sys, os;sys.path.insert(0, os.getcwd())') - if arg: - interp.runcode(arg) + if args.command: + full_command = utils.quote_string_if_needed(args.command) + for cur_token in args.remainder: + full_command += ' ' + utils.quote_string_if_needed(cur_token) + + interp.runcode(full_command) # If there are no args, then we will open an interactive Python console else: @@ -2787,11 +3061,14 @@ Usage: Usage: unalias [-a] name [name ...] sys.stdin = self.stdin cprt = 'Type "help", "copyright", "credits" or "license" for more information.' - docstr = self.do_py.__doc__.replace('pyscript_name', self.pyscript_name) + instructions = ('End with `Ctrl-D` (Unix) / `Ctrl-Z` (Windows), `quit()`, `exit()`.\n' + 'Non-Python commands can be issued with: {}("your command")\n' + 'Run Python code from external script files with: run("script.py")' + .format(self.pyscript_name)) try: - interp.interact(banner="Python {} on {}\n{}\n({})\n{}". - format(sys.version, sys.platform, cprt, self.__class__.__name__, docstr)) + interp.interact(banner="Python {} on {}\n{}\n\n{}\n". + format(sys.version, sys.platform, cprt, instructions)) except EmbeddedConsoleExit: pass @@ -2832,30 +3109,22 @@ Usage: Usage: unalias [-a] name [name ...] self._in_py = False return self._should_quit - @with_argument_list - def do_pyscript(self, arglist: List[str]) -> None: - """\nRuns a python script file inside the console - - Usage: pyscript <script_path> [script_arguments] + pyscript_parser = ACArgumentParser() + setattr(pyscript_parser.add_argument('script_path', help='path to the script file'), + ACTION_ARG_CHOICES, ('path_complete',)) + pyscript_parser.add_argument('script_arguments', nargs=argparse.REMAINDER, + help='arguments to pass to script') -Console commands can be executed inside this script with cmd("your command") -However, you cannot run nested "py" or "pyscript" commands from within this script -Paths or arguments that contain spaces must be enclosed in quotes -""" - if not arglist: - self.perror("pyscript command requires at least 1 argument ...", traceback_war=False) - self.do_help(['pyscript']) - return - - # Get the absolute path of the script - script_path = os.path.expanduser(arglist[0]) + @with_argparser(pyscript_parser) + def do_pyscript(self, args: argparse.Namespace) -> None: + """Run a Python script file inside the console""" + script_path = os.path.expanduser(args.script_path) # Save current command line arguments orig_args = sys.argv # Overwrite sys.argv to allow the script to take command line arguments - sys.argv = [script_path] - sys.argv.extend(arglist[1:]) + sys.argv = [script_path] + args.script_arguments # Run the script - use repr formatting to escape things which need to be escaped to prevent issues on Windows self.do_py("run({!r})".format(script_path)) @@ -2863,33 +3132,24 @@ Paths or arguments that contain spaces must be enclosed in quotes # Restore command line arguments to original state sys.argv = orig_args - def complete_pyscript(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: - """Enable tab-completion for pyscript command.""" - index_dict = {1: self.path_complete} - return self.index_based_complete(text, line, begidx, endidx, index_dict) - # Only include the do_ipy() method if IPython is available on the system - if ipython_available: - # noinspection PyMethodMayBeStatic,PyUnusedLocal - def do_ipy(self, arg: str) -> None: - """Enters an interactive IPython shell. - - Run python code from external files with ``run filename.py`` - End with ``Ctrl-D`` (Unix) / ``Ctrl-Z`` (Windows), ``quit()``, '`exit()``. - """ + if ipython_available: # pragma: no cover + @with_argparser(ACArgumentParser()) + def do_ipy(self, _: argparse.Namespace) -> None: + """Enter an interactive IPython shell""" from .pyscript_bridge import PyscriptBridge bridge = PyscriptBridge(self) + banner = ('Entering an embedded IPython shell. Type quit or <Ctrl>-d to exit.\n' + 'Run Python code from external files with: run filename.py\n') + exit_msg = 'Leaving IPython, back to {}'.format(sys.argv[0]) + if self.locals_in_py: def load_ipy(self, app): - banner = 'Entering an embedded IPython shell type quit() or <Ctrl>-d to exit ...' - exit_msg = 'Leaving IPython, back to {}'.format(sys.argv[0]) embed(banner1=banner, exit_msg=exit_msg) load_ipy(self, bridge) else: def load_ipy(app): - banner = 'Entering an embedded IPython shell type quit() or <Ctrl>-d to exit ...' - exit_msg = 'Leaving IPython, back to {}'.format(sys.argv[0]) embed(banner1=banner, exit_msg=exit_msg) load_ipy(bridge) @@ -2898,10 +3158,10 @@ Paths or arguments that contain spaces must be enclosed in quotes history_parser_group.add_argument('-r', '--run', action='store_true', help='run selected history items') history_parser_group.add_argument('-e', '--edit', action='store_true', help='edit and then run selected history items') - history_parser_group.add_argument('-s', '--script', action='store_true', help='script format; no separation lines') + history_parser_group.add_argument('-s', '--script', action='store_true', help='output commands in script format') history_parser_group.add_argument('-o', '--output-file', metavar='FILE', help='output commands to a script file') history_parser_group.add_argument('-t', '--transcript', help='output commands and results to a transcript file') - history_parser_group.add_argument('-c', '--clear', action="store_true", help='clears all history') + history_parser_group.add_argument('-c', '--clear', action="store_true", help='clear all history') _history_arg_help = """empty all history items a one history item by number a..b, a:b, a:, ..b items by indices (inclusive) @@ -3031,7 +3291,7 @@ a..b, a:b, a:, ..b items by indices (inclusive) # get the output out of the buffer output = membuf.read() # and add the regex-escaped output to the transcript - transcript += output.replace('/', '\/') + transcript += output.replace('/', r'\/') # Restore stdout to its original state self.stdout = saved_self_stdout @@ -3053,29 +3313,28 @@ a..b, a:b, a:, ..b items by indices (inclusive) msg = '{} {} saved to transcript file {!r}' self.pfeedback(msg.format(len(history), plural, transcript_file)) - @with_argument_list - def do_edit(self, arglist: List[str]) -> None: - """Edit a file in a text editor + edit_description = ("Edit a file in a text editor\n" + "\n" + "The editor used is determined by a settable parameter. To set it:\n" + "\n" + " set editor (program-name)") -Usage: edit [file_path] - Where: - * file_path - path to a file to open in editor + edit_parser = ACArgumentParser(description=edit_description) + setattr(edit_parser.add_argument('file_path', help="path to a file to open in editor", nargs="?"), + ACTION_ARG_CHOICES, ('path_complete',)) -The editor used is determined by the ``editor`` settable parameter. -"set editor (program-name)" to change or set the EDITOR environment variable. -""" + @with_argparser(edit_parser) + def do_edit(self, args: argparse.Namespace) -> None: + """Edit a file in a text editor""" if not self.editor: raise EnvironmentError("Please use 'set editor' to specify your text editing program of choice.") - filename = arglist[0] if arglist else '' - if filename: - os.system('"{}" "{}"'.format(self.editor, filename)) - else: - os.system('"{}"'.format(self.editor)) - def complete_edit(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: - """Enable tab-completion for edit command.""" - index_dict = {1: self.path_complete} - return self.index_based_complete(text, line, begidx, endidx, index_dict) + editor = utils.quote_string_if_needed(self.editor) + if args.file_path: + expanded_path = utils.quote_string_if_needed(os.path.expanduser(args.file_path)) + os.system('{} {}'.format(editor, expanded_path)) + else: + os.system('{}'.format(editor)) @property def _current_script_dir(self) -> Optional[str]: @@ -3085,54 +3344,25 @@ The editor used is determined by the ``editor`` settable parameter. else: return None - @with_argument_list - def do__relative_load(self, arglist: List[str]) -> None: - """Runs commands in script file that is encoded as either ASCII or UTF-8 text - - Usage: _relative_load <file_path> - - optional argument: - file_path a file path pointing to a script - -Script should contain one command per line, just like command would be typed in console. - -If this is called from within an already-running script, the filename will be interpreted -relative to the already-running script's directory. - -NOTE: This command is intended to only be used within text file scripts. - """ - # If arg is None or arg is an empty string this is an error - if not arglist: - self.perror('_relative_load command requires a file path:', traceback_war=False) - return - - file_path = arglist[0].strip() - # NOTE: Relative path is an absolute path, it is just relative to the current script directory - relative_path = os.path.join(self._current_script_dir or '', file_path) - self.do_load([relative_path]) - - def do_eos(self, _: str) -> None: - """Handles cleanup when a script has finished executing""" + @with_argparser(ACArgumentParser(epilog=INTERNAL_COMMAND_EPILOG)) + def do_eos(self, _: argparse.Namespace) -> None: + """Handle cleanup when a script has finished executing""" if self._script_dir: self._script_dir.pop() - @with_argument_list - def do_load(self, arglist: List[str]) -> None: - """Runs commands in script file that is encoded as either ASCII or UTF-8 text + load_description = ("Run commands in script file that is encoded as either ASCII or UTF-8 text\n" + "\n" + "Script should contain one command per line, just like the command would be\n" + "typed in the console.") - Usage: load <file_path> + load_parser = ACArgumentParser(description=load_description) + setattr(load_parser.add_argument('script_path', help="path to the script file"), + ACTION_ARG_CHOICES, ('path_complete',)) - * file_path - a file path pointing to a script - -Script should contain one command per line, just like command would be typed in console. - """ - # If arg is None or arg is an empty string this is an error - if not arglist: - self.perror('load command requires a file path', traceback_war=False) - return - - file_path = arglist[0].strip() - expanded_path = os.path.abspath(os.path.expanduser(file_path)) + @with_argparser(load_parser) + def do_load(self, args: argparse.Namespace) -> None: + """Run commands in script file that is encoded as either ASCII or UTF-8 text""" + expanded_path = os.path.abspath(os.path.expanduser(args.script_path)) # Make sure the path exists and we can access it if not os.path.exists(expanded_path): @@ -3166,10 +3396,24 @@ Script should contain one command per line, just like command would be typed in self._script_dir.append(os.path.dirname(expanded_path)) - def complete_load(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: - """Enable tab-completion for load command.""" - index_dict = {1: self.path_complete} - return self.index_based_complete(text, line, begidx, endidx, index_dict) + relative_load_description = load_description + relative_load_description += ("\n\n" + "If this is called from within an already-running script, the filename will be\n" + "interpreted relative to the already-running script's directory.") + + relative_load_epilog = ("Notes:\n" + " This command is intended to only be used within text file scripts.") + + relative_load_parser = ACArgumentParser(description=relative_load_description, epilog=relative_load_epilog) + relative_load_parser.add_argument('file_path', help='a file path pointing to a script') + + @with_argparser(relative_load_parser) + def do__relative_load(self, args: argparse.Namespace) -> None: + """""" + file_path = args.file_path + # NOTE: Relative path is an absolute path, it is just relative to the current script directory + relative_path = os.path.join(self._current_script_dir or '', file_path) + self.do_load(relative_path) def run_transcript_tests(self, callargs: List[str]) -> None: """Runs transcript tests for provided file(s). @@ -3191,6 +3435,125 @@ Script should contain one command per line, just like command would be typed in runner = unittest.TextTestRunner() runner.run(testcase) + def _clear_input_lines_str(self) -> str: # pragma: no cover + """ + Returns a string that if printed will clear the prompt and input lines in the terminal, + leaving the cursor at the beginning of the first input line + :return: the string to print + """ + if not (vt100_support and self.use_rawinput): + return '' + + import shutil + import colorama.ansi as ansi + from colorama import Cursor + + visible_prompt = self.visible_prompt + + # Get the size of the terminal + terminal_size = shutil.get_terminal_size() + + # Figure out how many lines the prompt and user input take up + total_str_size = len(visible_prompt) + len(readline.get_line_buffer()) + num_input_lines = int(total_str_size / terminal_size.columns) + 1 + + # Get the cursor's offset from the beginning of the first input line + cursor_input_offset = len(visible_prompt) + rl_get_point() + + # Calculate what input line the cursor is on + cursor_input_line = int(cursor_input_offset / terminal_size.columns) + 1 + + # Create a string that will clear all input lines and print the alert + terminal_str = '' + + # Move the cursor down to the last input line + if cursor_input_line != num_input_lines: + terminal_str += Cursor.DOWN(num_input_lines - cursor_input_line) + + # Clear each input line from the bottom up so that the cursor ends up on the original first input line + terminal_str += (ansi.clear_line() + Cursor.UP(1)) * (num_input_lines - 1) + terminal_str += ansi.clear_line() + + # Move the cursor to the beginning of the first input line + terminal_str += '\r' + + return terminal_str + + def async_alert(self, alert_msg: str, new_prompt: Optional[str] = None) -> None: # pragma: no cover + """ + Used to display an important message to the user while they are at the prompt in between commands. + To the user it appears as if an alert message is printed above the prompt and their current input + text and cursor location is left alone. + + IMPORTANT: Do not call this unless you have acquired self.terminal_lock + first, which ensures a prompt is onscreen + + :param alert_msg: the message to display to the user + :param new_prompt: if you also want to change the prompt that is displayed, then include it here + see async_update_prompt() docstring for guidance on updating a prompt + :raises RuntimeError if called while another thread holds terminal_lock + """ + if not (vt100_support and self.use_rawinput): + return + + # Sanity check that can't fail if self.terminal_lock was acquired before calling this function + if self.terminal_lock.acquire(blocking=False): + + # Generate a string to clear the prompt and input lines and replace with the alert + terminal_str = self._clear_input_lines_str() + if alert_msg: + terminal_str += alert_msg + '\n' + + # Set the new prompt now that _clear_input_lines_str is done using the old prompt + if new_prompt is not None: + self.prompt = new_prompt + rl_set_prompt(self.prompt) + + # Print terminal_str to erase the lines + if rl_type == RlType.GNU: + sys.stderr.write(terminal_str) + elif rl_type == RlType.PYREADLINE: + readline.rl.mode.console.write(terminal_str) + + # Redraw the prompt and input lines + rl_force_redisplay() + + self.terminal_lock.release() + + else: + raise RuntimeError("another thread holds terminal_lock") + + def async_update_prompt(self, new_prompt: str) -> None: # pragma: no cover + """ + Updates the prompt while the user is still typing at it. This is good for alerting the user to system + changes dynamically in between commands. For instance you could alter the color of the prompt to indicate + a system status or increase a counter to report an event. If you do alter the actual text of the prompt, + it is best to keep the prompt the same width as what's on screen. Otherwise the user's input text will + be shifted and the update will not be seamless. + + IMPORTANT: Do not call this unless you have acquired self.terminal_lock + first, which ensures a prompt is onscreen + + :param new_prompt: what to change the prompt to + """ + self.async_alert('', new_prompt) + + @staticmethod + def set_window_title(title: str) -> None: # pragma: no cover + """ + Sets the terminal window title + :param title: the new window title + """ + if not vt100_support: + return + + import colorama.ansi as ansi + try: + sys.stderr.write(ansi.set_title(title)) + except AttributeError: + # Debugging in Pycharm has issues with setting terminal title + pass + def cmdloop(self, intro: Optional[str]=None) -> None: """This is an outer wrapper around _cmdloop() which deals with extra features provided by cmd2. @@ -3216,6 +3579,14 @@ Script should contain one command per line, just like command would be typed in if callargs: self.cmdqueue.extend(callargs) + # Register a SIGINT signal handler for Ctrl+C + import signal + original_sigint_handler = signal.getsignal(signal.SIGINT) + signal.signal(signal.SIGINT, self.sigint_handler) + + # Grab terminal lock before the prompt has been drawn by readline + self.terminal_lock.acquire() + # Always run the preloop first for func in self._preloop_hooks: func() @@ -3241,6 +3612,13 @@ Script should contain one command per line, just like command would be typed in func() self.postloop() + # Release terminal lock now that postloop code should have stopped any terminal updater threads + # This will also zero the lock count in case cmdloop() is called again + self.terminal_lock.release() + + # Restore the original signal handler + signal.signal(signal.SIGINT, original_sigint_handler) + if self.exit_code is not None: sys.exit(self.exit_code) @@ -3249,7 +3627,7 @@ Script should contain one command per line, just like command would be typed in # plugin related functions # ### - def _initialize_plugin_system(self): + def _initialize_plugin_system(self) -> None: """Initialize the plugin system""" self._preloop_hooks = [] self._postloop_hooks = [] @@ -3259,7 +3637,7 @@ Script should contain one command per line, just like command would be typed in self._cmdfinalization_hooks = [] @classmethod - def _validate_callable_param_count(cls, func: Callable, count: int): + def _validate_callable_param_count(cls, func: Callable, count: int) -> None: """Ensure a function has the given number of parameters.""" signature = inspect.signature(func) # validate that the callable has the right number of parameters @@ -3272,7 +3650,7 @@ Script should contain one command per line, just like command would be typed in )) @classmethod - def _validate_prepostloop_callable(cls, func: Callable): + def _validate_prepostloop_callable(cls, func: Callable[[None], None]) -> None: """Check parameter and return types for preloop and postloop hooks.""" cls._validate_callable_param_count(func, 0) # make sure there is no return notation @@ -3282,18 +3660,18 @@ Script should contain one command per line, just like command would be typed in func.__name__, )) - def register_preloop_hook(self, func: Callable): + def register_preloop_hook(self, func: Callable[[None], None]) -> None: """Register a function to be called at the beginning of the command loop.""" self._validate_prepostloop_callable(func) self._preloop_hooks.append(func) - def register_postloop_hook(self, func: Callable): + def register_postloop_hook(self, func: Callable[[None], None]) -> None: """Register a function to be called at the end of the command loop.""" self._validate_prepostloop_callable(func) self._postloop_hooks.append(func) @classmethod - def _validate_postparsing_callable(cls, func: Callable): + def _validate_postparsing_callable(cls, func: Callable[[plugin.PostparsingData], plugin.PostparsingData]) -> None: """Check parameter and return types for postparsing hooks""" cls._validate_callable_param_count(func, 1) signature = inspect.signature(func) @@ -3307,13 +3685,13 @@ Script should contain one command per line, just like command would be typed in func.__name__ )) - def register_postparsing_hook(self, func: Callable): + def register_postparsing_hook(self, func: Callable[[plugin.PostparsingData], plugin.PostparsingData]) -> None: """Register a function to be called after parsing user input but before running the command""" self._validate_postparsing_callable(func) self._postparsing_hooks.append(func) @classmethod - def _validate_prepostcmd_hook(cls, func: Callable, data_type: Type): + def _validate_prepostcmd_hook(cls, func: Callable, data_type: Type) -> None: """Check parameter and return types for pre and post command hooks.""" signature = inspect.signature(func) # validate that the callable has the right number of parameters @@ -3340,18 +3718,19 @@ Script should contain one command per line, just like command would be typed in data_type, )) - def register_precmd_hook(self, func: Callable): + def register_precmd_hook(self, func: Callable[[plugin.PrecommandData], plugin.PrecommandData]) -> None: """Register a hook to be called before the command function.""" self._validate_prepostcmd_hook(func, plugin.PrecommandData) self._precmd_hooks.append(func) - def register_postcmd_hook(self, func: Callable): + def register_postcmd_hook(self, func: Callable[[plugin.PostcommandData], plugin.PostcommandData]) -> None: """Register a hook to be called after the command function.""" self._validate_prepostcmd_hook(func, plugin.PostcommandData) self._postcmd_hooks.append(func) @classmethod - def _validate_cmdfinalization_callable(cls, func: Callable): + def _validate_cmdfinalization_callable(cls, func: Callable[[plugin.CommandFinalizationData], + plugin.CommandFinalizationData]) -> None: """Check parameter and return types for command finalization hooks.""" cls._validate_callable_param_count(func, 1) signature = inspect.signature(func) @@ -3363,7 +3742,8 @@ Script should contain one command per line, just like command would be typed in raise TypeError("{} must declare return a return type of " "'cmd2.plugin.CommandFinalizationData'".format(func.__name__)) - def register_cmdfinalization_hook(self, func: Callable): + def register_cmdfinalization_hook(self, func: Callable[[plugin.CommandFinalizationData], + plugin.CommandFinalizationData]) -> None: """Register a hook to be called after a command is completed, whether it completes successfully or not.""" self._validate_cmdfinalization_callable(func) self._cmdfinalization_hooks.append(func) @@ -3429,7 +3809,7 @@ class History(list): def get(self, getme: Optional[Union[int, str]]=None) -> List[HistoryItem]: """Get an item or items from the History list using 1-based indexing. - :param getme: item(s) to get - either an integer index or string to search for + :param getme: optional item(s) to get (either an integer index or string to search for) :return: list of HistoryItems matching the retrieval criteria """ if not getme: diff --git a/cmd2/constants.py b/cmd2/constants.py index d3e8a125..3c133b70 100644 --- a/cmd2/constants.py +++ b/cmd2/constants.py @@ -17,3 +17,8 @@ REDIRECTION_TOKENS = [REDIRECTION_PIPE, REDIRECTION_OUTPUT, REDIRECTION_APPEND] ANSI_ESCAPE_RE = re.compile(r'\x1b[^m]*m') LINE_FEED = '\n' + +# values for colors setting +COLORS_NEVER = 'Never' +COLORS_TERMINAL = 'Terminal' +COLORS_ALWAYS = 'Always' diff --git a/cmd2/parsing.py b/cmd2/parsing.py index 8edfacb9..e90eac43 100644 --- a/cmd2/parsing.py +++ b/cmd2/parsing.py @@ -14,6 +14,55 @@ from . import utils @attr.s(frozen=True) +class MacroArg: + """ + Information used to replace or unescape arguments in a macro value when the macro is resolved + Normal argument syntax : {5} + Escaped argument syntax: {{5}} + """ + # The starting index of this argument in the macro value + start_index = attr.ib(validator=attr.validators.instance_of(int)) + + # The number string that appears between the braces + # This is a string instead of an int because we support unicode digits and must be able + # to reproduce this string later + number_str = attr.ib(validator=attr.validators.instance_of(str)) + + # Tells if this argument is escaped and therefore needs to be unescaped + is_escaped = attr.ib(validator=attr.validators.instance_of(bool)) + + # Pattern used to find normal argument + # Digits surrounded by exactly 1 brace on a side and 1 or more braces on the opposite side + # Match strings like: {5}, {{{{{4}, {2}}}}} + macro_normal_arg_pattern = re.compile(r'(?<!{){\d+}|{\d+}(?!})') + + # Pattern used to find escaped arguments + # Digits surrounded by 2 or more braces on both sides + # Match strings like: {{5}}, {{{{{4}}, {{2}}}}} + macro_escaped_arg_pattern = re.compile(r'{{2}\d+}{2}') + + # Finds a string of digits + digit_pattern = re.compile(r'\d+') + + +@attr.s(frozen=True) +class Macro: + """Defines a cmd2 macro""" + + # Name of the macro + name = attr.ib(validator=attr.validators.instance_of(str)) + + # The string the macro resolves to + value = attr.ib(validator=attr.validators.instance_of(str)) + + # The required number of args the user has to pass to this macro + required_arg_count = attr.ib(validator=attr.validators.instance_of(int)) + + # Used to fill in argument placeholders in the macro + arg_list = attr.ib(default=attr.Factory(list), validator=attr.validators.instance_of(list)) + + +@attr.s(frozen=True) class Statement(str): """String subclass with additional attributes to store the results of parsing. @@ -81,34 +130,34 @@ class Statement(str): argv[1:], which strips them all off for you. """ # the arguments, but not the command, nor the output redirection clauses. - args = attr.ib(default='', validator=attr.validators.instance_of(str), type=str) + args = attr.ib(default='', validator=attr.validators.instance_of(str)) # string containing exactly what we input by the user - raw = attr.ib(default='', validator=attr.validators.instance_of(str), type=str) + raw = attr.ib(default='', validator=attr.validators.instance_of(str)) # the command, i.e. the first whitespace delimited word - command = attr.ib(default='', validator=attr.validators.instance_of(str), type=str) + command = attr.ib(default='', validator=attr.validators.instance_of(str)) # list of arguments to the command, not including any output redirection or terminators; quoted args remain quoted - arg_list = attr.ib(factory=list, validator=attr.validators.instance_of(list), type=List[str]) + arg_list = attr.ib(default=attr.Factory(list), validator=attr.validators.instance_of(list)) # if the command is a multiline command, the name of the command, otherwise empty - multiline_command = attr.ib(default='', validator=attr.validators.instance_of(str), type=str) + multiline_command = attr.ib(default='', validator=attr.validators.instance_of(str)) # the character which terminated the multiline command, if there was one - terminator = attr.ib(default='', validator=attr.validators.instance_of(str), type=str) + terminator = attr.ib(default='', validator=attr.validators.instance_of(str)) # characters appearing after the terminator but before output redirection, if any - suffix = attr.ib(default='', validator=attr.validators.instance_of(str), type=str) + suffix = attr.ib(default='', validator=attr.validators.instance_of(str)) # if output was piped to a shell command, the shell command as a list of tokens - pipe_to = attr.ib(factory=list, validator=attr.validators.instance_of(list), type=List[str]) + pipe_to = attr.ib(default=attr.Factory(list), validator=attr.validators.instance_of(list)) # if output was redirected, the redirection token, i.e. '>>' - output = attr.ib(default='', validator=attr.validators.instance_of(str), type=str) + output = attr.ib(default='', validator=attr.validators.instance_of(str)) # if output was redirected, the destination file - output_to = attr.ib(default='', validator=attr.validators.instance_of(str), type=str) + output_to = attr.ib(default='', validator=attr.validators.instance_of(str)) def __new__(cls, value: object, *pos_args, **kw_args): """Create a new instance of Statement. @@ -247,23 +296,33 @@ class StatementParser: self._command_pattern = re.compile(expr) def is_valid_command(self, word: str) -> Tuple[bool, str]: - """Determine whether a word is a valid alias. + """Determine whether a word is a valid name for a command. - Aliases can not include redirection characters, whitespace, - or termination characters. + Commands can not include redirection characters, whitespace, + or termination characters. They also cannot start with a + shortcut. - If word is not a valid command, return False and a comma - separated string of characters that can not appear in a command. + If word is not a valid command, return False and error text This string is suitable for inclusion in an error message of your choice: - valid, invalidchars = statement_parser.is_valid_command('>') + valid, errmsg = statement_parser.is_valid_command('>') if not valid: - errmsg = "Aliases can not contain: {}".format(invalidchars) + errmsg = "Alias {}".format(errmsg) """ valid = False - errmsg = 'whitespace, quotes, ' + if not word: + return False, 'cannot be an empty string' + + for (shortcut, _) in self.shortcuts: + if word.startswith(shortcut): + # Build an error string with all shortcuts listed + errmsg = 'cannot start with a shortcut: ' + errmsg += ', '.join(shortcut for (shortcut, _) in self.shortcuts) + return False, errmsg + + errmsg = 'cannot contain: whitespace, quotes, ' errchars = [] errchars.extend(constants.REDIRECTION_CHARS) errchars.extend(self.terminators) diff --git a/cmd2/pyscript_bridge.py b/cmd2/pyscript_bridge.py index 3f58ab84..f03b530f 100644 --- a/cmd2/pyscript_bridge.py +++ b/cmd2/pyscript_bridge.py @@ -12,15 +12,15 @@ import functools import sys from typing import List, Callable +from .argparse_completer import _RangeAction +from .utils import namedtuple_with_defaults, StdSim + # Python 3.4 require contextlib2 for temporarily redirecting stderr and stdout if sys.version_info < (3, 5): from contextlib2 import redirect_stdout, redirect_stderr else: from contextlib import redirect_stdout, redirect_stderr -from .argparse_completer import _RangeAction -from .utils import namedtuple_with_defaults - class CommandResult(namedtuple_with_defaults('CommandResult', ['stdout', 'stderr', 'data'])): """Encapsulates the results from a command. @@ -38,37 +38,12 @@ class CommandResult(namedtuple_with_defaults('CommandResult', ['stdout', 'stderr return not self.stderr and self.data is not None -class CopyStream(object): - """Copies all data written to a stream""" - def __init__(self, inner_stream, echo: bool = False) -> None: - self.buffer = '' - self.inner_stream = inner_stream - self.echo = echo - - def write(self, s): - self.buffer += s - if self.echo: - self.inner_stream.write(s) - - def read(self): - raise NotImplementedError - - def clear(self): - self.buffer = '' - - def __getattr__(self, item: str): - if item in self.__dict__: - return self.__dict__[item] - else: - return getattr(self.inner_stream, item) - - def _exec_cmd(cmd2_app, func: Callable, echo: bool): """Helper to encapsulate executing a command and capturing the results""" - copy_stdout = CopyStream(sys.stdout, echo) - copy_stderr = CopyStream(sys.stderr, echo) + copy_stdout = StdSim(sys.stdout, echo) + copy_stderr = StdSim(sys.stderr, echo) - copy_cmd_stdout = CopyStream(cmd2_app.stdout, echo) + copy_cmd_stdout = StdSim(cmd2_app.stdout, echo) cmd2_app._last_result = None @@ -81,9 +56,9 @@ def _exec_cmd(cmd2_app, func: Callable, echo: bool): cmd2_app.stdout = copy_cmd_stdout.inner_stream # if stderr is empty, set it to None - stderr = copy_stderr.buffer if copy_stderr.buffer else None + stderr = copy_stderr.getvalue() if copy_stderr.getvalue() else None - outbuf = copy_cmd_stdout.buffer if copy_cmd_stdout.buffer else copy_stdout.buffer + outbuf = copy_cmd_stdout.getvalue() if copy_cmd_stdout.getvalue() else copy_stdout.getvalue() result = CommandResult(stdout=outbuf, stderr=stderr, data=cmd2_app._last_result) return result @@ -204,7 +179,10 @@ class ArgparseFunctor: def _run(self): # look up command function - func = getattr(self._cmd2_app, 'do_' + self._command_name) + func = self._cmd2_app.cmd_func(self._command_name) + if func is None: + raise AttributeError("{!r} object has no command called {!r}".format(self._cmd2_app.__class__.__name__, + self._command_name)) # reconstruct the cmd2 command from the python call cmd_str = [''] @@ -273,23 +251,20 @@ class PyscriptBridge(object): def __getattr__(self, item: str): """Check if the attribute is a command. If so, return a callable.""" - commands = self._cmd2_app.get_all_commands() - if item in commands: - func = getattr(self._cmd2_app, 'do_' + item) - - try: - # See if the command uses argparse - parser = getattr(func, 'argparser') - except AttributeError: - # Command doesn't, we will accept parameters in the form of a command string + func = self._cmd2_app.cmd_func(item) + + if func: + if hasattr(func, 'argparser'): + # Command uses argparse, return an object that can traverse the argparse subcommands and arguments + return ArgparseFunctor(self.cmd_echo, self._cmd2_app, item, getattr(func, 'argparser')) + else: + # Command doesn't use argparse, we will accept parameters in the form of a command string def wrap_func(args=''): return _exec_cmd(self._cmd2_app, functools.partial(func, args), self.cmd_echo) - return wrap_func - else: - # Command does use argparse, return an object that can traverse the argparse subcommands and arguments - return ArgparseFunctor(self.cmd_echo, self._cmd2_app, item, parser) - return super().__getattr__(item) + return wrap_func + else: + return super().__getattr__(item) def __dir__(self): """Return a custom set of attribute names to match the available commands""" diff --git a/cmd2/rl_utils.py b/cmd2/rl_utils.py index 7e49ea47..0819232d 100644 --- a/cmd2/rl_utils.py +++ b/cmd2/rl_utils.py @@ -26,13 +26,54 @@ class RlType(Enum): # Check what implementation of readline we are using - rl_type = RlType.NONE +# Tells if the terminal we are running in supports vt100 control characters +vt100_support = False + # The order of this check matters since importing pyreadline will also show readline in the modules list if 'pyreadline' in sys.modules: rl_type = RlType.PYREADLINE + from ctypes import byref + from ctypes.wintypes import DWORD, HANDLE + import atexit + + # Check if we are running in a terminal + if sys.stdout.isatty(): # pragma: no cover + # noinspection PyPep8Naming + def enable_win_vt100(handle: HANDLE) -> bool: + """ + Enables VT100 character sequences in a Windows console + This only works on Windows 10 and up + :param handle: the handle on which to enable vt100 + :return: True if vt100 characters are enabled for the handle + """ + ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004 + + # Get the current mode for this handle in the console + cur_mode = DWORD(0) + readline.rl.console.GetConsoleMode(handle, byref(cur_mode)) + + retVal = False + + # Check if ENABLE_VIRTUAL_TERMINAL_PROCESSING is already enabled + if (cur_mode.value & ENABLE_VIRTUAL_TERMINAL_PROCESSING) != 0: + retVal = True + + elif readline.rl.console.SetConsoleMode(handle, cur_mode.value | ENABLE_VIRTUAL_TERMINAL_PROCESSING): + # Restore the original mode when we exit + atexit.register(readline.rl.console.SetConsoleMode, handle, cur_mode) + retVal = True + + return retVal + + # Enable VT100 sequences for stdout and stderr + STD_OUT_HANDLE = -11 + STD_ERROR_HANDLE = -12 + vt100_support = (enable_win_vt100(readline.rl.console.GetStdHandle(STD_OUT_HANDLE)) and + enable_win_vt100(readline.rl.console.GetStdHandle(STD_ERROR_HANDLE))) + ############################################################################################################ # pyreadline is incomplete in terms of the Python readline API. Add the missing functions we need. ############################################################################################################ @@ -74,9 +115,13 @@ elif 'gnureadline' in sys.modules or 'readline' in sys.modules: import ctypes readline_lib = ctypes.CDLL(readline.__file__) + # Check if we are running in a terminal + if sys.stdout.isatty(): + vt100_support = True + # noinspection PyProtectedMember -def rl_force_redisplay() -> None: +def rl_force_redisplay() -> None: # pragma: no cover """ Causes readline to display the prompt and input text wherever the cursor is and start reading input from this location. This is the proper way to restore the input line after @@ -85,14 +130,77 @@ def rl_force_redisplay() -> None: if not sys.stdout.isatty(): return - if rl_type == RlType.GNU: # pragma: no cover + if rl_type == RlType.GNU: readline_lib.rl_forced_update_display() # After manually updating the display, readline asks that rl_display_fixed be set to 1 for efficiency display_fixed = ctypes.c_int.in_dll(readline_lib, "rl_display_fixed") display_fixed.value = 1 - elif rl_type == RlType.PYREADLINE: # pragma: no cover + elif rl_type == RlType.PYREADLINE: # Call _print_prompt() first to set the new location of the prompt readline.rl.mode._print_prompt() readline.rl.mode._update_line() + + +# noinspection PyProtectedMember +def rl_get_point() -> int: # pragma: no cover + """ + Returns the offset of the current cursor position in rl_line_buffer + """ + if rl_type == RlType.GNU: + return ctypes.c_int.in_dll(readline_lib, "rl_point").value + + elif rl_type == RlType.PYREADLINE: + return readline.rl.mode.l_buffer.point + + else: + return 0 + + +# noinspection PyProtectedMember +def rl_set_prompt(prompt: str) -> None: # pragma: no cover + """ + Sets readline's prompt + :param prompt: the new prompt value + """ + safe_prompt = rl_make_safe_prompt(prompt) + + if rl_type == RlType.GNU: + encoded_prompt = bytes(safe_prompt, encoding='utf-8') + readline_lib.rl_set_prompt(encoded_prompt) + + elif rl_type == RlType.PYREADLINE: + readline.rl._set_prompt(safe_prompt) + + +def rl_make_safe_prompt(prompt: str) -> str: # pragma: no cover + """Overcome bug in GNU Readline in relation to calculation of prompt length in presence of ANSI escape codes. + + :param prompt: original prompt + :return: prompt safe to pass to GNU Readline + """ + if rl_type == RlType.GNU: + # start code to tell GNU Readline about beginning of invisible characters + start = "\x01" + + # end code to tell GNU Readline about end of invisible characters + end = "\x02" + + escaped = False + result = "" + + for c in prompt: + if c == "\x1b" and not escaped: + result += start + c + escaped = True + elif c.isalpha() and escaped: + result += c + end + escaped = False + else: + result += c + + return result + + else: + return prompt diff --git a/cmd2/transcript.py b/cmd2/transcript.py index 5ba8d20d..2d94f4e4 100644 --- a/cmd2/transcript.py +++ b/cmd2/transcript.py @@ -44,7 +44,7 @@ class Cmd2TestCase(unittest.TestCase): # Trap stdout self._orig_stdout = self.cmdapp.stdout - self.cmdapp.stdout = OutputTrap() + self.cmdapp.stdout = utils.StdSim(self.cmdapp.stdout) def runTest(self): # was testall if self.cmdapp: @@ -106,7 +106,7 @@ class Cmd2TestCase(unittest.TestCase): self.assertTrue(re.match(expected, result, re.MULTILINE | re.DOTALL), message) def _transform_transcript_expected(self, s: str) -> str: - """Parse the string with slashed regexes into a valid regex. + r"""Parse the string with slashed regexes into a valid regex. Given a string like: @@ -203,24 +203,3 @@ class Cmd2TestCase(unittest.TestCase): if self.cmdapp: # Restore stdout self.cmdapp.stdout = self._orig_stdout - -class OutputTrap(object): - """Instantiate an OutputTrap to divert/capture ALL stdout output. - For use in transcript testing. - """ - - def __init__(self): - self.contents = '' - - def write(self, txt: str): - """Add text to the internal contents.""" - self.contents += txt - - def read(self) -> str: - """Read from the internal contents and then clear them out. - - :return: str - text from the internal contents - """ - result = self.contents - self.contents = '' - return result diff --git a/cmd2/utils.py b/cmd2/utils.py index 02956f6b..ddd43507 100644 --- a/cmd2/utils.py +++ b/cmd2/utils.py @@ -4,8 +4,9 @@ import collections import os -from typing import Any, List, Optional, Union +import re import unicodedata +from typing import Any, Iterable, List, Optional, Union from . import constants @@ -19,6 +20,28 @@ def strip_ansi(text: str) -> str: return constants.ANSI_ESCAPE_RE.sub('', text) +def is_quoted(arg: str) -> bool: + """ + Checks if a string is quoted + :param arg: the string being checked for quotes + :return: True if a string is quoted + """ + return len(arg) > 1 and arg[0] == arg[-1] and arg[0] in constants.QUOTES + + +def quote_string_if_needed(arg: str) -> str: + """ Quotes a string if it contains spaces and isn't already quoted """ + if is_quoted(arg) or ' ' not in arg: + return arg + + if '"' in arg: + quote = "'" + else: + quote = '"' + + return quote + arg + quote + + def strip_quotes(arg: str) -> str: """ Strip outer quotes from a string. @@ -27,7 +50,7 @@ def strip_quotes(arg: str) -> str: :param arg: string to strip outer quotes from :return: same string with potentially outer quotes stripped """ - if len(arg) > 1 and arg[0] == arg[-1] and arg[0] in constants.QUOTES: + if is_quoted(arg): arg = arg[1:-1] return arg @@ -66,10 +89,12 @@ def cast(current: Any, new: str) -> Any: """Tries to force a new value into the same type as the current when trying to set the value for a parameter. :param current: current value for the parameter, type varies - :param new: str - new value + :param new: new value :return: new value with same type as current, or the current value if there was an error casting """ typ = type(current) + orig_new = new + if typ == bool: try: return bool(int(new)) @@ -77,18 +102,18 @@ def cast(current: Any, new: str) -> Any: pass try: new = new.lower() + if (new == 'on') or (new[0] in ('y', 't')): + return True + if (new == 'off') or (new[0] in ('n', 'f')): + return False except AttributeError: pass - if (new == 'on') or (new[0] in ('y', 't')): - return True - if (new == 'off') or (new[0] in ('n', 'f')): - return False else: try: return typ(new) except (ValueError, TypeError): pass - print("Problem setting parameter (now %s) to %s; incorrect type?" % (current, new)) + print("Problem setting parameter (now {}) to {}; incorrect type?".format(current, orig_new)) return current @@ -169,10 +194,125 @@ def norm_fold(astr: str) -> str: return unicodedata.normalize('NFC', astr).casefold() -def alphabetical_sort(list_to_sort: List[str]) -> List[str]: +def alphabetical_sort(list_to_sort: Iterable[str]) -> List[str]: """Sorts a list of strings alphabetically. + For example: ['a1', 'A11', 'A2', 'a22', 'a3'] + + To sort a list in place, don't call this method, which makes a copy. Instead, do this: + + my_list.sort(key=norm_fold) + :param list_to_sort: the list being sorted :return: the sorted list """ return sorted(list_to_sort, key=norm_fold) + + +def try_int_or_force_to_lower_case(input_str: str) -> Union[int, str]: + """ + Tries to convert the passed-in string to an integer. If that fails, it converts it to lower case using norm_fold. + :param input_str: string to convert + :return: the string as an integer or a lower case version of the string + """ + try: + return int(input_str) + except ValueError: + return norm_fold(input_str) + + +def natural_keys(input_str: str) -> List[Union[int, str]]: + """ + Converts a string into a list of integers and strings to support natural sorting (see natural_sort). + + For example: natural_keys('abc123def') -> ['abc', '123', 'def'] + :param input_str: string to convert + :return: list of strings and integers + """ + return [try_int_or_force_to_lower_case(substr) for substr in re.split(r'(\d+)', input_str)] + + +def natural_sort(list_to_sort: Iterable[str]) -> List[str]: + """ + Sorts a list of strings case insensitively as well as numerically. + + For example: ['a1', 'A2', 'a3', 'A11', 'a22'] + + To sort a list in place, don't call this method, which makes a copy. Instead, do this: + + my_list.sort(key=natural_keys) + + :param list_to_sort: the list being sorted + :return: the list sorted naturally + """ + return sorted(list_to_sort, key=natural_keys) + + +class StdSim(object): + """Class to simulate behavior of sys.stdout or sys.stderr. + + Stores contents in internal buffer and optionally echos to the inner stream it is simulating. + """ + class ByteBuf(object): + """Inner class which stores an actual bytes buffer and does the actual output if echo is enabled.""" + def __init__(self, inner_stream, echo: bool = False) -> None: + self.byte_buf = b'' + self.inner_stream = inner_stream + self.echo = echo + + def write(self, b: bytes) -> None: + """Add bytes to internal bytes buffer and if echo is True, echo contents to inner stream.""" + if not isinstance(b, bytes): + raise TypeError('a bytes-like object is required, not {}'.format(type(b))) + self.byte_buf += b + if self.echo: + self.inner_stream.buffer.write(b) + + def __init__(self, inner_stream, echo: bool = False) -> None: + self.buffer = self.ByteBuf(inner_stream, echo) + self.inner_stream = inner_stream + + def write(self, s: str) -> None: + """Add str to internal bytes buffer and if echo is True, echo contents to inner stream.""" + if not isinstance(s, str): + raise TypeError('write() argument must be str, not {}'.format(type(s))) + b = s.encode() + self.buffer.write(b) + + def getvalue(self) -> str: + """Get the internal contents as a str. + + :return string from the internal contents + """ + return self.buffer.byte_buf.decode() + + def read(self) -> str: + """Read from the internal contents as a str and then clear them out. + + :return: string from the internal contents + """ + result = self.getvalue() + self.clear() + return result + + def clear(self) -> None: + """Clear the internal contents.""" + self.buffer.byte_buf = b'' + + def __getattr__(self, item: str): + if item in self.__dict__: + return self.__dict__[item] + else: + return getattr(self.inner_stream, item) + + +def unquote_redirection_tokens(args: List[str]) -> None: + """ + Unquote redirection tokens in a list of command-line arguments + This is used when redirection tokens have to be passed to another command + :param args: the command line args + """ + for i, arg in enumerate(args): + unquoted_arg = strip_quotes(arg) + if unquoted_arg in constants.REDIRECTION_TOKENS: + args[i] = unquoted_arg diff --git a/docs/freefeatures.rst b/docs/freefeatures.rst index a03a1d08..0a95a829 100644 --- a/docs/freefeatures.rst +++ b/docs/freefeatures.rst @@ -174,13 +174,9 @@ More Python examples: Type "help", "copyright", "credits" or "license" for more information. (CmdLineApp) - Invoke python command, shell, or script - - py <command>: Executes a Python command. - py: Enters interactive Python mode. - End with ``Ctrl-D`` (Unix) / ``Ctrl-Z`` (Windows), ``quit()``, '`exit()``. - Non-python commands can be issued with ``app("your command")``. - Run python code from external script files with ``run("script.py")`` + End with `Ctrl-D` (Unix) / `Ctrl-Z` (Windows), `quit()`, `exit()`. + Non-python commands can be issued with: app("your command") + Run python code from external script files with: run("script.py") >>> import os >>> os.uname() diff --git a/docs/hooks.rst b/docs/hooks.rst index 2a5d7b5f..1696d365 100644 --- a/docs/hooks.rst +++ b/docs/hooks.rst @@ -76,24 +76,21 @@ Command Processing Loop When you call `.cmdloop()`, the following sequence of events are repeated until the application exits: -1. Output the prompt -2. Accept user input -3. Call `preparse()` - for backwards compatibility with prior releases of cmd2, now deprecated -4. Parse user input into `Statement` object -5. Call methods registered with `register_postparsing_hook()` -6. Call `postparsing_precmd()` - for backwards compatibility with prior releases of cmd2, now deprecated -7. Redirect output, if user asked for it and it's allowed -8. Start timer -9. Call methods registered with `register_precmd_hook()` -10. Call `precmd()` - for backwards compatibility with ``cmd.Cmd`` -11. Add statement to history -12. Call `do_command` method -13. Call methods registered with `register_postcmd_hook()` -14. Call `postcmd(stop, statement)` - for backwards compatibility with ``cmd.Cmd`` -15. Stop timer and display the elapsed time -16. Stop redirecting output if it was redirected -17. Call methods registered with `register_cmdfinalization_hook()` -18. Call `postparsing_postcmd()` - for backwards compatibility - deprecated +#. Output the prompt +#. Accept user input +#. Parse user input into `Statement` object +#. Call methods registered with `register_postparsing_hook()` +#. Redirect output, if user asked for it and it's allowed +#. Start timer +#. Call methods registered with `register_precmd_hook()` +#. Call `precmd()` - for backwards compatibility with ``cmd.Cmd`` +#. Add statement to history +#. Call `do_command` method +#. Call methods registered with `register_postcmd_hook()` +#. Call `postcmd(stop, statement)` - for backwards compatibility with ``cmd.Cmd`` +#. Stop timer and display the elapsed time +#. Stop redirecting output if it was redirected +#. Call methods registered with `register_cmdfinalization_hook()` By registering hook methods, steps 4, 8, 12, and 16 allow you to run code during, and control the flow of the command processing loop. Be aware that @@ -305,21 +302,3 @@ If any command finalization hook raises an exception, no more command finalization hooks will be called. If the last hook to return a value returned ``True``, then the exception will be rendered, and the application will terminate. - -Deprecated Command Processing Hooks ------------------------------------ - -Inside the main loop, every time the user hits <Enter> the line is processed by the ``onecmd_plus_hooks`` method. - -.. automethod:: cmd2.cmd2.Cmd.onecmd_plus_hooks - -As the ``onecmd_plus_hooks`` name implies, there are a number of *hook* methods that can be defined in order to inject -application-specific behavior at various points during the processing of a line of text entered by the user. ``cmd2`` -increases the 2 hooks provided by ``cmd`` (**precmd** and **postcmd**) to 6 for greater flexibility. Here are -the various hook methods, presented in chronological order starting with the ones called earliest in the process. - -.. automethod:: cmd2.cmd2.Cmd.preparse - -.. automethod:: cmd2.cmd2.Cmd.postparsing_precmd - -.. automethod:: cmd2.cmd2.Cmd.postparsing_postcmd diff --git a/docs/integrating.rst b/docs/integrating.rst index 8f605e06..a8377fdb 100644 --- a/docs/integrating.rst +++ b/docs/integrating.rst @@ -135,22 +135,19 @@ script file. The **onecmd_plus_hooks()** method will do the following to execute a single ``cmd2`` command in a normal fashion: -1. Call `preparse()` - for backwards compatibility with prior releases of cmd2, now deprecated -2. Parse user input into `Statement` object -3. Call methods registered with `register_postparsing_hook()` -4. Call `postparsing_precmd()` - for backwards compatibility with prior releases of cmd2, now deprecated -5. Redirect output, if user asked for it and it's allowed -6. Start timer -7. Call methods registered with `register_precmd_hook()` -8. Call `precmd()` - for backwards compatibility with ``cmd.Cmd`` -9. Add statement to history -10. Call `do_command` method -11. Call methods registered with `register_postcmd_hook()` -12. Call `postcmd(stop, statement)` - for backwards compatibility with ``cmd.Cmd`` -13. Stop timer and display the elapsed time -14. Stop redirecting output if it was redirected -15. Call methods registered with `register_cmdfinalization_hook()` -16. Call `postparsing_postcmd()` - for backwards compatibility - deprecated +#. Parse user input into `Statement` object +#. Call methods registered with `register_postparsing_hook()` +#. Redirect output, if user asked for it and it's allowed +#. Start timer +#. Call methods registered with `register_precmd_hook()` +#. Call `precmd()` - for backwards compatibility with ``cmd.Cmd`` +#. Add statement to history +#. Call `do_command` method +#. Call methods registered with `register_postcmd_hook()` +#. Call `postcmd(stop, statement)` - for backwards compatibility with ``cmd.Cmd`` +#. Stop timer and display the elapsed time +#. Stop redirecting output if it was redirected +#. Call methods registered with `register_cmdfinalization_hook()` Running in this fashion enables the ability to integrate with an external event loop. However, how to integrate with any specific event loop is beyond the diff --git a/docs/settingchanges.rst b/docs/settingchanges.rst index 02955273..25a671ab 100644 --- a/docs/settingchanges.rst +++ b/docs/settingchanges.rst @@ -50,17 +50,54 @@ To define more shortcuts, update the dict ``App.shortcuts`` with the Aliases ======= -In addition to shortcuts, ``cmd2`` provides a full alias feature via the ``alias`` command which is similar to the -``alias`` command in Bash. +In addition to shortcuts, ``cmd2`` provides a full alias feature via the ``alias`` command. Aliases work in a similar +fashion to aliases in the Bash shell. -The syntax to create an alias is ``alias <name> <value>``. ``value`` can contain spaces and does not need -to be quoted. Ex: ``alias ls !ls -lF`` +The syntax to create an alias is: ``alias create name command [args]``. -If ``alias`` is run without arguments, then a list of all aliases will be printed to stdout and are in the proper -``alias`` command syntax, meaning they can easily be reused. + Ex: ``alias create ls !ls -lF`` -The ``unalias`` is used to clear aliases. Using the ``-a`` flag will clear all aliases. Otherwise provide a list of -aliases to clear. Ex: ``unalias ls cd pwd`` will clear the aliases called ls, cd, and pwd. +For more details run: ``help alias create`` + +Use ``alias list`` to see all or some of your aliases. The output of this command displays your aliases using the same command that +was used to create them. Therefore you can place this output in a ``cmd2`` startup script to recreate your aliases each time +you start the application + + Ex: ``alias list`` + +For more details run: ``help alias list`` + +Use ``alias delete`` to remove aliases + +For more details run: ``help alias delete`` + +Macros +====== + +``cmd2`` provides a feature that is similar to aliases called macros. The major difference between macros and aliases +is that macros are intended to take arguments when called. These can be useful if you need to run a complex command +frequently with different arguments that appear in various parts of the command. + +Arguments are expressed when creating a macro using {#} notation where {1} means the first argument. + +The following creates a macro called my_macro that expects two arguments: + + macro create my_macro make_dinner -meat {1} -veggie {2} + +When the macro is called, the provided arguments are resolved and the assembled +command is run. For example: + + my_macro beef broccoli ---> make_dinner -meat beef -veggie broccoli + +For more details run: ``help macro create`` + +The macro command has ``list`` and ``delete`` subcommands that function identically to the alias subcommands of the +same name. Like aliases, macros can be created via a ``cmd2`` startup script to preserve them across application +sessions. + +For more details on listing macros run: ``help macro list`` + +For more details on deleting macros run: ``help macro delete`` Default to shell @@ -137,7 +174,7 @@ comments, is viewable from within a running application with:: (Cmd) set --long - colors: True # Colorized output (*nix only) + colors: Terminal # Allow colorized output continuation_prompt: > # On 2nd+ line of input debug: False # Show full error stack on error echo: False # Echo command issued into output @@ -150,5 +187,5 @@ with:: Any of these user-settable parameters can be set while running your app with the ``set`` command like so:: - set colors False + set colors Never diff --git a/docs/unfreefeatures.rst b/docs/unfreefeatures.rst index cd27745d..364addc6 100644 --- a/docs/unfreefeatures.rst +++ b/docs/unfreefeatures.rst @@ -139,23 +139,43 @@ instead. These methods have these advantages: .. automethod:: cmd2.cmd2.Cmd.ppaged -color -===== +Colored Output +============== -Text output can be colored by wrapping it in the ``colorize`` method. +The output methods in the previous section all honor the ``colors`` setting, +which has three possible values: + +Never + poutput() and pfeedback() strip all ANSI escape sequences + which instruct the terminal to colorize output + +Terminal + (the default value) poutput() and pfeedback() do not strip any ANSI escape + sequences when the output is a terminal, but if the output is a pipe or a + file the escape sequences are stripped. If you want colorized output you + must add ANSI escape sequences, preferably using some python color library + like `plumbum.colors`, `colorama`, `blessings`, or `termcolor`. + +Always + poutput() and pfeedback() never strip ANSI escape sequences, regardless of + the output destination + + +The previously recommended ``colorize`` method is now deprecated. -.. automethod:: cmd2.cmd2.Cmd.colorize .. _quiet: +Suppressing non-essential output +================================ -quiet -===== +The ``quiet`` setting controls whether ``self.pfeedback()`` actually produces +any output. If ``quiet`` is ``False``, then the output will be produced. If +``quiet`` is ``True``, no output will be produced. -Controls whether ``self.pfeedback('message')`` output is suppressed; -useful for non-essential feedback that the user may not always want -to read. ``quiet`` is only relevant if -``app.pfeedback`` is sometimes used. +This makes ``self.pfeedback()`` useful for non-essential output like status +messages. Users can control whether they would like to see these messages by changing +the value of the ``quiet`` setting. select @@ -189,3 +209,33 @@ Exit code to shell The ``self.exit_code`` attribute of your ``cmd2`` application controls what exit code is sent to the shell when your application exits from ``cmdloop()``. + + +Asynchronous Feedback +===================== +``cmd2`` provides two functions to provide asynchronous feedback to the user without interfering with +the command line. This means the feedback is provided to the user when they are still entering text at +the prompt. To use this functionality, the application must be running in a terminal that supports +VT100 control characters and readline. Linux, Mac, and Windows 10 and greater all support these. + +async_alert() + Used to display an important message to the user while they are at the prompt in between commands. + To the user it appears as if an alert message is printed above the prompt and their current input + text and cursor location is left alone. + +async_update_prompt() + Updates the prompt while the user is still typing at it. This is good for alerting the user to system + changes dynamically in between commands. For instance you could alter the color of the prompt to indicate + a system status or increase a counter to report an event. + +``cmd2`` also provides a function to change the title of the terminal window. This feature requires the +application be running in a terminal that supports VT100 control characters. Linux, Mac, and Windows 10 and +greater all support these. + +set_window_title() + Sets the terminal window title + + +The easiest way to understand these functions is to see the AsyncPrinting_ example for a demonstration. + +.. _AsyncPrinting: https://github.com/python-cmd2/cmd2/blob/master/examples/async_printing.py diff --git a/examples/.cmd2rc b/examples/.cmd2rc index 4ffedab9..cedcbe20 100644 --- a/examples/.cmd2rc +++ b/examples/.cmd2rc @@ -1,2 +1,2 @@ -alias ls !ls -hal -alias pwd !pwd +alias create ls !ls -hal +alias create pwd !pwd diff --git a/examples/alias_startup.py b/examples/alias_startup.py index 4ae91661..8a289e79 100755 --- a/examples/alias_startup.py +++ b/examples/alias_startup.py @@ -14,6 +14,10 @@ class AliasAndStartup(cmd2.Cmd): alias_script = os.path.join(os.path.dirname(__file__), '.cmd2rc') super().__init__(startup_script=alias_script) + def do_nothing(self, args): + """This command does nothing and produces no output.""" + pass + if __name__ == '__main__': app = AliasAndStartup() diff --git a/examples/arg_print.py b/examples/arg_print.py index 4f0ca709..f2168126 100755 --- a/examples/arg_print.py +++ b/examples/arg_print.py @@ -2,13 +2,12 @@ # coding=utf-8 """A simple example demonstrating the following: 1) How arguments and options get parsed and passed to commands - 2) How to change what syntax get parsed as a comment and stripped from - the arguments + 2) How to change what syntax gets parsed as a comment and stripped from the arguments This is intended to serve as a live demonstration so that developers can experiment with and understand how command and argument parsing work. -It also serves as an example of how to create command aliases (shortcuts). +It also serves as an example of how to create shortcuts. """ import argparse @@ -18,7 +17,7 @@ class ArgumentAndOptionPrinter(cmd2.Cmd): """ Example cmd2 application where we create commands that just print the arguments they are called with.""" def __init__(self): - # Create command aliases which are shorter + # Create command shortcuts which are typically 1 character abbreviations which can be used in place of a command self.shortcuts.update({'$': 'aprint', '%': 'oprint'}) # Make sure to call this super class __init__ *after* setting and/or updating shortcuts diff --git a/examples/async_printing.py b/examples/async_printing.py new file mode 100755 index 00000000..a4165ae8 --- /dev/null +++ b/examples/async_printing.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python +# coding=utf-8 +""" +A simple example demonstrating an application that asynchronously prints alerts, updates the prompt +and changes the window title +""" + +import random +import threading +import time +from typing import List + +from colorama import Fore + +import cmd2 + +ALERTS = ["Watch as this application prints alerts and updates the prompt", + "This will only happen when the prompt is present", + "Notice how it doesn't interfere with your typing or cursor location", + "Go ahead and type some stuff and move the cursor throughout the line", + "Keep typing...", + "Move that cursor...", + "Pretty seamless, eh?", + "Feedback can also be given in the window title. Notice the arg count up there?", + "You can stop and start the alerts by typing stop_alerts and start_alerts", + "This demo will now continue to print alerts at random intervals" + ] + + +class AlerterApp(cmd2.Cmd): + """ An app that shows off async_alert() and async_update_prompt() """ + + def __init__(self, *args, **kwargs) -> None: + """ Initializer """ + + super().__init__(*args, **kwargs) + + self.prompt = "(APR)> " + + # The thread that will asynchronously alert the user of events + self._stop_thread = False + self._alerter_thread = threading.Thread() + self._alert_count = 0 + self._next_alert_time = 0 + + # Create some hooks to handle the starting and stopping of our thread + self.register_preloop_hook(self._preloop_hook) + self.register_postloop_hook(self._postloop_hook) + + def _preloop_hook(self) -> None: + """ Start the alerter thread """ + # This runs after cmdloop() acquires self.terminal_lock, which will be locked until the prompt appears. + # Therefore this is the best place to start the alerter thread since there is no risk of it alerting + # before the prompt is displayed. You can also start it via a command if its not something that should + # be running during the entire application. See do_start_alerts(). + self._stop_thread = False + + self._alerter_thread = threading.Thread(name='alerter', target=self._alerter_thread_func) + self._alerter_thread.start() + + def _postloop_hook(self) -> None: + """ Stops the alerter thread """ + + # After this function returns, cmdloop() releases self.terminal_lock which could make the alerter + # thread think the prompt is on screen. Therefore this is the best place to stop the alerter thread. + # You can also stop it via a command. See do_stop_alerts(). + self._stop_thread = True + if self._alerter_thread.is_alive(): + self._alerter_thread.join() + + def do_start_alerts(self, _): + """ Starts the alerter thread """ + if self._alerter_thread.is_alive(): + print("The alert thread is already started") + else: + self._stop_thread = False + self._alerter_thread = threading.Thread(name='alerter', target=self._alerter_thread_func) + self._alerter_thread.start() + + def do_stop_alerts(self, _): + """ Stops the alerter thread """ + self._stop_thread = True + if self._alerter_thread.is_alive(): + self._alerter_thread.join() + else: + print("The alert thread is already stopped") + + def _get_alerts(self) -> List[str]: + """ + Reports alerts + :return: the list of alerts + """ + global ALERTS + + cur_time = time.monotonic() + if cur_time < self._next_alert_time: + return [] + + alerts = [] + + if self._alert_count < len(ALERTS): + alerts.append(ALERTS[self._alert_count]) + self._alert_count += 1 + self._next_alert_time = cur_time + 4 + + else: + rand_num = random.randint(1, 20) + if rand_num > 2: + return [] + + for i in range(0, rand_num): + self._alert_count += 1 + alerts.append("Alert {}".format(self._alert_count)) + + self._next_alert_time = 0 + + return alerts + + def _generate_alert_str(self) -> str: + """ + Combines alerts into one string that can be printed to the terminal + :return: the alert string + """ + global ALERTS + + alert_str = '' + alerts = self._get_alerts() + + longest_alert = max(ALERTS, key=len) + num_astericks = len(longest_alert) + 8 + + for i, cur_alert in enumerate(alerts): + # Use padding to center the alert + padding = ' ' * int((num_astericks - len(cur_alert)) / 2) + + if i > 0: + alert_str += '\n' + alert_str += '*' * num_astericks + '\n' + alert_str += padding + cur_alert + padding + '\n' + alert_str += '*' * num_astericks + '\n' + + return alert_str + + def _generate_colored_prompt(self) -> str: + """ + Randomly generates a colored the prompt + :return: the new prompt + """ + rand_num = random.randint(1, 20) + + status_color = Fore.RESET + + if rand_num == 1: + status_color = Fore.LIGHTRED_EX + elif rand_num == 2: + status_color = Fore.LIGHTYELLOW_EX + elif rand_num == 3: + status_color = Fore.CYAN + elif rand_num == 4: + status_color = Fore.LIGHTGREEN_EX + elif rand_num == 5: + status_color = Fore.LIGHTBLUE_EX + + return status_color + self.visible_prompt + Fore.RESET + + def _alerter_thread_func(self) -> None: + """ Prints alerts and updates the prompt any time the prompt is showing """ + + self._alert_count = 0 + self._next_alert_time = 0 + + while not self._stop_thread: + # Always acquire terminal_lock before printing alerts or updating the prompt + # To keep the app responsive, do not block on this call + if self.terminal_lock.acquire(blocking=False): + + # Get any alerts that need to be printed + alert_str = self._generate_alert_str() + + # Generate a new prompt + new_prompt = self._generate_colored_prompt() + + # Check if we have alerts to print + if alert_str: + # new_prompt is an optional parameter to async_alert() + self.async_alert(alert_str, new_prompt) + new_title = "Alerts Printed: {}".format(self._alert_count) + self.set_window_title(new_title) + + # No alerts needed to be printed, check if the prompt changed + elif new_prompt != self.prompt: + self.async_update_prompt(new_prompt) + + # Don't forget to release the lock + self.terminal_lock.release() + + time.sleep(0.5) + + +if __name__ == '__main__': + app = AlerterApp() + app.set_window_title("Asynchronous Printer Test") + app.cmdloop() diff --git a/examples/colors.py b/examples/colors.py new file mode 100755 index 00000000..8765aee0 --- /dev/null +++ b/examples/colors.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python +# coding=utf-8 +""" +A sample application for cmd2. Demonstrating colorized output. + +Experiment with the command line options on the `speak` command to see how +different output colors ca + +The colors setting has three possible values: + +Never + poutput() and pfeedback() strip all ANSI escape sequences + which instruct the terminal to colorize output + +Terminal + (the default value) poutput() and pfeedback() do not strip any ANSI escape + sequences when the output is a terminal, but if the output is a pipe or a + file the escape sequences are stripped. If you want colorized output you + must add ANSI escape sequences, preferably using some python color library + like `plumbum.colors`, `colorama`, `blessings`, or `termcolor`. + +Always + poutput() and pfeedback() never strip ANSI escape sequences, regardless of + the output destination +""" + +import random +import argparse + +import cmd2 +from colorama import Fore, Back + +FG_COLORS = { + 'black': Fore.BLACK, + 'red': Fore.RED, + 'green': Fore.GREEN, + 'yellow': Fore.YELLOW, + 'blue': Fore.BLUE, + 'magenta': Fore.MAGENTA, + 'cyan': Fore.CYAN, + 'white': Fore.WHITE, +} +BG_COLORS = { + 'black': Back.BLACK, + 'red': Back.RED, + 'green': Back.GREEN, + 'yellow': Back.YELLOW, + 'blue': Back.BLUE, + 'magenta': Back.MAGENTA, + 'cyan': Back.CYAN, + 'white': Back.WHITE, +} + + +class CmdLineApp(cmd2.Cmd): + """Example cmd2 application demonstrating colorized output.""" + + # Setting this true makes it run a shell command if a cmd2/cmd command doesn't exist + # default_to_shell = True + MUMBLES = ['like', '...', 'um', 'er', 'hmmm', 'ahh'] + MUMBLE_FIRST = ['so', 'like', 'well'] + MUMBLE_LAST = ['right?'] + + def __init__(self): + self.multiline_commands = ['orate'] + self.maxrepeats = 3 + + # Add stuff to settable and shortcuts before calling base class initializer + self.settable['maxrepeats'] = 'max repetitions for speak command' + self.shortcuts.update({'&': 'speak'}) + + # Set use_ipython to True to enable the "ipy" command which embeds and interactive IPython shell + super().__init__(use_ipython=True) + + speak_parser = argparse.ArgumentParser() + speak_parser.add_argument('-p', '--piglatin', action='store_true', help='atinLay') + speak_parser.add_argument('-s', '--shout', action='store_true', help='N00B EMULATION MODE') + speak_parser.add_argument('-r', '--repeat', type=int, help='output [n] times') + speak_parser.add_argument('-f', '--fg', choices=FG_COLORS, help='foreground color to apply to output') + speak_parser.add_argument('-b', '--bg', choices=BG_COLORS, help='background color to apply to output') + speak_parser.add_argument('words', nargs='+', help='words to say') + + @cmd2.with_argparser(speak_parser) + def do_speak(self, args): + """Repeats what you tell me to.""" + words = [] + for word in args.words: + if args.piglatin: + word = '%s%say' % (word[1:], word[0]) + if args.shout: + word = word.upper() + words.append(word) + + repetitions = args.repeat or 1 + + color_on = '' + if args.fg: + color_on += FG_COLORS[args.fg] + if args.bg: + color_on += BG_COLORS[args.bg] + color_off = Fore.RESET + Back.RESET + + for i in range(min(repetitions, self.maxrepeats)): + # .poutput handles newlines, and accommodates output redirection too + self.poutput(color_on + ' '.join(words) + color_off) + + do_say = do_speak # now "say" is a synonym for "speak" + do_orate = do_speak # another synonym, but this one takes multi-line input + + mumble_parser = argparse.ArgumentParser() + mumble_parser.add_argument('-r', '--repeat', type=int, help='how many times to repeat') + mumble_parser.add_argument('-f', '--fg', help='foreground color to apply to output') + mumble_parser.add_argument('-b', '--bg', help='background color to apply to output') + mumble_parser.add_argument('words', nargs='+', help='words to say') + + @cmd2.with_argparser(mumble_parser) + def do_mumble(self, args): + """Mumbles what you tell me to.""" + color_on = '' + if args.fg and args.fg in FG_COLORS: + color_on += FG_COLORS[args.fg] + if args.bg and args.bg in BG_COLORS: + color_on += BG_COLORS[args.bg] + color_off = Fore.RESET + Back.RESET + + repetitions = args.repeat or 1 + for i in range(min(repetitions, self.maxrepeats)): + output = [] + if random.random() < .33: + output.append(random.choice(self.MUMBLE_FIRST)) + for word in args.words: + if random.random() < .40: + output.append(random.choice(self.MUMBLES)) + output.append(word) + if random.random() < .25: + output.append(random.choice(self.MUMBLE_LAST)) + self.poutput(color_on + ' '.join(output) + color_off) + + +if __name__ == '__main__': + c = CmdLineApp() + c.cmdloop() diff --git a/examples/hooks.py b/examples/hooks.py index 3d11457a..b6f6263e 100644..100755 --- a/examples/hooks.py +++ b/examples/hooks.py @@ -84,12 +84,12 @@ class CmdLineApp(cmd2.Cmd): def abbrev_hook(self, data: cmd2.plugin.PostparsingData) -> cmd2.plugin.PostparsingData: """Accept unique abbreviated commands""" - target = 'do_' + data.statement.command - if target not in dir(self): + func = self.cmd_func(data.statement.command) + if func is None: # check if the entered command might be an abbreviation - funcs = [func for func in self.keywords if func.startswith(data.statement.command)] - if len(funcs) == 1: - raw = data.statement.raw.replace(data.statement.command, funcs[0], 1) + possible_cmds = [cmd for cmd in self.keywords if cmd.startswith(data.statement.command)] + if len(possible_cmds) == 1: + raw = data.statement.raw.replace(data.statement.command, possible_cmds[0], 1) data.statement = self.statement_parser.parse(raw) return data diff --git a/examples/pirate.py b/examples/pirate.py index 34906a9f..22274dbf 100755 --- a/examples/pirate.py +++ b/examples/pirate.py @@ -8,8 +8,21 @@ It demonstrates many features of cmd2. """ import argparse +from colorama import Fore + import cmd2 +COLORS = { + 'black': Fore.BLACK, + 'red': Fore.RED, + 'green': Fore.GREEN, + 'yellow': Fore.YELLOW, + 'blue': Fore.BLUE, + 'magenta': Fore.MAGENTA, + 'cyan': Fore.CYAN, + 'white': Fore.WHITE, +} + class Pirate(cmd2.Cmd): """A piratical example cmd2 application involving looting and drinking.""" @@ -17,10 +30,10 @@ class Pirate(cmd2.Cmd): self.default_to_shell = True self.multiline_commands = ['sing'] self.terminators = self.terminators + ['...'] - self.songcolor = 'blue' + self.songcolor = Fore.BLUE # Add stuff to settable and/or shortcuts before calling base class initializer - self.settable['songcolor'] = 'Color to ``sing`` in (red/blue/green/cyan/magenta, bold, underline)' + self.settable['songcolor'] = 'Color to ``sing`` in (black/red/green/yellow/blue/magenta/cyan/white)' self.shortcuts.update({'~': 'sing'}) """Initialize the base class as well as this one""" @@ -68,7 +81,8 @@ class Pirate(cmd2.Cmd): def do_sing(self, arg): """Sing a colorful song.""" - self.poutput(self.colorize(arg, self.songcolor)) + color_escape = COLORS.get(self.songcolor, default=Fore.RESET) + self.poutput(arg, color=color_escape) yo_parser = argparse.ArgumentParser() yo_parser.add_argument('--ho', type=int, default=2, help="How often to chant 'ho'") diff --git a/examples/plumbum_colors.py b/examples/plumbum_colors.py new file mode 100755 index 00000000..942eaf80 --- /dev/null +++ b/examples/plumbum_colors.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python +# coding=utf-8 +""" +A sample application for cmd2. Demonstrating colorized output using the plumbum package. + +Experiment with the command line options on the `speak` command to see how +different output colors ca + +The colors setting has three possible values: + +Never + poutput() and pfeedback() strip all ANSI escape sequences + which instruct the terminal to colorize output + +Terminal + (the default value) poutput() and pfeedback() do not strip any ANSI escape + sequences when the output is a terminal, but if the output is a pipe or a + file the escape sequences are stripped. If you want colorized output you + must add ANSI escape sequences, preferably using some python color library + like `plumbum.colors`, `colorama`, `blessings`, or `termcolor`. + +Always + poutput() and pfeedback() never strip ANSI escape sequences, regardless of + the output destination + +WARNING: This example requires the plumbum package, which isn't normally required by cmd2. +""" + +import random +import argparse + +import cmd2 +from plumbum.colors import fg, bg, reset + +FG_COLORS = { + 'black': fg.Black, + 'red': fg.DarkRedA, + 'green': fg.MediumSpringGreen, + 'yellow': fg.LightYellow, + 'blue': fg.RoyalBlue1, + 'magenta': fg.Purple, + 'cyan': fg.SkyBlue1, + 'white': fg.White, +} +BG_COLORS = { + 'black': bg.BLACK, + 'red': bg.DarkRedA, + 'green': bg.MediumSpringGreen, + 'yellow': bg.LightYellow, + 'blue': bg.RoyalBlue1, + 'magenta': bg.Purple, + 'cyan': bg.SkyBlue1, + 'white': bg.White, +} + + +class CmdLineApp(cmd2.Cmd): + """Example cmd2 application demonstrating colorized output.""" + + # Setting this true makes it run a shell command if a cmd2/cmd command doesn't exist + # default_to_shell = True + MUMBLES = ['like', '...', 'um', 'er', 'hmmm', 'ahh'] + MUMBLE_FIRST = ['so', 'like', 'well'] + MUMBLE_LAST = ['right?'] + + def __init__(self): + self.multiline_commands = ['orate'] + self.maxrepeats = 3 + + # Add stuff to settable and shortcuts before calling base class initializer + self.settable['maxrepeats'] = 'max repetitions for speak command' + self.shortcuts.update({'&': 'speak'}) + + # Set use_ipython to True to enable the "ipy" command which embeds and interactive IPython shell + super().__init__(use_ipython=True) + + speak_parser = argparse.ArgumentParser() + speak_parser.add_argument('-p', '--piglatin', action='store_true', help='atinLay') + speak_parser.add_argument('-s', '--shout', action='store_true', help='N00B EMULATION MODE') + speak_parser.add_argument('-r', '--repeat', type=int, help='output [n] times') + speak_parser.add_argument('-f', '--fg', choices=FG_COLORS, help='foreground color to apply to output') + speak_parser.add_argument('-b', '--bg', choices=BG_COLORS, help='background color to apply to output') + speak_parser.add_argument('words', nargs='+', help='words to say') + + @cmd2.with_argparser(speak_parser) + def do_speak(self, args): + """Repeats what you tell me to.""" + words = [] + for word in args.words: + if args.piglatin: + word = '%s%say' % (word[1:], word[0]) + if args.shout: + word = word.upper() + words.append(word) + + repetitions = args.repeat or 1 + + color_on = '' + if args.fg: + color_on += FG_COLORS[args.fg] + if args.bg: + color_on += BG_COLORS[args.bg] + color_off = reset + + for i in range(min(repetitions, self.maxrepeats)): + # .poutput handles newlines, and accommodates output redirection too + self.poutput(color_on + ' '.join(words) + color_off) + + do_say = do_speak # now "say" is a synonym for "speak" + do_orate = do_speak # another synonym, but this one takes multi-line input + + mumble_parser = argparse.ArgumentParser() + mumble_parser.add_argument('-r', '--repeat', type=int, help='how many times to repeat') + mumble_parser.add_argument('-f', '--fg', help='foreground color to apply to output') + mumble_parser.add_argument('-b', '--bg', help='background color to apply to output') + mumble_parser.add_argument('words', nargs='+', help='words to say') + + @cmd2.with_argparser(mumble_parser) + def do_mumble(self, args): + """Mumbles what you tell me to.""" + color_on = '' + if args.fg and args.fg in FG_COLORS: + color_on += FG_COLORS[args.fg] + if args.bg and args.bg in BG_COLORS: + color_on += BG_COLORS[args.bg] + color_off = Fore.RESET + Back.RESET + + repetitions = args.repeat or 1 + for i in range(min(repetitions, self.maxrepeats)): + output = [] + if random.random() < .33: + output.append(random.choice(self.MUMBLE_FIRST)) + for word in args.words: + if random.random() < .40: + output.append(random.choice(self.MUMBLES)) + output.append(word) + if random.random() < .25: + output.append(random.choice(self.MUMBLE_LAST)) + self.poutput(color_on + ' '.join(output) + color_off) + + +if __name__ == '__main__': + c = CmdLineApp() + c.cmdloop() diff --git a/examples/python_scripting.py b/examples/python_scripting.py index 069bcff5..0b0030a5 100755 --- a/examples/python_scripting.py +++ b/examples/python_scripting.py @@ -17,6 +17,8 @@ This application and the "scripts/conditional.py" script serve as an example for import argparse import os +from colorama import Fore + import cmd2 @@ -27,20 +29,20 @@ class CmdLineApp(cmd2.Cmd): # Enable the optional ipy command if IPython is installed by setting use_ipython=True super().__init__(use_ipython=True) self._set_prompt() - self.intro = 'Happy 𝛑 Day. Note the full Unicode support: 😇 (Python 3 only) 💩' + self.intro = 'Happy 𝛑 Day. Note the full Unicode support: 😇 💩' self.locals_in_py = True def _set_prompt(self): """Set prompt so it displays the current working directory.""" self.cwd = os.getcwd() - self.prompt = self.colorize('{!r} $ '.format(self.cwd), 'cyan') + self.prompt = Fore.CYAN + '{!r} $ '.format(self.cwd) + Fore.RESET - def postcmd(self, stop, line): + def postcmd(self, stop: bool, line: str) -> bool: """Hook method executed just after a command dispatch is finished. - :param stop: bool - if True, the command has indicated the application should exit - :param line: str - the command line text for this command - :return: bool - if this is True, the application will exit after this command and the postloop() will run + :param stop: if True, the command has indicated the application should exit + :param line: the command line text for this command + :return: if this is True, the application will exit after this command and the postloop() will run """ """Override this so prompt always displays cwd.""" self._set_prompt() diff --git a/examples/subcommands.py b/examples/subcommands.py index 9d51fbee..02b06412 100755 --- a/examples/subcommands.py +++ b/examples/subcommands.py @@ -37,6 +37,33 @@ sport_arg = parser_sport.add_argument('sport', help='Enter name of a sport') setattr(sport_arg, 'arg_choices', sport_item_strs) +# create the top-level parser for the alternate command +# The alternate command doesn't provide its own help flag +base2_parser = argparse.ArgumentParser(prog='alternate', add_help=False) +base2_subparsers = base2_parser.add_subparsers(title='subcommands', help='subcommand help') + +# create the parser for the "foo" subcommand +parser_foo2 = base2_subparsers.add_parser('foo', help='foo help') +parser_foo2.add_argument('-x', type=int, default=1, help='integer') +parser_foo2.add_argument('y', type=float, help='float') +parser_foo2.add_argument('input_file', type=str, help='Input File') + +# create the parser for the "bar" subcommand +parser_bar2 = base2_subparsers.add_parser('bar', help='bar help') + +bar2_subparsers = parser_bar2.add_subparsers(title='layer3', help='help for 3rd layer of commands') +parser_bar2.add_argument('z', help='string') + +bar2_subparsers.add_parser('apple', help='apple help') +bar2_subparsers.add_parser('artichoke', help='artichoke help') +bar2_subparsers.add_parser('cranberries', help='cranberries help') + +# create the parser for the "sport" subcommand +parser_sport2 = base2_subparsers.add_parser('sport', help='sport help') +sport2_arg = parser_sport2.add_argument('sport', help='Enter name of a sport') +setattr(sport2_arg, 'arg_choices', sport_item_strs) + + class SubcommandsExample(cmd2.Cmd): """ Example cmd2 application where we a base command which has a couple subcommands @@ -74,6 +101,17 @@ class SubcommandsExample(cmd2.Cmd): # No subcommand was provided, so call help self.do_help('base') + @cmd2.with_argparser(base2_parser) + def do_alternate(self, args): + """Alternate command help""" + func = getattr(args, 'func', None) + if func is not None: + # Call whatever subcommand function was selected + func(self, args) + else: + # No subcommand was provided, so call help + self.do_help('alternate') + if __name__ == '__main__': app = SubcommandsExample() diff --git a/examples/table_display.py b/examples/table_display.py index 75eada85..63447377 100755 --- a/examples/table_display.py +++ b/examples/table_display.py @@ -151,8 +151,8 @@ class TableDisplay(cmd2.Cmd): def ptable(self, rows, columns, grid_args, row_stylist): """Format tabular data for pretty-printing as a fixed-width table and then display it using a pager. - :param rows: required argument - can be a list-of-lists (or another iterable of iterables), a two-dimensional - NumPy array, or an Iterable of non-iterable objects + :param rows: can be a list-of-lists (or another iterable of iterables), a two-dimensional + NumPy array, or an Iterable of non-iterable objects :param columns: column headers and formatting options per column :param grid_args: argparse arguments for formatting the grid :param row_stylist: function to determine how each row gets styled diff --git a/examples/transcripts/exampleSession.txt b/examples/transcripts/exampleSession.txt index 1d1b3b79..38fb0659 100644 --- a/examples/transcripts/exampleSession.txt +++ b/examples/transcripts/exampleSession.txt @@ -3,13 +3,13 @@ # The regex for editor will match whatever program you use. # regexes on prompts just make the trailing space obvious (Cmd) set -colors: /(True|False)/ +colors: /(Terminal|Always|Never)/ continuation_prompt: >/ / debug: False echo: False editor: /.*?/ feedback_to_output: False -locals_in_py: True +locals_in_py: False maxrepeats: 3 prompt: (Cmd)/ / quiet: False diff --git a/examples/transcripts/transcript_regex.txt b/examples/transcripts/transcript_regex.txt index adf4d77e..6980fac6 100644 --- a/examples/transcripts/transcript_regex.txt +++ b/examples/transcripts/transcript_regex.txt @@ -3,13 +3,13 @@ # The regex for editor will match whatever program you use. # regexes on prompts just make the trailing space obvious (Cmd) set -colors: /(True|False)/ +colors: /(Terminal|Always|Never)/ continuation_prompt: >/ / debug: False echo: False editor: /.*?/ feedback_to_output: False -locals_in_py: True +locals_in_py: False maxrepeats: 3 prompt: (Cmd)/ / quiet: False @@ -7,6 +7,6 @@ if __name__ == '__main__': # Set "use_ipython" to True to include the ipy command if IPython is installed, which supports advanced interactive # debugging of your application via introspection on self. - app = cmd2.Cmd(use_ipython=True) + app = cmd2.Cmd(use_ipython=True, persistent_history_file='cmd2_history.txt') app.locals_in_py = True app.cmdloop() @@ -16,7 +16,7 @@ https://cmd2.readthedocs.io/ Main features: - - Searchable command history (`history` command and `<Ctrl>+r`) + - Searchable command history (`history` command and `<Ctrl>+r`) - optionally persistent - Text file scripting of your application with `load` (`@`) and `_relative_load` (`@@`) - Python scripting of your application with ``pyscript`` - Run shell commands with ``!`` @@ -24,8 +24,12 @@ Main features: - Redirect command output to file with `>`, `>>` - Bare `>`, `>>` with no filename send output to paste buffer (clipboard) - `py` enters interactive Python console (opt-in `ipy` for IPython console) + - Option to display long output using a pager with ``cmd2.Cmd.ppaged()`` - Multi-line commands - Special-character command shortcuts (beyond cmd's `?` and `!`) + - Command aliasing similar to bash `alias` command + - Macros, which are similar to aliases, but can take arguments when called + - Ability to load commands at startup from an initialization script - Settable environment parameters - Parsing commands with arguments using `argparse`, including support for sub-commands - Unicode character support @@ -34,6 +38,7 @@ Main features: - Trivial to provide built-in help for all commands - Built-in regression testing framework for your applications (transcript-based testing) - Transcripts for use with built-in regression can be automatically generated from `history -t` + - Alerts that seamlessly print while user enters text at prompt Usable without modification anywhere cmd is used; simply import cmd2.Cmd in place of cmd.Cmd. @@ -61,7 +66,7 @@ Topic :: Software Development :: Libraries :: Python Modules SETUP_REQUIRES = ['setuptools_scm'] -INSTALL_REQUIRES = ['pyperclip >= 1.5.27', 'colorama', 'attrs'] +INSTALL_REQUIRES = ['pyperclip >= 1.5.27', 'colorama', 'attrs >= 16.3.0'] EXTRAS_REQUIRE = { # Windows also requires pyreadline to ensure tab completion works diff --git a/tests/conftest.py b/tests/conftest.py index a23c44d0..da7e8b08 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,11 +6,13 @@ Copyright 2016 Federico Ceratto <federico.ceratto@gmail.com> Released under MIT license, see LICENSE file """ import sys +from typing import Optional +from unittest import mock from pytest import fixture -from unittest import mock import cmd2 +from cmd2.utils import StdSim # Prefer statically linked gnureadline if available (for macOS compatibility due to issues with libedit) try: @@ -27,29 +29,29 @@ except ImportError: # Help text for base cmd2.Cmd application BASE_HELP = """Documented commands (type help <topic>): ======================================== -alias help load pyscript set shortcuts -edit history py quit shell unalias +alias help load py quit shell +edit history macro pyscript set shortcuts """ BASE_HELP_VERBOSE = """ Documented commands (type help <topic>): ================================================================================ -alias Define or display aliases +alias Manage aliases edit Edit a file in a text editor -help List available commands with "help" or detailed help with "help cmd" +help List available commands or provide detailed help for a specific command history View, run, edit, save, or clear previously entered commands -load Runs commands in script file that is encoded as either ASCII or UTF-8 text -py Invoke python command, shell, or script -pyscript Runs a python script file inside the console -quit Exits this application -set Sets a settable parameter or shows current settings of parameters +load Run commands in script file that is encoded as either ASCII or UTF-8 text +macro Manage macros +py Invoke Python command or shell +pyscript Run a Python script file inside the console +quit Exit this application +set Set a settable parameter or show current settings of parameters shell Execute a command as if at the OS prompt -shortcuts Lists shortcuts available -unalias Unsets aliases +shortcuts List available shortcuts """ # Help text for the history command -HELP_HISTORY = """Usage: history [arg] [-h] [-r | -e | -s | -o FILE | -t TRANSCRIPT | -c] +HELP_HISTORY = """Usage: history [-h] [-r | -e | -s | -o FILE | -t TRANSCRIPT | -c] [arg] View, run, edit, save, or clear previously entered commands @@ -64,12 +66,12 @@ optional arguments: -h, --help show this help message and exit -r, --run run selected history items -e, --edit edit and then run selected history items - -s, --script script format; no separation lines + -s, --script output commands in script format -o, --output-file FILE output commands to a script file -t, --transcript TRANSCRIPT output commands and results to a transcript file - -c, --clear clears all history + -c, --clear clear all history """ # Output from the shortcuts command with default built-in shortcuts @@ -80,11 +82,8 @@ SHORTCUTS_TXT = """Shortcuts for other commands: @@: _relative_load """ -expect_colors = True -if sys.platform.startswith('win'): - expect_colors = False # Output from the show command with default settings -SHOW_TXT = """colors: {} +SHOW_TXT = """colors: Terminal continuation_prompt: > debug: False echo: False @@ -94,14 +93,10 @@ locals_in_py: False prompt: (Cmd) quiet: False timing: False -""".format(expect_colors) +""" -if expect_colors: - color_str = 'True ' -else: - color_str = 'False' SHOW_LONG = """ -colors: {} # Colorized output (*nix only) +colors: Terminal # Allow colorized output (valid values: Terminal, Always, Never) continuation_prompt: > # On 2nd+ line of input debug: False # Show full error stack on error echo: False # Echo command issued into output @@ -111,23 +106,7 @@ locals_in_py: False # Allow access to your application in py via self prompt: (Cmd) # The prompt issued to solicit input quiet: False # Don't print nonessential feedback timing: False # Report execution times -""".format(color_str) - - -class StdOut(object): - """ Toy class for replacing self.stdout in cmd2.Cmd instances for unit testing. """ - def __init__(self): - self.buffer = '' - - def write(self, s): - self.buffer += s - - def read(self): - raise NotImplementedError - - def clear(self): - self.buffer = '' - +""" def normalize(block): """ Normalize a block of text to perform comparison. @@ -141,10 +120,10 @@ def normalize(block): def run_cmd(app, cmd): - """ Clear StdOut buffer, run the command, extract the buffer contents, """ + """ Clear StdSim buffer, run the command, extract the buffer contents, """ app.stdout.clear() app.onecmd_plus_hooks(cmd) - out = app.stdout.buffer + out = app.stdout.getvalue() app.stdout.clear() return normalize(out) @@ -152,21 +131,21 @@ def run_cmd(app, cmd): @fixture def base_app(): c = cmd2.Cmd() - c.stdout = StdOut() + c.stdout = StdSim(c.stdout) return c -def complete_tester(text, line, begidx, endidx, app): +def complete_tester(text: str, line: str, begidx: int, endidx: int, app) -> Optional[str]: """ This is a convenience function to test cmd2.complete() since in a unit test environment there is no actual console readline is monitoring. Therefore we use mock to provide readline data to complete(). - :param text: str - the string prefix we are attempting to match - :param line: str - the current input line with leading whitespace removed - :param begidx: int - the beginning index of the prefix text - :param endidx: int - the ending index of the prefix text + :param text: the string prefix we are attempting to match + :param line: the current input line with leading whitespace removed + :param begidx: the beginning index of the prefix text + :param endidx: the ending index of the prefix text :param app: the cmd2 app that will run completions :return: The first matched string or None if there are no matches Matches are stored in app.completion_matches @@ -181,11 +160,8 @@ def complete_tester(text, line, begidx, endidx, app): def get_endidx(): return endidx - first_match = None + # Run the readline tab-completion function with readline mocks in place with mock.patch.object(readline, 'get_line_buffer', get_line): with mock.patch.object(readline, 'get_begidx', get_begidx): with mock.patch.object(readline, 'get_endidx', get_endidx): - # Run the readline tab-completion function with readline mocks in place - first_match = app.complete(text, 0) - - return first_match + return app.complete(text, 0) diff --git a/tests/scripts/postcmds.txt b/tests/scripts/postcmds.txt index 2b478b57..dea8f265 100644 --- a/tests/scripts/postcmds.txt +++ b/tests/scripts/postcmds.txt @@ -1 +1 @@ -set colors off +set colors Never diff --git a/tests/scripts/precmds.txt b/tests/scripts/precmds.txt index d0b27fb6..0ae7eae8 100644 --- a/tests/scripts/precmds.txt +++ b/tests/scripts/precmds.txt @@ -1 +1 @@ -set colors on +set colors Always diff --git a/tests/test_argparse.py b/tests/test_argparse.py index 469cbe76..fdd16bcc 100644 --- a/tests/test_argparse.py +++ b/tests/test_argparse.py @@ -6,9 +6,9 @@ import argparse import pytest import cmd2 -from unittest import mock +from cmd2.utils import StdSim -from .conftest import run_cmd, StdOut +from .conftest import run_cmd # Prefer statically linked gnureadline if available (for macOS compatibility due to issues with libedit) try: @@ -115,7 +115,7 @@ class ArgparseApp(cmd2.Cmd): @pytest.fixture def argparse_app(): app = ArgparseApp() - app.stdout = StdOut() + app.stdout = StdSim(app.stdout) return app @@ -222,7 +222,7 @@ class SubcommandApp(cmd2.Cmd): @pytest.fixture def subcommand_app(): app = SubcommandApp() - app.stdout = StdOut() + app.stdout = StdSim(app.stdout) return app @@ -258,4 +258,3 @@ def test_subcommand_help(subcommand_app): def test_subcommand_invalid_help(subcommand_app): out = run_cmd(subcommand_app, 'help base baz') assert out[0].startswith('usage: base') - assert out[1].startswith("base: error: invalid choice: 'baz'") diff --git a/tests/test_autocompletion.py b/tests/test_autocompletion.py index 8aa26e0e..c6c1d1f6 100644 --- a/tests/test_autocompletion.py +++ b/tests/test_autocompletion.py @@ -1,3 +1,4 @@ +# coding=utf-8 """ Unit/functional testing for argparse completer in cmd2 @@ -5,16 +6,17 @@ Copyright 2018 Eric Lin <anselor@gmail.com> Released under MIT license, see LICENSE file """ import pytest -from .conftest import run_cmd, normalize, StdOut, complete_tester + +from cmd2.utils import StdSim +from .conftest import run_cmd, normalize, complete_tester from examples.tab_autocompletion import TabCompleteExample @pytest.fixture def cmd2_app(): - c = TabCompleteExample() - c.stdout = StdOut() - - return c + app = TabCompleteExample() + app.stdout = StdSim(app.stdout) + return app SUGGEST_HELP = '''Usage: suggest -t {movie, show} [-h] [-d DURATION{1..2}] @@ -32,9 +34,9 @@ optional arguments: single value - maximum duration [a, b] - duration range''' -MEDIA_MOVIES_ADD_HELP = '''Usage: media movies add title {G, PG, PG-13, R, NC-17} [actor [...]] - -d DIRECTOR{1..2} +MEDIA_MOVIES_ADD_HELP = '''Usage: media movies add -d DIRECTOR{1..2} [-h] + title {G, PG, PG-13, R, NC-17} [actor [...]] positional arguments: title Movie Title diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index fdf0f661..3ce7a11d 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -13,6 +13,7 @@ import os import sys import tempfile +from colorama import Fore, Back, Style import pytest # Python 3.5 had some regressions in the unitest.mock module, so use 3rd party mock if available @@ -25,7 +26,7 @@ import cmd2 from cmd2 import clipboard from cmd2 import utils from .conftest import run_cmd, normalize, BASE_HELP, BASE_HELP_VERBOSE, \ - HELP_HISTORY, SHORTCUTS_TXT, SHOW_TXT, SHOW_LONG, StdOut + HELP_HISTORY, SHORTCUTS_TXT, SHOW_TXT, SHOW_LONG def test_version(base_app): @@ -46,6 +47,11 @@ def test_base_help_verbose(base_app): expected = normalize(BASE_HELP_VERBOSE) assert out == expected + # Make sure :param type lines are filtered out of help summary + help_doc = base_app.do_help.__func__.__doc__ + help_doc += "\n:param fake param" + base_app.do_help.__func__.__doc__ = help_doc + out = run_cmd(base_app, 'help --verbose') assert out == expected @@ -64,7 +70,7 @@ def test_base_argparse_help(base_app, capsys): assert out1 == out2 assert out1[0].startswith('Usage: set') assert out1[1] == '' - assert out1[2].startswith('Sets a settable parameter') + assert out1[2].startswith('Set a settable parameter') def test_base_invalid_option(base_app, capsys): run_cmd(base_app, 'set -z') @@ -72,7 +78,7 @@ def test_base_invalid_option(base_app, capsys): out = normalize(out) err = normalize(err) assert 'Error: unrecognized arguments: -z' in err[0] - assert out[0] == 'Usage: set settable{0..2} [-h] [-a] [-l]' + assert out[0] == 'Usage: set [-h] [-a] [-l] [param] [value]' def test_base_shortcuts(base_app): out = run_cmd(base_app, 'shortcuts') @@ -178,6 +184,30 @@ now: True assert out == ['quiet: True'] +class OnChangeHookApp(cmd2.Cmd): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def _onchange_quiet(self, old, new) -> None: + """Runs when quiet is changed via set command""" + self.poutput("You changed quiet") + +@pytest.fixture +def onchange_app(): + app = OnChangeHookApp() + app.stdout = utils.StdSim(app.stdout) + return app + +def test_set_onchange_hook(onchange_app): + out = run_cmd(onchange_app, 'set quiet True') + expected = normalize(""" +quiet - was: False +now: True +You changed quiet +""") + assert out == expected + + def test_base_shell(base_app, monkeypatch): m = mock.Mock() monkeypatch.setattr("{}.Popen".format('subprocess'), m) @@ -215,13 +245,13 @@ def test_base_run_pyscript(base_app, capsys, request): out, err = capsys.readouterr() assert out == expected -def test_recursive_pyscript_not_allowed(base_app, capsys, request): +def test_recursive_pyscript_not_allowed(base_app, request): test_dir = os.path.dirname(request.module.__file__) python_script = os.path.join(test_dir, 'scripts', 'recursive.py') - expected = 'ERROR: Recursively entering interactive Python consoles is not allowed.\n' + expected = 'Recursively entering interactive Python consoles is not allowed.' run_cmd(base_app, "pyscript {}".format(python_script)) - out, err = capsys.readouterr() + err = base_app._last_result.stderr assert err == expected def test_pyscript_with_nonexist_file(base_app, capsys): @@ -241,7 +271,7 @@ def test_pyscript_with_exception(base_app, capsys, request): def test_pyscript_requires_an_argument(base_app, capsys): run_cmd(base_app, "pyscript") out, err = capsys.readouterr() - assert err.startswith('ERROR: pyscript command requires at least 1 argument ...') + assert "the following arguments are required: script_path" in err def test_base_error(base_app): @@ -471,7 +501,7 @@ def test_load_with_empty_args(base_app, capsys): out, err = capsys.readouterr() # The load command requires a file path argument, so we should get an error message - assert "load command requires a file path" in str(err) + assert "the following arguments are required" in str(err) assert base_app.cmdqueue == [] @@ -559,11 +589,11 @@ def test_load_nested_loads(base_app, request): expected = """ %s _relative_load precmds.txt -set colors on +set colors Always help shortcuts _relative_load postcmds.txt -set colors off""" % initial_load +set colors Never""" % initial_load assert run_cmd(base_app, 'history -s') == normalize(expected) @@ -581,11 +611,11 @@ def test_base_runcmds_plus_hooks(base_app, request): 'load ' + postfilepath]) expected = """ load %s -set colors on +set colors Always help shortcuts load %s -set colors off""" % (prefilepath, postfilepath) +set colors Never""" % (prefilepath, postfilepath) assert run_cmd(base_app, 'history -s') == normalize(expected) @@ -608,8 +638,7 @@ def test_base_relative_load(base_app, request): def test_relative_load_requires_an_argument(base_app, capsys): run_cmd(base_app, '_relative_load') out, err = capsys.readouterr() - assert out == '' - assert err.startswith('ERROR: _relative_load command requires a file path:\n') + assert 'Error: the following arguments' in err assert base_app.cmdqueue == [] @@ -812,12 +841,7 @@ def test_base_colorize(base_app): # But if we create a fresh Cmd() instance, it will fresh_app = cmd2.Cmd() color_test = fresh_app.colorize('Test', 'red') - # Actually, colorization only ANSI escape codes is only applied on non-Windows systems - if sys.platform == 'win32': - assert color_test == 'Test' - else: - assert color_test == '\x1b[31mTest\x1b[39m' - + assert color_test == '\x1b[31mTest\x1b[39m' def _expected_no_editor_error(): expected_exception = 'OSError' @@ -857,7 +881,8 @@ def test_edit_file(base_app, request, monkeypatch): run_cmd(base_app, 'edit {}'.format(filename)) # We think we have an editor, so should expect a system call - m.assert_called_once_with('"{}" "{}"'.format(base_app.editor, filename)) + m.assert_called_once_with('{} {}'.format(utils.quote_string_if_needed(base_app.editor), + utils.quote_string_if_needed(filename))) def test_edit_file_with_spaces(base_app, request, monkeypatch): # Set a fake editor just to make sure we have one. We aren't really going to call it due to the mock @@ -873,7 +898,8 @@ def test_edit_file_with_spaces(base_app, request, monkeypatch): run_cmd(base_app, 'edit "{}"'.format(filename)) # We think we have an editor, so should expect a system call - m.assert_called_once_with('"{}" "{}"'.format(base_app.editor, filename)) + m.assert_called_once_with('{} {}'.format(utils.quote_string_if_needed(base_app.editor), + utils.quote_string_if_needed(filename))) def test_edit_blank(base_app, monkeypatch): # Set a fake editor just to make sure we have one. We aren't really going to call it due to the mock @@ -930,7 +956,7 @@ def test_base_cmdloop_with_queue(): app.use_rawinput = True intro = 'Hello World, this is an intro ...' app.cmdqueue.append('quit\n') - app.stdout = StdOut() + app.stdout = utils.StdSim(app.stdout) # Need to patch sys.argv so cmd2 doesn't think it was called with arguments equal to the py.test args testargs = ["prog"] @@ -938,7 +964,7 @@ def test_base_cmdloop_with_queue(): with mock.patch.object(sys, 'argv', testargs): # Run the command loop with custom intro app.cmdloop(intro=intro) - out = app.stdout.buffer + out = app.stdout.getvalue() assert out == expected @@ -947,7 +973,7 @@ def test_base_cmdloop_without_queue(): app = cmd2.Cmd() app.use_rawinput = True app.intro = 'Hello World, this is an intro ...' - app.stdout = StdOut() + app.stdout = utils.StdSim(app.stdout) # Mock out the input call so we don't actually wait for a user's response on stdin m = mock.MagicMock(name='input', return_value='quit') @@ -959,7 +985,7 @@ def test_base_cmdloop_without_queue(): with mock.patch.object(sys, 'argv', testargs): # Run the command loop app.cmdloop() - out = app.stdout.buffer + out = app.stdout.getvalue() assert out == expected @@ -969,7 +995,7 @@ def test_cmdloop_without_rawinput(): app.use_rawinput = False app.echo = False app.intro = 'Hello World, this is an intro ...' - app.stdout = StdOut() + app.stdout = utils.StdSim(app.stdout) # Mock out the input call so we don't actually wait for a user's response on stdin m = mock.MagicMock(name='input', return_value='quit') @@ -981,22 +1007,25 @@ def test_cmdloop_without_rawinput(): with mock.patch.object(sys, 'argv', testargs): # Run the command loop app.cmdloop() - out = app.stdout.buffer + out = app.stdout.getvalue() assert out == expected class HookFailureApp(cmd2.Cmd): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + # register a postparsing hook method + self.register_postparsing_hook(self.postparsing_precmd) - def postparsing_precmd(self, statement): + def postparsing_precmd(self, data: cmd2.plugin.PostparsingData) -> cmd2.plugin.PostparsingData: """Simulate precmd hook failure.""" - return True, statement + data.stop = True + return data @pytest.fixture def hook_failure(): app = HookFailureApp() - app.stdout = StdOut() + app.stdout = utils.StdSim(app.stdout) return app def test_precmd_hook_success(base_app): @@ -1020,7 +1049,7 @@ class SayApp(cmd2.Cmd): def say_app(): app = SayApp() app.allow_cli_args = False - app.stdout = StdOut() + app.stdout = utils.StdSim(app.stdout) return app def test_interrupt_quit(say_app): @@ -1034,7 +1063,7 @@ def test_interrupt_quit(say_app): say_app.cmdloop() # And verify the expected output to stdout - out = say_app.stdout.buffer + out = say_app.stdout.getvalue() assert out == 'hello\n' def test_interrupt_noquit(say_app): @@ -1048,7 +1077,7 @@ def test_interrupt_noquit(say_app): say_app.cmdloop() # And verify the expected output to stdout - out = say_app.stdout.buffer + out = say_app.stdout.getvalue() assert out == 'hello\n^C\ngoodbye\n' @@ -1060,7 +1089,7 @@ class ShellApp(cmd2.Cmd): @pytest.fixture def shell_app(): app = ShellApp() - app.stdout = StdOut() + app.stdout = utils.StdSim(app.stdout) return app def test_default_to_shell_unknown(shell_app): @@ -1093,28 +1122,30 @@ def test_default_to_shell_failure(capsys): def test_ansi_prompt_not_esacped(base_app): + from cmd2.rl_utils import rl_make_safe_prompt prompt = '(Cmd) ' - assert base_app._surround_ansi_escapes(prompt) == prompt + assert rl_make_safe_prompt(prompt) == prompt def test_ansi_prompt_escaped(): + from cmd2.rl_utils import rl_make_safe_prompt app = cmd2.Cmd() - color = 'cyan' + color = Fore.CYAN prompt = 'InColor' - color_prompt = app.colorize(prompt, color) + color_prompt = color + prompt + Fore.RESET readline_hack_start = "\x01" readline_hack_end = "\x02" - readline_safe_prompt = app._surround_ansi_escapes(color_prompt) + readline_safe_prompt = rl_make_safe_prompt(color_prompt) + assert prompt != color_prompt if sys.platform.startswith('win'): - # colorize() does nothing on Windows due to lack of ANSI color support - assert prompt == color_prompt - assert readline_safe_prompt == prompt + # PyReadline on Windows doesn't suffer from the GNU readline bug which requires the hack + assert readline_safe_prompt.startswith(color) + assert readline_safe_prompt.endswith(Fore.RESET) else: - assert prompt != color_prompt - assert readline_safe_prompt.startswith(readline_hack_start + app._colorcodes[color][True] + readline_hack_end) - assert readline_safe_prompt.endswith(readline_hack_start + app._colorcodes[color][False] + readline_hack_end) + assert readline_safe_prompt.startswith(readline_hack_start + color + readline_hack_end) + assert readline_safe_prompt.endswith(readline_hack_start + Fore.RESET + readline_hack_end) class HelpApp(cmd2.Cmd): @@ -1140,7 +1171,7 @@ class HelpApp(cmd2.Cmd): @pytest.fixture def help_app(): app = HelpApp() - app.stdout = StdOut() + app.stdout = utils.StdSim(app.stdout) return app def test_custom_command_help(help_app): @@ -1153,8 +1184,8 @@ def test_custom_help_menu(help_app): expected = normalize(""" Documented commands (type help <topic>): ======================================== -alias help load pyscript set shortcuts unalias -edit history py quit shell squat +alias help load py quit shell squat +edit history macro pyscript set shortcuts Undocumented commands: ====================== @@ -1203,7 +1234,7 @@ class HelpCategoriesApp(cmd2.Cmd): @pytest.fixture def helpcat_app(): app = HelpCategoriesApp() - app.stdout = StdOut() + app.stdout = utils.StdSim(app.stdout) return app def test_help_cat_base(helpcat_app): @@ -1220,7 +1251,7 @@ diddly Other ===== -alias help history load py pyscript quit set shell shortcuts unalias +alias help history load macro py pyscript quit set shell shortcuts Undocumented commands: ====================== @@ -1243,17 +1274,17 @@ diddly This command does diddly Other ================================================================================ -alias Define or display aliases -help List available commands with "help" or detailed help with "help cmd" +alias Manage aliases +help List available commands or provide detailed help for a specific command history View, run, edit, save, or clear previously entered commands -load Runs commands in script file that is encoded as either ASCII or UTF-8 text -py Invoke python command, shell, or script -pyscript Runs a python script file inside the console -quit Exits this application -set Sets a settable parameter or shows current settings of parameters +load Run commands in script file that is encoded as either ASCII or UTF-8 text +macro Manage macros +py Invoke Python command or shell +pyscript Run a Python script file inside the console +quit Exit this application +set Set a settable parameter or show current settings of parameters shell Execute a command as if at the OS prompt -shortcuts Lists shortcuts available -unalias Unsets aliases +shortcuts List available shortcuts Undocumented commands: ====================== @@ -1296,7 +1327,7 @@ class SelectApp(cmd2.Cmd): @pytest.fixture def select_app(): app = SelectApp() - app.stdout = StdOut() + app.stdout = utils.StdSim(app.stdout) return app def test_select_options(select_app): @@ -1461,7 +1492,7 @@ class MultilineApp(cmd2.Cmd): @pytest.fixture def multiline_app(): app = MultilineApp() - app.stdout = StdOut() + app.stdout = utils.StdSim(app.stdout) return app def test_multiline_complete_empty_statement_raises_exception(multiline_app): @@ -1522,7 +1553,7 @@ class CommandResultApp(cmd2.Cmd): @pytest.fixture def commandresult_app(): app = CommandResultApp() - app.stdout = StdOut() + app.stdout = utils.StdSim(app.stdout) return app def test_commandresult_truthy(commandresult_app): @@ -1550,7 +1581,7 @@ def test_is_text_file_bad_input(base_app): def test_eof(base_app): # Only thing to verify is that it returns True - assert base_app.do_eof('dont care') + assert base_app.do_eof('') def test_eos(base_app): sdir = 'dummy_dir' @@ -1558,7 +1589,7 @@ def test_eos(base_app): assert len(base_app._script_dir) == 1 # Assert that it does NOT return true - assert not base_app.do_eos('dont care') + assert not base_app.do_eos('') # And make sure it reduced the length of the script dir list assert len(base_app._script_dir) == 0 @@ -1628,7 +1659,7 @@ def piped_rawinput_true(capsys, echo, command): # run the cmdloop, which should pull input from our mock app._cmdloop() out, err = capsys.readouterr() - return (app, out) + return app, out # using the decorator puts the original input function back when this unit test returns @mock.patch('builtins.input', mock.MagicMock(name='input', side_effect=['set', EOFError])) @@ -1658,7 +1689,7 @@ def piped_rawinput_false(capsys, echo, command): app.echo = echo app._cmdloop() out, err = capsys.readouterr() - return (app, out) + return app, out def test_pseudo_raw_input_piped_rawinput_false_echo_true(capsys): command = 'set' @@ -1715,100 +1746,358 @@ def test_empty_stdin_input(): def test_poutput_string(base_app): msg = 'This is a test' base_app.poutput(msg) - out = base_app.stdout.buffer + out = base_app.stdout.getvalue() expected = msg + '\n' assert out == expected def test_poutput_zero(base_app): msg = 0 base_app.poutput(msg) - out = base_app.stdout.buffer + out = base_app.stdout.getvalue() expected = str(msg) + '\n' assert out == expected def test_poutput_empty_string(base_app): msg = '' base_app.poutput(msg) - out = base_app.stdout.buffer + out = base_app.stdout.getvalue() expected = msg assert out == expected def test_poutput_none(base_app): msg = None base_app.poutput(msg) - out = base_app.stdout.buffer + out = base_app.stdout.getvalue() expected = '' assert out == expected +def test_poutput_color_always(base_app): + msg = 'Hello World' + color = Fore.CYAN + base_app.colors = 'Always' + base_app.poutput(msg, color=color) + out = base_app.stdout.getvalue() + expected = color + msg + '\n' + Fore.RESET + assert out == expected -def test_alias(base_app, capsys): +def test_poutput_color_never(base_app): + msg = 'Hello World' + color = Fore.CYAN + base_app.colors = 'Never' + base_app.poutput(msg, color=color) + out = base_app.stdout.getvalue() + expected = msg + '\n' + assert out == expected + + +# These are invalid names for aliases and macros +invalid_command_name = [ + '""', # Blank name + '!no_shortcut', + '">"', + '"no>pe"', + '"no spaces"', + '"nopipe|"', + '"noterm;"', + 'noembedded"quotes', +] + +def test_get_alias_names(base_app): + assert len(base_app.aliases) == 0 + run_cmd(base_app, 'alias create fake pyscript') + run_cmd(base_app, 'alias create ls !ls -hal') + assert len(base_app.aliases) == 2 + assert sorted(base_app.get_alias_names()) == ['fake', 'ls'] + +def test_get_macro_names(base_app): + assert len(base_app.macros) == 0 + run_cmd(base_app, 'macro create foo !echo foo') + run_cmd(base_app, 'macro create bar !echo bar') + assert len(base_app.macros) == 2 + assert sorted(base_app.get_macro_names()) == ['bar', 'foo'] + +def test_alias_no_subcommand(base_app, capsys): + out = run_cmd(base_app, 'alias') + assert "Usage: alias [-h]" in out[0] + +def test_alias_create(base_app, capsys): # Create the alias - out = run_cmd(base_app, 'alias fake pyscript') + out = run_cmd(base_app, 'alias create fake pyscript') assert out == normalize("Alias 'fake' created") # Use the alias run_cmd(base_app, 'fake') out, err = capsys.readouterr() - assert "pyscript command requires at least 1 argument" in err + assert "the following arguments are required: script_path" in err # See a list of aliases - out = run_cmd(base_app, 'alias') - assert out == normalize('alias fake pyscript') + out = run_cmd(base_app, 'alias list') + assert out == normalize('alias create fake pyscript') + + # Look up the new alias + out = run_cmd(base_app, 'alias list fake') + assert out == normalize('alias create fake pyscript') - # Lookup the new alias - out = run_cmd(base_app, 'alias fake') - assert out == normalize('alias fake pyscript') +def test_alias_quoted_name(base_app, capsys): + """Demonstrate that names can be quoted in alias commands because they will be stripped""" + # Create the alias + out = run_cmd(base_app, 'alias create "fake" pyscript') + + # The quotes on names are stripped + assert out == normalize("Alias 'fake' created") + + # Look up the new alias and quote the name + out = run_cmd(base_app, 'alias list "fake"') + assert out == normalize('alias create fake pyscript') + + # Delete the alias using quotes + out = run_cmd(base_app, 'alias delete "fake"') + assert out == normalize("Alias 'fake' deleted") + +def test_alias_create_with_quoted_value(base_app, capsys): + """Demonstrate that quotes in alias value will be preserved (except for redirectors)""" -def test_alias_with_quotes(base_app, capsys): # Create the alias - out = run_cmd(base_app, 'alias fake help ">" "out file.txt"') + out = run_cmd(base_app, 'alias create fake help ">" "out file.txt"') assert out == normalize("Alias 'fake' created") - # Lookup the new alias (Only the redirector should be unquoted) - out = run_cmd(base_app, 'alias fake') - assert out == normalize('alias fake help > "out file.txt"') + # Look up the new alias (Only the redirector should be unquoted) + out = run_cmd(base_app, 'alias list fake') + assert out == normalize('alias create fake help > "out file.txt"') + +@pytest.mark.parametrize('alias_name', invalid_command_name) +def test_alias_create_invalid_name(base_app, alias_name, capsys): + run_cmd(base_app, 'alias create {} help'.format(alias_name)) + out, err = capsys.readouterr() + assert "Invalid alias name" in err + +def test_alias_create_with_macro_name(base_app, capsys): + macro = "my_macro" + run_cmd(base_app, 'macro create {} help'.format(macro)) + run_cmd(base_app, 'alias create {} help'.format(macro)) + out, err = capsys.readouterr() + assert "Alias cannot have the same name as a macro" in err -def test_alias_lookup_invalid_alias(base_app, capsys): - # Lookup invalid alias - out = run_cmd(base_app, 'alias invalid') +def test_alias_list_invalid_alias(base_app, capsys): + # Look up invalid alias + out = run_cmd(base_app, 'alias list invalid') out, err = capsys.readouterr() - assert "not found" in err + assert "Alias 'invalid' not found" in err -def test_unalias(base_app): +def test_alias_delete(base_app): # Create an alias - run_cmd(base_app, 'alias fake pyscript') + run_cmd(base_app, 'alias create fake pyscript') - # Remove the alias - out = run_cmd(base_app, 'unalias fake') - assert out == normalize("Alias 'fake' cleared") + # Delete the alias + out = run_cmd(base_app, 'alias delete fake') + assert out == normalize("Alias 'fake' deleted") -def test_unalias_all(base_app): - out = run_cmd(base_app, 'unalias -a') - assert out == normalize("All aliases cleared") +def test_alias_delete_all(base_app): + out = run_cmd(base_app, 'alias delete --all') + assert out == normalize("All aliases deleted") -def test_unalias_non_existing(base_app, capsys): - run_cmd(base_app, 'unalias fake') +def test_alias_delete_non_existing(base_app, capsys): + run_cmd(base_app, 'alias delete fake') out, err = capsys.readouterr() - assert "does not exist" in err + assert "Alias 'fake' does not exist" in err + +def test_alias_delete_no_name(base_app, capsys): + out = run_cmd(base_app, 'alias delete') + assert "Usage: alias delete" in out[0] + +def test_multiple_aliases(base_app): + alias1 = 'h1' + alias2 = 'h2' + run_cmd(base_app, 'alias create {} help'.format(alias1)) + run_cmd(base_app, 'alias create {} help -v'.format(alias2)) + out = run_cmd(base_app, alias1) + expected = normalize(BASE_HELP) + assert out == expected -@pytest.mark.parametrize('alias_name', [ - '">"', - '"no>pe"', - '"no spaces"', - '"nopipe|"', - '"noterm;"', - 'noembedded"quotes', -]) -def test_create_invalid_alias(base_app, alias_name, capsys): - run_cmd(base_app, 'alias {} help'.format(alias_name)) + out = run_cmd(base_app, alias2) + expected = normalize(BASE_HELP_VERBOSE) + assert out == expected + +def test_macro_no_subcommand(base_app, capsys): + out = run_cmd(base_app, 'macro') + assert "Usage: macro [-h]" in out[0] + +def test_macro_create(base_app, capsys): + # Create the macro + out = run_cmd(base_app, 'macro create fake pyscript') + assert out == normalize("Macro 'fake' created") + + # Use the macro + run_cmd(base_app, 'fake') + out, err = capsys.readouterr() + assert "the following arguments are required: script_path" in err + + # See a list of macros + out = run_cmd(base_app, 'macro list') + assert out == normalize('macro create fake pyscript') + + # Look up the new macro + out = run_cmd(base_app, 'macro list fake') + assert out == normalize('macro create fake pyscript') + +def test_macro_create_quoted_name(base_app, capsys): + """Demonstrate that names can be quoted in macro commands because they will be stripped""" + # Create the macro + out = run_cmd(base_app, 'macro create "fake" pyscript') + + # The quotes on names are stripped + assert out == normalize("Macro 'fake' created") + + # Look up the new macro and quote the name + out = run_cmd(base_app, 'macro list "fake"') + assert out == normalize('macro create fake pyscript') + + # Delete the macro using quotes + out = run_cmd(base_app, 'macro delete "fake"') + assert out == normalize("Macro 'fake' deleted") + +def test_macro_create_with_quoted_value(base_app, capsys): + """Demonstrate that quotes in macro value will be preserved (except for redirectors)""" + # Create the macro + out = run_cmd(base_app, 'macro create fake help ">" "out file.txt"') + assert out == normalize("Macro 'fake' created") + + # Look up the new macro (Only the redirector should be unquoted) + out = run_cmd(base_app, 'macro list fake') + assert out == normalize('macro create fake help > "out file.txt"') + +@pytest.mark.parametrize('macro_name', invalid_command_name) +def test_macro_create_invalid_name(base_app, macro_name, capsys): + run_cmd(base_app, 'macro create {} help'.format(macro_name)) + out, err = capsys.readouterr() + assert "Invalid macro name" in err + +def test_macro_create_with_alias_name(base_app, capsys): + macro = "my_macro" + run_cmd(base_app, 'alias create {} help'.format(macro)) + run_cmd(base_app, 'macro create {} help'.format(macro)) + out, err = capsys.readouterr() + assert "Macro cannot have the same name as an alias" in err + +def test_macro_create_with_command_name(base_app, capsys): + macro = "my_macro" + run_cmd(base_app, 'macro create help stuff') + out, err = capsys.readouterr() + assert "Macro cannot have the same name as a command" in err + +def test_macro_create_with_args(base_app, capsys): + # Create the macro + out = run_cmd(base_app, 'macro create fake {1} {2}') + assert out == normalize("Macro 'fake' created") + + # Run the macro + out = run_cmd(base_app, 'fake help -v') + expected = normalize(BASE_HELP_VERBOSE) + assert out == expected + +def test_macro_create_with_escaped_args(base_app, capsys): + # Create the macro + out = run_cmd(base_app, 'macro create fake help {{1}}') + assert out == normalize("Macro 'fake' created") + + # Run the macro + out = run_cmd(base_app, 'fake') + assert 'No help on {1}' in out[0] + +def test_macro_create_with_missing_arg_nums(base_app, capsys): + # Create the macro + run_cmd(base_app, 'macro create fake help {1} {3}') out, err = capsys.readouterr() - assert "can not contain" in err + assert "Not all numbers between 1 and 3" in err + +def test_macro_create_with_invalid_arg_num(base_app, capsys): + # Create the macro + run_cmd(base_app, 'macro create fake help {1} {-1} {0}') + out, err = capsys.readouterr() + assert "Argument numbers must be greater than 0" in err + +def test_macro_create_with_wrong_arg_count(base_app, capsys): + # Create the macro + out = run_cmd(base_app, 'macro create fake help {1} {2}') + assert out == normalize("Macro 'fake' created") + + # Run the macro + run_cmd(base_app, 'fake arg1') + out, err = capsys.readouterr() + assert "expects 2 argument(s)" in err + +def test_macro_create_with_unicode_numbered_arg(base_app, capsys): + # Create the macro expecting 1 argument + out = run_cmd(base_app, 'macro create fake help {\N{ARABIC-INDIC DIGIT ONE}}') + assert out == normalize("Macro 'fake' created") + + # Run the macro + out = run_cmd(base_app, 'fake') + out, err = capsys.readouterr() + assert "expects 1 argument(s)" in err + +def test_macro_create_with_missing_unicode_arg_nums(base_app, capsys): + run_cmd(base_app, 'macro create fake help {1} {\N{ARABIC-INDIC DIGIT THREE}}') + out, err = capsys.readouterr() + assert "Not all numbers between 1 and 3" in err + +def test_macro_list_invalid_macro(base_app, capsys): + # Look up invalid macro + run_cmd(base_app, 'macro list invalid') + out, err = capsys.readouterr() + assert "Macro 'invalid' not found" in err + +def test_macro_delete(base_app): + # Create an macro + run_cmd(base_app, 'macro create fake pyscript') + + # Delete the macro + out = run_cmd(base_app, 'macro delete fake') + assert out == normalize("Macro 'fake' deleted") + +def test_macro_delete_all(base_app): + out = run_cmd(base_app, 'macro delete --all') + assert out == normalize("All macros deleted") + +def test_macro_delete_non_existing(base_app, capsys): + run_cmd(base_app, 'macro delete fake') + out, err = capsys.readouterr() + assert "Macro 'fake' does not exist" in err + +def test_macro_delete_no_name(base_app, capsys): + out = run_cmd(base_app, 'macro delete') + assert "Usage: macro delete" in out[0] + +def test_multiple_macros(base_app): + macro1 = 'h1' + macro2 = 'h2' + run_cmd(base_app, 'macro create {} help'.format(macro1)) + run_cmd(base_app, 'macro create {} help -v'.format(macro2)) + out = run_cmd(base_app, macro1) + expected = normalize(BASE_HELP) + assert out == expected + + out = run_cmd(base_app, macro2) + expected = normalize(BASE_HELP_VERBOSE) + assert out == expected + +def test_nonexistent_macro(base_app, capsys): + from cmd2.parsing import StatementParser + exception = None + + try: + base_app._run_macro(StatementParser().parse('fake')) + except KeyError as e: + exception = e + + assert exception is not None + def test_ppaged(base_app): msg = 'testing...' end = '\n' base_app.ppaged(msg) - out = base_app.stdout.buffer + out = base_app.stdout.getvalue() assert out == msg + end # we override cmd.parseline() so we always get consistent @@ -1841,14 +2130,14 @@ def test_readline_remove_history_item(base_app): def test_onecmd_raw_str_continue(base_app): line = "help" stop = base_app.onecmd(line) - out = base_app.stdout.buffer + out = base_app.stdout.getvalue() assert not stop assert out.strip() == BASE_HELP.strip() def test_onecmd_raw_str_quit(base_app): line = "quit" stop = base_app.onecmd(line) - out = base_app.stdout.buffer + out = base_app.stdout.getvalue() assert stop assert out == '' @@ -1925,12 +2214,11 @@ def test_bad_history_file_path(capsys, request): assert 'readline cannot read' in err - def test_get_all_commands(base_app): # Verify that the base app has the expected commands commands = base_app.get_all_commands() - expected_commands = ['_relative_load', 'alias', 'edit', 'eof', 'eos', 'help', 'history', 'load', 'py', 'pyscript', - 'quit', 'set', 'shell', 'shortcuts', 'unalias'] + expected_commands = ['_relative_load', 'alias', 'edit', 'eof', 'eos', 'help', 'history', 'load', 'macro', + 'py', 'pyscript', 'quit', 'set', 'shell', 'shortcuts'] assert commands == expected_commands def test_get_help_topics(base_app): @@ -1978,7 +2266,7 @@ def test_exit_code_default(exit_code_repl): # Create a cmd2.Cmd() instance and make sure basic settings are like we want for test app = exit_code_repl app.use_rawinput = True - app.stdout = StdOut() + app.stdout = utils.StdSim(app.stdout) # Mock out the input call so we don't actually wait for a user's response on stdin m = mock.MagicMock(name='input', return_value='exit') @@ -1990,14 +2278,14 @@ def test_exit_code_default(exit_code_repl): with mock.patch.object(sys, 'argv', testargs): # Run the command loop app.cmdloop() - out = app.stdout.buffer + out = app.stdout.getvalue() assert out == expected def test_exit_code_nonzero(exit_code_repl): # Create a cmd2.Cmd() instance and make sure basic settings are like we want for test app = exit_code_repl app.use_rawinput = True - app.stdout = StdOut() + app.stdout = utils.StdSim(app.stdout) # Mock out the input call so we don't actually wait for a user's response on stdin m = mock.MagicMock(name='input', return_value='exit 23') @@ -2010,5 +2298,138 @@ def test_exit_code_nonzero(exit_code_repl): # Run the command loop with pytest.raises(SystemExit): app.cmdloop() - out = app.stdout.buffer + out = app.stdout.getvalue() assert out == expected + + +class ColorsApp(cmd2.Cmd): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def do_echo(self, args): + self.poutput(args) + self.perror(args, False) + + def do_echo_error(self, args): + color_on = Fore.RED + Back.BLACK + color_off = Style.RESET_ALL + self.poutput(color_on + args + color_off) + # perror uses colors by default + self.perror(args, False) + +def test_colors_default(): + app = ColorsApp() + assert app.colors == cmd2.constants.COLORS_TERMINAL + +def test_colors_pouterr_always_tty(mocker, capsys): + app = ColorsApp() + app.colors = cmd2.constants.COLORS_ALWAYS + mocker.patch.object(app.stdout, 'isatty', return_value=True) + mocker.patch.object(sys.stderr, 'isatty', return_value=True) + + app.onecmd_plus_hooks('echo_error oopsie') + out, err = capsys.readouterr() + # if colors are on, the output should have some escape sequences in it + assert len(out) > len('oopsie\n') + assert 'oopsie' in out + assert len(err) > len('Error: oopsie\n') + assert 'ERROR: oopsie' in err + + # but this one shouldn't + app.onecmd_plus_hooks('echo oopsie') + out, err = capsys.readouterr() + assert out == 'oopsie\n' + # errors always have colors + assert len(err) > len('Error: oopsie\n') + assert 'ERROR: oopsie' in err + +def test_colors_pouterr_always_notty(mocker, capsys): + app = ColorsApp() + app.colors = cmd2.constants.COLORS_ALWAYS + mocker.patch.object(app.stdout, 'isatty', return_value=False) + mocker.patch.object(sys.stderr, 'isatty', return_value=False) + + app.onecmd_plus_hooks('echo_error oopsie') + out, err = capsys.readouterr() + # if colors are on, the output should have some escape sequences in it + assert len(out) > len('oopsie\n') + assert 'oopsie' in out + assert len(err) > len('Error: oopsie\n') + assert 'ERROR: oopsie' in err + + # but this one shouldn't + app.onecmd_plus_hooks('echo oopsie') + out, err = capsys.readouterr() + assert out == 'oopsie\n' + # errors always have colors + assert len(err) > len('Error: oopsie\n') + assert 'ERROR: oopsie' in err + +def test_colors_terminal_tty(mocker, capsys): + app = ColorsApp() + app.colors = cmd2.constants.COLORS_TERMINAL + mocker.patch.object(app.stdout, 'isatty', return_value=True) + mocker.patch.object(sys.stderr, 'isatty', return_value=True) + + app.onecmd_plus_hooks('echo_error oopsie') + # if colors are on, the output should have some escape sequences in it + out, err = capsys.readouterr() + assert len(out) > len('oopsie\n') + assert 'oopsie' in out + assert len(err) > len('Error: oopsie\n') + assert 'ERROR: oopsie' in err + + # but this one shouldn't + app.onecmd_plus_hooks('echo oopsie') + out, err = capsys.readouterr() + assert out == 'oopsie\n' + assert len(err) > len('Error: oopsie\n') + assert 'ERROR: oopsie' in err + +def test_colors_terminal_notty(mocker, capsys): + app = ColorsApp() + app.colors = cmd2.constants.COLORS_TERMINAL + mocker.patch.object(app.stdout, 'isatty', return_value=False) + mocker.patch.object(sys.stderr, 'isatty', return_value=False) + + app.onecmd_plus_hooks('echo_error oopsie') + out, err = capsys.readouterr() + assert out == 'oopsie\n' + assert err == 'ERROR: oopsie\n' + + app.onecmd_plus_hooks('echo oopsie') + out, err = capsys.readouterr() + assert out == 'oopsie\n' + assert err == 'ERROR: oopsie\n' + +def test_colors_never_tty(mocker, capsys): + app = ColorsApp() + app.colors = cmd2.constants.COLORS_NEVER + mocker.patch.object(app.stdout, 'isatty', return_value=True) + mocker.patch.object(sys.stderr, 'isatty', return_value=True) + + app.onecmd_plus_hooks('echo_error oopsie') + out, err = capsys.readouterr() + assert out == 'oopsie\n' + assert err == 'ERROR: oopsie\n' + + app.onecmd_plus_hooks('echo oopsie') + out, err = capsys.readouterr() + assert out == 'oopsie\n' + assert err == 'ERROR: oopsie\n' + +def test_colors_never_notty(mocker, capsys): + app = ColorsApp() + app.colors = cmd2.constants.COLORS_NEVER + mocker.patch.object(app.stdout, 'isatty', return_value=False) + mocker.patch.object(sys.stderr, 'isatty', return_value=False) + + app.onecmd_plus_hooks('echo_error oopsie') + out, err = capsys.readouterr() + assert out == 'oopsie\n' + assert err == 'ERROR: oopsie\n' + + app.onecmd_plus_hooks('echo oopsie') + out, err = capsys.readouterr() + assert out == 'oopsie\n' + assert err == 'ERROR: oopsie\n' diff --git a/tests/test_completion.py b/tests/test_completion.py index 00a120cc..1b7b65d2 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -15,7 +15,7 @@ import sys import pytest import cmd2 from cmd2 import utils -from .conftest import complete_tester, StdOut +from .conftest import base_app, complete_tester, normalize, run_cmd from examples.subcommands import SubcommandsExample # List of strings used with completion functions @@ -98,11 +98,10 @@ def test_complete_empty_arg(cmd2_app): endidx = len(line) begidx = endidx - len(text) - expected = sorted(cmd2_app.complete_help(text, line, begidx, endidx)) + expected = sorted(cmd2_app.get_visible_commands()) first_match = complete_tester(text, line, begidx, endidx, cmd2_app) - assert first_match is not None and \ - cmd2_app.completion_matches == expected + assert first_match is not None and cmd2_app.completion_matches == expected def test_complete_bogus_command(cmd2_app): text = '' @@ -113,13 +112,24 @@ def test_complete_bogus_command(cmd2_app): first_match = complete_tester(text, line, begidx, endidx, cmd2_app) assert first_match is None +def test_complete_macro(base_app, request): + # Create the macro + out = run_cmd(base_app, 'macro create fake pyscript {1}') + assert out == normalize("Macro 'fake' created") + + # Macros do path completion + test_dir = os.path.dirname(request.module.__file__) + + text = os.path.join(test_dir, 's') + line = 'fake {}'.format(text) -def test_cmd2_command_completion_single(cmd2_app): - text = 'hel' - line = text endidx = len(line) begidx = endidx - len(text) - assert cmd2_app.completenames(text, line, begidx, endidx) == ['help'] + + expected = [text + 'cript.py', text + 'cript.txt', text + 'cripts' + os.path.sep] + first_match = complete_tester(text, line, begidx, endidx, base_app) + assert first_match is not None and base_app.completion_matches + def test_cmd2_command_completion_multiple(cmd2_app): text = 'h' @@ -141,7 +151,11 @@ def test_cmd2_help_completion_single(cmd2_app): line = 'help {}'.format(text) endidx = len(line) begidx = endidx - len(text) - assert cmd2_app.complete_help(text, line, begidx, endidx) == ['help'] + + first_match = complete_tester(text, line, begidx, endidx, cmd2_app) + + # It is at end of line, so extra space is present + assert first_match is not None and cmd2_app.completion_matches == ['help '] def test_cmd2_help_completion_multiple(cmd2_app): text = 'h' @@ -149,15 +163,18 @@ def test_cmd2_help_completion_multiple(cmd2_app): endidx = len(line) begidx = endidx - len(text) - matches = sorted(cmd2_app.complete_help(text, line, begidx, endidx)) - assert matches == ['help', 'history'] + first_match = complete_tester(text, line, begidx, endidx, cmd2_app) + assert first_match is not None and cmd2_app.completion_matches == ['help', 'history'] + def test_cmd2_help_completion_nomatch(cmd2_app): text = 'fakecommand' line = 'help {}'.format(text) endidx = len(line) begidx = endidx - len(text) - assert cmd2_app.complete_help(text, line, begidx, endidx) == [] + + first_match = complete_tester(text, line, begidx, endidx, cmd2_app) + assert first_match is None def test_shell_command_completion_shortcut(cmd2_app): @@ -191,7 +208,9 @@ def test_shell_command_completion_doesnt_match_wildcards(cmd2_app): line = 'shell {}'.format(text) endidx = len(line) begidx = endidx - len(text) - assert cmd2_app.complete_shell(text, line, begidx, endidx) == [] + + first_match = complete_tester(text, line, begidx, endidx, cmd2_app) + assert first_match is None def test_shell_command_completion_multiple(cmd2_app): if sys.platform == "win32": @@ -204,21 +223,27 @@ def test_shell_command_completion_multiple(cmd2_app): line = 'shell {}'.format(text) endidx = len(line) begidx = endidx - len(text) - assert expected in cmd2_app.complete_shell(text, line, begidx, endidx) + + first_match = complete_tester(text, line, begidx, endidx, cmd2_app) + assert first_match is not None and expected in cmd2_app.completion_matches def test_shell_command_completion_nomatch(cmd2_app): text = 'zzzz' line = 'shell {}'.format(text) endidx = len(line) begidx = endidx - len(text) - assert cmd2_app.complete_shell(text, line, begidx, endidx) == [] + + first_match = complete_tester(text, line, begidx, endidx, cmd2_app) + assert first_match is None def test_shell_command_completion_doesnt_complete_when_just_shell(cmd2_app): text = '' line = 'shell {}'.format(text) endidx = len(line) begidx = endidx - len(text) - assert cmd2_app.complete_shell(text, line, begidx, endidx) == [] + + first_match = complete_tester(text, line, begidx, endidx, cmd2_app) + assert first_match is None def test_shell_command_completion_does_path_completion_when_after_command(cmd2_app, request): test_dir = os.path.dirname(request.module.__file__) @@ -229,7 +254,8 @@ def test_shell_command_completion_does_path_completion_when_after_command(cmd2_a endidx = len(line) begidx = endidx - len(text) - assert cmd2_app.complete_shell(text, line, begidx, endidx) == [text + '.py'] + first_match = complete_tester(text, line, begidx, endidx, cmd2_app) + assert first_match is not None and cmd2_app.completion_matches == [text + '.py '] def test_path_completion_single_end(cmd2_app, request): @@ -694,8 +720,7 @@ def test_add_opening_quote_delimited_space_in_prefix(cmd2_app): @pytest.fixture def sc_app(): c = SubcommandsExample() - c.stdout = StdOut() - + c.stdout = utils.StdSim(c.stdout) return c def test_cmd2_subcommand_completion_single_end(sc_app): @@ -733,7 +758,11 @@ def test_cmd2_help_subcommand_completion_single(sc_app): line = 'help {}'.format(text) endidx = len(line) begidx = endidx - len(text) - assert sc_app.complete_help(text, line, begidx, endidx) == ['base'] + + first_match = complete_tester(text, line, begidx, endidx, sc_app) + + # It is at end of line, so extra space is present + assert first_match is not None and sc_app.completion_matches == ['base '] def test_cmd2_help_subcommand_completion_multiple(sc_app): text = '' @@ -741,8 +770,8 @@ def test_cmd2_help_subcommand_completion_multiple(sc_app): endidx = len(line) begidx = endidx - len(text) - matches = sorted(sc_app.complete_help(text, line, begidx, endidx)) - assert matches == ['bar', 'foo', 'sport'] + first_match = complete_tester(text, line, begidx, endidx, sc_app) + assert first_match is not None and sc_app.completion_matches == ['bar', 'foo', 'sport'] def test_cmd2_help_subcommand_completion_nomatch(sc_app): @@ -750,7 +779,9 @@ def test_cmd2_help_subcommand_completion_nomatch(sc_app): line = 'help base {}'.format(text) endidx = len(line) begidx = endidx - len(text) - assert sc_app.complete_help(text, line, begidx, endidx) == [] + + first_match = complete_tester(text, line, begidx, endidx, sc_app) + assert first_match is None def test_subcommand_tab_completion(sc_app): # This makes sure the correct completer for the sport subcommand is called @@ -892,7 +923,11 @@ def test_cmd2_help_subcommand_completion_single_scu(scu_app): line = 'help {}'.format(text) endidx = len(line) begidx = endidx - len(text) - assert scu_app.complete_help(text, line, begidx, endidx) == ['base'] + + first_match = complete_tester(text, line, begidx, endidx, scu_app) + + # It is at end of line, so extra space is present + assert first_match is not None and scu_app.completion_matches == ['base '] def test_cmd2_help_subcommand_completion_multiple_scu(scu_app): @@ -901,8 +936,34 @@ def test_cmd2_help_subcommand_completion_multiple_scu(scu_app): endidx = len(line) begidx = endidx - len(text) - matches = sorted(scu_app.complete_help(text, line, begidx, endidx)) - assert matches == ['bar', 'foo', 'sport'] + first_match = complete_tester(text, line, begidx, endidx, scu_app) + assert first_match is not None and scu_app.completion_matches == ['bar', 'foo', 'sport'] + +def test_cmd2_help_subcommand_completion_with_flags_before_command(scu_app): + text = '' + line = 'help -h -v base {}'.format(text) + endidx = len(line) + begidx = endidx - len(text) + + first_match = complete_tester(text, line, begidx, endidx, scu_app) + assert first_match is not None and scu_app.completion_matches == ['bar', 'foo', 'sport'] + +def test_complete_help_subcommand_with_no_command(scu_app): + # No command because not enough tokens + text = '' + line = 'help ' + endidx = len(line) + begidx = endidx - len(text) + + assert not scu_app.complete_help_subcommand(text, line, begidx, endidx) + + # No command because everything is a flag + text = '-v' + line = 'help -f -v' + endidx = len(line) + begidx = endidx - len(text) + + assert not scu_app.complete_help_subcommand(text, line, begidx, endidx) def test_cmd2_help_subcommand_completion_nomatch_scu(scu_app): @@ -910,7 +971,9 @@ def test_cmd2_help_subcommand_completion_nomatch_scu(scu_app): line = 'help base {}'.format(text) endidx = len(line) begidx = endidx - len(text) - assert scu_app.complete_help(text, line, begidx, endidx) == [] + + first_match = complete_tester(text, line, begidx, endidx, scu_app) + assert first_match == None def test_subcommand_tab_completion_scu(scu_app): diff --git a/tests/test_parsing.py b/tests/test_parsing.py index 9cf9429a..1fa460a5 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -754,3 +754,104 @@ def test_statement_is_immutable(): statement.args = 'bar' with pytest.raises(attr.exceptions.FrozenInstanceError): statement.raw = 'baz' + + +def test_macro_normal_arg_pattern(): + # This pattern matches digits surrounded by exactly 1 brace on a side and 1 or more braces on the opposite side + from cmd2.parsing import MacroArg + pattern = MacroArg.macro_normal_arg_pattern + + # Valid strings + matches = pattern.findall('{5}') + assert matches == ['{5}'] + + matches = pattern.findall('{233}') + assert matches == ['{233}'] + + matches = pattern.findall('{{{{{4}') + assert matches == ['{4}'] + + matches = pattern.findall('{2}}}}}') + assert matches == ['{2}'] + + matches = pattern.findall('{3}{4}{5}') + assert matches == ['{3}', '{4}', '{5}'] + + matches = pattern.findall('{3} {4} {5}') + assert matches == ['{3}', '{4}', '{5}'] + + matches = pattern.findall('{3} {{{4} {5}}}}') + assert matches == ['{3}', '{4}', '{5}'] + + matches = pattern.findall('{3} text {4} stuff {5}}}}') + assert matches == ['{3}', '{4}', '{5}'] + + # Unicode digit + matches = pattern.findall('{\N{ARABIC-INDIC DIGIT ONE}}') + assert matches == ['{\N{ARABIC-INDIC DIGIT ONE}}'] + + # Invalid strings + matches = pattern.findall('5') + assert not matches + + matches = pattern.findall('{5') + assert not matches + + matches = pattern.findall('5}') + assert not matches + + matches = pattern.findall('{{5}}') + assert not matches + + matches = pattern.findall('{5text}') + assert not matches + +def test_macro_escaped_arg_pattern(): + # This pattern matches digits surrounded by 2 or more braces on both sides + from cmd2.parsing import MacroArg + pattern = MacroArg.macro_escaped_arg_pattern + + # Valid strings + matches = pattern.findall('{{5}}') + assert matches == ['{{5}}'] + + matches = pattern.findall('{{233}}') + assert matches == ['{{233}}'] + + matches = pattern.findall('{{{{{4}}') + assert matches == ['{{4}}'] + + matches = pattern.findall('{{2}}}}}') + assert matches == ['{{2}}'] + + matches = pattern.findall('{{3}}{{4}}{{5}}') + assert matches == ['{{3}}', '{{4}}', '{{5}}'] + + matches = pattern.findall('{{3}} {{4}} {{5}}') + assert matches == ['{{3}}', '{{4}}', '{{5}}'] + + matches = pattern.findall('{{3}} {{{4}} {{5}}}}') + assert matches == ['{{3}}', '{{4}}', '{{5}}'] + + matches = pattern.findall('{{3}} text {{4}} stuff {{5}}}}') + assert matches == ['{{3}}', '{{4}}', '{{5}}'] + + # Unicode digit + matches = pattern.findall('{{\N{ARABIC-INDIC DIGIT ONE}}}') + assert matches == ['{{\N{ARABIC-INDIC DIGIT ONE}}}'] + + # Invalid strings + matches = pattern.findall('5') + assert not matches + + matches = pattern.findall('{{5') + assert not matches + + matches = pattern.findall('5}}') + assert not matches + + matches = pattern.findall('{5}') + assert not matches + + matches = pattern.findall('{{5text}}') + assert not matches diff --git a/tests/test_plugin.py b/tests/test_plugin.py index e401e837..81dd7683 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -5,18 +5,14 @@ Test plugin infrastructure and hooks. Copyright 2018 Jared Crapo <jared@kotfu.net> Released under MIT license, see LICENSE file """ - -from typing import Tuple - import pytest import cmd2 from cmd2 import plugin -from .conftest import StdOut class Plugin: - "A mixin class for testing hook registration and calling" + """A mixin class for testing hook registration and calling""" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.reset_counters() @@ -35,19 +31,19 @@ class Plugin: # ### def prepost_hook_one(self) -> None: - "Method used for preloop or postloop hooks" + """Method used for preloop or postloop hooks""" self.poutput("one") def prepost_hook_two(self) -> None: - "Another method used for preloop or postloop hooks" + """Another method used for preloop or postloop hooks""" self.poutput("two") def prepost_hook_too_many_parameters(self, param) -> None: - "A preloop or postloop hook with too many parameters" + """A preloop or postloop hook with too many parameters""" pass def prepost_hook_with_wrong_return_annotation(self) -> bool: - "A preloop or postloop hook with incorrect return type" + """A preloop or postloop hook with incorrect return type""" pass ### @@ -55,10 +51,10 @@ class Plugin: # preparse hook # ### - def preparse(self, line: str) -> str: - "Preparsing hook" + def preparse(self, data: cmd2.plugin.PostparsingData) -> cmd2.plugin.PostparsingData: + """Preparsing hook""" self.called_preparse += 1 - return line + return data ### # @@ -66,44 +62,44 @@ class Plugin: # ### def postparse_hook(self, data: cmd2.plugin.PostparsingData) -> cmd2.plugin.PostparsingData: - "A postparsing hook" + """A postparsing hook""" self.called_postparsing += 1 return data def postparse_hook_stop(self, data: cmd2.plugin.PostparsingData) -> cmd2.plugin.PostparsingData: - "A postparsing hook with requests application exit" + """A postparsing hook with requests application exit""" self.called_postparsing += 1 data.stop = True return data def postparse_hook_emptystatement(self, data: cmd2.plugin.PostparsingData) -> cmd2.plugin.PostparsingData: - "A postparsing hook with raises an EmptyStatement exception" + """A postparsing hook with raises an EmptyStatement exception""" self.called_postparsing += 1 raise cmd2.EmptyStatement def postparse_hook_exception(self, data: cmd2.plugin.PostparsingData) -> cmd2.plugin.PostparsingData: - "A postparsing hook which raises an exception" + """A postparsing hook which raises an exception""" self.called_postparsing += 1 raise ValueError def postparse_hook_too_many_parameters(self, data1, data2) -> cmd2.plugin.PostparsingData: - "A postparsing hook with too many parameters" + """A postparsing hook with too many parameters""" pass def postparse_hook_undeclared_parameter_annotation(self, data) -> cmd2.plugin.PostparsingData: - "A postparsing hook with an undeclared parameter type" + """A postparsing hook with an undeclared parameter type""" pass def postparse_hook_wrong_parameter_annotation(self, data: str) -> cmd2.plugin.PostparsingData: - "A postparsing hook with the wrong parameter type" + """A postparsing hook with the wrong parameter type""" pass def postparse_hook_undeclared_return_annotation(self, data: cmd2.plugin.PostparsingData): - "A postparsing hook with an undeclared return type" + """A postparsing hook with an undeclared return type""" pass def postparse_hook_wrong_return_annotation(self, data: cmd2.plugin.PostparsingData) -> str: - "A postparsing hook with the wrong return type" + """A postparsing hook with the wrong return type""" pass ### @@ -112,43 +108,43 @@ class Plugin: # ### def precmd(self, statement: cmd2.Statement) -> cmd2.Statement: - "Override cmd.Cmd method" + """Override cmd.Cmd method""" self.called_precmd += 1 return statement def precmd_hook(self, data: plugin.PrecommandData) -> plugin.PrecommandData: - "A precommand hook" + """A precommand hook""" self.called_precmd += 1 return data def precmd_hook_emptystatement(self, data: plugin.PrecommandData) -> plugin.PrecommandData: - "A precommand hook which raises an EmptyStatement exception" + """A precommand hook which raises an EmptyStatement exception""" self.called_precmd += 1 raise cmd2.EmptyStatement def precmd_hook_exception(self, data: plugin.PrecommandData) -> plugin.PrecommandData: - "A precommand hook which raises an exception" + """A precommand hook which raises an exception""" self.called_precmd += 1 raise ValueError def precmd_hook_not_enough_parameters(self) -> plugin.PrecommandData: - "A precommand hook with no parameters" + """A precommand hook with no parameters""" pass def precmd_hook_too_many_parameters(self, one: plugin.PrecommandData, two: str) -> plugin.PrecommandData: - "A precommand hook with too many parameters" + """A precommand hook with too many parameters""" return one def precmd_hook_no_parameter_annotation(self, data) -> plugin.PrecommandData: - "A precommand hook with no type annotation on the parameter" + """A precommand hook with no type annotation on the parameter""" return data def precmd_hook_wrong_parameter_annotation(self, data: str) -> plugin.PrecommandData: - "A precommand hook with the incorrect type annotation on the parameter" + """A precommand hook with the incorrect type annotation on the parameter""" return data def precmd_hook_no_return_annotation(self, data: plugin.PrecommandData): - "A precommand hook with no type annotation on the return value" + """A precommand hook with no type annotation on the return value""" return data def precmd_hook_wrong_return_annotation(self, data: plugin.PrecommandData) -> cmd2.Statement: @@ -160,38 +156,38 @@ class Plugin: # ### def postcmd(self, stop: bool, statement: cmd2.Statement) -> bool: - "Override cmd.Cmd method" + """Override cmd.Cmd method""" self.called_postcmd += 1 return stop def postcmd_hook(self, data: plugin.PostcommandData) -> plugin.PostcommandData: - "A postcommand hook" + """A postcommand hook""" self.called_postcmd += 1 return data def postcmd_hook_exception(self, data: plugin.PostcommandData) -> plugin.PostcommandData: - "A postcommand hook with raises an exception" + """A postcommand hook with raises an exception""" self.called_postcmd += 1 raise ZeroDivisionError def postcmd_hook_not_enough_parameters(self) -> plugin.PostcommandData: - "A precommand hook with no parameters" + """A precommand hook with no parameters""" pass def postcmd_hook_too_many_parameters(self, one: plugin.PostcommandData, two: str) -> plugin.PostcommandData: - "A precommand hook with too many parameters" + """A precommand hook with too many parameters""" return one def postcmd_hook_no_parameter_annotation(self, data) -> plugin.PostcommandData: - "A precommand hook with no type annotation on the parameter" + """A precommand hook with no type annotation on the parameter""" return data def postcmd_hook_wrong_parameter_annotation(self, data: str) -> plugin.PostcommandData: - "A precommand hook with the incorrect type annotation on the parameter" + """A precommand hook with the incorrect type annotation on the parameter""" return data def postcmd_hook_no_return_annotation(self, data: plugin.PostcommandData): - "A precommand hook with no type annotation on the return value" + """A precommand hook with no type annotation on the return value""" return data def postcmd_hook_wrong_return_annotation(self, data: plugin.PostcommandData) -> cmd2.Statement: @@ -208,13 +204,13 @@ class Plugin: return data def cmdfinalization_hook_stop(self, data: cmd2.plugin.CommandFinalizationData) -> cmd2.plugin.CommandFinalizationData: - "A postparsing hook which requests application exit" + """A postparsing hook which requests application exit""" self.called_cmdfinalization += 1 data.stop = True return data def cmdfinalization_hook_exception(self, data: cmd2.plugin.CommandFinalizationData) -> cmd2.plugin.CommandFinalizationData: - "A postparsing hook which raises an exception" + """A postparsing hook which raises an exception""" self.called_cmdfinalization += 1 raise ValueError @@ -244,7 +240,7 @@ class Plugin: class PluggedApp(Plugin, cmd2.Cmd): - "A sample app with a plugin mixed in" + """A sample app with a plugin mixed in""" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -326,6 +322,7 @@ def test_postloop_hooks(capsys): ### def test_preparse(capsys): app = PluggedApp() + app.register_postparsing_hook(app.preparse) app.onecmd_plus_hooks('say hello') out, err = capsys.readouterr() assert out == 'hello\n' diff --git a/tests/test_pyscript.py b/tests/test_pyscript.py index 73c1a62a..36e48598 100644 --- a/tests/test_pyscript.py +++ b/tests/test_pyscript.py @@ -1,3 +1,4 @@ +# coding=utf-8 """ Unit/functional testing for argparse completer in cmd2 @@ -8,8 +9,9 @@ import os import pytest from cmd2.cmd2 import Cmd, with_argparser from cmd2 import argparse_completer -from .conftest import run_cmd, StdOut -from cmd2.utils import namedtuple_with_defaults +from .conftest import run_cmd +from cmd2.utils import namedtuple_with_defaults, StdSim + class PyscriptExample(Cmd): ratings_types = ['G', 'PG', 'PG-13', 'R', 'NC-17'] @@ -18,7 +20,7 @@ class PyscriptExample(Cmd): if not args.command: self.do_help('media movies') else: - print('media movies ' + str(args.__dict__)) + self.poutput('media movies ' + str(args.__dict__)) def _do_media_shows(self, args) -> None: if not args.command: @@ -27,7 +29,7 @@ class PyscriptExample(Cmd): if not args.command: self.do_help('media shows') else: - print('media shows ' + str(args.__dict__)) + self.poutput('media shows ' + str(args.__dict__)) media_parser = argparse_completer.ACArgumentParser(prog='media') @@ -82,7 +84,7 @@ class PyscriptExample(Cmd): @with_argparser(foo_parser) def do_foo(self, args): - print('foo ' + str(args.__dict__)) + self.poutput('foo ' + str(sorted(args.__dict__))) if self._in_py: FooResult = namedtuple_with_defaults('FooResult', ['counter', 'trueval', 'constval', @@ -108,14 +110,13 @@ class PyscriptExample(Cmd): out += '{' for key in keys: out += "'{}':'{}'".format(key, arg_dict[key]) - print(out) + self.poutput(out) @pytest.fixture def ps_app(): c = PyscriptExample() - c.stdout = StdOut() - + c.stdout = StdSim(c.stdout) return c @@ -125,14 +126,13 @@ class PyscriptCustomNameExample(Cmd): self.pyscript_name = 'custom' def do_echo(self, out): - print(out) + self.poutput(out) @pytest.fixture def ps_echo(): c = PyscriptCustomNameExample() - c.stdout = StdOut() - + c.stdout = StdSim(c.stdout) return c @@ -140,7 +140,7 @@ def ps_echo(): ('help', 'help.py'), ('help media', 'help_media.py'), ]) -def test_pyscript_help(ps_app, capsys, request, command, pyscript_file): +def test_pyscript_help(ps_app, request, command, pyscript_file): test_dir = os.path.dirname(request.module.__file__) python_script = os.path.join(test_dir, 'pyscript', pyscript_file) expected = run_cmd(ps_app, command) @@ -169,16 +169,14 @@ def test_pyscript_help(ps_app, capsys, request, command, pyscript_file): ('foo 11 22 33 44 55 66 -ccc', 'foo3.py'), ('bar 11 22', 'bar1.py'), ]) -def test_pyscript_out(ps_app, capsys, request, command, pyscript_file): +def test_pyscript_out(ps_app, request, command, pyscript_file): test_dir = os.path.dirname(request.module.__file__) python_script = os.path.join(test_dir, 'pyscript', pyscript_file) - run_cmd(ps_app, command) - expected, _ = capsys.readouterr() + expected = run_cmd(ps_app, command) + assert expected - assert len(expected) > 0 - run_cmd(ps_app, 'pyscript {}'.format(python_script)) - out, _ = capsys.readouterr() - assert len(out) > 0 + out = run_cmd(ps_app, 'pyscript {}'.format(python_script)) + assert out assert out == expected @@ -212,7 +210,7 @@ def test_pyscript_results(ps_app, capsys, request, pyscript_file, exp_out): @pytest.mark.parametrize('expected, pyscript_file', [ - ("['_relative_load', 'alias', 'bar', 'cmd_echo', 'edit', 'eof', 'eos', 'foo', 'help', 'history', 'load', 'media', 'py', 'pyscript', 'quit', 'set', 'shell', 'shortcuts', 'unalias']", + ("['_relative_load', 'alias', 'bar', 'cmd_echo', 'edit', 'eof', 'eos', 'foo', 'help', 'history', 'load', 'macro', 'media', 'py', 'pyscript', 'quit', 'set', 'shell', 'shortcuts']", 'pyscript_dir1.py'), ("['movies', 'shows']", 'pyscript_dir2.py') ]) @@ -227,14 +225,12 @@ def test_pyscript_dir(ps_app, capsys, request, expected, pyscript_file): assert out == expected -def test_pyscript_custom_name(ps_echo, capsys, request): +def test_pyscript_custom_name(ps_echo, request): message = 'blah!' test_dir = os.path.dirname(request.module.__file__) python_script = os.path.join(test_dir, 'pyscript', 'custom_echo.py') - run_cmd(ps_echo, 'pyscript {}'.format(python_script)) - expected, _ = capsys.readouterr() - assert len(expected) > 0 - expected = expected.splitlines() - assert message == expected[0] + out = run_cmd(ps_echo, 'pyscript {}'.format(python_script)) + assert out + assert message == out[0] diff --git a/tests/test_transcript.py b/tests/test_transcript.py index 84b9cec8..9a63cf09 100644 --- a/tests/test_transcript.py +++ b/tests/test_transcript.py @@ -16,8 +16,10 @@ from unittest import mock import pytest import cmd2 -from .conftest import run_cmd, StdOut +from .conftest import run_cmd from cmd2 import transcript +from cmd2.utils import StdSim + class CmdLineApp(cmd2.Cmd): @@ -87,9 +89,9 @@ def test_commands_at_invocation(): expected = "This is an intro banner ...\nhello\nGracie\n" with mock.patch.object(sys, 'argv', testargs): app = CmdLineApp() - app.stdout = StdOut() + app.stdout = StdSim(app.stdout) app.cmdloop() - out = app.stdout.buffer + out = app.stdout.getvalue() assert out == expected @pytest.mark.parametrize('filename,feedback_to_output', [ @@ -134,7 +136,7 @@ def test_transcript(request, capsys, filename, feedback_to_output): def test_history_transcript(request, capsys): app = CmdLineApp() - app.stdout = StdOut() + app.stdout = StdSim(app.stdout) run_cmd(app, 'orate this is\na /multiline/\ncommand;\n') run_cmd(app, 'speak /tmp/file.txt is not a regex') @@ -155,13 +157,13 @@ this is a \/multiline\/ command # read in the transcript created by the history command with open(history_fname) as f: - transcript = f.read() + xscript = f.read() - assert transcript == expected + assert xscript == expected def test_history_transcript_bad_filename(request, capsys): app = CmdLineApp() - app.stdout = StdOut() + app.stdout = StdSim(app.stdout) run_cmd(app, 'orate this is\na /multiline/\ncommand;\n') run_cmd(app, 'speak /tmp/file.txt is not a regex') diff --git a/tests/test_utils.py b/tests/test_utils.py index 61fd8373..53031567 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -5,6 +5,10 @@ Unit testing for cmd2/utils.py module. Copyright 2018 Todd Leonhardt <todd.leonhardt@gmail.com> Released under MIT license, see LICENSE file """ +import sys + +import pytest + from colorama import Fore import cmd2.utils as cu @@ -13,7 +17,7 @@ HELLO_WORLD = 'Hello, world!' def test_strip_ansi(): base_str = HELLO_WORLD - ansi_str = Fore.GREEN + base_str + Fore.RESET + ansi_str = Fore.GREEN + base_str + Fore.RESET assert base_str != ansi_str assert base_str == cu.strip_ansi(ansi_str) @@ -48,5 +52,117 @@ def test_unicode_casefold(): assert cu.norm_fold(micro) == cu.norm_fold(micro_cf) def test_alphabetical_sort(): - my_list = ['café', 'µ', 'A' , 'micro', 'unity', 'cafeteria'] + my_list = ['café', 'µ', 'A', 'micro', 'unity', 'cafeteria'] assert cu.alphabetical_sort(my_list) == ['A', 'cafeteria', 'café', 'micro', 'unity', 'µ'] + my_list = ['a3', 'a22', 'A2', 'A11', 'a1'] + assert cu.alphabetical_sort(my_list) == ['a1', 'A11', 'A2', 'a22', 'a3'] + +def test_try_int_or_force_to_lower_case(): + str1 = '17' + assert cu.try_int_or_force_to_lower_case(str1) == 17 + str1 = 'ABC' + assert cu.try_int_or_force_to_lower_case(str1) == 'abc' + str1 = 'X19' + assert cu.try_int_or_force_to_lower_case(str1) == 'x19' + str1 = '' + assert cu.try_int_or_force_to_lower_case(str1) == '' + +def test_natural_keys(): + my_list = ['café', 'µ', 'A', 'micro', 'unity', 'x1', 'X2', 'X11', 'X0', 'x22'] + my_list.sort(key=cu.natural_keys) + assert my_list == ['A', 'café', 'micro', 'unity', 'X0', 'x1', 'X2', 'X11', 'x22', 'µ'] + my_list = ['a3', 'a22', 'A2', 'A11', 'a1'] + my_list.sort(key=cu.natural_keys) + assert my_list == ['a1', 'A2', 'a3', 'A11', 'a22'] + +def test_natural_sort(): + my_list = ['café', 'µ', 'A', 'micro', 'unity', 'x1', 'X2', 'X11', 'X0', 'x22'] + assert cu.natural_sort(my_list) == ['A', 'café', 'micro', 'unity', 'X0', 'x1', 'X2', 'X11', 'x22', 'µ'] + my_list = ['a3', 'a22', 'A2', 'A11', 'a1'] + assert cu.natural_sort(my_list) == ['a1', 'A2', 'a3', 'A11', 'a22'] + +def test_is_quoted_short(): + my_str = '' + assert not cu.is_quoted(my_str) + your_str = '"' + assert not cu.is_quoted(your_str) + +def test_is_quoted_yes(): + my_str = '"This is a test"' + assert cu.is_quoted(my_str) + your_str = "'of the emergengy broadcast system'" + assert cu.is_quoted(your_str) + +def test_is_quoted_no(): + my_str = '"This is a test' + assert not cu.is_quoted(my_str) + your_str = "of the emergengy broadcast system'" + assert not cu.is_quoted(your_str) + simple_str = "hello world" + assert not cu.is_quoted(simple_str) + +def test_quote_string_if_needed_yes(): + my_str = "Hello World" + assert cu.quote_string_if_needed(my_str) == '"' + my_str + '"' + your_str = '"foo" bar' + assert cu.quote_string_if_needed(your_str) == "'" + your_str + "'" + +def test_quot_string_if_needed_no(): + my_str = "HelloWorld" + assert cu.quote_string_if_needed(my_str) == my_str + your_str = "'Hello World'" + assert cu.quote_string_if_needed(your_str) == your_str + + +@pytest.fixture +def stdout_sim(): + stdsim = cu.StdSim(sys.stdout) + return stdsim + +def test_stdsim_write_str(stdout_sim): + my_str = 'Hello World' + stdout_sim.write(my_str) + assert stdout_sim.getvalue() == my_str + +def test_stdsim_write_bytes(stdout_sim): + b_str = b'Hello World' + with pytest.raises(TypeError): + stdout_sim.write(b_str) + +def test_stdsim_buffer_write_bytes(stdout_sim): + b_str = b'Hello World' + stdout_sim.buffer.write(b_str) + assert stdout_sim.getvalue() == b_str.decode() + +def test_stdsim_buffer_write_str(stdout_sim): + my_str = 'Hello World' + with pytest.raises(TypeError): + stdout_sim.buffer.write(my_str) + +def test_stdsim_read(stdout_sim): + my_str = 'Hello World' + stdout_sim.write(my_str) + # getvalue() returns the value and leaves it unaffected internally + assert stdout_sim.getvalue() == my_str + # read() returns the value and then clears the internal buffer + assert stdout_sim.read() == my_str + assert stdout_sim.getvalue() == '' + +def test_stdsim_clear(stdout_sim): + my_str = 'Hello World' + stdout_sim.write(my_str) + assert stdout_sim.getvalue() == my_str + stdout_sim.clear() + assert stdout_sim.getvalue() == '' + +def test_stdsim_getattr_exist(stdout_sim): + # Here the StdSim getattr is allowing us to access methods within StdSim + my_str = 'Hello World' + stdout_sim.write(my_str) + val_func = getattr(stdout_sim, 'getvalue') + assert val_func() == my_str + +def test_stdsim_getattr_noexist(stdout_sim): + # Here the StdSim getattr is allowing us to access methods defined by the inner stream + assert not stdout_sim.isatty() + diff --git a/tests/transcripts/from_cmdloop.txt b/tests/transcripts/from_cmdloop.txt index e3ccbc5e..8c0dd007 100644 --- a/tests/transcripts/from_cmdloop.txt +++ b/tests/transcripts/from_cmdloop.txt @@ -5,8 +5,8 @@ Documented commands (type help <topic>): ======================================== -alias help load nothing py quit set shortcuts unalias/ */ -edit history mumble orate pyscript say shell speak/ */ +alias help load mumble orate pyscript say shell speak/ */ +edit history macro nothing py quit set shortcuts/ */ (Cmd) help say usage: speak [-h] [-p] [-s] [-r REPEAT]/ */ diff --git a/tests/transcripts/regex_set.txt b/tests/transcripts/regex_set.txt index b818c464..d45672a7 100644 --- a/tests/transcripts/regex_set.txt +++ b/tests/transcripts/regex_set.txt @@ -1,10 +1,10 @@ # Run this transcript with "python example.py -t transcript_regex.txt" -# The regex for colors is because no color on Windows. +# The regex for colors shows all possible settings for colors # The regex for editor will match whatever program you use. # Regexes on prompts just make the trailing space obvious (Cmd) set -colors: /(True|False)/ +colors: /(Terminal|Always|Never)/ continuation_prompt: >/ / debug: False echo: False |