[Distutils] Alternative build system abstraction PEP

Nathaniel Smith njs at pobox.com
Tue Feb 16 20:32:46 EST 2016

Hi all,

Finally found the time to sit down and take the various drafts I've sent of
this to the list before, add a more detailed rationale section, and turn it
into a pull request:


I've also included the text below for reference, in case people want to
quote-and-comment here rather than in the PR.

The main differences from Robert's proposal are described in the sections
"Build backend interface" and "Comparison to competing proposals".



PEP: ??
Title: A build-system independent format for source trees
Version: $Revision$
Last-Modified: $Date$
Author: Nathaniel J. Smith <njs at pobox.com>
Status: Draft
Type: Standards-Track
Content-Type: text/x-rst
Created: 30-Sep-2015
Post-History: 1 Oct 2015, 25 Oct 2015
Discussions-To: <distutils-sig at python.org>


While ``distutils`` / ``setuptools`` have taken us a long way, they
suffer from three serious problems: (a) they're missing important
features like usable build-time dependency declaration,
autoconfiguration, and even basic ergonomic niceties like `DRY
version number management, and (b) extending them is difficult, so
while there do exist various solutions to the above problems, they're
often quirky, fragile, and expensive to maintain, and yet (c) it's
very difficult to use anything else, because distutils/setuptools
provide the standard interface for installing packages expected by
both users and installation tools like ``pip``.

Previous efforts (e.g. distutils2 or setuptools itself) have attempted
to solve problems (a) and/or (b). This proposal aims to solve (c).

The goal of this PEP is get distutils-sig out of the business of being
a gatekeeper for Python build systems. If you want to use distutils,
great; if you want to use something else, then that should be easy to
do using standardized methods. The difficulty of interfacing with
distutils means that there aren't many such systems right now, but to
give a sense of what we're thinking about see `flit
<https://github.com/takluyver/flit>`_ or `bento
<https://cournape.github.io/Bento/>`_. Fortunately, wheels have now
solved many of the hard problems here -- e.g. it's no longer necessary
that a build system also know about every possible installation
configuration -- so pretty much all we really need from a build system
is that it have some way to spit out standard-compliant wheels and

We therefore propose a new, relatively minimal interface for
installation tools like ``pip`` to interact with package source trees
and source distributions.

 Terminology and goals

A *source tree* is something like a VCS checkout. We need a standard
interface for installing from this format, to support usages like
``pip install some-directory/``.

A *source distribution* is a static snapshot representing a particular
release of some source code, like ``lxml-3.4.4.zip``. Source
distributions serve many purposes: they form an archival record of
releases, they provide a stupid-simple de facto standard for tools
that want to ingest and process large corpora of code, possibly
written in many languages (e.g. code search), they act as the input to
downstream packaging systems like Debian/Fedora/Conda/..., and so
forth. In the Python ecosystem they additionally have a particularly
important role to play, because packaging tools like ``pip`` are able
to use source distributions to fulfill binary dependencies, e.g. if
there is a distribution ``foo.whl`` which declares a dependency on
``bar``, then we need to support the case where ``pip install bar`` or
``pip install foo`` automatically locates the sdist for ``bar``,
downloads it, builds it, and installs the resulting package.

Source distributions are also known as *sdists* for short.

A *build frontend* is a tool that users might run that takes arbitrary
source trees or source distributions and builds wheels from them. The
actual building is done by each source tree's *build backend*. In a
command like ``pip wheel some-directory/``, pip is acting as a build

An *integration frontend* is a tool that users might run that takes a
set of package requirements (e.g. a requirements.txt file) and
attempts to update a working environment to satisfy those
requirements. This may require locating, building, and installing a
combination of wheels and sdists. In a command like ``pip install
lxml==2.4.0``, pip is acting as an integration frontend.

 Source trees

We retroactively declare the legacy source tree format involving
``setup.py`` to be "version 0". We don't try to specify it further;
its de facto specification is encoded in the source code and
documentation of ``distutils``, ``setuptools``, ``pip``, and other

