Skip to content

Commit d2d4b91

Browse files
authored
Merge pull request #26 from craigds/cds-modernization
Modernize packaging and add type hints
2 parents fddbcad + e453862 commit d2d4b91

File tree

13 files changed

+287
-178
lines changed

13 files changed

+287
-178
lines changed

.github/workflows/test.yml

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,42 @@
11
name: tests
22

33
on:
4-
- push
5-
- pull_request
4+
push:
5+
branches: [master]
6+
pull_request:
67

78
jobs:
8-
build:
9+
test:
910
runs-on: ubuntu-latest
1011
strategy:
12+
fail-fast: false
1113
matrix:
12-
python-version: ["3.6", "3.8", "3.10"]
14+
python-version: ["3.8", "3.10", "3.12", "3.13"]
1315

1416
steps:
15-
- uses: actions/checkout@v3
17+
- uses: actions/checkout@v4
18+
- name: Install uv
19+
uses: astral-sh/setup-uv@v4
1620
- name: Set up Python ${{ matrix.python-version }}
17-
uses: actions/setup-python@v4
21+
uses: actions/setup-python@v5
1822
with:
1923
python-version: ${{ matrix.python-version }}
2024
- name: Install dependencies
21-
run: |
22-
python -m pip install --upgrade pip
23-
python -m pip install tox tox-gh-actions
25+
run: uv pip install --system tox tox-uv tox-gh-actions
2426
- name: Test with tox
25-
env:
26-
FORCE_COLOR: "YES"
2727
run: tox
28+
29+
lint:
30+
runs-on: ubuntu-latest
31+
steps:
32+
- uses: actions/checkout@v4
33+
- name: Install uv
34+
uses: astral-sh/setup-uv@v4
35+
- name: Set up Python
36+
uses: actions/setup-python@v5
37+
with:
38+
python-version: "3.12"
39+
- name: Install ruff
40+
run: uv pip install --system ruff
41+
- name: Run ruff
42+
run: ruff check .

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,7 @@ __pycache__/
77
.tox
88
.python-version
99
.vscode/
10+
.ruff_cache/
11+
uv.lock
12+
13+
.claude/settings.local.json

CLAUDE.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Overview
6+
7+
django-fieldsignals provides Django signals that fire when specific model fields change. It exposes two signals: `pre_save_changed` and `post_save_changed`, which work like Django's built-in `pre_save`/`post_save` but only trigger when specified fields have actually changed.
8+
9+
## Commands
10+
11+
```bash
12+
# Run tests (requires Django to be installed)
13+
pytest -v
14+
15+
# Run tests across all supported Python/Django versions
16+
tox
17+
18+
# Run a single test
19+
pytest -v fieldsignals/tests/test_signals.py::TestPreSave::test_pre_save_changed
20+
21+
# Lint
22+
ruff check .
23+
24+
# Type check
25+
tox -e mypy
26+
```
27+
28+
## Architecture
29+
30+
The library is small:
31+
- `fieldsignals/signals.py` - Core implementation with `ChangedSignal` base class and `PreSaveChangedSignal`/`PostSaveChangedSignal` subclasses
32+
- `fieldsignals/__init__.py` - Exports `pre_save_changed` and `post_save_changed` signal instances
33+
34+
Key design points:
35+
- Signals must be connected after the Django app registry is ready (in `AppConfig.ready()`)
36+
- Field change tracking stores original values on the model instance in `_fieldsignals_originals`
37+
- Deferred fields are excluded from change detection
38+
- No support for `ManyToManyField` or reverse FK relations

MANIFEST.in

Lines changed: 0 additions & 4 deletions
This file was deleted.

fieldsignals/__init__.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1-
from .signals import pre_save_changed, post_save_changed # noqa
1+
from __future__ import annotations
22

3-
__version__ = (0, 6, 0)
3+
from .signals import post_save_changed, pre_save_changed
4+
5+
__all__ = ["post_save_changed", "pre_save_changed"]
6+
__version__ = "0.8.0"

fieldsignals/py.typed

Whitespace-only changes.

fieldsignals/signals.py

Lines changed: 73 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,23 @@
1+
from __future__ import annotations
2+
13
from copy import deepcopy
4+
from typing import TYPE_CHECKING, Any
25

36
from django.apps import apps
47
from django.core.exceptions import AppRegistryNotReady
5-
from django.db.models.fields.related import ForeignObjectRel
68
from django.db.models import signals as _signals
9+
from django.db.models.fields.related import ForeignObjectRel
710
from django.dispatch import Signal
811

