Skip to content

Commit a98d70a

Browse files
authored
feat: support custom Python events with optional cancellation (#368)
* feat: support custom Python events with optional cancellation Previously, Python plugins could not define custom events — only C++ events exposed via pybind11 were available. This adds a PyEvent trampoline class that allows Python subclasses of Event to work correctly, and moves Cancellable to a pure Python mixin so custom events can opt into cancellation via multiple inheritance. Also removes the deprecated `cancelled` property (replaced by `is_cancelled` in a prior release). * fix: align event name resolution for custom Python events register_events and PyEvent::getEventName() now agree on naming: built-in events (endstone.*) use the short class name, custom plugin events use the fully qualified module.qualname to avoid collisions. Also adds runtime integration tests for custom event fire/handle cycle including cancellation semantics via plugin manager. * fix: use virtual isCancelled() in EventHandler dispatch EventHandler::callEvent() was reading the C++ cancelled_ field directly, but custom Python events store cancellation state in a Python attribute. Use virtual dispatch so PyEvent::isCancelled() is called correctly. * fix: always return false in Event::isCancelled()
1 parent 80087f7 commit a98d70a

13 files changed

Lines changed: 324 additions & 74 deletions

File tree

endstone/event/__init__.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,33 @@ def decorator(f):
1616
return decorator
1717

1818

19+
class Cancellable:
20+
"""
21+
Represents an event that may be cancelled by a plugin or the server.
22+
"""
23+
24+
@property
25+
def is_cancelled(self) -> bool:
26+
"""
27+
Gets or sets the cancellation state of this event.
28+
29+
A cancelled event will not be executed in the server, but will still pass to other plugins.
30+
"""
31+
return getattr(self, "_cancelled", False)
32+
33+
@is_cancelled.setter
34+
def is_cancelled(self, arg1: bool) -> None: # noqa
35+
setattr(self, "_cancelled", arg1)
36+
37+
def cancel(self) -> None:
38+
"""
39+
Cancel this event.
40+
41+
A cancelled event will not be executed in the server, but will still pass to other plugins.
42+
"""
43+
self.is_cancelled = True
44+
45+
1946
__getattr__, __dir__, __all__ = lazy.attach(
2047
"endstone._python",
2148
submod_attrs={
@@ -40,7 +67,6 @@ def decorator(f):
4067
"BlockPistonRetractEvent",
4168
"BlockPlaceEvent",
4269
"BroadcastMessageEvent",
43-
"Cancellable",
4470
"ChunkEvent",
4571
"ChunkLoadEvent",
4672
"ChunkUnloadEvent",
@@ -92,4 +118,4 @@ def decorator(f):
92118
},
93119
)
94120

95-
__all__.extend(["EventPriority", "event_handler"])
121+
__all__.extend(["Cancellable", "EventPriority", "event_handler"])

endstone/event/__init__.pyi

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ class Event:
123123
"""
124124
Represents an event.
125125
"""
126+
def __init__(self, is_async: bool = False) -> None: ...
126127
@property
127128
def event_name(self) -> str:
128129
"""
@@ -146,24 +147,20 @@ class Cancellable:
146147
Represents an event that may be cancelled by a plugin or the server.
147148
"""
148149
@property
149-
def cancelled(self) -> bool:
150-
"""
151-
Gets or sets the cancellation state of this event. A cancelled event will not be executed in the server, but will still pass to other plugins. [Warning] Deprecated: Use is_cancelled instead.
152-
"""
153-
...
154-
@cancelled.setter
155-
def cancelled(self, arg1: bool) -> None: ...
156-
@property
157150
def is_cancelled(self) -> bool:
158151
"""
159-
Gets or sets the cancellation state of this event. A cancelled event will not be executed in the server, but will still pass to other plugins.
152+
Gets or sets the cancellation state of this event.
153+
154+
A cancelled event will not be executed in the server, but will still pass to other plugins.
160155
"""
161156
...
162157
@is_cancelled.setter
163158
def is_cancelled(self, arg1: bool) -> None: ...
164159
def cancel(self) -> None:
165160
"""
166-
Cancel this event. A cancelled event will not be executed in the server, but will still pass to other plugins.
161+
Cancel this event.
162+
163+
A cancelled event will not be executed in the server, but will still pass to other plugins.
167164
"""
168165
...
169166

endstone/plugin/__init__.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,9 +85,11 @@ def register_events(self, listener: object) -> None:
8585
event_cls = params[0].annotation
8686
priority = getattr(func, "_priority")
8787
ignore_cancelled = getattr(func, "_ignore_cancelled")
88-
self.server.plugin_manager.register_event(
89-
getattr(event_cls, "NAME", event_cls.__name__), func, priority, self, ignore_cancelled
90-
)
88+
if event_cls.__module__.startswith("endstone."):
89+
event_name = event_cls.__name__
90+
else:
91+
event_name = f"{event_cls.__module__}.{event_cls.__qualname__}"
92+
self.server.plugin_manager.register_event(event_name, func, priority, self, ignore_cancelled)
9193

9294
@property
9395
def config(self) -> dict:

include/endstone/event/event.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ class Event {
6363

6464
private:
6565
[[nodiscard]] virtual bool isCancellable() const { return false; }
66+
[[nodiscard]] virtual bool isCancelled() const { return false; }
6667

6768
template <class T>
6869
friend class Cancellable;

include/endstone/event/event_handler.h

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ class EventHandler {
6868
if (event.getEventName() != event_) {
6969
return;
7070
}
71-
if (event.isCancellable() && event.cancelled_ && isIgnoreCancelled()) {
71+
if (event.isCancellable() && event.isCancelled() && isIgnoreCancelled()) {
7272
return;
7373
}
7474
executor_(event);

src/endstone/python/endstone_python.cpp

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
#include "endstone/detail.h"
2020
#include "endstone/endstone.hpp"
21+
#include "event.h"
2122
#include "registry.h"
2223
#include "type_caster.h"
2324

@@ -32,7 +33,7 @@ void init_command(py::module &, py_class<CommandSender> &command_sender);
3233
void init_damage(py::module_ &);
3334
void init_effect(py::module_ &);
3435
void init_enchantments(py::module_ &);
35-
void init_event(py::module_ &, py::class_<Event> &event);
36+
void init_event(py::module_ &, py::class_<Event, PyEvent> &event);
3637
void init_form(py::module_ &);
3738
void init_game_mode(py::module_ &);
3839
void init_inventory(py::module_ &, py::class_<ItemStack> &item_stack);
@@ -123,7 +124,7 @@ PYBIND11_MODULE(_python, m) // NOLINT(*-use-anonymous-namespace)
123124

124125
// Forward declaration, see:
125126
// https://pybind11.readthedocs.io/en/stable/advanced/misc.html#avoiding-c-types-in-docstrings
126-
auto event = py::class_<Event>(m_event, "Event", "Represents an event.");
127+
auto event = py::class_<Event, PyEvent>(m_event, "Event", "Represents an event.");
127128
auto permissible = py_class<Permissible>(
128129
m_permissions, "Permissible",
129130
"Represents an object that may become a server operator and can be assigned permissions.");

src/endstone/python/event.cpp

Lines changed: 10 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -12,48 +12,33 @@
1212
// See the License for the specific language governing permissions and
1313
// limitations under the License.
1414

15+
#include "event.h"
16+
1517
#include "endstone_python.h"
1618

1719
namespace py = pybind11;
1820

1921
namespace endstone::python {
2022

21-
void init_event(py::module_ &m, py::class_<Event> &event)
23+
void init_event(py::module_ &m, py::class_<Event, PyEvent> &event)
2224
{
2325
py::native_enum<EventResult>(m, "EventResult", "enum.Enum")
2426
.value("DENY", EventResult::Deny)
2527
.value("DEFAULT", EventResult::Default)
2628
.value("ALLOW", EventResult::Allow)
2729
.finalize();
2830

29-
event.def_property_readonly("event_name", &Event::getEventName, "Gets a user-friendly identifier for this event.")
31+
event.def(py::init<bool>(), py::arg("is_async") = false)
32+
.def_property_readonly("event_name", &Event::getEventName, "Gets a user-friendly identifier for this event.")
3033
.def_property_readonly("is_asynchronous", &Event::isAsynchronous, "Whether the event fires asynchronously.");
3134

3235
py::class_<ICancellable>(m, "Cancellable", "Represents an event that may be cancelled by a plugin or the server.")
33-
.def_property(
34-
"cancelled",
35-
[](const ICancellable &self) {
36-
PyErr_WarnEx(PyExc_FutureWarning,
37-
"The event.cancelled property is deprecated and will be removed from endstone in a future "
38-
"version. Use event.is_cancelled instead.",
39-
1);
40-
return self.isCancelled();
41-
},
42-
[](ICancellable &self, const bool value) {
43-
PyErr_WarnEx(PyExc_FutureWarning,
44-
"The event.cancelled property is deprecated and will be removed from endstone in a future "
45-
"version. Use event.is_cancelled instead.",
46-
1);
47-
self.setCancelled(value);
48-
},
49-
"Gets or sets the cancellation state of this event. A cancelled event will not be executed in "
50-
"the server, but will still pass to other plugins. [Warning] Deprecated: Use is_cancelled instead.")
5136
.def_property("is_cancelled", &ICancellable::isCancelled, &ICancellable::setCancelled,
52-
"Gets or sets the cancellation state of this event. A cancelled event will not be executed in "
53-
"the server, but will still pass to other plugins.")
37+
"Gets or sets the cancellation state of this event.\n\n"
38+
"A cancelled event will not be executed in the server, but will still pass to other plugins.")
5439
.def("cancel", &ICancellable::cancel,
55-
"Cancel this event. A cancelled event will not be executed in the server, but will still pass to other "
56-
"plugins.");
40+
"Cancel this event.\n\n"
41+
"A cancelled event will not be executed in the server, but will still pass to other plugins.");
5742

5843
// Actor events
5944
py::class_<ActorEvent<Actor>, Event>(m, "ActorEvent", "Represents an Actor-related event.")
@@ -125,8 +110,7 @@ void init_event(py::module_ &m, py::class_<Event> &event)
125110
"Called when a block is broken by a player.")
126111
.def_property_readonly("player", &BlockBreakEvent::getPlayer, py::return_value_policy::reference,
127112
"Gets the Player that is breaking the block involved in this event.");
128-
py::class_<BlockExplodeEvent, BlockEvent, ICancellable>(m, "BlockExplodeEvent",
129-
"Called when a block explodes.")
113+
py::class_<BlockExplodeEvent, BlockEvent, ICancellable>(m, "BlockExplodeEvent", "Called when a block explodes.")
130114
.def_property(
131115
"block_list",
132116
[](const BlockExplodeEvent &self) {

src/endstone/python/event.h

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// Copyright (c) 2024, The Endstone Project. (https://endstone.dev) All Rights Reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
#pragma once
16+
17+
#include <string>
18+
19+
#include <fmt/format.h>
20+
#include <pybind11/pybind11.h>
21+
22+
#include "endstone/event/cancellable.h"
23+
#include "endstone/event/event.h"
24+
25+
namespace py = pybind11;
26+
27+
namespace endstone::python {
28+
29+
class PyEvent : public Event, public ICancellable {
30+
public:
31+
using Event::Event;
32+
[[nodiscard]] std::string getEventName() const override
33+
{
34+
py::gil_scoped_acquire gil;
35+
const auto self = py::cast(this);
36+
const auto type = py::type::of(self);
37+
auto module = type.attr("__module__").cast<std::string>();
38+
auto qualname = type.attr("__qualname__").cast<std::string>();
39+
if (module.starts_with("endstone.")) {
40+
return qualname;
41+
}
42+
return fmt::format("{}.{}", module, qualname);
43+
}
44+
45+
[[nodiscard]] bool isCancelled() const override
46+
{
47+
py::gil_scoped_acquire gil;
48+
return py::getattr(py::cast(this), "_cancelled", py::bool_(false)).cast<bool>();
49+
}
50+
51+
void setCancelled(bool cancel) override
52+
{
53+
py::gil_scoped_acquire gil;
54+
py::setattr(py::cast(this), "_cancelled", py::bool_(cancel));
55+
}
56+
57+
void cancel() override { setCancelled(true); }
58+
59+
private:
60+
[[nodiscard]] bool isCancellable() const override
61+
{
62+
py::gil_scoped_acquire gil;
63+
const py::object cls = py::module_::import("endstone.event").attr("Cancellable");
64+
return py::isinstance(py::cast(this), cls);
65+
}
66+
};
67+
68+
} // namespace endstone::python
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
from endstone import Server
2+
from endstone.event import Cancellable, Event, EventPriority, event_handler
3+
from endstone.plugin import Plugin
4+
5+
6+
class CustomEvent(Event):
7+
"""A simple non-cancellable custom event."""
8+
9+
pass
10+
11+
12+
class CancellableCustomEvent(Event, Cancellable):
13+
"""A cancellable custom event."""
14+
15+
pass
16+
17+
18+
class TestCustomEvent:
19+
def test_fire_and_handle(self, server: Server, plugin: Plugin):
20+
handled = []
21+
22+
class Listener:
23+
@event_handler
24+
def on_custom(self, event: CustomEvent):
25+
handled.append(True)
26+
27+
plugin.register_events(Listener())
28+
server.plugin_manager.call_event(CustomEvent())
29+
assert len(handled) == 1
30+
31+
def test_event_name(self):
32+
event = CustomEvent()
33+
assert event.event_name.endswith("test_custom_event.CustomEvent")
34+
35+
def test_handler_receives_event_instance(self, server: Server, plugin: Plugin):
36+
received = []
37+
38+
class Listener:
39+
@event_handler
40+
def on_custom(self, event: CustomEvent):
41+
received.append(event)
42+
43+
plugin.register_events(Listener())
44+
event = CustomEvent()
45+
server.plugin_manager.call_event(event)
46+
assert len(received) == 1
47+
assert received[0] is event
48+
49+
50+
class TestCancellableCustomEvent:
51+
def test_fire_and_handle(self, server: Server, plugin: Plugin):
52+
handled = []
53+
54+
class Listener:
55+
@event_handler
56+
def on_custom(self, event: CancellableCustomEvent):
57+
handled.append(True)
58+
59+
plugin.register_events(Listener())
60+
server.plugin_manager.call_event(CancellableCustomEvent())
61+
assert len(handled) == 1
62+
63+
def test_cancel_in_handler(self, server: Server, plugin: Plugin):
64+
class Listener:
65+
@event_handler
66+
def on_custom(self, event: CancellableCustomEvent):
67+
event.cancel()
68+
69+
plugin.register_events(Listener())
70+
event = CancellableCustomEvent()
71+
server.plugin_manager.call_event(event)
72+
assert event.is_cancelled
73+
74+
def test_ignore_cancelled_skips_handler(self, server: Server, plugin: Plugin):
75+
results = []
76+
77+
class CancellingListener:
78+
@event_handler(priority=EventPriority.LOW)
79+
def on_custom(self, event: CancellableCustomEvent):
80+
event.cancel()
81+
results.append("low")
82+
83+
class IgnoringListener:
84+
@event_handler(priority=EventPriority.HIGH, ignore_cancelled=True)
85+
def on_custom(self, event: CancellableCustomEvent):
86+
results.append("high_ignore")
87+
88+
plugin.register_events(CancellingListener())
89+
plugin.register_events(IgnoringListener())
90+
event = CancellableCustomEvent()
91+
server.plugin_manager.call_event(event)
92+
assert "low" in results
93+
assert "high_ignore" not in results
94+
95+
def test_default_handler_still_runs_when_cancelled(self, server: Server, plugin: Plugin):
96+
results = []
97+
98+
class CancellingListener:
99+
@event_handler(priority=EventPriority.LOW)
100+
def on_custom(self, event: CancellableCustomEvent):
101+
event.cancel()
102+
results.append("low")
103+
104+
class DefaultListener:
105+
@event_handler(priority=EventPriority.HIGH)
106+
def on_custom(self, event: CancellableCustomEvent):
107+
results.append("high_default")
108+
109+
plugin.register_events(CancellingListener())
110+
plugin.register_events(DefaultListener())
111+
event = CancellableCustomEvent()
112+
server.plugin_manager.call_event(event)
113+
assert "low" in results
114+
assert "high_default" in results
115+
assert event.is_cancelled

0 commit comments

Comments
 (0)