A "version 1" (or greater) source tree is any directory which contains
a file named ``pypackage.json``. (If a tree contains both
``pypackage.json`` and ``setup.py`` then it is a version 1+ source
tree and the ``setup.py`` is ignored; this allows packages to include
a ``setup.py`` for compatibility with old build frontends, while using
the new system with new build frontends.)

This file has the following schema. Extra keys are ignored.

    The version of the schema. This PEP defines version "1".  Defaults to
    when absent. All tools reading the file MUST error on an unrecognised
    schema version.

    Optional list of PEP 508 dependency specifications that the
    build frontend must ensure are available before invoking the build
    backend. For instance, if using flit, then the requirements might

        "bootstrap_requires": ["flit"]

    A mandatory string naming a Python object that will be used to
    perform the build (see below for details). This is formatted
    following the same ``module:object`` syntax as a ``setuptools``
    entry point. For instance, if using flit, then the build system
    might be specified as::

        "build_system": "flit.api:main"

    and this object would be looked up by executing the equivalent of::

        import flit.api
        backend = flit.api.main

    It's also legal to leave out the ``:object`` part, e.g. ::

        "build_system": "flit.api"

    which acts like::

        import flit.api
        backend = flit.api

    Formally, the string should satisfy this grammar::

        identifier = (letter | '_') (letter | '_' | digit)*
        module_path = identifier ('.' identifier)*
        object_path = identifier ('.' identifier)*
        entry_point = module_path (':' object_path)?

    And we import ``module_path`` and then lookup
    ``module_path.object_path`` (or just ``module_path`` if
    ``object_path`` is missing).

 Build backend interface

The build backend object is expected to have attributes which provide
some or all of the following hooks. The common ``config_settings``
argument is described after the individual hooks::

  def get_build_requires(config_settings):

This hook MUST return an additional list of strings containing PEP 508
dependency specifications, above and beyond those specified in the
``pypackage.json`` file. Example::

  def get_build_requires(config_settings):
      return ["wheel >= 0.25", "setuptools"]

Optional. If not defined, the default implementation is equivalent to
``return []``.


  def get_wheel_metadata(metadata_directory, config_settings):

