Skip to content

Pep517 backend#6338

Closed
da-woods wants to merge 5 commits intocython:masterfrom
da-woods:pep517-backend
Closed

Pep517 backend#6338
da-woods wants to merge 5 commits intocython:masterfrom
da-woods:pep517-backend

Conversation

@da-woods
Copy link
Contributor

Initial work on a PEP517 build backend. Essentially allows build Cython projects declaratively (with pyproject.toml) rather than with a setup.py file.

Step 1 of #6305 (@webknjaz)

It's currently missing:

  • tests
  • documentation

It's deliberately missing most customization at this point just for the sake of merging something simple then improving it. My plan is to go for a "direct" translation of setup.py where possible.

Tests currently seem hard. I can usefully add unit tests for individual components, but testing the whole thing involves allowing pip to use isolated build environments, and at that stage it will want to download Cython via pypi rather than use the local version that we want to test.

Examples of valid pyproject.toml files:

[build-system]
requires = ["cython"]
build-backend = "Cython.Build.pep517_backend"

[project]
name = "something"
version = "0.1"
description = "description"

# equivalent to passing a filename/file glob to cythonize:
[tool.cython]
module_list = "a.pyx"
[build-system]
requires = ["cython"]
build-backend = "Cython.Build.pep517_backend"

[project]
name = "something"
version = "0.1"
description = "description"

# equivalent to passing a list of Extensions to cythonize
[[tool.cython.module_list]]
name = "a"
sources = ["a.pyx"]

[[tool.cython.module_list]]
name = "b"
sources = ["b.pyx"]

@scoder
Copy link
Contributor

scoder commented Aug 13, 2024 via email

@webknjaz
Copy link
Contributor

Cool. I think pip supports installing from a directory, so we could build a Cython wheel and let pip install that for the test environment.

@scoder did you mean in CI? For CI, I tend to build dists first and then, use them in tests so that the following job could publish what's actually been tested. Let me know if you'd like a PR or pointers.

@webknjaz
Copy link
Contributor

allowing pip to use isolated build environments, and at that stage it will want to download Cython via pypi rather than use the local version that we want to test.

You can add --no-index --find-links to pip wheel (or pip install for that matter) and that should avoid hitting PyPI. Additionally, python -Im build has a --no-isolation flag which can let you invoke hooks if you pre-provision the deps. There's also pyproject_hooks that may be useful, perhaps.

@webknjaz
Copy link
Contributor

Oh, and the downstreams have gpep517, pyproject-rpm-macros and dh-python/pybuild-plugin-pyproject that help them deal with the PEP 517 / 660 interface.

@@ -0,0 +1 @@
from ._backend import *
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You have to re-expose the hooks from setuptools before this. Otherwise, your backend won't pick up new hooks that might appear there. This happened to me once with PEP 660, because I was conservative and exposed stuff declared in PEP 517, then setuptools added support for PEP 660, but my backend didn't have editable installs support for a long time because I didn't take this into account. As a result, I documented how to properly do this in the setuptools' docs.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think I can do that. I want to declare setuptools as a requirement in get_requires_for_build_editable which means I don't think I can import setuptools first.

I think this is something you'd live with in an in-tree backend (which you only use for your own project) but for something that Cython's distributing for other people to use, we don't want them to have to remember to list setuptools as a dependency too.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here's how you do it:

Suggested change
from ._backend import *
from contextlib import suppress
with suppress(ImportError):
from setuptools.build_meta import *
from ._backend import *

I used this trick before — one subprocess wouldn't have setuptools but once the front-end installs it, it will be in place.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One caveat would be that you still can't use setuptools' get_requires_for_*() hooks but the rest should be fine.

Comment on lines +7 to +22
# I don't really want to be dealing with finding/vendoring replacement toml
# libraries for Python <3.11. Therefore use setuptools if possible.
# Try a number of options for maximum chance of success.
try:
# pyprojecttoml is marked as private in setuptools
from setuptools.config.pyprojecttoml import load_file
except ImportError:
try:
# setuptools 69.1.0+
from setuptools.compat.py310 import tomllib
except ImportError:
import tomllib # standard library from 3.11 onwards
def load_file(filename):
with open(filename, "rb") as f:
return tomllib.load(f)
return load_file(filename)
Copy link
Contributor

@webknjaz webknjaz Aug 13, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You don't need to do this. Just return tomlli; python_version < "3.11" from the get_requires_for_build_*() hooks.
This library is API-compatible with tomllib and is maintained by a few CPython Core Devs (PEP 680 authors/sponsors). In fact, tomllib grew out of tomli so it's a very safe bet: https://realpython.com/python311-tomllib/#read-toml-with-tomllib.

Suggested change
# I don't really want to be dealing with finding/vendoring replacement toml
# libraries for Python <3.11. Therefore use setuptools if possible.
# Try a number of options for maximum chance of success.
try:
# pyprojecttoml is marked as private in setuptools
from setuptools.config.pyprojecttoml import load_file
except ImportError:
try:
# setuptools 69.1.0+
from setuptools.compat.py310 import tomllib
except ImportError:
import tomllib # standard library from 3.11 onwards
def load_file(filename):
with open(filename, "rb") as f:
return tomllib.load(f)
return load_file(filename)
try:
import tomllib
except ImportError:
import tomli as tomllib # is in standard library from 3.11 onwards
with open(filename, "rb") as f:
return tomllib.load(f)

@@ -0,0 +1,142 @@
# Note - setuptools imports are all lazy so that we can specify it as a dependency
from contextlib import contextmanager as _contextmanager
from functools import lru_cache
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This name should also be private so that re-export in the init wouldn't pick it up by default.


return _Extension(name, sources)

