diff --git a/.gitignore b/.gitignore index 96491ad8..c91ac637 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,9 @@ __pycache__/ # C extensions *.so *.c +# temporarily disabled mypyc files +*.so_BAK +*.pyd_BAK # Distribution / packaging .Python diff --git a/Makefile b/Makefile index 145792e8..15f797ee 100644 --- a/Makefile +++ b/Makefile @@ -22,6 +22,7 @@ clean: rm -rf wheelhouse rm -f `find src -type f -name '*.c' ` rm -f `find src -type f -name '*.so' ` + rm -f `find src -type f -name '*.so_BAK' ` rm -rf coverage.xml # run benchmarks for all commits since v0.1.0 diff --git a/README.md b/README.md index da33fb87..925ca0f3 100644 --- a/README.md +++ b/README.md @@ -9,11 +9,13 @@ [![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 -[Qt-style Signals](https://doc.qt.io/qt-5/signalsandslots.html) with -(optional) signature and type checking, and support for threading. +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. -> Note: this library does _not_ require Qt. It just implements a similar pattern of inter-object communication with loose coupling. +> This library does ***not*** require or use Qt in any way, It simply implements +> a similar observer pattern API. ## Documentation @@ -31,22 +33,28 @@ conda install -c conda-forge psygnal ## Usage -A very simple example: +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) - shutting_down = Signal() +# 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}!") -my_obj.value_changed.emit('hi') +# 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/)! @@ -87,12 +95,27 @@ See the [dataclass documentation](https://psygnal.readthedocs.io/en/latest/datac 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 Cython to increase -performance. To import `psygnal` in uncompiled mode, without deleting the -shared library files from the psyngal module, set the environment variable -`PSYGNAL_UNCOMPILED` before importing psygnal. The `psygnal._compiled` variable -will tell you if you're running the compiled library or not. +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. diff --git a/docs/index.md b/docs/index.md index 852272ec..8b485031 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,36 +1,27 @@ # Overview -Psygnal (pronounced "signal") is a pure python implementation of -[Qt-style Signals](https://doc.qt.io/qt-5/signalsandslots.html) with -(optional) signature and type checking, and support for threading. +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. !!! important This library does ***not*** require or use Qt in any way. - It simply implements the [observer pattern](https://en.wikipedia.org/wiki/Observer_pattern): inter-object communication with loose coupling. - -**Performance** is a high priority, as signals are often emitted frequently, -[benchmarks](https://www.talleylambert.com/psygnal/) are routinely measured. -Code is compiled using [mypyc](https://mypyc.readthedocs.io/en/latest/index.html). - -!!! tip - - To run psygnal *without* using the compiled code, set an - `PSYGNAL_UNCOMPILED` environment variable to `1`. - (You can also just delete the `.so` files in the `psygnal` folder). + It simply implements a similar observer pattern API. ## Quickstart -A very simple example: +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 -# import psygnal.Signal from psygnal import Signal class MyObject: # define one or signals as class attributes value_changed = Signal(str) - shutting_down = Signal() # create an instance my_obj = MyObject() @@ -40,20 +31,25 @@ my_obj = MyObject() def on_change(new_value: str): print(f"The value changed to {new_value}!") -# you emit signals when appropriate (e.g. when the value changes) -my_obj.value_changed.emit('hi') +# 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!" ``` Please see the [Basic Usage](usage.md) guide for an overview on how to use psygnal, or the [API Reference](API/index.md) for details on a specific class or method. +### Features + In addition to the `Signal` object, psygnal contains: -- an `@evented` decorator that converts standard dataclasses (or attrs, or - pydantic) into [evented dataclasses](dataclasses.md) that emit signals when +- a method to convert standard dataclasses (or `attrs`, `pydantic`, or `msgspec` + objects) into [evented dataclasses](dataclasses.md) that emit signals when fields change. -- a number of ["evented" versions of mutable python containers](API/containers.md) -- an ["evented" pydantic model](API/model.md) that emits signals whenever a model field changes +- a number of ["evented" versions of mutable python + containers](API/containers.md) +- an ["evented" pydantic model](API/model.md) that emits signals whenever a + model field changes - [throttling/debouncing](API/throttler.md) decorators - an experimental ["evented object proxy"](API/proxy.md) - a few other [utilities](API/utilities.md) for dealing with events. @@ -71,3 +67,28 @@ from conda: ```sh conda install -c conda-forge psygnal ``` + +## Performance and Compiled Code + +**Performance** is a high priority, as signals are often emitted frequently, +[benchmarks](https://pyapp-kit.github.io/psygnal/) are routinely measured and +[tested during ci](https://codspeed.io/pyapp-kit/psygnal). + +Code is compiled using [mypyc](https://mypyc.readthedocs.io/en/latest/index.html). + +!!! tip + + To run psygnal *without* using the compiled code, 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()" + ``` + + *These commands rename the compiled files, and require write + permissions to the psygnal source directory* diff --git a/src/psygnal/__init__.py b/src/psygnal/__init__.py index 6d4ed73b..7a6e4433 100644 --- a/src/psygnal/__init__.py +++ b/src/psygnal/__init__.py @@ -8,22 +8,11 @@ if TYPE_CHECKING: PackageNotFoundError = Exception + from ._evented_model import EventedModel def version(package: str) -> str: """Return version.""" - from types import ModuleType - - from . import _group, _signal - from ._evented_model import EventedModel - - Signal = _signal.Signal - SignalInstance = _signal.SignalInstance - EmitLoopError = _signal.EmitLoopError - _compiled = _signal._compiled - SignalGroup = _group.SignalGroup - EmissionInfo = _group.EmissionInfo - else: # hiding this import from type checkers so mypyc can work on both 3.7 and later try: @@ -56,40 +45,26 @@ def version(package: str) -> str: "throttled", ] -from ._evented_decorator import evented -from ._group_descriptor import SignalGroupDescriptor, get_evented_namespace, is_evented if os.getenv("PSYGNAL_UNCOMPILED"): + import warnings - def _import_purepy_mod(name: str) -> "ModuleType": - """Import stuff from the uncompiled python module, for debugging.""" - import importlib.util - import os - import sys - - ROOT = os.path.dirname(__file__) - MODULE_PATH = os.path.join(ROOT, f"{name}.py") - spec = importlib.util.spec_from_file_location(name, MODULE_PATH) - if spec is None or spec.loader is None: # pragma: no cover - raise ImportError(f"Could not find pure python module: {MODULE_PATH}") - module = importlib.util.module_from_spec(spec) - sys.modules[spec.name] = module - spec.loader.exec_module(module) - return module - - m = _import_purepy_mod("_signal") - Signal, SignalInstance, _compiled = m.Signal, m.SignalInstance, m._compiled - EmitLoopError = m.EmitLoopError # type: ignore - m = _import_purepy_mod("_group") - SignalGroup, EmissionInfo = m.SignalGroup, m.EmissionInfo - m = _import_purepy_mod("_throttler") - throttled, debounced = m.throttled, m.debounced - del _import_purepy_mod + 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.)" + ) -else: - from ._group import EmissionInfo, SignalGroup - from ._signal import EmitLoopError, Signal, SignalInstance, _compiled - from ._throttler import debounced, throttled +from ._evented_decorator import evented +from ._group import EmissionInfo, SignalGroup +from ._group_descriptor import ( + SignalGroupDescriptor, + get_evented_namespace, + is_evented, +) +from ._signal import EmitLoopError, Signal, SignalInstance, _compiled +from ._throttler import debounced, throttled def __getattr__(name: str) -> Any: diff --git a/src/psygnal/_evented_decorator.py b/src/psygnal/_evented_decorator.py index ae4366a5..3bca2571 100644 --- a/src/psygnal/_evented_decorator.py +++ b/src/psygnal/_evented_decorator.py @@ -10,7 +10,7 @@ overload, ) -from ._group_descriptor import SignalGroupDescriptor +from psygnal._group_descriptor import SignalGroupDescriptor if TYPE_CHECKING: from typing_extensions import Literal diff --git a/src/psygnal/utils.py b/src/psygnal/utils.py index 54d3b67a..4732d7a8 100644 --- a/src/psygnal/utils.py +++ b/src/psygnal/utils.py @@ -2,6 +2,7 @@ from contextlib import contextmanager from functools import partial +from pathlib import Path from typing import Any, Callable, Iterable, Iterator, Tuple from ._signal import SignalInstance @@ -73,3 +74,27 @@ def iter_signal_instances( 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: + 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: + for path in Path(__file__).parent.rglob(f"**/*{suffix}{_BAK}"): + path.rename(path.with_suffix(suffix)) diff --git a/tests/test_group.py b/tests/test_group.py index 9074141d..db639ef7 100644 --- a/tests/test_group.py +++ b/tests/test_group.py @@ -10,11 +10,6 @@ class MyGroup(SignalGroup): sig2 = Signal(str) -class MyStrictGroup(SignalGroup, strict=True): - sig1 = Signal(int) - sig2 = Signal(int) - - def test_signal_group(): assert not MyGroup.is_uniform() group = MyGroup() @@ -28,6 +23,10 @@ def test_signal_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() diff --git a/tests/test_psygnal.py b/tests/test_psygnal.py index e99af47b..0f8566e8 100644 --- a/tests/test_psygnal.py +++ b/tests/test_psygnal.py @@ -669,24 +669,6 @@ class Emitter(dict): e.signal.emit(1) -def test_debug_import(monkeypatch): - """Test that PSYGNAL_UNCOMPILED always imports the pure python file.""" - import sys - - import psygnal._signal - - if not psygnal._signal.__file__.endswith(".py"): - assert psygnal._compiled - - monkeypatch.delitem(sys.modules, "psygnal") - monkeypatch.delitem(sys.modules, "psygnal._signal") - monkeypatch.setenv("PSYGNAL_UNCOMPILED", "1") - - import psygnal - - assert not psygnal._compiled - - def test_property_connect(): class A: def __init__(self): diff --git a/tests/test_utils.py b/tests/test_utils.py index 3745d6d6..4ca8d1b4 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,7 +1,12 @@ +import os +import sys +from pathlib import Path from unittest.mock import Mock, call +import pytest + from psygnal import Signal -from psygnal.utils import monitor_events +from psygnal.utils import decompile, monitor_events, recompile def test_event_debugger(capsys): @@ -30,3 +35,38 @@ class M: captured = capsys.readouterr() assert captured.out == "sig.emit(1, 2)\nsig.emit(3, 4)\n" + + +OLD_WIN = bool((sys.version_info < (3, 8)) and os.name == "nt") + + +@pytest.mark.skipif(OLD_WIN, reason="can't rewrite open files on Windows") +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