gradio-app / gradio

Build and share delightful machine learning apps, all in Python. 🌟 Star to support our work!
http://www.gradio.app
Apache License 2.0
30.36k stars 2.26k forks source link

Add event listener support to render blocks #8243

Closed aliabid94 closed 3 weeks ago

aliabid94 commented 1 month ago

This PR allows adding event listeners inside render blocks. Take a look at the code from demo/render_merge below:

import gradio as gr

with gr.Blocks() as demo:
    text_count = gr.Slider(1, 5, step=1, label="Textbox Count")

    @gr.render(inputs=[text_count], triggers=[text_count.change])
    def render_count(count):
        boxes = []
        for i in range(count):
            box = gr.Textbox(key=i, label=f"Box {i}")
            boxes.append(box)

        def merge(*args):
            return " ".join(args)

        merge_btn.click(merge, boxes, output)

        def clear():
            return [""] * count

        clear_btn.click(clear, None, boxes)

        def countup():
            return [i for i in range(count)]

        count_btn.click(countup, None, boxes)

    with gr.Row():
        merge_btn = gr.Button("Merge")
        clear_btn = gr.Button("Clear")
        count_btn = gr.Button("Count")

    output = gr.Textbox()

This is done by removing previous event listeners from a render block and attaching the new event listeners on every re-render. A previous assumption is no longer true: that dependencies is a static list of event listeners for an app, and event listener can be identified by it's fn_index which is it's index within this list of listeners. We've replaced this with a concept of an id for each dependency instead of using indices, and different sessions are able to extend the default set of event listeners by adding new dependencies with different ids.

Closes: https://github.com/gradio-app/gradio/issues/4689 Closes: https://github.com/gradio-app/gradio/issues/7739 Closes: https://github.com/gradio-app/gradio/issues/2570 Closes: https://github.com/gradio-app/gradio/issues/2107 (once https://github.com/gradio-app/gradio/pull/8297 is merged in)

gradio-pr-bot commented 1 month ago

🪼 branch checks and previews

• Name Status URL
Spaces ready! Spaces preview
Website ready! Website preview
Storybook ready! Storybook preview
:unicorn: Changes detected! Details

Install Gradio from this PR

pip install https://gradio-builds.s3.amazonaws.com/6cb2f6e95867ff849650e1378538141f1bba4c5b/gradio-4.31.5-py3-none-any.whl

Install Gradio Python Client from this PR

pip install "gradio-client @ git+https://github.com/gradio-app/gradio@6cb2f6e95867ff849650e1378538141f1bba4c5b#subdirectory=client/python"
gradio-pr-bot commented 1 month ago

🦄 change detected

This Pull Request includes changes to the following packages.

Package Version
@gradio/app patch
@gradio/client patch
gradio patch
gradio_client patch

With the following changelog entry.

Add event listener support to render blocks

Maintainers or the PR author can modify the PR title to modify this entry.

#### Something isn't right? - Maintainers can change the version label to modify the version bump. - If the bot has failed to detect any changes, or if this pull request needs to update multiple packages to different versions or requires a more comprehensive changelog entry, maintainers can [update the changelog file directly](https://github.com/gradio-app/gradio/edit/render_deps/.changeset/bitter-garlics-live.md).
jameszhou02 commented 1 month ago

Awesome work! Any chance I can get the pip download like last time or is the release coming soon?

abidlabs commented 1 month ago

@jameszhou02 You can try:

pip install https://gradio-builds.s3.amazonaws.com/b7e04a1a4cff8019f933ec147f33ee80f1d6aa0c/gradio-4.31.1-py3-none-any.whl

abidlabs commented 1 month ago

I'm seeing the same behavior that @freddyaboulton described, where the event does not happen at the first trigger, but happens at the second trigger and subsequent triggers. For example, in this demo:

import random 

with gr.Blocks()  as demo:
    t = gr.Textbox()
    @gr.render(triggers=[t.submit])
    def inference():
        with gr.Row():
            for i in range(random.randint(3, 8)):
                gr.Audio()

if __name__ == "__main__":
    demo.launch()        

When I first submit the textbox, nothing happens, but each subsequent submit works.

abidlabs commented 1 month ago

It seems that the load() event cannot be used as a trigger in gr.render -- edit: I think its the same bug as above since the event isn't being triggered the first time:

import random 
import gradio as gr

with gr.Blocks() as demo:    
    @gr.render(triggers=[demo.load])
    def inference():
        for i in range(random.randint(3, 8)):
            gr.Audio()

