Skip to content

Commit 32e3a9d

Browse files
authored
Merge pull request #34 from nlesc-nano/key
ENH,REL: Nano-Utils 2.3.0
2 parents 44513ce + adcbcd8 commit 32e3a9d

9 files changed

+176
-38
lines changed

.github/workflows/pythonpackage.yml

+14-16
Original file line numberDiff line numberDiff line change
@@ -44,16 +44,15 @@ jobs:
4444

4545
- name: Install dependencies
4646
shell: bash
47-
env:
48-
SPECIAL: ${{ matrix.special }}
4947
run: |
50-
if [[ $SPECIAL == '; no-optional' ]]; then
51-
pip install -e .[test_no_optional]
52-
elif [[ $SPECIAL == '; pre-release' ]]; then
53-
pip install --pre -e .[test] --upgrade --force-reinstall
54-
else
55-
pip install -e .[test]
56-
fi
48+
case "${{ matrix.special }}" in
49+
"; no-optional")
50+
pip install -e .[test_no_optional] ;;
51+
"; pre-release")
52+
pip install --pre -e .[test] --upgrade --force-reinstall ;;
53+
*)
54+
pip install -e .[test] ;;
55+
esac
5756
5857
- name: Python info
5958
run: |
@@ -65,14 +64,13 @@ jobs:
6564

6665
- name: Test with pytest
6766
shell: bash
68-
env:
69-
SPECIAL: ${{ matrix.special }}
7067
run: |
71-
if [[ $SPECIAL == '; no-optional' ]]; then
72-
pytest --mypy
73-
else
74-
pytest --mypy --doctest-modules
75-
fi
68+
case "${{ matrix.special }}" in
69+
"; no-optional")
70+
pytest --mypy ;;
71+
*)
72+
pytest --mypy --doctest-modules ;;
73+
esac
7674
7775
- name: Run codecov
7876
uses: codecov/codecov-action@v2

CHANGELOG.rst

+8
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ All notable changes to this project will be documented in this file.
66
This project adheres to `Semantic Versioning <http://semver.org/>`_.
77

88

9+
2.3.0
10+
*****
11+
* Added ``UserMapping`` entry points for the IPython key completioner
12+
and pretty printer.
13+
* Added a decorator for applying the effect of ``warnings.filterwarnings``
14+
to the decorated function.
15+
16+
917
2.2.0
1018
*****
1119
* Added a decorator for constructing positional-only signatures.

README.rst

+1-1
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222

2323

2424
################
25-
Nano-Utils 2.2.0
25+
Nano-Utils 2.3.0
2626
################
2727
Utility functions used throughout the various nlesc-nano repositories.
2828

nanoutils/__version__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""The **Nano-Utils** version."""
22

3-
__version__ = '2.2.0'
3+
__version__ = '2.3.0'

nanoutils/_user_dict.py

+32-8
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@
2626
from .utils import positional_only
2727
from .typing_utils import Protocol, runtime_checkable
2828

29+
if TYPE_CHECKING:
30+
from IPython.lib.pretty import RepresentationPrinter
31+
32+
class _ReprFunc(Protocol[_KT, _VT]):
33+
def __call__(self, __dct: dict[_KT, _VT], *, width: int) -> str: ...
34+
2935
__all__ = ["UserMapping", "MutableUserMapping", "_DictLike", "_SupportsKeysAndGetItem"]
3036

3137
_SENTINEL = object()
@@ -62,6 +68,17 @@ def __getitem__(self, __key: _KT) -> _VT_co:
6268
]
6369

6470

71+
def _repr_func(self: UserMapping[_KT, _VT], func: _ReprFunc[_KT, _VT]) -> str:
72+
"""Helper function for :meth:`UserMapping.__repr__`."""
73+
cls = type(self)
74+
dict_repr = func(self._dict, width=76)
75+
if len(dict_repr) <= 76:
76+
return f"{cls.__name__}({dict_repr})"
77+
else:
78+
dict_repr2 = textwrap.indent(dict_repr[1:-1], 3 * " ")
79+
return f"{cls.__name__}({{\n {dict_repr2},\n}})"
80+
81+
6582
class UserMapping(Mapping[_KT, _VT_co]):
6683
"""Base class for user-defined immutable mappings."""
6784

@@ -104,14 +121,21 @@ def copy(self: _ST1) -> _ST1:
104121
@reprlib.recursive_repr(fillvalue='...')
105122
def __repr__(self) -> str:
106123
"""Implement :func:`repr(self) <repr>`."""
107-
cls = type(self)
108-
width = 80 - 2 - len(cls.__name__)
109-
dct_repr = pformat(self._dict, width=width)
110-
if len(dct_repr) <= width:
111-
return f"{cls.__name__}({dct_repr})"
112-
else:
113-
dct_repr2 = textwrap.indent(dct_repr[1:-1], 3 * " ")
114-
return f"{cls.__name__}({{\n {dct_repr2},\n}})"
124+
return _repr_func(self, func=pformat)
125+
126+
def _repr_pretty_(self, p: RepresentationPrinter, cycle: bool) -> None:
127+
"""Entry point for the :mod:`IPython <IPython.lib.pretty>` pretty printer."""
128+
if cycle:
129+
p.text(f"{type(self).__name__}(...)")
130+
return None
131+
132+
from IPython.lib.pretty import pretty
133+
string = _repr_func(self, func=lambda dct, width: pretty(dct, max_width=width))
134+
p.text(string)
135+
136+
def _ipython_key_completions_(self) -> KeysView[_KT]:
137+
"""Entry point for the IPython key completioner."""
138+
return self.keys()
115139

116140
def __hash__(self) -> int:
117141
"""Implement :func:`hash(self) <hash>`.