@lru_cache() # incase we go through multiple calls to this function
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do you think it's called several times? PEP 517 hooks each are called in subprocesses IIUC.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes - it looks like they are. In which case caching is pointless

@webknjaz
Copy link
Contributor

Let's also invite @abravalheri to get an opinion from the setuptools upstream.

@webknjaz
Copy link
Contributor

webknjaz commented Aug 14, 2024

allowing pip to use isolated build environments, and at that stage it will want to download Cython via pypi rather than use the local version that we want to test.

You can add --no-index --find-links to pip wheel (or pip install for that matter) and that should avoid hitting PyPI. Additionally, python -Im build has a --no-isolation flag which can let you invoke hooks if you pre-provision the deps. There's also pyproject_hooks that may be useful, perhaps.

I forgot one more trick — you can have a constraints file and set the PIP_CONSTRAINT variable to that — it leaks into the underlying isolated call of pip install. The same goes for PIP_NO_INDEX and PIP_FIND_LINKS.

@abravalheri
Copy link

abravalheri commented Aug 14, 2024

Let's also invite @abravalheri to get an opinion from the setuptools upstream.

Last year we did something in setuptools-rust to add support for pyproject.toml but we followed a different approach and avoided patching setuptools altogether. I recon something very similar can be done for Cython.

For example, considering the following already exists in setuptools:

  1. A hook via the setuptools.finalize_distribution_options entrypoint that allows setting the attributes corresponding to the setup() keywords.
  2. Support for .pyx sources in Extension objects when Cython is installed (ref1, ref2).

Something like the following could be implemented:

# Draft based on setuptools-rust

if sys.version_info[:2] >= (3, 11):
    import tomllib
else:
    import tomli as tomlib


def pyprojecttoml_config(dist: Distribution) -> None:
    try:
        with open("pyproject.toml", "rb") as f:
            cfg = tomllib.read(f).get("tool", {}).get("cython")
    except FileNotFoundError:
        return None

    if cfg:
        existing = dist.ext_modules or []
        new = map(_create, cfg.get("ext-modules", []))
        dist.ext_modules = [*existing, *new]


def _create(config: dict) -> Extension:
    from setuptools.extension import Extension

    # PEP 517/621 convention: pyproject.toml uses dashes
    kwargs = {k.replace("-", "_"): v for k, v in config.items()}
    return Extension(**kwargs)

# Then, an entrypoint needs to be defined in the Cython distribution:
#
# [project.entry-points."setuptools.finalize_distribution_options"]
# cython = "Cython.whichever_module:pyprojecttoml_config"

That said, no specific code seems to be necessary to handle something like the following definition:

[[tool.cython.module_list]]
name = "a"
sources = ["a.pyx"]

... and from the setuptools point of view, this is probably likely to be implemented some day in the form of:

[[tool.setuptools.ext-modules]]
name = "a"
sources = ["a.pyx"]

(in fact we reached a point in the development of setuptools that we can start working on a PR for that1).

What is unlikely to be supported directly in setuptools is something like the following:

[tool.cython]
cythonize = ["a.pyx", "b.pyx", "c.py"]

But this could be implemented following the methodology discussed above (setuptools.finalize_distribution_options). The difference is that for each .pyx an extension object with a corresponding name would be created.

/crossref pypa/setuptools#4568

Footnotes

  1. Update: I opened the PR in https://github.com/pypa/setuptools/pull/4568, and I believe it would already cover some of the functionality discussed here (since setuptools already accepts .pyx source files when Cython is installed)..

@da-woods
Copy link
Contributor Author

Thanks @abravalheri - one of the things I was initially worried about was that it seemed to be (mostly) implementing a feature that would be better off in setuptools, and that the "cython" part of this was comparatively small.

It sounds like I should wait for the setuptools PR, and then investigate what's missing. And try to implement it with the less intrusive finalize_distribution_options if possible.

@webknjaz
Copy link
Contributor

@joshua-auchincloss this ain't for hatchling but perhaps you'd be interested in checking out the interface for building Cython extensions. Any feedback?

@da-woods
Copy link
Contributor Author

I'm not sure this is worth anyone reviewing at this point - most of it will hopefully be removed fairly soon.

@abravalheri
Copy link

abravalheri commented Sep 6, 2024

Simple use-cases can now be tried out with setuptools>74.1.
For example the following should be possible to write:

[build-system]
requires = ["setuptools>74.1", "cython"]
build-backend = "setuptools.build_meta"

[project]
name = "something"
version = "0.1"
description = "description"

[tool.setuptools]
ext-modules = [
   {name = "a", sources = ["a.pyx"]},
   {name = "b", sources = ["b.pyx"]},
]

Feedback targeting setuptools functionality should be directed at the pypa/setuptools repository.

More complex use cases (e.g. passing flags and configuration parameters to cythonize) are not covered in setuptools, and may be implemented by Cython (if the maintainers are interested1) using the setuptools.finalize_distribution_options hook as summarised in #6338 (comment).

Footnotes

  1. If Cython maintainers need to discuss integration problems with Setuptools or need other integration hooks, please let me know (you can also use the "Discussion > Dev" tab on the setuptools repository).

@da-woods
Copy link
Contributor Author

da-woods commented Sep 7, 2024

Thanks for merging that @abravalheri

I think what I'll do in the near future is add some documentation for it in Cython so that people know start using the declarative version.

I'll have to think about where because our "source files and compilation" docs page already seems to big and unwieldy to me so I don't really want to just dump more information in it.

I'm definitely willing to expand it a bit with Cython-specific additions as you suggest. Possibly worth seeing what gets requested first though.

I'll close this PR because I think it's completely superseded.

@da-woods da-woods closed this Sep 7, 2024
@da-woods da-woods deleted the pep517-backend branch September 7, 2024 08:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants