Skip to content

Commit 85dc060

Browse files
committed
Add guide on packaging command-line tooling
1 parent 1a64643 commit 85dc060

File tree

4 files changed

+218
-0
lines changed

4 files changed

+218
-0
lines changed

source/guides/analyzing-pypi-package-downloads.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
.. _analyzing-pypi-package-downloads:
2+
13
================================
24
Analyzing PyPI package downloads
35
================================
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
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/

source/guides/installing-stand-alone-command-line-tools.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
.. _installing-stand-alone-command-line-tools:
2+
13
Installing stand alone command line tools
24
=========================================
35

source/guides/section-build-and-publish.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ Building and Publishing
1111
dropping-older-python-versions
1212
packaging-binary-extensions
1313
packaging-namespace-packages
14+
creating-command-line-tools
1415
creating-and-discovering-plugins
1516
using-testpypi
1617
making-a-pypi-friendly-readme

0 commit comments

Comments
 (0)