Hacking Streamlit — Review

Ondřej Popelka
8 min readNov 20, 2023

--

A hacker is a person skilled in information technology who uses their technical knowledge to achieve a goal or overcome an obstacle, within a computerized system by non-standard means.

That quote comes from Wikipedia and that is a very politically correct expression. In my vocabulary, hacking is exploiting some technology to do something that it was not intended for.

Streamlit is a framework for building data applications. Judging by the reviews it apparently works very well for that use case.

I’m not a data engineer and I do not develop data apps. I’m mostly developing back-end services — i.e. APIs, and I was interested how much Streamlit can be exploited to create an ordinary application with front end. It’s been some time since I created my last UI application (and it was in Flutter if I recall correctly). I’m fully aware that I’m not the target audience for Streamlit.

The Task

I decided to create a simple calculator that implements the logic (ok, not really sure what is logical about that) outlined in the xkcd comic. That’s the goal.

Remember, Roman numerals are archaic, so always replace them with modern ones when doing math.

This is the outcome — or see for yourself the complete application:

Screenshot from the final application

It’s just plain wrong task for Streamlit.

The Overture ⭐⭐⭐⭐⭐

Starting development in Streamlit is sooooooo easy — All I need to do is create file some file e.g. main.py and put all the garbage inside:

import streamlit as st


def run() -> None:
if st.button('Click me'):
st.write('Button clicked')


if __name__ == '__main__':
run()

Or I can even write it all in one line if I am feeling more adventurous:

import streamlit as st


def run() -> None:
st.button("Click me", on_click=lambda: st.write("button clicked"))


if __name__ == '__main__':
run()

Then all I need to do is just run python -m streamlit run main.py . Streamlit development approach is basically — just put all the stuff in the main.py file inside the run method, and you’re done, and it works.

The Development ⭐⭐️️

So now I need to create the calculator key board — I want it modelled after my numeric keypad — this one:

My old keyboard

It is a 4 column 5 row layout. In total it is 17 buttons and it’s clearly apparent that writing the logic inline to each button is a no go, so I have to abandon GarbageCoding™️ and return to my usual CleanCoding™. The first step is to move the code to the outside functions — no problem with that on_click argument of a button doesn’t force me to use inline code. Let’s create the buttons:

col1.button('1', on_click=number_clicked, args=[1])
col1.button('2', on_click=number_clicked, args=[2])
col1.button('3', on_click=number_clicked, args=[3])

And so on. Unfortunately, the on_click handler seems not to receive the control, so the button value needs to be passed explicitly — annoying, but acceptable.

Now I would normally define 10 buttons with numbers and place them in a tabular grid. That doesn’t seem to be the Streamlit way. The principle is that controls must be basically created in the order they appear on page (with a little exception of containers).

A table is then created by using columns and then sticking the controls (or whatever else) inside the columns. I have to admire the simplicity of the API, because crude as it is — it works just fine — it just looks a bit awkward:

col1.button('Num Lock', on_click=numlock_clicked)
col1.button('7', on_click=number_clicked, args=[7])
col1.button('4', on_click=number_clicked, args=[4])
col1.button('1', on_click=number_clicked, args=[1])
col1.button('0', on_click=number_clicked, args=[0])

col2.button('/', on_click=operation_clicked, args=['/'])
col2.button('8', on_click=number_clicked, args=[8])
col2.button('5', on_click=number_clicked, args=[5])
col2.button('2', on_click=number_clicked, args=[2])

And so on. There is no templating layer and the code and the UI is tightly coupled. For me this is an interesting turn as I got completely used to loosely coupled application layers — an API returns data, that is consumed and transformed into a model, that is being used be a controller which fills the data from the model into some kind of view or viewmodel.

This is sort of explained by that the entire application — or specifically — the run method is the main event loop which gets executed upon every user interaction. If you need to retain something between the runs, use the session — which works fine, except that you must take care not to do something stupid like write into it unconditionally:

st.session_state['result'] = 0

Since it executes in every interaction, it always needs to be wrapped in some condition — e.g. button clicked or toggle changed, or check if the value is present in case you want to initialize:

if 'result' not in st.session_state:
st.session_state['result'] = 0

Which, of course, is clearly written in the docs, only in hacker mode I don’t read the docs, because then I would risk using the technology correctly.

All in all, it is refreshing to use:

use_roman = st.toggle('Use Roman keyboard', value=False)
use_confuser = not st.toggle('Stop modernizing', value=False)

to both generate the controls and immediately get their value. At the same time it the issue is obvious — if I need to move the controls around, I also need to move around the availability of variables.

I initially wanted to have the toggles below the calculator. But then the values are not available early enough in the code to change the rendering of the buttons. This is easily solvable fortunately, by using on_change and storing the rendering mode in a session variable and using that.