12+
if TYPE_CHECKING:
13+
from collections.abc import Callable, Hashable
14+
15+
from django.db import models
916

1017
__all__ = ("pre_save_changed", "post_save_changed")
1118

1219

13-
IMMUTABLE_TYPES_WHITELIST = tuple([tuple, frozenset, float, str, int])
20+
IMMUTABLE_TYPES_WHITELIST: tuple[type, ...] = (tuple, frozenset, float, str, int)
1421

1522

1623
class ChangedSignal(Signal):
@@ -19,7 +26,14 @@ class ChangedSignal(Signal):
1926
The given receiver is only called when one or more of the given fields has changed.
2027
"""
2128

22-
def connect(self, receiver, sender=None, fields=None, dispatch_uid=None, **kwargs):
29+
def connect( # type: ignore[override]
30+
self,
31+
receiver: Callable[..., Any],
32+
sender: type[models.Model] | None = None,
33+
fields: list[str] | None = None,
34+
dispatch_uid: Hashable | None = None,
35+
**kwargs: Any,
36+
) -> None:
2337
"""
2438
Connect a FieldSignal. Usage::
2539
@@ -28,7 +42,7 @@ def connect(self, receiver, sender=None, fields=None, dispatch_uid=None, **kwarg
2842

2943
if not apps.models_ready:
3044
# We require access to Model._meta.get_fields(), which isn't available yet.
31-
# (This error would be raised below anyway, but we want to add a more meaningful message)
45+
# (This error would be raised below anyway, but we want a more meaningful message)
3246
raise AppRegistryNotReady(
3347
"django-fieldsignals signals must be connected after the app cache is ready. "
3448
"Connect the signal in your AppConfig.ready() handler."
@@ -48,40 +62,41 @@ def connect(self, receiver, sender=None, fields=None, dispatch_uid=None, **kwarg
4862
if not isinstance(sender, type):
4963
raise ValueError("sender should be a model class")
5064

51-
def is_reverse_rel(f):
65+
def is_reverse_rel(f: Any) -> bool:
5266
return f.many_to_many or f.one_to_many or isinstance(f, ForeignObjectRel)
5367

5468
if fields is None:
55-
fields = sender._meta.get_fields()
56-
fields = [f for f in fields if not is_reverse_rel(f)]
69+
resolved_fields = sender._meta.get_fields()
70+
resolved_fields = [f for f in resolved_fields if not is_reverse_rel(f)]
5771
else:
58-
fields = [f for f in sender._meta.get_fields() if f.name in set(fields)]
59-
for f in fields:
72+
resolved_fields = [f for f in sender._meta.get_fields() if f.name in set(fields)]
73+
for f in resolved_fields:
6074
if is_reverse_rel(f):
6175
raise ValueError(
6276
"django-fieldsignals doesn't handle reverse related fields "
63-
"({f.name} is a {f.__class__.__name__})".format(f=f)
77+
f"({f.name} is a {f.__class__.__name__})"
6478
)
6579

66-
if not fields:
80+
if not resolved_fields:
6781
raise ValueError("fields must be non-empty")
6882

69-
proxy_receiver = self._make_proxy_receiver(receiver, sender, fields)
83+
proxy_receiver = self._make_proxy_receiver(receiver, sender, resolved_fields)
7084

71-
super(ChangedSignal, self).connect(
72-
proxy_receiver, sender=sender, weak=False, dispatch_uid=dispatch_uid
73-
)
85+
super().connect(proxy_receiver, sender=sender, weak=False, dispatch_uid=dispatch_uid)
7486

7587
### post_init : initialize the list of fields for each instance
76-
def post_init_closure(sender, instance, **kwargs):
77-
self.get_and_update_changed_fields(receiver, instance, fields)
88+
def post_init_closure(sender: type, instance: models.Model, **kwargs: Any) -> None:
89+
self.get_and_update_changed_fields(receiver, instance, resolved_fields)
7890

7991
_signals.post_init.connect(
80-
post_init_closure, sender=sender, weak=False, dispatch_uid=(self, receiver)
92+
post_init_closure,
93+
sender=sender,
94+
weak=False,
95+
dispatch_uid=(self, receiver), # type: ignore[arg-type]
8196
)
8297
self.connect_source_signals(sender)
8398

84-
def connect_source_signals(self, sender):
99+
def connect_source_signals(self, sender: type) -> None:
85100
"""
86101
Connects the source signals required to trigger updates for this
87102
ChangedSignal.
@@ -91,30 +106,41 @@ def connect_source_signals(self, sender):
91106
# override in subclasses
92107
pass
93108

94-
def _make_proxy_receiver(self, receiver, sender, fields):
109+
def _make_proxy_receiver(
110+
self,
111+
receiver: Callable[..., Any],
112+
sender: type,
113+
fields: list[Any],
114+
) -> Callable[..., Any]:
95115
"""
96116
Takes a receiver function and creates a closure around it that knows what fields
97117
to watch. The original receiver is called for an instance iff the value of
98118
at least one of the fields has changed since the last time it was called.
99119
"""
100120

101-
def pr(instance, *args, **kwargs):
102-
changed_fields = self.get_and_update_changed_fields(
103-
receiver, instance, fields
104-
)
121+
def pr(instance: Any, *args: Any, **kwargs: Any) -> None:
122+
changed_fields = self.get_and_update_changed_fields(receiver, instance, fields)
105123
if changed_fields:
106124
receiver(
107-
instance=instance, changed_fields=changed_fields, *args, **kwargs
125+
*args,
126+
instance=instance,
127+
changed_fields=changed_fields,
128+
**kwargs,
108129
)
109130

110-
pr._original_receiver = receiver
111-
pr._fields = fields
131+
pr._original_receiver = receiver # type: ignore[attr-defined]
132+
pr._fields = fields # type: ignore[attr-defined]
112133

113134
pr.__doc__ = receiver.__doc__
114135
pr.__name__ = receiver.__name__
115136
return pr
116137

117-
def get_and_update_changed_fields(self, receiver, instance, fields):
138+
def get_and_update_changed_fields(
139+
self,
140+
receiver: Callable[..., Any],
141+
instance: Any,
142+
fields: list[Any],
143+
) -> dict[str, tuple[Any, Any]]:
118144
"""
119145
Takes a receiver and a model instance, and a list of field instances.
120146
Gets the old and new values for each of the given fields, and stores their
@@ -135,7 +161,7 @@ def get_and_update_changed_fields(self, receiver, instance, fields):
135161
if key not in instance._fieldsignals_originals:
136162
instance._fieldsignals_originals[key] = {}
137163
originals = instance._fieldsignals_originals[key]
138-
changed_fields = {}
164+
changed_fields: dict[str, tuple[Any, Any]] = {}
139165

140166
deferred_fields = instance.get_deferred_fields()
141167

@@ -159,24 +185,35 @@ def get_and_update_changed_fields(self, receiver, instance, fields):
159185

160186

161187
class PreSaveChangedSignal(ChangedSignal):
162-
def _on_model_pre_save(self, sender, instance=None, **kwargs):
188+
def _on_model_pre_save(
189+
self, sender: type, instance: Any = None, **kwargs: Any
190+
) -> list[tuple[Callable[..., Any], Any]]:
163191
return self.send(sender, instance=instance)
164192

165-
def connect_source_signals(self, sender):
193+
def connect_source_signals(self, sender: type) -> None:
166194
_signals.pre_save.connect(
167-
self._on_model_pre_save, sender=sender, dispatch_uid=id(self)
195+
self._on_model_pre_save,
196+
sender=sender,
197+
dispatch_uid=id(self), # type: ignore[arg-type]
168198
)
169199

170200

171201
class PostSaveChangedSignal(ChangedSignal):
172202
def _on_model_post_save(
173-
self, sender, instance=None, created=None, using=None, **kwargs
174-
):
203+
self,
204+
sender: type,
205+
instance: Any = None,
206+
created: bool | None = None,
207+
using: str | None = None,
208+
**kwargs: Any,
209+
) -> list[tuple[Callable[..., Any], Any]]:
175210
return self.send(sender, instance=instance, created=created, using=using)
176211

177-
def connect_source_signals(self, sender):
212+
def connect_source_signals(self, sender: type) -> None:
178213
_signals.post_save.connect(
179-
self._on_model_post_save, sender=sender, dispatch_uid=id(self)
214+
self._on_model_post_save,
215+
sender=sender,
216+
dispatch_uid=id(self), # type: ignore[arg-type]
180217
)
181218

182219

fieldsignals/tests/__init__.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1 @@
1-
import os
2-
from .test_signals import *
1+
# Tests are discovered by pytest, no need to export here

fieldsignals/tests/settings.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
SECRET_KEY = "test"
2+
INSTALLED_APPS = ["fieldsignals"]
3+
USE_TZ = True

0 commit comments

Comments
 (0)