nanoutils/utils.py

+74-2
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import warnings
1919
import importlib
2020
import inspect
21+
import functools
2122
from types import ModuleType
2223
from functools import wraps
2324
from typing import (
@@ -34,9 +35,10 @@
3435
MutableMapping,
3536
Collection,
3637
cast,
37-
overload
38+
overload,
3839
)
3940

41+
from .typing_utils import Literal
4042
from .empty import EMPTY_CONTAINER
4143

4244
__all__ = [
@@ -58,6 +60,7 @@
5860
'positional_only',
5961
'UserMapping',
6062
'MutableUserMapping',
63+
'warning_filter',
6164
]
6265

6366
_T = TypeVar('_T')
@@ -737,6 +740,75 @@ def positional_only(func: _FT) -> _FT:
737740
return func
738741

739742

743+
def warning_filter(
744+
action: Literal["default", "error", "ignore", "always", "module", "once"],
745+
message: str = "",
746+
category: type[Warning] = Warning,
747+
module: str = "",
748+
lineno: int = 0,
749+
append: bool = False,
750+
) -> Callable[[_FT], _FT]:
751+
"""A decorator for wrapping function calls with :func:`warnings.filterwarnings`.
752+
753+
Examples
754+
--------
755+
.. code-block:: python
756+
757+
>>> from nanoutils import warning_filter
758+
>>> import warnings
759+
760+
>>> @warning_filter("error", category=UserWarning)
761+
... def func():
762+
... warnings.warn("test", UserWarning)
763+
764+
>>> func()
765+
Traceback (most recent call last):
766+
...
767+
UserWarning: test
768+
769+
Parameters
770+
----------
771+
action : :class:`str`
772+
One of the following strings:
773+
774+
* ``"default"``: Print the first occurrence of matching warnings for each location (module + line number) where the warning is issued
775+
* ``"error"``: Turn matching warnings into exceptions
776+
* ``"ignore"``: Never print matching warnings
777+
* ``"always"``: Always print matching warnings
778+
* ``"module"``: Print the first occurrence of matching warnings for each module where the warning is issued (regardless of line number)
779+
* ``"once"``: Print only the first occurrence of matching warnings, regardless of location
780+
781+
message : :class:`str`, optional
782+
A string containing a regular expression that the start of the warning message must match.
783+
The expression is compiled to always be case-insensitive.
784+
category : :class:`type[Warning] <type>`
785+
The to-be affected :class:`Warning` (sub-)class.
786+
module : :class:`str`, optional
787+
A string containing a regular expression that the module name must match.
788+
The expression is compiled to be case-sensitive.
789+
lineno : :class:`int`
790+
An integer that the line number where the warning occurred must match,
791+
or 0 to match all line numbers.
792+
append : :class:`bool`
793+
Whether the warning entry is inserted at the end.
794+
795+
See Also
796+
--------
797+
:func:`warnings.filterwarnings` :
798+
Insert a simple entry into the list of warnings filters (at the front).
799+
800+
"""
801+
def decorator(func: _FT) -> _FT:
802+
@functools.wraps(func)
803+
def wrapper(*args, **kwargs):
804+
with warnings.catch_warnings():
805+
warnings.filterwarnings(action, message, category, module, lineno, append)
806+
ret = func(*args, **kwargs)
807+
return ret
808+
return cast(_FT, wrapper)
809+
return decorator
810+
811+
740812
# Move to the end to reduce the risk of circular imports
741813
from ._partial import PartialPrepend
742814
from ._set_attr import SetAttr
@@ -747,5 +819,5 @@ def positional_only(func: _FT) -> _FT:
747819

748820
__doc__ = construct_api_doc(
749821
globals(),
750-
decorators={'set_docstring', 'raise_if', 'ignore_if', 'positional_only'},
822+
decorators={'set_docstring', 'raise_if', 'ignore_if', 'positional_only', 'warning_filter'},
751823
)

setup.py

+1
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
'pyyaml',
4343
'h5py',
4444
'numpy',
45+
'ipython',
4546
]
4647
tests_require += docs_require
4748
tests_require += build_requires

tests/test_dtype_mapping.py

+19-6
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import sys
44
import textwrap
55
from typing import TYPE_CHECKING, no_type_check
6-
from collections.abc import Iterator
6+
from collections.abc import Iterator, Callable
77

88
import pytest
99
from assertionlib import assertion
@@ -18,6 +18,14 @@
1818
import numpy.typing as npt
1919
import _pytest
2020

