diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..8be46672 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,31 @@ +# Basic dependabot.yml file with minimum configuration for two package managers + +version: 2 +updates: + # Enable version updates for python + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "monthly" + labels: ["dependabot"] + pull-request-branch-name: + separator: "-" + open-pull-requests-limit: 5 + reviewers: + - "dbieber" + + # Enable version updates for GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" + groups: + gh-actions: + patterns: + - "*" # Check all dependencies + labels: ["dependabot"] + pull-request-branch-name: + separator: "-" + open-pull-requests-limit: 5 + reviewers: + - "dbieber" diff --git a/.github/scripts/build.sh b/.github/scripts/build.sh new file mode 100755 index 00000000..d9207dfe --- /dev/null +++ b/.github/scripts/build.sh @@ -0,0 +1,31 @@ +# Copyright (C) 2018 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +#!/usr/bin/env bash + +# Exit when any command fails. +set -e + +PYTHON_VERSION=${PYTHON_VERSION:-3.7} + +pip install -e .[test] +python -m pytest # Run the tests without IPython. +pip install ipython +python -m pytest # Now run the tests with IPython. +pylint fire --ignore=test_components_py3.py,parser_fuzz_test.py,console +if [[ ${PYTHON_VERSION} == 3.12 ]]; then + # Run type-checking + pip install ty + python -m ty check --python $(which python) --exclude fire/test_components_py3.py --exclude fire/console/ --exclude fire/formatting_windows.py +fi diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..6b9d1eae --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,38 @@ +name: Python Fire + +on: + push: + branches: ["master"] + pull_request: + branches: ["master"] + +defaults: + run: + shell: bash + +jobs: + build: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: ["macos-latest", "ubuntu-latest"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14.0-rc.2"] + include: + - {os: "ubuntu-22.04", python-version: "3.7"} + + steps: + # Checkout the repo. + - name: Checkout Python Fire repository + uses: actions/checkout@v4 + + # Set up Python environment. + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + # Build Python Fire using the build.sh script. + - name: Run build script + run: ./.github/scripts/build.sh + env: + PYTHON_VERSION: ${{ matrix.python-version }} diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 44edde6e..00000000 --- a/.travis.yml +++ /dev/null @@ -1,34 +0,0 @@ -language: python -python: - - "2.7" - - "3.4" - - "3.5" - - "3.6" -# Workaround for testing Python 3.7: -# https://github.com/travis-ci/travis-ci/issues/9815 -matrix: - include: - - python: 3.7 - dist: xenial - sudo: yes -before_install: - - pip install --upgrade setuptools pip - - pip install --upgrade pylint pytest pytest-pylint pytest-runner -install: - - pip install termcolor - - pip install hypothesis python-Levenshtein - - python setup.py develop -script: - - python -m pytest # Run the tests without IPython. - - pip install ipython - - python -m pytest # Now run the tests with IPython. - - pylint fire --ignore=test_components_py3.py,parser_fuzz_test.py,console - - pip install pytype - # Run type-checking, excluding files that define or use py3 features in py2. - - if [[ $TRAVIS_PYTHON_VERSION == 2.7 ]]; then - pytype -x - fire/fire_test.py - fire/inspectutils_test.py - fire/test_components_py3.py; - else - pytype; fi diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0786fdf4..b5d67c96 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,6 +3,14 @@ We'd love to accept your patches and contributions to this project. There are just a few small guidelines you need to follow. +First, read these guidelines. +Before you begin making changes, state your intent to do so in an Issue. +Then, fork the project. Make changes in your copy of the repository. +Then open a pull request once your changes are ready. +If this is your first contribution, sign the Contributor License Agreement. +A discussion about your change will follow, and if accepted your contribution +will be incorporated into the Python Fire codebase. + ## Contributor License Agreement Contributions to this project must be accompanied by a Contributor License @@ -17,8 +25,35 @@ again. ## Code reviews -All submissions, including submissions by project members, require review. We -use GitHub pull requests for this purpose. Consult [GitHub Help] for more -information on using pull requests. +All submissions, including submissions by project members, require review. +For changes introduced by non-Googlers, we use GitHub pull requests for this +purpose. Consult [GitHub Help] for more information on using pull requests. [GitHub Help]: https://help.github.com/articles/about-pull-requests/ + +## Code style + +In general, Python Fire follows the guidelines in the +[Google Python Style Guide]. + +In addition, the project follows a convention of: +- Maximum line length: 80 characters +- Indentation: 2 spaces (4 for line continuation) +- PascalCase for function and method names. +- Single quotes around strings, three double quotes around docstrings. + +[Google Python Style Guide]: http://google.github.io/styleguide/pyguide.html + +## Testing + +Python Fire uses [GitHub Actions](https://github.com/google/python-fire/actions) to run tests on each pull request. You can run +these tests yourself as well. To do this, first install the test dependencies +listed in setup.py (e.g. pytest, mock, termcolor, and hypothesis). +Then run the tests by running `pytest` in the root directory of the repository. + +## Linting + +Please run lint on your pull requests to make accepting the requests easier. +To do this, run `pylint fire` in the root directory of the repository. +Note that even if lint is passing, additional style changes to your submission +may be made during merging. diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 1aba38f6..00000000 --- a/MANIFEST.in +++ /dev/null @@ -1 +0,0 @@ -include LICENSE diff --git a/README.md b/README.md index 9aea0877..1482d56d 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,19 @@ # Python Fire [![PyPI](https://img.shields.io/pypi/pyversions/fire.svg?style=plastic)](https://github.com/google/python-fire) + _Python Fire is a library for automatically generating command line interfaces (CLIs) from absolutely any Python object._ -- Python Fire is a simple way to create a CLI in Python. [[1]](docs/benefits.md#simple-cli) -- Python Fire is a helpful tool for developing and debugging Python code. [[2]](docs/benefits.md#debugging) -- Python Fire helps with exploring existing code or turning other people's code -into a CLI. [[3]](docs/benefits.md#exploring) -- Python Fire makes transitioning between Bash and Python easier. [[4]](docs/benefits.md#bash) -- Python Fire makes using a Python REPL easier by setting up the REPL with the -modules and variables you'll need already imported and created. [[5]](docs/benefits.md#repl) - +- Python Fire is a simple way to create a CLI in Python. + [[1]](docs/benefits.md#simple-cli) +- Python Fire is a helpful tool for developing and debugging Python code. + [[2]](docs/benefits.md#debugging) +- Python Fire helps with exploring existing code or turning other people's + code into a CLI. [[3]](docs/benefits.md#exploring) +- Python Fire makes transitioning between Bash and Python easier. + [[4]](docs/benefits.md#bash) +- Python Fire makes using a Python REPL easier by setting up the REPL with the + modules and variables you'll need already imported and created. + [[5]](docs/benefits.md#repl) ## Installation @@ -20,13 +24,32 @@ To install Python Fire with conda, run: `conda install fire -c conda-forge` To install Python Fire from source, first clone the repository and then run: `python setup.py install` - ## Basic Usage You can call `Fire` on any Python object:
functions, classes, modules, objects, dictionaries, lists, tuples, etc. They all work! +Here's an example of calling Fire on a function. + +```python +import fire + +def hello(name="World"): + return "Hello %s!" % name + +if __name__ == '__main__': + fire.Fire(hello) +``` + +Then, from the command line, you can run: + +```bash +python hello.py # Hello World! +python hello.py --name=David # Hello David! +python hello.py --help # Shows usage information. +``` + Here's an example of calling Fire on a class. ```python @@ -54,17 +77,14 @@ about Fire's other features, see the [Using a Fire CLI page](docs/using-cli.md). For additional examples, see [The Python Fire Guide](docs/guide.md). - ## Why is it called Fire? When you call `Fire`, it fires off (executes) your command. - ## Where can I learn more? Please see [The Python Fire Guide](docs/guide.md). - ## Reference | Setup | Command | Notes @@ -77,16 +97,16 @@ Please see [The Python Fire Guide](docs/guide.md). | Call | `fire.Fire()` | Turns the current module into a Fire CLI. | Call | `fire.Fire(component)` | Turns `component` into a Fire CLI. -| Using a CLI | Command | Notes -| :------------- | :------------------------- | :--------- -| [Help](docs/using-cli.md#help-flag) | `command -- --help` | -| [REPL](docs/using-cli.md#interactive-flag) | `command -- --interactive` | Enters interactive mode. -| [Separator](docs/using-cli.md#separator-flag) | `command -- --separator=X` | This sets the separator to `X`. The default separator is `-`. -| [Completion](docs/using-cli.md#completion-flag) | `command -- --completion [shell]` | Generate a completion script for the CLI. -| [Trace](docs/using-cli.md#trace-flag) | `command -- --trace` | Gets a Fire trace for the command. -| [Verbose](docs/using-cli.md#verbose-flag) | `command -- --verbose` | +| Using a CLI | Command | Notes +| :---------------------------------------------- | :-------------------------------------- | :---- +| [Help](docs/using-cli.md#help-flag) | `command --help` or `command -- --help` | +| [REPL](docs/using-cli.md#interactive-flag) | `command -- --interactive` | Enters interactive mode. +| [Separator](docs/using-cli.md#separator-flag) | `command -- --separator=X` | Sets the separator to `X`. The default separator is `-`. +| [Completion](docs/using-cli.md#completion-flag) | `command -- --completion [shell]` | Generates a completion script for the CLI. +| [Trace](docs/using-cli.md#trace-flag) | `command -- --trace` | Gets a Fire trace for the command. +| [Verbose](docs/using-cli.md#verbose-flag) | `command -- --verbose` | -_Note that flags are separated from the Fire command by an isolated `--` arg._ +_Note that these flags are separated from the Fire command by an isolated `--`._ ## License diff --git a/docs/api.md b/docs/api.md index c3bb2ef6..aae92cd6 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,20 +1,46 @@ +## Python Fire Quick Reference + | Setup | Command | Notes -| :------ | :------------------ | :--------- -| install | `pip install fire` | +| ------- | ------------------- | ---------- +| install | `pip install fire` | Installs fire from pypi | Creating a CLI | Command | Notes -| :--------------| :--------------------- | :--------- +| ---------------| ---------------------- | ---------- | import | `import fire` | | Call | `fire.Fire()` | Turns the current module into a Fire CLI. | Call | `fire.Fire(component)` | Turns `component` into a Fire CLI. -| Using a CLI | Command | Notes -| :------------- | :------------------------- | :--------- -| [Help](using-cli.md#help-flag) | `command -- --help` | -| [REPL](using-cli.md#interactive-flag) | `command -- --interactive` | Enters interactive mode. -| [Separator](using-cli.md#separator-flag) | `command -- --separator=X` | This sets the separator to `X`. The default separator is `-`. -| [Completion](using-cli.md#completion-flag) | `command -- --completion [shell]` | Generate a completion script for the CLI. -| [Trace](using-cli.md#trace-flag) | `command -- --trace` | Gets a Fire trace for the command. -| [Verbose](using-cli.md#verbose-flag) | `command -- --verbose` | +| Using a CLI | Command | Notes | +| ------------------------------------------ | ----------------- | -------------- | +| [Help](using-cli.md#help-flag) | `command --help` | Show the help screen. | +| [REPL](using-cli.md#interactive-flag) | `command -- --interactive` | Enters interactive mode. | +| [Separator](using-cli.md#separator-flag) | `command -- --separator=X` | This sets the separator to `X`. The default separator is `-`. | +| [Completion](using-cli.md#completion-flag) | `command -- --completion [shell]` | Generate a completion script for the CLI. | +| [Trace](using-cli.md#trace-flag) | `command -- --trace` | Gets a Fire trace for the command. | +| [Verbose](using-cli.md#verbose-flag) | `command -- --verbose` | | + +_Note that flags are separated from the Fire command by an isolated `--` arg. +Help is an exception; the isolated `--` is optional for getting help._ + +## Arguments for Calling fire.Fire() + +| Argument | Usage | Notes | +| --------- | ------------------------- | ------------------------------------ | +| component | `fire.Fire(component)` | If omitted, defaults to a dict of all locals and globals. | +| command | `fire.Fire(command='hello --name=5')` | Either a string or a list of arguments. If a string is provided, it is split to determine the arguments. If a list or tuple is provided, they are the arguments. If `command` is omitted, then `sys.argv[1:]` (the arguments from the command line) are used by default. | +| name | `fire.Fire(name='tool')` | The name of the CLI, ideally the name users will enter to run the CLI. This name will be used in the CLI's help screens. If the argument is omitted, it will be inferred automatically.| +| serialize | `fire.Fire(serialize=custom_serializer)` | If omitted, simple types are serialized via their builtin str method, and any objects that define a custom `__str__` method are serialized with that. If specified, all objects are serialized to text via the provided method. | + +## Using a Fire CLI without modifying any code + +You can use Python Fire on a module without modifying the code of the module. +The syntax for this is: + +`python -m fire ` + +or + +`python -m fire ` -_Note that flags are separated from the Fire command by an isolated `--` arg._ +For example, `python -m fire calendar -h` will treat the built in `calendar` +module as a CLI and provide its help. diff --git a/docs/guide.md b/docs/guide.md index 7f610699..444a76ff 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -30,7 +30,7 @@ the program to the command line. import fire def hello(name): - return 'Hello {name}!'.format(name=name) + return f'Hello {name}!' if __name__ == '__main__': fire.Fire() @@ -52,7 +52,7 @@ command line. import fire def hello(name): - return 'Hello {name}!'.format(name=name) + return f'Hello {name}!' if __name__ == '__main__': fire.Fire(hello) @@ -76,7 +76,7 @@ We can alternatively write this program like this: import fire def hello(name): - return 'Hello {name}!'.format(name=name) + return f'Hello {name}!' def main(): fire.Fire(hello) @@ -93,12 +93,36 @@ then simply this: import fire def hello(name): - return 'Hello {name}!'.format(name=name) + return f'Hello {name}!' def main(): fire.Fire(hello) ``` +##### Version 4: Fire Without Code Changes + +If you have a file `example.py` that doesn't even import fire: + +```python +def hello(name): + return f'Hello {name}!' +``` + +Then you can use it with Fire like this: + +```bash +$ python -m fire example hello --name=World +Hello World! +``` + +You can also specify the filepath of example.py rather than its module path, +like so: + +```bash +$ python -m fire example.py hello --name=World +Hello World! +``` + ### Exposing Multiple Commands In the previous example, we exposed a single function to the command line. Now @@ -294,8 +318,9 @@ class Pipeline(object): self.digestion = DigestionStage() def run(self): - self.ingestion.run() - self.digestion.run() + ingestion_output = self.ingestion.run() + digestion_output = self.digestion.run() + return [ingestion_output, digestion_output] if __name__ == '__main__': fire.Fire(Pipeline) @@ -413,7 +438,7 @@ if __name__ == '__main__': Now we can draw stuff :). ```bash -$ python example.py move 3 3 on move 3 6 on move 6 3 on move 6 6 on move 7 4 on move 7 5 on __str__ +$ python example.py move 3 3 on move 3 6 on move 6 3 on move 6 6 on move 7 4 on move 7 5 on 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 @@ -428,6 +453,16 @@ $ python example.py move 3 3 on move 3 6 on move 6 3 on move 6 6 on move 7 4 on It's supposed to be a smiley face. +### Custom Serialization + +You'll notice in the BinaryCanvas example, the canvas with the smiley face was +printed to the screen. You can determine how a component will be serialized by +defining its `__str__` method. + +If a custom `__str__` method is present on the final component, the object is +serialized and printed. If there's no custom `__str__` method, then the help +screen for the object is shown instead. + ### Can we make an even simpler example than Hello World? Yes, this program is even simpler than our original Hello World example. @@ -554,6 +589,25 @@ default values that you don't want to specify. It is also important to remember to change the separator if you want to pass `-` as an argument. +##### Async Functions + +Fire supports calling async functions too. Here's a simple example. + +```python +import asyncio + +async def count_to_ten(): + for i in range(1, 11): + await asyncio.sleep(1) + print(i) + +if __name__ == '__main__': + fire.Fire(count_to_ten) +``` + +Whenever fire encounters a coroutine function, it runs it, blocking until it completes. + + ### Argument Parsing The types of the arguments are determined by their values, rather than by the @@ -585,7 +639,7 @@ $ python example.py [1,2] list $ python example.py True bool -$ python example.py {name: David} +$ python example.py {name:David} dict ``` @@ -675,8 +729,9 @@ You can add the help flag to any command to see help and usage information. Fire incorporates your docstrings into the help and usage information that it generates. Fire will try to provide help even if you omit the isolated `--` separating the flags from the Fire command, but may not always be able to, since -`help` is a valid argument name. Use this feature like this: -`python example.py -- --help`. +`help` is a valid argument name. Use this feature like this: `python +example.py -- --help` or `python example.py --help` (or even `python example.py +-h`). The complete set of flags available is shown below, in the reference section. @@ -706,7 +761,18 @@ The complete set of flags available is shown below, in the reference section. | [Trace](using-cli.md#trace-flag) | `command -- --trace` | Gets a Fire trace for the command. | [Verbose](using-cli.md#verbose-flag) | `command -- --verbose` | Include private members in the output. -_Note that flags are separated from the Fire command by an isolated `--` arg._ +_Note that flags are separated from the Fire command by an isolated `--` arg. +Help is an exception; the isolated `--` is optional for getting help._ + + +##### Arguments for Calling fire.Fire() + +| Argument | Usage | Notes | +| --------- | ------------------------- | ------------------------------------ | +| component | `fire.Fire(component)` | If omitted, defaults to a dict of all locals and globals. | +| command | `fire.Fire(command='hello --name=5')` | Either a string or a list of arguments. If a string is provided, it is split to determine the arguments. If a list or tuple is provided, they are the arguments. If `command` is omitted, then `sys.argv[1:]` (the arguments from the command line) are used by default. | +| name | `fire.Fire(name='tool')` | The name of the CLI, ideally the name users will enter to run the CLI. This name will be used in the CLI's help screens. If the argument is omitted, it will be inferred automatically.| +| serialize | `fire.Fire(serialize=custom_serializer)` | If omitted, simple types are serialized via their builtin str method, and any objects that define a custom `__str__` method are serialized with that. If specified, all objects are serialized to text via the provided method. | ### Disclaimer diff --git a/docs/index.md b/docs/index.md index f6503c0b..8dcc5db6 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,15 +1,19 @@ # Python Fire [![PyPI](https://img.shields.io/pypi/pyversions/fire.svg?style=plastic)](https://github.com/google/python-fire) + _Python Fire is a library for automatically generating command line interfaces (CLIs) from absolutely any Python object._ -- Python Fire is a simple way to create a CLI in Python. [[1]](benefits.md#simple-cli) -- Python Fire is a helpful tool for developing and debugging Python code. [[2]](benefits.md#debugging) -- Python Fire helps with exploring existing code or turning other people's code -into a CLI. [[3]](benefits.md#exploring) -- Python Fire makes transitioning between Bash and Python easier. [[4]](benefits.md#bash) -- Python Fire makes using a Python REPL easier by setting up the REPL with the -modules and variables you'll need already imported and created. [[5]](benefits.md#repl) - +- Python Fire is a simple way to create a CLI in Python. + [[1]](benefits.md#simple-cli) +- Python Fire is a helpful tool for developing and debugging Python code. + [[2]](benefits.md#debugging) +- Python Fire helps with exploring existing code or turning other people's + code into a CLI. [[3]](benefits.md#exploring) +- Python Fire makes transitioning between Bash and Python easier. + [[4]](benefits.md#bash) +- Python Fire makes using a Python REPL easier by setting up the REPL with the + modules and variables you'll need already imported and created. + [[5]](benefits.md#repl) ## Installation @@ -20,13 +24,32 @@ To install Python Fire with conda, run: `conda install fire -c conda-forge` To install Python Fire from source, first clone the repository and then run: `python setup.py install` - ## Basic Usage You can call `Fire` on any Python object:
functions, classes, modules, objects, dictionaries, lists, tuples, etc. They all work! +Here's an example of calling Fire on a function. + +```python +import fire + +def hello(name="World"): + return "Hello %s!" % name + +if __name__ == '__main__': + fire.Fire(hello) +``` + +Then, from the command line, you can run: + +```bash +python hello.py # Hello World! +python hello.py --name=David # Hello David! +python hello.py --help # Shows usage information. +``` + Here's an example of calling Fire on a class. ```python @@ -54,17 +77,14 @@ about Fire's other features, see the [Using a Fire CLI page](using-cli.md). For additional examples, see [The Python Fire Guide](guide.md). - ## Why is it called Fire? When you call `Fire`, it fires off (executes) your command. - ## Where can I learn more? Please see [The Python Fire Guide](guide.md). - ## Reference | Setup | Command | Notes @@ -77,16 +97,17 @@ Please see [The Python Fire Guide](guide.md). | Call | `fire.Fire()` | Turns the current module into a Fire CLI. | Call | `fire.Fire(component)` | Turns `component` into a Fire CLI. -| Using a CLI | Command | Notes -| :------------- | :------------------------- | :--------- -| [Help](using-cli.md#help-flag) | `command -- --help` | -| [REPL](using-cli.md#interactive-flag) | `command -- --interactive` | Enters interactive mode. -| [Separator](using-cli.md#separator-flag) | `command -- --separator=X` | This sets the separator to `X`. The default separator is `-`. -| [Completion](using-cli.md#completion-flag) | `command -- --completion [shell]` | Generate a completion script for the CLI. -| [Trace](using-cli.md#trace-flag) | `command -- --trace` | Gets a Fire trace for the command. -| [Verbose](using-cli.md#verbose-flag) | `command -- --verbose` | - -_Note that flags are separated from the Fire command by an isolated `--` arg._ +| Using a CLI | Command | Notes +| :---------------------------------------------- | :-------------------------------------- | :---- +| [Help](using-cli.md#help-flag) | `command --help` or `command -- --help` | +| [REPL](using-cli.md#interactive-flag) | `command -- --interactive` | Enters interactive mode. +| [Separator](using-cli.md#separator-flag) | `command -- --separator=X` | Sets the separator to `X`. The default separator is `-`. +| [Completion](using-cli.md#completion-flag) | `command -- --completion [shell]` | Generates a completion script for the CLI. +| [Trace](using-cli.md#trace-flag) | `command -- --trace` | Gets a Fire trace for the command. +| [Verbose](using-cli.md#verbose-flag) | `command -- --verbose` | + +_Note that flags are separated from the Fire command by an isolated `--` arg. +Help is an exception; the isolated `--` is optional for getting help._ ## License diff --git a/docs/installation.md b/docs/installation.md index 614243af..7e4cccb8 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -4,5 +4,5 @@ To install Python Fire with pip, run: `pip install fire` To install Python Fire with conda, run: `conda install fire -c conda-forge` -To install Python Fire from source, first clone the repository and then run: -`python setup.py install` +To install Python Fire from source, first clone the repository and then run +`python setup.py install`. To install from source for development, instead run `python setup.py develop`. diff --git a/docs/using-cli.md b/docs/using-cli.md index 236a8228..bdfcb7db 100644 --- a/docs/using-cli.md +++ b/docs/using-cli.md @@ -9,10 +9,13 @@ arguments. This command corresponds to the Python component you called the `Fire` function on. If you did not supply an object in the call to `Fire`, then the context in which `Fire` was called will be used as the Python component. -You can append `-- --help` to any command to see what Python component it +You can append `--help` or `-h` to a command to see what Python component it corresponds to, as well as the various ways in which you can extend the command. -Flags are always separated from the Fire command by an isolated `--` in order -to distinguish between flags and named arguments. + +Flags to Fire should be separated from the Fire command by an isolated `--` in +order to distinguish between flags and named arguments. So, for example, to +enter interactive mode append `-- -i` or `-- --interactive` to any command. To +use Fire in verbose mode, append `-- --verbose`. Given a Fire command that corresponds to a Python object, you can extend that command to access a member of that object, call it with arguments if it is a @@ -54,7 +57,7 @@ If your command corresponds to a list or tuple, you can extend your command by adding the index of an element of the component to your command as an argument. For example, `widget function-that-returns-list 2` will correspond to item 2 of -the result of function_that_returns_list. +the result of `function_that_returns_list`. ### Calling a function @@ -87,10 +90,12 @@ See also the section on [Changing the Separator](#separator-flag). ### Instantiating a class If your command corresponds to a class, you can extend your command by adding -the arguments of the class's \_\_init\_\_ function. Arguments must be specified +the arguments of the class's `__init__` function. Arguments must be specified by name, using the flags syntax. See the section on [calling a function](#calling-a-function) for more details. +Similarly, when passing arguments to a callable object (an object with a custom +`__call__` function), those arguments must be passed using flags syntax. ## Using Flags with Fire CLIs @@ -100,8 +105,8 @@ after the final standalone `--` argument. (If there is no `--` argument, then no arguments are used for flags.) For example, to set the alsologtostderr flag, you could run the command: -`widget bang --noise=boom -- --alsologtostderr`. The --noise argument is -consumed by Fire, but the --alsologtostderr argument is treated as a normal +`widget bang --noise=boom -- --alsologtostderr`. The `--noise` argument is +consumed by Fire, but the `--alsologtostderr` argument is treated as a normal Flag. All CLIs built with Python Fire share some flags, as described in the next @@ -132,13 +137,16 @@ will put you in an IPython REPL, with the variable `widget` already defined. You can then explore the Python object that `widget` corresponds to interactively using Python. +Note: if you want fire to start the IPython REPL instead of the regular Python one, +the `ipython` package needs to be installed in your environment. + ### `--completion`: Generating a completion script Call `widget -- --completion` to generate a completion script for the Fire CLI `widget`. To save the completion script to your home directory, you could e.g. run `widget -- --completion > ~/.widget-completion`. You should then source this -file; to get permanent completion, source this file from your .bashrc file. +file; to get permanent completion, source this file from your `.bashrc` file. Call `widget -- --completion fish` to generate a completion script for the Fish shell. Source this file from your fish.config. @@ -169,7 +177,7 @@ corresponds to, as well as usage information for how to extend that command. ### `--trace`: Getting a Fire trace In order to understand what is happening when you call Python Fire, it can be -useful to request a trace. This is done via the --trace flag, e.g. +useful to request a trace. This is done via the `--trace` flag, e.g. `widget whack 5 -- --trace`. A trace provides step by step information about how the Fire command was diff --git a/examples/widget/widget.py b/examples/widget/widget.py index bf1cbeb2..9092ad75 100644 --- a/examples/widget/widget.py +++ b/examples/widget/widget.py @@ -25,7 +25,7 @@ def whack(self, n=1): def bang(self, noise='bang'): """Makes a loud noise.""" - return '{noise} bang!'.format(noise=noise) + return f'{noise} bang!' def main(): diff --git a/fire/__init__.py b/fire/__init__.py index ab0874f8..b1470692 100644 --- a/fire/__init__.py +++ b/fire/__init__.py @@ -14,10 +14,7 @@ """The Python Fire module.""" -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from fire.core import Fire __all__ = ['Fire'] +__version__ = '0.7.1' diff --git a/fire/__main__.py b/fire/__main__.py new file mode 100644 index 00000000..eb98b1a4 --- /dev/null +++ b/fire/__main__.py @@ -0,0 +1,126 @@ +# Copyright (C) 2018 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# pylint: disable=invalid-name +"""Enables use of Python Fire as a "main" function (i.e. "python -m fire"). + +This allows using Fire with third-party libraries without modifying their code. +""" + +import importlib +from importlib import util +import os +import sys + +import fire + +cli_string = """usage: python -m fire [module] [arg] ..." + +Python Fire is a library for creating CLIs from absolutely any Python +object or program. To run Python Fire from the command line on an +existing Python file, it can be invoked with "python -m fire [module]" +and passed a Python module using module notation: + +"python -m fire packageA.packageB.module" + +or with a file path: + +"python -m fire packageA/packageB/module.py" """ + + +def import_from_file_path(path): + """Performs a module import given the filename. + + Args: + path (str): the path to the file to be imported. + + Raises: + IOError: if the given file does not exist or importlib fails to load it. + + Returns: + Tuple[ModuleType, str]: returns the imported module and the module name, + usually extracted from the path itself. + """ + + if not os.path.exists(path): + raise OSError('Given file path does not exist.') + + module_name = os.path.basename(path) + + spec = util.spec_from_file_location(module_name, path) + + if spec is None or spec.loader is None: + raise OSError('Unable to load module from specified path.') + + module = util.module_from_spec(spec) # pylint: disable=no-member + spec.loader.exec_module(module) + + return module, module_name + + +def import_from_module_name(module_name): + """Imports a module and returns it and its name.""" + module = importlib.import_module(module_name) + return module, module_name + + +def import_module(module_or_filename): + """Imports a given module or filename. + + If the module_or_filename exists in the file system and ends with .py, we + attempt to import it. If that import fails, try to import it as a module. + + Args: + module_or_filename (str): string name of path or module. + + Raises: + ValueError: if the given file is invalid. + IOError: if the file or module can not be found or imported. + + Returns: + Tuple[ModuleType, str]: returns the imported module and the module name, + usually extracted from the path itself. + """ + + if os.path.exists(module_or_filename): + # importlib.util.spec_from_file_location requires .py + if not module_or_filename.endswith('.py'): + try: # try as module instead + return import_from_module_name(module_or_filename) + except ImportError: + raise ValueError('Fire can only be called on .py files.') + + return import_from_file_path(module_or_filename) + + if os.path.sep in module_or_filename: # Use / to detect if it was a filename. + raise OSError('Fire was passed a filename which could not be found.') + + return import_from_module_name(module_or_filename) # Assume it's a module. + + +def main(args): + """Entrypoint for fire when invoked as a module with python -m fire.""" + + if len(args) < 2: + print(cli_string) + sys.exit(1) + + module_or_filename = args[1] + module, module_name = import_module(module_or_filename) + + fire.Fire(module, name=module_name, command=args[2:]) + + +if __name__ == '__main__': + main(sys.argv) diff --git a/fire/completion.py b/fire/completion.py index cda7c936..1597d464 100644 --- a/fire/completion.py +++ b/fire/completion.py @@ -23,7 +23,6 @@ import inspect from fire import inspectutils -import six def Script(name, component, default_options=None, shell='bash'): @@ -104,7 +103,7 @@ def _BashScript(name, commands, default_options=None): option_already_entered() {{ local opt - for opt in ${{COMP_WORDS[@]:0:COMP_CWORD}} + for opt in ${{COMP_WORDS[@]:0:$COMP_CWORD}} do if [ $1 == $opt ]; then return 0 @@ -156,7 +155,11 @@ def _GetOptsAssignmentTemplate(command): return opts_assignment_subcommand_template lines = [] - for command in set(subcommands_map.keys()).union(set(options_map.keys())): + commands_set = set() + commands_set.add(name) + commands_set = commands_set.union(set(subcommands_map.keys())) + commands_set = commands_set.union(set(options_map.keys())) + for command in commands_set: opts_assignment = _GetOptsAssignmentTemplate(command).format( options=' '.join( sorted(options_map[command].union(subcommands_map[command])) @@ -274,41 +277,68 @@ def _FishScript(name, commands, default_options=None): ) return fish_source.format( - global_options=' '.join( - '"{option}"'.format(option=option) - for option in global_options - ) + global_options=' '.join(f'"{option}"' for option in global_options) ) -def _IncludeMember(name, verbose): +def MemberVisible(component, name, member, class_attrs=None, verbose=False): """Returns whether a member should be included in auto-completion or help. Determines whether a member of an object with the specified name should be included in auto-completion or help text(both usage and detailed help). - If the member starts with '__', it will always be excluded. If the member + If the member name starts with '__', it will always be excluded. If it starts with only one '_', it will be included for all non-string types. If - verbose is True, the members, including the private members, are always - included. + verbose is True, the members, including the private members, are included. + + When not in verbose mode, some modules and functions are excluded as well. Args: + component: The component containing the member. name: The name of the member. + member: The member itself. + class_attrs: (optional) If component is a class, provide this as: + inspectutils.GetClassAttrsDict(component). If not provided, it will be + computed. verbose: Whether to include private members. Returns A boolean value indicating whether the member should be included. - """ - if isinstance(name, six.string_types) and name[:2] == '__': + if isinstance(name, str) and name.startswith('__'): return False if verbose: return True - if isinstance(name, six.string_types): - return name and name[0] != '_' + if (member is absolute_import + or member is division + or member is print_function): + return False + if isinstance(member, type(absolute_import)): + return False + # TODO(dbieber): Determine more generally which modules to hide. + modules_to_hide = [] + if inspect.ismodule(member) and member in modules_to_hide: + return False + if inspect.isclass(component): + # If class_attrs has not been provided, compute it. + if class_attrs is None: + class_attrs = inspectutils.GetClassAttrsDict(component) or {} + class_attr = class_attrs.get(name) + if class_attr: + # Methods and properties should only be accessible on instantiated + # objects, not on uninstantiated classes. + if class_attr.kind in ('method', 'property'): + return False + # Backward compatibility notes: Before Python 3.8, namedtuple attributes + # were properties. In Python 3.8, they have type tuplegetter. + tuplegetter = getattr(collections, '_tuplegetter', type(None)) + if isinstance(class_attr.object, tuplegetter): + return False + if isinstance(name, str): + return not name.startswith('_') return True # Default to including the member -def _Members(component, verbose=False): +def VisibleMembers(component, class_attrs=None, verbose=False): """Returns a list of the members of the given component. If verbose is True, then members starting with _ (normally ignored) are @@ -316,6 +346,12 @@ def _Members(component, verbose=False): Args: component: The component whose members to list. + class_attrs: (optional) If component is a class, you may provide this as: + inspectutils.GetClassAttrsDict(component). If not provided, it will be + computed. If provided, this determines how class members will be treated + for visibility. In particular, methods are generally hidden for + non-instantiated classes, but if you wish them to be shown (e.g. for + completion scripts) then pass in a different class_attr for them. verbose: Whether to include private members. Returns: A list of tuples (member_name, member) of all members of the component. @@ -325,10 +361,13 @@ def _Members(component, verbose=False): else: members = inspect.getmembers(component) + # If class_attrs has not been provided, compute it. + if class_attrs is None: + class_attrs = inspectutils.GetClassAttrsDict(component) return [ - (member_name, member) - for member_name, member in members - if _IncludeMember(member_name, verbose) + (member_name, member) for member_name, member in members + if MemberVisible(component, member_name, member, class_attrs=class_attrs, + verbose=verbose) ] @@ -343,7 +382,7 @@ def _CompletionsFromArgs(fn_args): completions = [] for arg in fn_args: arg = arg.replace('_', '-') - completions.append('--{arg}'.format(arg=arg)) + completions.append(f'--{arg}') return completions @@ -372,7 +411,7 @@ def Completions(component, verbose=False): return [ _FormatForCommand(member_name) - for member_name, unused_member in _Members(component, verbose) + for member_name, _ in VisibleMembers(component, verbose=verbose) ] @@ -389,7 +428,7 @@ def _FormatForCommand(token): Returns: The transformed token. """ - if not isinstance(token, six.string_types): + if not isinstance(token, str): token = str(token) if token.startswith('_'): @@ -413,7 +452,7 @@ def _Commands(component, depth=3): Only traverses the member DAG up to a depth of depth. """ if inspect.isroutine(component) or inspect.isclass(component): - for completion in Completions(component): + for completion in Completions(component, verbose=False): yield (completion,) if inspect.isroutine(component): return # Don't descend into routines. @@ -421,7 +460,9 @@ def _Commands(component, depth=3): if depth < 1: return - for member_name, member in _Members(component): + # By setting class_attrs={} we don't hide methods in completion. + for member_name, member in VisibleMembers(component, class_attrs={}, + verbose=False): # TODO(dbieber): Also skip components we've already seen. member_name = _FormatForCommand(member_name) diff --git a/fire/completion_test.py b/fire/completion_test.py index 582e5bbc..c0d5d24f 100644 --- a/fire/completion_test.py +++ b/fire/completion_test.py @@ -14,10 +14,6 @@ """Tests for the completion module.""" -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from fire import completion from fire import test_components as tc from fire import testutils @@ -37,9 +33,8 @@ def testCompletionBashScript(self): self.assertIn('command', script) self.assertIn('halt', script) - assert_template = '{command})' for last_command in ['command', 'halt']: - self.assertIn(assert_template.format(command=last_command), script) + self.assertIn(f'{last_command})', script) def testCompletionFishScript(self): # A sanity check test to make sure the fish completion script satisfies diff --git a/fire/console/console_attr.py b/fire/console/console_attr.py index 94334f45..c0a3d784 100644 --- a/fire/console/console_attr.py +++ b/fire/console/console_attr.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2015 Google Inc. All Rights Reserved. +# Copyright 2015 Google LLC. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -95,12 +95,13 @@ import sys import unicodedata +# from fire.console import properties from fire.console import console_attr_os from fire.console import encoding as encoding_util - -import six +from fire.console import text +# TODO: Unify this logic with console.style.mappings class BoxLineCharacters(object): """Box/line drawing characters. @@ -161,6 +162,18 @@ class BoxLineCharactersAscii(BoxLineCharacters): d_vr = '#' +class BoxLineCharactersScreenReader(BoxLineCharactersAscii): + dl = ' ' + dr = ' ' + hd = ' ' + hu = ' ' + ul = ' ' + ur = ' ' + vh = ' ' + vl = ' ' + vr = ' ' + + class ProgressTrackerSymbols(object): """Characters used by progress trackers.""" @@ -172,8 +185,8 @@ class ProgressTrackerSymbolsUnicode(ProgressTrackerSymbols): def spin_marks(self): return ['⠏', '⠛', '⠹', '⠼', '⠶', '⠧'] - success = '✓' - failed = 'X' + success = text.TypedText(['✓'], text_type=text.TextTypes.PT_SUCCESS) + failed = text.TypedText(['X'], text_type=text.TextTypes.PT_FAILURE) interrupted = '-' not_started = '.' prefix_length = 2 @@ -232,7 +245,7 @@ class ConsoleAttr(object): _BULLETS_WINDOWS = ('■', '≡', '∞', 'Φ', '·') # cp437 compatible unicode _BULLETS_ASCII = ('o', '*', '+', '-') - def __init__(self, encoding=None): + def __init__(self, encoding=None, suppress_output=False): """Constructor. Args: @@ -240,6 +253,8 @@ def __init__(self, encoding=None): ascii -- ASCII art. This is the default. utf8 -- UTF-8 unicode. win -- Windows code page 437. + suppress_output: True to create a ConsoleAttr that doesn't want to output + anything. """ # Normalize the encoding name. if not encoding: @@ -247,11 +262,11 @@ def __init__(self, encoding=None): elif encoding == 'win': encoding = 'cp437' self._encoding = encoding or 'ascii' - self._term = os.getenv('TERM', '').lower() + self._term = '' if suppress_output else os.getenv('TERM', '').lower() # ANSI "standard" attributes. if self.SupportsAnsi(): - # Select Graphic Rendition paramaters from + # Select Graphic Rendition parameters from # http://en.wikipedia.org/wiki/ANSI_escape_code#graphics # Italic '3' would be nice here but its not widely supported. self._csi = '\x1b[' @@ -263,23 +278,27 @@ def __init__(self, encoding=None): self._font_italic = '' # Encoded character attributes. - if self._encoding == 'utf8': + is_screen_reader = False + if self._encoding == 'utf8' and not is_screen_reader: self._box_line_characters = BoxLineCharactersUnicode() self._bullets = self._BULLETS_UNICODE self._progress_tracker_symbols = ProgressTrackerSymbolsUnicode() - elif self._encoding == 'cp437': + elif self._encoding == 'cp437' and not is_screen_reader: self._box_line_characters = BoxLineCharactersUnicode() self._bullets = self._BULLETS_WINDOWS - # Windows does not suport the unicode characters used for the spinner. + # Windows does not support the unicode characters used for the spinner. self._progress_tracker_symbols = ProgressTrackerSymbolsAscii() else: self._box_line_characters = BoxLineCharactersAscii() + if is_screen_reader: + self._box_line_characters = BoxLineCharactersScreenReader() self._bullets = self._BULLETS_ASCII self._progress_tracker_symbols = ProgressTrackerSymbolsAscii() # OS specific attributes. self._get_raw_key = [console_attr_os.GetRawKeyFunction()] - self._term_size = console_attr_os.GetTermSize() + self._term_size = ( + (0, 0) if suppress_output else console_attr_os.GetTermSize()) self._display_width_cache = {} @@ -334,9 +353,9 @@ def ConvertOutputToUnicode(self, buf): Returns: The console output string buf converted to unicode. """ - if isinstance(buf, six.text_type): + if isinstance(buf, str): buf = buf.encode(self._encoding) - return six.text_type(buf, self._encoding, 'replace') + return str(buf, self._encoding, 'replace') def GetBoxLineCharacters(self): """Returns the box/line drawing characters object. @@ -373,7 +392,7 @@ def GetControlSequenceIndicator(self): """Returns the control sequence indicator string. Returns: - The conrol sequence indicator string or None if control sequences are not + The control sequence indicator string or None if control sequences are not supported. """ return self._csi @@ -387,7 +406,7 @@ def GetControlSequenceLen(self, buf): buf: The string to check for a control sequence. Returns: - The conrol sequence length at the beginning of buf or 0 if buf does not + The control sequence length at the beginning of buf or 0 if buf does not start with a control sequence. """ if not self._csi or not buf.startswith(self._csi): @@ -434,6 +453,14 @@ def GetRawKey(self): """ return self._get_raw_key[0]() + def GetTermIdentifier(self): + """Returns the TERM environment variable for the console. + + Returns: + str: A str that describes the console's text capabilities + """ + return self._term + def GetTermSize(self): """Returns the terminal (x, y) dimensions in characters. @@ -451,7 +478,7 @@ def DisplayWidth(self, buf): Returns: The display width of buf, handling unicode and ANSI controls. """ - if not isinstance(buf, six.string_types): + if not isinstance(buf, str): # Handle non-string objects like Colorizer(). return len(buf) @@ -566,16 +593,16 @@ def __init__(self, string, color, justify=None): self._justify = justify def __eq__(self, other): - return self._string == six.text_type(other) + return self._string == str(other) def __ne__(self, other): return not self == other def __gt__(self, other): - return self._string > six.text_type(other) + return self._string > str(other) def __lt__(self, other): - return self._string < six.text_type(other) + return self._string < str(other) def __ge__(self, other): return not self < other @@ -663,7 +690,7 @@ def GetCharacterDisplayWidth(char): Returns: The monospaced terminal display width of char: either 0, 1, or 2. """ - if not isinstance(char, six.text_type): + if not isinstance(char, str): # Non-unicode chars have width 1. Don't use this function on control chars. return 1 @@ -750,7 +777,7 @@ def EncodeToBytes(data): return data # Coerce to text that will be converted to bytes. - s = six.text_type(data) + s = str(data) try: # Assume the text can be directly converted to bytes (8-bit ascii). diff --git a/fire/console/console_attr_os.py b/fire/console/console_attr_os.py index 52decc2c..a7f38d4f 100644 --- a/fire/console/console_attr_os.py +++ b/fire/console/console_attr_os.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- # -# Copyright 2015 Google Inc. All Rights Reserved. +# Copyright 2015 Google LLC. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -14,9 +14,6 @@ # limitations under the License. """OS specific console_attr helper functions.""" -# This file contains platform specific code which is not currently handled -# by pytype. -# pytype: skip-file from __future__ import absolute_import from __future__ import division @@ -73,7 +70,7 @@ def _GetXY(fd): try: # This magic incantation converts a struct from ioctl(2) containing two # binary shorts to a (rows, columns) int tuple. - rc = struct.unpack(b'hh', fcntl.ioctl(fd, termios.TIOCGWINSZ, 'junk')) + rc = struct.unpack(b'hh', fcntl.ioctl(fd, termios.TIOCGWINSZ, b'junk')) return (rc[1], rc[0]) if rc else None except: # pylint: disable=bare-except return None @@ -123,7 +120,7 @@ def _GetTermSizeEnvironment(): def _GetTermSizeTput(): - """Returns the terminal x and y dimemsions from tput(1).""" + """Returns the terminal x and y dimensions from tput(1).""" import subprocess # pylint: disable=g-import-not-at-top output = encoding.Decode(subprocess.check_output(['tput', 'cols'], stderr=subprocess.STDOUT)) diff --git a/fire/console/console_io.py b/fire/console/console_io.py new file mode 100644 index 00000000..ec0858d9 --- /dev/null +++ b/fire/console/console_io.py @@ -0,0 +1,110 @@ +# -*- coding: utf-8 -*- # +# Copyright 2013 Google LLC. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""General console printing utilities used by the Cloud SDK.""" + +import os +import signal +import subprocess +import sys + +from fire.console import console_attr +from fire.console import console_pager +from fire.console import encoding +from fire.console import files + + +def IsInteractive(output=False, error=False, heuristic=False): + """Determines if the current terminal session is interactive. + + sys.stdin must be a terminal input stream. + + Args: + output: If True then sys.stdout must also be a terminal output stream. + error: If True then sys.stderr must also be a terminal output stream. + heuristic: If True then we also do some additional heuristics to check if + we are in an interactive context. Checking home path for example. + + Returns: + True if the current terminal session is interactive. + """ + if not sys.stdin.isatty(): + return False + if output and not sys.stdout.isatty(): + return False + if error and not sys.stderr.isatty(): + return False + + if heuristic: + # Check the home path. Most startup scripts for example are executed by + # users that don't have a home path set. Home is OS dependent though, so + # check everything. + # *NIX OS usually sets the HOME env variable. It is usually '/home/user', + # but can also be '/root'. If it's just '/' we are most likely in an init + # script. + # Windows usually sets HOMEDRIVE and HOMEPATH. If they don't exist we are + # probably being run from a task scheduler context. HOMEPATH can be '\' + # when a user has a network mapped home directory. + # Cygwin has it all! Both Windows and Linux. Checking both is perfect. + home = os.getenv('HOME') + homepath = os.getenv('HOMEPATH') + if not homepath and (not home or home == '/'): + return False + return True + + +def More(contents, out, prompt=None, check_pager=True): + """Run a user specified pager or fall back to the internal pager. + + Args: + contents: The entire contents of the text lines to page. + out: The output stream. + prompt: The page break prompt. + check_pager: Checks the PAGER env var and uses it if True. + """ + if not IsInteractive(output=True): + out.write(contents) + return + if check_pager: + pager = encoding.GetEncodedValue(os.environ, 'PAGER', None) + if pager == '-': + # Use the fallback Pager. + pager = None + elif not pager: + # Search for a pager that handles ANSI escapes. + for command in ('less', 'pager'): + if files.FindExecutableOnPath(command): + pager = command + break + if pager: + # If the pager is less(1) then instruct it to display raw ANSI escape + # sequences to enable colors and font embellishments. + less_orig = encoding.GetEncodedValue(os.environ, 'LESS', None) + less = '-R' + (less_orig or '') + encoding.SetEncodedValue(os.environ, 'LESS', less) + # Ignore SIGINT while the pager is running. + # We don't want to terminate the parent while the child is still alive. + signal.signal(signal.SIGINT, signal.SIG_IGN) + p = subprocess.Popen(pager, stdin=subprocess.PIPE, shell=True) + enc = console_attr.GetConsoleAttr().GetEncoding() + p.communicate(input=contents.encode(enc)) + p.wait() + # Start using default signal handling for SIGINT again. + signal.signal(signal.SIGINT, signal.SIG_DFL) + if less_orig is None: + encoding.SetEncodedValue(os.environ, 'LESS', None) + return + # Fall back to the internal pager. + console_pager.Pager(contents, out, prompt).Run() diff --git a/fire/console/console_pager.py b/fire/console/console_pager.py index c15a8ee5..565c7e1e 100644 --- a/fire/console/console_pager.py +++ b/fire/console/console_pager.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- # -# Copyright 2015 Google Inc. All Rights Reserved. +# Copyright 2015 Google LLC. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -94,7 +94,7 @@ def __init__(self, contents, out=None, prompt=None): Args: contents: The entire contents of the text lines to page. out: The output stream, log.out (effectively) if None. - prompt: The page break prompt, a defalt prompt is used if None.. + prompt: The page break prompt, a default prompt is used if None.. """ self._contents = contents self._out = out or sys.stdout diff --git a/fire/console/encoding.py b/fire/console/encoding.py index e8b1e571..662342c6 100644 --- a/fire/console/encoding.py +++ b/fire/console/encoding.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2015 Google Inc. All Rights Reserved. +# Copyright 2015 Google LLC. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -22,8 +22,6 @@ import sys -import six - def Encode(string, encoding=None): """Encode the text string to a byte string. @@ -35,18 +33,8 @@ def Encode(string, encoding=None): Returns: str, The binary string. """ - if string is None: - return None - if not six.PY2: - # In Python 3, the environment sets and gets accept and return text strings - # only, and it handles the encoding itself so this is not necessary. - return string - if isinstance(string, six.binary_type): - # Already an encoded byte string, we are done - return string - - encoding = encoding or _GetEncoding() - return string.encode(encoding) + del encoding # Unused. + return string def Decode(data, encoding=None): @@ -67,20 +55,13 @@ def Decode(data, encoding=None): return None # First we are going to get the data object to be a text string. - # Don't use six.string_types here because on Python 3 bytes is not considered - # a string type and we want to include that. - if isinstance(data, six.text_type) or isinstance(data, six.binary_type): + if isinstance(data, str) or isinstance(data, bytes): string = data else: # Some non-string type of object. - try: - string = six.text_type(data) - except (TypeError, UnicodeError): - # The string cannot be converted to unicode -- default to str() which will - # catch objects with special __str__ methods. - string = str(data) + string = str(data) - if isinstance(string, six.text_type): + if isinstance(string, str): # Our work is done here. return string @@ -199,7 +180,8 @@ def EncodeEnv(env, encoding=None): encoding = encoding or _GetEncoding() return { Encode(k, encoding=encoding): Encode(v, encoding=encoding) - for k, v in six.iteritems(env)} + for k, v in env.items() + } def _GetEncoding(): diff --git a/fire/console/files.py b/fire/console/files.py new file mode 100644 index 00000000..97222c3d --- /dev/null +++ b/fire/console/files.py @@ -0,0 +1,114 @@ +# -*- coding: utf-8 -*- # +# Copyright 2013 Google LLC. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Some general file utilities used that can be used by the Cloud SDK.""" + +from __future__ import absolute_import +from __future__ import division +from __future__ import unicode_literals + +import os + +from fire.console import encoding as encoding_util +from fire.console import platforms + + +def _GetSystemPath(): + """Returns properly encoded system PATH variable string.""" + return encoding_util.GetEncodedValue(os.environ, 'PATH') + + +def _FindExecutableOnPath(executable, path, pathext): + """Internal function to a find an executable. + + Args: + executable: The name of the executable to find. + path: A list of directories to search separated by 'os.pathsep'. + pathext: An iterable of file name extensions to use. + + Returns: + str, the path to a file on `path` with name `executable` + `p` for + `p` in `pathext`. + + Raises: + ValueError: invalid input. + """ + + if isinstance(pathext, str): + raise ValueError('_FindExecutableOnPath(..., pathext=\'{0}\') failed ' + 'because pathext must be an iterable of strings, but got ' + 'a string.'.format(pathext)) + + # Prioritize preferred extension over earlier in path. + for ext in pathext: + for directory in path.split(os.pathsep): + # Windows can have paths quoted. + directory = directory.strip('"') + full = os.path.normpath(os.path.join(directory, executable) + ext) + # On Windows os.access(full, os.X_OK) is always True. + if os.path.isfile(full) and os.access(full, os.X_OK): + return full + return None + + +def _PlatformExecutableExtensions(platform): + if platform == platforms.OperatingSystem.WINDOWS: + return ('.exe', '.cmd', '.bat', '.com', '.ps1') + else: + return ('', '.sh') + + +def FindExecutableOnPath(executable, path=None, pathext=None, + allow_extensions=False): + """Searches for `executable` in the directories listed in `path` or $PATH. + + Executable must not contain a directory or an extension. + + Args: + executable: The name of the executable to find. + path: A list of directories to search separated by 'os.pathsep'. If None + then the system PATH is used. + pathext: An iterable of file name extensions to use. If None then + platform specific extensions are used. + allow_extensions: A boolean flag indicating whether extensions in the + executable are allowed. + + Returns: + The path of 'executable' (possibly with a platform-specific extension) if + found and executable, None if not found. + + Raises: + ValueError: if executable has a path or an extension, and extensions are + not allowed, or if there's an internal error. + """ + + if not allow_extensions and os.path.splitext(executable)[1]: + raise ValueError('FindExecutableOnPath({0},...) failed because first ' + 'argument must not have an extension.'.format(executable)) + + if os.path.dirname(executable): + raise ValueError('FindExecutableOnPath({0},...) failed because first ' + 'argument must not have a path.'.format(executable)) + + if path is None: + effective_path = _GetSystemPath() + else: + effective_path = path + effective_pathext = (pathext if pathext is not None + else _PlatformExecutableExtensions( + platforms.OperatingSystem.Current())) + + return _FindExecutableOnPath(executable, effective_path, + effective_pathext) diff --git a/fire/console/platforms.py b/fire/console/platforms.py new file mode 100644 index 00000000..13fd8204 --- /dev/null +++ b/fire/console/platforms.py @@ -0,0 +1,483 @@ +# -*- coding: utf-8 -*- # +# Copyright 2013 Google LLC. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Utilities for determining the current platform and architecture.""" + +from __future__ import absolute_import +from __future__ import division +from __future__ import unicode_literals + +import os +import platform +import subprocess +import sys + + +class Error(Exception): + """Base class for exceptions in the platforms module.""" + pass + + +class InvalidEnumValue(Error): # pylint: disable=g-bad-exception-name + """Exception for when a string could not be parsed to a valid enum value.""" + + def __init__(self, given, enum_type, options): + """Constructs a new exception. + + Args: + given: str, The given string that could not be parsed. + enum_type: str, The human readable name of the enum you were trying to + parse. + options: list(str), The valid values for this enum. + """ + super(InvalidEnumValue, self).__init__( + 'Could not parse [{0}] into a valid {1}. Valid values are [{2}]' + .format(given, enum_type, ', '.join(options))) + + +class OperatingSystem(object): + """An enum representing the operating system you are running on.""" + + class _OS(object): + """A single operating system.""" + + # pylint: disable=redefined-builtin + def __init__(self, id, name, file_name): + self.id = id + self.name = name + self.file_name = file_name + + def __str__(self): + return self.id + + def __eq__(self, other): + return (isinstance(other, type(self)) and + self.id == other.id and + self.name == other.name and + self.file_name == other.file_name) + + def __hash__(self): + return hash(self.id) + hash(self.name) + hash(self.file_name) + + def __ne__(self, other): + return not self == other + + @classmethod + def _CmpHelper(cls, x, y): + """Just a helper equivalent to the cmp() function in Python 2.""" + return (x > y) - (x < y) + + def __lt__(self, other): + return self._CmpHelper( + (self.id, self.name, self.file_name), + (other.id, other.name, other.file_name)) < 0 + + def __gt__(self, other): + return self._CmpHelper( + (self.id, self.name, self.file_name), + (other.id, other.name, other.file_name)) > 0 + + def __le__(self, other): + return not self.__gt__(other) + + def __ge__(self, other): + return not self.__lt__(other) + + WINDOWS = _OS('WINDOWS', 'Windows', 'windows') + MACOSX = _OS('MACOSX', 'Mac OS X', 'darwin') + LINUX = _OS('LINUX', 'Linux', 'linux') + CYGWIN = _OS('CYGWIN', 'Cygwin', 'cygwin') + MSYS = _OS('MSYS', 'Msys', 'msys') + _ALL = [WINDOWS, MACOSX, LINUX, CYGWIN, MSYS] + + @staticmethod + def AllValues(): + """Gets all possible enum values. + + Returns: + list, All the enum values. + """ + return list(OperatingSystem._ALL) + + @staticmethod + def FromId(os_id, error_on_unknown=True): + """Gets the enum corresponding to the given operating system id. + + Args: + os_id: str, The operating system id to parse + error_on_unknown: bool, True to raise an exception if the id is unknown, + False to just return None. + + Raises: + InvalidEnumValue: If the given value cannot be parsed. + + Returns: + OperatingSystemTuple, One of the OperatingSystem constants or None if the + input is None. + """ + if not os_id: + return None + for operating_system in OperatingSystem._ALL: + if operating_system.id == os_id: + return operating_system + if error_on_unknown: + raise InvalidEnumValue(os_id, 'Operating System', + [value.id for value in OperatingSystem._ALL]) + return None + + @staticmethod + def Current(): + """Determines the current operating system. + + Returns: + OperatingSystemTuple, One of the OperatingSystem constants or None if it + cannot be determined. + """ + if os.name == 'nt': + return OperatingSystem.WINDOWS + elif 'linux' in sys.platform: + return OperatingSystem.LINUX + elif 'darwin' in sys.platform: + return OperatingSystem.MACOSX + elif 'cygwin' in sys.platform: + return OperatingSystem.CYGWIN + elif 'msys' in sys.platform: + return OperatingSystem.MSYS + return None + + @staticmethod + def IsWindows(): + """Returns True if the current operating system is Windows.""" + return OperatingSystem.Current() is OperatingSystem.WINDOWS + + +class Architecture(object): + """An enum representing the system architecture you are running on.""" + + class _ARCH(object): + """A single architecture.""" + + # pylint: disable=redefined-builtin + def __init__(self, id, name, file_name): + self.id = id + self.name = name + self.file_name = file_name + + def __str__(self): + return self.id + + def __eq__(self, other): + return (isinstance(other, type(self)) and + self.id == other.id and + self.name == other.name and + self.file_name == other.file_name) + + def __hash__(self): + return hash(self.id) + hash(self.name) + hash(self.file_name) + + def __ne__(self, other): + return not self == other + + @classmethod + def _CmpHelper(cls, x, y): + """Just a helper equivalent to the cmp() function in Python 2.""" + return (x > y) - (x < y) + + def __lt__(self, other): + return self._CmpHelper( + (self.id, self.name, self.file_name), + (other.id, other.name, other.file_name)) < 0 + + def __gt__(self, other): + return self._CmpHelper( + (self.id, self.name, self.file_name), + (other.id, other.name, other.file_name)) > 0 + + def __le__(self, other): + return not self.__gt__(other) + + def __ge__(self, other): + return not self.__lt__(other) + + x86 = _ARCH('x86', 'x86', 'x86') + x86_64 = _ARCH('x86_64', 'x86_64', 'x86_64') + ppc = _ARCH('PPC', 'PPC', 'ppc') + arm = _ARCH('arm', 'arm', 'arm') + _ALL = [x86, x86_64, ppc, arm] + + # Possible values for `uname -m` and what arch they map to. + # Examples of possible values: https://en.wikipedia.org/wiki/Uname + _MACHINE_TO_ARCHITECTURE = { + 'amd64': x86_64, 'x86_64': x86_64, 'i686-64': x86_64, + 'i386': x86, 'i686': x86, 'x86': x86, + 'ia64': x86, # Itanium is different x64 arch, treat it as the common x86. + 'powerpc': ppc, 'power macintosh': ppc, 'ppc64': ppc, + 'armv6': arm, 'armv6l': arm, 'arm64': arm, 'armv7': arm, 'armv7l': arm} + + @staticmethod + def AllValues(): + """Gets all possible enum values. + + Returns: + list, All the enum values. + """ + return list(Architecture._ALL) + + @staticmethod + def FromId(architecture_id, error_on_unknown=True): + """Gets the enum corresponding to the given architecture id. + + Args: + architecture_id: str, The architecture id to parse + error_on_unknown: bool, True to raise an exception if the id is unknown, + False to just return None. + + Raises: + InvalidEnumValue: If the given value cannot be parsed. + + Returns: + ArchitectureTuple, One of the Architecture constants or None if the input + is None. + """ + if not architecture_id: + return None + for arch in Architecture._ALL: + if arch.id == architecture_id: + return arch + if error_on_unknown: + raise InvalidEnumValue(architecture_id, 'Architecture', + [value.id for value in Architecture._ALL]) + return None + + @staticmethod + def Current(): + """Determines the current system architecture. + + Returns: + ArchitectureTuple, One of the Architecture constants or None if it cannot + be determined. + """ + return Architecture._MACHINE_TO_ARCHITECTURE.get(platform.machine().lower()) + + +class Platform(object): + """Holds an operating system and architecture.""" + + def __init__(self, operating_system, architecture): + """Constructs a new platform. + + Args: + operating_system: OperatingSystem, The OS + architecture: Architecture, The machine architecture. + """ + self.operating_system = operating_system + self.architecture = architecture + + def __str__(self): + return '{}-{}'.format(self.operating_system, self.architecture) + + @staticmethod + def Current(os_override=None, arch_override=None): + """Determines the current platform you are running on. + + Args: + os_override: OperatingSystem, A value to use instead of the current. + arch_override: Architecture, A value to use instead of the current. + + Returns: + Platform, The platform tuple of operating system and architecture. Either + can be None if it could not be determined. + """ + return Platform( + os_override if os_override else OperatingSystem.Current(), + arch_override if arch_override else Architecture.Current()) + + def UserAgentFragment(self): + """Generates the fragment of the User-Agent that represents the OS. + + Examples: + (Linux 3.2.5-gg1236) + (Windows NT 6.1.7601) + (Macintosh; PPC Mac OS X 12.4.0) + (Macintosh; Intel Mac OS X 12.4.0) + + Returns: + str, The fragment of the User-Agent string. + """ + # Below, there are examples of the value of platform.uname() per platform. + # platform.release() is uname[2], platform.version() is uname[3]. + if self.operating_system == OperatingSystem.LINUX: + # ('Linux', '', '3.2.5-gg1236', + # '#1 SMP Tue May 21 02:35:06 PDT 2013', 'x86_64', 'x86_64') + return '({name} {version})'.format( + name=self.operating_system.name, version=platform.release()) + elif self.operating_system == OperatingSystem.WINDOWS: + # ('Windows', '', '7', '6.1.7601', 'AMD64', + # 'Intel64 Family 6 Model 45 Stepping 7, GenuineIntel') + return '({name} NT {version})'.format( + name=self.operating_system.name, version=platform.version()) + elif self.operating_system == OperatingSystem.MACOSX: + # ('Darwin', '', '12.4.0', + # 'Darwin Kernel Version 12.4.0: Wed May 1 17:57:12 PDT 2013; + # root:xnu-2050.24.15~1/RELEASE_X86_64', 'x86_64', 'i386') + format_string = '(Macintosh; {name} Mac OS X {version})' + arch_string = (self.architecture.name + if self.architecture == Architecture.ppc else 'Intel') + return format_string.format( + name=arch_string, version=platform.release()) + else: + return '()' + + def AsyncPopenArgs(self): + """Returns the args for spawning an async process using Popen on this OS. + + Make sure the main process does not wait for the new process. On windows + this means setting the 0x8 creation flag to detach the process. + + Killing a group leader kills the whole group. Setting creation flag 0x200 on + Windows or running setsid on *nix makes sure the new process is in a new + session with the new process the group leader. This means it can't be killed + if the parent is killed. + + Finally, all file descriptors (FD) need to be closed so that waiting for the + output of the main process does not inadvertently wait for the output of the + new process, which means waiting for the termination of the new process. + If the new process wants to write to a file, it can open new FDs. + + Returns: + {str:}, The args for spawning an async process using Popen on this OS. + """ + args = {} + if self.operating_system == OperatingSystem.WINDOWS: + args['close_fds'] = True # This is enough to close _all_ FDs on windows. + detached_process = 0x00000008 + create_new_process_group = 0x00000200 + # 0x008 | 0x200 == 0x208 + args['creationflags'] = detached_process | create_new_process_group + else: + # Killing a group leader kills the whole group. + # Create a new session with the new process the group leader. + args['preexec_fn'] = os.setsid + args['close_fds'] = True # This closes all FDs _except_ 0, 1, 2 on *nix. + args['stdin'] = subprocess.PIPE + args['stdout'] = subprocess.PIPE + args['stderr'] = subprocess.PIPE + return args + + +class PythonVersion(object): + """Class to validate the Python version we are using. + + The Cloud SDK officially supports Python 2.7. + + However, many commands do work with Python 2.6, so we don't error out when + users are using this (we consider it sometimes "compatible" but not + "supported"). + """ + + # See class docstring for descriptions of what these mean + MIN_REQUIRED_PY2_VERSION = (2, 6) + MIN_SUPPORTED_PY2_VERSION = (2, 7) + MIN_SUPPORTED_PY3_VERSION = (3, 4) + ENV_VAR_MESSAGE = """\ + +If you have a compatible Python interpreter installed, you can use it by setting +the CLOUDSDK_PYTHON environment variable to point to it. + +""" + + def __init__(self, version=None): + if version: + self.version = version + elif hasattr(sys, 'version_info'): + self.version = sys.version_info[:2] + else: + self.version = None + + def SupportedVersionMessage(self, allow_py3): + if allow_py3: + return 'Please use Python version {0}.{1}.x or {2}.{3} and up.'.format( + PythonVersion.MIN_SUPPORTED_PY2_VERSION[0], + PythonVersion.MIN_SUPPORTED_PY2_VERSION[1], + PythonVersion.MIN_SUPPORTED_PY3_VERSION[0], + PythonVersion.MIN_SUPPORTED_PY3_VERSION[1]) + else: + return 'Please use Python version {0}.{1}.x.'.format( + PythonVersion.MIN_SUPPORTED_PY2_VERSION[0], + PythonVersion.MIN_SUPPORTED_PY2_VERSION[1]) + + def IsCompatible(self, allow_py3=False, raise_exception=False): + """Ensure that the Python version we are using is compatible. + + This will print an error message if not compatible. + + Compatible versions are 2.6 and 2.7 and > 3.4 if allow_py3 is True. + We don't guarantee support for 2.6 so we want to warn about it. + + Args: + allow_py3: bool, True if we should allow a Python 3 interpreter to run + gcloud. If False, this returns an error for Python 3. + raise_exception: bool, True to raise an exception rather than printing + the error and exiting. + + Raises: + Error: If not compatible and raise_exception is True. + + Returns: + bool, True if the version is valid, False otherwise. + """ + error = None + if not self.version: + # We don't know the version, not a good sign. + error = ('ERROR: Your current version of Python is not compatible with ' + 'the Google Cloud SDK. {0}\n' + .format(self.SupportedVersionMessage(allow_py3))) + else: + if self.version[0] < 3: + # Python 2 Mode + if self.version < PythonVersion.MIN_REQUIRED_PY2_VERSION: + error = ('ERROR: Python {0}.{1} is not compatible with the Google ' + 'Cloud SDK. {2}\n' + .format(self.version[0], self.version[1], + self.SupportedVersionMessage(allow_py3))) + else: + # Python 3 Mode + if not allow_py3: + error = ('ERROR: Python 3 and later is not compatible with the ' + 'Google Cloud SDK. {0}\n' + .format(self.SupportedVersionMessage(allow_py3))) + elif self.version < PythonVersion.MIN_SUPPORTED_PY3_VERSION: + error = ('ERROR: Python {0}.{1} is not compatible with the Google ' + 'Cloud SDK. {2}\n' + .format(self.version[0], self.version[1], + self.SupportedVersionMessage(allow_py3))) + + if error: + if raise_exception: + raise Error(error) + sys.stderr.write(error) + sys.stderr.write(PythonVersion.ENV_VAR_MESSAGE) + return False + + # Warn that 2.6 might not work. + if (self.version >= self.MIN_REQUIRED_PY2_VERSION and + self.version < self.MIN_SUPPORTED_PY2_VERSION): + sys.stderr.write("""\ +WARNING: Python 2.6.x is no longer officially supported by the Google Cloud SDK +and may not function correctly. {0} +{1}""".format(self.SupportedVersionMessage(allow_py3), + PythonVersion.ENV_VAR_MESSAGE)) + + return True diff --git a/fire/console/text.py b/fire/console/text.py new file mode 100644 index 00000000..73e68488 --- /dev/null +++ b/fire/console/text.py @@ -0,0 +1,103 @@ +# -*- coding: utf-8 -*- # +# Copyright 2018 Google LLC. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Semantic text objects that are used for styled outputting.""" + +from __future__ import absolute_import +from __future__ import division +from __future__ import unicode_literals + +import enum + + +class TextAttributes(object): + """Attributes to use to style text with.""" + + def __init__(self, format_str=None, color=None, attrs=None): + """Defines a set of attributes for a piece of text. + + Args: + format_str: (str), string that will be used to format the text + with. For example '[{}]', to enclose text in brackets. + color: (Colors), the color the text should be formatted with. + attrs: (Attrs), the attributes to apply to text. + """ + self._format_str = format_str + self._color = color + self._attrs = attrs or [] + + @property + def format_str(self): + return self._format_str + + @property + def color(self): + return self._color + + @property + def attrs(self): + return self._attrs + + +class TypedText(object): + """Text with a semantic type that will be used for styling.""" + + def __init__(self, texts, text_type=None): + """String of text and a corresponding type to use to style that text. + + Args: + texts: (list[str]), list of strs or TypedText objects + that should be styled using text_type. + text_type: (TextTypes), the semantic type of the text that + will be used to style text. + """ + self.texts = texts + self.text_type = text_type + + def __len__(self): + length = 0 + for text in self.texts: + length += len(text) + return length + + def __add__(self, other): + texts = [self, other] + return TypedText(texts) + + def __radd__(self, other): + texts = [other, self] + return TypedText(texts) + + +class _TextTypes(enum.Enum): + """Text types base class that defines base functionality.""" + + def __call__(self, *args): + """Returns a TypedText object using this style.""" + return TypedText(list(args), self) + + +# TODO: Add more types. +class TextTypes(_TextTypes): + """Defines text types that can be used for styling text.""" + RESOURCE_NAME = 1 + URL = 2 + USER_INPUT = 3 + COMMAND = 4 + INFO = 5 + URI = 6 + OUTPUT = 7 + PT_SUCCESS = 8 + PT_FAILURE = 9 + diff --git a/fire/core.py b/fire/core.py index e6138a6c..8e23e76b 100644 --- a/fire/core.py +++ b/fire/core.py @@ -49,14 +49,10 @@ def main(argv): --trace: Get the Fire Trace for the command. """ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - +import asyncio import inspect import json import os -import pipes import re import shlex import sys @@ -64,17 +60,17 @@ def main(argv): from fire import completion from fire import decorators -from fire import helputils +from fire import formatting +from fire import helptext from fire import inspectutils from fire import interact from fire import parser from fire import trace from fire import value_types -from fire.console import console_pager -import six +from fire.console import console_io -def Fire(component=None, command=None, name=None): +def Fire(component=None, command=None, name=None, serialize=None): """This function, Fire, is the main entrypoint for Python Fire. Executes a command either from the `command` argument or from sys.argv by @@ -90,6 +86,8 @@ def Fire(component=None, command=None, name=None): a string or a list of strings; a list of strings is preferred. name: Optional. The name of the command as entered at the command line. Used in interactive mode and for generating the completion script. + serialize: Optional. If supplied, all objects are serialized to text via + the provided callable. Returns: The result of executing the Fire command. Execution begins with the initial target component. The component is updated by using the command arguments @@ -108,7 +106,7 @@ def Fire(component=None, command=None, name=None): name = name or os.path.basename(sys.argv[0]) # Get args as a list. - if isinstance(command, six.string_types): + if isinstance(command, str): args = shlex.split(command) elif isinstance(command, (list, tuple)): args = command @@ -119,55 +117,56 @@ def Fire(component=None, command=None, name=None): raise ValueError('The command argument must be a string or a sequence of ' 'arguments.') - # Determine the calling context. - caller = inspect.stack()[1] - caller_frame = caller[0] - caller_globals = caller_frame.f_globals - caller_locals = caller_frame.f_locals + args, flag_args = parser.SeparateFlagArgs(args) + + argparser = parser.CreateParser() + parsed_flag_args, unused_args = argparser.parse_known_args(flag_args) + context = {} - context.update(caller_globals) - context.update(caller_locals) + if parsed_flag_args.interactive or component is None: + # Determine the calling context. + caller = inspect.stack()[1] + caller_frame = caller[0] + caller_globals = caller_frame.f_globals + caller_locals = caller_frame.f_locals + context.update(caller_globals) + context.update(caller_locals) - component_trace = _Fire(component, args, context, name) + component_trace = _Fire(component, args, parsed_flag_args, context, name) if component_trace.HasError(): _DisplayError(component_trace) raise FireExit(2, component_trace) if component_trace.show_trace and component_trace.show_help: - output = ['Fire trace:\n{trace}\n'.format(trace=component_trace)] + output = [f'Fire trace:\n{component_trace}\n'] result = component_trace.GetResult() - help_string = helputils.HelpString( - result, component_trace, component_trace.verbose) - output.append(help_string) - Display(output) + help_text = helptext.HelpText( + result, trace=component_trace, verbose=component_trace.verbose) + output.append(help_text) + Display(output, out=sys.stderr) raise FireExit(0, component_trace) if component_trace.show_trace: - output = ['Fire trace:\n{trace}'.format(trace=component_trace)] - Display(output) + output = [f'Fire trace:\n{component_trace}'] + Display(output, out=sys.stderr) raise FireExit(0, component_trace) if component_trace.show_help: result = component_trace.GetResult() - help_string = helputils.HelpString( - result, component_trace, component_trace.verbose) - output = [help_string] - Display(output) + help_text = helptext.HelpText( + result, trace=component_trace, verbose=component_trace.verbose) + output = [help_text] + Display(output, out=sys.stderr) raise FireExit(0, component_trace) # The command succeeded normally; print the result. - _PrintResult(component_trace, verbose=component_trace.verbose) + _PrintResult( + component_trace, verbose=component_trace.verbose, serialize=serialize) result = component_trace.GetResult() return result -def Display(lines): +def Display(lines, out): text = '\n'.join(lines) + '\n' - pager = console_pager.Pager(text, out=sys.stderr) - try: - pager.Run() - except: # pylint: disable=bare-except - # pager.Run() fails with termios.error(25, 'Inappropriate ioctl for device') - # for outputs that don't fit on a single screen in our test environment. - pass + console_io.More(text, out=out) def CompletionScript(name, component, shell): @@ -200,7 +199,7 @@ def __init__(self, code, component_trace): code: (int) Exit code for the Fire CLI. component_trace: (FireTrace) The trace for the Fire command. """ - super(FireExit, self).__init__(code) + super().__init__(code) self.trace = component_trace @@ -231,48 +230,76 @@ def _IsHelpShortcut(component_trace, remaining_args): if show_help: component_trace.show_help = True - command = '{cmd} -- --help'.format(cmd=component_trace.GetCommand()) - print('INFO: Showing help with the command {cmd}.\n'.format( - cmd=pipes.quote(command)), file=sys.stderr) + command = f'{component_trace.GetCommand()} -- --help' + print(f'INFO: Showing help with the command {shlex.quote(command)}.\n', + file=sys.stderr) return show_help -def _PrintResult(component_trace, verbose=False): +def _PrintResult(component_trace, verbose=False, serialize=None): """Prints the result of the Fire call to stdout in a human readable way.""" # TODO(dbieber): Design human readable deserializable serialization method - # and move serialization to it's own module. + # and move serialization to its own module. result = component_trace.GetResult() - if isinstance(result, (list, set, types.GeneratorType)): + # Allow users to modify the return value of the component and provide + # custom formatting. + if serialize: + if not callable(serialize): + raise FireError( + 'The argument `serialize` must be empty or callable:', serialize) + result = serialize(result) + + if value_types.HasCustomStr(result): + # If the object has a custom __str__ method, rather than one inherited from + # object, then we use that to serialize the object. + print(str(result)) + return + + if isinstance(result, (list, set, frozenset, types.GeneratorType)): for i in result: print(_OneLineResult(i)) elif inspect.isgeneratorfunction(result): raise NotImplementedError - elif isinstance(result, dict): + elif isinstance(result, dict) and value_types.IsSimpleGroup(result): print(_DictAsString(result, verbose)) elif isinstance(result, tuple): print(_OneLineResult(result)) elif isinstance(result, value_types.VALUE_TYPES): - print(result) - elif result is not None: - print(helputils.HelpString(result, component_trace, verbose)) + if result is not None: + print(result) + else: + help_text = helptext.HelpText( + result, trace=component_trace, verbose=verbose) + output = [help_text] + Display(output, out=sys.stdout) def _DisplayError(component_trace): """Prints the Fire trace and the error to stdout.""" + result = component_trace.GetResult() + output = [] + show_help = False for help_flag in ('-h', '--help'): if help_flag in component_trace.elements[-1].args: - command = '{cmd} -- --help'.format(cmd=component_trace.GetCommand()) - message = 'INFO: Showing help with the command {cmd}.\n'.format( - cmd=pipes.quote(command)) - output.append(message) - output.append('Fire trace:\n{trace}\n'.format(trace=component_trace)) - result = component_trace.GetResult() - help_string = helputils.HelpString(result, component_trace, - component_trace.verbose) - output.append(help_string) - Display(output) + show_help = True + + if show_help: + command = f'{component_trace.GetCommand()} -- --help' + print(f'INFO: Showing help with the command {shlex.quote(command)}.\n', + file=sys.stderr) + help_text = helptext.HelpText(result, trace=component_trace, + verbose=component_trace.verbose) + output.append(help_text) + Display(output, out=sys.stderr) + else: + print(formatting.Error('ERROR: ') + + component_trace.elements[-1].ErrorAsStr(), + file=sys.stderr) + error_text = helptext.UsageText(result, trace=component_trace, + verbose=component_trace.verbose) + print(error_text, file=sys.stderr) def _DictAsString(result, verbose=False): @@ -288,38 +315,42 @@ def _DictAsString(result, verbose=False): # We need to do 2 iterations over the items in the result dict # 1) Getting visible items and the longest key for output formatting # 2) Actually construct the output lines - result_visible = {key: value for key, value in result.items() - if _ComponentVisible(key, verbose)} + class_attrs = inspectutils.GetClassAttrsDict(result) + result_visible = { + key: value for key, value in result.items() + if completion.MemberVisible(result, key, value, + class_attrs=class_attrs, verbose=verbose) + } if not result_visible: return '{}' longest_key = max(len(str(key)) for key in result_visible.keys()) - format_string = '{{key:{padding}s}} {{value}}'.format(padding=longest_key + 1) + format_string = f'{{key:{longest_key + 1}s}} {{value}}' lines = [] for key, value in result.items(): - if _ComponentVisible(key, verbose): - line = format_string.format(key=str(key) + ':', - value=_OneLineResult(value)) + if completion.MemberVisible(result, key, value, class_attrs=class_attrs, + verbose=verbose): + line = format_string.format(key=f'{key}:', value=_OneLineResult(value)) lines.append(line) return '\n'.join(lines) -def _ComponentVisible(component, verbose=False): - """Returns whether a component should be visible in the output.""" - return ( - verbose - or not isinstance(component, six.string_types) - or not component.startswith('_')) - - def _OneLineResult(result): """Returns result serialized to a single line string.""" # TODO(dbieber): Ensure line is fewer than eg 120 characters. - if isinstance(result, six.string_types): + if isinstance(result, str): return str(result).replace('\n', ' ') + # TODO(dbieber): Show a small amount of usage information about the function + # or module if it fits cleanly on the line. + if inspect.isfunction(result): + return f'' + + if inspect.ismodule(result): + return f'' + try: # Don't force conversion to ascii. return json.dumps(result, ensure_ascii=False) @@ -327,7 +358,7 @@ def _OneLineResult(result): return str(result).replace('\n', ' ') -def _Fire(component, args, context, name=None): +def _Fire(component, args, parsed_flag_args, context, name=None): """Execute a Fire command on a target component using the args supplied. Arguments that come after a final isolated '--' are treated as Flags, eg for @@ -343,9 +374,15 @@ def _Fire(component, args, context, name=None): 2. Start with component as the current component. 2a. If the current component is a class, instantiate it using args from args. - 2b. If the current component is a routine, call it using args from args. - 2c. Otherwise access a member from component using an arg from args. - 2d. Repeat 2a-2c until no args remain. + 2b. If the component is a routine, call it using args from args. + 2c. If the component is a sequence, index into it using an arg from + args. + 2d. If possible, access a member from the component using an arg from args. + 2e. If the component is a callable object, call it using args from args. + 2f. Repeat 2a-2e until no args remain. + Note: Only the first applicable rule from 2a-2e is applied in each iteration. + After each iteration of step 2a-2e, the current component is updated to be the + result of the applied rule. 3a. Embed into ipython REPL if interactive mode is selected. 3b. Generate a completion script if that flag is provided. @@ -359,6 +396,8 @@ def _Fire(component, args, context, name=None): component: The target component for Fire. args: A list of args to consume in Firing on the component, usually from the command line. + parsed_flag_args: The values of the flag args (e.g. --verbose, --separator) + that are part of every Fire CLI. context: A dict with the local and global variables available at the call to Fire. name: Optional. The name of the command. Used in interactive mode and in @@ -370,10 +409,6 @@ def _Fire(component, args, context, name=None): ValueError: If there are arguments that cannot be consumed. ValueError: If --completion is specified but no name available. """ - args, flag_args = parser.SeparateFlagArgs(args) - - argparser = parser.CreateParser() - parsed_flag_args, unused_args = argparser.parse_known_args(flag_args) verbose = parsed_flag_args.verbose interactive = parsed_flag_args.interactive separator = parsed_flag_args.separator @@ -416,102 +451,98 @@ def _Fire(component, args, context, name=None): used_separator = True assert separator not in remaining_args - if inspect.isclass(component) or inspect.isroutine(component): + handled = False + candidate_errors = [] + + is_callable = inspect.isclass(component) or inspect.isroutine(component) + is_callable_object = callable(component) and not is_callable + is_sequence = isinstance(component, (list, tuple)) + is_map = isinstance(component, dict) or inspectutils.IsNamedTuple(component) + + if not handled and is_callable: # The component is a class or a routine; we'll try to initialize it or # call it. - isclass = inspect.isclass(component) + is_class = inspect.isclass(component) try: component, remaining_args = _CallAndUpdateTrace( component, remaining_args, component_trace, - treatment='class' if isclass else 'routine', + treatment='class' if is_class else 'routine', target=component.__name__) + handled = True except FireError as error: - component_trace.AddError(error, initial_args) - return component_trace + candidate_errors.append((error, initial_args)) - if last_component is initial_component: + if handled and last_component is initial_component: # If the initial component is a class, keep an instance for use with -i. instance = component - elif (isinstance(component, (list, tuple)) and remaining_args - and not inspectutils.IsNamedTuple(component)): + if not handled and is_sequence and remaining_args: # The component is a tuple or list; we'll try to access a member. arg = remaining_args[0] try: index = int(arg) component = component[index] + handled = True except (ValueError, IndexError): error = FireError( 'Unable to index into component with argument:', arg) - component_trace.AddError(error, initial_args) - return component_trace - - remaining_args = remaining_args[1:] - filename = None - lineno = None - component_trace.AddAccessedProperty( - component, index, [arg], filename, lineno) - - elif ((isinstance(component, dict) or inspectutils.IsNamedTuple(component)) - and remaining_args): - # The component is a dict; we'll try to access a member. + candidate_errors.append((error, initial_args)) + + if handled: + remaining_args = remaining_args[1:] + filename = None + lineno = None + component_trace.AddAccessedProperty( + component, index, [arg], filename, lineno) + + if not handled and is_map and remaining_args: + # The component is a dict or other key-value map; try to access a member. target = remaining_args[0] - # Allow indexing for namedtuples. - try: - index = int(target) - is_target_int = True - except ValueError: - is_target_int = False - - if inspectutils.IsNamedTuple(component) and is_target_int: - try: - component = component[index] - except (ValueError, IndexError): - error = FireError( - 'Unable to index into component with argument:', target) - component_trace.AddError(error, initial_args) - return component_trace - elif target in component: - component = component[target] - elif target.replace('-', '_') in component: - component = component[target.replace('-', '_')] + # Treat namedtuples as dicts when handling them as a map. + if inspectutils.IsNamedTuple(component): + component_dict = component._asdict() else: - # The target isn't present in the dict as a string, but maybe it is as - # another type. + component_dict = component + + if target in component_dict: + component = component_dict[target] + handled = True + elif target.replace('-', '_') in component_dict: + component = component_dict[target.replace('-', '_')] + handled = True + else: + # The target isn't present in the dict as a string key, but maybe it is + # a key as another type. # TODO(dbieber): Consider alternatives for accessing non-string keys. - found_target = False - # If the component is a namedtuple, we need to convert it to dict to - # be able to use the .items() method. - if inspectutils.IsNamedTuple(component): - component = component._asdict() # pytype: disable=attribute-error - for key, value in component.items(): + for key, value in ( + component_dict.items()): if target == str(key): component = value - found_target = True + handled = True break - if not found_target: - error = FireError( - 'Cannot find target in dict:', target, component) - component_trace.AddError(error, initial_args) - return component_trace - - remaining_args = remaining_args[1:] - filename = None - lineno = None - component_trace.AddAccessedProperty( - component, target, [target], filename, lineno) - - elif remaining_args: - # We'll try to access a member of the component. + + if handled: + remaining_args = remaining_args[1:] + filename = None + lineno = None + component_trace.AddAccessedProperty( + component, target, [target], filename, lineno) + else: + error = FireError('Cannot find key:', target) + candidate_errors.append((error, initial_args)) + + if not handled and remaining_args: + # Object handler. We'll try to access a member of the component. try: target = remaining_args[0] component, consumed_args, remaining_args = _GetMember( component, remaining_args) + handled = True filename, lineno = inspectutils.GetFileAndLine(component) @@ -519,19 +550,25 @@ def _Fire(component, args, context, name=None): component, target, consumed_args, filename, lineno) except FireError as error: - if not callable(component): - component_trace.AddError(error, initial_args) - return component_trace - - # If we can't access the member, try to treat component as a callable. - try: - component, remaining_args = _CallAndUpdateTrace(component, - remaining_args, - component_trace, - treatment='callable') - except FireError as error: - component_trace.AddError(error, initial_args) - return component_trace + # Couldn't access member. + candidate_errors.append((error, initial_args)) + + if not handled and is_callable_object: + # The component is a callable object; we'll try to call it. + try: + component, remaining_args = _CallAndUpdateTrace( + component, + remaining_args, + component_trace, + treatment='callable') + handled = True + except FireError as error: + candidate_errors.append((error, initial_args)) + + if not handled and candidate_errors: + error, initial_args = candidate_errors[0] + component_trace.AddError(error, initial_args) + return component_trace if used_separator: # Add back in the arguments from after the separator. @@ -598,7 +635,7 @@ def _GetMember(component, args): Raises: FireError: If we cannot consume an argument to get a member. """ - members = dict(inspect.getmembers(component)) + members = dir(component) arg = args[0] arg_names = [ arg, @@ -607,7 +644,7 @@ def _GetMember(component, args): for arg_name in arg_names: if arg_name in members: - return members[arg_name], [arg], args[1:] + return getattr(component, arg_name), [arg], args[1:] raise FireError('Could not consume arg:', arg) @@ -634,10 +671,23 @@ def _CallAndUpdateTrace(component, args, component_trace, treatment='class', if not target: target = component filename, lineno = inspectutils.GetFileAndLine(component) + metadata = decorators.GetMetadata(component) fn = component.__call__ if treatment == 'callable' else component - parse = _MakeParseFn(fn) + parse = _MakeParseFn(fn, metadata) (varargs, kwargs), consumed_args, remaining_args, capacity = parse(args) - component = fn(*varargs, **kwargs) + + # Call the function. + if inspectutils.IsCoroutineFunction(fn): + try: + loop = asyncio.get_running_loop() + except RuntimeError: + # No event loop running, create a new one + component = asyncio.run(fn(*varargs, **kwargs)) + else: + # Event loop is already running + component = loop.run_until_complete(fn(*varargs, **kwargs)) + else: + component = fn(*varargs, **kwargs) if treatment == 'class': action = trace.INSTANTIATED_CLASS @@ -652,11 +702,12 @@ def _CallAndUpdateTrace(component, args, component_trace, treatment='class', return component, remaining_args -def _MakeParseFn(fn): +def _MakeParseFn(fn, metadata): """Creates a parse function for fn. Args: fn: The function or class to create the parse function for. + metadata: Additional metadata about the component the parse function is for. Returns: A parse function for fn. The parse function accepts a list of arguments and returns (varargs, kwargs), remaining_args. The original function fn @@ -664,7 +715,6 @@ def _MakeParseFn(fn): the leftover args from the arguments to the parse function. """ fn_spec = inspectutils.GetFullArgSpec(fn) - metadata = decorators.GetMetadata(fn) # Note: num_required_args is the number of positional arguments without # default values. All of these arguments are required. @@ -826,6 +876,7 @@ def _ParseKeywordArgs(args, fn_spec): key, value = stripped_argument.split('=', 1) else: key = stripped_argument + value = None # value will be set later on. key = key.replace('-', '_') is_bool_syntax = (not contains_equals and @@ -843,9 +894,10 @@ def _ParseKeywordArgs(args, fn_spec): if len(matching_fn_args) == 1: keyword = matching_fn_args[0] elif len(matching_fn_args) > 1: - raise FireError("The argument '{}' is ambiguous as it could " - "refer to any of the following arguments: {}".format( - argument, matching_fn_args)) + raise FireError( + f"The argument '{argument}' is ambiguous as it could " + f"refer to any of the following arguments: {matching_fn_args}" + ) # Determine the value. if not keyword: diff --git a/fire/core_test.py b/fire/core_test.py index 2a906002..f48d6e2d 100644 --- a/fire/core_test.py +++ b/fire/core_test.py @@ -14,15 +14,12 @@ """Tests for the core module.""" -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function +from unittest import mock from fire import core from fire import test_components as tc from fire import testutils from fire import trace -import mock class CoreTest(testutils.BaseTestCase): @@ -74,34 +71,34 @@ def testInteractiveModeVariablesWithName(self, mock_embed): # TODO(dbieber): Use parameterized tests to break up repetitive tests. def testHelpWithClass(self): - with self.assertRaisesFireExit(0, 'Usage:.*ARG1'): + with self.assertRaisesFireExit(0, 'SYNOPSIS.*ARG1'): core.Fire(tc.InstanceVars, command=['--', '--help']) - with self.assertRaisesFireExit(0, 'INFO:.*Usage:.*ARG1'): + with self.assertRaisesFireExit(0, 'INFO:.*SYNOPSIS.*ARG1'): core.Fire(tc.InstanceVars, command=['--help']) - with self.assertRaisesFireExit(0, 'INFO:.*Usage:.*ARG1'): + with self.assertRaisesFireExit(0, 'INFO:.*SYNOPSIS.*ARG1'): core.Fire(tc.InstanceVars, command=['-h']) def testHelpWithMember(self): - with self.assertRaisesFireExit(0, 'Usage:.*capitalize'): + with self.assertRaisesFireExit(0, 'SYNOPSIS.*capitalize'): core.Fire(tc.TypedProperties, command=['gamma', '--', '--help']) - with self.assertRaisesFireExit(0, 'INFO:.*Usage:.*capitalize'): + with self.assertRaisesFireExit(0, 'INFO:.*SYNOPSIS.*capitalize'): core.Fire(tc.TypedProperties, command=['gamma', '--help']) - with self.assertRaisesFireExit(0, 'INFO:.*Usage:.*capitalize'): + with self.assertRaisesFireExit(0, 'INFO:.*SYNOPSIS.*capitalize'): core.Fire(tc.TypedProperties, command=['gamma', '-h']) - with self.assertRaisesFireExit(0, 'INFO:.*Usage:.*delta'): + with self.assertRaisesFireExit(0, 'INFO:.*SYNOPSIS.*delta'): core.Fire(tc.TypedProperties, command=['delta', '--help']) - with self.assertRaisesFireExit(0, 'INFO:.*Usage:.*echo'): + with self.assertRaisesFireExit(0, 'INFO:.*SYNOPSIS.*echo'): core.Fire(tc.TypedProperties, command=['echo', '--help']) def testHelpOnErrorInConstructor(self): - with self.assertRaisesFireExit(0, 'Usage:.*[VALUE]'): + with self.assertRaisesFireExit(0, 'SYNOPSIS.*VALUE'): core.Fire(tc.ErrorInConstructor, command=['--', '--help']) - with self.assertRaisesFireExit(0, 'INFO:.*Usage:.*[VALUE]'): + with self.assertRaisesFireExit(0, 'INFO:.*SYNOPSIS.*VALUE'): core.Fire(tc.ErrorInConstructor, command=['--help']) def testHelpWithNamespaceCollision(self): # Tests cases when calling the help shortcut should not show help. - with self.assertOutputMatches(stdout='Docstring.*', stderr=None): + with self.assertOutputMatches(stdout='DESCRIPTION.*', stderr=None): core.Fire(tc.WithHelpArg, command=['--help', 'False']) with self.assertOutputMatches(stdout='help in a dict', stderr=None): core.Fire(tc.WithHelpArg, command=['dictionary', '__help']) @@ -144,10 +141,22 @@ def testPrintNamedTupleField(self): with self.assertOutputMatches(stdout='11', stderr=None): core.Fire(tc.NamedTuple, command=['point', 'x']) + def testPrintNamedTupleFieldNameEqualsValue(self): + with self.assertOutputMatches(stdout='x', stderr=None): + core.Fire(tc.NamedTuple, command=['matching_names', 'x']) + def testPrintNamedTupleIndex(self): with self.assertOutputMatches(stdout='22', stderr=None): core.Fire(tc.NamedTuple, command=['point', '1']) + def testPrintSet(self): + with self.assertOutputMatches(stdout='.*three.*', stderr=None): + core.Fire(tc.simple_set(), command=[]) + + def testPrintFrozenSet(self): + with self.assertOutputMatches(stdout='.*three.*', stderr=None): + core.Fire(tc.simple_frozenset(), command=[]) + def testPrintNamedTupleNegativeIndex(self): with self.assertOutputMatches(stdout='11', stderr=None): core.Fire(tc.NamedTuple, command=['point', '-2']) @@ -160,6 +169,60 @@ def testCallable(self): with self.assertOutputMatches(stdout=r'', stderr=None): core.Fire(tc.CallableWithKeywordArgument(), command=[]) + def testCallableWithPositionalArgs(self): + with self.assertRaisesFireExit(2, ''): + # This does not give 7 since positional args are disallowed for callable + # objects. + core.Fire(tc.CallableWithPositionalArgs(), command=['3', '4']) + + def testStaticMethod(self): + self.assertEqual( + core.Fire(tc.HasStaticAndClassMethods, + command=['static_fn', 'alpha']), + 'alpha', + ) + + def testClassMethod(self): + self.assertEqual( + core.Fire(tc.HasStaticAndClassMethods, + command=['class_fn', '6']), + 7, + ) + + def testCustomSerialize(self): + def serialize(x): + if isinstance(x, list): + return ', '.join(str(xi) for xi in x) + if isinstance(x, dict): + return ', '.join('{}={!r}'.format(k, v) for k, v in sorted(x.items())) + if x == 'special': + return ['SURPRISE!!', "I'm a list!"] + return x + + ident = lambda x: x + + with self.assertOutputMatches(stdout='a, b', stderr=None): + _ = core.Fire(ident, command=['[a,b]'], serialize=serialize) + with self.assertOutputMatches(stdout='a=5, b=6', stderr=None): + _ = core.Fire(ident, command=['{a:5,b:6}'], serialize=serialize) + with self.assertOutputMatches(stdout='asdf', stderr=None): + _ = core.Fire(ident, command=['asdf'], serialize=serialize) + with self.assertOutputMatches( + stdout="SURPRISE!!\nI'm a list!\n", stderr=None): + _ = core.Fire(ident, command=['special'], serialize=serialize) + with self.assertRaises(core.FireError): + core.Fire(ident, command=['asdf'], serialize=55) + + def testLruCacheDecoratorBoundArg(self): + self.assertEqual( + core.Fire(tc.py3.LruCacheDecoratedMethod, + command=['lru_cache_in_class', 'foo']), 'foo') + + def testLruCacheDecorator(self): + self.assertEqual( + core.Fire(tc.py3.lru_cache_decorated, + command=['foo']), 'foo') + if __name__ == '__main__': testutils.main() diff --git a/fire/custom_descriptions.py b/fire/custom_descriptions.py new file mode 100644 index 00000000..ef1130a3 --- /dev/null +++ b/fire/custom_descriptions.py @@ -0,0 +1,144 @@ +# Copyright (C) 2018 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Custom descriptions and summaries for the builtin types. + +The docstrings for objects of primitive types reflect the type of the object, +rather than the object itself. For example, the docstring for any dict is this: + +> print({'key': 'value'}.__doc__) +dict() -> new empty dictionary +dict(mapping) -> new dictionary initialized from a mapping object's + (key, value) pairs +dict(iterable) -> new dictionary initialized as if via: + d = {} + for k, v in iterable: + d[k] = v +dict(**kwargs) -> new dictionary initialized with the name=value pairs + in the keyword argument list. For example: dict(one=1, two=2) + +As you can see, this docstring is more pertinent to the function `dict` and +would be suitable as the result of `dict.__doc__`, but is wholely unsuitable +as a description for the dict `{'key': 'value'}`. + +This modules aims to resolve that problem, providing custom summaries and +descriptions for primitive typed values. +""" + +from fire import formatting + +TWO_DOUBLE_QUOTES = '""' +STRING_DESC_PREFIX = 'The string ' + + +def NeedsCustomDescription(component): + """Whether the component should use a custom description and summary. + + Components of primitive type, such as ints, floats, dicts, lists, and others + have messy builtin docstrings. These are inappropriate for display as + descriptions and summaries in a CLI. This function determines whether the + provided component has one of these docstrings. + + Note that an object such as `int` has the same docstring as an int like `3`. + The docstring is OK for `int`, but is inappropriate as a docstring for `3`. + + Args: + component: The component of interest. + Returns: + Whether the component should use a custom description and summary. + """ + type_ = type(component) + if ( + type_ in (str, int, bytes) + or type_ in (float, complex, bool) + or type_ in (dict, tuple, list, set, frozenset) + ): + return True + return False + + +def GetStringTypeSummary(obj, available_space, line_length): + """Returns a custom summary for string type objects. + + This function constructs a summary for string type objects by double quoting + the string value. The double quoted string value will be potentially truncated + with ellipsis depending on whether it has enough space available to show the + full string value. + + Args: + obj: The object to generate summary for. + available_space: Number of character spaces available. + line_length: The full width of the terminal, default is 80. + + Returns: + A summary for the input object. + """ + if len(obj) + len(TWO_DOUBLE_QUOTES) <= available_space: + content = obj + else: + additional_len_needed = len(TWO_DOUBLE_QUOTES) + len(formatting.ELLIPSIS) + if available_space < additional_len_needed: + available_space = line_length + content = formatting.EllipsisTruncate( + obj, available_space - len(TWO_DOUBLE_QUOTES), line_length) + return formatting.DoubleQuote(content) + + +def GetStringTypeDescription(obj, available_space, line_length): + """Returns the predefined description for string obj. + + This function constructs a description for string type objects in the format + of 'The string ""'. could be potentially + truncated depending on whether it has enough space available to show the full + string value. + + Args: + obj: The object to generate description for. + available_space: Number of character spaces available. + line_length: The full width of the terminal, default if 80. + + Returns: + A description for input object. + """ + additional_len_needed = len(STRING_DESC_PREFIX) + len( + TWO_DOUBLE_QUOTES) + len(formatting.ELLIPSIS) + if available_space < additional_len_needed: + available_space = line_length + + return STRING_DESC_PREFIX + formatting.DoubleQuote( + formatting.EllipsisTruncate( + obj, available_space - len(STRING_DESC_PREFIX) - + len(TWO_DOUBLE_QUOTES), line_length)) + + +CUSTOM_DESC_SUM_FN_DICT = { + 'str': (GetStringTypeSummary, GetStringTypeDescription), + 'unicode': (GetStringTypeSummary, GetStringTypeDescription), +} + + +def GetSummary(obj, available_space, line_length): + obj_type_name = type(obj).__name__ + if obj_type_name in CUSTOM_DESC_SUM_FN_DICT: + return CUSTOM_DESC_SUM_FN_DICT[obj_type_name][0](obj, available_space, + line_length) + return None + + +def GetDescription(obj, available_space, line_length): + obj_type_name = type(obj).__name__ + if obj_type_name in CUSTOM_DESC_SUM_FN_DICT: + return CUSTOM_DESC_SUM_FN_DICT[obj_type_name][1](obj, available_space, + line_length) + return None diff --git a/fire/custom_descriptions_test.py b/fire/custom_descriptions_test.py new file mode 100644 index 00000000..6cff2d5d --- /dev/null +++ b/fire/custom_descriptions_test.py @@ -0,0 +1,69 @@ +# Copyright (C) 2018 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for custom description module.""" + +from fire import custom_descriptions +from fire import testutils + +LINE_LENGTH = 80 + + +class CustomDescriptionTest(testutils.BaseTestCase): + + def test_string_type_summary_enough_space(self): + component = 'Test' + summary = custom_descriptions.GetSummary( + obj=component, available_space=80, line_length=LINE_LENGTH) + self.assertEqual(summary, '"Test"') + + def test_string_type_summary_not_enough_space_truncated(self): + component = 'Test' + summary = custom_descriptions.GetSummary( + obj=component, available_space=5, line_length=LINE_LENGTH) + self.assertEqual(summary, '"..."') + + def test_string_type_summary_not_enough_space_new_line(self): + component = 'Test' + summary = custom_descriptions.GetSummary( + obj=component, available_space=4, line_length=LINE_LENGTH) + self.assertEqual(summary, '"Test"') + + def test_string_type_summary_not_enough_space_long_truncated(self): + component = 'Lorem ipsum dolor sit amet' + summary = custom_descriptions.GetSummary( + obj=component, available_space=10, line_length=LINE_LENGTH) + self.assertEqual(summary, '"Lorem..."') + + def test_string_type_description_enough_space(self): + component = 'Test' + description = custom_descriptions.GetDescription( + obj=component, available_space=80, line_length=LINE_LENGTH) + self.assertEqual(description, 'The string "Test"') + + def test_string_type_description_not_enough_space_truncated(self): + component = 'Lorem ipsum dolor sit amet' + description = custom_descriptions.GetDescription( + obj=component, available_space=20, line_length=LINE_LENGTH) + self.assertEqual(description, 'The string "Lore..."') + + def test_string_type_description_not_enough_space_new_line(self): + component = 'Lorem ipsum dolor sit amet' + description = custom_descriptions.GetDescription( + obj=component, available_space=10, line_length=LINE_LENGTH) + self.assertEqual(description, 'The string "Lorem ipsum dolor sit amet"') + + +if __name__ == '__main__': + testutils.main() diff --git a/fire/decorators.py b/fire/decorators.py index 3fcc4b97..547153c6 100644 --- a/fire/decorators.py +++ b/fire/decorators.py @@ -18,10 +18,7 @@ command line arguments to client code. """ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - +from typing import Any, Dict import inspect FIRE_METADATA = 'FIRE_METADATA' @@ -60,7 +57,7 @@ def SetParseFns(*positional, **named): Python arguments with which to call the function. A parse function should accept a single string argument and return a value to - be used in it's place when calling the decorated function. + be used in its place when calling the decorated function. Args: *positional: The functions to be used for parsing positional arguments. @@ -84,14 +81,30 @@ def _SetMetadata(fn, attribute, value): setattr(fn, FIRE_METADATA, metadata) -def GetMetadata(fn): +def GetMetadata(fn) -> Dict[str, Any]: + """Gets metadata attached to the function `fn` as an attribute. + + Args: + fn: The function from which to retrieve the function metadata. + Returns: + A dictionary mapping property strings to their value. + """ + # Class __init__ functions and object __call__ functions require flag style + # arguments. Other methods and functions may accept positional args. default = { - ACCEPTS_POSITIONAL_ARGS: not inspect.isclass(fn), + ACCEPTS_POSITIONAL_ARGS: inspect.isroutine(fn), } - return getattr(fn, FIRE_METADATA, default) + try: + metadata = getattr(fn, FIRE_METADATA, default) + if ACCEPTS_POSITIONAL_ARGS in metadata: + return metadata + else: + return default + except: # pylint: disable=bare-except + return default -def GetParseFns(fn): +def GetParseFns(fn) -> Dict[str, Any]: metadata = GetMetadata(fn) - default = dict(default=None, positional=[], named={}) + default = {'default': None, 'positional': [], 'named': {}} return metadata.get(FIRE_PARSE_FNS, default) diff --git a/fire/decorators_test.py b/fire/decorators_test.py index cc7d6203..9988743c 100644 --- a/fire/decorators_test.py +++ b/fire/decorators_test.py @@ -14,16 +14,12 @@ """Tests for the decorators module.""" -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from fire import core from fire import decorators from fire import testutils -class NoDefaults(object): +class NoDefaults: """A class for testing decorated functions without default values.""" @decorators.SetParseFns(count=int) @@ -44,7 +40,7 @@ def double(count): return 2 * count -class WithDefaults(object): +class WithDefaults: @decorators.SetParseFns(float) def example1(self, arg1=10): @@ -55,14 +51,14 @@ def example2(self, arg1=10): return arg1, type(arg1) -class MixedArguments(object): +class MixedArguments: @decorators.SetParseFns(float, arg2=str) def example3(self, arg1, arg2): return arg1, arg2 -class PartialParseFn(object): +class PartialParseFn: @decorators.SetParseFns(arg1=str) def example4(self, arg1, arg2): @@ -73,7 +69,7 @@ def example5(self, arg1, arg2): return arg1, arg2 -class WithKwargs(object): +class WithKwargs: @decorators.SetParseFns(mode=str, count=int) def example6(self, **kwargs): @@ -83,7 +79,7 @@ def example6(self, **kwargs): ) -class WithVarArgs(object): +class WithVarArgs: @decorators.SetParseFn(str) def example7(self, arg1, arg2=None, *varargs, **kwargs): # pylint: disable=keyword-arg-before-vararg diff --git a/fire/docstrings.py b/fire/docstrings.py index 4f0ffd93..2adfe5ec 100644 --- a/fire/docstrings.py +++ b/fire/docstrings.py @@ -49,15 +49,10 @@ - "True | False" indicates bool type. """ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - - import collections -import re - import enum +import re +import textwrap class DocstringInfo( @@ -76,6 +71,11 @@ class ArgInfo( ArgInfo.__new__.__defaults__ = (None,) * len(ArgInfo._fields) +class KwargInfo(ArgInfo): + pass +KwargInfo.__new__.__defaults__ = (None,) * len(KwargInfo._fields) + + class Namespace(dict): """A dict with attribute (dot-notation) access enabled.""" @@ -107,7 +107,7 @@ class Formats(enum.Enum): SECTION_TITLES = { - Sections.ARGS: ('argument', 'arg', 'parameter', 'param'), + Sections.ARGS: ('argument', 'arg', 'parameter', 'param', 'key'), Sections.RETURNS: ('return',), Sections.YIELDS: ('yield',), Sections.RAISES: ('raise', 'except', 'exception', 'throw', 'error', 'warn'), @@ -148,6 +148,7 @@ def parse(docstring): Args: docstring: The docstring to parse. + Returns: A DocstringInfo containing information about the docstring. """ @@ -167,6 +168,7 @@ def parse(docstring): state.summary.lines = [] state.description.lines = [] state.args = [] + state.kwargs = [] state.current_arg = None state.returns.lines = [] state.yields.lines = [] @@ -174,24 +176,27 @@ def parse(docstring): for index, line in enumerate(lines): has_next = index + 1 < lines_len + previous_line = lines[index - 1] if index > 0 else None next_line = lines[index + 1] if has_next else None - line_info = _create_line_info(line, next_line) + line_info = _create_line_info(line, next_line, previous_line) _consume_line(line_info, state) summary = ' '.join(state.summary.lines) if state.summary.lines else None - description = _join_lines(state.description.lines) + state.description.lines = _strip_blank_lines(state.description.lines) + description = textwrap.dedent('\n'.join(state.description.lines)) + if not description: + description = None returns = _join_lines(state.returns.lines) yields = _join_lines(state.yields.lines) raises = _join_lines(state.raises.lines) - args = [ - ArgInfo( - name=arg.name, - type=_cast_to_known_type(_join_lines(arg.type.lines)), - description=_join_lines(arg.description.lines), - ) - for arg in state.args - ] + args = [ArgInfo( + name=arg.name, type=_cast_to_known_type(_join_lines(arg.type.lines)), + description=_join_lines(arg.description.lines)) for arg in state.args] + + args.extend([KwargInfo( + name=arg.name, type=_cast_to_known_type(_join_lines(arg.type.lines)), + description=_join_lines(arg.description.lines)) for arg in state.kwargs]) return DocstringInfo( summary=summary, @@ -203,6 +208,33 @@ def parse(docstring): ) +def _strip_blank_lines(lines): + """Removes lines containing only blank characters before and after the text. + + Args: + lines: A list of lines. + Returns: + A list of lines without trailing or leading blank lines. + """ + # Find the first non-blank line. + start = 0 + num_lines = len(lines) + while lines and start < num_lines and _is_blank(lines[start]): + start += 1 + + lines = lines[start:] + + # Remove trailing blank lines. + while lines and _is_blank(lines[-1]): + lines.pop() + + return lines + + +def _is_blank(line): + return not line or line.isspace() + + def _join_lines(lines): """Joins lines with the appropriate connective whitespace. @@ -239,7 +271,7 @@ def _join_lines(lines): return '\n\n'.join(group_texts) -def _get_or_create_arg_by_name(state, name): +def _get_or_create_arg_by_name(state, name, is_kwarg=False): """Gets or creates a new Arg. These Arg objects (Namespaces) are turned into the ArgInfo namedtuples @@ -249,17 +281,21 @@ def _get_or_create_arg_by_name(state, name): Args: state: The state of the parser. name: The name of the arg to create. + is_kwarg: A boolean representing whether the argument is a keyword arg. Returns: The new Arg. """ - for arg in state.args: + for arg in state.args + state.kwargs: if arg.name == name: return arg arg = Namespace() # TODO(dbieber): Switch to an explicit class. arg.name = name arg.type.lines = [] arg.description.lines = [] - state.args.append(arg) + if is_kwarg: + state.kwargs.append(arg) + else: + state.args.append(arg) return arg @@ -267,9 +303,8 @@ def _is_arg_name(name): """Returns whether name is a valid arg name. This is used to prevent multiple words (plaintext) from being misinterpreted - as an argument name. So if ":" appears in the middle of a line in a docstring, - we don't accidentally interpret the first half of that line as a single arg - name. + as an argument name. Any line that doesn't match the pattern for a valid + argument is treated as not being an argument. Args: name: The name of the potential arg. @@ -277,9 +312,11 @@ def _is_arg_name(name): True if name looks like an arg name, False otherwise. """ name = name.strip() - return (name - and ' ' not in name - and ':' not in name) + # arg_pattern is a letter or underscore followed by + # zero or more letters, numbers, or underscores. + arg_pattern = r'^[a-zA-Z_]\w*$' + re.match(arg_pattern, name) + return re.match(arg_pattern, name) is not None def _as_arg_name_and_type(text): @@ -362,6 +399,7 @@ def _consume_google_args_line(line_info, state): arg = _get_or_create_arg_by_name(state, arg_name) arg.type.lines.append(type_str) arg.description.lines.append(second.strip()) + state.current_arg = arg else: if state.current_arg: state.current_arg.description.lines.append(split_line[0]) @@ -391,17 +429,21 @@ def _consume_line(line_info, state): else: # We're past the end of the summary. # Additions now contribute to the description. - state.description.lines.append(line_info.remaining) + state.description.lines.append(line_info.remaining_raw) else: state.summary.permitted = False if state.section.new and state.section.format == Formats.RST: # The current line starts with an RST directive, e.g. ":param arg:". directive = _get_directive(line_info) - directive_tokens = directive.split() # pytype: disable=attribute-error + directive_tokens = directive.split() if state.section.title == Sections.ARGS: name = directive_tokens[-1] - arg = _get_or_create_arg_by_name(state, name) + arg = _get_or_create_arg_by_name( + state, + name, + is_kwarg=directive_tokens[0] == 'key' + ) if len(directive_tokens) == 3: # A param directive of the form ":param type arg:". arg.type.lines.append(directive_tokens[1]) @@ -424,11 +466,11 @@ def _consume_line(line_info, state): elif state.section.format == Formats.NUMPY: line_stripped = line_info.remaining.strip() if _is_arg_name(line_stripped): - # Token on it's own line can either be the last word of the description + # Token on its own line can either be the last word of the description # of the previous arg, or a new arg. TODO: Whitespace can distinguish. arg = _get_or_create_arg_by_name(state, line_stripped) state.current_arg = arg - elif ':' in line_stripped: + elif _line_is_numpy_parameter_type(line_info): possible_args, type_data = line_stripped.split(':', 1) arg_names = _as_arg_names(possible_args) # re.split(' |,', s) if arg_names: @@ -465,17 +507,25 @@ def _consume_line(line_info, state): pass -def _create_line_info(line, next_line): - """Returns information about the current and next line of the docstring.""" +def _create_line_info(line, next_line, previous_line): + """Returns information about the current line and surrounding lines.""" line_info = Namespace() # TODO(dbieber): Switch to an explicit class. line_info.line = line line_info.stripped = line.strip() + line_info.remaining_raw = line_info.line line_info.remaining = line_info.stripped line_info.indentation = len(line) - len(line.lstrip()) + # TODO(dbieber): If next_line is blank, use the next non-blank line. line_info.next.line = next_line - line_info.next.stripped = next_line.strip() if next_line else None + next_line_exists = next_line is not None + line_info.next.stripped = next_line.strip() if next_line_exists else None line_info.next.indentation = ( - len(next_line) - len(next_line.lstrip()) if next_line else None) + len(next_line) - len(next_line.lstrip()) if next_line_exists else None) + line_info.previous.line = previous_line + previous_line_exists = previous_line is not None + line_info.previous.indentation = ( + len(previous_line) - + len(previous_line.lstrip()) if previous_line_exists else None) # Note: This counts all whitespace equally. return line_info @@ -497,6 +547,7 @@ def _update_section_state(line_info, state): state.section.format = Formats.GOOGLE state.section.title = google_section line_info.remaining = _get_after_google_header(line_info) + line_info.remaining_raw = line_info.remaining section_updated = True rst_section = _rst_section(line_info) @@ -504,6 +555,7 @@ def _update_section_state(line_info, state): state.section.format = Formats.RST state.section.title = rst_section line_info.remaining = _get_after_directive(line_info) + line_info.remaining_raw = line_info.remaining section_updated = True numpy_section = _numpy_section(line_info) @@ -511,6 +563,7 @@ def _update_section_state(line_info, state): state.section.format = Formats.NUMPY state.section.title = numpy_section line_info.remaining = '' + line_info.remaining_raw = line_info.remaining section_updated = True if section_updated: @@ -693,3 +746,29 @@ def _numpy_section(line_info): return _section_from_possible_title(possible_title) else: return None + + +def _line_is_numpy_parameter_type(line_info): + """Returns whether the line contains a numpy style parameter type definition. + + We look for a line of the form: + x : type + + And we have to exclude false positives on argument descriptions containing a + colon by checking the indentation of the line above. + + Args: + line_info: Information about the current line. + Returns: + True if the line is a numpy parameter type definition, False otherwise. + """ + line_stripped = line_info.remaining.strip() + if ':' in line_stripped: + previous_indent = line_info.previous.indentation + current_indent = line_info.indentation + if ':' in line_info.previous.line and current_indent > previous_indent: + # The parameter type was the previous line; this is the description. + return False + else: + return True + return False diff --git a/fire/docstrings_fuzz_test.py b/fire/docstrings_fuzz_test.py index 7609f4f8..66be8006 100644 --- a/fire/docstrings_fuzz_test.py +++ b/fire/docstrings_fuzz_test.py @@ -14,10 +14,6 @@ """Fuzz tests for the docstring parser module.""" -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from fire import docstrings from fire import testutils diff --git a/fire/docstrings_test.py b/fire/docstrings_test.py index 96810c7e..ce516944 100644 --- a/fire/docstrings_test.py +++ b/fire/docstrings_test.py @@ -14,16 +14,14 @@ """Tests for fire docstrings module.""" -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from fire import docstrings from fire import testutils - -DocstringInfo = docstrings.DocstringInfo # pylint: disable=invalid-name -ArgInfo = docstrings.ArgInfo # pylint: disable=invalid-name +# pylint: disable=invalid-name +DocstringInfo = docstrings.DocstringInfo +ArgInfo = docstrings.ArgInfo +KwargInfo = docstrings.KwargInfo +# pylint: enable=invalid-name class DocstringsTest(testutils.BaseTestCase): @@ -34,7 +32,7 @@ def test_one_line_simple(self): expected_docstring_info = DocstringInfo( summary='A simple one line docstring.', ) - self.assertEqual(docstring_info, expected_docstring_info) + self.assertEqual(expected_docstring_info, docstring_info) def test_one_line_simple_whitespace(self): docstring = """ @@ -44,45 +42,45 @@ def test_one_line_simple_whitespace(self): expected_docstring_info = DocstringInfo( summary='A simple one line docstring.', ) - self.assertEqual(docstring_info, expected_docstring_info) + self.assertEqual(expected_docstring_info, docstring_info) def test_one_line_too_long(self): # pylint: disable=line-too-long - docstring = """A one line docstring thats both a little too verbose and a little too long so it keeps going well beyond a reasonable length for a one-liner. + docstring = """A one line docstring that is both a little too verbose and a little too long so it keeps going well beyond a reasonable length for a one-liner. """ # pylint: enable=line-too-long docstring_info = docstrings.parse(docstring) expected_docstring_info = DocstringInfo( - summary='A one line docstring thats both a little too verbose and ' + summary='A one line docstring that is both a little too verbose and ' 'a little too long so it keeps going well beyond a reasonable length ' 'for a one-liner.', ) - self.assertEqual(docstring_info, expected_docstring_info) + self.assertEqual(expected_docstring_info, docstring_info) def test_one_line_runs_over(self): # pylint: disable=line-too-long - docstring = """A one line docstring thats both a little too verbose and a little too long + docstring = """A one line docstring that is both a little too verbose and a little too long so it runs onto a second line. """ # pylint: enable=line-too-long docstring_info = docstrings.parse(docstring) expected_docstring_info = DocstringInfo( - summary='A one line docstring thats both a little too verbose and ' + summary='A one line docstring that is both a little too verbose and ' 'a little too long so it runs onto a second line.', ) - self.assertEqual(docstring_info, expected_docstring_info) + self.assertEqual(expected_docstring_info, docstring_info) def test_one_line_runs_over_whitespace(self): docstring = """ - A one line docstring thats both a little too verbose and a little too long + A one line docstring that is both a little too verbose and a little too long so it runs onto a second line. """ docstring_info = docstrings.parse(docstring) expected_docstring_info = DocstringInfo( - summary='A one line docstring thats both a little too verbose and ' + summary='A one line docstring that is both a little too verbose and ' 'a little too long so it runs onto a second line.', ) - self.assertEqual(docstring_info, expected_docstring_info) + self.assertEqual(expected_docstring_info, docstring_info) def test_google_format_args_only(self): docstring = """One line description. @@ -99,7 +97,7 @@ def test_google_format_args_only(self): ArgInfo(name='arg2', description='arg2_description'), ] ) - self.assertEqual(docstring_info, expected_docstring_info) + self.assertEqual(expected_docstring_info, docstring_info) def test_google_format_arg_named_args(self): docstring = """ @@ -112,7 +110,7 @@ def test_google_format_arg_named_args(self): ArgInfo(name='args', description='arg_description'), ] ) - self.assertEqual(docstring_info, expected_docstring_info) + self.assertEqual(expected_docstring_info, docstring_info) def test_google_format_typed_args_and_returns(self): docstring = """Docstring summary. @@ -131,7 +129,7 @@ def test_google_format_typed_args_and_returns(self): expected_docstring_info = DocstringInfo( summary='Docstring summary.', description='This is a longer description of the docstring. It spans ' - 'multiple lines, as is allowed.', + 'multiple lines, as\nis allowed.', args=[ ArgInfo(name='param1', type='int', description='The first parameter.'), @@ -140,7 +138,33 @@ def test_google_format_typed_args_and_returns(self): ], returns='bool: The return value. True for success, False otherwise.' ) - self.assertEqual(docstring_info, expected_docstring_info) + self.assertEqual(expected_docstring_info, docstring_info) + + def test_google_format_multiline_arg_description(self): + docstring = """Docstring summary. + + This is a longer description of the docstring. It spans multiple lines, as + is allowed. + + Args: + param1 (int): The first parameter. + param2 (str): The second parameter. This has a lot of text, enough to + cover two lines. + """ + docstring_info = docstrings.parse(docstring) + expected_docstring_info = DocstringInfo( + summary='Docstring summary.', + description='This is a longer description of the docstring. It spans ' + 'multiple lines, as\nis allowed.', + args=[ + ArgInfo(name='param1', type='int', + description='The first parameter.'), + ArgInfo(name='param2', type='str', + description='The second parameter. This has a lot of text, ' + 'enough to cover two lines.'), + ], + ) + self.assertEqual(expected_docstring_info, docstring_info) def test_rst_format_typed_args_and_returns(self): docstring = """Docstring summary. @@ -159,7 +183,7 @@ def test_rst_format_typed_args_and_returns(self): expected_docstring_info = DocstringInfo( summary='Docstring summary.', description='This is a longer description of the docstring. It spans ' - 'across multiple lines.', + 'across multiple\nlines.', args=[ ArgInfo(name='arg1', type='str', description='Description of arg1.'), @@ -169,7 +193,7 @@ def test_rst_format_typed_args_and_returns(self): returns='int -- description of the return value.', raises='AttributeError, KeyError', ) - self.assertEqual(docstring_info, expected_docstring_info) + self.assertEqual(expected_docstring_info, docstring_info) def test_numpy_format_typed_args_and_returns(self): docstring = """Docstring summary. @@ -193,7 +217,7 @@ def test_numpy_format_typed_args_and_returns(self): expected_docstring_info = DocstringInfo( summary='Docstring summary.', description='This is a longer description of the docstring. It spans ' - 'across multiple lines.', + 'across multiple\nlines.', args=[ ArgInfo(name='param1', type='int', description='The first parameter.'), @@ -203,7 +227,36 @@ def test_numpy_format_typed_args_and_returns(self): # TODO(dbieber): Support return type. returns='bool True if successful, False otherwise.', ) - self.assertEqual(docstring_info, expected_docstring_info) + self.assertEqual(expected_docstring_info, docstring_info) + + def test_numpy_format_multiline_arg_description(self): + docstring = """Docstring summary. + + This is a longer description of the docstring. It spans across multiple + lines. + + Parameters + ---------- + param1 : int + The first parameter. + param2 : str + The second parameter. This has a lot of text, enough to cover two + lines. + """ + docstring_info = docstrings.parse(docstring) + expected_docstring_info = DocstringInfo( + summary='Docstring summary.', + description='This is a longer description of the docstring. It spans ' + 'across multiple\nlines.', + args=[ + ArgInfo(name='param1', type='int', + description='The first parameter.'), + ArgInfo(name='param2', type='str', + description='The second parameter. This has a lot of text, ' + 'enough to cover two lines.'), + ], + ) + self.assertEqual(expected_docstring_info, docstring_info) def test_multisection_docstring(self): docstring = """Docstring summary. @@ -216,11 +269,26 @@ def test_multisection_docstring(self): docstring_info = docstrings.parse(docstring) expected_docstring_info = DocstringInfo( summary='Docstring summary.', - description='This is the first section of a docstring description.\n\n' - 'This is the second section of a docstring description. This docstring ' + description='This is the first section of a docstring description.' + '\n\n' + 'This is the second section of a docstring description. This docstring' + '\n' 'description has just two sections.', ) - self.assertEqual(docstring_info, expected_docstring_info) + self.assertEqual(expected_docstring_info, docstring_info) + + def test_google_section_with_blank_first_line(self): + docstring = """Inspired by requests HTTPAdapter docstring. + + :param x: Simple param. + + Usage: + + >>> import requests + """ + docstring_info = docstrings.parse(docstring) + self.assertEqual('Inspired by requests HTTPAdapter docstring.', + docstring_info.summary) def test_ill_formed_docstring(self): docstring = """Docstring summary. @@ -232,6 +300,62 @@ def test_ill_formed_docstring(self): """ docstrings.parse(docstring) + def test_strip_blank_lines(self): + lines = [' ', ' foo ', ' '] + expected_output = [' foo '] + + self.assertEqual(expected_output, docstrings._strip_blank_lines(lines)) # pylint: disable=protected-access + + def test_numpy_colon_in_description(self): + docstring = """ + Greets name. + + Arguments + --------- + name : str + name, default : World + arg2 : int + arg2, default:None + arg3 : bool + """ + docstring_info = docstrings.parse(docstring) + expected_docstring_info = DocstringInfo( + summary='Greets name.', + description=None, + args=[ + ArgInfo(name='name', type='str', + description='name, default : World'), + ArgInfo(name='arg2', type='int', + description='arg2, default:None'), + ArgInfo(name='arg3', type='bool', description=None), + ] + ) + self.assertEqual(expected_docstring_info, docstring_info) + + def test_rst_format_typed_args_and_kwargs(self): + docstring = """Docstring summary. + + :param arg1: Description of arg1. + :type arg1: str. + :key arg2: Description of arg2. + :type arg2: bool. + :key arg3: Description of arg3. + :type arg3: str. + """ + docstring_info = docstrings.parse(docstring) + expected_docstring_info = DocstringInfo( + summary='Docstring summary.', + args=[ + ArgInfo(name='arg1', type='str', + description='Description of arg1.'), + KwargInfo(name='arg2', type='bool', + description='Description of arg2.'), + KwargInfo(name='arg3', type='str', + description='Description of arg3.'), + ], + ) + self.assertEqual(expected_docstring_info, docstring_info) + if __name__ == '__main__': testutils.main() diff --git a/fire/fire_import_test.py b/fire/fire_import_test.py index c5975681..a6b4acc3 100644 --- a/fire/fire_import_test.py +++ b/fire/fire_import_test.py @@ -15,10 +15,10 @@ """Tests importing the fire module.""" import sys +from unittest import mock import fire from fire import testutils -import mock class FireImportTest(testutils.BaseTestCase): diff --git a/fire/fire_test.py b/fire/fire_test.py index 734c123e..99b4a7c6 100644 --- a/fire/fire_test.py +++ b/fire/fire_test.py @@ -14,21 +14,14 @@ """Tests for the fire module.""" -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import os import sys -import unittest +from unittest import mock import fire from fire import test_components as tc from fire import testutils -import mock -import six - class FireTest(testutils.BaseTestCase): @@ -60,7 +53,7 @@ def testFireDefaultName(self): with mock.patch.object(sys, 'argv', [os.path.join('python-fire', 'fire', 'base_filename.py')]): - with self.assertOutputMatches(stdout='Usage: base_filename.py', + with self.assertOutputMatches(stdout='SYNOPSIS.*base_filename.py', stderr=None): fire.Fire(tc.Empty) @@ -185,7 +178,6 @@ def testFireAnnotatedArgs(self): self.assertEqual(fire.Fire(tc.Annotations, command=['double', '5']), 10) self.assertEqual(fire.Fire(tc.Annotations, command=['triple', '5']), 15) - @unittest.skipIf(six.PY2, 'Keyword-only arguments not in Python 2.') def testFireKeywordOnlyArgs(self): with self.assertRaisesFireExit(2): # Keyword arguments must be passed with flag syntax. @@ -290,6 +282,16 @@ def testFireObjectWithDict(self): self.assertEqual( fire.Fire(tc.TypedProperties, command=['delta', 'nest', '0']), 'a') + def testFireSet(self): + component = tc.simple_set() + result = fire.Fire(component, command=[]) + self.assertEqual(len(result), 3) + + def testFireFrozenset(self): + component = tc.simple_frozenset() + result = fire.Fire(component, command=[]) + self.assertEqual(len(result), 3) + def testFireList(self): component = ['zero', 'one', 'two', 'three'] self.assertEqual(fire.Fire(component, command=['2']), 'two') @@ -308,6 +310,16 @@ def testFireObjectWithTuple(self): self.assertEqual(fire.Fire(tc.TypedProperties, command=['fox', '1']), 'divide') + def testFireObjectWithListAsObject(self): + self.assertEqual( + fire.Fire(tc.TypedProperties, command=['echo', 'count', 'bethany']), + 1) + + def testFireObjectWithTupleAsObject(self): + self.assertEqual( + fire.Fire(tc.TypedProperties, command=['fox', 'count', 'divide']), + 1) + def testFireNoComponent(self): self.assertEqual(fire.Fire(command=['tc', 'WithDefaults', 'double', '10']), 20) @@ -536,12 +548,12 @@ def testHelpFlag(self): fire.Fire(tc.BoolConverter, command=['--', '--help']) def testHelpFlagAndTraceFlag(self): - with self.assertRaisesFireExit(0, 'Fire trace:\n.*Usage:'): + with self.assertRaisesFireExit(0, 'Fire trace:\n.*SYNOPSIS'): fire.Fire(tc.BoolConverter, command=['as-bool', 'True', '--', '--help', '--trace']) - with self.assertRaisesFireExit(0, 'Fire trace:\n.*Usage:'): + with self.assertRaisesFireExit(0, 'Fire trace:\n.*SYNOPSIS'): fire.Fire(tc.BoolConverter, command=['as-bool', 'True', '--', '-h', '-t']) - with self.assertRaisesFireExit(0, 'Fire trace:\n.*Usage:'): + with self.assertRaisesFireExit(0, 'Fire trace:\n.*SYNOPSIS'): fire.Fire(tc.BoolConverter, command=['--', '-h', '--trace']) def testTabCompletionNoName(self): @@ -683,6 +695,27 @@ def testTraceErrors(self): with self.assertRaisesFireExit(2): fire.Fire(tc.InstanceVars, command=['--arg1=a1', '--arg2=a2', '-', 'jog']) + def testClassWithDefaultMethod(self): + self.assertEqual( + fire.Fire(tc.DefaultMethod, command=['double', '10']), 20 + ) + + def testClassWithInvalidProperty(self): + self.assertEqual( + fire.Fire(tc.InvalidProperty, command=['double', '10']), 20 + ) + + def testHelpKwargsDecorator(self): + # Issue #190, follow the wrapped method instead of crashing. + with self.assertRaisesFireExit(0): + fire.Fire(tc.decorated_method, command=['-h']) + with self.assertRaisesFireExit(0): + fire.Fire(tc.decorated_method, command=['--help']) + + def testFireAsyncio(self): + self.assertEqual(fire.Fire(tc.py3.WithAsyncio, + command=['double', '--count', '10']), 20) + if __name__ == '__main__': testutils.main() diff --git a/fire/formatting.py b/fire/formatting.py index 3f818377..68484c27 100644 --- a/fire/formatting.py +++ b/fire/formatting.py @@ -14,13 +14,13 @@ """Formatting utilities for use in creating help text.""" -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - +from fire import formatting_windows # pylint: disable=unused-import import termcolor +ELLIPSIS = '...' + + def Indent(text, spaces=2): lines = text.split('\n') return '\n'.join( @@ -34,3 +34,60 @@ def Bold(text): def Underline(text): return termcolor.colored(text, attrs=['underline']) + + +def BoldUnderline(text): + return Bold(Underline(text)) + + +def WrappedJoin(items, separator=' | ', width=80): + """Joins the items by the separator, wrapping lines at the given width.""" + lines = [] + current_line = '' + for index, item in enumerate(items): + is_final_item = index == len(items) - 1 + if is_final_item: + if len(current_line) + len(item) <= width: + current_line += item + else: + lines.append(current_line.rstrip()) + current_line = item + else: + if len(current_line) + len(item) + len(separator) <= width: + current_line += item + separator + else: + lines.append(current_line.rstrip()) + current_line = item + separator + + lines.append(current_line) + return lines + + +def Error(text): + return termcolor.colored(text, color='red', attrs=['bold']) + + +def EllipsisTruncate(text, available_space, line_length): + """Truncate text from the end with ellipsis.""" + if available_space < len(ELLIPSIS): + available_space = line_length + # No need to truncate + if len(text) <= available_space: + return text + return text[:available_space - len(ELLIPSIS)] + ELLIPSIS + + +def EllipsisMiddleTruncate(text, available_space, line_length): + """Truncates text from the middle with ellipsis.""" + if available_space < len(ELLIPSIS): + available_space = line_length + if len(text) < available_space: + return text + available_string_len = available_space - len(ELLIPSIS) + first_half_len = int(available_string_len / 2) # start from middle + second_half_len = available_string_len - first_half_len + return text[:first_half_len] + ELLIPSIS + text[-second_half_len:] + + +def DoubleQuote(text): + return '"%s"' % text diff --git a/fire/formatting_test.py b/fire/formatting_test.py index 805d5455..e0f6699d 100644 --- a/fire/formatting_test.py +++ b/fire/formatting_test.py @@ -14,23 +14,21 @@ """Tests for formatting.py.""" -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from fire import formatting from fire import testutils +LINE_LENGTH = 80 + class FormattingTest(testutils.BaseTestCase): def test_bold(self): text = formatting.Bold('hello') - self.assertEqual('\x1b[1mhello\x1b[0m', text) + self.assertIn(text, ['hello', '\x1b[1mhello\x1b[0m']) def test_underline(self): text = formatting.Underline('hello') - self.assertEqual('\x1b[4mhello\x1b[0m', text) + self.assertIn(text, ['hello', '\x1b[4mhello\x1b[0m']) def test_indent(self): text = formatting.Indent('hello', spaces=2) @@ -40,6 +38,41 @@ def test_indent_multiple_lines(self): text = formatting.Indent('hello\nworld', spaces=2) self.assertEqual(' hello\n world', text) + def test_wrap_one_item(self): + lines = formatting.WrappedJoin(['rice']) + self.assertEqual(['rice'], lines) + + def test_wrap_multiple_items(self): + lines = formatting.WrappedJoin(['rice', 'beans', 'chicken', 'cheese'], + width=15) + self.assertEqual(['rice | beans |', + 'chicken |', + 'cheese'], lines) + + def test_ellipsis_truncate(self): + text = 'This is a string' + truncated_text = formatting.EllipsisTruncate( + text=text, available_space=10, line_length=LINE_LENGTH) + self.assertEqual('This is...', truncated_text) + + def test_ellipsis_truncate_not_enough_space(self): + text = 'This is a string' + truncated_text = formatting.EllipsisTruncate( + text=text, available_space=2, line_length=LINE_LENGTH) + self.assertEqual('This is a string', truncated_text) + + def test_ellipsis_middle_truncate(self): + text = '1000000000L' + truncated_text = formatting.EllipsisMiddleTruncate( + text=text, available_space=7, line_length=LINE_LENGTH) + self.assertEqual('10...0L', truncated_text) + + def test_ellipsis_middle_truncate_not_enough_space(self): + text = '1000000000L' + truncated_text = formatting.EllipsisMiddleTruncate( + text=text, available_space=2, line_length=LINE_LENGTH) + self.assertEqual('1000000000L', truncated_text) + if __name__ == '__main__': testutils.main() diff --git a/fire/formatting_windows.py b/fire/formatting_windows.py new file mode 100644 index 00000000..749ab6d0 --- /dev/null +++ b/fire/formatting_windows.py @@ -0,0 +1,58 @@ +# Copyright (C) 2018 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""This module is used for enabling formatting on Windows.""" + +import ctypes +import os +import platform +import subprocess +import sys + +try: + import colorama # pylint: disable=g-import-not-at-top + HAS_COLORAMA = True +except ImportError: + HAS_COLORAMA = False + + +def initialize_or_disable(): + """Enables ANSI processing on Windows or disables it as needed.""" + if HAS_COLORAMA: + wrap = True + if (hasattr(sys.stdout, 'isatty') + and sys.stdout.isatty() + and platform.release() == '10'): + # Enables native ANSI sequences in console. + # Windows 10, 2016, and 2019 only. + + wrap = False + kernel32 = ctypes.windll.kernel32 + enable_virtual_terminal_processing = 0x04 + out_handle = kernel32.GetStdHandle(subprocess.STD_OUTPUT_HANDLE) # pylint: disable=line-too-long, + # GetConsoleMode fails if the terminal isn't native. + mode = ctypes.wintypes.DWORD() + if kernel32.GetConsoleMode(out_handle, ctypes.byref(mode)) == 0: + wrap = True + if not mode.value & enable_virtual_terminal_processing: + if kernel32.SetConsoleMode( + out_handle, mode.value | enable_virtual_terminal_processing) == 0: + # kernel32.SetConsoleMode to enable ANSI sequences failed + wrap = True + colorama.init(wrap=wrap) + else: + os.environ['ANSI_COLORS_DISABLED'] = '1' + +if sys.platform.startswith('win'): + initialize_or_disable() diff --git a/fire/helptext.py b/fire/helptext.py index 6648fef0..347278da 100644 --- a/fire/helptext.py +++ b/fire/helptext.py @@ -12,11 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""helptext is the new, work in progress, help text module for Fire. - -This is a fork of, and is intended to replace, helputils. - -Utility for producing help strings for use in Fire CLIs. +"""Utilities for producing help strings for use in Fire CLIs. Can produce help strings suitable for display in Fire CLIs for any type of Python object, module, class, or function. @@ -33,499 +29,759 @@ information. """ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function +from __future__ import annotations -import inspect +import collections +import itertools from fire import completion +from fire import custom_descriptions +from fire import decorators +from fire import docstrings from fire import formatting from fire import inspectutils from fire import value_types +LINE_LENGTH = 80 +SECTION_INDENTATION = 4 +SUBSECTION_INDENTATION = 4 -def HelpString(component, trace=None, verbose=False): - """Returns the text to show for a supplied component. - The component can be any Python class, object, function, module, etc. +def HelpText(component, trace=None, verbose=False): + """Gets the help string for the current component, suitable for a help screen. Args: - component: The component to determine the help string for. - trace: The Fire trace leading to this component. - verbose: Whether to include private members in the help string. + component: The component to construct the help string for. + trace: The Fire trace of the command so far. The command executed so far + can be extracted from this trace. + verbose: Whether to include private members in the help screen. + Returns: - String suitable for display giving information about the component. + The full help screen as a string. """ + # Preprocessing needed to create the sections: info = inspectutils.Info(component) - - is_error_screen = False - if trace: - is_error_screen = trace.HasError() - - if is_error_screen: - return UsageText(component, info, trace, verbose=verbose) - else: - return HelpText(component, info, trace, verbose=verbose) - - -def GetArgsAngFlags(component): - """Returns all types of arguments and flags of a component.""" + actions_grouped_by_kind = _GetActionsGroupedByKind(component, verbose=verbose) spec = inspectutils.GetFullArgSpec(component) - args = spec.args - if spec.defaults is None: - num_defaults = 0 + metadata = decorators.GetMetadata(component) + + # Sections: + name_section = _NameSection(component, info, trace=trace, verbose=verbose) + synopsis_section = _SynopsisSection( + component, actions_grouped_by_kind, spec, metadata, trace=trace) + description_section = _DescriptionSection(component, info) + # TODO(dbieber): Add returns and raises sections for functions. + + if callable(component): + args_and_flags_sections, notes_sections = _ArgsAndFlagsSections( + info, spec, metadata) else: - num_defaults = len(spec.defaults) - args_with_no_defaults = args[:len(args) - num_defaults] - args_with_defaults = args[len(args) - num_defaults:] - flags = args_with_defaults + spec.kwonlyargs - return args_with_no_defaults, args_with_defaults, flags + args_and_flags_sections = [] + notes_sections = [] + usage_details_sections = _UsageDetailsSections(component, + actions_grouped_by_kind) + + sections = ( + [name_section, synopsis_section, description_section] + + args_and_flags_sections + + usage_details_sections + + notes_sections + ) + valid_sections = [section for section in sections if section is not None] + return '\n\n'.join( + _CreateOutputSection(name, content) + for name, content in valid_sections + ) -def GetSummaryAndDescription(docstring_info): - """Retrieves summary and description for help text generation.""" +def _NameSection(component, info, trace=None, verbose=False) -> tuple[str, str]: + """The "Name" section of the help string.""" - # To handle both empty string and None - summary = docstring_info.summary if docstring_info.summary else None - description = ( - docstring_info.description if docstring_info.description else None) - return summary, description + # Only include separators in the name in verbose mode. + current_command = _GetCurrentCommand(trace, include_separators=verbose) + summary = _GetSummary(info) + # If the docstring is one of the messy builtin docstrings, show custom one. + if custom_descriptions.NeedsCustomDescription(component): + available_space = LINE_LENGTH - SECTION_INDENTATION - len(current_command + + ' - ') + summary = custom_descriptions.GetSummary(component, available_space, + LINE_LENGTH) -def GetCurrentCommand(trace=None): - """Returns current command for the purpose of generating help text.""" - if trace: - current_command = trace.GetCommand() + if summary: + text = f'{current_command} - {summary}' else: - current_command = '' + text = current_command + return ('NAME', text) - return current_command +def _SynopsisSection(component, actions_grouped_by_kind, spec, metadata, + trace=None) -> tuple[str, str]: + """The "Synopsis" section of the help string.""" + current_command = _GetCurrentCommand(trace=trace, include_separators=True) -def HelpText(component, info, trace=None, verbose=False): - if inspect.isroutine(component) or inspect.isclass(component): - return HelpTextForFunction(component, info, trace) - else: - return HelpTextForObject(component, info, trace, verbose) + possible_actions = _GetPossibleActions(actions_grouped_by_kind) + + continuations = [] + if possible_actions: + continuations.append(_GetPossibleActionsString(possible_actions)) + if callable(component): + callable_continuation = _GetArgsAndFlagsString(spec, metadata) + if callable_continuation: + continuations.append(callable_continuation) + elif trace: + # This continuation might be blank if no args are needed. + # In this case, show a separator. + continuations.append(trace.separator) + continuation = ' | '.join(continuations) + text = f'{current_command} {continuation}' + return ('SYNOPSIS', text) -def GetDescriptionSectionText(summary, description): - """Returns description section text based on the input docstring info. - Returns the string that should be used as description section based on the - input. The logic is the following: If there's description available, use it. - Otherwise, use summary if available. If neither description or summary is - available, returns None. +def _DescriptionSection(component, info) -> tuple[str, str] | None: + """The "Description" sections of the help string. Args: - summary: summary found in object summary - description: description found in object docstring + component: The component to produce the description section for. + info: The info dict for the component of interest. Returns: - String for the description section in help screen. + Returns the description if available. If not, returns the summary. + If neither are available, returns None. """ - if not (description or summary): + if custom_descriptions.NeedsCustomDescription(component): + available_space = LINE_LENGTH - SECTION_INDENTATION + description = custom_descriptions.GetDescription(component, available_space, + LINE_LENGTH) + summary = custom_descriptions.GetSummary(component, available_space, + LINE_LENGTH) + else: + description = _GetDescription(info) + summary = _GetSummary(info) + # Fall back to summary if description is not available. + text = description or summary or None + if text: + return ('DESCRIPTION', text) + else: return None - if description: - return description - else: - return summary +def _CreateKeywordOnlyFlagItem(flag, docstring_info, spec, short_arg): + return _CreateFlagItem( + flag, docstring_info, spec, required=flag not in spec.kwonlydefaults, + short_arg=short_arg) -def HelpTextForFunction(component, info, trace=None, verbose=False): - """Returns detail help text for a function component. + +def _GetShortFlags(flags): + """Gets a list of single-character flags that uniquely identify a flag. Args: - component: Current component to generate help text for. - info: Info containing metadata of component. - trace: FireTrace object that leads to current component. - verbose: Whether to display help text in verbose mode. + flags: list of strings representing flags Returns: - Formatted help text for display. + List of single character short flags, + where the character occurred at the start of a flag once. """ - # TODO(joejoevictor): Implement verbose related output - del verbose + short_flags = [f[0] for f in flags] + short_flag_counts = collections.Counter(short_flags) + return [v for v in short_flags if short_flag_counts[v] == 1] - current_command = GetCurrentCommand(trace) - summary, description = GetSummaryAndDescription(info['docstring_info']) - spec = inspectutils.GetFullArgSpec(component) - args = spec.args - args_with_no_defaults, args_with_defaults, flags = GetArgsAngFlags(component) - del args_with_defaults +def _ArgsAndFlagsSections(info, spec, metadata): + """The "Args and Flags" sections of the help string.""" + args_with_no_defaults = spec.args[:len(spec.args) - len(spec.defaults)] + args_with_defaults = spec.args[len(spec.args) - len(spec.defaults):] - # Name section - name_section_template = '{current_command}{command_summary}' - command_summary_str = ' - ' + summary if summary else '' - name_section = name_section_template.format( - current_command=current_command, command_summary=command_summary_str) + # Check if positional args are allowed. If not, require flag syntax for args. + accepts_positional_args = metadata.get(decorators.ACCEPTS_POSITIONAL_ARGS) - args_and_flags = '' - if args_with_no_defaults: - items = [arg.upper() for arg in args_with_no_defaults] - args_and_flags = ' '.join(items) - - synopsis_flag_template = '[--{flag_name}={flag_name_upper}]' - if flags: - items = [ - synopsis_flag_template.format( - flag_name=flag, flag_name_upper=flag.upper()) for flag in flags - ] - args_and_flags = args_and_flags + ' '.join(items) - - # Synopsis section - synopsis_section_template = '{current_command} {args_and_flags}' - positional_arguments = '|'.join(args) - if positional_arguments: - positional_arguments = ' ' + positional_arguments - synopsis_section = synopsis_section_template.format( - current_command=current_command, args_and_flags=args_and_flags) - - # Description section - command_description = GetDescriptionSectionText(summary, description) - description_sections = [] - if command_description: - description_sections.append(('DESCRIPTION', command_description)) - - # Positional arguments and flags section - docstring_info = info['docstring_info'] args_and_flags_sections = [] notes_sections = [] - pos_arg_items = [] - pos_arg_items = [ - _CreatePositionalArgItem(arg, docstring_info) + docstring_info = info['docstring_info'] + + arg_items = [ + _CreateArgItem(arg, docstring_info, spec) for arg in args_with_no_defaults ] - if pos_arg_items: - positional_arguments_section = ('POSITIONAL ARGUMENTS', - '\n'.join(pos_arg_items).rstrip('\n')) - args_and_flags_sections.append(positional_arguments_section) - notes_sections.append( - ('NOTES', 'You could also use flags syntax for POSITIONAL ARGUMENTS') + + if spec.varargs: + arg_items.append( + _CreateArgItem(spec.varargs, docstring_info, spec) ) - flag_items = [ - _CreateFlagItem(flag, docstring_info) - for flag in flags + if arg_items: + title = 'POSITIONAL ARGUMENTS' if accepts_positional_args else 'ARGUMENTS' + arguments_section = (title, '\n'.join(arg_items).rstrip('\n')) + args_and_flags_sections.append(arguments_section) + if args_with_no_defaults and accepts_positional_args: + notes_sections.append( + ('NOTES', 'You can also use flags syntax for POSITIONAL ARGUMENTS') + ) + + unique_short_args = _GetShortFlags(args_with_defaults) + positional_flag_items = [ + _CreateFlagItem( + flag, docstring_info, spec, required=False, + short_arg=flag[0] in unique_short_args + ) + for flag in args_with_defaults ] + unique_short_kwonly_flags = _GetShortFlags(spec.kwonlyargs) + kwonly_flag_items = [ + _CreateKeywordOnlyFlagItem( + flag, docstring_info, spec, + short_arg=flag[0] in unique_short_kwonly_flags + ) + for flag in spec.kwonlyargs + ] + flag_items = positional_flag_items + kwonly_flag_items + + if spec.varkw: + # Include kwargs documented via :key param: + documented_kwargs = [] + + # add short flags if possible + flags = docstring_info.args or [] + flag_names = [f.name for f in flags] + unique_short_flags = _GetShortFlags(flag_names) + for flag in flags: + if isinstance(flag, docstrings.KwargInfo): + if flag.name[0] in unique_short_flags: + short_name = flag.name[0] + flag_string = f'-{short_name}, --{flag.name}' + else: + flag_string = f'--{flag.name}' + + flag_item = _CreateFlagItem( + flag.name, docstring_info, spec, + flag_string=flag_string) + documented_kwargs.append(flag_item) + if documented_kwargs: + # Separate documented kwargs from other flags using a message + if flag_items: + message = 'The following flags are also accepted.' + item = _CreateItem(message, None, indent=4) + flag_items.append(item) + flag_items.extend(documented_kwargs) + + description = _GetArgDescription(spec.varkw, docstring_info) + if documented_kwargs: + message = 'Additional undocumented flags may also be accepted.' + elif flag_items: + message = 'Additional flags are accepted.' + else: + message = 'Flags are accepted.' + item = _CreateItem(message, description, indent=4) + flag_items.append(item) + if flag_items: flags_section = ('FLAGS', '\n'.join(flag_items)) args_and_flags_sections.append(flags_section) - output_sections = [ - ('NAME', name_section), - ('SYNOPSIS', synopsis_section), - ] + description_sections + args_and_flags_sections + notes_sections + return args_and_flags_sections, notes_sections - return '\n\n'.join( - _CreateOutputSection(name, content) - for name, content in output_sections - ) + +def _UsageDetailsSections(component, actions_grouped_by_kind): + """The usage details sections of the help string.""" + groups, commands, values, indexes = actions_grouped_by_kind + + sections = [] + if groups.members: + sections.append(_MakeUsageDetailsSection(groups)) + if commands.members: + sections.append(_MakeUsageDetailsSection(commands)) + if values.members: + sections.append(_ValuesUsageDetailsSection(component, values)) + if indexes.members: + sections.append(('INDEXES', _NewChoicesSection('INDEX', indexes.names))) + + return sections + + +def _GetSummary(info): + docstring_info = info['docstring_info'] + return docstring_info.summary if docstring_info.summary else None + + +def _GetDescription(info): + docstring_info = info['docstring_info'] + return docstring_info.description if docstring_info.description else None + + +def _GetArgsAndFlagsString(spec, metadata): + """The args and flags string for showing how to call a function. + + If positional arguments are accepted, the args will be shown as positional. + E.g. "ARG1 ARG2 [--flag=FLAG]" + + If positional arguments are disallowed, the args will be shown with flags + syntax. + E.g. "--arg1=ARG1 [--flag=FLAG]" + + Args: + spec: The full arg spec for the component to construct the args and flags + string for. + metadata: Metadata for the component, including whether it accepts + positional arguments. + + Returns: + The constructed args and flags string. + """ + args_with_no_defaults = spec.args[:len(spec.args) - len(spec.defaults)] + args_with_defaults = spec.args[len(spec.args) - len(spec.defaults):] + + # Check if positional args are allowed. If not, require flag syntax for args. + accepts_positional_args = metadata.get(decorators.ACCEPTS_POSITIONAL_ARGS) + + arg_and_flag_strings = [] + if args_with_no_defaults: + if accepts_positional_args: + arg_strings = [formatting.Underline(arg.upper()) + for arg in args_with_no_defaults] + else: + arg_strings = [ + f'--{arg}={formatting.Underline(arg.upper())}' + for arg in args_with_no_defaults + ] + arg_and_flag_strings.extend(arg_strings) + + # If there are any arguments that are treated as flags: + if args_with_defaults or spec.kwonlyargs or spec.varkw: + arg_and_flag_strings.append('') + + if spec.varargs: + varargs_underlined = formatting.Underline(spec.varargs.upper()) + varargs_string = f'[{varargs_underlined}]...' + arg_and_flag_strings.append(varargs_string) + + return ' '.join(arg_and_flag_strings) + + +def _GetPossibleActions(actions_grouped_by_kind): + """The list of possible action kinds.""" + possible_actions = [] + for action_group in actions_grouped_by_kind: + if action_group.members: + possible_actions.append(action_group.name) + return possible_actions + + +def _GetPossibleActionsString(possible_actions): + """A help screen string listing the possible action kinds available.""" + return ' | '.join(formatting.Underline(action.upper()) + for action in possible_actions) + + +def _GetActionsGroupedByKind(component, verbose=False): + """Gets lists of available actions, grouped by action kind.""" + groups = ActionGroup(name='group', plural='groups') + commands = ActionGroup(name='command', plural='commands') + values = ActionGroup(name='value', plural='values') + indexes = ActionGroup(name='index', plural='indexes') + + members = completion.VisibleMembers(component, verbose=verbose) + for member_name, member in members: + member_name = str(member_name) + if value_types.IsGroup(member): + groups.Add(name=member_name, member=member) + if value_types.IsCommand(member): + commands.Add(name=member_name, member=member) + if value_types.IsValue(member): + values.Add(name=member_name, member=member) + + if isinstance(component, (list, tuple)) and component: + component_len = len(component) + if component_len < 10: + indexes.Add(name=', '.join(str(x) for x in range(component_len))) + else: + indexes.Add(name=f'0..{component_len-1}') + + return [groups, commands, values, indexes] + + +def _GetCurrentCommand(trace=None, include_separators=True): + """Returns current command for the purpose of generating help text.""" + if trace: + current_command = trace.GetCommand(include_separators=include_separators) + else: + current_command = '' + return current_command -def _CreateOutputSection(name, content): - return """{name} -{content}""".format(name=formatting.Bold(name), - content=formatting.Indent(content, 4)) +def _CreateOutputSection(name: str, content: str) -> str: + return f"""{formatting.Bold(name)} +{formatting.Indent(content, SECTION_INDENTATION)}""" -def _CreatePositionalArgItem(arg, docstring_info): +def _CreateArgItem(arg, docstring_info, spec): """Returns a string describing a positional argument. Args: arg: The name of the positional argument. docstring_info: A docstrings.DocstringInfo namedtuple with information about the containing function's docstring. + spec: An instance of fire.inspectutils.FullArgSpec, containing type and + default information about the arguments to a callable. + Returns: A string to be used in constructing the help screen for the function. """ - description = None - if docstring_info.args: - for arg_in_docstring in docstring_info.args: - if arg_in_docstring.name == arg: - description = arg_in_docstring.description - arg = arg.upper() - if description: - return _CreateItem(arg, description, indent=4) - else: - return arg + # The help string is indented, so calculate the maximum permitted length + # before indentation to avoid exceeding the maximum line length. + max_str_length = LINE_LENGTH - SECTION_INDENTATION - SUBSECTION_INDENTATION + + description = _GetArgDescription(arg, docstring_info) + + arg_string = formatting.BoldUnderline(arg.upper()) + + arg_type = _GetArgType(arg, spec) + arg_type = f'Type: {arg_type}' if arg_type else '' + available_space = max_str_length - len(arg_type) + arg_type = ( + formatting.EllipsisTruncate(arg_type, available_space, max_str_length)) + + description = '\n'.join(part for part in (arg_type, description) if part) + return _CreateItem(arg_string, description, indent=SUBSECTION_INDENTATION) -def _CreateFlagItem(flag, docstring_info): - """Returns a string describing a flag using information from the docstring. + +def _CreateFlagItem(flag, docstring_info, spec, required=False, + flag_string=None, short_arg=False): + """Returns a string describing a flag using docstring and FullArgSpec info. Args: flag: The name of the flag. docstring_info: A docstrings.DocstringInfo namedtuple with information about the containing function's docstring. + spec: An instance of fire.inspectutils.FullArgSpec, containing type and + default information about the arguments to a callable. + required: Whether the flag is required. + flag_string: If provided, use this string for the flag, rather than + constructing one from the flag name. + short_arg: Whether the flag has a short variation or not. Returns: A string to be used in constructing the help screen for the function. """ - description = None - if docstring_info.args: - for arg_in_docstring in docstring_info.args: - if arg_in_docstring.name == flag: - description = arg_in_docstring.description - break + # pylint: disable=g-bad-todo + # TODO(MichaelCG8): Get type and default information from docstrings if it is + # not available in FullArgSpec. This will require updating + # fire.docstrings.parser(). + + # The help string is indented, so calculate the maximum permitted length + # before indentation to avoid exceeding the maximum line length. + max_str_length = LINE_LENGTH - SECTION_INDENTATION - SUBSECTION_INDENTATION + + description = _GetArgDescription(flag, docstring_info) + + if not flag_string: + flag_name_upper = formatting.Underline(flag.upper()) + flag_string = f'--{flag}={flag_name_upper}' + if required: + flag_string += ' (required)' + if short_arg: + short_flag = flag[0] + flag_string = f'-{short_flag}, {flag_string}' + + arg_type = _GetArgType(flag, spec) + arg_default = _GetArgDefault(flag, spec) + + # We need to handle the case where there is a default of None, but otherwise + # the argument has another type. + if arg_default == 'None': + arg_type = f'Optional[{arg_type}]' + + arg_type = f'Type: {arg_type}' if arg_type else '' + available_space = max_str_length - len(arg_type) + arg_type = ( + formatting.EllipsisTruncate(arg_type, available_space, max_str_length)) + + arg_default = f'Default: {arg_default}' if arg_default else '' + available_space = max_str_length - len(arg_default) + arg_default = ( + formatting.EllipsisTruncate(arg_default, available_space, max_str_length)) + + description = '\n'.join( + part for part in (arg_type, arg_default, description) if part + ) - flag = '--{flag}'.format(flag=flag) - if description: - return _CreateItem(flag, description, indent=2) - else: - return flag + return _CreateItem(flag_string, description, indent=SUBSECTION_INDENTATION) -def _CreateItem(name, description, indent=2): - return """{name} -{description}""".format(name=name, - description=formatting.Indent(description, indent)) +def _GetArgType(arg, spec): + """Returns a string describing the type of an argument. + Args: + arg: The name of the argument. + spec: An instance of fire.inspectutils.FullArgSpec, containing type and + default information about the arguments to a callable. + Returns: + A string to be used in constructing the help screen for the function, the + empty string if the argument type is not available. + """ + if arg in spec.annotations: + arg_type = spec.annotations[arg] + try: + return arg_type.__qualname__ + except AttributeError: + # Some typing objects, such as typing.Union do not have either a __name__ + # or __qualname__ attribute. + # repr(typing.Union[int, str]) will return ': typing.Union[int, str]' + return repr(arg_type) + return '' -def HelpTextForObject(component, info, trace=None, verbose=False): - """Generates help text for python objects. - Args: - component: Current component to generate help text for. - info: Info containing metadata of component. - trace: FireTrace object that leads to current component. - verbose: Whether to display help text in verbose mode. +def _GetArgDefault(flag, spec): + """Returns a string describing a flag's default value. + Args: + flag: The name of the flag. + spec: An instance of fire.inspectutils.FullArgSpec, containing type and + default information about the arguments to a callable. Returns: - Formatted help text for display. + A string to be used in constructing the help screen for the function, the + empty string if the flag does not have a default or the default is not + available. """ - current_command = GetCurrentCommand(trace) + num_defaults = len(spec.defaults) + args_with_defaults = spec.args[-num_defaults:] - docstring_info = info['docstring_info'] - command_summary = docstring_info.summary if docstring_info.summary else '' - command_description = GetDescriptionSectionText(docstring_info.summary, - docstring_info.description) - groups = [] - commands = [] - values = [] - members = completion._Members(component, verbose) # pylint: disable=protected-access - for member_name, member in members: - if value_types.IsGroup(member): - groups.append((member_name, member)) - if value_types.IsCommand(member): - commands.append((member_name, member)) - if value_types.IsValue(member): - values.append((member_name, member)) + for arg, default in zip(args_with_defaults, spec.defaults): + if arg == flag: + return repr(default) + if flag in spec.kwonlydefaults: + return repr(spec.kwonlydefaults[flag]) + return '' - usage_details_sections = [] - possible_actions = [] - # TODO(joejoevictor): Add global flags to here. Also, if it's a callable, - # there will be additional flags. - possible_flags = '' - - if groups: - # TODO(joejoevictor): Add missing GROUPS section handling - possible_actions.append('GROUP') - if commands: - possible_actions.append('COMMAND') - command_item_strings = [] - for command_name, command in commands: - command_info = inspectutils.Info(command) - command_item = command_name - if 'docstring_info' in command_info: - command_docstring_info = command_info['docstring_info'] - if command_docstring_info and command_docstring_info.summary: - command_item = _CreateItem(command_name, - command_docstring_info.summary) - - command_item_strings.append(command_item) - usage_details_sections.append( - ('COMMANDS', _NewChoicesSection('COMMAND', command_item_strings))) - - if values: - possible_actions.append('VALUE') - value_item_strings = [] - for value_name, value in values: - del value - init_info = inspectutils.Info(component.__class__.__init__) - value_item = value_name - if 'docstring_info' in init_info: - init_docstring_info = init_info['docstring_info'] - if init_docstring_info.args: - for arg_info in init_docstring_info.args: - if arg_info.name == value_name: - value_item = _CreateItem(value_name, arg_info.description) - value_item_strings.append(value_item) - usage_details_sections.append( - ('VALUES', _NewChoicesSection('VALUE', value_item_strings))) - - possible_actions_string = ' | '.join( - formatting.Underline(action) for action in possible_actions) - - synopsis_template = '{current_command} {possible_actions}{possible_flags}' - synopsis_string = synopsis_template.format( - current_command=current_command, - possible_actions=possible_actions_string, - possible_flags=possible_flags) - - description_sections = [] - if command_description: - description_sections.append(('DESCRIPTION', command_description)) - - name_line = '{current_command} - {command_summary}'.format( - current_command=current_command, - command_summary=command_summary) - output_sections = [ - ('NAME', name_line), - ('SYNOPSIS', synopsis_string), - ] + description_sections + usage_details_sections - return '\n\n'.join( - _CreateOutputSection(name, content) - for name, content in output_sections - ) +def _CreateItem(name, description, indent=2): + if not description: + return name + description = formatting.Indent(description, indent) + return f"""{name} +{description}""" + + +def _GetArgDescription(name, docstring_info): + if docstring_info.args: + for arg_in_docstring in docstring_info.args: + if arg_in_docstring.name in (name, f'*{name}', f'**{name}'): + return arg_in_docstring.description + return None + + +def _MakeUsageDetailsSection(action_group): + """Creates a usage details section for the provided action group.""" + item_strings = [] + for name, member in action_group.GetItems(): + info = inspectutils.Info(member) + item = name + docstring_info = info.get('docstring_info') + if (docstring_info + and not custom_descriptions.NeedsCustomDescription(member)): + summary = docstring_info.summary + elif custom_descriptions.NeedsCustomDescription(member): + summary = custom_descriptions.GetSummary( + member, LINE_LENGTH - SECTION_INDENTATION, LINE_LENGTH) + else: + summary = None + item = _CreateItem(name, summary) + item_strings.append(item) + return (action_group.plural.upper(), + _NewChoicesSection(action_group.name.upper(), item_strings)) + + +def _ValuesUsageDetailsSection(component, values): + """Creates a section tuple for the values section of the usage details.""" + value_item_strings = [] + for value_name, value in values.GetItems(): + del value + init_info = inspectutils.Info(component.__class__.__init__) + value_item = None + if 'docstring_info' in init_info: + init_docstring_info = init_info['docstring_info'] + if init_docstring_info.args: + for arg_info in init_docstring_info.args: + if arg_info.name == value_name: + value_item = _CreateItem(value_name, arg_info.description) + if value_item is None: + value_item = str(value_name) + value_item_strings.append(value_item) + return ('VALUES', _NewChoicesSection('VALUE', value_item_strings)) def _NewChoicesSection(name, choices): + name_formatted = formatting.Bold(formatting.Underline(name)) return _CreateItem( - '{name} is one of the followings:'.format( - name=formatting.Bold(formatting.Underline(name))), + f'{name_formatted} is one of the following:', '\n' + '\n\n'.join(choices), indent=1) -def UsageText(component, info, trace=None, verbose=False): - del info # Unused. - if inspect.isroutine(component) or inspect.isclass(component): - return UsageTextForFunction(component, trace) - else: - return UsageTextForObject(component, trace, verbose) - - -def UsageTextForFunction(component, trace=None): - """Returns usage text for function objects. +def UsageText(component, trace=None, verbose=False): + """Returns usage text for the given component. Args: component: The component to determine the usage text for. trace: The Fire trace object containing all metadata of current execution. + verbose: Whether to display the usage text in verbose mode. Returns: - String suitable for display in error screen. + String suitable for display in an error screen. """ - - output_template = """Usage: {current_command} {args_and_flags} -{availability_lines} -For detailed information on this command, run: -{current_command}{hyphen_hyphen} --help -""" - + # Get the command so far: if trace: command = trace.GetCommand() - is_help_an_arg = trace.NeedsSeparatingHyphenHyphen() + needs_separating_hyphen_hyphen = trace.NeedsSeparatingHyphenHyphen() else: command = None - is_help_an_arg = False + needs_separating_hyphen_hyphen = False if not command: command = '' + # Build the continuations for the command: + continued_command = command + spec = inspectutils.GetFullArgSpec(component) - args = spec.args - if spec.defaults is None: - num_defaults = 0 - else: - num_defaults = len(spec.defaults) - args_with_no_defaults = args[:len(args) - num_defaults] - args_with_defaults = args[len(args) - num_defaults:] - flags = args_with_defaults + spec.kwonlyargs + metadata = decorators.GetMetadata(component) + + # Usage for objects. + actions_grouped_by_kind = _GetActionsGroupedByKind(component, verbose=verbose) + possible_actions = _GetPossibleActions(actions_grouped_by_kind) + + continuations = [] + if possible_actions: + continuations.append(_GetPossibleActionsUsageString(possible_actions)) + + availability_lines = _UsageAvailabilityLines(actions_grouped_by_kind) + + if callable(component): + callable_items = _GetCallableUsageItems(spec, metadata) + if callable_items: + continuations.append(' '.join(callable_items)) + elif trace: + continuations.append(trace.separator) + availability_lines.extend(_GetCallableAvailabilityLines(spec)) + + if continuations: + continued_command += ' ' + ' | '.join(continuations) + help_command = ( + command + + (' -- ' if needs_separating_hyphen_hyphen else ' ') + + '--help' + ) - items = [arg.upper() for arg in args_with_no_defaults] - if flags: - items.append('') - availability_lines = ( - '\nAvailable flags: ' - + ' | '.join('--' + flag for flag in flags) + '\n') - else: - availability_lines = '' - args_and_flags = ' '.join(items) + return f"""Usage: {continued_command} +{''.join(availability_lines)} +For detailed information on this command, run: + {help_command}""" - hyphen_hyphen = ' --' if is_help_an_arg else '' - return output_template.format( - current_command=command, - args_and_flags=args_and_flags, - availability_lines=availability_lines, - hyphen_hyphen=hyphen_hyphen) +def _GetPossibleActionsUsageString(possible_actions): + if possible_actions: + actions_str = '|'.join(possible_actions) + return f'<{actions_str}>' + return None -def UsageTextForObject(component, trace=None, verbose=False): - """Returns help text for usage screen for objects. +def _UsageAvailabilityLines(actions_grouped_by_kind): + availability_lines = [] + for action_group in actions_grouped_by_kind: + if action_group.members: + availability_line = _CreateAvailabilityLine( + header=f'available {action_group.plural}:', + items=action_group.names + ) + availability_lines.append(availability_line) + return availability_lines + + +def _GetCallableUsageItems(spec, metadata): + """A list of elements that comprise the usage summary for a callable.""" + args_with_no_defaults = spec.args[:len(spec.args) - len(spec.defaults)] + args_with_defaults = spec.args[len(spec.args) - len(spec.defaults):] + + # Check if positional args are allowed. If not, show flag syntax for args. + accepts_positional_args = metadata.get(decorators.ACCEPTS_POSITIONAL_ARGS) + + if not accepts_positional_args: + items = [f'--{arg}={arg.upper()}' + for arg in args_with_no_defaults] + else: + items = [arg.upper() for arg in args_with_no_defaults] - Construct help text for usage screen to inform the user about error occurred - and correct syntax for invoking the object. + # If there are any arguments that are treated as flags: + if args_with_defaults or spec.kwonlyargs or spec.varkw: + items.append('') - Args: - component: The component to determine the usage text for. - trace: The Fire trace object containing all metadata of current execution. - verbose: Whether to include private members in the usage text. - Returns: - String suitable for display in error screen. - """ - output_template = """Usage: {current_command} <{possible_actions}> -{availability_lines} + if spec.varargs: + items.append(f'[{spec.varargs.upper()}]...') -For detailed information on this command and its flags, run: -{current_command} --help -""" - if trace: - command = trace.GetCommand() - else: - command = None + return items - if not command: - command = '' - groups = [] - commands = [] - values = [] +def _KeywordOnlyArguments(spec, required=True): + return (flag for flag in spec.kwonlyargs + if required != (flag in spec.kwonlydefaults)) - members = completion._Members(component, verbose) # pylint: disable=protected-access - for member_name, member in members: - if value_types.IsGroup(member): - groups.append(member_name) - if value_types.IsCommand(member): - commands.append(member_name) - if value_types.IsValue(member): - values.append(member_name) - possible_actions = [] +def _GetCallableAvailabilityLines(spec): + """The list of availability lines for a callable for use in a usage string.""" + args_with_defaults = spec.args[len(spec.args) - len(spec.defaults):] + + # TODO(dbieber): Handle args_with_no_defaults if not accepts_positional_args. + optional_flags = [f'--{flag}' for flag in itertools.chain( + args_with_defaults, _KeywordOnlyArguments(spec, required=False))] + required_flags = [ + f'--{flag}' for flag in _KeywordOnlyArguments(spec, required=True) + ] + + # Flags section: availability_lines = [] - availability_lint_format = '{header:20s}{choices}' - if groups: - possible_actions.append('group') - groups_string = ' | '.join(groups) - groups_text = availability_lint_format.format( - header='available groups:', - choices=groups_string) - availability_lines.append(groups_text) - if commands: - possible_actions.append('command') - commands_string = ' | '.join(commands) - commands_text = availability_lint_format.format( - header='available commands:', - choices=commands_string) - availability_lines.append(commands_text) - if values: - possible_actions.append('value') - values_string = ' | '.join(values) - values_text = availability_lint_format.format( - header='available values:', - choices=values_string) - availability_lines.append(values_text) - possible_actions_string = '|'.join(possible_actions) - availability_lines_string = '\n'.join(availability_lines) - - return output_template.format( - current_command=command, - possible_actions=possible_actions_string, - availability_lines=availability_lines_string) + if optional_flags: + availability_lines.append( + _CreateAvailabilityLine(header='optional flags:', items=optional_flags, + header_indent=2)) + if required_flags: + availability_lines.append( + _CreateAvailabilityLine(header='required flags:', items=required_flags, + header_indent=2)) + if spec.varkw: + additional_flags = ('additional flags are accepted' + if optional_flags or required_flags else + 'flags are accepted') + availability_lines.append( + _CreateAvailabilityLine(header=additional_flags, items=[], + header_indent=2)) + return availability_lines + + +def _CreateAvailabilityLine(header, items, + header_indent=2, items_indent=25, + line_length=LINE_LENGTH): + items_width = line_length - items_indent + items_text = '\n'.join(formatting.WrappedJoin(items, width=items_width)) + indented_items_text = formatting.Indent(items_text, spaces=items_indent) + indented_header = formatting.Indent(header, spaces=header_indent) + return indented_header + indented_items_text[len(indented_header):] + '\n' + + +class ActionGroup: + """A group of actions of the same kind.""" + + def __init__(self, name, plural): + self.name = name + self.plural = plural + self.names = [] + self.members = [] + + def Add(self, name, member=None): + self.names.append(name) + self.members.append(member) + + def GetItems(self): + return zip(self.names, self.members) diff --git a/fire/helptext_test.py b/fire/helptext_test.py index c2943f5a..c7098fc4 100644 --- a/fire/helptext_test.py +++ b/fire/helptext_test.py @@ -14,15 +14,11 @@ """Tests for the helptext module.""" -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import os import textwrap +from fire import formatting from fire import helptext -from fire import inspectutils from fire import test_components as tc from fire import testutils from fire import trace @@ -31,14 +27,13 @@ class HelpTest(testutils.BaseTestCase): def setUp(self): + super().setUp() os.environ['ANSI_COLORS_DISABLED'] = '1' def testHelpTextNoDefaults(self): component = tc.NoDefaults - info = inspectutils.Info(component) help_screen = helptext.HelpText( component=component, - info=info, trace=trace.FireTrace(component, name='NoDefaults')) self.assertIn('NAME\n NoDefaults', help_screen) self.assertIn('SYNOPSIS\n NoDefaults', help_screen) @@ -47,15 +42,13 @@ def testHelpTextNoDefaults(self): def testHelpTextNoDefaultsObject(self): component = tc.NoDefaults() - info = inspectutils.Info(component) help_screen = helptext.HelpText( component=component, - info=info, trace=trace.FireTrace(component, name='NoDefaults')) self.assertIn('NAME\n NoDefaults', help_screen) self.assertIn('SYNOPSIS\n NoDefaults COMMAND', help_screen) self.assertNotIn('DESCRIPTION', help_screen) - self.assertIn('COMMANDS\n COMMAND is one of the followings:', + self.assertIn('COMMANDS\n COMMAND is one of the following:', help_screen) self.assertIn('double', help_screen) self.assertIn('triple', help_screen) @@ -63,38 +56,140 @@ def testHelpTextNoDefaultsObject(self): def testHelpTextFunction(self): component = tc.NoDefaults().double - info = inspectutils.Info(component) help_screen = helptext.HelpText( component=component, - info=info, trace=trace.FireTrace(component, name='double')) self.assertIn('NAME\n double', help_screen) self.assertIn('SYNOPSIS\n double COUNT', help_screen) self.assertNotIn('DESCRIPTION', help_screen) self.assertIn('POSITIONAL ARGUMENTS\n COUNT', help_screen) self.assertIn( - 'NOTES\n You could also use flags syntax for POSITIONAL ARGUMENTS', + 'NOTES\n You can also use flags syntax for POSITIONAL ARGUMENTS', help_screen) def testHelpTextFunctionWithDefaults(self): component = tc.WithDefaults().triple - info = inspectutils.Info(component) help_screen = helptext.HelpText( component=component, - info=info, trace=trace.FireTrace(component, name='triple')) self.assertIn('NAME\n triple', help_screen) - self.assertIn('SYNOPSIS\n triple [--count=COUNT]', help_screen) + self.assertIn('SYNOPSIS\n triple ', help_screen) + self.assertNotIn('DESCRIPTION', help_screen) + self.assertIn( + 'FLAGS\n -c, --count=COUNT\n Default: 0', + help_screen) + self.assertNotIn('NOTES', help_screen) + + def testHelpTextFunctionWithLongDefaults(self): + component = tc.WithDefaults().text + help_screen = helptext.HelpText( + component=component, + trace=trace.FireTrace(component, name='text')) + self.assertIn('NAME\n text', help_screen) + self.assertIn('SYNOPSIS\n text ', help_screen) self.assertNotIn('DESCRIPTION', help_screen) - self.assertIn('FLAGS\n --count', help_screen) + self.assertIn( + 'FLAGS\n -s, --string=STRING\n' + ' Default: \'0001020304050607080910' + '1112131415161718192021222324252627282...', + help_screen) + self.assertNotIn('NOTES', help_screen) + + def testHelpTextFunctionWithKwargs(self): + component = tc.fn_with_kwarg + help_screen = helptext.HelpText( + component=component, + trace=trace.FireTrace(component, name='text')) + self.assertIn('NAME\n text', help_screen) + self.assertIn('SYNOPSIS\n text ARG1 ARG2 ', help_screen) + self.assertIn('DESCRIPTION\n Function with kwarg', help_screen) + self.assertIn( + 'FLAGS\n --arg3\n Description of arg3.\n ' + 'Additional undocumented flags may also be accepted.', + help_screen) + + def testHelpTextFunctionWithKwargsAndDefaults(self): + component = tc.fn_with_kwarg_and_defaults + help_screen = helptext.HelpText( + component=component, + trace=trace.FireTrace(component, name='text')) + self.assertIn('NAME\n text', help_screen) + self.assertIn('SYNOPSIS\n text ARG1 ARG2 ', help_screen) + self.assertIn('DESCRIPTION\n Function with kwarg', help_screen) + self.assertIn( + 'FLAGS\n -o, --opt=OPT\n Default: True\n' + ' The following flags are also accepted.' + '\n --arg3\n Description of arg3.\n ' + 'Additional undocumented flags may also be accepted.', + help_screen) + + def testHelpTextFunctionWithDefaultsAndTypes(self): + component = ( + tc.py3.WithDefaultsAndTypes().double) + help_screen = helptext.HelpText( + component=component, + trace=trace.FireTrace(component, name='double')) + self.assertIn('NAME\n double', help_screen) + self.assertIn('SYNOPSIS\n double ', help_screen) + self.assertIn('DESCRIPTION', help_screen) + self.assertIn( + 'FLAGS\n -c, --count=COUNT\n Type: float\n Default: 0', + help_screen) self.assertNotIn('NOTES', help_screen) + def testHelpTextFunctionWithTypesAndDefaultNone(self): + component = ( + tc.py3.WithDefaultsAndTypes().get_int) + help_screen = helptext.HelpText( + component=component, + trace=trace.FireTrace(component, name='get_int')) + self.assertIn('NAME\n get_int', help_screen) + self.assertIn('SYNOPSIS\n get_int ', help_screen) + self.assertNotIn('DESCRIPTION', help_screen) + self.assertIn( + 'FLAGS\n -v, --value=VALUE\n' + ' Type: Optional[int]\n Default: None', + help_screen) + self.assertNotIn('NOTES', help_screen) + + def testHelpTextFunctionWithTypes(self): + component = tc.py3.WithTypes().double + help_screen = helptext.HelpText( + component=component, + trace=trace.FireTrace(component, name='double')) + self.assertIn('NAME\n double', help_screen) + self.assertIn('SYNOPSIS\n double COUNT', help_screen) + self.assertIn('DESCRIPTION', help_screen) + self.assertIn( + 'POSITIONAL ARGUMENTS\n COUNT\n Type: float', + help_screen) + self.assertIn( + 'NOTES\n You can also use flags syntax for POSITIONAL ARGUMENTS', + help_screen) + + def testHelpTextFunctionWithLongTypes(self): + component = tc.py3.WithTypes().long_type + help_screen = helptext.HelpText( + component=component, + trace=trace.FireTrace(component, name='long_type')) + self.assertIn('NAME\n long_type', help_screen) + self.assertIn('SYNOPSIS\n long_type LONG_OBJ', help_screen) + self.assertNotIn('DESCRIPTION', help_screen) + # TODO(dbieber): Assert type is displayed correctly. Type displayed + # differently in Travis vs in Google. + # self.assertIn( + # 'POSITIONAL ARGUMENTS\n LONG_OBJ\n' + # ' Type: typing.Tuple[typing.Tuple[' + # 'typing.Tuple[typing.Tuple[typing.Tupl...', + # help_screen) + self.assertIn( + 'NOTES\n You can also use flags syntax for POSITIONAL ARGUMENTS', + help_screen) + def testHelpTextFunctionWithBuiltin(self): component = 'test'.upper - info = inspectutils.Info(component) help_screen = helptext.HelpText( component=component, - info=info, trace=trace.FireTrace(component, 'upper')) self.assertIn('NAME\n upper', help_screen) self.assertIn('SYNOPSIS\n upper', help_screen) @@ -105,9 +200,8 @@ def testHelpTextFunctionWithBuiltin(self): def testHelpTextFunctionIntType(self): component = int - info = inspectutils.Info(component) help_screen = helptext.HelpText( - component=component, info=info, trace=trace.FireTrace(component, 'int')) + component=component, trace=trace.FireTrace(component, 'int')) self.assertIn('NAME\n int', help_screen) self.assertIn('SYNOPSIS\n int', help_screen) # We don't check description content here since the content is python @@ -116,68 +210,85 @@ def testHelpTextFunctionIntType(self): def testHelpTextEmptyList(self): component = [] - info = inspectutils.Info(component) help_screen = helptext.HelpText( component=component, - info=info, trace=trace.FireTrace(component, 'list')) self.assertIn('NAME\n list', help_screen) self.assertIn('SYNOPSIS\n list COMMAND', help_screen) - # We don't check description content here since the content could be python - # version dependent. - self.assertIn('DESCRIPTION\n', help_screen) + # TODO(zuhaochen): Change assertion after custom description is + # implemented for list type. + self.assertNotIn('DESCRIPTION', help_screen) # We don't check the listed commands either since the list API could # potentially change between Python versions. - self.assertIn('COMMANDS\n COMMAND is one of the followings:\n', + self.assertIn('COMMANDS\n COMMAND is one of the following:\n', help_screen) def testHelpTextShortList(self): component = [10] - info = inspectutils.Info(component) help_screen = helptext.HelpText( component=component, - info=info, trace=trace.FireTrace(component, 'list')) self.assertIn('NAME\n list', help_screen) self.assertIn('SYNOPSIS\n list COMMAND', help_screen) - # We don't check description content here since the content could be python - # version dependent. - self.assertIn('DESCRIPTION\n', help_screen) + # TODO(zuhaochen): Change assertion after custom description is + # implemented for list type. + self.assertNotIn('DESCRIPTION', help_screen) # We don't check the listed commands comprehensively since the list API # could potentially change between Python versions. Check a few # functions(command) that we're confident likely remain available. - self.assertIn('COMMANDS\n COMMAND is one of the followings:\n', + self.assertIn('COMMANDS\n COMMAND is one of the following:\n', help_screen) self.assertIn(' append\n', help_screen) def testHelpTextInt(self): component = 7 - info = inspectutils.Info(component) help_screen = helptext.HelpText( - component=component, info=info, trace=trace.FireTrace(component, '7')) + component=component, trace=trace.FireTrace(component, '7')) self.assertIn('NAME\n 7', help_screen) self.assertIn('SYNOPSIS\n 7 COMMAND | VALUE', help_screen) - self.assertIn('DESCRIPTION\n', help_screen) - self.assertIn('COMMANDS\n COMMAND is one of the followings:\n', + # TODO(zuhaochen): Change assertion after implementing custom + # description for int. + self.assertNotIn('DESCRIPTION', help_screen) + self.assertIn('COMMANDS\n COMMAND is one of the following:\n', help_screen) - self.assertIn('VALUES\n VALUE is one of the followings:\n', help_screen) + self.assertIn('VALUES\n VALUE is one of the following:\n', help_screen) def testHelpTextNoInit(self): component = tc.OldStyleEmpty - info = inspectutils.Info(component) help_screen = helptext.HelpText( component=component, - info=info, trace=trace.FireTrace(component, 'OldStyleEmpty')) self.assertIn('NAME\n OldStyleEmpty', help_screen) self.assertIn('SYNOPSIS\n OldStyleEmpty', help_screen) + def testHelpTextKeywordOnlyArgumentsWithDefault(self): + component = tc.py3.KeywordOnly.with_default + output = helptext.HelpText( + component=component, trace=trace.FireTrace(component, 'with_default')) + self.assertIn('NAME\n with_default', output) + self.assertIn('FLAGS\n -x, --x=X', output) + + def testHelpTextKeywordOnlyArgumentsWithoutDefault(self): + component = tc.py3.KeywordOnly.double + output = helptext.HelpText( + component=component, trace=trace.FireTrace(component, 'double')) + self.assertIn('NAME\n double', output) + self.assertIn('FLAGS\n -c, --count=COUNT (required)', output) + + def testHelpTextFunctionMixedDefaults(self): + component = tc.py3.HelpTextComponent().identity + t = trace.FireTrace(component, name='FunctionMixedDefaults') + output = helptext.HelpText(component, trace=t) + self.assertIn('NAME\n FunctionMixedDefaults', output) + self.assertIn('FunctionMixedDefaults ', output) + self.assertIn('--alpha=ALPHA (required)', output) + self.assertIn('--beta=BETA\n Default: \'0\'', output) + def testHelpScreen(self): component = tc.ClassWithDocstring() t = trace.FireTrace(component, name='ClassWithDocstring') - info = inspectutils.Info(component) - help_output = helptext.HelpText(component, info, t) + help_output = helptext.HelpText(component, t) expected_output = """ NAME ClassWithDocstring - Test class for testing help text output. @@ -189,25 +300,23 @@ def testHelpScreen(self): This is some detail description of this test class. COMMANDS - COMMAND is one of the followings: + COMMAND is one of the following: print_msg Prints a message. VALUES - VALUE is one of the followings: + VALUE is one of the following: message - The default message to print. -""" + The default message to print.""" self.assertEqual(textwrap.dedent(expected_output).strip(), help_output.strip()) def testHelpScreenForFunctionDocstringWithLineBreak(self): component = tc.ClassWithMultilineDocstring.example_generator t = trace.FireTrace(component, name='example_generator') - info = inspectutils.Info(component) - help_output = helptext.HelpText(component, info, t) + help_output = helptext.HelpText(component, t) expected_output = """ NAME example_generator - Generators have a ``Yields`` section instead of a ``Returns`` section. @@ -223,48 +332,114 @@ def testHelpScreenForFunctionDocstringWithLineBreak(self): The upper limit of the range to generate, from 0 to `n` - 1. NOTES - You could also use flags syntax for POSITIONAL ARGUMENTS - """ + You can also use flags syntax for POSITIONAL ARGUMENTS""" self.assertEqual(textwrap.dedent(expected_output).strip(), help_output.strip()) def testHelpScreenForFunctionFunctionWithDefaultArgs(self): component = tc.WithDefaults().double t = trace.FireTrace(component, name='double') - info = inspectutils.Info(component) - help_output = helptext.HelpText(component, info, t) + help_output = helptext.HelpText(component, t) expected_output = """ NAME double - Returns the input multiplied by 2. SYNOPSIS - double [--count=COUNT] + double DESCRIPTION Returns the input multiplied by 2. FLAGS - --count - Input number that you want to double. - """ + -c, --count=COUNT + Default: 0 + Input number that you want to double.""" self.assertEqual(textwrap.dedent(expected_output).strip(), help_output.strip()) + def testHelpTextUnderlineFlag(self): + component = tc.WithDefaults().triple + t = trace.FireTrace(component, name='triple') + help_screen = helptext.HelpText(component, t) + self.assertIn(formatting.Bold('NAME') + '\n triple', help_screen) + self.assertIn( + formatting.Bold('SYNOPSIS') + '\n triple ', + help_screen) + self.assertIn( + formatting.Bold('FLAGS') + '\n -c, --' + + formatting.Underline('count'), + help_screen) + + def testHelpTextBoldCommandName(self): + component = tc.ClassWithDocstring() + t = trace.FireTrace(component, name='ClassWithDocstring') + help_screen = helptext.HelpText(component, t) + self.assertIn( + formatting.Bold('NAME') + '\n ClassWithDocstring', help_screen) + self.assertIn(formatting.Bold('COMMANDS') + '\n', help_screen) + self.assertIn( + formatting.BoldUnderline('COMMAND') + ' is one of the following:\n', + help_screen) + self.assertIn(formatting.Bold('print_msg') + '\n', help_screen) + + def testHelpTextObjectWithGroupAndValues(self): + component = tc.TypedProperties() + t = trace.FireTrace(component, name='TypedProperties') + help_screen = helptext.HelpText( + component=component, trace=t, verbose=True) + print(help_screen) + self.assertIn('GROUPS', help_screen) + self.assertIn('GROUP is one of the following:', help_screen) + self.assertIn( + 'charlie\n Class with functions that have default arguments.', + help_screen) + self.assertIn('VALUES', help_screen) + self.assertIn('VALUE is one of the following:', help_screen) + self.assertIn('alpha', help_screen) + + def testHelpTextNameSectionCommandWithSeparator(self): + component = 9 + t = trace.FireTrace(component, name='int', separator='-') + t.AddSeparator() + help_screen = helptext.HelpText(component=component, trace=t, verbose=False) + self.assertIn('int -', help_screen) + self.assertNotIn('int - -', help_screen) + + def testHelpTextNameSectionCommandWithSeparatorVerbose(self): + component = tc.WithDefaults().double + t = trace.FireTrace(component, name='double', separator='-') + t.AddSeparator() + help_screen = helptext.HelpText(component=component, trace=t, verbose=True) + self.assertIn('double -', help_screen) + self.assertIn('double - -', help_screen) + + def testHelpTextMultipleKeywoardArgumentsWithShortArgs(self): + component = tc.fn_with_multiple_defaults + t = trace.FireTrace(component, name='shortargs') + help_screen = helptext.HelpText(component, t) + self.assertIn(formatting.Bold('NAME') + '\n shortargs', help_screen) + self.assertIn( + formatting.Bold('SYNOPSIS') + '\n shortargs ', + help_screen) + self.assertIn( + formatting.Bold('FLAGS') + '\n -f, --first', + help_screen) + self.assertIn('\n --last', help_screen) + self.assertIn('\n --late', help_screen) + class UsageTest(testutils.BaseTestCase): def testUsageOutput(self): component = tc.NoDefaults() t = trace.FireTrace(component, name='NoDefaults') - info = inspectutils.Info(component) - usage_output = helptext.UsageText(component, info, trace=t, verbose=False) - expected_output = ''' + usage_output = helptext.UsageText(component, trace=t, verbose=False) + expected_output = """ Usage: NoDefaults - available commands: double | triple + available commands: double | triple - For detailed information on this command and its flags, run: - NoDefaults --help - ''' + For detailed information on this command, run: + NoDefaults --help""" self.assertEqual( usage_output, @@ -273,15 +448,13 @@ def testUsageOutput(self): def testUsageOutputVerbose(self): component = tc.NoDefaults() t = trace.FireTrace(component, name='NoDefaults') - info = inspectutils.Info(component) - usage_output = helptext.UsageText(component, info, trace=t, verbose=True) - expected_output = ''' + usage_output = helptext.UsageText(component, trace=t, verbose=True) + expected_output = """ Usage: NoDefaults - available commands: double | triple + available commands: double | triple - For detailed information on this command and its flags, run: - NoDefaults --help - ''' + For detailed information on this command, run: + NoDefaults --help""" self.assertEqual( usage_output, textwrap.dedent(expected_output).lstrip('\n')) @@ -290,14 +463,12 @@ def testUsageOutputMethod(self): component = tc.NoDefaults().double t = trace.FireTrace(component, name='NoDefaults') t.AddAccessedProperty(component, 'double', ['double'], None, None) - info = inspectutils.Info(component) - usage_output = helptext.UsageText(component, info, trace=t, verbose=True) - expected_output = ''' + usage_output = helptext.UsageText(component, trace=t, verbose=False) + expected_output = """ Usage: NoDefaults double COUNT For detailed information on this command, run: - NoDefaults double --help - ''' + NoDefaults double --help""" self.assertEqual( usage_output, textwrap.dedent(expected_output).lstrip('\n')) @@ -305,16 +476,13 @@ def testUsageOutputMethod(self): def testUsageOutputFunctionWithHelp(self): component = tc.function_with_help t = trace.FireTrace(component, name='function_with_help') - info = inspectutils.Info(component) - usage_output = helptext.UsageText(component, info, trace=t, verbose=True) - expected_output = ''' + usage_output = helptext.UsageText(component, trace=t, verbose=False) + expected_output = """ Usage: function_with_help - - Available flags: --help + optional flags: --help For detailed information on this command, run: - function_with_help -- --help - ''' + function_with_help -- --help""" self.assertEqual( usage_output, textwrap.dedent(expected_output).lstrip('\n')) @@ -322,56 +490,107 @@ def testUsageOutputFunctionWithHelp(self): def testUsageOutputFunctionWithDocstring(self): component = tc.multiplier_with_docstring t = trace.FireTrace(component, name='multiplier_with_docstring') - info = inspectutils.Info(component) - usage_output = helptext.UsageText(component, info, trace=t, verbose=True) - expected_output = ''' + usage_output = helptext.UsageText(component, trace=t, verbose=False) + expected_output = """ Usage: multiplier_with_docstring NUM - - Available flags: --rate + optional flags: --rate For detailed information on this command, run: - multiplier_with_docstring --help - ''' + multiplier_with_docstring --help""" self.assertEqual( - usage_output, - textwrap.dedent(expected_output).lstrip('\n')) + textwrap.dedent(expected_output).lstrip('\n'), + usage_output) + + def testUsageOutputFunctionMixedDefaults(self): + component = tc.py3.HelpTextComponent().identity + t = trace.FireTrace(component, name='FunctionMixedDefaults') + usage_output = helptext.UsageText(component, trace=t, verbose=False) + expected_output = """ + Usage: FunctionMixedDefaults + optional flags: --beta + required flags: --alpha + + For detailed information on this command, run: + FunctionMixedDefaults --help""" + expected_output = textwrap.dedent(expected_output).lstrip('\n') + self.assertEqual(expected_output, usage_output) - @testutils.skip('The functionality is not implemented yet') def testUsageOutputCallable(self): - # This is both a group and a command! - component = tc.CallableWithKeywordArgument - t = trace.FireTrace(component, name='CallableWithKeywordArgument') - info = inspectutils.Info(component) - usage_output = helptext.UsageText(component, info, trace=t, verbose=True) - # TODO(zuhaohen): We need to handle the case for keyword args as well - # i.e. __call__ method of CallableWithKeywordArgument - expected_output = ''' - Usage: CallableWithKeywordArgument - - Available commands: print_msg + # This is both a group and a command. + component = tc.CallableWithKeywordArgument() + t = trace.FireTrace(component, name='CallableWithKeywordArgument', + separator='@') + usage_output = helptext.UsageText(component, trace=t, verbose=False) + expected_output = """ + Usage: CallableWithKeywordArgument | + available commands: print_msg + flags are accepted For detailed information on this command, run: - CallableWithKeywordArgument -- --help - ''' + CallableWithKeywordArgument -- --help""" self.assertEqual( - usage_output, - textwrap.dedent(expected_output).lstrip('\n')) + textwrap.dedent(expected_output).lstrip('\n'), + usage_output) def testUsageOutputConstructorWithParameter(self): component = tc.InstanceVars t = trace.FireTrace(component, name='InstanceVars') - info = inspectutils.Info(component) - usage_output = helptext.UsageText(component, info, trace=t, verbose=True) - expected_output = ''' - Usage: InstanceVars ARG1 ARG2 + usage_output = helptext.UsageText(component, trace=t, verbose=False) + expected_output = """ + Usage: InstanceVars --arg1=ARG1 --arg2=ARG2 For detailed information on this command, run: - InstanceVars --help - ''' + InstanceVars --help""" self.assertEqual( - usage_output, - textwrap.dedent(expected_output).lstrip('\n')) + textwrap.dedent(expected_output).lstrip('\n'), + usage_output) + def testUsageOutputConstructorWithParameterVerbose(self): + component = tc.InstanceVars + t = trace.FireTrace(component, name='InstanceVars') + usage_output = helptext.UsageText(component, trace=t, verbose=True) + expected_output = """ + Usage: InstanceVars | --arg1=ARG1 --arg2=ARG2 + available commands: run + + For detailed information on this command, run: + InstanceVars --help""" + self.assertEqual( + textwrap.dedent(expected_output).lstrip('\n'), + usage_output) + + def testUsageOutputEmptyDict(self): + component = {} + t = trace.FireTrace(component, name='EmptyDict') + usage_output = helptext.UsageText(component, trace=t, verbose=True) + expected_output = """ + Usage: EmptyDict + + For detailed information on this command, run: + EmptyDict --help""" + self.assertEqual( + textwrap.dedent(expected_output).lstrip('\n'), + usage_output) + + def testUsageOutputNone(self): + component = None + t = trace.FireTrace(component, name='None') + usage_output = helptext.UsageText(component, trace=t, verbose=True) + expected_output = """ + Usage: None + + For detailed information on this command, run: + None --help""" + self.assertEqual( + textwrap.dedent(expected_output).lstrip('\n'), + usage_output) + + def testInitRequiresFlagSyntaxSubclassNamedTuple(self): + component = tc.SubPoint + t = trace.FireTrace(component, name='SubPoint') + usage_output = helptext.UsageText(component, trace=t, verbose=False) + expected_output = 'Usage: SubPoint --x=X --y=Y' + self.assertIn(expected_output, usage_output) if __name__ == '__main__': testutils.main() diff --git a/fire/helputils.py b/fire/helputils.py deleted file mode 100644 index 2d906a11..00000000 --- a/fire/helputils.py +++ /dev/null @@ -1,263 +0,0 @@ -# Copyright (C) 2018 Google Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Utility for producing help strings for use in Fire CLIs. - -Can produce help strings suitable for display in Fire CLIs for any type of -Python object, module, class, or function. - -There are two types of informative strings: Usage and Help screens. - -Usage screens are shown when the user accesses a group or accesses a command -without calling it. A Usage screen shows information about how to use that group -or command. Usage screens are typically short and show the minimal information -necessary for the user to determine how to proceed. - -Help screens are shown when the user requests help with the help flag (--help). -Help screens are shown in a less-style console view, and contain detailed help -information. -""" - -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - -import inspect - -from fire import completion -from fire import docstrings -from fire import inspectutils - - -def _NormalizeField(field): - """Takes a field name and turns it into a human readable name for display. - - Args: - field: The field name, used to index into the inspection dict. - Returns: - The human readable name, suitable for display in a help string. - """ - if field == 'type_name': - field = 'type' - return (field[0].upper() + field[1:]).replace('_', ' ') - - -def _DisplayValue(info, field, padding): - """Gets the value of field from the dict info for display. - - Args: - info: The dict with information about the component. - field: The field to access for display. - padding: Number of spaces to indent text to line up with first-line text. - Returns: - The value of the field for display, or None if no value should be displayed. - """ - value = info.get(field) - - if value is None: - return None - - skip_doc_types = ('dict', 'list', 'unicode', 'int', 'float', 'bool') - - if field == 'docstring': - if info.get('type_name') in skip_doc_types: - # Don't show the boring default docstrings for these types. - return None - elif value == '': - return None - - elif field == 'usage': - lines = [] - for index, line in enumerate(value.split('\n')): - if index > 0: - line = ' ' * padding + line - lines.append(line) - return '\n'.join(lines) - - return value - - -def _GetFields(trace=None): - """Returns the field names to include in the help text for a component.""" - del trace # Unused. - return [ - 'type_name', - 'string_form', - 'file', - 'line', - 'docstring', - 'init_docstring', - 'class_docstring', - 'call_docstring', - 'length', - 'usage', - ] - - -def HelpString(component, trace=None, verbose=False): - """Returns a help string for a supplied component. - - The component can be any Python class, object, function, module, etc. - - Args: - component: The component to determine the help string for. - trace: The Fire trace leading to this component. - verbose: Whether to include private members in the help string. - Returns: - String suitable for display giving information about the component. - """ - info = inspectutils.Info(component) - info['usage'] = UsageString(component, trace, verbose) - info['docstring_info'] = docstrings.parse(info['docstring']) - - return _HelpText(info, trace) - - -def _HelpText(info, trace=None): - """Returns help text. - - This was a copy of previous HelpString function and will be removed once the - correct text formatters are implemented. - - Args: - info: The IR object containing metadata of an object. - trace: The Fire trace object containing all metadata of current execution. - Returns: - String suitable for display giving information about the component. - """ - fields = _GetFields(trace) - - try: - max_size = max( - len(_NormalizeField(field)) + 1 - for field in fields - if field in info and info[field]) - format_string = '{{field:{max_size}s}} {{value}}'.format(max_size=max_size) - except ValueError: - return '' - - lines = [] - for field in fields: - value = _DisplayValue(info, field, padding=max_size + 1) - if value: - if lines and field == 'usage': - lines.append('') # Ensure a blank line before usage. - - lines.append(format_string.format( - field=_NormalizeField(field) + ':', - value=value, - )) - return '\n'.join(lines) - - -def GetSummaryAndDescription(docstring_info): - """Retrieves summary and description for help text generation.""" - - # To handle both empty string and None - summary = docstring_info.summary if docstring_info.summary else None - description = ( - docstring_info.description if docstring_info.description else None) - return summary, description - - -def GetCurrentCommand(trace=None): - """Returns current command for the purpose of generating help text.""" - if trace: - current_command = trace.GetCommand() - else: - current_command = '' - - return current_command - - -def _UsageStringFromFullArgSpec(command, spec): - """Get a usage string from the FullArgSpec for the given command. - - The strings look like: - command --arg ARG [--opt OPT] [VAR ...] [--KWARGS ...] - - Args: - command: The command leading up to the function. - spec: a FullArgSpec object describing the function. - Returns: - The usage string for the function. - """ - num_required_args = len(spec.args) - len(spec.defaults) - - help_flags = [] - help_positional = [] - for index, arg in enumerate(spec.args): - flag = arg.replace('_', '-') - if index < num_required_args: - help_flags.append('--{flag} {value}'.format(flag=flag, value=arg.upper())) - help_positional.append('{value}'.format(value=arg.upper())) - else: - help_flags.append('[--{flag} {value}]'.format( - flag=flag, value=arg.upper())) - help_positional.append('[{value}]'.format(value=arg.upper())) - - if spec.varargs: - help_flags.append('[{var} ...]'.format(var=spec.varargs.upper())) - help_positional.append('[{var} ...]'.format(var=spec.varargs.upper())) - - for arg in spec.kwonlyargs: - if arg in spec.kwonlydefaults: - arg_str = '[--{flag} {value}]'.format(flag=arg, value=arg.upper()) - else: - arg_str = '--{flag} {value}'.format(flag=arg, value=arg.upper()) - help_flags.append(arg_str) - help_positional.append(arg_str) - - if spec.varkw: - help_flags.append('[--{kwarg} ...]'.format(kwarg=spec.varkw.upper())) - help_positional.append('[--{kwarg} ...]'.format(kwarg=spec.varkw.upper())) - - commands_flags = command + ' '.join(help_flags) - commands_positional = command + ' '.join(help_positional) - commands = [commands_positional] - - if commands_flags != commands_positional: - commands.append(commands_flags) - - return '\n'.join(commands) - - -def UsageString(component, trace=None, verbose=False): - """Returns a string showing how to use the component as a Fire command.""" - if trace: - command = trace.GetCommand() - else: - command = None - - if command: - command += ' ' - else: - command = '' - - if inspect.isroutine(component) or inspect.isclass(component): - spec = inspectutils.GetFullArgSpec(component) - return _UsageStringFromFullArgSpec(command, spec) - - if isinstance(component, (list, tuple)): - length = len(component) - if length == 0: - return command - if length == 1: - return command + '[0]' - return command + '[0..{cap}]'.format(cap=length - 1) - - completions = completion.Completions(component, verbose) - if command: - completions = [''] + completions - return '\n'.join(command + end for end in completions) diff --git a/fire/helputils_test.py b/fire/helputils_test.py deleted file mode 100644 index 08f7752c..00000000 --- a/fire/helputils_test.py +++ /dev/null @@ -1,131 +0,0 @@ -# Copyright (C) 2018 Google Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Tests for the helputils module.""" - -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - -import os - -from fire import helputils -from fire import test_components as tc -from fire import testutils -import six - - -class HelpUtilsTest(testutils.BaseTestCase): - - def testHelpStringClass(self): - helpstring = helputils.HelpString(tc.NoDefaults) - self.assertIn('Type: type', helpstring) - self.assertIn("String form: ", - helpstring) - self.assertIn('test_components.py', helpstring) - self.assertIn('Line: ', helpstring) - self.assertNotIn('Usage', helpstring) - - def testHelpStringObject(self): - obj = tc.NoDefaults() - helpstring = helputils.HelpString(obj) - self.assertIn('Type: NoDefaults', helpstring) - self.assertIn('String form: ", helpstring) - else: - self.assertIn("String form: ", helpstring) - self.assertNotIn('Usage', helpstring) - - def testHelpStringEmptyList(self): - helpstring = helputils.HelpString([]) - self.assertIn('Type: list', helpstring) - self.assertIn('String form: []', helpstring) - self.assertIn('Length: 0', helpstring) - - def testHelpStringShortList(self): - helpstring = helputils.HelpString([10]) - self.assertIn('Type: list', helpstring) - self.assertIn('String form: [10]', helpstring) - self.assertIn('Length: 1', helpstring) - self.assertIn('Usage: [0]', helpstring) # [] denotes optional. - - def testHelpStringInt(self): - helpstring = helputils.HelpString(7) - self.assertIn('Type: int', helpstring) - self.assertIn('String form: 7', helpstring) - self.assertIn('Usage: bit-length\n' - ' conjugate\n' - ' denominator\n', helpstring) - - def testHelpClassNoInit(self): - helpstring = helputils.HelpString(tc.OldStyleEmpty) - if six.PY2: - self.assertIn('Type: classobj\n', helpstring) - else: - self.assertIn('Type: type\n', helpstring) - self.assertIn('String form: ', helpstring) - self.assertIn('fire.test_components.OldStyleEmpty', helpstring) - self.assertIn(os.path.join('fire', 'test_components.py'), helpstring) - self.assertIn('Line: ', helpstring) - - -if __name__ == '__main__': - testutils.main() diff --git a/fire/inspectutils.py b/fire/inspectutils.py index 645b7b18..17508e30 100644 --- a/fire/inspectutils.py +++ b/fire/inspectutils.py @@ -14,17 +14,14 @@ """Inspection utility functions for Python Fire.""" -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import inspect -from fire import docstrings +import sys +import types -import six +from fire import docstrings -class FullArgSpec(object): +class FullArgSpec: """The arguments of a function, as in Python 3's inspect.FullArgSpec.""" def __init__(self, args=None, varargs=None, varkw=None, defaults=None, @@ -70,45 +67,141 @@ class with an __init__ method. """ skip_arg = False if inspect.isclass(fn): - # If the function is a class, we try to use it's init method. + # If the function is a class, we try to use its init method. skip_arg = True - if six.PY2 and hasattr(fn, '__init__'): - fn = fn.__init__ elif inspect.ismethod(fn): # If the function is a bound method, we skip the `self` argument. skip_arg = fn.__self__ is not None elif inspect.isbuiltin(fn): - # If the function is a bound builtin, we skip the `self` argument. - skip_arg = fn.__self__ is not None + # If the function is a bound builtin, we skip the `self` argument, unless + # the function is from a standard library module in which case its __self__ + # attribute is that module. + if not isinstance(fn.__self__, types.ModuleType): + skip_arg = True + elif not inspect.isfunction(fn): + # The purpose of this else clause is to set skip_arg for callable objects. + skip_arg = True return fn, skip_arg +def Py3GetFullArgSpec(fn): + """A alternative to the builtin getfullargspec. + + The builtin inspect.getfullargspec uses: + `skip_bound_args=False, follow_wrapped_chains=False` + in order to be backwards compatible. + + This function instead skips bound args (self) and follows wrapped chains. + + Args: + fn: The function or class of interest. + Returns: + An inspect.FullArgSpec namedtuple with the full arg spec of the function. + """ + # pylint: disable=no-member + + try: + sig = inspect._signature_from_callable( # pylint: disable=protected-access # type: ignore + fn, + skip_bound_arg=True, + follow_wrapper_chains=True, + sigcls=inspect.Signature) + except Exception: + # 'signature' can raise ValueError (most common), AttributeError, and + # possibly others. We catch all exceptions here, and reraise a TypeError. + raise TypeError('Unsupported callable.') + + args = [] + varargs = None + varkw = None + kwonlyargs = [] + defaults = () + annotations = {} + defaults = () + kwdefaults = {} + + if sig.return_annotation is not sig.empty: + annotations['return'] = sig.return_annotation + + for param in sig.parameters.values(): + kind = param.kind + name = param.name + + # pylint: disable=protected-access + if kind is inspect._POSITIONAL_ONLY: # type: ignore + args.append(name) + elif kind is inspect._POSITIONAL_OR_KEYWORD: # type: ignore + args.append(name) + if param.default is not param.empty: + defaults += (param.default,) + elif kind is inspect._VAR_POSITIONAL: # type: ignore + varargs = name + elif kind is inspect._KEYWORD_ONLY: # type: ignore + kwonlyargs.append(name) + if param.default is not param.empty: + kwdefaults[name] = param.default + elif kind is inspect._VAR_KEYWORD: # type: ignore + varkw = name + if param.annotation is not param.empty: + annotations[name] = param.annotation + # pylint: enable=protected-access + + if not kwdefaults: + # compatibility with 'func.__kwdefaults__' + kwdefaults = None + + if not defaults: + # compatibility with 'func.__defaults__' + defaults = None + return inspect.FullArgSpec(args, varargs, varkw, defaults, + kwonlyargs, kwdefaults, annotations) + # pylint: enable=no-member + + def GetFullArgSpec(fn): """Returns a FullArgSpec describing the given callable.""" - + original_fn = fn fn, skip_arg = _GetArgSpecInfo(fn) try: - if six.PY2: - args, varargs, varkw, defaults = inspect.getargspec(fn) # pylint: disable=deprecated-method - kwonlyargs = kwonlydefaults = None - annotations = getattr(fn, '__annotations__', None) - else: + if sys.version_info[0:2] >= (3, 5): + (args, varargs, varkw, defaults, + kwonlyargs, kwonlydefaults, annotations) = Py3GetFullArgSpec(fn) + else: # Specifically Python 3.4. (args, varargs, varkw, defaults, kwonlyargs, kwonlydefaults, annotations) = inspect.getfullargspec(fn) # pylint: disable=deprecated-method,no-member except TypeError: # If we can't get the argspec, how do we know if the fn should take args? # 1. If it's a builtin, it can take args. - # 2. If it's an implicit __init__ function (a 'slot wrapper'), take no args. - # Are there other cases? + # 2. If it's an implicit __init__ function (a 'slot wrapper'), that comes + # from a namedtuple, use _fields to determine the args. + # 3. If it's another slot wrapper (that comes from not subclassing object in + # Python 2), then there are no args. + # Are there other cases? We just don't know. + + # Case 1: Builtins accept args. if inspect.isbuiltin(fn): + # TODO(dbieber): Try parsing the docstring, if available. + # TODO(dbieber): Use known argspecs, like set.add and namedtuple.count. return FullArgSpec(varargs='vars', varkw='kwargs') + + # Case 2: namedtuples store their args in their _fields attribute. + # TODO(dbieber): Determine if there's a way to detect false positives. + # In Python 2, a class that does not subclass anything, does not define + # __init__, and has an attribute named _fields will cause Fire to think it + # expects args for its constructor when in fact it does not. + fields = getattr(original_fn, '_fields', None) + if fields is not None: + return FullArgSpec(args=list(fields)) + + # Case 3: Other known slot wrappers do not accept args. return FullArgSpec() - if skip_arg and args: + # In Python 3.5+ Py3GetFullArgSpec uses skip_bound_arg=True already. + skip_arg_required = sys.version_info[0:2] == (3, 4) + if skip_arg_required and skip_arg and args: args.pop(0) # Remove 'self' or 'cls' from the list of arguments. - return FullArgSpec(args, varargs, varkw, defaults, kwonlyargs, kwonlydefaults, annotations) @@ -134,7 +227,7 @@ def GetFileAndLine(component): try: unused_code, lineindex = inspect.findsource(component) lineno = lineindex + 1 - except IOError: + except (OSError, IndexError): lineno = None return filename, lineno @@ -160,8 +253,11 @@ def Info(component): A dict with information about the component. """ try: - from IPython.core import oinspect # pylint: disable=g-import-not-at-top - inspector = oinspect.Inspector() + from IPython.core import oinspect # pylint: disable=import-outside-toplevel,g-import-not-at-top + try: + inspector = oinspect.Inspector(theme_name="neutral") + except TypeError: # Only recent versions of IPython support theme_name. + inspector = oinspect.Inspector() # type: ignore info = inspector.info(component) # IPython's oinspect.Inspector.info may return '' @@ -172,12 +268,12 @@ def Info(component): try: unused_code, lineindex = inspect.findsource(component) - info['line'] = lineindex + 1 - except (TypeError, IOError): - info['line'] = None + info['line'] = lineindex + 1 # type: ignore + except (TypeError, OSError): + info['line'] = None # type: ignore if 'docstring' in info: - info['docstring_info'] = docstrings.parse(info['docstring']) + info['docstring_info'] = docstrings.parse(info['docstring']) # type: ignore return info @@ -233,3 +329,21 @@ def IsNamedTuple(component): has_fields = bool(getattr(component, '_fields', None)) return has_fields + + +def GetClassAttrsDict(component): + """Gets the attributes of the component class, as a dict with name keys.""" + if not inspect.isclass(component): + return None + class_attrs_list = inspect.classify_class_attrs(component) + return { + class_attr.name: class_attr + for class_attr in class_attrs_list + } + + +def IsCoroutineFunction(fn): + try: + return inspect.iscoroutinefunction(fn) + except: # pylint: disable=bare-except + return False diff --git a/fire/inspectutils_test.py b/fire/inspectutils_test.py index 0ebd4059..47de7e72 100644 --- a/fire/inspectutils_test.py +++ b/fire/inspectutils_test.py @@ -14,19 +14,12 @@ """Tests for the inspectutils module.""" -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import os -import unittest from fire import inspectutils from fire import test_components as tc from fire import testutils -import six - class InspectUtilsTest(testutils.BaseTestCase): @@ -40,7 +33,6 @@ def testGetFullArgSpec(self): self.assertEqual(spec.kwonlydefaults, {}) self.assertEqual(spec.annotations, {'arg2': int, 'arg4': int}) - @unittest.skipIf(six.PY2, 'No keyword arguments in python 2') def testGetFullArgSpecPy3(self): spec = inspectutils.GetFullArgSpec(tc.py3.identity) self.assertEqual(spec.args, ['arg1', 'arg2', 'arg3', 'arg4']) @@ -70,6 +62,26 @@ def testGetFullArgSpecFromSlotWrapper(self): self.assertEqual(spec.kwonlydefaults, {}) self.assertEqual(spec.annotations, {}) + def testGetFullArgSpecFromNamedTuple(self): + spec = inspectutils.GetFullArgSpec(tc.NamedTuplePoint) + self.assertEqual(spec.args, ['x', 'y']) + self.assertEqual(spec.defaults, ()) + self.assertEqual(spec.varargs, None) + self.assertEqual(spec.varkw, None) + self.assertEqual(spec.kwonlyargs, []) + self.assertEqual(spec.kwonlydefaults, {}) + self.assertEqual(spec.annotations, {}) + + def testGetFullArgSpecFromNamedTupleSubclass(self): + spec = inspectutils.GetFullArgSpec(tc.SubPoint) + self.assertEqual(spec.args, ['x', 'y']) + self.assertEqual(spec.defaults, ()) + self.assertEqual(spec.varargs, None) + self.assertEqual(spec.varkw, None) + self.assertEqual(spec.kwonlyargs, []) + self.assertEqual(spec.kwonlydefaults, {}) + self.assertEqual(spec.annotations, {}) + def testGetFullArgSpecFromClassNoInit(self): spec = inspectutils.GetFullArgSpec(tc.OldStyleEmpty) self.assertEqual(spec.args, []) @@ -105,10 +117,7 @@ def testInfoClass(self): def testInfoClassNoInit(self): info = inspectutils.Info(tc.OldStyleEmpty) - if six.PY2: - self.assertEqual(info.get('type_name'), 'classobj') - else: - self.assertEqual(info.get('type_name'), 'type') + self.assertEqual(info.get('type_name'), 'type') self.assertIn(os.path.join('fire', 'test_components.py'), info.get('file')) self.assertGreater(info.get('line'), 0) diff --git a/fire/interact.py b/fire/interact.py index 9f0a01e6..eccd3990 100644 --- a/fire/interact.py +++ b/fire/interact.py @@ -20,10 +20,6 @@ InteractiveConsole class. """ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import inspect @@ -69,16 +65,17 @@ def _AvailableString(variables, verbose=False): lists = [ ('Modules', modules), ('Objects', other)] - liststrs = [] + list_strs = [] for name, varlist in lists: if varlist: - liststrs.append( - '{name}: {items}'.format(name=name, items=', '.join(sorted(varlist)))) + items_str = ', '.join(sorted(varlist)) + list_strs.append(f'{name}: {items_str}') + lists_str = '\n'.join(list_strs) return ( 'Fire is starting a Python REPL with the following objects:\n' - '{liststrs}\n' - ).format(liststrs='\n'.join(liststrs)) + f'{lists_str}\n' + ) def _EmbedIPython(variables, argv=None): @@ -89,11 +86,11 @@ def _EmbedIPython(variables, argv=None): Values are variable values. argv: The argv to use for starting ipython. Defaults to an empty list. """ - import IPython # pylint: disable=g-import-not-at-top + import IPython # pylint: disable=import-outside-toplevel,g-import-not-at-top argv = argv or [] IPython.start_ipython(argv=argv, user_ns=variables) def _EmbedCode(variables): - import code # pylint: disable=g-import-not-at-top + import code # pylint: disable=import-outside-toplevel,g-import-not-at-top code.InteractiveConsole(variables).interact() diff --git a/fire/interact_test.py b/fire/interact_test.py index 29fa7597..2f286824 100644 --- a/fire/interact_test.py +++ b/fire/interact_test.py @@ -14,15 +14,11 @@ """Tests for the interact module.""" -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function +from unittest import mock from fire import interact from fire import testutils -import mock - try: import IPython # pylint: disable=unused-import, g-import-not-at-top diff --git a/fire/main_test.py b/fire/main_test.py new file mode 100644 index 00000000..9e1c382b --- /dev/null +++ b/fire/main_test.py @@ -0,0 +1,94 @@ +# Copyright (C) 2018 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Test using Fire via `python -m fire`.""" + +import os +import tempfile + +from fire import __main__ +from fire import testutils + + +class MainModuleTest(testutils.BaseTestCase): + """Tests to verify the behavior of __main__ (python -m fire).""" + + def testNameSetting(self): + # Confirm one of the usage lines has the gettempdir member. + with self.assertOutputMatches('gettempdir'): + __main__.main(['__main__.py', 'tempfile']) + + def testArgPassing(self): + expected = os.path.join('part1', 'part2', 'part3') + with self.assertOutputMatches('%s\n' % expected): + __main__.main( + ['__main__.py', 'os.path', 'join', 'part1', 'part2', 'part3']) + with self.assertOutputMatches('%s\n' % expected): + __main__.main( + ['__main__.py', 'os', 'path', '-', 'join', 'part1', 'part2', 'part3']) + + +class MainModuleFileTest(testutils.BaseTestCase): + """Tests to verify correct import behavior for file executables.""" + + def setUp(self): + super().setUp() + self.file = tempfile.NamedTemporaryFile(suffix='.py') # pylint: disable=consider-using-with + self.file.write(b'class Foo:\n def double(self, n):\n return 2 * n\n') + self.file.flush() + + self.file2 = tempfile.NamedTemporaryFile() # pylint: disable=consider-using-with + + def testFileNameFire(self): + # Confirm that the file is correctly imported and doubles the number. + with self.assertOutputMatches('4'): + __main__.main( + ['__main__.py', self.file.name, 'Foo', 'double', '--n', '2']) + + def testFileNameFailure(self): + # Confirm that an existing file without a .py suffix raises a ValueError. + with self.assertRaises(ValueError): + __main__.main( + ['__main__.py', self.file2.name, 'Foo', 'double', '--n', '2']) + + def testFileNameModuleDuplication(self): + # Confirm that a file that masks a module still loads the module. + with self.assertOutputMatches('gettempdir'): + dirname = os.path.dirname(self.file.name) + with testutils.ChangeDirectory(dirname): + with open('tempfile', 'w'): + __main__.main([ + '__main__.py', + 'tempfile', + ]) + + os.remove('tempfile') + + def testFileNameModuleFileFailure(self): + # Confirm that an invalid file that masks a non-existent module fails. + with self.assertRaisesRegex(ValueError, + r'Fire can only be called on \.py files\.'): # pylint: disable=line-too-long, + dirname = os.path.dirname(self.file.name) + with testutils.ChangeDirectory(dirname): + with open('foobar', 'w'): + __main__.main([ + '__main__.py', + 'foobar', + ]) + + os.remove('foobar') + + +if __name__ == '__main__': + testutils.main() diff --git a/fire/parser.py b/fire/parser.py index 404e18e7..a335cc2c 100644 --- a/fire/parser.py +++ b/fire/parser.py @@ -14,12 +14,14 @@ """Provides parsing functionality used by Python Fire.""" -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import argparse import ast +import sys + +if sys.version_info[0:2] < (3, 8): + _StrNode = ast.Str # type: ignore # pylint: disable=no-member # deprecated but needed for Python < 3.8 +else: + _StrNode = ast.Constant def CreateParser(): @@ -94,7 +96,7 @@ def _LiteralEval(value): SyntaxError: If the value string has a syntax error. """ root = ast.parse(value, mode='eval') - if isinstance(root.body, ast.BinOp): # pytype: disable=attribute-error + if isinstance(root.body, ast.BinOp): raise ValueError(value) for node in ast.walk(root): @@ -106,7 +108,7 @@ def _LiteralEval(value): elif isinstance(child, ast.Name): replacement = _Replacement(child) - node.__setattr__(field, replacement) + setattr(node, field, replacement) # ast.literal_eval supports the following types: # strings, bytes, numbers, tuples, lists, dicts, sets, booleans, and None @@ -127,4 +129,4 @@ def _Replacement(node): # These are the only builtin constants supported by literal_eval. if value in ('True', 'False', 'None'): return node - return ast.Str(value) + return _StrNode(value) diff --git a/fire/parser_fuzz_test.py b/fire/parser_fuzz_test.py index af0be038..10f497cf 100644 --- a/fire/parser_fuzz_test.py +++ b/fire/parser_fuzz_test.py @@ -14,10 +14,6 @@ """Fuzz tests for the parser module.""" -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from fire import parser from fire import testutils from hypothesis import example @@ -25,7 +21,6 @@ from hypothesis import settings from hypothesis import strategies as st import Levenshtein -import six class ParserFuzzTest(testutils.BaseTestCase): @@ -58,7 +53,7 @@ def testDefaultParseValueFuzz(self, value): result = parser.DefaultParseValue(value) except TypeError: # It's OK to get a TypeError if the string has the null character. - if u'\x00' in value: + if '\x00' in value: return raise except MemoryError: @@ -68,8 +63,8 @@ def testDefaultParseValueFuzz(self, value): raise try: - uvalue = six.text_type(value) - uresult = six.text_type(result) + uvalue = str(value) + uresult = str(result) except UnicodeDecodeError: # This is not what we're testing. return @@ -86,7 +81,7 @@ def testDefaultParseValueFuzz(self, value): if '#' in value: max_distance += len(value) - value.index('#') - if not isinstance(result, six.string_types): + if not isinstance(result, str): max_distance += value.count('0') # Leading 0s are stripped. # Note: We don't check distance for dicts since item order can be changed. diff --git a/fire/parser_test.py b/fire/parser_test.py index 0257be28..a404eea2 100644 --- a/fire/parser_test.py +++ b/fire/parser_test.py @@ -14,10 +14,6 @@ """Tests for the parser module.""" -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from fire import parser from fire import testutils @@ -117,8 +113,9 @@ def testDefaultParseValueBareWordsTuple(self): def testDefaultParseValueNestedContainers(self): self.assertEqual( - parser.DefaultParseValue('[(A, 2, "3"), 5, {alph: 10.2, beta: "cat"}]'), - [('A', 2, '3'), 5, {'alph': 10.2, 'beta': 'cat'}]) + parser.DefaultParseValue( + '[(A, 2, "3"), 5, {alpha: 10.2, beta: "cat"}]'), + [('A', 2, '3'), 5, {'alpha': 10.2, 'beta': 'cat'}]) def testDefaultParseValueComments(self): self.assertEqual(parser.DefaultParseValue('"0#comments"'), '0#comments') diff --git a/fire/test_components.py b/fire/test_components.py index ad3eecd6..887a0dc6 100644 --- a/fire/test_components.py +++ b/fire/test_components.py @@ -14,16 +14,11 @@ """This module has components that are used for testing Python Fire.""" -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import collections +import enum +import functools -import six - -if six.PY3: - from fire import test_components_py3 as py3 # pylint: disable=unused-import,no-name-in-module,g-import-not-at-top +from fire import test_components_py3 as py3 # pylint: disable=unused-import,no-name-in-module,g-import-not-at-top def identity(arg1, arg2, arg3=10, arg4=20, *arg5, **arg6): # pylint: disable=keyword-arg-before-vararg @@ -48,7 +43,7 @@ def function_with_help(help=True): # pylint: disable=redefined-builtin return help -class Empty(object): +class Empty: pass @@ -56,20 +51,20 @@ class OldStyleEmpty: # pylint: disable=old-style-class,no-init pass -class WithInit(object): +class WithInit: def __init__(self): pass -class ErrorInConstructor(object): +class ErrorInConstructor: def __init__(self, value='value'): self.value = value raise ValueError('Error in constructor') -class WithHelpArg(object): +class WithHelpArg: """Test class for testing when class has a help= arg.""" def __init__(self, help=True): # pylint: disable=redefined-builtin @@ -77,7 +72,7 @@ def __init__(self, help=True): # pylint: disable=redefined-builtin self.dictionary = {'__help': 'help in a dict'} -class NoDefaults(object): +class NoDefaults: def double(self, count): return 2 * count @@ -86,7 +81,7 @@ def triple(self, count): return 3 * count -class WithDefaults(object): +class WithDefaults: """Class with functions that have default arguments.""" def double(self, count=0): @@ -96,13 +91,20 @@ def double(self, count=0): count: Input number that you want to double. Returns: - A number that is the double of count.s + A number that is the double of count. """ return 2 * count def triple(self, count=0): return 3 * count + def text( + self, + string=('0001020304050607080910111213141516171819' + '2021222324252627282930313233343536373839') + ): + return string + class OldStyleWithDefaults: # pylint: disable=old-style-class,no-init @@ -113,7 +115,7 @@ def triple(self, count=0): return 3 * count -class MixedDefaults(object): +class MixedDefaults: def ten(self): return 10 @@ -125,7 +127,7 @@ def identity(self, alpha, beta='0'): return alpha, beta -class SimilarArgNames(object): +class SimilarArgNames: def identity(self, bool_one=False, bool_two=False): return bool_one, bool_two @@ -134,13 +136,13 @@ def identity2(self, a=None, alpha=None): return a, alpha -class CapitalizedArgNames(object): +class CapitalizedArgNames: def sum(self, Delta=1.0, Gamma=2.0): # pylint: disable=invalid-name return Delta + Gamma -class Annotations(object): +class Annotations: def double(self, count=0): return 2 * count @@ -152,7 +154,7 @@ def triple(self, count=0): triple.__annotations__ = {'count': float} -class TypedProperties(object): +class TypedProperties: """Test class for testing Python Fire with properties of various types.""" def __init__(self): @@ -171,7 +173,7 @@ def __init__(self): self.gamma = 'myexcitingstring' -class VarArgs(object): +class VarArgs: """Test class for testing Python Fire with a property with varargs.""" def cumsums(self, *items): @@ -189,7 +191,7 @@ def varchars(self, alpha=0, beta=0, *chars): # pylint: disable=keyword-arg-befo return alpha, beta, ''.join(chars) -class Underscores(object): +class Underscores: def __init__(self): self.underscore_example = 'fish fingers' @@ -198,20 +200,20 @@ def underscore_function(self, underscore_arg): return underscore_arg -class BoolConverter(object): +class BoolConverter: def as_bool(self, arg=False): - return arg + return bool(arg) -class ReturnsObj(object): +class ReturnsObj: def get_obj(self, *items): del items # Unused return BoolConverter() -class NumberDefaults(object): +class NumberDefaults: def reciprocal(self, divisor=10.0): return 1.0 / divisor @@ -220,7 +222,7 @@ def integer_reciprocal(self, divisor=10): return 1.0 / divisor -class InstanceVars(object): +class InstanceVars: def __init__(self, arg1, arg2): self.arg1 = arg1 @@ -230,7 +232,7 @@ def run(self, arg1, arg2): return (self.arg1, self.arg2, arg1, arg2) -class Kwargs(object): +class Kwargs: def props(self, **kwargs): return kwargs @@ -242,13 +244,13 @@ def run(self, positional, named=None, **kwargs): return (positional, named, kwargs) -class ErrorRaiser(object): +class ErrorRaiser: def fail(self): raise ValueError('This error is part of a test.') -class NonComparable(object): +class NonComparable: def __eq__(self, other): raise ValueError('Instances of this class cannot be compared.') @@ -257,7 +259,7 @@ def __ne__(self, other): raise ValueError('Instances of this class cannot be compared.') -class EmptyDictOutput(object): +class EmptyDictOutput: def totally_empty(self): return {} @@ -266,7 +268,7 @@ def nothing_printable(self): return {'__do_not_print_me': 1} -class CircularReference(object): +class CircularReference: def create(self): x = {} @@ -274,7 +276,7 @@ def create(self): return x -class OrderedDictionary(object): +class OrderedDictionary: def empty(self): return collections.OrderedDict() @@ -286,7 +288,8 @@ def non_empty(self): return ordered_dict -class NamedTuple(object): +class NamedTuple: + """Functions returning named tuples used for testing.""" def point(self): """Point example straight from Python docs.""" @@ -294,19 +297,50 @@ def point(self): Point = collections.namedtuple('Point', ['x', 'y']) return Point(11, y=22) + def matching_names(self): + """Field name equals value.""" + # pylint: disable=invalid-name + Point = collections.namedtuple('Point', ['x', 'y']) + return Point(x='x', y='y') + + +class CallableWithPositionalArgs: + """Test class for supporting callable.""" + + TEST = 1 + + def __call__(self, x, y): + return x + y + + def fn(self, x): + return x + 1 -class CallableWithKeywordArgument(object): + +NamedTuplePoint = collections.namedtuple('NamedTuplePoint', ['x', 'y']) + + +class SubPoint(NamedTuplePoint): + """Used for verifying subclasses of namedtuples behave as intended.""" + + def coordinate_sum(self): + return self.x + self.y + + +class CallableWithKeywordArgument: """Test class for supporting callable.""" def __call__(self, **kwargs): for key, value in kwargs.items(): - print('%s: %s' % (key, value)) + print('{}: {}'.format(key, value)) def print_msg(self, msg): print(msg) -class ClassWithDocstring(object): +CALLABLE_WITH_KEYWORD_ARGUMENT = CallableWithKeywordArgument() + + +class ClassWithDocstring: """Test class for testing help text output. This is some detail description of this test class. @@ -329,7 +363,7 @@ def print_msg(self, msg=None): print(msg) -class ClassWithMultilineDocstring(object): +class ClassWithMultilineDocstring: """Test class for testing help text output with multiline docstring. This is a test class that has a long docstring description that spans across @@ -354,5 +388,181 @@ def example_generator(n): [0, 1, 2, 3] """ - for i in range(n): - yield i + yield from range(n) + + +def simple_set(): + return {1, 2, 'three'} + + +def simple_frozenset(): + return frozenset({1, 2, 'three'}) + + +class Subdict(dict): + """A subclass of dict, for testing purposes.""" + + +# An example subdict. +SUBDICT = Subdict({1: 2, 'red': 'blue'}) + + +class Color(enum.Enum): + RED = 1 + GREEN = 2 + BLUE = 3 + + +class HasStaticAndClassMethods: + """A class with a static method and a class method.""" + + CLASS_STATE = 1 + + def __init__(self, instance_state): + self.instance_state = instance_state + + @staticmethod + def static_fn(args): + return args + + @classmethod + def class_fn(cls, args): + return args + cls.CLASS_STATE + + +def function_with_varargs(arg1, arg2, arg3=1, *varargs): # pylint: disable=keyword-arg-before-vararg + """Function with varargs. + + Args: + arg1: Position arg docstring. + arg2: Position arg docstring. + arg3: Flags docstring. + *varargs: Accepts unlimited positional args. + Returns: + The unlimited positional args. + """ + del arg1, arg2, arg3 # Unused. + return varargs + + +def function_with_keyword_arguments(arg1, arg2=3, **kwargs): + del arg2 # Unused. + return arg1, kwargs + + +def fn_with_code_in_docstring(): + """This has code in the docstring. + + + + Example: + x = fn_with_code_in_docstring() + indentation_matters = True + + + + Returns: + True. + """ + return True + + +class BinaryCanvas: + """A canvas with which to make binary art, one bit at a time.""" + + def __init__(self, size=10): + self.pixels = [[0] * size for _ in range(size)] + self._size = size + self._row = 0 # The row of the cursor. + self._col = 0 # The column of the cursor. + + def __str__(self): + return '\n'.join( + ' '.join(str(pixel) for pixel in row) for row in self.pixels) + + def show(self): + print(self) + return self + + def move(self, row, col): + self._row = row % self._size + self._col = col % self._size + return self + + def on(self): + return self.set(1) + + def off(self): + return self.set(0) + + def set(self, value): + self.pixels[self._row][self._col] = value + return self + + +class DefaultMethod: + + def double(self, number): + return 2 * number + + def __getattr__(self, name): + def _missing(): + return 'Undefined function' + return _missing + + +class InvalidProperty: + + def double(self, number): + return 2 * number + + @property + def prop(self): + raise ValueError('test') + + +def simple_decorator(f): + @functools.wraps(f) + def wrapper(*args, **kwargs): + return f(*args, **kwargs) + return wrapper + + +@simple_decorator +def decorated_method(name='World'): + return 'Hello %s' % name + + +# pylint: disable=g-doc-args,g-doc-return-or-yield +def fn_with_kwarg(arg1, arg2, **kwargs): + """Function with kwarg. + + :param arg1: Description of arg1. + :param arg2: Description of arg2. + :key arg3: Description of arg3. + """ + del arg1, arg2 + return kwargs.get('arg3') + + +def fn_with_kwarg_and_defaults(arg1, arg2, opt=True, **kwargs): + """Function with kwarg and defaults. + + :param arg1: Description of arg1. + :param arg2: Description of arg2. + :key arg3: Description of arg3. + """ + del arg1, arg2, opt + return kwargs.get('arg3') + + +def fn_with_multiple_defaults(first='first', last='last', late='late'): + """Function with kwarg and defaults. + + :key first: Description of first. + :key last: Description of last. + :key late: Description of late. + """ + del last, late + return first +# pylint: enable=g-doc-args,g-doc-return-or-yield diff --git a/fire/test_components_bin.py b/fire/test_components_bin.py new file mode 100644 index 00000000..62afdf11 --- /dev/null +++ b/fire/test_components_bin.py @@ -0,0 +1,28 @@ +# Copyright (C) 2018 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Python Fire test components Fire CLI. + +This file is useful for replicating test results manually. +""" + +import fire +from fire import test_components + + +def main(): + fire.Fire(test_components) + +if __name__ == '__main__': + main() diff --git a/fire/test_components_py3.py b/fire/test_components_py3.py index d705c43a..192302d3 100644 --- a/fire/test_components_py3.py +++ b/fire/test_components_py3.py @@ -14,16 +14,88 @@ """This module has components that use Python 3 specific syntax.""" +import asyncio +import functools +from typing import Tuple + +# pylint: disable=keyword-arg-before-vararg def identity(arg1, arg2: int, arg3=10, arg4: int = 20, *arg5, arg6, arg7: int, arg8=30, arg9: int = 40, **arg10): return arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 -class KeywordOnly(object): +class HelpTextComponent: + + def identity(self, *, alpha, beta='0'): + return alpha, beta + + +class KeywordOnly: def double(self, *, count): return count * 2 def triple(self, *, count): return count * 3 + + def with_default(self, *, x="x"): + print("x: " + x) + + +class LruCacheDecoratedMethod: + + @functools.lru_cache() + def lru_cache_in_class(self, arg1): + return arg1 + + +@functools.lru_cache() +def lru_cache_decorated(arg1): + return arg1 + + +class WithAsyncio: + + async def double(self, count=0): + return 2 * count + + +class WithTypes: + """Class with functions that have default arguments and types.""" + + def double(self, count: float) -> float: + """Returns the input multiplied by 2. + + Args: + count: Input number that you want to double. + + Returns: + A number that is the double of count. + """ + return 2 * count + + def long_type( + self, + long_obj: (Tuple[Tuple[Tuple[Tuple[Tuple[Tuple[Tuple[ + Tuple[Tuple[Tuple[Tuple[Tuple[int]]]]]]]]]]]]) + ): + return long_obj + + +class WithDefaultsAndTypes: + """Class with functions that have default arguments and types.""" + + def double(self, count: float = 0) -> float: + """Returns the input multiplied by 2. + + Args: + count: Input number that you want to double. + + Returns: + A number that is the double of count. + """ + return 2 * count + + def get_int(self, value: int = None): + return 0 if value is None else value diff --git a/fire/test_components_test.py b/fire/test_components_test.py index f35d7ab5..531f882c 100644 --- a/fire/test_components_test.py +++ b/fire/test_components_test.py @@ -14,10 +14,6 @@ """Tests for the test_components module.""" -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from fire import test_components as tc from fire import testutils diff --git a/fire/testutils.py b/fire/testutils.py index 0541a9a5..eca37f43 100644 --- a/fire/testutils.py +++ b/fire/testutils.py @@ -14,21 +14,17 @@ """Utilities for Python Fire's tests.""" -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import contextlib +import io +import os import re import sys import unittest +from unittest import mock from fire import core from fire import trace -import mock -import six - class BaseTestCase(unittest.TestCase): """Shared test case for Python Fire tests.""" @@ -44,11 +40,12 @@ def assertOutputMatches(self, stdout='.*', stderr='.*', capture=True): stdout: (str) regexp to match against stdout (None will check no stdout) stderr: (str) regexp to match against stderr (None will check no stderr) capture: (bool, default True) do not bubble up stdout or stderr + Yields: Yields to the wrapped context. """ - stdout_fp = six.StringIO() - stderr_fp = six.StringIO() + stdout_fp = io.StringIO() + stderr_fp = io.StringIO() try: with mock.patch.object(sys, 'stdout', stdout_fp): with mock.patch.object(sys, 'stderr', stderr_fp): @@ -80,6 +77,7 @@ def assertRaisesFireExit(self, code, regexp='.*'): Args: code: The status code that the FireExit should contain. regexp: stdout must match this regex. + Yields: Yields to the wrapped context. """ @@ -89,13 +87,26 @@ def assertRaisesFireExit(self, code, regexp='.*'): yield except core.FireExit as exc: if exc.code != code: - raise AssertionError('Incorrect exit code: %r != %r' % (exc.code, - code)) + raise AssertionError('Incorrect exit code: %r != %r' % + (exc.code, code)) self.assertIsInstance(exc.trace, trace.FireTrace) raise +@contextlib.contextmanager +def ChangeDirectory(directory): + """Context manager to mock a directory change and revert on exit.""" + cwdir = os.getcwd() + os.chdir(directory) + + try: + yield directory + finally: + os.chdir(cwdir) + + # pylint: disable=invalid-name main = unittest.main skip = unittest.skip +skipIf = unittest.skipIf # pylint: enable=invalid-name diff --git a/fire/testutils_test.py b/fire/testutils_test.py index ad604193..4cfc0937 100644 --- a/fire/testutils_test.py +++ b/fire/testutils_test.py @@ -14,16 +14,10 @@ """Test the test utilities for Fire's tests.""" -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import sys from fire import testutils -import six - class TestTestUtils(testutils.BaseTestCase): """Let's get meta.""" @@ -34,15 +28,15 @@ def testNoCheckOnException(self): raise ValueError() def testCheckStdoutOrStderrNone(self): - with six.assertRaisesRegex(self, AssertionError, 'stdout:'): + with self.assertRaisesRegex(AssertionError, 'stdout:'): with self.assertOutputMatches(stdout=None): print('blah') - with six.assertRaisesRegex(self, AssertionError, 'stderr:'): + with self.assertRaisesRegex(AssertionError, 'stderr:'): with self.assertOutputMatches(stderr=None): print('blah', file=sys.stderr) - with six.assertRaisesRegex(self, AssertionError, 'stderr:'): + with self.assertRaisesRegex(AssertionError, 'stderr:'): with self.assertOutputMatches(stdout='apple', stderr=None): print('apple') print('blah', file=sys.stderr) diff --git a/fire/trace.py b/fire/trace.py index f898f88d..601026fd 100644 --- a/fire/trace.py +++ b/fire/trace.py @@ -25,11 +25,7 @@ component will be None. """ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - -import pipes +import shlex from fire import inspectutils @@ -42,7 +38,7 @@ INTERACTIVE_MODE = 'Entered interactive mode' -class FireTrace(object): +class FireTrace: """A FireTrace represents the steps taken during a single Fire execution. A FireTrace consists of a sequence of FireTraceElement objects. Each element @@ -66,9 +62,7 @@ def __init__(self, initial_component, name=None, separator='-', verbose=False, def GetResult(self): """Returns the component from the last element of the trace.""" - # pytype: disable=attribute-error return self.GetLastHealthyElement().component - # pytype: enable=attribute-error def GetLastHealthyElement(self): """Returns the last element of the trace that is not an error. @@ -81,7 +75,7 @@ def GetLastHealthyElement(self): for element in reversed(self.elements): if not element.HasError(): return element - return None + return self.elements[0] # The initial element is always healthy. def HasError(self): """Returns whether the Fire execution encountered a Fire usage error.""" @@ -166,12 +160,15 @@ def display(arg1, arg2='!'): def _Quote(self, arg): if arg.startswith('--') and '=' in arg: prefix, value = arg.split('=', 1) - return pipes.quote(prefix) + '=' + pipes.quote(value) - return pipes.quote(arg) + return shlex.quote(prefix) + '=' + shlex.quote(value) + return shlex.quote(arg) - def GetCommand(self): + def GetCommand(self, include_separators=True): """Returns the command representing the trace up to this point. + Args: + include_separators: Whether or not to include separators in the command. + Returns: A string representing a Fire CLI command that would produce this trace. """ @@ -184,10 +181,10 @@ def GetCommand(self): continue if element.args: args.extend(element.args) - if element.HasSeparator(): + if element.HasSeparator() and include_separators: args.append(self.separator) - if self.NeedsSeparator(): + if self.NeedsSeparator() and include_separators: args.append(self.separator) return ' '.join(self._Quote(arg) for arg in args) @@ -211,13 +208,11 @@ def NeedsSeparator(self): return element.HasCapacity() and not element.HasSeparator() def __str__(self): - return '\n'.join( - '{index}. {trace_string}'.format( - index=index + 1, - trace_string=element, - ) - for index, element in enumerate(self.elements) - ) + lines = [] + for index, element in enumerate(self.elements): + line = f'{index + 1}. {element}' + lines.append(line) + return '\n'.join(lines) def NeedsSeparatingHyphenHyphen(self, flag='help'): """Returns whether a the trace need '--' before '--help'. @@ -241,7 +236,7 @@ def NeedsSeparatingHyphenHyphen(self, flag='help'): or flag in spec.kwonlyargs) -class FireTraceElement(object): +class FireTraceElement: """A FireTraceElement represents a single step taken by a Fire execution. Examples of a FireTraceElement are the instantiation of a class or the @@ -261,7 +256,7 @@ def __init__(self, Args: component: The result of this element of the trace. - action: The type of action (eg instantiating a class) taking place. + action: The type of action (e.g. instantiating a class) taking place. target: (string) The name of the component being acted upon. args: The args consumed by the represented action. filename: The file in which the action is defined, or None if N/A. @@ -291,18 +286,21 @@ def HasSeparator(self): def AddSeparator(self): self._separator = True + def ErrorAsStr(self): + return ' '.join(str(arg) for arg in self._error.args) + def __str__(self): - if not self.HasError(): + if self.HasError(): + return self.ErrorAsStr() + else: # Format is: {action} "{target}" ({filename}:{lineno}) string = self._action if self._target is not None: - string += ' "{target}"'.format(target=self._target) + string += f' "{self._target}"' if self._filename is not None: path = self._filename if self._lineno is not None: - path += ':{lineno}'.format(lineno=self._lineno) + path += f':{self._lineno}' - string += ' ({path})'.format(path=path) + string += f' ({path})' return string - else: - return str(self._error) diff --git a/fire/trace_test.py b/fire/trace_test.py index 1621a593..1f858f5e 100644 --- a/fire/trace_test.py +++ b/fire/trace_test.py @@ -14,10 +14,6 @@ """Tests for the trace module.""" -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from fire import testutils from fire import trace diff --git a/fire/value_types.py b/fire/value_types.py index 77d05dc7..81308973 100644 --- a/fire/value_types.py +++ b/fire/value_types.py @@ -14,16 +14,13 @@ """Types of values.""" -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import inspect -import six +from fire import inspectutils -VALUE_TYPES = (bool, six.string_types, six.integer_types, float, complex) +VALUE_TYPES = (bool, str, bytes, int, float, complex, + type(Ellipsis), type(None), type(NotImplemented)) def IsGroup(component): @@ -36,4 +33,48 @@ def IsCommand(component): def IsValue(component): - return isinstance(component, VALUE_TYPES) + return isinstance(component, VALUE_TYPES) or HasCustomStr(component) + + +def IsSimpleGroup(component): + """If a group is simple enough, then we treat it as a value in PrintResult. + + Only if a group contains all value types do we consider it simple enough to + print as a value. + + Args: + component: The group to check for value-group status. + Returns: + A boolean indicating if the group should be treated as a value for printing + purposes. + """ + assert isinstance(component, dict) + for unused_key, value in component.items(): + if not IsValue(value) and not isinstance(value, (list, dict)): + return False + return True + + +def HasCustomStr(component): + """Determines if a component has a custom __str__ method. + + Uses inspect.classify_class_attrs to determine the origin of the object's + __str__ method, if one is present. If it defined by `object` itself, then + it is not considered custom. Otherwise it is. This means that the __str__ + methods of primitives like ints and floats are considered custom. + + Objects with custom __str__ methods are treated as values and can be + serialized in places where more complex objects would have their help screen + shown instead. + + Args: + component: The object to check for a custom __str__ method. + Returns: + Whether `component` has a custom __str__ method. + """ + if hasattr(component, '__str__'): + class_attrs = inspectutils.GetClassAttrsDict(type(component)) or {} + str_attr = class_attrs.get('__str__') + if str_attr and str_attr.defining_class is not object: + return True + return False diff --git a/mkdocs.yml b/mkdocs.yml index bb815e37..bbe1e848 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,7 +1,7 @@ site_name: Python Fire theme: readthedocs markdown_extensions: [fenced_code] -pages: +nav: - Overview: index.md - Installation: installation.md - Benefits: benefits.md diff --git a/pylintrc b/pylintrc index 37bfa447..8896bb5b 100644 --- a/pylintrc +++ b/pylintrc @@ -7,9 +7,6 @@ # pygtk.require(). #init-hook= -# Profiled execution. -profile=no - # Add to the black list. It should be a base name, not a # path. You may set this option multiple times. ignore= @@ -32,7 +29,7 @@ enable=indexing-exception,old-raise-syntax # Disable the message, report, category or checker with the given id(s). You # can either give multiple identifier separated by comma (,) or put this option # multiple time. -disable=design,similarities,no-self-use,attribute-defined-outside-init,locally-disabled,star-args,pointless-except,bad-option-value,global-statement,fixme,suppressed-message,useless-suppression,locally-enabled,file-ignored,wrong-import-order,useless-object-inheritance,no-else-return +disable=design,similarities,no-self-use,attribute-defined-outside-init,locally-disabled,star-args,pointless-except,bad-option-value,global-statement,fixme,suppressed-message,useless-suppression,locally-enabled,file-ignored,wrong-import-order,useless-object-inheritance,no-else-return,super-with-arguments,raise-missing-from,consider-using-f-string,unspecified-encoding,unnecessary-lambda-assignment,wrong-import-position,ungrouped-imports,deprecated-module [REPORTS] @@ -41,14 +38,6 @@ disable=design,similarities,no-self-use,attribute-defined-outside-init,locally-d # (visual studio) and html output-format=text -# Include message's id in output -include-ids=no - -# Put messages in a separate file for each module / package specified on the -# command line instead of printing them on stdout. Reports (if any) will be -# written in a file name "pylint_global.[txt|html]". -files-output=no - # Tells whether to display a full report or only the messages reports=yes @@ -59,10 +48,6 @@ reports=yes # (R0004). evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) -# Add a comment according to your evaluation note. This is used by the global -# evaluation report (R0004). -comment=no - [VARIABLES] @@ -79,9 +64,6 @@ additional-builtins= [BASIC] -# List of builtins function names that should not be used, separated by a comma -bad-functions=map,filter,apply,input,reduce - # Regular expression which should only match correct module names module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ @@ -114,7 +96,7 @@ inlinevar-rgx=^[a-z][a-z0-9_]*$ good-names=i,j,k,ex,main,Run,_ # Bad variable names which should always be refused, separated by a comma -bad-names=foo,bar,baz,toto,tutu,tata +bad-names=map,filter,apply,input,reduce,foo,bar,baz,toto,tutu,tata # Regular expression which should only match functions or classes name which do # not require a docstring @@ -186,7 +168,7 @@ max-locals=15 max-returns=6 # Maximum number of branch for function / method body -max-branchs=12 +max-branches=12 # Maximum number of statements in function / method body max-statements=50 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..912c08aa --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,66 @@ +[build-system] +requires = ["setuptools>=45", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "fire" +version = "0.7.1" +description = "A library for automatically generating command line interfaces." +readme = "README.md" +license = {text = "Apache-2.0"} +authors = [ + {name = "David Bieber", email = "david810+fire@gmail.com"} +] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Topic :: Software Development :: Libraries :: Python Modules", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Operating System :: OS Independent", + "Operating System :: POSIX", + "Operating System :: MacOS", + "Operating System :: Unix", +] +keywords = ["command", "line", "interface", "cli", "python", "fire", "interactive", "bash", "tool"] +requires-python = ">=3.7" +dependencies = [ + "termcolor", +] + +[project.urls] +Homepage = "https://github.com/google/python-fire" +Repository = "https://github.com/google/python-fire" + +[project.optional-dependencies] +test = [ + "setuptools<=80.9.0", + "pip", + "pylint<3.3.8", + "pytest<=8.4.1", + "pytest-pylint<=1.1.2", + "pytest-runner<7.0.0", + "termcolor<3.2.0", + "hypothesis<6.137.0", + "levenshtein<=0.27.1", +] + +[tool.setuptools.packages.find] +include = ["fire*"] + +[tool.setuptools.package-data] +fire = ["console/*"] + +[tool.pytest.ini_options] +addopts = [ + "--ignore=fire/test_components_py3.py", + "--ignore=fire/parser_fuzz_test.py" +] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 9c558e35..00000000 --- a/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -. diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 058f329c..00000000 --- a/setup.cfg +++ /dev/null @@ -1,15 +0,0 @@ -[metadata] -license-file = LICENSE - -[wheel] -universal = 1 - -[aliases] -test = pytest - -[tool:pytest] -addopts = --ignore=fire/test_components_py3.py --ignore=fire/parser_fuzz_test.py - -[pytype] -inputs = . -output = .pytype diff --git a/setup.py b/setup.py deleted file mode 100644 index cf7d2b50..00000000 --- a/setup.py +++ /dev/null @@ -1,85 +0,0 @@ -# Copyright (C) 2018 Google Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""The setup.py file for Python Fire.""" - -from setuptools import setup - - -LONG_DESCRIPTION = """ -Python Fire is a library for automatically generating command line interfaces -(CLIs) with a single line of code. - -It will turn any Python module, class, object, function, etc. (any Python -component will work!) into a CLI. It's called Fire because when you call Fire(), -it fires off your command. -""".strip() - -SHORT_DESCRIPTION = """ -A library for automatically generating command line interfaces.""".strip() - -DEPENDENCIES = [ - 'six', -] - -TEST_DEPENDENCIES = [ - 'hypothesis', - 'mock', - 'python-Levenshtein', -] - -VERSION = '0.1.3' -URL = 'https://github.com/google/python-fire' - -setup( - name='fire', - version=VERSION, - description=SHORT_DESCRIPTION, - long_description=LONG_DESCRIPTION, - url=URL, - - author='David Bieber', - author_email='dbieber@google.com', - license='Apache Software License', - - classifiers=[ - 'Development Status :: 4 - Beta', - - 'Intended Audience :: Developers', - 'Topic :: Software Development :: Libraries :: Python Modules', - - 'License :: OSI Approved :: Apache Software License', - - 'Programming Language :: Python', - 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.4', - 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', - - 'Operating System :: OS Independent', - 'Operating System :: POSIX', - 'Operating System :: MacOS', - 'Operating System :: Unix', - ], - - keywords='command line interface cli python fire interactive bash tool', - - packages=['fire'], - - install_requires=DEPENDENCIES, - tests_require=TEST_DEPENDENCIES, -)