Skip to content

Commit 7396cb1

Browse files
committed
address review comments and adjust tests
1 parent e718d5a commit 7396cb1

File tree

5 files changed

+151
-99
lines changed

5 files changed

+151
-99
lines changed

hypothesis-python/src/hypothesis/database.py

Lines changed: 22 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -58,12 +58,12 @@
5858
from watchdog.observers.api import BaseObserver
5959

6060
StrPathT: "TypeAlias" = Union[str, PathLike[str]]
61-
ListenerEventType: "TypeAlias" = Literal["save", "delete", "move"]
61+
ListenerEventType: "TypeAlias" = Literal["save", "delete"]
6262
SaveDataT: "TypeAlias" = tuple[bytes, bytes] # key, value
6363
DeleteDataT: "TypeAlias" = tuple[bytes, Optional[bytes]] # key, value
6464
MoveDataT: "TypeAlias" = tuple[bytes, bytes, bytes] # src, dest, value
6565
ListenerDataT: "TypeAlias" = Union[SaveDataT, DeleteDataT, MoveDataT]
66-
ListenerT: "TypeAlias" = Callable[[ListenerEventType, ListenerDataT], Any]
66+
ListenerT: "TypeAlias" = Callable[[tuple[ListenerEventType, ListenerDataT]], Any]
6767

6868

6969
def _usable_dir(path: StrPathT) -> bool:
@@ -143,7 +143,6 @@ class ExampleDatabase(metaclass=_EDMeta):
143143

144144
def __init__(self) -> None:
145145
self._listeners: list[ListenerT] = []
146-
self._listening = False
147146

148147
@abc.abstractmethod
149148
def save(self, key: bytes, value: bytes) -> None:
@@ -182,8 +181,10 @@ def move(self, src: bytes, dest: bytes, value: bytes) -> None:
182181

183182
def add_listener(self, f: ListenerT, /) -> None:
184183
"""Add a change listener."""
184+
had_listeners = bool(self._listeners)
185185
self._listeners.append(f)
186-
self._update_listening()
186+
if not had_listeners:
187+
self._start_listening()
187188

