pax_global_header00006660000000000000000000000064144575407600014526gustar00rootroot0000000000000052 comment=2331418af764ef203959327354335c431978e70d editables-0.5/000077500000000000000000000000001445754076000133265ustar00rootroot00000000000000editables-0.5/.github/000077500000000000000000000000001445754076000146665ustar00rootroot00000000000000editables-0.5/.github/workflows/000077500000000000000000000000001445754076000167235ustar00rootroot00000000000000editables-0.5/.github/workflows/pre-commit.yml000066400000000000000000000011301445754076000215150ustar00rootroot00000000000000# 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@v1 - uses: actions/setup-python@v1 - 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@v1 with: path: ~/.cache/pre-commit key: pre-commit|${{ env.PY }}|${{ hashFiles('.pre-commit-config.yaml') }} - uses: pre-commit/action@v1.1.0 editables-0.5/.github/workflows/tests.yml000066400000000000000000000013421445754076000206100ustar00rootroot00000000000000name: 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.7" - "3.8" - "3.9" - "3.10" - "3.11" steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.py }} uses: actions/setup-python@v1 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.5/.gitignore000066400000000000000000000007731445754076000153250ustar00rootroot00000000000000# 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.5/.pre-commit-config.yaml000066400000000000000000000013301445754076000176040ustar00rootroot00000000000000repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.4.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: 23.1.0 hooks: - id: black - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. rev: v0.0.270 hooks: - id: ruff - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.3.0 hooks: - id: mypy files: src args: ["--pretty", "--show-error-codes"] additional_dependencies: [ 'nox', 'pytest', ] editables-0.5/CHANGELOG.md000066400000000000000000000010641445754076000151400ustar00rootroot00000000000000# Changes ## 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.5/LICENSE.txt000066400000000000000000000020361445754076000151520ustar00rootroot00000000000000Copyright (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.5/README.md000066400000000000000000000036151445754076000146120ustar00rootroot00000000000000# 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.5/bump_release.py000066400000000000000000000023431445754076000163450ustar00rootroot00000000000000# 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.5/docs/000077500000000000000000000000001445754076000142565ustar00rootroot00000000000000editables-0.5/docs/Makefile000066400000000000000000000011761445754076000157230ustar00rootroot00000000000000# 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.5/docs/make.bat000066400000000000000000000013741445754076000156700ustar00rootroot00000000000000@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.5/docs/requirements.txt000066400000000000000000000000301445754076000175330ustar00rootroot00000000000000sphinx myst_parser furo editables-0.5/docs/source/000077500000000000000000000000001445754076000155565ustar00rootroot00000000000000editables-0.5/docs/source/conf.py000066400000000000000000000035551445754076000170650ustar00rootroot00000000000000# 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.5" # -- 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.5/docs/source/implementation.md000066400000000000000000000143401445754076000211270ustar00rootroot00000000000000# 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. ## 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. 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. There are two downsides to this approach, as compared to the simple `.pth` file mechanism - lack of support for implicit namespace packages, and the need for runtime support code. The first issue (lack of support for implicit namespace packages) is unfortunate, but 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 does not interact properly with the import hook mechanisms. As a result, the `editables` import hook does not support implicit namespace packages, and will probably never be able to do so without help from the core Python implementation[^1]. The second issue (the need for runtime support) is more of an inconvenience than a major problem. 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). ## 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. `.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. [^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.5/docs/source/index.md000066400000000000000000000006251445754076000172120ustar00rootroot00000000000000% 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 implementation use-cases ``` # Indices and tables * {ref}`genindex` * {ref}`modindex` * {ref}`search` editables-0.5/docs/source/usage.md000066400000000000000000000107541445754076000172130ustar00rootroot00000000000000# 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. There are two mechanisms 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. Note that this method requires no runtime support. ### 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. Using the `map` method does require 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, 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 if the backend only uses the `add_to_path` method, no runtime support is needed, so the `dependencies` method will return an empty list. For safety, and to protect against future changes, it should still be called, though. editables-0.5/docs/source/use-cases.md000066400000000000000000000133221445754076000177710ustar00rootroot00000000000000# Use Cases We will cover here the main supported use cases for editable installs, including the recommended approaches for exposing the files to the import system. ## Project directory installed "as is" A key example of this is the recommended "`src` layout" for a project, where a single directory (typically named `src`) is copied unchanged into the target site-packages. For this use case, the `project.add_to_path` method is ideal, making the project directory available to the import system directly. There are almost no downsides to this approach, as it is using core import system mechanisms to manage `sys.path`. Furthermore, the method is implemented using `.pth` files, which are recognised by static analysis tools such as type checkers, and so editable installs created using this method will be visible in such tools. ## Project directory installed under an explicit package name This is essentially the same as the previous use case, but rather than installing the project directory directly into site-packages, it is installed under a partocular package name. So, for example, if the project has a `src` directory containing a package `foo` and a module `bar.py`, the requirement is to install the contents of `src` as `my.namespace.foo` and `my.namespace.bar`. For this use case, the `project.add_to_subpackage` method is available. This method creates the `my.namespace` package (by installing an `__init__.py` file for it into site-packages) and gives that package a `__path__` attribute pointing to the source directory to be installed under that package name. Again, this approach uses core import system mechanisms, and so will have few or no downsides at runtime. However, because this approach relies on *runtime* manipulation of `sys.path`, it will not be recognised by static analysis tools. ## Installing part of a source directory The most common case for this is a "flat" project layout, where the package and module files to be installed are stored alongside project files such as `pyproject.toml`. This layout is typically *not* recommended, particularly for new projects, although older projects may be using this type of layout for historical reasons. The core import machinery does not provide a "native" approach supporting excluding part of a directory like this, so custom import hooks are needed to implement it. At the time of writing, all such custom hook implementations have limitations, and should be considered experimental. As a result, build backends should *always* prefer one of the other implementation methods when available. The `project.map` method allows mapping of either a single Python file, or a Python package directory, to an explicit top-level name in the import system. It does this by installing a `.pth` file and a Python module. The `.pth` file simply runs the Python module, and the module installs the requested set of mappings using an import hook exported by the `editables` module. Downsides of this approach are: 1. The approach depends on the ability to run executable code from a `.pth` file. While this is a supported capability of `.pth` files, it is considered a risk, and there have been proposals to remove it. If that were to happen, this mechanism would no longer work. 2. It adds a *runtime* dependency on the `editables` module, rather than just a build-time dependency. 3. The import hook has known limitations when used with implicit namespace packages - there is [a CPython issue](https://github.com/python/cpython/issues/92054) discussing some of the problems. ## Unsupported use cases In addition to the above there are a number of use cases which are explicitly **not** supported by this library. That is not to say that editable installs cannot do these things, simply that the build backend will need to provide its own support. ### Metadata changes This library does not support dynamically changing installed project metadata when the project source changes. Typically, a reinstall is needed in those cases. A significant example of a metadata change is a change to the script entry points, which affects what command-line executables are installed. ### Binary extensions Binary extensions require a build step when the source code is changed. This library does not support any sort of automatic rebuilding, nor does it support automatic reinstallation of binaries. The build backend may choose to expose the "working" version of the built binary, for example by placing a symbolic link to the binary in a directory that is visible to the import system as a result of `project.add_to_path`, but that would need to be implemented by the backend. ### Mapping non-Python directories or files The methods of an editable project are all intended explicitly for exposing *Python code* to the import system. Other types of resource, such as data files, are *not* supported, except in the form of package data physically located in a Python package directory in the source. ### Combining arbitrary code into a package The library assumes that a typical project layout, at least roughly, matches the installed layout - and in particular that Python package directories are "intact" in the source. Build backends can support more complex structures, but in order to expose them as editable installs, they need to create some form of "live" reflection of the final layout in a local directory (for example by using symbolic links) and create the editable install using that shadow copy of the source. It is possible that a future version of this library may add support for more complex mappings of this form, but that would likely require a significant enhancement to the import hook mechanism being used, and would be a major, backward incompatible, change. There are currently no plans for such a feature, though. editables-0.5/noxfile.py000066400000000000000000000020561445754076000153470ustar00rootroot00000000000000import 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.5/pyproject.toml000066400000000000000000000024071445754076000162450ustar00rootroot00000000000000[build-system] requires = ["flit_core >=3.3"] build-backend = "flit_core.buildapi" [project] name = "editables" version = "0.5" 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.5/src/000077500000000000000000000000001445754076000141155ustar00rootroot00000000000000editables-0.5/src/editables/000077500000000000000000000000001445754076000160515ustar00rootroot00000000000000editables-0.5/src/editables/__init__.py000066400000000000000000000067271445754076000201760ustar00rootroot00000000000000import os import re from pathlib import Path from typing import Dict, Iterable, List, Tuple, Union __all__ = ( "EditableProject", "__version__", ) __version__ = "0.5" # 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.project_name = normalize(project_name) self.bootstrap = 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] = {} 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: raise EditableException( f"Cannot map {name} 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]]: yield f"{self.project_name}.pth", self.pth_file() if self.subpackages: for package, location in self.subpackages.items(): yield self.package_redirection(package, location) if self.redirections: yield f"{self.bootstrap}.py", self.bootstrap_file() def dependencies(self) -> List[str]: deps = [] if self.redirections: deps.append("editables") return deps def pth_file(self) -> str: lines = [] if self.redirections: lines.append(f"import {self.bootstrap}") 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 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.5/src/editables/py.typed000066400000000000000000000000001445754076000175360ustar00rootroot00000000000000editables-0.5/src/editables/redirector.py000066400000000000000000000026401445754076000205670ustar00rootroot00000000000000import 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: for f in sys.meta_path: if f == cls: break else: 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.5/tests/000077500000000000000000000000001445754076000144705ustar00rootroot00000000000000editables-0.5/tests/requirements.txt000066400000000000000000000001071445754076000177520ustar00rootroot00000000000000pip >= 20.1 coverage >= 5 pytest-coverage pytest >= 4 virtualenv >= 20 editables-0.5/tests/test_editable.py000066400000000000000000000114541445754076000176570ustar00rootroot00000000000000import 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(extra_site=None): extra_site = os.fspath(extra_site) 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 if extra_site: sys.path.append(extra_site) site.addsitedir(extra_site) 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) @pytest.fixture def project(tmp_path): project = tmp_path / "project" structure = { "foo": { "__init__.py": "print('foo')", "bar": {"__init__.py": "print('foo.bar')"}, "baz": {"__init__.py": "print('foo.baz')"}, } } 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") @pytest.mark.parametrize( "name,expected", [ ("_invalid", None), ("invalid_", None), ("invalid%character", None), ("project", "project.pth"), ("Project", "project.pth"), ("project_1", "project_1.pth"), ("project-1", "project_1.pth"), ("project.1", "project_1.pth"), ("project---1", "project_1.pth"), ("project-._1", "project_1.pth"), ("0leading_digit_ok", "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") assert len(p.dependencies()) == 1 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(extra_site=site_packages): import foo assert Path(foo.__file__) == project / "foo/__init__.py" 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(extra_site=site_packages): import foo assert Path(foo.__file__) == project / "foo/__init__.py" 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(extra_site=site_packages): import a.b.foo assert Path(a.b.foo.__file__) == project / "foo/__init__.py" editables-0.5/tests/test_redirects.py000066400000000000000000000043321445754076000200670ustar00rootroot00000000000000import 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 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()