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 [](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 [](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 e15450db..b1470692 100644
--- a/fire/__init__.py
+++ b/fire/__init__.py
@@ -14,11 +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.1.4'
+__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 35c10fba..c0a3d784 100644
--- a/fire/console/console_attr.py
+++ b/fire/console/console_attr.py
@@ -100,8 +100,6 @@
from fire.console import encoding as encoding_util
from fire.console import text
-import six
-
# TODO: Unify this logic with console.style.mappings
class BoxLineCharacters(object):
@@ -268,7 +266,7 @@ def __init__(self, encoding=None, suppress_output=False):
# 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['
@@ -288,7 +286,7 @@ def __init__(self, encoding=None, suppress_output=False):
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()
@@ -355,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.
@@ -394,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
@@ -408,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):
@@ -456,7 +454,7 @@ def GetRawKey(self):
return self._get_raw_key[0]()
def GetTermIdentifier(self):
- """Returns the TERM envrionment variable for the console.
+ """Returns the TERM environment variable for the console.
Returns:
str: A str that describes the console's text capabilities
@@ -480,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)
@@ -595,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
@@ -692,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
@@ -779,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 8482c7bc..a7f38d4f 100644
--- a/fire/console/console_attr_os.py
+++ b/fire/console/console_attr_os.py
@@ -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
index 777f1f48..ec0858d9 100644
--- a/fire/console/console_io.py
+++ b/fire/console/console_io.py
@@ -15,11 +15,8 @@
"""General console printing utilities used by the Cloud SDK."""
-from __future__ import absolute_import
-from __future__ import division
-from __future__ import print_function
-
import os
+import signal
import subprocess
import sys
@@ -97,10 +94,15 @@ def More(contents, out, prompt=None, check_pager=True):
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
diff --git a/fire/console/console_pager.py b/fire/console/console_pager.py
index 044fcb37..565c7e1e 100644
--- a/fire/console/console_pager.py
+++ b/fire/console/console_pager.py
@@ -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 780e5a28..662342c6 100644
--- a/fire/console/encoding.py
+++ b/fire/console/encoding.py
@@ -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
index 69970f43..97222c3d 100644
--- a/fire/console/files.py
+++ b/fire/console/files.py
@@ -24,8 +24,6 @@
from fire.console import encoding as encoding_util
from fire.console import platforms
-import six
-
def _GetSystemPath():
"""Returns properly encoded system PATH variable string."""
@@ -48,7 +46,7 @@ def _FindExecutableOnPath(executable, path, pathext):
ValueError: invalid input.
"""
- if isinstance(pathext, six.string_types):
+ if isinstance(pathext, str):
raise ValueError('_FindExecutableOnPath(..., pathext=\'{0}\') failed '
'because pathext must be an iterable of strings, but got '
'a string.'.format(pathext))
diff --git a/fire/console/platforms.py b/fire/console/platforms.py
index 018eb89e..13fd8204 100644
--- a/fire/console/platforms.py
+++ b/fire/console/platforms.py
@@ -153,6 +153,8 @@ def Current():
return OperatingSystem.MACOSX
elif 'cygwin' in sys.platform:
return OperatingSystem.CYGWIN
+ elif 'msys' in sys.platform:
+ return OperatingSystem.MSYS
return None
@staticmethod
diff --git a/fire/core.py b/fire/core.py
index db18ab65..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
@@ -72,10 +68,9 @@ def main(argv):
from fire import trace
from fire import value_types
from fire.console import console_io
-import six
-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
@@ -91,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
@@ -109,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
@@ -141,27 +138,28 @@ def Fire(component=None, command=None, name=None):
_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_text = helptext.HelpText(
- result, component_trace, component_trace.verbose)
+ 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)]
+ 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_text = helptext.HelpText(
- result, component_trace, component_trace.verbose)
+ 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
@@ -201,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
@@ -232,31 +230,47 @@ 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 its own module.
result = component_trace.GetResult()
+ # 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:
- help_text = helptext.HelpText(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)
@@ -272,19 +286,19 @@ def _DisplayError(component_trace):
show_help = True
if show_help:
- 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)
- help_text = helptext.HelpText(result, component_trace,
- component_trace.verbose)
+ 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, component_trace,
- component_trace.verbose)
+ error_text = helptext.UsageText(result, trace=component_trace,
+ verbose=component_trace.verbose)
print(error_text, file=sys.stderr)
@@ -301,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)
@@ -486,7 +504,7 @@ def _Fire(component, args, parsed_flag_args, context, name=None):
# Treat namedtuples as dicts when handling them as a map.
if inspectutils.IsNamedTuple(component):
- component_dict = component._asdict() # pytype: disable=attribute-error
+ component_dict = component._asdict()
else:
component_dict = component
@@ -500,7 +518,8 @@ def _Fire(component, args, parsed_flag_args, context, name=None):
# 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.
- for key, value in component_dict.items():
+ for key, value in (
+ component_dict.items()):
if target == str(key):
component = value
handled = True
@@ -616,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,
@@ -625,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)
@@ -656,7 +675,19 @@ def _CallAndUpdateTrace(component, args, component_trace, treatment='class',
fn = component.__call__ if treatment == 'callable' else component
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
@@ -845,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
@@ -862,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 171611a9..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):
@@ -178,5 +175,54 @@ def testCallableWithPositionalArgs(self):
# 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 b7b3c660..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'
@@ -84,16 +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: 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 8cf121af..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):
@@ -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.
@@ -703,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 880e2b18..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(
@@ -65,3 +65,29 @@ def WrappedJoin(items, separator=' | ', width=80):
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 c19db054..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)
@@ -51,6 +49,30 @@ def test_wrap_multiple_items(self):
'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 af3372e2..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,358 +29,575 @@
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 HelpText(component, trace=None, verbose=False):
+ """Gets the help string for the current component, suitable for a help screen.
+
+ Args:
+ 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.
-def GetArgsAngFlags(component):
- """Returns all types of arguments and flags of a component."""
+ Returns:
+ The full help screen as a string.
+ """
+ # Preprocessing needed to create the sections:
+ info = inspectutils.Info(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, include_separators=True):
- """Returns current command for the purpose of generating help text."""
- if trace:
- current_command = trace.GetCommand(include_separators=include_separators)
+ if summary:
+ text = f'{current_command} - {summary}'
else:
- current_command = ''
- return current_command
+ text = current_command
+ return ('NAME', text)
-def HelpText(component, trace=None, verbose=False):
- info = inspectutils.Info(component)
- if inspect.isroutine(component) or inspect.isclass(component):
- return HelpTextForFunction(component, info, trace=trace, verbose=verbose)
- else:
- return HelpTextForObject(component, info, trace=trace, verbose=verbose)
+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)
+
+ 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)
-def GetDescriptionSectionText(summary, description):
- """Returns description section text based on the input docstring info.
+ text = f'{current_command} {continuation}'
+ return ('SYNOPSIS', text)
- 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
-
- current_command = GetCurrentCommand(trace)
- current_command_without_separator = GetCurrentCommand(
- trace, include_separators=False)
- summary, description = GetSummaryAndDescription(info['docstring_info'])
+ 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]
- args_with_no_defaults, args_with_defaults, flags = GetArgsAngFlags(component)
- del args_with_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_without_separator,
- command_summary=command_summary_str)
+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):]
# Check if positional args are allowed. If not, require flag syntax for args.
- metadata = decorators.GetMetadata(component)
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 = [
- '--{arg}={arg_upper}'.format(
- arg=arg, arg_upper=formatting.Underline(arg.upper()))
- for arg in args_with_no_defaults]
- arg_and_flag_strings.extend(arg_strings)
-
- flag_string_template = '[--{flag_name}={flag_name_upper}]'
- if flags:
- flag_strings = [
- flag_string_template.format(
- flag_name=formatting.Underline(flag), flag_name_upper=flag.upper())
- for flag in flags
- ]
- arg_and_flag_strings.extend(flag_strings)
- args_and_flags = ' '.join(arg_and_flag_strings)
-
- # Synopsis section
- synopsis_section_template = '{current_command} {args_and_flags}'
- 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 = []
+ docstring_info = info['docstring_info']
+
arg_items = [
- _CreateArgItem(arg, docstring_info)
+ _CreateArgItem(arg, docstring_info, spec)
for arg in args_with_no_defaults
]
+
+ if spec.varargs:
+ arg_items.append(
+ _CreateArgItem(spec.varargs, docstring_info, spec)
+ )
+
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 accepts_positional_args:
+ if args_with_no_defaults and accepts_positional_args:
notes_sections.append(
('NOTES', 'You can also use flags syntax for POSITIONAL ARGUMENTS')
)
- flag_items = [
- _CreateFlagItem(flag, docstring_info)
- for flag in flags
+ 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 _CreateOutputSection(name, content):
- return """{name}
-{content}""".format(name=formatting.Bold(name),
- content=formatting.Indent(content, 4))
+
+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 _CreateArgItem(arg, docstring_info):
+def _CreateOutputSection(name: str, content: str) -> str:
+ return f"""{formatting.Bold(name)}
+{formatting.Indent(content, SECTION_INDENTATION)}"""
+
+
+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(formatting.BoldUnderline(arg), description, indent=4)
- else:
- return formatting.BoldUnderline(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())
-def _CreateFlagItem(flag, docstring_info):
- """Returns a string describing a flag using information from the docstring.
+ 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, 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=formatting.Underline(flag))
- if description:
- return _CreateItem(flag, description, indent=2)
- 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.
+def _GetArgDefault(flag, spec):
+ """Returns a string describing a flag's default value.
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.
-
+ 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)
- current_command_without_separator = GetCurrentCommand(
- trace, include_separators=False)
- 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))
+ num_defaults = len(spec.defaults)
+ args_with_defaults = spec.args[-num_defaults:]
- 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:
- possible_actions.append('GROUP')
- usage_details_section = GroupUsageDetailsSection(groups)
- usage_details_sections.append(usage_details_section)
- if commands:
- possible_actions.append('COMMAND')
- usage_details_section = CommandUsageDetailsSection(commands)
- usage_details_sections.append(usage_details_section)
- if values:
- possible_actions.append('VALUE')
- usage_details_section = ValuesUsageDetailsSection(component, values)
- usage_details_sections.append(usage_details_section)
-
- 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_without_separator,
- command_summary=command_summary)
- output_sections = [
- ('NAME', name_line),
- ('SYNOPSIS', synopsis_string),
- ] + description_sections + usage_details_sections
+ 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 ''
+
+
+def _CreateItem(name, description, indent=2):
+ if not description:
+ return name
+ description = formatting.Indent(description, indent)
+ return f"""{name}
+{description}"""
- return '\n\n'.join(
- _CreateOutputSection(name, content)
- for name, content in output_sections
- )
+
+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 GroupUsageDetailsSection(groups):
- """Creates a section tuple for the groups section of the usage details."""
- group_item_strings = []
- for group_name, group in groups:
- group_info = inspectutils.Info(group)
- group_item = group_name
- if 'docstring_info' in group_info:
- group_docstring_info = group_info['docstring_info']
- if group_docstring_info and group_docstring_info.summary:
- group_item = _CreateItem(group_name,
- group_docstring_info.summary)
- group_item_strings.append(group_item)
- return ('GROUPS', _NewChoicesSection('GROUP', group_item_strings))
-
-
-def CommandUsageDetailsSection(commands):
- """Creates a section tuple for the commands section of the usage details."""
- 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)
- return ('COMMANDS', _NewChoicesSection('COMMAND', command_item_strings))
-
-
-def ValuesUsageDetailsSection(component, values):
+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:
+ for value_name, value in values.GetItems():
del value
init_info = inspectutils.Info(component.__class__.__init__)
value_item = None
@@ -401,35 +614,25 @@ def ValuesUsageDetailsSection(component, values):
def _NewChoicesSection(name, choices):
+ name_formatted = formatting.Bold(formatting.Underline(name))
return _CreateItem(
- '{name} is one of the following:'.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, trace=None, verbose=False):
- 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.
+ """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 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()
needs_separating_hyphen_hyphen = trace.NeedsSeparatingHyphenHyphen()
@@ -440,121 +643,145 @@ def UsageTextForFunction(component, trace=None):
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'
+ )
+
+ return f"""Usage: {continued_command}
+{''.join(availability_lines)}
+For detailed information on this command, run:
+ {help_command}"""
+
+
+def _GetPossibleActionsUsageString(possible_actions):
+ if possible_actions:
+ actions_str = '|'.join(possible_actions)
+ return f'<{actions_str}>'
+ return None
+
+
+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.
- metadata = decorators.GetMetadata(component)
accepts_positional_args = metadata.get(decorators.ACCEPTS_POSITIONAL_ARGS)
+
if not accepts_positional_args:
- items = ['--{arg}={upper}'.format(arg=arg, upper=arg.upper())
+ items = [f'--{arg}={arg.upper()}'
for arg in args_with_no_defaults]
else:
items = [arg.upper() for arg in args_with_no_defaults]
- if flags:
+ # If there are any arguments that are treated as flags:
+ if args_with_defaults or spec.kwonlyargs or spec.varkw:
items.append('')
- availability_lines = (
- '\nAvailable flags: '
- + ' | '.join('--' + flag for flag in flags) + '\n')
- else:
- availability_lines = ''
- args_and_flags = ' '.join(items)
- hyphen_hyphen = ' --' if needs_separating_hyphen_hyphen else ''
+ if spec.varargs:
+ items.append(f'[{spec.varargs.upper()}]...')
- return output_template.format(
- current_command=command,
- args_and_flags=args_and_flags,
- availability_lines=availability_lines,
- hyphen_hyphen=hyphen_hyphen)
+ return items
-def _CreateAvailabilityLine(header, items,
- header_indent=2, items_indent=25, line_length=80):
- 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'
+def _KeywordOnlyArguments(spec, required=True):
+ return (flag for flag in spec.kwonlyargs
+ if required != (flag in spec.kwonlydefaults))
-def UsageTextForObject(component, trace=None, verbose=False):
- """Returns the usage text for the error screen for an object.
+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):]
- Constructs the usage text for the error screen to inform the user about how
- to use the current component.
+ # 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)
+ ]
- 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}
-For detailed information on this command, run:
- {current_command} --help"""
- if trace:
- command = trace.GetCommand()
- else:
- command = None
+ # Flags section:
+ availability_lines = []
+ 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
- if not command:
- command = ''
- groups = []
- commands = []
- values = []
+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'
- members = completion._Members(component, verbose) # pylint: disable=protected-access
- for member_name, member in members:
- member_name = str(member_name)
- 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 = []
- availability_lines = []
- if groups:
- possible_actions.append('group')
- groups_text = _CreateAvailabilityLine(
- header='available groups:',
- items=groups)
- availability_lines.append(groups_text)
- if commands:
- possible_actions.append('command')
- commands_text = _CreateAvailabilityLine(
- header='available commands:',
- items=commands)
- availability_lines.append(commands_text)
- if values:
- possible_actions.append('value')
- values_text = _CreateAvailabilityLine(
- header='available values:',
- items=values)
- availability_lines.append(values_text)
+class ActionGroup:
+ """A group of actions of the same kind."""
- if possible_actions:
- possible_actions_string = ' <{actions}>'.format(
- actions='|'.join(possible_actions))
- else:
- possible_actions_string = ''
+ def __init__(self, name, plural):
+ self.name = name
+ self.plural = plural
+ self.names = []
+ self.members = []
- availability_lines_string = ''.join(availability_lines)
+ def Add(self, name, member=None):
+ self.names.append(name)
+ self.members.append(member)
- return output_template.format(
- current_command=command,
- possible_actions=possible_actions_string,
- availability_lines=availability_lines_string)
+ def GetItems(self):
+ return zip(self.names, self.members)
diff --git a/fire/helptext_test.py b/fire/helptext_test.py
index dfd92455..c7098fc4 100644
--- a/fire/helptext_test.py
+++ b/fire/helptext_test.py
@@ -14,10 +14,6 @@
"""Tests for the helptext module."""
-from __future__ import absolute_import
-from __future__ import division
-from __future__ import print_function
-
import os
import textwrap
@@ -31,6 +27,7 @@
class HelpTest(testutils.BaseTestCase):
def setUp(self):
+ super().setUp()
os.environ['ANSI_COLORS_DISABLED'] = '1'
def testHelpTextNoDefaults(self):
@@ -76,11 +73,119 @@ def testHelpTextFunctionWithDefaults(self):
component=component,
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 -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 --count', 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
help_screen = helptext.HelpText(
@@ -110,9 +215,9 @@ def testHelpTextEmptyList(self):
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 following:\n',
@@ -125,9 +230,9 @@ def testHelpTextShortList(self):
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
@@ -142,7 +247,9 @@ def testHelpTextInt(self):
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)
+ # 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 following:\n', help_screen)
@@ -155,6 +262,29 @@ def testHelpTextNoInit(self):
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')
@@ -215,14 +345,15 @@ def testHelpScreenForFunctionFunctionWithDefaultArgs(self):
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())
@@ -232,10 +363,11 @@ def testHelpTextUnderlineFlag(self):
help_screen = helptext.HelpText(component, t)
self.assertIn(formatting.Bold('NAME') + '\n triple', help_screen)
self.assertIn(
- formatting.Bold('SYNOPSIS') + '\n triple [--count=COUNT]',
+ formatting.Bold('SYNOPSIS') + '\n triple ',
help_screen)
self.assertIn(
- formatting.Bold('FLAGS') + '\n --' + formatting.Underline('count'),
+ formatting.Bold('FLAGS') + '\n -c, --' +
+ formatting.Underline('count'),
help_screen)
def testHelpTextBoldCommandName(self):
@@ -269,10 +401,32 @@ def testHelpTextNameSectionCommandWithSeparator(self):
component = 9
t = trace.FireTrace(component, name='int', separator='-')
t.AddSeparator()
- help_screen = helptext.HelpText(component=component, trace=t, verbose=True)
+ 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):
@@ -280,12 +434,12 @@ def testUsageOutput(self):
component = tc.NoDefaults()
t = trace.FireTrace(component, name='NoDefaults')
usage_output = helptext.UsageText(component, trace=t, verbose=False)
- expected_output = '''
+ expected_output = """
Usage: NoDefaults
available commands: double | triple
For detailed information on this command, run:
- NoDefaults --help'''
+ NoDefaults --help"""
self.assertEqual(
usage_output,
@@ -295,12 +449,12 @@ def testUsageOutputVerbose(self):
component = tc.NoDefaults()
t = trace.FireTrace(component, name='NoDefaults')
usage_output = helptext.UsageText(component, trace=t, verbose=True)
- expected_output = '''
+ expected_output = """
Usage: NoDefaults
available commands: double | triple
For detailed information on this command, run:
- NoDefaults --help'''
+ NoDefaults --help"""
self.assertEqual(
usage_output,
textwrap.dedent(expected_output).lstrip('\n'))
@@ -309,12 +463,12 @@ def testUsageOutputMethod(self):
component = tc.NoDefaults().double
t = trace.FireTrace(component, name='NoDefaults')
t.AddAccessedProperty(component, 'double', ['double'], None, None)
- usage_output = helptext.UsageText(component, 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'))
@@ -322,14 +476,13 @@ def testUsageOutputMethod(self):
def testUsageOutputFunctionWithHelp(self):
component = tc.function_with_help
t = trace.FireTrace(component, name='function_with_help')
- usage_output = helptext.UsageText(component, 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'))
@@ -337,81 +490,105 @@ def testUsageOutputFunctionWithHelp(self):
def testUsageOutputFunctionWithDocstring(self):
component = tc.multiplier_with_docstring
t = trace.FireTrace(component, name='multiplier_with_docstring')
- usage_output = helptext.UsageText(component, 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)
- @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')
- usage_output = helptext.UsageText(component, trace=t, verbose=True)
- # TODO(joejoevictor): We need to handle the case for keyword args as well
- # i.e. __call__ method of CallableWithKeywordArgument
- expected_output = '''
- Usage: CallableWithKeywordArgument
+ 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)
- Available commands: print_msg
+ def testUsageOutputCallable(self):
+ # 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')
- usage_output = helptext.UsageText(component, trace=t, verbose=True)
- expected_output = '''
+ 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 = '''
+ expected_output = """
Usage: EmptyDict
For detailed information on this command, run:
- EmptyDict --help'''
+ EmptyDict --help"""
self.assertEqual(
- usage_output,
- textwrap.dedent(expected_output).lstrip('\n'))
+ 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 = '''
+ expected_output = """
Usage: None
For detailed information on this command, run:
- None --help'''
+ None --help"""
self.assertEqual(
- usage_output,
- textwrap.dedent(expected_output).lstrip('\n'))
+ textwrap.dedent(expected_output).lstrip('\n'),
+ usage_output)
- @testutils.skip('Only passes in Python 3 for now.')
def testInitRequiresFlagSyntaxSubclassNamedTuple(self):
component = tc.SubPoint
t = trace.FireTrace(component, name='SubPoint')
- usage_output = helptext.UsageText(component, trace=t, verbose=True)
+ usage_output = helptext.UsageText(component, trace=t, verbose=False)
expected_output = 'Usage: SubPoint --x=X --y=Y'
self.assertIn(expected_output, usage_output)
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 47d343cf..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 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,7 @@ def non_empty(self):
return ordered_dict
-class NamedTuple(object):
+class NamedTuple:
"""Functions returning named tuples used for testing."""
def point(self):
@@ -302,12 +304,17 @@ def matching_names(self):
return Point(x='x', y='y')
-class CallableWithPositionalArgs(object):
+class CallableWithPositionalArgs:
"""Test class for supporting callable."""
+ TEST = 1
+
def __call__(self, x, y):
return x + y
+ def fn(self, x):
+ return x + 1
+
NamedTuplePoint = collections.namedtuple('NamedTuplePoint', ['x', 'y'])
@@ -319,18 +326,21 @@ def coordinate_sum(self):
return self.x + self.y
-class CallableWithKeywordArgument(object):
+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.
@@ -353,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
@@ -378,8 +388,7 @@ def example_generator(n):
[0, 1, 2, 3]
"""
- for i in range(n):
- yield i
+ yield from range(n)
def simple_set():
@@ -388,3 +397,172 @@ def simple_set():
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
index fbb41952..62afdf11 100644
--- a/fire/test_components_bin.py
+++ b/fire/test_components_bin.py
@@ -17,10 +17,6 @@
This file is useful for replicating test results manually.
"""
-from __future__ import absolute_import
-from __future__ import division
-from __future__ import print_function
-
import fire
from fire import test_components
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 7174f994..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,8 +160,8 @@ 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, include_separators=True):
"""Returns the command representing the trace up to this point.
@@ -216,10 +210,7 @@ def NeedsSeparator(self):
def __str__(self):
lines = []
for index, element in enumerate(self.elements):
- line = '{index}. {trace_string}'.format(
- index=index + 1,
- trace_string=element,
- )
+ line = f'{index + 1}. {element}'
lines.append(line)
return '\n'.join(lines)
@@ -245,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
@@ -265,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.
@@ -305,11 +296,11 @@ def __str__(self):
# 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
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 0e34a55f..00000000
--- a/setup.py
+++ /dev/null
@@ -1,86 +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',
- 'termcolor',
-]
-
-TEST_DEPENDENCIES = [
- 'hypothesis',
- 'mock',
- 'python-Levenshtein',
-]
-
-VERSION = '0.1.4'
-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', 'fire.console'],
-
- install_requires=DEPENDENCIES,
- tests_require=TEST_DEPENDENCIES,
-)