188189
def remove_listener(self, f: ListenerT, /) -> None:
189190
"""
@@ -193,26 +194,17 @@ def remove_listener(self, f: ListenerT, /) -> None:
193194
if f not in self._listeners:
194195
return
195196
self._listeners.remove(f)
196-
self._update_listening()
197+
if not self._listeners:
198+
self._stop_listening()
197199

198200
def clear_listeners(self) -> None:
199201
"""Remove all change listeners."""
202+
had_listeners = bool(self._listeners)
200203
self._listeners.clear()
201-
self._update_listening()
202-
203-
def _update_listening(self) -> None:
204-
# - start listening if we're moving from zero to some listeners
205-
# - stop listening if we're moving from some to zero listeners
206-
if not self._listening and self._listeners:
207-
self._start_listening()
208-
self._listening = True
209-
elif self._listening and not self._listeners:
204+
if had_listeners:
210205
self._stop_listening()
211-
self._listening = False
212206

213-
def _broadcast_change(
214-
self, event_type: ListenerEventType, data: ListenerDataT
215-
) -> None:
207+
def _broadcast_change(self, event: tuple[ListenerEventType, ListenerDataT]) -> None:
216208
"""
217209
Called when a value has been either added to or deleted from a key in
218210
the underlying database store. event_type is one of "save" or "delete".
@@ -227,7 +219,7 @@ def _broadcast_change(
227219
for changing the file.
228220
"""
229221
for listener in self._listeners:
230-
listener(event_type, data)
222+
listener(event)
231223

232224
def _start_listening(self) -> None:
233225
"""
@@ -285,7 +277,7 @@ def save(self, key: bytes, value: bytes) -> None:
285277
values.add(value)
286278

287279
if changed:
288-
self._broadcast_change("save", (key, value))
280+
self._broadcast_change(("save", (key, value)))
289281

290282
def delete(self, key: bytes, value: bytes) -> None:
291283
value = bytes(value)
@@ -294,7 +286,7 @@ def delete(self, key: bytes, value: bytes) -> None:
294286
values.discard(value)
295287

296288
if changed:
297-
self._broadcast_change("delete", (key, value))
289+
self._broadcast_change(("delete", (key, value)))
298290

299291
def _start_listening(self) -> None:
300292
# declare compatibility with the listener api, but do the actual
@@ -367,7 +359,8 @@ def fetch(self, key: bytes) -> Iterable[bytes]:
367359
def save(self, key: bytes, value: bytes) -> None:
368360
key_path = self._key_path(key)
369361
if key_path.name != self._metakeys_hash:
370-
# add this key to our meta entry of all keys, avoiding infinite recursion.
362+
# add this key to our meta entry of all keys - taking care to avoid
363+
# infinite recursion.
371364
self.save(self._metakeys_name, key)
372365

373366
# Note: we attempt to create the dir in question now. We
@@ -447,6 +440,7 @@ class Handler(FileSystemEventHandler):
447440
def on_created(
448441
_self, event: Union[FileCreatedEvent, DirCreatedEvent]
449442
) -> None:
443+
print("create", event)
450444
# we only registered for the file creation event
451445
assert not isinstance(event, DirCreatedEvent)
452446
# watchdog events are only bytes if we passed a byte path to
@@ -472,11 +466,12 @@ def on_created(
472466
except OSError: # pragma: no cover
473467
return
474468

475-
_broadcast_change("save", (key, value))
469+
_broadcast_change(("save", (key, value)))
476470

477471
def on_deleted(
478472
self, event: Union[FileDeletedEvent, DirDeletedEvent]
479473
) -> None:
474+
print("delete", event)
480475
assert not isinstance(event, DirDeletedEvent)
481476
assert isinstance(event.src_path, str)
482477

@@ -485,9 +480,10 @@ def on_deleted(
485480
if key is None: # pragma: no cover
486481
return
487482

488-
_broadcast_change("delete", (key, None))
483+
_broadcast_change(("delete", (key, None)))
489484

490485
def on_moved(self, event: Union[FileMovedEvent, DirMovedEvent]) -> None:
486+
print("move", event)
491487
assert not isinstance(event, DirMovedEvent)
492488
assert isinstance(event.src_path, str)
493489
assert isinstance(event.dest_path, str)
@@ -505,7 +501,8 @@ def on_moved(self, event: Union[FileMovedEvent, DirMovedEvent]) -> None:
505501
except OSError: # pragma: no cover
506502
return
507503

508-
_broadcast_change("move", (k1, k2, value))
504+
_broadcast_change(("delete", (k1, value)))
505+
_broadcast_change(("save", (k2, value)))
509506

510507
self._observer = Observer()
511508
self._observer.schedule(

hypothesis-python/src/hypothesis/extra/redis.py

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -71,16 +71,19 @@ def _pipeline(
7171
for key in reset_expire_keys:
7272
pipe.expire(self._prefix + key, self._expire_after)
7373
if execute_and_publish:
74-
# pipe.execute returns a value for each operation, which includes
75-
# whatever we did in the yield as a prefix, and the n operations from
76-
# pipe.expire as a suffix. remove that suffix to get just the prefix.
77-
values = pipe.execute()
78-
values = values[: -len(reset_expire_keys)]
79-
if any(value > 0 for value in values):
74+
changed = pipe.execute()
75+
# pipe.execute returns the rows modified for each operation, which includes
76+
# the operations performed during the yield, followed by the n operations
77+
# from pipe.exire. Look at just the operations from during the yield.
78+
changed = changed[: -len(reset_expire_keys)]
79+
if any(count > 0 for count in changed):
8080
assert to_publish is not None
8181
assert event_type is not None
82-
to_publish = (event_type, *(self._encode(v) for v in to_publish))
83-
self.redis.publish(self.listener_channel, json.dumps(to_publish))
82+
self._publish((event_type, to_publish))
83+
84+
def _publish(self, event):
85+
event = (event[0], tuple(self._encode(v) for v in event[1]))
86+
self.redis.publish(self.listener_channel, json.dumps(event))
8487

8588
def _encode(self, value: bytes) -> str:
8689
return base64.b64encode(value).decode("ascii")
@@ -106,20 +109,24 @@ def move(self, src: bytes, dest: bytes, value: bytes) -> None:
106109
self.save(dest, value)
107110
return
108111

109-
with self._pipeline(
110-
src, dest, event_type="move", to_publish=(src, dest, value)
111-
) as pipe:
112+
with self._pipeline(src, dest, execute_and_publish=False) as pipe:
112113
pipe.srem(self._prefix + src, value)
113114
pipe.sadd(self._prefix + dest, value)
114115

116+
changed = pipe.execute()
117+
if changed[0] > 0:
118+
self._publish(("delete", (src, value)))
119+
if changed[1] > 0:
120+
self._publish(("save", (dest, value)))
121+
115122
def _handle_message(self, message: dict) -> None:
116123
# other message types include "subscribe" and "unsubscribe". these are
117124
# sent to the client, but not to the pubsub channel.
118125
assert message["type"] == "message"
119126
data = json.loads(message["data"])
120127
event_type = data[0]
121128
self._broadcast_change(
122-
event_type, tuple(self._decode(v) for v in data[1:]) # type: ignore
129+
(event_type, tuple(self._decode(v) for v in data[1])) # type: ignore
123130
)
124131

125132
def _start_listening(self) -> None:

hypothesis-python/tests/cover/test_database_backend.py

Lines changed: 61 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -515,9 +515,7 @@ def _database_conforms_to_listener_api(
515515
create_db,
516516
*,
517517
flush=None,
518-
supports_move=False,
519518
supports_value_delete=True,
520-
save_on_no_k1_in_move=False,
521519
parent_settings=None,
522520
):
523521
# this function is a big mess to support a bunch of different special cases
@@ -529,13 +527,9 @@ def _database_conforms_to_listener_api(
529527
# * flush is a callable which takes the instantiated db as an argument, and
530528
# is called on every step as an invariant. This lets the database do things
531529
# like, time.sleep to give time for events to fire.
532-
# * supports_move is True if the db fires a "move" event,
533-
# and False if it fires a "delete" followed by a "save" instead.
534530
# * suports_value_delete is True if the db supports passing
535531
# the exact value of a deleted key in "delete" events. The directory database
536532
# notably does not support this, and passes None instead.
537-
# * save_on_no_k1_in_move is True if the db fires a "save" rather than "move" if
538-
# the value is not present in the source key.
539533

540534
@settings(parent_settings)
541535
class TestDatabaseListener(RuleBasedStateMachine):
@@ -553,8 +547,8 @@ def __init__(self):
553547
self.expected_events = []
554548
self.actual_events = []
555549

556-
def listener(event_type, args):
557-
self.actual_events.append((event_type, args))
550+
def listener(event):
551+
self.actual_events.append(event)
558552

559553
self.listener = listener
560554
self.active_listeners = []
@@ -572,9 +566,6 @@ def _expect_delete(self, k, v):
572566
def _expect_save(self, k, v):
573567
self._expect_event("save", (k, v))
574568

575-
def _expect_move(self, k1, k2, v):
576-
self._expect_event("move", (k1, k2, v))
577-
578569
@rule(target=keys, k=st.binary())
579570
def k(self, k):
580571
return k
@@ -629,24 +620,17 @@ def move(self, k1, k2, v):
629620
delete_changed = k1 != k2 and in_k1
630621
self.db.move(k1, k2, v)
631622

632-
# in database backends that support it, this gets emitted as a move
633-
# (*unless* the keys are equal, in which case it is a save).
634-
#
635-
# otherwise it gets emitted as a delete followed by a save, unless
636-
# those did not change the db.
637-
if supports_move:
638-
if k1 == k2 and save_changed:
639-
self._expect_save(k2, v)
640-
elif save_on_no_k1_in_move and not in_k1 and save_changed:
641-
self._expect_save(k2, v)
642-
elif delete_changed or save_changed:
643-
self._expect_move(k1, k2, v)
644-
else:
645-
if delete_changed:
646-
self._expect_delete(k1, v)
647-
if save_changed:
648-
self._expect_save(k2, v)
649-
623+
# A move gets emitted as a delete followed by a save. The
624+
# delete may be omitted if k1==k2, and the save if v in db.fetch(k2).
625+
if delete_changed:
626+
self._expect_delete(k1, v)
627+
if save_changed:
628+
self._expect_save(k2, v)
629+
630+
# it would be nice if this was an @rule, but that runs into race condition
631+
# failures where an event listener is removed immediately after a
632+
# save/delete/move operation, before the listener can fire. This is only
633+
# relevant for DirectoryBasedExampleDatabase.
650634
@invariant()
651635
def events_agree(self):
652636
if flush is not None:
@@ -673,23 +657,18 @@ def test_database_listener_background_write():
673657

674658
def test_can_remove_nonexistent_listener():
675659
db = InMemoryExampleDatabase()
676-
db.remove_listener(lambda *_: _)
660+
db.remove_listener(lambda event: event)
677661

678662

679663
class DoesNotSupportListening(ExampleDatabase):
680-
def save(self, key: bytes, value: bytes) -> None:
681-
pass
682-
683-
def fetch(self, key: bytes) -> Iterable[bytes]:
684-
pass
685-
686-
def delete(self, key: bytes, value: bytes) -> None:
687-
pass
664+
def save(self, key: bytes, value: bytes) -> None: ...
665+
def fetch(self, key: bytes) -> Iterable[bytes]: ...
666+
def delete(self, key: bytes, value: bytes) -> None: ...
688667

689668

690669
def test_warns_when_listening_not_supported():
691670
db = DoesNotSupportListening()
692-
listener = lambda *_: _
671+
listener = lambda event: event
693672

694673
with pytest.warns(
695674
HypothesisWarning, match="does not support listening for changes"
@@ -705,7 +684,7 @@ def test_warns_when_listening_not_supported():
705684
def test_readonly_listener():
706685
db = ReadOnlyDatabase(InMemoryExampleDatabase())
707686

708-
def listener(event_type, data):
687+
def listener(event):
709688
raise AssertionError("ReadOnlyDatabase never fires change events")
710689

711690
db.add_listener(listener)
@@ -751,3 +730,45 @@ def test_metakeys(tmp_path):
751730

752731
db.save(b"k2", b"v1")
753732
assert set(db.fetch(db._metakeys_name)) == {b"k1", b"k2"}
733+
734+
735+
class TracksListens(ExampleDatabase):
736+
def __init__(self):
737+
super().__init__()
738+
self.starts = 0
739+
self.ends = 0
740+
741+
def save(self, key: bytes, value: bytes) -> None: ...
742+
def fetch(self, key: bytes) -> Iterable[bytes]: ...
743+
def delete(self, key: bytes, value: bytes) -> None: ...
744+
745+
def _start_listening(self):
746+
self.starts += 1
747+
748+
def _stop_listening(self):
749+
self.ends += 1
750+
751+
752+
def test_start_end_listening():
753+
db = TracksListens()
754+
755+
def listener1(event):
756+
pass
757+
758+
def listener2(event):
759+
pass
760+
761+
assert db.starts == 0
762+
db.add_listener(listener1)
763+
assert db.starts == 1
764+
db.add_listener(listener2)
765+
assert db.starts == 1
766+
767+
assert db.ends == 0
768+
db.remove_listener(listener2)
769+
assert db.ends == 0
770+
db.remove_listener(listener1)
771+
assert db.ends == 1
772+
773+
db.clear_listeners()
774+
assert db.ends == 1

hypothesis-python/tests/redis/test_redis_exampledatabase.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,14 +109,13 @@ def test_redis_listener():
109109
_database_conforms_to_listener_api(
110110
lambda _path: RedisExampleDatabase(FakeRedis()),
111111
flush=flush_messages,
112-
supports_move=True,
113112
)
114113

115114

116115
def test_redis_listener_explicit():
117116
calls = 0
118117

119-
def listener(event_type, data):
118+
def listener(event):
120119
nonlocal calls
121120
calls += 1
122121

0 commit comments

Comments
 (0)