Skip to content

Commit b6ff003

Browse files
committed
Merge branch 'auto-discovery' into experimental/support-pyproject (#2894)
Adds automatic discovery of `packages`, `py_modules` and `name` for `flat-` and `src-` layouts.
2 parents 6193a69 + 6706be9 commit b6ff003

File tree

13 files changed

+1109
-96
lines changed

13 files changed

+1109
-96
lines changed

changelog.d/2887.change.1.rst

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
Added automatic discovery for ``py_modules`` and ``packages``
2+
-- by :user:`abravalheri`.
3+
4+
Setuptools will try to find these values assuming that the package uses either
5+
the *src-layout* (a ``src`` directory containing all the packages or modules),
6+
the *flat-layout* (package directories directly under the project root),
7+
or the *single-module* approach (isolated Python files, directly under
8+
the project root).
9+
10+
The automatic discovery will also respect layouts that are explicitly
11+
configured using the ``package_dir`` option.
12+
13+
For backward-compatibility, this behavior will be observed **only if both**
14+
``py_modules`` **and** ``packages`` **are not set**.
15+
16+
If setuptools detects modules or packages that are not supposed to be in the
17+
distribution, please manually set ``py_modules`` and ``packages`` in your
18+
``setup.cfg`` or ``setup.py`` file.
19+
If you are using a *flat-layout*, you can also consider switching to
20+
*src-layout*.

changelog.d/2887.change.2.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
Added automatic configuration for the ``name`` metadata
2+
-- by :user:`abravalheri`.
3+
4+
Setuptools will adopt the name of the top-level package (or module in the case
5+
of single-module distributions), **only when** ``name`` **is not explicitly
6+
provided**.
7+
8+
Please note that it is not possible to automatically derive a single name when
9+
the distribution consists of multiple top-level packages or modules.

changelog.d/2894.breaking.rst

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
If you purposefully want to create an *"empty distribution"*, please be aware
2+
that some Python files (or general folders) might be automatically detected and
3+
included.
4+
5+
Projects that currently don't specify both ``packages`` and ``py_modules`` in their
6+
configuration and have extra Python files and folders (not meant for distribution),
7+
might see these files being included in the wheel archive.
8+
9+
You can check details about the automatic discovery behaviour (and
10+
how to configure a different one) in :doc:`/userguide/package_discovery`.

docs/userguide/package_discovery.rst

Lines changed: 144 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,142 @@ included manually in the following manner:
3838
packages=['mypkg1', 'mypkg2']
3939
)
4040
41-
This can get tiresome really quickly. To speed things up, we introduce two
42-
functions provided by setuptools:
41+
This can get tiresome really quickly. To speed things up, you can rely on
42+
setuptools automatic discovery, or use the provided tools, as explained in
43+
the following sections.
44+
45+
46+
Automatic discovery
47+
===================
48+
49+
By default setuptools will consider 2 popular project layouts, each one with
50+
its own set of advantages and disadvantages [#layout1]_ [#layout2]_.
51+
52+
src-layout:
53+
The project should contain a ``src`` directory under the project root and
54+
all modules and packages meant for distribution are placed inside this
55+
directory::
56+
57+
project_root_directory
58+
├── pyproject.toml
59+
├── setup.cfg # or setup.py
60+
├── ...
61+
└── src/
62+
└── mypkg/
63+
├── __init__.py
64+
├── ...
65+
└── mymodule.py
66+
67+
This layout is very handy when you wish to use automatic discovery,
68+
since you don't have to worry about other Python files or folders in your
69+
project root being distributed by mistake. In some circumstances it can be
70+
also less error-prone for testing or when using :pep:`420`-style packages.
71+
On the other hand you cannot rely on the implicit ``PYTHONPATH=.`` to fire
72+
up the Python REPL and play with your package (you will need an
73+
`editable install`_ to be able to do that).
74+
75+
flat-layout (also known as "adhoc"):
76+
The package folder(s) are placed directly under the project root::
77+
78+
project_root_directory
79+
├── pyproject.toml
80+
├── setup.cfg # or setup.py
81+
├── ...
82+
└── mypkg/
83+
├── __init__.py
84+
├── ...
85+
└── mymodule.py
86+
87+
This layout is very practical for using the REPL, but in some situations
88+
it can be can be more error-prone (e.g. during tests or if you have a bunch
89+
of folders or Python files hanging around your project root)
90+
91+
There is also a handy variation of the *flat-layout* for utilities/libraries
92+
that can be implemented with a single Python file:
93+
94+
single-module approach (or "few top-level modules"):
95+
Standalone modules are placed directly under the project root, instead of
96+
inside a package folder::
97+
98+
project_root_directory
99+
├── pyproject.toml
100+
├── setup.cfg # or setup.py
101+
├── ...
102+
└── single_file_lib.py
103+
104+
Setuptools will automatically scan your project directory looking for these
105+
layouts and try to guess the correct values for the :ref:`packages <declarative
106+
config>` and :doc:`py_modules </references/keywords>` configuration.
107+
108+
To avoid confusion, file and folder names that are used by popular tools (or
109+
that correspond to well-known conventions, such as distributing documentation
110+
alongside the project code) are automatically filtered out in the case of
111+
*flat-layouts*:
112+
113+
.. autoattribute:: setuptools.discovery.FlatLayoutPackageFinder.DEFAULT_EXCLUDE
114+
115+
.. autoattribute:: setuptools.discovery.FlatLayoutModuleFinder.DEFAULT_EXCLUDE
116+
117+
Also note that you can customise your project layout by explicitly setting
118+
``package_dir``:
119+
120+
.. tab:: setup.cfg
121+
122+
.. code-block:: ini
123+
124+
[options]
125+
# ...
126+
package_dir =
127+
= lib
128+
# similar to "src-layout" but using the "lib" folder
129+
# pkg.mod corresponds to lib/pkg/mod.py
130+
# OR
131+
package_dir =
132+
pkg1 = lib1
133+
# pkg1.mod corresponds to lib1/mod.py
134+
# pkg1.subpkg.mod corresponds to lib1/subpkg/mod.py
135+
pkg2 = lib2
136+
# pkg2.mod corresponds to lib2/mod.py
137+
pkg2.subpkg = lib3
138+
# pkg2.subpkg.mod corresponds to lib3/mod.py
139+
140+
.. tab:: setup.py
141+
142+
.. code-block:: python
143+
144+
setup(
145+
# ...
146+
package_dir = {"": "lib"}
147+
# similar to "src-layout" but using the "lib" folder
148+
# pkg.mod corresponds to lib/pkg/mod.py
149+
)
150+
151+
# OR
152+
153+
setup(
154+
# ...
155+
package_dir = {
156+
"pkg1": "lib1", # pkg1.mod corresponds to lib1/mod.py
157+
# pkg1.subpkg.mod corresponds to lib1/subpkg/mod.py
158+
"pkg2": "lib2", # pkg2.mod corresponds to lib2/mod.py
159+
"pkg2.subpkg": "lib3" # pkg2.subpkg.mod corresponds to lib3/mod.py
160+
# ...
161+
)
162+
163+
.. important:: Automatic discovery will **only** be enabled if you don't
164+
provide any configuration for both ``packages`` and ``py_modules``.
165+
If at least one of them is explicitly set, automatic discovery will not take
166+
place.
167+
168+
169+
Custom discovery
170+
================
171+
172+
If the automatic discovery does not work for you
173+
(e.g., you want to *include* in the distribution top-level packages with
174+
reserved names such as ``tasks``, ``example`` or ``docs``, or you want to
175+
*exclude* nested packages that would be otherwise included), you can use
176+
the provided tools for package discovery:
43177
44178
.. tab:: setup.cfg
45179
@@ -61,7 +195,7 @@ functions provided by setuptools:
61195
62196
63197
Using ``find:`` or ``find_packages``
64-
====================================
198+
------------------------------------
65199
Let's start with the first tool. ``find:`` (``find_packages``) takes a source
66200
directory and two lists of package name patterns to exclude and include, and
67201
then return a list of ``str`` representing the packages it could find. To use
@@ -113,7 +247,7 @@ in ``src`` that starts with the name ``pkg`` and not ``additional``:
113247
.. _Namespace Packages:
114248
115249
Using ``find_namespace:`` or ``find_namespace_packages``
116-
========================================================
250+
--------------------------------------------------------
117251
``setuptools`` provides the ``find_namespace:`` (``find_namespace_packages``)
118252
which behaves similarly to ``find:`` but works with namespace package. Before
119253
diving in, it is important to have a good understanding of what namespace
@@ -249,3 +383,9 @@ file contains the following:
249383
__path__ = __import__('pkgutil').extend_path(__path__, __name__)
250384
251385
The project layout remains the same and ``setup.cfg`` remains the same.
386+
387+
388+
.. [#layout1] https://blog.ionelmc.ro/2014/05/25/python-packaging/#the-structure
389+
.. [#layout2] https://blog.ionelmc.ro/2017/09/25/rehashing-the-src-layout/
390+
391+
.. _editable install: https://pip.pypa.io/en/stable/cli/pip_install/#editable-installs

setup.cfg

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,6 @@ testing-integration =
8080
build[virtualenv]
8181
filelock>=3.4.0
8282

83-
8483
docs =
8584
# upstream
8685
sphinx

setuptools/__init__.py

Lines changed: 1 addition & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
"""Extensions to the 'distutils' for large or complex distributions"""
22

3-
from fnmatch import fnmatchcase
43
import functools
54
import os
65
import re
@@ -9,14 +8,14 @@
98

109
import distutils.core
1110
from distutils.errors import DistutilsOptionError
12-
from distutils.util import convert_path
1311

1412
from ._deprecation_warning import SetuptoolsDeprecationWarning
1513

1614
import setuptools.version
1715
from setuptools.extension import Extension
1816
from setuptools.dist import Distribution
1917
from setuptools.depends import Require
18+
from setuptools.discovery import PackageFinder, PEP420PackageFinder
2019
from . import monkey
2120
from . import logging
2221

@@ -37,85 +36,6 @@
3736
bootstrap_install_from = None
3837

3938

40-
class PackageFinder:
41-
"""
42-
Generate a list of all Python packages found within a directory
43-
"""
44-
45-
@classmethod
46-
def find(cls, where='.', exclude=(), include=('*',)):
47-
"""Return a list all Python packages found within directory 'where'
48-
49-
'where' is the root directory which will be searched for packages. It
50-
should be supplied as a "cross-platform" (i.e. URL-style) path; it will
51-
be converted to the appropriate local path syntax.
52-
53-
'exclude' is a sequence of package names to exclude; '*' can be used
54-
as a wildcard in the names, such that 'foo.*' will exclude all
55-
subpackages of 'foo' (but not 'foo' itself).
56-
57-
'include' is a sequence of package names to include. If it's
58-
specified, only the named packages will be included. If it's not
59-
specified, all found packages will be included. 'include' can contain
60-
shell style wildcard patterns just like 'exclude'.
61-
"""
62-
63-
return list(
64-
cls._find_packages_iter(
65-
convert_path(where),
66-
cls._build_filter('ez_setup', '*__pycache__', *exclude),
67-
cls._build_filter(*include),
68-
)
69-
)
70-
71-
@classmethod
72-
def _find_packages_iter(cls, where, exclude, include):
73-
"""
74-
All the packages found in 'where' that pass the 'include' filter, but
75-
not the 'exclude' filter.
76-
"""
77-
for root, dirs, files in os.walk(where, followlinks=True):
78-
# Copy dirs to iterate over it, then empty dirs.
79-
all_dirs = dirs[:]
80-
dirs[:] = []
81-
82-
for dir in all_dirs:
83-
full_path = os.path.join(root, dir)
84-
rel_path = os.path.relpath(full_path, where)
85-
package = rel_path.replace(os.path.sep, '.')
86-
87-
# Skip directory trees that are not valid packages
88-
if '.' in dir or not cls._looks_like_package(full_path):
89-
continue
90-
91-
# Should this package be included?
92-
if include(package) and not exclude(package):
93-
yield package
94-
95-
# Keep searching subdirectories, as there may be more packages
96-
# down there, even if the parent was excluded.
97-
dirs.append(dir)
98-
99-
@staticmethod
100-
def _looks_like_package(path):
101-
"""Does a directory look like a package?"""
102-
return os.path.isfile(os.path.join(path, '__init__.py'))
103-
104-
@staticmethod
105-
def _build_filter(*patterns):
106-
"""
107-
Given a list of patterns, return a callable that will be true only if
108-
the input matches at least one of the patterns.
109-
"""
110-
return lambda name: any(fnmatchcase(name, pat=pat) for pat in patterns)
111-
112-
113-
class PEP420PackageFinder(PackageFinder):
114-
@staticmethod
115-
def _looks_like_package(path):
116-
return True
117-
118-
11939
find_packages = PackageFinder.find
12040
find_namespace_packages = PEP420PackageFinder.find
12141

0 commit comments

Comments
 (0)