Imported Upstream version 0.5 upstream/0.5
authorTizenOpenSource <tizenopensrc@samsung.com>
Tue, 6 Feb 2024 02:30:40 +0000 (11:30 +0900)
committerTizenOpenSource <tizenopensrc@samsung.com>
Tue, 6 Feb 2024 02:30:40 +0000 (11:30 +0900)
18 files changed:
LICENSE.txt [new file with mode: 0644]
PKG-INFO [new file with mode: 0644]
README.md [new file with mode: 0644]
docs/Makefile [new file with mode: 0644]
docs/make.bat [new file with mode: 0755]
docs/requirements.txt [new file with mode: 0644]
docs/source/conf.py [new file with mode: 0644]
docs/source/implementation.md [new file with mode: 0644]
docs/source/index.md [new file with mode: 0644]
docs/source/usage.md [new file with mode: 0644]
docs/source/use-cases.md [new file with mode: 0644]
pyproject.toml [new file with mode: 0644]
src/editables/__init__.py [new file with mode: 0644]
src/editables/py.typed [new file with mode: 0644]
src/editables/redirector.py [new file with mode: 0644]
tests/requirements.txt [new file with mode: 0644]
tests/test_editable.py [new file with mode: 0644]
tests/test_redirects.py [new file with mode: 0644]

