pax_global_header00006660000000000000000000000064141411372050014510gustar00rootroot0000000000000052 comment=87ddbcb62775f381d72aba2ed348d7cb1871d5dd knack-0.9.0/000077500000000000000000000000001414113720500126055ustar00rootroot00000000000000knack-0.9.0/.flake8000066400000000000000000000002251414113720500137570ustar00rootroot00000000000000[flake8] max-line-length = 120 max-complexity = 10 ignore = E126, E501, E741, E722, F401, F811, C901, W503, W504 knack-0.9.0/.gitattributes000066400000000000000000000003521414113720500155000ustar00rootroot00000000000000# Set the default behavior (used when a rule below doesn't match) * text=auto *.sln -text *.ico -text *.bmp -text *.png -text *.snk -text *.mht -text *.pickle -text # Some Windows-specific files should always be CRLF *.bat eol=crlf knack-0.9.0/.gitignore000066400000000000000000000015201414113720500145730ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # Distribution / packaging .Python env/ env27/ build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ obj/ parts/ sdist/ var/ wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST RECORD.txt # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover .hypothesis/ # Sphinx documentation docs/_build/ # User-specific files *.suo *.user *.sln.docstates .vs/ # Windows image file caches Thumbs.db ehthumbs.db # Folder config file Desktop.ini # Recycle Bin used on file shares $RECYCLE.BIN/ # Mac desktop service store files .DS_Store .idea .vscode/settings.json .vscode/.ropeproject/ .vscode/cSpell.json .project .pydevproject .pytest_cache/ env3/ env2/ knack-0.9.0/.pylintrc000066400000000000000000000014251414113720500144540ustar00rootroot00000000000000[MESSAGES CONTROL] # For all codes, run 'pylint --list-msgs' or go to 'http://pylint-messages.wikidot.com/all-codes' # W0511 fixme # C0111 Missing docstring # C0103 Invalid %s name "%s" # C0415 Import outside toplevel (import-outside-toplevel) # I0011 Warning locally suppressed using disable-msg # R0913 Too many arguments # R0903 too-few-public-methods # R0401 cyclic-import # R0205 useless-object-inheritance # R1717 consider-using-dict-comprehension disable=W0511,C0111,C0103,C0415,I0011,R0913,R0903,R0401,R0205,R1717,useless-suppression, consider-using-f-string [FORMAT] max-line-length=120 [DESIGN] # Maximum number of locals for function / method body max-locals=25 # Maximum number of branch for function / method body max-branches=20 [SIMILARITIES] min-similarity-lines=10 knack-0.9.0/.travis.yml000066400000000000000000000002221414113720500147120ustar00rootroot00000000000000arch: - amd64 - ppc64le sudo: false language: python python: - '3.6' - '3.7' - '3.8' - '3.9' - '3.10' install: pip install tox-travis script: tox knack-0.9.0/CONTRIBUTING.rst000066400000000000000000000010471414113720500152500ustar00rootroot00000000000000Contribute Code =============== This project has adopted the `Microsoft Open Source Code of Conduct `__. For more information see the `Code of Conduct FAQ `__ or contact `opencode@microsoft.com `__ with any additional questions or comments. If you would like to become an active contributor to this project please follow the instructions provided in `Contribution License Agreement `__. knack-0.9.0/HISTORY.rst000066400000000000000000000131161414113720500145020ustar00rootroot00000000000000.. :changelog: Release History =============== 0.9.0 +++++ * Support Python 3.10 (#250) * Only install colorama on Windows (#249) 0.8.2 +++++ * Always use UTF-8 for log file encoding (#247) 0.8.1 +++++ * Add error message for invalid argument value (#244) 0.8.0 +++++ * Make colors customizable (#242) * Init colorama only in Windows legacy terminal (#238) * Add `raw_result` to `CommandResultItem` (#235) * Refine code style to comply with Python 3 (#232, #233) * CI: Support Python 3.9 (#229) * Logging: `CLILogging.configure` returns as early as possible (#228) 0.8.0rc2 ++++++++ * Support multiple cli loggers by adding more logger names to `knack.log.cli_logger_names` list (#227) 0.8.0rc1 ++++++++ * Make config item names case-insensitive (#220) * `get_logger` uses `module_name` directly and no longer adds `cli` prefix (#221) * `CLILogging` accepts a custom `cli_logger_name` (#221) * Support ppc64le arch in Travis CI (#222) * Allow customizing tag message (#223) * Add `EVENT_CLI_SUCCESSFUL_EXECUTE` (#224) 0.7.2 ++++++++ * [Config] Support listing sections (#217) 0.7.1 ++++++++ * Rollback `get_config_parser` in `config.py` (#205) 0.7.0 ++++++++ * Add a `default_value_source` property in `HelpParameter` (#202) * Support removing option/section from config file (#201) * Support writing comment to config file (#201) * Import `configparser` directly instead of from `six` (#201) * Drop `get_config_parser` function from `config.py` (#201) 0.7.0rc4 ++++++++ * Change the timing to raise `EVENT_CLI_POST_EXECUTE` event (#199) * Make `CLI.invoke` catch `SystemExit` (#199) 0.7.0rc3 ++++++++ * Change experimental tag color to cyan (#196) 0.7.0rc1 ++++++++ * Allow disabling color (#171) * Support yaml and yamlc output (#173) * Drop support for python 2 and 3.5 (#174) * Support ``--only-show-errors`` to disable warnings (#179) * Add experimental tag (#180) 0.6.3 +++++ * Fix bug where arguments in preview did not call registered actions. This meant that parameter parsing did not behave completely as expected. 0.6.2 +++++ * Adds ability to declare that command groups, commands, and arguments are in a preview status and therefore might change or be removed. This is done by passing the kwarg `is_preview=True`. * Adds a generic ``StatusTag`` class to ``knack.util`` that allows you to create your own colorized tags like ``[Preview]`` and ``[Deprecated]``. * When an incorrect command name is entered, Knack will now attempt to suggest the closest alternative. 0.6.1 +++++ * Always read from local for configured_default 0.6.0 +++++ * Support local context chained config file 0.5.4 +++++ * Allows the loading of text files using @filename syntax. * Adds the argument kwarg configured_default to support setting argument defaults via the config file's [defaults] section or an environment variable. 0.5.3 +++++ * Removes an incorrect check when adding arguments. 0.5.2 +++++ * Updates usages of yaml.load to use yaml.safe_load. 0.5.1 +++++ * Fix issue with some scenarios (no args and --version) 0.5.0 +++++ * Adds support for positional arguments with the .positional helper method on ArgumentsContext. * Removes the necessity for the type field in help.py. This information can be inferred from the class, so specifying it causes unnecessary crashes. * Adds support for examining the result of a command after a call to invoke. The raw object, error (if any) an exit code are accessible. * Adds support for accessing the command instance from inside custom commands by putting the special argument cmd in the signature. * Fixes an issue with the default config directory. It use to be .cli and is now based on the CLI name. * Fixes regression in knack 0.4.5 in behavior when cli_name --verbose/debug is used. Displays the welcome message as intended. * Adds ability to specify line width for help text display. 0.4.5 +++++ * Preserves logging verbosity and output format on the namespace for use by validators. 0.4.4 +++++ * Adds ability to set config file name. * Fixes bug with argument deprecations. 0.4.3 +++++ * Fixes issue where values were sometimes ignored when using deprecated options regardless of which option was given. 0.4.2 +++++ * Bug fix: disable number parse on table mode PR #88 0.4.1 +++++ * Fixes bug with deprecation mechanism. * Fixes an issue where the command group table would only be filled by calls to create CommandGroup classes. This resulted in some gaps in the command group table. 0.4.0 +++++ * Add mechanism to deprecate commands, command groups, arguments and argument options. * Improve help display support for Unicode. 0.3.3 +++++ * expose a callback to let client side perform extra logics (#80) * output: don't skip false value on auto-tabulating (#83) 0.3.2 +++++ * ArgumentsContext.ignore() should use hidden options_list (#76) * Consolidate exception handling (#66) 0.3.1 +++++ * Performance optimization - Delay import of platform and colorama (#47) * CLIError: Inherit from Exception directly (#65) * Explicitly state which packages to include (so exclude 'tests') (#68) 0.2.0 +++++ * Support command level and argument level validators. * knack.commands.CLICommandsLoader now accepts a command_cls argument so you can provide your own CLICommand class. * logging: make determine_verbose_level private method. * Allow overriding of NAMED_ARGUMENTS * Only pass valid argparse kwargs to argparse.ArgumentParser.add_argument and ignore the rest * logging: make determine_verbose_level private method * Remove cli_command, register_cli_argument, register_extra_cli_argument as ways to register commands and arguments. 0.1.1 +++++ * Add more types of command and argument loaders. 0.1.0 +++++ * Initial release knack-0.9.0/LICENSE000066400000000000000000000022111414113720500136060ustar00rootroot00000000000000 MIT License Copyright (c) Microsoft Corporation. All rights reserved. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWAREknack-0.9.0/MANIFEST.in000066400000000000000000000000701414113720500143400ustar00rootroot00000000000000include *.rst include LICENSE recursive-include tests * knack-0.9.0/README.rst000066400000000000000000000103261414113720500142760ustar00rootroot00000000000000Knack ===== .. image:: https://img.shields.io/pypi/v/knack.svg :target: https://pypi.python.org/pypi/knack .. image:: https://img.shields.io/pypi/pyversions/knack.svg :target: https://pypi.python.org/pypi/knack .. image:: https://dev.azure.com/azure-sdk/public/_apis/build/status/cli/microsoft.knack?branchName=dev :target: https://dev.azure.com/azure-sdk/public/_build/latest?definitionId=1643&branchName=dev ------------ :: _ _ | | ___ __ __ _ ___| | __ | |/ / '_ \ / _` |/ __| |/ / | <| | | | (_| | (__| < |_|\_\_| |_|\__,_|\___|_|\_\ **A Command-Line Interface framework** Installation is easy via pip: .. code-block:: bash pip install knack Knack can be installed as a non-privileged user to your home directory by adding "--user" as below: .. code-block:: bash pip install knack --user ------------ .. note:: The project is in `initial development phase `__. We recommend pinning to at least a specific minor version when marking **knack** as a dependency in your project. ------------ Usage ===== .. code-block:: python import sys from collections import OrderedDict from knack import CLI, ArgumentsContext, CLICommandsLoader from knack.commands import CommandGroup def abc_str(length=3): import string return string.ascii_lowercase[:length] class MyCommandsLoader(CLICommandsLoader): def load_command_table(self, args): with CommandGroup(self, 'abc', '__main__#{}') as g: g.command('str', 'abc_str') return OrderedDict(self.command_table) def load_arguments(self, command): with ArgumentsContext(self, 'abc str') as ac: ac.argument('length', type=int) super(MyCommandsLoader, self).load_arguments(command) mycli = CLI(cli_name='mycli', commands_loader_cls=MyCommandsLoader) exit_code = mycli.invoke(sys.argv[1:]) sys.exit(exit_code) # $ python mycli.py abc str # "abc" # $ python mycli.py abc str --length 5 # "abcde" # $ python mycli.py abc str --length 100 # "abcdefghijklmnopqrstuvwxyz" More samples and snippets are available at `examples `__. Documentation ============= Documentation is available at `docs `__. Developer Setup =============== In a virtual environment, install the `requirements.txt` file. .. code-block:: bash pip install -r requirements.txt pip install -e . Run Automation ============== This project supports running automation using `tox `__. .. code-block:: bash pip install tox tox Real-world uses =============== - `Azure CLI `__: The Azure CLI 2.0 is Azure's new command line experience for managing Azure resources. - `VSTS CLI `__: A command-line interface for Visual Studio Team Services (VSTS) and Team Foundation Server (TFS). With the VSTS CLI, you can manage and work with resources including pull requests, work items, builds, and more. - `Service Fabric CLI `__: A command-line interface for interacting with Azure Service Fabric clusters and their related entities. Do you use knack in your CLI as well? Open a pull request to include it here. We would love to have it in our list. Release History =============== See `GitHub Releases `__. Contribute Code =============== This project has adopted the `Microsoft Open Source Code of Conduct `__. For more information see the `Code of Conduct FAQ `__ or contact `opencode@microsoft.com `__ with any additional questions or comments. If you would like to become an active contributor to this project, please follow the instructions provided in `Contribution License Agreement `__. License ======= Knack is licensed under `MIT `__. knack-0.9.0/VS2015.sln000066400000000000000000000016561414113720500141730ustar00rootroot00000000000000 Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio 15 VisualStudioVersion = 15.0.26730.16 MinimumVisualStudioVersion = 10.0.40219.1 Project("{888888A0-9F3D-457C-B088-3A5042F75D52}") = "knack", "knack.pyproj", "{27802D2F-7F88-44E9-9818-C960569098A6}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {27802D2F-7F88-44E9-9818-C960569098A6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {27802D2F-7F88-44E9-9818-C960569098A6}.Release|Any CPU.ActiveCfg = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {87C87E54-C86A-44F2-96F7-D282A01692A9} EndGlobalSection EndGlobal knack-0.9.0/VS2017.sln000066400000000000000000000016601414113720500141700ustar00rootroot00000000000000 Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio 15 VisualStudioVersion = 15.0.27004.2002 MinimumVisualStudioVersion = 10.0.40219.1 Project("{888888A0-9F3D-457C-B088-3A5042F75D52}") = "knack", "knack.pyproj", "{27802D2F-7F88-44E9-9818-C960569098A6}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {27802D2F-7F88-44E9-9818-C960569098A6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {27802D2F-7F88-44E9-9818-C960569098A6}.Release|Any CPU.ActiveCfg = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {87C87E54-C86A-44F2-96F7-D282A01692A9} EndGlobalSection EndGlobal knack-0.9.0/azure-pipeline.yml000066400000000000000000000043011414113720500162570ustar00rootroot00000000000000resources: - repo: self trigger: batch: true branches: include: - '*' pr: branches: include: - '*' jobs: - job: AutomationTest timeoutInMinutes: 20 pool: vmImage: 'ubuntu-20.04' strategy: matrix: Python36: python.version: '3.6' tox_env: 'py36' Python37: python.version: '3.7' tox_env: 'py37' Python38: python.version: '3.8' tox_env: 'py38' Python39: python.version: '3.9' tox_env: 'py39' Python310: python.version: '3.10' tox_env: 'py310' steps: - task: UsePythonVersion@0 displayName: 'Use Python $(python.version)' inputs: versionSpec: '$(python.version)' - bash: pip install --upgrade pip tox displayName: 'Install pip and tox' - bash: | set -ev tox displayName: 'Run Test' env: TOXENV: $(tox_env) - job: BuildPythonWheel condition: succeeded() pool: vmImage: 'ubuntu-20.04' steps: - task: UsePythonVersion@0 displayName: Use Python 3.9 inputs: versionSpec: 3.9 - bash: | set -ev : "${BUILD_STAGINGDIRECTORY:?BUILD_STAGINGDIRECTORY environment variable not set}" : "${BUILD_SOURCESDIRECTORY:=$(cd $(dirname $0); cd ../../; pwd)}" cd "${BUILD_SOURCESDIRECTORY}" echo "Build knack" pip install -U pip setuptools wheel python setup.py bdist_wheel -d "${BUILD_STAGINGDIRECTORY}" python setup.py sdist -d "${BUILD_STAGINGDIRECTORY}" displayName: Build Wheel - task: PublishPipelineArtifact@0 displayName: 'Publish Artifact: pypi' inputs: TargetPath: $(Build.ArtifactStagingDirectory) ArtifactName: pypi - task: DownloadPipelineArtifact@1 displayName: 'Download PyPI Packages' inputs: TargetPath: '$(Build.ArtifactStagingDirectory)/pypi' artifactName: pypi - bash: | set -ev cd $BUILD_ARTIFACTSTAGINGDIRECTORY/pypi pwd ls -la displayName: Test Build Wheel knack-0.9.0/docs/000077500000000000000000000000001414113720500135355ustar00rootroot00000000000000knack-0.9.0/docs/README.md000066400000000000000000000042071414113720500150170ustar00rootroot00000000000000Documentation ============= CLI Patterns ------------ - Be consistent with POSIX tools. - CLI success comes from ease and predictability of use so be consistent. - Support Piping and output direction to chain commands together. - Work with GREP, AWK, JQ and other common tools and commands. - Support productivity features like tab completion and parameter value completion. * Commands should follow a "[noun] [noun] [verb]" pattern. * *For nouns that only support a single verb, the command should be named as a single hyphenated verb-noun pair.* * Commands should support all output types (be consistent). * *Exceptions are okay if only a 'raw' format makes sense e.g. XML.* * Commands and arguments should have descriptions. * *Include examples for the less straightforward commands.* * Commands should return an object or dictionary, not strings/bools/etc.; `logging.info(“Upload of myfile.txt successful”)` **NOT** ~~`return “Upload successful”`~~. - Log to ERROR or WARNING for user messages; don't use `print()` function (by default it goes to STDOUT). - STDOUT vs. STDERR: STDOUT is used for actual command output. Everything else to STDERR (e.g. log/status/error messages). Doc Sections ------------ - [CLI](cli.md) - Provides the entry point. - [Commands](commands.md) - Provides logic to register and load commands. - [Arguments](arguments.md) - Provides logic to register and load command arguments. - [Validators](validators.md) - Provides logic to valid or transform command arguments. - [Events](events.md) - Provides an extensible events module that you can hook into. - [Config](config.md) - Provides user-configurable options back by environment variables or config files. - [Logging](logging.md) - Provides consistent logging. - [Completion](completion.md) - Provides tab completion support. - [Prompting](prompting.md) - Provides a consistent user-prompting experience. - [Help](help.md) - Provides command/argument help. - [Output](output.md) - Provides output options and displays command output. - [Query](query.md) - Provides JMESPath query support. - [Testing](testing.md) - Provides a framework to test your commands. knack-0.9.0/docs/arguments.md000066400000000000000000000102151414113720500160630ustar00rootroot00000000000000Arguments ========= Arguments are stored in the `CLICommandsLoader` as an `ArgumentRegistry`. **Customizing Arguments** There are a number of customizations that you can make to the arguments of a command that alter their behavior within the CLI. To modify/enhance your command arguments, use `ArgumentsContext`. - `dest` - This string is the name of the parameter you wish to modify, as specified in the function signature. - `scope` - This string is the level at which your customizations are applied. For example, consider the case where you have commands `mycli mypackage command1` and `mycli mypackage command2`, which both have a parameter `my_param`. ```Python with ArgumentsContext(self, 'mypackage') as ac: ac.argument('my_param', ...) # applies to both command1 and command2 ``` But ```Python with ArgumentsContext(self, 'mypackage command1') as ac: ac.argument('my_param', ...) # applies to command1 but not command2 ``` Like CSS rules, modifications are applied in order from generic to specific. ```Python with ArgumentsContext(self, 'mypackage') as ac: ac.argument('my_param', ...) # applies to both command1 and command2 with ArgumentsContext(self, 'mypackage command1') as ac: ac.argument('my_param', ...) # applies to command1 but not command2 # command2 inherits and build upon the previous changes ``` - `arg_type` - An instance of the `CLIArgumentType` class. This essentially serves as a named, reusable packaging of the `kwargs` that modify your command's argument. It is useful when you want to reuse an argument definition, but is generally not required. It is most commonly used for name type parameters. - `kwargs` - Most likely, you will simply specify keyword arguments in `ArgumentsContext.argument` that will accomplish what you need. Any `kwargs` specified will override or extend the definition in `arg_type`, if provided. The following keyword arguments are supported: - `options_list` - By default, your argument will be exposed as an option in hyphenated form (ex: `my_param` becomes `--my-param`). If you would like to change the option string without changing the parameter name, and/or add a short option, specify the `options_list` kwarg. This is a tuple of two string values, one for a standard option string, and the other for an optional short string. (Ex: `options_list=('--myparam', '-m')`) - `validator` - The name of a callable that takes the function namespace as a parameter. Allows you to perform any custom logic or validation on the entire namespace prior to command execution. Validators are executed after argument parsing, and thus after `type` and `action` have been applied. However, because the order in which validators are executed is random, you should not have multiple validators modifying the same parameter within the namespace. - `completer` - The name of a callable that takes the following parameters `(prefix, action, parsed_args, **kwargs)` and returns a list of completion values. Additionally, the following `kwargs`, supported by argparse, are supported as well: - `nargs` - See https://docs.python.org/3/library/argparse.html#nargs - `action` - See https://docs.python.org/3/library/argparse.html#action - `const` - See https://docs.python.org/3/library/argparse.html#const - `default` - See https://docs.python.org/3/library/argparse.html#default. Note that the default value is inferred from the parameter's default value in the function signature. If specified, this will override that value. - `type` - See https://docs.python.org/3/library/argparse.html#type - `choices` - See https://docs.python.org/3/library/argparse.html#choices. If specified this will also serve as a value completer for people using tab completion. - `required` - See https://docs.python.org/3/library/argparse.html#required. Note that this value is inferred from the function signature depending on whether or not the parameter has a default value. If specified, this will override that value. - `help` - See https://docs.python.org/3/library/argparse.html#help. Generally, you should avoid adding help text in this way, instead opting to create a help file as described above. - `metavar` - See https://docs.python.org/3/library/argparse.html#metavar knack-0.9.0/docs/cli.md000066400000000000000000000014421414113720500146270ustar00rootroot00000000000000CLI === CLI provides the entry point. The CLI object is used as a context, `cli_ctx`, that is passed around throughout the application. You will see this context, `cli_ctx`, referenced frequently. We recommend specifying `cli_name`, `config_dir` and `config_env_var_prefix`. For example: `cli_name` - Name of CLI. Typically the executable name. `config_dir` - Path to config dir. e.g. `os.path.expanduser(os.path.join('~', '.myconfig'))` `config_env_var_prefix` - A prefix for environment variables used in config e.g. `CLI_`. Use the `invoke()` method to invoke commands. For example: ```Python mycli = CLI(commands_loader_cls=MyCommandsLoader) exit_code = mycli.invoke(sys.argv[1:]) ``` How do I? --------- ### Show my own version info ### Subclass `CLI` and override `get_cli_version()`. knack-0.9.0/docs/commands.md000066400000000000000000000033661414113720500156700ustar00rootroot00000000000000Commands ======== The commands loader contains a command table. A command table is a dictionary from command name to a `CLICommand` instance. **Writing a Command** Write your command as a simple function, specifying your arguments as the parameter names. When choosing names, it is recommended that you look at similar commands and follow those naming conventions to take advantage of any aliasing that may already be in place. If you specify a default value in your function signature, this will flag the argument as optional and will automatically display the default value in the help text for the command. Any parameters that do not have a default value are required and will automatically appear in help with the [Required] label. The required and default behaviors for arguments can be overridden if needed with the `ArgumentsContext` function but this is not generally needed. There are a few different ways to register commands (see the examples directory for working samples). Typically, you would use `CommandGroup` to register commands. For example: ```Python def hello_command_handler(): return ['hello', 'world'] class MyCommandsLoader(CLICommandsLoader): def load_command_table(self, args): with CommandGroup(__name__, self, 'hello', '__main__#{}') as g: g.command('world', 'hello_command_handler') return OrderedDict(self.command_table) mycli = CLI(cli_name='mycli', commands_loader_cls=MyCommandsLoader) exit_code = mycli.invoke(sys.argv[1:]) ``` You can also provide your own command class to the CLICommandsLoader like so: ```Python class MyCommandsLoader(CLICommandsLoader): def __init__(self, cli_ctx=None): super(MyCommandsLoader, self).__init__(cli_ctx=cli_ctx, command_cls=MyCustomCLICommand) ``` knack-0.9.0/docs/completion.md000066400000000000000000000043101414113720500162260ustar00rootroot00000000000000# Completion # Tab completion is provided by [argcomplete](http://pypi.python.org/pypi/argcomplete). In your environment, you can enable it with `eval "$(register-python-argcomplete CLI_NAME)"`. You will then get tab completion for all command names, command arguments and global arguments. ## How to ship tab completion support with your pip package ## With the PyPI package of your CLI, you can include a shell script. For example: `mycli.completion.sh` ```Bash case $SHELL in */zsh) echo 'Enabling ZSH compatibility mode'; autoload bashcompinit && bashcompinit ;; */bash) ;; *) esac eval "$(register-python-argcomplete mycli)" ``` In your `setup.py` file, include this script so it is included in your package. ```Python setup( scripts=['mycli.completion.sh', ...], ) ``` Once your CLI has been installed with `pip`, instruct your users to source your completion file. ```Bash source mycli.completion.sh ``` ## How to ship tab completion support with your other installers ## The method above will not work for other installers as `register-python-argcomplete` is a command that gets enabled when `argcomplete` is installed with pip. `register-python-argcomplete` is a command that produces a shell script that you can consume directly; you can see this with running `register-python-argcomplete --no-defaults mycli`. We directly use the output of the above command. `mycli.completion` ```Bash _python_argcomplete() { local IFS=$'\013' local SUPPRESS_SPACE=0 if compopt +o nospace 2> /dev/null; then SUPPRESS_SPACE=1 fi COMPREPLY=( $(IFS="$IFS" \ COMP_LINE="$COMP_LINE" \ COMP_POINT="$COMP_POINT" \ COMP_TYPE="$COMP_TYPE" \ _ARGCOMPLETE_COMP_WORDBREAKS="$COMP_WORDBREAKS" \ _ARGCOMPLETE=1 \ _ARGCOMPLETE_SUPPRESS_SPACE=$SUPPRESS_SPACE \ "$1" 8>&1 9>&2 1>/dev/null 2>/dev/null) ) if [[ $? != 0 ]]; then unset COMPREPLY elif [[ $SUPPRESS_SPACE == 1 ]] && [[ "$COMPREPLY" =~ [=/:]$ ]]; then compopt -o nospace fi } complete -o nospace -F _python_argcomplete "mycli" ``` Ship the above file and include it as part of each installer. knack-0.9.0/docs/config.md000066400000000000000000000017641414113720500153340ustar00rootroot00000000000000Config ====== The config system is used for user-configurable options. They are backed by environment variables and config files. Here are the layers of config, with each layer overriding the layer below it: | Config hierarchy | |-----------------------| | Command line argument | | Environment variable | | Config file | Use the `config_dir` and `config_env_var_prefix` options in the constructor of `CLI` to set the config directory and environment variable prefix. Here's an example: Let's assume `config_dir=~/.myconfig` and `config_env_var_prefix='CLI'`. Environment variable format `export PREFIX_SECTION_OPTION=value`. Config file format: ``` [section] option = value ``` So to set the output type of commands, a user can set the environment variable `CLI_CORE_OUTPUT` or specify the section and option in the config file. The environment variable will override the config file. Lastly, some configurations (like output type) can be specified on a command-by-command basis also. knack-0.9.0/docs/events.md000066400000000000000000000016361414113720500153710ustar00rootroot00000000000000Events ====== An extensible event framework is built-in. Things to keep in mind: - The order of event handler calls is not guaranteed. - Event handlers cannot return anything. However, they can modify the arguments they receive. Register for an event --------------------- To register for an event, get the context, `cli_ctx`, and call `register_event()`. When an event is raised, the first argument is the context, `cli_ctx` and `kwargs` is any keyword arguments passed in by the raiser of the event (so this is event specific). ```Python def event_handler(cli_ctx, **kwargs): print(kwargs) self.cli_ctx.register_event(EVENT_NAME, event_handler) ``` Raise your own events --------------------- The framework has some events built-in. For the full list of events, see [events](../knack/events.py). You can also add your own events. ```Python self.cli_ctx.raise_event(EVENT_NAME, arg1=arg1, arg2=arg2, ...) ``` knack-0.9.0/docs/help.md000066400000000000000000000076041414113720500150160ustar00rootroot00000000000000# Help # Help authoring for commands is done in a number of places. The YAML-based system is the recommended way to update command and group help text. Command help starts with the docstring text on the handler, if available. Code can specify values that replace the docstring contents. YAML is the final override for help content and is the recommended way for authoring command and group help. Note that group help can only be authored via YAML. Here are the layers of help, with each layer overriding the layer below it: | Help Display | |----------------| | YAML Authoring | | Code Specified | | Docstring | The YAML syntax is described [here](http://www.yaml.org/spec/1.2/spec.html "here"). Authoring note: it is not recommended to use the product code to author command/group help--YAML is the recommended way (see above). This information is provided for completeness and may be useful for fixing small typos in existing help text. ### Example YAML help ### This is example YAML help for the command `mycli hello world`.
from knack.help_files import helps

helps['hello world'] = """
            type: command
            short-summary: Say hello to the world.
            long-summary: Longer summary of saying hello.
            parameters:
                - name: --language -l
                  type: string
                  short-summary: 'Language to say hello in'
                  long-summary: |
                      Longer summary with newlines preserved. Preserving newlines is helpful for paragraph breaks.
                  populator-commands:
                  - mycli hello languages
                  - These indicate where values can be retrieved for input to this command
                - name: --another-parameter
                  short-summary: These parameter names must match what is shown in the command's CLI help output, including abbreviation.
            examples:
                - name: Document a parameter that doesn't exist
                  text: >
                    You will get an error when you show help for the command stating there is an extra parameter.
                - name: Collapse whitespace in YAML
                  text: >
                    The > character collapses multiple lines into a single line, which is good for on-screen wrapping.
            """
You can also document groups using the same format.
helps['hello'] = """
            type: group
            short-summary: Commands to say hello
            long-summary: Longer summary of the hello group
            examples:
                - name: Example name
                  text: Description
            """
# Tips to write effective help for your command - Make sure the doc contains all the details that someone unfamiliar with the API needs to use the command. - Examples are worth a thousand words. Provide examples that cover common use cases. - Don't use "etc". Sometimes it makes sense to spell out a list completely. Sometimes it works to say "like ..." instead of "..., etc". - The short summary for a group should start with "Commands to...". - Use active voice. For example, say "Update web app configurations" instead of "Updates web app configurations" or "Updating web app configurations". - Don't use highly formal language. If you imagine that another dev sat down with you and you were telling him what he needs to know to use the command, that's exactly what you need to write, in those words. # Testing Authored Help # To verify the YAML help is correctly formatted, the command/group's help command must be executed at runtime. For example, to verify `mycli hello world`, run the command `mycli hello world -h` and verify the text. Runtime is also when help authoring errors will be reported, such as documenting a parameter that doesn't exist. Errors will only show when the CLI help is executed, so verifying the CLI help is required to ensure your authoring is correct. knack-0.9.0/docs/logging.md000066400000000000000000000032241414113720500155060ustar00rootroot00000000000000Logging ======= | Log Level | Usage | |-------------|---------------------------------------------------------------------------------------------| | Critical | A serious error, program may be unable to continue running. | | Error | Serious problem, software has not been able to perform some function. | | Warning | Something you want to draw the attention of the user to. Software still working as expected | | Info | Confirmation that things are working as expected. | | Debug | Detailed information, useful for diagnostics. | - By default, log messages Warning and above are shown to the user. - `--verbose` - This flag changes the logging level to Info and above. - `--debug` - This flag changes the logging level to Debug and above. - `--only-show-errors` - This flag changes the logging level to Error only, suppressing Warning. * All log messages go to STDERR (not STDOUT) * Log to Error or Warning for user messages instead of using the `print()` function * If file logging has been enabled by the user, full Debug logs are saved to rotating log files. * File logging is enabled if section=logging, option=enable_log_file is set in config (see [config](config.md)). Get the logger -------------- ```Python from knack.log import get_logger logger = get_logger(__name__) ``` See [Python Logger documentation](https://docs.python.org/3/library/logging.html#logging.Logger.debug) for how to format log messages. knack-0.9.0/docs/output.md000066400000000000000000000012111414113720500154120ustar00rootroot00000000000000Output ====== In general, all commands produce an output object that can be converted to any of the available output types by the CLI core. In other words, commands are output type independent. Supported output types: - JSON (human readable, can handle complex objects, useful for queries. - JSON colored - Table (human readable format) - TSV (great for *nix scripting e.g. with awk, grep, etc.) Table and TSV format can't display nested objects so a user can use the `--query` argument to select the properties they want to display. The `table_transformer` is available when registering a command to define how it should look in table output. knack-0.9.0/docs/prompting.md000066400000000000000000000075301414113720500161030ustar00rootroot00000000000000Prompting ========= We provide some utilities for prompting during command execution. Use this sparingly and provide non-interactive ways to specify such arguments. Each of these utility methods does a TTY check and raises a `NoTTYException`. Handle this appropriately in each case. Examples -------- **A basic message prompt** ```Python from knack.prompting import prompt ``` Prompt for any user input. ```Python >>> username = prompt('What is your name? ') What is your name? Jonathon >>> username 'Jonathon' ``` If you provide a help string, the user can type '?' to request this help string. All the prompting types support this functionality. ```Python >>> username = prompt('What is your name? ', help_string='The name you prefer to be known by.') What is your name? ? The name you prefer to be known by. What is your name? Jon >>> username 'Jon' ``` **Integer based prompt** ```Python from knack.prompting import prompt_int ``` It's straightforward to get the number entered. ```Python >>> number = prompt_int('How many do you want to create? ') How many do you want to create? 10 >>> number 10 ``` It has built-in checks that the user has entered an integer. ```Python >>> number = prompt_int('How many do you want to create? ') How many do you want to create? hello hello is not a valid number How many do you want to create? is not a valid number How many do you want to create? ten ten is not a valid number How many do you want to create? 10 >>> number 10 ``` **Password prompts** ```Python from knack.prompting import prompt_pass ``` This is a simple password prompt. As you can see, the entered password is not printed to the screen but is saved in the variable. ```Python >>> userpassword = prompt_pass() Password: >>> userpassword 'password123!@#' ``` You can change the prompt message with the `msg` argument. ```Python >>> secret = prompt_pass(msg='Client secret: ') Client secret: >>> secret 'm#@$%453edf' ``` If you're requesting a new password from the user, use the `confirm` argument. ```Python >>> userpassword = prompt_pass(msg='New resource password: ', confirm=True) New resource password: Confirm New resource password: >>> userpassword 'mysimplepassword' ``` If the passwords don't, the user will get a warning message and be required to enter again. ```Python >>> userpassword = prompt_pass(msg='New resource password: ', confirm=True) New resource password: Confirm New resource password: Passwords do not match. New resource password: ``` **Boolean prompts** ```Python from knack.prompting import prompt_y_n ``` ```Python >>> response = prompt_y_n('Do you agree to this? ') Do you agree to this? (y/n): y >>> response True ``` ```Python >>> response = prompt_y_n('Do you agree to this? ') Do you agree to this? (y/n): n >>> response False ``` A default value can be provided if a user does not specify one. ```Python >>> prompt_y_n('Do you agree to this? ', default='y') Do you agree to this? (Y/n): True ``` We also have a similar prompt for True/False: ```Python from knack.prompting import prompt_t_f ``` **Choice list prompts** ```Python from knack.prompting import prompt_choice_list ``` Prompt the user to choose from a choice list. You will be given the index of the list item the user selected. ```Python >>> a_list = ['size A', 'size B', 'size C'] >>> choice_index = prompt_choice_list('Default output type? ', a_list) Default output type? [1] size A [2] size B [3] size C Please enter a choice [1]: 3 >>> choice_index 2 ``` You can also provide a list with descriptions. ```Python >>> a_list = [{'name':'size A', 'desc':'A smaller size'}, {'name':'size B', 'desc':'An average size'}, {'name':'size C', 'desc':'A bigger size'}] >>> choice_index = prompt_choice_list('Default output type? ', a_list) Default output type? [1] size A - A smaller size [2] size B - An average size [3] size C - A bigger size Please enter a choice [1]: 2 ``` knack-0.9.0/docs/query.md000066400000000000000000000002031414113720500152170ustar00rootroot00000000000000Query ===== Query support is provided through [JMESPath](http://jmespath.org). This allows filter and project of command output. knack-0.9.0/docs/release-checklist.md000066400000000000000000000021411414113720500174440ustar00rootroot00000000000000# Release Checklist ## A Maintainer's Guide to Releasing Knack All releases will be of the form X.Y.Z where X is the major version number, Y is the minor version number and Z is the patch release number. This project strictly follows [semantic versioning](http://semver.org/) so following this step is critical. Modify the version number in `setup.py` and create a pull request for the changes. ### Release with Azure DevOps (preferred option) Once the changes have been merged to `dev`, create a tag on GitHub for that commit. Follow the format of other releases in the release notes you create on GitHub. Visit [Knack Release](https://dev.azure.com/azure-sdk/internal/_release?definitionId=83) to publish to PyPI. ### Release manually (backup option) Once the changes have been merged to `dev`, continue with the rest of the release. ``` git clone https://github.com/microsoft/knack cd knack python setup.py sdist bdist_wheel ``` ``` pip install twine ``` ``` export TWINE_REPOSITORY_URL=https://upload.pypi.org/legacy/ export TWINE_USERNAME=A_USERNAME export TWINE_PASSWORD=A_SECRET twine upload dist/* ``` knack-0.9.0/docs/testing.md000066400000000000000000000116541414113720500155430ustar00rootroot00000000000000Command Testing =============== ## Overview There are two types of automated tests you can add. They are the [unit tests](https://en.wikipedia.org/wiki/Unit_testing) and the [integration tests](https://en.wikipedia.org/wiki/Integration_testing). For unit tests, we support unit tests written in the forms of both standard [unittest](https://docs.python.org/3/library/unittest.html) and [nosetest](http://nose.readthedocs.io/en/latest/writing_tests.html). For integration tests, we provide `ScenarioTest` to support [VCR.py](https://vcrpy.readthedocs.io/en/latest/) based replayable tests. ## About replayable tests HTTP communication is captured and recorded, the integration tests can be replay in automation without a live testing environment. We rely on [VCR.py](https://vcrpy.readthedocs.io/en/latest/) to record and replay HTTP communications. On top of the VCR.py, we build `ScenarioTest` to facilitate authoring tests. ## Authoring Scenario Tests The `ScenarioTest` class is the preferred base class for and VCR based test cases from now on. ### Sample 1. Basic fixture ```Python from knack.testsdk import ScenarioTest class TestMyScenarios(ScenarioTest): def __init__(self, method_name): super(TestMyScenarios, self).__init__(mycli, method_name) def test_abc_list(self): self.cmd('abc list') ``` Note: 1. When the test is run without recording file, the test will be run under live mode. A recording file will be created at `recording/.yaml` 2. Wrap the command in `self.cmd` method. It will assert the exit code of the command to be zero. 3. All the functions and classes your need for writing tests are included in `knack.testsdk` namespace. It is recommended __not__ to reference the sub-namespace to avoid breaking changes. ### Sample 2. Validate the return value in JSON ``` Python class TestMyScenarios(ScenarioTest): def __init__(self, method_name): super(TestMyScenarios, self).__init__(mycli, method_name) def test_abc_list(self): result_list = self.cmd('abc list').get_output_in_json() assert len(result_list) > 0 ``` Note: 1. The return value of `self.cmd` is an instance of class `ExecutionResult`. It has the exit code and stdout as its properties. 2. `get_output_in_json` deserialize the output to a JSON object Tip: 1. Don't make any rigid assertions based on any assumptions which may not stand in a live test environment. ### Sample 3. Validate the return JSON value using JMESPath ``` Python from knack.testsdk import ScenarioTest, JMESPathCheck class TestMyScenarios(ScenarioTest): def __init__(self, method_name): super(TestMyScenarios, self).__init__(mycli, method_name) def test_abc_list(self): self.cmd('abc list', checks=[JMESPathCheck('length(@)', 26)]) ``` Note: 1. What is JMESPath? [JMESPath is a query language for JSON](http://jmespath.org/) 2. If a command is return value in JSON, multiple JMESPath based check can be added to the checks list to validate the result. 3. In addition to the `JMESPatchCheck`, there are other checks list `NoneCheck` which validate the output is `None`. The check mechanism is extensible. Any callable accept `ExecutionResult` can act as a check. ## Recording Tests ### Record test for the first time After the test is executed, a recording file will be generated at `recording/.yaml`. The recording file will be created no matter the test pass or not. The behavior makes it easy for you to find issues when a test fails. If you make changes to the test, delete the recording and rerun the test, a new recording file will be regenerated. It is a good practice to add a recording file to the local git cache, which makes it easy to diff the different versions of recording to detect issues or changes. Once the recording file is generated, execute the test again. This time the test will run in playback mode. The execution is offline. If the replay passes, you can commit the tests as well as recordings. ### Run test live When the recording file is missing, the test framework will execute the test in live mode. You can force tests to be run live by set following environment variable: ``` export CLI_TEST_RUN_LIVE='True' ``` Also, you can author tests which are for live test only. Just derive the test class from `LiveTest`. ## Test Issues Here are some common issues that occur when authoring tests that you should be aware of. - **Non-deterministic results**: If you find that a test will pass on some playbacks but fail on others, there are a couple possible things to check: 1. check if your command makes use of concurrency. 2. check your parameter aliasing (particularly if it complains that a required parameter is missing that you know is there) - **Paths**: When including paths in your tests as parameter values, always wrap them in double quotes. While this isn't necessary when running from the command line (depending on your shell environment), it will likely cause issues with the test framework. knack-0.9.0/docs/validators.md000066400000000000000000000014461414113720500162340ustar00rootroot00000000000000Validators ========== Validators allow you to validate or transform command arguments just before execution. Knack supports both command-level and argument-level validators. If a command has a command-level validator, then any argument-level validators that would ordinarily be applied are ignored. i.e. A command can have at most command validator or many argument level validators. **Command-level Validators** Command-level validators can operate on any arguments on the command. This is useful when you need to control the validation sequence. **Argument-level Validators** Argument-level validators should only operate on a single argument. The order argument-level validators are executed in is not guaranteed so don't use multiple argument-level validators that rely on the same arguments. knack-0.9.0/examples/000077500000000000000000000000001414113720500144235ustar00rootroot00000000000000knack-0.9.0/examples/exapp000077500000000000000000000045601414113720500154730ustar00rootroot00000000000000#!/usr/bin/env python """ User registers commands with CommandGroups """ import os import sys from collections import OrderedDict from knack import CLI from knack.commands import CLICommandsLoader, CommandGroup from knack.arguments import ArgumentsContext from knack.help import CLIHelp from knack.help_files import helps cli_name = os.path.basename(__file__) helps['abc'] = """ type: group short-summary: Manage the alphabet of words. """ helps['abc list'] = """ type: command short-summary: List the alphabet. examples: - name: It's pretty straightforward. text: {cli_name} abc list """.format(cli_name=cli_name) def a_test_command_handler(): return [{'a': 1, 'b': 1234}, {'a': 3, 'b': 4}] def abc_list_command_handler(): import string return list(string.ascii_lowercase) def hello_command_handler(myarg=None, abc=None): return ['hello', 'world', myarg, abc] WELCOME_MESSAGE = r""" _____ _ _____ / ____| | |_ _| | | | | | | | | | | | | | |____| |____ _| |_ \_____|______|_____| Welcome to the cool new CLI! """ class MyCLIHelp(CLIHelp): def __init__(self, cli_ctx=None): super(MyCLIHelp, self).__init__(cli_ctx=cli_ctx, privacy_statement='My privacy statement.', welcome_message=WELCOME_MESSAGE) class MyCommandsLoader(CLICommandsLoader): def load_command_table(self, args): with CommandGroup(self, 'hello', '__main__#{}') as g: g.command('world', 'hello_command_handler', confirmation=True) with CommandGroup(self, 'abc', '__main__#{}') as g: g.command('list', 'abc_list_command_handler') g.command('show', 'a_test_command_handler') return super(MyCommandsLoader, self).load_command_table(args) def load_arguments(self, command): with ArgumentsContext(self, 'hello world') as ac: ac.argument('myarg', type=int, default=100) super(MyCommandsLoader, self).load_arguments(command) mycli = CLI(cli_name=cli_name, config_dir=os.path.expanduser(os.path.join('~', '.{}'.format(cli_name))), config_env_var_prefix=cli_name, commands_loader_cls=MyCommandsLoader, help_cls=MyCLIHelp) exit_code = mycli.invoke(sys.argv[1:]) sys.exit(exit_code) knack-0.9.0/examples/exapp2000066400000000000000000000161521414113720500155520ustar00rootroot00000000000000#!/usr/bin/env python """ User registers commands with CommandGroups """ import os import sys from collections import OrderedDict from knack import CLI from knack.commands import CLICommandsLoader, CommandGroup from knack.arguments import ArgumentsContext from knack.help import CLIHelp from knack.help_files import helps cli_name = os.path.basename(__file__) helps['abc'] = """ type: group short-summary: Manage the alphabet of words. """ helps['abc list'] = """ type: command short-summary: List the alphabet. examples: - name: It's pretty straightforward. text: {cli_name} abc list """.format(cli_name=cli_name) helps['abc first'] = """ type: command short-summary: List the first several letters in the alphabet. examples: - name: Show the list of abc text: {cli_name} abc first --number 3 """.format(cli_name=cli_name) helps['abc last'] = """ type: command short-summary: List the last several letters in the alphabet. examples: - name: Show the list of xyz text: {cli_name} abc last --number 3 """.format(cli_name=cli_name) helps['ga'] = """ type: group short-summary: A general available command group """ helps['pre'] = """ type: group short-summary: A preview command group """ helps['exp'] = """ type: group short-summary: An experimental command group """ helps['demo'] = """ type: group short-summary: A command group for demos. """ helps['demo arg'] = """ type: group short-summary: A command showing how to use arguments. """ def abc_show_command_handler(): """ Show a JSON mapping of letters to their ASCII values """ import string lower = {} for ch in string.ascii_lowercase: lower[ch] = ord(ch) upper = {} for ch in string.ascii_uppercase: upper[ch] = ord(ch) return {"lowercase": lower, "uppercase": upper} def abc_list_command_handler(): import string return list(string.ascii_lowercase) def abc_first_command_handler(number=5): import string return list(string.ascii_lowercase)[0:number] def abc_last_command_handler(number=5): import string return list(string.ascii_lowercase)[-number:] def range_command_handler(start=0, end=5): """ Get a list of natural numbers from start to end :param start: the lower bound :param end: the higher bound :return: """ return list(range(int(start), int(end) + 1)) def sample_json_handler(): """ Get a sample JSON string """ # https://docs.microsoft.com/en-us/rest/api/resources/subscriptions/list#examples result = { "id": "/subscriptions/291bba3f-e0a5-47bc-a099-3bdcb2a50a05", "subscriptionId": "291bba3f-e0a5-47bc-a099-3bdcb2a50a05", "tenantId": "31c75423-32d6-4322-88b7-c478bdde4858", "displayName": "Example Subscription", "state": "Enabled", "subscriptionPolicies": { "locationPlacementId": "Internal_2014-09-01", "quotaId": "Internal_2014-09-01", "spendingLimit": "Off" }, "authorizationSource": "RoleBased", "managedByTenants": [ { "tenantId": "8f70baf1-1f6e-46a2-a1ff-238dac1ebfb7" } ] } return result def sample_logger_handler(): """ Print logs to stderr. """ print("""This is a demo for logging. The logging level can be controlled with: --only-show-errors: Show ERROR logs and above : Show WARNING logs and above --verbose: Show INFO logs and above --debug: Show DEBUG logs and above""") from knack.log import get_logger logger = get_logger(__name__) logger.debug("This is a debug log entry.") logger.info("This is a info log entry.") logger.warning("This is a warning log entry.") logger.error("This is a error log entry.") logger.critical("This is a critical log entry.") def hello_command_handler(greetings=None): """ Say "Hello World!" and my warm greetings :param greetings: My warm greetings """ return ['Hello World!', greetings] def demo_arg_handler(move=None): if move: print("Your move was: {}".format(move)) return print("Nothing to do.") WELCOME_MESSAGE = r""" _____ _ _____ / ____| | |_ _| | | | | | | | | | | | | | |____| |____ _| |_ \_____|______|_____| Welcome to the cool new CLI! """ class MyCLIHelp(CLIHelp): def __init__(self, cli_ctx=None): super(MyCLIHelp, self).__init__(cli_ctx=cli_ctx, privacy_statement='My privacy statement.', welcome_message=WELCOME_MESSAGE) class MyCommandsLoader(CLICommandsLoader): def load_command_table(self, args): with CommandGroup(self, '', '__main__#{}') as g: g.command('hello', 'hello_command_handler', confirmation=True) g.command('sample-json', 'sample_json_handler') g.command('sample-logger', 'sample_logger_handler') with CommandGroup(self, 'abc', '__main__#{}') as g: g.command('list', 'abc_list_command_handler') g.command('show', 'abc_show_command_handler') g.command('get', 'abc_show_command_handler', deprecate_info=g.deprecate(redirect='show', hide='1.0.0')) g.command('first', 'abc_first_command_handler', is_preview=True) g.command('last', 'abc_last_command_handler', is_experimental=True) # A GA command group with CommandGroup(self, 'ga', '__main__#{}') as g: g.command('range', 'range_command_handler') # A preview command group with CommandGroup(self, 'pre', '__main__#{}', is_preview=True) as g: g.command('first', 'abc_first_command_handler', is_preview=True) g.command('range', 'range_command_handler') # An experimental command group with CommandGroup(self, 'exp', '__main__#{}', is_experimental=True) as g: g.command('range', 'range_command_handler') # A deprecated command group with CommandGroup(self, 'dep', '__main__#{}', deprecate_info=g.deprecate(redirect='ga', hide='1.0.0')) as g: g.command('range', 'range_command_handler') with CommandGroup(self, 'demo', '__main__#{}') as g: g.command('arg', 'demo_arg_handler') return super(MyCommandsLoader, self).load_command_table(args) def load_arguments(self, command): with ArgumentsContext(self, 'ga range') as ac: ac.argument('start', type=int, is_preview=True) ac.argument('end', type=int, is_experimental=True) with ArgumentsContext(self, 'demo arg') as ac: ac.argument('move', choices=['rock', 'paper', 'scissors']) super(MyCommandsLoader, self).load_arguments(command) class MyCLI(CLI): def get_cli_version(self): return '0.1.0' mycli = MyCLI(cli_name=cli_name, config_dir=os.path.expanduser(os.path.join('~', '.{}'.format(cli_name))), config_env_var_prefix=cli_name, commands_loader_cls=MyCommandsLoader, help_cls=MyCLIHelp) exit_code = mycli.invoke(sys.argv[1:]) sys.exit(exit_code) knack-0.9.0/examples/test_exapp000077500000000000000000000041711414113720500165300ustar00rootroot00000000000000#!/usr/bin/env python """ Create ScenarioTests from a CLI you've created. """ import os import sys import unittest from collections import OrderedDict from knack import CLI from knack.commands import CLICommandsLoader, CommandGroup from knack.arguments import ArgumentsContext # DEFINE MY CLI def a_test_command_handler(): return [{'a': 1, 'b': 1234}, {'a': 3, 'b': 4}] def abc_list_command_handler(): import string return list(string.ascii_lowercase) def hello_command_handler(myarg=None, abc=None): return ['hello', 'world', myarg, abc] class MyCommandsLoader(CLICommandsLoader): def load_command_table(self, args): with CommandGroup(self, 'hello', '__main__#{}') as g: g.command('world', 'hello_command_handler', confirmation=True) with CommandGroup(self, 'abc', '__main__#{}') as g: g.command('list', 'abc_list_command_handler') g.command('show', 'a_test_command_handler') return super(MyCommandsLoader, self).load_command_table(args) def load_arguments(self, command): with ArgumentsContext(self, 'hello world') as ac: ac.argument('myarg', type=int, default=100) super(MyCommandsLoader, self).load_arguments(command) name = 'exapp4' mycli = CLI(cli_name=name, config_dir=os.path.expanduser(os.path.join('~', '.{}'.format(name))), config_env_var_prefix=name, commands_loader_cls=MyCommandsLoader) # END OF - DEFINE MY CLI # DEFINE MY TESTS from knack.testsdk import ScenarioTest, JMESPathCheck class TestMyScenarios(ScenarioTest): def __init__(self, method_name): super(TestMyScenarios, self).__init__(mycli, method_name) def test_hello_world_yes(self): self.cmd('hello world --yes', checks=[ JMESPathCheck('length(@)', 4) ]) def test_abc_list(self): self.cmd('abc list', checks=[ JMESPathCheck('length(@)', 26) ]) def test_abc_show(self): self.cmd('abc show', checks=[ JMESPathCheck('length(@)', 2), ]) # END OF - DEFINE MY TESTS if __name__ == '__main__': unittest.main() knack-0.9.0/knack.pyproj000066400000000000000000000116621414113720500151470ustar00rootroot00000000000000 Debug 2.0 {27802d2f-7f88-44e9-9818-c960569098a6} knack\__init__.py . . {888888a0-9f3d-457c-b088-3a5042f75d52} Standard Python launcher MSBuild|env2|$(MSBuildProjectFullPath) 10.0 Code env2 2.7 env2 (Python 2.7 (32-bit)) Scripts\python.exe Scripts\pythonw.exe PYTHONPATH X86 {52196499-2eb9-4ba7-924a-ca67b294886b} 2.7 env2 (Python 2.7) Scripts\python.exe Scripts\pythonw.exe Lib\ PYTHONPATH X86 env3 3.6 env3 (Python 3.6 (64-bit)) Scripts\python.exe Scripts\pythonw.exe PYTHONPATH X64 knack-0.9.0/knack/000077500000000000000000000000001414113720500136745ustar00rootroot00000000000000knack-0.9.0/knack/__init__.py000066400000000000000000000011321414113720500160020ustar00rootroot00000000000000# -------------------------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- import sys from knack.cli import CLI from knack.commands import CLICommandsLoader, CLICommand from knack.arguments import ArgumentsContext from knack.help import CLIHelp __all__ = ['CLI', 'CLICommandsLoader', 'CLICommand', 'CLIHelp', 'ArgumentsContext'] knack-0.9.0/knack/arguments.py000066400000000000000000000533501414113720500162610ustar00rootroot00000000000000# -------------------------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- import argparse from collections import defaultdict from .deprecation import Deprecated from .preview import PreviewItem from .experimental import ExperimentalItem from .log import get_logger from .util import CLIError, status_tag_messages logger = get_logger(__name__) class CLIArgumentType(object): REMOVE = '---REMOVE---' def __init__(self, overrides=None, **kwargs): """A base CLI Argument Type that can be applied to multiple command arguments :param overrides: The base argument that you are overriding :type overrides: knack.arguments.CLIArgumentType :param kwargs: Possible values: `options_list`, `validator`, `completer`, `nargs`, `action`, `const`, `default`, `type`, `choices`, `required`, `help`, `metavar`. See /docs/arguments.md. """ if isinstance(overrides, str): raise ValueError("Overrides has to be a {} (cannot be a string)".format(CLIArgumentType.__name__)) options_list = kwargs.get('options_list', None) if options_list and isinstance(options_list, str): kwargs['options_list'] = [options_list] self.settings = {} self.update(overrides, **kwargs) def update(self, other=None, **kwargs): if other: self.settings.update(**other.settings) self.settings.update(**kwargs) class CLICommandArgument(object): NAMED_ARGUMENTS = ['options_list', 'validator', 'completer', 'arg_group', 'deprecate_info', 'preview_info', 'experimental_info', 'default_value_source'] def __init__(self, dest=None, argtype=None, **kwargs): """An argument that has a specific destination parameter. :param dest: The parameter that this argument is for :type dest: str :param argtype: The argument type for this command argument :type argtype: knack.arguments.CLIArgumentType :param kwargs: see knack.arguments.CLIArgumentType """ self.type = CLIArgumentType(overrides=argtype, **kwargs) if dest: self.type.update(dest=dest) # We'll do an early fault detection to find any instances where we have inconsistent # set of parameters for argparse if not self.options.get('dest', False): raise ValueError('Missing dest') if not self.options_list: # pylint: disable=access-member-before-definition self.options_list = ('--{}'.format(self.options['dest'].replace('_', '-')),) def __getattr__(self, name): if name in self.NAMED_ARGUMENTS: return self.type.settings.get(name, None) if name == 'name': return self.type.settings.get('dest', None) if name == 'options': return {key: value for key, value in self.type.settings.items() if key != 'options' and key not in self.NAMED_ARGUMENTS and not value == CLIArgumentType.REMOVE} if name == 'choices': return self.type.settings.get(name, None) raise AttributeError(name) def __setattr__(self, name, value): # pylint: disable=inconsistent-return-statements if name == 'type': return super().__setattr__(name, value) self.type.settings[name] = value class ArgumentRegistry(object): """A registry of all the arguments registered""" def __init__(self): self.arguments = defaultdict(lambda: {}) def register_cli_argument(self, scope, dest, argtype, **kwargs): """ Add an argument to the argument registry :param scope: The command level to apply the argument registration (e.g. 'mygroup mycommand') :type scope: str :param dest: The parameter/destination that this argument is for :type dest: str :param argtype: The argument type for this command argument :type argtype: knack.arguments.CLIArgumentType :param kwargs: see knack.arguments.CLIArgumentType """ argument = CLIArgumentType(overrides=argtype, **kwargs) self.arguments[scope][dest] = argument def get_cli_argument(self, command, name): """ Get the argument for the command after applying the scope hierarchy :param command: The command that we want the argument for :type command: str :param name: The name of the argument :type name: str :return: The CLI command after all overrides in the scope hierarchy have been applied :rtype: knack.arguments.CLIArgumentType """ parts = command.split() result = CLIArgumentType() for index in range(0, len(parts) + 1): probe = ' '.join(parts[0:index]) override = self.arguments.get(probe, {}).get(name, None) if override: result.update(override) return result class ArgumentsContext(object): def __init__(self, command_loader, command_scope, **kwargs): # pylint: disable=unused-argument """ Context manager to register arguments :param command_loader: The command loader that arguments should be registered into :type command_loader: knack.commands.CLICommandsLoader :param command_scope: The scope to which arguments in this context apply. More specific scopes will override less specific scopes in the event of a conflict. :type command_scope: str """ self.command_loader = command_loader self.command_scope = command_scope self.is_stale = False def __enter__(self): return self def __exit__(self, exc_type, exc_val, exc_tb): self.is_stale = True def _applicable(self): if self.command_loader.skip_applicability: return True return self.command_loader.cli_ctx.invocation.data['command_string'].startswith(self.command_scope) def _check_stale(self): if self.is_stale: message = "command authoring error: argument context '{}' is stale! " \ "Check that the subsequent block for has a corresponding `as` " \ "statement.".format(self.command_scope) logger.error(message) raise CLIError(message) def _get_parent_class(self, **kwargs): # wrap any existing action action = kwargs.get('action', None) parent_class = argparse.Action # action is either a user-defined Action class or a string referring a library-defined Action if isinstance(action, type) and issubclass(action, argparse.Action): parent_class = action elif isinstance(action, str): parent_class = self.command_loader.cli_ctx.invocation.parser._registries['action'][action] # pylint: disable=protected-access return parent_class def _handle_deprecations(self, argument_dest, **kwargs): def _handle_argument_deprecation(deprecate_info): parent_class = self._get_parent_class(**kwargs) class DeprecatedArgumentAction(parent_class): def __call__(self, parser, namespace, values, option_string=None): if not hasattr(namespace, '_argument_deprecations'): setattr(namespace, '_argument_deprecations', [deprecate_info]) else: namespace._argument_deprecations.append(deprecate_info) # pylint: disable=protected-access try: super().__call__(parser, namespace, values, option_string) except NotImplementedError: setattr(namespace, self.dest, values) return DeprecatedArgumentAction def _handle_option_deprecation(deprecated_options): if not isinstance(deprecated_options, list): deprecated_options = [deprecated_options] parent_class = self._get_parent_class(**kwargs) class DeprecatedOptionAction(parent_class): def __call__(self, parser, namespace, values, option_string=None): deprecated_opt = next((x for x in deprecated_options if option_string == x.target), None) if deprecated_opt: if not hasattr(namespace, '_argument_deprecations'): setattr(namespace, '_argument_deprecations', [deprecated_opt]) else: namespace._argument_deprecations.append(deprecated_opt) # pylint: disable=protected-access try: super().__call__(parser, namespace, values, option_string) except NotImplementedError: setattr(namespace, self.dest, values) return DeprecatedOptionAction action = kwargs.get('action', None) deprecate_info = kwargs.get('deprecate_info', None) if deprecate_info: deprecate_info.target = deprecate_info.target or argument_dest action = _handle_argument_deprecation(deprecate_info) deprecated_opts = [x for x in kwargs.get('options_list', []) if isinstance(x, Deprecated)] if deprecated_opts: action = _handle_option_deprecation(deprecated_opts) return action def _handle_previews(self, argument_dest, **kwargs): if not kwargs.get('is_preview', False): return kwargs def _handle_argument_preview(preview_info): parent_class = self._get_parent_class(**kwargs) class PreviewArgumentAction(parent_class): def __call__(self, parser, namespace, values, option_string=None): if not hasattr(namespace, '_argument_previews'): setattr(namespace, '_argument_previews', [preview_info]) else: namespace._argument_previews.append(preview_info) # pylint: disable=protected-access try: super().__call__(parser, namespace, values, option_string) except NotImplementedError: setattr(namespace, self.dest, values) return PreviewArgumentAction def _get_preview_arg_message(self): # "Argument xxx" subject = "{} '{}'".format(self.object_type.capitalize(), self.target) return status_tag_messages['preview'].format(subject) options_list = kwargs.get('options_list', None) object_type = 'argument' if options_list is None: # convert argument dest target = '--{}'.format(argument_dest.replace('_', '-')) elif options_list: target = sorted(options_list, key=len)[-1] else: # positional argument target = kwargs.get('metavar', '<{}>'.format(argument_dest.upper())) object_type = 'positional argument' preview_info = PreviewItem( cli_ctx=self.command_loader.cli_ctx, target=target, object_type=object_type, message_func=_get_preview_arg_message ) kwargs['preview_info'] = preview_info kwargs['action'] = _handle_argument_preview(preview_info) return kwargs def _handle_experimentals(self, argument_dest, **kwargs): if not kwargs.get('is_experimental', False): return kwargs def _handle_argument_experimental(experimental_info): parent_class = self._get_parent_class(**kwargs) class ExperimentalArgumentAction(parent_class): def __call__(self, parser, namespace, values, option_string=None): if not hasattr(namespace, '_argument_experimentals'): setattr(namespace, '_argument_experimentals', [experimental_info]) else: namespace._argument_experimentals.append(experimental_info) # pylint: disable=protected-access try: super().__call__(parser, namespace, values, option_string) except NotImplementedError: setattr(namespace, self.dest, values) return ExperimentalArgumentAction def _get_experimental_arg_message(self): # "Argument xxx" subject = "{} '{}'".format(self.object_type.capitalize(), self.target) return status_tag_messages['experimental'].format(subject) options_list = kwargs.get('options_list', None) object_type = 'argument' if options_list is None: # convert argument dest target = '--{}'.format(argument_dest.replace('_', '-')) elif options_list: target = sorted(options_list, key=len)[-1] else: # positional argument target = kwargs.get('metavar', '<{}>'.format(argument_dest.upper())) object_type = 'positional argument' experimental_info = ExperimentalItem( self.command_loader.cli_ctx, target=target, object_type=object_type, message_func=_get_experimental_arg_message ) kwargs['experimental_info'] = experimental_info kwargs['action'] = _handle_argument_experimental(experimental_info) return kwargs # pylint: disable=inconsistent-return-statements def deprecate(self, **kwargs): def _get_deprecated_arg_message(self): msg = "{} '{}' has been deprecated and will be removed ".format( self.object_type, self.target).capitalize() if self.expiration: msg += "in version '{}'.".format(self.expiration) else: msg += 'in a future release.' if self.redirect: msg += " Use '{}' instead.".format(self.redirect) return msg self._check_stale() if not self._applicable(): return target = kwargs.get('target', '') kwargs['object_type'] = 'option' if target.startswith('-') else 'argument' kwargs['message_func'] = _get_deprecated_arg_message return Deprecated(self.command_loader.cli_ctx, **kwargs) def argument(self, argument_dest, arg_type=None, **kwargs): """ Register an argument for the given command scope using a knack.arguments.CLIArgumentType :param argument_dest: The destination argument to add this argument type to :type argument_dest: str :param arg_type: Predefined CLIArgumentType definition to register, as modified by any provided kwargs. :type arg_type: knack.arguments.CLIArgumentType :param kwargs: Possible values: `options_list`, `validator`, `completer`, `nargs`, `action`, `const`, `default`, `type`, `choices`, `required`, `help`, `metavar`, `is_preview`, `is_experimental`, `deprecate_info`. See /docs/arguments.md. """ self._check_stale() if not self._applicable(): return deprecate_action = self._handle_deprecations(argument_dest, **kwargs) if deprecate_action: kwargs['action'] = deprecate_action is_preview = kwargs.get('is_preview', False) is_experimental = kwargs.get('is_experimental', False) if is_preview and is_experimental: from .commands import PREVIEW_EXPERIMENTAL_CONFLICT_ERROR raise CLIError(PREVIEW_EXPERIMENTAL_CONFLICT_ERROR.format('argument', argument_dest)) kwargs = self._handle_previews(argument_dest, **kwargs) kwargs = self._handle_experimentals(argument_dest, **kwargs) self.command_loader.argument_registry.register_cli_argument(self.command_scope, argument_dest, arg_type, **kwargs) def positional(self, argument_dest, arg_type=None, **kwargs): """ Register a positional argument for the given command scope using a knack.arguments.CLIArgumentType :param argument_dest: The destination argument to add this argument type to :type argument_dest: str :param arg_type: Predefined CLIArgumentType definition to register, as modified by any provided kwargs. :type arg_type: knack.arguments.CLIArgumentType :param kwargs: Possible values: `validator`, `completer`, `nargs`, `action`, `const`, `default`, `type`, `choices`, `required`, `help`, `metavar`, `is_preview`, `is_experimental`, `deprecate_info`. See /docs/arguments.md. """ self._check_stale() if not self._applicable(): return if self.command_scope not in self.command_loader.command_table: raise ValueError("command authoring error: positional argument '{}' cannot be registered to a group-level " "scope '{}'. It must be registered to a specific command.".format( argument_dest, self.command_scope)) # Before adding the new positional arg, ensure that there are no existing positional arguments # registered for this command. command_args = self.command_loader.argument_registry.arguments[self.command_scope] positional_args = {k: v for k, v in command_args.items() if v.settings.get('options_list') == []} if positional_args and argument_dest not in positional_args: raise CLIError("command authoring error: commands may have, at most, one positional argument. '{}' already " "has positional argument: {}.".format(self.command_scope, ' '.join(positional_args.keys()))) kwargs['options_list'] = [] deprecate_action = self._handle_deprecations(argument_dest, **kwargs) if deprecate_action: kwargs['action'] = deprecate_action kwargs = self._handle_previews(argument_dest, **kwargs) kwargs = self._handle_experimentals(argument_dest, **kwargs) self.command_loader.argument_registry.register_cli_argument(self.command_scope, argument_dest, arg_type, **kwargs) def ignore(self, argument_dest, **kwargs): """ Register an argument with type knack.arguments.ignore_type (hidden/ignored) :param argument_dest: The destination argument to apply the ignore type to :type argument_dest: str """ self._check_stale() if not self._applicable(): return dest_option = ['--__{}'.format(argument_dest.upper())] self.argument(argument_dest, arg_type=ignore_type, options_list=dest_option, **kwargs) def extra(self, argument_dest, **kwargs): """Register extra parameters for the given command. Typically used to augment auto-command built commands to add more parameters than the specific SDK method introspected. :param argument_dest: The destination argument to add this argument type to :type argument_dest: str :param kwargs: Possible values: `options_list`, `validator`, `completer`, `nargs`, `action`, `const`, `default`, `type`, `choices`, `required`, `help`, `metavar`, `is_preview`, `is_experimental`, `deprecate_info`. See /docs/arguments.md. """ self._check_stale() if not self._applicable(): return if self.command_scope in self.command_loader.command_group_table: raise ValueError("command authoring error: extra argument '{}' cannot be registered to a group-level " "scope '{}'. It must be registered to a specific command.".format( argument_dest, self.command_scope)) deprecate_action = self._handle_deprecations(argument_dest, **kwargs) if deprecate_action: kwargs['action'] = deprecate_action kwargs = self._handle_previews(argument_dest, **kwargs) kwargs = self._handle_experimentals(argument_dest, **kwargs) self.command_loader.extra_argument_registry[self.command_scope][argument_dest] = CLICommandArgument( argument_dest, **kwargs) class IgnoreAction(argparse.Action): # pylint: disable=too-few-public-methods """ Show the argument as unrecognized if it is called """ def __call__(self, parser, namespace, values, option_string=None): raise argparse.ArgumentError(None, 'unrecognized argument: {} {}'.format( option_string, values or '')) class CaseInsensitiveList(list): """ Determine if a choice is in a choice list in a case-insensitive manner """ def __contains__(self, other): return next((True for x in self if other.lower() == x.lower()), False) def enum_choice_list(data): """ Creates the argparse choices and type kwargs for a supplied enum type or list of strings """ # transform enum types, otherwise assume list of string choices if not data: return {} try: choices = [x.value for x in data] except AttributeError: choices = data def _type(value): return next((x for x in choices if x.lower() == value.lower()), value) if value else value params = { 'choices': CaseInsensitiveList(choices), 'type': _type } return params # GLOBAL ARGUMENT DEFINITIONS ignore_type = CLIArgumentType( help=argparse.SUPPRESS, nargs='?', action=IgnoreAction, required=False) knack-0.9.0/knack/cli.py000066400000000000000000000301151414113720500150150ustar00rootroot00000000000000# -------------------------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- import os import sys from collections import defaultdict from .invocation import CommandInvoker from .completion import CLICompletion from .output import OutputProducer from .log import CLILogging, get_logger from .util import CLIError, is_modern_terminal from .config import CLIConfig from .query import CLIQuery from .events import EVENT_CLI_PRE_EXECUTE, EVENT_CLI_SUCCESSFUL_EXECUTE, EVENT_CLI_POST_EXECUTE from .parser import CLICommandParser from .commands import CLICommandsLoader from .help import CLIHelp logger = get_logger(__name__) class CLI(object): # pylint: disable=too-many-instance-attributes """ The main driver for the CLI """ def __init__(self, cli_name='cli', config_dir=None, config_env_var_prefix=None, out_file=sys.stdout, config_cls=CLIConfig, logging_cls=CLILogging, invocation_cls=CommandInvoker, output_cls=OutputProducer, completion_cls=CLICompletion, query_cls=CLIQuery, parser_cls=CLICommandParser, commands_loader_cls=CLICommandsLoader, help_cls=CLIHelp): """ :param cli_name: The name of the CLI (e.g. the executable name 'az') :type cli_name: str :param config_dir: Path to store config files for this CLI :type config_dir: str :param config_env_var_prefix: The prefix for configuration environment variables :type config_env_var_prefix: str :param out_file: File to write output to :type out_file: file-like object :param config_cls: Class to handle configuration :type config_cls: knack.config.CLIConfig :param logging_cls: Class to handle logging :type logging_cls: knack.log.CLILogging :param invocation_cls: Class to handle command invocations :type invocation_cls: knack.invocation.CommandInvoker :param output_cls: Class to handle output processing of commands :type output_cls: knack.output.OutputProducer :param completion_cls: Class to handle completions :type completion_cls: knack.completion.CLICompletion :param query_cls: Class to handle command queries :type query_cls: knack.query.CLIQuery :param parser_cls: Class to handler command parsing :type parser_cls: knack.parser.CLICommandParser :param commands_loader_cls: Class to handle loading commands :type commands_loader_cls: knack.commands.CLICommandsLoader :param help_cls: Class to handle help :type help_cls: knack.help.CLIHelp """ self.name = cli_name self.out_file = out_file self.config_cls = config_cls self.logging_cls = logging_cls self.output_cls = output_cls self.parser_cls = parser_cls self.help_cls = help_cls self.commands_loader_cls = commands_loader_cls self.invocation_cls = invocation_cls self.invocation = None self._event_handlers = defaultdict(lambda: []) # Data that's typically backed to persistent storage self.config = config_cls( config_dir=config_dir or os.path.expanduser(os.path.join('~', '.{}'.format(cli_name))), config_env_var_prefix=config_env_var_prefix or cli_name.upper() ) # In memory collection of key-value data for this current cli. This persists between invocations. self.data = defaultdict(lambda: None) self.completion = completion_cls(cli_ctx=self) self.logging = logging_cls(self.name, cli_ctx=self) self.output = self.output_cls(cli_ctx=self) self.result = None self.query = query_cls(cli_ctx=self) # As logging is initialized in `invoke`, call `logger.debug` or `logger.info` here won't work. self.init_debug_log = [] self.init_info_log = [] self.only_show_errors = self.config.getboolean('core', 'only_show_errors', fallback=False) self.enable_color = self._should_enable_color() # Init colorama only in Windows legacy terminal self._should_init_colorama = self.enable_color and sys.platform == 'win32' and not is_modern_terminal() @staticmethod def _should_show_version(args): return args and (args[0] == '--version' or args[0] == '-v') def get_cli_version(self): # pylint: disable=no-self-use """ Get the CLI Version. Override this to define how to get the CLI version :return: The CLI version :rtype: str """ return '' def get_runtime_version(self): # pylint: disable=no-self-use """ Get the runtime information. :return: Runtime information :rtype: str """ import platform version_info = '\n\n' version_info += 'Python ({}) {}'.format(platform.system(), sys.version) version_info += '\n\n' version_info += 'Python location \'{}\''.format(sys.executable) version_info += '\n' return version_info def show_version(self): """ Print version information to the out file. """ version_info = self.get_cli_version() version_info += self.get_runtime_version() print(version_info, file=self.out_file) def register_event(self, event_name, handler): """ Register a callable that will be called when event is raised. A handler will only be registered once. :param event_name: The name of the event (see knack.events for in-built events) :type event_name: str :param handler: A callback to handle the event :type handler: function """ self._event_handlers[event_name].append(handler) def unregister_event(self, event_name, handler): """ Unregister a callable that will be called when event is raised. :param event_name: The name of the event (see knack.events for in-built events) :type event_name: str :param handler: The callback that was used to register the event :type handler: function """ try: self._event_handlers[event_name].remove(handler) except ValueError: pass def raise_event(self, event_name, **kwargs): """ Raise an event. Calls each handler in turn with kwargs :param event_name: The name of the event to raise :type event_name: str :param kwargs: Kwargs to be passed to all event handlers """ handlers = list(self._event_handlers[event_name]) logger.debug('Event: %s %s', event_name, handlers) for func in handlers: func(self, **kwargs) def exception_handler(self, ex): # pylint: disable=no-self-use """ The default exception handler """ if isinstance(ex, CLIError): logger.error(ex) else: logger.exception(ex) return 1 def _print_init_log(self): """Print the debug/info log from CLI.__init__""" if self.init_debug_log: logger.debug('__init__ debug log:\n%s', '\n'.join(self.init_debug_log)) self.init_debug_log.clear() if self.init_info_log: logger.info('__init__ info log:\n%s', '\n'.join(self.init_info_log)) self.init_info_log.clear() def invoke(self, args, initial_invocation_data=None, out_file=None): """ Invoke a command. :param args: The arguments that represent the command :type args: list, tuple :param initial_invocation_data: Prime the in memory collection of key-value data for this invocation. :type initial_invocation_data: dict :param out_file: The file to send output to. If not used, we use out_file for knack.cli.CLI instance :type out_file: file-like object :return: The exit code of the invocation :rtype: int """ from .util import CommandResultItem if not isinstance(args, (list, tuple)): raise TypeError('args should be a list or tuple.') exit_code = 0 try: out_file = out_file or self.out_file if out_file is sys.stdout and self._should_init_colorama: self.init_debug_log.append("Init colorama.") import colorama colorama.init() # point out_file to the new sys.stdout which is overwritten by colorama out_file = sys.stdout args = self.completion.get_completion_args() or args self.logging.configure(args) logger.debug('Command arguments: %s', args) self._print_init_log() self.raise_event(EVENT_CLI_PRE_EXECUTE) if CLI._should_show_version(args): self.show_version() self.result = CommandResultItem(None) else: self.invocation = self.invocation_cls(cli_ctx=self, parser_cls=self.parser_cls, commands_loader_cls=self.commands_loader_cls, help_cls=self.help_cls, initial_data=initial_invocation_data) cmd_result = self.invocation.execute(args) self.result = cmd_result exit_code = self.result.exit_code output_type = self.invocation.data['output'] if cmd_result and cmd_result.result is not None: formatter = self.output.get_formatter(output_type) self.output.out(cmd_result, formatter=formatter, out_file=out_file) self.raise_event(EVENT_CLI_SUCCESSFUL_EXECUTE, result=cmd_result) except KeyboardInterrupt as ex: exit_code = 1 self.result = CommandResultItem(None, error=ex, exit_code=exit_code) except Exception as ex: # pylint: disable=broad-except exit_code = self.exception_handler(ex) self.result = CommandResultItem(None, error=ex, exit_code=exit_code) except SystemExit as ex: exit_code = ex.code self.result = CommandResultItem(None, error=ex, exit_code=exit_code) raise ex finally: self.raise_event(EVENT_CLI_POST_EXECUTE) if self._should_init_colorama: import colorama colorama.deinit() return exit_code def _should_enable_color(self): # When run in a normal terminal, color is only enabled when all conditions are met: # 1. [core] no_color config is not set # 2. stdout is a tty # - Otherwise, if the downstream command doesn't support color, Knack will fail with # BrokenPipeError: [Errno 32] Broken pipe, like `az --version | head --lines=1` # https://github.com/Azure/azure-cli/issues/13413 # - May also hit https://github.com/tartley/colorama/issues/200 # 3. stderr is a tty. # - Otherwise, the output in stderr won't have LEVEL tag # 4. out_file is stdout no_color_config = self.config.getboolean('core', 'no_color', fallback=False) # If color is disabled by config explicitly, never enable color if no_color_config: self.init_debug_log.append("Color is disabled by config.") return False if sys.stdout.isatty() and sys.stderr.isatty() and self.out_file is sys.stdout: self.init_debug_log.append("Enable color in terminal.") return True if 'PYCHARM_HOSTED' in os.environ and sys.stdout == sys.__stdout__ and sys.stderr == sys.__stderr__: self.init_debug_log.append("Enable color in PyCharm.") return True self.init_debug_log.append("Cannot enable color.") return False knack-0.9.0/knack/commands.py000066400000000000000000000425701414113720500160570ustar00rootroot00000000000000# -------------------------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- import types from collections import OrderedDict, defaultdict from importlib import import_module from .deprecation import Deprecated from .preview import PreviewItem from .experimental import ExperimentalItem from .prompting import prompt_y_n, NoTTYException from .util import CLIError, CtxTypeError from .arguments import ArgumentRegistry, CLICommandArgument from .introspection import extract_args_from_signature, extract_full_summary_from_signature from .events import (EVENT_CMDLOADER_LOAD_COMMAND_TABLE, EVENT_CMDLOADER_LOAD_ARGUMENTS, EVENT_COMMAND_CANCELLED) from .log import get_logger from .validators import DefaultInt, DefaultStr logger = get_logger(__name__) PREVIEW_EXPERIMENTAL_CONFLICT_ERROR = "Failed to register {} '{}', " \ "is_preview and is_experimental can't be true at the same time" class CLICommand(object): # pylint:disable=too-many-instance-attributes # pylint: disable=unused-argument def __init__(self, cli_ctx, name, handler, description=None, table_transformer=None, arguments_loader=None, description_loader=None, formatter_class=None, deprecate_info=None, validator=None, confirmation=None, preview_info=None, experimental_info=None, **kwargs): """ The command object that goes into the command table. :param cli_ctx: CLI Context :type cli_ctx: knack.cli.CLI :param name: The name of the command (e.g. 'mygroup mycommand') :type name: str :param handler: The function that will handle this command :type handler: function :param description: The description for the command :type description: str :param table_transformer: A function that transforms the command output for displaying in a table :type table_transformer: function :param arguments_loader: The function that defines how the arguments for the command should be loaded :type arguments_loader: function :param description_loader: The function that defines how the description for the command should be loaded :type description_loader: function :param formatter_class: The formatter for how help should be displayed :type formatter_class: class :param deprecate_info: Deprecation message to display when this command is invoked :type deprecate_info: str :param preview_info: Indicates a command is in preview :type preview_info: bool :param experimental_info: Indicates a command is experimental :type experimental_info: bool :param validator: The command validator :param confirmation: User confirmation required for command :type confirmation: bool, str, callable :param kwargs: Extra kwargs that are currently ignored """ from .cli import CLI if cli_ctx is not None and not isinstance(cli_ctx, CLI): raise CtxTypeError(cli_ctx) self.cli_ctx = cli_ctx self.name = name self.handler = handler self.help = None self.description = description_loader if description_loader and self.should_load_description() else description self.arguments = {} self.arguments_loader = arguments_loader self.table_transformer = table_transformer self.formatter_class = formatter_class self.deprecate_info = deprecate_info self.preview_info = preview_info self.experimental_info = experimental_info self.confirmation = confirmation self.validator = validator def should_load_description(self): return not self.cli_ctx.data['completer_active'] def _resolve_default_value_from_config_file(self, arg, overrides): default_key = overrides.settings.get('configured_default', None) if not default_key: return defaults_section = self.cli_ctx.config.defaults_section_name use_local_config_original = self.cli_ctx.config.use_local_config self.cli_ctx.config.set_to_use_local_config(True) config_value = self.cli_ctx.config.get(defaults_section, default_key, None) self.cli_ctx.config.set_to_use_local_config(use_local_config_original) if config_value: logger.info("Configured default '%s' for arg %s", config_value, arg.name) overrides.settings['default'] = DefaultStr(config_value) overrides.settings['required'] = False overrides.settings['default_value_source'] = 'Config' def load_arguments(self): if self.arguments_loader: cmd_args = self.arguments_loader() if self.confirmation: cmd_args.append(('yes', CLICommandArgument(dest='yes', options_list=['--yes', '-y'], action='store_true', help='Do not prompt for confirmation.'))) self.arguments.update(cmd_args) def add_argument(self, param_name, *option_strings, **kwargs): dest = kwargs.pop('dest', None) argument = CLICommandArgument(dest or param_name, options_list=option_strings, **kwargs) self.arguments[param_name] = argument def update_argument(self, param_name, argtype): arg = self.arguments[param_name] # resolve defaults from either environment variable or config file self._resolve_default_value_from_config_file(arg, argtype) arg.type.update(other=argtype) arg_default = arg.type.settings.get('default', None) # apply DefaultStr and DefaultInt to allow distinguishing between # when a default was applied or when the user specified a value # that coincides with the default if isinstance(arg_default, str): arg_default = DefaultStr(arg_default) elif isinstance(arg_default, int): arg_default = DefaultInt(arg_default) # update the default if arg_default: arg.type.settings['default'] = arg_default def execute(self, **kwargs): return self(**kwargs) def __call__(self, *args, **kwargs): cmd_args = args[0] confirm = self.confirmation and not cmd_args.pop('yes', None) \ and not self.cli_ctx.config.getboolean('core', 'disable_confirm_prompt', fallback=False) if confirm and not self._user_confirmed(self.confirmation, cmd_args): self.cli_ctx.raise_event(EVENT_COMMAND_CANCELLED, command=self.name, command_args=cmd_args) raise CLIError('Operation cancelled.') return self.handler(*args, **kwargs) @staticmethod def _user_confirmed(confirmation, command_args): if callable(confirmation): return confirmation(command_args) try: if isinstance(confirmation, str): return prompt_y_n(confirmation) return prompt_y_n('Are you sure you want to perform this operation?') except NoTTYException: logger.warning('Unable to prompt for confirmation as no tty available. Use --yes.') return False # pylint: disable=too-many-instance-attributes class CLICommandsLoader(object): def __init__(self, cli_ctx=None, command_cls=CLICommand, excluded_command_handler_args=None): """ The loader of commands. It contains the command table and argument registries. :param cli_ctx: CLI Context :type cli_ctx: knack.cli.CLI :param command_cls: The command type that the command table will be populated with :type command_cls: knack.commands.CLICommand :param excluded_command_handler_args: List of params to ignore and not extract from a commands handler. By default we ignore ['self', 'kwargs']. :type excluded_command_handler_args: list of str """ from .cli import CLI if cli_ctx is not None and not isinstance(cli_ctx, CLI): raise CtxTypeError(cli_ctx) self.cli_ctx = cli_ctx self.command_cls = command_cls self.skip_applicability = False self.excluded_command_handler_args = excluded_command_handler_args # A command table is a dictionary of name -> CLICommand instances self.command_table = {} # A command group table is a dictionary of names -> CommandGroup instances self.command_group_table = {} # An argument registry stores all arguments for commands self.argument_registry = ArgumentRegistry() self.extra_argument_registry = defaultdict(lambda: {}) def _populate_command_group_table_with_subgroups(self, name): if not name: return # ensure all subgroups have some entry in the command group table name_components = name.split() for i, _ in enumerate(name_components): subgroup_name = ' '.join(name_components[:i + 1]) if subgroup_name not in self.command_group_table: self.command_group_table[subgroup_name] = {} def load_command_table(self, args): # pylint: disable=unused-argument """ Load commands into the command table :param args: List of the arguments from the command line :type args: list :return: The ordered command table :rtype: collections.OrderedDict """ self.cli_ctx.raise_event(EVENT_CMDLOADER_LOAD_COMMAND_TABLE, cmd_tbl=self.command_table) return OrderedDict(self.command_table) def load_arguments(self, command): """ Load the arguments for the specified command :param command: The command to load arguments for :type command: str """ from knack.arguments import ArgumentsContext self.cli_ctx.raise_event(EVENT_CMDLOADER_LOAD_ARGUMENTS, cmd_tbl=self.command_table, command=command) try: self.command_table[command].load_arguments() except KeyError: return # ensure global 'cmd' is ignored with ArgumentsContext(self, '') as c: c.ignore('cmd') self._apply_parameter_info(command, self.command_table[command]) def _apply_parameter_info(self, command_name, command): for argument_name in command.arguments: overrides = self.argument_registry.get_cli_argument(command_name, argument_name) command.update_argument(argument_name, overrides) # Add any arguments explicitly registered for this command for argument_name, argument_definition in self.extra_argument_registry[command_name].items(): command.arguments[argument_name] = argument_definition command.update_argument(argument_name, self.argument_registry.get_cli_argument(command_name, argument_name)) def create_command(self, name, operation, **kwargs): """ Constructs the command object that can then be added to the command table """ if not isinstance(operation, str): raise ValueError("Operation must be a string. Got '{}'".format(operation)) name = ' '.join(name.split()) client_factory = kwargs.get('client_factory', None) def _command_handler(command_args): op = CLICommandsLoader._get_op_handler(operation) client = client_factory(command_args) if client_factory else None result = op(client, **command_args) if client else op(**command_args) return result def arguments_loader(): return list(extract_args_from_signature(CLICommandsLoader._get_op_handler(operation), excluded_params=self.excluded_command_handler_args)) def description_loader(): return extract_full_summary_from_signature(CLICommandsLoader._get_op_handler(operation)) kwargs['arguments_loader'] = arguments_loader kwargs['description_loader'] = description_loader cmd = self.command_cls(self.cli_ctx, name, _command_handler, **kwargs) return cmd @staticmethod def _get_op_handler(operation): """ Import and load the operation handler """ try: mod_to_import, attr_path = operation.split('#') op = import_module(mod_to_import) for part in attr_path.split('.'): op = getattr(op, part) if isinstance(op, types.FunctionType): return op # op as types.MethodType return op.__func__ except (ValueError, AttributeError) as ex: raise ValueError("The operation '{}' is invalid.".format(operation)) from ex def deprecate(self, **kwargs): kwargs['object_type'] = 'command group' return Deprecated(self.cli_ctx, **kwargs) class CommandGroup(object): def __init__(self, command_loader, group_name, operations_tmpl, **kwargs): """ Context manager for registering commands that share common properties. :param command_loader: The command loader that commands will be registered into :type command_loader: knack.commands.CLICommandsLoader :param group_name: The name of the group of commands in the command hierarchy :type group_name: str :param operations_tmpl: The template for handlers for this group of commands (e.g. '__main__#{}') :type operations_tmpl: str :param kwargs: Kwargs to apply to all commands in this group. Possible values: `client_factory`, `arguments_loader`, `description_loader`, `description`, `formatter_class`, `table_transformer`, `deprecate_info`, `validator`, `confirmation`. """ self.command_loader = command_loader self.group_name = group_name self.operations_tmpl = operations_tmpl self.group_kwargs = kwargs Deprecated.ensure_new_style_deprecation(self.command_loader.cli_ctx, self.group_kwargs, 'command group') if kwargs['deprecate_info']: kwargs['deprecate_info'].target = group_name is_preview = kwargs.get('is_preview', False) is_experimental = kwargs.get('is_experimental', False) if is_preview and is_experimental: raise CLIError(PREVIEW_EXPERIMENTAL_CONFLICT_ERROR.format("command group", group_name)) if is_preview: kwargs['preview_info'] = PreviewItem( cli_ctx=self.command_loader.cli_ctx, target=group_name, object_type='command group' ) if is_experimental: kwargs['experimental_info'] = ExperimentalItem( cli_ctx=self.command_loader.cli_ctx, target=group_name, object_type='command group' ) command_loader._populate_command_group_table_with_subgroups(group_name) # pylint: disable=protected-access self.command_loader.command_group_table[group_name] = self def __enter__(self): return self def __exit__(self, exc_type, exc_val, exc_tb): pass def command(self, name, handler_name, **kwargs): """ Register a command into the command table :param name: The name of the command :type name: str :param handler_name: The name of the handler that will be applied to the operations template :type handler_name: str :param kwargs: Kwargs to apply to the command. Possible values: `client_factory`, `arguments_loader`, `description_loader`, `description`, `formatter_class`, `table_transformer`, `deprecate_info`, `validator`, `confirmation`, `is_preview`, `is_experimental`. """ import copy command_name = '{} {}'.format(self.group_name, name) if self.group_name else name command_kwargs = copy.deepcopy(self.group_kwargs) command_kwargs.update(kwargs) # don't inherit deprecation, preview and experimental info from command group # https://github.com/Azure/azure-cli/blob/683b9709b67c4c9e8df92f9fbd53cbf83b6973d3/src/azure-cli-core/azure/cli/core/commands/__init__.py#L1155 command_kwargs['deprecate_info'] = kwargs.get('deprecate_info', None) is_preview = kwargs.get('is_preview', False) is_experimental = kwargs.get('is_experimental', False) if is_preview and is_experimental: raise CLIError(PREVIEW_EXPERIMENTAL_CONFLICT_ERROR.format("command", self.group_name + " " + name)) command_kwargs['preview_info'] = None if is_preview: command_kwargs['preview_info'] = PreviewItem(self.command_loader.cli_ctx, object_type='command') command_kwargs['experimental_info'] = None if is_experimental: command_kwargs['experimental_info'] = ExperimentalItem(self.command_loader.cli_ctx, object_type='command') self.command_loader._populate_command_group_table_with_subgroups(' '.join(command_name.split()[:-1])) # pylint: disable=protected-access self.command_loader.command_table[command_name] = self.command_loader.create_command( command_name, self.operations_tmpl.format(handler_name), **command_kwargs) def deprecate(self, **kwargs): kwargs['object_type'] = 'command' return Deprecated(self.command_loader.cli_ctx, **kwargs) knack-0.9.0/knack/completion.py000066400000000000000000000040131414113720500164150ustar00rootroot00000000000000# -------------------------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- import os import argcomplete from .util import CtxTypeError ARGCOMPLETE_ENV_NAME = '_ARGCOMPLETE' class CaseInsensitiveChoicesCompleter(argcomplete.completers.ChoicesCompleter): def __call__(self, prefix, **kwargs): return (c for c in self.choices if c.lower().startswith(prefix.lower())) # Override the choices completer with one that is case insensitive argcomplete.completers.ChoicesCompleter = CaseInsensitiveChoicesCompleter class CLICompletion(object): def __init__(self, cli_ctx=None): """ Sets up and gets completions for auto-complete :param cli_ctx: CLI Context :type cli_ctx: knack.cli.CLI """ from .cli import CLI if cli_ctx is not None and not isinstance(cli_ctx, CLI): raise CtxTypeError(cli_ctx) self.cli_ctx = cli_ctx self.cli_ctx.data['completer_active'] = ARGCOMPLETE_ENV_NAME in os.environ def get_completion_args(self, is_completion=False, comp_line=None): # pylint: disable=no-self-use """ Get the args that will be used to tab completion if completion is active. """ is_completion = is_completion or os.environ.get(ARGCOMPLETE_ENV_NAME) comp_line = comp_line or os.environ.get('COMP_LINE') # The first item is the exe name so ignore that. return comp_line.split()[1:] if is_completion and comp_line else None def enable_autocomplete(self, parser): if self.cli_ctx.data['completer_active']: argcomplete.autocomplete = argcomplete.CompletionFinder() argcomplete.autocomplete(parser, validator=lambda c, p: c.lower().startswith(p.lower()), default_completer=lambda _: ()) knack-0.9.0/knack/config.py000066400000000000000000000265071414113720500155250ustar00rootroot00000000000000# -------------------------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- import os import stat import configparser from .util import ensure_dir _UNSET = object() CONFIG_FILE_ENCODING = 'utf-8' def get_config_parser(): return configparser.ConfigParser() # keep this for backward compatibility class CLIConfig(object): _BOOLEAN_STATES = {'1': True, 'yes': True, 'true': True, 'on': True, '0': False, 'no': False, 'false': False, 'off': False} _DEFAULT_CONFIG_ENV_VAR_PREFIX = 'CLI' _DEFAULT_CONFIG_DIR = os.path.expanduser(os.path.join('~', '.{}'.format('cli'))) _DEFAULT_CONFIG_FILE_NAME = 'config' _CONFIG_DEFAULTS_SECTION = 'defaults' def __init__(self, config_dir=None, config_env_var_prefix=None, config_file_name=None, use_local_config=None): """ Manages configuration options available in the CLI :param config_dir: The directory to store config files :type config_dir: str :param config_env_var_prefix: The prefix for config environment variables :type config_env_var_prefix: str :param config_file_name: The name given to the config file to be created :type config_file_name: str """ config_dir = config_dir or CLIConfig._DEFAULT_CONFIG_DIR ensure_dir(config_dir) config_env_var_prefix = config_env_var_prefix or CLIConfig._DEFAULT_CONFIG_ENV_VAR_PREFIX env_var_prefix = '{}_'.format(config_env_var_prefix.upper()) default_config_dir = os.path.expanduser(config_dir) self.config_dir = os.environ.get('{}CONFIG_DIR'.format(env_var_prefix), default_config_dir) configuration_file_name = config_file_name or CLIConfig._DEFAULT_CONFIG_FILE_NAME self.config_path = os.path.join(self.config_dir, configuration_file_name) self._env_var_format = '{}{}'.format(env_var_prefix, '{section}_{option}') self.defaults_section_name = CLIConfig._CONFIG_DEFAULTS_SECTION self.use_local_config = use_local_config self._config_file_chain = [] current_dir = None try: current_dir = os.getcwd() except FileNotFoundError: from .log import get_logger logger = get_logger() logger.warning("The working directory has been deleted or recreated. " "Local config is ignored.") config_dir_name = os.path.basename(self.config_dir) while current_dir: current_config_dir = os.path.join(current_dir, config_dir_name) # Stop if already in the default .azure if (os.path.normcase(os.path.normpath(current_config_dir)) == os.path.normcase(os.path.normpath(self.config_dir))): break if os.path.isdir(current_config_dir): self._config_file_chain.append(_ConfigFile(current_config_dir, os.path.join(current_config_dir, configuration_file_name))) # Stop if already in root drive if current_dir == os.path.dirname(current_dir): break current_dir = os.path.dirname(current_dir) self._config_file_chain.append(_ConfigFile(self.config_dir, self.config_path)) def env_var_name(self, section, option): return self._env_var_format.format(section=section.upper(), option=option.upper()) def has_option(self, section, option): if self.env_var_name(section, option) in os.environ: return True config_files = self._config_file_chain if self.use_local_config else self._config_file_chain[-1:] return bool(next((f for f in config_files if f.has_option(section, option)), False)) def get(self, section, option, fallback=_UNSET): env = self.env_var_name(section, option) if env in os.environ: return os.environ[env] last_ex = None for config in self._config_file_chain if self.use_local_config else self._config_file_chain[-1:]: try: return config.get(section, option) except (configparser.NoSectionError, configparser.NoOptionError) as ex: last_ex = ex if fallback is _UNSET: raise last_ex # pylint:disable=raising-bad-type return fallback def sections(self): combined_sections = [] # Go through the config chain and combine all sections for config in self._config_file_chain if self.use_local_config else self._config_file_chain[-1:]: sections = config.sections() for section in sections: if section not in combined_sections: combined_sections.append(section) return combined_sections def items(self, section): import re # Only allow valid env vars, in all caps: CLI_SECTION_TEST_OPTION, CLI_SECTION__TEST_OPTION pattern = self.env_var_name(section, '([0-9A-Z_]+)') env_entries = [] for k in os.environ: # Must be a full match, otherwise CLI_SECTION_T part in CLI_MYSECTION_Test_Option will match matched = re.fullmatch(pattern, k) if matched: # (name, value, ENV_VAR_NAME) item = (matched.group(1).lower(), os.environ[k], k) env_entries.append(item) # Prepare result with env entries first result = {c[0]: c for c in env_entries} # Add entries from config files if they do not exist yet for config in self._config_file_chain if self.use_local_config else self._config_file_chain[-1:]: try: entries = config.items(section) for name, value in entries: if name not in result: result[name] = (name, value, config.config_path) except (configparser.NoSectionError, configparser.NoOptionError): pass return [{'name': name, 'value': value, 'source': source} for name, value, source in result.values()] def getint(self, section, option, fallback=_UNSET): return int(self.get(section, option, fallback)) def getfloat(self, section, option, fallback=_UNSET): return float(self.get(section, option, fallback)) def getboolean(self, section, option, fallback=_UNSET): val = str(self.get(section, option, fallback)) if val.lower() not in CLIConfig._BOOLEAN_STATES: raise ValueError('Not a boolean: {}'.format(val)) return CLIConfig._BOOLEAN_STATES[val.lower()] def set_value(self, section, option, value): if self.use_local_config: current_config_dir = os.path.join(os.getcwd(), os.path.basename(self.config_dir)) config_file_path = os.path.join(current_config_dir, os.path.basename(self.config_path)) if config_file_path == self._config_file_chain[0].config_path: self._config_file_chain[0].set_value(section, option, value) else: config = _ConfigFile(current_config_dir, config_file_path) config.set_value(section, option, value) self._config_file_chain.insert(0, config) else: self._config_file_chain[-1].set_value(section, option, value) def set_to_use_local_config(self, use_local_config): self.use_local_config = use_local_config def remove_option(self, section, option): for config in self._config_file_chain if self.use_local_config else self._config_file_chain[-1:]: if config.remove_option(section, option): return True return False class _ConfigFile(object): _BOOLEAN_STATES = {'1': True, 'yes': True, 'true': True, 'on': True, '0': False, 'no': False, 'false': False, 'off': False} def __init__(self, config_dir, config_path, config_comment=None): """ Manage configuration options available in the CLI :param config_dir: The directory to store the config file :type config_dir: str :param config_path: The path of the config file :type config_path: str :param config_comment: The comment which will be written into the head of the config file :type config_comment: str When 'config_comment' is given, each line should start with # or ;. For details about INI file comment, see https://docs.python.org/3/library/configparser.html#supported-ini-file-structure """ self.config_dir = config_dir self.config_path = config_path self.config_comment = config_comment self.config_parser = configparser.ConfigParser() if os.path.exists(config_path): self.config_parser.read(config_path, encoding=CONFIG_FILE_ENCODING) def items(self, section): return self.config_parser.items(section) if self.config_parser else [] def sections(self): return self.config_parser.sections() if self.config_parser else [] def has_option(self, section, option): return self.config_parser.has_option(section, option) if self.config_parser else False def get(self, section, option): if self.config_parser: return self.config_parser.get(section, option) raise configparser.NoOptionError(option, section) def getint(self, section, option): return int(self.get(section, option)) def getfloat(self, section, option): return float(self.get(section, option)) def getboolean(self, section, option): val = str(self.get(section, option)) if val.lower() not in _ConfigFile._BOOLEAN_STATES: raise ValueError('Not a boolean: {}'.format(val)) return _ConfigFile._BOOLEAN_STATES[val.lower()] def set(self, config): ensure_dir(self.config_dir) with open(self.config_path, 'w', encoding=CONFIG_FILE_ENCODING) as configfile: if self.config_comment: configfile.write(self.config_comment + '\n') config.write(configfile) os.chmod(self.config_path, stat.S_IRUSR | stat.S_IWUSR) self.config_parser.read(self.config_path) def set_value(self, section, option, value): config = configparser.ConfigParser() config.read(self.config_path) try: config.add_section(section) except configparser.DuplicateSectionError: pass config.set(section, option, value) self.set(config) def remove_option(self, section, option): existed = False if self.config_parser: try: existed = self.config_parser.remove_option(section, option) self.set(self.config_parser) except configparser.NoSectionError: pass return existed def remove_section(self, section): if self.config_parser and self.config_parser.remove_section(section): self.set(self.config_parser) return True return False def clear(self): if self.config_parser: for section in self.config_parser.sections(): self.config_parser.remove_section(section) self.set(self.config_parser) knack-0.9.0/knack/deprecation.py000066400000000000000000000132301414113720500165420ustar00rootroot00000000000000# -------------------------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- from .util import StatusTag, color_map DEFAULT_DEPRECATED_TAG = '[Deprecated]' _config_key = 'deprecation' def resolve_deprecate_info(cli_ctx, name): def _get_command(name): return cli_ctx.invocation.commands_loader.command_table[name] def _get_command_group(name): return cli_ctx.invocation.commands_loader.command_group_table.get(name, None) deprecate_info = None try: command = _get_command(name) deprecate_info = getattr(command, 'deprecate_info', None) except KeyError: command_group = _get_command_group(name) group_kwargs = getattr(command_group, 'group_kwargs', None) if group_kwargs: deprecate_info = group_kwargs.get('deprecate_info', None) return deprecate_info # pylint: disable=too-many-instance-attributes class Deprecated(StatusTag): @staticmethod def ensure_new_style_deprecation(cli_ctx, kwargs, object_type): """ Helper method to make the previous string-based deprecate_info kwarg work with the new style. """ deprecate_info = kwargs.get('deprecate_info', None) if isinstance(deprecate_info, Deprecated): deprecate_info.object_type = object_type elif isinstance(deprecate_info, str): deprecate_info = Deprecated(cli_ctx, redirect=deprecate_info, object_type=object_type) kwargs['deprecate_info'] = deprecate_info return deprecate_info def __init__(self, cli_ctx=None, object_type='', target=None, redirect=None, hide=False, expiration=None, tag_func=None, message_func=None, **kwargs): """ Create a collection of deprecation metadata. :param cli_ctx: The CLI context associated with the deprecated item. :type cli_ctx: knack.cli.CLI :param object_type: A label describing the type of object being deprecated. :type: object_type: str :param target: The name of the object being deprecated. :type target: str :param redirect: The alternative to redirect users to in lieu of the deprecated item. If omitted it, there is no alternative. :type redirect: str :param hide: A boolean or CLI version at or above-which the deprecated item will no longer appear in help text, but will continue to work. Warnings will be displayed if the deprecated item is used. :type hide: bool OR str :param expiration: The CLI version at or above-which the deprecated item will no longer work. :type expiration: str :param tag_func: Callable which returns the desired unformatted tag string for the deprecated item. Omit to use the default. :type tag_func: callable :param message_func: Callable which returns the desired unformatted message string for the deprecated item. Omit to use the default. :type message_func: callable """ def _default_get_message(self): msg = "This {} has been deprecated and will be removed ".format(self.object_type) if self.expiration: msg += "in version '{}'.".format(self.expiration) else: msg += 'in a future release.' if self.redirect: msg += " Use '{}' instead.".format(self.redirect) return msg self.redirect = redirect self.hide = hide self.expiration = expiration self._cli_version = cli_ctx.get_cli_version() super().__init__( cli_ctx=cli_ctx, object_type=object_type, target=target, color=color_map[_config_key], tag_func=tag_func or (lambda _: DEFAULT_DEPRECATED_TAG), message_func=message_func or _default_get_message ) # pylint: disable=no-self-use def _version_less_than_or_equal_to(self, v1, v2): """ Returns true if v1 <= v2. """ # pylint: disable=no-name-in-module, import-error from distutils.version import LooseVersion return LooseVersion(v1) <= LooseVersion(v2) def expired(self): if self.expiration: return self._version_less_than_or_equal_to(self.expiration, self._cli_version) return False def hidden(self): hidden = False if isinstance(self.hide, bool): hidden = self.hide elif isinstance(self.hide, str): hidden = self._version_less_than_or_equal_to(self.hide, self._cli_version) return hidden def show_in_help(self): return not self.hidden() and not self.expired() class ImplicitDeprecated(Deprecated): def __init__(self, **kwargs): def get_implicit_deprecation_message(self): msg = "This {} is implicitly deprecated because command group '{}' is deprecated " \ "and will be removed ".format(self.object_type, self.target) if self.expiration: msg += "in version '{}'.".format(self.expiration) else: msg += 'in a future release.' if self.redirect: msg += " Use '{}' instead.".format(self.redirect) return msg kwargs.update({ 'tag_func': lambda _: '', 'message_func': get_implicit_deprecation_message }) super().__init__(**kwargs) knack-0.9.0/knack/events.py000066400000000000000000000023211414113720500155500ustar00rootroot00000000000000# -------------------------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- EVENT_CLI_PRE_EXECUTE = 'Cli.PreExecute' EVENT_CLI_SUCCESSFUL_EXECUTE = 'Cli.SuccessfulExecute' EVENT_CLI_POST_EXECUTE = 'Cli.PostExecute' EVENT_INVOKER_PRE_CMD_TBL_CREATE = 'CommandInvoker.OnPreCommandTableCreate' EVENT_INVOKER_POST_CMD_TBL_CREATE = 'CommandInvoker.OnPostCommandTableCreate' EVENT_INVOKER_CMD_TBL_LOADED = 'CommandInvoker.OnCommandTableLoaded' EVENT_INVOKER_PRE_PARSE_ARGS = 'CommandInvoker.OnPreParseArgs' EVENT_INVOKER_POST_PARSE_ARGS = 'CommandInvoker.OnPostParseArgs' EVENT_INVOKER_TRANSFORM_RESULT = 'CommandInvoker.OnTransformResult' EVENT_INVOKER_FILTER_RESULT = 'CommandInvoker.OnFilterResult' EVENT_PARSER_GLOBAL_CREATE = 'CommandParser.OnGlobalArgumentsCreate' EVENT_CMDLOADER_LOAD_COMMAND_TABLE = 'CommandLoader.OnLoadCommandTable' EVENT_CMDLOADER_LOAD_ARGUMENTS = 'CommandLoader.OnLoadArguments' EVENT_COMMAND_CANCELLED = 'Command.OnOperationCancelled' knack-0.9.0/knack/experimental.py000066400000000000000000000057161414113720500167540ustar00rootroot00000000000000# -------------------------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- from .util import StatusTag, status_tag_messages, color_map _EXPERIMENTAL_TAG = '[Experimental]' _experimental_kwarg = 'experimental_info' _config_key = 'experimental' def resolve_experimental_info(cli_ctx, name): def _get_command(name): return cli_ctx.invocation.commands_loader.command_table[name] def _get_command_group(name): return cli_ctx.invocation.commands_loader.command_group_table.get(name, None) experimental_info = None try: command = _get_command(name) experimental_info = getattr(command, _experimental_kwarg, None) except KeyError: command_group = _get_command_group(name) group_kwargs = getattr(command_group, 'group_kwargs', None) if group_kwargs: experimental_info = group_kwargs.get(_experimental_kwarg, None) return experimental_info # pylint: disable=too-many-instance-attributes class ExperimentalItem(StatusTag): def __init__(self, cli_ctx, object_type='', target=None, tag_func=None, message_func=None, **kwargs): """ Create a collection of experimental metadata. :param cli_ctx: The CLI context associated with the experimental item. :type cli_ctx: knack.cli.CLI :param object_type: A label describing the type of object in experimental. :type: object_type: str :param target: The name of the object in experimental. :type target: str :param tag_func: Callable which returns the desired unformatted tag string for the experimental item. Omit to use the default. :type tag_func: callable :param message_func: Callable which returns the desired unformatted message string for the experimental item. Omit to use the default. :type message_func: callable """ def _default_get_message(self): return status_tag_messages[_config_key].format("This " + self.object_type) super().__init__( cli_ctx=cli_ctx, object_type=object_type, target=target, color=color_map[_config_key], tag_func=tag_func or (lambda _: _EXPERIMENTAL_TAG), message_func=message_func or _default_get_message ) class ImplicitExperimentalItem(ExperimentalItem): def __init__(self, **kwargs): def get_implicit_experimental_message(self): return status_tag_messages[_config_key].format("Command group '{}'".format(self.target)) kwargs.update({ 'tag_func': lambda _: '', 'message_func': get_implicit_experimental_message }) super().__init__(**kwargs) knack-0.9.0/knack/help.py000066400000000000000000000672731414113720500152150ustar00rootroot00000000000000# -------------------------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- import argparse import sys import textwrap from .deprecation import ImplicitDeprecated, resolve_deprecate_info from .log import get_logger from .preview import ImplicitPreviewItem, resolve_preview_info from .experimental import ImplicitExperimentalItem, resolve_experimental_info from .util import CtxTypeError from .help_files import _load_help_file logger = get_logger(__name__) FIRST_LINE_PREFIX = ' : ' REQUIRED_TAG = '[Required]' def _get_hanging_indent(max_length, indent): return max_length + (indent * 4) + len(FIRST_LINE_PREFIX) - 1 def _get_padding_len(max_len, layout): if layout['tags']: pad_len = max_len - layout['line_len'] + 1 else: pad_len = max_len - layout['line_len'] return pad_len def _get_line_len(name, tags_len): return len(name) + tags_len + (2 if tags_len else 1) def _print_indent(s, indent=0, subsequent_spaces=-1, width=100): tw = textwrap.TextWrapper(initial_indent=' ' * indent, subsequent_indent=(' ' * indent if subsequent_spaces == -1 else ' ' * subsequent_spaces), replace_whitespace=False, width=width) paragraphs = s.split('\n') for p in paragraphs: try: print(tw.fill(p), file=sys.stdout) except UnicodeEncodeError: print(tw.fill(p).encode('ascii', 'ignore').decode('utf-8', 'ignore'), file=sys.stdout) class HelpAuthoringException(Exception): pass class ArgumentGroupRegistry(object): # pylint: disable=too-few-public-methods def __init__(self, group_list): self.priorities = { None: 0, 'Global Arguments': 1000, } priority = 2 # any groups not already in the static dictionary should be prioritized alphabetically other_groups = [g for g in sorted(list(set(group_list))) if g not in self.priorities] for group in other_groups: self.priorities[group] = priority priority += 1 def get_group_priority(self, group_name): key = self.priorities.get(group_name, 0) return "%06d" % key class HelpObject(object): @staticmethod def _normalize_text(s): if not s or len(s) < 2: return s or '' s = s.strip() initial_upper = s[0].upper() + s[1:] trailing_period = '' if s[-1] in '.!?' else '.' return initial_upper + trailing_period def __init__(self, **kwargs): self._short_summary = '' self._long_summary = '' super().__init__(**kwargs) @property def short_summary(self): return self._short_summary @short_summary.setter def short_summary(self, value): self._short_summary = self._normalize_text(value) @property def long_summary(self): return self._long_summary @long_summary.setter def long_summary(self, value): self._long_summary = self._normalize_text(value) # pylint: disable=too-many-instance-attributes class HelpFile(HelpObject): @staticmethod def _load_help_file_from_string(text): import yaml try: return yaml.safe_load(text) if text else None except Exception: # pylint: disable=broad-except return text def __init__(self, help_ctx, delimiters): # pylint: disable=too-many-statements super().__init__() self.help_ctx = help_ctx self.delimiters = delimiters self.name = delimiters.split()[-1] if delimiters else delimiters self.command = delimiters self.type = '' self.short_summary = '' self.long_summary = '' self.examples = [] self.deprecate_info = None self.preview_info = None self.experimental_info = None direct_deprecate_info = resolve_deprecate_info(help_ctx.cli_ctx, delimiters) if direct_deprecate_info: self.deprecate_info = direct_deprecate_info # search for implicit deprecation path_comps = delimiters.split()[:-1] implicit_deprecate_info = None while path_comps and not implicit_deprecate_info: implicit_deprecate_info = resolve_deprecate_info(help_ctx.cli_ctx, ' '.join(path_comps)) del path_comps[-1] if implicit_deprecate_info: deprecate_kwargs = implicit_deprecate_info.__dict__.copy() deprecate_kwargs['object_type'] = 'command' if delimiters in \ help_ctx.cli_ctx.invocation.commands_loader.command_table else 'command group' del deprecate_kwargs['_get_tag'] del deprecate_kwargs['_get_message'] self.deprecate_info = ImplicitDeprecated(cli_ctx=help_ctx.cli_ctx, **deprecate_kwargs) # resolve preview info direct_preview_info = resolve_preview_info(help_ctx.cli_ctx, delimiters) if direct_preview_info: self.preview_info = direct_preview_info # search for implicit preview path_comps = delimiters.split()[:-1] implicit_preview_info = None while path_comps and not implicit_preview_info: implicit_preview_info = resolve_preview_info(help_ctx.cli_ctx, ' '.join(path_comps)) del path_comps[-1] if implicit_preview_info: preview_kwargs = implicit_preview_info.__dict__.copy() if delimiters in help_ctx.cli_ctx.invocation.commands_loader.command_table: preview_kwargs['object_type'] = 'command' else: preview_kwargs['object_type'] = 'command group' self.preview_info = ImplicitPreviewItem(cli_ctx=help_ctx.cli_ctx, **preview_kwargs) # resolve experimental info direct_experimental_info = resolve_experimental_info(help_ctx.cli_ctx, delimiters) if direct_experimental_info: self.experimental_info = direct_experimental_info # search for implicit experimental path_comps = delimiters.split()[:-1] implicit_experimental_info = None while path_comps and not implicit_experimental_info: implicit_experimental_info = resolve_experimental_info(help_ctx.cli_ctx, ' '.join(path_comps)) del path_comps[-1] if implicit_experimental_info: experimental_kwargs = implicit_experimental_info.__dict__.copy() if delimiters in help_ctx.cli_ctx.invocation.commands_loader.command_table: experimental_kwargs['object_type'] = 'command' else: experimental_kwargs['object_type'] = 'command group' self.experimental_info = ImplicitExperimentalItem(cli_ctx=help_ctx.cli_ctx, **experimental_kwargs) def load(self, options): description = getattr(options, 'description', None) try: self.short_summary = description[:description.index('.')] long_summary = description[description.index('.') + 1:].lstrip() self.long_summary = ' '.join(long_summary.splitlines()) except (ValueError, AttributeError): self.short_summary = description file_data = (self._load_help_file_from_string(options.help_file) if hasattr(options, '_defaults') else None) if file_data: self._load_from_data(file_data) else: self._load_from_file() def _load_from_file(self): file_data = _load_help_file(self.delimiters) if file_data: self._load_from_data(file_data) def _load_from_data(self, data): if not data: return if isinstance(data, str): self.long_summary = data return if 'type' in data: self.type = data['type'] if 'short-summary' in data: self.short_summary = data['short-summary'] self.long_summary = data.get('long-summary') if 'examples' in data: self.examples = [HelpExample(d) for d in data['examples']] class GroupHelpFile(HelpFile): def __init__(self, help_ctx, delimiters, parser): super().__init__(help_ctx, delimiters) self.type = 'group' self.children = [] if getattr(parser, 'choices', None): for options in parser.choices.values(): delimiters = ' '.join(options.prog.split()[1:]) child = (help_ctx.group_help_cls(self.help_ctx, delimiters, options) if options.is_group() else help_ctx.help_cls(self.help_ctx, delimiters)) child.load(options) try: # don't hide implicitly deprecated commands if not isinstance(child.deprecate_info, ImplicitDeprecated) and \ not child.deprecate_info.show_in_help(): continue except AttributeError: pass self.children.append(child) class CommandHelpFile(HelpFile): def __init__(self, help_ctx, delimiters, parser): super().__init__(help_ctx, delimiters) self.type = 'command' self.parameters = [] for action in [a for a in parser._actions if a.help != argparse.SUPPRESS]: # pylint: disable=protected-access if action.option_strings: self._add_parameter_help(action) else: # use metavar for positional parameters param_kwargs = { 'name_source': [action.metavar or action.dest], 'deprecate_info': getattr(action, 'deprecate_info', None), 'preview_info': getattr(action, 'preview_info', None), 'experimental_info': getattr(action, 'experimental_info', None), 'description': action.help, 'choices': action.choices, 'required': False, 'default': None, 'group_name': 'Positional' } self.parameters.append(HelpParameter(**param_kwargs)) help_param = next(p for p in self.parameters if p.name == '--help -h') help_param.group_name = 'Global Arguments' def _add_parameter_help(self, param): param_kwargs = { 'description': param.help, 'choices': param.choices, 'required': param.required, 'default': param.default, 'group_name': param.container.description } normal_options = [] deprecated_options = [] for item in param.option_strings: deprecated_info = getattr(item, 'deprecate_info', None) if deprecated_info: if deprecated_info.show_in_help(): deprecated_options.append(item) else: normal_options.append(item) if deprecated_options: param_kwargs.update({ 'name_source': deprecated_options, 'deprecate_info': deprecated_options[0].deprecate_info }) self.parameters.append(HelpParameter(**param_kwargs)) param_kwargs.update({ 'name_source': normal_options, 'deprecate_info': getattr(param, 'deprecate_info', None), 'preview_info': getattr(param, 'preview_info', None), 'experimental_info': getattr(param, 'experimental_info', None), 'default_value_source': getattr(param, 'default_value_source', None) }) self.parameters.append(HelpParameter(**param_kwargs)) def _load_from_data(self, data): super()._load_from_data(data) if isinstance(data, str) or not self.parameters or not data.get('parameters'): return loaded_params = [] loaded_param = {} for param in self.parameters: loaded_param = next((n for n in data['parameters'] if n['name'] == param.name), None) if loaded_param: param.update_from_data(loaded_param) loaded_params.append(param) self.parameters = loaded_params class HelpParameter(HelpObject): # pylint: disable=too-many-instance-attributes def __init__(self, name_source, description, required, choices=None, default=None, group_name=None, deprecate_info=None, preview_info=None, experimental_info=None, default_value_source=None): super().__init__() self.name_source = name_source self.name = ' '.join(sorted(name_source)) self.required = required self.type = 'string' self.short_summary = description self.long_summary = '' self.value_sources = [] self.choices = choices self.default = default self.group_name = group_name self.deprecate_info = deprecate_info self.preview_info = preview_info self.experimental_info = experimental_info self.default_value_source = default_value_source def update_from_data(self, data): if self.name != data.get('name'): raise HelpAuthoringException("mismatched name {} vs. {}" .format(self.name, data.get('name'))) if data.get('type'): self.type = data.get('type') if data.get('short-summary'): self.short_summary = data.get('short-summary') if data.get('long-summary'): self.long_summary = data.get('long-summary') if data.get('populator-commands'): self.value_sources = data.get('populator-commands') class HelpExample(object): # pylint: disable=too-few-public-methods def __init__(self, _data): self.name = _data['name'] self.text = _data['text'] class CLIHelp(object): def _print_header(self, cli_name, help_file): indent = 0 _print_indent('') _print_indent('Command' if help_file.type == 'command' else 'Group', indent) indent += 1 LINE_FORMAT = '{cli}{name}{separator}{summary}' line = LINE_FORMAT.format( cli=cli_name, name=' ' + help_file.command if help_file.command else '', separator=FIRST_LINE_PREFIX if help_file.short_summary else '', summary=help_file.short_summary if help_file.short_summary else '' ) _print_indent(line, indent, width=self.textwrap_width) def _build_long_summary(item): lines = [] if item.long_summary: lines.append(item.long_summary) if item.deprecate_info: lines.append(str(item.deprecate_info.message)) if item.preview_info: lines.append(str(item.preview_info.message)) if item.experimental_info: lines.append(str(item.experimental_info.message)) return '\n'.join(lines) indent += 1 long_sum = _build_long_summary(help_file) _print_indent(long_sum, indent, width=self.textwrap_width) def _print_groups(self, help_file): LINE_FORMAT = '{name}{padding}{tags}{separator}{summary}' indent = 1 self.max_line_len = 0 def _build_tags_string(item): preview_info = getattr(item, 'preview_info', None) preview = preview_info.tag if preview_info else '' experimental_info = getattr(item, 'experimental_info', None) experimental = experimental_info.tag if experimental_info else '' deprecate_info = getattr(item, 'deprecate_info', None) deprecated = deprecate_info.tag if deprecate_info else '' required = REQUIRED_TAG if getattr(item, 'required', None) else '' tags = ' '.join([x for x in [str(deprecated), str(preview), str(experimental), required] if x]) tags_len = sum([ len(deprecated), len(preview), len(experimental), len(required), tags.count(' ') ]) if not tags_len: tags = '' return tags, tags_len def _layout_items(items): layouts = [] for c in sorted(items, key=lambda h: h.name): tags, tags_len = _build_tags_string(c) line_len = _get_line_len(c.name, tags_len) layout = { 'name': c.name, 'tags': tags, 'separator': FIRST_LINE_PREFIX if c.short_summary else '', 'summary': c.short_summary or '', 'line_len': line_len } layout['summary'] = layout['summary'].replace('\n', ' ') if line_len > self.max_line_len: self.max_line_len = line_len layouts.append(layout) return layouts def _print_items(layouts): for layout in layouts: layout['padding'] = ' ' * _get_padding_len(self.max_line_len, layout) _print_indent( LINE_FORMAT.format(**layout), indent, _get_hanging_indent(self.max_line_len, indent), width=self.textwrap_width, ) _print_indent('') groups = [c for c in help_file.children if isinstance(c, self.group_help_cls)] group_layouts = _layout_items(groups) commands = [c for c in help_file.children if c not in groups] command_layouts = _layout_items(commands) if groups: _print_indent('Subgroups:') _print_items(group_layouts) if commands: _print_indent('Commands:') _print_items(command_layouts) @staticmethod def _get_choices_defaults_sources_str(p): choice_str = ' Allowed values: {}.'.format(', '.join(sorted([str(x) for x in p.choices]))) \ if p.choices else '' default_str = ' Default: {}.'.format(p.default) \ if p.default and p.default != argparse.SUPPRESS else '' value_sources_str = ' Values from: {}.'.format(', '.join(p.value_sources)) \ if p.value_sources else '' return '{}{}{}'.format(choice_str, default_str, value_sources_str) @staticmethod def print_description_list(help_files): indent = 1 max_length = max(len(f.name) for f in help_files) if help_files else 0 for help_file in sorted(help_files, key=lambda h: h.name): column_indent = max_length - len(help_file.name) _print_indent('{}{}{}'.format(help_file.name, ' ' * column_indent, FIRST_LINE_PREFIX + help_file.short_summary if help_file.short_summary else ''), indent, _get_hanging_indent(max_length, indent)) @staticmethod def _print_examples(help_file): indent = 0 _print_indent('Examples', indent) for e in help_file.examples: indent = 1 _print_indent('{0}'.format(e.name), indent) indent = 2 _print_indent('{0}'.format(e.text), indent) print('') def _print_arguments(self, help_file): # pylint: disable=too-many-statements LINE_FORMAT = '{name}{padding}{tags}{separator}{short_summary}' indent = 1 self.max_line_len = 0 if not help_file.parameters: _print_indent('None', indent) _print_indent('') return None def _build_tags_string(item): preview_info = getattr(item, 'preview_info', None) preview = preview_info.tag if preview_info else '' experimental_info = getattr(item, 'experimental_info', None) experimental = experimental_info.tag if experimental_info else '' deprecate_info = getattr(item, 'deprecate_info', None) deprecated = deprecate_info.tag if deprecate_info else '' required = REQUIRED_TAG if getattr(item, 'required', None) else '' tags = ' '.join([x for x in [str(deprecated), str(preview), str(experimental), required] if x]) tags_len = sum([ len(deprecated), len(preview), len(experimental), len(required), tags.count(' ') ]) if not tags_len: tags = '' return tags, tags_len def _layout_items(items): layouts = [] for c in sorted(items, key=_get_parameter_key): deprecate_info = getattr(c, 'deprecate_info', None) if deprecate_info and not deprecate_info.show_in_help(): continue tags, tags_len = _build_tags_string(c) short_summary = _build_short_summary(c) long_summary = _build_long_summary(c) line_len = _get_line_len(c.name, tags_len) layout = { 'name': c.name, 'tags': tags, 'separator': FIRST_LINE_PREFIX if short_summary else '', 'short_summary': short_summary, 'long_summary': long_summary, 'group_name': c.group_name, 'line_len': line_len } if line_len > self.max_line_len: self.max_line_len = line_len layouts.append(layout) return layouts def _print_items(layouts): last_group_name = '' for layout in layouts: indent = 1 if layout['group_name'] != last_group_name: if layout['group_name']: print('') print(layout['group_name']) last_group_name = layout['group_name'] layout['padding'] = ' ' * _get_padding_len(self.max_line_len, layout) _print_indent( LINE_FORMAT.format(**layout), indent, _get_hanging_indent(self.max_line_len, indent), width=self.textwrap_width, ) indent = 2 long_summary = layout.get('long_summary', None) if long_summary: _print_indent(long_summary, indent, width=self.textwrap_width) _print_indent('') def _build_short_summary(item): short_summary = item.short_summary possible_values_index = short_summary.find(' Possible values include') short_summary = short_summary[0:possible_values_index if possible_values_index >= 0 else len(short_summary)] short_summary += self._get_choices_defaults_sources_str(item) short_summary = short_summary.strip() return short_summary def _build_long_summary(item): lines = [] if item.long_summary: lines.append(item.long_summary) deprecate_info = getattr(item, 'deprecate_info', None) if deprecate_info: lines.append(str(item.deprecate_info.message)) preview_info = getattr(item, 'preview_info', None) if preview_info: lines.append(str(item.preview_info.message)) experimental_info = getattr(item, 'experimental_info', None) if experimental_info: lines.append(str(item.experimental_info.message)) return ' '.join(lines) group_registry = ArgumentGroupRegistry([p.group_name for p in help_file.parameters if p.group_name]) def _get_parameter_key(parameter): return '{}{}{}'.format(group_registry.get_group_priority(parameter.group_name), str(not parameter.required), parameter.name) parameter_layouts = _layout_items(help_file.parameters) _print_items(parameter_layouts) return indent def _print_detailed_help(self, cli_name, help_file): self._print_header(cli_name, help_file) if help_file.long_summary or getattr(help_file, 'deprecate_info', None): _print_indent('') # fix incorrect groupings instead of crashing if help_file.type == 'command' and not isinstance(help_file, CommandHelpFile): help_file.type = 'group' logger.info("'%s' is labeled a command but is actually a group!", help_file.delimiters) elif help_file.type == 'group' and not isinstance(help_file, GroupHelpFile): help_file.type = 'command' logger.info("'%s' is labeled a group but is actually a command!", help_file.delimiters) if help_file.type == 'command': _print_indent('Arguments') self._print_arguments(help_file) elif help_file.type == 'group': self._print_groups(help_file) if help_file.examples: self._print_examples(help_file) def __init__(self, cli_ctx=None, privacy_statement='', welcome_message='', group_help_cls=GroupHelpFile, command_help_cls=CommandHelpFile, help_cls=HelpFile, textwrap_width=100): """ Manages the generation and production of help in the CLI :param cli_ctx: CLI Context :type cli_ctx: knack.cli.CLI :param privacy_statement: Privacy statement for the CLI :type privacy_statement: str :param welcome_message: A welcome message for the CLI :type welcome_message: str :param group_help_cls: Class to use for formatting group help. :type group_help_cls: HelpFile :param command_help_cls: Class to use for formatting command help. :type command_help_cls: HelpFile :param command_help_cls: Class to use for formatting generic help. :type command_help_cls: HelpFile :param textwrap_width: Line length to which text will be wrapped. :type textwrap_width: int """ from .cli import CLI if cli_ctx is not None and not isinstance(cli_ctx, CLI): raise CtxTypeError(cli_ctx) self.cli_ctx = cli_ctx self.privacy_statement = privacy_statement self.welcome_message = welcome_message self.max_line_len = 0 self.group_help_cls = group_help_cls self.command_help_cls = command_help_cls self.help_cls = help_cls self.textwrap_width = textwrap_width def show_privacy_statement(self): ran_before = self.cli_ctx.config.getboolean('core', 'first_run', fallback=False) if not ran_before: if self.privacy_statement: print(self.privacy_statement, file=self.cli_ctx.out_file) self.cli_ctx.config.set_value('core', 'first_run', 'yes') def show_welcome_message(self): _print_indent(self.welcome_message, width=self.textwrap_width) def show_welcome(self, parser): self.show_privacy_statement() self.show_welcome_message() help_file = self.group_help_cls(self, '', parser) self.print_description_list(help_file.children) def show_help(self, cli_name, nouns, parser, is_group): delimiters = ' '.join(nouns) help_file = self.command_help_cls(self, delimiters, parser) if not is_group \ else self.group_help_cls(self, delimiters, parser) help_file.load(parser) if not nouns: help_file.command = '' self._print_detailed_help(cli_name, help_file) knack-0.9.0/knack/help_files.py000066400000000000000000000011071414113720500163570ustar00rootroot00000000000000# -------------------------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- # commands should add entries to helps in the form: "group command": "YAML help" helps = {} def _load_help_file(delimiters): import yaml if delimiters in helps: return yaml.safe_load(helps[delimiters]) return None knack-0.9.0/knack/introspection.py000066400000000000000000000076431414113720500171600ustar00rootroot00000000000000# -------------------------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- """ Utility file for introspection. """ import inspect import re from .arguments import CLICommandArgument def extract_full_summary_from_signature(operation): """ Extract the summary from the docstring of the command. """ lines = inspect.getdoc(operation) regex = r'\s*(:param)\s+(.+?)\s*:(.*)' summary = '' if lines: match = re.search(regex, lines) summary = lines[:match.regs[0][0]] if match else lines summary = summary.replace('\n', ' ').replace('\r', '') return summary def option_descriptions(operation): """ Extract parameter help from docstring of the command. """ lines = inspect.getdoc(operation) if not lines: return {} param_breaks = ["'''", '"""', ':param', ':type', ':return', ':rtype'] option_descs = {} lines = lines.splitlines() index = 0 while index < len(lines): l = lines[index] regex = r'\s*(:param)\s+(.+?)\s*:(.*)' match = re.search(regex, l) if not match: index += 1 continue # 'arg name' portion might have type info, we don't need it arg_name = str.split(match.group(2))[-1] arg_desc = match.group(3).strip() # look for more descriptions on subsequent lines index += 1 while index < len(lines): temp = lines[index].strip() if any(temp.startswith(x) for x in param_breaks): break if temp: arg_desc += (' ' + temp) index += 1 option_descs[arg_name] = arg_desc return option_descs def extract_args_from_signature(operation, excluded_params=None): """ Extracts basic argument data from an operation's signature and docstring excluded_params: List of params to ignore and not extract. By default we ignore ['self', 'kwargs']. """ args = [] try: # only supported in python3 - falling back to argspec if not available sig = inspect.signature(operation) args = sig.parameters except AttributeError: sig = inspect.getargspec(operation) # pylint: disable=deprecated-method, useless-suppression args = sig.args arg_docstring_help = option_descriptions(operation) excluded_params = excluded_params or ['self', 'kwargs'] for arg_name in [a for a in args if a not in excluded_params]: try: # this works in python3 default = args[arg_name].default required = default == inspect.Parameter.empty # pylint: disable=no-member, useless-suppression except TypeError: arg_defaults = (dict(zip(sig.args[-len(sig.defaults):], sig.defaults)) if sig.defaults else {}) default = arg_defaults.get(arg_name) required = arg_name not in arg_defaults action = 'store_' + str(not default).lower() if isinstance(default, bool) else None try: default = (default if default != inspect._empty # pylint: disable=protected-access else None) except AttributeError: pass options_list = ['--' + arg_name.replace('_', '-')] help_str = arg_docstring_help.get(arg_name) yield (arg_name, CLICommandArgument(arg_name, options_list=options_list, required=required, default=default, help=help_str, action=action)) knack-0.9.0/knack/invocation.py000066400000000000000000000236371414113720500164320ustar00rootroot00000000000000# -------------------------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- import sys from collections import defaultdict from .deprecation import ImplicitDeprecated, resolve_deprecate_info from .preview import ImplicitPreviewItem, resolve_preview_info from .experimental import ImplicitExperimentalItem, resolve_experimental_info from .util import CLIError, CtxTypeError, CommandResultItem, todict from .parser import CLICommandParser from .commands import CLICommandsLoader from .events import (EVENT_INVOKER_PRE_CMD_TBL_CREATE, EVENT_INVOKER_POST_CMD_TBL_CREATE, EVENT_INVOKER_CMD_TBL_LOADED, EVENT_INVOKER_PRE_PARSE_ARGS, EVENT_INVOKER_POST_PARSE_ARGS, EVENT_INVOKER_TRANSFORM_RESULT, EVENT_INVOKER_FILTER_RESULT) from .help import CLIHelp class CommandInvoker(object): def __init__(self, cli_ctx=None, parser_cls=CLICommandParser, commands_loader_cls=CLICommandsLoader, help_cls=CLIHelp, initial_data=None): """ Manages a single invocation of the CLI (i.e. running a command) :param cli_ctx: CLI Context :type cli_ctx: knack.cli.CLI :param parser_cls: A class to handle command parsing :type parser_cls: knack.parser.CLICommandParser :param commands_loader_cls: A class to handle loading commands :type commands_loader_cls: knack.commands.CLICommandsLoader :param help_cls: A class to handle help :type help_cls: knack.help.CLIHelp :param initial_data: The initial in-memory collection for this command invocation :type initial_data: dict """ from .cli import CLI if cli_ctx is not None and not isinstance(cli_ctx, CLI): raise CtxTypeError(cli_ctx) self.cli_ctx = cli_ctx # In memory collection of key-value data for this current invocation This does not persist between invocations. self.data = initial_data or defaultdict(lambda: None) self.data['command'] = 'unknown' self._global_parser = parser_cls.create_global_parser(cli_ctx=self.cli_ctx) self.help = help_cls(cli_ctx=self.cli_ctx) self.parser = parser_cls(cli_ctx=self.cli_ctx, cli_help=self.help, prog=self.cli_ctx.name, parents=[self._global_parser]) self.commands_loader = commands_loader_cls(cli_ctx=self.cli_ctx) def _filter_params(self, args): # pylint: disable=no-self-use # Consider - we are using any args that start with an underscore (_) as 'private' # arguments and remove them from the arguments that we pass to the actual function. params = {key: value for key, value in args.__dict__.items() if not key.startswith('_')} params.pop('func', None) params.pop('command', None) return params def _rudimentary_get_command(self, args): """ Rudimentary parsing to get the command """ nouns = [] command_names = self.commands_loader.command_table.keys() for arg in args: if arg and arg[0] != '-': nouns.append(arg) else: break def _find_args(args): search = ' '.join(args).lower() return next((x for x in command_names if x.startswith(search)), False) # since the command name may be immediately followed by a positional arg, strip those off while nouns and not _find_args(nouns): del nouns[-1] # ensure the command string is case-insensitive for i in range(len(nouns)): args[i] = args[i].lower() return ' '.join(nouns) def _validate_cmd_level(self, ns, cmd_validator): # pylint: disable=no-self-use if cmd_validator: cmd_validator(ns) try: delattr(ns, '_command_validator') except AttributeError: pass def _validate_arg_level(self, ns, **_): # pylint: disable=no-self-use for validator in getattr(ns, '_argument_validators', []): validator(ns) try: delattr(ns, '_argument_validators') except AttributeError: pass def _validation(self, parsed_ns): try: cmd_validator = getattr(parsed_ns, '_command_validator', None) if cmd_validator: self._validate_cmd_level(parsed_ns, cmd_validator) else: self._validate_arg_level(parsed_ns) except CLIError: raise except Exception: # pylint: disable=broad-except err = sys.exc_info()[1] getattr(parsed_ns, '_parser', self.parser).validation_error(str(err)) # pylint: disable=too-many-statements def execute(self, args): """ Executes the command invocation :param args: The command arguments for this invocation :type args: list :return: The command result :rtype: knack.util.CommandResultItem """ self.cli_ctx.raise_event(EVENT_INVOKER_PRE_CMD_TBL_CREATE, args=args) cmd_tbl = self.commands_loader.load_command_table(args) command = self._rudimentary_get_command(args) self.cli_ctx.invocation.data['command_string'] = command self.commands_loader.load_arguments(command) self.cli_ctx.raise_event(EVENT_INVOKER_POST_CMD_TBL_CREATE, cmd_tbl=cmd_tbl) self.parser.load_command_table(self.commands_loader) self.cli_ctx.raise_event(EVENT_INVOKER_CMD_TBL_LOADED, parser=self.parser) arg_check = [a for a in args if a not in ['--verbose', '--debug', '--only-show-warnings']] if not arg_check: self.cli_ctx.completion.enable_autocomplete(self.parser) subparser = self.parser.subparsers[tuple()] self.help.show_welcome(subparser) return CommandResultItem(None, exit_code=0) if args[0].lower() == 'help': args[0] = '--help' self.cli_ctx.completion.enable_autocomplete(self.parser) self.cli_ctx.raise_event(EVENT_INVOKER_PRE_PARSE_ARGS, args=args) parsed_args = self.parser.parse_args(args) self.cli_ctx.raise_event(EVENT_INVOKER_POST_PARSE_ARGS, command=parsed_args.command, args=parsed_args) self._validation(parsed_args) # save the command name (leaf in the tree) self.data['command'] = parsed_args.command cmd = parsed_args.func if hasattr(parsed_args, 'cmd'): parsed_args.cmd = cmd deprecations = getattr(parsed_args, '_argument_deprecations', []) if cmd.deprecate_info: deprecations.append(cmd.deprecate_info) previews = getattr(parsed_args, '_argument_previews', []) if cmd.preview_info: previews.append(cmd.preview_info) experimentals = getattr(parsed_args, '_argument_experimentals', []) if cmd.experimental_info: experimentals.append(cmd.experimental_info) params = self._filter_params(parsed_args) # search for implicit deprecation path_comps = cmd.name.split()[:-1] implicit_deprecate_info = None while path_comps and not implicit_deprecate_info: implicit_deprecate_info = resolve_deprecate_info(self.cli_ctx, ' '.join(path_comps)) del path_comps[-1] if implicit_deprecate_info: deprecate_kwargs = implicit_deprecate_info.__dict__.copy() deprecate_kwargs['object_type'] = 'command' del deprecate_kwargs['_get_tag'] del deprecate_kwargs['_get_message'] deprecations.append(ImplicitDeprecated(cli_ctx=self.cli_ctx, **deprecate_kwargs)) # search for implicit preview path_comps = cmd.name.split()[:-1] implicit_preview_info = None while path_comps and not implicit_preview_info: implicit_preview_info = resolve_preview_info(self.cli_ctx, ' '.join(path_comps)) del path_comps[-1] if implicit_preview_info: preview_kwargs = implicit_preview_info.__dict__.copy() preview_kwargs['object_type'] = 'command' previews.append(ImplicitPreviewItem(cli_ctx=self.cli_ctx, **preview_kwargs)) # search for implicit experimental path_comps = cmd.name.split()[:-1] implicit_experimental_info = None while path_comps and not implicit_experimental_info: implicit_experimental_info = resolve_experimental_info(self.cli_ctx, ' '.join(path_comps)) del path_comps[-1] if implicit_experimental_info: experimental_kwargs = implicit_experimental_info.__dict__.copy() experimental_kwargs['object_type'] = 'command' experimentals.append(ImplicitExperimentalItem(cli_ctx=self.cli_ctx, **experimental_kwargs)) if not self.cli_ctx.only_show_errors: for d in deprecations: print(d.message, file=sys.stderr) for p in previews: print(p.message, file=sys.stderr) for p in experimentals: print(p.message, file=sys.stderr) cmd_result = parsed_args.func(params) cmd_result = todict(cmd_result) event_data = {'result': cmd_result} self.cli_ctx.raise_event(EVENT_INVOKER_TRANSFORM_RESULT, event_data=event_data) self.cli_ctx.raise_event(EVENT_INVOKER_FILTER_RESULT, event_data=event_data) return CommandResultItem(event_data['result'], exit_code=0, table_transformer=cmd_tbl[parsed_args.command].table_transformer, is_query_active=self.data['query_active'], raw_result=cmd_result) knack-0.9.0/knack/log.py000066400000000000000000000225111414113720500150300ustar00rootroot00000000000000# -------------------------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- import os import logging from enum import IntEnum from .util import CtxTypeError, ensure_dir, CLIError, color_map from .events import EVENT_PARSER_GLOBAL_CREATE CLI_LOGGER_NAME = 'cli' # Add more logger names to this list so that ERROR, WARNING, INFO logs from these loggers can also be displayed # without --debug flag. cli_logger_names = [CLI_LOGGER_NAME] LOG_FILE_ENCODING = 'utf-8' class CliLogLevel(IntEnum): CRITICAL = 0 ERROR = 1 WARNING = 2 INFO = 3 DEBUG = 4 def get_logger(module_name=None): """ Get the logger for a module. If no module name is given, the current CLI logger is returned. Example: get_logger(__name__) :param module_name: The module to get the logger for :type module_name: str :return: The logger :rtype: logger """ if module_name: logger_name = '{}.{}'.format(CLI_LOGGER_NAME, module_name) else: logger_name = CLI_LOGGER_NAME return logging.getLogger(logger_name) class _CustomStreamHandler(logging.StreamHandler): @classmethod def wrap_with_color(cls, level_name, msg): color_marker = color_map[level_name.lower()] return '{}{}{}'.format(color_marker, msg, color_map['reset']) def __init__(self, log_level_config, log_format, enable_color): logging.StreamHandler.__init__(self) self.setLevel(log_level_config) self.enable_color = enable_color self.setFormatter(logging.Formatter(log_format)) def format(self, record): msg = logging.StreamHandler.format(self, record) if self.enable_color: msg = self.wrap_with_color(record.levelname, msg) return msg class CLILogging: # pylint: disable=too-many-instance-attributes DEBUG_FLAG = '--debug' VERBOSE_FLAG = '--verbose' ONLY_SHOW_ERRORS_FLAG = '--only-show-errors' @staticmethod def on_global_arguments(_, **kwargs): arg_group = kwargs.get('arg_group') # The arguments for verbosity don't get parsed by argparse but we add it here for help. arg_group.add_argument(CLILogging.VERBOSE_FLAG, dest='_log_verbosity_verbose', action='store_true', help='Increase logging verbosity. Use --debug for full debug logs.') arg_group.add_argument(CLILogging.DEBUG_FLAG, dest='_log_verbosity_debug', action='store_true', help='Increase logging verbosity to show all debug logs.') arg_group.add_argument(CLILogging.ONLY_SHOW_ERRORS_FLAG, dest='_log_verbosity_only_show_errors', action='store_true', help='Only show errors, suppressing warnings.') def __init__(self, name, cli_ctx=None): """ :param name: The name to be used for log files :type name: str :param cli_ctx: CLI Context :type cli_ctx: knack.cli.CLI """ from .cli import CLI if cli_ctx is not None and not isinstance(cli_ctx, CLI): raise CtxTypeError(cli_ctx) self.logfile_name = '{}.log'.format(name) self.file_log_enabled = CLILogging._is_file_log_enabled(cli_ctx) self.log_dir = CLILogging._get_log_dir(cli_ctx) self.cli_ctx = cli_ctx self.cli_ctx.register_event(EVENT_PARSER_GLOBAL_CREATE, CLILogging.on_global_arguments) # The value is determined when `configure` is called self.log_level = None def configure(self, args): """ Configure the loggers with the appropriate log level etc. :param args: The arguments from the command line :type args: list """ root_logger = logging.getLogger() if root_logger.handlers: # handlers already configured return self.log_level = self._determine_log_level(args) console_log_levels = self._get_console_log_levels() console_log_formats = self._get_console_log_formats() # Set the levels of the loggers to lowest level. # Handlers can override by choosing a higher level. root_logger.setLevel(logging.DEBUG) cli_loggers = [logging.getLogger(logger_name) for logger_name in cli_logger_names] for cli_logger in cli_loggers: cli_logger.setLevel(logging.DEBUG) cli_logger.propagate = False self._init_console_handlers(root_logger, cli_loggers, console_log_levels, console_log_formats) if self.file_log_enabled: self._init_logfile_handlers(root_logger, cli_loggers) get_logger(__name__).debug("File logging enabled - writing logs to '%s'.", self.log_dir) def _determine_log_level(self, args): """ Get verbose level by reading the arguments. """ # arguments have higher precedence than config if CLILogging.ONLY_SHOW_ERRORS_FLAG in args: if CLILogging.DEBUG_FLAG in args or CLILogging.VERBOSE_FLAG in args: raise CLIError("--only-show-errors can't be used together with --debug or --verbose") self.cli_ctx.only_show_errors = True return CliLogLevel.ERROR if CLILogging.DEBUG_FLAG in args: self.cli_ctx.only_show_errors = False return CliLogLevel.DEBUG if CLILogging.VERBOSE_FLAG in args: self.cli_ctx.only_show_errors = False return CliLogLevel.INFO if self.cli_ctx.only_show_errors: # only_show_errors is enabled by config return CliLogLevel.ERROR return CliLogLevel.WARNING # default to show WARNINGs and above def _init_console_handlers(self, root_logger, cli_loggers, log_levels, log_formats): root_logger.addHandler(_CustomStreamHandler(log_levels['root'], log_formats['root'], self.cli_ctx.enable_color)) cli_logger_console_handler = _CustomStreamHandler(log_levels['cli'], log_formats['cli'], self.cli_ctx.enable_color) for cli_logger in cli_loggers: cli_logger.addHandler(cli_logger_console_handler) def _init_logfile_handlers(self, root_logger, cli_loggers): ensure_dir(self.log_dir) log_file_path = os.path.join(self.log_dir, self.logfile_name) from logging.handlers import RotatingFileHandler logfile_handler = RotatingFileHandler(log_file_path, maxBytes=10 * 1024 * 1024, backupCount=5, encoding=LOG_FILE_ENCODING) lfmt = logging.Formatter('%(process)d : %(asctime)s : %(levelname)s : %(name)s : %(message)s') logfile_handler.setFormatter(lfmt) logfile_handler.setLevel(logging.DEBUG) root_logger.addHandler(logfile_handler) for cli_logger in cli_loggers: cli_logger.addHandler(logfile_handler) @staticmethod def _is_file_log_enabled(cli_ctx): return cli_ctx.config.getboolean('logging', 'enable_log_file', fallback=False) @staticmethod def _get_log_dir(cli_ctx): default_dir = (os.path.join(cli_ctx.config.config_dir, 'logs')) return os.path.expanduser(cli_ctx.config.get('logging', 'log_dir', fallback=default_dir)) def _get_console_log_levels(self): """Levels of cli logger and root logger for console logging. - cli logger level is controlled by the overall log level - root logger is only shown at DEBUG overall level """ level_list = [ # --only-show-critical [RESERVED] { 'cli': logging.CRITICAL, 'root': logging.CRITICAL }, # --only-show-errors { 'cli': logging.ERROR, 'root': logging.CRITICAL }, # (default) { 'cli': logging.WARNING, 'root': logging.CRITICAL, }, # --verbose { 'cli': logging.INFO, 'root': logging.CRITICAL, }, # --debug { 'cli': logging.DEBUG, 'root': logging.DEBUG, }] return level_list[self.log_level] def _get_console_log_formats(self): """Formats of cli logger and root logger for console logging, depending on color and level settings. - color: - True: Hide level names - False: Show level names (ERROR, WARNING, INFO, DEBUG) - level: - DEBUG: both cli and root logger names are shown - others: no logger names are shown (root logger won't be shown anyway) """ elements = [] if not self.cli_ctx.enable_color: elements.append('%(levelname)s') if self.log_level == CliLogLevel.DEBUG: elements.append('%(name)s') elements.append('%(message)s') log_format = ': '.join(elements) return { # Even though these loggers use the same format, keep the dict for further tuning 'cli': log_format, 'root': log_format } knack-0.9.0/knack/output.py000066400000000000000000000241331414113720500156110ustar00rootroot00000000000000# -------------------------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- import errno import json import traceback from collections import OrderedDict from io import StringIO from .events import EVENT_INVOKER_POST_PARSE_ARGS, EVENT_PARSER_GLOBAL_CREATE from .log import get_logger from .util import CLIError, CommandResultItem, CtxTypeError logger = get_logger(__name__) def _decode_str(output): if not isinstance(output, str): output = str(output) return output class _ComplexEncoder(json.JSONEncoder): def default(self, o): # pylint: disable=method-hidden if isinstance(o, bytes) and not isinstance(o, str): return o.decode() return json.JSONEncoder.default(self, o) def format_json(obj): result = obj.result # OrderedDict.__dict__ is always '{}', to persist the data, convert to dict first. input_dict = dict(result) if hasattr(result, '__dict__') else result return json.dumps(input_dict, ensure_ascii=False, indent=2, sort_keys=True, cls=_ComplexEncoder, separators=(',', ': ')) + '\n' def format_json_color(obj): from pygments import highlight, lexers, formatters return highlight(format_json(obj), lexers.JsonLexer(), formatters.TerminalFormatter()) # pylint: disable=no-member def format_yaml(obj): import yaml try: return yaml.safe_dump(obj.result, default_flow_style=False, allow_unicode=True) except yaml.representer.RepresenterError: # yaml.safe_dump fails when obj.result is an OrderedDict. knack's --query implementation converts the result to an OrderedDict. https://github.com/microsoft/knack/blob/af674bfea793ff42ae31a381a21478bae4b71d7f/knack/query.py#L46. # pylint: disable=line-too-long return yaml.safe_dump(json.loads(json.dumps(obj.result)), default_flow_style=False, allow_unicode=True) def format_yaml_color(obj): from pygments import highlight, lexers, formatters return highlight(format_yaml(obj), lexers.YamlLexer(), formatters.TerminalFormatter()) # pylint: disable=no-member def format_none(_): return "" def format_table(obj): result = obj.result try: if obj.table_transformer and not obj.is_query_active: if isinstance(obj.table_transformer, str): from jmespath import compile as compile_jmes, Options result = compile_jmes(obj.table_transformer).search(result, Options(OrderedDict)) else: result = obj.table_transformer(result) result_list = result if isinstance(result, list) else [result] should_sort_keys = not obj.is_query_active and not obj.table_transformer to = _TableOutput(should_sort_keys) return to.dump(result_list) except Exception as ex: logger.debug(traceback.format_exc()) raise CLIError("Table output unavailable. " "Use the --query option to specify an appropriate query. " "Use --debug for more info.") from ex def format_tsv(obj): result = obj.result result_list = result if isinstance(result, list) else [result] return _TsvOutput.dump(result_list) class OutputProducer(object): ARG_DEST = '_output_format' _FORMAT_DICT = { 'json': format_json, 'jsonc': format_json_color, 'yaml': format_yaml, 'yamlc': format_yaml_color, 'table': format_table, 'tsv': format_tsv, 'none': format_none, } @staticmethod def on_global_arguments(cli_ctx, **kwargs): arg_group = kwargs.get('arg_group') arg_group.add_argument('--output', '-o', dest=OutputProducer.ARG_DEST, choices=list(OutputProducer._FORMAT_DICT), default=cli_ctx.config.get('core', 'output', fallback='json'), help='Output format', type=str.lower) @staticmethod def handle_output_argument(cli_ctx, **kwargs): args = kwargs.get('args') # Set the output type for this invocation cli_ctx.invocation.data['output'] = getattr(args, OutputProducer.ARG_DEST) def __init__(self, cli_ctx=None): """ Manages the production of output from the result of a command invocation :param cli_ctx: CLI Context :type cli_ctx: knack.cli.CLI """ from .cli import CLI if cli_ctx is not None and not isinstance(cli_ctx, CLI): raise CtxTypeError(cli_ctx) self.cli_ctx = cli_ctx self.cli_ctx.register_event(EVENT_PARSER_GLOBAL_CREATE, OutputProducer.on_global_arguments) self.cli_ctx.register_event(EVENT_INVOKER_POST_PARSE_ARGS, OutputProducer.handle_output_argument) def out(self, obj, formatter=None, out_file=None): # pylint: disable=no-self-use """ Produces the output using the command result. The method does not return a result as the output is written straight to the output file. :param obj: The command result :type obj: knack.util.CommandResultItem :param formatter: The formatter we should use for the command result :type formatter: function :param out_file: The file to write output to :type out_file: file-like object """ if not isinstance(obj, CommandResultItem): raise TypeError('Expected {} got {}'.format(CommandResultItem.__name__, type(obj))) output = formatter(obj) try: print(output, file=out_file, end='') except IOError as ex: if ex.errno == errno.EPIPE: pass else: raise except UnicodeEncodeError: logger.warning("Unable to encode the output with %s encoding. Unsupported characters are discarded.", out_file.encoding) print(output.encode('ascii', 'ignore').decode('utf-8', 'ignore'), file=out_file, end='') def get_formatter(self, format_type): # pylint: disable=no-self-use # remove color if stdout is not a tty if not self.cli_ctx.enable_color and format_type == 'jsonc': return OutputProducer._FORMAT_DICT['json'] if not self.cli_ctx.enable_color and format_type == 'yamlc': return OutputProducer._FORMAT_DICT['yaml'] return OutputProducer._FORMAT_DICT[format_type] class _TableOutput(object): # pylint: disable=too-few-public-methods SKIP_KEYS = ['id', 'type', 'etag'] def __init__(self, should_sort_keys=False): self.should_sort_keys = should_sort_keys @staticmethod def _capitalize_first_char(x): return x[0].upper() + x[1:] if x else x def _auto_table_item(self, item): new_entry = OrderedDict() try: keys = sorted(item) if self.should_sort_keys and isinstance(item, dict) else item.keys() for k in keys: if k in _TableOutput.SKIP_KEYS: continue if item[k] is not None and not isinstance(item[k], (list, dict, set)): new_entry[_TableOutput._capitalize_first_char(k)] = item[k] except AttributeError: # handles odd cases where a string/bool/etc. is returned if isinstance(item, list): for col, val in enumerate(item): new_entry['Column{}'.format(col + 1)] = val else: new_entry['Result'] = item return new_entry def _auto_table(self, result): if isinstance(result, list): new_result = [] for item in result: new_result.append(self._auto_table_item(item)) return new_result return self._auto_table_item(result) def dump(self, data): from tabulate import tabulate table_data = self._auto_table(data) table_str = tabulate(table_data, headers="keys", tablefmt="simple", disable_numparse=True) if table_data else '' if table_str == '\n': raise ValueError('Unable to extract fields for table.') return table_str + '\n' class _TsvOutput(object): # pylint: disable=too-few-public-methods @staticmethod def _dump_obj(data, stream): if isinstance(data, list): stream.write(str(len(data))) elif isinstance(data, dict): # We need to print something to avoid mismatching # number of columns if the value is None for some instances # and a dictionary value in other... stream.write('') else: to_write = data if isinstance(data, str) else str(data) stream.write(to_write) @staticmethod def _dump_row(data, stream): separator = '' if isinstance(data, (dict, list)): if isinstance(data, OrderedDict): values = data.values() elif isinstance(data, dict): values = [value for _, value in sorted(data.items())] else: values = data # Iterate through the items either sorted by key value (if dict) or in the order # they were added (in the cases of an ordered dict) in order to make the output # stable for value in values: stream.write(separator) _TsvOutput._dump_obj(value, stream) separator = '\t' elif isinstance(data, list): for value in data: stream.write(separator) _TsvOutput._dump_obj(value, stream) separator = '\t' elif isinstance(data, bool): _TsvOutput._dump_obj(str(data).lower(), stream) else: _TsvOutput._dump_obj(data, stream) stream.write('\n') @staticmethod def dump(data): io = StringIO() for item in data: _TsvOutput._dump_row(item, io) result = io.getvalue() io.close() return result knack-0.9.0/knack/parser.py000066400000000000000000000324051414113720500155460ustar00rootroot00000000000000# -------------------------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- import argparse from .deprecation import Deprecated from .events import EVENT_PARSER_GLOBAL_CREATE from .log import get_logger from .util import CtxTypeError logger = get_logger(__name__) # List of keyword arguments supported in argparse # from https://github.com/python/cpython/blob/master/Lib/argparse.py#L748 ARGPARSE_SUPPORTED_KWARGS = [ 'option_strings', 'dest', 'nargs', 'const', 'default', 'type', 'choices', 'required', 'help', 'metavar', 'action', 'default_value_source' ] class CLICommandParser(argparse.ArgumentParser): @staticmethod def create_global_parser(cli_ctx=None): global_parser = argparse.ArgumentParser(prog=cli_ctx.name, add_help=False) arg_group = global_parser.add_argument_group('global', 'Global Arguments') cli_ctx.raise_event(EVENT_PARSER_GLOBAL_CREATE, arg_group=arg_group) return global_parser @staticmethod def _add_argument(obj, arg): """ Only pass valid argparse kwargs to argparse.ArgumentParser.add_argument """ argparse_options = {name: value for name, value in arg.options.items() if name in ARGPARSE_SUPPORTED_KWARGS} if arg.options_list: scrubbed_options_list = [] for item in arg.options_list: if isinstance(item, Deprecated): # don't add expired options to the parser if item.expired(): continue class _DeprecatedOption(str): def __new__(cls, *args, **kwargs): instance = str.__new__(cls, *args, **kwargs) return instance option = _DeprecatedOption(item.target) setattr(option, 'deprecate_info', item) item = option scrubbed_options_list.append(item) return obj.add_argument(*scrubbed_options_list, **argparse_options) if 'required' in argparse_options: del argparse_options['required'] if 'metavar' not in argparse_options: argparse_options['metavar'] = '<{}>'.format(argparse_options['dest'].upper()) return obj.add_argument(**argparse_options) @staticmethod def _expand_prefixed_files(args): """ Load arguments prefixed with '@' from file as string :param args: Arguments passed from command line :type args: list """ for arg, _ in enumerate(args): if args[arg].startswith('@'): try: logger.debug('Attempting to read file %s', args[arg][1:]) # Use the default system encoding: https://docs.python.org/3/library/functions.html#open with open(args[arg][1:], 'r') as f: # pylint: disable=unspecified-encoding content = f.read() args[arg] = content except IOError: # Leave arg unmodified logger.debug('File Error: Failed to open %s, assume not a file', args[arg][1:]) return args def __init__(self, cli_ctx=None, cli_help=None, **kwargs): """ Create the argument parser :param cli_ctx: CLI Context :type cli_ctx: knack.cli.CLI :param kwargs: These kwargs are typically used by argparse when creating the subparsers """ from .cli import CLI if cli_ctx is not None and not isinstance(cli_ctx, CLI): raise CtxTypeError(cli_ctx) self.cli_ctx = cli_ctx self.cli_help = cli_help self.subparsers = {} self.parents = kwargs.get('parents', []) self.help_file = kwargs.pop('help_file', None) # We allow a callable for description to be passed in in order to delay-load any help # or description for a command. We better stash it away before handing it off for # "normal" argparse handling... self._description = kwargs.pop('description', None) super().__init__(**kwargs) def load_command_table(self, command_loader): """ Process the command table and load it into the parser :param cmd_tbl: A dictionary containing the commands :type cmd_tbl: dict """ cmd_tbl = command_loader.command_table grp_tbl = command_loader.command_group_table if not cmd_tbl: raise ValueError('The command table is empty. At least one command is required.') # If we haven't already added a subparser, we # better do it. if not self.subparsers: sp = self.add_subparsers(dest='_command') sp.required = True self.subparsers = {(): sp} for command_name, metadata in cmd_tbl.items(): subparser = self._get_subparser(command_name.split(), grp_tbl) command_verb = command_name.split()[-1] # To work around http://bugs.python.org/issue9253, we artificially add any new # parsers we add to the "choices" section of the subparser. subparser = self._get_subparser(command_name.split(), grp_tbl) deprecate_info = metadata.deprecate_info if not subparser or (deprecate_info and deprecate_info.expired()): continue # inject command_module designer's help formatter -- default is HelpFormatter fc = metadata.formatter_class or argparse.HelpFormatter command_parser = subparser.add_parser(command_verb, description=metadata.description, parents=self.parents, conflict_handler='error', help_file=metadata.help, formatter_class=fc, cli_help=self.cli_help) command_parser.cli_ctx = self.cli_ctx command_validator = metadata.validator argument_validators = [] argument_groups = {} for arg in metadata.arguments.values(): # don't add deprecated arguments to the parser deprecate_info = arg.type.settings.get('deprecate_info', None) if deprecate_info and deprecate_info.expired(): continue if arg.validator: argument_validators.append(arg.validator) if arg.arg_group: try: group = argument_groups[arg.arg_group] except KeyError: # group not found so create group_name = '{} Arguments'.format(arg.arg_group) group = command_parser.add_argument_group(arg.arg_group, group_name) argument_groups[arg.arg_group] = group param = CLICommandParser._add_argument(group, arg) else: param = CLICommandParser._add_argument(command_parser, arg) param.completer = arg.completer param.deprecate_info = arg.deprecate_info param.preview_info = arg.preview_info param.experimental_info = arg.experimental_info param.default_value_source = arg.default_value_source command_parser.set_defaults( func=metadata, command=command_name, _command_validator=command_validator, _argument_validators=argument_validators, _parser=command_parser) def _get_subparser(self, path, group_table=None): """For each part of the path, walk down the tree of subparsers, creating new ones if one doesn't already exist. """ group_table = group_table or {} for length in range(0, len(path)): parent_path = path[:length] parent_subparser = self.subparsers.get(tuple(parent_path), None) if not parent_subparser: # No subparser exists for the given subpath - create and register # a new subparser. # Since we know that we always have a root subparser (we created) # one when we started loading the command table, and we walk the # path from left to right (i.e. for "cmd subcmd1 subcmd2", we start # with ensuring that a subparser for cmd exists, then for subcmd1, # subcmd2 and so on), we know we can always back up one step and # add a subparser if one doesn't exist command_group = group_table.get(' '.join(parent_path)) if command_group: deprecate_info = command_group.group_kwargs.get('deprecate_info', None) if deprecate_info and deprecate_info.expired(): continue grandparent_path = path[:length - 1] grandparent_subparser = self.subparsers[tuple(grandparent_path)] new_path = path[length - 1] new_parser = grandparent_subparser.add_parser(new_path, cli_help=self.cli_help) # Due to http://bugs.python.org/issue9253, we have to give the subparser # a destination and set it to required in order to get a meaningful error parent_subparser = new_parser.add_subparsers(dest='_subcommand') command_group = group_table.get(' '.join(parent_path), None) deprecate_info = None if command_group: deprecate_info = command_group.group_kwargs.get('deprecate_info', None) parent_subparser.required = True parent_subparser.deprecate_info = deprecate_info self.subparsers[tuple(path[0:length])] = parent_subparser return parent_subparser def validation_error(self, message): return super().error(message) def is_group(self): """ Determine if this parser instance represents a group or a command. Anything that has a func default is considered a group. This includes any dummy commands served up by the "filter out irrelevant commands based on argv" command filter """ cmd = self._defaults.get('func', None) return not (cmd and cmd.handler) def __getattribute__(self, name): """ Since getting the description can be expensive (require module loads), we defer this until someone actually wants to use it (i.e. show help for the command) """ if name == 'description': if self._description: self.description = self._description() \ if callable(self._description) else self._description self._description = None return object.__getattribute__(self, name) def format_help(self): is_group = self.is_group() self.cli_help.show_help(self.prog.split()[0], self.prog.split()[1:], self._actions[-1] if is_group else self, is_group) self.exit() def parse_args(self, args=None, namespace=None): """ Overrides argparse.ArgumentParser.parse_args Enables '@'-prefixed files to be expanded before arguments are processed by ArgumentParser.parse_args as usual """ self._expand_prefixed_files(args) return super().parse_args(args) def _check_value(self, action, value): # Override to customize the error message when a argument is not among the available choices # converted value must be one of the choices (if specified) import difflib import sys if action.choices is not None and value not in action.choices: if action.dest in ["_command", "_subcommand"]: # Command error_msg = "{prog}: '{value}' is not in the '{prog}' command group. See '{prog} --help'.".format( prog=self.prog, value=value) logger.error(error_msg) # Show suggestions candidates = difflib.get_close_matches(value, action.choices, cutoff=0.7) if candidates: suggestion_msg = "\nThe most similar choices to '{value}':\n".format(value=value) suggestion_msg += '\n'.join(['\t' + candidate for candidate in candidates]) print(suggestion_msg, file=sys.stderr) else: # Argument error_msg = "{prog}: '{value}' is not a valid value for '{name}'.".format( prog=self.prog, value=value, name=argparse._get_action_name(action)) # pylint: disable=protected-access logger.error(error_msg) # Show all allowed values suggestion_msg = "Allowed values: " + ', '.join(action.choices) print(suggestion_msg, file=sys.stderr) self.exit(2) knack-0.9.0/knack/preview.py000066400000000000000000000055261414113720500157370ustar00rootroot00000000000000# -------------------------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- from .util import StatusTag, status_tag_messages, color_map _PREVIEW_TAG = '[Preview]' _preview_kwarg = 'preview_info' _config_key = 'preview' def resolve_preview_info(cli_ctx, name): def _get_command(name): return cli_ctx.invocation.commands_loader.command_table[name] def _get_command_group(name): return cli_ctx.invocation.commands_loader.command_group_table.get(name, None) preview_info = None try: command = _get_command(name) preview_info = getattr(command, _preview_kwarg, None) except KeyError: command_group = _get_command_group(name) group_kwargs = getattr(command_group, 'group_kwargs', None) if group_kwargs: preview_info = group_kwargs.get(_preview_kwarg, None) return preview_info # pylint: disable=too-many-instance-attributes class PreviewItem(StatusTag): def __init__(self, cli_ctx, object_type='', target=None, tag_func=None, message_func=None, **kwargs): """ Create a collection of preview metadata. :param cli_ctx: The CLI context associated with the preview item. :type cli_ctx: knack.cli.CLI :param object_type: A label describing the type of object in preview. :type: object_type: str :param target: The name of the object in preview. :type target: str :param tag_func: Callable which returns the desired unformatted tag string for the preview item. Omit to use the default. :type tag_func: callable :param message_func: Callable which returns the desired unformatted message string for the preview item. Omit to use the default. :type message_func: callable """ def _default_get_message(self): return status_tag_messages[_config_key].format("This " + self.object_type) super().__init__( cli_ctx=cli_ctx, object_type=object_type, target=target, color=color_map[_config_key], tag_func=tag_func or (lambda _: _PREVIEW_TAG), message_func=message_func or _default_get_message ) class ImplicitPreviewItem(PreviewItem): def __init__(self, **kwargs): def get_implicit_preview_message(self): return status_tag_messages[_config_key].format("Command group '{}'".format(self.target)) kwargs.update({ 'tag_func': lambda _: '', 'message_func': get_implicit_preview_message }) super().__init__(**kwargs) knack-0.9.0/knack/prompting.py000066400000000000000000000102211414113720500162610ustar00rootroot00000000000000# -------------------------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- import sys import getpass from .log import get_logger logger = get_logger(__name__) _INVALID_PASSWORD_MSG = 'Passwords do not match.' class NoTTYException(Exception): pass def _input(msg): return input(msg) def verify_is_a_tty(): if not sys.stdin.isatty(): logger.debug('No tty available.') raise NoTTYException() def prompt(msg, help_string=None): verify_is_a_tty() while True: val = _input(msg) if val == '?' and help_string is not None: print(help_string) continue return val def prompt_int(msg, help_string=None): verify_is_a_tty() while True: value = _input(msg) if value == '?' and help_string is not None: print(help_string) continue try: return int(value) except ValueError: logger.warning('%s is not a valid number', value) def prompt_pass(msg='Password: ', confirm=False, help_string=None): verify_is_a_tty() while True: password = getpass.getpass(msg) if password == '?' and help_string is not None: print(help_string) continue if confirm: password2 = getpass.getpass('Confirm ' + msg) if password != password2: logger.warning(_INVALID_PASSWORD_MSG) continue return password def prompt_y_n(msg, default=None, help_string=None): return _prompt_bool(msg, 'y', 'n', default=default, help_string=help_string) def prompt_t_f(msg, default=None, help_string=None): return _prompt_bool(msg, 't', 'f', default=default, help_string=help_string) def _prompt_bool(msg, true_str, false_str, default=None, help_string=None): verify_is_a_tty() if default not in [None, true_str, false_str]: raise ValueError("Valid values for default are {}, {} or None".format(true_str, false_str)) y = true_str.upper() if default == true_str else true_str n = false_str.upper() if default == false_str else false_str while True: ans = _input('{} ({}/{}): '.format(msg, y, n)) if ans == '?' and help_string is not None: print(help_string) continue if ans.lower() == n.lower(): return False if ans.lower() == y.lower(): return True if default and not ans: return default == y.lower() def prompt_choice_list(msg, a_list, default=1, help_string=None): """Prompt user to select from a list of possible choices. :param msg:A message displayed to the user before the choice list :type msg: str :param a_list:The list of choices (list of strings or list of dicts with 'name' & 'desc') "type a_list: list :param default:The default option that should be chosen if user doesn't enter a choice :type default: int :returns: The list index of the item chosen. """ verify_is_a_tty() options = '\n'.join([' [{}] {}{}' .format(i + 1, x['name'] if isinstance(x, dict) and 'name' in x else x, ' - ' + x['desc'] if isinstance(x, dict) and 'desc' in x else '') for i, x in enumerate(a_list)]) allowed_vals = list(range(1, len(a_list) + 1)) while True: val = _input('{}\n{}\nPlease enter a choice [Default choice({})]: '.format(msg, options, default)) if val == '?' and help_string is not None: print(help_string) continue if not val: val = '{}'.format(default) try: ans = int(val) if ans in allowed_vals: # array index is 0-based, user input is 1-based return ans - 1 raise ValueError except ValueError: logger.warning('Valid values are %s', allowed_vals) knack-0.9.0/knack/query.py000066400000000000000000000051651414113720500154220ustar00rootroot00000000000000# -------------------------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- import collections from .events import (EVENT_PARSER_GLOBAL_CREATE, EVENT_INVOKER_POST_PARSE_ARGS, EVENT_INVOKER_FILTER_RESULT) from .util import CtxTypeError class CLIQuery(object): @staticmethod def jmespath_type(raw_query): """Compile the query with JMESPath and return the compiled result. JMESPath raises exceptions which subclass from ValueError. In addition though, JMESPath can raise a KeyError. ValueErrors are caught by argparse so argument errors can be generated. """ from jmespath import compile as compile_jmespath try: return compile_jmespath(raw_query) except KeyError as ex: # Raise a ValueError which argparse can handle raise ValueError from ex @staticmethod def on_global_arguments(_, **kwargs): arg_group = kwargs.get('arg_group') arg_group.add_argument('--query', dest='_jmespath_query', metavar='JMESPATH', help='JMESPath query string. See http://jmespath.org/ for more' ' information and examples.', type=CLIQuery.jmespath_type) @staticmethod def handle_query_parameter(cli_ctx, **kwargs): args = kwargs['args'] query_expression = args._jmespath_query # pylint: disable=protected-access del args._jmespath_query if query_expression: def filter_output(cli_ctx, **kwargs): from jmespath import Options kwargs['event_data']['result'] = query_expression.search( kwargs['event_data']['result'], Options(collections.OrderedDict)) cli_ctx.unregister_event(EVENT_INVOKER_FILTER_RESULT, filter_output) cli_ctx.register_event(EVENT_INVOKER_FILTER_RESULT, filter_output) cli_ctx.invocation.data['query_active'] = True def __init__(self, cli_ctx=None): from .cli import CLI if cli_ctx is not None and not isinstance(cli_ctx, CLI): raise CtxTypeError(cli_ctx) self.cli_ctx = cli_ctx self.cli_ctx.register_event(EVENT_PARSER_GLOBAL_CREATE, CLIQuery.on_global_arguments) self.cli_ctx.register_event(EVENT_INVOKER_POST_PARSE_ARGS, CLIQuery.handle_query_parameter) knack-0.9.0/knack/testsdk/000077500000000000000000000000001414113720500153555ustar00rootroot00000000000000knack-0.9.0/knack/testsdk/__init__.py000066400000000000000000000015171414113720500174720ustar00rootroot00000000000000# -------------------------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- from .base import ScenarioTest, LiveTest from .exceptions import CliTestError from .checkers import (JMESPathCheck, JMESPathCheckExists, JMESPathCheckGreaterThan, NoneCheck, StringCheck, StringContainCheck) from .decorators import live_only, record_only __all__ = ['ScenarioTest', 'LiveTest', 'CliTestError', 'JMESPathCheck', 'JMESPathCheckExists', 'NoneCheck', 'live_only', 'record_only', 'StringCheck', 'StringContainCheck', 'JMESPathCheckGreaterThan'] knack-0.9.0/knack/testsdk/base.py000066400000000000000000000223441414113720500166460ustar00rootroot00000000000000# -------------------------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- import unittest import os import inspect import json import shlex import tempfile import shutil import logging import io import vcr from .patches import patch_time_sleep_api from .exceptions import CliExecutionError from .const import (ENV_LIVE_TEST, ENV_SKIP_ASSERT, ENV_TEST_DIAGNOSE) from .decorators import live_only from .recording_processors import (GeneralNameReplacer, LargeRequestBodyProcessor, LargeResponseBodyProcessor, LargeResponseBodyReplacer) from .util import find_recording_dir, create_random_name logger = logging.getLogger('clicore.testsdk') class IntegrationTestBase(unittest.TestCase): def __init__(self, cli, method_name): super().__init__(method_name) self.cli = cli self.diagnose = os.environ.get(ENV_TEST_DIAGNOSE, None) == 'True' def cmd(self, command, checks=None, expect_failure=False): return ExecutionResult(self.cli, command, expect_failure=expect_failure).assert_with_checks(checks) def create_random_name(self, prefix, length): # pylint: disable=no-self-use return create_random_name(prefix=prefix, length=length) def create_temp_file(self, size_kb, full_random=False): """ Create a temporary file for testing. The test harness will delete the file during tearing down. """ fd, path = tempfile.mkstemp() os.close(fd) self.addCleanup(lambda: os.remove(path)) with open(path, mode='r+b') as f: if full_random: chunk = os.urandom(1024) else: chunk = bytearray([0] * 1024) for _ in range(size_kb): f.write(chunk) return path def create_temp_dir(self): """ Create a temporary directory for testing. The test harness will delete the directory during tearing down. """ temp_dir = tempfile.mkdtemp() self.addCleanup(lambda: shutil.rmtree(temp_dir, ignore_errors=True)) return temp_dir @classmethod def set_env(cls, key, val): os.environ[key] = val @classmethod def pop_env(cls, key): return os.environ.pop(key, None) @live_only() class LiveTest(IntegrationTestBase): pass class ScenarioTest(IntegrationTestBase): # pylint: disable=too-many-instance-attributes def __init__(self, cli, method_name, filter_headers=None): super().__init__(cli, method_name) self.name_replacer = GeneralNameReplacer() self.recording_processors = [LargeRequestBodyProcessor(), LargeResponseBodyProcessor(), self.name_replacer] self.replay_processors = [LargeResponseBodyReplacer()] self.filter_headers = filter_headers or [] test_file_path = inspect.getfile(self.__class__) recordings_dir = find_recording_dir(test_file_path) live_test = os.environ.get(ENV_LIVE_TEST, None) == 'True' self.vcr = vcr.VCR( cassette_library_dir=recordings_dir, before_record_request=self._process_request_recording, before_record_response=self._process_response_recording, decode_compressed_response=True, record_mode='once' if not live_test else 'all', filter_headers=self.filter_headers ) self.vcr.register_matcher('query', self._custom_request_query_matcher) self.recording_file = os.path.join(recordings_dir, '{}.yaml'.format(method_name)) if live_test and os.path.exists(self.recording_file): os.remove(self.recording_file) self.in_recording = live_test or not os.path.exists(self.recording_file) self.test_resources_count = 0 self.original_env = os.environ.copy() def setUp(self): super().setUp() # set up cassette cm = self.vcr.use_cassette(self.recording_file) self.cassette = cm.__enter__() self.addCleanup(cm.__exit__) if not self.in_recording: patch_time_sleep_api(self) def tearDown(self): os.environ = self.original_env def create_random_name(self, prefix, length): self.test_resources_count += 1 moniker = '{}{:06}'.format(prefix, self.test_resources_count) if self.in_recording: name = create_random_name(prefix, length) self.name_replacer.register_name_pair(name, moniker) return name return moniker def _process_request_recording(self, request): if self.in_recording: for processor in self.recording_processors: request = processor.process_request(request) if not request: break else: for processor in self.replay_processors: request = processor.process_request(request) if not request: break return request def _process_response_recording(self, response): if self.in_recording: # make header name lower case and filter unwanted headers headers = {} for key in response['headers']: if key.lower() not in self.filter_headers: headers[key.lower()] = response['headers'][key] response['headers'] = headers body = response['body']['string'] if body and not isinstance(body, str): response['body']['string'] = body.decode('utf-8') for processor in self.recording_processors: response = processor.process_response(response) if not response: break else: for processor in self.replay_processors: response = processor.process_response(response) if not response: break return response @classmethod def _custom_request_query_matcher(cls, r1, r2): """ Ensure method, path, and query parameters match. """ from urllib.parse import urlparse, parse_qs # pylint: disable=useless-suppression url1 = urlparse(r1.uri) url2 = urlparse(r2.uri) q1 = parse_qs(url1.query) q2 = parse_qs(url2.query) shared_keys = set(q1.keys()).intersection(set(q2.keys())) if len(shared_keys) != len(q1) or len(shared_keys) != len(q2): return False for key in shared_keys: if q1[key][0].lower() != q2[key][0].lower(): return False return True class ExecutionResult(object): def __init__(self, cli, command, expect_failure=False): self.cli = cli self._in_process_execute(command) if expect_failure and self.exit_code == 0: logger.error('Command "%s" => %d. (It did not fail as expected) Output: %s', command, self.exit_code, self.output) raise AssertionError('The command did not fail as it was expected.') if not expect_failure and self.exit_code != 0: logger.error('Command "%s" => %d. Output: %s', command, self.exit_code, self.output) raise AssertionError('The command failed. Exit code: {}'.format(self.exit_code)) logger.info('Command "%s" => %d. Output: %s', command, self.exit_code, self.output) self.json_value = None self.skip_assert = os.environ.get(ENV_SKIP_ASSERT, None) == 'True' def assert_with_checks(self, *args): checks = [] for each in args: if isinstance(each, list): checks.extend(each) elif callable(each): checks.append(each) if not self.skip_assert: for c in checks: c(self) return self def get_output_in_json(self): if not self.json_value: self.json_value = json.loads(self.output) if self.json_value is None: raise AssertionError('The command output cannot be parsed in json.') return self.json_value def _in_process_execute(self, command): cli_name_prefixed = '{} '.format(self.cli.name) if command.startswith(cli_name_prefixed): command = command[len(cli_name_prefixed):] out_buffer = io.StringIO() try: # issue: stderr cannot be redirect in this form, as a result some failure information # is lost when command fails. self.exit_code = self.cli.invoke(shlex.split(command), out_file=out_buffer) or 0 self.output = out_buffer.getvalue() except vcr.errors.CannotOverwriteExistingCassetteException as ex: raise AssertionError(ex) from ex except CliExecutionError as ex: if ex.exception: raise ex.exception raise ex except Exception as ex: # pylint: disable=broad-except self.exit_code = 1 self.output = out_buffer.getvalue() self.process_error = ex finally: out_buffer.close() knack-0.9.0/knack/testsdk/checkers.py000066400000000000000000000105031414113720500175150ustar00rootroot00000000000000# -------------------------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- import collections import jmespath from .exceptions import JMESPathCheckAssertionError class JMESPathCheck(object): # pylint: disable=too-few-public-methods def __init__(self, query, expected_result): self._query = query self._expected_result = expected_result def __call__(self, execution_result): json_value = execution_result.get_output_in_json() actual_result = jmespath.search(self._query, json_value, jmespath.Options(collections.OrderedDict)) if not actual_result == self._expected_result: if actual_result: raise JMESPathCheckAssertionError(self._query, self._expected_result, actual_result, execution_result.output) raise JMESPathCheckAssertionError(self._query, self._expected_result, 'None', execution_result.output) class JMESPathCheckExists(object): # pylint: disable=too-few-public-methods def __init__(self, query): self._query = query def __call__(self, execution_result): json_value = execution_result.get_output_in_json() actual_result = jmespath.search(self._query, json_value, jmespath.Options(collections.OrderedDict)) if not actual_result: raise JMESPathCheckAssertionError(self._query, 'some value', actual_result, execution_result.output) class JMESPathCheckGreaterThan(object): # pylint: disable=too-few-public-methods def __init__(self, query, expected_result): self._query = query self._expected_result = expected_result def __call__(self, execution_result): json_value = execution_result.get_output_in_json() actual_result = jmespath.search(self._query, json_value, jmespath.Options(collections.OrderedDict)) if not actual_result > self._expected_result: expected_result_format = "> {}".format(self._expected_result) if actual_result: raise JMESPathCheckAssertionError(self._query, expected_result_format, actual_result, execution_result.output) raise JMESPathCheckAssertionError(self._query, expected_result_format, 'None', execution_result.output) class NoneCheck(object): # pylint: disable=too-few-public-methods def __call__(self, execution_result): none_strings = ['[]', '{}', 'false'] try: data = execution_result.output.strip() assert not data or data in none_strings except AssertionError as ex: raise AssertionError("Actual value '{}' != Expected value falsy (None, '', []) or " "string in {}".format(data, none_strings)) from ex class StringCheck(object): # pylint: disable=too-few-public-methods def __init__(self, expected_result): self.expected_result = expected_result def __call__(self, execution_result): try: result = execution_result.output.strip().strip('"') assert result == self.expected_result except AssertionError as ex: raise AssertionError( "Actual value '{}' != Expected value {}".format(result, self.expected_result)) from ex class StringContainCheck(object): # pylint: disable=too-few-public-methods def __init__(self, expected_result): self.expected_result = expected_result def __call__(self, execution_result): try: result = execution_result.output.strip('"') assert self.expected_result in result except AssertionError as ex: raise AssertionError( "Actual value '{}' doesn't contain Expected value {}".format(result, self.expected_result)) from ex knack-0.9.0/knack/testsdk/const.py000066400000000000000000000007171414113720500170620ustar00rootroot00000000000000# -------------------------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- ENV_LIVE_TEST = 'CLI_TEST_RUN_LIVE' ENV_SKIP_ASSERT = 'CLI_TEST_SKIP_ASSERT' ENV_TEST_DIAGNOSE = 'CLI_TEST_DIAGNOSE' knack-0.9.0/knack/testsdk/decorators.py000066400000000000000000000014361414113720500201000ustar00rootroot00000000000000# -------------------------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- import os import unittest from .const import ENV_LIVE_TEST def live_only(): return unittest.skipUnless( os.environ.get(ENV_LIVE_TEST, False), 'This is a live only test. A live test will bypass all vcrpy components.') def record_only(): return unittest.skipUnless( not os.environ.get(ENV_LIVE_TEST, False), 'This test is excluded from being run live. To force a recording, please remove the recording file.') knack-0.9.0/knack/testsdk/exceptions.py000066400000000000000000000022051414113720500201070ustar00rootroot00000000000000# -------------------------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- class CliTestError(Exception): def __init__(self, error_message): message = 'An error caused by the CLI test harness failed the test: {}' super().__init__(message.format(error_message)) class CliExecutionError(Exception): def __init__(self, exception): self.exception = exception message = 'The CLI throws exception {} during execution and fails the command.' super().__init__(message.format(exception.__class__.__name__, exception)) class JMESPathCheckAssertionError(AssertionError): def __init__(self, query, expected, actual, json_data): message = "Query '{}' doesn't yield expected value '{}', instead the actual value " \ "is '{}'. Data: \n{}\n".format(query, expected, actual, json_data) super().__init__(message) knack-0.9.0/knack/testsdk/patches.py000066400000000000000000000015261414113720500173620ustar00rootroot00000000000000# -------------------------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- import unittest from unittest import mock from .exceptions import CliTestError def patch_time_sleep_api(unit_test): def _time_sleep_skip(*_): return _mock_in_unit_test(unit_test, 'time.sleep', _time_sleep_skip) def _mock_in_unit_test(unit_test, target, replacement): if not isinstance(unit_test, unittest.TestCase): raise CliTestError('The patch can be only used in unit test') mp = mock.patch(target, replacement) mp.__enter__() unit_test.addCleanup(mp.__exit__) knack-0.9.0/knack/testsdk/recording_processors.py000066400000000000000000000076001414113720500221700ustar00rootroot00000000000000# -------------------------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- class RecordingProcessor(object): def process_request(self, request): # pylint: disable=no-self-use return request def process_response(self, response): # pylint: disable=no-self-use return response @classmethod def replace_header(cls, entity, header, old, new): cls.replace_header_fn(entity, header, lambda v: v.replace(old, new)) @classmethod def replace_header_fn(cls, entity, header, replace_fn): try: header = header.lower() values = entity['headers'][header] entity['headers'][header] = [replace_fn(v) for v in values] except KeyError: pass class LargeRequestBodyProcessor(RecordingProcessor): def __init__(self, max_request_body=128): self._max_request_body = max_request_body def process_request(self, request): if request.body and len(request.body) > self._max_request_body * 1024: request.body = '!!! The request body has been omitted from the recording because its ' \ 'size {} is larger than {}KB. !!!'.format(len(request.body), self._max_request_body) return request class LargeResponseBodyProcessor(RecordingProcessor): control_flag = '' def __init__(self, max_response_body=256): self._max_response_body = max_response_body def process_response(self, response): length = len(response['body']['string'] or '') if length > self._max_response_body * 1024: response['body']['string'] = \ "!!! The response body has been omitted from the recording because it is larger " \ "than {max} KB. It will be replaced with blank content of {length} bytes while replay. " \ "{flag}{length}".format(max=self._max_response_body, length=length, flag=self.control_flag) return response class LargeResponseBodyReplacer(RecordingProcessor): def process_response(self, response): body = response['body']['string'] # backward compatibility. under 2.7 response body is unicode, under 3.5 response body is # bytes. when set the value back, the same type must be used. body_is_string = isinstance(body, str) content_in_string = (response['body']['string'] or b'').decode('utf-8') index = content_in_string.find(LargeResponseBodyProcessor.control_flag) if index > -1: length = int(content_in_string[index + len(LargeResponseBodyProcessor.control_flag):]) if body_is_string: response['body']['string'] = '0' * length else: response['body']['string'] = bytes([0] * length) return response class GeneralNameReplacer(RecordingProcessor): def __init__(self): self.names_name = [] def register_name_pair(self, old, new): self.names_name.append((old, new)) def process_request(self, request): for old, new in self.names_name: request.uri = request.uri.replace(old, new) if request.body: body = str(request.body) if old in body: request.body = body.replace(old, new) return request def process_response(self, response): for old, new in self.names_name: if response['body']['string']: response['body']['string'] = response['body']['string'].replace(old, new) self.replace_header(response, 'location', old, new) return response knack-0.9.0/knack/testsdk/util.py000066400000000000000000000021541414113720500167060ustar00rootroot00000000000000# -------------------------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- import os import math import base64 from .exceptions import CliTestError def find_recording_dir(test_file): return os.path.join(os.path.dirname(test_file), 'recordings') def create_random_name(prefix='clitest', length=24): if len(prefix) > length: raise CliTestError('The length of the prefix must not be longer than random name length') padding_size = length - len(prefix) if padding_size < 4: raise CliTestError('The randomized part of the name is shorter than 4, which may not be able to offer enough ' 'randomness') random_bytes = os.urandom(int(math.ceil(float(padding_size) / 8) * 5)) random_padding = base64.b32encode(random_bytes)[:padding_size] return str(prefix + random_padding.decode().lower()) knack-0.9.0/knack/util.py000066400000000000000000000136731414113720500152350ustar00rootroot00000000000000# -------------------------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- import errno import os import re from datetime import date, time, datetime, timedelta from enum import Enum NO_COLOR_VARIABLE_NAME = 'KNACK_NO_COLOR' # Override these values to customize the status message. # The message should contain a placeholder indicating the subject (like 'This command group', 'Command group xxx'). # (A dict is used to avoid the "from A import B" pitfall that creates a copy of the imported B.) status_tag_messages = { 'preview': "{} is in preview. It may be changed/removed in a future release.", 'experimental': "{} is experimental and under development." } # https://docs.microsoft.com/en-us/windows/console/console-virtual-terminal-sequences color_map = { 'reset': '\x1b[0m', # Default 'preview': '\x1b[36m', # Foreground Cyan 'experimental': '\x1b[36m', # Foreground Cyan 'deprecation': '\x1b[33m', # Foreground Yellow 'critical': '\x1b[41m', # Background Red 'error': '\x1b[91m', # Bright Foreground Red 'warning': '\x1b[33m', # Foreground Yellow 'info': '\x1b[32m', # Foreground Green 'debug': '\x1b[36m', # Foreground Cyan } class CommandResultItem(object): # pylint: disable=too-few-public-methods def __init__(self, result, table_transformer=None, is_query_active=False, exit_code=0, error=None, raw_result=None): self.result = result self.error = error self.exit_code = exit_code self.table_transformer = table_transformer self.is_query_active = is_query_active # The result before applying query self.raw_result = raw_result class CLIError(Exception): """Base class for exceptions that occur during normal operation of the CLI. Typically due to user error and can be resolved by the user. """ pass # pylint: disable=unnecessary-pass class CtxTypeError(TypeError): def __init__(self, obj): from .cli import CLI super().__init__('expected instance of {} got {}'.format(CLI.__name__, obj.__class__.__name__)) class ColorizedString(object): def __init__(self, message, color): self._message = message self._color = color def __len__(self): return len(self._message) def __str__(self): if not self._color: return self._message return self._color + self._message + color_map['reset'] class StatusTag(object): # pylint: disable=unused-argument def __init__(self, cli_ctx, object_type, target, tag_func, message_func, color, **kwargs): self.object_type = object_type self.target = target self._color = color self._enable_color = cli_ctx.enable_color self._get_tag = tag_func self._get_message = message_func # pylint: disable=no-self-use def hidden(self): return False def show_in_help(self): return not self.hidden() @property def tag(self): """ Returns a tag object. """ return ColorizedString(self._get_tag(self), self._color) if self._enable_color else self._get_tag(self) @property def message(self): """ Returns a tuple with the formatted message string and the message length. """ return ColorizedString(self._get_message(self), self._color) if self._enable_color \ else "WARNING: " + self._get_message(self) def ensure_dir(d): """ Create a directory if it doesn't exist """ if not os.path.isdir(d): try: os.makedirs(d) except OSError as e: if e.errno != errno.EEXIST: raise e def normalize_newlines(str_to_normalize): return str_to_normalize.replace('\r\n', '\n') KEYS_CAMELCASE_PATTERN = re.compile('(?!^)_([a-zA-Z])') def to_camel_case(s): return re.sub(KEYS_CAMELCASE_PATTERN, lambda x: x.group(1).upper(), s) def to_snake_case(s): s1 = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', s) return re.sub('([a-z0-9])([A-Z])', r'\1_\2', s1).lower() def todict(obj, post_processor=None): # pylint: disable=too-many-return-statements """ Convert an object to a dictionary. Use 'post_processor(original_obj, dictionary)' to update the dictionary in the process """ if isinstance(obj, dict): result = {k: todict(v, post_processor) for (k, v) in obj.items()} return post_processor(obj, result) if post_processor else result if isinstance(obj, list): return [todict(a, post_processor) for a in obj] if isinstance(obj, Enum): return obj.value if isinstance(obj, (date, time, datetime)): return obj.isoformat() if isinstance(obj, timedelta): return str(obj) if hasattr(obj, '_asdict'): return todict(obj._asdict(), post_processor) if hasattr(obj, '__dict__'): result = {to_camel_case(k): todict(v, post_processor) for k, v in obj.__dict__.items() if not callable(v) and not k.startswith('_')} return post_processor(obj, result) if post_processor else result return obj def is_modern_terminal(): """Detect whether the current terminal is a modern terminal that supports Unicode and Console Virtual Terminal Sequences. Currently, these terminals can be detected: - VS Code terminal - PyCharm - Windows Terminal """ # VS Code: https://github.com/microsoft/vscode/pull/30346 if os.environ.get('TERM_PROGRAM', '').lower() == 'vscode': return True # PyCharm: https://youtrack.jetbrains.com/issue/PY-4853 if 'PYCHARM_HOSTED' in os.environ: return True # Windows Terminal: https://github.com/microsoft/terminal/issues/1040 if 'WT_SESSION' in os.environ: return True return False knack-0.9.0/knack/validators.py000066400000000000000000000012731414113720500164210ustar00rootroot00000000000000# -------------------------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- class DefaultStr(str): def __new__(cls, *args, **kwargs): instance = str.__new__(cls, *args, **kwargs) instance.is_default = True return instance class DefaultInt(int): def __new__(cls, *args, **kwargs): instance = int.__new__(cls, *args, **kwargs) instance.is_default = True return instance knack-0.9.0/requirements.txt000066400000000000000000000002241414113720500160670ustar00rootroot00000000000000argcomplete==1.12.2 colorama==0.4.4 flake8==4.0.1 jmespath==0.10.0 Pygments==2.8.1 pylint==2.11.1 pytest==6.2.5 PyYAML tabulate==0.8.9 vcrpy==4.1.1 knack-0.9.0/scripts/000077500000000000000000000000001414113720500142745ustar00rootroot00000000000000knack-0.9.0/scripts/license_verify.py000066400000000000000000000042151414113720500176560ustar00rootroot00000000000000# -------------------------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- # Verify that all *.py files have a license header in the file. import os import sys ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(os.path.realpath(__file__)), '..')) PY_LICENSE_HEADER = """# -------------------------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- """ env_folders = [ os.path.join(ROOT_DIR, 'env'), os.path.join(ROOT_DIR, 'env27'), os.path.join(ROOT_DIR, '.tox'), ] def contains_header(text): return PY_LICENSE_HEADER in text def get_files_without_header(): files_without_header = [] for current_dir, _, files in os.walk(ROOT_DIR): # skip folders generated by virtual env if any(d for d in env_folders if d in current_dir): continue for a_file in files: if a_file.endswith('.py'): cur_file_path = os.path.join(current_dir, a_file) with open(cur_file_path, 'r', encoding='utf-8') as f: file_text = f.read() if len(file_text) > 0 and not contains_header(file_text): files_without_header.append((cur_file_path, file_text)) return files_without_header files_without_header = [file_path for file_path, file_contents in get_files_without_header()] if files_without_header: print("Error: The following files don't have the required license headers:", file=sys.stderr) print('\n'.join(files_without_header), file=sys.stderr) print("Error: {} file(s) found without license headers.".format(len(files_without_header)), file=sys.stderr) sys.exit(1) else: pass knack-0.9.0/setup.cfg000066400000000000000000000000431414113720500144230ustar00rootroot00000000000000[metadata] license_files = LICENSE knack-0.9.0/setup.py000066400000000000000000000031331414113720500143170ustar00rootroot00000000000000#!/usr/bin/env python # -------------------------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- import sys from setuptools import setup VERSION = '0.9.0' DEPENDENCIES = [ 'argcomplete', 'jmespath', 'pygments', 'pyyaml', 'tabulate' ] # On Windows, colorama is required for legacy terminals. if sys.platform == 'win32': DEPENDENCIES.append('colorama') with open('README.rst', 'r') as f: README = f.read() setup( name='knack', version=VERSION, description='A Command-Line Interface framework', long_description=README, license='MIT', author='Microsoft Corporation', author_email='azpycli@microsoft.com', url='https://github.com/microsoft/knack', zip_safe=False, classifiers=[ 'Development Status :: 4 - Beta', 'Intended Audience :: Developers', 'Intended Audience :: System Administrators', 'Programming Language :: Python', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', 'License :: OSI Approved :: MIT License', ], packages=['knack', 'knack.testsdk'], install_requires=DEPENDENCIES ) knack-0.9.0/tests/000077500000000000000000000000001414113720500137475ustar00rootroot00000000000000knack-0.9.0/tests/README.md000066400000000000000000000002531414113720500152260ustar00rootroot00000000000000Running all tests ----------------- ``` python -m unittest discover tests ``` Running specific test --------------------- ``` python Class.method ``` knack-0.9.0/tests/__init__.py000066400000000000000000000005311414113720500160570ustar00rootroot00000000000000# -------------------------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- knack-0.9.0/tests/test_cli_scenarios.py000066400000000000000000000140431414113720500201770ustar00rootroot00000000000000# -------------------------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- import os from collections import OrderedDict import unittest from unittest import mock from io import StringIO from knack import CLI from knack.commands import CLICommand, CLICommandsLoader from knack.invocation import CommandInvoker from tests.util import MockContext, redirect_io class TestCLIScenarios(unittest.TestCase): def setUp(self): self.mock_ctx = MockContext() def invoke_with_command_table(self, command, command_table): self.mock_ctx.invocation = CommandInvoker(cli_ctx=self.mock_ctx) self.mock_ctx.invocation.commands_loader.command_table = command_table self.mock_ctx.invocation.execute(command.split()) def test_list_value_parameter(self): handler_args = {} def handler(args): handler_args.update(args) command = CLICommand(self.mock_ctx, 'test command', handler) command.add_argument('hello', '--hello', nargs='+') command.add_argument('something', '--something') cmd_table = {'test command': command} self.invoke_with_command_table('test command --hello world sir --something else', cmd_table) self.assertEqual(handler_args['something'], 'else') self.assertEqual(handler_args['hello'][0], 'world') self.assertEqual(handler_args['hello'][1], 'sir') def test_case_insensitive_command_path(self): def handler(_): return 'PASSED' command = CLICommand(self.mock_ctx, 'test command', handler) command.add_argument('var', '--var', '-v') cmd_table = {'test command': command} def _test(cmd_line): ci = CommandInvoker(cli_ctx=self.mock_ctx) self.mock_ctx.invocation = ci self.mock_ctx.invocation.commands_loader.command_table = cmd_table return self.mock_ctx.invocation.execute(cmd_line.split()) # case insensitive command paths result = _test('TEST command --var blah') self.assertEqual(result.result, 'PASSED') result = _test('test COMMAND --var blah') self.assertEqual(result.result, 'PASSED') result = _test('test command -v blah') self.assertEqual(result.result, 'PASSED') # verify that long and short options remain case sensitive with mock.patch('sys.stderr', new_callable=StringIO): with self.assertRaises(SystemExit): _test('test command --vAR blah') with self.assertRaises(SystemExit): _test('test command -V blah') def test_cli_exapp1(self): def a_test_command_handler(_): return [{'a': 1, 'b': 1234}, {'a': 3, 'b': 4}] class MyCommandsLoader(CLICommandsLoader): def load_command_table(self, args): self.command_table['abc xyz'] = CLICommand(self.cli_ctx, 'abc xyz', a_test_command_handler) self.command_table['abc list'] = CLICommand(self.cli_ctx, 'abc list', a_test_command_handler) return OrderedDict(self.command_table) mycli = CLI(cli_name='exapp1', config_dir=os.path.expanduser(os.path.join('~', '.exapp1')), commands_loader_cls=MyCommandsLoader) expected_output = """[ { "a": 1, "b": 1234 }, { "a": 3, "b": 4 } ] """ mock_stdout = StringIO() exit_code = mycli.invoke(['abc', 'xyz'], out_file=mock_stdout) self.assertEqual(expected_output, mock_stdout.getvalue()) self.assertEqual(0, exit_code) mock_stdout = StringIO() mycli.invoke(['abc', 'list'], out_file=mock_stdout) self.assertEqual(expected_output, mock_stdout.getvalue()) self.assertEqual(0, exit_code) expected_output = """{ "a": 1, "b": 1234 } """ mock_stdout = StringIO() mycli.invoke(['abc', 'list', '--query', '[0]'], out_file=mock_stdout) self.assertEqual(expected_output, mock_stdout.getvalue()) self.assertEqual(0, exit_code) expected_output = "1\n" mock_stdout = StringIO() mycli.invoke(['abc', 'list', '--query', '[0].a'], out_file=mock_stdout) self.assertEqual(expected_output, mock_stdout.getvalue()) self.assertEqual(0, exit_code) @mock.patch('sys.stderr.isatty') @mock.patch('sys.stdout.isatty') @mock.patch.dict('os.environ') def test_should_enable_color(self, stdout_isatty_mock, stderr_isatty_mock): # Make sure we mock a normal terminal, instead of PyCharm terminal os.environ.pop('PYCHARM_HOSTED', None) cli = CLI() # Color is turned on by default stdout_isatty_mock.return_value = True stderr_isatty_mock.return_value = True self.assertEqual(cli._should_enable_color(), True) # Color is turned off with a main switch os.environ['CLI_CORE_NO_COLOR'] = 'yes' self.assertEqual(cli._should_enable_color(), False) del os.environ['CLI_CORE_NO_COLOR'] # Mock stderr is not a TTY stdout_isatty_mock.return_value = False stderr_isatty_mock.return_value = True self.assertEqual(cli._should_enable_color(), False) # Mock stdout is not a TTY stdout_isatty_mock.return_value = True stderr_isatty_mock.return_value = False self.assertEqual(cli._should_enable_color(), False) @redirect_io def test_init_log(self): class MyCLI(CLI): def __init__(self, **kwargs): super().__init__(**kwargs) self.init_debug_log.append("init debug log: 6aa19a11") self.init_info_log.append("init info log: b0746f58") cli = MyCLI() cli.invoke(["--debug"]) actual = self.io.getvalue() self.assertIn("6aa19a11", actual) self.assertIn("b0746f58", actual) if __name__ == '__main__': unittest.main() knack-0.9.0/tests/test_command_registration.py000066400000000000000000000457611414113720500216050ustar00rootroot00000000000000# -------------------------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- import sys import unittest from knack.commands import CLICommandsLoader, CommandGroup from knack.arguments import CLIArgumentType, CLICommandArgument, ArgumentsContext from tests.util import MockContext def _dictContainsSubset(expected, actual): """Checks whether actual is a superset of expected. Helper for deprecated assertDictContainsSubset""" missing = False mismatched = False for key, value in expected.items(): if key not in actual: missing = True elif value != actual[key]: mismatched = True return False if missing or mismatched else True class TestCommandRegistration(unittest.TestCase): def setUp(self): self.mock_ctx = MockContext() @staticmethod def sample_command_handler(group_name, resource_name, opt_param=None, expand=None): """ The operation to get a virtual machine. :param group_name: The name of the group. :type group_name: str :param resource_name: The name of the resource. :type resource_name: str :param opt_param: Used to verify reflection correctly identifies optional params. :type opt_param: object :param expand: The expand expression to apply on the operation. :type expand: str :param dict custom_headers: headers that will be added to the request :param boolean raw: returns the direct response alongside the deserialized response """ pass @staticmethod def sample_command_handler2(group_name, resource_name, opt_param=None, expand=None, custom_headers=None, raw=False, **operation_config): pass def _set_command_name(self, command): self.mock_ctx.invocation.data['command_string'] = command return command def test_register_cli_argument(self): cl = CLICommandsLoader(self.mock_ctx) command_name = self._set_command_name('test register sample-command') with CommandGroup(cl, 'test register', '{}#{{}}'.format(__name__)) as g: g.command('sample-command', '{}.{}'.format(TestCommandRegistration.__name__, TestCommandRegistration.sample_command_handler.__name__)) with ArgumentsContext(cl, command_name) as ac: ac.argument('resource_name', CLIArgumentType( options_list=('--wonky-name', '-n'), metavar='RNAME', help='Completely WONKY name...', required=False )) cl.load_arguments(command_name) self.assertEqual(len(cl.command_table), 1, 'We expect exactly one command in the command table') command_metadata = cl.command_table[command_name] self.assertEqual(len(command_metadata.arguments), 4, 'We expected exactly 4 arguments') some_expected_arguments = { 'group_name': CLIArgumentType(dest='group_name', required=True), 'resource_name': CLIArgumentType(dest='resource_name', required=False), } for probe in some_expected_arguments: existing = next(arg for arg in command_metadata.arguments if arg == probe) contains_subset = _dictContainsSubset(some_expected_arguments[existing].settings, command_metadata.arguments[existing].options) self.assertTrue(contains_subset) self.assertEqual(command_metadata.arguments['resource_name'].options_list, ('--wonky-name', '-n')) def test_register_command_custom_excluded_params(self): command_name = self._set_command_name('test sample-command') ep = ['self', 'raw', 'custom_headers', 'operation_config', 'content_version', 'kwargs', 'client'] cl = CLICommandsLoader(self.mock_ctx, excluded_command_handler_args=ep) with CommandGroup(cl, 'test', '{}#{{}}'.format(__name__)) as g: g.command('sample-command', '{}.{}'.format(TestCommandRegistration.__name__, TestCommandRegistration.sample_command_handler2.__name__)) self.assertEqual(len(cl.command_table), 1, 'We expect exactly one command in the command table') cl.load_arguments(command_name) command_metadata = cl.command_table[command_name] self.assertEqual(len(command_metadata.arguments), 4, 'We expected exactly 4 arguments') self.assertIn(command_name, cl.command_table) def test_register_command(self): cl = CLICommandsLoader(self.mock_ctx) command_name = self._set_command_name('test register sample-command') with CommandGroup(cl, 'test register', '{}#{{}}'.format(__name__)) as g: g.command('sample-command', '{}.{}'.format(TestCommandRegistration.__name__, TestCommandRegistration.sample_command_handler.__name__)) self.assertEqual(len(cl.command_table), 1, 'We expect exactly one command in the command table') cl.load_arguments(command_name) command_metadata = cl.command_table[command_name] self.assertEqual(len(command_metadata.arguments), 4, 'We expected exactly 4 arguments') some_expected_arguments = { 'group_name': CLIArgumentType(dest='group_name', required=True, help='The name of the group.'), 'resource_name': CLIArgumentType(dest='resource_name', required=True, help='The name of the resource.'), 'opt_param': CLIArgumentType(required=False, help='Used to verify reflection correctly identifies optional params.'), 'expand': CLIArgumentType(required=False, help='The expand expression to apply on the operation.') } for probe in some_expected_arguments: existing = next(arg for arg in command_metadata.arguments if arg == probe) contains_subset = _dictContainsSubset(some_expected_arguments[existing].settings, command_metadata.arguments[existing].options) self.assertTrue(contains_subset) self.assertEqual(command_metadata.arguments['resource_name'].options_list, ['--resource-name']) def test_register_command_group_with_no_group_name(self): cl = CLICommandsLoader(self.mock_ctx) command_name = self._set_command_name('sample-command') with CommandGroup(cl, None, '{}#{{}}'.format(__name__)) as g: g.command('sample-command', '{}.{}'.format(TestCommandRegistration.__name__, TestCommandRegistration.sample_command_handler.__name__)) self.assertEqual(len(cl.command_table), 1, 'We expect exactly one command in the command table') self.assertIn(command_name, cl.command_table) def test_register_command_confirmation_bool(self): cl = CLICommandsLoader(self.mock_ctx) command_name = self._set_command_name('test sample-command') with CommandGroup(cl, 'test', '{}#{{}}'.format(__name__)) as g: g.command('sample-command', '{}.{}'.format(TestCommandRegistration.__name__, TestCommandRegistration.sample_command_handler.__name__), confirmation=True) self.assertEqual(len(cl.command_table), 1, 'We expect exactly one command in the command table') cl.load_arguments(command_name) command_metadata = cl.command_table[command_name] self.assertIn('yes', command_metadata.arguments) self.assertEqual(command_metadata.arguments['yes'].type.settings['action'], 'store_true') self.assertIs(command_metadata.confirmation, True) def test_register_command_confirmation_callable(self): cl = CLICommandsLoader(self.mock_ctx) def confirm_callable(_): pass command_name = self._set_command_name('test sample-command') with CommandGroup(cl, 'test', '{}#{{}}'.format(__name__)) as g: g.command('sample-command', '{}.{}'.format(TestCommandRegistration.__name__, TestCommandRegistration.sample_command_handler.__name__), confirmation=confirm_callable) self.assertEqual(len(cl.command_table), 1, 'We expect exactly one command in the command table') cl.load_arguments(command_name) command_metadata = cl.command_table[command_name] self.assertIn('yes', command_metadata.arguments) self.assertEqual(command_metadata.arguments['yes'].type.settings['action'], 'store_true') self.assertIs(command_metadata.confirmation, confirm_callable) def test_register_cli_argument_with_overrides(self): cl = CLICommandsLoader(self.mock_ctx) base_type = CLIArgumentType(options_list=['--foo', '-f'], metavar='FOO', help='help1') derived_type = CLIArgumentType(base_type=base_type, help='help2') with CommandGroup(cl, 'test', '{}#{{}}'.format(__name__)) as g: g.command('sample-get', '{}.{}'.format(TestCommandRegistration.__name__, TestCommandRegistration.sample_command_handler.__name__)) g.command('command sample-get-1', '{}.{}'.format(TestCommandRegistration.__name__, TestCommandRegistration.sample_command_handler.__name__)) g.command('command sample-get-2', '{}.{}'.format(TestCommandRegistration.__name__, TestCommandRegistration.sample_command_handler.__name__)) self.assertEqual(len(cl.command_table), 3, 'We expect exactly three commands in the command table') def test_with_command(command, target_value): self._set_command_name(command) with ArgumentsContext(cl, 'test') as c: c.argument('resource_name', base_type) with ArgumentsContext(cl, 'test command') as c: c.argument('resource_name', derived_type) with ArgumentsContext(cl, 'test command sample-get-2') as c: c.argument('resource_name', derived_type, help='help3') cl.load_arguments(command) command1 = cl.command_table[command].arguments['resource_name'] self.assertEqual(command1.options['help'], target_value) test_with_command('test sample-get', 'help1') test_with_command('test command sample-get-1', 'help2') test_with_command('test command sample-get-2', 'help3') def test_register_extra_cli_argument(self): cl = CLICommandsLoader(self.mock_ctx) command_name = self._set_command_name('test register sample-command') with CommandGroup(cl, 'test register', '{}#{{}}'.format(__name__)) as g: g.command('sample-command', '{}.{}'.format(TestCommandRegistration.__name__, TestCommandRegistration.sample_command_handler.__name__)) with ArgumentsContext(cl, command_name) as ac: ac.extra('added_param', options_list=('--added-param',), metavar='ADDED', help='Just added this right now!', required=True) cl.load_arguments(command_name) self.assertEqual(len(cl.command_table), 1, 'We expect exactly one command in the command table') command_metadata = cl.command_table[command_name] self.assertEqual(len(command_metadata.arguments), 5, 'We expected exactly 5 arguments') some_expected_arguments = { 'added_param': CLIArgumentType(dest='added_param', required=True) } for probe in some_expected_arguments: existing = next(arg for arg in command_metadata.arguments if arg == probe) contains_subset = _dictContainsSubset(some_expected_arguments[existing].settings, command_metadata.arguments[existing].options) self.assertTrue(contains_subset) def test_register_ignore_cli_argument(self): cl = CLICommandsLoader(self.mock_ctx) command_name = self._set_command_name('test register sample-command') self.mock_ctx.invocation.data['command_string'] = command_name with CommandGroup(cl, 'test register', '{}#{{}}'.format(__name__)) as g: g.command('sample-command', '{}.{}'.format(TestCommandRegistration.__name__, TestCommandRegistration.sample_command_handler.__name__)) with ArgumentsContext(cl, 'test register') as ac: ac.argument('resource_name', options_list=['--this']) with ArgumentsContext(cl, 'test register sample-command') as ac: ac.ignore('resource_name') ac.argument('opt_param', options_list=['--this']) cl.load_arguments(command_name) self.assertNotEqual(cl.command_table[command_name].arguments['resource_name'].options_list, cl.command_table[command_name].arguments['opt_param'].options_list, "Name conflict in options list") def test_command_build_argument_help_text(self): def sample_sdk_method_with_weird_docstring(param_a, param_b, param_c): # pylint: disable=unused-argument """ An operation with nothing good. :param dict param_a: :param param_b: The name of nothing. :param param_c: The name of nothing2. """ pass cl = CLICommandsLoader(self.mock_ctx) command_name = self._set_command_name('test command foo') setattr(sys.modules[__name__], sample_sdk_method_with_weird_docstring.__name__, sample_sdk_method_with_weird_docstring) with CommandGroup(cl, 'test command', '{}#{{}}'.format(__name__)) as g: g.command('foo', sample_sdk_method_with_weird_docstring.__name__) cl.load_arguments(command_name) command_metadata = cl.command_table[command_name] self.assertEqual(len(command_metadata.arguments), 3, 'We expected exactly 3 arguments') some_expected_arguments = { 'param_a': CLIArgumentType(dest='param_a', required=True, help=''), 'param_b': CLIArgumentType(dest='param_b', required=True, help='The name of nothing.'), 'param_c': CLIArgumentType(dest='param_c', required=True, help='The name of nothing2.') } for probe in some_expected_arguments: existing = next(arg for arg in command_metadata.arguments if arg == probe) contains_subset = _dictContainsSubset(some_expected_arguments[existing].settings, command_metadata.arguments[existing].options) self.assertTrue(contains_subset) def test_override_existing_option_string(self): arg = CLIArgumentType(options_list=('--funky', '-f')) updated_options_list = ('--something-else', '-s') arg.update(options_list=updated_options_list, validator=lambda: (), completer=lambda: ()) self.assertEqual(arg.settings['options_list'], updated_options_list) self.assertIsNotNone(arg.settings['validator']) self.assertIsNotNone(arg.settings['completer']) def test_dont_override_existing_option_string(self): existing_options_list = ('--something-else', '-s') arg = CLIArgumentType(options_list=existing_options_list) arg.update() self.assertEqual(arg.settings['options_list'], existing_options_list) def test_override_remove_validator(self): existing_options_list = ('--something-else', '-s') arg = CLIArgumentType(options_list=existing_options_list, validator=lambda *args, **kwargs: ()) arg.update(validator=None) self.assertIsNone(arg.settings['validator']) def test_override_using_register_cli_argument(self): def sample_sdk_method(param_a): # pylint: disable=unused-argument pass def test_validator_completer(): pass cl = CLICommandsLoader(self.mock_ctx) command_name = self._set_command_name('override_using_register_cli_argument foo') setattr(sys.modules[__name__], sample_sdk_method.__name__, sample_sdk_method) with CommandGroup(cl, 'override_using_register_cli_argument', '{}#{{}}'.format(__name__)) as g: g.command('foo', sample_sdk_method.__name__) with ArgumentsContext(cl, 'override_using_register_cli_argument') as ac: ac.argument('param_a', options_list=('--overridden', '-r'), validator=test_validator_completer, completer=test_validator_completer, required=False) cl.load_arguments(command_name) command_metadata = cl.command_table[command_name] self.assertEqual(len(command_metadata.arguments), 1, 'We expected exactly 1 arguments') actual_arg = command_metadata.arguments['param_a'] self.assertEqual(actual_arg.options_list, ('--overridden', '-r')) self.assertEqual(actual_arg.validator, test_validator_completer) self.assertEqual(actual_arg.completer, test_validator_completer) self.assertFalse(actual_arg.options['required']) def test_override_argtype_with_argtype(self): existing_options_list = ('--default', '-d') arg = CLIArgumentType(options_list=existing_options_list, validator=None, completer='base', help='base', required=True) overriding_argtype = CLIArgumentType(options_list=('--overridden',), validator='overridden', completer=None, overrides=arg, help='overridden', required=CLIArgumentType.REMOVE) self.assertEqual(overriding_argtype.settings['validator'], 'overridden') self.assertIsNone(overriding_argtype.settings['completer']) self.assertEqual(overriding_argtype.settings['options_list'], ('--overridden',)) self.assertEqual(overriding_argtype.settings['help'], 'overridden') self.assertEqual(overriding_argtype.settings['required'], CLIArgumentType.REMOVE) cmd_arg = CLICommandArgument(dest='whatever', argtype=overriding_argtype, help=CLIArgumentType.REMOVE) self.assertNotIn('required', cmd_arg.options) self.assertNotIn('help', cmd_arg.options) def test_cli_ctx_type_error(self): with self.assertRaises(TypeError): CLICommandsLoader(cli_ctx=object()) if __name__ == '__main__': unittest.main() knack-0.9.0/tests/test_command_with_configured_defaults.py000066400000000000000000000055471414113720500241400ustar00rootroot00000000000000# -------------------------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- import os import logging import sys import unittest from unittest import mock from knack.arguments import ArgumentsContext from knack.commands import CLICommandsLoader, CommandGroup from tests.util import DummyCLI, redirect_io # a dummy callback for arg-parse def load_params(_): pass def list_foo(my_param): print(str(my_param), end='') class TestCommandWithConfiguredDefaults(unittest.TestCase): @classmethod def setUpClass(cls): # Ensure initialization has occurred correctly logging.basicConfig(level=logging.DEBUG) @classmethod def tearDownClass(cls): logging.shutdown() def _set_up_command_table(self, required): class TestCommandsLoader(CLICommandsLoader): def load_command_table(self, args): super().load_command_table(args) with CommandGroup(self, 'foo', '{}#{{}}'.format(__name__)) as g: g.command('list', 'list_foo') return self.command_table def load_arguments(self, command): with ArgumentsContext(self, 'foo') as c: c.argument('my_param', options_list='--my-param', configured_default='param', required=required) super().load_arguments(command) self.cli_ctx = DummyCLI(commands_loader_cls=TestCommandsLoader) @mock.patch.dict(os.environ, {'CLI_DEFAULTS_PARAM': 'myVal'}) @redirect_io def test_apply_configured_defaults_on_required_arg(self): self._set_up_command_table(required=True) self.cli_ctx.invoke('foo list'.split()) actual = self.io.getvalue() expected = 'myVal' self.assertEqual(expected, actual) @redirect_io def test_no_configured_default_on_required_arg(self): self._set_up_command_table(required=True) with self.assertRaises(SystemExit): self.cli_ctx.invoke('foo list'.split()) actual = self.io.getvalue() expected = 'required: --my-param' if sys.version_info[0] == 2: expected = 'argument --my-param is required' self.assertEqual(expected in actual, True) @mock.patch.dict(os.environ, {'CLI_DEFAULTS_PARAM': 'myVal'}) @redirect_io def test_apply_configured_defaults_on_optional_arg(self): self._set_up_command_table(required=False) self.cli_ctx.invoke('foo list'.split()) actual = self.io.getvalue() expected = 'myVal' self.assertEqual(expected, actual) if __name__ == '__main__': unittest.main() knack-0.9.0/tests/test_completion.py000066400000000000000000000060201414113720500175270ustar00rootroot00000000000000# -------------------------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- import os import unittest from unittest import mock from knack.completion import CLICompletion, CaseInsensitiveChoicesCompleter, ARGCOMPLETE_ENV_NAME from tests.util import MockContext class TestCompletion(unittest.TestCase): def setUp(self): self.mock_ctx = MockContext() def test_cli_ctx_type_error(self): with self.assertRaises(TypeError): CLICompletion(cli_ctx=object()) @mock.patch.dict(os.environ, {}) def test_completer_not_active(self): CLICompletion(cli_ctx=self.mock_ctx) self.assertFalse(self.mock_ctx.data['completer_active']) @mock.patch.dict(os.environ, {ARGCOMPLETE_ENV_NAME: '1'}) def test_completer_active(self): CLICompletion(cli_ctx=self.mock_ctx) self.assertTrue(self.mock_ctx.data['completer_active']) def test_case_insensitive_choices_empty(self): choices = [] prefix = '' actual_result = list(CaseInsensitiveChoicesCompleter(choices)(prefix)) expected_result = [] self.assertListEqual(actual_result, expected_result) def test_case_insensitive_choices_empty_with_prefix(self): choices = [] prefix = 'abc' actual_result = list(CaseInsensitiveChoicesCompleter(choices)(prefix)) expected_result = [] self.assertListEqual(actual_result, expected_result) def test_case_insensitive_choices_list_same_case(self): choices = ['abc', 'xyz'] prefix = '' actual_result = list(CaseInsensitiveChoicesCompleter(choices)(prefix)) expected_result = ['abc', 'xyz'] self.assertListEqual(actual_result, expected_result) def test_case_insensitive_choices_list_diff_case(self): choices = ['ABC', 'xyz'] prefix = '' actual_result = list(CaseInsensitiveChoicesCompleter(choices)(prefix)) expected_result = ['ABC', 'xyz'] self.assertListEqual(actual_result, expected_result) def test_case_insensitive_choices_list_diff_case_with_prefix(self): choices = ['ABC', 'xyz'] prefix = 'ab' actual_result = list(CaseInsensitiveChoicesCompleter(choices)(prefix)) # Casing is returned the same as the original choice list expected_result = ['ABC'] self.assertListEqual(actual_result, expected_result) def test_case_insensitive_choices_list_multi_case_with_prefix(self): choices = ['red', 'blue', 'YelLoW'] prefix = 'y' actual_result = list(CaseInsensitiveChoicesCompleter(choices)(prefix)) # Casing is returned the same as the original choice list expected_result = ['YelLoW'] self.assertListEqual(actual_result, expected_result) if __name__ == '__main__': unittest.main() knack-0.9.0/tests/test_config.py000066400000000000000000000374371414113720500166430ustar00rootroot00000000000000# -------------------------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- import os import stat import unittest import shutil from unittest import mock import configparser from knack.config import CLIConfig from .util import TEMP_FOLDER_NAME, new_temp_folder def clean_local_temp_folder(): local_temp_folders = os.path.join(os.getcwd(), TEMP_FOLDER_NAME) if os.path.exists(local_temp_folders): shutil.rmtree(local_temp_folders) class TestCLIConfig(unittest.TestCase): def setUp(self): self.cli_config = CLIConfig(config_dir=new_temp_folder()) # In case the previous test is stopped and doesn't clean up clean_local_temp_folder() def tearDown(self): clean_local_temp_folder() def test_has_option(self): section = 'MySection' option = 'myoption' value = 'myvalue' self.cli_config.set_value(section, option, value) self.assertTrue(self.cli_config.has_option(section, option)) self.cli_config.set_to_use_local_config(True) self.assertTrue(self.cli_config.has_option(section, option)) def test_has_option_env(self): with mock.patch.dict('os.environ', {self.cli_config.env_var_name('MySection', 'myoption'): 'myvalue'}): section = 'MySection' option = 'myoption' self.assertTrue(self.cli_config.has_option(section, option)) self.cli_config.set_to_use_local_config(True) self.assertTrue(self.cli_config.has_option(section, option)) def test_has_option_env_no(self): section = 'MySection' option = 'myoption' self.assertFalse(self.cli_config.has_option(section, option)) self.cli_config.set_to_use_local_config(True) self.assertFalse(self.cli_config.has_option(section, option)) def test_get(self): section = 'MySection' option = 'myoption' value = 'myvalue' self.cli_config.set_value(section, option, value) self.assertEqual(self.cli_config.get(section, option), value) self.cli_config.set_to_use_local_config(True) self.assertEqual(self.cli_config.get(section, option), value) def test_get_env(self): with mock.patch.dict('os.environ', {self.cli_config.env_var_name('MySection', 'myoption'): 'myvalue'}): section = 'MySection' option = 'myoption' value = 'myvalue' self.assertEqual(self.cli_config.get(section, option), value) self.cli_config.set_to_use_local_config(True) self.assertEqual(self.cli_config.get(section, option), value) def test_get_not_found_section(self): section = 'MySection' option = 'myoption' with self.assertRaises(configparser.NoSectionError): self.cli_config.get(section, option) self.cli_config.set_to_use_local_config(True) with self.assertRaises(configparser.NoSectionError): self.cli_config.get(section, option) def test_get_not_found_option(self): section = 'MySection' option = 'myoption' option_other = 'option' value = 'myvalue' self.cli_config.set_value(section, option_other, value) with self.assertRaises(configparser.NoOptionError): self.cli_config.get(section, option) self.cli_config.set_to_use_local_config(True) self.cli_config.set_value(section, option_other, value) with self.assertRaises(configparser.NoOptionError): self.cli_config.get(section, option) def test_get_fallback(self): section = 'MySection' option = 'myoption' self.assertEqual(self.cli_config.get(section, option, fallback='fallback'), 'fallback') self.cli_config.set_to_use_local_config(True) self.assertEqual(self.cli_config.get(section, option, fallback='fallback'), 'fallback') def test_getint(self): section = 'MySection' option = 'myoption' value = '123' self.cli_config.set_value(section, option, value) self.assertEqual(self.cli_config.getint(section, option), int(value)) self.cli_config.set_to_use_local_config(True) self.assertEqual(self.cli_config.getint(section, option), int(value)) def test_getint_error(self): section = 'MySection' option = 'myoption' value = 'not_an_int' self.cli_config.set_value(section, option, value) with self.assertRaises(ValueError): self.cli_config.getint(section, option) self.cli_config.set_to_use_local_config(True) with self.assertRaises(ValueError): self.cli_config.getint(section, option) self.cli_config.set_value(section, option, value) with self.assertRaises(ValueError): self.cli_config.getint(section, option) def test_getfloat(self): section = 'MySection' option = 'myoption' value = '123.456' self.cli_config.set_value(section, option, value) self.assertEqual(self.cli_config.getfloat(section, option), float(value)) self.cli_config.set_to_use_local_config(True) self.assertEqual(self.cli_config.getfloat(section, option), float(value)) def test_getfloat_error(self): section = 'MySection' option = 'myoption' value = 'not_a_float' self.cli_config.set_value(section, option, value) with self.assertRaises(ValueError): self.cli_config.getfloat(section, option) self.cli_config.set_to_use_local_config(True) self.cli_config.set_value(section, option, value) with self.assertRaises(ValueError): self.cli_config.getfloat(section, option) def test_getboolean(self): section = 'MySection' option = 'myoption' value = 'true' self.cli_config.set_value(section, option, value) self.assertTrue(self.cli_config.getboolean(section, option)) self.cli_config.set_to_use_local_config(True) self.assertTrue(self.cli_config.getboolean(section, option)) def test_getboolean_error(self): section = 'MySection' option = 'myoption' value = 'not_a_boolean' self.cli_config.set_value(section, option, value) with self.assertRaises(ValueError): self.cli_config.getboolean(section, option) self.cli_config.set_to_use_local_config(True) self.cli_config.set_value(section, option, value) with self.assertRaises(ValueError): self.cli_config.getboolean(section, option) def test_items(self): file_section = "MySection" file_value = 'file_value' env_value = 'env_value' # Test file-only options are listed file_only_option = 'file_only_option' self.cli_config.set_value(file_section, file_only_option, file_value) items_result = self.cli_config.items(file_section) self.assertEqual(len(items_result), 1) self.assertEqual(items_result[0]['name'], file_only_option) self.assertEqual(items_result[0]['value'], file_value) self.cli_config.remove_option(file_section, file_only_option) # Test env-only options are listed with mock.patch.dict('os.environ', {'CLI_MYSECTION_ENV_ONLY_OPTION': env_value}): items_result = self.cli_config.items(file_section) self.assertEqual(len(items_result), 1) self.assertEqual(items_result[0]['name'], 'env_only_option') self.assertEqual(items_result[0]['value'], env_value) # Test file options are overridden by env options, for both single-word and multi-word options test_options = [ # file_option, file_value, env_name, env_value # Test single-word option ('optionsingle', 'file_value_single', 'CLI_MYSECTION_OPTIONSINGLE', 'env_value_single'), # Test multi-word option ('option_multiple', 'file_value_multiple', 'CLI_MYSECTION_OPTION_MULTIPLE', 'env_value_multiple') ] for file_option, file_value, env_name, env_value in test_options: self.cli_config.set_value(file_section, file_option, file_value) items_result = self.cli_config.items(file_section) self.assertEqual(len(items_result), 1) self.assertEqual(items_result[0]['value'], file_value) with mock.patch.dict('os.environ', {env_name: env_value}): items_result = self.cli_config.items(file_section) self.assertEqual(len(items_result), 1) self.assertEqual(items_result[0]['value'], env_value) self.cli_config.remove_option(file_section, file_option) # Test Invalid_Env_Var is not accepted on Linux # Windows' env var is case-insensitive, so skip import platform if platform.system() != 'Windows': # Not shown with mock.patch.dict('os.environ', {'CLI_MYSECTION_Test_Option': env_value}): items_result = self.cli_config.items(file_section) self.assertEqual(len(items_result), 0) # No overriding self.cli_config.set_value(file_section, 'test_option', file_value) with mock.patch.dict('os.environ', {'CLI_MYSECTION_Test_Option': env_value}): items_result = self.cli_config.items(file_section) self.assertEqual(len(items_result), 1) self.assertEqual(items_result[0]['value'], file_value) def test_set_config_value(self): self.cli_config.set_value('test_section', 'test_option', 'a_value') config = configparser.ConfigParser() config.read(self.cli_config.config_path) self.assertEqual(config.get('test_section', 'test_option'), 'a_value') def test_set_config_value_duplicate_section_ok(self): self.cli_config.set_value('test_section', 'test_option', 'a_value') self.cli_config.set_value('test_section', 'test_option_another', 'another_value') self.assertEqual(self.cli_config.get('test_section', 'test_option'), 'a_value') self.assertEqual(self.cli_config.get('test_section', 'test_option_another'), 'another_value') self.cli_config.set_to_use_local_config(True) self.cli_config.set_value('test_section', 'test_option', 'a_value') self.cli_config.set_value('test_section', 'test_option_another', 'another_value') self.assertEqual(self.cli_config.get('test_section', 'test_option'), 'a_value') self.assertEqual(self.cli_config.get('test_section', 'test_option_another'), 'another_value') def test_set_config_value_not_string(self): with self.assertRaises(TypeError): self.cli_config.set_value('test_section', 'test_option', False) self.cli_config.set_to_use_local_config(True) with self.assertRaises(TypeError): self.cli_config.set_value('test_section', 'test_option', False) def test_set_config_value_file_permissions(self): self.cli_config.set_value('test_section', 'test_option', 'a_value') file_mode = os.stat(self.cli_config.config_path).st_mode self.assertTrue(bool(file_mode & stat.S_IRUSR)) self.assertTrue(bool(file_mode & stat.S_IWUSR)) # only S_IRUSR and S_IWUSR are supported on Windows: https://docs.python.org/3.8/library/os.html#os.chmod if os.name != 'nt': self.assertFalse(bool(file_mode & stat.S_IXUSR)) self.assertFalse(bool(file_mode & stat.S_IRGRP)) self.assertFalse(bool(file_mode & stat.S_IWGRP)) self.assertFalse(bool(file_mode & stat.S_IXGRP)) self.assertFalse(bool(file_mode & stat.S_IROTH)) self.assertFalse(bool(file_mode & stat.S_IWOTH)) self.assertFalse(bool(file_mode & stat.S_IXOTH)) def test_has_option_local(self): section = 'MySection' option = 'myoption' value = 'myvalue' # check local config self.cli_config.set_to_use_local_config(True) self.cli_config.set_value(section, option, value) self.assertTrue(self.cli_config.has_option(section, option)) # check default config self.cli_config.set_to_use_local_config(False) self.assertFalse(self.cli_config.has_option(section, option)) self.cli_config.set_value(section, option, value) self.assertTrue(self.cli_config.has_option(section, option)) def test_get_local(self): section = 'MySection' option = 'myoption' value = 'myvalue' local_value = 'localvalue' # check local config self.cli_config.set_to_use_local_config(True) self.cli_config.set_value(section, option, local_value) self.assertEqual(self.cli_config.get(section, option), local_value) # check default config self.cli_config.set_to_use_local_config(False) self.assertFalse(self.cli_config.has_option(section, option)) self.cli_config.set_value(section, option, value) self.assertEqual(self.cli_config.get(section, option), value) def test_getint_local(self): section = 'MySection' option = 'myoption' value = '123' local_value = '1234' # check local config self.cli_config.set_to_use_local_config(True) self.cli_config.set_value(section, option, local_value) self.assertEqual(self.cli_config.getint(section, option), int(local_value)) # check default config self.cli_config.set_to_use_local_config(False) self.assertFalse(self.cli_config.has_option(section, option)) self.cli_config.set_value(section, option, value) self.assertEqual(self.cli_config.getint(section, option), int(value)) def test_getfloat_local(self): section = 'MySection' option = 'myoption' value = '123.456' local_value = '1234.56' # check local config self.cli_config.set_to_use_local_config(True) self.cli_config.set_value(section, option, local_value) self.assertEqual(self.cli_config.getfloat(section, option), float(local_value)) # check default config self.cli_config.set_to_use_local_config(False) self.assertFalse(self.cli_config.has_option(section, option)) self.cli_config.set_value(section, option, value) self.assertEqual(self.cli_config.getfloat(section, option), float(value)) def test_getboolean_local(self): section = 'MySection' option = 'myoption' local_value = 'true' value = 'false' # check local config self.cli_config.set_to_use_local_config(True) self.cli_config.set_value(section, option, local_value) self.assertTrue(self.cli_config.getboolean(section, option)) # check default config self.cli_config.set_to_use_local_config(False) self.assertFalse(self.cli_config.has_option(section, option)) self.cli_config.set_value(section, option, value) self.assertFalse(self.cli_config.getboolean(section, option)) def test_set_config_value_duplicate_section_ok_local(self): self.cli_config.set_to_use_local_config(True) self.cli_config.set_value('test_section', 'test_option', 'a_value') self.cli_config.set_value('test_section', 'test_option_another', 'another_value') self.assertEqual(self.cli_config.get('test_section', 'test_option'), 'a_value') self.assertEqual(self.cli_config.get('test_section', 'test_option_another'), 'another_value') self.cli_config.set_to_use_local_config(False) self.assertFalse(self.cli_config.has_option('test_section', 'test_option')) self.assertFalse(self.cli_config.has_option('test_section', 'test_option_another')) if __name__ == '__main__': unittest.main() knack-0.9.0/tests/test_deprecation.py000066400000000000000000000472511414113720500176660ustar00rootroot00000000000000# -------------------------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- import unittest from unittest import mock from knack.arguments import ArgumentsContext from knack.commands import CLICommandsLoader, CommandGroup from tests.util import DummyCLI, redirect_io, assert_in_multi_line, disable_color def example_handler(arg1, arg2=None, arg3=None): """ Short summary here. Long summary here. Still long summary. """ pass def example_arg_handler(arg1, opt1, arg2=None, opt2=None, arg3=None, opt3=None, arg4=None, opt4=None, arg5=None, opt5=None): pass # pylint: disable=line-too-long class TestCommandDeprecation(unittest.TestCase): def setUp(self): from knack.help_files import helps class DeprecationTestCommandLoader(CLICommandsLoader): def load_command_table(self, args): super().load_command_table(args) with CommandGroup(self, '', '{}#{{}}'.format(__name__)) as g: g.command('cmd1', 'example_handler', deprecate_info=g.deprecate(redirect='alt-cmd1')) g.command('cmd2', 'example_handler', deprecate_info=g.deprecate(redirect='alt-cmd2', hide='1.0.0')) g.command('cmd3', 'example_handler', deprecate_info=g.deprecate(redirect='alt-cmd3', hide='0.1.0')) g.command('cmd4', 'example_handler', deprecate_info=g.deprecate(redirect='alt-cmd4', expiration='1.0.0')) g.command('cmd5', 'example_handler', deprecate_info=g.deprecate(redirect='alt-cmd5', expiration='0.1.0')) with CommandGroup(self, 'grp1', '{}#{{}}'.format(__name__), deprecate_info=self.deprecate(redirect='alt-grp1')) as g: g.command('cmd1', 'example_handler') return self.command_table def load_arguments(self, command): with ArgumentsContext(self, '') as c: c.argument('arg1', options_list=['--arg', '-a'], required=False, type=int, choices=[1, 2, 3]) c.argument('arg2', options_list=['-b'], required=True, choices=['a', 'b', 'c']) super().load_arguments(command) helps['grp1'] = """ type: group short-summary: A group. """ self.cli_ctx = DummyCLI(commands_loader_cls=DeprecationTestCommandLoader) @redirect_io def test_deprecate_command_group_help(self): """ Ensure deprecated commands appear (or don't appear) correctly in group help view. """ with self.assertRaises(SystemExit): self.cli_ctx.invoke('-h'.split()) actual = self.io.getvalue() expected = """ Group {} Subgroups: grp1 [Deprecated] : A group. Commands: cmd1 [Deprecated] : Short summary here. cmd2 [Deprecated] : Short summary here. cmd4 [Deprecated] : Short summary here. """.format(self.cli_ctx.name) assert_in_multi_line(expected, actual) @redirect_io def test_deprecate_command_help_hidden(self): """ Ensure hidden deprecated command can be used. """ with self.assertRaises(SystemExit): self.cli_ctx.invoke('cmd3 -h'.split()) actual = self.io.getvalue() expected = """ Command {} cmd3 : Short summary here. Long summary here. Still long summary. This command has been deprecated and will be removed in a future release. Use 'alt- cmd3' instead. Arguments -b [Required] : Allowed values: a, b, c. --arg -a : Allowed values: 1, 2, 3. --arg3 """.format(self.cli_ctx.name) assert_in_multi_line(expected, actual) @redirect_io def test_deprecate_command_plain_execute(self): """ Ensure general warning displayed when running deprecated command. """ self.cli_ctx.invoke('cmd1 -b b'.split()) actual = self.io.getvalue() expected = "This command has been deprecated and will be removed in a future release. Use 'alt-cmd1' instead." self.assertIn(expected, actual) @redirect_io def test_deprecate_command_hidden_execute(self): """ Ensure general warning displayed when running hidden deprecated command. """ self.cli_ctx.invoke('cmd3 -b b'.split()) actual = self.io.getvalue() expected = "This command has been deprecated and will be removed in a future release. Use 'alt-cmd3' instead." self.assertIn(expected, actual) @redirect_io def test_deprecate_command_expiring_execute(self): """ Ensure specific warning displayed when running expiring deprecated command. """ self.cli_ctx.invoke('cmd4 -b b'.split()) actual = self.io.getvalue() expected = "This command has been deprecated and will be removed in version '1.0.0'. Use 'alt-cmd4' instead." self.assertIn(expected, actual) @redirect_io def test_deprecate_command_expiring_execute_no_color(self): """ Ensure warning is displayed without color. """ self.cli_ctx.enable_color = False self.cli_ctx.invoke('cmd4 -b b'.split()) actual = self.io.getvalue() expected = "WARNING: This command has been deprecated and will be removed in version '1.0.0'" self.assertIn(expected, actual) @redirect_io def test_deprecate_command_expired_execute(self): """ Ensure expired command cannot be reached. """ with self.assertRaises(SystemExit): self.cli_ctx.invoke('cmd5 -h'.split()) actual = self.io.getvalue() expected = """cli: 'cmd5' is not in the 'cli' command group.""" self.assertIn(expected, actual) @redirect_io @disable_color def test_deprecate_command_expired_execute_no_color(self): """ Ensure error is displayed without color. """ with self.assertRaises(SystemExit): self.cli_ctx.invoke('cmd5 -h'.split()) actual = self.io.getvalue() expected = """ERROR: cli: 'cmd5' is not in the 'cli' command group.""" self.assertIn(expected, actual) class TestCommandGroupDeprecation(unittest.TestCase): def setUp(self): from knack.help_files import helps class DeprecationTestCommandLoader(CLICommandsLoader): def load_command_table(self, args): super().load_command_table(args) with CommandGroup(self, 'group1', '{}#{{}}'.format(__name__), deprecate_info=self.deprecate(redirect='alt-group1')) as g: g.command('cmd1', 'example_handler') with CommandGroup(self, 'group2', '{}#{{}}'.format(__name__), deprecate_info=self.deprecate(redirect='alt-group2', hide='1.0.0')) as g: g.command('cmd1', 'example_handler') with CommandGroup(self, 'group3', '{}#{{}}'.format(__name__), deprecate_info=self.deprecate(redirect='alt-group3', hide='0.1.0')) as g: g.command('cmd1', 'example_handler') with CommandGroup(self, 'group4', '{}#{{}}'.format(__name__), deprecate_info=self.deprecate(redirect='alt-group4', expiration='1.0.0')) as g: g.command('cmd1', 'example_handler') with CommandGroup(self, 'group5', '{}#{{}}'.format(__name__), deprecate_info=self.deprecate(redirect='alt-group5', expiration='0.1.0')) as g: g.command('cmd1', 'example_handler') return self.command_table def load_arguments(self, command): with ArgumentsContext(self, '') as c: c.argument('arg1', options_list=['--arg', '-a'], required=False, type=int, choices=[1, 2, 3]) c.argument('arg2', options_list=['-b'], required=True, choices=['a', 'b', 'c']) super().load_arguments(command) helps['group1'] = """ type: group short-summary: A group. """ self.cli_ctx = DummyCLI(commands_loader_cls=DeprecationTestCommandLoader) @redirect_io def test_deprecate_command_group_help_plain(self): """ Ensure help warnings appear for deprecated command group help. """ with self.assertRaises(SystemExit): self.cli_ctx.invoke('group1 -h'.split()) actual = self.io.getvalue() expected = """ Group cli group1 : A group. This command group has been deprecated and will be removed in a future release. Use 'alt-group1' instead. Commands: cmd1 : Short summary here. """.format(self.cli_ctx.name) assert_in_multi_line(expected, actual) @redirect_io def test_deprecate_command_group_help_hidden(self): """ Ensure hidden deprecated command can be used. """ with self.assertRaises(SystemExit): self.cli_ctx.invoke('group3 -h'.split()) actual = self.io.getvalue() expected = """ Group {} group3 This command group has been deprecated and will be removed in a future release. Use 'alt-group3' instead. Commands: cmd1 : Short summary here. """.format(self.cli_ctx.name) assert_in_multi_line(expected, actual) @redirect_io def test_deprecate_command_group_help_expiring(self): """ Ensure specific warning displayed when running expiring deprecated command. """ with self.assertRaises(SystemExit): self.cli_ctx.invoke('group4 -h'.split()) actual = self.io.getvalue() expected = """ Group {} group4 This command group has been deprecated and will be removed in version '1.0.0'. Use 'alt-group4' instead. """.format(self.cli_ctx.name) assert_in_multi_line(expected, actual) @redirect_io @disable_color def test_deprecate_command_group_help_expiring_no_color(self): """ Ensure warning is displayed without color. """ with self.assertRaises(SystemExit): self.cli_ctx.invoke('group4 -h'.split()) actual = self.io.getvalue() expected = """ Group cli group4 WARNING: This command group has been deprecated and will be removed in version \'1.0.0\'. Use 'alt-group4' instead. """.format(self.cli_ctx.name) self.assertIn(expected, actual) @redirect_io def test_deprecate_command_group_expired(self): """ Ensure expired command cannot be reached. """ with self.assertRaises(SystemExit): self.cli_ctx.invoke('group5 -h'.split()) actual = self.io.getvalue() expected = """The most similar choices to 'group5'""" self.assertIn(expected, actual) @redirect_io def test_deprecate_command_implicitly(self): """ Ensure help warning displayed for command deprecated because of a deprecated parent group. """ with self.assertRaises(SystemExit): self.cli_ctx.invoke('group1 cmd1 -h'.split()) actual = self.io.getvalue() expected = """ Command {} group1 cmd1 : Short summary here. Long summary here. Still long summary. This command is implicitly deprecated because command group 'group1' is deprecated and will be removed in a future release. Use 'alt-group1' instead. """.format(self.cli_ctx.name) assert_in_multi_line(expected, actual) class TestArgumentDeprecation(unittest.TestCase): def setUp(self): from knack.help_files import helps class DeprecationTestCommandLoader(CLICommandsLoader): def load_command_table(self, args): super().load_command_table(args) with CommandGroup(self, '', '{}#{{}}'.format(__name__)) as g: g.command('arg-test', 'example_arg_handler') return self.command_table def load_arguments(self, command): with ArgumentsContext(self, 'arg-test') as c: c.argument('arg1', help='Arg1', deprecate_info=c.deprecate()) c.argument('opt1', help='Opt1', options_list=['--opt1', c.deprecate(redirect='--opt1', target='--alt1')]) c.argument('arg2', help='Arg2', deprecate_info=c.deprecate(hide='1.0.0')) c.argument('opt2', help='Opt2', options_list=['--opt2', c.deprecate(redirect='--opt2', target='--alt2', hide='1.0.0')]) c.argument('arg3', help='Arg3', deprecate_info=c.deprecate(hide='0.1.0')) c.argument('opt3', help='Opt3', options_list=['--opt3', c.deprecate(redirect='--opt3', target='--alt3', hide='0.1.0')]) c.argument('arg4', deprecate_info=c.deprecate(expiration='1.0.0')) c.argument('opt4', options_list=['--opt4', c.deprecate(redirect='--opt4', target='--alt4', expiration='1.0.0')]) c.argument('arg5', deprecate_info=c.deprecate(expiration='0.1.0')) c.argument('opt5', options_list=['--opt5', c.deprecate(redirect='--opt5', target='--alt5', expiration='0.1.0')]) super().load_arguments(command) helps['grp1'] = """ type: group short-summary: A group. """ self.cli_ctx = DummyCLI(commands_loader_cls=DeprecationTestCommandLoader) @redirect_io def test_deprecate_arguments_command_help(self): """ Ensure deprecated arguments and options appear (or don't appear) correctly in command help view. """ with self.assertRaises(SystemExit): self.cli_ctx.invoke('arg-test -h'.split()) actual = self.io.getvalue() expected = """ Command {} arg-test Arguments --alt1 [Deprecated] [Required] : Opt1. Option '--alt1' has been deprecated and will be removed in a future release. Use '-- opt1' instead. --arg1 [Deprecated] [Required] : Arg1. Argument 'arg1' has been deprecated and will be removed in a future release. --opt1 [Required] : Opt1. --alt2 [Deprecated] : Opt2. Option '--alt2' has been deprecated and will be removed in a future release. Use '-- opt2' instead. --alt4 [Deprecated] Option '--alt4' has been deprecated and will be removed in version '1.0.0'. Use '-- opt4' instead. --arg2 [Deprecated] : Arg2. Argument 'arg2' has been deprecated and will be removed in a future release. --arg4 [Deprecated] Argument 'arg4' has been deprecated and will be removed in version '1.0.0'. --opt2 : Opt2. --opt3 : Opt3. --opt4 --opt5 """.format(self.cli_ctx.name) assert_in_multi_line(expected, actual) @redirect_io def test_deprecate_arguments_execute(self): """ Ensure deprecated arguments can be used. """ self.cli_ctx.invoke('arg-test --arg1 foo --opt1 bar'.split()) actual = self.io.getvalue() expected = "Argument 'arg1' has been deprecated and will be removed in a future release." self.assertIn(expected, actual) @redirect_io def test_deprecate_arguments_execute_hidden(self): """ Ensure hidden deprecated arguments can be used. """ self.cli_ctx.invoke('arg-test --arg1 foo --opt1 bar --arg3 bar'.split()) actual = self.io.getvalue() expected = "Argument 'arg3' has been deprecated and will be removed in a future release." self.assertIn(expected, actual) @redirect_io def test_deprecate_arguments_execute_expiring(self): """ Ensure hidden deprecated arguments can be used. """ self.cli_ctx.invoke('arg-test --arg1 foo --opt1 bar --arg4 bar'.split()) actual = self.io.getvalue() expected = "Argument 'arg4' has been deprecated and will be removed in version '1.0.0'." self.assertIn(expected, actual) @redirect_io def test_deprecate_arguments_execute_expired(self): """ Ensure expired deprecated arguments can't be used. """ with self.assertRaises(SystemExit): self.cli_ctx.invoke('arg-test --arg1 foo --opt1 bar --arg5 foo'.split()) actual = self.io.getvalue() expected = 'unrecognized arguments: --arg5 foo' self.assertIn(expected, actual) @redirect_io def test_deprecate_options_execute(self): """ Ensure deprecated options can be used with a warning. """ self.cli_ctx.invoke('arg-test --arg1 foo --alt1 bar'.split()) actual = self.io.getvalue() expected = "Option '--alt1' has been deprecated and will be removed in a future release. Use '--opt1' instead." self.assertIn(expected, actual) @redirect_io def test_deprecate_options_execute_non_deprecated(self): """ Ensure non-deprecated options don't show warning. """ self.cli_ctx.invoke('arg-test --arg1 foo --opt1 bar'.split()) actual = self.io.getvalue() expected = "Option '--alt1' has been deprecated and will be removed in a future release. Use '--opt1' instead." self.assertNotIn(expected, actual) @redirect_io def test_deprecate_options_execute_hidden(self): """ Ensure hidden deprecated options can be used with warning. """ self.cli_ctx.invoke('arg-test --arg1 foo --opt1 bar --alt3 bar'.split()) actual = self.io.getvalue() expected = "Option '--alt3' has been deprecated and will be removed in a future release. Use '--opt3' instead." self.assertIn(expected, actual) @redirect_io def test_deprecate_options_execute_hidden_non_deprecated(self): """ Ensure hidden non-deprecated optionss can be used without warning. """ self.cli_ctx.invoke('arg-test --arg1 foo --opt1 bar --opt3 bar'.split()) actual = self.io.getvalue() expected = "Option '--alt3' has been deprecated and will be removed in a future release. Use '--opt3' instead." self.assertNotIn(expected, actual) @redirect_io def test_deprecate_options_execute_expired(self): """ Ensure expired deprecated options can't be used. """ with self.assertRaises(SystemExit): self.cli_ctx.invoke('arg-test --arg1 foo --opt1 bar --alt5 foo'.split()) actual = self.io.getvalue() expected = 'unrecognized arguments: --alt5 foo' self.assertIn(expected, actual) @redirect_io def test_deprecate_options_execute_expired_non_deprecated(self): """ Ensure non-expired options can be used without warning. """ self.cli_ctx.invoke('arg-test --arg1 foo --opt1 bar --opt5 foo'.split()) actual = self.io.getvalue() self.assertTrue('--alt5' not in actual and '--opt5' not in actual) @redirect_io def test_deprecate_options_execute_expiring(self): """ Ensure expiring options can be used with warning. """ self.cli_ctx.invoke('arg-test --arg1 foo --opt1 bar --alt4 bar'.split()) actual = self.io.getvalue() expected = "Option '--alt4' has been deprecated and will be removed in version '1.0.0'. Use '--opt4' instead." self.assertIn(expected, actual) @redirect_io @disable_color def test_deprecate_options_execute_expiring_no_color(self): """ Ensure error is displayed without color. """ self.cli_ctx.invoke('arg-test --arg1 foo --opt1 bar --alt4 bar'.split()) actual = self.io.getvalue() expected = "WARNING: Option '--alt4' has been deprecated and will be removed in version '1.0.0'. Use '--opt4' instead." self.assertIn(expected, actual) @redirect_io def test_deprecate_options_execute_expiring_non_deprecated(self): """ Ensure non-expiring options can be used without warning. """ self.cli_ctx.invoke('arg-test --arg1 foo --opt1 bar --opt4 bar'.split()) actual = self.io.getvalue() expected = "Option '--alt4' has been deprecated and will be removed in version '1.0.0'. Use '--opt4' instead." self.assertNotIn(expected, actual) if __name__ == '__main__': unittest.main() knack-0.9.0/tests/test_experimental.py000066400000000000000000000171431414113720500200630ustar00rootroot00000000000000# -------------------------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- import unittest from unittest import mock import sys import argparse from knack.arguments import ArgumentsContext from knack.commands import CLICommandsLoader, CommandGroup from tests.util import DummyCLI, redirect_io, assert_in_multi_line def example_handler(arg1, arg2=None, arg3=None): """ Short summary here. Long summary here. Still long summary. """ pass def example_arg_handler(arg1, opt1, arg2=None, opt2=None, arg3=None, opt3=None, arg4=None, opt4=None, arg5=None, opt5=None): pass class TestCommandExperimental(unittest.TestCase): def setUp(self): from knack.help_files import helps class ExperimentalTestCommandLoader(CLICommandsLoader): def load_command_table(self, args): super(ExperimentalTestCommandLoader, self).load_command_table(args) with CommandGroup(self, '', '{}#{{}}'.format(__name__)) as g: g.command('cmd1', 'example_handler', is_experimental=True) with CommandGroup(self, 'grp1', '{}#{{}}'.format(__name__), is_experimental=True) as g: g.command('cmd1', 'example_handler') return self.command_table def load_arguments(self, command): with ArgumentsContext(self, '') as c: c.argument('arg1', options_list=['--arg', '-a'], required=False, type=int, choices=[1, 2, 3]) c.argument('arg2', options_list=['-b'], required=True, choices=['a', 'b', 'c']) super(ExperimentalTestCommandLoader, self).load_arguments(command) helps['grp1'] = """ type: group short-summary: A group. """ self.cli_ctx = DummyCLI(commands_loader_cls=ExperimentalTestCommandLoader) @redirect_io def test_experimental_command_implicitly_execute(self): """ Ensure general warning displayed when running command from an experimental parent group. """ self.cli_ctx.invoke('grp1 cmd1 -b b'.split()) actual = self.io.getvalue() expected = "Command group 'grp1' is experimental and under development." self.assertIn(expected, actual) @redirect_io def test_experimental_command_group_help(self): """ Ensure experimental commands appear correctly in group help view. """ with self.assertRaises(SystemExit): self.cli_ctx.invoke('-h'.split()) actual = self.io.getvalue() expected = """ Group {} Subgroups: grp1 [Experimental] : A group. Commands: cmd1 [Experimental] : Short summary here. """.format(self.cli_ctx.name) assert_in_multi_line(expected, actual) @redirect_io def test_experimental_command_plain_execute(self): """ Ensure general warning displayed when running experimental command. """ self.cli_ctx.invoke('cmd1 -b b'.split()) actual = self.io.getvalue() expected = "This command is experimental and under development." self.assertIn(expected, actual) class TestCommandGroupExperimental(unittest.TestCase): def setUp(self): from knack.help_files import helps class ExperimentalTestCommandLoader(CLICommandsLoader): def load_command_table(self, args): super(ExperimentalTestCommandLoader, self).load_command_table(args) with CommandGroup(self, 'group1', '{}#{{}}'.format(__name__), is_experimental=True) as g: g.command('cmd1', 'example_handler') return self.command_table def load_arguments(self, command): with ArgumentsContext(self, '') as c: c.argument('arg1', options_list=['--arg', '-a'], required=False, type=int, choices=[1, 2, 3]) c.argument('arg2', options_list=['-b'], required=True, choices=['a', 'b', 'c']) super(ExperimentalTestCommandLoader, self).load_arguments(command) helps['group1'] = """ type: group short-summary: A group. """ self.cli_ctx = DummyCLI(commands_loader_cls=ExperimentalTestCommandLoader) @redirect_io def test_experimental_command_group_help_plain(self): """ Ensure help warnings appear for experimental command group help. """ with self.assertRaises(SystemExit): self.cli_ctx.invoke('group1 -h'.split()) actual = self.io.getvalue() expected = """ Group cli group1 : A group. This command group is experimental and under development. Commands: cmd1 : Short summary here. """.format(self.cli_ctx.name) assert_in_multi_line(expected, actual) @redirect_io def test_experimental_command_implicitly(self): """ Ensure help warning displayed for command in experimental because of a experimental parent group. """ with self.assertRaises(SystemExit): self.cli_ctx.invoke('group1 cmd1 -h'.split()) actual = self.io.getvalue() expected = """ Command {} group1 cmd1 : Short summary here. Long summary here. Still long summary. Command group 'group1' is experimental and under development. """.format(self.cli_ctx.name) assert_in_multi_line(expected, actual) class TestArgumentExperimental(unittest.TestCase): def setUp(self): from knack.help_files import helps class LoggerAction(argparse.Action): def __call__(self, parser, namespace, values, option_string=None): print("Side-effect from some original action!", file=sys.stderr) class ExperimentalTestCommandLoader(CLICommandsLoader): def load_command_table(self, args): super(ExperimentalTestCommandLoader, self).load_command_table(args) with CommandGroup(self, '', '{}#{{}}'.format(__name__)) as g: g.command('arg-test', 'example_arg_handler') return self.command_table def load_arguments(self, command): with ArgumentsContext(self, 'arg-test') as c: c.argument('arg1', help='Arg1', is_experimental=True, action=LoggerAction) super(ExperimentalTestCommandLoader, self).load_arguments(command) helps['grp1'] = """ type: group short-summary: A group. """ self.cli_ctx = DummyCLI(commands_loader_cls=ExperimentalTestCommandLoader) @redirect_io def test_experimental_arguments_command_help(self): """ Ensure experimental arguments appear correctly in command help view. """ with self.assertRaises(SystemExit): self.cli_ctx.invoke('arg-test -h'.split()) actual = self.io.getvalue() expected = """ Arguments --arg1 [Experimental] [Required] : Arg1. Argument '--arg1' is experimental and under development. """.format(self.cli_ctx.name) assert_in_multi_line(expected, actual) @redirect_io def test_experimental_arguments_execute(self): """ Ensure deprecated arguments can be used. """ self.cli_ctx.invoke('arg-test --arg1 foo --opt1 bar'.split()) actual = self.io.getvalue() experimental_expected = "Argument '--arg1' is experimental and under development." self.assertIn(experimental_expected, actual) action_expected = "Side-effect from some original action!" self.assertIn(action_expected, actual) if __name__ == '__main__': unittest.main() knack-0.9.0/tests/test_help.py000066400000000000000000000373261414113720500163230ustar00rootroot00000000000000# -------------------------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- import sys import unittest from unittest import mock from knack.arguments import ArgumentsContext from knack.commands import CLICommandsLoader, CommandGroup from knack.events import EVENT_PARSER_GLOBAL_CREATE from knack.help import ArgumentGroupRegistry, HelpObject from tests.util import DummyCLI, redirect_io def example_handler(arg1, arg2=None, arg3=None): """ Short summary here. Long summary here. Still long summary. """ pass class TestHelpArgumentGroupRegistry(unittest.TestCase): def test_help_argument_group_registry(self): groups = [ 'Z Arguments', 'B Arguments', 'Global Arguments', 'A Arguments', ] group_registry = ArgumentGroupRegistry(groups) self.assertEqual(group_registry.get_group_priority('A Arguments'), '000002') self.assertEqual(group_registry.get_group_priority('B Arguments'), '000003') self.assertEqual(group_registry.get_group_priority('Z Arguments'), '000004') self.assertEqual(group_registry.get_group_priority('Global Arguments'), '001000') class TestHelpObject(unittest.TestCase): def test_short_summary_no_fullstop(self): obj = HelpObject() original_summary = 'This summary has no fullstop' obj.short_summary = original_summary self.assertEqual(obj.short_summary, '{}.'.format(original_summary)) def test_short_summary_fullstop(self): obj = HelpObject() original_summary = 'This summary has fullstop.' obj.short_summary = original_summary self.assertEqual(obj.short_summary, original_summary) def test_short_summary_exclamation_point(self): obj = HelpObject() original_summary = 'This summary has exclamation point!' obj.short_summary = original_summary self.assertEqual(obj.short_summary, original_summary) class TestHelp(unittest.TestCase): def setUp(self): from knack.help_files import helps class HelpTestCommandLoader(CLICommandsLoader): def load_command_table(self, args): super().load_command_table(args) with CommandGroup(self, '', '{}#{{}}'.format(__name__)) as g: g.command('n1', 'example_handler') g.command('n2', 'example_handler') g.command('n3', 'example_handler') g.command('n4', 'example_handler') g.command('n5', 'example_handler') with CommandGroup(self, 'group alpha', '{}#{{}}'.format(__name__)) as g: g.command('n1', 'example_handler') with CommandGroup(self, 'group beta', '{}#{{}}'.format(__name__)) as g: g.command('n1', 'example_handler') return self.command_table def load_arguments(self, command): for scope in ['n1', 'group alpha', 'group beta']: with ArgumentsContext(self, scope) as c: c.argument('arg1', options_list=['--arg', '-a'], required=False, type=int, choices=[1, 2, 3]) c.argument('arg2', options_list=['-b'], required=True, choices=['a', 'b', 'c']) for scope in ['n4', 'n5']: with ArgumentsContext(self, scope) as c: c.argument('arg1', options_list=['--foobar']) c.argument('arg2', options_list=['--foobar2'], required=True) c.argument('arg3', options_list=['--foobar3'], help='the foobar3') super().load_arguments(command) helps['n2'] = """ type: command short-summary: YAML short summary. long-summary: YAML long summary. More summary. """ helps['n3'] = """ type: command long-summary: | line1 line2 """ helps['n4'] = """ type: command parameters: - name: --foobar type: string required: false short-summary: one line partial sentence long-summary: text, markdown, etc. populator-commands: - mycli abc xyz - default - name: --foobar2 type: string required: true short-summary: one line partial sentence long-summary: paragraph(s) """ helps['n5'] = """ type: command short-summary: this module does xyz one-line or so long-summary: | this module.... kjsdflkj... klsfkj paragraph1 this module.... kjsdflkj... klsfkj paragraph2 parameters: - name: --foobar type: string required: false short-summary: one line partial sentence long-summary: text, markdown, etc. populator-commands: - mycli abc xyz - default - name: --foobar2 type: string required: true short-summary: one line partial sentence long-summary: paragraph(s) examples: - name: foo example text: example details """ helps['group alpha'] = """ type: group short-summary: this module does xyz one-line or so long-summary: | this module.... kjsdflkj... klsfkj paragraph1 this module.... kjsdflkj... klsfkj paragraph2 """ helps['group alpha n1'] = """ short-summary: this module does xyz one-line or so long-summary: | this module.... kjsdflkj... klsfkj paragraph1 this module.... kjsdflkj... klsfkj paragraph2 parameters: - name: --arg -a type: string required: false short-summary: one line partial sentence long-summary: text, markdown, etc. populator-commands: - mycli abc xyz - default - name: -b type: string required: true short-summary: one line partial sentence long-summary: paragraph(s) examples: - name: foo example text: example details """ self.cli_ctx = DummyCLI(commands_loader_cls=HelpTestCommandLoader) @redirect_io def test_choice_list_with_ints(self): """ Ensure choice_list works with integer lists. """ with self.assertRaises(SystemExit): self.cli_ctx.invoke('n1 -h'.split()) actual = self.io.getvalue() expected = 'Allowed values: 1, 2, 3' self.assertIn(expected, actual) @redirect_io def test_help_param(self): """ Ensure both --help and -h produce the same output. """ with self.assertRaises(SystemExit): self.cli_ctx.invoke('n1 -h'.split()) with self.assertRaises(SystemExit): self.cli_ctx.invoke('n1 --help'.split()) @redirect_io def test_help_long_and_short_description_from_docstring(self): """ Ensure the first sentence of a docstring is parsed as the short summary and subsequent text is interpretted as the long summary. """ with self.assertRaises(SystemExit): self.cli_ctx.invoke('n1 -h'.split()) actual = self.io.getvalue() expected = '\nCommand\n {} n1 : Short summary here.\n Long summary here. Still long summary.'.format(self.cli_ctx.name) self.assertTrue(actual.startswith(expected)) @redirect_io def test_help_long_and_short_description_from_yaml(self): """ Ensure the YAML version of short and long summary display correctly and override any values that may have been obtained through reflection. """ with self.assertRaises(SystemExit): self.cli_ctx.invoke('n2 -h'.split()) actual = self.io.getvalue() expected = '\nCommand\n {} n2 : YAML short summary.\n YAML long summary. More summary.'.format(self.cli_ctx.name) self.assertTrue(actual.startswith(expected)) @redirect_io def test_help_long_description_multi_line(self): """ Ensure that multi-line help in the YAML is displayed correctly. """ with self.assertRaises(SystemExit): self.cli_ctx.invoke('n3 -h'.split()) actual = self.io.getvalue() expected = '\nCommand\n {} n3 : Short summary here.\n Line1\n line2.\n'.format(self.cli_ctx.name) self.assertTrue(actual.startswith(expected)) @redirect_io def test_help_params_documentations(self): """ Ensure argument help is rendered according to the YAML spec. """ with self.assertRaises(SystemExit): self.cli_ctx.invoke('n4 -h'.split()) expected = """ Command {} n4 : Short summary here. Arguments --foobar [Required] : One line partial sentence. Values from: mycli abc xyz, default. Text, markdown, etc. --foobar2 [Required] : One line partial sentence. Paragraph(s). --foobar3 : The foobar3. """ actual = self.io.getvalue() expected = expected.format(self.cli_ctx.name) self.assertTrue(actual.startswith(expected)) @redirect_io def test_help_full_documentations(self): """ Test all features of YAML format. """ with self.assertRaises(SystemExit): self.cli_ctx.invoke('n5 -h'.split()) expected = """ Command {} n5 : This module does xyz one-line or so. This module.... kjsdflkj... klsfkj paragraph1 this module.... kjsdflkj... klsfkj paragraph2. Arguments --foobar [Required] : One line partial sentence. Values from: mycli abc xyz, default. Text, markdown, etc. --foobar2 [Required] : One line partial sentence. Paragraph(s). --foobar3 : The foobar3. Global Arguments --debug : Increase logging verbosity to show all debug logs. --help -h : Show this help message and exit. --only-show-errors : Only show errors, suppressing warnings. --output -o : Output format. Allowed values: json, jsonc, none, table, tsv, yaml, yamlc. Default: json. --query : JMESPath query string. See http://jmespath.org/ for more information and examples. --verbose : Increase logging verbosity. Use --debug for full debug logs. Examples foo example example details """ actual = self.io.getvalue() expected = expected.format(self.cli_ctx.name) self.assertTrue(actual.startswith(expected)) @redirect_io def test_help_with_param_specified(self): """ Ensure help appears even if some arguments are specified. """ with self.assertRaises(SystemExit): self.cli_ctx.invoke('n1 --arg 1 -h'.split()) expected = """ Command {} n1 : Short summary here. Long summary here. Still long summary. Arguments -b [Required] : Allowed values: a, b, c. --arg -a : Allowed values: 1, 2, 3. --arg3 Global Arguments --debug : Increase logging verbosity to show all debug logs. --help -h : Show this help message and exit. --only-show-errors : Only show errors, suppressing warnings. --output -o : Output format. Allowed values: json, jsonc, none, table, tsv, yaml, yamlc. Default: json. --query : JMESPath query string. See http://jmespath.org/ for more information and examples. --verbose : Increase logging verbosity. Use --debug for full debug logs. """ actual = self.io.getvalue() expected = expected.format(self.cli_ctx.name) self.assertEqual(actual, expected) @redirect_io def test_help_group_children(self): """ Ensure subgroups appear correctly. """ with self.assertRaises(SystemExit): self.cli_ctx.invoke('group -h'.split()) expected = """ Group {} group Subgroups: alpha : This module does xyz one-line or so. beta """ actual = self.io.getvalue() expected = expected.format(self.cli_ctx.name) self.assertEqual(actual, expected) @redirect_io def test_help_missing_params(self): """ Ensure the appropriate error is thrown when a required argument is missing. """ # work around an argparse behavior where output is not printed and SystemExit # is not raised on Python 2.7.9 if sys.version_info < (2, 7, 10): try: self.cli_ctx.invoke('n1 -a 1 --arg 2'.split()) except SystemExit: pass else: with self.assertRaises(SystemExit): self.cli_ctx.invoke('n1 -a 1 --arg 2'.split()) actual = self.io.getvalue() self.assertTrue('required' in actual and '-b' in actual) @redirect_io def test_help_extra_params(self): """ Ensure appropriate error is thrown when an extra argument is used. """ # work around an argparse behavior where output is not printed and SystemExit # is not raised on Python 2.7.9 if sys.version_info < (2, 7, 10): try: self.cli_ctx.invoke('n1 -a 1 -b c -c extra'.split()) except SystemExit: pass else: with self.assertRaises(SystemExit): self.cli_ctx.invoke('n1 -a 1 -b c -c extra'.split()) actual = self.io.getvalue() expected = 'unrecognized arguments: -c extra' self.assertIn(expected, actual) @redirect_io def test_help_group_help(self): """ Ensure group help appears correctly. """ with self.assertRaises(SystemExit): self.cli_ctx.invoke('group alpha -h'.split()) expected = """ Group {} group alpha : This module does xyz one-line or so. This module.... kjsdflkj... klsfkj paragraph1 this module.... kjsdflkj... klsfkj paragraph2. Commands: n1 : This module does xyz one-line or so. """ actual = self.io.getvalue() expected = expected.format(self.cli_ctx.name) self.assertEqual(actual, expected) @redirect_io @mock.patch('knack.cli.CLI.register_event') def test_help_global_params(self, _): """ Ensure global parameters can be added and display correctly. """ def register_globals(_, **kwargs): arg_group = kwargs.get('arg_group') arg_group.add_argument('--exampl', help='This is a new global argument.') self.cli_ctx._event_handlers[EVENT_PARSER_GLOBAL_CREATE].append(register_globals) # pylint: disable=protected-access with self.assertRaises(SystemExit): self.cli_ctx.invoke('n1 -h'.split()) s = """ Command {} n1 : Short summary here. Long summary here. Still long summary. Arguments -b [Required] : Allowed values: a, b, c. --arg -a : Allowed values: 1, 2, 3. --arg3 Global Arguments --debug : Increase logging verbosity to show all debug logs. --exampl : This is a new global argument. --help -h : Show this help message and exit. --only-show-errors : Only show errors, suppressing warnings. --output -o : Output format. Allowed values: json, jsonc, none, table, tsv, yaml, yamlc. Default: json. --query : JMESPath query string. See http://jmespath.org/ for more information and examples. --verbose : Increase logging verbosity. Use --debug for full debug logs. """ actual = self.io.getvalue() expected = s.format(self.cli_ctx.name) self.assertEqual(actual, expected) if __name__ == '__main__': unittest.main() knack-0.9.0/tests/test_introspection.py000066400000000000000000000054451414113720500202700ustar00rootroot00000000000000# -------------------------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- import unittest from knack.introspection import extract_full_summary_from_signature, option_descriptions, extract_args_from_signature def op1(self, arg1, arg2=False, arg3='mydefaultvalue', **kwargs): # pylint: disable=unused-argument """ This is the command description. :param arg3: This is an arg for a test. """ pass def op2(self, **kwargs): # pylint: disable=unused-argument # This operation has no summary pass class TestExtractArgs(unittest.TestCase): def test_extract_args_simple(self): arguments = dict(extract_args_from_signature(op1)) self.assertEqual(len(arguments), 3) self.assertNotIn('self', arguments) self.assertNotIn('kwargs', arguments) self.assertIn('arg1', arguments) self.assertListEqual(arguments['arg1'].options_list, ['--arg1']) self.assertTrue(arguments['arg1'].options['required']) self.assertIn('arg2', arguments) self.assertListEqual(arguments['arg2'].options_list, ['--arg2']) self.assertFalse(arguments['arg2'].options['required']) self.assertEqual(arguments['arg2'].options['action'], 'store_true') self.assertIn('arg3', arguments) self.assertListEqual(arguments['arg3'].options_list, ['--arg3']) self.assertFalse(arguments['arg3'].options['required']) self.assertEqual(arguments['arg3'].options['default'], 'mydefaultvalue') self.assertEqual(arguments['arg3'].options['help'], 'This is an arg for a test.') def test_extract_args_custom_exclude(self): excluded_params = ['self', 'kwargs', 'arg2', 'arg3'] arguments = dict(extract_args_from_signature(op1, excluded_params=excluded_params)) self.assertEqual(len(arguments), 1) for param in excluded_params: self.assertNotIn(param, arguments) class TestOptionDescriptions(unittest.TestCase): def test_option_desc_simple(self): option_descs = option_descriptions(op1) self.assertIn('arg3', option_descs) self.assertEqual(option_descs['arg3'], 'This is an arg for a test.') class TestExtractSummary(unittest.TestCase): def test_extract_summary(self): summary = extract_full_summary_from_signature(op1) self.assertEqual(summary, 'This is the command description.') def test_extract_summary_no_summary(self): summary = extract_full_summary_from_signature(op2) self.assertEqual(summary, '') if __name__ == '__main__': unittest.main() knack-0.9.0/tests/test_log.py000066400000000000000000000166101414113720500161450ustar00rootroot00000000000000# -------------------------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- import unittest from unittest import mock import logging from knack.events import EVENT_PARSER_GLOBAL_CREATE, EVENT_INVOKER_PRE_CMD_TBL_CREATE from knack.log import CLILogging, get_logger, CLI_LOGGER_NAME, _CustomStreamHandler from knack.util import CLIError from tests.util import MockContext class TestLoggingEventHandling(unittest.TestCase): def setUp(self): self.mock_ctx = MockContext() self.cli_logging = CLILogging('clitest', cli_ctx=self.mock_ctx) def test_cli_ctx_type_error(self): with self.assertRaises(TypeError): CLILogging('myclitest', cli_ctx=object()) def test_logging_argument_registrations(self): parser_arg_group_mock = mock.MagicMock() self.mock_ctx.raise_event(EVENT_PARSER_GLOBAL_CREATE, arg_group=parser_arg_group_mock) parser_arg_group_mock.add_argument.assert_any_call(CLILogging.VERBOSE_FLAG, dest=mock.ANY, action=mock.ANY, help=mock.ANY) parser_arg_group_mock.add_argument.assert_any_call(CLILogging.DEBUG_FLAG, dest=mock.ANY, action=mock.ANY, help=mock.ANY) class TestCLILogging(unittest.TestCase): def setUp(self): self.mock_ctx = MockContext() self.cli_logging = CLILogging('clitest', cli_ctx=self.mock_ctx) def test_determine_log_level_default(self): argv = [] actual_level = self.cli_logging._determine_log_level(argv) # pylint: disable=protected-access expected_level = 2 self.assertEqual(actual_level, expected_level) def test_determine_log_level_verbose(self): argv = ['--verbose'] actual_level = self.cli_logging._determine_log_level(argv) # pylint: disable=protected-access expected_level = 3 self.assertEqual(actual_level, expected_level) def test_determine_log_level_debug(self): argv = ['--debug'] actual_level = self.cli_logging._determine_log_level(argv) # pylint: disable=protected-access expected_level = 4 self.assertEqual(actual_level, expected_level) def test_determine_log_level_v_v_v_default(self): argv = ['--verbose', '--debug'] actual_level = self.cli_logging._determine_log_level(argv) # pylint: disable=protected-access expected_level = 4 self.assertEqual(actual_level, expected_level) def test_determine_log_level_only_show_errors(self): argv = ['--only-show-errors'] actual_level = self.cli_logging._determine_log_level(argv) # pylint: disable=protected-access expected_level = 1 self.assertEqual(actual_level, expected_level) def test_determine_log_level_only_show_errors_config(self): argv = [] self.mock_ctx.only_show_errors = True actual_level = self.cli_logging._determine_log_level(argv) # pylint: disable=protected-access expected_level = 1 self.assertEqual(actual_level, expected_level) self.mock_ctx.only_show_errors = False def test_determine_log_level_all_flags(self): argv = ['--verbose', '--debug', '--only-show-errors'] with self.assertRaises(CLIError): self.cli_logging._determine_log_level(argv) # pylint: disable=protected-access def test_determine_log_level_other_args_verbose(self): argv = ['account', '--verbose'] actual_level = self.cli_logging._determine_log_level(argv) # pylint: disable=protected-access expected_level = 3 self.assertEqual(actual_level, expected_level) def test_determine_log_level_other_args_debug(self): argv = ['account', '--debug'] actual_level = self.cli_logging._determine_log_level(argv) # pylint: disable=protected-access expected_level = 4 self.assertEqual(actual_level, expected_level) def test_get_cli_logger(self): logger = get_logger() self.assertEqual(logger.name, CLI_LOGGER_NAME) def test_get_module_logger(self): module_logger = get_logger('a.module') self.assertEqual(module_logger.name, 'cli.a.module') def test_get_console_log_levels(self): # CRITICAL self.cli_logging.log_level = 0 levels = self.cli_logging._get_console_log_levels() expected = {'cli': 50, 'root': 50} self.assertEqual(levels, expected) # ERROR self.cli_logging.log_level = 1 levels = self.cli_logging._get_console_log_levels() expected = {'cli': 40, 'root': 50} self.assertEqual(levels, expected) # WARNING self.cli_logging.log_level = 2 levels = self.cli_logging._get_console_log_levels() expected = {'cli': 30, 'root': 50} self.assertEqual(levels, expected) # INFO self.cli_logging.log_level = 3 levels = self.cli_logging._get_console_log_levels() expected = {'cli': 20, 'root': 50} self.assertEqual(levels, expected) # DEBUG self.cli_logging.log_level = 4 levels = self.cli_logging._get_console_log_levels() expected = {'cli': 10, 'root': 10} self.assertEqual(levels, expected) def test_get_console_log_formats(self): # DEBUG level, color enabled self.cli_logging.log_level = 4 self.cli_logging.cli_ctx.enable_color = True formats = self.cli_logging._get_console_log_formats() expected = {'cli': '%(name)s: %(message)s', 'root': '%(name)s: %(message)s'} self.assertEqual(formats, expected) # DEBUG level, color disabled self.cli_logging.log_level = 4 self.cli_logging.cli_ctx.enable_color = False formats = self.cli_logging._get_console_log_formats() expected = {'cli': '%(levelname)s: %(name)s: %(message)s', 'root': '%(levelname)s: %(name)s: %(message)s'} self.assertEqual(formats, expected) # WARNING level, color enabled self.cli_logging.log_level = 2 self.cli_logging.cli_ctx.enable_color = True formats = self.cli_logging._get_console_log_formats() expected = {'cli': '%(message)s', 'root': '%(message)s'} self.assertEqual(formats, expected) # WARNING level, color disabled self.cli_logging.log_level = 2 self.cli_logging.cli_ctx.enable_color = False formats = self.cli_logging._get_console_log_formats() expected = {'cli': '%(levelname)s: %(message)s', 'root': '%(levelname)s: %(message)s'} self.assertEqual(formats, expected) class TestCustomStreamHandler(unittest.TestCase): expectation = { 'critical': '\x1b[41m', # Background Red 'error': '\x1b[91m', # Bright Foreground Red 'warning': '\x1b[33m', # Foreground Yellow 'info': '\x1b[32m', # Foreground Green 'debug': '\x1b[36m', # Foreground Cyan } def test_get_color_wrapper(self): for level, prefix in self.expectation.items(): message = _CustomStreamHandler.wrap_with_color(level, 'test') self.assertTrue(message.startswith(prefix)) self.assertTrue(message.endswith('\x1b[0m')) if __name__ == '__main__': unittest.main() knack-0.9.0/tests/test_output.py000066400000000000000000000260371414113720500167300ustar00rootroot00000000000000#!/usr/bin/env python # -*- coding: utf-8 -*- # -------------------------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- import unittest from unittest import mock from collections import OrderedDict from io import StringIO from knack.output import OutputProducer, format_json, format_json_color, format_yaml, format_yaml_color, \ format_table, format_tsv from knack.util import CommandResultItem, normalize_newlines from tests.util import MockContext class TestOutput(unittest.TestCase): def setUp(self): self.mock_ctx = MockContext() self.io = StringIO() def tearDown(self): self.io.close() def test_cli_ctx_type_error(self): with self.assertRaises(TypeError): OutputProducer(cli_ctx=object()) # JSON output tests def test_out_json_valid(self): """ The JSON output when the input is a dict should be the dict serialized to JSON """ output_producer = OutputProducer(cli_ctx=self.mock_ctx) output_producer.out(CommandResultItem({'active': True, 'id': '0b1f6472'}), formatter=format_json, out_file=self.io) self.assertEqual(normalize_newlines(self.io.getvalue()), normalize_newlines( """{ "active": true, "id": "0b1f6472" } """)) def test_out_json_from_ordered_dict(self): """ The JSON output when the input is OrderedDict should be serialized to JSON """ output_producer = OutputProducer(cli_ctx=self.mock_ctx) output_producer.out(CommandResultItem(OrderedDict({'active': True, 'id': '0b1f6472'})), formatter=format_json, out_file=self.io) self.assertEqual(normalize_newlines(self.io.getvalue()), normalize_newlines( """{ "active": true, "id": "0b1f6472" } """)) def test_out_json_byte(self): output_producer = OutputProducer(cli_ctx=self.mock_ctx) output_producer.out(CommandResultItem({'active': True, 'contents': b'0b1f6472'}), formatter=format_json, out_file=self.io) self.assertEqual(normalize_newlines(self.io.getvalue()), normalize_newlines( """{ "active": true, "contents": "0b1f6472" } """)) def test_out_json_byte_empty(self): output_producer = OutputProducer(cli_ctx=self.mock_ctx) output_producer.out(CommandResultItem({'active': True, 'contents': b''}), formatter=format_json, out_file=self.io) self.assertEqual(normalize_newlines(self.io.getvalue()), normalize_newlines( """{ "active": true, "contents": "" } """)) def test_out_json_non_ASCII(self): output_producer = OutputProducer(cli_ctx=self.mock_ctx) output_producer.out(CommandResultItem({'active': True, 'contents': '生活很糟糕'}), formatter=format_json, out_file=self.io) self.assertEqual(normalize_newlines(self.io.getvalue()), normalize_newlines( """{ "active": true, "contents": "生活很糟糕" } """)) # YAML output tests def test_out_yaml_valid(self): """ Test Dict serialized to YAML """ output_producer = OutputProducer(cli_ctx=self.mock_ctx) output_producer.out(CommandResultItem({'active': True, 'id': '0b1f6472'}), formatter=format_yaml, out_file=self.io) self.assertEqual(normalize_newlines(self.io.getvalue()), normalize_newlines( """active: true id: 0b1f6472 """)) def test_out_yaml_from_ordered_dict(self): """ Test OrderedDict serialized to YAML """ output_producer = OutputProducer(cli_ctx=self.mock_ctx) output_producer.out(CommandResultItem(OrderedDict({'active': True, 'id': '0b1f6472'})), formatter=format_yaml, out_file=self.io) self.assertEqual(normalize_newlines(self.io.getvalue()), normalize_newlines( """active: true id: 0b1f6472 """)) def test_out_yaml_byte(self): output_producer = OutputProducer(cli_ctx=self.mock_ctx) output_producer.out(CommandResultItem({'active': True, 'contents': b'0b1f6472'}), formatter=format_yaml, out_file=self.io) self.assertEqual(normalize_newlines(self.io.getvalue()), normalize_newlines( """active: true contents: !!binary | MGIxZjY0NzI= """)) def test_out_yaml_byte_empty(self): output_producer = OutputProducer(cli_ctx=self.mock_ctx) output_producer.out(CommandResultItem({'active': True, 'contents': b''}), formatter=format_yaml, out_file=self.io) self.assertEqual(normalize_newlines(self.io.getvalue()), normalize_newlines( """active: true contents: !!binary "" """)) def test_out_yaml_non_ASCII(self): output_producer = OutputProducer(cli_ctx=self.mock_ctx) output_producer.out(CommandResultItem({'active': True, 'contents': 'こんにちは'}), formatter=format_yaml, out_file=self.io) self.assertEqual(normalize_newlines(self.io.getvalue()), normalize_newlines( """active: true contents: こんにちは """)) # TABLE output tests def test_out_table(self): output_producer = OutputProducer(cli_ctx=self.mock_ctx) obj = OrderedDict() obj['active'] = True obj['val'] = '0b1f6472' obj['lun'] = 0 output_producer.out(CommandResultItem(obj), formatter=format_table, out_file=self.io) self.assertEqual(normalize_newlines(self.io.getvalue()), normalize_newlines( """Active Lun Val -------- ----- -------- True 0 0b1f6472 """)) def test_out_table_list_of_lists(self): output_producer = OutputProducer(cli_ctx=self.mock_ctx) obj = [['a', 'b'], ['c', 'd']] output_producer.out(CommandResultItem(obj), formatter=format_table, out_file=self.io) self.assertEqual(normalize_newlines(self.io.getvalue()), normalize_newlines( """Column1 Column2 --------- --------- a b c d """)) def test_out_table_complex_obj(self): output_producer = OutputProducer(cli_ctx=self.mock_ctx) obj = OrderedDict() obj['name'] = 'qwerty' obj['val'] = '0b1f6472qwerty' obj['sub'] = {'1'} result_item = CommandResultItem(obj) output_producer.out(result_item, formatter=format_table, out_file=self.io) self.assertEqual(normalize_newlines(self.io.getvalue()), normalize_newlines( """Name Val ------ -------------- qwerty 0b1f6472qwerty """)) def test_out_table_no_query_no_transformer_order(self): output_producer = OutputProducer(cli_ctx=self.mock_ctx) obj = {'name': 'qwerty', 'val': '0b1f6472qwerty', 'active': True, 'sub': '0b1f6472'} result_item = CommandResultItem(obj, table_transformer=None, is_query_active=False) output_producer.out(result_item, formatter=format_table, out_file=self.io) # Should be alphabetical order as no table transformer and query is not active. self.assertEqual(normalize_newlines(self.io.getvalue()), normalize_newlines( """Active Name Sub Val -------- ------ -------- -------------- True qwerty 0b1f6472 0b1f6472qwerty """)) def test_out_table_no_query_yes_transformer_order(self): output_producer = OutputProducer(cli_ctx=self.mock_ctx) obj = {'name': 'qwerty', 'val': '0b1f6472qwerty', 'active': True, 'sub': '0b1f6472'} def transformer(r): return OrderedDict([('Name', r['name']), ('Val', r['val']), ('Active', r['active']), ('Sub', r['sub'])]) result_item = CommandResultItem(obj, table_transformer=transformer, is_query_active=False) output_producer.out(result_item, formatter=format_table, out_file=self.io) # Should be table transformer order self.assertEqual(normalize_newlines(self.io.getvalue()), normalize_newlines( """Name Val Active Sub ------ -------------- -------- -------- qwerty 0b1f6472qwerty True 0b1f6472 """)) def test_out_table_no_query_yes_jmespath_table_transformer(self): output_producer = OutputProducer(cli_ctx=self.mock_ctx) obj = {'name': 'qwerty', 'val': '0b1f6472qwerty', 'active': True, 'sub': '0b1f6472'} result_item = CommandResultItem(obj, table_transformer='{Name:name, Val:val, Active:active}', is_query_active=False) output_producer.out(result_item, formatter=format_table, out_file=self.io) # Should be table transformer order self.assertEqual(normalize_newlines(self.io.getvalue()), normalize_newlines( """Name Val Active ------ -------------- -------- qwerty 0b1f6472qwerty True """)) def test_out_table_with_number(self): output_producer = OutputProducer(cli_ctx=self.mock_ctx) obj = OrderedDict() obj['Sku'] = '6.10' output_producer.out(CommandResultItem(obj), formatter=format_table, out_file=self.io) self.assertEqual(normalize_newlines(self.io.getvalue()), normalize_newlines( """Sku ----- 6.10 """)) # TSV output tests def test_output_format_dict(self): obj = {} obj['A'] = 1 obj['B'] = 2 result = format_tsv(CommandResultItem(obj)) self.assertEqual(result, '1\t2\n') def test_output_format_dict_sort(self): obj = {} obj['B'] = 1 obj['A'] = 2 result = format_tsv(CommandResultItem(obj)) self.assertEqual(result, '2\t1\n') def test_output_format_ordereddict_not_sorted(self): obj = OrderedDict() obj['B'] = 1 obj['A'] = 2 result = format_tsv(CommandResultItem(obj)) self.assertEqual(result, '1\t2\n') def test_output_format_ordereddict_list_not_sorted(self): obj1 = OrderedDict() obj1['B'] = 1 obj1['A'] = 2 obj2 = OrderedDict() obj2['A'] = 3 obj2['B'] = 4 result = format_tsv(CommandResultItem([obj1, obj2])) self.assertEqual(result, '1\t2\n3\t4\n') def test_remove_color_no_tty(self): output_producer = OutputProducer(cli_ctx=self.mock_ctx) self.mock_ctx.enable_color = False formatter = output_producer.get_formatter('jsonc') self.assertEqual(formatter, format_json) formatter = output_producer.get_formatter('yamlc') self.assertEqual(formatter, format_yaml) self.mock_ctx.enable_color = True formatter = output_producer.get_formatter('jsonc') self.assertEqual(formatter, format_json_color) formatter = output_producer.get_formatter('yamlc') self.assertEqual(formatter, format_yaml_color) if __name__ == '__main__': unittest.main() knack-0.9.0/tests/test_parser.py000066400000000000000000000157531414113720500166670ustar00rootroot00000000000000# -------------------------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- import unittest from io import StringIO from knack.parser import CLICommandParser from knack.commands import CLICommand from knack.arguments import enum_choice_list from tests.util import MockContext, redirect_io class TestParser(unittest.TestCase): def setUp(self): self.io = StringIO() self.mock_ctx = MockContext() def tearDown(self): self.io.close() def test_register_simple_commands(self): def test_handler1(): pass def test_handler2(): pass command = CLICommand(self.mock_ctx, 'command the-name', test_handler1) command2 = CLICommand(self.mock_ctx, 'sub-command the-second-name', test_handler2) cmd_table = {'command the-name': command, 'sub-command the-second-name': command2} self.mock_ctx.commands_loader.command_table = cmd_table parser = CLICommandParser() parser.load_command_table(self.mock_ctx.commands_loader) args = parser.parse_args('command the-name'.split()) self.assertIs(args.func, command) args = parser.parse_args('sub-command the-second-name'.split()) self.assertIs(args.func, command2) CLICommandParser.error = VerifyError(self,) parser.parse_args('sub-command'.split()) self.assertTrue(CLICommandParser.error.called) def test_required_parameter(self): def test_handler(args): # pylint: disable=unused-argument pass command = CLICommand(self.mock_ctx, 'test command', test_handler) command.add_argument('req', '--req', required=True) cmd_table = {'test command': command} self.mock_ctx.commands_loader.command_table = cmd_table parser = CLICommandParser() parser.load_command_table(self.mock_ctx.commands_loader) args = parser.parse_args('test command --req yep'.split()) self.assertIs(args.func, command) CLICommandParser.error = VerifyError(self) parser.parse_args('test command'.split()) self.assertTrue(CLICommandParser.error.called) def test_nargs_parameter(self): def test_handler(): pass command = CLICommand(self.mock_ctx, 'test command', test_handler) command.add_argument('req', '--req', required=True, nargs=2) cmd_table = {'test command': command} self.mock_ctx.commands_loader.command_table = cmd_table parser = CLICommandParser() parser.load_command_table(self.mock_ctx.commands_loader) args = parser.parse_args('test command --req yep nope'.split()) self.assertIs(args.func, command) CLICommandParser.error = VerifyError(self) parser.parse_args('test command -req yep'.split()) self.assertTrue(CLICommandParser.error.called) def _enum_parser(self): from enum import Enum class TestEnum(Enum): # pylint: disable=too-few-public-methods opt1 = "ALL_CAPS" opt2 = "camelCase" opt3 = "snake_case" def test_handler(): pass command = CLICommand(self.mock_ctx, 'test command', test_handler) command.add_argument('opt', '--opt', required=True, **enum_choice_list(TestEnum)) cmd_table = {'test command': command} self.mock_ctx.commands_loader.command_table = cmd_table parser = CLICommandParser() parser.load_command_table(self.mock_ctx.commands_loader) return parser def test_case_insensitive_enum_choices(self): parser = self._enum_parser() args = parser.parse_args('test command --opt alL_cAps'.split()) self.assertEqual(args.opt, 'ALL_CAPS') args = parser.parse_args('test command --opt CAMELCASE'.split()) self.assertEqual(args.opt, 'camelCase') args = parser.parse_args('test command --opt sNake_CASE'.split()) self.assertEqual(args.opt, 'snake_case') @redirect_io def test_check_value_invalid_command(self): parser = self._enum_parser() with self.assertRaises(SystemExit) as cm: parser.parse_args('test command1'.split()) # 'command1' is invalid actual = self.io.getvalue() assert "is not in the" in actual and "command group" in actual @redirect_io def test_check_value_invalid_argument_value(self): parser = self._enum_parser() with self.assertRaises(SystemExit) as cm: parser.parse_args('test command --opt foo'.split()) # 'foo' is invalid actual = self.io.getvalue() assert "is not a valid value for" in actual def test_cli_ctx_type_error(self): with self.assertRaises(TypeError): CLICommandParser(cli_ctx=object()) def test_extra_nonargparse_parameters(self): """ Add argument that has non argparse parameters. 'mycustomarg' should be filtered out and load_command_table should complete successfully instead of throwing TypeError: __init__() got an unexpected keyword argument 'mycustomarg' """ def test_handler(): pass command = CLICommand(self.mock_ctx, 'test command', test_handler) command.add_argument('req', '--req', required=True, mycustomarg=True) cmd_table = {'test command': command} self.mock_ctx.commands_loader.command_table = cmd_table parser = CLICommandParser() parser.load_command_table(self.mock_ctx.commands_loader) def test_prefix_file_expansion(self): import json, os def test_handler(): pass def create_test_file(file, contents): with open(file, 'w') as f: f.write(contents) def remove_test_file(file): os.remove(file) json_test_data = json.dumps({'one': 1, 'two': 2, 'three': 3}) create_test_file('test.json', json_test_data) command = CLICommand(self.mock_ctx, 'test command', test_handler) command.add_argument('json_data', '--param') cmd_table = {'test command': command} self.mock_ctx.commands_loader.command_table = cmd_table parser = CLICommandParser() parser.load_command_table(self.mock_ctx.commands_loader) args = parser.parse_args('test command --param @test.json'.split()) self.assertEqual(json_test_data, args.json_data) remove_test_file('test.json') class VerifyError(object): # pylint: disable=too-few-public-methods def __init__(self, test, substr=None): self.test = test self.substr = substr self.called = False def __call__(self, message): if self.substr: self.test.assertGreaterEqual(message.find(self.substr), 0) self.called = True if __name__ == '__main__': unittest.main() knack-0.9.0/tests/test_preview.py000066400000000000000000000256151414113720500170520ustar00rootroot00000000000000# -------------------------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- import unittest from unittest import mock import sys import argparse from knack.arguments import ArgumentsContext from knack.commands import CLICommandsLoader, CommandGroup from tests.util import DummyCLI, redirect_io, assert_in_multi_line, disable_color def example_handler(arg1, arg2=None, arg3=None): """ Short summary here. Long summary here. Still long summary. """ pass def example_arg_handler(arg1, opt1, arg2=None, opt2=None, arg3=None, opt3=None, arg4=None, opt4=None, arg5=None, opt5=None): pass class TestCommandPreview(unittest.TestCase): def setUp(self): from knack.help_files import helps class PreviewTestCommandLoader(CLICommandsLoader): def load_command_table(self, args): super().load_command_table(args) with CommandGroup(self, '', '{}#{{}}'.format(__name__)) as g: g.command('cmd1', 'example_handler', is_preview=True) with CommandGroup(self, 'grp1', '{}#{{}}'.format(__name__), is_preview=True) as g: g.command('cmd1', 'example_handler') return self.command_table def load_arguments(self, command): with ArgumentsContext(self, '') as c: c.argument('arg1', options_list=['--arg', '-a'], required=False, type=int, choices=[1, 2, 3]) c.argument('arg2', options_list=['-b'], required=True, choices=['a', 'b', 'c']) super().load_arguments(command) helps['grp1'] = """ type: group short-summary: A group. """ self.cli_ctx = DummyCLI(commands_loader_cls=PreviewTestCommandLoader) @redirect_io def test_preview_command_group_help(self): """ Ensure preview commands appear correctly in group help view. """ with self.assertRaises(SystemExit): self.cli_ctx.invoke('-h'.split()) actual = self.io.getvalue() expected = """ Group {} Subgroups: grp1 [Preview] : A group. Commands: cmd1 [Preview] : Short summary here. """.format(self.cli_ctx.name) assert_in_multi_line(expected, actual) @redirect_io def test_preview_command_plain_execute(self): """ Ensure general warning displayed when running preview command. """ self.cli_ctx.invoke('cmd1 -b b'.split()) actual = self.io.getvalue() expected = "This command is in preview. It may be changed/removed in a future release." self.assertIn(expected, actual) @redirect_io def test_preview_command_plain_execute_only_show_error(self): """ Ensure warning is suppressed when running preview command. """ # Directly use --only-show-errors self.cli_ctx.invoke('cmd1 -b b --only-show-errors'.split()) actual = self.io.getvalue() self.assertNotIn("preview", actual) # Apply --only-show-errors with config self.cli_ctx.only_show_errors = True self.cli_ctx.config.set_value('core', 'only_show_errors', 'True') self.cli_ctx.invoke('cmd1 -b b'.split()) actual = self.io.getvalue() self.assertNotIn("preview", actual) self.cli_ctx.config.set_value('core', 'only_show_errors', '') self.cli_ctx.only_show_errors = False @redirect_io @disable_color def test_preview_command_plain_execute_no_color(self): """ Ensure warning is displayed without color. """ self.cli_ctx.invoke('cmd1 -b b'.split()) actual = self.io.getvalue() self.assertIn("WARNING: This command is in preview. It may be changed/removed in a future release.", actual) @redirect_io def test_preview_command_implicitly_execute(self): """ Ensure general warning displayed when running command from a preview parent group. """ self.cli_ctx.invoke('grp1 cmd1 -b b'.split()) actual = self.io.getvalue() expected = "Command group 'grp1' is in preview. It may be changed/removed in a future release." self.assertIn(expected, actual) @redirect_io @disable_color def test_preview_command_implicitly_no_color(self): """ Ensure warning is displayed without color. """ self.cli_ctx.invoke('grp1 cmd1 -b b'.split()) actual = self.io.getvalue() expected = "WARNING: Command group 'grp1' is in preview. It may be changed/removed in a future release." self.assertIn(expected, actual) class TestCommandGroupPreview(unittest.TestCase): def setUp(self): from knack.help_files import helps class PreviewTestCommandLoader(CLICommandsLoader): def load_command_table(self, args): super().load_command_table(args) with CommandGroup(self, 'group1', '{}#{{}}'.format(__name__), is_preview=True) as g: g.command('cmd1', 'example_handler') return self.command_table def load_arguments(self, command): with ArgumentsContext(self, '') as c: c.argument('arg1', options_list=['--arg', '-a'], required=False, type=int, choices=[1, 2, 3]) c.argument('arg2', options_list=['-b'], required=True, choices=['a', 'b', 'c']) super().load_arguments(command) helps['group1'] = """ type: group short-summary: A group. """ self.cli_ctx = DummyCLI(commands_loader_cls=PreviewTestCommandLoader) @redirect_io def test_preview_command_group_help_plain(self): """ Ensure help warnings appear for preview command group help. """ with self.assertRaises(SystemExit): self.cli_ctx.invoke('group1 -h'.split()) actual = self.io.getvalue() expected = """ Group cli group1 : A group. This command group is in preview. It may be changed/removed in a future release. Commands: cmd1 : Short summary here. """.format(self.cli_ctx.name) assert_in_multi_line(expected, actual) @redirect_io @disable_color def test_preview_command_group_help_plain_no_color(self): """ Ensure warning is displayed without color. """ with self.assertRaises(SystemExit): self.cli_ctx.invoke('group1 -h'.split()) actual = self.io.getvalue() expected = """ Group cli group1 : A group. WARNING: This command group is in preview. It may be changed/removed in a future release. Commands: cmd1 : Short summary here. """.format(self.cli_ctx.name) self.assertEqual(expected, actual) @redirect_io def test_preview_command_implicitly(self): """ Ensure help warning displayed for command in preview because of a preview parent group. """ with self.assertRaises(SystemExit): self.cli_ctx.invoke('group1 cmd1 -h'.split()) actual = self.io.getvalue() expected = """ Command {} group1 cmd1 : Short summary here. Long summary here. Still long summary. Command group 'group1' is in preview. It may be changed/removed in a future release. """.format(self.cli_ctx.name) assert_in_multi_line(expected, actual) class TestArgumentPreview(unittest.TestCase): def setUp(self): from knack.help_files import helps class LoggerAction(argparse.Action): def __call__(self, parser, namespace, values, option_string=None): print("Side-effect from some original action!", file=sys.stderr) class PreviewTestCommandLoader(CLICommandsLoader): def load_command_table(self, args): super().load_command_table(args) with CommandGroup(self, '', '{}#{{}}'.format(__name__)) as g: g.command('arg-test', 'example_arg_handler') return self.command_table def load_arguments(self, command): with ArgumentsContext(self, 'arg-test') as c: c.argument('arg1', help='Arg1', is_preview=True, action=LoggerAction) super().load_arguments(command) helps['grp1'] = """ type: group short-summary: A group. """ self.cli_ctx = DummyCLI(commands_loader_cls=PreviewTestCommandLoader) @redirect_io def test_preview_arguments_command_help(self): """ Ensure preview arguments appear correctly in command help view. """ with self.assertRaises(SystemExit): self.cli_ctx.invoke('arg-test -h'.split()) actual = self.io.getvalue() expected = """ Arguments --arg1 [Preview] [Required] : Arg1. Argument '--arg1' is in preview. It may be changed/removed in a future release. """.format(self.cli_ctx.name) assert_in_multi_line(expected, actual) @redirect_io @disable_color def test_preview_arguments_command_help_no_color(self): """ Ensure warning is displayed without color. """ with self.assertRaises(SystemExit): self.cli_ctx.invoke('arg-test -h'.split()) actual = self.io.getvalue() expected = """ Arguments --arg1 [Preview] [Required] : Arg1. WARNING: Argument '--arg1' is in preview. It may be changed/removed in a future release. """.format(self.cli_ctx.name) self.assertIn(expected, actual) @redirect_io def test_preview_arguments_execute(self): """ Ensure deprecated arguments can be used. """ self.cli_ctx.invoke('arg-test --arg1 foo --opt1 bar'.split()) actual = self.io.getvalue() preview_expected = "Argument '--arg1' is in preview. It may be changed/removed in a future release." self.assertIn(preview_expected, actual) action_expected = "Side-effect from some original action!" self.assertIn(action_expected, actual) @redirect_io @disable_color def test_preview_arguments_execute_no_color(self): """ Ensure warning is displayed without color. """ self.cli_ctx.invoke('arg-test --arg1 foo --opt1 bar'.split()) actual = self.io.getvalue() preview_expected = "WARNING: Argument '--arg1' is in preview. It may be changed/removed in a future release." self.assertIn(preview_expected, actual) action_expected = "Side-effect from some original action!" self.assertIn(action_expected, actual) @redirect_io def test_preview_arguments_execute_only_show_error(self): """ Ensure warning is suppressed when using preview arguments. """ self.cli_ctx.invoke('arg-test --arg1 foo --opt1 bar --only-show-errors'.split()) actual = self.io.getvalue() self.assertNotIn("preview", actual) action_expected = "Side-effect from some original action!" self.assertIn(action_expected, actual) if __name__ == '__main__': unittest.main() knack-0.9.0/tests/test_prompting.py000066400000000000000000000373471414113720500174150ustar00rootroot00000000000000# -------------------------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- import sys import unittest from unittest import mock from io import StringIO from knack.prompting import (verify_is_a_tty, NoTTYException, _INVALID_PASSWORD_MSG, prompt, prompt_int, prompt_pass, prompt_y_n, prompt_t_f, prompt_choice_list) @unittest.skipIf(sys.version_info[0] == 2, "Can't modify isatty in Python 2 as it's read-only") class TestPrompting(unittest.TestCase): @mock.patch('sys.stdin.isatty', return_value=True) def test_tty_no_exception(self, _): verify_is_a_tty() @mock.patch('sys.stdin.isatty', return_value=False) def test_no_tty_should_raise_exception(self, _): with self.assertRaises(NoTTYException): verify_is_a_tty() @mock.patch('sys.stdin.isatty', return_value=True) def test_prompt_msg(self, _): expected_result = 'This is my response.' with mock.patch('knack.prompting._input', return_value=expected_result): actual_result = prompt('Please enter some text: ') self.assertEqual(expected_result, actual_result) @mock.patch('sys.stdin.isatty', return_value=True) def test_prompt_msg_empty_response(self, _): expected_result = '' with mock.patch('knack.prompting._input', return_value=expected_result): actual_result = prompt('Please enter some text: ') self.assertEqual(expected_result, actual_result) @mock.patch('sys.stdin.isatty', return_value=True) def test_prompt_msg_question_no_help_string(self, _): expected_result = '?' with mock.patch('knack.prompting._input', return_value='?'): actual_result = prompt('Please enter some text: ') self.assertEqual(expected_result, actual_result) @mock.patch('sys.stdin.isatty', return_value=True) def test_prompt_msg_question_with_help_string(self, _): expected_result = 'My response' with mock.patch('knack.prompting._input', side_effect=['?', expected_result]): with mock.patch('sys.stdout', new_callable=StringIO) as mock_stdout: actual_result = prompt('Please enter some text: ', help_string='Anything you want!') self.assertEqual(expected_result, actual_result) self.assertIn('Anything you want!', mock_stdout.getvalue()) @mock.patch('sys.stdin.isatty', return_value=True) def test_prompt_int(self, _): my_response = '42' with mock.patch('knack.prompting._input', return_value=my_response): actual_result = prompt_int('Please enter a number: ') self.assertEqual(int(my_response), actual_result) @mock.patch('sys.stdin.isatty', return_value=True) def test_prompt_int_empty_response(self, _): my_response = '' with mock.patch('logging.Logger.warning') as mock_log_warn: with self.assertRaises(StopIteration): with mock.patch('knack.prompting._input', side_effect=[my_response]): prompt_int('Please enter some text: ') mock_log_warn.assert_called_once_with('%s is not a valid number', my_response) @mock.patch('sys.stdin.isatty', return_value=True) def test_prompt_int_nan(self, _): my_response = 'This is clearly not a number.' with mock.patch('logging.Logger.warning') as mock_log_warn: with self.assertRaises(StopIteration): with mock.patch('knack.prompting._input', side_effect=[my_response]): prompt_int('Please enter some text: ') mock_log_warn.assert_called_once_with('%s is not a valid number', my_response) @mock.patch('sys.stdin.isatty', return_value=True) def test_prompt_int_question_no_help_string(self, _): my_response = '?' with mock.patch('logging.Logger.warning') as mock_log_warn: with self.assertRaises(StopIteration): with mock.patch('knack.prompting._input', side_effect=['?']): prompt_int('Please enter a number: ') mock_log_warn.assert_called_once_with('%s is not a valid number', my_response) @mock.patch('sys.stdin.isatty', return_value=True) def test_prompt_int_question_with_help_string(self, _): my_response = '42' with mock.patch('knack.prompting._input', side_effect=['?', my_response]): with mock.patch('sys.stdout', new_callable=StringIO) as mock_stdout: actual_result = prompt_int('Please enter a number: ', help_string='Anything you want!') self.assertEqual(int(my_response), actual_result) self.assertIn('Anything you want!', mock_stdout.getvalue()) @mock.patch('sys.stdin.isatty', return_value=True) def test_prompt_pass(self, _): my_password = '7ndBkS3zKQazD5N3zzstubZq' with mock.patch('getpass.getpass', return_value=my_password): actual_result = prompt_pass() self.assertEqual(my_password, actual_result) @mock.patch('sys.stdin.isatty', return_value=True) def test_prompt_pass_empty_response(self, _): my_password = '' with mock.patch('getpass.getpass', return_value=my_password): actual_result = prompt_pass() self.assertEqual(my_password, actual_result) @mock.patch('sys.stdin.isatty', return_value=True) def test_prompt_pass_custom_msg(self, _): my_password = '7ndBkS3zKQazD5N3zzstubZq' with mock.patch('getpass.getpass', return_value=my_password): actual_result = prompt_pass(msg='A Custom password message: ') self.assertEqual(my_password, actual_result) @mock.patch('sys.stdin.isatty', return_value=True) def test_prompt_pass_question_no_help_string(self, _): expected_result = '?' with mock.patch('getpass.getpass', return_value='?'): actual_result = prompt_pass() self.assertEqual(expected_result, actual_result) @mock.patch('sys.stdin.isatty', return_value=True) def test_prompt_pass_question_with_help_string(self, _): my_password = '7ndBkS3zKQazD5N3zzstubZq' with mock.patch('getpass.getpass', side_effect=['?', my_password]): with mock.patch('sys.stdout', new_callable=StringIO) as mock_stdout: actual_result = prompt_pass(help_string='Anything you want!') self.assertEqual(my_password, actual_result) self.assertIn('Anything you want!', mock_stdout.getvalue()) @mock.patch('sys.stdin.isatty', return_value=True) def test_prompt_pass_confirm_valid(self, _): my_password = '7ndBkS3zKQazD5N3zzstubZq' with mock.patch('getpass.getpass', side_effect=[my_password, my_password]): actual_result = prompt_pass(confirm=True) self.assertEqual(my_password, actual_result) @mock.patch('sys.stdin.isatty', return_value=True) def test_prompt_pass_confirm_invalid(self, _): my_password1 = '7ndBkS3zKQazD5N3zzstubZq' my_password2 = 'LTQ9haNMCSGp8p2uQHw2K9xf' with mock.patch('logging.Logger.warning') as mock_log_warn: with self.assertRaises(StopIteration): with mock.patch('getpass.getpass', side_effect=[my_password1, my_password2]): prompt_pass(confirm=True) mock_log_warn.assert_called_once_with(_INVALID_PASSWORD_MSG) @mock.patch('sys.stdin.isatty', return_value=True) def test_prompt_pass_confirm_invalid_then_valid(self, _): my_password1 = '7ndBkS3zKQazD5N3zzstubZq' my_password2 = 'LTQ9haNMCSGp8p2uQHw2K9xf' with mock.patch('getpass.getpass', side_effect=[my_password1, my_password2, my_password2, my_password2]): with mock.patch('logging.Logger.warning') as mock_log_warn: actual_result = prompt_pass(confirm=True) mock_log_warn.assert_called_once_with(_INVALID_PASSWORD_MSG) self.assertEqual(my_password2, actual_result) @mock.patch('sys.stdin.isatty', return_value=True) def test_prompt_y_n_yes(self, _): my_response = 'y' with mock.patch('knack.prompting._input', return_value=my_response): actual_result = prompt_y_n('Do you accept?') self.assertTrue(actual_result) @mock.patch('sys.stdin.isatty', return_value=True) def test_prompt_y_n_no(self, _): my_response = 'n' with mock.patch('knack.prompting._input', return_value=my_response): actual_result = prompt_y_n('Do you accept?') self.assertFalse(actual_result) @mock.patch('sys.stdin.isatty', return_value=True) def test_prompt_y_n_yes_caps(self, _): my_response = 'Y' with mock.patch('knack.prompting._input', return_value=my_response): actual_result = prompt_y_n('Do you accept?') self.assertTrue(actual_result) @mock.patch('sys.stdin.isatty', return_value=True) def test_prompt_y_n_no_caps(self, _): my_response = 'N' with mock.patch('knack.prompting._input', return_value=my_response): actual_result = prompt_y_n('Do you accept?') self.assertFalse(actual_result) @mock.patch('sys.stdin.isatty', return_value=True) def test_prompt_y_n_empty_response(self, _): with self.assertRaises(StopIteration): with mock.patch('knack.prompting._input', side_effect=['']): prompt_y_n('Do you accept?') @mock.patch('sys.stdin.isatty', return_value=True) def test_prompt_y_n_question_no_help_string(self, _): with self.assertRaises(StopIteration): with mock.patch('knack.prompting._input', side_effect=['?']): prompt_y_n('Do you accept?') @mock.patch('sys.stdin.isatty', return_value=True) def test_prompt_y_n_question_with_help_string(self, _): with mock.patch('knack.prompting._input', side_effect=['?', 'y']): with mock.patch('sys.stdout', new_callable=StringIO) as mock_stdout: actual_result = prompt_y_n('Do you accept?', help_string='y to accept conditions; no otherwise') self.assertTrue(actual_result) self.assertIn('y to accept conditions; no otherwise', mock_stdout.getvalue()) @mock.patch('sys.stdin.isatty', return_value=True) def test_prompt_y_n_default(self, _): with mock.patch('knack.prompting._input', return_value=''): actual_result = prompt_y_n('Do you accept?', default='y') self.assertTrue(actual_result) # HERE @mock.patch('sys.stdin.isatty', return_value=True) def test_prompt_t_f_yes(self, _): my_response = 't' with mock.patch('knack.prompting._input', return_value=my_response): actual_result = prompt_t_f('Do you accept?') self.assertTrue(actual_result) @mock.patch('sys.stdin.isatty', return_value=True) def test_prompt_t_f_no(self, _): my_response = 'f' with mock.patch('knack.prompting._input', return_value=my_response): actual_result = prompt_t_f('Do you accept?') self.assertFalse(actual_result) @mock.patch('sys.stdin.isatty', return_value=True) def test_prompt_t_f_yes_caps(self, _): my_response = 'T' with mock.patch('knack.prompting._input', return_value=my_response): actual_result = prompt_t_f('Do you accept?') self.assertTrue(actual_result) @mock.patch('sys.stdin.isatty', return_value=True) def test_prompt_t_f_no_caps(self, _): my_response = 'F' with mock.patch('knack.prompting._input', return_value=my_response): actual_result = prompt_t_f('Do you accept?') self.assertFalse(actual_result) @mock.patch('sys.stdin.isatty', return_value=True) def test_prompt_t_f_empty_response(self, _): with self.assertRaises(StopIteration): with mock.patch('knack.prompting._input', side_effect=['']): prompt_t_f('Do you accept?') @mock.patch('sys.stdin.isatty', return_value=True) def test_prompt_t_f_question_no_help_string(self, _): with self.assertRaises(StopIteration): with mock.patch('knack.prompting._input', side_effect=['?']): prompt_t_f('Do you accept?') @mock.patch('sys.stdin.isatty', return_value=True) def test_prompt_t_f_question_with_help_string(self, _): with mock.patch('knack.prompting._input', side_effect=['?', 't']): with mock.patch('sys.stdout', new_callable=StringIO) as mock_stdout: actual_result = prompt_t_f('Do you accept?', help_string='t to accept conditions; no otherwise') self.assertTrue(actual_result) self.assertIn('t to accept conditions; no otherwise', mock_stdout.getvalue()) @mock.patch('sys.stdin.isatty', return_value=True) def test_prompt_t_f_default(self, _): with mock.patch('knack.prompting._input', return_value=''): actual_result = prompt_t_f('Do you accept?', default='t') self.assertTrue(actual_result) @mock.patch('sys.stdin.isatty', return_value=True) def test_prompt_choice_list(self, _): a_list = ['red', 'blue', 'yellow', 'green'] with mock.patch('knack.prompting._input', return_value='3'): actual_result = prompt_choice_list('What is your favourite color?', a_list) self.assertEqual(2, actual_result) @mock.patch('sys.stdin.isatty', return_value=True) def test_prompt_choice_list_with_name_desc(self, _): a_list = [{'name': 'red', 'desc': ' A desc.'}, {'name': 'blue', 'desc': ' A desc.'}, {'name': 'yellow', 'desc': ' A desc.'}, {'name': 'green', 'desc': ' A desc.'}] with mock.patch('knack.prompting._input', return_value='2'): actual_result = prompt_choice_list('What is your favourite color?', a_list) self.assertEqual(1, actual_result) @mock.patch('sys.stdin.isatty', return_value=True) def test_prompt_choice_list_invalid_choice(self, _): a_list = ['red', 'blue', 'yellow', 'green'] with mock.patch('logging.Logger.warning') as mock_log_warn: with self.assertRaises(StopIteration): with mock.patch('knack.prompting._input', side_effect=['5']): prompt_choice_list('What is your favourite color?', a_list) mock_log_warn.assert_called_once_with('Valid values are %s', mock.ANY) @mock.patch('sys.stdin.isatty', return_value=True) def test_prompt_choice_list_question_no_help_string(self, _): a_list = ['red', 'blue', 'yellow', 'green'] with mock.patch('logging.Logger.warning') as mock_log_warn: with self.assertRaises(StopIteration): with mock.patch('knack.prompting._input', side_effect=['?']): prompt_choice_list('What is your favourite color?', a_list) mock_log_warn.assert_called_once_with('Valid values are %s', mock.ANY) @mock.patch('sys.stdin.isatty', return_value=True) def test_prompt_choice_list_question_with_help_string(self, _): a_list = ['red', 'blue', 'yellow', 'green'] with mock.patch('knack.prompting._input', side_effect=['?', '1']): with mock.patch('sys.stdout', new_callable=StringIO) as mock_stdout: actual_result = prompt_choice_list('What is your favourite color?', a_list, help_string='Your real favourite.') self.assertEqual(0, actual_result) self.assertIn('Your real favourite.', mock_stdout.getvalue()) if __name__ == '__main__': unittest.main() knack-0.9.0/tests/test_query.py000066400000000000000000000060041414113720500165250ustar00rootroot00000000000000# -------------------------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- import unittest from unittest import mock from knack.events import EVENT_PARSER_GLOBAL_CREATE from knack.query import CLIQuery from tests.util import MockContext class TestQueryEventHandling(unittest.TestCase): def setUp(self): self.mock_ctx = MockContext() self.cli_query = CLIQuery(cli_ctx=self.mock_ctx) def test_cli_ctx_type_error(self): with self.assertRaises(TypeError): CLIQuery(cli_ctx=object()) def test_query_argument_registrations(self): parser_arg_group_mock = mock.MagicMock() self.mock_ctx.raise_event(EVENT_PARSER_GLOBAL_CREATE, arg_group=parser_arg_group_mock) parser_arg_group_mock.add_argument.assert_any_call('--query', metavar=mock.ANY, dest=mock.ANY, help=mock.ANY, type=mock.ANY) class TestQuery(unittest.TestCase): '''Tests for the values that can be passed to the --query parameter. These tests ensure that we are handling invalid queries correctly and raising appropriate errors that argparse can then handle. (We are not testing JMESPath itself here) ''' def test_query_valid_1(self): # pylint: disable=no-self-use query = 'length(@)' # Should not raise any exception as it is valid CLIQuery.jmespath_type(query) def test_query_valid_2(self): # pylint: disable=no-self-use query = "[?propertyX.propertyY.propertyZ=='AValue'].[col1,col2]" # Should not raise any exception as it is valid CLIQuery.jmespath_type(query) def test_query_empty(self): query = '' with self.assertRaises(ValueError): CLIQuery.jmespath_type(query) def test_query_unbalanced(self): query = 'length(@' with self.assertRaises(ValueError): CLIQuery.jmespath_type(query) def test_query_invalid_1(self): query = '[?asdf=asdf]' with self.assertRaises(ValueError): CLIQuery.jmespath_type(query) def test_query_invalid_2(self): query = '[?name=My Value]' with self.assertRaises(ValueError): CLIQuery.jmespath_type(query) def test_query_invalid_3(self): query = "[].location='westus'" with self.assertRaises(ValueError): CLIQuery.jmespath_type(query) def test_query_invalid_4(self): query = "length([?contains('id', 'Publishers'])" with self.assertRaises(ValueError): CLIQuery.jmespath_type(query) if __name__ == '__main__': unittest.main() knack-0.9.0/tests/test_util.py000066400000000000000000000071371414113720500163450ustar00rootroot00000000000000# -------------------------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- import unittest from collections import namedtuple from datetime import date, time, datetime from unittest import mock from knack.util import todict, to_snake_case, is_modern_terminal class TestUtils(unittest.TestCase): def test_application_todict_none(self): the_input = None actual = todict(the_input) expected = None self.assertEqual(actual, expected) def test_application_todict_dict_empty(self): the_input = {} actual = todict(the_input) expected = {} self.assertEqual(actual, expected) def test_application_todict_dict(self): the_input = {'a': 'b'} actual = todict(the_input) expected = {'a': 'b'} self.assertEqual(actual, expected) def test_application_todict_list(self): the_input = [{'a': 'b'}] actual = todict(the_input) expected = [{'a': 'b'}] self.assertEqual(actual, expected) def test_application_todict_obj(self): MyObject = namedtuple('MyObject', 'a b') the_input = MyObject('x', 'y') actual = todict(the_input) expected = {'a': 'x', 'b': 'y'} self.assertEqual(actual, expected) def test_application_todict_dict_with_obj(self): MyObject = namedtuple('MyObject', 'a b') mo = MyObject('x', 'y') the_input = {'a': mo} actual = todict(the_input) expected = {'a': {'a': 'x', 'b': 'y'}} self.assertEqual(actual, expected) def test_application_todict_dict_with_date(self): the_input = date(2017, 10, 13) actual = todict(the_input) expected = the_input.isoformat() self.assertEqual(actual, expected) def test_application_todict_dict_with_datetime(self): the_input = datetime(2017, 10, 13, 1, 23, 45) actual = todict(the_input) expected = the_input.isoformat() self.assertEqual(actual, expected) def test_application_todict_dict_with_time(self): the_input = time(1, 23, 45) actual = todict(the_input) expected = the_input.isoformat() self.assertEqual(actual, expected) def test_to_snake_case_from_camel(self): the_input = 'thisIsCamelCase' expected = 'this_is_camel_case' actual = to_snake_case(the_input) self.assertEqual(expected, actual) def test_to_snake_case_empty(self): the_input = '' expected = '' actual = to_snake_case(the_input) self.assertEqual(expected, actual) def test_to_snake_case_already_snake(self): the_input = 'this_is_snake_cased' expected = 'this_is_snake_cased' actual = to_snake_case(the_input) self.assertEqual(expected, actual) def test_is_modern_terminal(self): with mock.patch.dict("os.environ", clear=True): self.assertEqual(is_modern_terminal(), False) with mock.patch.dict("os.environ", TERM_PROGRAM='vscode'): self.assertEqual(is_modern_terminal(), True) with mock.patch.dict("os.environ", PYCHARM_HOSTED='1'): self.assertEqual(is_modern_terminal(), True) with mock.patch.dict("os.environ", WT_SESSION='c25cb945-246a-49e5-b37a-1e4b6671b916'): self.assertEqual(is_modern_terminal(), True) if __name__ == '__main__': unittest.main() knack-0.9.0/tests/util.py000066400000000000000000000053351414113720500153040ustar00rootroot00000000000000# -------------------------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- from unittest import mock import logging import os import re import shutil import sys import tempfile from io import StringIO from knack.cli import CLI, CLICommandsLoader, CommandInvoker from knack.log import CLI_LOGGER_NAME TEMP_FOLDER_NAME = "knack_temp" def redirect_io(func): original_stderr = sys.stderr original_stdout = sys.stdout def wrapper(self): # Ensure a clean startup - no log handlers root_logger = logging.getLogger() cli_logger = logging.getLogger(CLI_LOGGER_NAME) root_logger.handlers.clear() cli_logger.handlers.clear() sys.stdout = sys.stderr = self.io = StringIO() func(self) self.io.close() sys.stdout = original_stdout sys.stderr = original_stderr # Remove the handlers added by CLI, so that the next invoke call init them again with the new stderr # Otherwise, the handlers will write to a closed StringIO from a preview test root_logger.handlers.clear() cli_logger.handlers.clear() return wrapper def disable_color(func): def wrapper(self): self.cli_ctx.enable_color = False func(self) self.cli_ctx.enable_color = True return wrapper def _remove_control_sequence(string): return re.sub(r'\x1b[^m]+m', '', string) def _remove_whitespace(string): return re.sub(r'\s', '', string) def assert_in_multi_line(sub_string, string): # assert sub_string is in string, with all whitespaces, line breaks and control sequences ignored assert _remove_whitespace(sub_string) in _remove_control_sequence(_remove_whitespace(string)) class MockContext(CLI): def __init__(self): super().__init__(config_dir=new_temp_folder()) loader = CLICommandsLoader(cli_ctx=self) invocation = mock.MagicMock(spec=CommandInvoker) invocation.data = {} setattr(self, 'commands_loader', loader) setattr(self, 'invocation', invocation) class DummyCLI(CLI): def get_cli_version(self): return '0.1.0' def __init__(self, **kwargs): kwargs['config_dir'] = new_temp_folder() super().__init__(**kwargs) # Force to enable color self.enable_color = True def new_temp_folder(): temp_dir = os.path.join(tempfile.gettempdir(), TEMP_FOLDER_NAME) if os.path.exists(temp_dir): shutil.rmtree(temp_dir) os.mkdir(temp_dir) return temp_dir knack-0.9.0/tox.ini000066400000000000000000000004231414113720500141170ustar00rootroot00000000000000[tox] envlist = py36,py37,py38,py39,py310 [testenv] deps = -rrequirements.txt commands= python ./scripts/license_verify.py flake8 --statistics --append-config=.flake8 knack pylint knack --rcfile=.pylintrc -r n -d I0013 pytest python ./examples/test_exapp