psygnal-0.9.1/CHANGELOG.md0000644000000000000000000005660113615410400011737 0ustar00# Changelog ## [0.9.1](https://github.com/pyapp-kit/psygnal/tree/0.9.1) (2023-05-29) [Full Changelog](https://github.com/pyapp-kit/psygnal/compare/v0.9.0...0.9.1) **Implemented enhancements:** - feat: Support toolz [\#210](https://github.com/pyapp-kit/psygnal/pull/210) ([tlambert03](https://github.com/tlambert03)) **Fixed bugs:** - fix: better error message with keyword only partials [\#209](https://github.com/pyapp-kit/psygnal/pull/209) ([tlambert03](https://github.com/tlambert03)) **Tests & CI:** - build: add test dep [\#206](https://github.com/pyapp-kit/psygnal/pull/206) ([tlambert03](https://github.com/tlambert03)) **Merged pull requests:** - ci\(dependabot\): bump pypa/cibuildwheel from 2.12.3 to 2.13.0 [\#207](https://github.com/pyapp-kit/psygnal/pull/207) ([dependabot[bot]](https://github.com/apps/dependabot)) - ci\(pre-commit.ci\): autoupdate [\#205](https://github.com/pyapp-kit/psygnal/pull/205) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci)) - ci\(dependabot\): bump pypa/cibuildwheel from 2.12.1 to 2.12.3 [\#204](https://github.com/pyapp-kit/psygnal/pull/204) ([dependabot[bot]](https://github.com/apps/dependabot)) ## [v0.9.0](https://github.com/pyapp-kit/psygnal/tree/v0.9.0) (2023-04-07) [Full Changelog](https://github.com/pyapp-kit/psygnal/compare/v0.8.1...v0.9.0) **Implemented enhancements:** - feat: add thread parameter to connection method, allowed "queued connections" [\#200](https://github.com/pyapp-kit/psygnal/pull/200) ([tlambert03](https://github.com/tlambert03)) - build: add pyinstaller hook to simplify frozing apps using pyinstaller [\#194](https://github.com/pyapp-kit/psygnal/pull/194) ([Czaki](https://github.com/Czaki)) **Merged pull requests:** - docs: add docs on connecting across thread [\#203](https://github.com/pyapp-kit/psygnal/pull/203) ([tlambert03](https://github.com/tlambert03)) - chore: deprecate async keyword in emit method [\#201](https://github.com/pyapp-kit/psygnal/pull/201) ([tlambert03](https://github.com/tlambert03)) - ci\(dependabot\): bump pypa/cibuildwheel from 2.12.0 to 2.12.1 [\#197](https://github.com/pyapp-kit/psygnal/pull/197) ([dependabot[bot]](https://github.com/apps/dependabot)) - ci\(dependabot\): bump actions/setup-python from 3 to 4 [\#193](https://github.com/pyapp-kit/psygnal/pull/193) ([dependabot[bot]](https://github.com/apps/dependabot)) ## [v0.8.1](https://github.com/pyapp-kit/psygnal/tree/v0.8.1) (2023-02-23) [Full Changelog](https://github.com/pyapp-kit/psygnal/compare/v0.8.0...v0.8.1) **Fixed bugs:** - fix: fix strict signal group checking when signatures aren't hashable [\#192](https://github.com/pyapp-kit/psygnal/pull/192) ([tlambert03](https://github.com/tlambert03)) **Tests & CI:** - test: add back typesafety tests [\#190](https://github.com/pyapp-kit/psygnal/pull/190) ([tlambert03](https://github.com/tlambert03)) ## [v0.8.0](https://github.com/pyapp-kit/psygnal/tree/v0.8.0) (2023-02-23) [Full Changelog](https://github.com/pyapp-kit/psygnal/compare/v0.7.2...v0.8.0) **Implemented enhancements:** - feat: compile throttler module, improve typing [\#187](https://github.com/pyapp-kit/psygnal/pull/187) ([tlambert03](https://github.com/tlambert03)) - feat: improved `monitor_events` [\#181](https://github.com/pyapp-kit/psygnal/pull/181) ([tlambert03](https://github.com/tlambert03)) - feat: make SignalGroupDescriptor public [\#173](https://github.com/pyapp-kit/psygnal/pull/173) ([tlambert03](https://github.com/tlambert03)) **Fixed bugs:** - fix: fix inheritance of classes with a SignalGroupDescriptor [\#186](https://github.com/pyapp-kit/psygnal/pull/186) ([tlambert03](https://github.com/tlambert03)) - fix: minor typing fixes on `connect` [\#180](https://github.com/pyapp-kit/psygnal/pull/180) ([tlambert03](https://github.com/tlambert03)) - fix: add getattr to signalgroup for typing [\#174](https://github.com/pyapp-kit/psygnal/pull/174) ([tlambert03](https://github.com/tlambert03)) **Tests & CI:** - ci: add dataclasses benchmarks [\#189](https://github.com/pyapp-kit/psygnal/pull/189) ([tlambert03](https://github.com/tlambert03)) - test: no cover compile funcs [\#185](https://github.com/pyapp-kit/psygnal/pull/185) ([tlambert03](https://github.com/tlambert03)) - ci: add evented benchmark [\#175](https://github.com/pyapp-kit/psygnal/pull/175) ([tlambert03](https://github.com/tlambert03)) - ci: add codspeed benchmarks [\#170](https://github.com/pyapp-kit/psygnal/pull/170) ([tlambert03](https://github.com/tlambert03)) **Merged pull requests:** - refactor: change patching of \_\_setattr\_\_ in SignalGroupDescriptor, make more explicit [\#188](https://github.com/pyapp-kit/psygnal/pull/188) ([tlambert03](https://github.com/tlambert03)) - docs: small docs updates, document EmissionLoopError [\#184](https://github.com/pyapp-kit/psygnal/pull/184) ([tlambert03](https://github.com/tlambert03)) - refactor: remove PSYGNAL\_UNCOMPILED flag. [\#183](https://github.com/pyapp-kit/psygnal/pull/183) ([tlambert03](https://github.com/tlambert03)) - docs: adding spellchecking to docs [\#182](https://github.com/pyapp-kit/psygnal/pull/182) ([tlambert03](https://github.com/tlambert03)) - docs: update evented docs to descript SignalGroupDescriptor [\#179](https://github.com/pyapp-kit/psygnal/pull/179) ([tlambert03](https://github.com/tlambert03)) - refactor: split out SlotCaller logic into new `weak_callable` module... maybe public eventually [\#178](https://github.com/pyapp-kit/psygnal/pull/178) ([tlambert03](https://github.com/tlambert03)) - refactor: split out dataclass utils [\#176](https://github.com/pyapp-kit/psygnal/pull/176) ([tlambert03](https://github.com/tlambert03)) - refactor: use weakmethod instead of \_get\_method\_name [\#168](https://github.com/pyapp-kit/psygnal/pull/168) ([tlambert03](https://github.com/tlambert03)) ## [v0.7.2](https://github.com/pyapp-kit/psygnal/tree/v0.7.2) (2023-02-11) [Full Changelog](https://github.com/pyapp-kit/psygnal/compare/v0.7.1...v0.7.2) **Fixed bugs:** - fix: use weakref when instance is passed to SignalGroup [\#167](https://github.com/pyapp-kit/psygnal/pull/167) ([tlambert03](https://github.com/tlambert03)) ## [v0.7.1](https://github.com/pyapp-kit/psygnal/tree/v0.7.1) (2023-02-11) [Full Changelog](https://github.com/pyapp-kit/psygnal/compare/v0.7.0...v0.7.1) **Implemented enhancements:** - feat: add `is_evented` and `get_evented_namespace` [\#166](https://github.com/pyapp-kit/psygnal/pull/166) ([tlambert03](https://github.com/tlambert03)) - feat: add support for msgspec Struct classes to evented decorator [\#165](https://github.com/pyapp-kit/psygnal/pull/165) ([tlambert03](https://github.com/tlambert03)) **Fixed bugs:** - fix: fix clobbering of SignalGroup name in EventedModel [\#158](https://github.com/pyapp-kit/psygnal/pull/158) ([tlambert03](https://github.com/tlambert03)) **Merged pull requests:** - ci\(dependabot\): bump pypa/cibuildwheel from 2.11.4 to 2.12.0 [\#164](https://github.com/pyapp-kit/psygnal/pull/164) ([dependabot[bot]](https://github.com/apps/dependabot)) - ci\(dependabot\): bump pypa/cibuildwheel from 2.11.3 to 2.11.4 [\#159](https://github.com/pyapp-kit/psygnal/pull/159) ([dependabot[bot]](https://github.com/apps/dependabot)) ## [v0.7.0](https://github.com/pyapp-kit/psygnal/tree/v0.7.0) (2022-12-20) [Full Changelog](https://github.com/pyapp-kit/psygnal/compare/v0.6.1...v0.7.0) **Implemented enhancements:** - build: use mypyc instead of cython, move to hatch [\#149](https://github.com/pyapp-kit/psygnal/pull/149) ([tlambert03](https://github.com/tlambert03)) **Fixed bugs:** - fix: add dataclass\_transform to maintain IDE typing support for EventedModel.\_\_init\_\_ [\#154](https://github.com/pyapp-kit/psygnal/pull/154) ([tlambert03](https://github.com/tlambert03)) - Don't unblock/resume within nested contexts [\#150](https://github.com/pyapp-kit/psygnal/pull/150) ([hanjinliu](https://github.com/hanjinliu)) **Merged pull requests:** - ci\(pre-commit.ci\): autoupdate [\#155](https://github.com/pyapp-kit/psygnal/pull/155) ([tlambert03](https://github.com/tlambert03)) - ci\(dependabot\): bump pypa/cibuildwheel from 2.11.2 to 2.11.3 [\#153](https://github.com/pyapp-kit/psygnal/pull/153) ([dependabot[bot]](https://github.com/apps/dependabot)) - style: use ruff instead of flake8, isort, pyupgrade, autoflake, etc... [\#146](https://github.com/pyapp-kit/psygnal/pull/146) ([tlambert03](https://github.com/tlambert03)) - chore: add deps to setup.py [\#145](https://github.com/pyapp-kit/psygnal/pull/145) ([tlambert03](https://github.com/tlambert03)) - refactor: remove PartialMethodMeta for TypeGuard func [\#144](https://github.com/pyapp-kit/psygnal/pull/144) ([tlambert03](https://github.com/tlambert03)) - refactor: don't use metaclass for signal group [\#143](https://github.com/pyapp-kit/psygnal/pull/143) ([tlambert03](https://github.com/tlambert03)) ## [v0.6.1](https://github.com/pyapp-kit/psygnal/tree/v0.6.1) (2022-11-13) [Full Changelog](https://github.com/pyapp-kit/psygnal/compare/v0.6.0.post0...v0.6.1) **Fixed bugs:** - fix: fix failed weakref in connect\_setattr [\#142](https://github.com/pyapp-kit/psygnal/pull/142) ([tlambert03](https://github.com/tlambert03)) - fix: fix disconnection of partials [\#134](https://github.com/pyapp-kit/psygnal/pull/134) ([tlambert03](https://github.com/tlambert03)) **Merged pull requests:** - chore: rename org to pyapp-kit [\#141](https://github.com/pyapp-kit/psygnal/pull/141) ([tlambert03](https://github.com/tlambert03)) ## [v0.6.0.post0](https://github.com/pyapp-kit/psygnal/tree/v0.6.0.post0) (2022-11-09) [Full Changelog](https://github.com/pyapp-kit/psygnal/compare/v0.6.0...v0.6.0.post0) **Merged pull requests:** - build: unskip cibuildwheel py311 [\#140](https://github.com/pyapp-kit/psygnal/pull/140) ([tlambert03](https://github.com/tlambert03)) - ci\(dependabot\): bump pypa/cibuildwheel from 2.11.1 to 2.11.2 [\#138](https://github.com/pyapp-kit/psygnal/pull/138) ([dependabot[bot]](https://github.com/apps/dependabot)) ## [v0.6.0](https://github.com/pyapp-kit/psygnal/tree/v0.6.0) (2022-10-29) [Full Changelog](https://github.com/pyapp-kit/psygnal/compare/v0.5.0...v0.6.0) **Implemented enhancements:** - build: drop py3.7 add py3.11 [\#135](https://github.com/pyapp-kit/psygnal/pull/135) ([tlambert03](https://github.com/tlambert03)) **Merged pull requests:** - chore: changelog v0.6.0 [\#137](https://github.com/pyapp-kit/psygnal/pull/137) ([tlambert03](https://github.com/tlambert03)) - build: support 3.7 again [\#136](https://github.com/pyapp-kit/psygnal/pull/136) ([tlambert03](https://github.com/tlambert03)) - ci\(dependabot\): bump pypa/cibuildwheel from 2.10.2 to 2.11.1 [\#133](https://github.com/pyapp-kit/psygnal/pull/133) ([dependabot[bot]](https://github.com/apps/dependabot)) ## [v0.5.0](https://github.com/pyapp-kit/psygnal/tree/v0.5.0) (2022-10-14) [Full Changelog](https://github.com/pyapp-kit/psygnal/compare/v0.4.2...v0.5.0) **Implemented enhancements:** - feat: add warning for poor usage [\#132](https://github.com/pyapp-kit/psygnal/pull/132) ([tlambert03](https://github.com/tlambert03)) - feat: add `@evented` decorator, turn any dataclass, attrs model, or pydantic model into evented [\#129](https://github.com/pyapp-kit/psygnal/pull/129) ([tlambert03](https://github.com/tlambert03)) **Merged pull requests:** - docs: update readme [\#131](https://github.com/pyapp-kit/psygnal/pull/131) ([tlambert03](https://github.com/tlambert03)) - docs: documentation for evented decorator [\#130](https://github.com/pyapp-kit/psygnal/pull/130) ([tlambert03](https://github.com/tlambert03)) - ci\(dependabot\): bump pypa/cibuildwheel from 2.10.1 to 2.10.2 [\#127](https://github.com/pyapp-kit/psygnal/pull/127) ([dependabot[bot]](https://github.com/apps/dependabot)) ## [v0.4.2](https://github.com/pyapp-kit/psygnal/tree/v0.4.2) (2022-09-25) [Full Changelog](https://github.com/pyapp-kit/psygnal/compare/v0.4.1...v0.4.2) **Fixed bugs:** - fix: fix inheritance of property setters [\#126](https://github.com/pyapp-kit/psygnal/pull/126) ([tlambert03](https://github.com/tlambert03)) - fix: fix bug in setattr with private attrs [\#125](https://github.com/pyapp-kit/psygnal/pull/125) ([tlambert03](https://github.com/tlambert03)) ## [v0.4.1](https://github.com/pyapp-kit/psygnal/tree/v0.4.1) (2022-09-22) [Full Changelog](https://github.com/pyapp-kit/psygnal/compare/v0.4.0...v0.4.1) **Implemented enhancements:** - feat: Add ability to disconnect slots from Signal group directly [\#118](https://github.com/pyapp-kit/psygnal/pull/118) ([alisterburt](https://github.com/alisterburt)) **Fixed bugs:** - fix: fix listevents docstring parameter mismatch [\#119](https://github.com/pyapp-kit/psygnal/pull/119) ([alisterburt](https://github.com/alisterburt)) **Tests & CI:** - ci: skip building py311 wheel [\#124](https://github.com/pyapp-kit/psygnal/pull/124) ([tlambert03](https://github.com/tlambert03)) **Merged pull requests:** - ci\(dependabot\): bump pypa/cibuildwheel from 2.9.0 to 2.10.1 [\#123](https://github.com/pyapp-kit/psygnal/pull/123) ([dependabot[bot]](https://github.com/apps/dependabot)) - ci\(dependabot\): bump pypa/cibuildwheel from 2.8.1 to 2.9.0 [\#121](https://github.com/pyapp-kit/psygnal/pull/121) ([dependabot[bot]](https://github.com/apps/dependabot)) - build: pin cython [\#120](https://github.com/pyapp-kit/psygnal/pull/120) ([tlambert03](https://github.com/tlambert03)) - ci\(dependabot\): bump pypa/gh-action-pypi-publish from 1.5.0 to 1.5.1 [\#116](https://github.com/pyapp-kit/psygnal/pull/116) ([dependabot[bot]](https://github.com/apps/dependabot)) ## [v0.4.0](https://github.com/pyapp-kit/psygnal/tree/v0.4.0) (2022-07-26) [Full Changelog](https://github.com/pyapp-kit/psygnal/compare/v0.3.5...v0.4.0) **Implemented enhancements:** - feat: raise exceptions as EmitLoopError [\#115](https://github.com/pyapp-kit/psygnal/pull/115) ([tlambert03](https://github.com/tlambert03)) - feat: add connect\_setitem [\#108](https://github.com/pyapp-kit/psygnal/pull/108) ([tlambert03](https://github.com/tlambert03)) - build: move entirely to pyproject, and src setup [\#101](https://github.com/pyapp-kit/psygnal/pull/101) ([tlambert03](https://github.com/tlambert03)) - add readthedocs config, make EventedCallableObjectProxy public [\#86](https://github.com/pyapp-kit/psygnal/pull/86) ([tlambert03](https://github.com/tlambert03)) **Fixed bugs:** - refactor: guard paramspec import [\#112](https://github.com/pyapp-kit/psygnal/pull/112) ([tlambert03](https://github.com/tlambert03)) **Merged pull requests:** - replace docs/requirements with extra, fix rtd install [\#87](https://github.com/pyapp-kit/psygnal/pull/87) ([tlambert03](https://github.com/tlambert03)) ## [v0.3.5](https://github.com/pyapp-kit/psygnal/tree/v0.3.5) (2022-05-25) [Full Changelog](https://github.com/pyapp-kit/psygnal/compare/v0.3.4...v0.3.5) **Merged pull requests:** - \[pre-commit.ci\] pre-commit autoupdate [\#85](https://github.com/pyapp-kit/psygnal/pull/85) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci)) - Add documentation [\#84](https://github.com/pyapp-kit/psygnal/pull/84) ([tlambert03](https://github.com/tlambert03)) - Evented pydantic model [\#83](https://github.com/pyapp-kit/psygnal/pull/83) ([tlambert03](https://github.com/tlambert03)) - \[pre-commit.ci\] pre-commit autoupdate [\#82](https://github.com/pyapp-kit/psygnal/pull/82) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci)) ## [v0.3.4](https://github.com/pyapp-kit/psygnal/tree/v0.3.4) (2022-05-02) [Full Changelog](https://github.com/pyapp-kit/psygnal/compare/v0.3.3...v0.3.4) **Implemented enhancements:** - Add `EventedDict` [\#79](https://github.com/pyapp-kit/psygnal/pull/79) ([alisterburt](https://github.com/alisterburt)) - add `SelectableEventedList` [\#78](https://github.com/pyapp-kit/psygnal/pull/78) ([alisterburt](https://github.com/alisterburt)) - Add Throttler class [\#75](https://github.com/pyapp-kit/psygnal/pull/75) ([tlambert03](https://github.com/tlambert03)) - Add Selection model ported from napari [\#64](https://github.com/pyapp-kit/psygnal/pull/64) ([alisterburt](https://github.com/alisterburt)) **Fixed bugs:** - Make SignalInstance weak referenceable \(Fix forwarding signals\) [\#71](https://github.com/pyapp-kit/psygnal/pull/71) ([tlambert03](https://github.com/tlambert03)) ## [v0.3.3](https://github.com/pyapp-kit/psygnal/tree/v0.3.3) (2022-02-14) [Full Changelog](https://github.com/pyapp-kit/psygnal/compare/v0.3.2...v0.3.3) **Fixed bugs:** - Used custom tuple for cython compatibility [\#69](https://github.com/pyapp-kit/psygnal/pull/69) ([tlambert03](https://github.com/tlambert03)) ## [v0.3.2](https://github.com/pyapp-kit/psygnal/tree/v0.3.2) (2022-02-14) [Full Changelog](https://github.com/pyapp-kit/psygnal/compare/v0.3.1...v0.3.2) **Implemented enhancements:** - work with older cython [\#67](https://github.com/pyapp-kit/psygnal/pull/67) ([tlambert03](https://github.com/tlambert03)) **Merged pull requests:** - generate gh release in CI [\#68](https://github.com/pyapp-kit/psygnal/pull/68) ([tlambert03](https://github.com/tlambert03)) ## [v0.3.1](https://github.com/pyapp-kit/psygnal/tree/v0.3.1) (2022-02-12) [Full Changelog](https://github.com/pyapp-kit/psygnal/compare/v0.3.0...v0.3.1) **Fixed bugs:** - Don't use `repr(obj)` when checking for Qt emit signature [\#66](https://github.com/pyapp-kit/psygnal/pull/66) ([tlambert03](https://github.com/tlambert03)) **Tests & CI:** - add a magicgui test to CI [\#65](https://github.com/pyapp-kit/psygnal/pull/65) ([tlambert03](https://github.com/tlambert03)) - skip cibuildwheel tests on musllinux and i686 [\#63](https://github.com/pyapp-kit/psygnal/pull/63) ([tlambert03](https://github.com/tlambert03)) ## [v0.3.0](https://github.com/pyapp-kit/psygnal/tree/v0.3.0) (2022-02-10) [Full Changelog](https://github.com/pyapp-kit/psygnal/compare/v0.2.0...v0.3.0) **Implemented enhancements:** - Add EventedObjectProxy [\#62](https://github.com/pyapp-kit/psygnal/pull/62) ([tlambert03](https://github.com/tlambert03)) - Misc small changes, add iter\_signal\_instances to utils [\#61](https://github.com/pyapp-kit/psygnal/pull/61) ([tlambert03](https://github.com/tlambert03)) - Add EventedSet and EventedOrderedSet [\#59](https://github.com/pyapp-kit/psygnal/pull/59) ([tlambert03](https://github.com/tlambert03)) - add SignalGroup blocked context manager, improve inheritance, and fix strong refs [\#57](https://github.com/pyapp-kit/psygnal/pull/57) ([tlambert03](https://github.com/tlambert03)) - Add evented list \(more evented containers coming\) [\#56](https://github.com/pyapp-kit/psygnal/pull/56) ([tlambert03](https://github.com/tlambert03)) - add debug\_events util \(later changed to `monitor_events`\) [\#55](https://github.com/pyapp-kit/psygnal/pull/55) ([tlambert03](https://github.com/tlambert03)) - support Qt SignalInstance Emit [\#49](https://github.com/pyapp-kit/psygnal/pull/49) ([tlambert03](https://github.com/tlambert03)) - Add SignalGroup [\#42](https://github.com/pyapp-kit/psygnal/pull/42) ([tlambert03](https://github.com/tlambert03)) **Tests & CI:** - add typesafety tests to evented containers [\#60](https://github.com/pyapp-kit/psygnal/pull/60) ([tlambert03](https://github.com/tlambert03)) - deal with changing API in benchmarks [\#43](https://github.com/pyapp-kit/psygnal/pull/43) ([tlambert03](https://github.com/tlambert03)) ## [v0.2.0](https://github.com/pyapp-kit/psygnal/tree/v0.2.0) (2021-11-07) [Full Changelog](https://github.com/pyapp-kit/psygnal/compare/v0.1.4...v0.2.0) **Implemented enhancements:** - Add `connect/disconnect_settattr` [\#39](https://github.com/pyapp-kit/psygnal/pull/39) ([tlambert03](https://github.com/tlambert03)) - Enable uncompiled import with PSYGNAL\_UNCOMPILED env var [\#33](https://github.com/pyapp-kit/psygnal/pull/33) ([tlambert03](https://github.com/tlambert03)) - Add asv benchmark to CI [\#31](https://github.com/pyapp-kit/psygnal/pull/31) ([tlambert03](https://github.com/tlambert03)) - Avoid holding strong reference to decorated and partial methods [\#29](https://github.com/pyapp-kit/psygnal/pull/29) ([Czaki](https://github.com/Czaki)) - Change confusing variable name in \_acceptable\_posarg\_range [\#25](https://github.com/pyapp-kit/psygnal/pull/25) ([tlambert03](https://github.com/tlambert03)) **Fixed bugs:** - Set SignalInstances directly as attributes on objects \(fix bug with hashable signal holders\) [\#28](https://github.com/pyapp-kit/psygnal/pull/28) ([tlambert03](https://github.com/tlambert03)) **Tests & CI:** - Add benchmarks for connect\_setattr [\#41](https://github.com/pyapp-kit/psygnal/pull/41) ([Czaki](https://github.com/Czaki)) - Extend emit benchmarks to include methods [\#40](https://github.com/pyapp-kit/psygnal/pull/40) ([tlambert03](https://github.com/tlambert03)) - Fix codecov CI and bring coverage back to 100 [\#34](https://github.com/pyapp-kit/psygnal/pull/34) ([tlambert03](https://github.com/tlambert03)) - Change benchmark publication approach [\#32](https://github.com/pyapp-kit/psygnal/pull/32) ([tlambert03](https://github.com/tlambert03)) **Merged pull requests:** - Misc-typing and minor reorg [\#35](https://github.com/pyapp-kit/psygnal/pull/35) ([tlambert03](https://github.com/tlambert03)) ## [v0.1.4](https://github.com/pyapp-kit/psygnal/tree/v0.1.4) (2021-10-17) [Full Changelog](https://github.com/pyapp-kit/psygnal/compare/v0.1.3...v0.1.4) **Implemented enhancements:** - support python 3.10 [\#24](https://github.com/pyapp-kit/psygnal/pull/24) ([tlambert03](https://github.com/tlambert03)) - Add ability to pause & resume/reduce signals [\#23](https://github.com/pyapp-kit/psygnal/pull/23) ([tlambert03](https://github.com/tlambert03)) ## [v0.1.3](https://github.com/pyapp-kit/psygnal/tree/v0.1.3) (2021-10-01) [Full Changelog](https://github.com/pyapp-kit/psygnal/compare/v0.1.2...v0.1.3) **Implemented enhancements:** - add \_\_call\_\_ as alias for `emit` on SignalInstance [\#18](https://github.com/pyapp-kit/psygnal/pull/18) ([tlambert03](https://github.com/tlambert03)) ## [v0.1.2](https://github.com/pyapp-kit/psygnal/tree/v0.1.2) (2021-07-12) [Full Changelog](https://github.com/pyapp-kit/psygnal/compare/v0.1.1...v0.1.2) **Implemented enhancements:** - Provide signatures for common builtins [\#7](https://github.com/pyapp-kit/psygnal/pull/7) ([tlambert03](https://github.com/tlambert03)) **Tests & CI:** - Add more typing tests [\#9](https://github.com/pyapp-kit/psygnal/pull/9) ([tlambert03](https://github.com/tlambert03)) - test working with qtbot [\#8](https://github.com/pyapp-kit/psygnal/pull/8) ([tlambert03](https://github.com/tlambert03)) ## [v0.1.1](https://github.com/pyapp-kit/psygnal/tree/v0.1.1) (2021-07-07) [Full Changelog](https://github.com/pyapp-kit/psygnal/compare/v0.1.0...v0.1.1) **Implemented enhancements:** - connect decorator, optional args [\#5](https://github.com/pyapp-kit/psygnal/pull/5) ([tlambert03](https://github.com/tlambert03)) **Fixed bugs:** - Catch inspection failures on connect \(e.g. `print`\), and improve maxargs syntax [\#6](https://github.com/pyapp-kit/psygnal/pull/6) ([tlambert03](https://github.com/tlambert03)) ## [v0.1.0](https://github.com/pyapp-kit/psygnal/tree/v0.1.0) (2021-07-06) [Full Changelog](https://github.com/pyapp-kit/psygnal/compare/bd037d2cb3cdc1c9423fd7d88ac6edfdd40f39d9...v0.1.0) **Implemented enhancements:** - Add readme, add `@connect` decorator [\#3](https://github.com/pyapp-kit/psygnal/pull/3) ([tlambert03](https://github.com/tlambert03)) **Tests & CI:** - fix ci [\#2](https://github.com/pyapp-kit/psygnal/pull/2) ([tlambert03](https://github.com/tlambert03)) - ci [\#1](https://github.com/pyapp-kit/psygnal/pull/1) ([tlambert03](https://github.com/tlambert03)) \* *This Changelog was automatically generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator)* psygnal-0.9.1/src/psygnal/__init__.py0000644000000000000000000000414713615410400014501 0ustar00"""Psygnal implements the observer pattern for Python. It emulates the signal/slot pattern from Qt, but it does not require Qt. """ import os from typing import TYPE_CHECKING, Any if TYPE_CHECKING: PackageNotFoundError = Exception from ._evented_model import EventedModel def version(package: str) -> str: """Return version.""" else: # hiding this import from type checkers so mypyc can work on both 3.7 and later try: from importlib.metadata import PackageNotFoundError, version except ImportError: from importlib_metadata import PackageNotFoundError, version try: __version__ = version("psygnal") except PackageNotFoundError: # pragma: no cover __version__ = "0.0.0" __author__ = "Talley Lambert" __email__ = "talley.lambert@gmail.com" __all__ = [ "__version__", "_compiled", "debounced", "EmissionInfo", "EmitLoopError", "emit_queued", "evented", "EventedModel", "get_evented_namespace", "is_evented", "Signal", "SignalGroup", "SignalGroupDescriptor", "SignalInstance", "throttled", ] if os.getenv("PSYGNAL_UNCOMPILED"): import warnings warnings.warn( "PSYGNAL_UNCOMPILED no longer has any effect. If you wish to run psygnal " "without compiled files, you can run:\n\n" 'python -c "import psygnal.utils; psygnal.utils.decompile()"\n\n' "(You will need to reinstall psygnal to get the compiled version back.)", stacklevel=2, ) from ._evented_decorator import evented from ._exceptions import EmitLoopError from ._group import EmissionInfo, SignalGroup from ._group_descriptor import ( SignalGroupDescriptor, get_evented_namespace, is_evented, ) from ._queue import emit_queued from ._signal import Signal, SignalInstance, _compiled from ._throttler import debounced, throttled def __getattr__(name: str) -> Any: if name == "EventedModel": from ._evented_model import EventedModel return EventedModel raise AttributeError( # pragma: no cover f"module {__name__!r} has no attribute {name!r}" ) del os, TYPE_CHECKING psygnal-0.9.1/src/psygnal/_dataclass_utils.py0000644000000000000000000001205413615410400016254 0ustar00from __future__ import annotations import contextlib import dataclasses import sys import types from typing import TYPE_CHECKING, Any, Iterator, List, cast, overload from typing_extensions import Protocol if TYPE_CHECKING: import attrs import msgspec from pydantic import BaseModel from typing_extensions import TypeGuard GenericAlias = getattr(types, "GenericAlias", type(List[int])) # safe for < py 3.9 class _DataclassParams(Protocol): init: bool repr: bool eq: bool order: bool unsafe_hash: bool frozen: bool class AttrsType: __attrs_attrs__: tuple[attrs.Attribute, ...] _DATACLASS_PARAMS = "__dataclass_params__" with contextlib.suppress(ImportError): from dataclasses import _DATACLASS_PARAMS # type: ignore _DATACLASS_FIELDS = "__dataclass_fields__" with contextlib.suppress(ImportError): from dataclasses import _DATACLASS_FIELDS # type: ignore class DataClassType: __dataclass_params__: _DataclassParams __dataclass_fields__: dict[str, dataclasses.Field] @overload def is_dataclass(obj: type) -> TypeGuard[type[DataClassType]]: ... @overload def is_dataclass(obj: object) -> TypeGuard[DataClassType]: ... def is_dataclass(obj: object) -> TypeGuard[DataClassType]: """Return True if the object is a dataclass.""" cls = ( obj if isinstance(obj, type) and not isinstance(obj, GenericAlias) else type(obj) ) return hasattr(cls, _DATACLASS_FIELDS) @overload def is_attrs_class(obj: type) -> TypeGuard[type[AttrsType]]: ... @overload def is_attrs_class(obj: object) -> TypeGuard[AttrsType]: ... def is_attrs_class(obj: object) -> TypeGuard[type[AttrsType]]: """Return True if the class is an attrs class.""" attr = sys.modules.get("attr", None) cls = obj if isinstance(obj, type) else type(obj) return attr.has(cls) if attr is not None else False # type: ignore [no-any-return] @overload def is_pydantic_model(obj: type) -> TypeGuard[type[BaseModel]]: ... @overload def is_pydantic_model(obj: object) -> TypeGuard[BaseModel]: ... def is_pydantic_model(obj: object) -> TypeGuard[BaseModel]: """Return True if the class is a pydantic BaseModel.""" pydantic = sys.modules.get("pydantic", None) cls = obj if isinstance(obj, type) else type(obj) return pydantic is not None and issubclass(cls, pydantic.BaseModel) @overload def is_msgspec_struct(obj: type) -> TypeGuard[type[msgspec.Struct]]: ... @overload def is_msgspec_struct(obj: object) -> TypeGuard[msgspec.Struct]: ... def is_msgspec_struct(obj: object) -> TypeGuard[msgspec.Struct]: """Return True if the class is a `msgspec.Struct`.""" msgspec = sys.modules.get("msgspec", None) cls = obj if isinstance(obj, type) else type(obj) return msgspec is not None and issubclass(cls, msgspec.Struct) def is_frozen(obj: Any) -> bool: """Return True if the object is frozen.""" # sourcery skip: reintroduce-else cls = obj if isinstance(obj, type) else type(obj) params = cast("_DataclassParams | None", getattr(cls, _DATACLASS_PARAMS, None)) if params is not None: return params.frozen # pydantic cfg = getattr(cls, "__config__", None) if cfg is not None and getattr(cfg, "allow_mutation", None) is False: return True # attrs if getattr(cls.__setattr__, "__name__", None) == "_frozen_setattrs": return True cfg = getattr(cls, "__struct_config__", None) if cfg is not None: # pragma: no cover # this will be covered in msgspec > 0.13.1 return bool(getattr(cfg, "frozen", False)) return False def iter_fields( cls: type, exclude_frozen: bool = True ) -> Iterator[tuple[str, type | None]]: """Iterate over all fields in the class, including inherited fields. This function recognizes dataclasses, attrs classes, msgspec Structs, and pydantic models. Parameters ---------- cls : type The class to iterate over. exclude_frozen : bool, optional If True, frozen fields will be excluded. By default True. Yields ------ tuple[str, type | None] The name and type of each field. """ # generally opting for speed here over public API dclass_fields = getattr(cls, "__dataclass_fields__", None) if dclass_fields is not None: for d_field in dclass_fields.values(): if d_field._field_type is dataclasses._FIELD: # type: ignore [attr-defined] yield d_field.name, d_field.type return if is_pydantic_model(cls): for p_field in cls.__fields__.values(): if p_field.field_info.allow_mutation or not exclude_frozen: yield p_field.name, p_field.outer_type_ return attrs_fields = getattr(cls, "__attrs_attrs__", None) if attrs_fields is not None: for a_field in attrs_fields: yield a_field.name, a_field.type return if is_msgspec_struct(cls): for m_field in cls.__struct_fields__: type_ = cls.__annotations__.get(m_field, None) yield m_field, type_ return psygnal-0.9.1/src/psygnal/_evented_decorator.py0000644000000000000000000001016413615410400016571 0ustar00from typing import ( TYPE_CHECKING, Any, Callable, Dict, Optional, Type, TypeVar, Union, overload, ) from psygnal._group_descriptor import SignalGroupDescriptor if TYPE_CHECKING: from typing_extensions import Literal __all__ = ["evented"] T = TypeVar("T", bound=Type) EqOperator = Callable[[Any, Any], bool] PSYGNAL_GROUP_NAME = "_psygnal_group_" _NULL = object() @overload def evented( cls: T, *, events_namespace: str = "events", equality_operators: Optional[Dict[str, EqOperator]] = None, warn_on_no_fields: bool = ..., cache_on_instance: bool = ..., ) -> T: ... @overload def evented( cls: "Literal[None]" = None, *, events_namespace: str = "events", equality_operators: Optional[Dict[str, EqOperator]] = None, warn_on_no_fields: bool = ..., cache_on_instance: bool = ..., ) -> Callable[[T], T]: ... def evented( cls: Optional[T] = None, *, events_namespace: str = "events", equality_operators: Optional[Dict[str, EqOperator]] = None, warn_on_no_fields: bool = True, cache_on_instance: bool = True, ) -> Union[Callable[[T], T], T]: """A decorator to add events to a dataclass. See also the documentation for [`SignalGroupDescriptor`][psygnal.SignalGroupDescriptor]. This decorator is equivalent setting a class variable named `events` to a new `SignalGroupDescriptor` instance. Note that this decorator will modify `cls` *in place*, as well as return it. !!!tip It is recommended to use the `SignalGroupDescriptor` descriptor rather than the decorator, as it it is more explicit and provides for easier static type inference. Parameters ---------- cls : type The class to decorate. events_namespace : str The name of the namespace to add the events to, by default `"events"` equality_operators : Optional[Dict[str, Callable]] A dictionary mapping field names to equality operators (a function that takes two values and returns `True` if they are equal). These will be used to determine if a field has changed when setting a new value. By default, this will use the `__eq__` method of the field type, or np.array_equal, for numpy arrays. But you can provide your own if you want to customize how equality is checked. Alternatively, if the class has an `__eq_operators__` class attribute, it will be used. warn_on_no_fields : bool If `True` (the default), a warning will be emitted if no mutable dataclass-like fields are found on the object. cache_on_instance : bool, optional If `True` (the default), a newly-created SignalGroup instance will be cached on the instance itself, so that subsequent accesses to the descriptor will return the same SignalGroup instance. This makes for slightly faster subsequent access, but means that the owner instance will no longer be pickleable. If `False`, the SignalGroup instance will *still* be cached, but not on the instance itself. Returns ------- type The decorated class, which gains a new SignalGroup instance at the `events_namespace` attribute (by default, `events`). Raises ------ TypeError If the class is frozen or is not a class. Examples -------- ```python from psygnal import evented from dataclasses import dataclass @evented @dataclass class Person: name: str age: int = 0 ``` """ def _decorate(cls: T) -> T: if not isinstance(cls, type): # pragma: no cover raise TypeError("evented can only be used on classes") descriptor = SignalGroupDescriptor( equality_operators=equality_operators, warn_on_no_fields=warn_on_no_fields, cache_on_instance=cache_on_instance, ) # as a decorator, this will have already been called descriptor.__set_name__(cls, events_namespace) setattr(cls, events_namespace, descriptor) return cls return _decorate(cls) if cls is not None else _decorate psygnal-0.9.1/src/psygnal/_evented_model.py0000644000000000000000000004363613615410400015721 0ustar00import sys import warnings from contextlib import contextmanager from typing import ( TYPE_CHECKING, Any, Callable, ClassVar, Dict, Iterator, Set, Type, Union, cast, no_type_check, ) import pydantic.main from pydantic import BaseModel, PrivateAttr, utils from pydantic.fields import Field, FieldInfo from ._group import SignalGroup from ._group_descriptor import _check_field_equality, _pick_equality_operator from ._signal import Signal, SignalInstance if TYPE_CHECKING: from inspect import Signature from pydantic.fields import ModelField from typing_extensions import dataclass_transform EqOperator = Callable[[Any, Any], bool] else: try: from typing_extensions import dataclass_transform except ImportError: # pragma: no cover def dataclass_transform(*args, **kwargs): return lambda a: a _NULL = object() ALLOW_PROPERTY_SETTERS = "allow_property_setters" PROPERTY_DEPENDENCIES = "property_dependencies" GUESS_PROPERTY_DEPENDENCIES = "guess_property_dependencies" @contextmanager def no_class_attributes() -> Iterator[None]: # pragma: no cover """Context in which pydantic.main.ClassAttribute just passes value 2. Due to a very annoying decision by PySide2, all class ``__signature__`` attributes may only be assigned **once**. (This seems to be regardless of whether the class has anything to do with PySide2 or not). Furthermore, the PySide2 ``__signature__`` attribute seems to break the python descriptor protocol, which means that class attributes that have a ``__get__`` method will not be able to successfully retrieve their value (instead, the descriptor object itself will be accessed). This plays terribly with Pydantic, which assigns a ``ClassAttribute`` object to the value of ``cls.__signature__`` in ``ModelMetaclass.__new__`` in order to avoid masking the call signature of object instances that have a ``__call__`` method (https://github.com/samuelcolvin/pydantic/pull/1466). So, because we only get to set the ``__signature__`` once, this context manager basically "opts-out" of pydantic's ``ClassAttribute`` strategy, thereby directly setting the ``cls.__signature__`` to an instance of ``inspect.Signature``. For additional context, see: - https://github.com/napari/napari/issues/2264 - https://github.com/napari/napari/pull/2265 - https://bugreports.qt.io/browse/PYSIDE-1004 - https://codereview.qt-project.org/c/pyside/pyside-setup/+/261411 """ if "PySide2" not in sys.modules: yield return # monkey patch the pydantic ClassAttribute object # the second argument to ClassAttribute is the inspect.Signature object def _return2(x: str, y: "Signature") -> "Signature": return y pydantic.main.ClassAttribute = _return2 # type: ignore try: yield finally: # undo our monkey patch pydantic.main.ClassAttribute = utils.ClassAttribute # type: ignore @dataclass_transform(kw_only_default=True, field_specifiers=(Field, FieldInfo)) class EventedMetaclass(pydantic.main.ModelMetaclass): """pydantic ModelMetaclass that preps "equality checking" operations. A metaclass is the thing that "constructs" a class, and ``ModelMetaclass`` is where pydantic puts a lot of it's type introspection and ``ModelField`` creation logic. Here, we simply tack on one more function, that builds a ``cls.__eq_operators__`` dict which is mapping of field name to a function that can be called to check equality of the value of that field with some other object. (used in ``EventedModel.__eq__``) This happens only once, when an ``EventedModel`` class is created (and not when each instance of an ``EventedModel`` is instantiated). """ @no_type_check def __new__( mcs: type, name: str, bases: tuple, namespace: dict, **kwargs: Any ) -> "EventedMetaclass": """Create new EventedModel class.""" with no_class_attributes(): cls = super().__new__(mcs, name, bases, namespace, **kwargs) cls.__eq_operators__ = {} signals = {} fields: Dict[str, "ModelField"] = cls.__fields__ for n, f in fields.items(): cls.__eq_operators__[n] = _pick_equality_operator(f.type_) if f.field_info.allow_mutation: signals[n] = Signal(f.type_) # If a field type has a _json_encode method, add it to the json # encoders for this model. # NOTE: a _json_encode field must return an object that can be # passed to json.dumps ... but it needn't return a string. if hasattr(f.type_, "_json_encode"): encoder = f.type_._json_encode cls.__config__.json_encoders[f.type_] = encoder # also add it to the base config # required for pydantic>=1.8.0 due to: # https://github.com/samuelcolvin/pydantic/pull/2064 EventedModel.__config__.json_encoders[f.type_] = encoder allow_props = getattr(cls.__config__, ALLOW_PROPERTY_SETTERS, False) # check for @_.setters defined on the class, so we can allow them # in EventedModel.__setattr__ cls.__property_setters__ = {} if allow_props: for b in reversed(cls.__bases__): if hasattr(b, "__property_setters__"): cls.__property_setters__.update(b.__property_setters__) for key, attr in namespace.items(): if isinstance(attr, property) and attr.fset is not None: cls.__property_setters__[key] = attr signals[key] = Signal(object) else: for b in cls.__bases__: conf = getattr(b, "__config__", None) if conf and getattr(conf, ALLOW_PROPERTY_SETTERS, False): raise ValueError( "Cannot set 'allow_property_setters' to 'False' when base " f"class {b} sets it to True" ) cls.__field_dependents__ = _get_field_dependents(cls) cls.__signal_group__ = type(f"{name}SignalGroup", (SignalGroup,), signals) return cls def _get_field_dependents(cls: "EventedModel") -> Dict[str, Set[str]]: """Return mapping of field name -> dependent set of property names. Dependencies may be declared in the Model Config to emit an event for a computed property when a model field that it depends on changes e.g. (@property 'c' depends on model fields 'a' and 'b') Examples -------- class MyModel(EventedModel): a: int = 1 b: int = 1 @property def c(self) -> List[int]: return [self.a, self.b] @c.setter def c(self, val: Sequence[int]): self.a, self.b = val class Config: property_dependencies={'c': ['a', 'b']} """ deps: Dict[str, Set[str]] = {} cfg_deps = getattr(cls.__config__, PROPERTY_DEPENDENCIES, {}) # sourcery skip if cfg_deps: if not isinstance(cfg_deps, dict): # pragma: no cover raise TypeError( f"Config property_dependencies must be a dict, not {cfg_deps!r}" ) for prop, fields in cfg_deps.items(): if prop not in cls.__property_setters__: raise ValueError( "Fields with dependencies must be property.setters." f"{prop!r} is not." ) for field in fields: if field not in cls.__fields__: warnings.warn( f"Unrecognized field dependency: {field!r}", stacklevel=2 ) deps.setdefault(field, set()).add(prop) if getattr(cls.__config__, GUESS_PROPERTY_DEPENDENCIES, False): # if property_dependencies haven't been explicitly defined, we can glean # them from the property.fget code object: # SKIP THIS MAGIC FOR NOW? for prop, setter in cls.__property_setters__.items(): if setter.fget is not None: for name in setter.fget.__code__.co_names: if name in cls.__fields__: deps.setdefault(name, set()).add(prop) return deps class EventedModel(BaseModel, metaclass=EventedMetaclass): """A pydantic BaseModel that emits a signal whenever a field value is changed. !!! important This class requires `pydantic` to be installed. You can install directly (`pip install pydantic`) or by using the psygnal extra: `pip install psygnal[pydantic]` In addition to standard pydantic `BaseModel` properties (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`][]. 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). 2. Gains support for properties and property.setters (not supported in pydantic's BaseModel). Enable by adding `allow_property_setters = True` to your model `Config`. 3. If you would like properties (i.e. "computed fields") to emit an event when one of the model fields it depends on is mutated you must set one of the following options in the `Config`: - `property_dependencies` may be a `Dict[str, List[str]]`, where the keys are the names of properties, and the values are a list of field names (strings) that the property depends on for its value - `guess_property_dependencies` may be set to `True` to "guess" property 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`. Examples -------- Standard EventedModel example: ```python class MyModel(EventedModel): x: int = 1 m = MyModel() m.events.x.connect(lambda v: print(f'new value is {v}')) m.x = 3 # prints 'new value is 3' ``` An example of using property_setters and emitting signals when a field dependency is mutated. ```python class MyModel(EventedModel): a: int = 1 b: int = 1 @property def c(self) -> List[int]: return [self.a, self.b] @c.setter def c(self, val: Sequence[int]) -> None: self.a, self.b = val class Config: allow_property_setters = True property_dependencies = {"c": ["a", "b"]} m = MyModel() assert m.c == [1, 1] m.events.c.connect(lambda v: print(f"c updated to {v}")) m.a = 2 # prints 'c updated to [2, 1]' ``` """ # add private attributes for event emission _events: ClassVar[SignalGroup] = PrivateAttr() # mapping of name -> property obj for methods that are property setters __property_setters__: ClassVar[Dict[str, property]] # mapping of field name -> dependent set of property names # when field is changed, an event for dependent properties will be emitted. __field_dependents__: ClassVar[Dict[str, Set[str]]] __eq_operators__: ClassVar[Dict[str, "EqOperator"]] __slots__ = {"__weakref__"} __signal_group__: ClassVar[Type[SignalGroup]] # pydantic BaseModel configuration. see: # https://pydantic-docs.helpmanual.io/usage/model_config/ class Config: # this seems to be necessary for the _json_encoders trick to work json_encoders = {"____": None} def __init__(_model_self_, **data: Any) -> None: super().__init__(**data) Group = _model_self_.__signal_group__ # the type error is "cannot assign to a class variable" ... # but if we don't use `ClassVar`, then the `dataclass_transform` decorator # will add _events: SignalGroup to the __init__ signature, for *all* user models _model_self_._events = Group(_model_self_) # type: ignore [misc] def _super_setattr_(self, name: str, value: Any) -> None: # pydantic will raise a ValueError if extra fields are not allowed # so we first check to see if this field has a property.setter. # if so, we use it instead. if name in self.__property_setters__: self.__property_setters__[name].fset(self, value) # type: ignore else: super().__setattr__(name, value) def __setattr__(self, name: str, value: Any) -> None: if ( name == "_events" or not hasattr(self, "_events") # can happen on init or name not in self._events.signals ): # fallback to default behavior return self._super_setattr_(name, value) # grab current value before = getattr(self, name, object()) # set value using original setter self._super_setattr_(name, value) # if different we emit the event with new value after = getattr(self, name) if not _check_field_equality(type(self), name, after, before): signal_instance: SignalInstance = getattr(self.events, name) signal_instance.emit(after) # emit event # emit events for any dependent computed property setters as well for dep in self.__field_dependents__.get(name, ()): getattr(self.events, dep).emit(getattr(self, dep)) # expose the private SignalGroup publically @property def events(self) -> SignalGroup: """Return the `SignalGroup` containing all events for this model.""" return self._events @property def _defaults(self) -> dict: return _get_defaults(self) def reset(self) -> None: """Reset the state of the model to default values.""" for name, value in self._defaults.items(): if isinstance(value, EventedModel): cast("EventedModel", getattr(self, name)).reset() elif ( self.__config__.allow_mutation and self.__fields__[name].field_info.allow_mutation ): setattr(self, name, value) def update(self, values: Union["EventedModel", dict], recurse: bool = True) -> None: """Update a model in place. Parameters ---------- values : Union[dict, EventedModel] Values to update the model with. If an EventedModel is passed it is first converted to a dictionary. The keys of this dictionary must be found as attributes on the current model. recurse : bool If True, recursively update fields that are EventedModels. Otherwise, just update the immediate fields of this EventedModel, which is useful when the declared field type (e.g. ``Union``) can have different realized types with different fields. """ if isinstance(values, BaseModel): values = values.dict() if not isinstance(values, dict): # pragma: no cover raise TypeError(f"values must be a dict or BaseModel. got {type(values)}") with self.events.paused(): # TODO: reduce? for key, value in values.items(): field = getattr(self, key) if isinstance(field, EventedModel) and recurse: field.update(value, recurse=recurse) else: setattr(self, key, value) def __eq__(self, other: Any) -> bool: """Check equality with another object. We override the pydantic approach (which just checks ``self.dict() == other.dict()``) to accommodate more complicated types like arrays, whose truth value is often ambiguous. ``__eq_operators__`` is constructed in ``EqualityMetaclass.__new__`` """ if not isinstance(other, EventedModel): return self.dict() == other # type: ignore for f_name, _ in self.__eq_operators__.items(): if not hasattr(self, f_name) or not hasattr(other, f_name): return False # pragma: no cover a = getattr(self, f_name) b = getattr(other, f_name) if not _check_field_equality(type(self), f_name, a, b): return False return True @contextmanager def enums_as_values(self, as_values: bool = True) -> Iterator[None]: """Temporarily override how enums are retrieved. Parameters ---------- as_values : bool Whether enums should be shown as values (or as enum objects), by default `True` """ before = getattr(self.Config, "use_enum_values", _NULL) self.Config.use_enum_values = as_values # type: ignore try: yield finally: if before is not _NULL: self.Config.use_enum_values = before # type: ignore # pragma: no cover else: delattr(self.Config, "use_enum_values") def _get_defaults(obj: BaseModel) -> Dict[str, Any]: """Get possibly nested default values for a Model object.""" dflt = {} for k, v in obj.__fields__.items(): d = v.get_default() if d is None and isinstance(v.type_, pydantic.main.ModelMetaclass): d = _get_defaults(v.type_) # pragma: no cover dflt[k] = d return dflt psygnal-0.9.1/src/psygnal/_exceptions.py0000644000000000000000000000073313615410400015257 0ustar00class EmitLoopError(Exception): """Error type raised when an exception occurs during a callback.""" def __init__(self, slot_repr: str, args: tuple, exc: BaseException) -> None: self.slot_repr = slot_repr self.args = args self.__cause__ = exc # mypyc doesn't set this, but uncompiled code would super().__init__( f"calling {self.slot_repr} with args={args!r} caused " f"{type(exc).__name__}: {exc}." ) psygnal-0.9.1/src/psygnal/_group.py0000644000000000000000000002162213615410400014232 0ustar00"""A SignalGroup class that allows connecting to all SignalInstances on the class. Note that unlike a slot/callback connected to SignalInstance.connect, a slot connected to SignalGroup.connect does *not* receive the direct arguments that were emitted by a given SignalInstance. Instead, the slot/callback will receive an EmissionInfo named tuple, which contains `.signal`: the SignalInstance doing the emitting, and `.args`: the args that were emitted. """ from __future__ import annotations from typing import ( Any, Callable, ClassVar, ContextManager, Iterable, Mapping, NamedTuple, ) from mypy_extensions import mypyc_attr from psygnal._signal import Signal, SignalInstance, _SignalBlocker __all__ = ["EmissionInfo", "SignalGroup"] class EmissionInfo(NamedTuple): """Tuple containing information about an emission event. Attributes ---------- signal : SignalInstance args: tuple """ signal: SignalInstance args: tuple[Any, ...] @mypyc_attr(allow_interpreted_subclasses=True) class SignalGroup(SignalInstance): """`SignalGroup` that enables connecting to all `SignalInstances`. Parameters ---------- instance : Any, optional An instance to which this event group is bound, by default None name : str, optional Optional name for this event group, by default will be the name of the group subclass. (e.g., 'Events' in the example below.) Examples -------- >>> class Events(SignalGroup): ... sig1 = Signal(str) ... sig2 = Signal(str) ... >>> events = Events() ... >>> def some_callback(record): ... record.signal # the SignalInstance that emitted ... record.args # the args that were emitted ... >>> events.connect(some_callback) note that the `SignalGroup` may also be created with `strict=True`, which will enforce that *all* signals have the same emission signature This is ok: >>> class Events(SignalGroup, strict=True): ... sig1 = Signal(int) ... sig1 = Signal(int) This will raise an exception >>> class Events(SignalGroup, strict=True): ... sig1 = Signal(int) ... sig1 = Signal(str) # not the same signature """ _signals_: ClassVar[Mapping[str, Signal]] _uniform: ClassVar[bool] = False # this is only here to indicate to mypy that all attributes are SignalInstances. def __getattr__(self, name: str) -> SignalInstance: raise AttributeError( f"SignalGroup {type(self).__name__!r} has no attribute {name!r}" ) def __init_subclass__(cls, strict: bool = False) -> None: """Finds all Signal instances on the class and add them to `cls._signals_`.""" cls._signals_ = {} for k in dir(cls): v = getattr(cls, k) if isinstance(v, Signal): cls._signals_[k] = v cls._uniform = _is_uniform(cls._signals_.values()) if strict and not cls._uniform: raise TypeError( "All Signals in a strict SignalGroup must have the same signature" ) return super().__init_subclass__() def __init__(self, instance: Any = None, name: str | None = None) -> None: super().__init__( signature=(EmissionInfo,), instance=instance, name=name or self.__class__.__name__, ) self._sig_was_blocked: dict[str, bool] = {} for _, sig in self.signals.items(): sig.connect(self._slot_relay, check_nargs=False, check_types=False) def __len__(self) -> int: return len(self._slots) @property def signals(self) -> dict[str, SignalInstance]: """Return {name -> SignalInstance} map of all signal instances in this group.""" return {n: getattr(self, n) for n in type(self)._signals_} @classmethod def is_uniform(cls) -> bool: """Return true if all signals in the group have the same signature.""" return cls._uniform def _slot_relay(self, *args: Any) -> None: emitter = Signal.current_emitter() if emitter: info = EmissionInfo(emitter, args) self._run_emit_loop((info,)) def connect_direct( self, slot: Callable | None = None, *, check_nargs: bool | None = None, check_types: bool | None = None, unique: bool | str = False, max_args: int | None = None, ) -> Callable[[Callable], Callable] | Callable: """Connect `slot` to be called whenever *any* Signal in this group is emitted. Params are the same as {meth}`~psygnal.SignalInstance.connect`. It's probably best to check whether `self.is_uniform()` Parameters ---------- slot : Callable A callable to connect to this signal. If the callable accepts less arguments than the signature of this slot, then they will be discarded when calling the slot. check_nargs : Optional[bool] If `True` and the provided `slot` requires more positional arguments than the signature of this Signal, raise `TypeError`. by default `True`. check_types : Optional[bool] If `True`, An additional check will be performed to make sure that types declared in the slot signature are compatible with the signature declared by this signal, by default `False`. unique : Union[bool, str] If `True`, returns without connecting if the slot has already been connected. If the literal string "raise" is passed to `unique`, then a `ValueError` will be raised if the slot is already connected. By default `False`. max_args : int, optional If provided, `slot` will be called with no more more than `max_args` when this SignalInstance is emitted. (regardless of how many arguments are emitted). Returns ------- Union[Callable[[Callable], Callable], Callable] [description] """ def _inner(slot: Callable) -> Callable: for sig in self.signals.values(): sig.connect( slot, check_nargs=check_nargs, check_types=check_types, unique=unique, max_args=max_args, ) return slot return _inner if slot is None else _inner(slot) def block(self, exclude: Iterable[str | SignalInstance] = ()) -> None: """Block this signal and all emitters from emitting.""" super().block() for k, v in self.signals.items(): if exclude and v in exclude or k in exclude: continue self._sig_was_blocked[k] = v._is_blocked v.block() def unblock(self) -> None: """Unblock this signal and all emitters, allowing them to emit.""" super().unblock() for k, v in self.signals.items(): if not self._sig_was_blocked.pop(k, False): v.unblock() def blocked( self, exclude: Iterable[str | SignalInstance] = () ) -> ContextManager[None]: """Context manager to temporarily block all emitters in this group. Parameters ---------- exclude : iterable of str or SignalInstance, optional An iterable of signal instances or names to exempt from the block, by default () """ return _SignalBlocker(self, exclude=exclude) def disconnect(self, slot: Callable | None = None, missing_ok: bool = True) -> None: """Disconnect slot from all signals. Parameters ---------- slot : callable, optional The specific slot to disconnect. If `None`, all slots will be disconnected, by default `None` missing_ok : bool, optional If `False` and the provided `slot` is not connected, raises `ValueError. by default `True` Raises ------ ValueError If `slot` is not connected and `missing_ok` is False. """ for signal in self.signals.values(): signal.disconnect(slot, missing_ok) super().disconnect(slot, missing_ok) def __repr__(self) -> str: """Return repr(self).""" name = f" {self.name!r}" if self.name else "" instance = f" on {self.instance!r}" if self.instance else "" nsignals = len(self.signals) signals = f"{nsignals} signals" if nsignals > 1 else "" return f"" def _is_uniform(signals: Iterable[Signal]) -> bool: """Return True if all signals have the same signature.""" seen: set[tuple[str, ...]] = set() for s in signals: v = tuple(str(p.annotation) for p in s.signature.parameters.values()) if seen and v not in seen: # allow zero or one return False seen.add(v) return True psygnal-0.9.1/src/psygnal/_group_descriptor.py0000644000000000000000000003752613615410400016502 0ustar00from __future__ import annotations import contextlib import operator import sys import warnings import weakref from functools import lru_cache from typing import TYPE_CHECKING, Any, Callable, Iterable, Type, TypeVar, cast, overload from typing_extensions import Literal from ._dataclass_utils import iter_fields from ._group import SignalGroup from ._signal import Signal if TYPE_CHECKING: from ._signal import SignalInstance __all__ = ["is_evented", "get_evented_namespace", "SignalGroupDescriptor"] T = TypeVar("T", bound=Type) S = TypeVar("S") EqOperator = Callable[[Any, Any], bool] _EQ_OPERATORS: dict[type, dict[str, EqOperator]] = {} _EQ_OPERATOR_NAME = "__eq_operators__" PSYGNAL_GROUP_NAME = "_psygnal_group_" PATCHED_BY_PSYGNAL = "_patched_by_psygnal_" _NULL = object() def _get_eq_operator_map(cls: type) -> dict[str, EqOperator]: """Return the map of field_name -> equality operator for the class.""" # 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)) else: return _EQ_OPERATORS.setdefault(cls, {}) def _check_field_equality( cls: type, name: str, before: Any, after: Any, _fail: bool = False ) -> bool: """Test if two values are equal for a given field. This function will look for a field-specific operator in the the `__eq_operators__` attribute of the class if present, otherwise it will use the default equality operator for the type. Parameters ---------- cls : type The class that contains the field. name : str The name of the field. before : Any The value of the field before the change. after : Any The value of the field after the change. _fail : bool, optional If True, raise a ValueError if the field is not found in the class. by default False Returns ------- bool True if the values are equal, False otherwise. """ if before is _NULL: # field didn't exist to begin with (unlikely) return after is _NULL # pragma: no cover eq_map = _get_eq_operator_map(cls) # get and execute the equality operator for the field are_equal = eq_map.setdefault(name, operator.eq) try: # may fail depending on the __eq__ method for the type return bool(are_equal(after, before)) except Exception: if _fail: raise # pragma: no cover # if we fail, we try to pick a new equality operator # if it's a numpy array, we use np.array_equal # finally, fallback to operator.is_ np = sys.modules.get("numpy", None) if ( hasattr(after, "__array__") and np is not None and are_equal is not np.array_equal ): eq_map[name] = np.array_equal return _check_field_equality(cls, name, before, after, _fail=False) else: eq_map[name] = operator.is_ return _check_field_equality(cls, name, before, after, _fail=True) def _pick_equality_operator(type_: type | None) -> EqOperator: """Get the default equality operator for a given type.""" np = sys.modules.get("numpy", None) if np is not None and hasattr(type_, "__array__"): return np.array_equal # type: ignore [no-any-return] return operator.eq @lru_cache(maxsize=None) def _build_dataclass_signal_group( cls: type, equality_operators: Iterable[tuple[str, EqOperator]] | None = None ) -> type[SignalGroup]: """Build a SignalGroup with events for each field in a dataclass.""" _equality_operators = dict(equality_operators) if equality_operators else {} signals = {} eq_map = _get_eq_operator_map(cls) for name, type_ in iter_fields(cls): if name in _equality_operators: if not callable(_equality_operators[name]): # pragma: no cover raise TypeError("EqOperator must be callable") eq_map[name] = _equality_operators[name] else: eq_map[name] = _pick_equality_operator(type_) signals[name] = Signal(object if type_ is None else type_) return type(f"{cls.__name__}SignalGroup", (SignalGroup,), signals) def is_evented(obj: object) -> bool: """Return `True` if the object or its class has been decorated with evented. This also works for a __setattr__ method that has been patched by psygnal. """ return hasattr(obj, PSYGNAL_GROUP_NAME) or hasattr(obj, PATCHED_BY_PSYGNAL) def get_evented_namespace(obj: object) -> str | None: """Return the name of the evented SignalGroup for an object. Note: if you get the returned name as an attribute of the object, it will be a SignalGroup instance only if `obj` is an *instance* of an evented class. If `obj` is the evented class itself, it will be a `_SignalGroupDescriptor`. Examples -------- ```python from psygnal import evented, get_evented_namespace, is_evented @evented(events_namespace="my_events") class Foo: ... assert get_evented_namespace(Foo) == "my_events" assert is_evented(Foo) """ return getattr(obj, PSYGNAL_GROUP_NAME, None) class _changes_emitted: def __init__(self, obj: object, field: str, signal: SignalInstance) -> None: self.obj = obj self.field = field self.signal = signal def __enter__(self) -> None: self._prev = getattr(self.obj, self.field, _NULL) def __exit__(self, *args: Any) -> None: new: Any = getattr(self.obj, self.field, _NULL) if not _check_field_equality(type(self.obj), self.field, self._prev, new): self.signal.emit(new) SetAttr = Callable[[Any, str, Any], None] @overload def evented_setattr(signal_group_name: str, super_setattr: SetAttr) -> SetAttr: ... @overload def evented_setattr( signal_group_name: str, super_setattr: Literal[None] = None ) -> Callable[[SetAttr], SetAttr]: ... def evented_setattr( signal_group_name: str, super_setattr: SetAttr | None = None ) -> SetAttr | Callable[[SetAttr], SetAttr]: """Create a new __setattr__ method that emits events when fields change. `signal_group_name` must point to an attribute on the `self` object provided to __setattr__ that obeys the following "SignalGroup interface": 1. For every "evented" field in the class, there must be a corresponding attribute on the SignalGroup instance: `assert hasattr(signal_group, attr_name)` 2. The object returned by `getattr(signal_group, attr_name)` must be a SignalInstance-like object, i.e. it must have an `emit` method that accepts one (or more) positional arguments. ```python class SignalInstanceProtocol(Protocol): def emit(self, *args: Any) -> Any: ... class SignalGroupProtocol(Protocol): def __getattr__(self, name: str) -> SignalInstanceProtocol: ... ``` Parameters ---------- signal_group_name : str, optional The name of the attribute on `self` that holds the `SignalGroup` instance, by default "_psygnal_group_". super_setattr: Callable The original __setattr__ method for the class. """ def _inner(super_setattr: SetAttr) -> SetAttr: # don't patch twice if getattr(super_setattr, PATCHED_BY_PSYGNAL, False): return super_setattr def _setattr_and_emit_(self: object, name: str, value: Any) -> None: """New __setattr__ method that emits events when fields change.""" if name == signal_group_name: return super_setattr(self, name, value) group = getattr(self, signal_group_name, None) signal = cast("SignalInstance | None", getattr(group, name, None)) if signal is None: return super_setattr(self, name, value) with _changes_emitted(self, name, signal): super_setattr(self, name, value) setattr(_setattr_and_emit_, PATCHED_BY_PSYGNAL, True) return _setattr_and_emit_ return _inner(super_setattr) if super_setattr else _inner class SignalGroupDescriptor: """Create a [`psygnal.SignalGroup`][] on first instance attribute access. This descriptor is designed to be used as a class attribute on a dataclass-like class (e.g. a [`dataclass`](https://docs.python.org/3/library/dataclasses.html), a [`pydantic.BaseModel`](https://docs.pydantic.dev/usage/models/), an [attrs](https://www.attrs.org/en/stable/overview.html) class, a [`msgspec.Struct`](https://jcristharif.com/msgspec/structs.html)) On first access of the descriptor on an instance, it will create a [`SignalGroup`][psygnal.SignalGroup] bound to the instance, with a [`SignalInstance`][psygnal.SignalInstance] for each field in the dataclass. !!!important Using this descriptor will *patch* the class's `__setattr__` method to emit events when fields change. (That patching occurs on first access of the descriptor name on an instance). To prevent this patching, you can set `patch_setattr=False` when creating the descriptor, but then you will need to manually call `emit` on the appropriate `SignalInstance` when you want to emit an event. Or you can use `evented_setattr` yourself ```python from psygnal._group_descriptor import evented_setattr from psygnal import SignalGroupDescriptor from dataclasses import dataclass from typing import ClassVar @dataclass class Foo: x: int _events: ClassVar = SignalGroupDescriptor(patch_setattr=False) @evented_setattr("_events") # pass the name of your SignalGroup def __setattr__(self, name: str, value: Any) -> None: super().__setattr__(name, value) ``` *This currently requires a private import, please open an issue if you would like to depend on this functionality.* Parameters ---------- equality_operators : dict[str, Callable[[Any, Any], bool]], optional A dictionary mapping field names to custom equality operators, where an equality operator is a callable that accepts two arguments and returns True if the two objects are equal. This will be used when comparing the old and new values of a field to determine whether to emit an event. If not provided, the default equality operator is `operator.eq`, except for numpy arrays, where `np.array_equal` is used. signal_group_class : type[SignalGroup], optional A custom SignalGroup class to use, by default None warn_on_no_fields : bool, optional If `True` (the default), a warning will be emitted if no mutable dataclass-like fields are found on the object. cache_on_instance : bool, optional If `True` (the default), a newly-created SignalGroup instance will be cached on the instance itself, so that subsequent accesses to the descriptor will return the same SignalGroup instance. This makes for slightly faster subsequent access, but means that the owner instance will no longer be pickleable. If `False`, the SignalGroup instance will *still* be cached, but not on the instance itself. patch_setattr : bool, optional If `True` (the default), a new `__setattr__` method will be created that emits events when fields change. If `False`, no `__setattr__` method will be created. (This will prevent signal emission, and assumes you are using a different mechanism to emit signals when fields change.) Examples -------- ```python from typing import ClassVar from dataclasses import dataclass from psygnal import SignalGroupDescriptor @dataclass class Person: name: str age: int = 0 events: ClassVar[SignalGroupDescriptor] = SignalGroupDescriptor() john = Person('John', 40) john.events.age.connect(print) john.age += 1 # prints 41 ``` """ def __init__( self, *, equality_operators: dict[str, EqOperator] | None = None, signal_group_class: type[SignalGroup] | None = None, warn_on_no_fields: bool = True, cache_on_instance: bool = True, patch_setattr: bool = True, ): self._signal_group = signal_group_class self._name: str | None = None self._eqop = tuple(equality_operators.items()) if equality_operators else None self._warn_on_no_fields = warn_on_no_fields self._cache_on_instance = cache_on_instance self._patch_setattr = patch_setattr def __set_name__(self, owner: type, name: str) -> None: """Called when this descriptor is added to class `owner` as attribute `name`.""" self._name = name with contextlib.suppress(AttributeError): # This is the flag that identifies this object as evented setattr(owner, PSYGNAL_GROUP_NAME, name) def _do_patch_setattr(self, owner: type) -> None: """Patch the owner class's __setattr__ method to emit events.""" if not self._patch_setattr: return if getattr(owner.__setattr__, PATCHED_BY_PSYGNAL, False): return try: # assign a new __setattr__ method to the class owner.__setattr__ = evented_setattr( # type: ignore self._name, owner.__setattr__ # type: ignore ) except Exception as e: # pragma: no cover # not sure what might cause this ... but it will have consequences raise type(e)( f"Could not update __setattr__ on class: {owner}. Events will not be " "emitted when fields change." ) from e # map of id(obj) -> SignalGroup # cached here in case the object isn't modifiable _instance_map: dict[int, SignalGroup] = {} @overload def __get__(self, instance: None, owner: type) -> SignalGroupDescriptor: ... @overload def __get__(self, instance: object, owner: type) -> SignalGroup: ... def __get__( self, instance: object, owner: type ) -> SignalGroup | SignalGroupDescriptor: """Return a SignalGroup instance for `instance`.""" if instance is None: return self # if we haven't yet instantiated a SignalGroup for this instance, # do it now and cache it. Note that we cache it here in addition to # the instance (in case the instance is not modifiable). obj_id = id(instance) if obj_id not in self._instance_map: # cache it self._instance_map[obj_id] = self._create_group(owner)(instance) # also *try* to set it on the instance as well, since it will skip all the # __get__ logic in the future, but if it fails, no big deal. if self._name and self._cache_on_instance: with contextlib.suppress(Exception): setattr(instance, self._name, self._instance_map[obj_id]) # clean up the cache when the instance is deleted with contextlib.suppress(TypeError): # mypy says too many attributes for weakref.finalize, but it's wrong. weakref.finalize( # type: ignore [call-arg] instance, self._instance_map.pop, obj_id, None ) return self._instance_map[obj_id] def _create_group(self, owner: type) -> type[SignalGroup]: Group = self._signal_group or _build_dataclass_signal_group(owner, self._eqop) if self._warn_on_no_fields and not Group._signals_: warnings.warn( f"No mutable fields found on class {owner}: no events will be " "emitted. (Is this a dataclass, attrs, msgspec, or pydantic model?)", stacklevel=2, ) self._do_patch_setattr(owner) return Group psygnal-0.9.1/src/psygnal/_queue.py0000644000000000000000000000626213615410400014225 0ustar00from __future__ import annotations from queue import Queue from threading import Thread, current_thread, main_thread from typing import TYPE_CHECKING, Any, Callable, ClassVar, DefaultDict, Tuple if TYPE_CHECKING: from typing_extensions import Literal from ._exceptions import EmitLoopError from ._weak_callback import WeakCallback Callback = Callable[[Tuple[Any, ...]], Any] CbArgsTuple = Tuple[Callback, tuple] class QueuedCallback(WeakCallback): """WeakCallback that queues the callback to be called on a different thread. (...rather than invoking it immediately.) Parameters ---------- wrapped : WeakCallback The actual callback to be invoked. thread : Thread | Literal["main", "current"] | None The thread on which to invoke the callback. If not provided, the main thread will be used. """ _GLOBAL_QUEUE: ClassVar[DefaultDict[Thread, Queue[CbArgsTuple]]] = DefaultDict( Queue ) def __init__( self, wrapped: WeakCallback, thread: Thread | Literal["main", "current"] | None = None, ) -> None: self._wrapped = wrapped # keeping the wrapped key allows this slot to be disconnected # regardless of whether it was connected with type='queue' or 'direct' ... self._key: str = wrapped._key self._max_args: int | None = wrapped._max_args self._alive: bool = wrapped._alive self._on_ref_error = wrapped._on_ref_error if thread is None or thread == "main": thread = main_thread() elif thread == "current": thread = current_thread() elif not isinstance(thread, Thread): # pragma: no cover raise TypeError( f"`thread` must be a Thread instance, not {type(thread).__name__}" ) # NOTE: for some strange reason, mypyc crashes if we use `self._thread` here # so we use `self._thred` instead self._thred = thread def cb(self, args: tuple = ()) -> None: if current_thread() is self._thred: self._wrapped.cb(args) else: QueuedCallback._GLOBAL_QUEUE[self._thred].put((self._wrapped.cb, args)) def dereference(self) -> Callable | None: return self._wrapped.dereference() def emit_queued(thread: Thread | None = None) -> None: """Trigger emissions of all callbacks queued in the current thread. Parameters ---------- thread : Thread, optional The thread on which to invoke the callback. If not provided, the main thread will be used. Raises ------ EmitLoopError If an exception is raised while invoking a queued callback. This exception can be caught and optionally supressed or handled by the caller, allowing the emission of other queued callbacks to continue even if one of them raises an exception. """ _thread = current_thread() if thread is None else thread queue = QueuedCallback._GLOBAL_QUEUE[_thread] while not queue.empty(): cb, args = queue.get() try: cb(args) except Exception as e: # pragma: no cover raise EmitLoopError(slot_repr=repr(cb), args=args, exc=e) from e psygnal-0.9.1/src/psygnal/_signal.py0000644000000000000000000014233413615410400014357 0ustar00from __future__ import annotations import inspect import threading import warnings import weakref from contextlib import contextmanager, suppress from functools import lru_cache, partial, reduce from inspect import Parameter, Signature, isclass from typing import ( TYPE_CHECKING, Any, Callable, ClassVar, ContextManager, Iterable, Iterator, NoReturn, Type, TypeVar, Union, cast, get_type_hints, overload, ) from mypy_extensions import mypyc_attr from typing_extensions import get_args, get_origin from ._exceptions import EmitLoopError from ._queue import QueuedCallback from ._weak_callback import WeakCallback, _WeakSetattr, _WeakSetitem, weak_callback if TYPE_CHECKING: from typing_extensions import Literal from ._group import EmissionInfo from ._weak_callback import RefErrorChoice ReducerFunc = Callable[[tuple, tuple], tuple] __all__ = ["Signal", "SignalInstance", "_compiled"] _NULL = object() F = TypeVar("F", bound=Callable) class Signal: """Declares a signal emitter on a class. This is class implements the [descriptor protocol](https://docs.python.org/3/howto/descriptor.html#descriptorhowto) and is designed to be used as a class attribute, with the supported signature types provided in the constructor: ```python from psygnal import Signal class MyEmitter: changed = Signal(int) def receiver(arg: int): print("new value:", arg) emitter = MyEmitter() emitter.changed.connect(receiver) emitter.changed.emit(1) # prints 'new value: 1' ``` !!! note in the example above, `MyEmitter.changed` is an instance of `Signal`, and `emitter.changed` is an instance of `SignalInstance`. See the documentation on [`SignalInstance`][psygnal.SignalInstance] for details on how to connect to and/or emit a signal on an instance of an object that has a `Signal`. Parameters ---------- *types : Union[Type[Any], Signature] A sequence of individual types, or a *single* [`inspect.Signature`][] object. description : str Optional descriptive text for the signal. (not used internally). name : Optional[str] Optional name of the signal. If it is not specified then the name of the class attribute that is bound to the signal will be used. default None check_nargs_on_connect : bool Whether to check the number of positional args against `signature` when connecting a new callback. This can also be provided at connection time using `.connect(..., check_nargs=True)`. By default, True. check_types_on_connect : bool Whether to check the callback parameter types against `signature` when connecting a new callback. This can also be provided at connection time using `.connect(..., check_types=True)`. By default, False. """ # _signature: Signature # callback signature for this signal _current_emitter: ClassVar[SignalInstance | None] = None def __init__( self, *types: type[Any] | Signature, description: str = "", name: str | None = None, check_nargs_on_connect: bool = True, check_types_on_connect: bool = False, ) -> None: self._name = name self.description = description self._check_nargs_on_connect = check_nargs_on_connect self._check_types_on_connect = check_types_on_connect if types and isinstance(types[0], Signature): self._signature = types[0] if len(types) > 1: warnings.warn( "Only a single argument is accepted when directly providing a" f" `Signature`. These args were ignored: {types[1:]}", stacklevel=2, ) else: self._signature = _build_signature(*cast("tuple[Type[Any], ...]", types)) @property def signature(self) -> Signature: """[Signature][inspect.Signature] supported by this Signal.""" return self._signature def __set_name__(self, owner: type[Any], name: str) -> None: """Set name of signal when declared as a class attribute on `owner`.""" if self._name is None: self._name = name @overload def __get__(self, instance: None, owner: type[Any] | None = None) -> Signal: ... # pragma: no cover @overload def __get__(self, instance: Any, owner: type[Any] | None = None) -> SignalInstance: ... # pragma: no cover def __get__( self, instance: Any, owner: type[Any] | None = None ) -> Signal | SignalInstance: """Get signal instance. This is called when accessing a Signal instance. If accessed as an attribute on the class `owner`, instance, will be `None`. Otherwise, if `instance` is not None, we're being accessed on an instance of `owner`. class Emitter: signal = Signal() e = Emitter() E.signal # instance will be None, owner will be Emitter e.signal # instance will be e, owner will be Emitter Returns ------- Signal or SignalInstance Depending on how this attribute is accessed. """ if instance is None: return self name = cast("str", self._name) signal_instance = SignalInstance( self.signature, instance=instance, name=name, check_nargs_on_connect=self._check_nargs_on_connect, check_types_on_connect=self._check_types_on_connect, ) # instead of caching this signal instance on self, we just assign it # to instance.name ... this essentially breaks the descriptor, # (i.e. __get__ will never again be called for this instance, and we have no # idea how many instances are out there), # but it allows us to prevent creating a key for this instance (which may # not be hashable or weak-referenceable), and also provides a significant # speedup on attribute access (affecting everything). setattr(instance, name, signal_instance) return signal_instance @classmethod @contextmanager def _emitting(cls, emitter: SignalInstance) -> Iterator[None]: """Context that sets the sender on a receiver object while emitting a signal.""" previous, cls._current_emitter = cls._current_emitter, emitter try: yield finally: cls._current_emitter = previous @classmethod def current_emitter(cls) -> SignalInstance | None: """Return currently emitting `SignalInstance`, if any. This will typically be used in a callback. Examples -------- ```python from psygnal import Signal def my_callback(): source = Signal.current_emitter() ``` """ return cls._current_emitter @classmethod def sender(cls) -> Any: """Return currently emitting object, if any. This will typically be used in a callback. """ return getattr(cls._current_emitter, "instance", None) _empty_signature = Signature() @mypyc_attr(allow_interpreted_subclasses=True) class SignalInstance: """A signal instance (optionally) bound to an object. In most cases, users will not create a `SignalInstance` directly -- instead creating a [Signal][psygnal.Signal] class attribute. This object will be instantiated by the `Signal.__get__` method (i.e. the descriptor protocol), when a `Signal` instance is accessed from an *instance* of a class with `Signal` attribute. However, it is the `SignalInstance` that you will most often be interacting with when you access the name of a `Signal` on an instance -- so understanding the `SignalInstance` API is key to using psygnal. ```python class Emitter: signal = Signal() e = Emitter() # when accessed on an *instance* of Emitter, # the signal attribute will be a SignalInstance e.signal # This is what you will use to connect your callbacks e.signal.connect(some_callback) ``` Parameters ---------- signature : Optional[inspect.Signature] The signature that this signal accepts and will emit, by default `Signature()`. instance : Optional[Any] An object to which this signal is bound. Normally this will be provided by the `Signal.__get__` method (see above). However, an unbound `SignalInstance` may also be created directly. by default `None`. name : Optional[str] An optional name for this signal. Normally this will be provided by the `Signal.__get__` method. by default `None` check_nargs_on_connect : bool Whether to check the number of positional args against `signature` when connecting a new callback. This can also be provided at connection time using `.connect(..., check_nargs=True)`. By default, True. check_types_on_connect : bool Whether to check the callback parameter types against `signature` when connecting a new callback. This can also be provided at connection time using `.connect(..., check_types=True)`. By default, False. Raises ------ TypeError If `signature` is neither an instance of `inspect.Signature`, or a `tuple` of types. """ _is_blocked: bool = False _is_paused: bool = False _debug_hook: ClassVar[Callable[[EmissionInfo], None] | None] = None def __init__( self, signature: Signature | tuple = _empty_signature, *, instance: Any = None, name: str | None = None, check_nargs_on_connect: bool = True, check_types_on_connect: bool = False, ) -> None: self._name = name if instance is None: self._instance: Callable = lambda: None else: try: self._instance = weakref.ref(instance) except TypeError: # fall back to strong reference if instance is not weak-referenceable self._instance = lambda: instance self._args_queue: list[Any] = [] # filled when paused if isinstance(signature, (list, tuple)): signature = _build_signature(*signature) elif not isinstance(signature, Signature): # pragma: no cover raise TypeError( "`signature` must be either a sequence of types, or an " "instance of `inspect.Signature`" ) self._signature = signature self._check_nargs_on_connect = check_nargs_on_connect self._check_types_on_connect = check_types_on_connect self._slots: list[WeakCallback] = [] self._is_blocked: bool = False self._is_paused: bool = False self._lock = threading.RLock() @property def signature(self) -> Signature: """Signature supported by this `SignalInstance`.""" return self._signature @property def instance(self) -> Any: """Object that emits this `SignalInstance`.""" return self._instance() @property def name(self) -> str: """Name of this `SignalInstance`.""" return self._name or "" def __repr__(self) -> str: """Return repr.""" name = f" {self.name!r}" if self.name else "" instance = f" on {self.instance!r}" if self.instance is not None else "" return f"<{type(self).__name__}{name}{instance}>" @overload def connect( self, *, thread: threading.Thread | Literal["main", "current"] | None = ..., check_nargs: bool | None = ..., check_types: bool | None = ..., unique: bool | str = ..., max_args: int | None = None, on_ref_error: RefErrorChoice = ..., ) -> Callable[[F], F]: ... # pragma: no cover @overload def connect( self, slot: F, *, thread: threading.Thread | Literal["main", "current"] | None = ..., check_nargs: bool | None = ..., check_types: bool | None = ..., unique: bool | str = ..., max_args: int | None = None, on_ref_error: RefErrorChoice = ..., ) -> F: ... # pragma: no cover def connect( self, slot: F | None = None, *, thread: threading.Thread | Literal["main", "current"] | None = None, check_nargs: bool | None = None, check_types: bool | None = None, unique: bool | str = False, max_args: int | None = None, on_ref_error: RefErrorChoice = "warn", ) -> Callable[[F], F] | F: """Connect a callback (`slot`) to this signal. `slot` is compatible if: * it requires no more than the number of positional arguments emitted by this `SignalInstance`. (It *may* require less) * it has no *required* keyword arguments (keyword only arguments that have no default). * if `check_types` is `True`, the parameter types in the callback signature must match the signature of this `SignalInstance`. This method may be used as a decorator. ```python @signal.connect def my_function(): ... ``` !!!important If a signal is connected with `thread != None`, then it is up to the user to ensure that `psygnal.emit_queued` is called, or that one of the backend convenience functions is used (e.g. `psygnal.qt.start_emitting_from_queue`). Otherwise, callbacks that are connected to signals that are emitted from another thread will never be called. Parameters ---------- slot : Callable A callable to connect to this signal. If the callable accepts less arguments than the signature of this slot, then they will be discarded when calling the slot. check_nargs : Optional[bool] If `True` and the provided `slot` requires more positional arguments than the signature of this Signal, raise `TypeError`. by default `True`. thread: Thread | Literal["main", "current"] | None If `None` (the default), this slot will be invoked immediately when a signal is emitted, from whatever thread emitted the signal. If a thread object is provided, then the callback will only be immediately invoked if the signal is emitted from that thread. Otherwise, the callback will be added to a queue. **Note!**, when using the `thread` parameter, the user is responsible for calling `psygnal.emit_queued()` in the corresponding thread, otherwise the slot will never be invoked. (See note above). (The strings `"main"` and `"current"` are also accepted, and will be interpreted as the `threading.main_thread()` and `threading.current_thread()`, respectively). check_types : Optional[bool] If `True`, An additional check will be performed to make sure that types declared in the slot signature are compatible with the signature declared by this signal, by default `False`. unique : Union[bool, str, None] If `True`, returns without connecting if the slot has already been connected. If the literal string "raise" is passed to `unique`, then a `ValueError` will be raised if the slot is already connected. By default `False`. max_args : Optional[int] If provided, `slot` will be called with no more more than `max_args` when this SignalInstance is emitted. (regardless of how many arguments are emitted). on_ref_error : {'raise', 'warn', 'ignore'}, optional What to do if a weak reference cannot be created. If 'raise', a ReferenceError will be raised. If 'warn' (default), a warning will be issued and a strong-reference will be used. If 'ignore' a strong-reference will be used (silently). Raises ------ TypeError If a non-callable object is provided. ValueError If the provided slot fails validation, either due to mismatched positional argument requirements, or failed type checking. ValueError If `unique` is `True` and `slot` has already been connected. """ if check_nargs is None: check_nargs = self._check_nargs_on_connect if check_types is None: check_types = self._check_types_on_connect def _wrapper( slot: F, max_args: int | None = max_args, _on_ref_err: RefErrorChoice = on_ref_error, ) -> F: if not callable(slot): raise TypeError(f"Cannot connect to non-callable object: {slot}") with self._lock: if unique and slot in self: if unique == "raise": raise ValueError( "Slot already connect. Use `connect(..., unique=False)` " "to allow duplicate connections" ) return slot slot_sig: Signature | None = None if check_nargs and (max_args is None): slot_sig, max_args, isqt = self._check_nargs(slot, self.signature) if isqt: _on_ref_err = "ignore" if check_types: slot_sig = slot_sig or signature(slot) if not _parameter_types_match(slot, self.signature, slot_sig): extra = f"- Slot types {slot_sig} do not match types in signal." self._raise_connection_error(slot, extra) # this type ignore could be fixed with ParamSpec, but that's not yet # supported by mypyc. we need cb to be a WeakCallback[R], but we can't # preserve the full typing information of the callback without using # Callable[ParamSpec, R] or a general TypeVar('F', bound=Callable). cb = weak_callback( # type: ignore [var-annotated] slot, max_args=max_args, finalize=self._try_discard, on_ref_error=_on_ref_err, ) if thread is None: self._slots.append(cb) else: self._slots.append(QueuedCallback(cb, thread=thread)) return slot return _wrapper if slot is None else _wrapper(slot) def _try_discard(self, callback: WeakCallback, missing_ok: bool = True) -> None: """Try to discard a callback from the list of slots. Parameters ---------- callback : WeakCallback A callback to discard. missing_ok : bool, optional If `True`, do not raise an error if the callback is not found in the list. """ try: self._slots.remove(callback) except ValueError: if not missing_ok: raise def connect_setattr( self, obj: weakref.ref | object, attr: str, maxargs: int | None = None, *, on_ref_error: RefErrorChoice = "warn", ) -> WeakCallback[None]: """Bind an object attribute to the emitted value of this signal. Equivalent to calling `self.connect(functools.partial(setattr, obj, attr))`, but with additional weakref safety (i.e. a strong reference to `obj` will not be retained). The return object can be used to [`disconnect()`][psygnal.SignalInstance.disconnect], (or you can use [`disconnect_setattr()`][psygnal.SignalInstance.disconnect_setattr]). Parameters ---------- obj : Union[weakref.ref, object] An object or weak reference (deprecated) to an object. attr : str The name of an attribute on `obj` that should be set to the value of this signal when emitted. maxargs : Optional[int] max number of positional args to accept on_ref_error: {'raise', 'warn', 'ignore'}, optional What to do if a weak reference cannot be created. If 'raise', a ReferenceError will be raised. If 'warn' (default), a warning will be issued and a strong-reference will be used. If 'ignore' a strong-reference will be used (silently). Returns ------- Tuple (weakref.ref, name, callable). Reference to the object, name of the attribute, and setattr closure. Can be used to disconnect the slot. Raises ------ ValueError If this is not a single-value signal AttributeError If `obj` has no attribute `attr`. Examples -------- >>> class T: ... sig = Signal(int) ... >>> class SomeObj: ... x = 1 ... >>> t = T() >>> my_obj = SomeObj() >>> t.sig.connect_setattr(my_obj, 'x') >>> t.sig.emit(5) >>> assert my_obj.x == 5 """ if isinstance(obj, weakref.ReferenceType): # pragma: no cover warnings.warn( 'Using a weakref as the "obj" argument is deprecated. ' "Use the object directly instead. This will raise an error in " "a future release.", FutureWarning, stacklevel=2, ) obj = obj() if not hasattr(obj, attr): raise AttributeError(f"Object {obj} has no attribute {attr!r}") with self._lock: caller = _WeakSetattr( obj, attr, max_args=maxargs, finalize=self._try_discard, on_ref_error=on_ref_error, ) self._slots.append(caller) return caller def disconnect_setattr( self, obj: object, attr: str, missing_ok: bool = True ) -> None: """Disconnect a previously connected attribute setter. Parameters ---------- obj : object An object. attr : str The name of an attribute on `obj` that was previously used for `connect_setattr`. missing_ok : bool If `False` and the provided `slot` is not connected, raises `ValueError`. by default `True` Raises ------ ValueError If `missing_ok` is `True` and no attribute setter is connected. """ # sourcery skip: merge-nested-ifs, use-next with self._lock: cb = _WeakSetattr(obj, attr, on_ref_error="ignore") self._try_discard(cb, missing_ok) def connect_setitem( self, obj: weakref.ref | object, key: str, maxargs: int | None = None, *, on_ref_error: RefErrorChoice = "warn", ) -> WeakCallback[None]: """Bind a container item (such as a dict key) to emitted value of this signal. Equivalent to calling `self.connect(functools.partial(obj.__setitem__, attr))`, but with additional weakref safety (i.e. a strong reference to `obj` will not be retained). The return object can be used to [`disconnect()`][psygnal.SignalInstance.disconnect], (or you can use [`disconnect_setitem()`][psygnal.SignalInstance.disconnect_setitem]). Parameters ---------- obj : Union[weakref.ref, object] An object or weak reference (deprecated) to an object. key : str Name of the key in `obj` that should be set to the value of this signal when emitted maxargs : Optional[int] max number of positional args to accept on_ref_error: {'raise', 'warn', 'ignore'}, optional What to do if a weak reference cannot be created. If 'raise', a ReferenceError will be raised. If 'warn' (default), a warning will be issued and a strong-reference will be used. If 'ignore' a strong-reference will be used (silently). Returns ------- Tuple (weakref.ref, name, callable). Reference to the object, name of the attribute, and setitem closure. Can be used to disconnect the slot. Raises ------ ValueError If this is not a single-value signal TypeError If `obj` does not support __setitem__. Examples -------- >>> class T: ... sig = Signal(int) ... >>> t = T() >>> my_obj = dict() >>> t.sig.connect_setitem(my_obj, 'x') >>> t.sig.emit(5) >>> assert my_obj == {'x': 5} """ if isinstance(obj, weakref.ReferenceType): # pragma: no cover warnings.warn( 'Using a weakref as the "obj" argument is deprecated. ' "Use the object directly instead. This will raise an error in " "a future release.", FutureWarning, stacklevel=2, ) obj = obj() if not hasattr(obj, "__setitem__"): raise TypeError(f"Object {obj} does not support __setitem__") with self._lock: caller = _WeakSetitem( obj, # type: ignore key, max_args=maxargs, finalize=self._try_discard, on_ref_error=on_ref_error, ) self._slots.append(caller) return caller def disconnect_setitem( self, obj: object, key: str, missing_ok: bool = True ) -> None: """Disconnect a previously connected item setter. Parameters ---------- obj : object An object. key : str The name of a key in `obj` that was previously used for `connect_setitem`. missing_ok : bool If `False` and the provided `slot` is not connected, raises `ValueError`. by default `True` Raises ------ ValueError If `missing_ok` is `True` and no item setter is connected. """ if not hasattr(obj, "__setitem__"): raise TypeError(f"Object {obj} does not support __setitem__") # sourcery skip: merge-nested-ifs, use-next with self._lock: caller = _WeakSetitem(obj, key, on_ref_error="ignore") self._try_discard(caller, missing_ok) def _check_nargs( self, slot: Callable, spec: Signature ) -> tuple[Signature | None, int | None, bool]: """Make sure slot is compatible with signature. Also returns the maximum number of arguments that we can pass to the slot Returns ------- slot_sig : Signature | None The signature of the slot, or None if it could not be determined. maxargs : int | None The maximum number of arguments that we can pass to the slot. is_qt : bool Whether the slot is a Qt slot. """ try: slot_sig = _get_signature_possibly_qt(slot) except ValueError as e: warnings.warn( f"{e}. To silence this warning, connect with " "`check_nargs=False`", stacklevel=2, ) return None, None, False try: minargs, maxargs = _acceptable_posarg_range(slot_sig) except ValueError as e: if isinstance(slot, partial): raise ValueError( f"{e}. (Note: prefer using positional args with " "functools.partials when possible)." ) from e raise n_spec_params = len(spec.parameters) # if `slot` requires more arguments than we will provide, raise. if minargs > n_spec_params: extra = ( f"- Slot requires at least {minargs} positional " f"arguments, but spec only provides {n_spec_params}" ) self._raise_connection_error(slot, extra) return None if isinstance(slot_sig, str) else slot_sig, maxargs, True def _raise_connection_error(self, slot: Callable, extra: str = "") -> NoReturn: name = getattr(slot, "__name__", str(slot)) msg = f"Cannot connect slot {name!r} with signature: {signature(slot)}:\n" msg += extra msg += f"\n\nAccepted signature: {self.signature}" raise ValueError(msg) def _slot_index(self, slot: Callable) -> int: """Get index of `slot` in `self._slots`. Return -1 if not connected.""" with self._lock: normed = weak_callback(slot, on_ref_error="ignore") # NOTE: # the == method here relies on the __eq__ method of each SlotCaller subclass return next((i for i, s in enumerate(self._slots) if s == normed), -1) def disconnect(self, slot: Callable | None = None, missing_ok: bool = True) -> None: """Disconnect slot from signal. Parameters ---------- slot : callable, optional The specific slot to disconnect. If `None`, all slots will be disconnected, by default `None` missing_ok : Optional[bool] If `False` and the provided `slot` is not connected, raises `ValueError. by default `True` Raises ------ ValueError If `slot` is not connected and `missing_ok` is False. """ with self._lock: if slot is None: # NOTE: clearing an empty list is actually a RuntimeError in Qt self._slots.clear() return idx = self._slot_index(slot) if idx != -1: self._slots.pop(idx) elif not missing_ok: raise ValueError(f"slot is not connected: {slot}") def __contains__(self, slot: Callable) -> bool: """Return `True` if slot is connected.""" return self._slot_index(slot) >= 0 def __len__(self) -> int: """Return number of connected slots.""" return len(self._slots) @overload def emit( self, *args: Any, check_nargs: bool = False, check_types: bool = False, asynchronous: Literal[False] = False, ) -> None: ... # pragma: no cover @overload def emit( self, *args: Any, check_nargs: bool = False, check_types: bool = False, asynchronous: Literal[True], ) -> EmitThread | None: # will return `None` if emitter is blocked ... # pragma: no cover def emit( self, *args: Any, check_nargs: bool = False, check_types: bool = False, asynchronous: bool = False, ) -> EmitThread | None: """Emit this signal with arguments `args`. !!! note `check_args` and `check_types` both add overhead when calling emit. Parameters ---------- *args : Any These arguments will be passed when calling each slot (unless the slot accepts fewer arguments, in which case extra args will be discarded.) check_nargs : bool If `False` and the provided arguments cannot be successfully bound to the signature of this Signal, raise `TypeError`. Incurs some overhead. by default False. check_types : bool If `False` and the provided arguments do not match the types declared by the signature of this Signal, raise `TypeError`. Incurs some overhead. by default False. asynchronous : bool If `True`, run signal emission in another thread. by default `False`. **DEPRECATED:**. *If you need to emit from a thread, please just create your own [`threading.Thread`][] and call [`SignalInstance.emit`][psygnal.SignalInstance.emit]. See also the `thread` parameter in the [`SignalInstance.connect`][psygnal.SignalInstance.connect] method.* Raises ------ TypeError If `check_nargs` and/or `check_types` are `True`, and the corresponding checks fail. """ if self._is_blocked: return None if check_nargs: try: self.signature.bind(*args) except TypeError as e: raise TypeError( f"Cannot emit args {args} from signal {self!r} with " f"signature {self.signature}:\n{e}" ) from e if check_types and not _parameter_types_match( lambda: None, self.signature, _build_signature(*[type(a) for a in args]) ): raise TypeError( f"Types provided to '{self.name}.emit' " f"{tuple(type(a).__name__ for a in args)} do not match signal " f"signature: {self.signature}" ) if self._is_paused: self._args_queue.append(args) return None if SignalInstance._debug_hook is not None: from ._group import EmissionInfo SignalInstance._debug_hook(EmissionInfo(self, args)) if asynchronous: warnings.warn( "The `asynchronous` parameter is deprecated and will be removed in a " "future release. If you need this, please create your own " "`threading.Thread` and call `SignalInstance.emit`. See also the new " "`thread` parameter in the `SignalInstance.connect` method.", FutureWarning, stacklevel=2, ) sd = EmitThread(self, args) sd.start() return sd self._run_emit_loop(args) return None @overload def __call__( self, *args: Any, check_nargs: bool = False, check_types: bool = False, asynchronous: Literal[False] = False, ) -> None: ... # pragma: no cover @overload def __call__( self, *args: Any, check_nargs: bool = False, check_types: bool = False, asynchronous: Literal[True], ) -> EmitThread | None: # will return `None` if emitter is blocked ... # pragma: no cover def __call__( self, *args: Any, check_nargs: bool = False, check_types: bool = False, asynchronous: bool = False, ) -> EmitThread | None: """Alias for `emit()`.""" return self.emit( # type: ignore *args, check_nargs=check_nargs, check_types=check_types, asynchronous=asynchronous, ) def _run_emit_loop(self, args: tuple[Any, ...]) -> None: # allow receiver to query sender with Signal.current_emitter() with self._lock: with Signal._emitting(self): for caller in self._slots: try: caller.cb(args) except Exception as e: raise EmitLoopError( slot_repr=repr(caller), args=args, exc=e ) from e return None def block(self, exclude: Iterable[str | SignalInstance] = ()) -> None: """Block this signal from emitting. NOTE: the `exclude` argument is only for SignalGroup subclass, but we have to include it here to make mypyc happy. """ self._is_blocked = True def unblock(self) -> None: """Unblock this signal, allowing it to emit.""" self._is_blocked = False def blocked(self) -> ContextManager[None]: """Context manager to temporarily block this signal. Useful if you need to temporarily block all emission of a given signal, (for example, to avoid a recursive signal loop) Examples -------- ```python class MyEmitter: changed = Signal() def make_a_change(self): self.changed.emit() obj = MyEmitter() with obj.changed.blocked() obj.make_a_change() # will NOT emit a changed signal. ``` """ return _SignalBlocker(self) def pause(self) -> None: """Pause all emission and collect *args tuples from emit(). args passed to `emit` will be collected and re-emitted when `resume()` is called. For a context manager version, see `paused()`. """ self._is_paused = True def resume(self, reducer: ReducerFunc | None = None, initial: Any = _NULL) -> None: """Resume (unpause) this signal, emitting everything in the queue. Parameters ---------- reducer : Callable[[tuple, tuple], Any], optional If provided, all gathered args will be reduced into a single argument by passing `reducer` to `functools.reduce`. NOTE: args passed to `emit` are collected as tuples, so the two arguments passed to `reducer` will always be tuples. `reducer` must handle that and return an args tuple. For example, three `emit(1)` events would be reduced and re-emitted as follows: `self.emit(*functools.reduce(reducer, [(1,), (1,), (1,)]))` initial: any, optional initial value to pass to `functools.reduce` Examples -------- >>> class T: ... sig = Signal(int) >>> t = T() >>> t.sig.pause() >>> t.sig.emit(1) >>> t.sig.emit(2) >>> t.sig.emit(3) >>> t.sig.resume(lambda a, b: (a[0].union(set(b)),), (set(),)) >>> # results in t.sig.emit({1, 2, 3}) """ self._is_paused = False # not sure why this attribute wouldn't be set, but when resuming in # EventedModel.update, it may be undefined (as seen in tests) if not getattr(self, "_args_queue", None): return if reducer is not None: if initial is _NULL: args = reduce(reducer, self._args_queue) else: args = reduce(reducer, self._args_queue, initial) self._run_emit_loop(args) else: for args in self._args_queue: self._run_emit_loop(args) self._args_queue.clear() def paused( self, reducer: ReducerFunc | None = None, initial: Any = _NULL ) -> ContextManager[None]: """Context manager to temporarily pause this signal. Parameters ---------- reducer : Callable[[tuple, tuple], Any], optional If provided, all gathered args will be reduced into a single argument by passing `reducer` to `functools.reduce`. NOTE: args passed to `emit` are collected as tuples, so the two arguments passed to `reducer` will always be tuples. `reducer` must handle that and return an args tuple. For example, three `emit(1)` events would be reduced and re-emitted as follows: `self.emit(*functools.reduce(reducer, [(1,), (1,), (1,)]))` initial: any, optional initial value to pass to `functools.reduce` Examples -------- >>> with obj.signal.paused(lambda a, b: (a[0].union(set(b)),), (set(),)): ... t.sig.emit(1) ... t.sig.emit(2) ... t.sig.emit(3) >>> # results in obj.signal.emit({1, 2, 3}) """ return _SignalPauser(self, reducer, initial) def __getstate__(self) -> dict: """Return dict of current state, for pickle.""" attrs = ( "_signature", "_instance", "_name", "_slots", "_is_blocked", "_is_paused", "_args_queue", "_lock", "_check_nargs_on_connect", "_check_types_on_connect", "__weakref__", ) d = {slot: getattr(self, slot) for slot in attrs} d.pop("_lock", None) return d class _SignalBlocker: """Context manager to block and unblock a signal.""" def __init__( self, signal: SignalInstance, exclude: Iterable[str | SignalInstance] = () ) -> None: self._signal = signal self._exclude = exclude self._was_blocked = signal._is_blocked def __enter__(self) -> None: self._signal.block(exclude=self._exclude) def __exit__(self, *args: Any) -> None: if not self._was_blocked: self._signal.unblock() class _SignalPauser: """Context manager to pause and resume a signal.""" def __init__( self, signal: SignalInstance, reducer: ReducerFunc | None, initial: Any ) -> None: self._was_paused = signal._is_paused self._signal = signal self._reducer = reducer self._initial = initial def __enter__(self) -> None: self._signal.pause() def __exit__(self, *args: Any) -> None: if not self._was_paused: self._signal.resume(self._reducer, self._initial) class EmitThread(threading.Thread): """A thread to emit a signal asynchronously.""" def __init__(self, signal_instance: SignalInstance, args: tuple[Any, ...]) -> None: super().__init__(name=signal_instance.name) self._signal_instance = signal_instance self.args = args # current = threading.currentThread() # self.parent = (current.getName(), current.ident) def run(self) -> None: """Run thread.""" self._signal_instance._run_emit_loop(self.args) # ############################################################################# # ############################################################################# def signature(obj: Any) -> inspect.Signature: try: return inspect.signature(obj) except ValueError as e: with suppress(Exception): if not inspect.ismethod(obj): return _stub_sig(obj) raise e from e _ANYSIG = Signature( [ Parameter(name="args", kind=Parameter.VAR_POSITIONAL), Parameter(name="kwargs", kind=Parameter.VAR_KEYWORD), ] ) @lru_cache(maxsize=None) def _stub_sig(obj: Any) -> Signature: """Called as a backup when inspect.signature fails.""" import builtins # this nonsense is here because it's hard to get the signature of mypyc-compiled # objects, but we still want to be able to connect a signal instance. if ( type(getattr(obj, "__self__", None)) is SignalInstance and getattr(obj, "__name__", None) == "emit" ) or type(obj) is SignalInstance: # we won't reach this in testing because # Compiled functions don't trigger profiling and tracing hooks return _ANYSIG # pragma: no cover # just a common case if obj is builtins.print: params = [ Parameter(name="value", kind=Parameter.VAR_POSITIONAL), Parameter(name="sep", kind=Parameter.KEYWORD_ONLY, default=" "), Parameter(name="end", kind=Parameter.KEYWORD_ONLY, default="\n"), Parameter(name="file", kind=Parameter.KEYWORD_ONLY, default=None), Parameter(name="flush", kind=Parameter.KEYWORD_ONLY, default=False), ] return Signature(params) raise ValueError("unknown object") def _build_signature(*types: type[Any]) -> Signature: params = [ Parameter(name=f"p{i}", kind=Parameter.POSITIONAL_ONLY, annotation=t) for i, t in enumerate(types) ] return Signature(params) # def f(a, /, b, c=None, *d, f=None, **g): print(locals()) # # a: kind=POSITIONAL_ONLY, default=Parameter.empty # 1 required posarg # b: kind=POSITIONAL_OR_KEYWORD, default=Parameter.empty # 1 requires posarg # c: kind=POSITIONAL_OR_KEYWORD, default=None # 1 optional posarg # d: kind=VAR_POSITIONAL, default=Parameter.empty # N optional posargs # e: kind=KEYWORD_ONLY, default=Parameter.empty # 1 REQUIRED kwarg # f: kind=KEYWORD_ONLY, default=None # 1 optional kwarg # g: kind=VAR_KEYWORD, default=Parameter.empty # N optional kwargs def _get_signature_possibly_qt(slot: Callable) -> Signature | str: # checking qt has to come first, since the signature of the emit method # of a Qt SignalInstance is just None> # https://bugreports.qt.io/browse/PYSIDE-1713 sig = _guess_qtsignal_signature(slot) return signature(slot) if sig is None else sig def _acceptable_posarg_range( sig: Signature | str, forbid_required_kwarg: bool = True ) -> tuple[int, int | None]: """Return tuple of (min, max) accepted positional arguments. Parameters ---------- sig : Signature Signature object to evaluate forbid_required_kwarg : Optional[bool] Whether to allow required KEYWORD_ONLY parameters. by default True. Returns ------- arg_range : Tuple[int, int] minimum, maximum number of acceptable positional arguments Raises ------ ValueError If the signature has a required keyword_only parameter and `forbid_required_kwarg` is `True`. """ if isinstance(sig, str): if "(" not in sig: # pragma: no cover raise ValueError(f"Unrecognized string signature format: {sig!r}") inner = sig.split("(", 1)[1].split(")", 1)[0] minargs = maxargs = inner.count(",") + 1 if inner else 0 return minargs, maxargs required = 0 optional = 0 posargs_unlimited = False _pos_required = {Parameter.POSITIONAL_ONLY, Parameter.POSITIONAL_OR_KEYWORD} for param in sig.parameters.values(): if param.kind in _pos_required: if param.default is Parameter.empty: required += 1 else: optional += 1 elif param.kind is Parameter.VAR_POSITIONAL: posargs_unlimited = True elif ( param.kind is Parameter.KEYWORD_ONLY and param.default is Parameter.empty and forbid_required_kwarg ): raise ValueError(f"Unsupported KEYWORD_ONLY parameters in signature: {sig}") return (required, None if posargs_unlimited else required + optional) def _parameter_types_match( function: Callable, spec: Signature, func_sig: Signature | None = None ) -> bool: """Return True if types in `function` signature match those in `spec`. Parameters ---------- function : Callable A function to validate spec : Signature The Signature against which the `function` should be validated. func_sig : Signature, optional Signature for `function`, if `None`, signature will be inspected. by default None Returns ------- bool True if the parameter types match. """ fsig = func_sig or signature(function) func_hints: dict | None = None for f_param, spec_param in zip(fsig.parameters.values(), spec.parameters.values()): f_anno = f_param.annotation if f_anno is fsig.empty: # if function parameter is not type annotated, allow it. continue if isinstance(f_anno, str): if func_hints is None: func_hints = get_type_hints(function) f_anno = func_hints.get(f_param.name) if not _is_subclass(f_anno, spec_param.annotation): return False return True def _is_subclass(left: type[Any], right: type) -> bool: """Variant of issubclass with support for unions.""" if not isclass(left) and get_origin(left) is Union: return any(issubclass(i, right) for i in get_args(left)) return issubclass(left, right) def _guess_qtsignal_signature(obj: Any) -> str | None: """Return string signature if `obj` is a SignalInstance or Qt emit method. This is a bit of a hack, but we found no better way: https://stackoverflow.com/q/69976089/1631624 https://bugreports.qt.io/browse/PYSIDE-1713 """ # on my machine, this takes ~700ns on PyQt5 and 8.7µs on PySide2 type_ = type(obj) if "pyqtBoundSignal" in type_.__name__: return cast("str", obj.signal) qualname = getattr(obj, "__qualname__", "") if qualname == "pyqtBoundSignal.emit": return cast("str", obj.__self__.signal) # note: this IS all actually covered in tests... but only in the Qt tests, # so it (annoyingly) briefly looks like it fails coverage. if qualname == "SignalInstance.emit" and type_.__name__.startswith("builtin"): # we likely have the emit method of a SignalInstance # call it with ridiculous params to get the err return _ridiculously_call_emit(obj.__self__.emit) # pragma: no cover if "SignalInstance" in type_.__name__ and "QtCore" in getattr( type_, "__module__", "" ): # pragma: no cover return _ridiculously_call_emit(obj.emit) return None _CRAZY_ARGS = (1,) * 255 # note: this IS all actually covered in tests... but only in the Qt tests, # so it (annoyingly) briefly looks like it fails coverage. def _ridiculously_call_emit(emitter: Any) -> str | None: # pragma: no cover """Call SignalInstance emit() to get the signature from err message.""" try: emitter(*_CRAZY_ARGS) except TypeError as e: if "only accepts" in str(e): return str(e).split("only accepts")[0].strip() return None # pragma: no cover _compiled: bool def __getattr__(name: str) -> Any: if name == "_compiled": return hasattr(Signal, "__mypyc_attrs__") raise AttributeError(f"module {__name__!r} has no attribute {name!r}") psygnal-0.9.1/src/psygnal/_throttler.py0000644000000000000000000001625513615410400015133 0ustar00from __future__ import annotations from threading import Timer from typing import Any, Callable from typing_extensions import Literal Kind = Literal["throttler", "debouncer"] EmissionPolicy = Literal["trailing", "leading"] class _ThrottlerBase: _timer: Timer def __init__( self, func: Callable[..., Any], interval: int = 100, policy: EmissionPolicy = "leading", ) -> None: self._func = func self._interval: int = interval self._policy: EmissionPolicy = policy self._has_pending: bool = False self._timer: Timer = Timer(0, lambda: None) self._timer.start() self._args: tuple[Any, ...] = () self._kwargs: dict[str, Any] = {} def _actually_call(self) -> None: self._has_pending = False self._func(*self._args, **self._kwargs) self._start_timer() def _call_if_has_pending(self) -> None: if self._has_pending: self._actually_call() def _start_timer(self) -> None: self._timer.cancel() self._timer = Timer(self._interval / 1000, self._call_if_has_pending) self._timer.start() def cancel(self) -> None: """Cancel any pending calls.""" self._has_pending = False self._timer.cancel() def flush(self) -> None: """Force a call if there is one pending.""" self._call_if_has_pending() def __call__(self, *args: Any, **kwargs: Any) -> None: raise NotImplementedError("Subclasses must implement this method.") class Throttler(_ThrottlerBase): """Class that prevents calling `func` more than once per `interval`. Parameters ---------- func : Callable[..., Any] a function to wrap interval : int, optional the minimum interval in ms that must pass before the function is called again, by default 100 policy : EmissionPolicy, optional Whether to invoke the function on the "leading" or "trailing" edge of the wait timer, by default "leading" """ _timer: Timer def __init__( self, func: Callable[..., Any], interval: int = 100, policy: EmissionPolicy = "leading", ) -> None: super().__init__(func, interval, policy) def __call__(self, *args: Any, **kwargs: Any) -> None: """Call underlying function.""" self._has_pending = True self._args = args self._kwargs = kwargs if not self._timer.is_alive(): if self._policy == "leading": self._actually_call() else: self._start_timer() class Debouncer(_ThrottlerBase): """Class that waits at least `interval` before calling `func`. Parameters ---------- func : Callable[..., Any] a function to wrap interval : int, optional the minimum interval in ms that must pass before the function is called again, by default 100 policy : EmissionPolicy, optional Whether to invoke the function on the "leading" or "trailing" edge of the wait timer, by default "trailing" """ _timer: Timer def __init__( self, func: Callable[..., Any], interval: int = 100, policy: EmissionPolicy = "trailing", ) -> None: super().__init__(func, interval, policy) def __call__(self, *args: Any, **kwargs: Any) -> None: """Call underlying function.""" self._has_pending = True self._args = args self._kwargs = kwargs if not self._timer.is_alive() and self._policy == "leading": self._actually_call() self._start_timer() def throttled( func: Callable[..., Any] | None = None, timeout: int = 100, leading: bool = True, ) -> Callable[..., None] | Callable[[Callable[..., Any]], Callable[..., None]]: """Create a throttled function that invokes func at most once per timeout. The throttled function comes with a `cancel` method to cancel delayed func invocations and a `flush` method to immediately invoke them. Options to indicate whether func should be invoked on the leading and/or trailing edge of the wait timeout. The func is invoked with the last arguments provided to the throttled function. Subsequent calls to the throttled function return the result of the last func invocation. This decorator may be used with or without parameters. Parameters ---------- func : Callable A function to throttle timeout : int Timeout in milliseconds to wait before allowing another call, by default 100 leading : bool Whether to invoke the function on the leading edge of the wait timer, by default True Examples -------- ```python from psygnal import Signal, throttled class MyEmitter: changed = Signal(int) def on_change(val: int) # do something possibly expensive ... emitter = MyEmitter() # connect the `on_change` whenever `emitter.changed` is emitted # BUT, no more than once every 50 milliseconds emitter.changed.connect(throttled(on_change, timeout=50)) ``` """ def deco(func: Callable[..., Any]) -> Callable[..., None]: policy: EmissionPolicy = "leading" if leading else "trailing" return Throttler(func, timeout, policy) return deco(func) if func is not None else deco def debounced( func: Callable[..., Any] | None = None, timeout: int = 100, leading: bool = False, ) -> Callable[..., None] | Callable[[Callable[..., Any]], Callable[..., None]]: """Create a debounced function that delays invoking `func`. `func` will not be invoked until `timeout` ms have elapsed since the last time the debounced function was invoked. The debounced function comes with a `cancel` method to cancel delayed func invocations and a `flush` method to immediately invoke them. Options indicate whether func should be invoked on the leading and/or trailing edge of the wait timeout. The func is invoked with the *last* arguments provided to the debounced function. Subsequent calls to the debounced function return the result of the last `func` invocation. This decorator may be used with or without parameters. Parameters ---------- func : Callable A function to throttle timeout : int Timeout in milliseconds to wait before allowing another call, by default 100 leading : bool Whether to invoke the function on the leading edge of the wait timer, by default False Examples -------- ```python from psygnal import Signal, debounced class MyEmitter: changed = Signal(int) def on_change(val: int) # do something possibly expensive ... emitter = MyEmitter() # connect the `on_change` whenever `emitter.changed` is emitted # ONLY once at least 50 milliseconds have passed since the last signal emission. emitter.changed.connect(debounced(on_change, timeout=50)) ``` """ def deco(func: Callable[..., Any]) -> Callable[..., None]: policy: EmissionPolicy = "leading" if leading else "trailing" return Debouncer(func, timeout, policy) return deco(func) if func is not None else deco psygnal-0.9.1/src/psygnal/_throttler.pyi0000644000000000000000000000435213615410400015277 0ustar00# this pyi file exists until we can use ParamSpec with mypyc in the main file. from typing import Any, Callable, Generic, overload from typing_extensions import Literal, ParamSpec P = ParamSpec("P") Kind = Literal["throttler", "debouncer"] EmissionPolicy = Literal["trailing", "leading"] class _ThrottlerBase(Generic[P]): def __init__( self, func: Callable[P, Any], interval: int = 100, policy: EmissionPolicy = "leading", ) -> None: ... def cancel(self) -> None: """Cancel any pending calls.""" def flush(self) -> None: """Force a call if there is one pending.""" def __call__(self, *args: P.args, **kwargs: P.kwargs) -> None: ... class Throttler(_ThrottlerBase[P]): """Class that prevents calling `func` more than once per `interval`. Parameters ---------- func : Callable[P, Any] a function to wrap interval : int, optional the minimum interval in ms that must pass before the function is called again, by default 100 policy : EmissionPolicy, optional Whether to invoke the function on the "leading" or "trailing" edge of the wait timer, by default "leading" """ class Debouncer(_ThrottlerBase[P]): """Class that waits at least `interval` before calling `func`. Parameters ---------- func : Callable[P, Any] a function to wrap interval : int, optional the minimum interval in ms that must pass before the function is called again, by default 100 policy : EmissionPolicy, optional Whether to invoke the function on the "leading" or "trailing" edge of the wait timer, by default "trailing" """ @overload def throttled( func: Callable[P, Any], timeout: int = 100, leading: bool = True, ) -> Throttler[P]: ... @overload def throttled( func: Literal[None] = None, timeout: int = 100, leading: bool = True, ) -> Callable[[Callable[P, Any]], Throttler[P]]: ... @overload def debounced( func: Callable[P, Any], timeout: int = 100, leading: bool = False, ) -> Debouncer[P]: ... @overload def debounced( func: Literal[None] = None, timeout: int = 100, leading: bool = False, ) -> Callable[[Callable[P, Any]], Debouncer[P]]: ... psygnal-0.9.1/src/psygnal/_weak_callback.py0000644000000000000000000004114713615410400015645 0ustar00from __future__ import annotations import sys import weakref from functools import partial from types import BuiltinMethodType, FunctionType, MethodType, MethodWrapperType from typing import TYPE_CHECKING, Any, Callable, Generic, TypeVar, cast from warnings import warn from typing_extensions import Protocol if TYPE_CHECKING: import toolz from typing_extensions import Literal, TypeAlias, TypeGuard RefErrorChoice: TypeAlias = Literal["raise", "warn", "ignore"] __all__ = ["weak_callback", "WeakCallback"] _T = TypeVar("_T") _R = TypeVar("_R") # return type of cb def _is_toolz_curry(obj: Any) -> TypeGuard[toolz.curry]: """Return True if obj is a toolz.curry object.""" tz = sys.modules.get("toolz") return False if tz is None else isinstance(obj, tz.curry) def weak_callback( cb: Callable[..., _R] | WeakCallback[_R], *args: Any, max_args: int | None = None, finalize: Callable[[WeakCallback], Any] | None = None, strong_func: bool = True, on_ref_error: RefErrorChoice = "warn", ) -> WeakCallback[_R]: """Create a weakly-referenced callback. This function creates a weakly-referenced callback, with special considerations for many known callable types (functions, lambdas, partials, bound methods, partials on bound methods, builtin methods, etc.). NOTE: For the sake of least-surprise, an exception is made for functions and, lambdas, which are strongly-referenced by default. See the `strong_func` parameter for more details. Parameters ---------- cb : callable The callable to be called. *args Additional positional arguments to be passed to the callback (similar to functools.partial). max_args : int, optional The maximum number of positional arguments to pass to the callback. If provided, additional arguments passed to WeakCallback.cb will be ignored. finalize : callable, optional A callable that will be called when the callback is garbage collected. The callable will be passed the WeakCallback instance as its only argument. strong_func : bool, optional If True (default), a strong reference will be kept to the function `cb` if it is a function or lambda. If False, a weak reference will be kept. The reasoning for this is that functions and lambdas are very often defined *only* to be passed to this function, and would likely be immediately garbage collected if we weakly referenced them. If you would specifically like to *allow* the function to be garbage collected, set this to False. on_ref_error : {'raise', 'warn', 'ignore'}, optional What to do if a weak reference cannot be created. If 'raise', a ReferenceError will be raised. If 'warn' (default), a warning will be issued and a strong-reference will be used. If 'ignore' a strong-reference will be used (silently). Returns ------- WeakCallback A WeakCallback subclass instance appropriate for the given callable. The fast way to "call" the callback is to use the `cb` method, passing a single args tuple, it returns nothing. A `__call__` method is also provided, that can be used to call the original function as usual. Examples -------- ```python from psygnal._weak_callback import weak_callback class T: def greet(self, name): print("hello,", name) def _on_delete(weak_cb): print("deleting!") t = T() weak_cb = weak_callback(t.greet, finalize=_on_delete) weak_cb.cb(("world",)) # "hello, world" del t # "deleting!" weak_cb.cb(("world",)) # ReferenceError ``` """ if isinstance(cb, WeakCallback): return cb kwargs: dict[str, Any] | None = None if isinstance(cb, partial): args = cb.args + args kwargs = cb.keywords cb = cb.func if isinstance(cb, FunctionType): return ( _StrongFunction(cb, max_args, args, kwargs) if strong_func else _WeakFunction(cb, max_args, args, kwargs, finalize, on_ref_error) ) if isinstance(cb, MethodType): if getattr(cb, "__name__", None) == "__setitem__": try: key = args[0] except IndexError as e: # pragma: no cover raise TypeError( "WeakCallback.__setitem__ requires a key argument" ) from e obj = cast("SupportsSetitem", cb.__self__) return _WeakSetitem(obj, key, max_args, finalize, on_ref_error) return _WeakMethod(cb, max_args, args, kwargs, finalize, on_ref_error) if isinstance(cb, (MethodWrapperType, BuiltinMethodType)): if kwargs: # pragma: no cover raise NotImplementedError( "MethodWrapperTypes do not support keyword arguments" ) if cb is setattr: try: obj, attr = args[:2] except IndexError as e: # pragma: no cover raise TypeError( "setattr requires two arguments, an object and an attribute name." ) from e return _WeakSetattr(obj, attr, max_args, finalize, on_ref_error) return _WeakBuiltin(cb, max_args, args, finalize, on_ref_error) if _is_toolz_curry(cb): cb_partial = getattr(cb, "_partial", None) if cb_partial is None: # pragma: no cover raise TypeError( "toolz.curry object found without a '_partial' attribute. This " "version of toolz is not supported. Please open an issue at psygnal." ) return weak_callback( cb_partial, *args, max_args=max_args, finalize=finalize, on_ref_error=on_ref_error, ) if callable(cb): return _WeakFunction(cb, max_args, args, kwargs, finalize, on_ref_error) raise TypeError(f"unsupported type {type(cb)}") # pragma: no cover class WeakCallback(Generic[_R]): """Abstract Base Class for weakly-referenced callbacks. Do not instantiate this class directly, use the `weak_callback` function instead. The main public-facing methods of all subclasses are: cb(args: tuple[Any, ...] = ()) -> None: special fast callback method, args only. dereference() -> Callable[..., _R] | None: return strong dereferenced callback. __call__(*args: Any, **kwargs: Any) -> _R: call original callback __eq__: compare two WeakCallback instances for equality object_key: static method that returns a unique key for an object. NOTE: can't use ABC here because then mypyc and PySide2 don't play nice together. """ def __init__( self, obj: Any, max_args: int | None = None, on_ref_error: RefErrorChoice = "warn", ) -> None: self._key: str = WeakCallback.object_key(obj) self._max_args: int | None = max_args self._alive: bool = True self._on_ref_error: RefErrorChoice = on_ref_error def cb(self, args: tuple[Any, ...] = ()) -> None: """Call the callback with `args`. Args will be spread when calling the func.""" raise NotImplementedError() def dereference(self) -> Callable[..., _R] | None: """Return the original object, or None if dead.""" raise NotImplementedError() def __call__(self, *args: Any, **kwds: Any) -> _R: func = self.dereference() if func is None: raise ReferenceError("callback is dead") if self._max_args is not None: args = args[: self._max_args] return func(*args, **kwds) def __eq__(self, other: object) -> bool: # sourcery skip: swap-if-expression if isinstance(other, WeakCallback): return self._key == other._key return NotImplemented def _try_ref( self, obj: _T, finalize: Callable[[WeakCallback], Any] | None = None, ) -> Callable[[], _T | None]: _cb = None if finalize is None else _kill_and_finalize(self, finalize) try: return weakref.ref(obj, _cb) except TypeError: if self._on_ref_error == "raise": raise if self._on_ref_error == "warn": warn( f"failed to create weakref for {obj!r}, returning strong ref", stacklevel=2, ) def _strong_ref() -> _T: return obj return _strong_ref @staticmethod def object_key(obj: Any) -> str: """Return a unique key for an object. This includes information about the object's type, module, and id. It has considerations for bound methods (which would otherwise have a different id for each instance). """ if hasattr(obj, "__self__"): # bound method ... don't take the id of the bound method itself. obj_id = id(obj.__self__) owner_cls = type(obj.__self__) type_name = getattr(owner_cls, "__name__", None) or "" module = getattr(owner_cls, "__module__", None) or "" method_name = getattr(obj, "__name__", None) or "" obj_name = f"{type_name}.{method_name}" else: obj_id = id(obj) module = getattr(obj, "__module__", None) or "" obj_name = getattr(obj, "__name__", None) or "" return f"{module}:{obj_name}@{hex(obj_id)}" def _kill_and_finalize( wcb: WeakCallback, finalize: Callable[[WeakCallback], Any] ) -> Callable[[weakref.ReferenceType], None]: def _cb(_: weakref.ReferenceType) -> None: if wcb._alive: wcb._alive = False finalize(wcb) return _cb class _StrongFunction(WeakCallback): """Wrapper around a strong function reference.""" def __init__( self, obj: Callable, max_args: int | None = None, args: tuple[Any, ...] = (), kwargs: dict[str, Any] | None = None, on_ref_error: RefErrorChoice = "warn", ) -> None: super().__init__(obj, max_args, on_ref_error) self._f = obj self._args = args self._kwargs = kwargs or {} def cb(self, args: tuple[Any, ...] = ()) -> None: if self._max_args is not None: args = args[: self._max_args] self._f(*self._args, *args, **self._kwargs) def dereference(self) -> Callable: if self._args or self._kwargs: return partial(self._f, *self._args, **self._kwargs) return self._f class _WeakFunction(WeakCallback): """Wrapper around a weak function reference.""" def __init__( self, obj: Callable, max_args: int | None = None, args: tuple[Any, ...] = (), kwargs: dict[str, Any] | None = None, finalize: Callable | None = None, on_ref_error: RefErrorChoice = "warn", ) -> None: super().__init__(obj, max_args, on_ref_error) self._f = self._try_ref(obj, finalize) self._args = args self._kwargs = kwargs or {} def cb(self, args: tuple[Any, ...] = ()) -> None: f = self._f() if f is None: raise ReferenceError("weakly-referenced object no longer exists") if self._max_args is not None: args = args[: self._max_args] f(*self._args, *args, **self._kwargs) def dereference(self) -> Callable | None: f = self._f() if f is None: return None if self._args or self._kwargs: return partial(f, *self._args, **self._kwargs) return f class _WeakMethod(WeakCallback): """Wrapper around a method bound to a weakly-referenced object. Bound methods have a `__self__` attribute that holds a strong reference to the object they are bound to and a `__func__` attribute that holds a reference to the function that implements the method (on the class level) When `cb` is called here, it dereferences the two, and calls: `obj.__func__(obj.__self__, *args, **kwargs)` """ def __init__( self, obj: MethodType, max_args: int | None = None, args: tuple[Any, ...] = (), kwargs: dict[str, Any] | None = None, finalize: Callable | None = None, on_ref_error: RefErrorChoice = "warn", ) -> None: super().__init__(obj.__self__, max_args, on_ref_error) self._obj_ref = self._try_ref(obj.__self__, finalize) self._func_ref = self._try_ref(obj.__func__, finalize) self._args = args self._kwargs = kwargs or {} def cb(self, args: tuple[Any, ...] = ()) -> None: obj = self._obj_ref() func = self._func_ref() if obj is None or func is None: raise ReferenceError("weakly-referenced object no longer exists") if self._max_args is not None: args = args[: self._max_args] func(obj, *self._args, *args, **self._kwargs) def dereference(self) -> MethodType | partial | None: obj = self._obj_ref() func = self._func_ref() if obj is None or func is None: return None method = func.__get__(obj) if self._args or self._kwargs: return partial(method, *self._args, **self._kwargs) return method class _WeakBuiltin(WeakCallback): """Wrapper around a c-based method on a weakly-referenced object. Builtin/extension methods do have a `__self__` attribute (the object to which they are bound), but don't have a __func__ attribute, so we need to store the name of the method and look it up on the object when the callback is called. When `cb` is called here, it dereferences the object, and calls: `getattr(obj.__self__, obj.__name__)(*args, **kwargs)` """ def __init__( self, obj: MethodWrapperType | BuiltinMethodType, max_args: int | None = None, args: tuple[Any, ...] = (), finalize: Callable | None = None, on_ref_error: RefErrorChoice = "warn", ) -> None: super().__init__(obj, max_args, on_ref_error) self._obj_ref = self._try_ref(obj.__self__, finalize) self._func_name = obj.__name__ self._args = args def cb(self, args: tuple[Any, ...] = ()) -> None: func = getattr(self._obj_ref(), self._func_name, None) if func is None: raise ReferenceError("weakly-referenced object no longer exists") if self._max_args is None: func(*self._args, *args) else: func(*self._args, *args[: self._max_args]) def dereference(self) -> MethodWrapperType | BuiltinMethodType | None: return getattr(self._obj_ref(), self._func_name, None) class _WeakSetattr(WeakCallback): """Caller to set an attribute on a weakly-referenced object.""" def __init__( self, obj: object, attr: str, max_args: int | None = None, finalize: Callable | None = None, on_ref_error: RefErrorChoice = "warn", ) -> None: super().__init__(obj, max_args, on_ref_error) self._key += f".__setattr__({attr!r})" self._obj_ref = self._try_ref(obj, finalize) self._attr = attr def cb(self, args: tuple[Any, ...] = ()) -> None: obj = self._obj_ref() if obj is None: raise ReferenceError("weakly-referenced object no longer exists") if self._max_args is not None: args = args[: self._max_args] setattr(obj, self._attr, args[0] if len(args) == 1 else args) def dereference(self) -> partial | None: obj = self._obj_ref() return None if obj is None else partial(setattr, obj, self._attr) class SupportsSetitem(Protocol): def __setitem__(self, key: Any, value: Any) -> None: ... class _WeakSetitem(WeakCallback): """Caller to call __setitem__ on a weakly-referenced object.""" def __init__( self, obj: SupportsSetitem, key: Any, max_args: int | None = None, finalize: Callable | None = None, on_ref_error: RefErrorChoice = "warn", ) -> None: super().__init__(obj, max_args, on_ref_error) self._key += f".__setitem__({key!r})" self._obj_ref = self._try_ref(obj, finalize) self._itemkey = key def cb(self, args: tuple[Any, ...] = ()) -> None: obj = self._obj_ref() if obj is None: raise ReferenceError("weakly-referenced object no longer exists") if self._max_args is not None: args = args[: self._max_args] obj[self._itemkey] = args[0] if len(args) == 1 else args def dereference(self) -> partial | None: obj = self._obj_ref() return None if obj is None else partial(obj.__setitem__, self._itemkey) psygnal-0.9.1/src/psygnal/py.typed0000644000000000000000000000000013615410400014047 0ustar00psygnal-0.9.1/src/psygnal/qt.py0000644000000000000000000000557313615410400013372 0ustar00"""Module that provides Qt-specific functionality for psygnal. This module provides convenience functions for starting and stopping a QTimer that will monitor "queued" signals and invoke their callbacks. This is useful when psygnal is used in a Qt application, and you'd like to emit signals from a thread but have their callbacks invoked in the main thread. """ from __future__ import annotations from threading import Thread, current_thread from ._queue import emit_queued try: from qtpy.QtCore import Qt, QTimer except (ImportError, RuntimeError): # pragma: no cover raise ImportError( "The psygnal.qt module requires qtpy and some Qt backend to be installed" ) from None _TIMERS: dict[Thread, QTimer] = {} def start_emitting_from_queue( msec: int = 0, timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer, thread: Thread | None = None, ) -> None: """Start a QTimer that will monitor the global emission queue. If a QTimer is already running in the current thread, then this function will update the interval and timer type of that QTimer. (It is safe to call this function multiple times in the same thread.) When callbacks are connected to signals with `connect(type='queued')`, then they are not invoked immediately, but rather added to a global queue. This function starts a QTimer that will periodically check the queue and invoke any callbacks that are waiting to be invoked (in whatever thread this QTimer is running in). Parameters ---------- msec : int, optional The interval (in milliseconds) at which the QTimer will check the global emission queue. By default, the QTimer will check the queue as often as possible (i.e. 0 milliseconds). timer_type : Qt.TimerType, optional The type of timer to use. By default, Qt.PreciseTimer is used, which is the most accurate timer available on the system. thread : Thread, optional The thread in which to start the QTimer. By default, the QTimer will be started in the thread from which this function is called. """ _thread = current_thread() if thread is None else thread if _thread not in _TIMERS: _TIMERS[_thread] = QTimer() _TIMERS[_thread].timeout.connect(emit_queued) _TIMERS[_thread].setTimerType(timer_type) if _TIMERS[_thread].isActive(): _TIMERS[_thread].setInterval(msec) else: _TIMERS[_thread].start(msec) def stop_emitting_from_queue(thread: Thread | None = None) -> None: """Stop the QTimer that monitors the global emission queue. thread : Thread, optional The thread in which to stop the QTimer. By default, will stop any QTimers in the thread from which this function is called. """ _thread = current_thread() if thread is None else thread timer = _TIMERS.get(_thread) if timer is not None: timer.stop() psygnal-0.9.1/src/psygnal/utils.py0000644000000000000000000001045413615410400014100 0ustar00"""These utilities may help when using signals and evented objects.""" from __future__ import annotations from contextlib import contextmanager from functools import partial from pathlib import Path from typing import Any, Callable, Generator, Iterator from warnings import warn from ._group import EmissionInfo from ._signal import SignalInstance __all__ = ["monitor_events", "iter_signal_instances"] def _default_event_monitor(info: EmissionInfo) -> None: print(f"{info.signal.name}.emit{info.args!r}") @contextmanager def monitor_events( obj: Any | None = None, logger: Callable[[EmissionInfo], Any] = _default_event_monitor, include_private_attrs: bool = False, ) -> Iterator[None]: """Context manager to print or collect events emitted by SignalInstances on `obj`. Parameters ---------- obj : object, optional Any object that has an attribute that has a SignalInstance (or SignalGroup). If None, all SignalInstances will be monitored. logger : Callable[[EmissionInfo], None], optional A optional function to handle the logging of the event emission. This function must take two positional args: a signal name string, and a tuple that contains the emitted arguments. The default logger simply prints the signal name and emitted args. include_private_attrs : bool Whether private signals (starting with an underscore) should also be logged, by default False """ code = getattr(logger, "__code__", None) _old_api = bool(code and code.co_argcount > 1) if obj is None: # install the hook globally if _old_api: raise ValueError( "logger function must take a single argument (an EmissionInfo instance)" ) before, SignalInstance._debug_hook = SignalInstance._debug_hook, logger else: if _old_api: warn( "logger functions must now take a single argument (an instance of " "psygnal.EmissionInfo). Please update your logger function.", stacklevel=2, ) disconnectors = set() for siginst in iter_signal_instances(obj, include_private_attrs): if _old_api: def _report(*args: Any, signal: SignalInstance = siginst) -> None: logger(signal.name, args) # type: ignore else: def _report(*args: Any, signal: SignalInstance = siginst) -> None: logger(EmissionInfo(signal, args)) disconnectors.add(partial(siginst.disconnect, siginst.connect(_report))) try: yield finally: if obj is None: SignalInstance._debug_hook = before else: for disconnector in disconnectors: disconnector() def iter_signal_instances( obj: Any, include_private_attrs: bool = False ) -> Generator[SignalInstance, None, None]: """Yield all `SignalInstance` attributes found on `obj`. Parameters ---------- obj : object Any object that has an attribute that has a SignalInstance (or SignalGroup). include_private_attrs : bool Whether private signals (starting with an underscore) should also be logged, by default False Yields ------ SignalInstance SignalInstances (and SignalGroups) found as attributes on `obj`. """ for n in dir(obj): if include_private_attrs or not n.startswith("_"): attr = getattr(obj, n) if isinstance(attr, SignalInstance): yield attr _COMPILED_EXTS = (".so", ".pyd") _BAK = "_BAK" def decompile() -> None: """Mangle names of mypyc-compiled files so that they aren't used. This function requires write permissions to the psygnal source directory. """ for suffix in _COMPILED_EXTS: # pragma: no cover for path in Path(__file__).parent.rglob(f"**/*{suffix}"): path.rename(path.with_suffix(f"{suffix}{_BAK}")) def recompile() -> None: """Fix all name-mangled mypyc-compiled files so that they ARE used. This function requires write permissions to the psygnal source directory. """ for suffix in _COMPILED_EXTS: # pragma: no cover for path in Path(__file__).parent.rglob(f"**/*{suffix}{_BAK}"): path.rename(path.with_suffix(suffix)) psygnal-0.9.1/src/psygnal/_pyinstaller_util/__init__.py0000644000000000000000000000000013615410400020223 0ustar00psygnal-0.9.1/src/psygnal/_pyinstaller_util/_pyinstaller_hook.py0000644000000000000000000000023013615410400022216 0ustar00from pathlib import Path from typing import List CURRENT_DIR = Path(__file__).parent def get_hook_dirs() -> List[str]: return [str(CURRENT_DIR)] psygnal-0.9.1/src/psygnal/_pyinstaller_util/hook-psygnal.py0000644000000000000000000000273413615410400021117 0ustar00from pathlib import Path from typing import Iterable, List, Union try: from importlib.metadata import PackageNotFoundError, PackagePath from importlib.metadata import files as package_files except ImportError: from importlib_metadata import ( # type: ignore[no-redef] PackageNotFoundError, PackagePath, ) from importlib_metadata import files as package_files # type: ignore[no-redef] try: import psygnal PSYGNAL_DIR = Path(psygnal.__file__).parent except ImportError: PSYGNAL_DIR = Path(__file__).parent.parent def binary_files(file_list: Iterable[Union[PackagePath, Path]]) -> List[Path]: return [Path(file) for file in file_list if file.suffix in {".so", ".pyd"}] def create_hiddenimports() -> List[str]: res = ["queue", "mypy_extensions", "__future__"] try: files_list = package_files("psygnal") except PackageNotFoundError: return res if files_list is None: return res modules = binary_files(files_list) if len(modules) < 2: # This is a workaround for a bug in importlib.metadata in editable mode src_path = PSYGNAL_DIR.parent modules = [ x.relative_to(src_path) for x in binary_files(PSYGNAL_DIR.iterdir()) + binary_files(src_path.iterdir()) ] for module in modules: res.append(str(module).split(".")[0].replace("/", ".").replace("\\", ".")) return res hiddenimports = create_hiddenimports() psygnal-0.9.1/src/psygnal/containers/__init__.py0000644000000000000000000000205013615410400016635 0ustar00"""Containers backed by psygnal events.""" from typing import TYPE_CHECKING, Any from ._evented_dict import EventedDict from ._evented_list import EventedList from ._evented_set import EventedOrderedSet, EventedSet, OrderedSet from ._selectable_evented_list import SelectableEventedList from ._selection import Selection if TYPE_CHECKING: from ._evented_proxy import EventedCallableObjectProxy, EventedObjectProxy __all__ = [ "EventedCallableObjectProxy", "EventedDict", "EventedList", "EventedObjectProxy", "EventedOrderedSet", "EventedSet", "OrderedSet", "SelectableEventedList", "Selection", ] def __getattr__(name: str) -> Any: if name == "EventedObjectProxy": from ._evented_proxy import EventedObjectProxy return EventedObjectProxy if name == "EventedCallableObjectProxy": from ._evented_proxy import EventedCallableObjectProxy return EventedCallableObjectProxy raise AttributeError( # pragma: no cover f"module {__name__!r} has no attribute {name!r}" ) psygnal-0.9.1/src/psygnal/containers/_evented_dict.py0000644000000000000000000001316213615410400017700 0ustar00"""Dict that emits events when altered.""" from typing import ( Dict, Iterable, Iterator, Mapping, MutableMapping, Optional, Sequence, Tuple, Type, TypeVar, Union, ) from psygnal._group import SignalGroup from psygnal._signal import Signal _K = TypeVar("_K") _V = TypeVar("_V") TypeOrSequenceOfTypes = Union[Type[_V], Sequence[Type[_V]]] DictArg = Union[Mapping[_K, _V], Iterable[Tuple[_K, _V]]] class TypedMutableMapping(MutableMapping[_K, _V]): """Dictionary that enforces value type. Parameters ---------- data : Union[Mapping[_K, _V], Iterable[Tuple[_K, _V]], None], optional Data suitable of passing to dict(). Mapping of {key: value} pairs, or Iterable of two-tuples [(key, value), ...], or None to create an basetype : TypeOrSequenceOfTypes, optional Type or Sequence of Type objects. If provided, values entered into this Mapping must be an instance of one of the provided types. by default () """ def __init__( self, data: Optional[DictArg] = None, *, basetype: TypeOrSequenceOfTypes = (), **kwargs: _V, ): self._dict: Dict[_K, _V] = {} self._basetypes: Tuple[Type[_V], ...] = ( tuple(basetype) if isinstance(basetype, Sequence) else (basetype,) ) self.update({} if data is None else data, **kwargs) def __setitem__(self, key: _K, value: _V) -> None: self._dict[key] = self._type_check(value) def __delitem__(self, key: _K) -> None: del self._dict[key] def __getitem__(self, key: _K) -> _V: return self._dict[key] def __len__(self) -> int: return len(self._dict) def __iter__(self) -> Iterator[_K]: return iter(self._dict) def __repr__(self) -> str: return repr(self._dict) def _type_check(self, value: _V) -> _V: """Check the types of items if basetypes are set for the model.""" if self._basetypes and not any(isinstance(value, t) for t in self._basetypes): raise TypeError( f"Cannot add object with type {type(value)} to TypedDict expecting" f"type {self._basetypes}" ) return value def __newlike__( self, mapping: MutableMapping[_K, _V] ) -> "TypedMutableMapping[_K, _V]": new = self.__class__() # separating this allows subclasses to omit these from their `__init__` new._basetypes = self._basetypes new.update(mapping) return new def copy(self) -> "TypedMutableMapping[_K, _V]": """Return a shallow copy of the dictionary.""" return self.__newlike__(self) 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` """ adding = Signal(object) # (key, ) added = Signal(object, object) # (key, value) changing = Signal(object) # (key, ) changed = Signal(object, object, object) # (key, old_value, value) removing = Signal(object) # (key, ) removed = Signal(object, object) # (key, value) class EventedDict(TypedMutableMapping[_K, _V]): """Mutable mapping that emits events when altered. This class is designed to behave exactly like the builtin [`dict`][], but will emit events before and after all mutations (addition, removal, and changing). Parameters ---------- data : Union[Mapping[_K, _V], Iterable[Tuple[_K, _V]], None], optional Data suitable of passing to dict(). Mapping of {key: value} pairs, or Iterable of two-tuples [(key, value), ...], or None to create an basetype : TypeOrSequenceOfTypes, optional Type or Sequence of Type objects. If provided, values entered into this Mapping must be an instance of one of the provided types. by default (). Attributes ---------- events: DictEvents The `SignalGroup` object that emits all events available on an `EventedDict`. """ events: DictEvents # pragma: no cover def __init__( self, data: Optional[DictArg] = None, *, basetype: TypeOrSequenceOfTypes = (), **kwargs: _V, ): self.events = DictEvents() super().__init__(data, basetype=basetype, **kwargs) def __setitem__(self, key: _K, value: _V) -> None: if key not in self._dict: self.events.adding.emit(key) super().__setitem__(key, value) self.events.added.emit(key, value) else: old_value = self._dict[key] if value is not old_value: self.events.changing.emit(key) super().__setitem__(key, value) self.events.changed.emit(key, old_value, value) def __delitem__(self, key: _K) -> None: item = self._dict[key] self.events.removing.emit(key) super().__delitem__(key) self.events.removed.emit(key, item) def __repr__(self) -> str: return f"EventedDict({super().__repr__()})" psygnal-0.9.1/src/psygnal/containers/_evented_list.py0000644000000000000000000003610313615410400017730 0ustar00"""MutableSequence that emits events when altered. Note For Developers =================== Be cautious when re-implementing typical list-like methods here (e.g. extend, pop, clear, etc...). By not re-implementing those methods, we force ALL "CRUD" (create, read, update, delete) operations to go through a few key methods defined by the abc.MutableSequence interface, where we can emit the necessary events. Specifically: - `insert` = "create" : add a new item/index to the list - `__getitem__` = "read" : get the value of an existing index - `__setitem__` = "update" : update the value of an existing index - `__delitem__` = "delete" : remove an existing index from the list All of the additional list-like methods are provided by the MutableSequence interface, and call one of those 4 methods. So if you override a method, you MUST make sure that all the appropriate events are emitted. (Tests should cover this in test_evented_list.py) """ from __future__ import annotations # pragma: no cover from typing import Any, Iterable, MutableSequence, TypeVar, Union, cast, overload from psygnal._group import EmissionInfo, SignalGroup from psygnal._signal import Signal, SignalInstance from psygnal.utils import iter_signal_instances _T = TypeVar("_T") Index = Union[int, slice] 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. """ inserting = Signal(int) # idx inserted = Signal(int, object) # (idx, value) removing = Signal(int) # idx removed = Signal(int, object) # (idx, value) moving = Signal(int, int) # (src_idx, dest_idx) moved = Signal(int, int, object) # (src_idx, dest_idx, value) changed = Signal(object, object, object) # (int | slice, old, new) reordered = Signal() child_event = Signal(int, object, SignalInstance, tuple) class EventedList(MutableSequence[_T]): """Mutable Sequence that emits events when altered. This class is designed to behave exactly like the builtin `list`, but will emit events before and after all mutations (insertion, removal, setting, and moving). Parameters ---------- data : iterable, optional Elements to initialize the list with. hashable : bool Whether the list should be hashable as id(self). By default `True`. child_events: bool Whether to re-emit events from emitted from evented items in the list (i.e. items that have SignalInstances). If `True`, child events can be connected at `EventedList.events.child_event`. By default, `False`. Attributes ---------- events : ListEvents SignalGroup that with events related to list mutation. (see ListEvents) """ events: ListEvents # pragma: no cover def __init__( self, data: Iterable[_T] = (), *, hashable: bool = True, child_events: bool = False, ): super().__init__() self._data: list[_T] = [] self._hashable = hashable self._child_events = child_events self.events = ListEvents() self.extend(data) # WAIT!! ... Read the module docstring before reimplement these methods # def append(self, item): ... # def clear(self): ... # def pop(self, index=-1): ... # def extend(self, value: Iterable[_T]): ... # def remove(self, value: Any): ... def insert(self, index: int, value: _T) -> None: """Insert `value` before index.""" _value = self._pre_insert(value) self.events.inserting.emit(index) self._data.insert(index, _value) self.events.inserted.emit(index, value) self._post_insert(value) @overload def __getitem__(self, key: int) -> _T: ... @overload def __getitem__(self, key: slice) -> EventedList[_T]: ... def __getitem__(self, key: Index) -> _T | EventedList[_T]: """Return self[key].""" result = self._data[key] return self.__newlike__(result) if isinstance(result, list) else result @overload def __setitem__(self, key: int, value: _T) -> None: ... @overload def __setitem__(self, key: slice, value: Iterable[_T]) -> None: ... def __setitem__(self, key: Index, value: _T | Iterable[_T]) -> None: """Set self[key] to value.""" old = self._data[key] if value is old: return # sourcery skip: hoist-similar-statement-from-if, hoist-statement-from-if if isinstance(key, slice): if not isinstance(value, Iterable): raise TypeError("Can only assign an iterable to slice") value = [self._pre_insert(v) for v in value] # before we mutate the list self._data[key] = value else: value = self._pre_insert(cast("_T", value)) self._data[key] = value self.events.changed.emit(key, old, value) def __delitem__(self, key: Index) -> None: """Delete self[key].""" # delete from the end for parent, index in sorted(self._delitem_indices(key), reverse=True): parent.events.removing.emit(index) parent._pre_remove(index) item = parent._data.pop(index) self.events.removed.emit(index, item) def _delitem_indices(self, key: Index) -> Iterable[tuple[EventedList[_T], int]]: # returning (self, int) allows subclasses to pass nested members if isinstance(key, int): yield (self, key if key >= 0 else key + len(self)) elif isinstance(key, slice): yield from ((self, i) for i in range(*key.indices(len(self)))) else: n = repr(type(key).__name__) raise TypeError(f"EventedList indices must be integers or slices, not {n}") def _pre_insert(self, value: _T) -> _T: """Validate and or modify values prior to inserted.""" return value def _post_insert(self, new_item: _T) -> None: """Modify and or handle values after insertion.""" if self._child_events: self._connect_child_emitters(new_item) def _pre_remove(self, index: int) -> None: """Modify and or handle values before removal.""" if self._child_events: self._disconnect_child_emitters(self[index]) def __newlike__(self, iterable: Iterable[_T]) -> EventedList[_T]: """Return new instance of same class.""" return self.__class__(iterable) def copy(self) -> EventedList[_T]: """Return a shallow copy of the list.""" return self.__newlike__(self) def __add__(self, other: Iterable[_T]) -> EventedList[_T]: """Add other to self, return new object.""" copy = self.copy() copy.extend(other) return copy def __iadd__(self, other: Iterable[_T]) -> EventedList[_T]: """Add other to self in place (self += other).""" self.extend(other) return self def __radd__(self, other: list) -> list: """Reflected add (other + self). Cast self to list.""" return other + list(self) def __len__(self) -> int: """Return len(self).""" return len(self._data) def __repr__(self) -> str: """Return repr(self).""" return f"{type(self).__name__}({self._data})" def __eq__(self, other: Any) -> bool: """Return self==value.""" return bool(self._data == other) def __hash__(self) -> int: """Return hash(self).""" # it's important to add this to allow this object to be hashable # given that we've also reimplemented __eq__ if self._hashable: return id(self) name = self.__class__.__name__ raise TypeError( f"unhashable type: {name!r}. " f"Create with {name}(..., hashable=True) if you need hashability" ) def reverse(self, *, emit_individual_events: bool = False) -> None: """Reverse list *IN PLACE*.""" if emit_individual_events: super().reverse() else: self._data.reverse() self.events.reordered.emit() def move(self, src_index: int, dest_index: int = 0) -> bool: """Insert object at `src_index` before `dest_index`. Both indices refer to the list prior to any object removal (pre-move space). """ if dest_index < 0: dest_index += len(self) + 1 if dest_index in (src_index, src_index + 1): # this is a no-op return False self.events.moving.emit(src_index, dest_index) item = self._data.pop(src_index) if dest_index > src_index: dest_index -= 1 self._data.insert(dest_index, item) self.events.moved.emit(src_index, dest_index, item) self.events.reordered.emit() return True def move_multiple(self, sources: Iterable[Index], dest_index: int = 0) -> int: """Move a batch of `sources` indices, to a single destination. Note, if `dest_index` is higher than any of the `sources`, then the resulting position of the moved objects after the move operation is complete will be lower than `dest_index`. Parameters ---------- sources : Iterable[Union[int, slice]] A sequence of indices dest_index : int, optional The destination index. All sources will be inserted before this index (in pre-move space), by default 0... which has the effect of "bringing to front" everything in `sources`, or acting as a "reorder" method if `sources` contains all indices. Returns ------- int The number of successful move operations completed. Raises ------ TypeError If the destination index is a slice, or any of the source indices are not `int` or `slice`. """ # calling list here makes sure that there are no index errors up front move_plan = list(self._move_plan(sources, dest_index)) # don't assume index adjacency ... so move objects one at a time # this *could* be simplified with an intermediate list ... but this way # allows any views (such as QtViews) to update themselves more easily. # If this needs to be changed in the future for performance reasons, # then the associated QtListView will need to changed from using # `beginMoveRows` & `endMoveRows` to using `layoutAboutToBeChanged` & # `layoutChanged` while *manually* updating model indices with # `changePersistentIndexList`. That becomes much harder to do with # nested tree-like models. with self.events.reordered.blocked(): for src, dest in move_plan: self.move(src, dest) self.events.reordered.emit() return len(move_plan) def _move_plan( self, sources: Iterable[Index], dest_index: int ) -> Iterable[tuple[int, int]]: """Yield prepared indices for a multi-move. Given a set of `sources` from anywhere in the list, and a single `dest_index`, this function computes and yields `(from_index, to_index)` tuples that can be used sequentially in single move operations. It keeps track of what has moved where and updates the source and destination indices to reflect the model at each point in the process. This is useful for a drag-drop operation with a QtModel/View. Parameters ---------- sources : Iterable[tuple[int, ...]] An iterable of tuple[int] that should be moved to `dest_index`. dest_index : Tuple[int] The destination for sources. """ if isinstance(dest_index, slice): raise TypeError("Destination index may not be a slice") # pragma: no cover to_move: list[int] = [] for idx in sources: if isinstance(idx, slice): to_move.extend(list(range(*idx.indices(len(self))))) elif isinstance(idx, int): to_move.append(idx) else: raise TypeError( "Can only move integer or slice indices" ) # pragma: no cover to_move = list(dict.fromkeys(to_move)) if dest_index < 0: dest_index += len(self) + 1 d_inc = 0 popped: list[int] = [] for i, src in enumerate(to_move): if src != dest_index: # we need to decrement the src_i by 1 for each time we have # previously pulled items out from in front of the src_i src -= sum(x <= src for x in popped) # if source is past the insertion point, increment src for each # previous insertion if src >= dest_index: src += i yield src, dest_index + d_inc popped.append(src) # if the item moved up, icrement the destination index if dest_index <= src: d_inc += 1 def _connect_child_emitters(self, child: _T) -> None: """Connect all events from the child to be reemitted.""" for emitter in iter_signal_instances(child): emitter.connect(self._reemit_child_event) def _disconnect_child_emitters(self, child: _T) -> None: """Disconnect all events from the child from the reemitter.""" for emitter in iter_signal_instances(child): emitter.disconnect(self._reemit_child_event) def _reemit_child_event(self, *args: Any) -> None: """Re-emit event from child with index.""" emitter = Signal.current_emitter() if emitter is None: return # pragma: no cover obj = emitter.instance try: idx = self.index(obj) except ValueError: # pragma: no cover return if ( args and isinstance(emitter, SignalGroup) and isinstance(args[0], EmissionInfo) ): emitter, args = args[0] self.events.child_event.emit(idx, obj, emitter, args) psygnal-0.9.1/src/psygnal/containers/_evented_proxy.py0000644000000000000000000001601613615410400020137 0ustar00from functools import partial from typing import Any, Callable, Dict, Generic, List, TypeVar from weakref import finalize try: from wrapt import ObjectProxy except ImportError as e: raise type(e)( f"{e}. Please `pip install psygnal[proxy]` to use EventedObjectProxies" ) from e from psygnal._group import SignalGroup from psygnal._signal import Signal T = TypeVar("T") _UNSET = object() class ProxyEvents(SignalGroup): """ObjectProxy events.""" attribute_set = Signal(str, object) attribute_deleted = Signal(str) item_set = Signal(object, object) item_deleted = Signal(object) in_place = Signal(str, object) class CallableProxyEvents(ProxyEvents): """CallableObjectProxy events.""" called = Signal(tuple, dict) # we're using a cache instead of setting the events object directly on the proxy # because when wrapt is compiled as a C extensions, the ObjectProxy is not allowed # to add any new attributes. _OBJ_CACHE: Dict[int, ProxyEvents] = {} class EventedObjectProxy(ObjectProxy, Generic[T]): """Create a proxy of `target` that includes an `events` [psygnal.SignalGroup][]. !!! important This class requires `wrapt` to be installed. You can install directly (`pip install wrapt`) or by using the psygnal extra: `pip install psygnal[proxy]` Signals will be emitted whenever an attribute is set or deleted, or (if the object implements `__getitem__`) whenever an item is set or deleted. If the object supports in-place modification (i.e. any of the `__i{}__` magic methods), then an `in_place` event is emitted (with the name of the method) whenever any of them are used. The events available at target.events include: - `attribute_set`: `Signal(str, object)` - `attribute_deleted`: `Signal(str)` - `item_set`: `Signal(object, object)` - `item_deleted`: `Signal(object)` - `in_place`: `Signal(str, object)` Parameters ---------- target : Any An object to wrap """ def __init__(self, target: Any): super().__init__(target) @property def events(self) -> ProxyEvents: # pragma: no cover # unclear why """`SignalGroup` containing events for this object proxy.""" obj_id = id(self) if obj_id not in _OBJ_CACHE: _OBJ_CACHE[obj_id] = ProxyEvents() finalize(self, partial(_OBJ_CACHE.pop, obj_id, None)) return _OBJ_CACHE[obj_id] def __setattr__(self, name: str, value: None) -> None: before = getattr(self, name, _UNSET) super().__setattr__(name, value) after = getattr(self, name, _UNSET) if before is not after: self.events.attribute_set(name, after) def __delattr__(self, name: str) -> None: super().__delattr__(name) self.events.attribute_deleted(name) def __setitem__(self, key: Any, value: Any) -> None: before = self[key] super().__setitem__(key, value) after = self[key] if before is not after: self.events.item_set(key, after) def __delitem__(self, key: Any) -> None: super().__delitem__(key) self.events.item_deleted(key) def __repr__(self) -> str: return repr(self.__wrapped__) def __dir__(self) -> List[str]: return [*dir(self.__wrapped__), "events"] def __iadd__(self, other: Any) -> T: self.events.in_place("add", other) return super().__iadd__(other) # type: ignore def __isub__(self, other: Any) -> T: self.events.in_place("sub", other) return super().__isub__(other) # type: ignore def __imul__(self, other: Any) -> T: self.events.in_place("mul", other) return super().__imul__(other) # type: ignore def __imatmul__(self, other: Any) -> T: self.events.in_place("matmul", other) self.__wrapped__ @= other # not in wrapt # type: ignore return self def __itruediv__(self, other: Any) -> T: self.events.in_place("truediv", other) return super().__itruediv__(other) # type: ignore def __ifloordiv__(self, other: Any) -> T: self.events.in_place("floordiv", other) return super().__ifloordiv__(other) # type: ignore def __imod__(self, other: Any) -> T: self.events.in_place("mod", other) return super().__imod__(other) # type: ignore def __ipow__(self, other: Any) -> T: self.events.in_place("pow", other) return super().__ipow__(other) # type: ignore def __ilshift__(self, other: Any) -> T: self.events.in_place("lshift", other) return super().__ilshift__(other) # type: ignore def __irshift__(self, other: Any) -> T: self.events.in_place("rshift", other) return super().__irshift__(other) # type: ignore def __iand__(self, other: Any) -> T: self.events.in_place("and", other) return super().__iand__(other) # type: ignore def __ixor__(self, other: Any) -> T: self.events.in_place("xor", other) return super().__ixor__(other) # type: ignore def __ior__(self, other: Any) -> T: self.events.in_place("or", other) return super().__ior__(other) # type: ignore class EventedCallableObjectProxy(EventedObjectProxy): """Create a proxy of `target` that includes an `events` [psygnal.SignalGroup][]. `target` must be callable. !!! important This class requires `wrapt` to be installed. You can install directly (`pip install wrapt`) or by using the psygnal extra: `pip install psygnal[proxy]` Signals will be emitted whenever an attribute is set or deleted, or (if the object implements `__getitem__`) whenever an item is set or deleted. If the object supports in-place modification (i.e. any of the `__i{}__` magic methods), then an `in_place` event is emitted (with the name of the method) whenever any of them are used. Lastly, if the item is called, a `called` event is emitted with the (args, kwargs) used in the call. The events available at `target.events` include: - `attribute_set`: `Signal(str, object)` - `attribute_deleted`: `Signal(str)` - `item_set`: `Signal(object, object)` - `item_deleted`: `Signal(object)` - `in_place`: `Signal(str, object)` - `called`: `Signal(tuple, dict)` Parameters ---------- target : Callable An callable object to wrap """ def __init__(self, target: Callable): super().__init__(target) @property def events(self) -> CallableProxyEvents: # pragma: no cover # unclear why """`SignalGroup` containing events for this object proxy.""" obj_id = id(self) if obj_id not in _OBJ_CACHE: _OBJ_CACHE[obj_id] = CallableProxyEvents() finalize(self, partial(_OBJ_CACHE.pop, obj_id, None)) return _OBJ_CACHE[obj_id] # type: ignore def __call__(self, *args: Any, **kwargs: Any) -> Any: """Call the wrapped object and emit a `called` signal.""" self.events.called(args, kwargs) return self.__wrapped__(*args, **kwargs) psygnal-0.9.1/src/psygnal/containers/_evented_set.py0000644000000000000000000002335713615410400017557 0ustar00from __future__ import annotations from itertools import chain from typing import Any, Iterable, Iterator, MutableSet, TypeVar from typing_extensions import Final from psygnal import Signal, SignalGroup _T = TypeVar("_T") _Cls = TypeVar("_Cls", bound="_BaseMutableSet") class BailType: pass BAIL: Final = BailType() class _BaseMutableSet(MutableSet[_T]): _data: set[_T] # pragma: no cover def __init__(self, iterable: Iterable[_T] = ()): self._data = set() self._data.update(iterable) def add(self, item: _T) -> None: """Add an element to a set. This has no effect if the element is already present. """ _item = self._pre_add_hook(item) if not isinstance(_item, BailType): self._do_add(_item) self._post_add_hook(_item) def update(self, *others: Iterable[_T]) -> None: """Update this set with the union of this set and others.""" for i in chain(*others): self.add(i) def discard(self, item: _T) -> None: """Remove an element from a set if it is a member. If the element is not a member, do nothing. """ _item = self._pre_discard_hook(item) if not isinstance(_item, BailType): self._do_discard(_item) self._post_discard_hook(_item) def __contains__(self, value: object) -> bool: """Return True if value is in set.""" return value in self._data def __iter__(self) -> Iterator[_T]: """Implement iter(self).""" return iter(self._data) def __len__(self) -> int: """Return len(self).""" return len(self._data) def __repr__(self) -> str: """Return repr(self).""" return f"{self.__class__.__name__}({self._data!r})" # -------- def _pre_add_hook(self, item: _T) -> _T | BailType: return item # pragma: no cover def _post_add_hook(self, item: _T) -> None: ... # pragma: no cover def _pre_discard_hook(self, item: _T) -> _T | BailType: return item # pragma: no cover def _post_discard_hook(self, item: _T) -> None: ... # pragma: no cover def _do_add(self, item: _T) -> None: self._data.add(item) def _do_discard(self, item: _T) -> None: self._data.discard(item) # -------- To match set API def __copy__(self: _Cls) -> _Cls: inst = self.__class__.__new__(self.__class__) inst.__dict__.update(self.__dict__) return inst def copy(self: _Cls) -> _Cls: return self.__class__(self) def difference(self: _Cls, *s: Iterable[_T]) -> _Cls: """Return the difference of two or more sets as a new set. (i.e. all elements that are in this set but not the others.) """ other = set(chain(*s)) return self.__class__(i for i in self if i not in other) def difference_update(self, *s: Iterable[_T]) -> None: """Remove all elements of another set from this set.""" for i in chain(*s): self.discard(i) def intersection(self: _Cls, *s: Iterable[_T]) -> _Cls: """Return the intersection of two sets as a new set. (i.e. all elements that are in both sets.) """ other = set.intersection(*(set(x) for x in s)) return self.__class__(i for i in self if i in other) def intersection_update(self, *s: Iterable[_T]) -> None: """Update this set with the intersection of itself and another.""" other = set.intersection(*(set(x) for x in s)) for i in tuple(self): if i not in other: self.discard(i) def issubset(self, __s: Iterable[Any]) -> bool: """Report whether another set contains this set.""" return set(self).issubset(__s) def issuperset(self, __s: Iterable[Any]) -> bool: """Report whether this set contains another set.""" return set(self).issuperset(__s) def symmetric_difference(self: _Cls, __s: Iterable[_T]) -> _Cls: """Return the symmetric difference of two sets as a new set. (i.e. all elements that are in exactly one of the sets.) """ a = chain((i for i in __s if i not in self), (i for i in self if i not in __s)) return self.__class__(a) def symmetric_difference_update(self, __s: Iterable[_T]) -> None: """Update this set with the symmetric difference of itself and another. This will remove any items in this set that are also in `other`, and add any items in others that are not present in this set. """ for i in __s: self.discard(i) if i in self else self.add(i) def union(self: _Cls, *s: Iterable[_T]) -> _Cls: """Return the union of sets as a new set. (i.e. all elements that are in either set.) """ new = self.copy() new.update(*s) return new class OrderedSet(_BaseMutableSet[_T]): """A set that preserves insertion order, uses dict behind the scenes.""" _data: dict[_T, None] # type: ignore # pragma: no cover def __init__(self, iterable: Iterable[_T] = ()): self._data = {} self.update(iterable) def _do_add(self, item: _T) -> None: self._data[item] = None def _do_discard(self, item: _T) -> None: self._data.pop(item, None) def __repr__(self) -> str: """Return repr(self).""" inner = ", ".join(str(x) for x in self._data) return f"{self.__class__.__name__}(({inner}))" class SetEvents(SignalGroup): """Events available on [EventedSet][psygnal.containers.EventedSet]. Attributes ---------- items_changed (added: Tuple[Any, ...], removed: Tuple[Any, ...]) A signal that will emitted whenever an item or items are added or removed. Connected callbacks will be called with `callback(added, removed)`, where `added` and `removed` are tuples containing the objects that have been added or removed from the set. """ items_changed = Signal(tuple, tuple) class EventedSet(_BaseMutableSet[_T]): """A set with an `items_changed` signal that emits when items are added/removed. Parameters ---------- iterable : Iterable[_T] Data to populate the set. If omitted, an empty set is created. Attributes ---------- events : SetEvents SignalGroup that with events related to set mutation. (see SetEvents) Examples -------- >>> from psygnal.containers import EventedSet >>> >>> my_set = EventedSet([1, 2, 3]) >>> my_set.events.items_changed.connect( >>> lambda a, r: print(f"added={a}, removed={r}") >>> ) >>> my_set.update({3, 4, 5}) added=(4, 5), removed=() Multi-item events will be reduced into a single emission: >>> my_set.symmetric_difference_update({4, 5, 6, 7}) added=(6, 7), removed=(4, 5) >>> my_set EventedSet({1, 2, 3, 6, 7}) """ events: SetEvents # pragma: no cover def __init__(self, iterable: Iterable[_T] = ()): self.events = self._get_events_class() super().__init__(iterable) def update(self, *others: Iterable[_T]) -> None: """Update this set with the union of this set and others.""" with self.events.items_changed.paused(_reduce_events, ((), ())): super().update(*others) def clear(self) -> None: """Remove all elements from this set.""" with self.events.items_changed.paused(_reduce_events, ((), ())): super().clear() def difference_update(self, *s: Iterable[_T]) -> None: """Remove all elements of another set from this set.""" with self.events.items_changed.paused(_reduce_events, ((), ())): super().difference_update(*s) def intersection_update(self, *s: Iterable[_T]) -> None: """Update this set with the intersection of itself and another.""" with self.events.items_changed.paused(_reduce_events, ((), ())): super().intersection_update(*s) def symmetric_difference_update(self, __s: Iterable[_T]) -> None: """Update this set with the symmetric difference of itself and another. This will remove any items in this set that are also in `other`, and add any items in others that are not present in this set. """ with self.events.items_changed.paused(_reduce_events, ((), ())): super().symmetric_difference_update(__s) def _pre_add_hook(self, item: _T) -> _T | BailType: return BAIL if item in self else item def _post_add_hook(self, item: _T) -> None: self._emit_change((item,), ()) def _pre_discard_hook(self, item: _T) -> _T | BailType: return BAIL if item not in self else item def _post_discard_hook(self, item: _T) -> None: self._emit_change((), (item,)) def _emit_change(self, added: tuple[_T, ...], removed: tuple[_T, ...]) -> None: """Emit a change event.""" self.events.items_changed.emit(added, removed) def _get_events_class(self) -> SetEvents: return SetEvents() class EventedOrderedSet(EventedSet, OrderedSet[_T]): """A ordered variant of EventedSet that maintains insertion order. Parameters ---------- iterable : Iterable[_T] Data to populate the set. If omitted, an empty set is created. Attributes ---------- events : SetEvents SignalGroup that with events related to set mutation. (see SetEvents) """ # reproducing init here to avoid a mkdocs warning: # "Parameter 'iterable' does not appear in the function signature" def __init__(self, iterable: Iterable[_T] = ()): super().__init__(iterable) def _reduce_events(a: tuple, b: tuple) -> tuple[tuple, tuple]: """Combine two events (a and b) each of which contain (added, removed).""" a0, a1 = a b0, b1 = b return (a0 + b0, a1 + b1) psygnal-0.9.1/src/psygnal/containers/_selectable_evented_list.py0000644000000000000000000001015313615410400022110 0ustar00"""MutableSequence with a selection model.""" from typing import Any, Iterable, Tuple, TypeVar from ._evented_list import EventedList, ListEvents from ._selection import Selectable _T = TypeVar("_T") class SelectableEventedList(Selectable[_T], EventedList[_T]): """`EventedList` subclass with a built in selection model. In addition to all `EventedList` properties, this class also has a `selection` attribute that manages a set of selected items in the list. Parameters ---------- data : iterable, optional Elements to initialize the list with. hashable : bool Whether the list should be hashable as id(self). By default `True`. child_events: bool Whether to re-emit events from emitted from evented items in the list (i.e. items that have SignalInstances). If `True`, child events can be connected at `EventedList.events.child_event`. By default, `False`. Attributes ---------- events : ListEvents SignalGroup that with events related to list mutation. (see ListEvents) selection : Selection An evented set containing the currently selected items, along with an `active` and `current` item. (See `Selection`) """ events: ListEvents # pragma: no cover def __init__( self, data: Iterable[_T] = (), *, hashable: bool = True, child_events: bool = False, ): self._activate_on_insert: bool = True super().__init__(data=data, hashable=hashable, child_events=child_events) self.events.removed.connect(self._on_item_removed) def _on_item_removed(self, idx: int, obj: Any) -> None: self.selection.discard(obj) def insert(self, index: int, value: _T) -> None: """Insert item(s) into the list and update the selection.""" super().insert(index, value) if self._activate_on_insert: self.selection.active = value def select_all(self) -> None: """Select all items in the list.""" self.selection.update(self) def deselect_all(self) -> None: """Deselect all items in the list.""" self.selection.clear() def select_next( self, step: int = 1, expand_selection: bool = False, wraparound: bool = False ) -> None: """Select the next item in the list. Parameters ---------- step : int The step size to take when picking the next item, by default 1 expand_selection : bool If True, will expand the selection to contain the both the current item and the next item, by default False wraparound : bool Whether to return to the beginning of the list of the end has been reached, by default False """ if len(self) == 0: return elif not self.selection: idx = -1 if step > 0 else 0 else: idx = self.index(self.selection._current) + step idx_in_sequence = len(self) > idx >= 0 if wraparound: idx = idx % len(self) elif not idx_in_sequence: idx = -1 if step > 0 else 0 next_item = self[idx] if expand_selection: self.selection.add(next_item) self.selection._current = next_item else: self.selection.active = next_item def select_previous( self, expand_selection: bool = False, wraparound: bool = False ) -> None: """Select the previous item in the list.""" self.select_next( step=-1, expand_selection=expand_selection, wraparound=wraparound ) def remove_selected(self) -> Tuple[_T, ...]: """Remove selected items from the list and the selection. Returns ------- Tuple[_T, ...] The items that were removed. """ selected_items = tuple(self.selection) idx = 0 for item in list(self.selection): idx = self.index(item) self.remove(item) new_idx = max(0, idx - 1) if len(self) > new_idx: self.selection.add(self[new_idx]) return selected_items psygnal-0.9.1/src/psygnal/containers/_selection.py0000644000000000000000000001456113615410400017234 0ustar00from __future__ import annotations from typing import TYPE_CHECKING, Any, Container, TypeVar from psygnal._signal import Signal from ._evented_set import BailType, EventedOrderedSet, SetEvents if TYPE_CHECKING: from typing import Iterable _T = TypeVar("_T") _S = TypeVar("_S") class SelectionEvents(SetEvents): """Events available on [Selection][psygnal.containers.Selection]. Attributes ---------- items_changed (added: Tuple[_T], removed: Tuple[_T]) A signal that will emitted whenever an item or items are added or removed. Connected callbacks will be called with `callback(added, removed)`, where `added` and `removed` are tuples containing the objects that have been added or removed from the set. active (value: _T) Emitted when the active item has changed. An active item is a single selected item. _current (value: _T) Emitted when the current item has changed. (Private event) """ active = Signal(object) _current = Signal(object) class Selection(EventedOrderedSet[_T]): """An model of selected items, with a `active` and `current` item. There can only be one `active` and one `current` item, but there can be multiple selected items. An "active" item is defined as a single selected item (if multiple items are selected, there is no active item). The "current" item is mostly useful for (e.g.) keyboard actions: even with multiple items selected, you may only have one current item, and keyboard events (like up and down) can modify that current item. It's possible to have a current item without an active item, but an active item will always be the current item. An item can be the current item and selected at the same time. Qt views will ensure that there is always a current item as keyboard navigation, for example, requires a current item. This pattern mimics current/selected items from Qt: https://doc.qt.io/qt-5/model-view-programming.html#current-item-and-selected-items Parameters ---------- data : iterable, optional Elements to initialize the set with. parent : Container, optional The parent container, if any. This is used to provide validation upon mutation in common use cases. Attributes ---------- events : SelectionEvents SignalGroup that with events related to selection changes. (see SelectionEvents) active : Any, optional The active item, if any. An "active" item is defined as a single selected item (if multiple items are selected, there is no active item) _current : Any, optional The current item, if any. This is used primarily by GUI views when handling mouse/key events. """ events: SelectionEvents # pragma: no cover def __init__(self, data: Iterable[_T] = (), parent: Container | None = None): self._active: _T | None = None self._current_: _T | None = None self._parent: Container | None = parent super().__init__(iterable=data) self._update_active() @property def _current(self) -> _T | None: # pragma: no cover """Get current item.""" return self._current_ @_current.setter def _current(self, value: _T | None) -> None: # pragma: no cover """Set current item.""" if value == self._current_: return self._current_ = value self.events._current.emit(value) @property def active(self) -> _T | None: # pragma: no cover """Return the currently active item or None.""" return self._active @active.setter def active(self, value: _T | None) -> None: # pragma: no cover """Set the active item. This makes `value` the only selected item, and makes it current. """ if value == self._active: return self._active = value self.clear() if value is None else self.select_only(value) self._current = value self.events.active.emit(value) def clear(self, keep_current: bool = False) -> None: """Clear the selection. Parameters ---------- keep_current : bool If `False` (the default), the "current" item will also be set to None. """ if not keep_current: self._current = None super().clear() def toggle(self, obj: _T) -> None: """Toggle selection state of obj.""" self.symmetric_difference_update({obj}) def select_only(self, obj: _T) -> None: """Unselect everything but `obj`. Add to selection if not currently selected.""" self.intersection_update({obj}) self.add(obj) def _update_active(self) -> None: """On a selection event, update the active item based on selection. An active item is a single selected item. """ if len(self) == 1: self.active = list(self)[0] elif self._active is not None: self._active = None self.events.active.emit(None) def _get_events_class(self) -> SelectionEvents: """Override SetEvents with SelectionEvents.""" return SelectionEvents() def _emit_change(self, added: tuple[_T, ...], removed: tuple[_T, ...]) -> None: """Emit a change event.""" super()._emit_change(added, removed) self._update_active() def _pre_add_hook(self, item: _T) -> _T | BailType: if self._parent is not None and item not in self._parent: raise ValueError( "Cannot select an item that is not in the parent container." ) return super()._pre_add_hook(item) def __hash__(self) -> int: """Make selection hashable.""" return id(self) class Selectable(Container[_S]): """Mixin that adds a selection model to a container.""" def __init__(self, *args: Any, **kwargs: Any) -> None: self._selection: Selection[_S] = Selection(parent=self) super().__init__(*args, **kwargs) @property def selection(self) -> Selection[_S]: # pragma: no cover """Get current selection.""" return self._selection @selection.setter def selection(self, new_selection: Iterable[_S]) -> None: # pragma: no cover """Set selection, without deleting selection model object.""" self._selection.intersection_update(new_selection) self._selection.update(new_selection) psygnal-0.9.1/tests/test_bench.py0000644000000000000000000001357413615410400013762 0ustar00import sys from dataclasses import dataclass from functools import partial from inspect import signature from typing import Callable, ClassVar from unittest.mock import Mock import pytest from psygnal import EmissionInfo, Signal, SignalGroupDescriptor, SignalInstance, evented if all(x not in {"--codspeed", "--benchmark", "tests/test_bench.py"} for x in sys.argv): pytest.skip("use --benchmark to run benchmark", allow_module_level=True) CALLBACK_TYPES = [ "function", "method", "lambda", "partial", "partial_method", "setattr", "setitem", "real_func", "print", ] # fmt: off class Emitter: one_int = Signal(int) int_str = Signal(int, str) class Obj: x: int = 0 def __setitem__(self, key: str, value: int) -> None: self.x = value def no_args(self) -> None: ... def one_int(self, x: int) -> None: ... def int_str(self, x: int, y: str) -> None: ... def no_args() -> None: ... def one_int(x: int) -> None: ... def int_str(x: int, y: str) -> None: ... def real_func() -> None: list(range(4)) # simulate a brief thing INT_SIG = signature(one_int) # fmt: on def _get_callback(callback_type: str, obj: Obj) -> Callable: callback_types: dict[str, Callable] = { "function": one_int, "method": obj.one_int, "lambda": lambda x: None, "partial": partial(int_str, y="foo"), "partial_method": partial(obj.int_str, y="foo"), "real_func": real_func, "print": print, } return callback_types[callback_type] # Creation suite ------------------------------------------ def test_create_signal(benchmark: Callable) -> None: benchmark(Signal, int) def test_create_signal_instance(benchmark: Callable) -> None: benchmark(SignalInstance, INT_SIG) # Connect suite --------------------------------------------- @pytest.mark.parametrize("check_types", ["check_types", ""]) @pytest.mark.parametrize("callback_type", CALLBACK_TYPES) def test_connect_time( benchmark: Callable, callback_type: str, check_types: str ) -> None: emitter = Emitter() obj = Obj() kwargs = {} if callback_type == "setattr": func: Callable = emitter.one_int.connect_setattr args: tuple = (obj, "x") elif callback_type == "setitem": func = emitter.one_int.connect_setitem args = (obj, "x") else: func = emitter.one_int.connect args = (_get_callback(callback_type, obj),) kwargs = {"check_types": bool(check_types)} benchmark(func, *args, **kwargs) # Emit suite ------------------------------------------------ @pytest.mark.parametrize("n_connections", range(2, 2**6, 16)) @pytest.mark.parametrize("callback_type", CALLBACK_TYPES) def test_emit_time(benchmark: Callable, n_connections: int, callback_type: str) -> None: emitter = Emitter() obj = Obj() if callback_type == "setattr": for _ in range(n_connections): emitter.one_int.connect_setattr(obj, "x") elif callback_type == "setitem": for _ in range(n_connections): emitter.one_int.connect_setitem(obj, "x") else: callback = _get_callback(callback_type, obj) for _ in range(n_connections): emitter.one_int.connect(callback, unique=False) benchmark(emitter.one_int.emit, 1) @pytest.mark.benchmark def test_evented_creation() -> None: @evented @dataclass class Obj: x: int = 0 y: str = "hi" z: bool = False _ = Obj().events # type: ignore def test_evented_setattr(benchmark: Callable) -> None: @evented @dataclass class Obj: x: int = 0 y: str = "hi" z: bool = False obj = Obj() _ = obj.events # type: ignore benchmark(setattr, obj, "x", 1) def _get_dataclass(type_: str) -> type: if type_ == "attrs": from attrs import define @define class Foo: a: int b: str c: bool d: float e: tuple[int, str] events: ClassVar = SignalGroupDescriptor() elif type_ == "dataclass": @dataclass class Foo: # type: ignore [no-redef] a: int b: str c: bool d: float e: tuple[int, str] events: ClassVar = SignalGroupDescriptor() elif type_ == "msgspec": import msgspec class Foo(msgspec.Struct): # type: ignore [no-redef] a: int b: str c: bool d: float e: tuple[int, str] events: ClassVar = SignalGroupDescriptor() elif type_ == "pydantic": from pydantic import BaseModel class Foo(BaseModel): # type: ignore [no-redef] a: int b: str c: bool d: float e: tuple[int, str] events: ClassVar = SignalGroupDescriptor() return Foo @pytest.mark.parametrize("type_", ["dataclass", "pydantic", "attrs", "msgspec"]) def test_dataclass_group_create(type_: str, benchmark: Callable) -> None: if type_ == "msgspec": pytest.importorskip("msgspec") Foo = _get_dataclass(type_) foo = Foo(a=1, b="hi", c=True, d=1.0, e=(1, "hi")) benchmark(getattr, foo, "events") @pytest.mark.parametrize("type_", ["dataclass", "pydantic", "attrs", "msgspec"]) def test_dataclass_setattr(type_: str, benchmark: Callable) -> None: if type_ == "msgspec": pytest.importorskip("msgspec") Foo = _get_dataclass(type_) foo = Foo(a=1, b="hi", c=True, d=1.0, e=(1, "hi")) mock = Mock() foo.events.connect(mock) def _doit() -> None: foo.a = 2 foo.b = "hello" foo.c = False foo.d = 2.0 foo.e = (2, "hello") benchmark(_doit) for newval, attr in zip([2, "hello", False, 2.0, (2, "hello")], "abcde"): mock.assert_any_call(EmissionInfo(getattr(foo.events, attr), (newval,))) assert getattr(foo, attr) == newval psygnal-0.9.1/tests/test_dataclass_utils.py0000644000000000000000000000313613615410400016053 0ustar00from dataclasses import dataclass import pytest from attr import define from pydantic import BaseModel from psygnal import _dataclass_utils try: from msgspec import Struct except ImportError: Struct = None VARIANTS = ["dataclass", "attrs_class", "pydantic_model"] if Struct is not None: VARIANTS.append("msgspec_struct") @pytest.mark.parametrize("frozen", [True, False], ids=["frozen", ""]) @pytest.mark.parametrize("type_", VARIANTS) def test_dataclass_utils(type_: str, frozen: bool) -> None: if type_ == "attrs_class": @define(frozen=frozen) # type: ignore class Foo: x: int y: str = "foo" elif type_ == "dataclass": @dataclass(frozen=frozen) # type: ignore class Foo: # type: ignore [no-redef] x: int y: str = "foo" elif type_ == "msgspec_struct": class Foo(Struct, frozen=frozen): # type: ignore [no-redef] x: int y: str = "foo" elif type_ == "pydantic_model": class Foo(BaseModel): # type: ignore [no-redef] x: int y: str = "foo" class Config: allow_mutation = not frozen for name in VARIANTS: is_type = getattr(_dataclass_utils, f"is_{name}") assert is_type(Foo) is (name == type_) assert is_type(Foo(x=1)) is (name == type_) assert list(_dataclass_utils.iter_fields(Foo)) == [("x", int), ("y", str)] if type_ == "msgspec_struct" and frozen: # not supported until next release of msgspec return assert _dataclass_utils.is_frozen(Foo) == frozen psygnal-0.9.1/tests/test_evented_decorator.py0000644000000000000000000001226013615410400016366 0ustar00import operator import sys from dataclasses import dataclass from typing import TYPE_CHECKING, ClassVar, no_type_check from unittest.mock import Mock import numpy as np import pytest from psygnal import ( SignalGroup, SignalGroupDescriptor, evented, get_evented_namespace, is_evented, ) decorated_or_descriptor = pytest.mark.parametrize( "decorator", [True, False], ids=["decorator", "descriptor"] ) @no_type_check def _check_events(cls, events_ns="events"): obj = cls(bar=1, baz="2", qux=np.zeros(3)) assert is_evented(obj) assert is_evented(cls) assert get_evented_namespace(cls) == events_ns assert isinstance(getattr(cls, events_ns), SignalGroupDescriptor) events = getattr(obj, events_ns) assert isinstance(events, SignalGroup) assert set(events.signals) == {"bar", "baz", "qux"} mock = Mock() events.bar.connect(mock) assert obj.bar == 1 obj.bar = 2 assert obj.bar == 2 mock.assert_called_once_with(2) mock.reset_mock() obj.baz = "3" mock.assert_not_called() mock.reset_mock() events.qux.connect(mock) obj.qux = np.ones(3) mock.assert_called_once() assert np.array_equal(obj.qux, np.ones(3)) DCLASS_KWARGS = [] if sys.version_info >= (3, 10): DCLASS_KWARGS.extend([{"slots": True}, {"slots": False}]) @decorated_or_descriptor @pytest.mark.parametrize("kwargs", DCLASS_KWARGS) def test_native_dataclass(decorator: bool, kwargs: dict) -> None: @dataclass(**kwargs) class Base: bar: int baz: str qux: np.ndarray if decorator: @evented(equality_operators={"qux": operator.eq}) # just for test coverage class Foo(Base): ... else: class Foo(Base): # type: ignore [no-redef] events: ClassVar[SignalGroupDescriptor] = SignalGroupDescriptor( equality_operators={"qux": operator.eq} ) _check_events(Foo) @decorated_or_descriptor @pytest.mark.parametrize("slots", [True, False]) def test_attrs_dataclass(decorator: bool, slots: bool) -> None: from attrs import define @define(slots=slots) # type: ignore [misc] class Base: bar: int baz: str qux: np.ndarray if decorator: @evented class Foo(Base): ... else: class Foo(Base): # type: ignore [no-redef] events: ClassVar[SignalGroupDescriptor] = SignalGroupDescriptor() _check_events(Foo) class Config: arbitrary_types_allowed = True @decorated_or_descriptor def test_pydantic_dataclass(decorator: bool) -> None: from pydantic.dataclasses import dataclass @dataclass(config=Config) class Base: bar: int baz: str qux: np.ndarray if decorator: @evented class Foo(Base): ... else: class Foo(Base): # type: ignore [no-redef] events: ClassVar[SignalGroupDescriptor] = SignalGroupDescriptor() _check_events(Foo) @decorated_or_descriptor def test_pydantic_base_model(decorator: bool) -> None: from pydantic import BaseModel class Base(BaseModel): bar: int baz: str qux: np.ndarray Config = Config # type: ignore if decorator: @evented(events_namespace="my_events") class Foo(Base): ... else: class Foo(Base): # type: ignore [no-redef] my_events: ClassVar[SignalGroupDescriptor] = SignalGroupDescriptor() _check_events(Foo, "my_events") @pytest.mark.parametrize("decorator", [True, False], ids=["decorator", "descriptor"]) def test_msgspec_struct(decorator: bool) -> None: if TYPE_CHECKING: import msgspec else: msgspec = pytest.importorskip("msgspec") # remove when py37 is dropped if decorator: @evented class Foo(msgspec.Struct): bar: int baz: str qux: np.ndarray else: class Foo(msgspec.Struct): # type: ignore [no-redef] bar: int baz: str qux: np.ndarray events: ClassVar[SignalGroupDescriptor] = SignalGroupDescriptor() _check_events(Foo) def test_no_signals_warn() -> None: with pytest.warns(UserWarning, match="No mutable fields found on class"): @evented class Foo: ... _ = Foo().events # type: ignore with pytest.warns(UserWarning, match="No mutable fields found on class"): class Foo2: events = SignalGroupDescriptor() _ = Foo2().events @dataclass class Foo3: events = SignalGroupDescriptor(warn_on_no_fields=False) # no warning _ = Foo3().events @dataclass class FooPicklable: bar: int events: ClassVar[SignalGroupDescriptor] = SignalGroupDescriptor( cache_on_instance=False ) def test_pickle() -> None: """Make sure that evented classes are still picklable.""" import pickle obj = FooPicklable(1) obj2 = pickle.loads(pickle.dumps(obj)) assert obj2.bar == 1 def test_get_namespace() -> None: @evented(events_namespace="my_events") @dataclass class Foo: x: int assert get_evented_namespace(Foo) == "my_events" assert is_evented(Foo) psygnal-0.9.1/tests/test_evented_model.py0000644000000000000000000003147413615410400015514 0ustar00import inspect import sys from typing import ClassVar, List, Sequence, Union from unittest.mock import Mock import numpy as np import pytest from pydantic import PrivateAttr from typing_extensions import Protocol, runtime_checkable from psygnal import EventedModel, SignalGroup def test_creating_empty_evented_model(): """Test creating an empty evented pydantic model.""" model = EventedModel() assert model is not None assert model.events is not None def test_evented_model(): """Test creating an evented pydantic model.""" class User(EventedModel): id: int name: str = "Alex" age: ClassVar[int] = 100 user = User(id=0) # test basic functionality assert user.id == 0 assert user.name == "Alex" user.id = 2 assert user.id == 2 # test event system assert isinstance(user.events, SignalGroup) assert "id" in user.events.signals assert "name" in user.events.signals # ClassVars are excluded from events assert "age" not in user.events.signals id_mock = Mock() name_mock = Mock() user.events.id.connect(id_mock) user.events.name.connect(name_mock) # setting an attribute should, by default, emit an event with the value user.id = 4 id_mock.assert_called_with(4) name_mock.assert_not_called() # and event should only be emitted when the value has changed. id_mock.reset_mock() user.id = 4 id_mock.assert_not_called() name_mock.assert_not_called() @pytest.mark.skipif(sys.version_info < (3, 8), reason="requires python3.8 or higher") def test_evented_model_array_updates(): """Test updating an evented pydantic model with an array.""" class Model(EventedModel): """Demo evented model.""" values: np.ndarray class Config: arbitrary_types_allowed = True first_values = np.array([1, 2, 3]) model = Model(values=first_values) # Mock events values_mock = Mock() model.events.values.connect(values_mock) np.testing.assert_almost_equal(model.values, first_values) # Updating with new data new_array = np.array([1, 2, 4]) model.values = new_array np.testing.assert_array_equal(values_mock.call_args.args[0], new_array) values_mock.reset_mock() # Updating with same data, no event should be emitted model.values = new_array values_mock.assert_not_called() def test_evented_model_np_array_equality(): """Test checking equality with an evented model with direct numpy.""" class Model(EventedModel): values: np.ndarray class Config: arbitrary_types_allowed = True model1 = Model(values=np.array([1, 2, 3])) model2 = Model(values=np.array([1, 5, 6])) assert model1 == model1 assert model1 != model2 model2.values = np.array([1, 2, 3]) assert model1 == model2 def test_evented_model_da_array_equality(): """Test checking equality with an evented model with direct dask.""" da = pytest.importorskip("dask.array") class Model(EventedModel): values: da.Array class Config: arbitrary_types_allowed = True r = da.ones((64, 64)) model1 = Model(values=r) model2 = Model(values=da.ones((64, 64))) assert model1 == model1 # dask arrays will only evaluate as equal if they are the same object. assert model1 != model2 model2.values = r assert model1 == model2 def test_values_updated(): class User(EventedModel): """Demo evented model. Parameters ---------- id : int User id. name : str, optional User name. """ id: int name: str = "A" age: ClassVar[int] = 100 user1 = User(id=0) user2 = User(id=1, name="K") # Check user1 and user2 dicts assert user1.dict() == {"id": 0, "name": "A"} assert user2.dict() == {"id": 1, "name": "K"} # Add mocks user1_events = Mock() u1_id_events = Mock() u2_id_events = Mock() user1.events.connect(user1_events) user1.events.id.connect(u1_id_events) user2.events.id.connect(u2_id_events) # Update user1 from user2 user1.update(user2) assert user1.dict() == {"id": 1, "name": "K"} u1_id_events.assert_called_with(1) u2_id_events.assert_not_called() assert user1_events.call_count == 2 u1_id_events.reset_mock() u2_id_events.reset_mock() user1_events.reset_mock() # Update user1 from user2 again, no event emission expected user1.update(user2) assert user1.dict() == {"id": 1, "name": "K"} u1_id_events.assert_not_called() u2_id_events.assert_not_called() assert user1_events.call_count == 0 def test_update_with_inner_model_union(): class Inner(EventedModel): w: str class AltInner(EventedModel): x: str class Outer(EventedModel): y: int z: Union[Inner, AltInner] original = Outer(y=1, z=Inner(w="a")) updated = Outer(y=2, z=AltInner(x="b")) original.update(updated, recurse=False) assert original == updated def test_update_with_inner_model_protocol(): @runtime_checkable class InnerProtocol(Protocol): def string(self) -> str: ... # Protocol fields are not successfully set without explicit validation. @classmethod def __get_validators__(cls): yield cls.validate @classmethod def validate(cls, v): return v class Inner(EventedModel): w: str def string(self) -> str: return self.w class AltInner(EventedModel): x: str def string(self) -> str: return self.x class Outer(EventedModel): y: int z: InnerProtocol original = Outer(y=1, z=Inner(w="a")) updated = Outer(y=2, z=AltInner(x="b")) original.update(updated, recurse=False) assert original == updated def test_evented_model_signature(): class T(EventedModel): x: int y: str = "yyy" z = b"zzz" assert isinstance(T.__signature__, inspect.Signature) sig = inspect.signature(T) assert str(sig) == "(*, x: int, y: str = 'yyy', z: bytes = b'zzz') -> None" class MyObj: def __init__(self, a: int, b: str) -> None: self.a = a self.b = b @classmethod def __get_validators__(cls): yield cls.validate_type @classmethod def validate_type(cls, val): # turn a generic dict into object if isinstance(val, dict): a = val.get("a") b = val.get("b") elif isinstance(val, MyObj): return val # perform additional validation here return cls(a, b) def __eq__(self, other): return self.__dict__ == other.__dict__ def _json_encode(self): return self.__dict__ def test_evented_model_serialization(): class Model(EventedModel): """Demo evented model.""" obj: MyObj m = Model(obj=MyObj(1, "hi")) raw = m.json() assert raw == '{"obj": {"a": 1, "b": "hi"}}' deserialized = Model.parse_raw(raw) assert deserialized == m def test_nested_evented_model_serialization(): """Test that encoders on nested sub-models can be used by top model.""" class NestedModel(EventedModel): obj: MyObj class Model(EventedModel): nest: NestedModel m = Model(nest={"obj": {"a": 1, "b": "hi"}}) raw = m.json() assert raw == r'{"nest": {"obj": {"a": 1, "b": "hi"}}}' deserialized = Model.parse_raw(raw) assert deserialized == m def test_evented_model_dask_delayed(): """Test that evented models work with dask delayed objects""" dd = pytest.importorskip("dask.delayed") dask = pytest.importorskip("dask") class MyObject(EventedModel): attribute: dd.Delayed class Config: arbitrary_types_allowed = True @dask.delayed def my_function(): pass o1 = MyObject(attribute=my_function) # check that equality checking works as expected assert o1 == o1 class T(EventedModel): a: int = 1 b: int = 1 @property def c(self) -> List[int]: return [self.a, self.b] @c.setter def c(self, val: Sequence[int]): self.a, self.b = val class Config: allow_property_setters = True guess_property_dependencies = True def test_defaults(): class R(EventedModel): x: str = "hi" default_r = R() class D(EventedModel): a: int = 1 b: int = 1 r: R = default_r d = D() assert d._defaults == {"a": 1, "b": 1, "r": default_r} d.update({"a": 2, "r": {"x": "asdf"}}, recurse=True) assert d.dict() == {"a": 2, "b": 1, "r": {"x": "asdf"}} assert d.dict() != d._defaults d.reset() assert d.dict() == d._defaults def test_enums_as_values(): from enum import Enum class MyEnum(Enum): A = "value" class SomeModel(EventedModel): a: MyEnum = MyEnum.A m = SomeModel() assert m.dict() == {"a": MyEnum.A} with m.enums_as_values(): assert m.dict() == {"a": "value"} assert m.dict() == {"a": MyEnum.A} def test_properties_with_explicit_property_dependencies(): class MyModel(EventedModel): a: int = 1 b: int = 1 @property def c(self) -> List[int]: return [self.a, self.b] @c.setter def c(self, val: Sequence[int]) -> None: self.a, self.b = val class Config: allow_property_setters = True property_dependencies = {"c": ["a", "b"]} assert list(MyModel.__property_setters__) == ["c"] # the metaclass should have figured out that both a and b affect c assert MyModel.__field_dependents__ == {"a": {"c"}, "b": {"c"}} def test_evented_model_with_property_setters(): t = T() assert list(T.__property_setters__) == ["c"] # the metaclass should have figured out that both a and b affect c assert T.__field_dependents__ == {"a": {"c"}, "b": {"c"}} # all the fields and properties behave as expected assert t.c == [1, 1] t.a = 4 assert t.c == [4, 1] t.c = [2, 3] assert t.c == [2, 3] assert t.a == 2 assert t.b == 3 def test_evented_model_with_property_setters_events(): t = T() assert "c" in t.events.signals # the setter has an event mock_a = Mock() mock_b = Mock() mock_c = Mock() t.events.a.connect(mock_a) t.events.b.connect(mock_b) t.events.c.connect(mock_c) # setting t.c emits events for all three a, b, and c t.c = [10, 20] mock_a.assert_called_with(10) mock_b.assert_called_with(20) mock_c.assert_called_with([10, 20]) assert t.a == 10 assert t.b == 20 mock_a.reset_mock() mock_b.reset_mock() mock_c.reset_mock() # setting t.a emits events for a and c, but not b # this is because we declared c to be dependent on ['a', 'b'] t.a = 5 mock_a.assert_called_with(5) mock_c.assert_called_with([5, 20]) mock_b.assert_not_called() assert t.c == [5, 20] def test_non_setter_with_dependencies(): with pytest.raises(ValueError) as e: class M(EventedModel): x: int @property def y(self): ... @y.setter def y(self, v): ... class Config: allow_property_setters = True property_dependencies = {"a": []} assert "Fields with dependencies must be property.setters" in str(e.value) def test_unrecognized_property_dependencies(): with pytest.warns(UserWarning) as e: class M(EventedModel): x: int @property def y(self): ... @y.setter def y(self, v): ... class Config: allow_property_setters = True property_dependencies = {"y": ["b"]} assert "Unrecognized field dependency: 'b'" in str(e[0]) def test_setattr_before_init(): class M(EventedModel): _x: int = PrivateAttr() def __init__(_model_self_, x: int, **data) -> None: _model_self_._x = x super().__init__(**data) @property def x(self) -> int: return self._x m = M(x=2) assert m.x == 2 def test_setter_inheritance(): class M(EventedModel): _x: int = PrivateAttr() def __init__(self, x: int, **data) -> None: self.x = x super().__init__(**data) @property def x(self) -> int: return self._x @x.setter def x(self, v: int) -> None: self._x = v class Config: allow_property_setters = True assert M(x=2).x == 2 class N(M): ... assert N(x=2).x == 2 with pytest.raises(ValueError, match="Cannot set 'allow_property_setters' to"): class Bad(M): class Config: allow_property_setters = False psygnal-0.9.1/tests/test_group.py0000644000000000000000000001150613615410400014030 0ustar00from unittest.mock import Mock, call import pytest from typing_extensions import Annotated from psygnal import EmissionInfo, Signal, SignalGroup class MyGroup(SignalGroup): sig1 = Signal(int) sig2 = Signal(str) def test_signal_group(): assert not MyGroup.is_uniform() group = MyGroup() assert not group.is_uniform() assert isinstance(group.signals, dict) assert group.signals == {"sig1": group.sig1, "sig2": group.sig2} assert repr(group) == "" def test_uniform_group(): """In a uniform group, all signals must have the same signature.""" class MyStrictGroup(SignalGroup, strict=True): sig1 = Signal(int) sig2 = Signal(int) assert MyStrictGroup.is_uniform() group = MyStrictGroup() assert group.is_uniform() assert isinstance(group.signals, dict) assert set(group.signals) == {"sig1", "sig2"} with pytest.raises(TypeError) as e: class BadGroup(SignalGroup, strict=True): sig1 = Signal(str) sig2 = Signal(int) assert str(e.value).startswith("All Signals in a strict SignalGroup must") def test_nonhashable_args(): """Test that non-hashable annotations are allowed in a SignalGroup""" class MyGroup(SignalGroup): sig1 = Signal(Annotated[int, {"a": 1}]) # type: ignore sig2 = Signal(Annotated[float, {"b": 1}]) # type: ignore assert not MyGroup.is_uniform() with pytest.raises(TypeError): class MyGroup2(SignalGroup, strict=True): sig1 = Signal(Annotated[int, {"a": 1}]) # type: ignore sig2 = Signal(Annotated[float, {"b": 1}]) # type: ignore @pytest.mark.parametrize("direct", [True, False]) def test_signal_group_connect(direct: bool): mock = Mock() group = MyGroup() if direct: # the callback wants the emitted arguments directly group.connect_direct(mock) else: # the callback will receive an EmissionInfo tuple # (SignalInstance, arg_tuple) group.connect(mock) group.sig1.emit(1) group.sig2.emit("hi") assert mock.call_count == 2 # if connect_with_info was used, the callback will be given an EmissionInfo # tuple that contains the args as well as the signal instance used if direct: expected_calls = [call(1), call("hi")] else: expected_calls = [ call(EmissionInfo(group.sig1, (1,))), call(EmissionInfo(group.sig2, ("hi",))), ] mock.assert_has_calls(expected_calls) def test_signal_group_connect_no_args(): """Test that group.connect can take a callback that wants no args""" group = MyGroup() count = [] def my_slot() -> None: count.append(1) group.connect(my_slot) group.sig1.emit(1) group.sig2.emit("hi") assert len(count) == 2 def test_group_blocked(): group = MyGroup() mock1 = Mock() mock2 = Mock() group.connect(mock1) group.sig1.connect(mock2) group.sig1.emit(1) mock1.assert_called_once_with(EmissionInfo(group.sig1, (1,))) mock2.assert_called_once_with(1) mock1.reset_mock() mock2.reset_mock() group.sig2.block() assert group.sig2._is_blocked with group.blocked(): group.sig1.emit(1) assert group.sig1._is_blocked assert not group.sig1._is_blocked # the blocker should have restored subthings to their previous states assert group.sig2._is_blocked mock1.assert_not_called() mock2.assert_not_called() def test_group_blocked_exclude(): """Test that we can exempt certain signals from being blocked.""" group = MyGroup() mock1 = Mock() mock2 = Mock() group.sig1.connect(mock1) group.sig2.connect(mock2) with group.blocked(exclude=("sig2",)): group.sig1.emit(1) group.sig2.emit("hi") mock1.assert_not_called() mock2.assert_called_once_with("hi") def test_group_disconnect_single_slot(): """Test that we can disconnect single slots from groups.""" group = MyGroup() mock1 = Mock() mock2 = Mock() group.sig1.connect(mock1) group.sig2.connect(mock2) group.disconnect(mock1) group.sig1.emit() mock1.assert_not_called() group.sig2.emit() mock2.assert_called_once() def test_group_disconnect_all_slots(): """Test that we can disconnect all slots from groups.""" group = MyGroup() mock1 = Mock() mock2 = Mock() group.sig1.connect(mock1) group.sig2.connect(mock2) group.disconnect() group.sig1.emit() group.sig2.emit() mock1.assert_not_called() mock2.assert_not_called() def test_weakref(): """Make sure that the group doesn't keep a strong reference to the instance.""" import gc class T: ... obj = T() group = MyGroup(obj) assert group.instance is obj del obj gc.collect() assert group.instance is None psygnal-0.9.1/tests/test_group_descriptor.py0000644000000000000000000001172613615410400016272 0ustar00from dataclasses import dataclass from typing import Any, ClassVar from unittest.mock import Mock, patch import pytest from psygnal import SignalGroupDescriptor, _compiled, _group_descriptor @pytest.mark.parametrize("type_", ["dataclass", "pydantic", "attrs", "msgspec"]) def test_descriptor_inherits(type_: str) -> None: if type_ == "dataclass": from dataclasses import dataclass @dataclass class Base: a: int events: ClassVar = SignalGroupDescriptor() @dataclass class Foo(Base): b: str @dataclass class Bar(Foo): c: float elif type_ == "pydantic": from pydantic import BaseModel class Base(BaseModel): a: int events: ClassVar = SignalGroupDescriptor() class Foo(Base): b: str class Bar(Foo): c: float elif type_ == "attrs": from attrs import define @define class Base: a: int events: ClassVar = SignalGroupDescriptor() @define class Foo(Base): b: str @define class Bar(Foo): c: float elif type_ == "msgspec": msgspec = pytest.importorskip("msgspec") class Base(msgspec.Struct): # type: ignore a: int events: ClassVar = SignalGroupDescriptor() class Foo(Base): b: str class Bar(Foo): c: float assert Bar.events is Base.events with patch.object( _group_descriptor, "evented_setattr", wraps=_group_descriptor.evented_setattr ) as mock_decorator: base = Base(a=1) foo = Foo(a=1, b="2") bar = Bar(a=1, b="2", c=3.0) bar2 = Bar(a=1, b="2", c=3.0) # the patching of __setattr__ should only happen once # and it will happen only on the first access of .events mock_decorator.assert_not_called() assert set(base.events.signals) == {"a"} assert set(foo.events.signals) == {"a", "b"} assert set(bar.events.signals) == {"a", "b", "c"} assert set(bar2.events.signals) == {"a", "b", "c"} if not _compiled: # can't patch otherwise assert mock_decorator.call_count == 1 mock = Mock() foo.events.a.connect(mock) # base doesn't affect subclass base.events.a.emit(1) mock.assert_not_called() # subclass doesn't affect superclass bar.events.a.emit(1) mock.assert_not_called() foo.events.a.emit(1) mock.assert_called_once_with(1) @pytest.mark.parametrize("patch_setattr", [True, False]) def test_no_patching(patch_setattr: bool) -> None: """Test patch_setattr=False doesn't patch the class""" # sourcery skip: extract-duplicate-method @dataclass class Foo: a: int _events: ClassVar = SignalGroupDescriptor(patch_setattr=patch_setattr) with patch.object( _group_descriptor, "evented_setattr", wraps=_group_descriptor.evented_setattr ) as mock_decorator: foo = Foo(a=1) _ = foo._events if not _compiled: # can't patch otherwise assert mock_decorator.call_count == int(patch_setattr) assert _group_descriptor.is_evented(Foo.__setattr__) == patch_setattr mock = Mock() foo._events.a.connect(mock) foo.a = 2 if patch_setattr: mock.assert_called_once_with(2) else: mock.assert_not_called() def test_direct_patching() -> None: """Test directly using evented_setattr on a class""" mock1 = Mock() @dataclass class Foo: a: int _events: ClassVar = SignalGroupDescriptor(patch_setattr=False) @_group_descriptor.evented_setattr("_events") def __setattr__(self, __name: str, __value: Any) -> None: mock1(__name, __value) super().__setattr__(__name, __value) assert _group_descriptor.is_evented(Foo.__setattr__) # patch again ... this should NOT cause a double event emission. Foo.__setattr__ = _group_descriptor.evented_setattr("_events", Foo.__setattr__) foo = Foo(a=1) mock = Mock() foo._events.a.connect(mock) foo.a = 2 mock.assert_called_once_with(2) # confirm no double event emission mock1.assert_called_with("a", 2) def test_no_getattr_on_non_evented_fields() -> None: """Make sure that we're not accidentally calling getattr on non-evented fields.""" a_mock = Mock() b_mock = Mock() @dataclass class Foo: a: int events: ClassVar = SignalGroupDescriptor() @property def b(self) -> int: b_mock(self._b) return self._b @b.setter def b(self, value: int) -> None: self._b = value foo = Foo(a=1) foo.events.a.connect(a_mock) foo.a = 2 a_mock.assert_called_once_with(2) foo.b = 1 b_mock.assert_not_called() # getter shouldn't have been called assert foo.b == 1 b_mock.assert_called_once_with(1) # getter should have been called only once psygnal-0.9.1/tests/test_psygnal.py0000644000000000000000000006231013615410400014350 0ustar00import gc import sys import time from contextlib import suppress from functools import partial, wraps from inspect import Signature from typing import Optional from unittest.mock import MagicMock, Mock, call import pytest import toolz from typing_extensions import Literal from psygnal import EmitLoopError, Signal, SignalInstance, _compiled from psygnal._weak_callback import WeakCallback def stupid_decorator(fun): def _fun(*args): fun(*args) _fun.__annotations__ = fun.__annotations__ _fun.__name__ = "f_no_arg" return _fun def good_decorator(fun): @wraps(fun) def _fun(*args): fun(*args) return _fun # fmt: off class Emitter: no_arg = Signal() one_int = Signal(int) two_int = Signal(int, int) str_int = Signal(str, int) no_check = Signal(str, check_nargs_on_connect=False, check_types_on_connect=False) class MyObj: def f_no_arg(self): ... def f_str_int_vararg(self, a: str, b: int, *c): ... def f_str_int_any(self, a: str, b: int, c): ... def f_str_int_kwarg(self, a: str, b: int, c=None): ... def f_str_int(self, a: str, b: int): ... def f_str_any(self, a: str, b): ... def f_str(self, a: str): ... def f_int(self, a: int): ... def f_any(self, a): ... def f_int_int(self, a: int, b: int): ... def f_str_str(self, a: str, b: str): ... def f_arg_kwarg(self, a, b=None): ... def f_vararg(self, *a): ... def f_vararg_varkwarg(self, *a, **b): ... def f_vararg_kwarg(self, *a, b=None): ... @stupid_decorator def f_int_decorated_stupid(self, a: int): ... @good_decorator def f_int_decorated_good(self, a: int): ... f_any_assigned = lambda self, a: None # noqa x: int = 0 def __setitem__(self, key: str, value: int): if key == "x": self.x = value def f_no_arg(): ... def f_str_int_vararg(a: str, b: int, *c): ... def f_str_int_any(a: str, b: int, c): ... def f_str_int_kwarg(a: str, b: int, c=None): ... def f_str_int(a: str, b: int): ... def f_str_any(a: str, b): ... def f_str(a: str): ... def f_int(a: int): ... def f_any(a): ... def f_int_int(a: int, b: int): ... def f_str_str(a: str, b: str): ... def f_arg_kwarg(a, b=None): ... def f_vararg(*a): ... def f_vararg_varkwarg(*a, **b): ... def f_vararg_kwarg(*a, b=None): ... class MyReceiver: expect_signal = None expect_sender = None expect_name = None def assert_sender(self, *a): assert Signal.current_emitter() is self.expect_signal assert self.expect_name in repr(Signal.current_emitter()) assert Signal.current_emitter().instance is self.expect_sender assert Signal.sender() is self.expect_sender assert Signal.current_emitter()._name is self.expect_name def assert_not_sender(self, *a): # just to make sure we're actually calling it assert Signal.current_emitter().instance is not self.expect_sender # fmt: on def test_basic_signal(): """standard Qt usage, as class attribute""" emitter = Emitter() mock = MagicMock() emitter.one_int.connect(mock) emitter.one_int.emit(1) mock.assert_called_once_with(1) mock.reset_mock() # calling directly also works emitter.one_int(1) mock.assert_called_once_with(1) def test_decorator(): emitter = Emitter() err = ValueError() @emitter.one_int.connect def boom(v: int): raise err @emitter.one_int.connect(check_nargs=False) def bad_cb(a, b, c): ... with pytest.raises(EmitLoopError) as e: emitter.one_int.emit(1) assert e.value.__cause__ is err assert e.value.__context__ is err def test_misc(): emitter = Emitter() assert isinstance(Emitter.one_int, Signal) assert isinstance(emitter.one_int, SignalInstance) with pytest.raises(AttributeError): _ = emitter.one_int.asdf with pytest.raises(AttributeError): _ = emitter.one_int.asdf def test_getattr(): s = Signal() with pytest.raises(AttributeError): _ = s.not_a_thing def test_signature_provided(): s = Signal(Signature()) assert s.signature == Signature() with pytest.warns(UserWarning): s = Signal(Signature(), 1) def test_emit_checks(): emitter = Emitter() emitter.one_int.emit(check_nargs=False) emitter.one_int.emit() with pytest.raises(TypeError): emitter.one_int.emit(check_nargs=True) emitter.one_int.emit(1) emitter.one_int.emit(1, 2, check_nargs=False) emitter.one_int.emit(1, 2) with pytest.raises(TypeError): emitter.one_int.emit(1, 2, check_nargs=True) with pytest.raises(TypeError): emitter.one_int.emit("sdr", check_types=True) emitter.one_int.emit("sdr", check_types=False) def test_basic_signal_blocked(): """standard Qt usage, as class attribute""" emitter = Emitter() mock = MagicMock() emitter.one_int.connect(mock) emitter.one_int.emit(1) mock.assert_called_once_with(1) mock.reset_mock() with emitter.one_int.blocked(): emitter.one_int.emit(1) mock.assert_not_called() def test_nested_signal_blocked(): """unblock signal on exit of the last context""" emitter = Emitter() mock = MagicMock() emitter.one_int.connect(mock) mock.reset_mock() with emitter.one_int.blocked(): with emitter.one_int.blocked(): emitter.one_int.emit(1) emitter.one_int.emit(2) emitter.one_int.emit(3) mock.assert_called_once_with(3) @pytest.mark.parametrize("thread", [None, "main"]) def test_disconnect(thread: Literal[None, "main"]) -> None: emitter = Emitter() mock = MagicMock() with pytest.raises(ValueError) as e: emitter.one_int.disconnect(mock, missing_ok=False) assert "slot is not connected" in str(e) emitter.one_int.disconnect(mock) emitter.one_int.connect(mock, thread=thread) assert len(emitter.one_int) == 1 if thread is None: emitter.one_int.emit(1) mock.assert_called_once_with(1) mock.reset_mock() emitter.one_int.disconnect(mock) emitter.one_int.emit(1) mock.assert_not_called() assert len(emitter.one_int) == 0 @pytest.mark.parametrize( "type_", [ "function", "lambda", "method", "partial_method", "toolz_function", "toolz_method", "partial_method_kwarg", "partial_method_kwarg_bad", "setattr", "setitem", ], ) def test_slot_types(type_: str) -> None: emitter = Emitter() signal = emitter.one_int assert len(signal) == 0 obj = MyObj() if type_ == "setattr": signal.connect_setattr(obj, "x") elif type_ == "setitem": signal.connect_setitem(obj, "x") elif type_ == "function": signal.connect(f_int) elif type_ == "lambda": signal.connect(lambda x: None) elif type_ == "method": signal.connect(obj.f_int) elif type_ == "partial_method": signal.connect(partial(obj.f_int_int, 2)) elif type_ == "toolz_function": signal.connect(toolz.curry(f_int_int, 2)) elif type_ == "toolz_method": signal.connect(toolz.curry(obj.f_int_int, 2)) elif type_ == "partial_method_kwarg": signal.connect(partial(obj.f_int_int, b=2)) elif type_ == "partial_method_kwarg_bad": with pytest.raises(ValueError, match=".*prefer using positional args"): signal.connect(partial(obj.f_int_int, a=2)) return assert len(signal) == 1 stored_slot = signal._slots[-1] assert isinstance(stored_slot, WeakCallback) assert stored_slot == stored_slot with pytest.raises(TypeError): emitter.one_int.connect("not a callable") # type: ignore def test_basic_signal_with_sender_receiver(): """standard Qt usage, as class attribute""" emitter = Emitter() receiver = MyReceiver() receiver.expect_sender = emitter receiver.expect_signal = emitter.one_int receiver.expect_name = "one_int" assert Signal.current_emitter() is None emitter.one_int.connect(receiver.assert_sender) emitter.one_int.emit(1) # back to none after the call is over. assert Signal.current_emitter() is None emitter.one_int.disconnect() # sanity check... to make sure that methods are in fact being called. emitter.one_int.connect(receiver.assert_not_sender) with pytest.raises(EmitLoopError) as e: emitter.one_int.emit(1) assert isinstance(e.value.__cause__, AssertionError) assert isinstance(e.value.__context__, AssertionError) def test_basic_signal_with_sender_nonreceiver(): """standard Qt usage, as class attribute""" emitter = Emitter() nr = MyObj() emitter.one_int.connect(nr.f_no_arg) emitter.one_int.connect(nr.f_int) emitter.one_int.connect(nr.f_vararg_varkwarg) emitter.one_int.emit(1) # emitter.one_int.connect(nr.two_int) def test_signal_instance(): """make a signal instance without a class""" signal = SignalInstance((int,)) mock = MagicMock() signal.connect(mock) signal.emit(1) mock.assert_called_once_with(1) signal = SignalInstance() mock = MagicMock() signal.connect(mock) signal.emit() mock.assert_called_once_with() @pytest.mark.parametrize( "slot", [ "f_no_arg", "f_int_decorated_stupid", "f_int_decorated_good", "f_any_assigned", "partial", "toolz_curry", ], ) def test_weakref(slot): """Test that a connected method doesn't hold strong ref.""" emitter = Emitter() obj = MyObj() assert len(emitter.one_int) == 0 if slot == "partial": emitter.one_int.connect(partial(obj.f_int_int, 1)) elif slot == "toolz_curry": emitter.one_int.connect(toolz.curry(obj.f_int_int, 1)) else: emitter.one_int.connect(getattr(obj, slot)) assert len(emitter.one_int) == 1 emitter.one_int.emit(1) assert len(emitter.one_int) == 1 del obj gc.collect() emitter.one_int.emit(1) # this should trigger deletion assert len(emitter.one_int) == 0 @pytest.mark.parametrize( "slot", [ "f_no_arg", "f_int_decorated_stupid", "f_int_decorated_good", "f_any_assigned", "partial", ], ) def test_group_weakref(slot): """Test that a connected method doesn't hold strong ref.""" from psygnal import SignalGroup class MyGroup(SignalGroup): sig1 = Signal(int) emitter = MyGroup() obj = MyObj() # simply by nature of being in a group, sig1 will have a callback assert len(emitter.sig1) == 1 # but the group itself doesn't have any assert len(emitter) == 0 # connecting something to the group adds to the group connections emitter.connect( partial(obj.f_int_int, 1) if slot == "partial" else getattr(obj, slot) ) assert len(emitter.sig1) == 1 assert len(emitter) == 1 emitter.sig1.emit(1) assert len(emitter.sig1) == 1 del obj gc.collect() emitter.sig1.emit(1) # this should trigger deletion, so would emitter.emit() assert len(emitter.sig1) == 1 assert len(emitter) == 0 # it's been cleaned up # def test_norm_slot(): # r = MyObj() # normed1 = _normalize_slot(r.f_any) # normed2 = _normalize_slot(normed1) # normed3 = _normalize_slot((r, "f_any", None)) # normed4 = _normalize_slot((weakref.ref(r), "f_any", None)) # assert normed1 == (weakref.ref(r), "f_any", None) # assert normed1 == normed2 == normed3 == normed4 # assert _normalize_slot(f_any) == f_any ALL = {n for n, f in locals().items() if callable(f) and n.startswith("f_")} COUNT_INCOMPATIBLE = { "no_arg": ALL - {"f_no_arg", "f_vararg", "f_vararg_varkwarg", "f_vararg_kwarg"}, "one_int": { "f_int_int", "f_str_any", "f_str_int_any", "f_str_int_kwarg", "f_str_int_vararg", "f_str_int", "f_str_str", }, "str_int": {"f_str_int_any"}, } SIG_INCOMPATIBLE = { "no_arg": {"f_int_int", "f_int", "f_str_int_any", "f_str_str"}, "one_int": { "f_int_int", "f_str_int_any", "f_str_int_vararg", "f_str_str", "f_str_str", "f_str", }, "str_int": {"f_int_int", "f_int", "f_str_int_any", "f_str_str"}, } @pytest.mark.parametrize("typed", ["typed", "untyped"]) @pytest.mark.parametrize("func_name", ALL) @pytest.mark.parametrize("sig_name", ["no_arg", "one_int", "str_int"]) @pytest.mark.parametrize("mode", ["func", "meth", "partial"]) def test_connect_validation(func_name, sig_name, mode, typed): from functools import partial if mode == "meth": func = getattr(MyObj(), func_name) elif mode == "partial": func = partial(globals()[func_name]) else: func = globals()[func_name] e = Emitter() check_types = typed == "typed" signal: SignalInstance = getattr(e, sig_name) bad_count = COUNT_INCOMPATIBLE[sig_name] bad_sig = SIG_INCOMPATIBLE[sig_name] if func_name in bad_count or check_types and func_name in bad_sig: with pytest.raises(ValueError) as er: signal.connect(func, check_types=check_types) assert "Accepted signature:" in str(er) return signal.connect(func, check_types=check_types) args = (p.annotation() for p in signal.signature.parameters.values()) signal.emit(*args) def test_connect_lambdas(): e = Emitter() assert len(e.two_int._slots) == 0 e.two_int.connect(lambda: None) e.two_int.connect(lambda x: None) assert len(e.two_int._slots) == 2 e.two_int.connect(lambda x, y: None) e.two_int.connect(lambda x, y, z=None: None) assert len(e.two_int._slots) == 4 e.two_int.connect(lambda x, y, *z: None) e.two_int.connect(lambda *z: None) assert len(e.two_int._slots) == 6 e.two_int.connect(lambda *z, **k: None) assert len(e.two_int._slots) == 7 with pytest.raises(ValueError): e.two_int.connect(lambda x, y, z: None) def test_mock_connect(): e = Emitter() e.one_int.connect(MagicMock()) # fmt: off class TypeA: ... class TypeB(TypeA): ... class TypeC(TypeB): ... class Rcv: def methodA(self, obj: TypeA): ... def methodA_ref(self, obj: 'TypeA'): ... def methodB(self, obj: TypeB): ... def methodB_ref(self, obj: 'TypeB'): ... def methodOptB(self, obj: Optional[TypeB]): ... def methodOptB_ref(self, obj: 'Optional[TypeB]'): ... def methodC(self, obj: TypeC): ... def methodC_ref(self, obj: 'TypeC'): ... class Emt: signal = Signal(TypeB) # fmt: on def test_forward_refs_type_checking(): e = Emt() r = Rcv() e.signal.connect(r.methodB, check_types=True) e.signal.connect(r.methodB_ref, check_types=True) e.signal.connect(r.methodOptB, check_types=True) e.signal.connect(r.methodOptB_ref, check_types=True) e.signal.connect(r.methodC, check_types=True) e.signal.connect(r.methodC_ref, check_types=True) # signal is emitting a TypeB, but method is expecting a typeA assert not issubclass(TypeA, TypeB) # typeA is not a TypeB, so we get an error with pytest.raises(ValueError): e.signal.connect(r.methodA, check_types=True) with pytest.raises(ValueError): e.signal.connect(r.methodA_ref, check_types=True) def test_checking_off(): e = Emitter() # the no_check signal was instantiated with check_[nargs/types] = False @e.no_check.connect def bad_in_many_ways(x: int, y, z): ... def test_keyword_only_not_allowed(): e = Emitter() def f(a: int, *, b: int): ... with pytest.raises(ValueError) as er: e.two_int.connect(f) assert "Unsupported KEYWORD_ONLY parameters in signature" in str(er) def test_unique_connections(): e = Emitter() assert len(e.one_int._slots) == 0 e.one_int.connect(f_no_arg, unique=True) assert len(e.one_int._slots) == 1 e.one_int.connect(f_no_arg, unique=True) assert len(e.one_int._slots) == 1 with pytest.raises(ValueError): e.one_int.connect(f_no_arg, unique="raise") assert len(e.one_int._slots) == 1 e.one_int.connect(f_no_arg) assert len(e.one_int._slots) == 2 @pytest.mark.skipif(_compiled, reason="passes, but segfaults on exit") def test_asynchronous_emit(): e = Emitter() a = [] def slow_append(arg: int): time.sleep(0.1) a.append(arg) mock = MagicMock(wraps=slow_append) e.no_arg.connect(mock, unique=False) assert not Signal.current_emitter() value = 42 with pytest.warns(FutureWarning): thread = e.no_arg.emit(value, asynchronous=True) mock.assert_called_once() assert Signal.current_emitter() is e.no_arg # dude, you have to wait. assert not a if thread: thread.join() assert a == [value] assert not Signal.current_emitter() def test_sig_unavailable(): """In some cases, signature.inspect() fails on a callable, (many builtins). We should still connect, but with a warning. """ e = Emitter() e.one_int.connect(vars, check_nargs=False) # no warning with pytest.warns(UserWarning): e.one_int.connect(vars) # we've special cased print... due to frequency of use. e.one_int.connect(print) # no warning def test_pause(): """Test that we can pause, and resume emission of (possibly reduced) args.""" emitter = Emitter() mock = MagicMock() emitter.one_int.connect(mock) emitter.one_int.emit(1) mock.assert_called_once_with(1) mock.reset_mock() emitter.one_int.pause() emitter.one_int.emit(1) emitter.one_int.emit(2) emitter.one_int.emit(3) mock.assert_not_called() emitter.one_int.resume() mock.assert_has_calls([call(1), call(2), call(3)]) mock.reset_mock() with emitter.one_int.paused(lambda a, b: (a[0].union(set(b)),), (set(),)): emitter.one_int.emit(1) emitter.one_int.emit(2) emitter.one_int.emit(3) mock.assert_called_once_with({1, 2, 3}) mock.reset_mock() emitter.one_int.pause() emitter.one_int.resume() mock.assert_not_called() def test_resume_with_initial(): emitter = Emitter() mock = MagicMock() emitter.one_int.connect(mock) with emitter.one_int.paused(lambda a, b: (a[0] + b[0],)): emitter.one_int.emit(1) emitter.one_int.emit(2) emitter.one_int.emit(3) mock.assert_called_once_with(6) mock.reset_mock() with emitter.one_int.paused(lambda a, b: (a[0] + b[0],), (20,)): emitter.one_int.emit(1) emitter.one_int.emit(2) emitter.one_int.emit(3) mock.assert_called_once_with(26) def test_nested_pause(): emitter = Emitter() mock = MagicMock() emitter.one_int.connect(mock) with emitter.one_int.paused(): emitter.one_int.emit(1) emitter.one_int.emit(2) with emitter.one_int.paused(): emitter.one_int.emit(3) emitter.one_int.emit(4) emitter.one_int.emit(5) mock.assert_has_calls([call(i) for i in (1, 2, 3, 4, 5)]) def test_signals_on_unhashables(): class Emitter(dict): signal = Signal(int) e = Emitter() e.signal.connect(lambda x: print(x)) e.signal.emit(1) def test_property_connect(): class A: def __init__(self): self.li = [] @property def x(self): return self.li @x.setter def x(self, value): self.li.append(value) a = A() emitter = Emitter() emitter.one_int.connect_setattr(a, "x") assert len(emitter.one_int) == 1 emitter.two_int.connect_setattr(a, "x") assert len(emitter.two_int) == 1 emitter.one_int.emit(1) assert a.li == [1] emitter.two_int.emit(1, 1) assert a.li == [1, (1, 1)] emitter.two_int.disconnect_setattr(a, "x") assert len(emitter.two_int) == 0 with pytest.raises(ValueError): emitter.two_int.disconnect_setattr(a, "x", missing_ok=False) emitter.two_int.disconnect_setattr(a, "x") s = emitter.two_int.connect_setattr(a, "x", maxargs=1) emitter.two_int.emit(2, 3) assert a.li == [1, (1, 1), 2] emitter.two_int.disconnect(s, missing_ok=False) with pytest.raises(AttributeError): emitter.one_int.connect_setattr(a, "y") def test_connect_setitem(): class T: sig = Signal(int) class SupportsItem: def __init__(self) -> None: self._dict = {} def __setitem__(self, key, value): self._dict[key] = value t = T() my_obj = SupportsItem() t.sig.connect_setitem(my_obj, "x") t.sig.emit(5) assert my_obj._dict == {"x": 5} t.sig.disconnect_setitem(my_obj, "x") t.sig.emit(7) assert my_obj._dict == {"x": 5} obj = object() with pytest.raises(TypeError, match="does not support __setitem__"): t.sig.connect_setitem(obj, "x") with pytest.raises(TypeError): t.sig.disconnect_setitem(obj, "x", missing_ok=False) def test_repr_not_used(): """Test that we don't use repr() or __call__ to check signature.""" mock = MagicMock() class T: def __repr__(self): mock() return "" def __call__(self): mock() t = T() sig = SignalInstance() sig.connect(t) mock.assert_not_called() # b.signal2.emit will warn that compiled SignalInstances cannot be weakly referenced @pytest.mark.filterwarnings("ignore:failed to create weakref:UserWarning") def test_signal_emit_as_slot(): class A: signal1 = Signal(int) class B: signal2 = Signal(int) mock = Mock() a = A() b = B() a.signal1.connect(b.signal2.emit) b.signal2.connect(mock) a.signal1.emit(1) mock.assert_called_once_with(1) mock.reset_mock() a.signal1.disconnect(b.signal2.emit) a.signal1.connect(b.signal2) # you can also just connect the signal instance a.signal1.emit(2) mock.assert_called_once_with(2) def test_emit_loop_exceptions(): emitter = Emitter() mock1 = Mock(side_effect=ValueError("Bad callback!")) mock2 = Mock() emitter.one_int.connect(mock1) emitter.one_int.connect(mock2) with pytest.raises(EmitLoopError): emitter.one_int.emit(1) mock1.assert_called_once_with(1) mock1.reset_mock() mock2.assert_not_called() with suppress(EmitLoopError): emitter.one_int.emit(2) mock1.assert_called_once_with(2) mock1.assert_called_once_with(2) # def test_partial_weakref(): # """Test that a connected method doesn't hold strong ref.""" # obj = MyObj() # cb = partial(obj.f_int_int, 1) # assert _partial_weakref(cb) == _partial_weakref(cb) @pytest.mark.parametrize( "slot", [ "f_no_arg", "f_int_decorated_stupid", "f_int_decorated_good", "f_any_assigned", "partial", "partial_kwargs", pytest.param( "partial", marks=pytest.mark.xfail( sys.version_info < (3, 8), reason="no idea why this fails on 3.7" ), ), ], ) def test_weakref_disconnect(slot): """Test that a connected method doesn't hold strong ref.""" emitter = Emitter() obj = MyObj() assert len(emitter.one_int) == 0 if slot == "partial": cb = partial(obj.f_int_int, 1) elif slot == "partial_kwargs": cb = partial(obj.f_int_int, b=1) else: cb = getattr(obj, slot) emitter.one_int.connect(cb) assert len(emitter.one_int) == 1 emitter.one_int.emit(1) assert len(emitter.one_int) == 1 emitter.one_int.disconnect(cb) assert len(emitter.one_int) == 0 def test_queued_connections(): from threading import Thread, current_thread from psygnal import emit_queued this_thread = current_thread() emitter = Emitter() # function to run in another thread def _run(): emit_queued() emitter.one_int.emit(2) other_thread = Thread(target=_run) this_thread_mock = Mock() other_thread_mock = Mock() any_thread_mock = Mock() # mock1 wants to be called in this thread @emitter.one_int.connect(thread=this_thread) def cb1(arg): this_thread_mock(arg, current_thread()) # mock2 wants to be called in other_thread @emitter.one_int.connect(thread=other_thread) def cb2(arg): other_thread_mock(arg, current_thread()) # mock3 wants to be called in whatever thread the emitter is in @emitter.one_int.connect def cb3(arg): any_thread_mock(arg, current_thread()) # emit in this thread emitter.one_int.emit(1) this_thread_mock.assert_called_once_with(1, this_thread) # other_thread_mock not called because it's waiting for other_thread other_thread_mock.assert_not_called() # any_thread_mock called because it's waiting for any thread any_thread_mock.assert_called_once_with(1, this_thread) # Now we run `_run` in other_thread this_thread_mock.reset_mock() any_thread_mock.reset_mock() other_thread.start() other_thread.join() # now mock2 should be called TWICE. Once for the .emit(1) queued from this thread, # and once for the .emit(2) in other_thread other_thread_mock.assert_has_calls([call(1, other_thread), call(2, other_thread)]) # stuff queued for any_thread_mock should have also been called any_thread_mock.assert_called_once_with(2, other_thread) # stuff queued for this thread should NOT have been called this_thread_mock.assert_not_called() # ... until we call emit_queued() from this thread emit_queued() this_thread_mock.assert_called_once_with(2, this_thread) psygnal-0.9.1/tests/test_pyinstaller_hook.py0000644000000000000000000000265613615410400016270 0ustar00import importlib.util import os import subprocess import warnings from pathlib import Path import pytest import psygnal def test_hook_content(): spec = importlib.util.spec_from_file_location( "hook", os.path.join( os.path.dirname(psygnal.__file__), "_pyinstaller_util", "hook-psygnal.py" ), ) hook = importlib.util.module_from_spec(spec) spec.loader.exec_module(hook) assert "mypy_extensions" in hook.hiddenimports if not psygnal._compiled: return assert "psygnal._dataclass_utils" in hook.hiddenimports def test_pyintstaller_hiddenimports(tmp_path: Path) -> None: with warnings.catch_warnings(): warnings.simplefilter("ignore") pyi_main = pytest.importorskip("PyInstaller.__main__") build_path = tmp_path / "build" dist_path = tmp_path / "dist" app_name = "psygnal_test" app = tmp_path / f"{app_name}.py" app.write_text("\n".join(["import psygnal", "print(psygnal.__version__)"])) args = [ # Place all generated files in ``tmp_path``. "--workpath", str(build_path), "--distpath", str(dist_path), "--specpath", str(tmp_path), str(app), ] with warnings.catch_warnings(): warnings.simplefilter("ignore") # silence warnings about deprecations pyi_main.run(args) subprocess.run([str(dist_path / app_name / app_name)], check=True) psygnal-0.9.1/tests/test_qt_compat.py0000644000000000000000000001021613615410400014660 0ustar00"""qtbot should work for testing!""" from threading import Thread, current_thread, main_thread from typing import TYPE_CHECKING, Any, Callable, Tuple from unittest.mock import Mock import pytest from typing_extensions import Literal from psygnal import Signal from psygnal._signal import _guess_qtsignal_signature pytest.importorskip("pytestqt") if TYPE_CHECKING: from pytestqt.qtbot import QtBot def _equals(*val: Any) -> Callable[[Tuple[Any, ...]], bool]: def _inner(*other: Any) -> bool: return other == val return _inner def test_wait_signals(qtbot: "QtBot") -> None: class Emitter: sig1 = Signal() sig2 = Signal(int) sig3 = Signal(int, int) e = Emitter() with qtbot.waitSignal(e.sig2, check_params_cb=_equals(1)): e.sig2.emit(1) with qtbot.waitSignal(e.sig3, check_params_cb=_equals(2, 3)): e.sig3.emit(2, 3) with qtbot.waitSignals([e.sig3], check_params_cbs=[_equals(2, 3)]): e.sig3.emit(2, 3) signals = [e.sig1, e.sig2, e.sig3, e.sig1] checks = [_equals(), _equals(1), _equals(2, 3), _equals()] with qtbot.waitSignals(signals, check_params_cbs=checks, order="strict"): e.sig1.emit() e.sig2.emit(1) e.sig3.emit(2, 3) e.sig1.emit() def test_guess_signal_sig(qtbot: "QtBot") -> None: from qtpy import QtCore class QtObject(QtCore.QObject): qsig1 = QtCore.Signal() qsig2 = QtCore.Signal(int) qsig3 = QtCore.Signal(int, str) q_obj = QtObject() assert "qsig1()" in _guess_qtsignal_signature(q_obj.qsig1) assert "qsig1()" in _guess_qtsignal_signature(q_obj.qsig1.emit) assert "qsig2(int)" in _guess_qtsignal_signature(q_obj.qsig2) assert "qsig2(int)" in _guess_qtsignal_signature(q_obj.qsig2.emit) assert "qsig3(int,QString)" in _guess_qtsignal_signature(q_obj.qsig3) assert "qsig3(int,QString)" in _guess_qtsignal_signature(q_obj.qsig3.emit) def test_connect_qt_signal_instance(qtbot: "QtBot") -> None: from qtpy import QtCore class Emitter: sig1 = Signal() sig2 = Signal(int) sig3 = Signal(int, int) class QtObject(QtCore.QObject): qsig1 = QtCore.Signal() qsig2 = QtCore.Signal(int) q_obj = QtObject() e = Emitter() # the hard case: signal.emit takes less args than we emit def test_receives_1(value: int) -> bool: # making sure that qsig2.emit only receives and emits 1 value return value == 1 e.sig3.connect(q_obj.qsig2.emit) with qtbot.waitSignal(q_obj.qsig2, check_params_cb=test_receives_1): e.sig3.emit(1, 2) # too many # the "standard" cases, where params match e.sig1.connect(q_obj.qsig1.emit) with qtbot.waitSignal(q_obj.qsig1): e.sig1.emit() e.sig2.connect(q_obj.qsig2.emit) with qtbot.waitSignal(q_obj.qsig2): e.sig2.emit(1) # the flip case: signal.emit takes more args than we emit with pytest.raises(ValueError): e.sig1.connect(q_obj.qsig2.emit) e.sig1.emit() @pytest.mark.parametrize("thread", [None, "main"]) def test_q_main_thread_emit( thread: Literal["main", None], qtbot: "QtBot", qapp ) -> None: """Test using signal.emit(..., queue=True) ... and receiving it on the main thread with a QTimer connected to `emit_queued` """ from psygnal.qt import start_emitting_from_queue, stop_emitting_from_queue class C: sig = Signal(int) obj = C() mock = Mock() @obj.sig.connect(thread=thread) def _some_slot(val: int) -> None: mock(val) assert (current_thread() == main_thread()) == (thread == "main") def _emit_from_thread() -> None: assert current_thread() != main_thread() obj.sig.emit(1) with qtbot.waitSignal(obj.sig, timeout=1000): t = Thread(target=_emit_from_thread) t.start() t.join() qapp.processEvents() if thread is None: mock.assert_called_once_with(1) else: mock.assert_not_called() start_emitting_from_queue() qapp.processEvents() mock.assert_called_once_with(1) start_emitting_from_queue(10) # just for test coverage stop_emitting_from_queue() psygnal-0.9.1/tests/test_throttler.py0000644000000000000000000000272413615410400014725 0ustar00import time from unittest.mock import Mock from psygnal import debounced, throttled def test_debounced() -> None: mock1 = Mock() f1 = debounced(mock1, timeout=10, leading=False) f2 = Mock() for _ in range(10): f1() f2() time.sleep(0.1) mock1.assert_called_once() assert f2.call_count == 10 def test_debounced_leading() -> None: mock1 = Mock() f1 = debounced(mock1, timeout=10, leading=True) f2 = Mock() for _ in range(10): f1() f2() time.sleep(0.1) assert mock1.call_count == 2 assert f2.call_count == 10 def test_throttled() -> None: mock1 = Mock() f1 = throttled(mock1, timeout=10, leading=True) f2 = Mock() for _ in range(10): f1() f2() time.sleep(0.1) assert mock1.call_count == 2 assert f2.call_count == 10 def test_throttled_trailing() -> None: mock1 = Mock() f1 = throttled(mock1, timeout=10, leading=False) f2 = Mock() for _ in range(10): f1() f2() time.sleep(0.1) assert mock1.call_count == 1 assert f2.call_count == 10 def test_cancel() -> None: mock1 = Mock() f1 = debounced(mock1, timeout=50, leading=False) f1() f1() f1.cancel() time.sleep(0.2) mock1.assert_not_called() def test_flush() -> None: mock1 = Mock() f1 = debounced(mock1, timeout=50, leading=False) f1() f1() f1.flush() time.sleep(0.2) mock1.assert_called_once() psygnal-0.9.1/tests/test_utils.py0000644000000000000000000000634713615410400014043 0ustar00import os import sys from pathlib import Path from unittest.mock import Mock, call import pytest from psygnal import EmissionInfo, Signal from psygnal.utils import decompile, monitor_events, recompile def test_event_debugger(capsys) -> None: """Test that the event debugger works""" class M: sig = Signal(int, int) m = M() _logger = Mock() assert not m.sig._slots with monitor_events(m, _logger): assert len(m.sig._slots) == 1 m.sig.emit(1, 2) m.sig.emit(3, 4) assert _logger.call_count == 2 _logger.assert_has_calls( [call(EmissionInfo(m.sig, (1, 2))), call(EmissionInfo(m.sig, (3, 4)))] ) assert not m.sig._slots with monitor_events(m): m.sig.emit(1, 2) m.sig.emit(3, 4) captured = capsys.readouterr() assert captured.out == "sig.emit(1, 2)\nsig.emit(3, 4)\n" def test_old_monitor_api_dep_warning() -> None: class M: sig = Signal(int, int) mock = Mock() def _monitor(signal_name: str, args: tuple) -> None: mock(signal_name, args) m = M() with pytest.warns( UserWarning, match="logger functions must now take a single argument" ): with monitor_events(m, logger=_monitor): # type: ignore m.sig.emit(1, 2) mock.assert_called_once_with("sig", (1, 2)) with pytest.raises(ValueError, match="logger function must take a single argument"): with monitor_events(logger=_monitor): # type: ignore m.sig.emit(1, 2) mock.reset_mock() with monitor_events(m, logger=mock): m.sig.emit(1, 2) mock.assert_called_once_with(EmissionInfo(m.sig, (1, 2))) # global monitor mock.reset_mock() with monitor_events(logger=mock): m.sig.emit(1, 2) mock.assert_called_once_with(EmissionInfo(m.sig, (1, 2))) def test_monitor_all() -> None: class M: sig = Signal(int, int) m1 = M() m2 = M() _logger = Mock() with monitor_events(logger=_logger): m1.sig.emit(1, 2) m2.sig.emit(3, 4) m1.sig.emit(5, 6) m2.sig.emit(7, 8) assert _logger.call_args_list == [ call(EmissionInfo(m1.sig, (1, 2))), call(EmissionInfo(m2.sig, (3, 4))), call(EmissionInfo(m1.sig, (5, 6))), call(EmissionInfo(m2.sig, (7, 8))), ] @pytest.mark.skipif(os.name == "nt", reason="rewrite open files on Windows is buggy") def test_decompile_recompile(monkeypatch): import psygnal was_compiled = psygnal._compiled decompile() monkeypatch.delitem(sys.modules, "psygnal") monkeypatch.delitem(sys.modules, "psygnal._signal") import psygnal assert not psygnal._compiled if was_compiled: assert list(Path(psygnal.__file__).parent.rglob("**/*_BAK")) recompile() monkeypatch.delitem(sys.modules, "psygnal") monkeypatch.delitem(sys.modules, "psygnal._signal") import psygnal assert psygnal._compiled def test_debug_import(monkeypatch): """Test that PSYGNAL_UNCOMPILED gives a warning.""" monkeypatch.delitem(sys.modules, "psygnal") monkeypatch.setenv("PSYGNAL_UNCOMPILED", "1") with pytest.warns(UserWarning, match="PSYGNAL_UNCOMPILED no longer has any effect"): import psygnal # noqa: F401 psygnal-0.9.1/tests/test_weak_callable.py0000644000000000000000000001133313615410400015440 0ustar00import gc from functools import partial from unittest.mock import Mock from weakref import ref import pytest import toolz from psygnal._weak_callback import WeakCallback, weak_callback @pytest.mark.parametrize( "type_", [ "function", "toolz_function", "weak_func", "lambda", "method", "partial_method", "toolz_method", "setattr", "setitem", "mock", "weak_cb", "print", ], ) def test_slot_types(type_: str, capsys) -> None: mock = Mock() final_mock = Mock() class MyObj: def method(self, x: int) -> None: mock(x) return x def __setitem__(self, key, value): mock(value) return value def __setattr__(self, __name: str, __value) -> None: if __name == "x": mock(__value) return __value obj = MyObj() if type_ == "setattr": cb = weak_callback(setattr, obj, "x", finalize=final_mock) elif type_ == "setitem": cb = weak_callback(obj.__setitem__, "x", finalize=final_mock) elif type_ in {"function", "weak_func"}: def obj(x: int) -> None: mock(x) return x cb = weak_callback(obj, strong_func=(type_ == "function"), finalize=final_mock) elif type_ == "toolz_function": @toolz.curry def obj(z: int, x: int) -> None: mock(x) return x cb = weak_callback(obj(5), finalize=final_mock) elif type_ == "lambda": cb = weak_callback(lambda x: mock(x) and x, finalize=final_mock) elif type_ == "method": cb = weak_callback(obj.method, finalize=final_mock) elif type_ == "partial_method": cb = weak_callback(partial(obj.method, 2), max_args=0, finalize=final_mock) elif type_ == "toolz_method": cb = weak_callback(toolz.curry(obj.method, 2), max_args=0, finalize=final_mock) elif type_ == "mock": cb = weak_callback(mock, finalize=final_mock) elif type_ == "weak_cb": cb = weak_callback(obj.method, finalize=final_mock) cb = weak_callback(cb, finalize=final_mock) elif type_ == "print": cb = weak_callback(print, finalize=final_mock) assert isinstance(cb, WeakCallback) cb.cb((2,)) assert cb.dereference() is not None if type_ == "print": assert capsys.readouterr().out == "2\n" return mock.assert_called_once_with(2) mock.reset_mock() result = cb(2) if type_ not in ("setattr", "mock"): assert result == 2 mock.assert_called_once_with(2) del obj if type_ not in ("function", "toolz_function", "lambda", "mock"): final_mock.assert_called_once_with(cb) assert cb.dereference() is None with pytest.raises(ReferenceError): cb.cb((2,)) with pytest.raises(ReferenceError): cb(2) else: cb.cb((4,)) mock.assert_called_with(4) def test_weak_callable_equality() -> None: """Slot callers should be equal only if they represent the same bound-method.""" class T: def x(self): ... t1 = T() t2 = T() t1_ref = ref(t1) t2_ref = ref(t2) bmt1_a = weak_callback(t1.x) bmt1_b = weak_callback(t1.x) bmt2_a = weak_callback(t2.x) bmt2_b = weak_callback(t2.x) assert bmt1_a != "not a weak callback" def _assert_equality() -> None: assert bmt1_a == bmt1_b assert bmt2_a == bmt2_b assert bmt1_a != bmt2_a assert bmt1_b != bmt2_b _assert_equality() del t1 gc.collect() assert t1_ref() is None _assert_equality() del t2 gc.collect() assert t2_ref() is None _assert_equality() def test_nonreferencable() -> None: class T: __slots__ = ("x",) def method(self) -> None: ... t = T() with pytest.warns(UserWarning, match="failed to create weakref"): cb = weak_callback(t.method) assert cb.dereference() == t.method with pytest.raises(TypeError): weak_callback(t.method, on_ref_error="raise") cb = weak_callback(t.method, on_ref_error="ignore") assert cb.dereference() == t.method @pytest.mark.parametrize("strong", [True, False]) def test_deref(strong: bool) -> None: def func(x): ... p = partial(func, 1) cb = weak_callback(p, strong_func=strong) dp = cb.dereference() assert dp.func is p.func assert dp.args == p.args assert dp.keywords == p.keywords def test_queued_callbacks(): from psygnal._queue import QueuedCallback def func(x): return x cb = weak_callback(func) qcb = QueuedCallback(cb, thread="current") assert qcb.dereference() is func assert qcb(1) == 1 psygnal-0.9.1/tests/containers/test_evented_dict.py0000644000000000000000000000765113615410400017504 0ustar00from unittest.mock import Mock import pytest from psygnal.containers._evented_dict import EventedDict @pytest.fixture def regular_dict(): return {"A": 1, "B": 2, "C": 3} @pytest.fixture def test_dict(regular_dict): """EventedDict without basetype set.""" test_dict = EventedDict(regular_dict) test_dict.events = Mock(wraps=test_dict.events) return test_dict @pytest.mark.parametrize( "method_name, args, expected", [ ("__getitem__", ("A",), 1), # read ("__setitem__", ("A", 3), None), # update ("__setitem__", ("D", 3), None), # add new entry ("__delitem__", ("A",), None), # delete ("__len__", (), 3), ("__newlike__", ({"A": 1},), {"A": 1}), ("copy", (), {"A": 1, "B": 2, "C": 3}), ], ) def test_dict_interface_parity(regular_dict, test_dict, method_name, args, expected): """Test that EventedDict interface is equivalent to the builtin dict.""" test_dict_method = getattr(test_dict, method_name) assert test_dict == regular_dict if hasattr(regular_dict, method_name): regular_dict_method = getattr(regular_dict, method_name) assert test_dict_method(*args) == regular_dict_method(*args) == expected assert test_dict == regular_dict else: test_dict_method(*args) # smoke test def test_dict_inits(): a = EventedDict({"A": 1, "B": 2, "C": 3}) b = EventedDict(A=1, B=2, C=3) c = EventedDict({"A": 1}, B=2, C=3) assert a == b == c def test_dict_repr(test_dict): assert repr(test_dict) == "EventedDict({'A': 1, 'B': 2, 'C': 3})" def test_instantiation_without_data(): """Test that EventedDict can be instantiated without data.""" test_dict = EventedDict() assert isinstance(test_dict, EventedDict) def test_basetype_enforcement_on_instantiation(): """EventedDict with basetype set should enforce types on instantiation.""" with pytest.raises(TypeError): test_dict = EventedDict({"A": "not an int"}, basetype=int) test_dict = EventedDict({"A": 1}) assert isinstance(test_dict, EventedDict) def test_basetype_enforcement_on_set_item(): """EventedDict with basetype set should enforces types on setitem.""" test_dict = EventedDict(basetype=int) test_dict["A"] = 1 with pytest.raises(TypeError): test_dict["A"] = "not an int" def test_dict_add_events(test_dict): """Test that events are emitted before and after an item is added.""" test_dict.events.adding.emit = Mock(wraps=test_dict.events.adding.emit) test_dict.events.added.emit = Mock(wraps=test_dict.events.added.emit) test_dict["D"] = 4 test_dict.events.adding.emit.assert_called_with("D") test_dict.events.added.emit.assert_called_with("D", 4) test_dict.events.adding.emit.reset_mock() test_dict.events.added.emit.reset_mock() test_dict["D"] = 4 test_dict.events.adding.emit.assert_not_called() test_dict.events.added.emit.assert_not_called() def test_dict_change_events(test_dict): """Test that events are emitted when an item in the dictionary is replaced.""" # events shouldn't be emitted on addition test_dict.events.changing.emit = Mock(wraps=test_dict.events.changing.emit) test_dict.events.changed.emit = Mock(wraps=test_dict.events.changed.emit) test_dict["D"] = 4 test_dict.events.changing.emit.assert_not_called() test_dict.events.changed.emit.assert_not_called() test_dict["C"] = 4 test_dict.events.changing.emit.assert_called_with("C") test_dict.events.changed.emit.assert_called_with("C", 3, 4) def test_dict_remove_events(test_dict): """Test that events are emitted before and after an item is removed.""" test_dict.events.removing.emit = Mock(wraps=test_dict.events.removing.emit) test_dict.events.removed.emit = Mock(wraps=test_dict.events.removed.emit) test_dict.pop("C") test_dict.events.removing.emit.assert_called_with("C") test_dict.events.removed.emit.assert_called_with("C", 3) psygnal-0.9.1/tests/containers/test_evented_list.py0000644000000000000000000002712313615410400017530 0ustar00import os from typing import List, cast from unittest.mock import Mock, call import numpy as np import pytest from psygnal import EmissionInfo, Signal, SignalGroup from psygnal.containers import EventedList @pytest.fixture def regular_list(): return list(range(5)) @pytest.fixture def test_list(regular_list): test_list = EventedList(regular_list) test_list.events = Mock(wraps=test_list.events) return test_list @pytest.mark.parametrize( "meth", [ # METHOD, ARGS, EXPECTED EVENTS # primary interface ("insert", (2, 10), ("inserting", "inserted")), # create ("__getitem__", (2,), ()), # read ("__setitem__", (2, 3), ("changed",)), # update ("__setitem__", (slice(2), [1, 2]), ("changed",)), # update slice ("__setitem__", (slice(2, 2), [1, 2]), ("changed",)), # update slice ("__delitem__", (2,), ("removing", "removed")), # delete ( "__delitem__", (slice(2),), ("removing", "removed") * 2, ), ("__delitem__", (slice(0, 0),), ("removing", "removed")), ( "__delitem__", (slice(-3),), ("removing", "removed") * 2, ), ( "__delitem__", (slice(-2, None),), ("removing", "removed") * 2, ), # inherited interface ("append", (3,), ("inserting", "inserted")), ("clear", (), ("removing", "removed") * 5), ("count", (3,), ()), ("extend", ([7, 8, 9],), ("inserting", "inserted") * 3), ("index", (3,), ()), ("pop", (-2,), ("removing", "removed")), ("remove", (3,), ("removing", "removed")), ("reverse", (), ("reordered",)), ("__add__", ([7, 8, 9],), ()), ("__iadd__", ([7, 9],), ("inserting", "inserted") * 2), ("__radd__", ([7, 9],), ("inserting", "inserted") * 2), # sort? ], ids=lambda x: x[0], ) def test_list_interface_parity(test_list, regular_list, meth): test_list.events = cast("Mock", test_list.events) method_name, args, expected = meth test_list_method = getattr(test_list, method_name) assert tuple(test_list) == tuple(regular_list) if hasattr(regular_list, method_name): regular_list_method = getattr(regular_list, method_name) assert test_list_method(*args) == regular_list_method(*args) assert tuple(test_list) == tuple(regular_list) else: test_list_method(*args) # smoke test for c, expect in zip(test_list.events.call_args_list, expected): event = c.args[0] assert event.type == expect def test_delete(test_list): assert test_list == [0, 1, 2, 3, 4] del test_list[1] assert test_list == [0, 2, 3, 4] del test_list[2:] assert test_list == [0, 2] @pytest.mark.xfail("i686" in os.getenv("AUDITWHEEL_PLAT", ""), reason="failing on i686") def test_hash(test_list): assert id(test_list) == hash(test_list) b = EventedList([2, 3], hashable=False) with pytest.raises(TypeError): hash(b) def test_repr(test_list): assert repr(test_list) == "EventedList([0, 1, 2, 3, 4])" def test_reverse(test_list): assert test_list == [0, 1, 2, 3, 4] test_list.reverse() test_list.events.reordered.emit.assert_called_once() test_list.events.changed.emit.assert_not_called() assert test_list == [4, 3, 2, 1, 0] test_list.events.reordered.emit.reset_mock() test_list.reverse(emit_individual_events=True) test_list.events.reordered.emit.assert_called_once() assert test_list.events.changed.emit.call_count == 4 test_list.events.changed.emit.assert_has_calls( [call(0, 4, 0), call(4, 0, 4), call(1, 3, 1), call(3, 1, 3)] ) assert test_list == [0, 1, 2, 3, 4] def test_list_interface_exceptions(test_list): bad_index = {"a": "dict"} with pytest.raises(TypeError): test_list[bad_index] with pytest.raises(TypeError): test_list[bad_index] = 1 with pytest.raises(TypeError): del test_list[bad_index] with pytest.raises(TypeError): test_list.insert([bad_index], 0) def test_copy(test_list, regular_list): """Copying an evented list should return a same-class evented list.""" new_test = test_list.copy() new_reg = regular_list.copy() assert id(new_test) != id(test_list) assert new_test == test_list assert tuple(new_test) == tuple(test_list) == tuple(new_reg) test_list.events.assert_not_called() def test_array_like_setitem(): """Test that EventedList.__setitem__ works for array-like items""" array = np.array((10, 10)) evented_list = EventedList([array]) evented_list[0] = array def test_slice(test_list, regular_list): """Slicing an evented list should return a same-class evented list.""" test_slice = test_list[1:3] regular_slice = regular_list[1:3] assert tuple(test_slice) == tuple(regular_slice) assert isinstance(test_slice, test_list.__class__) change_emit = test_list.events.changed.emit assert test_list == [0, 1, 2, 3, 4] test_list[1:3] = [6, 7, 8] assert test_list == [0, 6, 7, 8, 3, 4] change_emit.assert_called_with(slice(1, 3, None), [1, 2], [6, 7, 8]) with pytest.raises(ValueError) as e: test_list[1:6:2] = [6, 7, 8, 6, 7] assert str(e.value).startswith("attempt to assign sequence of size 5 to extended ") test_list[1:6:2] = [9, 9, 9] assert test_list == [0, 9, 7, 9, 3, 9] change_emit.assert_called_with(slice(1, 6, 2), [6, 8, 4], [9, 9, 9]) with pytest.raises(TypeError) as e2: test_list[1:3] = 1 assert str(e2.value) == "Can only assign an iterable to slice" def test_move(test_list: EventedList): """Test the that we can move objects with the move method""" test_list.events = cast("Mock", test_list.events) def _fail() -> None: raise AssertionError("unexpected event called") test_list.events.removing.connect(_fail) test_list.events.removed.connect(_fail) test_list.events.inserting.connect(_fail) test_list.events.inserted.connect(_fail) before = list(test_list) assert before == [0, 1, 2, 3, 4] # from fixture # pop the object at 0 and insert at current position 3 test_list.move(0, 3) expectation = [1, 2, 0, 3, 4] assert test_list != before assert test_list == expectation test_list.events.moving.emit.assert_called_once_with(0, 3) test_list.events.moved.emit.assert_called_once_with(0, 2, 0) test_list.events.reordered.emit.assert_called_once() test_list.events.moving.emit.reset_mock() test_list.move(2, 2) test_list.events.moving.emit.assert_not_called() # noop # move the other way # pop the object at 3 and insert at current position 0 assert test_list == [1, 2, 0, 3, 4] test_list.move(3, 0) assert test_list == [3, 1, 2, 0, 4] # negative index destination test_list.move(1, -2) assert test_list == [3, 2, 0, 1, 4] BASIC_INDICES: List[tuple] = [ ((2,), 0, [2, 0, 1, 3, 4, 5, 6, 7]), # move single item ([0, 2, 3], 6, [1, 4, 5, 0, 2, 3, 6, 7]), # move back ([4, 7], 1, [0, 4, 7, 1, 2, 3, 5, 6]), # move forward ([0, 5, 6], 3, [1, 2, 0, 5, 6, 3, 4, 7]), # move in between ([1, 3, 5, 7], 3, [0, 2, 1, 3, 5, 7, 4, 6]), # same as above ([0, 2, 3, 2, 3], 6, [1, 4, 5, 0, 2, 3, 6, 7]), # strip dupe indices ] OTHER_INDICES: List[tuple] = [ ([7, 4], 1, [0, 7, 4, 1, 2, 3, 5, 6]), # move forward reorder ([3, 0, 2], 6, [1, 4, 5, 3, 0, 2, 6, 7]), # move back reorder ((2, 4), -2, [0, 1, 3, 5, 6, 2, 4, 7]), # negative indexing ([slice(None, 3)], 6, [3, 4, 5, 0, 1, 2, 6, 7]), # move slice back ([slice(5, 8)], 2, [0, 1, 5, 6, 7, 2, 3, 4]), # move slice forward ([slice(1, 8, 2)], 3, [0, 2, 1, 3, 5, 7, 4, 6]), # move slice between ([slice(None, 8, 3)], 4, [1, 2, 0, 3, 6, 4, 5, 7]), ([slice(None, 8, 3), 0, 3, 6], 4, [1, 2, 0, 3, 6, 4, 5, 7]), ] MOVING_INDICES = BASIC_INDICES + OTHER_INDICES @pytest.mark.parametrize("sources, dest, expectation", MOVING_INDICES) def test_move_multiple(sources, dest, expectation): """Test the that we can move objects with the move method""" el = EventedList(range(8)) el.events = Mock(wraps=el.events) assert el == [0, 1, 2, 3, 4, 5, 6, 7] def _fail(): raise AssertionError("unexpected event called") el.events.removing.connect(_fail) el.events.removed.connect(_fail) el.events.inserting.connect(_fail) el.events.inserted.connect(_fail) el.move_multiple(sources, dest) assert el == expectation el.events.moving.emit.assert_called() el.events.moved.emit.assert_called() el.events.reordered.emit.assert_called() def test_move_multiple_mimics_slice_reorder(): """Test the that move_multiple provides the same result as slice insertion.""" data = list(range(8)) el = EventedList(data) el.events = Mock(wraps=el.events) assert el == data new_order = [1, 5, 3, 4, 6, 7, 2, 0] # this syntax el.move_multiple(new_order, 0) # is the same as this syntax data[:] = [data[i] for i in new_order] assert el == new_order assert el == data assert el.events.moving.emit.call_args_list == [ call(1, 0), call(5, 1), call(4, 2), call(5, 3), call(6, 4), call(7, 5), call(7, 6), ] assert el.events.moved.emit.call_args_list == [ call(1, 0, 1), call(5, 1, 5), call(4, 2, 3), call(5, 3, 4), call(6, 4, 6), call(7, 5, 7), call(7, 6, 2), ] el.events.reordered.emit.assert_called() # move_multiple also works omitting the insertion index el[:] = list(range(8)) expected = [el[i] for i in new_order] el.move_multiple(new_order) assert el == expected def test_child_events(): """Test that evented lists bubble child events.""" # create a random object that emits events class E: test = Signal(str) e_obj = E() root: EventedList[E] = EventedList(child_events=True) mock = Mock() root.events.connect(mock) root.append(e_obj) assert len(e_obj.test) == 1 assert root == [e_obj] e_obj.test.emit("hi") assert mock.call_count == 3 expected = [ call(EmissionInfo(root.events.inserting, (0,))), call(EmissionInfo(root.events.inserted, (0, e_obj))), call(EmissionInfo(root.events.child_event, (0, e_obj, e_obj.test, ("hi",)))), ] mock.assert_has_calls(expected) del root[0] assert len(e_obj.test) == 0 def test_child_events_groups(): """Test that evented lists bubble child events.""" # create a random object that emits events class Group(SignalGroup): test = Signal(str) test2 = Signal(str) class E: def __init__(self): self.events = Group(self) e_obj = E() root: EventedList[E] = EventedList(child_events=True) mock = Mock() root.events.connect(mock) root.append(e_obj) assert root == [e_obj] e_obj.events.test2.emit("hi") assert mock.call_count == 3 # when an object in the list owns an emitter group, then any emitter in that group # will also be detected, and child_event will emit (index, sub-emitter, args) expected = [ call(EmissionInfo(root.events.inserting, (0,))), call(EmissionInfo(root.events.inserted, (0, e_obj))), call( EmissionInfo( root.events.child_event, (0, e_obj, e_obj.events.test2, ("hi",)) ) ), ] # note that we can get back to the actual object in the list using the .instance # attribute on signal instances. assert e_obj.events.test2.instance.instance == e_obj mock.assert_has_calls(expected) psygnal-0.9.1/tests/containers/test_evented_proxy.py0000644000000000000000000001173513615410400017740 0ustar00from unittest.mock import Mock, call import numpy as np from psygnal import EmissionInfo, SignalGroup from psygnal.containers import ( EventedCallableObjectProxy, EventedObjectProxy, _evented_proxy, ) from psygnal.utils import monitor_events def test_evented_proxy(): class T: def __init__(self) -> None: self.x = 1 self.f = "f" self._list = [0, 1] def __getitem__(self, key): return self._list[key] def __setitem__(self, key, value): self._list[key] = value def __delitem__(self, key): del self._list[key] t = EventedObjectProxy(T()) assert "events" in dir(t) assert t.x == 1 mock = Mock() with monitor_events(t.events, mock): t.x = 2 t.f = "f" del t.x t.y = "new" t[0] = 7 t[0] = 7 # no event del t[0] assert mock.call_args_list == [ call(EmissionInfo(t.events.attribute_set, ("x", 2))), call(EmissionInfo(t.events.attribute_deleted, ("x",))), call(EmissionInfo(t.events.attribute_set, ("y", "new"))), call(EmissionInfo(t.events.item_set, (0, 7))), call(EmissionInfo(t.events.item_deleted, (0,))), ] def test_evented_proxy_ref(): class T: def __init__(self) -> None: self.x = 1 assert not _evented_proxy._OBJ_CACHE t = EventedObjectProxy(T()) assert not _evented_proxy._OBJ_CACHE assert isinstance(t.events, SignalGroup) # this will actually create the group assert len(_evented_proxy._OBJ_CACHE) == 1 del t # this should clean up the object from the cache assert not _evented_proxy._OBJ_CACHE def test_in_place_proxies(): # fmt: off class T: x = 0 def __iadd__(self, other): return self def __isub__(self, other): return self def __imul__(self, other): return self def __imatmul__(self, other): return self def __itruediv__(self, other): return self def __ifloordiv__(self, other): return self def __imod__(self, other): return self def __ipow__(self, other): return self def __ilshift__(self, other): return self def __irshift__(self, other): return self def __iand__(self, other): return self def __ixor__(self, other): return self def __ior__(self, other): return self # fmt: on t = EventedObjectProxy(T()) mock = Mock() with monitor_events(t.events, mock): t += 1 mock.assert_called_with(EmissionInfo(t.events.in_place, ("add", 1))) t -= 2 mock.assert_called_with(EmissionInfo(t.events.in_place, ("sub", 2))) t *= 3 mock.assert_called_with(EmissionInfo(t.events.in_place, ("mul", 3))) t /= 4 mock.assert_called_with(EmissionInfo(t.events.in_place, ("truediv", 4))) t //= 5 mock.assert_called_with(EmissionInfo(t.events.in_place, ("floordiv", 5))) t @= 6 mock.assert_called_with(EmissionInfo(t.events.in_place, ("matmul", 6))) t %= 7 mock.assert_called_with(EmissionInfo(t.events.in_place, ("mod", 7))) t **= 8 mock.assert_called_with(EmissionInfo(t.events.in_place, ("pow", 8))) t <<= 9 mock.assert_called_with(EmissionInfo(t.events.in_place, ("lshift", 9))) t >>= 10 mock.assert_called_with(EmissionInfo(t.events.in_place, ("rshift", 10))) t &= 11 mock.assert_called_with(EmissionInfo(t.events.in_place, ("and", 11))) t ^= 12 mock.assert_called_with(EmissionInfo(t.events.in_place, ("xor", 12))) t |= 13 mock.assert_called_with(EmissionInfo(t.events.in_place, ("or", 13))) def test_numpy_proxy(): ary = np.ones((4, 4)) t = EventedObjectProxy(ary) assert repr(t) == repr(ary) mock = Mock() with monitor_events(t.events, mock): t[0] = 2 signal, (key, value) = tuple(mock.call_args)[0][0] assert signal.name == "item_set" assert key == 0 assert np.array_equal(value, [2, 2, 2, 2]) mock.reset_mock() t[2:] = np.arange(8).reshape(2, 4) signal, (key, value) = tuple(mock.call_args)[0][0] assert signal.name == "item_set" assert key == slice(2, None, None) assert np.array_equal(value, [[0, 1, 2, 3], [4, 5, 6, 7]]) mock.reset_mock() t += 1 t -= 1 assert np.array_equal( t, np.asarray([[2, 2, 2, 2], [1, 1, 1, 1], [0, 1, 2, 3], [4, 5, 6, 7]]) ) t *= [0, 0, 0, 0] assert not t.any() assert mock.call_args_list == [ call(EmissionInfo(t.events.in_place, ("add", 1))), call(EmissionInfo(t.events.in_place, ("sub", 1))), call(EmissionInfo(t.events.in_place, ("mul", [0, 0, 0, 0]))), ] def test_evented_callable_proxy(): calls = [] def f(*args, **kwargs): calls.append((args, kwargs)) ef = EventedCallableObjectProxy(f) ef(1, 2, foo="bar") assert calls == [((1, 2), {"foo": "bar"})] psygnal-0.9.1/tests/containers/test_evented_set.py0000644000000000000000000000731313615410400017347 0ustar00from unittest.mock import Mock, call import pytest from psygnal.containers import EventedOrderedSet, EventedSet, OrderedSet @pytest.fixture def regular_set(): return set(range(5)) @pytest.fixture(params=[EventedSet, EventedOrderedSet]) def test_set(request, regular_set): return request.param(regular_set) @pytest.mark.parametrize( "meth", [ # METHOD, ARGS, EXPECTED EVENTS # primary interface ("add", 2, []), ("add", 10, [call((10,), ())]), ("discard", 2, [call((), (2,))]), ("remove", 2, [call((), (2,))]), ("discard", 10, []), # parity with set ("update", {3, 4, 5, 6}, [call((5, 6), ())]), ("difference_update", {3, 4, 5, 6}, [call((), (3, 4))]), ("intersection_update", {3, 4, 5, 6}, [call((), (0, 1, 2))]), ("symmetric_difference_update", {3, 4, 5, 6}, [call((5, 6), (3, 4))]), ], ids=lambda x: x[0], ) def test_set_interface_parity(test_set: EventedSet, regular_set: set, meth): method_name, arg, expected = meth mock = Mock() test_set.events.items_changed.connect(mock) test_set_method = getattr(test_set, method_name) assert tuple(test_set) == tuple(regular_set) regular_set_method = getattr(regular_set, method_name) assert test_set_method(arg) == regular_set_method(arg) assert tuple(test_set) == tuple(regular_set) mock.assert_has_calls(expected) assert type(test_set).__name__ in repr(test_set) def test_set_pop(test_set: EventedSet): mock = Mock() test_set.events.items_changed.connect(mock) npops = len(test_set) while test_set: test_set.pop() assert mock.call_count == npops with pytest.raises(KeyError): test_set.pop() with pytest.raises(KeyError): test_set.remove(34) def test_set_clear(test_set: EventedSet): mock = Mock() test_set.events.items_changed.connect(mock) mock.assert_not_called() test_set.clear() mock.assert_called_once_with((), (0, 1, 2, 3, 4)) @pytest.mark.parametrize( "meth", [ ("difference", {3, 4, 5, 6}), ("intersection", {3, 4, 5, 6}), ("issubset", {3, 4}), ("issubset", {3, 4, 5, 6}), ("issubset", {1, 2, 3, 4, 5, 6}), ("issuperset", {3, 4}), ("issuperset", {3, 4, 5, 6}), ("issuperset", {1, 2, 3, 4, 5, 6}), ("symmetric_difference", {3, 4, 5, 6}), ("union", {3, 4, 5, 6}), ], ) def test_set_new_objects(test_set: EventedSet, regular_set: set, meth): method_name, arg = meth test_set_method = getattr(test_set, method_name) assert tuple(test_set) == tuple(regular_set) mock = Mock() test_set.events.items_changed.connect(mock) regular_set_method = getattr(regular_set, method_name) result = test_set_method(arg) assert result == regular_set_method(arg) assert isinstance(result, (EventedSet, EventedOrderedSet, bool)) assert result is not test_set mock.assert_not_called() def test_ordering(): tup = (24, 16, 8, 4, 5, 6) s_tup = set(tup) os_tup = OrderedSet(tup) assert tuple(s_tup) != tup assert repr(s_tup) == "{4, 5, 6, 8, 16, 24}" assert tuple(os_tup) == tup assert repr(os_tup) == "OrderedSet((24, 16, 8, 4, 5, 6))" os_tup.discard(8) os_tup.add(8) assert tuple(os_tup) == (24, 16, 4, 5, 6, 8) def test_copy(test_set): from copy import copy assert test_set.copy() == copy(test_set) assert test_set is not copy(test_set) assert isinstance(copy(test_set), type(test_set)) def test_repr(test_set): if isinstance(test_set, EventedOrderedSet): assert repr(test_set) == "EventedOrderedSet((0, 1, 2, 3, 4))" else: assert repr(test_set) == "EventedSet({0, 1, 2, 3, 4})" psygnal-0.9.1/tests/containers/test_selectable_evented_list.py0000644000000000000000000001054513615410400021713 0ustar00from unittest.mock import Mock import pytest from psygnal.containers import SelectableEventedList @pytest.fixture def regular_list() -> list: return list(range(5)) @pytest.fixture def test_list(regular_list: list) -> SelectableEventedList: test_list = SelectableEventedList(regular_list) test_list.events = Mock(wraps=test_list.events) test_list.selection.events = Mock(wraps=test_list.selection.events) return test_list def test_select_item_not_in_list(test_list: SelectableEventedList) -> None: """Items not in list should not be added to selection.""" with pytest.raises(ValueError): test_list.selection.add(6) assert 6 not in test_list.selection def test_newly_selected_item_is_active(test_list: SelectableEventedList) -> None: """Items added to a selection should become active.""" test_list.selection.clear() test_list.selection.add(1) assert test_list.selection.active == 1 def test_select_all(test_list: SelectableEventedList) -> None: """Select all should populate the selection.""" test_list.selection.update = Mock(wraps=test_list.selection.update) test_list.selection.clear() assert not test_list.selection test_list.select_all() assert all(el in test_list.selection for el in range(5)) test_list.selection.update.assert_called_once() def test_deselect_all(test_list: SelectableEventedList) -> None: """Deselect all should clear the selection""" test_list.selection.clear = Mock(wraps=test_list.selection.clear) test_list.selection = list(range(5)) assert all(el in test_list.selection for el in range(5)) test_list.deselect_all() assert not test_list.selection test_list.selection.clear.assert_called_once() @pytest.mark.parametrize( "initial_selection, step, expand_selection, wraparound, expected", [ ({0}, 1, False, False, {1}), ({0}, 2, False, False, {2}), ({0}, 2, True, False, {0, 2}), ({0, 1}, 1, False, False, {1}), ({0}, 5, False, False, {4}), ({0}, 5, False, True, {0}), ({}, 1, False, False, {4}), ], ) def test_select_next( test_list: SelectableEventedList, initial_selection, step, expand_selection, wraparound, expected, ): """Test select next method behaviour.""" test_list.selection = initial_selection test_list.select_next( step=step, expand_selection=expand_selection, wraparound=wraparound ) assert test_list.selection == expected def test_select_next_with_empty_list(): """Selection should remain unchanged on advancing if list is empty.""" test_list = SelectableEventedList([]) initial_selection = test_list.selection.copy() test_list.select_next() assert test_list.selection == initial_selection @pytest.mark.parametrize( "initial_selection, expand_selection, wraparound, expected", [ ({1}, False, False, {0}), ({0}, False, False, {0}), ({1}, True, False, {0, 1}), ({1, 2}, False, False, {0}), ({0}, False, True, {4}), ], ) def test_select_previous( test_list, initial_selection, expand_selection, wraparound, expected ): """Test select next method behaviour.""" test_list.selection = initial_selection test_list.select_previous(expand_selection=expand_selection, wraparound=wraparound) assert test_list.selection == expected def test_item_discarded_from_selection_on_removal_from_list( test_list: SelectableEventedList, ) -> None: """Check that items removed from a list are also removed from the selection.""" test_list.selection.clear() test_list.selection.discard = Mock(wraps=test_list.selection.discard) test_list.selection = {0} assert 0 in test_list.selection test_list.remove(0) assert 0 not in test_list.selection test_list.selection.discard.assert_called_once() def test_remove_selected(test_list: SelectableEventedList) -> None: """Test items are removed from both the selection and the list.""" test_list.selection.clear() initial_selection = {0, 1} test_list.selection = initial_selection assert test_list.selection == initial_selection output = test_list.remove_selected() assert set(output) == initial_selection assert all(el not in test_list for el in initial_selection) assert all(el not in test_list.selection for el in initial_selection) assert test_list.selection == {2} psygnal-0.9.1/tests/containers/test_selection.py0000644000000000000000000000506313615410400017027 0ustar00from unittest.mock import Mock from psygnal.containers import Selection def test_add_and_remove_from_selection(): selection = Selection() selection.events._current = Mock() assert not selection._current assert not selection selection.add(1) selection._current = 1 selection.events._current.emit.assert_called_once() assert 1 in selection assert selection._current == 1 selection.remove(1) assert not selection def test_update_active_called_on_selection_change(): selection = Selection() selection._update_active = Mock() selection.add(1) selection._update_active.assert_called_once() def test_active_event_emitted_on_selection_change(): selection = Selection() selection.events.active = Mock() assert not selection.active selection.add(1) assert selection.active == 1 selection.events.active.emit.assert_called_once() def test_current_setter(): """Current event should only emit if value changes.""" selection = Selection() selection._current = 1 selection.events._current = Mock() selection._current = 1 selection.events._current.emit.assert_not_called() selection._current = 2 selection.events._current.emit.assert_called_once() def test_active_setter(): """Active setter should make value the only selected item, make it current and emit the active event.""" selection = Selection() selection.events.active = Mock() assert not selection._current selection.active = 1 assert selection.active == 1 assert selection._current == 1 selection.events.active.emit.assert_called_once() def test_select_only(): selection = Selection([1, 2]) selection.active = 1 assert selection.active == 1 selection.select_only(2) assert selection.active == 2 def test_clear(): selection = Selection([1, 2]) selection._current = 2 assert len(selection) == 2 selection.clear(keep_current=True) assert len(selection) == 0 assert selection._current == 2 selection.clear(keep_current=False) assert selection._current is None def test_toggle(): selection = Selection() selection.symmetric_difference_update = Mock() selection.toggle(1) selection.symmetric_difference_update.assert_called_once() def test_emit_change(): """emit change is overridden to also update the active value.""" selection = Selection() selection._update_active = Mock() selection._emit_change((None,), (None,)) selection._update_active.assert_called_once() def test_hash(): assert hash(Selection()) psygnal-0.9.1/.gitignore0000644000000000000000000000244113615410400012107 0ustar00.idea/ # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so *.c # temporarily disabled mypyc files *.so_BAK *.pyd_BAK # Distribution / packaging .Python env/ build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ *.egg-info/ .installed.cfg *.egg # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover .hypothesis/ .pytest_cache/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # pyenv .python-version # celery beat schedule file celerybeat-schedule # SageMath parsed files *.sage.py # dotenv .env # virtualenv .venv venv/ ENV/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ # IDE settings .vscode/ _version.py psygnal/_version.py .asv/ wheelhouse/ psygnal-0.9.1/LICENSE0000644000000000000000000000275213615410400011131 0ustar00 BSD License Copyright (c) 2021, Talley Lambert All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. psygnal-0.9.1/README.md0000644000000000000000000000747713615410400011414 0ustar00# psygnal [![License](https://img.shields.io/pypi/l/psygnal.svg?color=green)](https://github.com/pyapp-kit/psygnal/raw/master/LICENSE) [![PyPI](https://img.shields.io/pypi/v/psygnal.svg?color=green)](https://pypi.org/project/psygnal) ![Conda](https://img.shields.io/conda/v/conda-forge/psygnal) [![Python Version](https://img.shields.io/pypi/pyversions/psygnal.svg?color=green)](https://python.org) [![CI](https://github.com/pyapp-kit/psygnal/actions/workflows/test.yml/badge.svg)](https://github.com/pyapp-kit/psygnal/actions/workflows/test.yml) [![codecov](https://codecov.io/gh/pyapp-kit/psygnal/branch/main/graph/badge.svg?token=qGnz9GXpEb)](https://codecov.io/gh/pyapp-kit/psygnal) [![Documentation Status](https://readthedocs.org/projects/psygnal/badge/?version=latest)](https://psygnal.readthedocs.io/en/latest/?badge=latest) [![Benchmarks](https://img.shields.io/badge/⏱-codspeed-%23FF7B53)](https://codspeed.io/pyapp-kit/psygnal) Psygnal (pronounced "signal") is a pure python implementation of the [observer pattern](https://en.wikipedia.org/wiki/Observer_pattern), with the API of [Qt-style Signals](https://doc.qt.io/qt-5/signalsandslots.html) with (optional) signature and type checking, and support for threading. > This library does ***not*** require or use Qt in any way, It simply implements > a similar observer pattern API. ## Documentation https://psygnal.readthedocs.io/ ### Install ```sh pip install psygnal ``` ```sh conda install -c conda-forge psygnal ``` ## Usage The [observer pattern](https://en.wikipedia.org/wiki/Observer_pattern) is a software design pattern in which an object maintains a list of its dependents ("**observers**"), and notifies them of any state changes – usually by calling a **callback function** provided by the observer. Here is a simple example of using psygnal: ```python from psygnal import Signal class MyObject: # define one or signals as class attributes value_changed = Signal(str) # create an instance my_obj = MyObject() # You (or others) can connect callbacks to your signals @my_obj.value_changed.connect def on_change(new_value: str): print(f"The value changed to {new_value}!") # The object may now emit signals when appropriate, # (for example in a setter method) my_obj.value_changed.emit('hi') # prints "The value changed to hi!" ``` Much more detail available in the [documentation](https://psygnal.readthedocs.io/)! ### Evented Dataclasses A particularly nice usage of the signal pattern is to emit signals whenever a field of a dataclass changes. Psygnal provides an `@evented` decorator that will emit a signal whenever a field changes. It is compatible with `dataclasses` from [the standard library](https://docs.python.org/3/library/dataclasses.html), as well as [attrs](https://www.attrs.org/en/stable/), and [pydantic](https://pydantic-docs.helpmanual.io): ```python from psygnal import evented from dataclasses import dataclass @evented @dataclass class Person: name: str age: int = 0 person = Person('John', age=30) # connect callbacks @person.events.age.connect def _on_age_change(new_age: str): print(f"Age changed to {new_age}") person.age = 31 # prints: Age changed to 31 ``` See the [dataclass documentation](https://psygnal.readthedocs.io/en/latest/dataclasses/) for more details. ## Benchmark history https://pyapp-kit.github.io/psygnal/ and https://codspeed.io/pyapp-kit/psygnal ## Developers ### Debugging While `psygnal` is a pure python module, it is compiled with mypyc to increase performance. To disable all compiled files and run the pure python version, you may run: ```bash python -c "import psygnal.utils; psygnal.utils.decompile()" ``` To return the compiled version, run: ```bash python -c "import psygnal.utils; psygnal.utils.recompile()" ``` The `psygnal._compiled` variable will tell you if you're using the compiled version or not. psygnal-0.9.1/pyproject.toml0000644000000000000000000001315513615410400013037 0ustar00# https://peps.python.org/pep-0517/ [build-system] requires = ["hatchling>=1.8.0", "hatch-vcs"] build-backend = "hatchling.build" # https://peps.python.org/pep-0621/ [project] name = "psygnal" description = "Fast python callback/event system modeled after Qt Signals" readme = "README.md" requires-python = ">=3.7" license = { text = "BSD 3-Clause License" } authors = [{ name = "Talley Lambert", email = "talley.lambert@gmail.com" }] classifiers = [ "Development Status :: 3 - Alpha", "License :: OSI Approved :: BSD License", "Natural Language :: English", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Typing :: Typed", ] dynamic = ["version"] dependencies = [ "typing-extensions", "mypy_extensions", "importlib_metadata ; python_version < '3.8'", ] # extras # https://peps.python.org/pep-0621/#dependencies-optional-dependencies [project.optional-dependencies] dev = [ "black", "cruft", "dask", "ruff", "ipython", "mypy", "numpy", "pre-commit", "pydantic", "PyQt5", "pytest-cov", "pytest-qt", "pytest", "qtpy", "rich", "wrapt", ] 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"] test = [ "dask", "attrs", "numpy", "pydantic", "pyinstaller>=4.0", "pytest>=6.0", "pytest-codspeed", "pytest-cov", "wrapt", "msgspec ; python_version >= '3.8'", "toolz", ] testqt = ["pytest-qt", "qtpy"] [project.urls] homepage = "https://github.com/pyapp-kit/psygnal" repository = "https://github.com/pyapp-kit/psygnal" documentation = "https://psygnal.readthedocs.io" [project.entry-points.pyinstaller40] hook-dirs = "psygnal._pyinstaller_util._pyinstaller_hook:get_hook_dirs" [tool.hatch.version] source = "vcs" [tool.hatch.build.targets.sdist] include = ["src", "tests", "CHANGELOG.md"] [tool.hatch.build.targets.wheel] only-include = ["src"] sources = ["src"] [tool.hatch.build.targets.wheel.hooks.mypyc] mypy-args = ["--ignore-missing-imports"] enable-by-default = false require-runtime-dependencies = true dependencies = [ "hatch-mypyc>=0.13.0", "mypy>=0.991", "pydantic", "types-attrs", "msgspec ; python_version >= '3.8'", ] exclude = [ "src/psygnal/__init__.py", "src/psygnal/_evented_model.py", "src/psygnal/utils.py", "src/psygnal/containers", "src/psygnal/qt.py", "src/psygnal/_pyinstaller_util", ] [tool.cibuildwheel] # Skip 32-bit builds & PyPy wheels on all platforms skip = ["*-manylinux_i686", "*-musllinux_i686", "*-win32", "pp*"] test-extras = ["test"] test-command = "pytest {project}/tests -v" test-skip = "*-musllinux*" [tool.cibuildwheel.environment] HATCH_BUILD_HOOKS_ENABLE = "1" # https://github.com/charliermarsh/ruff [tool.ruff] line-length = 88 target-version = "py37" src = ["src", "tests"] select = [ "E", # style errors "F", # flakes "D", # pydocstyle "I", # isort "UP", # pyupgrade # "N", # pep8-naming "S", # bandit "C4", # flake8-comprehensions "B", # flake8-bugbear "A001", # flake8-builtins "TID", # flake8-tidy-imports "RUF", # ruff-specific rules ] ignore = [ "D100", # Missing docstring in public module "D107", # Missing docstring in __init__ "D203", # 1 blank line required before class docstring "D212", # Multi-line docstring summary should start at the first line "D213", # Multi-line docstring summary should start at the second line "D401", # First line should be in imperative mood "D413", # Missing blank line after last section "D416", # Section name should end with a colon "RUF009", # Do not perform function call in dataclass defaults ] [tool.ruff.per-file-ignores] "tests/*.py" = ["D", "S"] "benchmarks/*.py" = ["D"] "setup.py" = ["D"] # https://docs.pytest.org/en/6.2.x/customize.html [tool.pytest.ini_options] minversion = "6.0" testpaths = ["tests"] filterwarnings = [ "error", "ignore:The distutils package is deprecated:DeprecationWarning:", "ignore:.*BackendFinder.find_spec()", # pyinstaller import ] # https://mypy.readthedocs.io/en/stable/config_file.html [tool.mypy] files = "src/**/*.py" strict = true disallow_any_generics = false disallow_subclassing_any = false show_error_codes = true pretty = true [[tool.mypy.overrides]] module = ["numpy.*", "wrapt", "pydantic.*"] ignore_errors = true [[tool.mypy.overrides]] # msgspec is only available on Python 3.8+ ... so we need to ignore it module = ["wrapt", "msgspec"] ignore_missing_imports = true # https://coverage.readthedocs.io/en/6.4/config.html [tool.coverage.report] exclude_lines = [ "pragma: no cover", "if TYPE_CHECKING:", "@overload", "except ImportError", "\\.\\.\\.", "raise NotImplementedError()", ] [tool.coverage.run] source = ["src"] omit = ["src/psygnal/_pyinstaller_util/hook-psygnal.py"] # https://github.com/mgedmin/check-manifest#configuration [tool.check-manifest] ignore = [ ".ruff_cache/**/*", ".github_changelog_generator", ".pre-commit-config.yaml", "tests/**/*", "typesafety/*", ".devcontainer/*", ".readthedocs.yaml", "Makefile", "asv.conf.json", "benchmarks/*", "docs/**/*", "mkdocs.yml", "src/**/*.c", "codecov.yml", "CHANGELOG.md", "setup.py", ] psygnal-0.9.1/PKG-INFO0000644000000000000000000001507513615410400011223 0ustar00Metadata-Version: 2.1 Name: psygnal Version: 0.9.1 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 Project-URL: documentation, https://psygnal.readthedocs.io Author-email: Talley Lambert License: BSD 3-Clause License License-File: LICENSE Classifier: Development Status :: 3 - Alpha Classifier: License :: OSI Approved :: BSD License Classifier: Natural Language :: English Classifier: Programming Language :: Python :: 3 Classifier: Programming Language :: Python :: 3.7 Classifier: Programming Language :: Python :: 3.8 Classifier: Programming Language :: Python :: 3.9 Classifier: Programming Language :: Python :: 3.10 Classifier: Programming Language :: Python :: 3.11 Classifier: Typing :: Typed Requires-Python: >=3.7 Requires-Dist: importlib-metadata; python_version < '3.8' Requires-Dist: mypy-extensions Requires-Dist: typing-extensions Provides-Extra: dev Requires-Dist: black; extra == 'dev' Requires-Dist: cruft; extra == 'dev' Requires-Dist: dask; extra == 'dev' Requires-Dist: ipython; extra == 'dev' Requires-Dist: mypy; extra == 'dev' Requires-Dist: numpy; extra == 'dev' Requires-Dist: pre-commit; extra == 'dev' Requires-Dist: pydantic; extra == 'dev' Requires-Dist: pyqt5; extra == 'dev' Requires-Dist: pytest; extra == 'dev' Requires-Dist: pytest-cov; extra == 'dev' Requires-Dist: pytest-qt; extra == 'dev' Requires-Dist: qtpy; extra == 'dev' Requires-Dist: rich; extra == 'dev' Requires-Dist: ruff; 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; extra == 'test' Requires-Dist: msgspec; python_version >= '3.8' and extra == 'test' Requires-Dist: numpy; extra == 'test' Requires-Dist: pydantic; extra == 'test' Requires-Dist: pyinstaller>=4.0; extra == 'test' Requires-Dist: pytest-codspeed; 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 [![License](https://img.shields.io/pypi/l/psygnal.svg?color=green)](https://github.com/pyapp-kit/psygnal/raw/master/LICENSE) [![PyPI](https://img.shields.io/pypi/v/psygnal.svg?color=green)](https://pypi.org/project/psygnal) ![Conda](https://img.shields.io/conda/v/conda-forge/psygnal) [![Python Version](https://img.shields.io/pypi/pyversions/psygnal.svg?color=green)](https://python.org) [![CI](https://github.com/pyapp-kit/psygnal/actions/workflows/test.yml/badge.svg)](https://github.com/pyapp-kit/psygnal/actions/workflows/test.yml) [![codecov](https://codecov.io/gh/pyapp-kit/psygnal/branch/main/graph/badge.svg?token=qGnz9GXpEb)](https://codecov.io/gh/pyapp-kit/psygnal) [![Documentation Status](https://readthedocs.org/projects/psygnal/badge/?version=latest)](https://psygnal.readthedocs.io/en/latest/?badge=latest) [![Benchmarks](https://img.shields.io/badge/⏱-codspeed-%23FF7B53)](https://codspeed.io/pyapp-kit/psygnal) Psygnal (pronounced "signal") is a pure python implementation of the [observer pattern](https://en.wikipedia.org/wiki/Observer_pattern), with the API of [Qt-style Signals](https://doc.qt.io/qt-5/signalsandslots.html) with (optional) signature and type checking, and support for threading. > This library does ***not*** require or use Qt in any way, It simply implements > a similar observer pattern API. ## Documentation https://psygnal.readthedocs.io/ ### Install ```sh pip install psygnal ``` ```sh conda install -c conda-forge psygnal ``` ## Usage The [observer pattern](https://en.wikipedia.org/wiki/Observer_pattern) is a software design pattern in which an object maintains a list of its dependents ("**observers**"), and notifies them of any state changes – usually by calling a **callback function** provided by the observer. Here is a simple example of using psygnal: ```python from psygnal import Signal class MyObject: # define one or signals as class attributes value_changed = Signal(str) # create an instance my_obj = MyObject() # You (or others) can connect callbacks to your signals @my_obj.value_changed.connect def on_change(new_value: str): print(f"The value changed to {new_value}!") # The object may now emit signals when appropriate, # (for example in a setter method) my_obj.value_changed.emit('hi') # prints "The value changed to hi!" ``` Much more detail available in the [documentation](https://psygnal.readthedocs.io/)! ### Evented Dataclasses A particularly nice usage of the signal pattern is to emit signals whenever a field of a dataclass changes. Psygnal provides an `@evented` decorator that will emit a signal whenever a field changes. It is compatible with `dataclasses` from [the standard library](https://docs.python.org/3/library/dataclasses.html), as well as [attrs](https://www.attrs.org/en/stable/), and [pydantic](https://pydantic-docs.helpmanual.io): ```python from psygnal import evented from dataclasses import dataclass @evented @dataclass class Person: name: str age: int = 0 person = Person('John', age=30) # connect callbacks @person.events.age.connect def _on_age_change(new_age: str): print(f"Age changed to {new_age}") person.age = 31 # prints: Age changed to 31 ``` See the [dataclass documentation](https://psygnal.readthedocs.io/en/latest/dataclasses/) for more details. ## Benchmark history https://pyapp-kit.github.io/psygnal/ and https://codspeed.io/pyapp-kit/psygnal ## Developers ### Debugging While `psygnal` is a pure python module, it is compiled with mypyc to increase performance. To disable all compiled files and run the pure python version, you may run: ```bash python -c "import psygnal.utils; psygnal.utils.decompile()" ``` To return the compiled version, run: ```bash python -c "import psygnal.utils; psygnal.utils.recompile()" ``` The `psygnal._compiled` variable will tell you if you're using the compiled version or not.