-
Notifications
You must be signed in to change notification settings - Fork 841
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[key bindings] Add dynamic "$event.[something]" mini-language to our key bindings management #543
Conversation
scroll_to_placeholders_keys_to_bind = ",".join( | ||
[str(i) for i in range(placeholders_count)] | ||
) | ||
self.bind( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Practical use of this new feature: rather than calling self.bind()
10 times, we can now call it once and have the $event.key
injected into the handler
"""Count the number of parameters in a callable""" | ||
return len(signature(func).parameters) | ||
def count_parameters(func: Callable) -> tuple[int, bool]: | ||
"""Count the number of parameters in a callable, and checks if the last positional one is a "capture all" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I made a slight change to this function because of an edge case: if the parameters are (1, 'a', True)
for example, and the function uses *arg
to capture all the positional arguments, we were seeing only one arg and injected only the first one (1
is this example).
With this update we use the already instantiated Signature object to also determine if the callable is using such a "capture all" argument, which will allow callers of this function to inject 1
, 'a'
and True
to the callable if that's the case.
src/textual/actions.py
Outdated
@@ -32,6 +42,21 @@ def parse(action: str) -> tuple[str, tuple[Any, ...]]: | |||
) | |||
|
|||
|
|||
def _process_dynamic_event_params(action_params_str: str, event: Event) -> str: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It works but it is definitely a bit hacky to be honest - and also limited, as the only things we can inject with this mini language are scalar values.
On one hand it certainly limits the potential security issues of this feature (if the keybinding is taken from a database for example, and contains sequences such as $event.__class__.os.environ
to expose the environment variables the app is running with )
But on the other hand it prevents passing the Event instance itself for example, which I guess could be handy if we wanted to do checks such as this in the event handler:
def action_on_digit(self, key:str, sender):
if sender is left_panel_widget: # <-- not possible, since we cannot pass objects with this syntax
do_something()
We can always inject is the string representation of the sender's __class__
with this mini-language, but it's more limited.
Ah, the toughness of trade-offs... 😅 ¯_(ツ)_/¯
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am fine with it being limited to scalars. For precisely the reason you gave. These bindings could come from external sources, and we don't want to expose or run any code.
src/textual/app.py
Outdated
@@ -931,21 +931,20 @@ def bell(self) -> None: | |||
"""Play the console 'bell'.""" | |||
self.console.bell() | |||
|
|||
async def press(self, key: str) -> bool: | |||
async def press(self, event: events.Key) -> bool: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
the action
method now needs to be able to optionally receive the event, so we can process the $event.[something]
placeholders of the binding expression
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not keen on this. The name no longer makes sense. You press a key, but you don't press an event.
Could you construct the events.Key
within this method?
@@ -25,13 +25,15 @@ | |||
|
|||
_SYNC_START_SEQUENCE = TERMINAL_MODES_ANSI_SEQUENCES["sync_start"] | |||
|
|||
_DEFAULT_SIZE = Size(20, 10) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
for some tests, such as this key bindings new one, we really don't care about the size of the app 🙂 - let's make this size optional!
await wait_for_key_processing() | ||
assert app.key_binding_result == ( | ||
( | ||
"Ellipsis", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
All the $event.[something]
dynamic parameters are scalar-ified, so for example,:
- the Python ellipsis becomes
"Ellipsis"
$event.__class__.__mro__
becomes the string representation of the event class' MRO
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A few changes as discussed:
- White list the allows substitutions within the event object. Attributes must be simple atomic types (which can be parsed with literal_eval), raise an error if not.
- Do the substitution with the repr of the object. This will limit what could be done, but reduce the possibility of breaking the action
src/textual/_callback.py
Outdated
def count_parameters(func: Callable) -> int: | ||
"""Count the number of parameters in a callable""" | ||
return len(signature(func).parameters) | ||
def count_parameters(func: Callable) -> tuple[int, bool]: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The change to the functionality as rendered the name inaccurate, since its does more than count. Is there a better name?
src/textual/actions.py
Outdated
|
||
class ActionError(Exception): | ||
pass | ||
|
||
|
||
re_action_params = re.compile(r"([\w\.]+)(\(.*?\))") | ||
# We use our own "mini language" for dynamic event properties injections: | ||
# e.g. `'$event.key'` will inject the pressed key, `$event.sender` the string representation of the sender, etc. | ||
re_injected_event = re.compile(r"\$(event[\w.]+)") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We probably shouldn't restrict ourselves to just event
. Maybe that all we ever user, but it seems an arbitrary restriction.
src/textual/actions.py
Outdated
@@ -32,6 +42,21 @@ def parse(action: str) -> tuple[str, tuple[Any, ...]]: | |||
) | |||
|
|||
|
|||
def _process_dynamic_event_params(action_params_str: str, event: Event) -> str: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am fine with it being limited to scalars. For precisely the reason you gave. These bindings could come from external sources, and we don't want to expose or run any code.
src/textual/app.py
Outdated
@@ -931,21 +931,20 @@ def bell(self) -> None: | |||
"""Play the console 'bell'.""" | |||
self.console.bell() | |||
|
|||
async def press(self, key: str) -> bool: | |||
async def press(self, event: events.Key) -> bool: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not keen on this. The name no longer makes sense. You press a key, but you don't press an event.
Could you construct the events.Key
within this method?
src/textual/actions.py
Outdated
@@ -32,6 +42,21 @@ def parse(action: str) -> tuple[str, tuple[Any, ...]]: | |||
) | |||
|
|||
|
|||
def _process_dynamic_event_params(action_params_str: str, event: Event) -> str: | |||
for dynamic_event_match in re.finditer(re_injected_event, action_params_str): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I have a feeling this can be done with a single re.sub
and without using format.
1ee1b53
to
9b12010
Compare
@willmcgugan As discussed I updated the code for a new "action bindings mini-language" machinery: 9b12010 🙂 Wrapping dynamic expressions in quotes or not wrapping them? 🤔My first shot didn't require the use of quotes around dynamic parameters, as we said, so one could simply use an expression such as But if instead of this we still wrap dynamic expressions in quotes we can then let It's a trade-off, for sure, and I would have preferred to be able to use Classes can opt-in to allow their attributes use in dynamic action expressionsRegarding the explicit "opt-in" of classes to their attributes in such expressions...
|
@willmcgugan if you want to take a look at my last iteration on this PR, so I have a chance to address your comments before I'm off for the next 2 weeks... 🙂 🏖️ --> #543 (comment) |
@drbenton I think I might have to defer this one for later. Needs a bit of a deep dive. No worries though, it can wait until after your break! |
9b12010
to
a9691e4
Compare
@willmcgugan branch is now rebased, ready for your input regarding #543 (comment) 🙂 |
Going to put this one on hold for now. There probably is a requirement for this, but the current solution of substitution and then parsing with |
It's not as hard as it may seem to implement our own |
With this PR we can now use a "mini-language" to inject event-related data into the key bindings' handlers
e.g.
self.bind("1,2,3", "digit_pressed('$event.key')")
So our previous expression becomes this:
Limits of this "action binding mini-language"
As explained in one of my comments on the GitHub diff, I also realised that this "action binding mini-language" has some strong limits, which can be a good thing for security but also has limits.
We can pass
$event.key
to the action method for example, but we cannot pass the instance of the Event itself, or the sender instance - because this SDL is based onast.literal_eval
, which intentionally doesn't allow using custom objects.I guess that time and dog-fooding will tell us if expressions such as
$event.sender
(which is the string representation of the sender, rather than the sender instance) is enough, or if we should rather start thinking of something else? 🙂Potential future improvements: dependency injection?
One potential way to solve this would be to use "a la Pytest" / "a la FastAPI" dependency injection...
So if the action method declares a param named
event
for example we detect that and inject the Event instance for this parameter. Same with a param namedsender
, etc. 🙂Dependency injection would also allow users to not use error-prone "Python-syntax-in-a-string expressions" - i.e. it's easy to miss the syntax error in something like this:
(a closing single quote is missing)
closes #496