Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Editable installs not working for meson-python package #323

Open
peekxc opened this issue Jan 10, 2024 · 10 comments
Open

Editable installs not working for meson-python package #323

peekxc opened this issue Jan 10, 2024 · 10 comments

Comments

@peekxc
Copy link
Contributor

peekxc commented Jan 10, 2024

I thought I might make this issue for future reference, or for someone else who encounters this, though I admit I don't have either a fix yet.

I'm not able to get quartodoc to load my package once it's in editable mode (on the other hand, it works perfectly fine when not installed in editable mode).

In particular, quartodoc build fails to find the package modules (I'm working from this package):

$ quartodoc build 
> Traceback (most recent call last):
> ...
> File "/Users/mpiekenbrock/opt/miniconda3/envs/spri/lib/python3.11/site-packages/quartodoc/builder/blueprint.py", line 173, in get_object_fixed
> raise WorkaroundKeyError(
> quartodoc.builder.utils.WorkaroundKeyError: Cannot find an object named: trace. Does an object with the path primate:trace.hutch exist?

Related to #12, but also #193 is relevant.

This bug almost surely due to the fact that I'm building with meson python as opposed to setuptools, and the fact that meson_python modifies the .pth files for editable installs (for extension module reasons). In particular, installing e.g. my package primate in editable mode puts two files in the virtualenv's site_packages:

  1. a primate_py-editable.pth file with the contents:
import _primate_py_editable_loader
  1. a _primate_py_editable_loader.py file with a bunch of loading-related stuff.

I assume quartodoc fails to load the package modules because of 172, but I can't be sure. It's actually not clear if this is a quartodoc issue, a griffe issue, a meson-python problem

@pawamoy
Copy link

pawamoy commented Jan 15, 2024

Hi @peekxc, that looks like missing support in Griffe indeed 🙂
Griffe is able to read .pth files, and then it has to match the name of the editable module (here _primate_py_editable_loader) against various supported backend (we currently support setuptools, scikit-build and editables). See https://discuss.python.org/t/standardized-name-for-editable-modules/26606/7.

I'll add support for meson, and release it with next Griffe version 🙂
Could you share the full contents of the _primate_py_editable_loader.py file please? I need to see what's inside to know how to extract package locations.

@pawamoy
Copy link

pawamoy commented Jan 15, 2024

Hmm, I built your package locally, and I don't get a _primate_py_editable_loader.py file but a _scikit_primate_editable_loader.py one. Can you confirm that is what you get too? Maybe your dist name was previously primate-py and you changed it to scikit-primate?

@peekxc
Copy link
Contributor Author

peekxc commented Jan 15, 2024

Hmm, I built your package locally, and I don't get a _primate_py_editable_loader.py file but a _scikit_primate_editable_loader.py one

Ahh yes, this was a very recent change---dist was named primate, changed to primate-py and then later scikit-primate as primate is reserved on PyPI (for unknown reasons).

The dist-name is irrelevent though, I think. The module to import is still primate. In particular, running:

(~/primate)$ pip install . --no-build-isolation
(~/primate/docs/src)$ quartodoc build && quarto preview .

Builds the docs fine, all tests pass, and all imports get resolved. Meanwhile the editable install does not work:

(~/primate)$ pip install --editable . --no-build-isolation
(~/primate/docs/src)$ quartodoc build && quarto preview .
# Fails with quartodoc.builder.utils.WorkaroundKeyError: Cannot find an object named: trace. Does an object with the path primate:trace.hutch exist?

Can you confirm that is what you get too?

Yes, I get a scikit_primate-editable.pth file and a _scikit_primate_editable_loader.py file.

Could you share the full contents of the _primate_py_editable_loader.py file please? I need to see what's inside to know how to extract package locations.

Here are the contents:

Click to expand `_primate_py_editable_loader.py`
# SPDX-FileCopyrightText: 2022 The meson-python developers
#
# SPDX-License-Identifier: MIT

# This file should be standalone! It is copied during the editable hook installation.

from __future__ import annotations

import functools
import importlib.abc
import importlib.machinery
import importlib.util
import json
import os
import pathlib
import subprocess
import sys
import typing


if typing.TYPE_CHECKING:
    from collections.abc import Sequence, Set
    from types import ModuleType
    from typing import Any, Dict, Iterator, List, Optional, Tuple, Union

    from typing_extensions import Buffer

    NodeBase = Dict[str, Union['Node', str]]
    PathStr = Union[str, os.PathLike[str]]
else:
    NodeBase = dict


if sys.version_info >= (3, 12):
    from importlib.resources.abc import Traversable, TraversableResources
elif sys.version_info >= (3, 9):
    from importlib.abc import Traversable, TraversableResources
else:
    class Traversable:
        pass
    class TraversableResources:
        pass


MARKER = 'MESONPY_EDITABLE_SKIP'
VERBOSE = 'MESONPY_EDITABLE_VERBOSE'


class MesonpyOrphan(Traversable):
    def __init__(self, name: str):
        self._name = name

    @property
    def name(self) -> str:
        return self._name

    def is_dir(self) -> bool:
        return False

    def is_file(self) -> bool:
        return False

    def iterdir(self) -> Iterator[Traversable]:
        raise FileNotFoundError()

    def open(self, *args, **kwargs):  # type: ignore
        raise FileNotFoundError()

    def joinpath(self, *descendants: PathStr) -> Traversable:
        if not descendants:
            return self
        name = os.fspath(descendants[-1]).split('/')[-1]
        return MesonpyOrphan(name)

    def __truediv__(self, child: PathStr) -> Traversable:
        return self.joinpath(child)

    def read_bytes(self) -> bytes:
        raise FileNotFoundError()

    def read_text(self, encoding: Optional[str] = None) -> str:
        raise FileNotFoundError()


class MesonpyTraversable(Traversable):
    def __init__(self, name: str, tree: Node):
        self._name = name
        self._tree = tree

    @property
    def name(self) -> str:
        return self._name

    def is_dir(self) -> bool:
        return True

    def is_file(self) -> bool:
        return False

    def iterdir(self) -> Iterator[Traversable]:
        for name, node in self._tree.items():
            yield MesonpyTraversable(name, node) if isinstance(node, dict) else pathlib.Path(node)  # type: ignore

    def open(self, *args, **kwargs):  # type: ignore
        raise IsADirectoryError()

    @staticmethod
    def _flatten(names: Tuple[PathStr, ...]) -> Iterator[str]:
        for name in names:
            yield from os.fspath(name).split('/')

    def joinpath(self, *descendants: PathStr) -> Traversable:
        if not descendants:
            return self
        names = self._flatten(descendants)
        name = next(names)
        node = self._tree.get(name)
        if isinstance(node, dict):
            return MesonpyTraversable(name, node).joinpath(*names)
        if isinstance(node, str):
            return pathlib.Path(node).joinpath(*names)
        return MesonpyOrphan(name).joinpath(*names)

    def __truediv__(self, child: PathStr) -> Traversable:
        return self.joinpath(child)

    def read_bytes(self) -> bytes:
        raise IsADirectoryError()

    def read_text(self, encoding: Optional[str] = None) -> str:
        raise IsADirectoryError()


class MesonpyReader(TraversableResources):
    def __init__(self, name: str, tree: Node):
        self._name = name
        self._tree = tree

    def files(self) -> Traversable:
        return MesonpyTraversable(self._name, self._tree)


class ExtensionFileLoader(importlib.machinery.ExtensionFileLoader):
    def __init__(self, name: str, path: str, tree: Node):
        super().__init__(name, path)
        self._tree = tree

    def get_resource_reader(self, name: str) -> TraversableResources:
        return MesonpyReader(name, self._tree)


class SourceFileLoader(importlib.machinery.SourceFileLoader):
    def __init__(self, name: str, path: str, tree: Node):
        super().__init__(name, path)
        self._tree = tree

    def set_data(self, path: Union[bytes, str], data: Buffer, *, _mode: int = ...) -> None:
        # disable saving bytecode
        pass

    def get_resource_reader(self, name: str) -> TraversableResources:
        return MesonpyReader(name, self._tree)


class SourcelessFileLoader(importlib.machinery.SourcelessFileLoader):
    def __init__(self, name: str, path: str, tree: Node):
        super().__init__(name, path)
        self._tree = tree

    def get_resource_reader(self, name: str) -> TraversableResources:
        return MesonpyReader(name, self._tree)


LOADERS = [
    (ExtensionFileLoader, tuple(importlib.machinery.EXTENSION_SUFFIXES)),
    (SourceFileLoader, tuple(importlib.machinery.SOURCE_SUFFIXES)),
    (SourcelessFileLoader, tuple(importlib.machinery.BYTECODE_SUFFIXES)),
]


def build_module_spec(cls: type, name: str, path: str, tree: Optional[Node]) -> importlib.machinery.ModuleSpec:
    loader = cls(name, path, tree)
    spec = importlib.machinery.ModuleSpec(name, loader, origin=path)
    spec.has_location = True
    if loader.is_package(name):
        spec.submodule_search_locations = []
    return spec


class Node(NodeBase):
    """Tree structure to store a virtual filesystem view."""

    def __missing__(self, key: str) -> Node:
        value = self[key] = Node()
        return value

    def __setitem__(self, key: Union[str, Tuple[str, ...]], value: Union[Node, str]) -> None:
        node = self
        if isinstance(key, tuple):
            for k in key[:-1]:
                node = typing.cast(Node, node[k])
            key = key[-1]
        dict.__setitem__(node, key, value)

    def __getitem__(self, key: Union[str, Tuple[str, ...]]) -> Union[Node, str]:
        node = self
        if isinstance(key, tuple):
            for k in key[:-1]:
                node = typing.cast(Node, node[k])
            key = key[-1]
        return dict.__getitem__(node, key)

    def get(self, key: Union[str, Tuple[str, ...]]) -> Optional[Union[Node, str]]:  # type: ignore[override]
        node = self
        if isinstance(key, tuple):
            for k in key[:-1]:
                v = dict.get(node, k)
                if v is None:
                    return None
                node = typing.cast(Node, v)
            key = key[-1]
        return dict.get(node, key)


def walk(root: str, path: str = '') -> Iterator[pathlib.Path]:
    with os.scandir(os.path.join(root, path)) as entries:
        for entry in entries:
            if entry.is_dir():
                yield from walk(root, os.path.join(path, entry.name))
            else:
                yield pathlib.Path(path, entry.name)


def collect(install_plan: Dict[str, Dict[str, Any]]) -> Node:
    tree = Node()
    for key, data in install_plan.items():
        for src, target in data.items():
            path = pathlib.Path(target['destination'])
            if path.parts[0] in {'{py_platlib}', '{py_purelib}'}:
                if key == 'install_subdirs' and os.path.isdir(src):
                    for entry in walk(src):
                        tree[(*path.parts[1:], *entry.parts)] = os.path.join(src, *entry.parts)
                else:
                    tree[path.parts[1:]] = src
    return tree


class MesonpyMetaFinder(importlib.abc.MetaPathFinder):
    def __init__(self, names: Set[str], path: str, cmd: List[str], verbose: bool = False):
        self._top_level_modules = names
        self._build_path = path
        self._build_cmd = cmd
        self._verbose = verbose
        self._loaders: List[Tuple[type, str]] = []
        for loader, suffixes in LOADERS:
            self._loaders.extend((loader, suffix) for suffix in suffixes)

    def __repr__(self) -> str:
        return f'{self.__class__.__name__}({self._build_path!r})'

    def find_spec(
            self,
            fullname: str,
            path: Optional[Sequence[Union[bytes, str]]] = None,
            target: Optional[ModuleType] = None
    ) -> Optional[importlib.machinery.ModuleSpec]:
        if fullname.split('.', maxsplit=1)[0] in self._top_level_modules:
            if self._build_path in os.environ.get(MARKER, '').split(os.pathsep):
                return None
            namespace = False
            tree = self.rebuild()
            parts = fullname.split('.')

            # look for a package
            package = tree.get(tuple(parts))
            if isinstance(package, Node):
                for loader, suffix in self._loaders:
                    src = package.get('__init__' + suffix)
                    if isinstance(src, str):
                        return build_module_spec(loader, fullname, src, package)
                else:
                    namespace = True

            # look for a module
            for loader, suffix in self._loaders:
                src = tree.get((*parts[:-1], parts[-1] + suffix))
                if isinstance(src, str):
                    return build_module_spec(loader, fullname, src, None)

            # namespace
            if namespace:
                spec = importlib.machinery.ModuleSpec(fullname, None)
                spec.submodule_search_locations = []
                return spec

        return None

    @functools.lru_cache(maxsize=1)
    def rebuild(self) -> Node:
        # skip editable wheel lookup during rebuild: during the build
        # the module we are rebuilding might be imported causing a
        # rebuild loop.
        env = os.environ.copy()
        env[MARKER] = os.pathsep.join((env.get(MARKER, ''), self._build_path))

        if self._verbose or bool(env.get(VERBOSE, '')):
            print('+ ' + ' '.join(self._build_cmd))
            stdout = None
        else:
            stdout = subprocess.DEVNULL

        subprocess.run(self._build_cmd, cwd=self._build_path, env=env, stdout=stdout, check=True)

        install_plan_path = os.path.join(self._build_path, 'meson-info', 'intro-install_plan.json')
        with open(install_plan_path, 'r', encoding='utf8') as f:
            install_plan = json.load(f)
        return collect(install_plan)


def install(names: Set[str], path: str, cmd: List[str], verbose: bool) -> None:
    sys.meta_path.insert(0, MesonpyMetaFinder(names, path, cmd, verbose))

install(
    {'_random_gen', '_operators', '_orthogonalize', '_trace', 'primate', '_lanczos'},
    '/Users/mpiekenbrock/primate/build/cp311',
    ['/Users/mpiekenbrock/opt/miniconda3/envs/spri/bin/ninja'],
    False,
)

@pawamoy
Copy link

pawamoy commented Jan 15, 2024

The dist-name is irrelevent though, I think.

Indeed it's not really relevant for Griffe, but I needed it to know the name pattern of the editable module 🙂

Thanks!

So, meson-python's editable modules will now be understood, however there's a second issue: your Python sources have an __init__.py module, which means that, even now that we find the build directory with the compiled modules within it, Griffe will not scan the modules inside it, because the Python sources take precedence. I suppose the code in meson-python's editable modules does a few dynamic things with the import machinery so that Python can import both the source modules and the compiled ones. We will have to refactor Griffe's finder code a bit to make this work 🤔

@peekxc
Copy link
Contributor Author

peekxc commented Jan 15, 2024

even now that we find the build directory with the compiled modules within it, Griffe will not scan the modules inside it, because the Python sources take precedence.

Not sure if I understand entirely the connection to __init__.py, but at least I'm fine with having Griffe only scan python sources for the time being, as I don't need the docstring capabilities that comes with e.g. native pybind11 modules.

I suppose the code in meson-python's editable modules does a few dynamic things with the import machinery so that Python can import both the source modules and the compiled ones.

It definitely does, I can at least confirm meson-python actually adds C++ source files to the set of files to watch during edits, unlike setuptools and the like. So for example, if you change a cpp file during development and you start a new python session and try to import the module, meson-python actually detects the file has changed and it will (perhaps slowly) re-compile the source files as needed.

I don't know exactly how it works though :)

@pawamoy
Copy link

pawamoy commented Jan 15, 2024

I'm fine with having Griffe only scan python sources

Oh, then I'm not sure what's wrong in Quarto, because Griffe was already able to scan the Python sources, given src is added to Griffe's search paths or to PYTHONPATH.

@machow
Copy link
Owner

machow commented Jan 19, 2024

@peekxc were you able to get it working? Would be interested to hear what was needed!

Thanks for debugging / adding the pth support to griffe @pawamoy! With quartodoc, people can use the source_dir option to add sources:

quartodoc:
  source_dir: "custom_source_directory"

It looks like currently quartodoc just adds it to the sys.path. I haven't used it much, so there could be some funkyness there 😅.

@pawamoy
Copy link

pawamoy commented Jan 21, 2024

As long as sys.path is updated before a Griffe loader is instantiated, this should work :) The loader inits the finder with the given search paths, or sys path if not provided.

@machow machow added this to quartodoc Jan 22, 2024
@peekxc
Copy link
Contributor Author

peekxc commented Oct 16, 2024

@peekxc were you able to get it working? Would be interested to hear what was needed!

Unfortunately I was not able to get this working; adding a source_dir made no difference for me.

For awhile now I've just been building the docs by building the package normally every time, however I've realized this is just becoming way too slow of a dev loop to maintain.

@peekxc
Copy link
Contributor Author

peekxc commented Oct 16, 2024

Ahh, I've figured it out partially.

Using the no-package trick from here, I was able to get docs building in an editable install working that auto updated the docs via:

quartodoc:
  package: null
  source_dir: ../src/my_package
  sections:
     ...

The issue was that core Python imports for whatever reason interfere with the imports. In my case, I had an operator.py, a trace.py, and a random.py; the native python modules would take precedence over these, causing the corresponding functions to not be found.

The temporary solution I have is to just rename them, though this is suboptimal, as conficts between package namespace happen all the time, and it's probably more pythonic to have these conflicts, e.g. np.random and random, np.linalg vs. scipy.linalg, etc.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
Status: No status
Development

No branches or pull requests

3 participants