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

py: resolve module paths without executing modules #58

Merged
merged 1 commit into from
Jan 13, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions meson.build
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ py.install_sources(
[
'src/pkgconf/__init__.py',
'src/pkgconf/__main__.py',
'src/pkgconf/_import.py',
'src/pkgconf/diagnose.py',
],
subdir: 'pkgconf',
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,9 @@ extend-select = [
lines-after-imports = 2
lines-between-types = 1

[tool.ruff.lint.per-file-ignores]
'tests/packages/error-on-import/**' = ['TRY002', 'TRY003', 'EM101']

[tool.coverage.run]
source = [
'pkgconf',
Expand Down
14 changes: 11 additions & 3 deletions src/pkgconf/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import importlib
import itertools
import logging
import operator
Expand All @@ -12,6 +11,8 @@

from typing import Any, Optional

import pkgconf._import


if sys.version_info >= (3, 9):
from collections.abc import Sequence
Expand Down Expand Up @@ -71,8 +72,15 @@


def _get_module_paths(name: str) -> Sequence[str]:
module = importlib.import_module(name)
return list(module.__path__)
try:
module = pkgconf._import.import_module_no_exec(name)
if not hasattr(module, '__path__'):
warnings.warn(f"{module} isn't a package, it won't be added to PKG_CONFIG_PATH", stacklevel=2)
return []

Check warning on line 79 in src/pkgconf/__init__.py

View check run for this annotation

Codecov / codecov/patch

src/pkgconf/__init__.py#L78-L79

Added lines #L78 - L79 were not covered by tests
return list(module.__path__)
except Exception:
_LOGGER.exception(f'Failed to find paths for module {name!r}')
return []

Check warning on line 83 in src/pkgconf/__init__.py

View check run for this annotation

Codecov / codecov/patch

src/pkgconf/__init__.py#L81-L83

Added lines #L81 - L83 were not covered by tests


def get_pkg_config_path() -> Sequence[str]:
Expand Down
73 changes: 73 additions & 0 deletions src/pkgconf/_import.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import contextlib
import importlib.util
import sys
import types

from typing import Any


class LazyModule(types.ModuleType):
def __getattribute__(self, name: str) -> Any:
with contextlib.suppress(AttributeError):
return object.__getattribute__(self, name)

Check warning on line 12 in src/pkgconf/_import.py

View check run for this annotation

Codecov / codecov/patch

src/pkgconf/_import.py#L11-L12

Added lines #L11 - L12 were not covered by tests
# Undo patching — restore the original __getattribute__, __class__, and loader_state
__spec__ = object.__getattribute__(self, '__spec__')
object.__setattr__(self, '__class__', __spec__.loader_state['__class__'])
object.__setattr__(self, '__getattribute__', __spec__.loader_state['__getattribute__'])
__spec__.loader_state = __spec__.loader_state['loader_state']

Check warning on line 17 in src/pkgconf/_import.py

View check run for this annotation

Codecov / codecov/patch

src/pkgconf/_import.py#L14-L17

Added lines #L14 - L17 were not covered by tests
# Run the original __getattribute__
return self.__getattribute__(name)

Check warning on line 19 in src/pkgconf/_import.py

View check run for this annotation

Codecov / codecov/patch

src/pkgconf/_import.py#L19

Added line #L19 was not covered by tests


class LazyLoader(importlib.util.LazyLoader):
"""Custom type extending importlib.util.LazyLoader's capabilities.

It uses the importlib.util.LazyLoader implementation, but provides a custom
__getattribute__ that delays the execution when accessing types.ModuleType
attributes (like __spec__, __path__, __file__, etc.).

The original implementation triggers the module execution when accessing
*any* attribute, even ones initialized in types.ModuleType. While it is
highly discouraged to change any of these attributes during module
execution, it is technically permitted, so the default LazyLoader
implementation makes the decision to trigger the module execution on any
attribute access. We don't care so much about that, so we make choice to
allow attribute access to to existing pre-initilized attributes without
executing the module.
"""

def exec_module(self, module: types.ModuleType) -> None:
# Run
super().exec_module(module)

Check warning on line 41 in src/pkgconf/_import.py

View check run for this annotation

Codecov / codecov/patch

src/pkgconf/_import.py#L41

Added line #L41 was not covered by tests
# Get module.__spec__ using object.__getattribute__ to avoid triggering
# the original __getattribute__, which will always execute the module.
__spec__ = object.__getattribute__(module, '__spec__')

Check warning on line 44 in src/pkgconf/_import.py

View check run for this annotation

Codecov / codecov/patch

src/pkgconf/_import.py#L44

Added line #L44 was not covered by tests
# Save the original __getattribute__ and __class__
__spec__.loader_state = {

Check warning on line 46 in src/pkgconf/_import.py

View check run for this annotation

Codecov / codecov/patch

src/pkgconf/_import.py#L46

Added line #L46 was not covered by tests
'__class__': object.__getattribute__(module, '__class__'),
'__getattribute__': object.__getattribute__(module, '__getattribute__'),
'loader_state': __spec__.loader_state,
}
# Replace __class__ so that attribute lookups use our __getattribute__
module.__class__ = LazyModule

Check warning on line 52 in src/pkgconf/_import.py

View check run for this annotation

Codecov / codecov/patch

src/pkgconf/_import.py#L52

Added line #L52 was not covered by tests


def import_module_no_exec(name: str) -> types.ModuleType:
"""Return a module object with all the module attributes set, but without executing it."""
if name in sys.modules:
return sys.modules[name]
# Import parent without execution
if parent := name.rpartition('.')[0]:
import_module_no_exec(parent)
# Find spec
spec = importlib.util.find_spec(name)
if not spec:
msg = f'No module named {name!r}'
raise ModuleNotFoundError(msg)

Check warning on line 66 in src/pkgconf/_import.py

View check run for this annotation

Codecov / codecov/patch

src/pkgconf/_import.py#L65-L66

Added lines #L65 - L66 were not covered by tests
# Create module object without executing
module = importlib.util.module_from_spec(spec)
# Make the module load lazily (only executes when an attribute is accessed)
LazyLoader(spec.loader).create_module(module)
# Save to sys.modules
sys.modules[name] = module
return module
1 change: 1 addition & 0 deletions tests/packages/error-on-import/foo/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
raise Exception('Error during import :(')
1 change: 1 addition & 0 deletions tests/packages/error-on-import/foo/bar/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
raise Exception('Error during import :(')
5 changes: 5 additions & 0 deletions tests/packages/error-on-import/meson.build
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
project('error-on-import', 'c', version: '1.0.0')

py = import('python').find_installation()
py.install_sources('foo/__init__.py', subdir: 'foo')
py.install_sources('foo/bar/__init__.py', subdir: 'foo/bar')
11 changes: 11 additions & 0 deletions tests/packages/error-on-import/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
[build-system]
build-backend = 'mesonpy'
requires = ['meson-python']

[project]
name = 'error-on-import'
version = '1.0.0'

[project.entry-points.pkg_config]
error-on-import-foo = 'foo'
error-on-import-bar = 'foo.bar'
11 changes: 11 additions & 0 deletions tests/test_python_module.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,17 @@ def test_pkg_config_path_namespace(env, packages):
assert path == [pathlib.Path(env.scheme['purelib'], 'namespace')]


def test_pkg_config_path_error_on_import(env, packages):
path = list(env.introspectable.call('pkgconf.get_pkg_config_path'))
assert len(path) == 0

env.install_from_path(packages / 'error-on-import', from_sdist=False)
env_site_dir = pathlib.Path(env.scheme['purelib'])

path = set(map(pathlib.Path, env.introspectable.call('pkgconf.get_pkg_config_path')))
assert path == {env_site_dir / 'foo', env_site_dir / 'foo' / 'bar'}


def test_run_pkgconfig(env):
output = env.introspectable.call('pkgconf.run_pkgconf', '--help', capture_output=True)
assert output.stdout.decode().startswith('usage: pkgconf')
Expand Down
Loading