In order to release your python code you need to package it building a python package.
Do exist two different type of python packages:
- Source distribution: (called
sdist
) is a compressed archive file of the source code with an extensiontgz
,tar.gz
- Binary distribution: (called
wheel
) is a binary file with an extension.whl
Main difference between those two artifcats is that a source distribution allows most anyone to build your code on their platform, a binary distribution is prebuilt for a given platform and saves users the work of building it themselves.
Now python packages building process involves two main actors:
- A build frontend: it's just an interface between you and a build backend. build package is a build frontend and you'll need to install to follow this tutorial
- A build backend: it makes real dirty work of building artifacts (sdist or wheel). setuptools is a build backend and we'll use it during this tutorial
A build frontend and a build backend talk with each other throug an interface defined in PEP 517.
+------------+
| build |
| (BE) |
+------------+
||
\/
+------------+
| PEP 517 |
| (interface)|
+------------+
||
\/
+------------+
| setuptools |
| (BE) |
+------------+
In order to create a binary distribution (wheel) setuptools
uses wheel package.
build-->setuptools-->wheel-->.whl
Altenatives build backends are for example: poetry and flit
In this way you can change build backend used by build frontend, but how/where to specify which build backend you wanna use?
According to PEP 518 pyproject.toml
is default configuartion file where you can define which build backend will be used and its dependecies (for example we said that setuptools uses wheel to build binary distrubution so wheel must be defined as setuptools's dependecy in pyproject.toml
to make building process working)
Two other files as important as pyproject.toml
in building process are:
setup.py
(dynamic): setup.py is the build script for setuptools. It tells setuptools about your package (such as the name and version) as well as which code files to includesetup.cfg
(static): is the configuration file for setuptools. It tells setuptools about your package (such as the name and version) as well as which code files to include. Eventually much of this configuration may be able to move to pyproject.toml.
They are basically used to define metadata of your package that will be consumed by build backend (setuptools)
So at this point we could say: there are two types of metadata: static and dynamic.
- Static metadata (
setup.cfg
): guaranteed to be the same every time. This is simpler, easier to read, and avoids many common errors, like encoding errors. - Dynamic metadata (
setup.py
): possibly non-deterministic. Any items that are dynamic or determined at install-time, as well as extension modules or extensions to setuptools, need to go into setup.py.
Static metadata (setup.cfg
) should be preferred. Dynamic metadata (setup.py
) should be used only as an escape hatch when absolutely necessary. setup.py used to be required, but can be omitted with newer versions of setuptools and pip
As mentioned in official python documentation pyproject.toml
+ setup.cfg
should be always preferred rather than setup.py
-
Install
pyproject-build
pip install build
-
Check your installation
pyproject-build --version
-
Run build process
pyproject-build
ERROR Source /home/antonio/dev/python-package-tutorial does not appear to be a Python project: no pyproject.toml or setup.py
-
Create a
pyproject.toml
touch pyproject.toml
-
Run build process
pyproject-build
* Creating venv isolated environment... * Installing packages in isolated environment... (setuptools >= 40.8.0, wheel) * Getting dependencies for sdist... running egg_info creating UNKNOWN.egg-info writing manifest file 'UNKNOWN.egg-info/SOURCES.txt' writing manifest file 'UNKNOWN.egg-info/SOURCES.txt' * Building sdist... running sdist running egg_info writing manifest file 'UNKNOWN.egg-info/SOURCES.txt' running check warning: check: missing required meta-data: name, url warning: check: missing meta-data: either (author and author_email) or (maintainer and maintainer_email) should be supplied creating UNKNOWN-0.0.0 creating UNKNOWN-0.0.0/UNKNOWN.egg-info copying README.md -> UNKNOWN-0.0.0 copying pyproject.toml -> UNKNOWN-0.0.0 copying UNKNOWN.egg-info/PKG-INFO -> UNKNOWN-0.0.0/UNKNOWN.egg-info copying UNKNOWN.egg-info/SOURCES.txt -> UNKNOWN-0.0.0/UNKNOWN.egg-info copying UNKNOWN.egg-info/dependency_links.txt -> UNKNOWN-0.0.0/UNKNOWN.egg-info copying UNKNOWN.egg-info/top_level.txt -> UNKNOWN-0.0.0/UNKNOWN.egg-info Creating tar archive removing 'UNKNOWN-0.0.0' (and everything under it) * Building wheel from sdist * Creating venv isolated environment... * Installing packages in isolated environment... (setuptools >= 40.8.0, wheel) * Getting dependencies for wheel... running egg_info writing manifest file 'UNKNOWN.egg-info/SOURCES.txt' * Installing packages in isolated environment... (wheel) * Building wheel... running bdist_wheel running build running install running install_egg_info running egg_info writing manifest file 'UNKNOWN.egg-info/SOURCES.txt' Copying UNKNOWN.egg-info to build/bdist.linux-x86_64/wheel/UNKNOWN-0.0.0-py3.7.egg-info running install_scripts Successfully built UNKNOWN-0.0.0.tar.gz and UNKNOWN-0.0.0-py3-none-any.whl
-
List root directory a new
dist
dir has been created for you, it cointains your packagels -al . total 4 drwxrwxr-x 1 antonio antonio 106 Dec 23 14:52 . drwxrwxr-x 1 antonio antonio 1852 Dec 23 14:45 .. drwxrwxr-x 1 antonio antonio 100 Dec 23 14:51 dist drwxrwxr-x 1 antonio antonio 138 Dec 23 14:45 .git drwxrwxr-x 1 antonio antonio 56 Dec 23 14:46 .myenv -rw-rw-r-- 1 antonio antonio 0 Dec 23 14:50 pyproject.toml -rw-rw-r-- 1 antonio antonio 2037 Dec 23 14:52 README.md drwxrwxr-x 1 antonio antonio 104 Dec 23 14:51 UNKNOWN.egg-info
-
dist
contains:UNKNOWN-0.0.0.tar.gz
source distrubtionUNKNOWN-0.0.0-py3-none-any.whl
binary destribution (wheel)
ls -al dist total 8 drwxrwxr-x 1 antonio antonio 100 Dec 23 14:51 . drwxrwxr-x 1 antonio antonio 106 Dec 23 14:54 .. -rw-rw-r-- 1 antonio antonio 961 Dec 23 14:51 UNKNOWN-0.0.0-py3-none-any.whl -rw-rw-r-- 1 antonio antonio 686 Dec 23 14:51 UNKNOWN-0.0.0.tar.gz
-
Create
setup.cfg
touch setup.cfg
-
Add in
setup.cfg
under[metadata]
section commit- name
- version
-
Run build process again
rm -rf dist pyproject-build
-
Now our package has its own
name
andversion
ls -al dist total 8 drwxrwxr-x 1 antonio antonio 152 Dec 23 15:09 . drwxrwxr-x 1 antonio antonio 182 Dec 23 15:09 .. -rw-rw-r-- 1 antonio antonio 1101 Dec 23 15:09 first_python_package-0.0.1-py3-none-any.whl -rw-rw-r-- 1 antonio antonio 1481 Dec 23 15:09 first-python-package-0.0.1.tar.gz
-
Add in
setup.cfg
under[metadata]
section commit- url
- author
- author_email
-
Add in
setup.cfg
under[metadata]
section commit- description
-
Add in
setup.cfg
under[metadata]
section commit- long_description
- long_description_content_type
-
Add a LICENCE file in root dir commit
-
Add in
setup.cfg
under[metadata]
section commit- license
- license_files
- classifiers
-
Check its content, you'have an empty package, let's add some code
tar -ztvf dist/first-python-package-0.0.1.tar.gz drwxrwxr-x antonio/antonio 0 2021-12-23 15:09 first-python-package-0.0.1/ -rw-rw-r-- antonio/antonio 145 2021-12-23 15:09 first-python-package-0.0.1/PKG-INFO -rw-rw-r-- antonio/antonio 2862 2021-12-23 15:09 first-python-package-0.0.1/README.md drwxrwxr-x antonio/antonio 0 2021-12-23 15:09 first-python-package-0.0.1/first_python_package.egg-info/ -rw-rw-r-- antonio/antonio 145 2021-12-23 15:09 first-python-package-0.0.1/first_python_package.egg-info/PKG-INFO -rw-rw-r-- antonio/antonio 210 2021-12-23 15:09 first-python-package-0.0.1/first_python_package.egg-info/SOURCES.txt -rw-rw-r-- antonio/antonio 1 2021-12-23 15:09 first-python-package-0.0.1/first_python_package.egg-info/dependency_links.txt -rw-rw-r-- antonio/antonio 1 2021-12-23 15:09 first-python-package-0.0.1/first_python_package.egg-info/top_level.txt -rw-rw-r-- antonio/antonio 90 2021-12-23 15:06 first-python-package-0.0.1/pyproject.toml -rw-rw-r-- antonio/antonio 94 2021-12-23 15:09 first-python-package-0.0.1/setup.cfg
-
Add a package layout like this commit
├── LICENSE ├── pyproject.toml ├── README.md ├── setup.cfg ├── src │ └── imppkg │ ├── hello.py │ └── __init__.py └── test
-
Add options in
setup.cfg
to inform setuptools how/where to find your packages commit -
Add a new file
data.json
to show how to add non python code commit -
Add
MANIFEST.in
commit -
Run again building process and check dist content, you can see your new package and
data.json
thererm -rf dist && pyproject-build
tar -ztvf dist/first-python-package-0.0.1.tar.gz drwxrwxr-x antonio/antonio 0 2022-01-04 11:39 first-python-package-0.0.1/ -rw-rw-r-- antonio/antonio 1092 2021-12-23 15:25 first-python-package-0.0.1/LICENSE -rw-rw-r-- antonio/antonio 50 2021-12-23 16:21 first-python-package-0.0.1/MANIFEST.in -rw-rw-r-- antonio/antonio 4835 2022-01-04 11:39 first-python-package-0.0.1/PKG-INFO -rw-rw-r-- antonio/antonio 4447 2021-12-23 15:35 first-python-package-0.0.1/README.md -rw-rw-r-- antonio/antonio 90 2021-12-23 15:06 first-python-package-0.0.1/pyproject.toml -rw-rw-r-- antonio/antonio 575 2022-01-04 11:39 first-python-package-0.0.1/setup.cfg drwxrwxr-x antonio/antonio 0 2022-01-04 11:39 first-python-package-0.0.1/src/ drwxrwxr-x antonio/antonio 0 2022-01-04 11:39 first-python-package-0.0.1/src/first_python_package.egg-info/ -rw-rw-r-- antonio/antonio 4835 2022-01-04 11:39 first-python-package-0.0.1/src/first_python_package.egg-info/PKG-INFO -rw-rw-r-- antonio/antonio 350 2022-01-04 11:39 first-python-package-0.0.1/src/first_python_package.egg-info/SOURCES.txt -rw-rw-r-- antonio/antonio 1 2022-01-04 11:39 first-python-package-0.0.1/src/first_python_package.egg-info/dependency_links.txt -rw-rw-r-- antonio/antonio 7 2022-01-04 11:39 first-python-package-0.0.1/src/first_python_package.egg-info/top_level.txt drwxrwxr-x antonio/antonio 0 2022-01-04 11:39 first-python-package-0.0.1/src/imppkg/ -rw-rw-r-- antonio/antonio 0 2021-12-23 15:34 first-python-package-0.0.1/src/imppkg/__init__.py -rw-rw-r-- antonio/antonio 0 2021-12-23 16:23 first-python-package-0.0.1/src/imppkg/data.json -rw-rw-r-- antonio/antonio 0 2021-12-23 15:34 first-python-package-0.0.1/src/imppkg/hello.py -rw-rw-r-- antonio/antonio 0 2021-12-23 16:05 first-python-package-0.0.1/src/imppkg/test_exclude_me_from_dist.py
-
Even if
data.json
has been included into source distribution, it's still missing in .whlunzip -l dist/first_python_package-0.0.1-py3-none-any.whl Archive: dist/first_python_package-0.0.1-py3-none-any.whl Length Date Time Name --------- ---------- ----- ---- 0 2021-12-23 14:34 imppkg/__init__.py 0 2021-12-23 15:23 imppkg/data.json 0 2021-12-23 14:34 imppkg/hello.py 0 2021-12-23 15:05 imppkg/test_exclude_me_from_dist.py 1092 2022-01-04 10:39 first_python_package-0.0.1.dist-info/LICENSE 4835 2022-01-04 10:39 first_python_package-0.0.1.dist-info/METADATA 92 2022-01-04 10:39 first_python_package-0.0.1.dist-info/WHEEL 7 2022-01-04 10:39 first_python_package-0.0.1.dist-info/top_level.txt 750 2022-01-04 10:39 first_python_package-0.0.1.dist-info/RECORD --------- ------- 6776 9 files
-
Set
include_package_data = True
in setup.cfg to include non python files in source distrubution into binary one commit
-
Add a main module:
src/imppkg/say.py
commitNow we have an entrypoint for our project
src.imppkg.say.main()
, some new code has been added insrc/imppkg/hello.py
in order to be able our project to do something: it just receives in input a language (a language code following iso639-1) and printHello World
translated in that language. In order to test you need to create a vevn and install say hello in your venv. Run donw below command in say hello root dir in order to respectively:- create a venv
- activate a venv
- install say hello in a venv
python3 -m venv .venv
source .venv/bin/activate
pip install .
At this point you have say hello installed in your env (and venv is activated under your fingers) and you can test it in this way:
python -m imppkg.say DE Traceback (most recent call last): File "/home/antonio/.pyenv/versions/3.9.0/lib/python3.9/runpy.py", line 197, in _run_module_as_main return _run_code(code, main_globals, None, File "/home/antonio/.pyenv/versions/3.9.0/lib/python3.9/runpy.py", line 87, in _run_code exec(code, run_globals) File "/home/antonio/dev/python-package-tutorial/.venv/lib/python3.9/site-packages/imppkg/say.py", line 3, in <module> from imppkg.hello import say_hello File "/home/antonio/dev/python-package-tutorial/.venv/lib/python3.9/site-packages/imppkg/hello.py", line 1, in <module> from googletrans import Translator ModuleNotFoundError: No module named 'googletrans'
As you can see we didn't include googletrans as depencencies of your project, we use it to translate
Hello World
. Let's add googletrans as dep in our project -
Add
install_requires
key of the[options]
section in thesetup.cfg
commit -
Now let's remove our venv
rm -rf .venv
-
Create a new venv and install our project again
python3 -m venv .venv
source .venv/bin/activate
pip install .
-
Run it again
python -m imppkg.say DE Hallo Welt
As you can see above you're running our project as module but we'd like to have a command called say
installed in bin
in order to execute our project just typing say DE
. In order to do that we need to add an entrypoint in setup.cfg
-
Add an
entry_point
insetup.cfg
, a command calledsay
will be avaible inbin
after a new installation commit. Remove.venv
and re-create a new one as you did before, then activate it and runsay
in this way:.venv/bin/say DE Hallo Welt
In order to biuld a test seuite you'll need a test runner. We'll use
pytest
(python -m pytest
)-
Add pytest's configuration in
setup.cfg
commit- add
testpaths
under[tool:pytest]
pytest is looking everywhere under the package’s root directory for tests To encourage placement of tests in the appropriate location, you should configure pytest to look only in the test/ directory.You can add configur ation for pytest into your package’s setup.cfg file using a new section called [tool:pytest] . The testpaths key maps to a list of paths in which to look for tests. You need just one: test .After you add this configurat ion,pytest should confirm in its output both that it’s using setup.cfg as the configuration file and that it found the testpaths configuration
[tool:pytest] testpaths = test
- add
-
Add a simple
assert True
to mock a test suite commit -
Run
python -m pytest
, you'll get an error like below, fix it just importing your packages as done in this commitModule imppkg was never imported. (module-not-imported)
-
Now Run pytest agaiin, it should work having your test suite up and running
-
-
Install
pytest-cov
-
Try it just running
python -m pytest --cov
-
You can specify on which module (we have one module:
imppkg
) --cov should measure test coverage in this way:python -m pytest --cov=imppkg
-
Add branch coverage commit
It enables branch coverage, in other words how many alternative execution paths are possible, and which of those paths are untested. To configure branch coverage for your tests, add a new section to setup.cfg called [coverage:run] . In this section, add a branch key with a value of True This produces two new columns in the coverage output - Branch : how many branches exist through the code - BrPart : how many branches are only partially covered by tests python -m pytest --cov=imppkg test/test_translations.py . [100%] ----------- coverage: platform linux, python 3.9.0-final-0 ----------- Name Stmts Miss Branch BrPart Cover --------------------------------------------------------------------------------------------------------- .venv/lib/python3.9/site-packages/imppkg/__init__.py 0 0 0 0 100% .venv/lib/python3.9/site-packages/imppkg/hello.py 4 1 0 0 75% .venv/lib/python3.9/site-packages/imppkg/say.py 18 12 4 1 32% .venv/lib/python3.9/site-packages/imppkg/test_exclude_me_from_dist.py 0 0 0 0 100% --------------------------------------------------------------------------------------------------------- TOTAL 22 13 4 1 38%
[coverage:run] branch = True
-
Improve coverage output adding source key under
[coverage:run]
commitadd a source key with a value of imppkg This is a handy way to stop specifying imppkg to the --cov option for pytest each time, and ensures that anyone runn ing tests with coverage will see the sa me output.
[coverage:run] source = imppkg
-
Avoid to type
--cov
every time and add that toaddopts
key under[tool:pytest]
[commit[(https://github.com/kinderp/python-package-tutorial/commit/c8ac84a9e5a8a7f76f4e6a4423a90004ac911abe)You can also avoid specifying --cov altogether by adding an addopts key to the [tool:pytest] section with a value of --cov . You can override this at the command line later as desired using the corresponding: --no-cov option.
[tool:pytest] testpaths = test addopts = --cov
-
Show missing tests in coverage commit
Coverage.py can keep track of exactly which lines and branches aren’t covered by tests, which is a big help as you try to write tests that increase the coverage of your code. You can turn this on by adding a new section to setup.cfg called [coverage:report] with a new key called show_missing set to a value of True. This will produce one new Missing column in the coverage output. The Missing column lists the following: Lines or ranges of lines that aren’t covered. As an example, 9 means line 9 is uncovered, and 10-12 means lines 10, 11, and 12 are uncovered. Logic flow from one line to another that represents a branch that isn’t covered. As an example, 1319 means the execution path that starts at line 13 that would next execute line 19 is uncovered test/test_translations.py . [100%] ----------- coverage: platform linux, python 3.9.0-final-0 ----------- Name Stmts Miss Branch BrPart Cover Missing ------------------------------------------------------------------------------------------------------------------- .venv/lib/python3.9/site-packages/imppkg/__init__.py 0 0 0 0 100% .venv/lib/python3.9/site-packages/imppkg/hello.py 4 1 0 0 75% 8 .venv/lib/python3.9/site-packages/imppkg/say.py 18 12 4 1 32% 9-20, 24 .venv/lib/python3.9/site-packages/imppkg/test_exclude_me_from_dist.py 0 0 0 0 100% ------------------------------------------------------------------------------------------------------------------- TOTAL 22 13 4 1 38%
-
Simplify coverage report output commit
In your project, the .venv directory of your installed package is roughly equivalent to the src/imppkg/ directory of the package’s source code. Tell Coverage.py this is the case with a new section in setup.cfg called [coverage:paths] . Add a source key to this section, with a list value of equivalent file paths. Coverage.py will use the first entry to replace any subsequent entries in the output test/test_translations.py . [100%] ----------- coverage: platform linux, python 3.9.0-final-0 ----------- Name Stmts Miss Branch BrPart Cover Missing ------------------------------------------------------------------------------------- src/imppkg/__init__.py 0 0 0 0 100% src/imppkg/hello.py 4 1 0 0 75% 8 src/imppkg/say.py 18 12 4 1 32% 9-20, 24 src/imppkg/test_exclude_me_from_dist.py 0 0 0 0 100% ------------------------------------------------------------------------------------- TOTAL 22 13 4 1 38%
[coverage:paths] source = src/imppkg/ .venv/*/python3.9/site-packages/imppkg/
-
Skip covered files commit
As your project grows and you spend more time testing it might become harder to pick out uncovered modules from the coverage report. If you’re reaching 100% cov erage for several files, it can be helpful to ignore them in the report output. You can add a skip_covered key with avalue of True to the [coverage:report] sect ion to filter those out. Files that are filtered out are only removed from the list their coverage's still considered in the total coverage calculation for your code test/test_translations.py . [100%] ----------- coverage: platform linux, python 3.9.0-final-0 ----------- Name Stmts Miss Branch BrPart Cover Missing ----------------------------------------------------------------- src/imppkg/hello.py 4 1 0 0 75% 8 src/imppkg/say.py 18 12 4 1 32% 9-20, 24 ----------------------------------------------------------------- TOTAL 22 13 4 1 38%
-
Add tox settings in
setup.cfg
commit and then runtox
[tox:tox] isolated_build = True
-
Set envs under test commit
The envlist key in the tox configuration defines which environments tox should create and execute by default when running the tox comma nd. The environments in the envlist can also be run individually as desired by using the -e argument to the tox command and specifying the environment name. To get started, add an envlist key to the tox:tox section in your setup.cfg file with a value of py39 The next time you run tox, it will: 1. Create an isolated build of your package 2. Create a virtual environment with a copy of Python 3.9 3. Install your package in the virtual environment
[tox:tox] isolated_build = True envlist = py39
-
Configure tox test environment with
posargs
commitSo far you’ve configured tox in the [tox:tox] section to indicate how to build your package and which environments to create. To configure the test environments themselves, add a new [testenv] section. This section is used by default for any configured test environment. In this section, you tell tox what commands to run using the commands key. This key accepts a list of commands to run, with some special syntax available to pass arguments to the comman ds within each command you can use the {posargs} placeholder, which will pass any arguments to specify to the tox command along to the test environment commands. As an example, if you specify python -c 'print("{posargs}")' as a command, running tox hello world will execute python -c 'print("hello world")' in the environment. You can also pass options to a test command by separating them from the tox command and any of its options with a -- . As an example, if you specify python as a command, running tox -- -V will execute python -V in the environment After you add the pytest command to the commands list, run tox again. You’ll see that, after the steps you saw previously, tox tries to execute pytest and fails as shown in the following output WARNING: test command found but not installed in testenv cmd: /home/antonio/dev/python-package-tutorial/.venv/bin/pytest env: /home/antonio/dev/python-package-tutorial/.tox/py39 Maybe you forgot to specify a dependency? See also the allowlist_externals envconfig setting. Even though you installed pytest into the virtual environment for your project earlier, recall that tox creates and uses an isolated virtual environment for each test environment.This means that tox won’t use the copy of pytest that you’ve been running. You haven’t told tox to install pytest in those environments, so it can’t find a copy there either.
[testenv] commands = pytest {posargs}
-
Define dependencies for tox envs, python packages we'll need in tox envs commit
You can specify dependencies in the [testenv] section using the deps key The value for deps is a list of Python packages to install, with syntax similar to requirements.txt or install_requires . For now, add pytest and pytest-cov as dependencies
deps = pytest pytest-cov
-
Run
tox