7

Let's say that you're using Poetry to manage a Python PyPI package. As it stands, your project has a Makefile that contains procedures for managing the installation, unit testing, and linting of your project. However, since this goes against the spirit of Poetry where you ideally have one configuration file to rule them all (pyproject.toml), and because said Makefile can be annoying on Windows builds, you would like to instead move the Makefile functionality to be managed by Poetry directly by making use of Poetry's support for setuptools scripts.

Here's an example of such a Makefile:

PYMODULE := awesome_sauce
TESTS := tests
INSTALL_STAMP := .install.stamp
POETRY := $(shell command -v poetry 2> /dev/null)
MYPY := $(shell command -v mypy 2> /dev/null)

.DEFAULT_GOAL := help

.PHONY: all
all: install lint test

.PHONY: help
help:
    @echo "Please use 'make <target>', where <target> is one of"
    @echo ""
    @echo "  install     install packages and prepare environment"
    @echo "  lint        run the code linters"
    @echo "  test        run all the tests"
    @echo "  all         install, lint, and test the project"
    @echo "  clean       remove all temporary files listed in .gitignore"
    @echo ""
    @echo "Check the Makefile to know exactly what each target is doing."
    @echo "Most actions are configured in 'pyproject.toml'."

install: $(INSTALL_STAMP)
$(INSTALL_STAMP): pyproject.toml
    @if [ -z $(POETRY) ]; then echo "Poetry could not be found. See https://python-poetry.org/docs/"; exit 2; fi
    $(POETRY) run pip install --upgrade pip setuptools
    $(POETRY) install
    touch $(INSTALL_STAMP)

.PHONY: lint
lint: $(INSTALL_STAMP)
    # Configured in pyproject.toml
    # Skips mypy if not installed
    @if [ -z $(MYPY) ]; then echo "Mypy not found, skipping..."; else echo "Running Mypy..."; $(POETRY) run mypy $(PYMODULE) $(TESTS); fi
    @echo "Running Flake8..."; $(POETRY) run pflake8 # This is not a typo
    @echo "Running Pylint..."; $(POETRY) run pylint $(PYMODULE)

.PHONY: test
test: $(INSTALL_STAMP)
    # Configured in pyproject.toml
    $(POETRY) run pytest

.PHONY: clean
clean:
    # Delete all files in .gitignore
    git clean -Xdf

But then, you face another problem; due to how these scripts operate, they're expected to be in a package or module of their own, so the first idea would be to simply include the scripts as part of the project package itself:

awesome_sauce
 ┣ awesome_sauce
 ┃ ┣ poetry_scripts
 ┃ ┃ ┣ __init__.py
 ┃ ┃ ┣ run_unit_tests.py
 ┃ ┃ ┗ run_linters.py
 ┃ ┣ __init__.py
 ┃ ┗ sauce.py
 ┣ docs
 ┣ tests
 ┣ .gitignore
 ┣ poetry.lock
 ┗ pyproject.toml

In this case, you could add the following to pyproject.toml

[tool.poetry.scripts]
tests = 'awesome_sauce.poetry_scripts.run_unit_tests:main'
linters = 'awesome_sauce.poetry_scripts.run_linters:main'

and they could be run as

poetry run tests
poetry run linters

But this isn't ideal; now your build configuration is part of the package and presumably goes straight into production, unless you pull off something fancy during the CI/CD process. It's dead weight to the end-users, and needlessly adds stuff to the package namespace.

Then, another idea would be to have everything in a top-level script:

awesome_sauce
 ┣ awesome_sauce
 ┃ ┣ __init__.py
 ┃ ┗ sauce.py
 ┣ docs
 ┣ tests
 ┣ .gitignore
 ┣ poetry.lock
 ┣ poetry_scripts.py
 ┗ pyproject.toml

which would change the lines in pyproject.toml to

[tool.poetry.scripts]
tests = 'poetry_scripts:run_unit_tests'
linters = 'poetry_scripts:run_linters'

but this isn't necessarily ideal either, because now you potentially have a relatively lengthy script sitting at the project root, and it can be subjectively ugly. Though at least now it shouldn't be part of the production package, which is a net gain.