21+
try:
22+
from IPython.lib.pretty import pretty
23+
except ModuleNotFoundError:
24+
IPYTHON: bool = False
25+
pretty = NotImplemented
26+
else:
27+
IPYTHON = True
28+
2129

2230
class BasicMapping:
2331
def __init__(self, dct: dict[str, npt.DTypeLike]) -> None:
@@ -128,16 +136,21 @@ def test_repr(self, obj: DTypeMapping) -> None:
128136
)""").strip()
129137
assertion.str_eq(obj, string1, str_converter=repr)
130138

131-
string2 = textwrap.dedent(f"""
139+
string2 = f"{type(obj).__name__}()"
140+
assertion.str_eq(type(obj)(), string2, str_converter=repr)
141+
142+
@pytest.mark.parametrize("str_func", [
143+
str,
144+
pytest.param(pretty, marks=pytest.mark.skipif(not IPYTHON, reason="Requires IPython")),
145+
], ids=["str", "pretty"])
146+
def test_str(self, obj: DTypeMapping, str_func: Callable[[object], str]) -> None:
147+
string = textwrap.dedent(f"""
132148
{type(obj).__name__}(
133149
a = int64,
134150
b = float64,
135151
c = <U5,
136152
)""").strip()
137-
assertion.str_eq(obj, string2, str_converter=str)
138-
139-
string3 = f"{type(obj).__name__}()"
140-
assertion.str_eq(type(obj)(), string3, str_converter=repr)
153+
assertion.str_eq(obj, string, str_converter=str_func)
141154

142155
@pytest.mark.skipif(sys.version_info < (3, 9), reason="Requires python >= 3.9")
143156
@no_type_check

tests/test_user_mapping.py

+26-4
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import textwrap
88
import string
99
from typing import TYPE_CHECKING, no_type_check
10-
from collections.abc import KeysView, ValuesView, ItemsView, Iterator
10+
from collections.abc import KeysView, ValuesView, ItemsView, Iterator, Callable
1111

1212
import pytest
1313
from assertionlib import assertion
@@ -17,6 +17,14 @@
1717
if TYPE_CHECKING:
1818
import _pytest
1919

20+
try:
21+
from IPython.lib.pretty import pretty
22+
except ModuleNotFoundError:
23+
IPYTHON: bool = False
24+
pretty = NotImplemented
25+
else:
26+
IPYTHON = True
27+
2028

2129
class BasicMapping:
2230
def __init__(self, dct: dict[str, int]) -> None:
@@ -75,9 +83,14 @@ def test_eq(self, obj: UserMapping[str, int]) -> None:
7583
def test_getitem(self, obj: UserMapping[str, int], key: str, value: int) -> None:
7684
assertion.eq(obj[key], value)
7785

78-
def test_repr(self, obj: UserMapping[str, int]) -> None:
86+
@pytest.mark.parametrize("str_func", [
87+
str,
88+
repr,
89+
pytest.param(pretty, marks=pytest.mark.skipif(not IPYTHON, reason="Requires IPython")),
90+
], ids=["str", "repr", "pretty"])
91+
def test_repr(self, obj: UserMapping[str, int], str_func: Callable[[object], str]) -> None:
7992
string1 = f"{type(obj).__name__}({{'a': 0, 'b': 1, 'c': 2}})"
80-
assertion.str_eq(obj, string1)
93+
assertion.str_eq(obj, string1, str_converter=str_func)
8194

8295
cls = type(obj)
8396
ref2 = cls(zip(string.ascii_lowercase[:12], range(12)))
@@ -97,7 +110,12 @@ def test_repr(self, obj: UserMapping[str, int]) -> None:
97110
'l': 11,
98111
}})
99112
""").strip()
100-
assertion.str_eq(ref2, string2)
113+
assertion.str_eq(ref2, string2, str_converter=str_func)
114+
115+
@pytest.mark.skipif(not IPYTHON, reason="Rquires IPython")
116+
def test_pretty_repr(self, obj: UserMapping[str, int]) -> None:
117+
string1 = f"{type(obj).__name__}({{'a': 0, 'b': 1, 'c': 2}})"
118+
assertion.str_eq(obj, string1, str_converter=pretty)
101119

102120
def test_hash(self, obj: UserMapping[str, int]) -> None:
103121
if isinstance(obj, MutableUserMapping):
@@ -134,6 +152,10 @@ def test_fromkeys(self, obj: UserMapping[str, int]) -> None:
134152
assertion.isinstance(dct, cls)
135153
assertion.eq(dct.keys(), obj.keys())
136154

155+
def test_key_completions(self, obj: UserMapping[str, int]) -> None:
156+
assertion.isinstance(obj._ipython_key_completions_(), KeysView)
157+
assertion.eq(obj._ipython_key_completions_(), obj.keys())
158+
137159
def test_get(self, obj: UserMapping[str, int]) -> None:
138160
assertion.eq(obj.get("a"), 0)
139161
assertion.is_(obj.get("d"), None)

0 commit comments

Comments
 (0)