if __name__ == "__main__":
    demo.launch()        
abidlabs commented 1 month ago

When we write docs for this, we should clarify that any event listeners that use rendered components must be inside the render function. For example, this works:

import random 
import gradio as gr

with gr.Blocks() as demo:    
    @gr.render(triggers=[demo.load])
    def inference():
        audios = []
        for i in range(random.randint(3, 8)):
            audios.append(gr.Audio())

        count.click(lambda *args:len(args), audios, number)

    count = gr.Button("Count")
    number = gr.Number()

if __name__ == "__main__":
    demo.launch()        

but a user might write this, which doesn't:

import random 
import gradio as gr

audios = []

with gr.Blocks() as demo:    
    @gr.render(triggers=[demo.load])
    def inference():
        audios = []
        for i in range(random.randint(3, 8)):
            audios.append(gr.Audio())

    count = gr.Button("Count")
    number = gr.Number()

    count.click(lambda *args:len(args), audios, number)

if __name__ == "__main__":
    demo.launch()        
abidlabs commented 1 month ago

Didn't find anything else, great work @aliabid94!!

aliabid94 commented 4 weeks ago

Saw a weird behavior with reload mode where updating an input to the slider caused the render to trigger

Actually a general bug with reload mode that all change listeners trigger on a refresh. Since render is listening to slider.change, it gets triggered. Separate bug I can tackle separately but not too high priority.

Sometimes the render does not trigger? When I update the slider, the event does not run. Doesn't happen all the time though.

Fixed!

You cannot define an event which skips the queue from within a render. If that event is triggered, an error happens.

Fixed!

It seems that the load() event cannot be used as a trigger in gr.render -- edit: I think its the same bug as above since the event isn't being triggered the first time:

Fixed!

Should be ready to merge if there are no other issues

abidlabs commented 4 weeks ago

Nice @aliabid94! I'll give this another pass to ensure that this works. Can we add docs for this as part of this PR so that we can fully release this feature once this PR is merged in? (Btw we can update or remove this section in the docs: https://www.gradio.app/guides/controlling-layout#variable-number-of-outputs). I've added a bunch of issues that this closes to the parent commment

abidlabs commented 4 weeks ago

Actually 2 small things I noticed:

E.g. the slider here is not interactive automatically;

import gradio as gr

with gr.Blocks() as demo:    
    s = gr.Slider(1, 4, step=1)

    @gr.render(inputs=s, triggers=[s.change])
    def inference(num):
        with gr.Row():
            for i in range(num):
                gr.Textbox()

demo.launch()        
abidlabs commented 3 weeks ago

One other thing I noticed was that a @gr.render automatically creates a gr.Column for the rendered components, even if the parent BlocksContext is a gr.Row.

I.e. this does not work as expected:

import gradio as gr

with gr.Blocks() as demo:
    b = gr.Button("Add Textbox")
    num = gr.State(0)

    b.click(lambda x:x+1, num, num)

    with gr.Row():
        @gr.render(inputs=num)
        def show_textbox(n):
            for i in range(n):
                gr.Textbox()

demo.launch()

(it renders the textboxes in a column). However, this works as expected:

import gradio as gr

with gr.Blocks() as demo:
    b = gr.Button("Add Textbox")
    num = gr.State(0)

    b.click(lambda x:x+1, num, num)

    @gr.render(inputs=num)
    def show_textbox(n):
        with gr.Row():
            for i in range(n):
                gr.Textbox()

demo.launch()
abidlabs commented 3 weeks ago

And one other thing that I noticed is that the trigger_mode="always_last" behavior doesn't seem to apply to gr.render(). Here's what I mean. I built a Space with this code:

import gradio as gr

with gr.Blocks() as demo:
    gr.Markdown("# Train a Text Classifier with Synthetic Data")
    labels = gr.Dropdown(choices=[], value=[], label="Classes", allow_custom_value=True, multiselect=True)

    @gr.render(inputs=[labels])
    def show_textbox(labels_):
        with gr.Row():
            for label in labels_:
                gr.TextArea(label=f"Samples for class: {label}")
        if len(labels_)>=2:
            with gr.Row():
                gr.Button("TRAIN!", variant="primary")

demo.launch()

If I quickly change the value of the dropdown by backspacing and deleting the choices, you can see that a couple of the textareas are still visible at the end:

https://github.com/gradio-app/gradio/assets/1778297/8d262d9f-5161-42bd-bb15-745231f71c4f

aliabid94 commented 3 weeks ago

