Skip to content
Open
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 CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Changes
* FIX: mitigate speed regressions introduced in 5.0.0 #376
* FIX: Use import system to locate module file run by ``kernprof -m`` #389
* FIX: Fixed build on Windows-ARM64 and now building wheels therefor in CI #391
* FIX: Edge case where ``line_profiler.toml_config`` misbehaves if ``importlib.resources`` is replaced with certain versions of the ``importlib_resources`` backport #406

5.0.0
~~~~~
Expand Down
19 changes: 17 additions & 2 deletions line_profiler/toml_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,11 +105,26 @@ def from_default(cls, *, copy=True):
New instance if ``copy`` is true, the global default
instance otherwise.
"""
# Note: NORMALLY `importlib.resources.path()` is available on
# all targetted Python versions and is not deprecated, so we
# could've just used said function directly.
# However, there are edge cases in which `importlib.resources`
# has been superseded with `importlib_resources`, which may
# cause various issues (incl. `DeprecationWarning`s or even
# errors) dep. on the latter's version (see GitHub issue #405).
ir = importlib.resources
try:
ir_files, ir_as_file = ir.files, ir.as_file
except AttributeError: # Python < 3.9
find_file = ir.path
else:
def find_file(anc, *chunks):
return ir_as_file(ir_files(anc).joinpath(*chunks))

global _DEFAULTS
if _DEFAULTS is None:
package = __spec__.name.rpartition('.')[0]
with importlib.resources.path(package + '.rc',
'line_profiler.toml') as path:
with find_file(package + '.rc', 'line_profiler.toml') as path:
conf_dict, source = find_and_read_config_file(config=path)
conf_dict = get_subtable(conf_dict, NAMESPACE, allow_absence=False)
_DEFAULTS = cls(conf_dict, source, list(NAMESPACE))
Expand Down
179 changes: 171 additions & 8 deletions tests/test_toml_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,30 @@
Test the handling of TOML configs.
"""
import os
import pathlib
import re
import sys
from functools import partial
from platform import system
from pathlib import Path
from shutil import which
from subprocess import run, CompletedProcess
from tempfile import TemporaryDirectory
from textwrap import dedent
from typing import Generator, Sequence, Union

import pytest
import textwrap

from line_profiler.toml_config import ConfigSource


def write_text(path: pathlib.Path, text: str, /, *args, **kwargs) -> int:
text = textwrap.dedent(text).strip('\n')
def write_text(path: Path, text: str, /, *args, **kwargs) -> int:
text = dedent(text).strip('\n')
return path.write_text(text, *args, **kwargs)


@pytest.fixture(autouse=True)
def fresh_curdir(monkeypatch: pytest.MonkeyPatch,
tmp_path_factory: pytest.TempPathFactory) -> pathlib.Path:
tmp_path_factory: pytest.TempPathFactory) -> Path:
"""
Ensure that the tests start on a clean slate: they shouldn't see
the environment variable :envvar:`LINE_PROFILER_RC`, nor should the
Expand Down Expand Up @@ -61,7 +71,7 @@ def test_default_config_deep_copy() -> None:
assert column_widths is not default_2['show']['column_widths']


def test_table_normalization(fresh_curdir: pathlib.Path) -> None:
def test_table_normalization(fresh_curdir: Path) -> None:
"""
Test that even if a config file misses some items (and has so extra
ones), it is properly normalized to contain the same keys as the
Expand All @@ -86,7 +96,7 @@ def test_table_normalization(fresh_curdir: pathlib.Path) -> None:
assert loaded.conf_dict == default_config


def test_malformed_table(fresh_curdir: pathlib.Path) -> None:
def test_malformed_table(fresh_curdir: Path) -> None:
"""
Test that we get a `ValueError` when loading a malformed table with a
non-subtable value taking the place of a supposed subtable.
Expand All @@ -103,7 +113,7 @@ def test_malformed_table(fresh_curdir: pathlib.Path) -> None:


def test_config_lookup_hierarchy(monkeypatch: pytest.MonkeyPatch,
fresh_curdir: pathlib.Path) -> None:
fresh_curdir: Path) -> None:
"""
Test the hierarchy according to which we load config files.
"""
Expand Down Expand Up @@ -145,3 +155,156 @@ def test_config_lookup_hierarchy(monkeypatch: pytest.MonkeyPatch,
# (`None`), and `False` to disabling all lookup
assert ConfigSource.from_config(True).path.samefile(high_priority)
assert ConfigSource.from_config(False).path.samefile(default)


########################################################################
# START: edge-case test for `importlib_resources` #
########################################################################

# XXX: this addresses an edge case where `importlib.resources` have been
# superseded by `importlib_resources` during runtime.
# Do we REALLY need such an involved (and slow) test for something we
# don't otherwise interact with?


@pytest.fixture(scope='module')
def _venv() -> Generator[Path, None, None]:
"""
A MODULE-scoped fixture for a venv in which `line_profiler` has been
separately installed.
"""
with TemporaryDirectory() as tmpdir_:
tmpdir = Path(tmpdir_)
# Build the venv
venv = tmpdir / 'venv'
cmd = [sys.executable, '-m', 'venv', venv]
run_proc(cmd).check_returncode()
# Install `line_profiler` in the venv
# (somehow `--system-site-packages` doesn't work)
source = Path(__file__).parent.parent
install = run_pip_in_venv('install', [source], tmpdir, venv)
install.check_returncode()
yield venv


@pytest.fixture
def venv(tmp_path: Path, _venv: Path) -> Generator[Path, None, None]:
"""
A FUNCTION-scoped fixture for a venv in which:
- `line_profiler` has been separately installed, and
- `importlib-resources` is uninstalled after each test.

