diff --git a/doc/conf.py b/doc/conf.py index 59a86f41..63cae9f2 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -57,7 +57,7 @@ # built documents. # # The short X.Y version. -version = '3.2.0' +version = '3.3.0' # The full version, including alpha/beta/rc tags. release = version #+ '.1' diff --git a/doc/history.rst b/doc/history.rst index 00dd6a39..92c716f1 100644 --- a/doc/history.rst +++ b/doc/history.rst @@ -1,13 +1,24 @@ Release history =============== +Version 3.3 +----------- + +- ``PKG-INFO`` files in sdists are now generated the same way as ``METADATA`` in + wheels, fixing some issues with sdists (:ghpull:`410`). +- ``flit publish`` now sends SHA-256 hashes, fixing uploads to GitLab package + repositories (:ghpull:`416`). +- The ``[project]`` metadata table from :pep:`621` is now fully supported and + :ref:`documented `. Projects using this can now + specify ``requires = ["flit_core >=3.2,<4"]`` in the ``[build-system]`` table. + Version 3.2 ----------- - Experimental support for specifying metadata in a ``[project]`` table in ``pyproject.toml`` as specified by :pep:`621` (:ghpull:`393`). If you try using this, please specify ``requires = ["flit_core >=3.2.0,<3.3"]`` in the - ``[build-system`` table for now, in case it needs to change for the next + ``[build-system]`` table for now, in case it needs to change for the next release. - Fix writing METADATA file with multi-line information in certain fields such as ``Author`` (:ghpull:`402`). diff --git a/doc/pyproject_toml.rst b/doc/pyproject_toml.rst index 8d38bf95..304f847b 100644 --- a/doc/pyproject_toml.rst +++ b/doc/pyproject_toml.rst @@ -15,18 +15,230 @@ Build system section -------------------- This tells tools like pip to build your project with flit. It's a standard -defined by PEP 517. For any project using Flit, it will look like this: +defined by PEP 517. For any new project using Flit, it will look like this: .. code-block:: toml [build-system] - requires = ["flit_core >=2,<4"] + requires = ["flit_core >=3.2,<4"] build-backend = "flit_core.buildapi" -Metadata section ----------------- +Version constraints: + +- For now, all packages should specify ``<4``, so they won't be impacted by + changes in the next major version. +- :ref:`pyproject_toml_project` requires ``flit_core >=3.2`` +- :ref:`pyproject_old_metadata` requires ``flit_core >=2,<4`` +- The older :doc:`flit.ini file ` requires ``flit_core <3``. +- Only ``flit_core`` 2.x can build packages on Python 2, so packages still + supporting Python 2 cannot use new-style metadata (the ``[project]`` table). + +.. _pyproject_toml_project: + +New style metadata +------------------ + +.. versionadded:: 3.2 + +The new standard way to specify project metadata is in a ``[project]`` table, +as defined by :pep:`621`. Flit works for now with either this or the older +``[tool.flit.metadata]`` table (:ref:`described below `), +but it won't allow you to mix them. + +A simple ``[project]`` table might look like this: + +.. code-block:: toml + + [project] + name = "astcheck" + authors = [ + {name = "Thomas Kluyver", email = "thomas@kluyver.me.uk"}, + ] + readme = "README.rst" + classifiers = [ + "License :: OSI Approved :: MIT License", + ] + requires-python = ">=3.5" + dynamic = ['version', 'description'] + +The allowed fields are: + +name + The name your package will have on PyPI. This field is required. For Flit, + this also points to your package as an import name by default (see + :ref:`pyproject_module` if that needs to be different). +version + Version number as a string. If you want Flit to get this from a + ``__version__`` attribute, leave it out of the TOML config and include + "version" in the ``dynamic`` field. +description + A one-line description of your project. If you want Flit to get this from + the module docstring, leave it out of the TOML config and include + "description" in the ``dynamic`` field. +readme + A path (relative to the .toml file) to a file containing a longer description + of your package to show on PyPI. This should be written in `reStructuredText + `_, Markdown or + plain text, and the filename should have the appropriate extension + (``.rst``, ``.md`` or ``.txt``). Alternatively, ``readme`` can be a table with + either a ``file`` key (a relative path) or a ``text`` key (literal text), and + an optional ``content-type`` key (e.g. ``text/x-rst``). +requires-python + A version specifier for the versions of Python this requires, e.g. ``~=3.3`` or + ``>=3.3,<4``, which are equivalents. +license + A table with either a ``file`` key (a relative path to a license file) or a + ``text`` key (the license text). +authors + A list of tables with ``name`` and ``email`` keys (both optional) describing + the authors of the project. +maintainers + Same format as authors. +keywords + A list of words to help with searching for your package. +classifiers + A list of `Trove classifiers `_. + Add ``Private :: Do Not Upload`` into the list to prevent a private package + from being uploaded to PyPI by accident. +dependencies & optional-dependencies + See :ref:`pyproject_project_dependencies`. +urls + See :ref:`pyproject_project_urls`. +scripts & gui-scripts + See :ref:`pyproject_project_scripts`. +entry-points + See :ref:`pyproject_project_entrypoints`. +dynamic + A list of field names which aren't specified here, for which Flit should + find a value at build time. Only "version" and "description" are accepted. + +.. _pyproject_project_dependencies: + +Dependencies +~~~~~~~~~~~~ + +The ``dependencies`` field is a list of other packages from PyPI that this +package needs. Each package may be followed by a version specifier like +``>=4.1``, and/or an `environment marker`_ +after a semicolon. For example: + + .. code-block:: toml + + dependencies = [ + "requests >=2.6", + "configparser; python_version == '2.7'", + ] + +The ``[project.optional-dependencies]`` table contains lists of packages needed +for every optional feature. The requirements are specified in the same format as +for ``dependencies``. For example: + + .. code-block:: toml + + [project.optional-dependencies] + test = [ + "pytest >=2.7.3", + "pytest-cov", + ] + doc = ["sphinx"] + +You can call these optional features anything you want, although ``test`` and +``doc`` are common ones. You specify them for installation in square brackets +after the package name or directory, e.g. ``pip install '.[test]'``. + +.. _pyproject_project_urls: + +URLs table +~~~~~~~~~~ + +Your project's page on `pypi.org `_ can show a number of +links. You can point people to documentation or a bug tracker, for example. + +This section is called ``[project.urls]`` in the file. You can use +any names inside it. Here it is for flit: + +.. code-block:: toml + + [project.urls] + Documentation = "https://flit.readthedocs.io/en/latest/" + Source = "https://github.com/takluyver/flit" + +.. _pyproject_project_scripts: + +Scripts section +~~~~~~~~~~~~~~~ + +This section is called ``[project.scripts]`` in the file. +Each key and value describes a shell command to be installed along with +your package. These work like setuptools 'entry points'. Here's the section +for flit: + +.. code-block:: toml + + [project.scripts] + flit = "flit:main" + + +This will create a ``flit`` command, which will call the function ``main()`` +imported from :mod:`flit`. + +A similar table called ``[project.gui-scripts]`` defines commands which launch +a GUI. This only makes a difference on Windows, where GUI scripts are run +without a console. + +.. _pyproject_project_entrypoints: + +Entry points sections +~~~~~~~~~~~~~~~~~~~~~ + +You can declare `entry points `_ +using sections named :samp:`[project.entry-points.{groupname}]`. E.g. to +provide a pygments lexer from your package: + +.. code-block:: toml + + [project.entry-points."pygments.lexers"] + dogelang = "dogelang.lexer:DogeLexer" + +In each ``package:name`` value, the part before the colon should be an +importable module name, and the latter part should be the name of an object +accessible within that module. The details of what object to expose depend on +the application you're extending. + +If the group name contains a dot, it must be quoted (``"pygments.lexers"`` +above). Script entry points are defined in :ref:`scripts tables +`, so you can't use the group names +``console_scripts`` or ``gui_scripts`` here. + +.. _pyproject_module: + +Module section +~~~~~~~~~~~~~~ + +If your package will have different names for installation and import, +you should specify the install (PyPI) name in the ``[project]`` table +(:ref:`see above `), and the import name in a +``[tool.flit.module]`` table: + +.. code-block:: toml + + [project] + name = "pynsist" + # ... + + [tool.flit.module] + name = "nsist" + +.. _pyproject_old_metadata: + +Old style metadata +------------------ + +Flit's older way to specify metadata is in a ``[tool.flit.metadata]`` table, +along with ``[tool.flit.scripts]`` and ``[tool.flit.entrypoints]``, described +below. This is still recognised for now, but you can't mix it with +:ref:`pyproject_toml_project`. -This section is called ``[tool.flit.metadata]`` in the file. There are three required fields: module @@ -56,8 +268,7 @@ home-page requires A list of other packages from PyPI that this package needs. Each package may be followed by a version specifier like ``(>=4.1)`` or ``>=4.1``, and/or an - `environment marker - `_ + `environment marker`_ after a semicolon. For example: .. code-block:: toml @@ -92,6 +303,8 @@ description-file (``.rst``, ``.md`` or ``.txt``). classifiers A list of `Trove classifiers `_. + Add ``Private :: Do Not Upload`` into the list to prevent a private package + from uploading on PyPI by accident. requires-python A version specifier for the versions of Python this requires, e.g. ``~=3.3`` or ``>=3.3,<4`` which are equivalents. @@ -138,7 +351,7 @@ URLs subsection ~~~~~~~~~~~~~~~ Your project's page on `pypi.org `_ can show a number of -links, in addition to the required ``home-page`` URL described above. You can +links, in addition to the ``home-page`` URL described above. You can point people to documentation or a bug tracker, for example. This section is called ``[tool.flit.metadata.urls]`` in the file. You can use @@ -154,38 +367,18 @@ any names inside it. Here it is for flit: .. _pyproject_toml_scripts: Scripts section ---------------- - -This section is called ``[tool.flit.scripts]`` in the file. -Each key and value describes a shell command to be installed along with -your package. These work like setuptools 'entry points'. Here's the section -for flit: - -.. code-block:: toml - - [tool.flit.scripts] - flit = "flit:main" - +~~~~~~~~~~~~~~~ -This will create a ``flit`` command, which will call the function ``main()`` -imported from :mod:`flit`. +A ``[tool.flit.scripts]`` table can be used along with ``[tool.flit.metadata]``. +It is in the same format as the newer ``[project.scripts]`` table +:ref:`described above `. Entry points sections ---------------------- - -You can declare `entry points `_ -using sections named :samp:`[tool.flit.entrypoints.{groupname}]`. E.g. to -provide a pygments lexer from your package: - -.. code-block:: toml +~~~~~~~~~~~~~~~~~~~~~ - [tool.flit.entrypoints."pygments.lexers"] - dogelang = "dogelang.lexer:DogeLexer" - -In each ``package:name`` value, the part before the colon should be an -importable module name, and the latter part should be the name of an object -accessible within that module. The details of what object to expose depend on -the application you're extending. +``[tool.flit.entrypoints]`` tables can be used along with ``[tool.flit.metadata]``. +They are in the same format as the newer ``[project.entry-points]`` tables +:ref:`described above `. .. _pyproject_toml_sdist: @@ -219,3 +412,5 @@ These paths: is platform dependent Exclusions have priority over inclusions. + +.. _environment marker: https://www.python.org/dev/peps/pep-0508/#environment-markers diff --git a/flit/__init__.py b/flit/__init__.py index f1770492..a793a52f 100644 --- a/flit/__init__.py +++ b/flit/__init__.py @@ -12,7 +12,7 @@ from .config import ConfigError from .log import enable_colourful_output -__version__ = '3.2.0' +__version__ = '3.3.0' log = logging.getLogger(__name__) diff --git a/flit/init.py b/flit/init.py index b0c77c66..496166c4 100644 --- a/flit/init.py +++ b/flit/init.py @@ -1,4 +1,3 @@ -from collections import OrderedDict from datetime import date import json import os @@ -181,7 +180,7 @@ def initialise(self): module = self.prompt_text('Module name', self.guess_module_name(), str.isidentifier) author = self.prompt_text('Author', self.defaults.get('author'), - lambda s: s != '') + lambda s: True) author_email = self.prompt_text('Author email', self.defaults.get('author_email'), self.validate_email) if 'home_page_template' in self.defaults: @@ -199,33 +198,50 @@ def initialise(self): self.update_defaults(author=author, author_email=author_email, home_page=home_page, module=module, license=license) - metadata = OrderedDict([ - ('module', module), - ('author', author), - ]) + # Format information as TOML + # This is ugly code, but I want the generated pyproject.toml, which + # will mostly be edited by hand, to look a particular way - e.g. authors + # in inline tables. It's easier to 'cheat' with some string formatting + # than to do this through a TOML library. + author_info = [] + if author: + author_info.append(f'name = {json.dumps(author)}') if author_email: - metadata['author-email'] = author_email - if home_page: - metadata['home-page'] = home_page + author_info.append(f'email = {json.dumps(author_email)}') + if author_info: + authors_list = "[{%s}]" % ", ".join(author_info) + else: + authors_list = "[]" + + classifiers = [] if license != 'skip': - metadata['classifiers'] = [license_names_to_classifiers[license]] + classifiers = [license_names_to_classifiers[license]] self.write_license(license, author) - if readme: - metadata['description-file'] = readme with (self.directory / 'pyproject.toml').open('w', encoding='utf-8') as f: - f.write(TEMPLATE.format(metadata=toml.dumps(metadata))) + f.write(TEMPLATE.format( + name=json.dumps(module), authors=authors_list + )) + if readme: + toml.dump({'readme': readme}, f) + if classifiers: + f.write(f"classifiers = {json.dumps(classifiers)}\n") + f.write('dynamic = ["version", "description"]\n') + if home_page: + f.write("\n") + toml.dump({'project': {'urls': {'Home': home_page}}}, f) print() print("Written pyproject.toml; edit that file to add optional extra info.") TEMPLATE = """\ [build-system] -requires = ["flit_core >=2,<4"] +requires = ["flit_core >=3.2,<4"] build-backend = "flit_core.buildapi" -[tool.flit.metadata] -{metadata} +[project] +name = {name} +authors = {authors} """ if __name__ == '__main__': diff --git a/flit/tomlify.py b/flit/tomlify.py index a9c9c605..d50cc9ee 100644 --- a/flit/tomlify.py +++ b/flit/tomlify.py @@ -8,7 +8,16 @@ import toml from .config import metadata_list_fields -from .init import TEMPLATE + + +TEMPLATE = """\ +[build-system] +requires = ["flit_core >=2,<4"] +build-backend = "flit_core.buildapi" + +[tool.flit.metadata] +{metadata} +""" class CaseSensitiveConfigParser(configparser.ConfigParser): optionxform = staticmethod(str) diff --git a/flit/upload.py b/flit/upload.py index f25cea80..a99180f6 100644 --- a/flit/upload.py +++ b/flit/upload.py @@ -226,6 +226,7 @@ def upload_file(file:Path, metadata:Metadata, repo): content = f.read() files = {'content': (file.name, content)} data['md5_digest'] = hashlib.md5(content).hexdigest() + data['sha256_digest'] = hashlib.sha256(content).hexdigest() log.info('Uploading %s...', file) resp = requests.post(repo['url'], diff --git a/flit/validate.py b/flit/validate.py index c1a26581..7184b1f5 100644 --- a/flit/validate.py +++ b/flit/validate.py @@ -13,6 +13,12 @@ log = logging.getLogger(__name__) +CUSTOM_CLASSIFIERS = frozenset({ + # https://github.com/pypa/warehouse/pull/5440 + 'Private :: Do Not Upload', +}) + + def get_cache_dir() -> Path: """Locate a platform-appropriate cache directory for flit to use @@ -96,6 +102,7 @@ def validate_classifiers(classifiers): classifiers = set(classifiers) try: valid_classifiers = _read_classifiers_cached() + valid_classifiers.update(CUSTOM_CLASSIFIERS) problems = _verify_classifiers(classifiers, valid_classifiers) except (FileNotFoundError, PermissionError) as e1: # We haven't yet got the classifiers cached or couldn't read it @@ -120,8 +127,8 @@ def validate_classifiers(classifiers): log.warning( "Couldn't get list of valid classifiers to check against") return problems - else: - return _verify_classifiers(classifiers, valid_classifiers) + valid_classifiers.update(CUSTOM_CLASSIFIERS) + return _verify_classifiers(classifiers, valid_classifiers) def validate_entrypoints(entrypoints): diff --git a/flit_core/flit_core/__init__.py b/flit_core/flit_core/__init__.py index d79d7ac1..01b9e170 100644 --- a/flit_core/flit_core/__init__.py +++ b/flit_core/flit_core/__init__.py @@ -4,4 +4,4 @@ All the convenient development features live in the main 'flit' package. """ -__version__ = '3.2.0' +__version__ = '3.3.0' diff --git a/flit_core/flit_core/sdist.py b/flit_core/flit_core/sdist.py index 963b4e55..80ccf25d 100644 --- a/flit_core/flit_core/sdist.py +++ b/flit_core/flit_core/sdist.py @@ -15,17 +15,6 @@ log = logging.getLogger(__name__) -PKG_INFO = """\ -Metadata-Version: 1.1 -Name: {name} -Version: {version} -Summary: {summary} -Home-page: {home_page} -Author: {author} -Author-email: {author_email} -""" - - def clean_tarinfo(ti, mtime=None): """Clean metadata from a TarInfo object to make it more reproducible. @@ -195,14 +184,9 @@ def build(self, target_dir, gen_setup_py=True): if gen_setup_py: self.add_setup_py(files_to_add, tf) - pkg_info = PKG_INFO.format( - name=self.metadata.name, - version=self.metadata.version, - summary=self.metadata.summary, - home_page=self.metadata.home_page, - author=self.metadata.author, - author_email=self.metadata.author_email, - ).encode('utf-8') + stream = io.StringIO() + self.metadata.write_metadata_file(stream) + pkg_info = stream.getvalue().encode() ti = tarfile.TarInfo(pjoin(self.dir_name, 'PKG-INFO')) ti.size = len(pkg_info) tf.addfile(ti, io.BytesIO(pkg_info)) diff --git a/pyproject.toml b/pyproject.toml index ad10c84d..e5a82b7c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["flit_core >=3.2.0,<3.3"] +requires = ["flit_core >=3.3.0,<4"] build-backend = "flit_core.buildapi" [project] @@ -8,13 +8,13 @@ authors = [ {name = "Thomas Kluyver", email = "thomas@kluyver.me.uk"}, ] dependencies = [ - "flit_core>=3.2.0", + "flit_core>=3.3.0", "requests", "docutils", "toml", - "zipfile36; python_version in '3.3 3.4 3.5'", + "zipfile36; python_version == '3.5'", ] -requires-python = ">=3.5" +requires-python = ">=3.6" readme = "README.rst" classifiers = ["Intended Audience :: Developers", "License :: OSI Approved :: BSD License", diff --git a/tests/test_init.py b/tests/test_init.py index 7330ff52..c30e6d36 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -108,8 +108,7 @@ def test_init(): assert_isfile(generated) with generated.open() as f: data = toml.load(f) - assert data['tool']['flit']['metadata'][ - 'author-email'] == "test@example.com" + assert data['project']['authors'][0]['email'] == "test@example.com" license = Path(td) / 'LICENSE' assert_isfile(license) with license.open() as f: @@ -133,11 +132,10 @@ def test_init_homepage_and_license_are_optional(): with Path(td, 'pyproject.toml').open() as f: data = toml.load(f) assert not Path(td, 'LICENSE').exists() - metadata = data['tool']['flit']['metadata'] - assert metadata == { - 'author': 'Test Author', - 'author-email': 'test_email@example.com', - 'module': 'test_module_name', + assert data['project'] == { + 'authors': [{'name': 'Test Author', 'email': 'test_email@example.com'}], + 'name': 'test_module_name', + 'dynamic': ['version', 'description'], } def test_init_homepage_validator(): @@ -155,12 +153,11 @@ def test_init_homepage_validator(): ti.initialise() with Path(td, 'pyproject.toml').open() as f: data = toml.load(f) - metadata = data['tool']['flit']['metadata'] - assert metadata == { - 'author': 'Test Author', - 'author-email': 'test_email@example.com', - 'home-page': 'https://www.example.org', - 'module': 'test_module_name', + assert data['project'] == { + 'authors': [{'name': 'Test Author', 'email': 'test_email@example.com'}], + 'name': 'test_module_name', + 'urls': {'Home': 'https://www.example.org'}, + 'dynamic': ['version', 'description'], } def test_author_email_field_is_optional(): @@ -178,11 +175,12 @@ def test_author_email_field_is_optional(): with Path(td, 'pyproject.toml').open() as f: data = toml.load(f) assert not Path(td, 'LICENSE').exists() - metadata = data['tool']['flit']['metadata'] - assert metadata == { - 'author': 'Test Author', - 'module': 'test_module_name', - 'home-page': 'https://www.example.org', + + assert data['project'] == { + 'authors': [{'name': 'Test Author'}], + 'name': 'test_module_name', + 'urls': {'Home': 'https://www.example.org'}, + 'dynamic': ['version', 'description'], } @@ -218,10 +216,9 @@ def test_init_readme_found_yes_choosen(): with Path(td, 'pyproject.toml').open() as f: data = toml.load(f) - metadata = data['tool']['flit']['metadata'] - assert metadata == { - 'author': 'Test Author', - 'author-email': 'test_email@example.com', - 'module': 'test_module_name', - 'description-file': 'readme.md' + assert data['project'] == { + 'authors': [{'name': 'Test Author', 'email': 'test_email@example.com'}], + 'name': 'test_module_name', + 'readme': 'readme.md', + 'dynamic': ['version', 'description'], } diff --git a/tests/test_validate.py b/tests/test_validate.py index e6dd3175..2c2da5fe 100644 --- a/tests/test_validate.py +++ b/tests/test_validate.py @@ -145,6 +145,26 @@ def mock_get_cache_dir(): assert classifiers == {"A", "B", "C"} +def test_validate_classifiers_private(monkeypatch): + """ + Test that `Private :: Do Not Upload` considered a valid classifier. + This is a special case because it is not listed in a trove classifier + but it is a way to make sure that a private package is not get uploaded + on PyPI by accident. + + Implementation on PyPI side: + https://github.com/pypa/warehouse/pull/5440 + Issue about officially documenting the trick: + https://github.com/pypa/packaging.python.org/issues/643 + """ + monkeypatch.setattr(fv, "_read_classifiers_cached", lambda: set()) + + actual = fv.validate_classifiers({'invalid'}) + assert actual == ["Unrecognised classifier: 'invalid'"] + + assert fv.validate_classifiers({'Private :: Do Not Upload'}) == [] + + @responses.activate @pytest.mark.parametrize("error", [PermissionError, OSError(errno.EROFS, "")]) def test_download_and_cache_classifiers_with_unacessible_dir(monkeypatch, error): diff --git a/tox.ini b/tox.ini index d1025e10..1ca9e387 100644 --- a/tox.ini +++ b/tox.ini @@ -25,6 +25,9 @@ deps = py35: zipfile36 py34: zipfile36 + # pytest requires attrs, and it now gets a version for Py >= 3.5 by default + py34: attrs <21 + skip_install=true setenv = @@ -33,11 +36,15 @@ setenv = commands = python -m pytest --cov=flit --cov=flit_core/flit_core -# Python 3.4: only test flit_core +# Python 3.4 & 3.5: only test flit_core [testenv:py34] commands = python -m pytest --cov=flit_core/flit_core --pyargs flit_core +[testenv:py35] +commands = + python -m pytest --cov=flit_core/flit_core --pyargs flit_core + [testenv:bootstrap] skip_install = true # Make the install step a no-op, so nothing gets installed in the env