Skip to content

Commit

Permalink
refactor: remove PSYGNAL_UNCOMPILED flag. (#183)
Browse files Browse the repository at this point in the history
* fix: trying to fix uncompiled flag

* refactor: removing uncompiled flag

* test: change test

* test: add tests

* docs: fix quotes

* fix: add back typing for evented model

* docs: more docs updates

* test: skip on windows

* test: change skip
  • Loading branch information
tlambert03 committed Feb 21, 2023
1 parent aecc202 commit 108000c
Show file tree
Hide file tree
Showing 10 changed files with 171 additions and 102 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ __pycache__/
# C extensions
*.so
*.c
# temporarily disabled mypyc files
*.so_BAK
*.pyd_BAK

# Distribution / packaging
.Python
Expand Down
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 35 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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/)!
Expand Down Expand Up @@ -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.
67 changes: 44 additions & 23 deletions docs/index.md
Original file line number Diff line number Diff line change
@@ -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()
Expand All @@ -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.
Expand All @@ -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*
59 changes: 17 additions & 42 deletions src/psygnal/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion src/psygnal/_evented_decorator.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
overload,
)

from ._group_descriptor import SignalGroupDescriptor
from psygnal._group_descriptor import SignalGroupDescriptor

if TYPE_CHECKING:
from typing_extensions import Literal
Expand Down
25 changes: 25 additions & 0 deletions src/psygnal/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
9 changes: 4 additions & 5 deletions tests/test_group.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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()
Expand Down
18 changes: 0 additions & 18 deletions tests/test_psygnal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading

0 comments on commit 108000c

Please sign in to comment.