Thanks for the reviews! Will add docs, and some more tests and cleanup in follow up PR

acylam commented 3 weeks ago

And one other thing that I noticed is that the trigger_mode="always_last" behavior doesn't seem to apply to gr.render(). Here's what I mean. I built a Space with this code:

import gradio as gr

with gr.Blocks() as demo:
    gr.Markdown("# Train a Text Classifier with Synthetic Data")
    labels = gr.Dropdown(choices=[], value=[], label="Classes", allow_custom_value=True, multiselect=True)

    @gr.render(inputs=[labels])
    def show_textbox(labels_):
        with gr.Row():
            for label in labels_:
                gr.TextArea(label=f"Samples for class: {label}")
        if len(labels_)>=2:
            with gr.Row():
                gr.Button("TRAIN!", variant="primary")

demo.launch()

If I quickly change the value of the dropdown by backspacing and deleting the choices, you can see that a couple of the textareas are still visible at the end: Screen.Recording.2024-05-21.at.11.33.06.PM.mov

Awesome feature! I'm just wondering if there's a way to define the event listener outside gr.Blocks? This is crucial for readability in large applications.

abidlabs commented 3 weeks ago

Hi @acylam great point. One thing that you can do is import Blocks from other files and .render() them (this .render() is unrelated to gr.render() -- sorry!) inside the main Blocks object. Like this:

Say this is your utils.py file:

import gradio as gr

with gr.Blocks() as func_blocks:
    labels = gr.Dropdown(choices=[], value=[], label="Classes", allow_custom_value=True, multiselect=True)

    @gr.render(inputs=[labels])
    def show_textbox(labels_):
        with gr.Row():
            for label in labels_:
                gr.TextArea(label=f"Samples for class: {label}")
        if len(labels_)>=2:
            with gr.Row():
                gr.Button("TRAIN!", variant="primary")

and in your main app.py file, you could do:

import gradio as gr
from utils import func_blocks

with gr.Blocks() as demo:
    gr.Markdown("# Train a Text Classifier with Synthetic Data")

    func_blocks.render()

demo.launch()

Does this work for you? cc @aliabid94 if he has any other recommendations

acylam commented 3 weeks ago

Hi @acylam great point. One thing that you can do is import Blocks from other files and .render() them (this .render() is unrelated to gr.render() -- sorry!) inside the main Blocks object. Like this:

Say this is your utils.py file:

import gradio as gr

with gr.Blocks() as func_blocks:
    labels = gr.Dropdown(choices=[], value=[], label="Classes", allow_custom_value=True, multiselect=True)

    @gr.render(inputs=[labels])
    def show_textbox(labels_):
        with gr.Row():
            for label in labels_:
                gr.TextArea(label=f"Samples for class: {label}")
        if len(labels_)>=2:
            with gr.Row():
                gr.Button("TRAIN!", variant="primary")

and in your main app.py file, you could do:

import gradio as gr
from utils import func_blocks

with gr.Blocks() as demo:
    gr.Markdown("# Train a Text Classifier with Synthetic Data")

    func_blocks.render()

demo.launch()

Does this work for you? cc @aliabid94 if he has any other recommendations

@abidlabs Thanks for the quick reply. This works, but I was hoping for a way to separate event handling from UI elements. Kind of like how you'd define the structure of the UI in HTML and the event handling logic in JavaScript in traditional web development? So ideally, gr.Markdown and labels=gr.Dropdown will be defined in one file, and show_text will be defined in a separate file. I hope I'm making sense. It could also be I'm completely missing the design of gradio.

cc @aliabid94

pngwn commented 3 weeks ago

I've searched high and low and I can't find the issue or notes anywhere. I did find this message in slack that I wrote a long time ago:

Eventually i envisage many kinds of custom components:

  • fully custom with a new python + html/js/css
    • either extending from existing gradio components or fully custom implementations
  • pure frontend components that use some gradio python class. Basically a new frontend for existing components
  • Pure python components.
    • Something like the above, composition for components with a new interface
    • or new python implementation for an existing component that will use an existing frontend. (Maybe is changes stuff like preprocessing or w/e)

This one seems relevant here:

composition of [python] components with a new interface

The idea being that you could somehow contain a component within a function or block and wrap the events (or just forward them) with a new interface for better abstraction.

The original use case was:

gr.Render was but a dream back then but I could see the above approach working for render as well.

Imo it is very valuable for complex apps and probably the last missing piece in terms of making gradio a really expressive way of building complex UI.