But then we’re back to the need to put more thought into the code organization and moving the logic outside of the element definition and into classical event handlers.

There is no free lunch (nor cake).

The lovely thing about Streamlit is that the API is basically so easy and so well designed, that I don’t need to read the docs. What I expect to be there, actually is there. If something is missing, it probably does not exist. Which I consider a good thing — definitely better then having it hidden by some obscure setting.

Customization ❌

Oh the pain, Oh the pain

Just Plain Old Pain

Now I got to the “requirement” that all buttons are equal, but some buttons are more equal than others. So how to make the plus and enter button span two “table cells” ?

I ended up with add this raw CSS to the page:

/* Enter button */
div[data-testid='stHorizontalBlock'] div[data-testid='column']:nth-child(4)
div[data-testid='element-container']:nth-child(3) button {
background-color: #fcd53f;
height: 176px;
}
div[data-testid='stHorizontalBlock'] div[data-testid='column']:nth-child(4)
div[data-testid='element-container']:nth-child(3) button p {
font-size: 18px;
}

Oh Christ.

This is seriously the only thing that I was swearing at. Why can’t be the widget key be propagated to some data attribute or something. Also why are the data attributes named so weirdly? I’m sure there are some good reasons for this, so I do not wish to pass any easy advice (which I just did!).

All in all, customizing the produced UI is one hell of a pain and took me most of the time.

Richness of the API ⭐⭐⭐⭐️️️️

The API contains all the basic controls — This Is cool. But then there are some, which in my opinion have low value — e.g. the st.video element — I tried to make work with different hosting options, either it did not auto-play, or the size was wrong or something else. In the end it was far easiest to load the video to YouTube and use the provided embed code with unsafe_html.

Unfortunately, I ended up using unsafe_html in 3 places already. I expect that this will become a plague in many applications when they eventually end up being prone to XSS, because Streamlit doesn’t force you to use templates.

Then there is st.baloons which is something Streamlit should pretend never existed — long live <marquee> and <blink>!

Cleanliness of code ⭐️️️️⭐️️️️⭐️️️️⭐️️️️

This connects with the previous one. I’ve seen only about 5 Streamlit apps so far and from the point of code cleanliness, they were complete garbage. Which is not at all Streamlit’s fault, because it is perfectly possible to write clean code in Streamlit.

I ended up with a 90 line run method which defines the whole UI (except styles) which I think it pretty cool. Everything else is in isolated methods.

Testing ⭐⭐⭐⭐⭐➕⭐

AppTest is a very fresh feature and it is a killer feature. A framework for building UI with built-in testing capability is just awesome.

The testing is absolutely easy to do — meaning there is no excuse for not doing it.

import unittest
from streamlit.testing.v1 import AppTest


class TestApp(unittest.TestCase):
def test_run_calc(self) -> None:
at = AppTest.from_file('main.py')
at.run()
self.assertEqual('A confusing calculator :abacus:', at.title[0].value)

button_9 = at.button[12]
button_9.click()
at.run()
self.assertEqual('110', at.markdown[1].value)

Yes, the value 110 is actually correct when the button 9 is pressed.

I ran into an issue with containers (and reported another one) — which — given that the application testing capability was announced about a month ago is perfectly fine.

Accessing the UI elements with a numeric index at.button[12] is a bit chaotic, but with a little helper method it looks fine.

    def find_button(at: AppTest, label: str) -> Button:
for i in range(at.button.len):
if at.button[i].label == label:
return at.button[i]

...
button_9 = find_button('9')

The Finale⭐⭐⭐⭐⭐️️️️

Deploying a Streamlit app is a breze, all I need is a Dockerfile as I can take care of the rest. I can simplify the initially provided Dockerfile by incorporating static code (i don’t need to install the application from a repository). I also prefer to use pyproject.toml instead of requirements.txt. But all of this is kindergarten level easy — no surprises here.

FROM python:3.11-slim

EXPOSE 8501
RUN python -m pip install --no-cache-dir --upgrade pip

COPY . /app
WORKDIR /app
RUN pip install --no-cache-dir .

CMD ["streamlit", "run", "main.py", "--server.port=8501", "--server.address=0.0.0.0"]

Or you can check the entire application code at my Github or you can see the working application.

Verdict

Streamlit is super cool framework for prototyping simple applications. I highly recommend everyone to check it out even if you are not living in the Python world or if you don’t intend to have the final application in Python.

The simplicity of the APIs and usage is really worth it. However, once you see the requests for customization coming in, run away as fast as you can.

Measured by the Number of WTFs per minute Streamlit is an awesome thing to try. And mind you I completely ignored all the cool stuff like chat integration, data frame support, screen cast recording, magic outputs, etc.

--

--