diff --git a/Doc/distutils/apiref.rst b/Doc/distutils/apiref.rst --- a/Doc/distutils/apiref.rst +++ b/Doc/distutils/apiref.rst @@ -280,16 +280,20 @@ the full reference. .. class:: Distribution A :class:`Distribution` describes how to build, install and package up a Python software package. See the :func:`setup` function for a list of keyword arguments accepted by the Distribution constructor. :func:`setup` creates a Distribution instance. + .. versionchanged:: 3.5 + :class:`~distutils.core.Distribution` now raises a :exc:`TypeError` if + ``classifiers``, ``keywords`` and ``platforms`` fields are not specified + as a list. .. class:: Command A :class:`Command` class (or rather, an instance of one of its subclasses) implement a single distutils command. :mod:`distutils.ccompiler` --- CCompiler base class diff --git a/Doc/distutils/setupscript.rst b/Doc/distutils/setupscript.rst --- a/Doc/distutils/setupscript.rst +++ b/Doc/distutils/setupscript.rst @@ -576,58 +576,68 @@ This information includes: | | package maintainer | | | +----------------------+---------------------------+-----------------+--------+ | ``url`` | home page for the package | URL | \(1) | +----------------------+---------------------------+-----------------+--------+ | ``description`` | short, summary | short string | | | | description of the | | | | | package | | | +----------------------+---------------------------+-----------------+--------+ -| ``long_description`` | longer description of the | long string | \(5) | +| ``long_description`` | longer description of the | long string | \(4) | | | package | | | +----------------------+---------------------------+-----------------+--------+ -| ``download_url`` | location where the | URL | \(4) | +| ``download_url`` | location where the | URL | | | | package may be downloaded | | | +----------------------+---------------------------+-----------------+--------+ -| ``classifiers`` | a list of classifiers | list of strings | \(4) | +| ``classifiers`` | a list of classifiers | list of strings | (6)(7) | +----------------------+---------------------------+-----------------+--------+ -| ``platforms`` | a list of platforms | list of strings | | +| ``platforms`` | a list of platforms | list of strings | (6)(8) | +----------------------+---------------------------+-----------------+--------+ -| ``license`` | license for the package | short string | \(6) | +| ``keywords`` | a list of keywords | list of strings | (6)(8) | ++----------------------+---------------------------+-----------------+--------+ +| ``license`` | license for the package | short string | \(5) | +----------------------+---------------------------+-----------------+--------+ Notes: (1) These fields are required. (2) 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`. (4) - These fields should not be used if your package is to be compatible with Python - versions prior to 2.2.3 or 2.3. The list is available from the `PyPI website - `_. - -(5) The ``long_description`` field is used by PyPI when you are :ref:`registering ` a package, to :ref:`build its home page `. -(6) +(5) The ``license`` field is a text indicating the license covering the package where the license is not a selection from the "License" Trove classifiers. See the ``Classifier`` field. Notice that there's a ``licence`` distribution option which is deprecated but still acts as an alias for ``license``. +(6) + This field must be a list. + +(7) + The valid classifiers are listed on + `PyPI `_. + +(8) + To preserve backward compatibility, this field also accepts a string. If + you pass a comma-separated string ``'foo, bar'``, it will be converted to + ``['foo', 'bar']``, Otherwise, it will be converted to a list of one + string. + 'short string' A single line of text, not more than 200 characters. 'long string' Multiple lines of plain text in reStructuredText format (see http://docutils.sourceforge.net/). 'list of strings' @@ -645,17 +655,17 @@ information is sometimes used to indicat (for final pre-release release testing). Some examples: 0.1.0 the first, experimental release of a package 1.0.1a2 the second alpha release of the first patch version of 1.0 -``classifiers`` are specified in a Python list:: +``classifiers`` must be specified in a list:: setup(..., classifiers=[ 'Development Status :: 4 - Beta', 'Environment :: Console', 'Environment :: Web Environment', 'Intended Audience :: End Users/Desktop', 'Intended Audience :: Developers', @@ -666,16 +676,22 @@ 1.0.1a2 'Operating System :: POSIX', 'Programming Language :: Python', 'Topic :: Communications :: Email', 'Topic :: Office/Business', 'Topic :: Software Development :: Bug Tracking', ], ) +.. versionchanged:: 3.5 + :class:`~distutils.core.setup` now raises a :exc:`TypeError` if + ``classifiers``, ``keywords`` and ``platforms`` fields are not specified + as a list. + + .. _debug-setup-script: Debugging the setup script ========================== Sometimes things go wrong, and the setup script doesn't do what the developer wants. diff --git a/Doc/whatsnew/3.5.rst b/Doc/whatsnew/3.5.rst --- a/Doc/whatsnew/3.5.rst +++ b/Doc/whatsnew/3.5.rst @@ -279,16 +279,22 @@ difflib distutils --------- * The ``build`` and ``build_ext`` commands now accept a ``-j`` option to enable parallel building of extension modules. (Contributed by Antoine Pitrou in :issue:`5309`.) +* :class:`distutils.core.setup` now raises a :exc:`TypeError` if + ``classifiers``, ``keywords`` and ``platforms`` fields are not specified + as a list. However, to minimize backwards incompatibility concerns, + ``keywords`` and ``platforms`` fields still accept a comma separated string. + (Contributed by Berker Peksag in :issue:`19610`.) + doctest ------- * :func:`doctest.DocTestSuite` returns an empty :class:`unittest.TestSuite` if *module* contains no docstrings instead of raising :exc:`ValueError`. (Contributed by Glenn Jones in :issue:`15916`.) glob diff --git a/Lib/distutils/dist.py b/Lib/distutils/dist.py --- a/Lib/distutils/dist.py +++ b/Lib/distutils/dist.py @@ -1184,22 +1184,48 @@ class DistributionMetadata: return self.description or "UNKNOWN" def get_long_description(self): return self.long_description or "UNKNOWN" def get_keywords(self): return self.keywords or [] + def set_keywords(self, value): + # if keywords is a string, it will be converted to list + # by Distribution.finalize_options(). to maintain backwards + # compatibility, do not raise an exception if keywords is + # a string + if not isinstance(value, (list, str)): + msg = "'keywords' should be a 'list', not %r" + raise TypeError(msg % type(value).__name__) + self.keywords = value + def get_platforms(self): return self.platforms or ["UNKNOWN"] + def set_platforms(self, value): + # if platforms is a string, it will be converted to list + # by Distribution.finalize_options(). to maintain backwards + # compatibility, do not raise an exception if platforms is + # a string + if not isinstance(value, (list, str)): + msg = "'platforms' should be a 'list', not %r" + raise TypeError(msg % type(value).__name__) + self.platforms = value + def get_classifiers(self): return self.classifiers or [] + def set_classifiers(self, value): + if not isinstance(value, list): + msg = "'classifiers' should be a 'list', not %r" + raise TypeError(msg % type(value).__name__) + self.classifiers = value + def get_download_url(self): return self.download_url or "UNKNOWN" # PEP 314 def get_requires(self): return self.requires or [] def set_requires(self, value): diff --git a/Lib/distutils/tests/test_dist.py b/Lib/distutils/tests/test_dist.py --- a/Lib/distutils/tests/test_dist.py +++ b/Lib/distutils/tests/test_dist.py @@ -189,16 +189,23 @@ class DistributionTestCase(support.Loggi dist = Distribution(attrs=attrs) dist.finalize_options() # finalize_option splits platforms and keywords self.assertEqual(dist.metadata.platforms, ['one', 'two']) self.assertEqual(dist.metadata.keywords, ['one', 'two']) + attrs = {'keywords': 'foo bar', + 'platforms': 'foo bar'} + dist = Distribution(attrs=attrs) + dist.finalize_options() + self.assertEqual(dist.metadata.platforms, ['foo bar']) + self.assertEqual(dist.metadata.keywords, ['foo bar']) + def test_get_command_packages(self): dist = Distribution() self.assertEqual(dist.command_packages, None) cmds = dist.get_command_packages() self.assertEqual(cmds, ['distutils.command']) self.assertEqual(dist.command_packages, ['distutils.command']) @@ -332,19 +339,56 @@ class MetadataTestCase(support.TempdirMa {"name": "package", "version": "1.0", "obsoletes": ["my.pkg (splat)"]}) def test_classifier(self): attrs = {'name': 'Boa', 'version': '3.0', 'classifiers': ['Programming Language :: Python :: 3']} dist = Distribution(attrs) + self.assertEqual(dist.get_classifiers(), + ['Programming Language :: Python :: 3']) meta = self.format_metadata(dist) self.assertIn('Metadata-Version: 1.1', meta) + def test_classifier_invalid_type(self): + attrs = {'name': 'Boa', 'version': '3.0', + 'classifiers': ('Programming Language :: Python :: 3',)} + msg = "'classifiers' should be a 'list', not 'tuple'" + with self.assertRaises(TypeError, msg=msg): + Distribution(attrs) + + def test_keywords(self): + attrs = {'name': 'Monty', 'version': '1.0', + 'keywords': ['spam', 'eggs', 'life of brian']} + dist = Distribution(attrs) + self.assertEqual(dist.get_keywords(), + ['spam', 'eggs', 'life of brian']) + + def test_keywords_invalid_type(self): + attrs = {'name': 'Monty', 'version': '1.0', + 'keywords': ('spam', 'eggs', 'life of brian')} + msg = "'keywords' should be a 'list', not 'tuple'" + with self.assertRaises(TypeError, msg=msg): + Distribution(attrs) + + def test_platforms(self): + attrs = {'name': 'Monty', 'version': '1.0', + 'platforms': ['GNU/Linux', 'Some Evil Platform']} + dist = Distribution(attrs) + self.assertEqual(dist.get_platforms(), + ['GNU/Linux', 'Some Evil Platform']) + + def test_platforms_invalid_types(self): + attrs = {'name': 'Monty', 'version': '1.0', + 'platforms': ('GNU/Linux', 'Some Evil Platform')} + msg = "'platforms' should be a 'list', not 'tuple'" + with self.assertRaises(TypeError, msg=msg): + Distribution(attrs) + def test_download_url(self): attrs = {'name': 'Boa', 'version': '3.0', 'download_url': 'http://example.org/boa'} dist = Distribution(attrs) meta = self.format_metadata(dist) self.assertIn('Metadata-Version: 1.1', meta) def test_long_description(self):