|
| 1 | +.. _creating-command-line-tools: |
| 2 | + |
| 3 | +========================================= |
| 4 | +Creating and packaging command-line tools |
| 5 | +========================================= |
| 6 | + |
| 7 | +This guide will walk you through creating and packaging a standalone command-line application |
| 8 | +that can be installed with :ref:`pipx`, a tool creating and managing :term:`Python Virtual Environments <Virtual Environment>` |
| 9 | +and exposing the executable scripts of packages (and available manual pages) for use on the command-line. |
| 10 | + |
| 11 | +Creating the package |
| 12 | +==================== |
| 13 | + |
| 14 | +First of all, we'll need to create a source tree for the :term:`project <Project>`. For the sake of an example, we'll |
| 15 | +create a simple tool outputting a greeting (a string) for a person based on arguments given on the command-line. |
| 16 | + |
| 17 | +.. todo:: Advise on the optimal structure of a Python package in another guide or discussion and link to it here. |
| 18 | + |
| 19 | +This project will adhere to :ref:`src-layout <src-layout-vs-flat-layout>` and in the end be alike this file tree, |
| 20 | +with the top-level folder and package name ``greetings``: |
| 21 | + |
| 22 | +:: |
| 23 | + |
| 24 | + . |
| 25 | + ├── pyproject.toml |
| 26 | + └── src |
| 27 | + └── greetings |
| 28 | + ├── cli.py |
| 29 | + ├── greet.py |
| 30 | + ├── __init__.py |
| 31 | + └── __main__.py |
| 32 | + |
| 33 | +The actual code responsible for the tool's functionality will be stored in the file :file:`greet.py`, |
| 34 | +named after the main module: |
| 35 | + |
| 36 | +.. code-block:: python |
| 37 | +
|
| 38 | + def greet(name="", gender="", knight=False, count=1): |
| 39 | + greeting = "Greetings, dear " |
| 40 | + masculine = gender == "masculine" |
| 41 | + feminine = gender == "feminine" |
| 42 | + if gender or knight: |
| 43 | + salutation = "" |
| 44 | + if knight: |
| 45 | + salutation = "Sir " |
| 46 | + elif masculine: |
| 47 | + salutation = "Mr. " |
| 48 | + elif feminine: |
| 49 | + salutation = "Ms. " |
| 50 | + greeting += salutation |
| 51 | + if name: |
| 52 | + greeting += f"{name}!" |
| 53 | + else: |
| 54 | + pronoun = "her" if feminine else "his" if masculine or knight else "its" |
| 55 | + greeting += f"what's-{pronoun}-name!" |
| 56 | + else: |
| 57 | + if name: |
| 58 | + greeting += f"{name}!" |
| 59 | + elif not gender: |
| 60 | + greeting += "friend!" |
| 61 | + for i in range(0, count): |
| 62 | + print(greeting) |
| 63 | +
|
| 64 | +The above function receives several keyword arguments that determine how the greeting to output is constructed. |
| 65 | +Now, the command-line interface to provision it with the same needs to be constructed, which is done |
| 66 | +in :file:`cli.py`: |
| 67 | + |
| 68 | +.. code-block:: python |
| 69 | +
|
| 70 | + import argparse |
| 71 | + import sys |
| 72 | +
|
| 73 | + from .greet import greet |
| 74 | +
|
| 75 | + _arg_spec = { |
| 76 | + '--name': { |
| 77 | + 'metavar': 'STRING', |
| 78 | + 'type': str, |
| 79 | + 'help': 'The (last, if "gender" is given) name of the person to greet', |
| 80 | + }, |
| 81 | + '--count': { |
| 82 | + 'metavar': 'INT', |
| 83 | + 'type': int, |
| 84 | + 'default': 1, |
| 85 | + 'help': 'Number of times to greet the person', |
| 86 | + }, |
| 87 | +
|
| 88 | + } |
| 89 | + _arg_spec_mutually_exclusive = { |
| 90 | + '--gender': { |
| 91 | + 'metavar': 'STRING', |
| 92 | + 'type': str, |
| 93 | + 'help': 'The gender of the person to greet', |
| 94 | + }, |
| 95 | + '--knight': { |
| 96 | + 'action': 'store_true', |
| 97 | + 'default': False, |
| 98 | + 'help': 'Whether the person is a knight', |
| 99 | + }, |
| 100 | + } |
| 101 | +
|
| 102 | +
|
| 103 | + def main(): |
| 104 | + parser = argparse.ArgumentParser( |
| 105 | + description="Greet a person (semi-)formally." |
| 106 | + ) |
| 107 | + group = parser.add_mutually_exclusive_group() |
| 108 | + for arg, spec in _arg_spec.items(): |
| 109 | + parser.add_argument(arg, **spec) |
| 110 | + for arg, spec in _arg_spec_mutually_exclusive.items(): |
| 111 | + group.add_argument(arg, **spec) |
| 112 | + parsed_args = parser.parse_args() |
| 113 | + args = { |
| 114 | + arg: value |
| 115 | + for arg, value in vars(parsed_args).items() |
| 116 | + if value is not None |
| 117 | + } |
| 118 | + # Run the function with the command-line arguments as keyword arguments. |
| 119 | + # A more complex setup is normally initialized at this point. |
| 120 | + greet(**args) |
| 121 | +
|
| 122 | +
|
| 123 | + if __name__ == "__main__": |
| 124 | + sys.exit(main()) |
| 125 | +
|
| 126 | +The command-line interface is built with :py:mod:`argparse`, a command-line parser which is included in Python's |
| 127 | +standard library. It is a bit rudimentary but sufficient for most needs. Another easy-to-use alternative is docopt_; |
| 128 | +advanced users are encouraged to make use of click_. |
| 129 | + |
| 130 | +We'll add an empty :file:`__init__.py` file, too, to define the project as a regular :term:`import package <Import Package>`. |
| 131 | + |
| 132 | +The file :file:`__main__.py` marks the main entry point for the application when running it via ``python -m greetings``, |
| 133 | +so we'll just initizalize the command-line interface here. The first condition isn't necessary, but may be added in order |
| 134 | +to make the package runnable directly from the source tree, by prepending the package folder to Python's :py:data:`sys.path`: |
| 135 | + |
| 136 | +.. code-block:: python |
| 137 | +
|
| 138 | + import os |
| 139 | + import sys |
| 140 | +
|
| 141 | + if not __package__: |
| 142 | + # Make package runnable from source tree with |
| 143 | + # python src/greetings |
| 144 | + package_source_path = os.path.dirname(os.path.dirname(__file__)) |
| 145 | + sys.path.insert(0, package_source_path) |
| 146 | +
|
| 147 | + if __name__ == "__main__": |
| 148 | + from greetings.cli import main |
| 149 | + sys.exit(main()) |
| 150 | +
|
| 151 | +
|
| 152 | +``pyproject.toml`` |
| 153 | +------------------ |
| 154 | + |
| 155 | +The project's :term:`metadata <Pyproject Metadata>` is placed in :term:`pyproject.toml`. The :term:`pyproject metadata keys <Pyproject Metadata Key>` and the ``[build-system]`` table may be filled in as described in :ref:`writing-pyproject-toml`. |
| 156 | + |
| 157 | +For the project to be recognised as a command-line tool, additionally a ``console_scripts`` :ref:`entry point <entry-points>` (see :ref:`console_scripts`) needs to be added as a :term:`subkey <Pyproject Metadata Subkey>`: |
| 158 | + |
| 159 | +.. code-block:: toml |
| 160 | +
|
| 161 | + [project.scripts] |
| 162 | + greet = "greetings.cli:main" |
| 163 | +
|
| 164 | +Besides, it could prove rewarding to add a ``pipx``-specific entry point, the meaning of which is described below: |
| 165 | + |
| 166 | +.. code-block:: toml |
| 167 | +
|
| 168 | + [project.entry-points."pipx.run"] |
| 169 | + greetings = "greetings.cli:main" |
| 170 | +
|
| 171 | +
|
| 172 | +Now, the project's source tree is ready to be transformed into a :term:`distribution package <Distribution Package>`, |
| 173 | +which makes it installable. |
| 174 | + |
| 175 | + |
| 176 | +Installing the package with ``pipx`` |
| 177 | +==================================== |
| 178 | + |
| 179 | +After installing ``pipx`` as described in :ref:`installing-stand-alone-command-line-tools`, you're ready to install your project: |
| 180 | + |
| 181 | +.. code-block:: console |
| 182 | +
|
| 183 | + $ pipx install ./greetings/ |
| 184 | +
|
| 185 | +This will expose the executable script we defined as an entry point and make the command ``greet`` available to you. |
| 186 | +Let's test it: |
| 187 | + |
| 188 | +.. code-block:: console |
| 189 | +
|
| 190 | + $ greet --knight --name Lancelot |
| 191 | + Greetings, dear Sir Lancelot! |
| 192 | + $ greet --gender feminine --name Parks |
| 193 | + Greetings, dear Ms. Parks! |
| 194 | + $ greet --gender masculine |
| 195 | + Greetings, dear Mr. what's-his-name! |
| 196 | +
|
| 197 | +To just run the program without installing it permanently, you could use ``pipx run``, which will create a temporary (but cached) virtual environment for it: |
| 198 | + |
| 199 | +.. code-block:: console |
| 200 | +
|
| 201 | + $ pipx run ./greetings/ --knight |
| 202 | +
|
| 203 | +Thanks to the entry point we defined above (which *must* match the package name), ``pipx`` will pick up the executable script as the |
| 204 | +default one and run it; otherwise, you'd need to specify the entry point's name explicitly with ``pipx run --spec ./greetings/ greet --knight``. |
| 205 | + |
| 206 | +Conclusion |
| 207 | +========== |
| 208 | + |
| 209 | +You know by now how to package a command-line application written in Python. A further step could be to distribute you package, |
| 210 | +meaning uploading it to a :term:`package index <Package Index>`, most commonly :term:`PyPI <Python Package Index (PyPI)>`. To do that, follow the instructions at :ref:`Packaging your project`. And once you're done, don't forget to :ref:`do some research <analyzing-pypi-package-downloads>` on how your package is received! |
| 211 | + |
| 212 | +.. _click: https://click.palletsprojects.com/ |
| 213 | +.. _docopt: https://docopt.readthedocs.io/en/latest/ |
0 commit comments