diff --git a/LICENSE.txt b/LICENSE.txt
new file mode 100644 (file)
index 0000000..472cbdc
--- /dev/null
@@ -0,0 +1,18 @@
+Copyright (c) 2020 Paul Moore\r
+\r
+Permission is hereby granted, free of charge, to any person obtaining a copy of\r
+this software and associated documentation files (the "Software"), to deal in\r
+the Software without restriction, including without limitation the rights to\r
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of\r
+the Software, and to permit persons to whom the Software is furnished to do so,\r
+subject to the following conditions:\r
+\r
+The above copyright notice and this permission notice shall be included in all\r
+copies or substantial portions of the Software.\r
+\r
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\r
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS\r
+FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR\r
+COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER\r
+IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN\r
+CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\r
diff --git a/PKG-INFO b/PKG-INFO
new file mode 100644 (file)
index 0000000..7613804
--- /dev/null
+++ b/PKG-INFO
@@ -0,0 +1,91 @@
+Metadata-Version: 2.1
+Name: editables
+Version: 0.5
+Summary: Editable installations
+Author-email: Paul Moore <p.f.moore@gmail.com>
+Requires-Python: >=3.7
+Description-Content-Type: text/markdown
+Classifier: Development Status :: 5 - Production/Stable
+Classifier: Intended Audience :: Developers
+Classifier: License :: OSI Approved :: MIT License
+Classifier: Programming Language :: Python
+Classifier: Programming Language :: Python :: 3
+Classifier: Programming Language :: Python :: 3 :: Only
+Classifier: Programming Language :: Python :: 3.7
+Classifier: Programming Language :: Python :: 3.8
+Classifier: Programming Language :: Python :: 3.9
+Classifier: Programming Language :: Python :: 3.10
+Classifier: Programming Language :: Python :: 3.11
+Classifier: Programming Language :: Python :: Implementation :: CPython
+Classifier: Programming Language :: Python :: Implementation :: PyPy
+Classifier: Topic :: Software Development :: Libraries
+Classifier: Topic :: Utilities
+Classifier: Typing :: Typed
+Project-URL: Documentation, https://editables.readthedocs.io
+Project-URL: Source, https://github.com/pfmoore/editables
+Project-URL: Tracker, https://github.com/pfmoore/editables/issues
+
+# 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).
+
diff --git a/README.md b/README.md
new file mode 100644 (file)
index 0000000..70ccd1f
--- /dev/null
+++ b/README.md
@@ -0,0 +1,63 @@
+# A Python library for creating "editable wheels"\r
+\r
+This library supports the building of wheels which, when installed, will\r
+expose packages in a local directory on `sys.path` in "editable mode". In\r
+other words, changes to the package source will be  reflected in the package\r
+visible to Python, without needing a reinstall.\r
+\r
+## Usage\r
+\r
+Suppose you want to build a wheel for your project `foo`. Your project is\r
+located in the directory `/path/to/foo`. Under that directory, you have a\r
+`src` directory containing your project, which is a package called `foo`\r
+and a Python module called `bar.py`. So your directory structure looks like\r
+this:\r
+\r
+```\r
+/path/to/foo\r
+|\r
++-- src\r
+|   +-- foo\r
+|   |   +-- __init__.py\r
+|   +-- bar.py\r
+|\r
++-- setup.py\r
++-- other files\r
+```\r
+\r
+Build your wheel as follows:\r
+\r
+```python\r
+from editables import EditableProject\r
+\r
+my_project = EditableProject("foo", "/path/to/foo")\r
+my_project.add_to_path("src")\r
+\r
+# Build a wheel however you prefer...\r
+wheel = BuildAWheel()\r
+\r
+# Add files to the wheel\r
+for name, content in my_project.files():\r
+    wheel.add_file(name, content)\r
+\r
+# Record any runtime dependencies\r
+for dep in my_project.dependencies():\r
+    wheel.metadata.dependencies.add(dep)\r
+```\r
+\r
+The resulting wheel will, when installed, put the project `src` directory on\r
+`sys.path` so that editing the original source will take effect without needing\r
+a reinstall (i.e., as "editable" packages). The project is exposed on `sys.path`\r
+by adding a single `.pth` file, named after the project, into the wheel.\r
+\r
+For more details, including how to control what gets exposed more precisely, see\r
+[the documentation](https://editables.readthedocs.io/en/latest/).\r
+\r
+Note that this project doesn't build wheels directly. That's the responsibility\r
+of the calling code.\r
+\r
+## Python Compatibility\r
+\r
+This project supports the same versions of Python as pip does. Currently\r
+that is Python 3.7 and later, and PyPy3 (although we don't test against\r
+PyPy).\r
diff --git a/docs/Makefile b/docs/Makefile
new file mode 100644 (file)
index 0000000..26b9422
--- /dev/null
@@ -0,0 +1,20 @@
+# Minimal makefile for Sphinx documentation\r
+#\r
+\r
+# You can set these variables from the command line, and also\r
+# from the environment for the first two.\r
+SPHINXOPTS    ?=\r
+SPHINXBUILD   ?= sphinx-build\r
+SOURCEDIR     = source\r
+BUILDDIR      = build\r
+\r
+# Put it first so that "make" without argument is like "make help".\r
+help:\r
+       @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)\r
+\r
+.PHONY: help Makefile\r
+\r
+# Catch-all target: route all unknown targets to Sphinx using the new\r
+# "make mode" option.  $(O) is meant as a shortcut for $(SPHINXOPTS).\r
+%: Makefile\r
+       @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)\r
diff --git a/docs/make.bat b/docs/make.bat
new file mode 100755 (executable)
index 0000000..6247f7e
--- /dev/null
@@ -0,0 +1,35 @@
+@ECHO OFF\r
+\r
+pushd %~dp0\r
+\r
+REM Command file for Sphinx documentation\r
+\r
+if "%SPHINXBUILD%" == "" (\r
+       set SPHINXBUILD=sphinx-build\r
+)\r
+set SOURCEDIR=source\r
+set BUILDDIR=build\r
+\r
+if "%1" == "" goto help\r
+\r
+%SPHINXBUILD% >NUL 2>NUL\r
+if errorlevel 9009 (\r
+       echo.\r
+       echo.The 'sphinx-build' command was not found. Make sure you have Sphinx\r
+       echo.installed, then set the SPHINXBUILD environment variable to point\r
+       echo.to the full path of the 'sphinx-build' executable. Alternatively you\r
+       echo.may add the Sphinx directory to PATH.\r
+       echo.\r
+       echo.If you don't have Sphinx installed, grab it from\r
+       echo.http://sphinx-doc.org/\r
+       exit /b 1\r
+)\r
+\r
+%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%\r
+goto end\r
+\r
+:help\r
+%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%\r
+\r
+:end\r
+popd\r
diff --git a/docs/requirements.txt b/docs/requirements.txt
new file mode 100644 (file)
index 0000000..62661e3
--- /dev/null
@@ -0,0 +1,3 @@
+sphinx\r
+myst_parser\r
+furo\r
diff --git a/docs/source/conf.py b/docs/source/conf.py
new file mode 100644 (file)
index 0000000..f74b3e4
--- /dev/null
@@ -0,0 +1,56 @@
+# Configuration file for the Sphinx documentation builder.\r
+#\r
+# This file only contains a selection of the most common options. For a full\r
+# list see the documentation:\r
+# https://www.sphinx-doc.org/en/master/usage/configuration.html\r
+\r
+# -- Path setup --------------------------------------------------------------\r
+\r
+# If extensions (or modules to document with autodoc) are in another directory,\r
+# add these directories to sys.path here. If the directory is relative to the\r
+# documentation root, use os.path.abspath to make it absolute, like shown here.\r
+#\r
+# import os\r
+# import sys\r
+# sys.path.insert(0, os.path.abspath('.'))\r
+\r
+\r
+# -- Project information -----------------------------------------------------\r
+\r
+project = "editables"\r
+copyright = "2021, Paul Moore"\r
+author = "Paul Moore"\r
+\r
+# The full version, including alpha/beta/rc tags\r
+release = "0.5"\r
+\r
+\r
+# -- General configuration ---------------------------------------------------\r
+\r
+# Add any Sphinx extension module names here, as strings. They can be\r
+# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom\r
+# ones.\r
+extensions = [\r
+    "myst_parser",\r
+]\r
+\r
+# Add any paths that contain templates here, relative to this directory.\r
+templates_path = ["_templates"]\r
+\r
+# List of patterns, relative to source directory, that match files and\r
+# directories to ignore when looking for source files.\r
+# This pattern also affects html_static_path and html_extra_path.\r
+exclude_patterns = []\r
+\r
+\r
+# -- Options for HTML output -------------------------------------------------\r
+\r
+# The theme to use for HTML and HTML Help pages.  See the documentation for\r
+# a list of builtin themes.\r
+#\r
+html_theme = "furo"\r
+\r
+# Add any paths that contain custom static files (such as style sheets) here,\r
+# relative to this directory. They are copied after the builtin static files,\r
+# so a file named "default.css" will overwrite the builtin "default.css".\r
+html_static_path = ["_static"]\r
diff --git a/docs/source/implementation.md b/docs/source/implementation.md
new file mode 100644 (file)
index 0000000..c20b77e
--- /dev/null
@@ -0,0 +1,117 @@
+# Implementation Details\r
+\r
+The key feature of a project that is installed in "editable mode" is that the\r
+code for the project remains in the project's working directory, and what gets\r
+installed into the user's Python installation is simply a "pointer" to that\r
+code. The implication of this is that the user can continue to edit the project\r
+source, and expect to see the changes reflected immediately in the Python\r
+interpreter, without needing to reinstall.\r
+\r
+The exact details of how such a "pointer" works, and indeed precisely how much\r
+of the project is exposed to Python, are generally considered to be\r
+implementation details, and users should not concern themselves too much with\r
+how things work "under the hood". However, there are practical implications\r
+which users of this library (typically build backend developers) should be aware\r
+of.\r
+\r
+The basic import machinery in Python works by scanning a list of directories\r
+recorded in `sys.path` and looking for Python modules and packages in these\r
+directories. (There's a *lot* more complexity behind the scenes, and interested\r
+readers are directed to [the Python documentation](https://docs.python.org) for\r
+more details). The initial value of `sys.path` is set by the interpreter, but\r
+there are various ways of influencing this.\r
+\r
+As part of startup, Python checks various "site directories" on `sys.path` for\r
+files called `*.pth`. In their simplest form, `.pth` files contain a list of\r
+directory names, which are *added* to `sys.path`. In addition, for more advanced\r
+cases, `.pth` files can also run executable code (typically, to set up import\r
+hooks to further configure the import machinery).\r
+\r
+## Editables using `.pth` entries\r
+\r
+The simplest way of setting up an editable project is to install a `.pth` file\r
+containing a single line specifying the project directory. This will cause the\r
+project directory to be added to `sys.path` at interpreter startup, making it\r
+available to Python in "editable" form.\r
+\r
+This is the approach which has been used by setuptools for many years, as part\r
+of the `setup.py develop` command, and subsequently exposed by pip under the\r
+name "editable installs", via the command `pip install --editable <project_dir>`.\r
+\r
+In general, this is an extremely effective and low-cost approach to implementing\r
+editable installs. It does, however, have one major disadvantage, in that it does\r
+*not* necessarily expose the same packages as a normal install would do. If the\r
+project is not laid out with this in mind, an editable install may expose importable\r
+files that were not intended. For example, if the project root directory is added\r
+directly to the `.pth` file, `import setup` could end up running the project's\r
+`setup.py`! However, the recommended project layout, putting the Python source in\r
+a `src` subdirectory (with the `src` directory then being what gets added to\r
+`sys.path`) reduces the risk of such issues significantly.\r
+\r
+The `editables` project implements this approach using the `add_to_path` method.\r
+\r
+## Package-specific paths\r
+\r
+If a package sets the `__path__` variable to a list of those directories, the\r
+import system will search those directories when looking for subpackages or\r
+submodules. This allows the user to "graft" a directory into an existing package,\r
+simply by setting an appropriate `__path__` value.\r
+\r
+The `editables` project implements this approach using the `add_to_subpackage` method.\r
+\r
+## Import hooks\r
+\r
+Python's import machinery includes an "import hook" mechanism which in theory\r
+allows almost any means of exposing a package to Python. Import hooks have been\r
+used to implement importing from zip files, for example. It is possible, therefore,\r
+to write an import hook that exposes a project in editable form.\r
+\r
+The `editables` project implements an import hook that redirects the import of a\r
+package to a filesystem location specifically designated as where that package's\r
+code is located. By using this import hook, it is possible to exercise precise\r
+control over what is exposed to Python. For details of how the hook works,\r
+readers should investigate the source of the `editables.redirector` module, part\r
+of the `editables` package.\r
+\r
+The `editables` project implements this approach for the `map` method. The\r
+`.pth` file that gets written loads the redirector and calls a method on it\r
+to add the requested mappings to it.\r
+\r
+There are two downsides to this approach, as compared to the simple `.pth` file\r
+mechanism - lack of support for implicit namespace packages, and the need for\r
+runtime support code.\r
+\r
+The first issue (lack of support for implicit namespace packages) is\r
+unfortunate, but inherent in how Python (currently) implements the feature.\r
+Implicit namespace package support is handled as part of how the core import\r
+machinery does directory scans, and does not interact properly with the import\r
+hook mechanisms. As a result, the `editables` import hook does not support\r
+implicit namespace packages, and will probably never be able to do so without\r
+help from the core Python implementation[^1].\r
+\r
+The second issue (the need for runtime support) is more of an inconvenience than\r
+a major problem. Because the implementation of the import hook is non-trivial,\r
+it should be shared between all editable installs, to avoid conflicts between\r
+import hooks, and performance issues from having unnecessary numbers of\r
+identical hooks running. As a consequence, projects installed in this manner\r
+will have a runtime dependency on the hook implementation (currently distributed\r
+as part of `editables`, although it could be split out into an independent\r
+project).\r
+\r
+## Reserved Names\r
+\r
+The `editables` project uses the following file names when building an editable\r
+wheel. These should be considered reserved. While backends would not normally\r
+add extra files to wheels generated using this library, they are allowed to do\r
+so, as long as those files don't use any of the reserved names.\r
+\r
+1. `<project_name>.pth`\r
+2. `_editable_impl_<project_name>*.py`\r
+\r
+Here, `<project_name>` is the name supplied to the `EditableProject` constructor,\r
+normalised as described in [PEP 503](https://peps.python.org/pep-0503/#normalized-names),\r
+with dashes replaced by underscores.\r
+\r
+[^1]: The issue is related to how the same namespace can be present in multiple\r
+      `sys.path` entries, and must be dynamically recomputed if the filesystem\r
+      changes while the interpreter is running.\r
diff --git a/docs/source/index.md b/docs/source/index.md
new file mode 100644 (file)
index 0000000..a6af13e
--- /dev/null
@@ -0,0 +1,22 @@
+% editables documentation master file, created by\r
+% sphinx-quickstart on Sun Apr 25 10:00:23 2021.\r
+% You can adapt this file completely to your liking, but it should at least\r
+% contain the root `toctree` directive.\r
+\r
+# Building editable wheels\r
+\r
+```{toctree}\r
+---\r
+maxdepth: 2\r
+caption: Contents\r
+---\r
+usage\r
+implementation\r
+use-cases\r
+```\r
+\r
+# Indices and tables\r
+\r
+* {ref}`genindex`\r
+* {ref}`modindex`\r
+* {ref}`search`\r
diff --git a/docs/source/usage.md b/docs/source/usage.md
new file mode 100644 (file)
index 0000000..88a88f2
--- /dev/null
@@ -0,0 +1,119 @@
+# Basic workflow\r
+\r
+The `editables` project is designed to support *build backends*, allowing them\r
+to declare what they wish to expose as "editable", and returning a list of\r
+support files that need to be included in the wheel generated by the\r
+`build_editable` [backend hook](https://peps.python.org/pep-0660/#build-editable).\r
+Note that the `editables` library does not build wheel files directly - it\r
+returns the content that needs to be added to the wheel, but it is the build\r
+backend's responsibility to actually create the wheel from that data.\r
+\r
+## Create a project\r
+\r
+The first step is for the backend to create an "editable project". The project\r
+name must follow the normal rules for Python project names from\r
+[PEP 426](https://peps.python.org/pep-0426/#name).\r
+\r
+```python\r
+project = EditableProject("myproject")\r
+```\r
+\r
+## Specify what to expose\r
+\r
+Once the project has been created, the backend can specify which files should be\r
+exposed when the editable install is done. There are two mechanisms currently\r
+implemented for this.\r
+\r
+### Adding a directory to `sys.path`\r
+\r
+To add a particular directory (typically the project's "src" directory) to\r
+`sys.path` at runtime, simply call the `add_to_path` method\r
+\r
+```python\r
+project.add_to_path("src")\r
+```\r
+\r
+This will simply write the given directory into the `.pth` file added to the\r
+wheel. See the "Implementation Details" section for more information. Note that\r
+this method requires no runtime support.\r
+\r
+### Adding a directory as package content\r
+\r
+To expose a directory as a package on `sys.path`, call the `add_to_subpackage`\r
+method, giving the package name to use, and the path to the directory containing\r
+the contents of that package.\r
+\r
+For example, if the directory `src` contains a package `my_pkg`, which you want\r
+to expose to the target interpreter as `some.package.my_pkg`, run the following:\r
+\r
+```python\r
+project.add_to_subpackage("some.package", "src")\r
+```\r
+\r
+Note that everything in the source directory will be available under the given\r
+package name, and the source directory should *not* contain an `__init__.py`\r
+file (if it does, that file will simply be ignored).\r
+\r
+Also, the target (`some.package` here) must *not* be an existing package that\r
+is already part of the editable wheel. This is because its `__init__.py` file\r
+will be overwritten by the one created by this method.\r
+\r
+# Mapping individual files/packages\r
+\r
+To expose a single `.py` file as a module, call the `map` method, giving the\r
+name by which the module can be imported, and the path to the implementation\r
+`.py` file. It *is* possible to give the module a name that is not the same as\r
+the implementation filename, although this is expected to be extremely uncommon.\r
+\r
+```python\r
+project.map("module", "src/module.py")\r
+```\r
+\r
+To expose a directory with an `__init__.py` file as a package, the `map`\r
+method is used in precisely the same way, but with the directory name:\r
+\r
+```python\r
+project.map("mypackage", "src/mypackage")\r
+```\r
+\r
+The directory *must* be a Python package - i.e., it must contain an `__init__.py`\r
+file, and the target package name must be a top-level name, not a dotted name.\r
+\r
+Using the `map` method does require a runtime support module.\r
+\r
+## Build the wheel\r
+\r
+### Files to add\r
+\r
+Once all of the content to expose is specified, the backend can start building\r
+the wheel. To determine what files to write to the wheel, the `files` method\r
+should be used. This returns a sequence of pairs, each of which specifies a\r
+filename, and the content to write to that file. Both the name and the content\r
+are strings, and so should be encoded appropriately (i.e., in UTF-8) when\r
+writing to the wheel.\r
+\r
+```python\r
+for name, content in my_project.files():\r
+    wheel.add_file(name, content)\r
+```\r
+\r
+Note that the files to be added must be included unchanged - it is *not*\r
+supported for the caller to modify the returned content. Also, it is the\r
+caller's responsibility to ensure that none of the generated files clash with\r
+files that the caller is adding to the wheel as part of its own processes.\r
+\r
+### Runtime dependencies\r
+\r
+If the `map` method is used, the resulting wheel will require that the runtime\r
+support module is installed. To ensure that is the case, dependency metadata\r
+must be added to the wheel. The `dependencies` method provides the required\r
+metadata.\r
+\r
+```python\r
+for dep in my_project.dependencies():\r
+    wheel.metadata.dependencies.add(dep)\r
+```\r
+\r
+Note that if the backend only uses the `add_to_path` method, no runtime support\r
+is needed, so the `dependencies` method will return an empty list. For safety,\r
+and to protect against future changes, it should still be called, though.\r
diff --git a/docs/source/use-cases.md b/docs/source/use-cases.md
new file mode 100644 (file)
index 0000000..7cec6ef
--- /dev/null
@@ -0,0 +1,120 @@
+# Use Cases\r
+\r
+We will cover here the main supported use cases for editable installs,\r
+including the recommended approaches for exposing the files to the\r
+import system.\r
+\r
+## Project directory installed "as is"\r
+\r
+A key example of this is the recommended "`src` layout" for a project,\r
+where a single directory (typically named `src`) is copied unchanged\r
+into the target site-packages.\r
+\r
+For this use case, the `project.add_to_path` method is ideal, making\r
+the project directory available to the import system directly.\r
+\r
+There are almost no downsides to this approach, as it is using core\r
+import system mechanisms to manage `sys.path`. Furthermore, the method\r
+is implemented using `.pth` files, which are recognised by static analysis\r
+tools such as type checkers, and so editable installs created using this\r
+method will be visible in such tools.\r
+\r
+## Project directory installed under an explicit package name\r
+\r
+This is essentially the same as the previous use case, but rather than\r
+installing the project directory directly into site-packages, it is\r
+installed under a partocular package name. So, for example, if the\r
+project has a `src` directory containing a package `foo` and a module\r
+`bar.py`, the requirement is to install the contents of `src` as\r
+`my.namespace.foo` and `my.namespace.bar`.\r
+\r
+For this use case, the `project.add_to_subpackage` method is available.\r
+This method creates the `my.namespace` package (by installing an `__init__.py`\r
+file for it into site-packages) and gives that package a `__path__` attribute\r
+pointing to the source directory to be installed under that package name.\r
+\r
+Again, this approach uses core import system mechanisms, and so will have\r
+few or no downsides at runtime. However, because this approach relies on\r
+*runtime* manipulation of `sys.path`, it will not be recognised by static\r
+analysis tools.\r
+\r
+## Installing part of a source directory\r
+\r
+The most common case for this is a "flat" project layout, where the\r
+package and module files to be installed are stored alongside project\r
+files such as `pyproject.toml`. This layout is typically *not* recommended,\r
+particularly for new projects, although older projects may be using this\r
+type of layout for historical reasons.\r
+\r
+The core import machinery does not provide a "native" approach supporting\r
+excluding part of a directory like this, so custom import hooks are needed\r
+to implement it. At the time of writing, all such custom hook implementations\r
+have limitations, and should be considered experimental. As a result, build\r
+backends should *always* prefer one of the other implementation methods when\r
+available.\r
+\r
+The `project.map` method allows mapping of either a single Python file, or\r
+a Python package directory, to an explicit top-level name in the import system.\r
+It does this by installing a `.pth` file and a Python module. The `.pth` file\r
+simply runs the Python module, and the module installs the requested set of\r
+mappings using an import hook exported by the `editables` module.\r
+\r
+Downsides of this approach are:\r
+\r
+1. The approach depends on the ability to run executable code from a `.pth`\r
+   file. While this is a supported capability of `.pth` files, it is\r
+   considered a risk, and there have been proposals to remove it. If that\r
+   were to happen, this mechanism would no longer work.\r
+2. It adds a *runtime* dependency on the `editables` module, rather than\r
+   just a build-time dependency.\r
+3. The import hook has known limitations when used with implicit namespace\r
+   packages - there is [a CPython issue](https://github.com/python/cpython/issues/92054)\r
+   discussing some of the problems.\r
+\r
+## Unsupported use cases\r
+\r
+In addition to the above there are a number of use cases which are explicitly\r
+**not** supported by this library. That is not to say that editable installs\r
+cannot do these things, simply that the build backend will need to provide\r
+its own support.\r
+\r
+### Metadata changes\r
+\r
+This library does not support dynamically changing installed project metadata\r
+when the project source changes. Typically, a reinstall is needed in those\r
+cases. A significant example of a metadata change is a change to the script\r
+entry points, which affects what command-line executables are installed.\r
+\r
+### Binary extensions\r
+\r
+Binary extensions require a build step when the source code is changed. This\r
+library does not support any sort of automatic rebuilding, nor does it\r
+support automatic reinstallation of binaries.\r
+\r
+The build backend may choose to expose the "working" version of the built\r
+binary, for example by placing a symbolic link to the binary in a directory\r
+that is visible to the import system as a result of `project.add_to_path`,\r
+but that would need to be implemented by the backend.\r
+\r
+### Mapping non-Python directories or files\r
+\r
+The methods of an editable project are all intended explicitly for exposing\r
+*Python code* to the import system. Other types of resource, such as data\r
+files, are *not* supported, except in the form of package data physically\r
+located in a Python package directory in the source.\r
+\r
+### Combining arbitrary code into a package\r
+\r
+The library assumes that a typical project layout, at least roughly, matches\r
+the installed layout - and in particular that Python package directories are\r
+"intact" in the source. Build backends can support more complex structures,\r
+but in order to expose them as editable installs, they need to create some\r
+form of "live" reflection of the final layout in a local directory (for\r
+example by using symbolic links) and create the editable install using that\r
+shadow copy of the source.\r
+\r
+It is possible that a future version of this library may add support for\r
+more complex mappings of this form, but that would likely require a\r
+significant enhancement to the import hook mechanism being used, and would\r
+be a major, backward incompatible, change. There are currently no plans for\r
+such a feature, though.\r
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644 (file)
index 0000000..2eeda94
--- /dev/null
@@ -0,0 +1,39 @@
+[build-system]\r
+requires = ["flit_core >=3.3"]\r
+build-backend = "flit_core.buildapi"\r
+\r
+[project]\r
+name = "editables"\r
+version = "0.5"\r
+description = "Editable installations"\r
+readme = "README.md"\r
+requires-python = ">=3.7"\r
+authors = [{name = "Paul Moore", email = "p.f.moore@gmail.com"}]\r
+classifiers = [\r
+  "Development Status :: 5 - Production/Stable",\r
+  "Intended Audience :: Developers",\r
+  "License :: OSI Approved :: MIT License",\r
+  "Programming Language :: Python",\r
+  "Programming Language :: Python :: 3",\r
+  "Programming Language :: Python :: 3 :: Only",\r
+  "Programming Language :: Python :: 3.7",\r
+  "Programming Language :: Python :: 3.8",\r
+  "Programming Language :: Python :: 3.9",\r
+  "Programming Language :: Python :: 3.10",\r
+  "Programming Language :: Python :: 3.11",\r
+  "Programming Language :: Python :: Implementation :: CPython",\r
+  "Programming Language :: Python :: Implementation :: PyPy",\r
+  "Topic :: Software Development :: Libraries",\r
+  "Topic :: Utilities",\r
+  "Typing :: Typed",\r
+]\r
+dependencies = []\r
+\r
+[project.urls]\r
+Documentation = "https://editables.readthedocs.io"\r
+Source = "https://github.com/pfmoore/editables"\r
+Tracker = "https://github.com/pfmoore/editables/issues"\r
+\r
+[tool.flit.sdist]\r
+include = ["LICENSE*", "tests/", "docs/"]\r
+exclude = ["docs/build", "tests/__pycache__"]\r
diff --git a/src/editables/__init__.py b/src/editables/__init__.py
new file mode 100644 (file)
index 0000000..a65205b
--- /dev/null
@@ -0,0 +1,102 @@
+import os\r
+import re\r
+from pathlib import Path\r
+from typing import Dict, Iterable, List, Tuple, Union\r
+\r
+__all__ = (\r
+    "EditableProject",\r
+    "__version__",\r
+)\r
+\r
+__version__ = "0.5"\r
+\r
+\r
+# Check if a project name is valid, based on PEP 426:\r
+# https://peps.python.org/pep-0426/#name\r
+def is_valid(name: str) -> bool:\r
+    return (\r
+        re.match(r"^([A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9])$", name, re.IGNORECASE)\r
+        is not None\r
+    )\r
+\r
+\r
+# Slightly modified version of the normalisation from PEP 503:\r
+# https://peps.python.org/pep-0503/#normalized-names\r
+# This version uses underscore, so that the result is more\r
+# likely to be a valid import name\r
+def normalize(name: str) -> str:\r
+    return re.sub(r"[-_.]+", "_", name).lower()\r
+\r
+\r
+class EditableException(Exception):\r
+    pass\r
+\r
+\r
+class EditableProject:\r
+    def __init__(self, project_name: str, project_dir: Union[str, os.PathLike]) -> None:\r
+        if not is_valid(project_name):\r
+            raise ValueError(f"Project name {project_name} is not valid")\r
+        self.project_name = normalize(project_name)\r
+        self.bootstrap = f"_editable_impl_{self.project_name}"\r
+        self.project_dir = Path(project_dir)\r
+        self.redirections: Dict[str, str] = {}\r
+        self.path_entries: List[Path] = []\r
+        self.subpackages: Dict[str, Path] = {}\r
+\r
+    def make_absolute(self, path: Union[str, os.PathLike]) -> Path:\r
+        return (self.project_dir / path).resolve()\r
+\r
+    def map(self, name: str, target: Union[str, os.PathLike]) -> None:\r
+        if "." in name:\r
+            raise EditableException(\r
+                f"Cannot map {name} as it is not a top-level package"\r
+            )\r
+        abs_target = self.make_absolute(target)\r
+        if abs_target.is_dir():\r
+            abs_target = abs_target / "__init__.py"\r
+        if abs_target.is_file():\r
+            self.redirections[name] = str(abs_target)\r
+        else:\r
+            raise EditableException(f"{target} is not a valid Python package or module")\r
+\r
+    def add_to_path(self, dirname: Union[str, os.PathLike]) -> None:\r
+        self.path_entries.append(self.make_absolute(dirname))\r
+\r
+    def add_to_subpackage(self, package: str, dirname: Union[str, os.PathLike]) -> None:\r
+        self.subpackages[package] = self.make_absolute(dirname)\r
+\r
+    def files(self) -> Iterable[Tuple[str, str]]:\r
+        yield f"{self.project_name}.pth", self.pth_file()\r
+        if self.subpackages:\r
+            for package, location in self.subpackages.items():\r
+                yield self.package_redirection(package, location)\r
+        if self.redirections:\r
+            yield f"{self.bootstrap}.py", self.bootstrap_file()\r
+\r
+    def dependencies(self) -> List[str]:\r
+        deps = []\r
+        if self.redirections:\r
+            deps.append("editables")\r
+        return deps\r
+\r
+    def pth_file(self) -> str:\r
+        lines = []\r
+        if self.redirections:\r
+            lines.append(f"import {self.bootstrap}")\r
+        for entry in self.path_entries:\r
+            lines.append(str(entry))\r
+        return "\n".join(lines)\r
+\r
+    def package_redirection(self, package: str, location: Path) -> Tuple[str, str]:\r
+        init_py = package.replace(".", "/") + "/__init__.py"\r
+        content = f"__path__ = [{str(location)!r}]"\r
+        return init_py, content\r
+\r
+    def bootstrap_file(self) -> str:\r
+        bootstrap = [\r
+            "from editables.redirector import RedirectingFinder as F",\r
+            "F.install()",\r
+        ]\r
+        for name, path in self.redirections.items():\r
+            bootstrap.append(f"F.map_module({name!r}, {path!r})")\r
+        return "\n".join(bootstrap)\r
diff --git a/src/editables/py.typed b/src/editables/py.typed
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/editables/redirector.py b/src/editables/redirector.py
new file mode 100644 (file)
index 0000000..cfb6990
--- /dev/null
@@ -0,0 +1,47 @@
+import importlib.abc\r
+import importlib.machinery\r
+import importlib.util\r
+import sys\r
+from types import ModuleType\r
+from typing import Dict, Optional, Sequence, Union\r
+\r
+ModulePath = Optional[Sequence[Union[bytes, str]]]\r
+\r
+\r
+class RedirectingFinder(importlib.abc.MetaPathFinder):\r
+    _redirections: Dict[str, str] = {}\r
+\r
+    @classmethod\r
+    def map_module(cls, name: str, path: str) -> None:\r
+        cls._redirections[name] = path\r
+\r
+    @classmethod\r
+    def find_spec(\r
+        cls, fullname: str, path: ModulePath = None, target: Optional[ModuleType] = None\r
+    ) -> Optional[importlib.machinery.ModuleSpec]:\r
+        if "." in fullname:\r
+            return None\r
+        if path is not None:\r
+            return None\r
+        try:\r
+            redir = cls._redirections[fullname]\r
+        except KeyError:\r
+            return None\r
+        spec = importlib.util.spec_from_file_location(fullname, redir)\r
+        return spec\r
+\r
+    @classmethod\r
+    def install(cls) -> None:\r
+        for f in sys.meta_path:\r
+            if f == cls:\r
+                break\r
+        else:\r
+            sys.meta_path.append(cls)\r
+\r
+    @classmethod\r
+    def invalidate_caches(cls) -> None:\r
+        # importlib.invalidate_caches calls finders' invalidate_caches methods,\r
+        # and since we install this meta path finder as a class rather than an instance,\r
+        # we have to override the inherited invalidate_caches method (using self)\r
+        # as a classmethod instead\r
+        pass\r
diff --git a/tests/requirements.txt b/tests/requirements.txt
new file mode 100644 (file)
index 0000000..54845fa
--- /dev/null
@@ -0,0 +1,5 @@
+pip >= 20.1\r
+coverage >= 5\r
+pytest-coverage\r
+pytest >= 4\r
+virtualenv >= 20\r
diff --git a/tests/test_editable.py b/tests/test_editable.py
new file mode 100644 (file)
index 0000000..5e55a28
--- /dev/null
@@ -0,0 +1,162 @@
+import contextlib\r
+import os\r
+import site\r
+import sys\r
+from pathlib import Path\r
+\r
+import pytest\r
+\r
+from editables import EditableException, EditableProject\r
+\r
+# Use a project name that is not a valid Python identifier,\r
+# to test that it gets normalised correctly\r
+PROJECT_NAME = "my-project"\r
+\r
+\r
+def build_project(target, structure):\r
+    target.mkdir(exist_ok=True, parents=True)\r
+    for name, content in structure.items():\r
+        path = target / name\r
+        if isinstance(content, str):\r
+            # If the name contains slashes, create any\r
+            # required parent directories\r
+            path.parent.mkdir(exist_ok=True, parents=True)\r
+            path.write_text(content, encoding="utf-8")\r
+        else:\r
+            build_project(path, content)\r
+\r
+\r
+# to test in-process:\r
+#   Put stuff in somedir\r
+#   sys.path.append("somedir")\r
+#   site.addsitedir("somedir")\r
+#   Check stuff is visible\r
+@contextlib.contextmanager\r
+def import_state(extra_site=None):\r
+    extra_site = os.fspath(extra_site)\r
+    orig_modules = set(sys.modules.keys())\r
+    orig_path = list(sys.path)\r
+    orig_meta_path = list(sys.meta_path)\r
+    orig_path_hooks = list(sys.path_hooks)\r
+    orig_path_importer_cache = sys.path_importer_cache\r
+    if extra_site:\r
+        sys.path.append(extra_site)\r
+        site.addsitedir(extra_site)\r
+    try:\r
+        yield\r
+    finally:\r
+        remove = [key for key in sys.modules if key not in orig_modules]\r
+        for key in remove:\r
+            del sys.modules[key]\r
+        sys.path[:] = orig_path\r
+        sys.meta_path[:] = orig_meta_path\r
+        sys.path_hooks[:] = orig_path_hooks\r
+        sys.path_importer_cache.clear()\r
+        sys.path_importer_cache.update(orig_path_importer_cache)\r
+\r
+\r
+@pytest.fixture\r
+def project(tmp_path):\r
+    project = tmp_path / "project"\r
+    structure = {\r
+        "foo": {\r
+            "__init__.py": "print('foo')",\r
+            "bar": {"__init__.py": "print('foo.bar')"},\r
+            "baz": {"__init__.py": "print('foo.baz')"},\r
+        }\r
+    }\r
+    build_project(project, structure)\r
+    yield project\r
+\r
+\r
+def test_invalid_project():\r
+    with pytest.raises(ValueError):\r
+        _ = EditableProject("a$b", "")\r
+\r
+\r
+def test_nonexistent_module(project):\r
+    p = EditableProject(PROJECT_NAME, project)\r
+    with pytest.raises(EditableException):\r
+        p.map("foo", "xxx")\r
+\r
+\r
+def test_not_toplevel(project):\r
+    p = EditableProject(PROJECT_NAME, project)\r
+    with pytest.raises(EditableException):\r
+        p.map("foo.bar", "foo/bar")\r
+\r
+\r
+@pytest.mark.parametrize(\r
+    "name,expected",\r
+    [\r
+        ("_invalid", None),\r
+        ("invalid_", None),\r
+        ("invalid%character", None),\r
+        ("project", "project.pth"),\r
+        ("Project", "project.pth"),\r
+        ("project_1", "project_1.pth"),\r
+        ("project-1", "project_1.pth"),\r
+        ("project.1", "project_1.pth"),\r
+        ("project---1", "project_1.pth"),\r
+        ("project-._1", "project_1.pth"),\r
+        ("0leading_digit_ok", "0leading_digit_ok.pth"),\r
+    ],\r
+)\r
+def test_project_names_normalised(name, expected):\r
+    try:\r
+        # Tricky here. We create a dummy project, add\r
+        # an empty directory name to the path,\r
+        # then get the list of files generated.\r
+        # The .pth file should always be the first one,\r
+        # and we only care about the first item (the name)\r
+        p = EditableProject(name, "")\r
+        p.add_to_path("")\r
+        pth = next(p.files())[0]\r
+    except ValueError:\r
+        # If the project name isn't valid, we don't\r
+        # expect a pth file\r
+        pth = None\r
+    assert pth == expected\r
+\r
+\r
+def test_dependencies(project):\r
+    p = EditableProject(PROJECT_NAME, project)\r
+    assert len(p.dependencies()) == 0\r
+    p.map("foo", "foo")\r
+    assert len(p.dependencies()) == 1\r
+\r
+\r
+def test_simple_pth(tmp_path, project):\r
+    p = EditableProject(PROJECT_NAME, project)\r
+    p.add_to_path(".")\r
+    structure = {name: content for name, content in p.files()}\r
+    site_packages = tmp_path / "site-packages"\r
+    build_project(site_packages, structure)\r
+    with import_state(extra_site=site_packages):\r
+        import foo\r
+\r
+        assert Path(foo.__file__) == project / "foo/__init__.py"\r
+\r
+\r
+def test_make_project(project, tmp_path):\r
+    p = EditableProject(PROJECT_NAME, project)\r
+    p.map("foo", "foo")\r
+    structure = {name: content for name, content in p.files()}\r
+    site_packages = tmp_path / "site-packages"\r
+    build_project(site_packages, structure)\r
+    with import_state(extra_site=site_packages):\r
+        import foo\r
+\r
+        assert Path(foo.__file__) == project / "foo/__init__.py"\r
+\r
+\r
+def test_subpackage_pth(tmp_path, project):\r
+    p = EditableProject(PROJECT_NAME, project)\r
+    p.add_to_subpackage("a.b", ".")\r
+    structure = {name: content for name, content in p.files()}\r
+    site_packages = tmp_path / "site-packages"\r
+    build_project(site_packages, structure)\r
+    with import_state(extra_site=site_packages):\r
+        import a.b.foo\r
+\r
+        assert Path(a.b.foo.__file__) == project / "foo/__init__.py"\r
diff --git a/tests/test_redirects.py b/tests/test_redirects.py
new file mode 100644 (file)
index 0000000..f0e00f3
--- /dev/null
@@ -0,0 +1,91 @@
+import contextlib\r
+import importlib\r
+import sys\r
+\r
+from editables.redirector import RedirectingFinder as F\r
+\r
+\r
+@contextlib.contextmanager\r
+def save_import_state():\r
+    orig_modules = set(sys.modules.keys())\r
+    orig_path = list(sys.path)\r
+    orig_meta_path = list(sys.meta_path)\r
+    orig_path_hooks = list(sys.path_hooks)\r
+    orig_path_importer_cache = sys.path_importer_cache\r
+    try:\r
+        yield\r
+    finally:\r
+        remove = [key for key in sys.modules if key not in orig_modules]\r
+        for key in remove:\r
+            del sys.modules[key]\r
+        sys.path[:] = orig_path\r
+        sys.meta_path[:] = orig_meta_path\r
+        sys.path_hooks[:] = orig_path_hooks\r
+        sys.path_importer_cache.clear()\r
+        sys.path_importer_cache.update(orig_path_importer_cache)\r
+        # HACK\r
+        F._redirections = {}\r
+\r
+\r
+def build(target, structure):\r
+    target.mkdir(exist_ok=True, parents=True)\r
+    for name, content in structure.items():\r
+        path = target / name\r
+        if isinstance(content, str):\r
+            path.write_text(content, encoding="utf-8")\r
+        else:\r
+            build(path, content)\r
+\r
+\r
+def test_double_install():\r
+    with save_import_state():\r
+        old_len = len(sys.meta_path)\r
+        F.install()\r
+        F.install()\r
+        assert len(sys.meta_path) == old_len + 1\r
+\r
+\r
+def test_toplevel_only():\r
+    assert F.find_spec("foo.bar") is None\r
+\r
+\r
+def test_no_path():\r
+    assert F.find_spec("foo", path=[]) is None\r
+\r
+\r
+def test_no_map_returns_none():\r
+    assert F.find_spec("foo") is None\r
+\r
+\r
+def test_redirects(tmp_path):\r
+    project = tmp_path / "project"\r
+    project_files = {\r
+        "mod.py": "val = 42",\r
+        "pkg": {\r
+            "__init__.py": "val = 42",\r
+            "sub.py": "val = 42",\r
+        },\r
+    }\r
+    build(project, project_files)\r
+\r
+    with save_import_state():\r
+        F.install()\r
+        F.map_module("mod", project / "mod.py")\r
+        F.map_module("pkg", project / "pkg/__init__.py")\r
+\r
+        import mod\r
+\r
+        assert mod.val == 42\r
+        import pkg\r
+\r
+        assert pkg.val == 42\r
+        import pkg.sub\r
+\r
+        assert pkg.sub.val == 42\r
+\r
+\r
+def test_cache_invalidation():\r
+    F.install()\r
+    # assert that the finder matches importlib's expectations\r
+    # see https://github.com/pfmoore/editables/issues/31\r
+    importlib.invalidate_caches()\r