[bumpversion]
-current_version = 62.1.0
+current_version = 62.2.0
commit = True
tag = True
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python }}
+ - uses: actions/cache@v3
+ id: cache
+ with:
+ path: setuptools/tests/config/downloads/*.cfg
+ key: >-
+ ${{ hashFiles('setuptools/tests/config/setupcfg_examples.txt') }}-
+ ${{ hashFiles('setuptools/tests/config/downloads/*.py') }}
+ - name: Populate download cache
+ if: steps.cache.outputs.cache-hit != 'true'
+ working-directory: setuptools/tests/config
+ run: python -m downloads.preload setupcfg_examples.txt
- name: Install tox
run: |
python -m pip install tox
${{ matrix.python }}
test_cygwin:
- runs-on: windows-latest
+ strategy:
+ matrix:
+ python:
+ - 39
+ platform:
+ - windows-latest
+ runs-on: ${{ matrix.platform }}
timeout-minutes: 75
steps:
- uses: actions/checkout@v2
with:
platform: x86_64
packages: >-
- git,
+ python${{ matrix.python }},
+ python${{ matrix.python }}-devel,
+ python${{ matrix.python }}-tox,
gcc-core,
- python38,
- python38-devel,
- python38-pip
- - name: Install tox
- shell: C:\cygwin\bin\env.exe CYGWIN_NOWINPATH=1 CHERE_INVOKING=1 C:\cygwin\bin\bash.exe -leo pipefail -o igncr {0}
- run: |
- python3.8 -m pip install tox
+ git,
- name: Run tests
shell: C:\cygwin\bin\env.exe CYGWIN_NOWINPATH=1 CHERE_INVOKING=1 C:\cygwin\bin\bash.exe -leo pipefail -o igncr {0}
- run: |
- tox -- --cov-report xml
+ run: tox
integration-test:
needs: test
+v62.2.0
+-------
+
+
+Changes
+^^^^^^^
+* #3299: Optional metadata fields are now truly optional. Includes merge with pypa/distutils@a7cfb56 per pypa/distutils#138.
+
+Misc
+^^^^
+* #3282: Added CI cache for ``setup.cfg`` examples used when testing ``setuptools.config``.
+
+
v62.1.0
-------
with **environment markers** are enough to differentiate operating systems
and platforms.
-If you add the following configuration to your ``pyprojec.toml``:
+If you add the following configuration to your ``pyproject.toml``:
.. code-block:: toml
$ python setup.py check
running check
- warning: check: missing required meta-data: version, url
- warning: check: missing meta-data: either (author and author_email) or
- (maintainer and maintainer_email) should be supplied
+ warning: check: missing required meta-data: version
If you use the reStructuredText syntax in the ``long_description`` field and
| ``maintainer_email`` | email address of the | email address | \(3) |
| | package maintainer | | |
+----------------------+---------------------------+-----------------+--------+
-| ``url`` | home page for the package | URL | \(1) |
+| ``url`` | home page for the package | URL | |
+----------------------+---------------------------+-----------------+--------+
| ``description`` | short, summary | short string | |
| | description of the | | |
It is recommended that versions take the form *major.minor[.patch[.sub]]*.
(3)
- Either the author or the maintainer must be identified. If maintainer is
- provided, distutils lists it as the author in :file:`PKG-INFO`.
+ If maintainer is provided and author is not, distutils lists maintainer as
+ the author in :file:`PKG-INFO`.
(4)
The ``long_description`` field is used by PyPI when you publish a package,
└── mymodule.py
This layout is very practical for using the REPL, but in some situations
-it can be can be more error-prone (e.g. during tests or if you have a bunch
+it can be more error-prone (e.g. during tests or if you have a bunch
of folders or Python files hanging around your project root)
To avoid confusion, file and folder names that are used by popular tools (or
For simple projects, it's usually easy enough to manually add packages to
the ``packages`` keyword in ``setup.cfg``. However, for very large projects,
it can be a big burden to keep the package list updated.
-Therefore, ``setuptoops`` provides a convenient way to automatically list all
+Therefore, ``setuptools`` provides a convenient way to automatically list all
the packages in your project directory:
.. tab:: setup.cfg
[metadata]
name = setuptools
-version = 62.1.0
+version = 62.2.0
author = Python Packaging Authority
author_email = distutils-sig@python.org
description = Easily download, build, install, upgrade, and uninstall Python packages
--- /dev/null
+import functools
+
+
+# from jaraco.functools 3.5
+def pass_none(func):
+ """
+ Wrap func so it's not called if its first param is None
+
+ >>> print_text = pass_none(print)
+ >>> print_text('text')
+ text
+ >>> print_text(None)
+ """
+
+ @functools.wraps(func)
+ def wrapper(param, *args, **kwargs):
+ if param is not None:
+ return func(param, *args, **kwargs)
+
+ return wrapper
if os.path.exists(installer_name): os.unlink(installer_name)
metadata = self.distribution.metadata
- author = metadata.author
- if not author:
- author = metadata.maintainer
- if not author:
- author = "UNKNOWN"
+ author = metadata.author or metadata.maintainer
version = metadata.get_version()
# ProductVersion must be strictly numeric
# XXX need to deal with prerelease versions
'%define unmangled_version ' + self.distribution.get_version(),
'%define release ' + self.release.replace('-','_'),
'',
- 'Summary: ' + self.distribution.get_description(),
+ 'Summary: ' + (self.distribution.get_description() or "UNKNOWN"),
]
# Workaround for #14443 which affects some RPM based systems such as
spec_file.append('Source0: %{name}-%{unmangled_version}.tar.gz')
spec_file.extend([
- 'License: ' + self.distribution.get_license(),
+ 'License: ' + (self.distribution.get_license() or "UNKNOWN"),
'Group: ' + self.group,
'BuildRoot: %{_tmppath}/%{name}-%{version}-%{release}-buildroot',
'Prefix: %{_prefix}', ])
spec_file.append('%s: %s' % (field, val))
- if self.distribution.get_url() != 'UNKNOWN':
+ if self.distribution.get_url():
spec_file.append('Url: ' + self.distribution.get_url())
if self.distribution_name:
spec_file.extend([
'',
'%description',
- self.distribution.get_long_description()
+ self.distribution.get_long_description() or "",
])
# put locale descriptions into spec file
"""Ensures that all required elements of meta-data are supplied.
Required fields:
- name, version, URL
-
- Recommended fields:
- (author and author_email) or (maintainer and maintainer_email))
+ name, version
Warns if any are missing.
"""
metadata = self.distribution.metadata
missing = []
- for attr in ('name', 'version', 'url'):
- if not (hasattr(metadata, attr) and getattr(metadata, attr)):
+ for attr in 'name', 'version':
+ if not getattr(metadata, attr, None):
missing.append(attr)
if missing:
- self.warn("missing required meta-data: %s" % ', '.join(missing))
- if not (
- self._check_contact("author", metadata) or
- self._check_contact("maintainer", metadata)
- ):
- self.warn("missing meta-data: either (author and author_email) " +
- "or (maintainer and maintainer_email) " +
- "should be supplied")
-
- def _check_contact(self, kind, metadata):
- """
- Returns True if the contact's name is specified and False otherwise.
- This function will warn if the contact's email is not specified.
- """
- name = getattr(metadata, kind) or ''
- email = getattr(metadata, kind + '_email') or ''
-
- msg = ("missing meta-data: if '{}' supplied, " +
- "'{}' should be supplied too")
-
- if name and email:
- return True
-
- if name:
- self.warn(msg.format(kind, kind + '_email'))
- return True
-
- addresses = [(alias, addr) for alias, addr in getaddresses([email])]
- if any(alias and addr for alias, addr in addresses):
- # The contact's name can be encoded in the email: `Name <email>`
- return True
-
- return False
+ self.warn("missing required meta-data: %s" % ', '.join(missing))
def check_restructuredtext(self):
"""Checks if the long string fields are reST-compliant."""
def _read_field(name):
value = msg[name]
- if value == 'UNKNOWN':
- return None
- return value
+ if value and value != "UNKNOWN":
+ return value
def _read_list(name):
values = msg.get_all(name, None)
self.classifiers or self.download_url):
version = '1.1'
+ # required fields
file.write('Metadata-Version: %s\n' % version)
file.write('Name: %s\n' % self.get_name())
file.write('Version: %s\n' % self.get_version())
- file.write('Summary: %s\n' % self.get_description())
- file.write('Home-page: %s\n' % self.get_url())
- file.write('Author: %s\n' % self.get_contact())
- file.write('Author-email: %s\n' % self.get_contact_email())
- file.write('License: %s\n' % self.get_license())
- if self.download_url:
- file.write('Download-URL: %s\n' % self.download_url)
- long_desc = rfc822_escape(self.get_long_description())
- file.write('Description: %s\n' % long_desc)
+ def maybe_write(header, val):
+ if val:
+ file.write("{}: {}\n".format(header, val))
- keywords = ','.join(self.get_keywords())
- if keywords:
- file.write('Keywords: %s\n' % keywords)
+ # optional fields
+ maybe_write("Summary", self.get_description())
+ maybe_write("Home-page", self.get_url())
+ maybe_write("Author", self.get_contact())
+ maybe_write("Author-email", self.get_contact_email())
+ maybe_write("License", self.get_license())
+ maybe_write("Download-URL", self.download_url)
+ maybe_write("Description", rfc822_escape(self.get_long_description() or ""))
+ maybe_write("Keywords", ",".join(self.get_keywords()))
self._write_list(file, 'Platform', self.get_platforms())
self._write_list(file, 'Classifier', self.get_classifiers())
self._write_list(file, 'Obsoletes', self.get_obsoletes())
def _write_list(self, file, name, values):
+ values = values or []
for value in values:
file.write('%s: %s\n' % (name, value))
return "%s-%s" % (self.get_name(), self.get_version())
def get_author(self):
- return self.author or "UNKNOWN"
+ return self.author
def get_author_email(self):
- return self.author_email or "UNKNOWN"
+ return self.author_email
def get_maintainer(self):
- return self.maintainer or "UNKNOWN"
+ return self.maintainer
def get_maintainer_email(self):
- return self.maintainer_email or "UNKNOWN"
+ return self.maintainer_email
def get_contact(self):
- return self.maintainer or self.author or "UNKNOWN"
+ return self.maintainer or self.author
def get_contact_email(self):
- return self.maintainer_email or self.author_email or "UNKNOWN"
+ return self.maintainer_email or self.author_email
def get_url(self):
- return self.url or "UNKNOWN"
+ return self.url
def get_license(self):
- return self.license or "UNKNOWN"
+ return self.license
get_licence = get_license
def get_description(self):
- return self.description or "UNKNOWN"
+ return self.description
def get_long_description(self):
- return self.long_description or "UNKNOWN"
+ return self.long_description
def get_keywords(self):
return self.keywords or []
self.keywords = _ensure_list(value, 'keywords')
def get_platforms(self):
- return self.platforms or ["UNKNOWN"]
+ return self.platforms
def set_platforms(self, value):
self.platforms = _ensure_list(value, 'platforms')
self.classifiers = _ensure_list(value, 'classifiers')
def get_download_url(self):
- return self.download_url or "UNKNOWN"
+ return self.download_url
# PEP 314
def get_requires(self):
from .errors import DistutilsPlatformError
from . import py39compat
+from ._functools import pass_none
IS_PYPY = '__pypy__' in sys.builtin_module_names
_sys_home = getattr(sys, '_home', None)
+
+def _is_parent(dir_a, dir_b):
+ """
+ Return True if a is a parent of b.
+ """
+ return os.path.normcase(dir_a).startswith(os.path.normcase(dir_b))
+
+
if os.name == 'nt':
+ @pass_none
def _fix_pcbuild(d):
- if d and os.path.normcase(d).startswith(
- os.path.normcase(os.path.join(PREFIX, "PCbuild"))):
- return PREFIX
- return d
+ # In a venv, sys._home will be inside BASE_PREFIX rather than PREFIX.
+ prefixes = PREFIX, BASE_PREFIX
+ matched = (
+ prefix
+ for prefix in prefixes
+ if _is_parent(d, os.path.join(prefix, "PCbuild"))
+ )
+ return next(matched, d)
project_base = _fix_pcbuild(project_base)
_sys_home = _fix_pcbuild(_sys_home)
# by default, check is checking the metadata
# should have some warnings
cmd = self._run()
- self.assertEqual(cmd._warnings, 2)
+ self.assertEqual(cmd._warnings, 1)
# now let's add the required fields
# and run it again, to make sure we don't get
cmd = self._run(metadata)
self.assertEqual(cmd._warnings, 0)
- # the check should warn if only email is given and it does not
- # contain the name
+ # the check should not warn if only email is given
metadata[kind + '_email'] = 'name@email.com'
cmd = self._run(metadata)
- self.assertEqual(cmd._warnings, 1)
+ self.assertEqual(cmd._warnings, 0)
- # the check should warn if only the name is given
+ # the check should not warn if only the name is given
metadata[kind] = "Name"
del metadata[kind + '_email']
cmd = self._run(metadata)
- self.assertEqual(cmd._warnings, 1)
+ self.assertEqual(cmd._warnings, 0)
@unittest.skipUnless(HAS_DOCUTILS, "won't test without docutils")
def test_check_document(self):
self.assertEqual(metadata.description, "xxx")
self.assertEqual(metadata.download_url, 'http://example.com')
self.assertEqual(metadata.keywords, ['one', 'two'])
- self.assertEqual(metadata.platforms, ['UNKNOWN'])
+ self.assertEqual(metadata.platforms, None)
self.assertEqual(metadata.obsoletes, None)
self.assertEqual(metadata.requires, ['foo'])
req1 = dict(self.conn.reqs[0].headers)
req2 = dict(self.conn.reqs[1].headers)
- self.assertEqual(req1['Content-length'], '1374')
- self.assertEqual(req2['Content-length'], '1374')
+ self.assertEqual(req1['Content-length'], '1359')
+ self.assertEqual(req2['Content-length'], '1359')
self.assertIn(b'xxx', self.conn.reqs[1].data)
def test_password_not_in_file(self):
cmd.run()
warnings = [msg for msg in self.get_logs(WARN) if
msg.startswith('warning: check:')]
- self.assertEqual(len(warnings), 2)
+ self.assertEqual(len(warnings), 1)
# trying with a complete set of metadata
self.clear_logs()
import textwrap
import unittest
+import jaraco.envs
+
+import distutils
from distutils import sysconfig
from distutils.ccompiler import get_default_compiler
from distutils.unixccompiler import UnixCCompiler
self.assertTrue(sysconfig.get_config_var("EXT_SUFFIX").endswith(".pyd"))
self.assertNotEqual(sysconfig.get_config_var("EXT_SUFFIX"), ".pyd")
+ @unittest.skipUnless(
+ sys.platform == 'win32',
+ 'Testing Windows build layout')
+ @unittest.skipUnless(
+ sys.implementation.name == 'cpython',
+ 'Need cpython for this test')
+ @unittest.skipUnless(
+ '\\PCbuild\\'.casefold() in sys.executable.casefold(),
+ 'Need sys.executable to be in a source tree')
+ def test_win_build_venv_from_source_tree(self):
+ """Ensure distutils.sysconfig detects venvs from source tree builds."""
+ env = jaraco.envs.VEnv()
+ env.create_opts = env.clean_opts
+ env.root = TESTFN
+ env.ensure_env()
+ cmd = [
+ env.exe(),
+ "-c",
+ "import distutils.sysconfig; print(distutils.sysconfig.python_build)"
+ ]
+ distutils_path = os.path.dirname(os.path.dirname(distutils.__file__))
+ out = subprocess.check_output(cmd, env={**os.environ, "PYTHONPATH": distutils_path})
+ assert out == "True"
+
+
def test_suite():
suite = unittest.TestSuite()
suite.addTest(unittest.TestLoader().loadTestsFromTestCase(SysconfigTestCase))
def _project_urls(dist: "Distribution", val: dict, _root_dir):
- special = {"downloadurl": "download_url", "homepage": "url"}
- for key, url in val.items():
- norm_key = json_compatible_key(key).replace("_", "")
- _set_config(dist, special.get(norm_key, key), url)
- # If `homepage` is missing, distutils will warn the following message:
- # "warning: check: missing required meta-data: url"
- # In the context of PEP 621, users might ask themselves: "which url?".
- # Let's add a warning before distutils check to help users understand the problem:
- if not dist.metadata.url:
- msg = (
- "Missing `Homepage` url.\nIt is advisable to link some kind of reference "
- "for your project (e.g. source code or documentation).\n"
- )
- _logger.warning(msg)
- _set_config(dist, "project_urls", val.copy())
+ _set_config(dist, "project_urls", val)
def _python_requires(dist: "Distribution", val: dict, _root_dir):
def _read_payload_from_msg(msg: "Message") -> Optional[str]:
value = msg.get_payload().strip()
- if value == 'UNKNOWN':
+ if value == 'UNKNOWN' or not value:
return None
return value
write_field('Metadata-Version', str(version))
write_field('Name', self.get_name())
write_field('Version', self.get_version())
- write_field('Summary', single_line(self.get_description()))
+
+ summary = self.get_description()
+ if summary:
+ write_field('Summary', single_line(summary))
optional_fields = (
('Home-page', 'url'),
if attr_val is not None:
write_field(field, attr_val)
- license = rfc822_escape(self.get_license())
- write_field('License', license)
+ license = self.get_license()
+ if license:
+ write_field('License', rfc822_escape(license))
+
for project_url in self.project_urls.items():
write_field('Project-URL', '%s, %s' % project_url)
if keywords:
write_field('Keywords', keywords)
- for platform in self.get_platforms():
+ platforms = self.get_platforms() or []
+ for platform in platforms:
write_field('Platform', platform)
self._write_list(file, 'Classifier', self.get_classifiers())
self._write_list(file, 'License-File', self.license_files or [])
- file.write("\n%s\n\n" % self.get_long_description())
+ long_description = self.get_long_description()
+ if long_description:
+ file.write("\n%s" % long_description)
+ if not long_description.endswith("\n"):
+ file.write("\n")
sequence = tuple, list
import re
+import time
from pathlib import Path
+from urllib.error import HTTPError
from urllib.request import urlopen
__all__ = ["DOWNLOAD_DIR", "retrieve_file", "output_file", "urls_from_file"]
return Path(download_dir, re.sub(r"[^\-_\.\w\d]+", "_", file_name))
-def retrieve_file(url: str, download_dir: Path = DOWNLOAD_DIR):
+def retrieve_file(url: str, download_dir: Path = DOWNLOAD_DIR, wait: float = 5):
path = output_file(url, download_dir)
if path.exists():
print(f"Skipping {url} (already exists: {path})")
else:
download_dir.mkdir(exist_ok=True, parents=True)
print(f"Downloading {url} to {path}")
- download(url, path)
+ try:
+ download(url, path)
+ except HTTPError:
+ time.sleep(wait) # wait a few seconds and try again.
+ download(url, path)
return path
def core_metadata(dist) -> str:
with io.StringIO() as buffer:
dist.metadata.write_pkg_file(buffer)
- value = "\n".join(buffer.getvalue().strip().splitlines())
+ pkg_file_txt = buffer.getvalue()
+ skip_prefixes = ()
+ skip_lines = set()
# ---- DIFF NORMALISATION ----
# PEP 621 is very particular about author/maintainer metadata conversion, so skip
- value = re.sub(r"^(Author|Maintainer)(-email)?:.*$", "", value, flags=re.M)
+ skip_prefixes += ("Author:", "Author-email:", "Maintainer:", "Maintainer-email:")
# May be redundant with Home-page
- value = re.sub(r"^Project-URL: Homepage,.*$", "", value, flags=re.M)
+ skip_prefixes += ("Project-URL: Homepage,", "Home-page:")
# May be missing in original (relying on default) but backfilled in the TOML
- value = re.sub(r"^Description-Content-Type:.*$", "", value, flags=re.M)
+ skip_prefixes += ("Description-Content-Type:",)
# ini2toml can automatically convert `tests_require` to `testing` extra
- value = value.replace("Provides-Extra: testing\n", "")
+ skip_lines.add("Provides-Extra: testing")
# Remove empty lines
- value = re.sub(r"^\s*$", "", value, flags=re.M)
- value = re.sub(r"^\n", "", value, flags=re.M)
+ skip_lines.add("")
- return value
+ result = []
+ for line in pkg_file_txt.splitlines():
+ if line.startswith(skip_prefixes) or line in skip_lines:
+ continue
+ result.append(line + "\n")
+
+ return "".join(result)
[tox]
envlist = python
-minversion = 3.2
+minversion = 3.25
# https://github.com/jaraco/skeleton/issues/6
tox_pip_extensions_ext_venv_update = true
toxworkdir={env:TOX_WORK_DIR:.tox}
windir # required for test_pkg_resources
# honor git config in pytest-perf
HOME
- # workaround for tox-dev/tox#2382
- PROGRAMDATA
- PROGRAMFILES
- PROGRAMFILES(x86)
[testenv:integration]
deps = {[testenv]deps}
passenv =
{[testenv]passenv}
DOWNLOAD_PATH
- # workaround for tox-dev/tox#2382
- PROGRAMDATA
- PROGRAMFILES
- PROGRAMFILES(x86)
setenv =
PROJECT_ROOT = {toxinidir}
commands =