"""
Read and resolve user-supplied TOML files and combine them with the
default to generate configurations.
"""
import copy
import dataclasses
import functools
import importlib.resources
import itertools
import os
import pathlib
try:
import tomllib
except ImportError: # Python < 3.11
import tomli as tomllib # type: ignore[no-redef] # noqa: F811
from collections.abc import Mapping
from typing import Dict, List, Any
__all__ = ['ConfigSource']
NAMESPACE = 'tool', 'line_profiler'
TARGETS = 'line_profiler.toml', 'pyproject.toml'
ENV_VAR = 'LINE_PROFILER_RC'
_DEFAULTS = None
[docs]
@dataclasses.dataclass
class ConfigSource:
"""
Object encapsulating the config dict and the source whence it is
read from.
Attributes:
conf_dict (dict[str, Any])
The combination of the ``tool.line_profiler`` tables of the
provided/looked-up config file (if any) and the default as a
dictionary.
path (pathlib.Path)
Absolute path to the config file whence the config options
are loaded.
subtable (list[str])
Sequence of table headers under which in
:py:attr:`~.ConfigSource.path`
:py:attr:`~.ConfigSource.conf_dict` can be found.
"""
conf_dict: Dict[str, Any]
path: pathlib.Path
subtable: List[str]
[docs]
def copy(self):
"""
Returns:
Copy of the object.
"""
return type(self)(
copy.deepcopy(self.conf_dict), self.path, self.subtable.copy())
[docs]
def get_subconfig(self, *headers, allow_absence=False, copy=False):
"""
Arguments:
headers (str):
Table headers.
allow_absence (bool):
If true, allow for the keys to be absent (and return an
instance with an empty
:py:attr:`~.ConfigSource.conf_dict`);
otherwise, raise a :py:class:`KeyError`.
copy (bool):
If true, create a (deep) copy of the subtable in
``self`` for the new instance's
:py:attr:`~.ConfigSource.conf_dict`;
otherwise, just refer to the existing subtable.
Returns:
New instance which consists of the required subtable of the
existing one.
Example:
>>> default = ConfigSource.from_default()
>>> display_widths = default.get_subconfig(
... 'show', 'column_widths')
>>> assert display_widths.path == default.path
>>> assert (display_widths.subtable
... == default.subtable + ['show', 'column_widths'])
>>> assert (display_widths.conf_dict
... is default.conf_dict['show']['column_widths'])
"""
new_dict = get_subtable(
self.conf_dict, headers, allow_absence=allow_absence)
new_subtable = [*self.subtable, *headers]
return type(self)(new_dict, self.path, new_subtable)
[docs]
@classmethod
def from_default(cls, *, copy=True):
"""
Get the default TOML configuration that ships with the package.
Arguments:
copy (bool):
Whether to make a copy.
Returns:
New instance if ``copy`` is true, the global default
instance otherwise.
"""
# Note: `importlib.resources.path()` is deprecated on 3.11 and
# legacy patch versions of 3.12, and only later un-deprecated
# on 3.13 onwards. So we use the newer APIs where available.
# (See the discussions on 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 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))
if not copy:
return _DEFAULTS
return _DEFAULTS.copy()
[docs]
@classmethod
def from_config(cls, config=None, *, read_env=True):
"""
Create an instance by loading from a config file.
Arguments:
config (str | os.PathLike[str] | bool | None):
Optional path to a specific TOML file;
if a (string) path, try to read from that file;
if :py:data:`None` or :py:data:`True`, look up and
resolve to the correct file;
if :py:data:`False`, just return a copy of the default
config (see :py:meth:`~.ConfigSource.from_default`).
read_env (bool):
How to look up the config file if not provided (i.e.
``config = None`` or equivalently :py:data:`True`):
:py:data:`True`
Try to read the environment variable
:envvar:`!LINE_PROFILER_RC` as the path to a config
file;
if that fails, fall back to the default configuation
(see :py:meth:`~.ConfigSource.from_default`).
:py:data:`False`
Use path-based lookup (see Note) to resolve to a
config file.
Returns:
New instance
Note:
* For the config TOML file, it is required that each of the
following keys either is absent or maps to a table:
* ``tool`` and ``tool.line_profiler``
* ``tool.line_profiler.kernprof``, ``.cli``, ``.setup``,
``.write``, and ``.show``
* ``tool.line_profiler.show.column_widths``
If this is not the case:
* If ``config`` is provided, a :py:class:`ValueError` is
raised.
* Otherwise, the looked-up file is considered invalid and
ignored.
* When performing path-based lookup:
* The current directory is checked first to see if it has
a valid, readable TOML file named ``line_profiler.toml``.
* If not, check if there is a valid, readable TOML file
named ``pyproject.toml``.
* If not, check the parent directory, and so on.
* If we reached the file-system root without finding a
valid, readable TOML file, fall back to the default
configuration (see
:py:meth:`~.ConfigSource.from_default`).
"""
def merge(template, supplied):
if not (isinstance(template, dict) and isinstance(supplied, dict)):
return supplied
result = {}
for key, default in template.items():
if key in supplied:
result[key] = merge(default, supplied[key])
else:
result[key] = default
return result
default_instance = cls.from_default()
if config in (True, False):
if config:
config = None
else:
return default_instance
if config is not None:
# Promote to `Path` (and catch type errors) early
config = pathlib.Path(config)
if read_env:
get_conf = functools.partial(find_and_read_config_file,
config=config)
else: # Shield the lookup from the environment
get_conf = functools.partial(find_and_read_config_file,
config=config, env_var=None)
try:
content, source = get_conf()
except TypeError: # Got `None`
if config:
if os.path.exists(config):
Error = ValueError
else:
Error = FileNotFoundError
raise Error(
f'Cannot load configurations from {config!r}') from None
return default_instance
conf = {}
try:
for header in get_headers(default_instance.conf_dict):
# Get the top-level subtable
key, *subheader = header
subtable = get_subtable(content, [*NAMESPACE, key])
# Check the existence of nested subtables (if any)
get_subtable(subtable, subheader)
# If it looks OK, remember the top-level subtable
conf.setdefault(key, subtable)
except (TypeError, AttributeError):
if config is None:
# No file explicitly provided and the looked-up file is
# invalid, just fall back to the default configs
return default_instance
else:
# The explicitly provided config file is invalid, raise
# an error
all_headers = {'tool', 'tool.line_profiler'}
all_headers.update(
'.'.join(('tool.line_profiler', *header))
for header in get_headers(default_instance.conf_dict,
include_implied=True))
raise ValueError(
f'config = {config!r}: expected each of these keys to '
'either be nonexistent or map to a table: '
f'{sorted(all_headers)!r}') from None
# Filter the content of `conf` down to just the key-value pairs
# pairs present in the default configs
return cls(
merge(default_instance.conf_dict, conf), source, list(NAMESPACE))
def find_and_read_config_file(
*, config=None, env_var=ENV_VAR, targets=TARGETS):
"""
Arguments:
config (str | os.PathLike[str] | None):
Optional path to a specific TOML file;
if provided, skip lookup and just try to read from that file
env_var (str | None):
Name of the of the environment variable containing the path
to a TOML file;
if true-y and if ``config`` isn't provided, skip lookup and
just try to read from that file
targets (Sequence[str | os.PathLike[str]]):
Filenames among which TOML files are looked up (if neither
``config`` or ``env_var`` is given)
Returns:
If the provided/looked-up file is readable and is valid TOML:
tuple[dict, Path]: content, path
* ``content``: parsed content of the file as a
dictionary
* ``path``: absolute path to the file
Otherwise
None
"""
def iter_configs(dir_path):
for dpath in itertools.chain((dir_path,), dir_path.parents):
for target in targets:
cfg = dpath / target
try:
if cfg.is_file():
yield cfg
except OSError: # E.g. permission errors
pass
if config:
configs = pathlib.Path(config).absolute(),
elif env_var and os.environ.get(env_var):
configs = pathlib.Path(os.environ[env_var]).absolute(),
else:
pwd = pathlib.Path.cwd().absolute()
configs = iter_configs(pwd)
for config in configs:
try:
with config.open(mode='rb') as fobj:
return tomllib.load(fobj), config
except (OSError, tomllib.TOMLDecodeError):
pass
return None
def get_subtable(table, keys, *, allow_absence=True):
"""
Arguments:
table (Mapping):
(Nested) Mapping.
keys (Sequence):
Sequence of keys for item access on ``table`` and its
descendant tables.
allow_absence (bool):
If true, allow for the keys to be absent;
otherwise, raise a :py:class:`KeyError`.
Returns:
Mapping: subtable
Example:
>>> table = {'a': 1, 'b': {'c': 2, 'd': 3, 'e': {}}}
>>> assert get_subtable(table, []) == table
>>> assert get_subtable(table, ['b']) == table['b']
>>> assert get_subtable(table, ['b', 'e']) == table['b']['e']
>>> assert get_subtable(table, ['c']) == {}
>>> get_subtable(table, ['c'], allow_absence=False)
Traceback (most recent call last):
...
KeyError: 'c'
>>> get_subtable( # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE
... table, ['a'])
Traceback (most recent call last):
...
TypeError: table = ..., keys = ['a']:
expected result to be a mapping, got a `int` (1)
"""
subtable = table
for key in keys:
if allow_absence:
subtable = subtable.get(key, {})
else:
subtable = subtable[key]
if not isinstance(subtable, Mapping):
raise TypeError(f'table = {table!r}, keys = {list(keys)!r}: '
'expected result to be a mapping, got a '
f'`{type(subtable).__name__}` ({subtable!r})')
return subtable
def get_headers(table, *, include_implied=False):
"""
Arguments:
table (Mapping):
(Nested) Mapping.
include_implied (bool):
if false and if a subtable has other subtables, only the
terminal key sequences are returned (that is to say, if
``table['a']['b']`` is a subtable, then ``('a',)`` is only
in ``headers`` if ``include_implied`` is true.
Returns:
set[tuple]: headers
Key sequences corresponding to the subtables of ``table``.
Example:
>>> table = {'a': 1,
... 'b': {'c': 2, 'd': 3, 'e': {}, 'f': {'g': 4}},
... 'h': {'i': 5}}
>>> assert get_headers(table) == {
... ('b', 'e'), ('b', 'f'), ('h',)}
>>> assert get_headers(table, include_implied=True) == {
... ('b',), ('b', 'e'), ('b', 'f'), ('h',)}
>>> assert get_headers({}) == set()
>>> assert get_headers({'a': 1, 'b': 2}) == set()
"""
results = set()
for key, value in table.items():
if not isinstance(value, Mapping):
continue
subheaders = get_headers(value, include_implied=include_implied)
if subheaders:
results.update((key,) + header for header in subheaders)
if include_implied or not subheaders:
results.add((key,))
return results