These two approaches could be combined, and the script package in the first version would simply be moved to the root, meaning the project now consists of two different packages.

awesome_sauce
 ┣ awesome_sauce
 ┃ ┣ __init__.py
 ┃ ┗ sauce.py
 ┣ docs
 ┣ poetry_scripts
 ┃ ┣ __init__.py
 ┃ ┣ run_unit_tests.py
 ┃ ┗ run_linters.py
 ┣ tests
 ┣ .gitignore
 ┣ poetry.lock
 ┗ pyproject.toml
[tool.poetry.scripts]
tests = 'poetry_scripts.run_unit_tests:main'
linters = 'poetry_scripts.run_linters:main'

but it isn't clear whether this is a better approach.

Does a consensus exist for handling the Poetry/setuptools scripts within a given project? If so, I don't believe that has been previously documented in public.

Diapolo10
  • 286
  • 8
  • 25

1 Answers1

4

I don't believe there is a clear consensus on this.

However I've been working on a solution called Poe the Poet which is intended to be a better fit than make for most projects; it integrates nicely with poetry, works seamlessly cross-platform (depending how you define your tasks), and it's self documenting.

Firstly, it's important to be aware that I don't think [tool.poetry.scripts] is what you want at all, because anything you define there will actually be installed into the PATH of any environment where your package is installed!

What poethepoet allows you to do is define tasks in your pyproject.toml like so:

[tool.poe.tasks]
test = "pytest -v"

Which can then be invoked with the poe CLI tool like

poe test tests/features # extra args are appended to the command

Or if you want to define the task logic in a python module (and make the task self documenting while you're at it) you can do something like:

[tool.poe.tasks.lint]
script = "poetry_scripts.run_linters:main"
help   = "Check code style and such"

Running poe without any arguments prints documentation including available tasks and their help messages.

As for the project layout, I'd suggest putting all your python code under ./src to get something like:

awesome_sauce
 ┣ src
 ┃ ┣ awesome_sauce
 ┃ ┃ ┣ __init__.py
 ┃ ┃ ┗ sauce.py
 ┃ ┣ scripts
 ┃ ┃ ┣ __init__.py
 ┃ ┃ ┣ run_unit_tests.py
 ┃ ┃ ┗ run_linters.py
 ┣ docs
 ┣ tests
 ┣ .gitignore
 ┣ poetry.lock
 ┗ pyproject.toml

and then setting the following in the poetry section of your pyproject.toml so that awesome_sauce is included in your build, but everything else in src will be ignored by poetry build

packages = [{include = "awesome_sauce", from = "src"}]

It also works as a poetry plugin.

Nat
  • 2,689
  • 2
  • 29
  • 35
  • 2
    To be honest, I'm not entirely on board with this suggestion. First, it requires adding a third-party dependency that hasn't been verified to be safe to the build process which may not be viable in company projects that do heavy auditing on dependencies (including mine). Second, I believe it to be non-standard or old-fashioned to use `src` as the project directory in modern Python libraries. – Diapolo10 Jan 01 '22 at 22:43
  • 3
    If your toolset is restricted then that makes things harder... Probably calling commands or python scripts from your Makefile is the best you can do. As for using the src layout, I don't know about it being non-standard or old-fashioned, but it recommended [by pytest](https://docs.pytest.org/en/6.2.x/goodpractices.html), [supported](https://python-poetry.org/docs/cli/) and [used](https://github.com/python-poetry/poetry) by poetry, and has [some concrete advantages](https://hynek.me/articles/testing-packaging/). And I believe it helps in your situation. – Nat Jan 02 '22 at 08:08
  • compare contrast poe vs invoke? – John Vandivier Feb 26 '23 at 21:12
  • 1
    @JohnVandivier key high level differences here are that 1) poe works seamlessly with poetry out of the box (no need to or abuse `[tool.poetry.scripts]` or `poetry run` everything), 2) poe tasks are defined in pyproject.toml and can take many forms, whereas invoke tasks are python functions in tasks.py. – Nat Feb 27 '23 at 13:02