Yields:
venv (pathlib.Path):
The temporary venv directory.
"""
try:
yield _venv
finally:
run_pip_in_venv('uninstall', ['--yes', 'importlib-resources'],
tmp_path, _venv)


def run_proc(cmd: Sequence[Union[str, Path]], /, **kwargs) -> CompletedProcess:
"""Convenience wrapper around `subprocess.run()`."""
kwargs.update(text=True, capture_output=True)
proc = run([str(arg) for arg in cmd], **kwargs)
print(proc.stderr, end='', file=sys.stderr)
print(proc.stdout, end='')
return proc


def run_python_in_venv(args: Sequence[Union[str, Path]],
tmpdir: Path,
venv: Path) -> CompletedProcess:
"""Run `$ python *args` in `venv`."""
if 'windows' in system().lower(): # Use PowerShell on Windows
shell = which('PowerShell.exe')
assert shell is not None
script_file = tmpdir / 'script.ps1'
write_text(script_file, """
$Activate, $Remainder = $args
Invoke-Expression $Activate
python @Remainder
""")
base_cmd = [shell, '-NonInteractive', '-File', script_file,
venv / 'Scripts' / 'Activate.ps1']
else: # Use Bash otherwise (*nix etc.)
shell = which('bash')
assert shell is not None
script_file = tmpdir / 'script.bsh'
write_text(script_file, """
activate="$1"; shift
source "${activate}"
python "${@}"
""")
base_cmd = [shell, script_file, venv / 'bin' / 'activate']

return run_proc(base_cmd + list(args))


def run_pip_in_venv(
subcmd: str, args: Union[Sequence[Union[str, Path]], None] = None, /,
*a, **k) -> CompletedProcess:
"""Run `$ pip subcmd *args` in `venv`."""
cmd = ['-m', 'pip', subcmd, '--require-virtualenv', *(args or [])]
return run_python_in_venv(cmd, *a, **k)


@pytest.mark.parametrize(
'version',
[False, # Don't use `importlib_resources`
True, # Newest (`path()` imported from stdlib)
'< 6', # Legacy (defines `path()` but deprecates it)
'>= 6, < 6.4']) # Corner case (`path()` unavailable)
def test_backported_importlib_resources(
tmp_path: Path, venv: Path, version: Union[str, bool]) -> None:
"""
Test that the location of the installed TOML config file by
`line_profiler.toml_config` works even when `importlib.resources`
has been replaced by `importlib_resources` during runtime (see
GitHub issue #405).
"""
run_python = partial(run_python_in_venv, tmpdir=tmp_path, venv=venv)
run_pip = partial(run_pip_in_venv, tmpdir=tmp_path, venv=venv)

# Install the required `importlib_resources` version
if version:
ir = 'importlib_resources'
if isinstance(version, str):
ir = f'{ir} {version}'

run_pip('install', ['--upgrade', ir]).check_returncode()
run_pip('list')

# Run python code which substitutes `importlib.resources` with
# `importlib_resources` before importing `line_profiler` and see
# what happens
python_script = tmp_path / 'script.py'
if version:
preamble = dedent("""
import importlib
import importlib_resources as _ir
import sys

importlib.resources = sys.modules['importlib.resources'] = _ir
del _ir
""")
else:
preamble = ''
sanity_check = dedent("""
from line_profiler.toml_config import ConfigSource


print(ConfigSource.from_default())
""")
write_text(python_script, f'{preamble}\n\n{sanity_check}')
proc = run_python(['-W', 'always::DeprecationWarning', python_script])
proc.check_returncode()
assert not re.search('DeprecationWarning.*importlib[-_]?resources',
proc.stderr), proc.stderr


########################################################################
# END: edge-case test for `importlib_resources` #
########################################################################
Loading