├── .editorconfig ├── .flake8 ├── .gitignore ├── .travis.yml ├── README.rst ├── docs ├── Makefile ├── make.bat ├── requirements.txt └── source │ ├── cmds.rst │ ├── conf.py │ └── index.rst ├── mypy.ini ├── pyproject.toml ├── setup.cfg ├── setup.py └── src └── setl ├── __init__.py ├── __main__.py ├── _logging.py ├── cmds ├── __init__.py ├── _utils.py ├── build.py ├── clean.py ├── develop.py ├── publish.py └── setuppy.py ├── errs.py └── projects ├── __init__.py ├── _envs.py ├── base.py ├── build.py ├── clean.py ├── dev.py ├── hook.py ├── meta.py └── setup.py /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | indent_size = 4 6 | trim_trailing_whitespace = true 7 | insert_final_newline = true 8 | charset = utf-8 9 | 10 | [*.md] 11 | trim_trailing_whitespace = false 12 | 13 | [*.{py,rst,yml}] 14 | indent_style = space 15 | 16 | [*.{ini,toml,yml}] 17 | indent_size = 2 18 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = 3 | E203, 4 | W503 5 | exclude = 6 | .git, 7 | .venvs, 8 | __pycache__, 9 | dist, 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | dist/ 3 | docs/target/ 4 | __pycache__/ 5 | .venvs/ 6 | 7 | *.egg-info/ 8 | 9 | .venv 10 | *.pyc 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | cache: pip 4 | 5 | jobs: 6 | include: 7 | - stage: lint 8 | python: "3.8" 9 | install: pip install mypy black flake8 10 | script: 11 | - black --check . 12 | - mypy src 13 | - flake8 src tests 14 | - stage: package 15 | python: "3.8" 16 | install: pip install setl 17 | script: setl publish --no-upload 18 | allow_failures: 19 | - python: "nightly" 20 | 21 | # python: 22 | # - "3.8" 23 | # - "3.7" 24 | # - "nightly" 25 | 26 | # install: 27 | # - pip install setl 28 | # - setl install 29 | 30 | # script: pytest tests 31 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ==== 2 | Setl 3 | ==== 4 | 5 | .. image:: https://travis-ci.com/uranusjr/setl.svg?branch=master 6 | :target: https://travis-ci.com/uranusjr/setl 7 | :alt: Travis CI Status 8 | 9 | .. image:: https://readthedocs.org/projects/setl/badge/?version=latest 10 | :target: https://setl.readthedocs.io/en/latest/?badge=latest 11 | :alt: Documentation Status 12 | 13 | Setl (pronounced like *settle*) is a simple way to work with PEP 518 projects 14 | with Setuptools as the backend. 15 | 16 | The interface is strongly influenced by Flit_. 17 | 18 | .. _Flit: https://flit.readthedocs.io/en/latest/ 19 | 20 | 21 | Install 22 | ======= 23 | 24 | The recommended install method is pipx_:: 25 | 26 | pipx install setl 27 | 28 | .. _pipx: https://pipxproject.github.io/pipx/ 29 | 30 | Setl needs to be installed with Python 3.7 or later, but **can be used to build 31 | projects using older Python** with the ``--python`` option. 32 | 33 | 34 | Quickstart for Setuptools Veterans 35 | ================================== 36 | 37 | Aside from the usual Setuptools configurations, you need to create a file 38 | ``pyproject.toml`` beside ``setup.py``, with the following content:: 39 | 40 | [build-system] 41 | requires = ["setuptools>=43", "wheel"] 42 | 43 | Command comparisons to Setuptools: 44 | 45 | +------------------+-------------------------------------------------+ 46 | | Setl | Setuptools approximation | 47 | +==================+=================================================+ 48 | | ``setl develop`` | ``setup.py develop`` | 49 | +------------------+-------------------------------------------------+ 50 | | ``setl build`` | ``setup.py egg_info build`` | 51 | +------------------+-------------------------------------------------+ 52 | | ``setl publish`` | | ``setup.py egg_info build sdist bdist_wheel`` | 53 | | | | ``twine upload`` | 54 | +------------------+-------------------------------------------------+ 55 | 56 | 57 | But Why? 58 | ======== 59 | 60 | The main difference is how build and runtime dependencies are installed. 61 | 62 | Traditionally Setuptools projects use ``setup_requires``, but that has 63 | `various problems `__ and is 64 | discouraged in favour of using PEP 518 to specify build time dependencies 65 | instead. But Setuptools's project management commands do not handle PEP 518 66 | declarations, leaving the user to install those build dependencies manually 67 | before using ``setup.py``. Setl commands mimic pip's build setup before calling 68 | their ``setup.py`` counterparts, so the build environment stays up-to-date. 69 | 70 | Similarly, ``setup.py develop`` installs *runtime* dependencies with 71 | ``easy_install``, instead of pip. It therefore does not respect PEP 518 72 | declarations in those dependencies, and may even fail if one of the 73 | dependencies does not support the "legacy mode" build process. 74 | ``setl develop`` works around this by ``pip install``-ing runtime dependencies 75 | before calling ``setup.py develop --no-deps``, so dependencies are installed 76 | in the modern format. 77 | 78 | The rest are more about providing more useful defaults. It is easy to forget 79 | to re-build ``egg-info`` when you modify metadata, so Setl tries to be 80 | helpful. Nowadays people almost always want to build both sdist and wheel, so 81 | Setl does it by default. The PyPA recommends against using ``setup.py upload``, 82 | so Setl bundles Twine for uploading instead. Nothing rocket science. 83 | 84 | 85 | Next Steps 86 | ========== 87 | 88 | * Read the documentation_ for detailed command descriptions and inner workings. 89 | * View the source_ and help contribute to the project. 90 | 91 | .. _documentation: https://setl.readthedocs.io 92 | .. _source: https://github.com/uranusjr/setl 93 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | .[docs] 2 | -------------------------------------------------------------------------------- /docs/source/cmds.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | Commands 3 | ======== 4 | 5 | The Global ``--python`` Option 6 | ============================== 7 | 8 | .. argparse:: 9 | :ref: setl.cmds.get_parser 10 | :prog: setl 11 | :nodefault: 12 | :nosubcommands: 13 | 14 | Setl is able to run "out of environment," i.e. does not need to be installed 15 | into the same environment that builds the package. The interpreter is instead 16 | passed in by the ``--python`` command. For example, the following command 17 | builds the project against the command ``python2.7``:: 18 | 19 | sefl --python=python2.7 build 20 | 21 | The ``--python`` option accepts one of the followings: 22 | 23 | * Absolute or relative path to a Python executable. 24 | * Python command (``shutil.which`` is used to resolve). 25 | * Python version specifier (the `Python launcher`_ is used to resolve). 26 | 27 | .. _`Python launcher`: https://www.python.org/dev/peps/pep-0397/ 28 | 29 | 30 | Default Heuristic 31 | ----------------- 32 | 33 | Setl tries to do the right thing if no ``--python`` value is explicitly given. 34 | It looks for the following places, in this order: 35 | 36 | * A non-empty environment variable ``SETL_PYTHON`` (interpreted with the same 37 | rules as ``--python``). 38 | * If Setl is run in an activated virtual environment context, use that 39 | active environment's interpreter. (Setl detects the ``VIRTUAL_ENV`` 40 | environment variable.) 41 | * If Setl is *installed* inside a virtual environment, use the interpreter it 42 | is installed in (i.e. ``sys.executable``). 43 | 44 | If all of the above checks fail, Setl will require an explicit ``--python`` 45 | value, or otherwise error out. 46 | 47 | .. note:: The specified Python needs to have pip available. 48 | 49 | 50 | Build Files 51 | =========== 52 | 53 | .. argparse:: 54 | :ref: setl.cmds.get_parser 55 | :prog: setl 56 | :path: build 57 | 58 | Most of the flags have a direct ``setup.py build_*`` counterpart. ``--info`` 59 | corresponds to ``setup.py egg_info``. 60 | 61 | If no flags are passed, Setl will run ``setup.py egg_info build`` to go through 62 | all the build steps. 63 | 64 | 65 | Install for Development 66 | ======================= 67 | 68 | .. argparse:: 69 | :ref: setl.cmds.get_parser 70 | :prog: setl 71 | :path: develop 72 | 73 | Behaves very much like `setup.py develop`. 74 | 75 | 76 | Build and Publish Distributions 77 | =============================== 78 | 79 | .. argparse:: 80 | :ref: setl.cmds.get_parser 81 | :prog: setl 82 | :path: publish 83 | :nodefault: 84 | 85 | Builds distribution packages, and uploads them to a repository (package index). 86 | The default is to upload to PyPI. Use the repository flags to change. For 87 | example, this uploads the files to TestPyPI instead:: 88 | 89 | setl publish --repository-url https://test.pypi.org/legacy/ 90 | 91 | Repository options are passed directly to Twine. 92 | 93 | 94 | Clean up Built Files 95 | ==================== 96 | 97 | .. argparse:: 98 | :ref: setl.cmds.get_parser 99 | :prog: setl 100 | :path: clean 101 | 102 | Unlike ``setup.py clean``, this cleans up *all* the built files (except the 103 | generated distributions). The in-tree ``.egg-info`` files associated to the 104 | package is also removed. 105 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | # import os 14 | # import sys 15 | # sys.path.insert(0, os.path.abspath('.')) 16 | 17 | 18 | # -- Project information ----------------------------------------------------- 19 | 20 | project = "Setl" 21 | copyright = "2019, Tzu-ping Chung" 22 | author = "Tzu-ping Chung" 23 | 24 | 25 | # -- General configuration --------------------------------------------------- 26 | 27 | # Add any Sphinx extension module names here, as strings. They can be 28 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 29 | # ones. 30 | extensions = ["sphinxarg.ext", "sphinx_rtd_theme"] 31 | 32 | # Add any paths that contain templates here, relative to this directory. 33 | templates_path = ["_templates"] 34 | 35 | # List of patterns, relative to source directory, that match files and 36 | # directories to ignore when looking for source files. 37 | # This pattern also affects html_static_path and html_extra_path. 38 | exclude_patterns = [] 39 | 40 | master_doc = "index" 41 | 42 | 43 | # -- Options for HTML output ------------------------------------------------- 44 | 45 | # The theme to use for HTML and HTML Help pages. See the documentation for 46 | # a list of builtin themes. 47 | # 48 | html_theme = "sphinx_rtd_theme" 49 | 50 | # Add any paths that contain custom static files (such as style sheets) here, 51 | # relative to this directory. They are copied after the builtin static files, 52 | # so a file named "default.css" will overwrite the builtin "default.css". 53 | html_static_path = ["_static"] 54 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../../README.rst 2 | 3 | .. toctree:: 4 | :maxdepth: 2 5 | 6 | cmds 7 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | python_version = 3.7 3 | warn_unused_configs = true 4 | 5 | [mypy-cached_property.*] 6 | ignore_missing_imports = true 7 | 8 | [mypy-packaging.*] 9 | ignore_missing_imports = true 10 | 11 | [mypy-pep517.*] 12 | ignore_missing_imports = true 13 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=39.2.0", "wheel"] 3 | 4 | [tool.black] 5 | line-length = 79 6 | target-version = ["py37"] 7 | include = '^/(docs|src|tests)/.+\.py$' 8 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = setl 3 | version = attr: setl.__version__ 4 | description = Packaging tool for PEP 518 projects with Setuptools backend. 5 | long_description = file: README.rst 6 | long_description_content_type = text/x-rst 7 | classifiers = 8 | Development Status :: 4 - Beta 9 | Environment :: Console 10 | Intended Audience :: Developers 11 | License :: OSI Approved :: ISC License (ISCL) 12 | Operating System :: OS Independent 13 | Programming Language :: Python :: 3 :: Only 14 | Programming Language :: Python :: 3.7 15 | Programming Language :: Python :: 3.8 16 | Programming Language :: Python :: 3.9 17 | Topic :: Software Development :: Build Tools 18 | Topic :: System :: Archiving :: Packaging 19 | Topic :: System :: Software Distribution 20 | Topic :: Utilities 21 | 22 | license = ISC 23 | url = https://github.com/uranusjr/setl 24 | 25 | project_urls = 26 | Documentation = https://setl.readthedocs.io 27 | CI = https://travis-ci.com/uranusjr/setl 28 | 29 | author = Tzu-ping Chung 30 | author_email = uranusjr@gmail.com 31 | 32 | [options] 33 | package_dir = 34 | = src 35 | packages = find: 36 | 37 | python_requires = >=3.7 38 | install_requires = 39 | cached_property 40 | packaging 41 | pep517 42 | toml 43 | twine 44 | 45 | zip_safe = true 46 | 47 | [options.packages.find] 48 | where = src 49 | 50 | [options.extras_require] 51 | docs = 52 | sphinx 53 | sphinx-argparse 54 | sphinx-rtd-theme 55 | 56 | test = 57 | pytest 58 | 59 | [options.entry_points] 60 | console_scripts = 61 | setl = setl.__main__:main 62 | 63 | [bdist_wheel] 64 | universal = 1 65 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup() 4 | -------------------------------------------------------------------------------- /src/setl/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.10.0" 2 | -------------------------------------------------------------------------------- /src/setl/__main__.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | import sys 3 | 4 | 5 | def main(): 6 | # If we are running from a wheel or without packaging, add the package to 7 | # sys.path. I stole this technique from pip. 8 | if not __package__: 9 | sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) 10 | from setl.cmds import dispatch 11 | else: 12 | from .cmds import dispatch 13 | 14 | return dispatch(None) 15 | 16 | 17 | if __name__ == "__main__": 18 | sys.exit(main()) 19 | -------------------------------------------------------------------------------- /src/setl/_logging.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | 4 | 5 | class _PrettyFormatter(logging.Formatter): 6 | def format(self, record): 7 | message = record.getMessage() 8 | if record.levelno < logging.WARNING: 9 | return message 10 | return f"{record.levelname}: {message}" 11 | 12 | 13 | def configure_logging(level): 14 | h = logging.StreamHandler(sys.stderr) 15 | if level >= logging.INFO: 16 | f = _PrettyFormatter() 17 | else: 18 | f = logging.Formatter("%(levelname)s: %(message)s") 19 | h.setFormatter(f) 20 | logging.root.addHandler(h) 21 | logging.root.setLevel(level) 22 | -------------------------------------------------------------------------------- /src/setl/cmds/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = ["dispatch"] 2 | 3 | import argparse 4 | import dataclasses 5 | import logging 6 | import os 7 | import pathlib 8 | import shutil 9 | import sys 10 | 11 | from typing import Any, Dict, List, Optional 12 | 13 | from setl._logging import configure_logging 14 | from setl.errs import Error 15 | from setl.projects import InterpreterNotFound, Project, PyUnavailable 16 | 17 | from . import build, clean, develop, publish, setuppy 18 | 19 | 20 | logger = logging.getLogger(__name__) 21 | 22 | 23 | def _find_active_venv_python() -> Optional[pathlib.Path]: 24 | """Find Python interpreter of the currently active virtual environment. 25 | 26 | * A virtual environment should be active (via ``VIRTUAL_ENV``). 27 | * The ``python`` command should resolve into an executable inside the 28 | virtual environment. 29 | """ 30 | virtual_env = os.environ.get("VIRTUAL_ENV") 31 | if not virtual_env: 32 | return None 33 | command = shutil.which("python") 34 | if not command: 35 | return None 36 | python = pathlib.Path(command) 37 | try: 38 | py_in_env = python.relative_to(virtual_env) 39 | except ValueError: 40 | return None 41 | if pathlib.Path(virtual_env, py_in_env).samefile(python): 42 | return python 43 | return None 44 | 45 | 46 | def _find_installed_venv_python() -> Optional[pathlib.Path]: 47 | # Venv: sys.prefix should be different from sys.base_prefix. 48 | if sys.prefix != getattr(sys, "base_prefix", sys.prefix): 49 | return pathlib.Path(sys.executable) 50 | # Virtualenv: sys.real_prefix should be set. 51 | if hasattr(sys, "real_prefix"): 52 | return pathlib.Path(sys.executable) 53 | # Nothing is set, this is global. 54 | return None 55 | 56 | 57 | def _get_python_kwargs() -> Dict[str, Any]: 58 | """Additional flags for the ``--python`` option. 59 | 60 | * If the ``SETL_PYTHON`` environment variable is set, use it as default. 61 | * If setl is running in a virtual environment context, default to the 62 | environment's ``python`` command. 63 | * If setl is installed in a virtual environment context, default to the 64 | environment's interpreter (i.e. ``sys.executable``). 65 | * Otherwise require an explicit ``--python`` argument. 66 | """ 67 | default = ( 68 | os.environ.get("SETL_PYTHON") 69 | or _find_active_venv_python() 70 | or _find_installed_venv_python() 71 | ) 72 | if default: 73 | return {"default": os.fspath(default)} 74 | return {"required": True} 75 | 76 | 77 | def get_parser() -> argparse.ArgumentParser: 78 | parser = argparse.ArgumentParser() 79 | parser.add_argument( 80 | "--python", help="Target Python executable", **_get_python_kwargs(), 81 | ) 82 | 83 | subparsers = parser.add_subparsers() 84 | for sub in [build, clean, develop, publish, setuppy]: 85 | sub.get_parser(subparsers) # type: ignore 86 | 87 | return parser 88 | 89 | 90 | def _is_project_root(p: pathlib.Path) -> bool: 91 | """Check is a path marks a project's root directory. 92 | 93 | A valid project needs: 94 | 95 | * A pyproject.toml (because this is a PEP 517 tool). 96 | * Either setup.py or setup.cfg (or both) so we can invoke Setuptools. 97 | """ 98 | if not p.joinpath("pyproject.toml").is_file(): 99 | return False 100 | if p.joinpath("setup.py").is_file() or p.joinpath("setup.cfg").is_file(): 101 | return True 102 | return False 103 | 104 | 105 | @dataclasses.dataclass() 106 | class _ProjectNotFound(Exception): 107 | start: pathlib.Path 108 | 109 | 110 | def _find_project() -> Project: 111 | start = pathlib.Path.cwd() 112 | for path in start.joinpath("__placeholder__").parents: 113 | if _is_project_root(path): 114 | return Project(path) 115 | raise _ProjectNotFound(start) 116 | 117 | 118 | def dispatch(argv: Optional[List[str]]) -> int: 119 | configure_logging(logging.INFO) # TODO: Make this configurable. 120 | 121 | try: 122 | project = _find_project() 123 | except _ProjectNotFound as e: 124 | logger.error("Project not found from %s", e.start) 125 | return Error.project_not_found 126 | 127 | opts = get_parser().parse_args(argv) 128 | 129 | try: 130 | result = opts.func(project, opts) 131 | except InterpreterNotFound as e: 132 | logger.error("Not a valid interpreter: %r", e.spec) 133 | return Error.interpreter_not_found 134 | except PyUnavailable: 135 | if os.name == "nt": 136 | url = "https://docs.python.org/3/using/windows.html" 137 | else: 138 | url = "https://github.com/brettcannon/python-launcher" 139 | logger.error( 140 | "Specifying Python with version requires the Python " 141 | "Launcher\n%s", 142 | url, 143 | ) 144 | return Error.py_unavailable 145 | 146 | return result 147 | -------------------------------------------------------------------------------- /src/setl/cmds/_utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pathlib 3 | import subprocess 4 | import sys 5 | 6 | from typing import Union 7 | 8 | 9 | def twine(c: str, *args: Union[str, pathlib.Path]): 10 | cmd = [sys.executable, "-m", "twine", c, *(os.fspath(a) for a in args)] 11 | subprocess.check_call(cmd) 12 | -------------------------------------------------------------------------------- /src/setl/cmds/build.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import enum 3 | 4 | from setl.projects import Project 5 | 6 | 7 | class Step(enum.Enum): 8 | info = "egg_info" 9 | build = "build" 10 | clib = "build_clib" 11 | ext = "build_ext" 12 | py = "build_py" 13 | scripts = "build_scripts" 14 | 15 | 16 | def _handle(project: Project, options) -> int: 17 | if options.steps is None: 18 | steps = [Step.info, Step.build] 19 | else: 20 | steps = options.steps 21 | 22 | with project.ensure_build_envdir(options.python) as env: 23 | project.ensure_build_requirements(env) 24 | project.setuppy(env, *(s.value for s in steps)) 25 | 26 | return 0 27 | 28 | 29 | def get_parser(subparsers) -> argparse.ArgumentParser: 30 | parser = subparsers.add_parser("build", description="Build the package") 31 | parser.set_defaults(steps=None, func=_handle) 32 | parser.add_argument( 33 | "--info", 34 | dest="steps", 35 | action="append_const", 36 | const=Step.info, 37 | help="Build .egg-info directory", 38 | ) 39 | parser.add_argument( 40 | "--ext", 41 | dest="steps", 42 | action="append_const", 43 | const=Step.ext, 44 | help="Build extensions", 45 | ) 46 | parser.add_argument( 47 | "--py", 48 | dest="steps", 49 | action="append_const", 50 | const=Step.py, 51 | help="Build pure Python modules", 52 | ) 53 | parser.add_argument( 54 | "--clib", 55 | dest="steps", 56 | action="append_const", 57 | const=Step.clib, 58 | help="Build C libraries", 59 | ) 60 | parser.add_argument( 61 | "--scripts", 62 | dest="steps", 63 | action="append_const", 64 | const=Step.scripts, 65 | help="Build scripts", 66 | ) 67 | return parser 68 | -------------------------------------------------------------------------------- /src/setl/cmds/clean.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | 3 | from setl.projects import Project 4 | 5 | 6 | def _handle(project: Project, options) -> int: 7 | with project.ensure_build_envdir(options.python) as env: 8 | project.clean(env) 9 | return 0 10 | 11 | 12 | def get_parser(subparsers) -> argparse.ArgumentParser: 13 | parser = subparsers.add_parser( 14 | "clean", description="Clean up temporary files from build" 15 | ) 16 | parser.set_defaults(steps=None, func=_handle) 17 | return parser 18 | -------------------------------------------------------------------------------- /src/setl/cmds/develop.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | 3 | from setl.projects import Project 4 | 5 | 6 | def _handle(project: Project, options) -> int: 7 | with project.ensure_build_envdir(options.python) as env: 8 | project.ensure_build_requirements(env) 9 | project.install_for_development(env) 10 | return 0 11 | 12 | 13 | def get_parser(subparsers) -> argparse.ArgumentParser: 14 | parser = subparsers.add_parser( 15 | "develop", description="Install package in 'development mode'" 16 | ) 17 | parser.set_defaults(steps=None, func=_handle) 18 | return parser 19 | -------------------------------------------------------------------------------- /src/setl/cmds/publish.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import enum 3 | import pathlib 4 | import typing 5 | 6 | from setl.projects import Project 7 | 8 | from ._utils import twine 9 | 10 | 11 | class Step(enum.Enum): 12 | sdist = Project.build_sdist 13 | wheel = Project.build_wheel 14 | 15 | 16 | def _upload(options, targets: typing.List[pathlib.Path]): 17 | if options.repository: 18 | upload_flags = ["--repository", options.repository] 19 | elif options.repository_url: 20 | upload_flags = ["--repository-url", options.repository_url] 21 | else: 22 | upload_flags = [] 23 | twine("upload", *upload_flags, *targets) 24 | 25 | 26 | def _handle(project: Project, options) -> int: 27 | steps = options.steps 28 | if steps is None: 29 | steps = [Step.sdist, Step.wheel] 30 | 31 | with project.ensure_build_envdir(options.python) as env: 32 | project.ensure_build_requirements(env) 33 | targets = [step(project, env) for step in steps] 34 | 35 | if options.check: 36 | twine("check", *targets) 37 | 38 | if options.upload: 39 | _upload(options, targets) 40 | 41 | return 0 42 | 43 | 44 | def get_parser(subparsers) -> argparse.ArgumentParser: 45 | parser = subparsers.add_parser( 46 | "publish", description="Publish distributions to PyPI" 47 | ) 48 | parser.set_defaults(steps=None, func=_handle) 49 | parser.add_argument( 50 | "--source", 51 | dest="steps", 52 | action="append_const", 53 | const=Step.sdist, 54 | help="Publish the sdist", 55 | ) 56 | parser.add_argument( 57 | "--wheel", 58 | dest="steps", 59 | action="append_const", 60 | const=Step.wheel, 61 | help="Publish the wheel", 62 | ) 63 | parser.add_argument( 64 | "--no-check", 65 | dest="check", 66 | action="store_false", 67 | default=True, 68 | help="Do not check the distributions before upload", 69 | ) 70 | 71 | repo_group = parser.add_mutually_exclusive_group() 72 | repo_group.add_argument( 73 | "--no-upload", 74 | dest="upload", 75 | action="store_false", 76 | default=True, 77 | help="Do no upload built distributions", 78 | ) 79 | repo_group.add_argument( 80 | "--repository", 81 | metavar="NAME", 82 | default=None, 83 | help="Repository declared in the config file to upload to", 84 | ) 85 | repo_group.add_argument( 86 | "--repository-url", 87 | metavar="URL", 88 | default=None, 89 | help="Repository URL to upload to", 90 | ) 91 | 92 | return parser 93 | -------------------------------------------------------------------------------- /src/setl/cmds/setuppy.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | 3 | from setl.projects import Project 4 | 5 | 6 | def _handle(project: Project, options) -> int: 7 | with project.ensure_build_envdir(options.python) as env: 8 | proc = project.setuppy(env, *options.arguments, check=False) 9 | return proc.returncode 10 | 11 | 12 | def get_parser(subparsers) -> argparse.ArgumentParser: 13 | parser = subparsers.add_parser( 14 | "setup.py", 15 | description="Run setup.py", 16 | usage="%(prog)s setup.py [-h] [--] arg [arg ...]", 17 | epilog=( 18 | "Use two dashes (`--`) to pass flags (e.g. `--help`) to setup.py. " 19 | "If you're passing flags both to Setl and setup.py, flags before " 20 | "`--` go to Setl." 21 | ), 22 | ) 23 | parser.set_defaults(steps=None, func=_handle) 24 | parser.add_argument("arguments", metavar="arg", nargs="+") 25 | return parser 26 | -------------------------------------------------------------------------------- /src/setl/errs.py: -------------------------------------------------------------------------------- 1 | import enum 2 | 3 | 4 | class Error(enum.IntEnum): 5 | # Error code 1 is reserved because Python returns it on an uncaught 6 | # exception. We want to be more specific if we return on our own. 7 | unknown = 0x01 8 | project_not_found = 0x02 9 | 10 | # Errors resolving interpreter from `--python`. 11 | interpreter_not_found = 0x10 12 | py_unavailable = 0x11 13 | -------------------------------------------------------------------------------- /src/setl/projects/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = ["InterpreterNotFound", "Project", "PyUnavailable"] 2 | 3 | from ._envs import PyUnavailable 4 | from .base import BaseProject 5 | from .build import InterpreterNotFound, ProjectBuildManagementMixin 6 | from .clean import ProjectCleanMixin 7 | from .dev import ProjectDevelopMixin 8 | from .hook import ProjectPEP517HookCallerMixin 9 | from .meta import ProjectMetadataMixin 10 | from .setup import ProjectSetupMixin 11 | 12 | 13 | # Order is important here for Mypy; more derived mixins need to come first. 14 | class Project( 15 | ProjectCleanMixin, 16 | ProjectDevelopMixin, 17 | ProjectPEP517HookCallerMixin, 18 | ProjectBuildManagementMixin, 19 | ProjectMetadataMixin, 20 | ProjectSetupMixin, 21 | BaseProject, 22 | ): 23 | pass 24 | -------------------------------------------------------------------------------- /src/setl/projects/_envs.py: -------------------------------------------------------------------------------- 1 | # THIS FILE IS VENDORED FROM PYEM, only removing references to virtenv. 2 | # DO NOT MODIFY THIS DIRECTLY OTHERWISE. 3 | 4 | __all__ = [ 5 | "EnvironmentCreationError", 6 | "PyUnavailable", 7 | "get_interpreter_quintuplet", 8 | "resolve_python", 9 | ] 10 | 11 | import dataclasses 12 | import os 13 | import pathlib 14 | import re 15 | import shutil 16 | import subprocess 17 | import typing 18 | 19 | 20 | class PyUnavailable(Exception): 21 | pass 22 | 23 | 24 | def _get_command_output(args: typing.Sequence[str]) -> str: 25 | return subprocess.check_output(args, text=True).strip() 26 | 27 | 28 | _PY_VER_RE = re.compile( 29 | r""" 30 | ^ 31 | (\d+) # Major. 32 | (:?\.(\d+))? # Dot + Minor. 33 | (:?\-(32|64))? # Dash + either 32 or 64. 34 | $ 35 | """, 36 | re.VERBOSE, 37 | ) 38 | 39 | 40 | def _find_python_with_py(python: str) -> typing.Optional[pathlib.Path]: 41 | py = shutil.which("py") 42 | if not py: 43 | raise PyUnavailable() 44 | code = "import sys; print(sys.executable)" 45 | try: 46 | output = _get_command_output([py, f"-{python}", "-c", code]) 47 | except subprocess.CalledProcessError: 48 | return None 49 | if not output: 50 | return None 51 | return pathlib.Path(output).resolve() 52 | 53 | 54 | def resolve_python(python: str) -> typing.Optional[pathlib.Path]: 55 | if _PY_VER_RE.match(python): 56 | return _find_python_with_py(python) 57 | resolved = shutil.which(python) 58 | if resolved: 59 | return pathlib.Path(resolved) 60 | path = pathlib.Path(python) 61 | if path.is_file(): 62 | return path 63 | return None 64 | 65 | 66 | # The prefix part is adopted from Virtualenv's approach. This allows us to find 67 | # the most "base" prefix as possible, going through both virtualenv and venv 68 | # boundaries. In particular `real_prefix` must be tried first since virtualenv 69 | # does not preserve any other values. 70 | # https://github.com/pypa/virtualenv/blob/16.7.7/virtualenv.py#L1419-L1426 71 | _VENV_NAME_CODE = """ 72 | from __future__ import print_function 73 | 74 | import hashlib 75 | import sys 76 | import sysconfig 77 | import platform 78 | 79 | try: 80 | prefix = sys.real_prefix 81 | except AttributeError: 82 | try: 83 | prefix = sys.base_prefix 84 | except AttributeError: 85 | prefix = sys.prefix 86 | 87 | prefix = prefix.encode(sys.getfilesystemencoding(), "ignore") 88 | 89 | print("{impl}-{vers}-{syst}-{plat}-{hash}".format( 90 | impl=platform.python_implementation(), 91 | vers=sysconfig.get_python_version(), 92 | syst=platform.uname()[0], 93 | plat=sysconfig.get_platform().split("-")[-1], 94 | hash=hashlib.sha256(prefix).hexdigest()[:8], 95 | ).lower()) 96 | """ 97 | 98 | 99 | def get_interpreter_quintuplet(python: typing.Union[str, pathlib.Path]) -> str: 100 | """Build a unique identifier for the interpreter to place the venv. 101 | 102 | This is done by asking the interpreter to format a string containing the 103 | following parts, lowercased and joined with `-` (dash): 104 | 105 | * Python inplementation. 106 | * Python version (major.minor). 107 | * Plarform name. 108 | * Processor type. 109 | * A 8-char hash of the interpreter prefix for disambiguation. 110 | 111 | Example: `cpython-3.7-darwin-x86_64-3d3725a6`. 112 | """ 113 | return _get_command_output([os.fspath(python), "-c", _VENV_NAME_CODE]) 114 | 115 | 116 | @dataclasses.dataclass() 117 | class EnvironmentCreationError(Exception): 118 | context: Exception 119 | -------------------------------------------------------------------------------- /src/setl/projects/base.py: -------------------------------------------------------------------------------- 1 | __all__ = ["BaseProject"] 2 | 3 | import dataclasses 4 | import pathlib 5 | 6 | 7 | @dataclasses.dataclass() 8 | class BaseProject: 9 | root: pathlib.Path 10 | -------------------------------------------------------------------------------- /src/setl/projects/build.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | __all__ = ["BuildEnv", "ProjectBuildManagementMixin"] 4 | 5 | import contextlib 6 | import dataclasses 7 | import json 8 | import os 9 | import pathlib 10 | import shutil 11 | import subprocess 12 | 13 | from typing import Dict, Iterable, Iterator, Optional, Set 14 | 15 | from packaging.requirements import Requirement 16 | from packaging.utils import canonicalize_name 17 | 18 | from ._envs import get_interpreter_quintuplet, resolve_python 19 | from .meta import ProjectMetadataMixin 20 | 21 | 22 | @dataclasses.dataclass() 23 | class InterpreterNotFound(Exception): 24 | spec: str 25 | 26 | 27 | _ENV_CONTAINER_NAME = ".isoenvs" 28 | 29 | 30 | _GET_PATHS_CODE = """ 31 | from __future__ import print_function 32 | 33 | import json 34 | import os 35 | import sys 36 | import sysconfig 37 | 38 | base = sys.argv[1] 39 | print(json.dumps(sysconfig.get_paths(vars={"base": base, "platbase": base}))) 40 | """ 41 | 42 | 43 | def _get_env_paths(python: pathlib.Path, root: pathlib.Path) -> Dict[str, str]: 44 | args = [os.fspath(python), "-c", _GET_PATHS_CODE, os.fspath(root)] 45 | output = subprocess.check_output(args, text=True).strip() 46 | return json.loads(output) 47 | 48 | 49 | def _environ_path_format(*paths: Optional[str]) -> str: 50 | return os.pathsep.join(path for path in paths if path) 51 | 52 | 53 | @dataclasses.dataclass() 54 | class BuildEnv: 55 | root: pathlib.Path 56 | interpreter: pathlib.Path 57 | libdirs: Set[pathlib.Path] 58 | 59 | def __post_init__(self): 60 | self._should_delete = False 61 | 62 | def mark_for_cleanup(self): 63 | self._should_delete = True 64 | 65 | 66 | def _list_installed(env: BuildEnv) -> Dict[str, str]: 67 | """List versions of installed packages in the build environment. 68 | 69 | This only lists packages under the isolated environment prefix, NOT 70 | packages in the parent environment. 71 | 72 | Returns a `{canonical_name: version}` mapping. 73 | """ 74 | args = [ 75 | os.fspath(env.interpreter), 76 | "-m", 77 | "pip", 78 | "list", 79 | "--format=json", 80 | *(f"--path={p}" for p in env.libdirs), 81 | ] 82 | environ = os.environ.copy() 83 | environ["PIP_DISABLE_PIP_VERSION_CHECK"] = "true" 84 | output = subprocess.check_output(args, env=environ, text=True).strip() 85 | return { 86 | canonicalize_name(e["name"]): e["version"] 87 | for e in json.loads(output) 88 | if "name" in e and "version" in e 89 | } 90 | 91 | 92 | def _is_req_met(req: str, workingset: Dict[str, str]) -> bool: 93 | """Check whether a request requirement is met by given workingset. 94 | 95 | :param req: A PEP 508 requirement string. 96 | :param workingset: A `{canonical_name: version}` mapping (e.g. returned by 97 | `_list_installed`). 98 | """ 99 | r = Requirement(req) 100 | try: 101 | version = workingset[canonicalize_name(r.name)] 102 | except KeyError: 103 | return False 104 | return not r.specifier or version in r.specifier 105 | 106 | 107 | class ProjectBuildManagementMixin(ProjectMetadataMixin): 108 | @contextlib.contextmanager 109 | def ensure_build_envdir(self, spec: str) -> Iterator[BuildEnv]: 110 | """Ensure an isolated environment exists for build. 111 | 112 | Environment setup is based on ``pep517.envbuild.BuildEnvironment``, 113 | which is in turn based on pip's implementation. The difference is the 114 | environment in a non-temporary, predictable location, and not cleaned 115 | up on exit. It is actually reused across builds. 116 | 117 | :param spec: Specification of the base interpreter. 118 | :returns: A context manager to control build setup/teardown. 119 | """ 120 | # Identify the Python interpreter to use. 121 | python = resolve_python(spec) 122 | if not python: 123 | raise InterpreterNotFound(spec) 124 | 125 | # Create isolated environment. 126 | quintuplet = get_interpreter_quintuplet(python) 127 | env_dir = self.root.joinpath("build", _ENV_CONTAINER_NAME, quintuplet) 128 | env_dir.mkdir(exist_ok=True, parents=True) 129 | 130 | # Set up environment variables so PEP 517 subprocess calls can find 131 | # dependencies in the isolated environment. 132 | backenv = {k: os.environ.get(k) for k in ["PATH", "PYTHONPATH"]} 133 | paths = _get_env_paths(python, env_dir) 134 | libdirs = {paths["purelib"], paths["platlib"]} 135 | os.environ["PATH"] = _environ_path_format( 136 | paths["scripts"], backenv["PATH"] or os.defpath 137 | ) 138 | os.environ["PYTHONPATH"] = _environ_path_format( 139 | *libdirs, backenv["PYTHONPATH"] 140 | ) 141 | 142 | env = BuildEnv( 143 | root=env_dir, 144 | interpreter=python, 145 | libdirs={pathlib.Path(p) for p in libdirs}, 146 | ) 147 | yield env 148 | 149 | # Restore environment variables. 150 | for k, v in backenv.items(): 151 | if v is None: 152 | os.environ.pop(k, None) 153 | else: 154 | os.environ[k] = v 155 | 156 | if getattr(env, "_should_delete", False): 157 | shutil.rmtree(env.root) 158 | 159 | def install_build_requirements(self, env: BuildEnv, reqs: Iterable[str]): 160 | reqs = [r for r in reqs if not _is_req_met(r, _list_installed(env))] 161 | if not reqs: 162 | return 163 | args = [ 164 | os.fspath(env.interpreter), 165 | "-m", 166 | "pip", 167 | "install", 168 | "--ignore-installed", 169 | "--prefix", 170 | os.fspath(env.root), 171 | *reqs, 172 | ] 173 | subprocess.check_call(args) 174 | 175 | def ensure_build_requirements(self, env: BuildEnv): 176 | """Ensure the given environment has build requirements populated. 177 | """ 178 | self.install_build_requirements(env, self.build_requirements) 179 | # TODO: We might need to install things to build for development? 180 | # PEP 517 does not cover this yet, so we just do nothing for now. 181 | -------------------------------------------------------------------------------- /src/setl/projects/clean.py: -------------------------------------------------------------------------------- 1 | __all__ = ["ProjectCleanMixin"] 2 | 3 | import json 4 | import os 5 | import shutil 6 | import subprocess 7 | 8 | from typing import Dict, Iterator, List, Optional 9 | 10 | from packaging.utils import canonicalize_name 11 | 12 | from .build import BuildEnv 13 | from .dev import ProjectDevelopMixin 14 | 15 | 16 | def _get_name(f: Iterator[str]) -> Optional[str]: 17 | for line in f: 18 | if ":" not in line: # End of metadata. 19 | break 20 | k, v = line.strip().split(":", 1) 21 | if k.lower() == "name": 22 | return v.strip() 23 | return None 24 | 25 | 26 | def _iter_egg_infos(entries: List[Dict[str, str]], name: str) -> Iterator[str]: 27 | for entry in entries: 28 | if name != canonicalize_name(entry["name"]): 29 | continue 30 | location = entry["location"] 31 | for filename in os.listdir(location): 32 | stem, ext = os.path.splitext(filename) 33 | if canonicalize_name(stem) == name and ext == ".egg-info": 34 | yield os.path.join(location, filename) 35 | 36 | 37 | class ProjectCleanMixin(ProjectDevelopMixin): 38 | def _clean_egg_info(self, env: BuildEnv): 39 | name = _get_name(self.iter_metadata_for_development(env)) 40 | if not name: 41 | return 42 | 43 | args = [ 44 | os.fspath(env.interpreter), 45 | "-m", 46 | "pip", 47 | "list", 48 | "--format=json", 49 | "--editable", 50 | "--verbose", # Needed for the "location" field. (pypa/pip#7664) 51 | ] 52 | output = subprocess.check_output(args, text=True) 53 | entries = json.loads(output) 54 | for path in _iter_egg_infos(entries, canonicalize_name(name)): 55 | shutil.rmtree(path) 56 | 57 | def clean(self, env: BuildEnv): 58 | # Clean up the built .egg-info. This is probably a good idea. 59 | # https://github.com/pypa/setuptools/issues/1347 60 | self._clean_egg_info(env) 61 | 62 | self.setuppy(env, "clean", "--all") 63 | 64 | # We don't just clean up here because the build environment is still 65 | # active, and the caller might want to use it after the call. Set a 66 | # flag so it is deleted when the context exits instead. 67 | env.mark_for_cleanup() 68 | -------------------------------------------------------------------------------- /src/setl/projects/dev.py: -------------------------------------------------------------------------------- 1 | __all__ = ["ProjectDevelopMixin"] 2 | 3 | import os 4 | import subprocess 5 | 6 | from typing import Collection, Iterable, Iterator, Optional 7 | 8 | import packaging.markers 9 | import packaging.requirements 10 | 11 | from .build import BuildEnv 12 | from .hook import ProjectPEP517HookCallerMixin 13 | from .setup import ProjectSetupMixin 14 | 15 | 16 | def _evaluate_marker( 17 | marker: Optional[packaging.markers.Marker], extras: Collection[str] 18 | ) -> bool: 19 | if not marker: 20 | return True 21 | if marker.evaluate({"extra": ""}): 22 | return True 23 | return any(marker.evaluate({"extra": e}) for e in extras) 24 | 25 | 26 | def _iter_requirements( 27 | f: Iterator[str], key: str, extras: Collection[str] 28 | ) -> Iterator[str]: 29 | """Hand-rolled implementation to read ``*.dist-info/METADATA``. 30 | 31 | I don't want to pull in distlib for this (it's not even good at this). The 32 | wheel format is quite well-documented anyway. This is almost too simple 33 | and I'm quite sure I'm missing edge cases, but let's fix them when needed. 34 | """ 35 | key = key.lower() 36 | for line in f: 37 | if ":" not in line: # End of metadata. 38 | break 39 | k, v = line.strip().split(":", 1) 40 | if k.lower() != key: 41 | continue 42 | try: 43 | r = packaging.requirements.Requirement(v) 44 | except ValueError: 45 | continue 46 | if not r.marker or _evaluate_marker(r.marker, extras): 47 | yield v 48 | 49 | 50 | class ProjectDevelopMixin(ProjectPEP517HookCallerMixin, ProjectSetupMixin): 51 | def iter_metadata_for_development(self, env: BuildEnv) -> Iterator[str]: 52 | """Generate metadata for development install. 53 | 54 | Since PEP 517 does not cover this yet, we fall back to use wheel 55 | metadata instead. This is good enough because we only use this for 56 | one of the followings: 57 | 58 | * Requires-Dist 59 | * Name 60 | 61 | Please keep the list updated if you call this function. 62 | 63 | Generated metadata are stored in the build environment, so it is more 64 | easily ignored and cleaned up. 65 | """ 66 | requirements = self.hooks.get_requires_for_build_wheel() 67 | self.install_build_requirements(env, requirements) 68 | 69 | container = env.root.joinpath("setl-wheel-metadata") 70 | container.mkdir(parents=True, exist_ok=True) 71 | target = self.hooks.prepare_metadata_for_build_wheel(container) 72 | with container.joinpath(target, "METADATA").open(encoding="utf8") as f: 73 | yield from f 74 | 75 | def install_run_requirements(self, env: BuildEnv, reqs: Iterable[str]): 76 | if not reqs: 77 | return 78 | args = [os.fspath(env.interpreter), "-m", "pip", "install", *reqs] 79 | subprocess.check_call(args) 80 | 81 | def install_for_development(self, env: BuildEnv): 82 | """Install the project for development. 83 | 84 | This is a mis-mash between `setup.py develop` and `pip install -e .` 85 | because we want to have the best of both worlds. Setuptools installs 86 | egg-info distributions, which is less than ideal. pip, on the other 87 | hand, does not let us reuse our own build environment, and also 88 | creates ``pip-wheel-metadata`` ("fixed" in pip 20.0, but still). 89 | 90 | Our own solution... 91 | 92 | 1. Installs build requirements (see next step). 93 | 2. Build metadata to know what run-time requirements this project has. 94 | 3. Install run-time requirements with pip, so they are installed as 95 | modern distributions (dist-info). 96 | 4. Call `setup.py develop --no-deps` so we install the package itself 97 | without pip machinery. 98 | """ 99 | metadata = self.iter_metadata_for_development(env) 100 | requirements = _iter_requirements(metadata, "requires-dist", []) 101 | self.install_run_requirements(env, requirements) 102 | self.setuppy(env, "develop", "--no-deps") 103 | -------------------------------------------------------------------------------- /src/setl/projects/hook.py: -------------------------------------------------------------------------------- 1 | __all__ = ["ProjectPEP517HookCallerMixin"] 2 | 3 | import logging 4 | import pathlib 5 | import re 6 | 7 | import cached_property 8 | import pep517.wrappers 9 | 10 | from .build import BuildEnv, ProjectBuildManagementMixin 11 | from .meta import ProjectMetadataMixin 12 | 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | PYPROJECT_TOML_PATTERN = re.compile(r"^[^/]+/pyproject\.toml$") 17 | 18 | 19 | class ProjectPEP517HookCallerMixin( 20 | ProjectBuildManagementMixin, ProjectMetadataMixin 21 | ): 22 | @cached_property.cached_property 23 | def hooks(self) -> pep517.wrappers.Pep517HookCaller: 24 | return pep517.wrappers.Pep517HookCaller( 25 | self.root, 26 | self.build_backend, 27 | self.backend_path, 28 | runner=pep517.wrappers.quiet_subprocess_runner, 29 | ) 30 | 31 | def build_sdist(self, env: BuildEnv) -> pathlib.Path: 32 | requirements = self.hooks.get_requires_for_build_sdist() 33 | self.install_build_requirements(env, requirements) 34 | target = self.hooks.build_sdist(self.root.joinpath("dist")) 35 | return self.root.joinpath("dist", target) 36 | 37 | def build_wheel(self, env: BuildEnv) -> pathlib.Path: 38 | requirements = self.hooks.get_requires_for_build_wheel() 39 | self.install_build_requirements(env, requirements) 40 | target = self.hooks.build_wheel(self.root.joinpath("dist")) 41 | return self.root.joinpath("dist", target) 42 | -------------------------------------------------------------------------------- /src/setl/projects/meta.py: -------------------------------------------------------------------------------- 1 | __all__ = ["ProjectMetadataMixin"] 2 | 3 | from typing import Any, Dict, List, Optional 4 | 5 | import cached_property 6 | import toml 7 | 8 | from .base import BaseProject 9 | 10 | 11 | _DEFAULT_BUILD_REQUIREMENTS = ["setuptools", "wheel"] 12 | _DEFAULT_BUILD_BACKEND = "setuptools.build_meta:__legacy__" 13 | _DEFAULT_BUILD_SYSTEM = { 14 | "requires": _DEFAULT_BUILD_REQUIREMENTS, 15 | "build-backend": _DEFAULT_BUILD_BACKEND, 16 | } 17 | 18 | 19 | class ProjectMetadataMixin(BaseProject): 20 | @cached_property.cached_property 21 | def _build_system(self) -> Dict[str, Any]: 22 | with self.root.joinpath("pyproject.toml").open() as f: 23 | data = toml.load(f) 24 | try: 25 | build_system = data["build-system"] 26 | except KeyError: 27 | return _DEFAULT_BUILD_SYSTEM 28 | if "build-backend" not in build_system: 29 | return _DEFAULT_BUILD_SYSTEM 30 | return build_system 31 | 32 | @property 33 | def build_requirements(self) -> List[str]: 34 | return self._build_system.get("requires", _DEFAULT_BUILD_REQUIREMENTS) 35 | 36 | @property 37 | def build_backend(self) -> str: 38 | return self._build_system.get("build-backend", _DEFAULT_BUILD_BACKEND) 39 | 40 | @property 41 | def backend_path(self) -> Optional[str]: 42 | return self._build_system.get("backend-path") 43 | -------------------------------------------------------------------------------- /src/setl/projects/setup.py: -------------------------------------------------------------------------------- 1 | __all__ = ["ProjectSetupMixin"] 2 | 3 | import os 4 | import pathlib 5 | import subprocess 6 | 7 | from typing import Sequence 8 | 9 | from .base import BaseProject 10 | from .build import BuildEnv 11 | 12 | 13 | def _get_setuppy_args(root: pathlib.Path) -> Sequence[str]: 14 | """Get an entry point to invoke Setuptools. 15 | 16 | * If there's a setup.py file, just use it. 17 | * If setup.py does not exist, but setup.cfg does. This is a more "modern" 18 | setup; invoke Setuptools with a stub and let Setuptools do the rest. 19 | """ 20 | if root.joinpath("setup.py").exists(): 21 | return ["setup.py"] 22 | if not root.joinpath("setup.cfg").is_file(): 23 | raise FileNotFoundError("setup.py") 24 | return ["-c", "from setuptools import setup; setup()"] 25 | 26 | 27 | class ProjectSetupMixin(BaseProject): 28 | def setuppy( 29 | self, env: BuildEnv, *args: str, check: bool = True 30 | ) -> subprocess.CompletedProcess: 31 | cmd = [ 32 | os.fspath(env.interpreter), 33 | *_get_setuppy_args(self.root), 34 | *args, 35 | ] 36 | return subprocess.run(cmd, cwd=self.root, check=check) 37 | --------------------------------------------------------------------------------