Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package python-psygnal for openSUSE:Factory checked in at 2025-05-20 09:32:53 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/python-psygnal (Old) and /work/SRC/openSUSE:Factory/.python-psygnal.new.30101 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-psygnal" Tue May 20 09:32:53 2025 rev:8 rq:1277832 version:0.13.0 Changes: -------- --- /work/SRC/openSUSE:Factory/python-psygnal/python-psygnal.changes 2025-04-30 19:04:38.426971570 +0200 +++ /work/SRC/openSUSE:Factory/.python-psygnal.new.30101/python-psygnal.changes 2025-05-20 09:35:00.837502815 +0200 @@ -1,0 +2,17 @@ +Thu May 15 08:05:13 UTC 2025 - John Paul Adrian Glaubitz <adrian.glaub...@suse.com> + +- Update to 0.13.0 + * feat: add testing utilities (#368) + * fix: Don't use deprecated model_fields access (#364) + * build: fix building of wheels with uv (#370) + * ci(pre-commit.ci): autoupdate (#369) + * docs: general docs update, use mkdocs-api-autonav (#367) + * build: use pyproject dependency groups and uv (#366) + * ci(dependabot): bump pypa/cibuildwheel from 2.22 to 2.23 (#360) + * Add back universal (none-any) wheel (#358) + * ci(pre-commit.ci): autoupdate (#355) +- Drop support-pydantic-211.patch, merged upstream +- Update Suggests from pyproject.toml +- Use Python 3.11 on SLE-15 by default + +------------------------------------------------------------------- Old: ---- psygnal-0.12.0.tar.gz support-pydantic-211.patch New: ---- psygnal-0.13.0.tar.gz BETA DEBUG BEGIN: Old: * ci(pre-commit.ci): autoupdate (#355) - Drop support-pydantic-211.patch, merged upstream - Update Suggests from pyproject.toml BETA DEBUG END: ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ python-psygnal.spec ++++++ --- /var/tmp/diff_new_pack.HL211x/_old 2025-05-20 09:35:02.637578001 +0200 +++ /var/tmp/diff_new_pack.HL211x/_new 2025-05-20 09:35:02.645578335 +0200 @@ -16,15 +16,14 @@ # +%{?sle15_python_module_pythons} Name: python-psygnal -Version: 0.12.0 +Version: 0.13.0 Release: 0 Summary: Fast python callback/event system modeled after Qt Signals License: BSD-3-Clause URL: https://github.com/pyapp-kit/psygnal Source: https://files.pythonhosted.org/packages/source/p/psygnal/psygnal-%{version}.tar.gz -# PATCH-FIX-UPSTREAM gh#pyapp-kit/psygnal#364 -Patch0: support-pydantic-211.patch BuildRequires: %{python_module hatch-vcs} BuildRequires: %{python_module hatchling >= 1.8.0} BuildRequires: %{python_module pip} @@ -46,7 +45,7 @@ Suggests: python-numpy Suggests: python-pydantic Suggests: python-qtpy -Suggests: python-rich +Suggests: python-rich >= 14.0.0 Suggests: python-wrapt Suggests: python-griffe == 0.25.5 Suggests: python-wrapt ++++++ psygnal-0.12.0.tar.gz -> psygnal-0.13.0.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/psygnal-0.12.0/.gitignore new/psygnal-0.13.0/.gitignore --- old/psygnal-0.12.0/.gitignore 2025-02-03 16:41:13.000000000 +0100 +++ new/psygnal-0.13.0/.gitignore 2025-05-06 00:09:46.000000000 +0200 @@ -113,3 +113,6 @@ psygnal/_version.py .asv/ wheelhouse/ + +# for now... +uv.lock \ No newline at end of file diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/psygnal-0.12.0/CHANGELOG.md new/psygnal-0.13.0/CHANGELOG.md --- old/psygnal-0.12.0/CHANGELOG.md 2025-02-03 16:41:13.000000000 +0100 +++ new/psygnal-0.13.0/CHANGELOG.md 2025-05-06 00:09:46.000000000 +0200 @@ -1,5 +1,27 @@ # Changelog +## [v0.13.0](https://github.com/pyapp-kit/psygnal/tree/v0.13.0) (2025-05-05) + +[Full Changelog](https://github.com/pyapp-kit/psygnal/compare/v0.12.0...v0.13.0) + +**Implemented enhancements:** + +- feat: add testing utilities [\#368](https://github.com/pyapp-kit/psygnal/pull/368) ([tlambert03](https://github.com/tlambert03)) + +**Fixed bugs:** + +- fix: Don't use deprecated model\_fields access [\#364](https://github.com/pyapp-kit/psygnal/pull/364) ([s-t-e-v-e-n-k](https://github.com/s-t-e-v-e-n-k)) + +**Merged pull requests:** + +- build: fix building of wheels with uv [\#370](https://github.com/pyapp-kit/psygnal/pull/370) ([tlambert03](https://github.com/tlambert03)) +- ci\(pre-commit.ci\): autoupdate [\#369](https://github.com/pyapp-kit/psygnal/pull/369) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci)) +- docs: general docs update, use mkdocs-api-autonav [\#367](https://github.com/pyapp-kit/psygnal/pull/367) ([tlambert03](https://github.com/tlambert03)) +- build: use pyproject dependency groups and uv [\#366](https://github.com/pyapp-kit/psygnal/pull/366) ([tlambert03](https://github.com/tlambert03)) +- ci\(dependabot\): bump pypa/cibuildwheel from 2.22 to 2.23 [\#360](https://github.com/pyapp-kit/psygnal/pull/360) ([dependabot[bot]](https://github.com/apps/dependabot)) +- Add back universal \(none-any\) wheel [\#358](https://github.com/pyapp-kit/psygnal/pull/358) ([tlambert03](https://github.com/tlambert03)) +- ci\(pre-commit.ci\): autoupdate [\#355](https://github.com/pyapp-kit/psygnal/pull/355) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci)) + ## [v0.12.0](https://github.com/pyapp-kit/psygnal/tree/v0.12.0) (2025-02-03) [Full Changelog](https://github.com/pyapp-kit/psygnal/compare/v0.11.1...v0.12.0) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/psygnal-0.12.0/PKG-INFO new/psygnal-0.13.0/PKG-INFO --- old/psygnal-0.12.0/PKG-INFO 2025-02-03 16:41:13.000000000 +0100 +++ new/psygnal-0.13.0/PKG-INFO 2025-05-06 00:09:46.000000000 +0200 @@ -1,6 +1,6 @@ Metadata-Version: 2.4 Name: psygnal -Version: 0.12.0 +Version: 0.13.0 Summary: Fast python callback/event system modeled after Qt Signals Project-URL: homepage, https://github.com/pyapp-kit/psygnal Project-URL: repository, https://github.com/pyapp-kit/psygnal @@ -19,54 +19,10 @@ Classifier: Programming Language :: Python :: 3.13 Classifier: Typing :: Typed Requires-Python: >=3.9 -Provides-Extra: dev -Requires-Dist: attrs; extra == 'dev' -Requires-Dist: dask[array]>=2024.0.0; extra == 'dev' -Requires-Dist: ipython; extra == 'dev' -Requires-Dist: msgspec; extra == 'dev' -Requires-Dist: mypy; extra == 'dev' -Requires-Dist: mypy-extensions; extra == 'dev' -Requires-Dist: numpy>1.21.6; extra == 'dev' -Requires-Dist: pre-commit; extra == 'dev' -Requires-Dist: pydantic; extra == 'dev' -Requires-Dist: pyinstaller>=4.0; extra == 'dev' -Requires-Dist: pyqt6; extra == 'dev' -Requires-Dist: pytest-cov; extra == 'dev' -Requires-Dist: pytest-mypy-plugins; extra == 'dev' -Requires-Dist: pytest-qt; extra == 'dev' -Requires-Dist: pytest>=6.0; extra == 'dev' -Requires-Dist: qtpy; extra == 'dev' -Requires-Dist: rich; extra == 'dev' -Requires-Dist: ruff; extra == 'dev' -Requires-Dist: toolz; extra == 'dev' -Requires-Dist: typing-extensions; extra == 'dev' -Requires-Dist: wrapt; extra == 'dev' -Provides-Extra: docs -Requires-Dist: griffe==0.25.5; extra == 'docs' -Requires-Dist: mkdocs-material==8.5.10; extra == 'docs' -Requires-Dist: mkdocs-minify-plugin; extra == 'docs' -Requires-Dist: mkdocs-spellcheck[all]; extra == 'docs' -Requires-Dist: mkdocs==1.4.2; extra == 'docs' -Requires-Dist: mkdocstrings-python==0.8.3; extra == 'docs' -Requires-Dist: mkdocstrings==0.20.0; extra == 'docs' Provides-Extra: proxy Requires-Dist: wrapt; extra == 'proxy' Provides-Extra: pydantic Requires-Dist: pydantic; extra == 'pydantic' -Provides-Extra: test -Requires-Dist: attrs; extra == 'test' -Requires-Dist: dask[array]>=2024.0.0; extra == 'test' -Requires-Dist: msgspec; extra == 'test' -Requires-Dist: numpy>1.21.6; extra == 'test' -Requires-Dist: pydantic; extra == 'test' -Requires-Dist: pyinstaller>=4.0; extra == 'test' -Requires-Dist: pytest-cov; extra == 'test' -Requires-Dist: pytest>=6.0; extra == 'test' -Requires-Dist: toolz; extra == 'test' -Requires-Dist: wrapt; extra == 'test' -Provides-Extra: testqt -Requires-Dist: pytest-qt; extra == 'testqt' -Requires-Dist: qtpy; extra == 'testqt' Description-Content-Type: text/markdown # psygnal @@ -192,16 +148,22 @@ ## Developers +### Setup + +This project uses PEP 735 dependency groups. + +After cloning, setup your env with `uv sync` or `pip install -e . --group dev` + ### Compiling While `psygnal` is a pure python package, it is compiled with mypyc to increase performance. To test the compiled version locally, you can run: ```bash -make build +HATCH_BUILD_HOOKS_ENABLE=1 uv sync --force-reinstall ``` -(which is just an alias for `HATCH_BUILD_HOOKS_ENABLE=1 pip install -e .`) +(which is also available as `make build` if you have make installed) ### Debugging diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/psygnal-0.12.0/README.md new/psygnal-0.13.0/README.md --- old/psygnal-0.12.0/README.md 2025-02-03 16:41:13.000000000 +0100 +++ new/psygnal-0.13.0/README.md 2025-05-06 00:09:46.000000000 +0200 @@ -121,16 +121,22 @@ ## Developers +### Setup + +This project uses PEP 735 dependency groups. + +After cloning, setup your env with `uv sync` or `pip install -e . --group dev` + ### Compiling While `psygnal` is a pure python package, it is compiled with mypyc to increase performance. To test the compiled version locally, you can run: ```bash -make build +HATCH_BUILD_HOOKS_ENABLE=1 uv sync --force-reinstall ``` -(which is just an alias for `HATCH_BUILD_HOOKS_ENABLE=1 pip install -e .`) +(which is also available as `make build` if you have make installed) ### Debugging diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/psygnal-0.12.0/pyproject.toml new/psygnal-0.13.0/pyproject.toml --- old/psygnal-0.12.0/pyproject.toml 2025-02-03 16:41:13.000000000 +0100 +++ new/psygnal-0.13.0/pyproject.toml 2025-05-06 00:09:46.000000000 +0200 @@ -32,17 +32,10 @@ # extras # https://peps.python.org/pep-0621/#dependencies-optional-dependencies [project.optional-dependencies] -docs = [ - "griffe==0.25.5", - "mkdocs-material==8.5.10", - "mkdocs-minify-plugin", - "mkdocs==1.4.2", - "mkdocstrings-python==0.8.3", - "mkdocstrings==0.20.0", - "mkdocs-spellcheck[all]", -] proxy = ["wrapt"] pydantic = ["pydantic"] + +[dependency-groups] test = [ "dask[array]>=2024.0.0", "attrs", @@ -55,19 +48,30 @@ "msgspec", "toolz", ] -testqt = ["pytest-qt", "qtpy"] - +testqt = [{ include-group = "test" }, "pytest-qt", "qtpy"] +test-codspeed = [{ include-group = "test" }, "pytest-codspeed"] +docs = [ + "mkdocs-api-autonav", + "mkdocs-material", + "mkdocs-minify-plugin", + "mkdocs-spellcheck[all]", + "mkdocs", + "mkdocstrings-python", + "ruff", +] dev = [ - "psygnal[test, testqt]", + { include-group = "test" }, + { include-group = "docs" }, "PyQt6", "ipython", "mypy", "mypy_extensions", "pre-commit", "pytest-mypy-plugins", - "rich", "ruff", "typing-extensions", + "rich>=14.0.0", + "pdbpp; sys_platform != 'win32'", ] [project.urls] @@ -116,9 +120,10 @@ # Skip 32-bit builds & PyPy wheels on all platforms skip = ["*-manylinux_i686", "*-musllinux_i686", "*-win32", "pp*"] build = ["cp39-*", "cp310-*", "cp311-*", "cp312-*", "cp313-*"] -test-extras = ["test"] +test-groups = ["test"] test-command = "pytest {project}/tests -v" test-skip = ["*-musllinux*", "cp312-win*", "*-macosx_arm64"] +build-frontend = "build[uv]" [[tool.cibuildwheel.overrides]] select = "*-manylinux_i686*" @@ -127,6 +132,10 @@ [tool.cibuildwheel.environment] HATCH_BUILD_HOOKS_ENABLE = "1" +[tool.check-wheel-contents] +# W004: Module is not located at importable path (hook-psygnal.py) +ignore = ["W004"] + # https://docs.astral.sh/ruff/ [tool.ruff] line-length = 88 @@ -147,7 +156,7 @@ "C4", # flake8-comprehensions "B", # flake8-bugbear "A001", # flake8-builtins - "TC", # flake8-typecheck + "TC", # flake8-typecheck "TID", # flake8-tidy-imports "RUF", # ruff-specific rules ] @@ -167,6 +176,7 @@ [tool.pytest.ini_options] minversion = "6.0" testpaths = ["tests"] +addopts = ["--color=yes"] filterwarnings = [ "error", "ignore:The distutils package is deprecated:DeprecationWarning:", diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/psygnal-0.12.0/src/psygnal/__init__.py new/psygnal-0.13.0/src/psygnal/__init__.py --- old/psygnal-0.12.0/src/psygnal/__init__.py 2025-02-03 16:41:13.000000000 +0100 +++ new/psygnal-0.13.0/src/psygnal/__init__.py 2025-05-06 00:09:46.000000000 +0200 @@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Any if TYPE_CHECKING: - from ._evented_model import EventedModel # noqa: TC004 + from ._evented_model import EventedModel try: diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/psygnal-0.12.0/src/psygnal/_evented_model.py new/psygnal-0.13.0/src/psygnal/_evented_model.py --- old/psygnal-0.12.0/src/psygnal/_evented_model.py 2025-02-03 16:41:13.000000000 +0100 +++ new/psygnal-0.13.0/src/psygnal/_evented_model.py 2025-05-06 00:09:46.000000000 +0200 @@ -111,7 +111,8 @@ ) -> dict[str, Any]: """Get possibly nested default values for a Model object.""" dflt = {} - for k, v in obj.model_fields.items(): + cls = obj if isinstance(obj, type) else type(obj) + for k, v in cls.model_fields.items(): d = v.get_default() if ( d is None @@ -125,7 +126,9 @@ def _get_config(cls: pydantic.BaseModel) -> "ConfigDict": return cls.model_config - def _get_fields(cls: pydantic.BaseModel) -> dict[str, pydantic.fields.FieldInfo]: + def _get_fields( + cls: type[pydantic.BaseModel], + ) -> dict[str, pydantic.fields.FieldInfo]: return cls.model_fields def _model_dump(obj: pydantic.BaseModel) -> dict: @@ -384,7 +387,7 @@ (see [pydantic docs](https://pydantic-docs.helpmanual.io/usage/models/)), this class adds the following: - 1. gains an `events` attribute that is an instance of [`psygnal.SignalGroup`][]. + 1. Gains an `events` attribute that is an instance of [`psygnal.SignalGroup`][]. This group will have a signal for each field in the model (excluding private attributes and non-mutable fields). Whenever a field in the model is mutated, the corresponding signal will emit with the new value (see example below). @@ -404,14 +407,16 @@ dependencies by inspecting the source code of the property getter for. 4. If you would like to allow custom fields to provide their own json_encoders, you - can either use the standard pydantic method of adding json_encoders to your - model, for each field type you'd like to support: - https://pydantic-docs.helpmanual.io/usage/exporting_models/#json_encoders - This `EventedModel` class will additionally look for a `_json_encode` method - on any field types in the model. If a field type declares a `_json_encode` - method, it will be added to the - [`json_encoders`](https://pydantic-docs.helpmanual.io/usage/exporting_models/#json_encoders) - dict in the model `Config`. + can either: + + 1. use the [standard pydantic + method](https://pydantic-docs.helpmanual.io/usage/exporting_models) of adding + json_encoders to your model, for each field type you'd like to support: 1. This + `EventedModel` class will additionally look for a `_json_encode` method on any + field types in the model. If a field type declares a `_json_encode` method, it + will be added to the + [`json_encoders`](https://pydantic-docs.helpmanual.io/usage/exporting_models/#json_encoders) + dict in the model `Config`. (Prefer using the standard pydantic method) Examples -------- @@ -547,7 +552,7 @@ def reset(self) -> None: """Reset the state of the model to default values.""" model_config = _get_config(self) - model_fields = _get_fields(self) + model_fields = _get_fields(type(self)) for name, value in self._defaults.items(): if isinstance(value, EventedModel): cast("EventedModel", getattr(self, name)).reset() @@ -710,6 +715,6 @@ yield finally: if before is not NULL: # pragma: no cover - cls.model_config["use_enum_values"] = cast(bool, before) + cls.model_config["use_enum_values"] = cast("bool", before) else: cls.model_config.pop("use_enum_values") diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/psygnal-0.12.0/src/psygnal/_group_descriptor.py new/psygnal-0.13.0/src/psygnal/_group_descriptor.py --- old/psygnal-0.12.0/src/psygnal/_group_descriptor.py 2025-02-03 16:41:13.000000000 +0100 +++ new/psygnal-0.13.0/src/psygnal/_group_descriptor.py 2025-05-06 00:09:46.000000000 +0200 @@ -52,7 +52,7 @@ # if the class has an __eq_operators__ attribute, we use it # otherwise use/create the entry for `cls` in the global _EQ_OPERATORS map if hasattr(cls, _EQ_OPERATOR_NAME): - return cast(dict, getattr(cls, _EQ_OPERATOR_NAME)) + return cast("dict", getattr(cls, _EQ_OPERATOR_NAME)) else: return _EQ_OPERATORS.setdefault(cls, {}) @@ -389,7 +389,7 @@ if name == signal_group_name: return super_setattr(self, name, value) - group = cast(SignalGroup, getattr(self, signal_group_name)) + group = cast("SignalGroup", getattr(self, signal_group_name)) if not with_aliases and name not in group: return super_setattr(self, name, value) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/psygnal-0.12.0/src/psygnal/_weak_callback.py new/psygnal-0.13.0/src/psygnal/_weak_callback.py --- old/psygnal-0.12.0/src/psygnal/_weak_callback.py 2025-02-03 16:41:13.000000000 +0100 +++ new/psygnal-0.13.0/src/psygnal/_weak_callback.py 2025-05-06 00:09:46.000000000 +0200 @@ -458,7 +458,7 @@ func = self._func_ref() if obj is None or func is None: return None - method = cast(MethodType, func.__get__(obj)) + method = cast("MethodType", func.__get__(obj)) if self._args or self._kwargs: return partial(method, *self._args, **self._kwargs) return method diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/psygnal-0.12.0/src/psygnal/containers/__init__.py new/psygnal-0.13.0/src/psygnal/containers/__init__.py --- old/psygnal-0.12.0/src/psygnal/containers/__init__.py 2025-02-03 16:41:13.000000000 +0100 +++ new/psygnal-0.13.0/src/psygnal/containers/__init__.py 2025-05-06 00:09:46.000000000 +0200 @@ -1,30 +1,47 @@ -"""Containers backed by psygnal events.""" +"""Containers backed by psygnal events. + +These classes provide "evented" versions of mutable python containers. +They each have an `events` attribute (`SignalGroup`) that has a variety of +signals that will emit whenever the container is mutated. See +[Container SignalGroups](#container-signalgroups) for the corresponding +container type for details on the available signals. +""" from typing import TYPE_CHECKING, Any -from ._evented_dict import EventedDict -from ._evented_list import EventedList -from ._evented_set import EventedOrderedSet, EventedSet, OrderedSet +from ._evented_dict import DictEvents, EventedDict +from ._evented_list import EventedList, ListEvents +from ._evented_set import EventedOrderedSet, EventedSet, OrderedSet, SetEvents from ._selectable_evented_list import SelectableEventedList from ._selection import Selection if TYPE_CHECKING: - from ._evented_proxy import EventedCallableObjectProxy, EventedObjectProxy # noqa + from ._evented_proxy import ( + CallableProxyEvents, + EventedCallableObjectProxy, + EventedObjectProxy, + ProxyEvents, + ) __all__ = [ + "CallableProxyEvents", + "DictEvents", "EventedCallableObjectProxy", "EventedDict", "EventedList", "EventedObjectProxy", "EventedOrderedSet", "EventedSet", + "ListEvents", "OrderedSet", + "ProxyEvents", "SelectableEventedList", "Selection", + "SetEvents", ] -def __getattr__(name: str) -> Any: +def __getattr__(name: str) -> Any: # pragma: no cover if name == "EventedObjectProxy": from ._evented_proxy import EventedObjectProxy @@ -33,6 +50,14 @@ from ._evented_proxy import EventedCallableObjectProxy return EventedCallableObjectProxy + if name == "CallableProxyEvents": + from ._evented_proxy import CallableProxyEvents + + return CallableProxyEvents + if name == "ProxyEvents": + from ._evented_proxy import ProxyEvents + + return ProxyEvents raise AttributeError( # pragma: no cover f"module {__name__!r} has no attribute {name!r}" ) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/psygnal-0.12.0/src/psygnal/containers/_evented_dict.py new/psygnal-0.13.0/src/psygnal/containers/_evented_dict.py --- old/psygnal-0.12.0/src/psygnal/containers/_evented_dict.py 2025-02-03 16:41:13.000000000 +0100 +++ new/psygnal-0.13.0/src/psygnal/containers/_evented_dict.py 2025-05-06 00:09:46.000000000 +0200 @@ -111,32 +111,22 @@ class DictEvents(SignalGroup): - """Events available on [EventedDict][psygnal.containers.EventedDict]. - - Attributes - ---------- - adding: Signal[Any] - `(key,)` emitted before an item is added at `key` - added : Signal[Any, Any] - `(key, value)` emitted after a `value` is added at `key` - changing : Signal[Any, Any, Any] - `(key, old_value, new_value)` emitted before `old_value` is replaced with - `new_value` at `key` - changed : Signal[Any, Any, Any] - `(key, old_value, new_value)` emitted before `old_value` is replaced with - `new_value` at `key` - removing: Signal[Any] - `(key,)` emitted before an item is removed at `key` - removed : Signal[Any, Any] - `(key, value)` emitted after `value` is removed at `index` - """ + """Events available on [EventedDict][psygnal.containers.EventedDict].""" adding = Signal(object) # (key, ) + """`(key,)` emitted before an item is added at `key`""" added = Signal(object, object) # (key, value) + """`(key, value)` emitted after a `value` is added at `key`""" changing = Signal(object) # (key, ) + """`(key, old_value, new_value)` emitted before `old_value` is replaced with + `new_value` at `key`""" changed = Signal(object, object, object) # (key, old_value, value) + """`(key, old_value, new_value)` emitted before `old_value` is replaced with + `new_value` at `key`""" removing = Signal(object) # (key, ) + """`(key,)` emitted before an item is removed at `key`""" removed = Signal(object, object) # (key, value) + """`(key, value)` emitted after `value` is removed at `key`""" class EventedDict(TypedMutableMapping[_K, _V]): diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/psygnal-0.12.0/src/psygnal/containers/_evented_list.py new/psygnal-0.13.0/src/psygnal/containers/_evented_list.py --- old/psygnal-0.12.0/src/psygnal/containers/_evented_list.py 2025-02-03 16:41:13.000000000 +0100 +++ new/psygnal-0.13.0/src/psygnal/containers/_evented_list.py 2025-05-06 00:09:46.000000000 +0200 @@ -48,43 +48,32 @@ class ListEvents(SignalGroup): - """Events available on [EventedList][psygnal.containers.EventedList]. - - Attributes - ---------- - inserting : Signal[int] - `(index)` emitted before an item is inserted at `index` - inserted : Signal[int, Any] - `(index, value)` emitted after `value` is inserted at `index` - removing : Signal[int] - `(index)` emitted before an item is removed at `index` - removed: Signal[int, Any] - `(index, value)` emitted after `value` is removed at `index` - moving : Signal[int, int] - `(index, new_index)` emitted before an item is moved from `index` to `new_index` - moved : Signal[int, int, Any] - `(index, new_index, value)` emitted after `value` is moved from `index` to - `new_index` - changed : Signal[Union[int, slice], Any, Any] - `(index_or_slice, old_value, value)` emitted when `index` is set from - `old_value` to `value` - reordered : Signal - emitted when the list is reordered (eg. moved/reversed). - child_event : Signal[int, Any, SignalInstance, tuple] - `(index, object, emitter, args)` emitted when an object in the list emits an - event. Note that the `EventedList` must be created with `child_events=True` in - order for this to be emitted. - """ + """Events available on [EventedList][psygnal.containers.EventedList].""" inserting = Signal(int) # idx + """`(index)` emitted before an item is inserted at `index`""" inserted = Signal(int, object) # (idx, value) + """`(index, value)` emitted after `value` is inserted at `index`""" removing = Signal(int) # idx + """`(index)` emitted before an item is removed at `index`""" removed = Signal(int, object) # (idx, value) + """`(index, value)` emitted after `value` is removed at `index`""" moving = Signal(int, int) # (src_idx, dest_idx) + """`(index, new_index)` emitted before an item is moved from `index` to + `new_index`""" moved = Signal(int, int, object) # (src_idx, dest_idx, value) + """`(index, new_index, value)` emitted after `value` is moved from + `index` to `new_index`""" changed = Signal(object, object, object) # (int | slice, old, new) + """`(index_or_slice, old_value, value)` emitted when `index` is set from + `old_value` to `value`""" reordered = Signal() + """Emitted when the list is reordered (eg. moved/reversed).""" child_event = Signal(int, object, SignalInstance, tuple) + """`(index, object, emitter, args)` emitted when an object in the list emits an + event. Note that the `EventedList` must be created with `child_events=True` in + order for this to be emitted. + """ class EventedList(MutableSequence[_T]): diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/psygnal-0.12.0/src/psygnal/containers/_evented_proxy.py new/psygnal-0.13.0/src/psygnal/containers/_evented_proxy.py --- old/psygnal-0.12.0/src/psygnal/containers/_evented_proxy.py 2025-02-03 16:41:13.000000000 +0100 +++ new/psygnal-0.13.0/src/psygnal/containers/_evented_proxy.py 2025-05-06 00:09:46.000000000 +0200 @@ -17,19 +17,25 @@ class ProxyEvents(SignalGroup): - """ObjectProxy events.""" + """Events emitted by `EventedObjectProxy` and `EventedCallableObjectProxy`.""" attribute_set = Signal(str, object) + """Emitted when an attribute is set.""" attribute_deleted = Signal(str) + """Emitted when an attribute is deleted.""" item_set = Signal(object, object) + """Emitted when an item is set.""" item_deleted = Signal(object) + """Emitted when an item is deleted.""" in_place = Signal(str, object) + """Emitted when an in-place operation is performed.""" class CallableProxyEvents(ProxyEvents): - """CallableObjectProxy events.""" + """Events emitted by `EventedCallableObjectProxy`.""" called = Signal(tuple, dict) + """Emitted when the object is called.""" # we're using a cache instead of setting the events object directly on the proxy @@ -41,6 +47,9 @@ class EventedObjectProxy(ObjectProxy, Generic[T]): """Create a proxy of `target` that includes an `events` [psygnal.SignalGroup][]. + Provides an "evented" subclasses of + [`wrapt.ObjectProxy`](https://wrapt.readthedocs.io/en/latest/wrappers.html#object-proxy) + !!! important This class requires `wrapt` to be installed. @@ -61,6 +70,13 @@ - `item_deleted`: `Signal(object)` - `in_place`: `Signal(str, object)` + !!! warning "Experimental" + + This object is experimental! They may affect the behavior of + the wrapped object in unanticipated ways. Please consult + the [wrapt documentation](https://wrapt.readthedocs.io/en/latest/wrappers.html) + for details on how the Object Proxy works. + Parameters ---------- target : Any diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/psygnal-0.12.0/src/psygnal/testing.py new/psygnal-0.13.0/src/psygnal/testing.py --- old/psygnal-0.12.0/src/psygnal/testing.py 1970-01-01 01:00:00.000000000 +0100 +++ new/psygnal-0.13.0/src/psygnal/testing.py 2025-05-06 00:09:46.000000000 +0200 @@ -0,0 +1,329 @@ +"""Utilities for testing psygnal Signals.""" + +from __future__ import annotations + +from contextlib import contextmanager +from typing import TYPE_CHECKING +from unittest.mock import Mock +from unittest.util import safe_repr + +if TYPE_CHECKING: + from collections.abc import Iterator + from typing import Any + + from typing_extensions import Self + + import psygnal + +__all__ = [ + "SignalTester", + "assert_emitted", + "assert_emitted_once", + "assert_emitted_once_with", + "assert_emitted_with", + "assert_ever_emitted_with", + "assert_not_emitted", +] + + +class SignalTester: + """A tester object that listens to a signal and records its emissions. + + This class wraps a [`psygnal.SignalInstance`][] and a [`unittest.mock.Mock`][] + object. It provides methods to connect and disconnect the mock from the signal, and + to assert that the signal was emitted with the expected arguments. It also behaves + as a **context manager**, so you can monitor emissions of a signal within a specific + context. + + !!! important + + The signal is *not* automatically connected to the mock when the SignalTester is + created. You must call [`connect()`][psygnal.testing.SignalTester.connect] or + use the context manager to connect the mock to the signal. + + Parameters + ---------- + signal_instance : psygnal.SignalInstance + The signal instance to test. + + Attributes + ---------- + signal_instance : psygnal.SignalInstance + The signal instance to test. + mock : unittest.mock.Mock + The mock object that will be connected to the signal. + + Examples + -------- + ```python + from psygnal import Signal + from psygnal.testing import SignalTester + + + class MyObject: + value_changed = Signal(int) + + + obj = MyObject() + tester = SignalTester(obj.value_changed) + tester.assert_not_emitted() + + with tester: + obj.value_changed.emit(1) + + tester.assert_emitted() + tester.assert_emitted_once() + tester.assert_emitted_once_with(1) + assert tester.emit_count == 1 + tester.reset() + assert tester.emit_count == 0 + ``` + """ + + def __init__(self, signal_instance: psygnal.SignalInstance) -> None: + super().__init__() + self.mock = Mock() + self.signal_instance = signal_instance + + def reset(self) -> None: + """Reset the underlying mock object.""" + self.mock.reset_mock() + + @property + def emit_count(self) -> int: + """Return the number of times the signal was emitted.""" + return self.mock.call_count + + @property + def emit_args(self) -> tuple[Any, ...]: + """Return the arguments of the last emission of the signal.""" + if (call_args := self.mock.call_args) is None: + return () + + return call_args[0] # type: ignore[no-any-return] + + @property + def emit_args_list(self) -> list[tuple[Any, ...]]: + """Return the arguments of all emissions of the signal.""" + return [call[0] for call in self.mock.call_args_list] + + def connect(self) -> None: + """Connect the mock to the signal.""" + self.signal_instance.connect(self.mock) + + def disconnect(self) -> None: + """Disconnect the mock from the signal.""" + self.signal_instance.disconnect(self.mock) + + def __enter__(self) -> Self: + """Connect the mock to the signal.""" + self.connect() + return self + + def __exit__(self, *args: Any) -> None: + """Disconnect the mock from the signal.""" + self.disconnect() + + @property + def signal_name(self) -> str: + """Return the name of the signal.""" + return self.signal_instance.name or "signal" + + def assert_not_emitted(self) -> None: + """Assert that the signal was never emitted.""" + if self.mock.call_count != 0: + if self.mock.call_count == 1: + n = "once" + else: + n = f"{self.mock.call_count} times" + raise AssertionError( + f"Expected {self.signal_name!r} to not have been emitted. Emitted {n}." + ) + + def assert_emitted(self) -> None: + """Assert that the signal was emitted at least once.""" + if self.mock.call_count == 0: + raise AssertionError(f"Expected {self.signal_name!r} to have been emitted.") + + def assert_emitted_once(self) -> None: + """Assert that the signal was emitted exactly once.""" + if not self.mock.call_count == 1: + raise AssertionError( + f"Expected {self.signal_name!r} to have been emitted once. " + f"Emitted {self.mock.call_count} times." + ) + + def assert_emitted_with(self, /, *args: Any) -> None: + """Assert that the *last* emission of the signal had the given arguments.""" + if self.mock.call_args is None: + raise AssertionError( + f"Expected {self.signal_name!r} to have been emitted with arguments " + f"{args!r}.\nActual: not emitted" + ) + + actual = self.mock.call_args[0] + if actual != args: + raise AssertionError( + f"Expected {self.signal_name!r} to have been emitted with arguments " + f"{args!r}.\nActual: {actual}" + ) + + def assert_emitted_once_with(self, /, *args: Any) -> None: + """Assert that the signal was emitted exactly once with the given arguments.""" + if not self.mock.call_count == 1: + raise AssertionError( + f"Expected {self.signal_name!r} to have been emitted exactly once. " + f"Emitted {self.mock.call_count} times." + ) + + actual = self.mock.call_args[0] + if actual != args: + raise AssertionError( + f"Expected {self.signal_name!r} to have been emitted once with " + f"arguments {args!r}.\nActual: {safe_repr(actual)}" + ) + + def assert_ever_emitted_with(self, /, *args: Any) -> None: + """Assert that the signal was emitted *ever* with the given arguments.""" + if self.mock.call_args is None: + raise AssertionError( + f"Expected {self.signal_name!r} to have been emitted at least once " + f"with arguments {args!r}.\nActual: not emitted" + ) + + actual = [call[0] for call in self.mock.call_args_list] + if not any(call == args for call in actual): + _actual: tuple | list = actual[0] if len(actual) == 1 else actual + raise AssertionError( + f"Expected {self.signal_name!r} to have been emitted at least once " + f"with arguments {args!r}.\nActual: {safe_repr(_actual)}" + ) + + +@contextmanager +def assert_emitted(signal: psygnal.SignalInstance) -> Iterator[SignalTester]: + """Assert that a signal was emitted at least once. + + Parameters + ---------- + signal : psygnal.SignalInstance + The signal instance to test. + + Raises + ------ + AssertionError + If the signal was never emitted. + """ + with SignalTester(signal) as mock: + yield mock + mock.assert_emitted() + + +@contextmanager +def assert_emitted_once(signal: psygnal.SignalInstance) -> Iterator[SignalTester]: + """Assert that a signal was emitted exactly once. + + Parameters + ---------- + signal : psygnal.SignalInstance + The signal instance to test. + + Raises + ------ + AssertionError + If the signal was emitted more than once. + """ + with SignalTester(signal) as mock: + yield mock + mock.assert_emitted_once() + + +@contextmanager +def assert_not_emitted(signal: psygnal.SignalInstance) -> Iterator[SignalTester]: + """Assert that a signal was never emitted. + + Parameters + ---------- + signal : psygnal.SignalInstance + The signal instance to test. + + Raises + ------ + AssertionError + If the signal was emitted at least once. + """ + with SignalTester(signal) as mock: + yield mock + mock.assert_not_emitted() + + +@contextmanager +def assert_emitted_with( + signal: psygnal.SignalInstance, *args: Any +) -> Iterator[SignalTester]: + """Assert that the *last* emission of the signal had the given arguments. + + Parameters + ---------- + signal : psygnal.SignalInstance + The signal instance to test. + args : Any + The arguments to check for in the last emission of the signal. + + Raises + ------ + AssertionError + If the signal was never emitted or if the last emission did not have the + expected arguments. + """ + with assert_emitted(signal) as mock: + yield mock + mock.assert_emitted_with(*args) + + +@contextmanager +def assert_emitted_once_with( + signal: psygnal.SignalInstance, *args: Any +) -> Iterator[SignalTester]: + """Assert that the signal was emitted exactly once with the given arguments. + + Parameters + ---------- + signal : psygnal.SignalInstance + The signal instance to test. + args : Any + The arguments to check for in the last emission of the signal. + + Raises + ------ + AssertionError + If the signal was not emitted or was emitted more than once or if the last + emission did not have the expected arguments. + """ + with assert_emitted_once(signal) as mock: + yield mock + mock.assert_emitted_once_with(*args) + + +@contextmanager +def assert_ever_emitted_with( + signal: psygnal.SignalInstance, *args: Any +) -> Iterator[SignalTester]: + """Assert that the signal was emitted *ever* with the given arguments. + + Parameters + ---------- + signal : psygnal.SignalInstance + The signal instance to test. + args : Any + The arguments to check for in any emission of the signal. + + Raises + ------ + AssertionError + If the signal was never emitted or if it was emitted but not with the expected + arguments. + """ + with assert_emitted(signal) as mock: + yield mock + mock.assert_ever_emitted_with(*args) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/psygnal-0.12.0/tests/test_testing_utils.py new/psygnal-0.13.0/tests/test_testing_utils.py --- old/psygnal-0.12.0/tests/test_testing_utils.py 1970-01-01 01:00:00.000000000 +0100 +++ new/psygnal-0.13.0/tests/test_testing_utils.py 2025-05-06 00:09:46.000000000 +0200 @@ -0,0 +1,201 @@ +import re + +import pytest + +import psygnal.testing as pt +from psygnal import Signal + + +class MyObject: + changed = Signal() + value_changed = Signal(int) + + +def test_assert_emitted() -> None: + obj = MyObject() + + with pt.assert_emitted(obj.changed) as tester: + obj.changed.emit() + + assert isinstance(tester, pt.SignalTester) + + with pytest.raises( + AssertionError, match="Expected 'changed' to have been emitted." + ): + with pt.assert_emitted(obj.changed): + pass + + +def test_assert_emitted_once(): + obj = MyObject() + with pt.assert_emitted_once(obj.changed) as tester: + obj.changed.emit() + assert isinstance(tester, pt.SignalTester) + + with pytest.raises( + AssertionError, + match="Expected 'changed' to have been emitted once. Emitted 2 times.", + ): + with pt.assert_emitted_once(obj.changed): + obj.changed.emit() + obj.changed.emit() + + +def test_assert_not_emitted() -> None: + obj = MyObject() + with pt.assert_not_emitted(obj.changed) as tester: + pass + + assert isinstance(tester, pt.SignalTester) + + with pytest.raises( + AssertionError, + match="Expected 'changed' to not have been emitted. Emitted once.", + ): + with pt.assert_not_emitted(obj.changed): + obj.changed.emit() + + with pytest.raises( + AssertionError, + match="Expected 'changed' to not have been emitted. Emitted 4 times.", + ): + with pt.assert_not_emitted(obj.changed): + obj.changed.emit() + obj.changed.emit() + obj.changed.emit() + obj.changed.emit() + + +def test_assert_emitted_with() -> None: + obj = MyObject() + with pt.assert_emitted_with(obj.value_changed, 42) as tester: + obj.value_changed.emit(41) + obj.value_changed.emit(42) + + assert isinstance(tester, pt.SignalTester) + + with pytest.raises( + AssertionError, + match=re.escape( + "Expected 'value_changed' to have been emitted with arguments (42,)." + "\nActual: not emitted" + ), + ): + with pt.assert_emitted_with(obj.value_changed, 42): + pass + + with pytest.raises( + AssertionError, + match=re.escape( + "Expected 'value_changed' to have been emitted with arguments (42,)." + "\nActual: (43,)" + ), + ): + with pt.assert_emitted_with(obj.value_changed, 42): + obj.value_changed.emit(42) + obj.value_changed.emit(43) + + +def test_assert_emitted_once_with() -> None: + obj = MyObject() + with pt.assert_emitted_once_with(obj.value_changed, 42) as tester: + obj.value_changed.emit(42) + + assert isinstance(tester, pt.SignalTester) + + with pytest.raises( + AssertionError, + match=re.escape( + "Expected 'value_changed' to have been emitted exactly once. " + "Emitted 2 times." + ), + ): + with pt.assert_emitted_once_with(obj.value_changed, 42): + obj.value_changed.emit(42) + obj.value_changed.emit(42) + + with pytest.raises( + AssertionError, + match=re.escape( + "Expected 'value_changed' to have been emitted once with arguments (42,)." + "\nActual: (43,)" + ), + ): + with pt.assert_emitted_once_with(obj.value_changed, 42): + obj.value_changed.emit(43) + + +def test_assert_ever_emitted_with() -> None: + obj = MyObject() + + with pt.assert_ever_emitted_with(obj.value_changed, 42) as tester: + obj.value_changed.emit(41) + obj.value_changed.emit(42) + obj.value_changed.emit(43) + + assert isinstance(tester, pt.SignalTester) + + with pytest.raises( + AssertionError, + match=re.escape( + "Expected 'value_changed' to have been emitted at least once with " + "arguments (42,)." + "\nActual: not emitted" + ), + ): + with pt.assert_ever_emitted_with(obj.value_changed, 42): + pass + + with pytest.raises( + AssertionError, + match=re.escape( + "Expected 'value_changed' to have been emitted at least once with " + "arguments (42,)." + "\nActual: (43,)" + ), + ): + with pt.assert_ever_emitted_with(obj.value_changed, 42): + obj.value_changed.emit(43) + + with pytest.raises( + AssertionError, + match=re.escape( + "Expected 'value_changed' to have been emitted at least once with " + "arguments (42,)." + "\nActual: [(41,), (42, 43)]" + ), + ): + with pt.assert_ever_emitted_with(obj.value_changed, 42): + obj.value_changed.emit(41) + obj.value_changed.emit(42, 43) + + +def test_signal_tester() -> None: + obj = MyObject() + tester = pt.SignalTester(obj.changed) + tester.connect() + assert tester.signal_name == "changed" + assert tester.mock.call_count == 0 + + obj.changed.emit() + + tester.assert_emitted_once() + tester.assert_emitted() + tester.assert_emitted_with() + assert tester.emit_count == 1 + tester.reset() + assert tester.emit_count == 0 + + tester2 = pt.SignalTester(obj.value_changed) + + with tester2: + obj.value_changed.emit(42) + obj.value_changed.emit(43) + + tester2.assert_emitted() + tester2.assert_emitted_with(43) + tester2.assert_ever_emitted_with(42) + + assert tester2.emit_args_list == [(42,), (43,)] + assert tester2.emit_count == 2 + assert tester2.emit_args == (43,)