Must create a ``.dist-info`` directory containing wheel metadata
inside the specified ``metadata_directory`` (i.e., creates a directory
like ``{metadata_directory}/{package}-{version}.dist-info/``. This
directory MUST be a valid ``.dist-info`` directory as defined in the
wheel specification, except that it need not contain ``RECORD`` or
signatures. The hook MAY also create other files inside this
directory, and a build frontend MUST ignore such files; the intention
here is that in cases where the metadata depends on build-time
decisions, the build backend may need to record these decisions in
some convenient format for re-use by the actual wheel-building step.

Return value is ignored.

Optional. If a build frontend needs this information and the method is
not defined, it should call ``build_wheel`` and look at the resulting
metadata directly.


  def build_wheel(wheel_directory, config_settings,

Must build a ``.whl`` file, and place it in the specified

If the build frontend has previously called ``get_wheel_metadata`` and
depends on the wheel resulting from this call to have metadata
matching this earlier call, then it should provide the path to the
previous ``metadata_directory`` as an argument. If this argument is
provided, then ``build_wheel`` MUST produce a wheel with identical
metadata. The directory passed in by the build frontend MUST be
identical to the directory created by ``get_wheel_metadata``,
including any unrecognized files it created.



  def install_editable(prefix, config_settings, metadata_directory=None):

Must perform whatever actions are necessary to install the current
project into the Python installation at ``install_prefix`` in an
"editable" fashion. This is intentionally underspecified, because it's
included as a stopgap to avoid regressing compared to the current
equally underspecified setuptools ``develop`` command; hopefully a
future PEP will replace this hook with something that works better and
is better specified. (Unfortunately, cleaning up editable installs to
actually work well and be well-specified turns out to be a large and
difficult job, so we prefer not to do a half-way job here.)

For the meaning and requirements of the ``metadata_directory``
argument, see ``build_wheel`` above.

Optional. If not defined, then this build backend does not support
editable builds.



This argument, which is passed to all hooks, is an arbitrary
dictionary provided as an "escape hatch" for users to pass ad-hoc
configuration into individual package builds. Build backends MAY
assign any semantics they like to this dictionary. Build frontends
SHOULD provide some mechanism for users to specify arbitrary
string-key/string-value pairs to be placed in this dictionary. For
example, they might support some syntax like ``--package-config
CC=gcc``. Build frontends MAY also provide arbitrary other mechanisms
for users to place entries in this dictionary. For example, ``pip``
might choose to map a mix of modern and legacy command line arguments

  pip install                                           \
    --package-config CC=gcc                             \
    --global-option="--some-global-option"              \
    --build-option="--build-option1"                    \

into a ``config_settings`` dictionary like::

   "CC": "gcc",
   "--global-option": ["--some-global-option"],
   "--build-option": ["--build-option1", "--build-option2"],

Of course, it's up to users to make sure that they pass options which
make sense for the particular build backend and package that they are

All hooks are run with working directory set to the root of the source
tree, and MAY print arbitrary informational text on stdout and
stderr. They MUST NOT read from stdin, and the build frontend MAY
close stdin before invoking the hooks.

If a hook raises an exception, or causes the process to terminate,
then this indicates an error.

Build environment

One of the responsibilities of a build frontend is to set up the
Python environment in which the build backend will run.

We do not require that any particular "virtual environment" mechanism
be used; a build frontend might use virtualenv, or venv, or no special
mechanism at all. But whatever mechanism is used MUST meet the
following criteria:

- All requirements specified by the project's build-requirements must
  be available for import from Python. In particular:

  - The ``get_build_requires`` hook is executed in an environment
    which contains the bootstrap requirements specified in the
    ``pypackage.json`` file.

  - All other hooks are executed in an environment which contains both
    the bootstrap requirements specified in the ``pypackage.json`` hook
    and those specified by the ``get_build_requires`` hook.

- This must remain true even for new Python subprocesses spawned by
  the build environment, e.g. code like::

    import sys, subprocess
    subprocess.check_call([sys.executable, ...])

  must spawn a Python process which has access to all the project's
  build-requirements. This is necessary e.g. for build backends that
  want to run legacy ``setup.py`` scripts in a subprocess.

- All command-line scripts provided by the build-required packages
  must be present in the build environment's PATH. For example, if a
  project declares a build-requirement on `flit
  <https://flit.readthedocs.org/en/latest/>`_, then the following must
  work as a mechanism for running the flit command-line tool::

    import subprocess
    subprocess.check_call(["flit", ...])

A build backend MUST be prepared to function in any environment which
meets the above criteria. In particular, it MUST NOT assume that it
has access to any packages except those that are present in the
stdlib, or that are explicitly declared as build-requirements.

Recommendations for build frontends (non-normative)

A build frontend MAY use any mechanism for setting up a build
environment that meets the above criteria. For example, simply
installing all build-requirements into the global environment would be
sufficient to build any compliant package -- but this would be
sub-optimal for a number of reasons. This section contains
non-normative advice to frontend implementors.

A build frontend SHOULD, by default, create an isolated environment
for each build, containing only the standard library and any
explicitly requested build-dependencies. This has two benefits:

- It allows for a single installation run to build multiple packages
  that have contradictory build-requirements. E.g. if package1
  build-requires pbr==1.8.1, and package2 build-requires pbr==1.7.2,
  then these cannot both be installed simultaneously into the global
  environment -- which is a problem when the user requests ``pip
  install package1 package2``. Or if the user already has pbr==1.8.1
  installed in their global environment, and a package build-requires
  pbr==1.7.2, then downgrading the user's version would be rather

- It acts as a kind of public health measure to maximize the number of
  packages that actually do declare accurate build-dependencies. We
  can write all the strongly worded admonitions to package authors we
  want, but if build frontends don't enforce isolation by default,
  then we'll inevitably end up with lots of packages on PyPI that
  build fine on the original author's machine and nowhere else, which
  is a headache that no-one needs.

However, there will also be situations where build-requirements are
problematic in various ways. For example, a package author might
accidentally leave off some crucial requirement despite our best
efforts; or, a package might declare a build-requirement on `foo >=
1.0` which worked great when 1.0 was the latest version, but now 1.1
is out and it has a showstopper bug; or, the user might decide to
build a package against numpy==1.7 -- overriding the package's
preferred numpy==1.8 -- to guarantee that the resulting build will be
compatible at the C ABI level with an older version of numpy (even if
this means the resulting build is unsupported upstream). Therefore,
build frontends SHOULD provide some mechanism for users to override
the above defaults. For example, a build frontend could have a
``--build-with-system-site-packages`` option that causes the
``--system-site-packages`` option to be passed to
virtualenv-or-equivalent when creating build environments, or a
``--build-requirements-override=my-requirements.txt`` option that
overrides the project's normal build-requirements.

The general principle here is that we want to enforce hygiene on
package *authors*, while still allowing *end-users* to open up the
hood and apply duct tape when necessary.

 Source distributions

For now, we continue with the legacy sdist format which is mostly
undefined, but basically comes down to: a file named
``{NAME}-{VERSION}.{EXT}``, which unpacks into a buildable source tree
called ``{NAME}-{VERSION}/``. Traditionally these have always
contained "version 0" source trees; we now allow them to also contain
version 1+ source trees.

Integration frontends require that an sdist named
``{NAME}-{VERSION}.{EXT}`` will generate a wheel named

 Comparison to competing proposals

The primary difference between this and competing proposals (`in
<https://github.com/pypa/interoperability-peps/pull/54/files>`_) is
that our build backend is defined via a Python hook-based interface
rather than a command-line based interface.

We do *not* expect that this will, by itself, intrinsically reduce the
complexity calling into the backend, because build frontends will
in any case want to run hooks inside a child -- this is important to
isolate the build frontend itself from the backend code and to better
control the build backends execution environment. So under both
proposals, there will need to be some code in ``pip`` to spawn a
subprocess and talk to some kind of command-line/IPC interface, and
there will need to be some code in the subprocess that knows how to
parse these command line arguments and call the actual build backend
implementation. So this diagram applies to all proposals equally::

  +-----------+          +---------------+           +----------------+
  | frontend  | -spawn-> | child cmdline | -Python-> |    backend     |
  |   (pip)   |          |   interface   |           | implementation |
  +-----------+          +---------------+           +----------------+

The key difference between the two approaches is how these interface
boundaries map onto project structure::

  .-= This PEP =-.

  +-----------+          +---------------+    |      +----------------+
  | frontend  | -spawn-> | child cmdline | -Python-> |    backend     |
  |   (pip)   |          |   interface   |    |      | implementation |
  +-----------+          +---------------+    |      +----------------+
  |______________________________________|    |
     Owned by pip, updated in lockstep        |
                                   PEP-defined interface boundary
                                 Changes here require distutils-sig

  .-= Alternative =-.

  +-----------+    |     +---------------+           +----------------+
  | frontend  | -spawn-> | child cmdline | -Python-> |    backend     |
  |   (pip)   |    |     |   interface   |           | implementation |
  +-----------+    |     +---------------+           +----------------+
                   |     |____________________________________________|
                   |      Owned by build backend, updated in lockstep
      PEP-defined interface boundary
    Changes here require distutils-sig

By moving the PEP-defined interface boundary into Python code, we gain
three key advantages.

**First**, because there will likely be only a small number of build
frontends (``pip``, and... maybe a few others?), while there will
likely be a long tail of custom build backends (since these are chosen
separately by each package to match their particular build
requirements), the actual diagrams probably look more like::

  .-= This PEP =-.

  +-----------+          +---------------+           +----------------+
  | frontend  | -spawn-> | child cmdline | -Python+> |    backend     |
  |   (pip)   |          |   interface   |        |  | implementation |
  +-----------+          +---------------+        |  +----------------+
                                                  |  +----------------+
                                                  +> |    backend     |
                                                  |  | implementation |
                                                  |  +----------------+

  .-= Alternative =-.

  +-----------+          +---------------+           +----------------+
  | frontend  | -spawn+> | child cmdline | -Python-> |    backend     |
  |   (pip)   |       |  |   interface   |           | implementation |
  +-----------+       |  +---------------+           +----------------+
                      |  +---------------+           +----------------+
                      +> | child cmdline | -Python-> |    backend     |
                      |  |   interface   |           | implementation |
                      |  +---------------+           +----------------+

That is, this PEP leads to less total code in the overall
ecosystem. And in particular, it reduces the barrier to entry of
making a new build system. For example, this is a complete, working
build backend::

    # mypackage_custom_build_backend.py
    import os.path

    def get_build_requires(config_settings, config_directory):
        return ["wheel"]

    def build_wheel(wheel_directory, config_settings,
        from wheel.archive import archive_wheelfile
        path = os.path.join(wheel_directory,
        archive_wheelfile(path, "src/")

Of course, this is a *terrible* build backend: it requires the user to
have manually set up the wheel metadata in
``src/mypackage-0.1.dist-info/``; when the version number changes it
must be manually updated in multiple places; it doesn't implement the
metadata or develop hooks, ... but it works, and these features can be
added incrementally. Much experience suggests that large successful
projects often originate as quick hacks (e.g., Linux -- "just a hobby,
won't be big and professional"; `IPython/Jupyter
<https://en.wikipedia.org/wiki/IPython#Grants_and_awards>`_ -- `a grad
student's ``$PYTHONSTARTUP`` file
so if our goal is to encourage the growth of a vibrant ecosystem of
good build tools, it's important to minimize the barrier to entry.

**Second**, because Python provides a simpler yet richer structure for
describing interfaces, we remove unnecessary complexity from the
specification -- and specifications are the worst place for
complexity, because changing specifications requires painful
consensus-building across many stakeholders. In the command-line
interface approach, we have to come up with ad hoc ways to map
multiple different kinds of inputs into a single linear command line
(e.g. how do we avoid collisions between user-specified configuration
arguments and PEP-defined arguments? how do we specify optional
arguments? when working with a Python interface these questions have
simple, obvious answers). When spawning and managing subprocesses,
there are many fiddly details that must be gotten right, subtle
cross-platform differences, and some of the most obvious approaches --
e.g., using stdout to return data for the ``build_requires`` operation
-- can create unexpected pitfalls (e.g., what happens when computing
the build requirements requires spawning some child processes, and
these children occasionally print an error message to stdout?
obviously a careful build backend author can avoid this problem, but
the most obvious way of defining a Python interface removes this
possibility entirely, because the hook return value is clearly

In general, the need to isolate build backends into their own process
means that we can't remove IPC complexity entirely -- but by placing
both sides of the IPC channel under the control of a single project,
we make it much much cheaper to fix bugs in the IPC interface than if
fixing bugs requires coordinated agreement and coordinated changes
across the ecosystem.

**Third**, and most crucially, the Python hook approach gives us much
more powerful options for evolving this specification in the future.

For concreteness, imagine that next year we add a new
``install_editable2`` hook, which replaces the current
``install_editable`` hook with something better specified. In order to
manage the transition, we want it to be possible for build frontends
to transparently use ``install_editable2`` when available and fall
back onto ``install_editable`` otherwise; and we want it to be
possible for build backends to define both methods, for compatibility
with both old and new build frontends.

Furthermore, our mechanism should also fulfill two more goals: (a) If
new versions of e.g. ``pip`` and ``flit`` are both updated to support
the new interface, then this should be sufficient for it to be used;
in particular, it should *not* be necessary for every project that
*uses* ``flit`` to update its individual ``pypackage.json`` file. (b)
We do not want to have to spawn extra processes just to perform this
negotation, because process spawns can easily become a bottleneck when
deploying large multi-package stacks on some platforms (Windows).

In the interface described here, all of these goals are easy to
achieve. Because ``pip`` controls the code that runs inside the child
process, it can easily write it to do something like::

    command, backend, args = parse_command_line_args(...)
    if command == "do_editable_install":
       if hasattr(backend, "install_editable2"):
       elif hasattr(backend, "install_editable"):
           # error handling

In the alternative where the public interface boundary is placed at
the subprocess call, this is not possible -- either we need to spawn
an extra process just to query what interfaces are supported (as was
included in an earlier version of `this alternative PEP
<https://github.com/pypa/interoperability-peps/pull/54/files>`_), or
else we give up on autonegotiation entirely (as in the current version
of that PEP), meaning that any changes in the interface will require
N individual packages to update their ``pypackage.json`` files before
any change can go live, and that any changes will necessarily be
restricted to new releases.

One specific consequence of this is that in this PEP, we're able to
make the ``get_wheel_metadata`` command optional. In our design, this
can easily be worked around by a tool like ``pip``, which can put code
in its subprocess runner like::

    def get_wheel_metadata(output_dir, config_settings):
         if hasattr(backend, "get_wheel_metadata"):
             backend.get_wheel_metadata(output_dir, config_settings)
             backend.build_wheel(output_dir, config_settings)
             touch(output_dir / "PIP_ALREADY_BUILT_WHEELS")

    def build_wheel(output_dir, config_settings, metadata_dir):
         if os.path.exists(metadata_dir / "PIP_ALREADY_BUILT_WHEELS"):
             copy(metadata_dir / *.whl, output_dir)
             backend.build_wheel(output_dir, config_settings, metadata_dir)

and thus expose a totally uniform interface to the rest of ``pip``,
with no extra subprocess calls, no duplicated builds, etc. But
obviously this is the kind of code that you only want to write as part
of a private, within-project interface.

(And, of course, making the ``metadata`` command optional is one piece
of lowering the barrier to entry, as discussed above.)

Other differences

Besides the key command line versus Python hook difference described
above, there are a few other differences in this proposal:

* Metadata command is optional (as described above).

* We return metadata as a directory, rather than a single METADATA
  file. This aligns better with the way that in practice wheel metadata
  is distributed across multiple files (e.g. entry points), and gives us
  more options in the future. (For example, instead of following the PEP
  426 proposal of switching the format of METADATA to JSON, we might
  decide to keep the existing METADATA the way it is for backcompat,
  while adding new extensions as JSON "sidecar" files inside the same
  directory. Or maybe not; the point is it keeps our options more open.)

* We provide a mechanism for passing information between the metadata
  step and the wheel building step. I guess everyone probably will
  agree this is a good idea?

* We call our config file ``pypackage.json`` instead of
  ``pypa.json``. This is because it describes a package, rather than
  describing a packaging authority. But really, who cares.

* We provide more detailed recommendations about the build environment,
  but these aren't normative anyway.

 Evolutionary notes

A goal here is to make it as simple as possible to convert old-style
sdists to new-style sdists. (E.g., this is one motivation for
supporting dynamic build requirements.) The ideal would be that there
would be a single static pypackage.cfg that could be dropped into any
"version 0" VCS checkout to convert it to the new shiny. This is
probably not 100% possible, but we can get close, and it's important
to keep track of how close we are... hence this section.

A rough plan would be: Create a build system package
(``setuptools_pypackage`` or whatever) that knows how to speak
whatever hook language we come up with, and convert them into calls to
``setup.py``. This will probably require some sort of hooking or
monkeypatching to setuptools to provide a way to extract the
``setup_requires=`` argument when needed, and to provide a new version
of the sdist command that generates the new-style format. This all
seems doable and sufficient for a large proportion of packages (though
obviously we'll want to prototype such a system before we finalize
anything here). (Alternatively, these changes could be made to
setuptools itself rather than going into a separate package.)

But there remain two obstacles that mean we probably won't be able to
automatically upgrade packages to the new format:

1) There currently exist packages which insist on particular packages
   being available in their environment before setup.py is
   executed. This means that if we decide to execute build scripts in
   an isolated virtualenv-like environment, then projects will need to
   check whether they do this, and if so then when upgrading to the
   new system they will have to start explicitly declaring these
   dependencies (either via ``setup_requires=`` or via static
   declaration in ``pypackage.cfg``).

2) There currently exist packages which do not declare consistent
   metadata (e.g. ``egg_info`` and ``bdist_wheel`` might get different
   ``install_requires=``). When upgrading to the new system, projects
   will have to evaluate whether this applies to them, and if so they
   will need to stop doing that.


This document has been placed in the public domain.

Nathaniel J. Smith -- https://vorpus.org
-------------- next part --------------
An HTML attachment was scrubbed...
URL: <http://mail.python.org/pipermail/distutils-sig/attachments/20160216/5fe1ce84/attachment-0001.html>

More information about the Distutils-SIG mailing list