pax_global_header00006660000000000000000000000064151674144230014521gustar00rootroot0000000000000052 comment=6bb75a27a5177825e4e178a8f02421802c654b40 editables-0.6/000077500000000000000000000000001516741442300133225ustar00rootroot00000000000000editables-0.6/.github/000077500000000000000000000000001516741442300146625ustar00rootroot00000000000000editables-0.6/.github/workflows/000077500000000000000000000000001516741442300167175ustar00rootroot00000000000000editables-0.6/.github/workflows/pre-commit.yml000066400000000000000000000011301516741442300215110ustar00rootroot00000000000000# per https://github.com/pre-commit/action name: pre-commit on: push: branches: [main] pull_request: jobs: pre-commit: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: actions/setup-python@v6 - name: set PY run: echo "PY=$(python -c 'import hashlib, sys;print(hashlib.sha256(sys.version.encode()+sys.executable.encode()).hexdigest())')" >>$GITHUB_ENV - uses: actions/cache@v5 with: path: ~/.cache/pre-commit key: pre-commit|${{ env.PY }}|${{ hashFiles('.pre-commit-config.yaml') }} - uses: pre-commit/action@v3.0.1 editables-0.6/.github/workflows/publish.yml000066400000000000000000000024541516741442300211150ustar00rootroot00000000000000name: Publish Python 🐍 distribution 📦 to PyPI and TestPyPI on: push: tags: - "*" jobs: build: name: Build distribution 📦 runs-on: ubuntu-latest steps: - uses: actions/checkout@v6.0.2 - name: Set up Python uses: actions/setup-python@v6.2.0 with: python-version: "3.x" - name: Install pypa/build run: >- python3 -m pip install build --user - name: Build a binary wheel and a source tarball run: python3 -m build - name: Store the distribution packages uses: actions/upload-artifact@v6.0.0 with: name: python-package-distributions path: dist/ publish-to-pypi: name: >- Publish Python 🐍 distribution 📦 to PyPI if: startsWith(github.ref, 'refs/tags/') # only publish to PyPI on tag pushes needs: - build runs-on: ubuntu-latest environment: name: pypi url: https://pypi.org/project/${{ github.ref_name }} permissions: id-token: write # IMPORTANT: mandatory for trusted publishing steps: - name: Download all the dists uses: actions/download-artifact@v7.0.0 with: name: python-package-distributions path: dist/ - name: Publish distribution 📦 to PyPI uses: pypa/gh-action-pypi-publish@v1.13.0 editables-0.6/.github/workflows/tests.yml000066400000000000000000000013451516741442300206070ustar00rootroot00000000000000name: Tests on: push: branches: [main] pull_request: jobs: test: name: ${{ matrix.os }}-Python${{ matrix.py }} runs-on: ${{ matrix.os }}-latest strategy: matrix: os: - Ubuntu - Windows # - MacOS py: - "3.10" - "3.11" - "3.12" - "3.13" - "3.14" steps: - uses: actions/checkout@v6 - name: Set up Python ${{ matrix.py }} uses: actions/setup-python@v6 with: python-version: ${{ matrix.py }} # Get the latest nox - name: Install nox run: python -m pip install nox # Main check - name: Run test suite for ${{ matrix.os }}-Python${{ matrix.py }} run: python -m nox -s tests editables-0.6/.gitignore000066400000000000000000000007731516741442300153210ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # Distribution / packaging /build/ /dist/ *.egg *.eggs *.egg-info/ MANIFEST # Documentation docs/build/ # mypy .mypy_cache/ # Unit test / coverage reports .[nt]ox/ htmlcov/ .coverage .coverage.* .*cache nosetests.xml coverage.xml *.cover tests/data/common_wheels/ # Misc *~ .*.sw? .env/ # For IntelliJ IDEs (basically PyCharm) .idea/ # For Visual Studio Code .vscode/ # Scratch Pad for experiments .scratch/ # Mac .DS_Store editables-0.6/.pre-commit-config.yaml000066400000000000000000000013301516741442300176000ustar00rootroot00000000000000repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 hooks: - id: check-added-large-files - id: check-builtin-literals - id: check-case-conflict - id: check-toml - id: check-yaml - id: debug-statements - id: end-of-file-fixer - id: forbid-new-submodules - id: trailing-whitespace - repo: https://github.com/psf/black rev: 26.3.1 hooks: - id: black - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. rev: v0.15.9 hooks: - id: ruff - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.20.0 hooks: - id: mypy files: src args: ["--pretty", "--show-error-codes"] additional_dependencies: [ 'nox', 'pytest', ] editables-0.6/.readthedocs.yaml000066400000000000000000000005601516741442300165520ustar00rootroot00000000000000version: 2 # Set the OS, Python version and other tools you might need build: os: ubuntu-lts-latest tools: python: latest # Optional but recommended, declare the Python requirements required # to build your documentation # See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html python: install: - requirements: docs/requirements.txt editables-0.6/CHANGELOG.md000066400000000000000000000020261516741442300151330ustar00rootroot00000000000000# Changes ## Release 0.6 * Add a new "self_replace" strategy for `map` (and name the old strategy "import_hook"). Based on an idea by Daniel Tang in [#40](https://github.com/pfmoore/editables/pull/40). * Rename the generated `.pth` file to `_editable_impl.pth` and document that it is possible to customise the file names used. * Rework the documentataion, replacing the "use cases" section with an expanded and less opinionated "scope" section. * Test suite improvements. ## Release 0.5 * Fix a bug that broke `importlib.invalidate_caches` ## Release 0.4 * Add a new `add_to_subpackage` method. * Add type annotations. * Internal admin: Switch to nox for automation * Internal admin: Switch to ruff for linting * Internal admin: Switch from setuptools to flit_core ## Release 0.3 * Add documentation * Validate and normalise project names * Change: bootstrap file is now named `_editable_impl_.py` * Drop support for Python 3.6 * Add minimal release automation to update the version number * Add this changelog editables-0.6/LICENSE.txt000066400000000000000000000020361516741442300151460ustar00rootroot00000000000000Copyright (c) 2020 Paul Moore Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. editables-0.6/README.md000066400000000000000000000036151516741442300146060ustar00rootroot00000000000000# A Python library for creating "editable wheels" This library supports the building of wheels which, when installed, will expose packages in a local directory on `sys.path` in "editable mode". In other words, changes to the package source will be reflected in the package visible to Python, without needing a reinstall. ## Usage Suppose you want to build a wheel for your project `foo`. Your project is located in the directory `/path/to/foo`. Under that directory, you have a `src` directory containing your project, which is a package called `foo` and a Python module called `bar.py`. So your directory structure looks like this: ``` /path/to/foo | +-- src | +-- foo | | +-- __init__.py | +-- bar.py | +-- setup.py +-- other files ``` Build your wheel as follows: ```python from editables import EditableProject my_project = EditableProject("foo", "/path/to/foo") my_project.add_to_path("src") # Build a wheel however you prefer... wheel = BuildAWheel() # Add files to the wheel for name, content in my_project.files(): wheel.add_file(name, content) # Record any runtime dependencies for dep in my_project.dependencies(): wheel.metadata.dependencies.add(dep) ``` The resulting wheel will, when installed, put the project `src` directory on `sys.path` so that editing the original source will take effect without needing a reinstall (i.e., as "editable" packages). The project is exposed on `sys.path` by adding a single `.pth` file, named after the project, into the wheel. For more details, including how to control what gets exposed more precisely, see [the documentation](https://editables.readthedocs.io/en/latest/). Note that this project doesn't build wheels directly. That's the responsibility of the calling code. ## Python Compatibility This project supports the same versions of Python as pip does. Currently that is Python 3.7 and later, and PyPy3 (although we don't test against PyPy). editables-0.6/bump_release.py000066400000000000000000000023431516741442300163410ustar00rootroot00000000000000# Very hacky script to bump the version when cutting a new release. # # This does the following: # # 1. Edit the version number in the 3 places in the code it's specified # 2. Commit the change to git # 3. Tag the commit with the version # # Note - this assumes that you will *immediately* release the new version, # if you make changes before doing so, the tag will not match the release. import re import subprocess import sys from pathlib import Path new_version = sys.argv[1] files = [ ("src/editables/__init__.py", r'^__version__ = "(\d+\.\d+)"$'), ("pyproject.toml", r'^version = "(\d+\.\d+)"$'), ("docs/source/conf.py", r'^release = "(\d+\.\d+)"$'), ] def repl(m): # Replace group 1 with new_version return ( m.group(0)[: m.start(1) - m.start(0)] + new_version + m.group(0)[m.end(1) - m.start(0) :] ) for file, regex in files: file = Path(file) content = file.read_text(encoding="utf-8") new_content = re.sub(regex, repl, content, flags=re.MULTILINE) file.write_text(new_content, encoding="utf-8") subprocess.run(["git", "add"] + [f for f, re in files]) subprocess.run(["git", "commit", "-m", f"Bump version to {new_version}"]) subprocess.run(["git", "tag", new_version]) editables-0.6/docs/000077500000000000000000000000001516741442300142525ustar00rootroot00000000000000editables-0.6/docs/Makefile000066400000000000000000000011761516741442300157170ustar00rootroot00000000000000# Minimal makefile for Sphinx documentation # # You can set these variables from the command line, and also # from the environment for the first two. SPHINXOPTS ?= SPHINXBUILD ?= sphinx-build SOURCEDIR = source BUILDDIR = build # Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) .PHONY: help Makefile # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) editables-0.6/docs/make.bat000066400000000000000000000013741516741442300156640ustar00rootroot00000000000000@ECHO OFF pushd %~dp0 REM Command file for Sphinx documentation if "%SPHINXBUILD%" == "" ( set SPHINXBUILD=sphinx-build ) set SOURCEDIR=source set BUILDDIR=build if "%1" == "" goto help %SPHINXBUILD% >NUL 2>NUL if errorlevel 9009 ( echo. echo.The 'sphinx-build' command was not found. Make sure you have Sphinx echo.installed, then set the SPHINXBUILD environment variable to point echo.to the full path of the 'sphinx-build' executable. Alternatively you echo.may add the Sphinx directory to PATH. echo. echo.If you don't have Sphinx installed, grab it from echo.http://sphinx-doc.org/ exit /b 1 ) %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% goto end :help %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% :end popd editables-0.6/docs/requirements.txt000066400000000000000000000000301516741442300175270ustar00rootroot00000000000000sphinx myst_parser furo editables-0.6/docs/source/000077500000000000000000000000001516741442300155525ustar00rootroot00000000000000editables-0.6/docs/source/conf.py000066400000000000000000000035551516741442300170610ustar00rootroot00000000000000# Configuration file for the Sphinx documentation builder. # # This file only contains a selection of the most common options. For a full # list see the documentation: # https://www.sphinx-doc.org/en/master/usage/configuration.html # -- Path setup -------------------------------------------------------------- # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # # import os # import sys # sys.path.insert(0, os.path.abspath('.')) # -- Project information ----------------------------------------------------- project = "editables" copyright = "2021, Paul Moore" author = "Paul Moore" # The full version, including alpha/beta/rc tags release = "0.6" # -- General configuration --------------------------------------------------- # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ "myst_parser", ] # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path. exclude_patterns = [] # -- Options for HTML output ------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # html_theme = "furo" # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ["_static"] editables-0.6/docs/source/implementation.md000066400000000000000000000174231516741442300211300ustar00rootroot00000000000000# Implementation Details The key feature of a project that is installed in "editable mode" is that the code for the project remains in the project's working directory, and what gets installed into the user's Python installation is simply a "pointer" to that code. The implication of this is that the user can continue to edit the project source, and expect to see the changes reflected immediately in the Python interpreter, without needing to reinstall. The exact details of how such a "pointer" works, and indeed precisely how much of the project is exposed to Python, are generally considered to be implementation details, and users should not concern themselves too much with how things work "under the hood". However, there are practical implications which users of this library (typically build backend developers) should be aware of. The basic import machinery in Python works by scanning a list of directories recorded in `sys.path` and looking for Python modules and packages in these directories. (There's a *lot* more complexity behind the scenes, and interested readers are directed to [the Python documentation](https://docs.python.org) for more details). The initial value of `sys.path` is set by the interpreter, but there are various ways of influencing this. As part of startup, Python checks various "site directories" on `sys.path` for files called `*.pth`. In their simplest form, `.pth` files contain a list of directory names, which are *added* to `sys.path`. In addition, for more advanced cases, `.pth` files can also run executable code (typically, to set up import hooks to further configure the import machinery). ## Editables using `.pth` entries The simplest way of setting up an editable project is to install a `.pth` file containing a single line specifying the project directory. This will cause the project directory to be added to `sys.path` at interpreter startup, making it available to Python in "editable" form. This is the approach which has been used by setuptools for many years, as part of the `setup.py develop` command, and subsequently exposed by pip under the name "editable installs", via the command `pip install --editable `. In general, this is an extremely effective and low-cost approach to implementing editable installs. It does, however, have one major disadvantage, in that it does *not* necessarily expose the same packages as a normal install would do. If the project is not laid out with this in mind, an editable install may expose importable files that were not intended. For example, if the project root directory is added directly to the `.pth` file, `import setup` could end up running the project's `setup.py`! However, the recommended project layout, putting the Python source in a `src` subdirectory (with the `src` directory then being what gets added to `sys.path`) reduces the risk of such issues significantly. The `editables` project implements this approach using the `add_to_path` method. ## Package-specific paths If a package sets the `__path__` variable to a list of those directories, the import system will search those directories when looking for subpackages or submodules. This allows the user to "graft" a directory into an existing package, simply by setting an appropriate `__path__` value. The `editables` project implements this approach using the `add_to_subpackage` method. ## Self-replacing modules Importing a module involves running the module's code to set up the module namespace and perform any initialisation actions needed. By installing "bootstrap" code which reads the actual module code from its original location, and then executes that code and sets the module up to reflect the details of the source rather than the bootstrap code, it is possible for a module to look and behave identically to the source file. When an editable project is configured to use a map method of "self_replace", the `editables` project implements this approach with the `map` method. ## Import hooks Python's import machinery includes an "import hook" mechanism which in theory allows almost any means of exposing a package to Python. Import hooks have been used to implement importing from zip files, for example. It is possible, therefore, to write an import hook that exposes a project in editable form. The `editables` project implements an import hook that redirects the import of a package to a filesystem location specifically designated as where that package's code is located. By using this import hook, it is possible to exercise precise control over what is exposed to Python. For details of how the hook works, readers should investigate the source of the `editables.redirector` module, part of the `editables` package. When an editable project is configured to use a map method of "import_hook", the `editables` project implements this approach for the `map` method. The `.pth` file that gets written loads the redirector and calls a method on it to add the requested mappings to it. One downside of this approach is that editable projects using the import hook will have an additional runtime dependency. Because the implementation of the import hook is non-trivial, it should be shared between all editable installs, to avoid conflicts between import hooks, and performance issues from having unnecessary numbers of identical hooks running. As a consequence, projects installed in this manner will have a runtime dependency on the hook implementation (currently distributed as part of `editables`, although it could be split out into an independent project). This is not likely to be a significant problem in practice, as the dependency is not needed in a production (non-editable) install. ## Implicit namespace package support The `map` method has some limitations in its support of implicit namespace packages (directories which do not contain an `__init__.py` file): 1. The `target` in the `map` call cannot be a namespace package. 2. The `name` in the `map` call cannot be a dotted name if the "import_hook" method is used. If the "self_replace" method is used, the name can be dotted, and will be interpreted as a module in a namespace package (so `pkg.foo` will be module `foo` in the namespace package `pkg`). The limitations are unfortunate, but are inherent in how Python (currently) implements the feature. Implicit namespace package support is handled as part of how the core import machinery does directory scans, and cannot be simulated as part of an import hook or self-replacing module[^1]. ## Static Analysis The `map` and `add_to_subpackage` functions use runtime mechanisms to "graft" source files into the package namespace. These methods are dynamic, and as such, cannot be detected by static analysis tools like type checkers or IDE autocompletion. If static analysis is important, users should restrict themselves to `add_to_path`, which uses standard `.pth` files which are understood by static analysis tools. ## Reserved Names The `editables` project uses the following file names when building an editable wheel. These should be considered reserved. While backends would not normally add extra files to wheels generated using this library, they are allowed to do so, as long as those files don't use any of the reserved names. 1. `_editable_impl_*.pth` 2. `_editable_impl_*.py` Here, `` is the name supplied to the `EditableProject` constructor, normalised as described in [PEP 503](https://peps.python.org/pep-0503/#normalized-names), with dashes replaced by underscores. The names used can be changed by setting `project.pth_name` and `project.bootstrap_name` respectively (although this should not normally be necessary). [^1]: The issue is related to how the same namespace can be present in multiple `sys.path` entries, and must be dynamically recomputed if the filesystem changes while the interpreter is running. editables-0.6/docs/source/index.md000066400000000000000000000006211516741442300172020ustar00rootroot00000000000000% editables documentation master file, created by % sphinx-quickstart on Sun Apr 25 10:00:23 2021. % You can adapt this file completely to your liking, but it should at least % contain the root `toctree` directive. # Building editable wheels ```{toctree} --- maxdepth: 2 caption: Contents --- usage scope implementation ``` # Indices and tables * {ref}`genindex` * {ref}`modindex` * {ref}`search` editables-0.6/docs/source/scope.md000066400000000000000000000234571516741442300172200ustar00rootroot00000000000000# The scope of this project ## Overview The `editables` library provides a set of functions intended to allow build backends to install an editable copy of a project source tree (the *source*) into a Python environment (the *target*). It is *not* a general tool allowing arbitrary mapping of code files to importable mdules. As a consequence, there are a number of constraints on the scenarios supported by the library: * Source files must be laid out in a reasonable project structure. * The target is a standard site-packages directory, managed by tools conforming to Python packaging standards. Editable installations are a development convenience, and are not generally intended for use in a production environment. While this doesn't directly affect the functionality provided by this library, it *does* affect some of the trade-offs made, particularly in terms of security (if an attacker has access to a development environment, the developer has far greater issues than whether or not their editable install mechanism can be exploited). ## Source Layouts The library is designed to support common source layouts, and reasonable variations of those layouts. While the functionality provided is general, and can be used regardless of how the developer lays out their source code, we do not guarantee that it will be possible to support every possible arrangement of code files. When raising a bug or feature request, users should be prepared to explain what source layout they are using, and if it is not compatible with any of the supported layouts described here, they should either be able to reproduce their issue with a supported layout, or be prepared to request that their layout is added to this list as a supported alternative. ### The `src` layout This is by far the most common project layout, and is recommended for most new Python projects. It is described in [the packaging guide] (https://packaging.python.org/en/latest/discussions/src-layout-vs-flat-layout/). In this layout, all of the project code is stored in a single directory within the project repository, typically called `src`, and installation consists of simply copying the content of the `src` directory to the target environment. For this layout, the `add_to_path` method of an editable project should be all that is needed in order to create an editable install. ### The "flat" layout This is probably the second most common project layout, and is also described in [the packaging guide] (https://packaging.python.org/en/latest/discussions/src-layout-vs-flat-layout/). It is generally considered inferior to the `src` layout because the project code is mixed in with configuration files, tools and other scripts relating to workflow, and other non-code files. However, it is attractive for single-file projects, and for cases where being able to simply import the project from the source directory, without an installation step, is useful. For this layout, mapping the various top-level files and directories to their expected import names is the recommended approach. However, if the project avoids putting importable `.py` files in the workspace root directory, the `add_to_path` method can be an acceptable (and simpler) alternative. ### Library code stored separately Where a project has a significant amount of library code that is shipped as part of the installed wheel, the developers may choose to keep that library code in a separate directory within the project repository. For example, consider the following project structure: ``` pyproject.toml src myproject __init__.py cli __init__.py ... ... libs utils __init__.py ... ``` When installed, the project should be importable as `import myproject`, with `import myproject.cli`, and `import myproject.libs.utils` importing individual subpackages. One important feature of this layout is that there is no `libs` directory in the `src` tree - we only support grafting an external directory into the module tree structure, *not* merging an external structure with content held within the main source tree. This layout is supported using the `add_to_subpackage` method. For the example given above, the build backend should call `editable_project.add_to_subpackage("myproject.libs", "/libs")` ### Multiple top level import names This library supports projects that install multiple top-level names into the target environment. This can involve a `src` directory with multiple packages in it, or a number of directories and files exposed via `map`, or a combination of both of these approaches. However, it should be noted that we do *not* support installing a top-level name that is already present, owned by another package. The Python import system does technically allow top-level names to appear multiple times. The semantics of this are subtle, though, and the implementation details of this library mean that we don't always match the semantics of an actual install. Rather than try to deal with these differences, we take the simple approach of saying "don't do that". ## Notes on other structures ### The monorepo structure A popular approach for developing Python code in larger projects is to use what is called a "monorepo" - multiple projects maintained within a single VCS repository. Whether you use a monorepo or not is not relevant to this project, though, as the individual project directories are independent. ### Implicit namespace packages Implicit namespace packages are directories that have no `__init__.py` file. The import system treats them specially, considering them to be packages, but unlike normal packages: 1. A namespace package is not considered to be "owned" by any one project. 2. If a namespace package appears multiple times in `sys.path`, the content is merged to produce a single combined import module. 3. The content of a namespace package is re-evaluated dynamically whenever an import occurs, rather than being cached at interpreter startup. These behaviours are built in to the importlib path finder, and cannot be customised by user code. As a result, we only support namespace packages in a very limited number of cases. 1. When `add_to_path` is used, any namespace packages within the added path are handled normally. 2. When `map` is used, *with the self-replace method*, to register an import name that is in a namespace package, for example `ns.mypkg`, the containing namespace will work as normal. Any other uses of namespace packages are not supported. Note in particular that the import hook method of implementing `map` does *not* support namespace paths. Import hooks cannot customise namespace package behaviour, as noted above. ## Additional notes ### Metadata changes If a project's metadata is changed (for example, by editing the `pyproject.toml` file), the metadata of the editable install will *not* change automatically. These changes require a reinstall of the project to take effect. One particular example here is adding or amending entry points to the project. The modified entry points will not be available without a reinstall. ### Binary extensions Native binary extensions are not supported. There are two key issues here: 1. Native code generally requires a compilation step, which needs a detailed understanding of the native build process. This knowledge is part of the build backend, and is not available to a general support library like this one. 2. The module mapping techniques used in this library have only been tested with pure Python modules. No attempt has been made to ensure that they work with binary extensions. In practice, methods that simply add entries to `sys.path` (the `add_to_path` and `add_to_subpackage` methods) will probably work, but other methods generally won't. Build backends that handle native code can, of course, add their own support for editable installs of native binary extensions. ### Filesystem operations Because the editable import mechanisms (particularly the `map` method) break the correspondence between filesystem structure and import package nesting, code which uses filesystem operations to locate files relative to an imported module may not behave as expected. The `add_to_path` and `add_to_subpackage` methods, which simply manipulate `sys.path`, should be fine as long as filesystem operations are constrained to searching *within* the exposed module structure, and not reaching "upwards" in the filesystem layout. ### Package resource access The import hook strategy for `map` does not provide a resource loader as part of its implementation. Nor does it support mapping anything other than Python modules. As a result, `importlib.resources` is not supported. This could be viewed as a bug, but given that the newer self replacing strategy handles the resource API without needing special code, the recommended approach is simply to switch to that strategy. The self-replacing module strategy for `map` does not have this limitation, but because it is only possible to map Python modules, and not data files, complex maps are likely to not work as expected. Care should be taken in this case. As usual, methods which work by manipulating `sys.path` (namely `add_to_path` and `add_to_subpackage`) do not have these limitation, and package resource access should work fine. ### Complex mapping of files It is important to remember that this library is designed for exposing the development sources of a Python package to an interpreter environment. Generally, source layouts should match the structure of the modules being developed fairly closely. As a result, the methods in this library are *not* designed as a general module mapping mechanism. Complex mappings are likely to cause issues, and the recommended solution in such cases is to simplify your development source layout. Most projects should not need much more than a single `add_to_path` call, or maybe one or two `map` calls. editables-0.6/docs/source/usage.md000066400000000000000000000116401516741442300172020ustar00rootroot00000000000000# Basic workflow The `editables` project is designed to support *build backends*, allowing them to declare what they wish to expose as "editable", and returning a list of support files that need to be included in the wheel generated by the `build_editable` [backend hook](https://peps.python.org/pep-0660/#build-editable). Note that the `editables` library does not build wheel files directly - it returns the content that needs to be added to the wheel, but it is the build backend's responsibility to actually create the wheel from that data. ## Create a project The first step is for the backend to create an "editable project". The project name must follow the normal rules for Python project names from [PEP 426](https://peps.python.org/pep-0426/#name). ```python project = EditableProject("myproject") ``` ## Specify what to expose Once the project has been created, the backend can specify which files should be exposed when the editable install is done. The following mechanisms are currently implemented for this. ### Adding a directory to `sys.path` To add a particular directory (typically the project's "src" directory) to `sys.path` at runtime, simply call the `add_to_path` method ```python project.add_to_path("src") ``` This will simply write the given directory into the `.pth` file added to the wheel. See the "Implementation Details" section for more information. ### Adding a directory as package content To expose a directory as a package on `sys.path`, call the `add_to_subpackage` method, giving the package name to use, and the path to the directory containing the contents of that package. For example, if the directory `src` contains a package `my_pkg`, which you want to expose to the target interpreter as `some.package.my_pkg`, run the following: ```python project.add_to_subpackage("some.package", "src") ``` Note that everything in the source directory will be available under the given package name, and the source directory should *not* contain an `__init__.py` file (if it does, that file will simply be ignored). Also, the target (`some.package` here) must *not* be an existing package that is already part of the editable wheel. This is because its `__init__.py` file will be overwritten by the one created by this method. # Mapping individual files/packages To expose a single `.py` file as a module, call the `map` method, giving the name by which the module can be imported, and the path to the implementation `.py` file. It *is* possible to give the module a name that is not the same as the implementation filename, although this is expected to be extremely uncommon. ```python project.map("module", "src/module.py") ``` To expose a directory with an `__init__.py` file as a package, the `map` method is used in precisely the same way, but with the directory name: ```python project.map("mypackage", "src/mypackage") ``` The directory *must* be a Python package - i.e., it must contain an `__init__.py` file, and the target package name must be a top-level name, not a dotted name. The `map` method does *not* support mapping implicit namespace packages (directories which do not contain an `__init__.py` file). There are two different ways that the `map` method can be implemented at runtime. To choose which one to use, set the `map_method` attribute on your `EditableProject` instance. The two methods are `import_hook` (the default) and `self_replace`. For details on the differences between the two methods, see the "Implementation Details" section of the documentation. Using the `map` method with the `import_hook` method requires a runtime support module. ## Build the wheel ### Files to add Once all of the content to expose is specified, the backend can start building the wheel. To determine what files to write to the wheel, the `files` method should be used. This returns a sequence of pairs, each of which specifies a filename, and the content to write to that file. Both the name and the content are strings, and so should be encoded appropriately (i.e., in UTF-8) when writing to the wheel. ```python for name, content in my_project.files(): wheel.add_file(name, content) ``` Note that the files to be added must be included unchanged - it is *not* supported for the caller to modify the returned content. Also, it is the caller's responsibility to ensure that none of the generated files clash with files that the caller is adding to the wheel as part of its own processes. ### Runtime dependencies If the `map` method is used with the "import_hook" implementation, the resulting wheel will require that the runtime support module is installed. To ensure that is the case, dependency metadata must be added to the wheel. The `dependencies` method provides the required metadata. ```python for dep in my_project.dependencies(): wheel.metadata.dependencies.add(dep) ``` Note that when runtime support is not needed, the `dependencies` method will return an empty list. So it is safe (and recommended) to call it in all cases. editables-0.6/noxfile.py000066400000000000000000000020561516741442300153430ustar00rootroot00000000000000import glob import shutil import nox nox.options.sessions = ["lint"] nox.options.reuse_existing_virtualenvs = True @nox.session def tests(session): session.install("-r", "tests/requirements.txt") session.install(".") session.run( "pytest", "--cov-report", "term-missing", "--cov", "editables", "tests", *session.posargs, ) @nox.session def lint(session): # Run the linters (via pre-commit) session.install("pre-commit") session.run("pre-commit", "run", "--all-files", *session.posargs) @nox.session def build(session): # Check the distribution session.install("build", "twine") session.run("pyproject-build") session.run("twine", "check", *glob.glob("dist/*")) @nox.session def docs(session): shutil.rmtree("docs/build", ignore_errors=True) session.install("-r", "docs/requirements.txt") session.run( "sphinx-build", "-b", "html", "docs/source", # source directory "docs/build", # output directory ) editables-0.6/pyproject.toml000066400000000000000000000024071516741442300162410ustar00rootroot00000000000000[build-system] requires = ["flit_core >=3.3"] build-backend = "flit_core.buildapi" [project] name = "editables" version = "0.6" description = "Editable installations" readme = "README.md" requires-python = ">=3.7" authors = [{name = "Paul Moore", email = "p.f.moore@gmail.com"}] classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Software Development :: Libraries", "Topic :: Utilities", "Typing :: Typed", ] dependencies = [] [project.urls] Documentation = "https://editables.readthedocs.io" Source = "https://github.com/pfmoore/editables" Tracker = "https://github.com/pfmoore/editables/issues" [tool.flit.sdist] include = ["LICENSE*", "tests/", "docs/"] exclude = ["docs/build", "tests/__pycache__"] editables-0.6/src/000077500000000000000000000000001516741442300141115ustar00rootroot00000000000000editables-0.6/src/editables/000077500000000000000000000000001516741442300160455ustar00rootroot00000000000000editables-0.6/src/editables/__init__.py000066400000000000000000000113631516741442300201620ustar00rootroot00000000000000import os import re from pathlib import Path from typing import Dict, Iterable, List, Tuple, Union __all__ = ( "EditableProject", "__version__", ) __version__ = "0.6" # Self-replacing module code SELF_REPLACER = """\ import importlib.util import sys def import_from_path(module_name, file_path): spec = importlib.util.spec_from_file_location(module_name, file_path) module = importlib.util.module_from_spec(spec) sys.modules[module_name] = module spec.loader.exec_module(module) import_from_path(__name__, {target!r}) """ # Check if a project name is valid, based on PEP 426: # https://peps.python.org/pep-0426/#name def is_valid(name: str) -> bool: return ( re.match(r"^([A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9])$", name, re.IGNORECASE) is not None ) # Slightly modified version of the normalisation from PEP 503: # https://peps.python.org/pep-0503/#normalized-names # This version uses underscore, so that the result is more # likely to be a valid import name def normalize(name: str) -> str: return re.sub(r"[-_.]+", "_", name).lower() class EditableException(Exception): pass class EditableProject: def __init__(self, project_name: str, project_dir: Union[str, os.PathLike]) -> None: if not is_valid(project_name): raise ValueError(f"Project name {project_name} is not valid") self._map_method = "import_hook" # or "self_replace" self.project_name = normalize(project_name) self.pth_name = f"_editable_impl_{self.project_name}" self.bootstrap_name = f"_editable_impl_{self.project_name}" self.project_dir = Path(project_dir) self.redirections: Dict[str, str] = {} self.path_entries: List[Path] = [] self.subpackages: Dict[str, Path] = {} @property def map_method(self) -> str: return self._map_method @map_method.setter def map_method(self, value: str): if value not in ("import_hook", "self_replace"): raise ValueError(f"Unsupported map method: {value}") self._map_method = value def use_hook(self) -> bool: return self._map_method == "import_hook" def make_absolute(self, path: Union[str, os.PathLike]) -> Path: return (self.project_dir / path).resolve() def map(self, name: str, target: Union[str, os.PathLike]) -> None: if "." in name and self.use_hook(): raise EditableException( f"Cannot map {name} with an import hook as it is not a top-level package" ) abs_target = self.make_absolute(target) if abs_target.is_dir(): abs_target = abs_target / "__init__.py" if abs_target.is_file(): self.redirections[name] = str(abs_target) else: raise EditableException(f"{target} is not a valid Python package or module") def add_to_path(self, dirname: Union[str, os.PathLike]) -> None: self.path_entries.append(self.make_absolute(dirname)) def add_to_subpackage(self, package: str, dirname: Union[str, os.PathLike]) -> None: self.subpackages[package] = self.make_absolute(dirname) def files(self) -> Iterable[Tuple[str, str]]: pth_file = self.pth_file() if pth_file: yield f"{self.pth_name}.pth", pth_file if self.subpackages: for package, location in self.subpackages.items(): yield self.package_redirection(package, location) if self.redirections: if self.use_hook(): yield f"{self.bootstrap_name}.py", self.bootstrap_file() else: for name, target in self.redirections.items(): yield f"{name}.py", self.self_replacer(target) def dependencies(self) -> List[str]: deps = [] if self.redirections and self.use_hook(): deps.append("editables") return deps def pth_file(self) -> str: lines = [] if self.redirections and self.use_hook(): lines.append(f"import {self.bootstrap_name}") for entry in self.path_entries: lines.append(str(entry)) return "\n".join(lines) def package_redirection(self, package: str, location: Path) -> Tuple[str, str]: init_py = package.replace(".", "/") + "/__init__.py" content = f"__path__ = [{str(location)!r}]" return init_py, content def self_replacer(self, target: str) -> str: return SELF_REPLACER.format(target=target) def bootstrap_file(self) -> str: bootstrap = [ "from editables.redirector import RedirectingFinder as F", "F.install()", ] for name, path in self.redirections.items(): bootstrap.append(f"F.map_module({name!r}, {path!r})") return "\n".join(bootstrap) editables-0.6/src/editables/py.typed000066400000000000000000000000001516741442300175320ustar00rootroot00000000000000editables-0.6/src/editables/redirector.py000066400000000000000000000025501516741442300205630ustar00rootroot00000000000000import importlib.abc import importlib.machinery import importlib.util import sys from types import ModuleType from typing import Dict, Optional, Sequence, Union ModulePath = Optional[Sequence[Union[bytes, str]]] class RedirectingFinder(importlib.abc.MetaPathFinder): _redirections: Dict[str, str] = {} @classmethod def map_module(cls, name: str, path: str) -> None: cls._redirections[name] = path @classmethod def find_spec( cls, fullname: str, path: ModulePath = None, target: Optional[ModuleType] = None ) -> Optional[importlib.machinery.ModuleSpec]: if "." in fullname: return None if path is not None: return None try: redir = cls._redirections[fullname] except KeyError: return None spec = importlib.util.spec_from_file_location(fullname, redir) return spec @classmethod def install(cls) -> None: if cls not in sys.meta_path: sys.meta_path.append(cls) @classmethod def invalidate_caches(cls) -> None: # importlib.invalidate_caches calls finders' invalidate_caches methods, # and since we install this meta path finder as a class rather than an instance, # we have to override the inherited invalidate_caches method (using self) # as a classmethod instead pass editables-0.6/tests/000077500000000000000000000000001516741442300144645ustar00rootroot00000000000000editables-0.6/tests/requirements.txt000066400000000000000000000001071516741442300177460ustar00rootroot00000000000000pip >= 20.1 coverage >= 5 pytest-coverage pytest >= 4 virtualenv >= 20 editables-0.6/tests/test_editable.py000066400000000000000000000141511516741442300176500ustar00rootroot00000000000000import contextlib import os import site import sys from pathlib import Path import pytest from editables import EditableException, EditableProject # Use a project name that is not a valid Python identifier, # to test that it gets normalised correctly PROJECT_NAME = "my-project" def build_project(target, structure): target.mkdir(exist_ok=True, parents=True) for name, content in structure.items(): path = target / name if isinstance(content, str): # If the name contains slashes, create any # required parent directories path.parent.mkdir(exist_ok=True, parents=True) path.write_text(content, encoding="utf-8") else: build_project(path, content) # to test in-process: # Put stuff in somedir # sys.path.append("somedir") # site.addsitedir("somedir") # Check stuff is visible @contextlib.contextmanager def import_state(project, extra_site=None): extra_site = os.fspath(extra_site) orig_modules = sys.modules.copy() orig_path = list(sys.path) orig_meta_path = list(sys.meta_path) orig_path_hooks = list(sys.path_hooks) orig_path_importer_cache = sys.path_importer_cache.copy() if extra_site: sys.path.append(extra_site) site.addsitedir(extra_site) # Make sure we don't use this project unless we say we need it if "editables" in sys.modules and "editables" not in project.dependencies(): del sys.modules["editables"] try: yield finally: sys.modules.clear() sys.modules.update(orig_modules) sys.path[:] = orig_path sys.meta_path[:] = orig_meta_path sys.path_hooks[:] = orig_path_hooks sys.path_importer_cache.clear() sys.path_importer_cache.update(orig_path_importer_cache) @pytest.fixture def project(tmp_path): project = tmp_path / "project" structure = { "foo": { "__init__.py": "var = 42", "bar": {"__init__.py": "var = 42"}, "baz": {"__init__.py": "var = 42"}, "mod.py": "var = 42", } } build_project(project, structure) yield project def test_invalid_project(): with pytest.raises(ValueError): _ = EditableProject("a$b", "") def test_nonexistent_module(project): p = EditableProject(PROJECT_NAME, project) with pytest.raises(EditableException): p.map("foo", "xxx") def test_not_toplevel(project): p = EditableProject(PROJECT_NAME, project) with pytest.raises(EditableException): p.map("foo.bar", "foo/bar") def test_map_method(project): p = EditableProject(PROJECT_NAME, project) assert p.map_method == "import_hook" assert p.use_hook() p.map_method = "self_replace" assert not p.use_hook() p.map_method = "import_hook" assert p.use_hook() with pytest.raises(ValueError): p.map_method = "invalid_value" @pytest.mark.parametrize( "name,expected", [ ("_invalid", None), ("invalid_", None), ("invalid%character", None), ("project", "_editable_impl_project.pth"), ("Project", "_editable_impl_project.pth"), ("project_1", "_editable_impl_project_1.pth"), ("project-1", "_editable_impl_project_1.pth"), ("project.1", "_editable_impl_project_1.pth"), ("project---1", "_editable_impl_project_1.pth"), ("project-._1", "_editable_impl_project_1.pth"), ("0leading_digit_ok", "_editable_impl_0leading_digit_ok.pth"), ], ) def test_project_names_normalised(name, expected): try: # Tricky here. We create a dummy project, add # an empty directory name to the path, # then get the list of files generated. # The .pth file should always be the first one, # and we only care about the first item (the name) p = EditableProject(name, "") p.add_to_path("") pth = next(p.files())[0] except ValueError: # If the project name isn't valid, we don't # expect a pth file pth = None assert pth == expected def test_dependencies(project): p = EditableProject(PROJECT_NAME, project) assert len(p.dependencies()) == 0 p.map("foo", "foo") p.map_method = "import_hook" assert len(p.dependencies()) == 1 p.map_method = "self_replace" assert len(p.dependencies()) == 0 def test_simple_pth(tmp_path, project): p = EditableProject(PROJECT_NAME, project) p.add_to_path(".") structure = {name: content for name, content in p.files()} site_packages = tmp_path / "site-packages" build_project(site_packages, structure) with import_state(p, extra_site=site_packages): import foo assert Path(foo.__file__) == project / "foo/__init__.py" assert foo.var == 42 def test_make_project(project, tmp_path): p = EditableProject(PROJECT_NAME, project) p.map("foo", "foo") structure = {name: content for name, content in p.files()} site_packages = tmp_path / "site-packages" build_project(site_packages, structure) with import_state(p, extra_site=site_packages): import foo assert Path(foo.__file__) == project / "foo/__init__.py" assert foo.var == 42 def test_subpackage_pth(tmp_path, project): p = EditableProject(PROJECT_NAME, project) p.add_to_subpackage("a.b", ".") structure = {name: content for name, content in p.files()} site_packages = tmp_path / "site-packages" build_project(site_packages, structure) with import_state(p, extra_site=site_packages): import a.b.foo assert Path(a.b.foo.__file__) == project / "foo/__init__.py" assert a.b.foo.var == 42 @pytest.mark.parametrize("map_method", ["import_hook", "self_replace"]) def test_map(map_method, tmp_path, project): p = EditableProject(PROJECT_NAME, project) p.map_method = map_method p.map("a", "foo") structure = {name: content for name, content in p.files()} site_packages = tmp_path / "site-packages" build_project(site_packages, structure) with import_state(p, extra_site=site_packages): import a assert Path(a.__file__) == project / "foo/__init__.py" assert a.var == 42 editables-0.6/tests/test_redirects.py000066400000000000000000000043411516741442300200630ustar00rootroot00000000000000import contextlib import importlib import sys from editables.redirector import RedirectingFinder as F @contextlib.contextmanager def save_import_state(): orig_modules = set(sys.modules.keys()) orig_path = list(sys.path) orig_meta_path = list(sys.meta_path) orig_path_hooks = list(sys.path_hooks) orig_path_importer_cache = sys.path_importer_cache.copy() try: yield finally: remove = [key for key in sys.modules if key not in orig_modules] for key in remove: del sys.modules[key] sys.path[:] = orig_path sys.meta_path[:] = orig_meta_path sys.path_hooks[:] = orig_path_hooks sys.path_importer_cache.clear() sys.path_importer_cache.update(orig_path_importer_cache) # HACK F._redirections = {} def build(target, structure): target.mkdir(exist_ok=True, parents=True) for name, content in structure.items(): path = target / name if isinstance(content, str): path.write_text(content, encoding="utf-8") else: build(path, content) def test_double_install(): with save_import_state(): old_len = len(sys.meta_path) F.install() F.install() assert len(sys.meta_path) == old_len + 1 def test_toplevel_only(): assert F.find_spec("foo.bar") is None def test_no_path(): assert F.find_spec("foo", path=[]) is None def test_no_map_returns_none(): assert F.find_spec("foo") is None def test_redirects(tmp_path): project = tmp_path / "project" project_files = { "mod.py": "val = 42", "pkg": { "__init__.py": "val = 42", "sub.py": "val = 42", }, } build(project, project_files) with save_import_state(): F.install() F.map_module("mod", project / "mod.py") F.map_module("pkg", project / "pkg/__init__.py") import mod assert mod.val == 42 import pkg assert pkg.val == 42 import pkg.sub assert pkg.sub.val == 42 def test_cache_invalidation(): F.install() # assert that the finder matches importlib's expectations # see https://github.com/pfmoore/editables/issues/31 importlib.invalidate_caches()