Technical Documentation: h2oai/wave
ℹ️ Provenance: Multi-Tier Ingestion · CodeWiki Reference
Overview (README.md)
H2O Wave
New! R Language API Build and control Wave dashboards using R!
H2O Wave is a software stack for building beautiful, low-latency, realtime, browser-based applications and dashboards entirely in Python/R without using HTML, Javascript, or CSS.
H2O Wave excels at capturing information from multiple sources and broadcasting them live over the web, letting you build and deploy realtime analytics with dramatically less effort.
- Download / How to install
- Documentation: Getting Started / Widgets / Gallery and Examples / Python API
- Developing
- Discuss / Support / Q&A / Ideas
- Report a problem
- Blog / @h2o_wave
Live demos
- Wave Tour - 200+ interactive examples.
- Wave University - Learn Wave basics interactively.
- Wave Theme Generator - Create your color theme.
Responsive Layouts
Code: https://github.com/h2oai/wave/tree/main/py/demo
Themes
(Coming soon!)
80+ components, 250+ examples
More at https://wave.h2o.ai/docs/examples
License
H2O Wave is licensed under the Apache License 2.0. See LICENSE for more information.
Getting Started (`website/docs/getting-started.md`)
title: Introduction
H2O Wave lets you build and deploy amazing, realtime analytics with dramatically less effort.
What is H2O Wave?
H2O Wave is a software stack for building beautiful, low-latency, realtime, browser-based applications and dashboards entirely in Python without using HTML, Javascript, or CSS.
It excels at capturing data, visualizations, and graphics from multiple sources and broadcasting them live over the web.
What can I do with it?
H2O Wave gives your Python programs the ability to push content to connected clients as it happens, in realtime. In other words, it lets your program display up-to-date information without asking your users to hit their browser's reload button.
You can use H2O Wave for:
- Dashboards and visualizations for live monitoring.
- Live information displays: news, tickers, health, or performance data.
- Apps that require instant notifications, updates, events, or alerts.
- Apps that involve messaging: chat, bots, etc.
- Collaborative apps: whiteboards, sharing, etc.
You can also use H2O Wave when you find yourself reaching for a web application framework - it can handle regular (non-realtime) apps just fine.
How do I use it?
H2O Wave's mental model is simple, but powerful[^1]:
- Your instance holds a collection of pages.
- To change a page, simply grab a reference to a page, change it, and save it.
That's it. Your changes are now visible to everyone.
This simplicity makes it fast, fun, and easy to rapidly build and deploy interactive applications without having to reason about client/server, HTTP request/response, browser quirks, or front-end development.
The API is concise and elegant, freeing you to create in broad strokes, tackling high level ideas first and polishing up the details later.
Show me some code
Here's a bean counter. Clicking the button increments the bean count:
And here's how it's written:
from h2o_wave import Q, main, app, ui
bean_count = 0
@app('/counter')
async def serve(q: Q):
global bean_count
# Was the 'increment' button clicked?
if q.args.increment:
bean_count += 1
# Display a form on the page
q.page['beans'] = ui.form_card(
box='1 1 1 2',
items=[
ui.text_xl('Beans!'),
ui.button(name='increment', label=f'{bean_count} beans'),
],
)
# Save the page
await q.page.save()What's included?
The SDK ships batteries-included with a wide variety of user interface widgets and charts. You also get to use your favorite Python libraries seamlessly - anything that works in a Jupyter notebook works in H2O Wave - including Altair, Bokeh, H2O, Keras, Matplotlib, Plotly, PyTorch, Seaborn, TensorFlow, Vega-lite, and others. H2O Wave lets you use and broadcast results from all of these libraries, in realtime.
H2O Wave is not only a programming toolkit but a programmable content server as well: it can capture, retain, and relay information efficiently in realtime. The content server (or Wave server) is written in Go, a ~10MB static executable without runtime dependencies[^2]. It currently ships with a Python language driver, but is language-agnostic (an R language driver is under development).
The Wave server stores all the content and acts as a hub between your user's web browser and your apps. Therefore, it must be up and running before you launch Wave apps. Typically, you only need one Wave server to serve several apps.
+---------+
+------------->+ app1.py |
| +---------+
|
+---------+ +----+-----+ +---------+
| Browser +------>+ Server +------->+ app2.py |
+---------+ +----+-----+ +---------+
|
| +---------+
+------------->+ app3.py |
+---------+In Summary
H2O Wave is rapid application development for a more... civilized age[^3].
Also, this page was mostly hyperbole, so let's download it and take it for a spin, shall we.
[^1]: The model parallels retained mode graphics, with compositing performed on an remote server.
[^2]: Runs anywhere Go executables run, which is almost everywhere.
[^3]: Hat tip to xkcd.
Interactive learning
If you don't feel like going through the docs because you are more of a hands-on person, you can try our Wave University app.
Within your activated virtual environment, run:
wave learnThe command requires h2o_wave_university to be installed. To do that, run:
pip install h2o_wave_university:::tip
Wave University is a standalone package that can be pip-installed and run independently. After installation, you will be able to launch it via wave-university command within your python environment.
:::
Apps (`website/docs/apps.md`)
title: Wave Apps
A Wave app is the primary mechanism to publish interactive web content in Wave.
A Wave app can publish content and handle user interactions, unlike a Wave script, which can publish content but not handle user interactions.
Structure
Here is the skeleton of a Wave app:
# Note: Main must be imported even though it is not used.
from h2o_wave import main, app, Q, ui
@app('/foo')
async def serve(q: Q):
# Modify the page
q.page['qux'] = ui.some_card()
# Save the page
await q.page.save()An app typically imports four symbols from h2o_wave:
main: An ASGI-compatible function. See Deployment.app: A decorator for your query handler (or request handler).Q: A class that represents the query sent to your query handler.ui: The module containing the API for drawing UI elements.
@app() has one required argument - the route your app is interested in (in this case /foo). Whenever a user performs any action at /foo - access the page, reload it, click a button, access a menu, enter text, and so on - the query handler serve() is called. The details about what action was performed, and who performed the action, are available in the argument passed to serve(), the query context q (of type Q).
Wave apps are run using the wave run command, which accepts the name of the Python module in which main is imported from h2o_wave.
:::warning
Do not remove imported main despite linters saying it's not used. It is used for marking the entrypoint file of your app.
:::
If your app is contained in app.py, run it like this:
wave run appOr,
wave run app.pyIf your app is contained in path/to/app.py, run it like this:
wave run path.to.appOr,
wave run path/to/app.pyWhen run, the app starts an event loop listening for user interaction events, and announces itself to the Wave server. The Wave server then starts routing any user actions happening at /foo to your app.
Runtime context
The query context q carries the following attributes:
route: The route at which the action was performed (in this case,/foo).page: A reference to the current page (in this case, the page hosted at/foo).site: A reference to the page's parent site, from which you can grab references to other pages.args: The event arguments, a dictionary-like object containing information about the user action.app,user,client: Dictionary-like objects holding server-side state, at the app-level, the user-level and the client-level, respectively. Here, 'client' refers to the browser tab.username: The username of the user who performed the action.mode: The app's realtime mode, one ofunicast,multicast, orbroadcast.
Lifecycle
To perform actions at application startup and shutdown, pass on_startup and on_shutdown functions to @app, like this:
from h2o_wave import main, app, Q, ui
def on_startup():
print('App started!')
def on_shutdown():
print('App stopped!')
@app('/foo', on_startup=on_startup, on_shutdown=on_shutdown)
async def serve(q: Q):
passThe on_startup and on_shutdown functions can also be async.
Architecture (`website/docs/architecture.md`)
title: Architecture
H2O Wave is a software stack for building beautiful, low-latency, realtime, browser-based applications and dashboards entirely in Python without using HTML, Javascript or CSS.
It excels at capturing data, visualizations, and graphics from multiple sources, and broadcasting them live over the web.
The stack is engineered from the ground-up for low-latency, realtime information display, and is usable on its own (not only a programming framework, but a programmable content server).
Overview
The Wave runtime operates over three tiers:
- A content server. The Wave server, a ~10MB static binary executable that runs anywhere[^1].
- A language driver. The h2o-wave PyPI package used by Wave scripts and apps.
- A browser-based client. The user interface and components.
+---------+ +---------+
| Browser +---------+ +------>+ app1.py |
+---------+ | | +---------+
v |
+---------+ +-+------+-+ +---------+
| Browser +------>+ Server +---->+ app2.py |
+---------+ +-+------+-+ +---------+
^ |
+---------+ | | +---------+
| Browser +---------+ +------>+ app3.py |
+---------+ +---------+The Wave server has three main functions:
- Store site content.
- Transmit content changes to browsers.
- Transmit browser events to apps.
In other words, the Wave server is comparable to an in-memory realtime database, an HTTP web server and a proxy server, all rolled into one, with browsers (clients) downstream, and Wave apps (or scripts) upstream.
The language driver (the h2o-wave PyPI package) provides the ability to manage content on the Wave server. It's similar in function to a database driver, but unlike typical database drivers (which use SQL as a protocol), the Wave driver provides an API closely integrated with the Python language that feels natural and idiomatic in practice.
The browser-based client's job is to render content on the user interface, and transmit user actions in the form of events back to the Wave server.
How does it work?
The Wave server stores all content in a page cache called a site. A site is a collection of pages. Each page has an address, called its route. A page is composed of cards. A card holds content, and any tabular data associated with the content, called data buffers.
When a browser is pointed to a route, it pulls a copy of the page, creates a replica locally, and renders the content on the user interface.
The language driver (the h2o-wave PyPI package) maintains an illusion that server-side content is available locally. Local updates to pages and cards are transmitted in the form of operations to the server. The server applies those updates to the master copy of the content. If any browser is currently displaying that content, the server forwards updates to the browser, causing the browser to update its replica and re-render its user interface.
Python
+------------+
| app.py |
| + |
| | |
| v |
| +---+----+ |
+-+ Driver +-+
+---+----+
|
|Operations
|
v
+-----+------+
| Server |
| |
| +------+ |
| | Page | |
| +------+ |
| |
+-----+------+
|
|Replication
|
+-------------+
| +---------+ |
| | Replica | |
| +---------+ |
| |
| UI |
+-------------+
BrowserThe language driver can be used by two kinds of Python programs: Wave apps and Wave scripts.
- Wave apps are interactive programs that can update content and respond to user actions.
- Wave scripts are simpler, non-interactive (batch) programs: they can update content, but not respond to user actions.
Wave apps are ASGI servers under the hood. When a Wave app is launched, it announces its existence to the Wave server, and the Wave server establishes a relay with the Wave app. When a browser tries to connect to an app, the Wave server acts as a hub, relaying information back and forth between the browser and the app.
How is it different?
The Wave server retains content. This is an important concept to understand, and the primary reason why Wave is different from a typical web framework. A Wave script can update content and exit, and the Wave server will happily continue serving that content. In other words, no Python process needs to be around if a new user arrives after you script has exited.
Different parts of the same page can be updated by different scripts running on different devices. Also, all content is live (or reactive) all the time: browsers always display up-to-date content without the need to reload.
[^1]: Linux, Windows, Darwin, BSD, Solaris, Android on amd64, arm, 386, ppc, mips; almost everywhere.
Arguments (`website/docs/arguments.md`)
title: Query Arguments
When a user interacts with components on a page - like typing in text, making choices, clicking buttons, and so on - that information is available to your app in the form of query arguments.
The query arguments can be read from q.args, a read-only dictionary-like object passed to your @app() handler:
from h2o_wave import Q, main, app
@app('/foo')
async def serve(q: Q):
print(q.args.foo)
print(q.args.bar)
print(q.args.qux):::tipq.args is an Expando instance, which means it behaves both like a dictionary and an object: q.args['foo'] is the same as q.args.foo. q.args.foo is easier to read.
:::
Interpreting arguments
The table below summarizes how to interpret inputs from various components.
| Component | If the component is named foo, the value of q.args.foo is... |
|---|---|
ui.button() |
value if provided, else True. |
ui.breadcrumb() |
True if clicked. |
ui.checkbox() |
True if checked, False if unchecked, None if indeterminate. |
ui.checklist() |
A list of names of all the selected choices (a list of strings). |
ui.choice_group() |
The name of the selected choice (a string). |
ui.color_picker() |
The selected color (a string). |
ui.combobox() |
Either the name of the selected choice or the value typed in (a string). |
ui.command() |
value if provided, else True. |
ui.date_picker() |
The selected date in YYYY-MM-DD format (a string). |
ui.dropdown() |
If multi-valued, a list of names of all the selected choices (a list of strings), otherwise the name of the selected choice (a string). |
ui.expander() |
True if expanded, else False. |
ui.facepile() |
value if provided, else True. |
ui.file_upload() |
A list of paths to the uploaded files (a list of strings). |
ui.nav_item() |
True if clicked. |
ui.persona() |
True if clicked. |
ui.picker() |
A list of names of all the selected choices (a list of strings). |
ui.range_slider() |
[min, max] (a list of two numbers). |
ui.slider() |
The selected value (a number). |
ui.spinbox() |
The selected value (a number). |
ui.table() |
A list of names of all the selected rows (a list of strings). |
ui.tabs() |
The name of the active tab (a string). |
ui.textbox() |
The value typed in (a string). |
ui.text_annotator() |
List of lists containing parts of text with accompanying tag (if not annotated, tag is ''). |
ui.toggle() |
True if checked, False if unchecked. |
App-wide global loading spinner
Wave assumes that every user interaction (every time q.args or q.events is submitted to the app) needs to result in a UI update. The reason is proper UX - you should always inform your user what's going on, give them feedback. With this assumption, we try to make the UI unclickable (to prevent double submission) until server responds with the newest changes.
You should always handle:
- Button clicks.
- Actions that result in sending events (e.g. plots, dismissing dialogs).
- Every input into components having
trigger=Trueset.
There might be very specific cases when you would want to get around this behavior. The solution is to update any card, even non-existent so simply specifying q.page['non-existent'].items = [] will result in loading spinner going away. However, you should almost always default to proper UX, thus update the UI properly and inform your user about what's currently happening within the app.
Handling interactivity
A common pattern for inspecting query arguments and determining the appropriate response is a simple if/elif/else conditional.
If add_to_cart, empty_cart, place_order and display_products are all names of buttons somewhere in the user interface, then you can structure your serve() function something like this:
from h2o_wave import Q, main, app
async def add_to_cart(q: Q):
pass
async def empty_cart(q: Q):
pass
async def place_order(q: Q):
pass
async def display_products(q: Q):
pass
@app('/hole_foods')
async def serve(q: Q):
if q.args.add_to_cart:
await add_to_cart(q)
elif q.args.empty_cart:
await empty_cart(q)
elif q.args.place_order:
await place_order(q)
else: # Display products
await display_products(q)If this feels too repetitive, you can use on and run_on to remove some of the boilerplate:
from h2o_wave import Q, main, app, on, run_on
@on('add_to_cart')
async def add_to_cart(q: Q):
pass
@on('empty_cart')
async def empty_cart(q: Q):
pass
@on('place_order')
async def place_order(q: Q):
pass
@on('display_products')
async def display_products(q: Q):
pass
@app('/hole_foods')
async def serve(q: Q):
await run_on(q)In the above example, the @on('add_to_cart') is read as "when q.arg['add_to_cart'] is True (or truthy), then invoke the function the @on() is applied to" - in this case, add_to_cart().
If the name of the function is the same as the name of the query argument, then the name can be elided. This simplifies the above example to:
from h2o_wave import Q, main, app, on, run_on
@on()
async def add_to_cart(q: Q):
pass
@on()
async def empty_cart(q: Q):
pass
@on()
async def place_order(q: Q):
pass
@on()
async def display_products(q: Q):
pass
@app('/hole_foods')
async def serve(q: Q):
await run_on(q):::tip@on() also supports pattern matching. See Routing for more information.
:::
Background (`website/docs/background.md`)
title: Background Tasks
Wave apps are servers based on asyncio, Python's library for Asynchronous I/O, and lets you develop and deploy high-performance applications.
Your @app query handler is invoked every time a user performs some action in your app's UI - access the page, reload it, click a button, access a menu, enter text, and so on. Performing blocking operations in your handler will hang your app's server and make your app's UI appear unresponsive until the blocking operation completes.
In some cases, blocking calls can be pushed to the background to improve concurrency. To achieve this, the Wave API provides two lightweight wrappers over asyncio.run_in_executor(): q.run() and q.exec().
Here is an example of a function that blocks:
import time
def blocking_function(seconds) -> str:
time.sleep(seconds) # Blocks!
return f'Done!'To call the above function from an app, don't do this:
@app('/demo')
async def serve(q: Q):
# ...
message = blocking_function(42)
# ...Instead, do this:
@app('/demo')
async def serve(q: Q):
# ...
message = await q.run(blocking_function, 42)
# ...q.run() runs the blocking function in the background, in-process.
Depending on your use case, you might want to use a separate process pool or thread pool from Python's multiprocessing library, like this:
import concurrent.futures
@app('/demo')
async def serve(q: Q):
# ...
with concurrent.futures.ThreadPoolExecutor() as pool:
message = await q.exec(pool, blocking_function, 42)
# ...q.exec() accepts a custom process pool or thread pool to run the blocking function.
import concurrent.futures
@app('/demo')
async def serve(q: Q):
# ...
with concurrent.futures.ProcessPoolExecutor() as pool:
message = await q.exec(pool, blocking_function, seconds)
# ...:::tip
Apps that make calls to external services or APIs are better off replacing blocking HTTP clients like requests with non-blocking clients like HTTPX.
:::
To update UI from within a background job, see this example or a detailed blog post.
Backup (`website/docs/backup.md`)
title: Backup and Recovery
:::caution
This feature is experimental. Do not rely on this in production!
:::
The Wave server logs all content changes to stderr. The changes are written in a format that can be read back in. This means that you can replay the log from beginning to end to recover the server's state (content, pages, everything). The log is, literally, a change log.
To capture the log, redirect stderr to a file when you launch the server:
./waved 2> backup.logTo recover state, feed the log file back in the next time you launch the server:
./waved -init backup.logTo recover state and continue capturing the log, use:
./waved -init backup.log 2> other.logIf you end up with a big log file, you can compact it like this:
./waved -compact big.log 2> small.logBrowser Testing (`website/docs/browser-testing.md`)
title: Browser Testing
:::caution Deprecated
Wave native testing tools are no longer supported. Use modern alternatives like Playwright.
:::
Wave supports authoring functional tests in Python for the Cypress test framework. This feature lets you automate browser-based point-and-click tests for your app.
Installation
Step 1: Install Node.js
Install a recent version of Node.js.
Step 2: Set up Cypress
Using your terminal, go to your Wave installation's test directory and install Cypress
cd $HOME/wave/test
npm installWriting a test
See the Wizard example to understand how to author tests for your interactive app. Specifically, note how the @cypress attribute is used. Refer to the Cypress API to learn how to author assertions.
from h2o_wave import cypress
@cypress('Walk through the wizard')
def test_wizard(cy):
cy.visit('/demo')
cy.locate('step1').click()
cy.locate('text').should('contain.text', 'What is your name?')
cy.locate('nickname').clear().type('Fred')
cy.locate('step2').click()
cy.locate('text').should('contain.text', 'Hi Fred! How do you feel right now?')
cy.locate('feeling').clear().type('quirky')
cy.locate('step3').click()
cy.locate('text').should('contain.text', 'What a coincidence, Fred! I feel quirky too!'):::tip
To escape Cypress function names that overlap with Python's reserved keywords, prefix the name with an underscore _. For example, use cy._as() instead of cy.as().
:::
Running your test
Step 1: Start the Cypress test runner
cd $HOME/wave/test
./node_modules/.bin/cypress openStep 2: Start the Wave server and your app as usual
./waved(venv) $ wave run --no-reload examples.wizardStep 3: Translate your Python tests to Javascript
To translate your Python tests to Javascript, execute the Python module or file containing your tests like this:
CYPRESS_INTEGRATION_TEST_DIR=$HOME/wave/test/cypress/integration ./venv/bin/python examples/wizard.pyThe CYPRESS_INTEGRATION_TEST_DIR environment variable indicates where the Wave SDK should write translated files to. This must be set to the cypress/integration directory.
Alternatively, you can set the CYPRESS_INTEGRATION_TEST_DIR environment variable in your shell (or IDE) to simplify running your test file:
export CYPRESS_INTEGRATION_TEST_DIR=$HOME/wave/test/cypress/integration
./venv/bin/python examples/wizard.pyStep 4: Run your tests
At this point, you should find all your tests displayed in the Cypress UI. Simply click on a test to run it. Happy testing!
Buffers (`website/docs/buffers.md`)
title: Data Buffers
Data buffers are in-memory data structures designed to hold a card's tabular data.
Data buffers make it convenient to separate data (what is displayed) from presentation (how it is displayed). You can declare a card once, and update its underlying data buffer multiple times. A card can hold zero or more data buffers.
Cards and buffers
Data buffers are tabular data structures containing columns and rows. Different cards utilize data buffers in different ways. For example, the plot in the ui.small_series_stat_card() uses a data buffer called plot_data to hold time series data.
card = ui.small_series_stat_card(
box=f'1 1 1 1',
title='CPU',
value='10%',
plot_data=data('time usage', -15),
plot_category='time',
plot_value='usage',
))In the above snippet, data('time usage', -15) defines a placeholder for a table with two columns (time and usage) and 15 rows (we'll get to why the 15 is negative shortly), and the card's plot_category and plot_value point to the two columns time and usage, respectively.
Appending rows (tuples or records) to the data buffer make the card plot those rows.
while True:
card.plot_data[-1] = [get_time(), get_usage()]
time.sleep(1)The while loop above does something like this:
card.plot_data[-1] = ['2020-10-05T02:10:20Z', 42.5]
card.plot_data[-1] = ['2020-10-05T02:10:21Z', 43.1]
card.plot_data[-1] = ['2020-10-05T02:10:22Z', 39.2]
card.plot_data[-1] = ['2020-10-05T02:10:23Z', 38.7]Types of buffers
There are three types of data buffers:
- Array buffers hold tabular data with a fixed number of rows, and allow random access to rows using 0-based integers as keys.
- Cyclic buffers also hold tabular data with a fixed number of rows, but do not allow random access to rows. They can only be appended to. Rows are first-in first-out (FIFO), and adding rows beyond its capacity overwrites the oldest rows.
- Map buffers (or dictionary buffers) hold tabular data with a variable number of rows, and allow random access to rows using string keys.
- List buffers (or dynamic array buffers) hold tabular data with a variable number of rows, allow random access to rows using 0-based integers as keys (negative indexing supported), and allow appending via
+=operator.
:::caution
Map buffers are convenient to use, but use more memory on the server compared to array buffers and cyclic buffers, so use them sparingly.
:::
The data function
Use the data() function to declare a data buffer. The Wave server uses this declaration to allocate memory for the data buffer.
data() takes multiple arguments:
fields: The names of columns, in order; a space-separated string or a list or a tuple ('foo bar'or['foo', 'bar']or('foo', 'bar').size: The number of rows to allocate.- A positive row count creates an array buffer.
- A negative row count creates a cyclic buffer.
- A zero row count (or
None) creates a map buffer.
rows: Adictorlistof rows to initialize the data buffer with. Each row can be a list or tuple.- For array or cyclic buffers, pass a list of rows.
- For map buffers, pass a dict with strings as keys and rows as values.
columns: Alistof columns to initialize the data buffer with. All columns must be of the same length. The columns are automatically transposed torows.pack:Trueto pack (compress) the provided rows or columns, use less memory on the server-side, and improve performance. Setpack=Trueif you intend to never make any changes to the data buffer once created. Defaults toFalse.t: Buffer type. One of 'list', 'map', 'cyclic' or 'fixed'. Overrides the buffer type inferred from the size.
Buffers in practice
Declare a 5-row buffer with columns donut and price.
# Array buffer
b = data(fields='donut price', size=5)
# Cyclic buffer
b = data(fields='donut price', size=-5)
# Map buffer
b = data(fields='donut price')
# List buffer
b = data(fields='donut price', t='list')Declare and initialize a 5-row buffer with columns donut and price.
# Array buffer
data(fields='donut price', size=5, rows=[
['cream', 3.99],
['custard', 2.99],
['cinnamon', 2.49],
['sprinkles', 2.49],
['sugar', 1.99],
])
# Cyclic buffer
data(fields='donut price', size=-5, rows=[
['cream', 3.99],
['custard', 2.99],
['cinnamon', 2.49],
['sprinkles', 2.49],
['sugar', 1.99],
])
# Map buffer
data(fields='donut price', rows=dict(
crm=['cream', 3.99],
cst=['custard', 2.99],
cin=['cinnamon', 2.49],
spr=['sprinkles', 2.49],
sug=['sugar', 1.99],
))
# List buffer
data(fields='donut price', t='list', rows=[
['cream', 3.99],
['custard', 2.99],
['cinnamon', 2.49],
['sprinkles', 2.49],
['sugar', 1.99],
])A buffer can only be modified via card. To get a reference to the buffer:
page['example'] = ui.plot_card(
box='1 1 4 5',
title='Line',
data=data(fields='donut price', size=5, rows=[
['cream', 3.99],
['custard', 2.99],
['cinnamon', 2.49],
['sprinkles', 2.49],
['sugar', 1.99],
])
plot=ui.plot([ui.mark(type='line', x_scale='time', x='=year', y='=value', y_min=0)])
)
b = page['example'].dataModify a buffer row:
# Array buffer
b[2] = ['cinnamon', 2.99]
# Cyclic buffer
# Appends to the end, shifts all elements by 1,
# dropping the first one as it would be the 6th element.
b[-1] = ['fruit', 2.99]
# Map buffer (the following two forms are equivalent)
b['cin'] = ['cinnamon', 2.99]
b.cin = ['cinnamon', 2.99]
# List buffer
b[2] += ['fruit', 2.99]
b[-2] += ['fruit', 2.99] # Modify second index from the end.Modify a buffer value:
# Array and list buffer (the following two forms are equivalent).
b[2]['price'] = 2.99
b[2].price = 2.99
# Map buffer (the following three forms are equivalent).
b['cin']['price'] = 2.99
b['cin'].price = 2.99
b.cin.price = 2.99
# List buffer (the following two forms are equivalent)
b[2]['price'] = 2.99 # third donut on menu now costs 2.99
b[2].price = 2.99
b[-1]['price'] = 2.99 # last donut from end on menu now costs 2.99
b[-1].price = 2.99Packed buffers
The following is true for Wave scripts only. Unicast apps do not store data buffers on server unless started with -editable option.
If you intend to create tabular data once and never change individual rows or values, it is better to avoid allocating memory on the server by using a packed buffer. Packed buffers use less memory on the server and improve performance. To create a packed buffer, use data(..., pack=True). Note that size is not required, and is ignored if provided.
b = data(fields='donut price', rows=[
['cream', 3.99],
['custard', 2.99],
['cinnamon', 2.49],
['sprinkles', 2.49],
['sugar', 1.99],
], pack=True)b = data(fields='donut price', columns=[
['cream', 'custard', 'cinnamon', 'sprinkles', 'sugar'],
[3.99 , 2.99, 2.49, 2.49, 1.99],
], pack=True)Cli (`website/docs/cli.md`)
title: Command Line Interface (CLI)
The CLI is accessible using the wave command, available once you pip install h2o-wave.
To view a list of sub-commands, simply run wave:
waveUsage: wave [OPTIONS] COMMAND [ARGS]...
Options:
--help Show this message and exit.
Commands:
fetch Download examples and related files to ./wave.
init Initial scaffolding for your Wave project.
learn Run interactive learning app - Wave university.
run Run an app.
share Share your locally running app with the world.Get help on a sub-command:
wave <command> --helpwave run
wave run runs an app.
Run app.py with auto reload:
wave run appRun path/to/app.py with auto reload:
wave run path.to.appRun path/to/app.py without auto reload:
wave run --no-reload path.to.appDo not start waved automatically:
wave run app --no-autostartwave init
Pick a starter app template
wave fetch
Download examples and waved.
wave fetchUsage: wave fetch [OPTIONS]
Download examples and related files to ./wave folder.
$ wave fetch
Options:
--platform [linux|windows|darwin] Operating system type.
--arch [amd64|arm64] Processor architecture type.
--help Show this message and exit.wave learn
Run interactive learning app - Wave University. (Requires h2o-wave-university package to be installed)
wave learnwave share
Expose locally running Wave app to the public internet. Stop sharing by hitting CTRL+C.
wave shareUsage: wave share [OPTIONS]
Share your locally running app with the world.
$ wave share
Options:
--port INTEGER Port your app is running on (defaults to 10101).
--subdomain TEXT Subdomain to use. If not available, a random one is generated.
--remote-host TEXT Remote host to use (defaults to h2oai.app).
--remote-port INTEGER Remote port to use (defaults to 443).
--open Open the shared app in your browser automatically.
--help Show this message and exit.Color Theming (`website/docs/color-theming.md`)
title: Colors
The general idea of Wave is that (almost) all the colors are handled for you meaning you do not have much control over the individual colors for components usually. If you want to tweak all the colors at once, you can reach out for themes.
Themes
Wave ships with prebuilt themes that are ready to be used. These include:
defaultneonh2o-darkbenextemberfuchasiakiwilightingmonokainaturenordoceanicone-dark-prosolarizedwinter-is-coming
Setting a theme is very simple:
q.page['meta'] = ui.meta_card(box='', theme='h2o-dark')Custom user-defined themes
Having only predefined color palettes keeps you from matching your Wave app to your specific color brand, so we have added custom, user-defined palettes.
When using custom colors, first a new ui.theme needs to be registered within the themes attribute in ui.meta_card. It takes the name of the theme and 4 main colors as arguments:
primary- Accent color for primary buttons, checkboxes etc.text- Default text color.card- Card background color.page- Base background color behind cards.
After the theme is successfully registered, you can use it in the same fashion as predefined themes, by setting ui.meta_card's theme attribute.
q.page['meta'] = ui.meta_card(
box='',
themes=[
ui.theme(
name='my-awesome-theme',
primary='#13ebe7',
text='#e8e1e1',
card='#12123b',
page='#070b1a',
)
],
theme='my-awesome-theme'
):::tip
You can register multiple themes, which might be handy for switching between the light / dark mode of your app.
:::
Theme generator
Wave comes with a simple Theme generator app that allows you to pick colors and see how they work together instantly.
This Wave app provides:
- Immediate visual feedback.
- Color contrast warnings when a particular contrast ratio is less than 4.5.
- Quick theme copy-pasting.
We would love to see all the beautiful themes our community came up with so don't hesitate and post the screenshots into our show and tell Github Discussions section! The best ones might even be included as predefined themes within Wave itself in the future.
Can individual colors be changed?
In general, the answer is no. Wave tries to be smart and do all the minor decisions for you, e.g. picking a color for primary/secondary buttons, checkboxes, etc. This way, a consistent UX is ensured and you can focus solely on the product itself rather than wasting time on nitpicking small stuff.
If full control over colors/typography etc. is needed, going with regular HTML/CSS/JS is probably a better choice.
However, some cards allow changing colors of particular parts, for example, icons or plots, but this is more of an exception rather than a strict rule. You can find available colors in the section below.
Wave colors
In addition to arbitrary CSS colors, you can use the colors that Wave uses itself.
- Spectrum colors are the most colors common colors with a slightly edited saturation so that they match the current theme.
- Wave's most used colors (text, card and primary) and their variations with increasing transparency (tone).
| Spectrum colors | Text tones | Card tones | Primary color tones |
|---|---|---|---|
| $amber | $text | $card | $primary |
| $azure | $text0 | $card0 | $primary0 |
| $blue | $text1 | $card1 | $primary1 |
| $brown | $text2 | $card2 | $primary2 |
| $cyan | $text3 | $card3 | $primary3 |
| $gray | $text4 | $card4 | $primary4 |
| $green | $text5 | $card5 | $primary5 |
| $indigo | $text6 | $card6 | $primary6 |
| $lime | $text7 | $card7 | $primary7 |
| $mint | $text8 | $card8 | $primary8 |
| $orange | $text9 | $card9 | $primary9 |
| $page | |||
| $pink | |||
| $purple | |||
| $red | |||
| $tangerine | |||
| $teal | |||
| $violet | |||
| $yellow |
:::tip
All the above colors are just CSS variables with a $ prefix replaced with wave- which means they can be also used within your custom CSS. E.g. red color would be --wave-red.
:::
Configuration (`website/docs/configuration.md`)
title: Configuration
Configuring the server
Wave allows starting Wave server in 2 ways:
- via
wave runcommand - this automatically starts Wave server (waved) under the hood, useful for development. - via Wave server binary (waved) - useful during deployment or when you need to run your Wave server on a different machine than your app.
Wave can be configured via configuration (.env) file, environment variables or command line arguments with the following priority: cmd arg > env var > config > default.
| ENV var or config (wave run or waved) | CLI args (waved) | Description |
|---|---|---|
| H2O_WAVE_ACCESS_KEY_ID | -access-key-id string | default API access key ID (default "access_key_id") |
| H2O_WAVE_ACCESS_KEY_SECRET | -access-key-secret string | default API access key secret (default "access_key_secret") |
| H2O_WAVE_ACCESS_KEYCHAIN | -access-keychain string | path to file containing API access keys (default ".wave-keychain") |
| -compact string | compact AOF log | |
| -create-access-key | generate and add a new API access key ID and secret pair to the keychain | |
| H2O_WAVE_DATA_DIR | -data-dir string | directory to store site data (default "./data"). |
| H2O_WAVE_DEBUG [^1] | -debug | enable debug mode (profiling, inspection, etc.) |
| H2O_WAVE_EDITABLE [^1] | -editable | allow users to edit web pages |
| H2O_WAVE_FORWARDED_HTTP_HEADERS | -forwarded-http-headers string | comma-separated list of case-insensitive HTTP header keys to forward to the Wave app from the browser WS connection. If not specified, defaults to '*' - all headers are allowed. If set to an empty string, no headers are forwarded. |
| H2O_WAVE_HTTP_HEADERS_FILE | -http-headers-file string | path to a MIME-formatted file containing additional HTTP headers to add to responses from the server |
| H2O_WAVE_INIT | -init string | initialize site content from AOF log |
| H2O_WAVE_LISTEN | -listen string | listen on this address (default ":10101") |
| -list-access-keys | list all the access key IDs in the keychain | |
| H2O_WAVE_BASE_URL | -base-url string | the base URL (path prefix) to be used for resolving relative URLs (e.g. /foo/ or /foo/bar/, without the host (default "/") |
| H2O_WAVE_MAX_CACHE_REQUEST_SIZE | -max-cache-request-size string | maximum allowed size of HTTP requests to the server cache (e.g. 5M or 5MB or 5MiB) (default "5M") |
| H2O_WAVE_MAX_PROXY_REQUEST_SIZE | -max-proxy-request-size string | maximum allowed size of proxied HTTP requests (e.g. 5M or 5MB or 5MiB) (default "5M") |
| H2O_WAVE_MAX_PROXY_RESPONSE_SIZE | -max-proxy-response-size string | maximum allowed size of proxied HTTP responses (e.g. 5M or 5MB or 5MiB) (default "5M") |
| H2O_WAVE_MAX_REQUEST_SIZE | -max-request-size string | maximum allowed size of HTTP requests to the server (e.g. 5M or 5MB or 5MiB) (default "5M") |
| H2O_WAVE_NO_STORE [^1] | -no-store | disable storage (scripts and multicast/broadcast apps will not work) |
| H2O_WAVE_NO_LOG [^1] | -no-log | disable AOF logging (connect/disconnect and diagnostic logging messages are not disabled) |
| H2O_WAVE_OIDC_AUTH_URL_PARAMS | -oidc-auth-url-params string | additional URL parameters to pass during OIDC authorization, in the format "key:value", comma-separated, e.g. "foo:bar,qux:42" |
| H2O_WAVE_OIDC_CLIENT_ID | -oidc-client-id string | OIDC client ID |
| H2O_WAVE_OIDC_CLIENT_SECRET | -oidc-client-secret string | OIDC client secret |
| H2O_WAVE_OIDC_END_SESSION_URL | -oidc-end-session-url string | OIDC end session URL |
| H2O_WAVE_OIDC_PROVIDER_URL | -oidc-provider-url string | OIDC provider URL |
| H2O_WAVE_OIDC_REDIRECT_URL | -oidc-redirect-url string | OIDC redirect URL |
| H2O_WAVE_OIDC_POST_LOGOUT_REDIRECT_URL | -oidc-post-logout-redirect-url string | OIDC post logout redirect URL |
| H2O_WAVE_OIDC_SCOPES | -oidc-scopes | OIDC scopes separated by comma (default "openid,profile") |
| H2O_WAVE_OIDC_SKIP_LOGIN [^1] | -oidc-skip-login | don't show the built -in login form during OIDC authorization |
| H2O_WAVE_PRIVATE_DIR [^2] | -private-dir value | additional directory to serve files from (authenticated users only), in the format "[url-path]@[filesystem-path]", e.g. "/public/files/@/some/local/path" will host /some/local/path/foo.txt at /public/files/foo.txt; multiple directory mappings allowed; paths need to be relative to Wave server binary location |
| H2O_WAVE_PUBLIC_DIR [^2] | -public-dir value | additional directory to serve files from, in the format "[url-path]@[filesystem-path]", e.g. "/public/files/@/some/local/path" will host /some/local/path/foo.txt at /public/files/foo.txt; multiple directory mappings allowed; paths need to be relative to Wave server binary location |
| H2O_WAVE_PROXY [^1] | -proxy | enable HTTP proxy (for IDE / language server support only - not recommended for internet-facing websites) |
| -remove-access-key string | remove the specified API access key ID from the keychain | |
| H2O_WAVE_SESSION_EXPIRY | -session-expiry string | session cookie lifetime duration (e.g. 1800s or 30m or 0.5h) (default "720h") |
| H2O_WAVE_SESSION_INACTIVITY_TIMEOUT | -session-inactivity-timeout string | session inactivity timeout duration (e.g. 1800s or 30m or 0.5h) (default "30m") |
| H2O_WAVE_TLS_CERT_FILE | -tls-cert-file string | path to certificate file (TLS only) |
| H2O_WAVE_TLS_KEY_FILE | -tls-key-file string | path to private key file (TLS only) |
| H2O_WAVE_NO_TLS_VERIFY [^1] | -no-tls-verify | do not verify TLS certificates during external communication - DO NOT USE IN PRODUCTION |
| -version | print version and exit | |
| H2O_WAVE_KEEP_APP_LIVE [^1] | -keep-app-live | do not unregister unresponsive apps (default false) |
| H2O_WAVE_WEB_DIR | -web-dir string | directory to serve web assets from (default "./www") |
| H2O_WAVE_CONF | -conf string | path to a configuration file (default ".env") |
| H2O_WAVE_PING_INTERVAL | -ping-interval string | how often should ping messages be sent (e.g. 60s or 1m or 0.1h) to keep the websocket connection alive (default "50s") |
| H2O_WAVE_RECONNECT_TIMEOUT | -reconnect-timeout string | Time to wait for reconnect before dropping the client (default "2s") |
| H2O_WAVE_ALLOWED_ORIGINS | -allowed-origins string | comma-separated list of allowed origins (e.g. http://foo.com) for websocket upgrades, use * to allow all origins |
[^1]: 1, t, true to enable; 0, f, false to disable (case insensitive).
[^2]: Use OS-specific path list separator to specify multiple arguments - : for Linux/OSX and ; for Windows. For example, H2O_WAVE_PUBLIC_DIR=/images/@./files/images:/downloads/@./files/downloads.
File paths
All the configuration options that expect a path as value (public/private dir, data dir etc.) need the path provided to be either absolute or relative to a directory where waved is stored.
Those that do not start waved manually (but use wave run instead) and would like to use relative paths need to know that waved is stored in their 'virtualenv' (venv) folder (when installed via pip). If the venv folder is within the project root, then all the paths relative to your project dir need to be prepended with ../ (up one directory).
Supported size units (case insensitive)
- Exabyte:
E/EB/EIB. - Petabyte:
P/PB/PIB. - Terabyte:
T/TB/TIB. - Gigabyte:
G/GB/GIB. - Megabyte:
M/MB/MIB. - Kilobyte:
K/KB/KIB. - Byte:
B
Suported time units
- Nanosecond:
ns. - Microsecond:
us(orµs). - Millisecond
ms. - Second:
s. - Minute:
m. - Hour:
h.
Public/Private dirs
When the wave server (waved) is started as part of the wave run command, the waved binary is located in the root of the currently active python virtual environment. Local paths need to be relative to this location. Another quick trick to find the waved location is to check web-dir during app startup which is located in the same directory as waved itself.
wave run app --no-reload
2023/09/12 14:24:35 #
2023/09/12 14:24:35 # ┌────────────────┐ H2O Wave
2023/09/12 14:24:35 # │ ┐┌┐┐┌─┐┌ ┌┌─┐ │ 0.26.2 20230803101411
2023/09/12 14:24:35 # │ └┘└┘└─└└─┘└── │ © 2021 H2O.ai, Inc.
2023/09/12 14:24:35 # └────────────────┘
2023/09/12 14:24:35 # ┌──────────────────────────────────────┐
2023/09/12 14:24:35 # │ Running at http://localhost:10101/ │
2023/09/12 14:24:35 # └──────────────────────────────────────┘
2023/09/12 14:24:35 # {"address":":10101","base-url":"/","t":"listen","web-dir":"/path/to/venv/www"}
INFO: Started server process [10429]
INFO: Waiting for application startup.
2023/09/12 14:24:36 # {"host":"http://127.0.0.1:8000","route":"/","t":"app_add"}
INFO: Application startup complete.
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)From the above, the root of the active virtual python environment is /path/to/venv. Another, more straightforward, option is to use absolute paths.
Wave serves whole directories as they are. This means that these directories are listable by default. If you wish to turn off this behavior, simply put an empty file called index.html into the folder you wish to not list.
TLS verification
During development, you might want to test out TLS encryption, e.g. communication between Wave server and Keycloak. The easiest thing to do is to generate a self-signed certificate. However, Wave server verifies certificates for all communication by default, thus would throw an error for a self-signed one. FOR DEVELOPMENT PURPOSES ONLY, it's possible to turn off the check using either H2O_WAVE_NO_TLS_VERIFY environment variable or no-tls-verify parameter.
:::warning
Disabling TLS verification is a security risk. Make sure TLS is not disabled in production environments.
:::
Configuring your app
Your Wave application is an ASGI server. When you run your app during development, the app server runs at http://127.0.0.1:8000/ by default (localhost, port 8000), and assumes that your Wave server is running at http://127.0.0.1:10101/ (localhost, port 10101). The wave run command automatically picks another available port if 8000 is not available.
The Wave server and apps communicate with each other using RPC over persistent HTTP connections.
For production deployments, you'll want to configure which port your app listens to, how it can access the Wave server, and how the Wave server can access your app.
You can use the following environment variables to configure your app's server's behavior:
H2O_WAVE_APP_ADDRESS
The public host/port of the app server. Defaults to http://127.0.0.1:8000. Set this variable if you are running your Wave server and your app on different machines or containers.
H2O_WAVE_APP_MODE
The realtime sync mode of the app server. One of unicast (default), multicast, or broadcast.
H2O_WAVE_ADDRESS
The public host/port of the Wave server. Defaults to http://127.0.0.1:10101. Set this variable if you are running the Wave server on a remote machine or container. Another common usage for this var is changing the app port together with H2O_WAVE_LISTEN.
H2O_WAVE_CONNECTION_TIMEOUT
The number of seconds to attempt to connect to the Wave server before giving up.
H2O_WAVE_ACCESS_KEY_ID
The API access key ID to use when communicating with the Wave server.
H2O_WAVE_ACCESS_KEY_SECRET
The API access key secret to use when communicating with the Wave server.
H2O_WAVE_APP_ACCESS_KEY_ID
The API access key ID to use when communicating with the app server. Automatically generated if not specfied.
H2O_WAVE_APP_ACCESS_KEY_SECRET
The API access key secret to use when communicating with the app server. Automatically generated if not specified.
H2O_WAVE_CHECKPOINT_DIR
The directory to save/load application and session state. Enables checkpointing. If set, the app saves the contents of q.app and q.user before exiting. When restarted, the contents of q.app and q.user are restored. The directory is automatically created if it does not exist.
You can use checkpointing as a simple way to save/load your app's data while prototyping.
The checkpoint file is named h2o_wave.checkpoint, and is serialized using Python's pickle protocol. Due to the nature of the pickle format, checkpointing is only guaranteed to work if the Python version and the versions of your app's dependencies are a perfect match, down to the patch version. In other words, do not expect checkpointing to work if an app is restarted using a newer/older Python version or a newer/older package. If you use checkpointing, it is recommended that you explicitly use == to set the major.minor.patch version of every package in your app's requirements.txt or setup.py.
H2O_WAVE_NO_AUTOSTART
Disable/enable Wave server boot during wave run. Defaults to false. Available values: 1, t, true to disable autostart; 0, f, false to enable autostart (case insensitive). Same as calling wave run --no-autostart.
H2O_WAVE_BASE_URL
The base URL (path prefix) to be used for resolving relative URLs (e.g. /foo/ or /foo/bar/, without the host (default "/"). If you run your Wave server (waved) and Wave app separately, it's necessary to set this env variable for both.
H2O_WAVE_NO_COPY_UPLOAD
If the Wave server and Wave app run on the same machine, q.site.upload() will copy files instead of using HTTP requests. This is done for performance reasons (avoids serialization overheads). To disable this optimization and force HTTP requests every time, set H2O_WAVE_NO_COPY_UPLOAD to 1 or t or true.
H2O_WAVE_WAVED_DIR
Provides the location of the Wave server's root directory to the Wave app, if both the server and the app are running on the same machine. Useful for performance optimizations during file uploads. Makes q.site.upload() copy files instead of using HTTP requests.
H2O_WAVE_RELOAD_EXCLUDE
Excludes certain files or directories from being watched for app reload. Only relative paths are allowed and requires watchfiles to be installed. See Uvicorn docs.
Multiple values are supported. Use OS path separator (: for Unix and ; for Windows) as a delimiter. E.g. H2O_WAVE_RELOAD_EXCLUDE=tmp_dir1/*.py:tmp_dir2/*.txt.
H2O_WAVE_APP_CONNECT_TIMEOUT
The connect timeout specifies the maximum amount of time to wait until a socket connection to the Wave server is established. If unable to connect within this time frame, a ConnectTimeout exception is raised.
Defaults to 5 (seconds). Use -1 to turn off.
H2O_WAVE_APP_WRITE_TIMEOUT
The write timeout specifies the maximum duration to wait for a chunk of data to be sent (for example, a chunk of the request body). If unable to send data within this time frame, a WriteTimeout exception is raised.
Defaults to 5 (seconds). Use -1 to turn off.
H2O_WAVE_APP_READ_TIMEOUT
The read timeout specifies the maximum duration to wait for a chunk of data to be received (for example, a chunk of the response body). If unable to receive data within this time frame, a ReadTimeout exception is raised.
Defaults to 5 (seconds). Use -1 to turn off.
H2O_WAVE_APP_POOL_TIMEOUT
The pool timeout specifies the maximum duration to wait for acquiring a connection from the connection pool. If unable to acquire a connection within this time frame, a PoolTimeout exception is raised.
Defaults to 5 (seconds). Use -1 to turn off.
Web Analytics
You can configure your app's web pages to send basic usage information to a third-party web analytics or tracking site. This lets you measure and analyze how users are interacting with various parts of your app.
By default, Wave apps do not load any third-party trackers or capture usage data. Third-party trackers have to be enabled explicitly by the application's author, and are loaded on-demand.
Once enabled, your app's UI will send events every time the user performs some kind of action that triggers a request from the browser to your app. Only two kinds of information are sent to the third-party trackers:
- The names of the elements that were possibly interacted with (and not values). For example, if a button named
foowas clicked on, the valuefoo=trueis tracked. - The hash part of the URL, if any. For example if the page
/foo/barwas navigated to, the value#=/foo/baris tracked.
Google Analytics
To enable usage tracking via Google Analytics, create and set a ui.tracker() on your page's meta card, with the id set to the measurement ID of your web property.
q.page['meta'] = ui.meta_card('',
tracker=ui.tracker(type=ui.TrackerType.GA, id='G-XXXXXXXXXX')
)FAQ
How to start a Wave app on a different port?
You first need to set H2O_WAVE_LISTEN env variable, which is a string prefixed with : to make your Wave server expose the port you want. Afterwards set H2O_WAVE_ADDRESS='http://127.0.0.1:10102' to tell your Wave app where it should connect to.
H2O_WAVE_LISTEN=":10102" H2O_WAVE_ADDRESS='http://127.0.0.1:10102' wave run app.pyContributing (`website/docs/contributing.md`)
title: Contributing
We appreciate all contributions. If you are planning to contribute back bug-fixes, please do so without any further discussion.
If you plan to contribute new features, please first open an issue and discuss the feature with us. Sending a PR without discussion might end up resulting in a rejected PR because we might be taking the software in a different direction than you might be aware of.
(Based on the PyTorch Contribution Guide.)
About open source development
If this is your first time contributing to an open source project, some aspects of the development process may seem unusual to you.
There is no way to "claim" issues. People often want to "claim" an issue when they decide to work on it, to ensure that there isn’t wasted work when someone else ends up working on it. This doesn’t really work too well in open source, since someone may decide to work on something, and end up not having time to do it. Feel free to give information in an advisory fashion, but at the end of the day, we will take running code and rough consensus.
There is a high bar for new functionality that is added. Unlike in a corporate environment, where the person who wrote code implicitly "owns" it and can be expected to take care of it in the beginning of its lifetime, once a pull request is merged into an open source project, it immediately becomes the collective responsibility of all maintainers on the project. When we merge code, we are saying that we, the maintainers, are able to review subsequent changes and make a bugfix to the code. This naturally leads to a higher standard of contribution.
Proposing new features
New feature ideas are best discussed on a specific issue. Please include as much information as you can, any accompanying data, and your proposed solution. The H2O Wave team and community frequently reviews new issues and comments where they think they can help. If you feel confident in your solution, go ahead and implement it.
Reporting issues
If you’ve identified an issue, first search through the list of existing issues on the repo. If you are unable to find a similar issue, then create a new one. Supply as much information you can to reproduce the problematic behavior. Also, include any additional insights like the behavior you expect.
Implementing features
If you want to fix a specific issue, it’s best to comment on the individual issue with your intent. However, we do not lock or assign issues except in cases where we have worked with the developer before. It’s best to strike up a conversation on the issue and discuss your proposed solution. The H2O Wave team can provide guidance that saves you time.
Issues that are labeled good first issue, low or medium priority are great places to start. Only issues that have assigned a milestone or are tagged with help needed / good first issue will be merged.
Please be respectful of maintainer's time and do not ask questions like "Which file should I edit" etc. Do some due diligence first. Chances are that if you are not able to find the file that needs to be changed, the feature implementation might be overwhelming for you as well.
Improving documentation and tutorials
We aim to produce high quality documentation and tutorials. On rare occasions that content includes typos or bugs. If you find something you can fix, send us a pull request for consideration.
Submitting pull requests
You can view a list of all open issues. Commenting on an issue is a great way to get the attention of the team. From here you can share your ideas and how you plan to resolve the issue.
For more challenging issues, the team will provide feedback and direction for how to best solve the issue.
If you’re not able to fix the issue yourself, commenting and sharing whether you can reproduce the issue can be useful for helping the team identify problem areas.
Improving code readability
Improved code readability helps everyone. It is often better to submit a small number of pull requests that touch few files versus a large pull request that touches many files. Opening an issue related to your improvement is the best way to get started.
Adding test cases
Additional test coverage is appreciated. Help us make the codebase more robust.
Security vulnerabilities
If you discover a security vulnerability within H2O Wave, please send an email to Prithvi Prabhu at . All security vulnerabilities will be promptly addressed.
Code of Conduct
See https://github.com/h2oai/wave/blob/main/.github/CODE_OF_CONDUCT.md.
Development Setup
Prerequisites:
- Go v1.19+
- Node.js v16+
- Python 3.8+
- A C/C++ compiler XCode on OSX,
build-essentialon Debian,base-develon Arch, etc.) to build Python/Node.js dependencies.
:warning: This project is best developed on OSX or Linux at the moment. If you develop on Windows, WSL is recommended.
You will need Go 1.19+, Node.js 16+, Python 3.7+. You should already have Python 3.7+ on a modern OS. It is recommended that you get Go and Node.js from their websites, since your OS package managers (apt, brew, etc.) are likely to have old packages.
Getting started
To set up all development dependencies, clone the repo and run:
make allTo launch the Wave server, run:
make runTry running the button example to verify if your setup is functional:
cd py && ./venv/bin/wave run examples.buttonYou should now see the button example at http://localhost:10101/demo.
For front-end development, you'll also need to start the a UI dev server:
make run-uiYou should now see the button example at http://localhost:3000/demo.
Once you have the UI dev server running, you should be able to visualize any changes to ./ui in real time.
Happy hacking!
Daily development
For daily development, you'll only need to pull main from git and run make run and make run-ui. Running make setup is not necessary unless make run or make run-ui fail due to missing dependencies.
Other make targets
# Generates Python and R APIs.
# Run only if you add new cards/components, attributes or fix docstrings.
$ make generate
# Displays all the available make targets.
$ make helpRepo Structure
cmd: Go executablesdata: contains data created by a Wave-app itself (e.g. file upload files)py: Python packager: R packagetools/wavegen: Typescript to Python/R code-generatorui: Typescript + React sources, primarily built using Fluent UI.configcontains webpack configurationeslintcontains custom eslint rules fortsandtsxfiles. It is required to runnpm ciafter changinglinter.jsin order for changes to take effect.
website: documentation sources
Committing Changes
Commit messages must follow Conventional commits.
Make sure your commit message also ends with an issue number e.g. fix: Typo #11.
Custom Css (`website/docs/custom-css.md`)
title: Custom CSS
Wave tries to handle all the styling for you to both save you time and provide the most optimal user experience. However, there might be cases, when you are not 100% happy with it:
- You feel like some components are hard to use and you have some ideas about improvement. In this case, please don't hesitate to file a new feature request where we can further discuss and improve Wave for everybody.
- You do not like alignment or some particular color - subjective case for which you need to use custom CSS.
:::info
It's recommended to keep CSS changes at minimum (ideally at 0) since the CSS selectors that you will need to use are not guaranteed to work across Wave updates. The HTML code in Wave is considered an "implementation detail" and is prone to change in order to deliver new features and bugfixes.
:::
Loading external stylesheets
If the stylesheet you want to use is hosted somewhere, e.g. CDN, you need to use the stylesheets attribute. This is also handy for cases when you wish to load a well-known CSS lib like Bootstrap and use it within your ui.markup_card for example.
q.page['meta'] = ui.meta_card(
box='',
stylesheets=[
ui.stylesheet('https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css')
]
)Using local CSS stylesheet
Prior to using your own CSS files, they need to be uploaded to Wave server. There are 2 ways of doing this:
- Uploading the CSS files from your Wave app via q.site.upload.
- Serving the CSS files directly from Wave server via public/private dir.
from h2o_wave import Q, main, app, ui
import os
current_dir = os.path.dirname(os.path.realpath(__file__))
@app('/')
async def serve(q: Q):
# Upload the `styles.css` file to the Wave server
# and save its path into the `stylesheet_path` variable.
stylesheet_path, = await q.site.upload([os.path.join(current_dir, 'styles.css')])
# Use the uploaded file path in the `ui.stylesheet`.
q.page['meta'] = ui.meta_card(
box='',
stylesheets=[
ui.stylesheet(stylesheet_path)
]
)
await q.page.save()Inline stylesheets
On the other hand, if all you want to do is tweak a few things here and there, the simpler solution would be to use the stylesheet attribute that accepts a CSS string.
# Use red colors for text in all paragraphs.
style = '''
p {
color: red;
}
'''
page['meta'] = ui.meta_card(box='', stylesheet=ui.inline_stylesheet(style))Targeting the desired element
CSS class names are autogenerated by our build tooling which makes them inappropriate for CSS selectors - they change with every build (new version) of Wave. Instead, you can notice there are data-test attributes all over the HTML markup. These are filled based on either card name (q.page['<card-name>']) or on the name attribute in case of components. It's recommended to use the data-test attributes where possible instead.
Below, you can find a simple example on how to change the alignment of header title and subtitle.
from h2o_wave import main, app, Q, ui
@app('/')
async def serve(q: Q):
# Use "my_header" as card name.
q.page['my_header'] = ui.header_card(box='1 1 -1 1', title='Title', subtitle='Subtitle')
# Target all divs with data-test attribute that have value of "my_header",
# then target it's first immediate child div.
q.page['meta'] = ui.meta_card(box='', stylesheet=ui.inline_stylesheet('''
div[data-test="my_header"]>div {
flex-grow: 1;
justify-content: center;
}
'''))
await q.page.save()Deployment (`website/docs/deployment.md`)
title: Deployment
At the end of the day, Wave produces web-apps with their own web server. This means any VM provider can be used for Wave deployment.
Deploying Wave scripts
Wave scripts are regular Python scripts. Deploy them as you would any Python script.
Deploying Wave apps
If you are aiming for the most simplest deployment, use wave run your_app --no-reload. The -no-reload flag is very important from performance and security standpoints.
Wave uses 2 separate servers under the hood - waved (Go server) and wave app (Python server). See architecture to learn more.
Wave apps are ASGI-compatible, based on Uvicorn / Starlette, a high-performance Python server.
You can run Wave apps behind any ASGI server, like uvicorn, gunicorn, daphne, hypercorn, etc.
It's highly recommended to run Wave server (waved) separately from Wave app for production deployment in order to achieve the highest flexibility (custom ASGI server configuration). However, if you don't need that, you are good to go with a simple wave run --no-reload app.
To run your app using an ASGI server, append :main to the app argument. For example, if you were normally executing your app foo.py using wave run foo, and want to run your app using Uvicorn, use uvicorn foo:main.
These commands are equivalent:
(venv) $ wave run --no-reload --no-autostart foo(venv) $ uvicorn foo:mainFor more information, see uvicorn.org/deployment and starlette.io/#performance.
To configure a different port, see configuring ports.
:::tipuvicorn accepts --env-file flag to specify .env configuration file for the Wave app. Note that as of time of writing, --env-file does not support configuration of Uvicorn itself. Using this flag may require running pip install python-dotenv first.
:::
More info about various configuration options can be found in the configuration section.
Beyond defaults
If different than default ports are used for Wave server (http://localhost:10101) or Wave app (http://localhost:8000), it's necessary to properly set env variables:
- Wave server -
H2O_WAVE_LISTEN. - Wave app -
H2O_WAVE_APP_ADDRESSandH2O_WAVE_ADDRESS.
Deploying with HTTPS
Wave consists of 2 servers and both need to be configured separately.
- Wave server -
H2O_WAVE_TLS_CERT_FILEandH2O_WAVE_TLS_KEY_FILEenvironment variables. - Wave app -
uvicorn foo:main <other-params> --ssl-keyfile=<key file> --ssl-certfile=<cert file>. See uvicorn docs.
HTTPS is all or nothing meaning either both server and app use TLS or none does.
Once set, the Wave app needs to know it should talk to the Wave server via https and not http as it does by default. This can be set using H2O_WAVE_ADDRESS="https://127.0.0.1:10101" env variable when starting the Wave app.
Deployment to separate machines
Although most people deploy their Wave server and Wave app to the same machine, it's not the only way. When deploying to separate machines, the main challenge is to make sure Wave server and Wave app can communicate properly.
For wave app machine, the following needs to be set:
H2O_WAVE_APP_ADDRESS- the address of the Wave app machine that Wave server connects to. Defaults tohttp://localhost:8000. In real world, this would be something likehttp://myapp1.mycompany.com:8000.H2O_WAVE_ADDRESS- the address of the Wave server machine. Defaults tohttp://localhost:10101. In real world, this would be something likehttp://myapp1server.mycompany.com:10101.--host 0.0.0.0- Uvicorn exposeslocalhostby default, which would only listen for local network requests.0.0.0.0tells it to listen on all addresses.
:::tip
Do not specify addresses with https protocol if you haven't configured Wave server and Wave app to use TLS.
:::
AWS EC2
See a step-by-step blog post by Greg Fousas.
Azure
See a step-by-step blog post by Audrey Leteve.
Hugging Face Spaces
See a step-by-step blog post.
Behind Nginx (reverse proxy)
Since Wave relies on websockets, the following configuration is necessary.
# Enable websockets.
# http://nginx.org/en/docs/http/websocket.html
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
location / {
proxy_http_version 1.1;
; Change to your wave server address.
proxy_pass http://localhost:10101/;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Proto http;
proxy_set_header X-NginX-Proxy true;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_redirect off;
}
}Read more at official Nginx docs or see an example repo.
Development (`website/docs/development.md`)
title: Development
Wave scripts are plain Python programs. Wave apps are ASGI programs. You can develop, debug and test them from the command-line, from the Python REPL, or from your favorite text editor.
Both PyCharm Community Edition and Visual Studio Code are excellent for Python programming.
:::tip
At the time of writing, PyCharm's type-checking and error-detection is superior to Visual Studio Code's Python plugin.
:::
Getting started
The simplest way to get started in either PyCharm or Visual Studio Code is the same:
- Create a working directory.
- Set up a Python virtual environment.
- Install the
h2o-wavepackage. - Open the directory in your IDE.
mkdir $HOME/wave-apps
cd $HOME/wave-apps
python3 -m venv venv
./venv/bin/pip install h2o-waveUsing PyCharm
- Launch PyCharm
- Click "File" -> "Open...", then choose
$HOME/wave-apps. - Right-click on
wave-appsin the "Project" tree, then click "New" -> "Python File". - Enter a file name, say,
foo.py. - Write some code (see sample below).
- Right-click anywhere inside the file and choose "Run foo" or "Debug foo".
:::tip
To make Wave app development easier, we suggest installing our PyCharm plugin. If you want to learn more about all of its features check out this blog post.
:::
Using Visual Studio Code
- Launch Visual Studio Code
- Click "File" -> "Open...", then choose
$HOME/wave-apps. - Click "File" -> "New File"; save the file as, say,
foo.py. - You should now get a prompt asking if you want to install extensions for Python. Click "Install".
- Write some code (see sample below).
- Hit
Ctrl+F5to run, orF5to debug.
:::tip
To make Wave app development easier, we suggest installing our VSCode extension. If you want to learn more about all of its features check out this blog post.
:::
Debugging Apps
To debug Wave apps, set your IDE or editor's configuration to execute the command python -m h2o_wave run --no-reload foo instead of python foo.py.
:::tip
The command wave run --no-reload foo is equivalent to python -m h2o_wave run --no-reload foo.
:::
Debug in PyCharm
- Open the "Run/Debug Configurations" dialog for your script.
- Under "Configuration", change the "Script path" dropdown to "Module name".
- Set "Module name" to
h2o_wave. - Set "Parameters" to
run foo(assuming your app's source code is infoo.py)
Debug in Visual Studio Code
- Run "Open launch.json" command (cmd + shift + P).
- Add the configuration listed below.
- Go to Debug pane, select your newly added config and run it to start debugging.
{
"name": "Debug Wave App",
"type": "python",
"request": "launch",
// Path to your wave installation, usually inside virtual env.
"program": "${workspaceFolder}/venv/bin/wave",
// Path to python you are using, usually inside virtual env.
"python": "${workspaceFolder}/venv/bin/python",
"args": [
"run",
"app"
]
}Using OpenID Connect
You can set up a local Keycloak instance for developing apps that use OpenID Connect, OAuth 2.0, or SAML 2.0 for authentication, single sign-on, and so on.
Run Keycloak
First, create a local directory to persist all the realms, clients, and users you'll be adding to Keycloak:
mkdir ~/.keycloakRun Keycloak using Docker:
docker run \
-p 8080:8080 \
-e KEYCLOAK_ADMIN=admin \
-e KEYCLOAK_ADMIN_PASSWORD=admin \
--volume ~/.keycloak:/opt/jboss/keycloak/standalone/data \
quay.io/keycloak/keycloak:21.0.1 \
start-devKeycloak should now be running at http://localhost:8080/.
Add a client
Next, create a client in Keycloak to represent our app:
- Go to Keycloak at http://localhost:8080/admin.
- Log in with username
admin, passwordadmin. - In the top left, click on
Clients- Click the
Create clientbutton to create a new client. - Set
Client IDtowave. - Click "Next".
- Change the "Client authentication" to
Onto change access type of the to confidential. - Click
Next. - Set
Valid Redirect URIsto*(never do this in production). - Click
Save. - Click the
Credentialstab. - Copy the
Client secretfield (e.g.xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx).
- Click the
Add test users
Next, add one or more users to Keycloak:
- In the top left, click on
Users.- Click the
Add Userbutton to create a new user. - Set the
Usernamefield. - Click
Create. - Go to the
Credentialstab. - Click
Set password. - Set the password fields.
- Change
TemporarytoOff. - Click
Save.
- Click the
:::tip
You can also use admin user for logging in with credentials admin/admin if you just need a quick testing.
:::
Point Wave to Keycloak
We're ready to use Keycloak now. Make sure you log out of Keycloak, otherwise you'll be logged in as admin when you access your Wave app!
Finally, start the Wave daemon with the following -oidc- command line arguments (use the client secret you copied above):
./waved \
-oidc-client-id wave \
-oidc-client-secret xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx \
-oidc-redirect-url http://localhost:10101/_auth/callback \
-oidc-provider-url http://localhost:8080/realms/master \
-oidc-end-session-url http://localhost:8080/realms/master/protocol/openid-connect/logoutNow, when you launch and access your Wave app, you should see a OpenID prompt which you can use to log in one of the users you created in Keycloak.
Expressions (`website/docs/expressions.md`)
title: Data-binding
Some cards in the Wave SDK support data-binding expressions, a mini language that allows computing a card's attribute from the card's data.
Rationale
A card's attribute can be set directly when created:
card = page.add(f'donut', ui.small_stat_card(
box='1 1 2 2',
title='Donut',
value='2.99',
))The attribute can be updated later using:
card.value = '3.49'The above approach works fine, but sometimes it's prudent to separate the data (what is displayed) from the presentation (how it is displayed). This is especially important if you care about internationalization and displaying language-sensitive number, date, and time formatting.
The above example can be rewritten by pulling the donut price out into data, and pointing the value to the data using an expression:
card = page.add(f'donut', ui.small_stat_card(
box='1 1 2 2',
title='Donut',
value='=${{intl price minimum_fraction_digits=2 maximum_fraction_digits=2}}',
data=dict(price=2.99),
))The attribute can be update later using:
card.data.price = 3.49Syntax
Data-binding expressions are indicated with a leading = (similar to spreadsheet formulae). For example, '={{price}}' is an expression, but '{{price}}' is not.
Placeholders are enclosed between {{ and }}. The placeholder price in ={{price}} points to data.price. If data.price is 2.99:
={{price}}translates to2.99.=${{price}}translates to$2.99.=Donuts cost ${{price}}translates toDonuts cost $2.99.
An expression can have multiple placeholders. For example, ={{product}} costs {{price}}
If an expression has a placeholder and nothing else, the {{ and }} can be elided. For example, =price is shorthand for ={{price}}.
Functions can be applied to placeholders using the general form {{function_name placeholder param1=arg1 param2=arg2 ...}}. For example {{intl price minimum_fraction_digits=2 maximum_fraction_digits=2}} applies the formatting function intl to price with arguments minimum_fraction_digits=2 and maximum_fraction_digits=2.
Functions
Expressions currently support only one function, intl, which provides language-sensitive number, date, and time formatting using the ECMAScript Internationalization API.
:::tip
Options in the Internationalization API are camelCased, but you can use both camelCased and snake_cased options in data-binding expressions. For example, both maximum_fraction_digits and maximumFractionDigits are valid.
:::
Formatting numbers
| Param | Available values |
|---|---|
| style | 'decimal' | 'percent' | 'currency' | 'unit' |
| currency | ISO 4217 currency codes |
| unit | Available units |
| unitDisplay | 'long' | 'short' | 'narrow' |
| currencySign | 'standard' | 'accounting' |
| currencyDisplay | 'symbol' | 'code' | 'name' | 'narrowSymbol' |
| notation | 'standard' | 'scientific' | 'engineering' | 'compact' |
| compactDisplay | 'short' | 'long' |
| signDisplay | 'auto' | 'always' | 'never' | 'exceptZero' |
| useGrouping | true | false |
| minimumIntegerDigits | number |
| minimumFractionDigits | number |
| maximumFractionDigits | number |
| minimumSignificantDigits | number |
| maximumSignificantDigits | number |
| numberingSystem | Available numbering systems |
Formatting date
| Param | Available values |
|---|---|
| hour12 | true | false |
| weekday | 'long' | 'short' | 'narrow' |
| era | 'long' | 'short' | 'narrow' |
| year | 'numeric' | '2-digit' | 'long' | 'short' | 'narrow' |
| month | 'numeric' | '2-digit' | 'long' | 'short' | 'narrow' |
| day | 'numeric' | '2-digit' |
| hour | 'numeric' | '2-digit' |
| minute | 'numeric' | '2-digit' |
| second | 'numeric' | '2-digit' |
| timeZoneName | 'long' | 'short' | 'shortOffset' | 'longOffset' | 'shortGeneric' | 'longGeneric' |
| hourCycle | 'h11' | 'h12' | 'h23' | 'h24' |
| dateStyle | 'full' | 'long' | 'medium' | 'short' |
| timeStyle | 'full' | 'long' | 'medium' | 'short' |
| numberingSystem | Available numbering systems |
| calendar | Available calendars |
| formatMatcher | "basic" | "best fit" | "best fit" |
| dayPeriod | "narrow" | "short" | "long" |
| fractionalSecondDigits | 0 | 1 | 2 | 3 |
See the official spec for more info.
Files (`website/docs/files.md`)
title: Files
Wave provides four functions to manage files from your app:
ui.file_upload()allows uploading files from the browser to the Wave server.q.site.upload()uploads files from your app to the Wave server.q.site.download()downloads a file from the Wave server to your app.q.site.unload()deletes a file from the Wave server.
Accept file uploads
Use a file upload component (ui.file_upload()) to accept file uploads from the browser. Files get uploaded from the browser and get stored on the Wave server. Use q.site.download() to download files from the Wave server to your app.
from h2o_wave import Q, main, app, ui
@app('/uploads')
async def serve(q: Q):
paths = q.args.datasets
if not paths:
q.page['example'] = ui.form_card(box='1 1 4 10', items=[
ui.text_xl('Upload datasets'),
ui.file_upload(name='datasets', label='Upload', multiple=True),
])
else:
for path in paths:
local_path = await q.site.download(path, '.')
# Do something with the file located at local_path
# ...
await q.page.save():::tip
After a file is uploaded from the browser, it is stored forever on the Wave server. If you don't need the file any longer, use q.site.unload() to delete it from the Wave server.
:::
Provide file downloads
Use q.site.upload() to upload files from your app to the Wave server. Use the returned paths to display download links in the browser and a download attribute to initiate the download process right after the click.
from h2o_wave import Q, main, app, ui
@app('/downloads')
async def serve(q: Q):
download_path, = await q.site.upload(['results.csv'])
q.page['download'] = ui.form_card(box='1 1 2 2', items = [
ui.link(label='Download Results', path=download_path, download=True),
])
await q.page.save()See download link for more info.
:::tipq.site.upload() accepts a list of file paths, so you can upload multiple files at a time.
:::
Use q.site.upload_dir() to upload whole directories and preserve their structure. Useful when you want to host Javascript files having relative paths to other scripts for example. The function returns a list of uploaded paths and since it only takes a single directory at a time, the list is always going to have size of 1.
:::tipq.site.upload_dir() is necessary only in very specific edgecases. For most of the time we recommend serving files directly.
:::
:::warning
If running Wave on Windows, you might encounter a 500 internal error during q.site.upload call due to lack of permissions since Wave server needs to write to your file system. The simple solution
is to start the Wave server (or wave run your_app.py for Wave > 0.20.0) within a terminal with Admin rights (open terminal as Admin).
:::
Serving images
Use q.site.upload() to upload images from your app to the Wave server. Use the returned paths in ui.image() or `ui.image_card().
image, = await q.site.upload(['path/to/my/image.png'])
q.page['example'] = ui.form_card(box='1 1 4 4', items=[
ui.image(title='Image title', image=image, type='png'),
])Serving image streams
Use image streams to display images that can be updated in near real time, say for use cases such as real time object detection in videos or webcam streams. This feature lets you display an initial image on a web page, then follow up with updated images (or frames).
Use q.site.uplink() to stream a frame. This function returns the path to an image that can be passed to ui.image() or ui.image_card(), or any UI or HTML element that accepts an image source.
Use q.site.unlink() to signal the end of a stream.
@app('/demo')
async def serve(q: Q):
# Create stream by sending initial image
path = await q.site.uplink('my_stream', 'image/jpeg', my_image)
# Display image on page
q.page['qux'] = ui.form_card(box='1 1 5 5', items=[ui.image('Image Stream', path=path)])
await q.page.save()
# Update image
for i in range(100):
await q.site.uplink('my_stream', 'image/jpeg', my_image)
# Stop stream
await q.site.unlink('my_stream')See examples/file_stream.py for a complete example.
Rendering documents
Use q.site.upload() to upload document from your app to the Wave server (or see the following section for other ways for serving files). Use the returned paths in ui.frame() component.
q.app.document_path, = await q.site.upload(['path/to/my/document.pdf'])
q.page['example'] = ui.form_card(box='1 1 7 7', items=[
ui.frame(path=document_path)
])Serving files directly from the Wave server
As an alternative to using the above upload() or download() mechanisms, you can make the Wave server (waved) directly serve the contents of one or more existing directories. If the Wave server and your app both have access to the directories on the file system, your app can simply create or copy files to the directories to make them accessible from web browsers.
Serve the contents of directory /home/zaphod/data at http://localhost:10101/datasets/
waved -public-dir /datasets/@/home/zaphod/dataServe the contents of directory /home/zaphod/data at http://localhost:10101/datasets/, and /home/zaphod/models at http://localhost:10101/public/models
waved -public-dir /datasets/@/home/zaphod/data -public-dir /public/models/@/home/zaphod/modelsServe the contents of directory /home/zaphod/data at http://localhost:10101/datasets/, but only to authenticated users.
waved -private-dir /datasets/@/home/zaphod/dataServe the contents of directory /home/zaphod/data at http://localhost:10101/datasets/, and /home/zaphod/models at http://localhost:10101/public/models, but only to authenticated users.
waved -private-dir /datasets/@/home/zaphod/data -private-dir /public/models/@/home/zaphod/modelsNote that any number of -public-dir and -private-dir arguments are allowed.
Graphics (`website/docs/graphics.md`)
title: Graphics
Wave provides a versatile API for rendering vector graphics (SVG) backed by data buffers.
Use the graphics card ui.graphics_card() to render graphics.
Stages and scenes
The graphics card has two attributes that control how graphics are rendered: stage and scene:
- A stage holds static shapes that are rendered as part of the background (literally, behind the scene).
- A scene holds shapes whose attributes need to be animated (causing a re-render, or, literally, cause a scene).
Use ui.stage() and ui.scene() to create the stage and the scene:
ui.stage()creates a packed, static representation of its shapes (recall that the stage is not supposed to change).ui.scene()declares a data buffer to efficiently update its shapes if changed (recall that the scene is animated).
Here's an example that draws a clock with a circular face placed on the stage, and hour, min and sec hands placed in the scene:
from h2o_wave import site, ui, graphics as g
page = site['/demo']
page['example'] = ui.graphics_card(
box='1 1 2 3', view_box='0 0 100 100', width='100%', height='100%',
stage=g.stage(
face=g.circle(cx='50', cy='50', r='45', fill='#111', stroke_width='2px', stroke='#f55'),
),
scene=g.scene(
hour=g.rect(x='47.5', y='12.5', width='5', height='40', rx='2.5', fill='#333', stroke='#555'),
min=g.rect(x='48.5', y='12.5', width='3', height='40', rx='2', fill='#333', stroke='#555'),
sec=g.line(x1='50', y1='50', x2='50', y2='16', stroke='#f55', stroke_width='1px'),
),
)Shapes
Use drawing functions to add shapes to the stage or scene:
| Primitive | Use |
|---|---|
| arc() | Circular or annular sector, as in a pie or donut chart |
| circle() | Circle |
| ellipse() | Ellipse |
| image() | Image |
| line() | Line |
| path() | Path |
| polygon() | Polygon |
| polyline() | Polyline |
| spline() | Curves (including radial curves) using various algorithms: basis, cardinal, smooth, linear, monotone, natural, step. |
| rect() | Rect |
| text() | Text |
Paths
The path() function is special: it can draw arbitrary shapes. This is because SVG path elements support an attribute called d which holds drawing commands.
Here's a path that represents a red square:
g.path(d='m 25 25 h 50 v 50 h -50 z', fill='red')The above snippet produces:
<path d='m 25 25 h 50 v 50 h -50 z' fill='red'/>The drawing commands in the above example work like this:
m 25 25: Move 25px left, 25px downh 50: Draw a line 50px rightv 50: Draw a line 50px downh -50: Draw a line 50px leftz: Close the path (going back to where we started)
The little drawing syntax above is part of the SVG specification, not something unique to Wave.
Authoring path() drawing commands by hand is tedious, so Wave provides two utilities to make it easier: p() and turtle().
Drawing with paths
p() creates a path generator with convenience methods to draw step by step. For example, the following two lines are equivalent:
red_square = g.path(d='m 25 25 h 50 v 50 h -50 z', fill='red')
red_square = g.p().m(25, 25).h(50).v(50).h(-50).z().path(fill='red')Commands
| Method | Use |
|---|---|
M() |
Move, absolute |
H() |
Horizontal line, absolute |
V() |
Vertical line, absolute |
L() |
Line, absolute |
A() |
Elliptical arc, absolute |
C() |
Cubic Bézier curve, absolute |
S() |
Cubic Bézier curve, smooth, absolute |
Q() |
Quadratic Bézier curve, absolute |
T() |
Quadratic Bézier curve, smooth, absolute |
Z() |
Close path |
m() |
Move, relative |
h() |
Horizontal line, relative |
v() |
Vertical line, relative |
l() |
Line, relative |
a() |
Elliptical arc, relative |
c() |
Cubic Bézier curve, relative |
s() |
Cubic Bézier curve, smooth, relative |
q() |
Quadratic Bézier curve, relative |
t() |
Quadratic Bézier curve, smooth, relative |
z() |
Close path |
d() |
Serialize this path's commands into SVG path data. |
path() |
Create a SVG path element |
:::info
The upper-cased commands use absolute coordinates. The lower-cased commands use relative coordinates. For example, L(4,2) means "draw a line to (4,2)", but l(4,2) means "draw a line 4px right, 2px down from the current position".
:::
Drawing with a turtle
turtle() creates a path generator (similar to p()), but using Turtle Geometry.
Commands
| Method | Use |
|---|---|
pd() |
Pen down |
pu() |
Pen up |
p() |
Set the turtle's position |
a() |
Set the turtle's orientation |
f() |
Move forward |
b() |
Move backward |
l() |
Turn left |
r() |
Turn right |
d() |
Serialize this turtle's movements into SVG path data |
path() |
Create a SVG path element |
Example
Here is an example from Python's turtle module:
Here is the above example recreated in Wave:
from turtle import *
color('red', 'yellow')
begin_fill()
while True:
forward(200)
left(170)
if abs(pos()) < 1:
break
end_fill()
done()from h2o_wave import site, ui, graphics as g
t = g.turtle().f(100).r(90).pd()
for _ in range(36):
t.f(200).l(170)
spirograph = t.pu(1).path(stroke='red', fill='yellow')
page = site['/demo']
page['example'] = ui.graphics_card(
box='1 1 2 3', view_box='0 0 220 220', width='100%', height='100%',
scene=g.scene(foo=spirograph),
)
page.save():::info
Turtle graphics is not just kid stuff: See Turtle Geometry by Harold Abelson and Andrea diSessa.
:::
Guide (`website/docs/guide.md`)
title: Introduction slug: /guide
Welcome to the H2O Wave Guide! If you prefer to learn concepts step by step, you've come to the right place.
:::info
If you haven't already, it's worthwhile reading the Getting Started page, which offers a 5-minute overview of what H2O Wave is all about.
:::
Before you start
If you prefer to learn by doing, start with our tutorials - they're short and simple, illustrate the most important concepts in a beginner-friendly way, and are the best way to get hands-on with Wave. Like any unfamiliar technology, Wave has a slight learning curve, but you will get the hang of it with practice and patience.
Who is this for?
This documentation assumes that you have some familiarity with the Python programming language. You don't have to be an expert, but it might be harder to learn both Wave and Python at the same time.
If you already know Python, but have no experience authoring web apps, you'll probably find Wave a fun and easy way to get started, without the hassle of learning HTML, CSS, Javascript and client-server programming.
If you have some experience building web apps using Python (or even other languages), you'll find that Wave offers an approach quite different from traditional client-server or request-reply programming. The mental model is much simpler, functional (in the functional programming sense), and lets you reason about your code in terms of a single tier (server-only) as opposed to two tiers (client and server). Additionally, Wave offers straightforward ways to integrate realtime features into your apps using the same programming model.
Icons (`website/docs/icons.mdx`)
title: Icons slug: /icons
import { IconsPage } from '@site/src/components/icons'
Installation (`website/docs/installation.mdx`)
title: Installation
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
pip install h2o-waveconda config --append channels conda-forge
conda install -c h2oai h2o-wave:::info
Conda packaging does not contain Wave server which means, you will still need to run the wave server (waved) separately.
:::
To install in a virtual environment (recommended):
python3 -m venv venv
source venv/bin/activate
pip install h2o-wavepython -m venv venv
.\venv\Scripts\activate
pip install h2o-waveconda create -n venv
conda activate venv
conda config --append channels conda-forge
conda install -c h2oai h2o-wave:::info
Conda packaging does not contain Wave server which means, you will still need to run the wave server (waved) separately.
:::
Project templates
Once the installation part is complete, you can use wave init command to pick a predefined app template that will bootstrap your first app and give you a head start when building your Wave apps.
Interactive learning
If you don't feel like going through the docs because you are more of a hands-on person, you can try our Wave University app.
Within your activated virtual environment, run:
wave learnThe command requires h2o_wave_university to be installed. To do that, run:
pip install h2o_wave_university:::tip
Wave University is a standalone package that can be pip-installed and run independently. After installation, you will be able to launch it via wave-university command within your python environment.
:::
Download examples
Run wave fetch to download app examples, demos, and the interactive tour.
(venv) $ wave fetch
Fetching examples and related files. Please wait...
Downloading https://github.com/h2oai/wave/releases/download/v0.20.0/wave-0.20.0-linux-amd64.tar.gz
Extracting...
All additional files downloaded and extracted successfully!
Examples and tour............. /home/wave/examples
Demos and layout samples...... /home/wave/demo
Automated test harness........ /home/wave/test
Wave daemon for deployments... /home/waveYou now have access to 200+ examples locally.
:::info
On recent versions of OSX, it's possible to run into certificate verification errors while running wave fetch, specifically urllib.error.URLError: urlopen error [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate. To fix this problem, navigate to Application/Python 3.7 on your system and execute Install Certificates.command.
:::
Run the tour
The Wave tour is a Wave app that lets you play with all the examples interactively.
To run the tour, simply launch examples/tour.py like this:
cd wave
pip install -r examples/requirements.txt
wave run --no-reload examples.tourThen navigate to http://localhost:10101/tour to access the tour.
tour.py is an ordinary Wave app that runs other apps. The tour itself runs at the route /tour, and each of the examples runs at /demo.
:::tip
To play with the tour's active example in isolation, simply open a new browser tab and head to http://localhost:10101/demo.
:::
Downloading Wave
If you would like to run Wave server manually (e.g. for deployment or Wave scripts), you can download it either from here or from our Github releases.
If unsure which package to pick, here is a helper table:
| OS | Wave server | Python client |
|---|---|---|
| Mac | darwin-amd64 | macosx_10_9_x86_64 |
| Mac M1 | darwin-arm64 | macosx_11_0_arm64 |
| Windows | windows-amd64 | win_amd64 |
| Linux | linux-amd64 | manylinux1_x86_64 |
import { Downloads } from '@site/src/components/downloads'
Early adopters
For people who simply cannot wait until next release and would like to try the new cool feature merged just a day ago, we also provide so-called "nightly builds". These are builds that are released daily (over night).
We highly encourage using nightly builds as they allow catching possible regressions ahead of an official release. So please, if you spot something weird that is not happening in the latest release,
file an issue.
To use nightly:
- Go to nightly release page.
- Download the appropriate
.whlfile for your OS. - Run
pip install <downloaded-nightly>.whl(this is an alternative topip install h2o-wavewith official releases).
:::tip
It's possible to include the local .whl file directly within your requirements.txt if needed.
:::
Smoother dev experience
For Wave-specific IDE autocomplete and additional snippets that will make your life way easier, we recommend installing VSCode extension or Jetbrains Plugin depending on the IDE of your choice.
Feel free to read more at this and this blog posts.
Wrapping up
In this section, we installed Wave and then launched tour.py to experience the tour. In general, this is how you'd typically launch any app, including your own. There is nothing special about tour.py. In fact, to run any example, all you need to do is repeat the steps above in a new terminal window. For example, to run todo.py, simply run:
wave run examples.todoYou can now access the example at http://localhost:10101/demo. Simple!
Javascript (`website/docs/javascript.md`)
title: Javascript
Wave ships with a growing library of cards and components for assembling user interfaces. For most apps, the built-in components can be adequate. They're designed to work well with each other, with consistent typography, layout and theming; and the library of components keeps expanding with each new release.
However, no matter how comprehensive the Wave library gets over time, there will be situations where an app needs to use external Javascript components to supplement Wave's capabilities, like custom visualizations, UI enhancements, and graphics.
Wave 0.16+ allows importing and using third-party Javascript libraries on a page. This provides an escape-hatch of sorts, allowing you to add UI capabilities that are not yet possible with stock Wave.
Basic Usage
The simplest way to run custom Javascript code on a page is to use an inline script (ui.inline_script()):
q.page['meta'] = ui.meta_card(box='', script=ui.inline_script('alert("Hello World!");'))
q.page.save()To execute additional bits of Javascript code, set the meta_card's script property again:
q.page['meta'].script = ui.inline_script('console.log("One Mississippi");')
q.page.save()
q.page['meta'].script = ui.inline_script('console.log("Two Mississippi");')
q.page.save()
q.page['meta'].script = ui.inline_script('console.log("Three Mississippi");')
q.page.save()Passing scripts to ui.meta_card() dynamically imports external Javascript libraries.
Import a library
q.page['meta'] = ui.meta_card(box='', scripts=[ui.script('https://cdnjs.cloudflare.com/ajax/libs/animejs/2.0.2/anime.min.js')])Import multiple libraries
q.page['meta'] = ui.meta_card(box='', scripts=[
ui.script('https://cdnjs.cloudflare.com/ajax/libs/animejs/2.0.2/anime.min.js'),
ui.script('https://cdnjs.cloudflare.com/ajax/libs/d3/6.7.0/d3.min.js'),
ui.script('https://cdnjs.cloudflare.com/ajax/libs/bokeh/2.3.2/bokeh.min.js'),
])Import and use a library
q.page['meta'] = ui.meta_card(
box='',
# Load anime.js
scripts=[ui.script('https://cdnjs.cloudflare.com/ajax/libs/animejs/2.0.2/anime.min.js')],
script=ui.inline_script(
# The Javascript code for this script.
content='anime.timeline(...);',
# Execute this script only if the 'anime' library is available.
requires=['anime'],
# Execute this script only if the 'animation' element is available.
targets=['animation'],
)
)
q.page['example'] = ui.markup_card(
box='1 1 10 8',
title='Animation',
content='<div id="animation"/>',
)In the above example, we create an empty div HTML element on the page, load an animation library (anime.js), add a bit of Javascript to animate the div element. To tie things together correctly, we pass two additional arguments to ui.inline_script():
- The
requiresargument ensures that the library we intend to use (in this case,anime.js) is downloaded, imported, and ready to use. - The
targetsargument ensures that the HTML element the scripts operates on (in this case, thedivelement namedanimation), is available on the page. CSS selectors can also be used to identify target elements.
Import ESM module
When using JavaScript module files (.mjs), the script type="module" attribute needs to be specified.
q.page['meta'] = ui.meta_card(box='', scripts=[ui.script(type="module", path='https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.2.67/pdf.min.mjs')])For other script types see the official documentation.
Handling Events
In most cases, when employing custom Javascript libraries, you'll want to handle and transmit events from the library to your app. To do this, use the Javascript function wave.emit():
// Javascript
wave.emit('event_source', 'event_name', event_data);You can then access this information in your app using:
# Python
event_data = q.events.event_source.event_nameFor example, emitting an event using wave.emit('graph', 'node_clicked', 42) in Javascript will set q.events.graph.node_clicked to 42 in Python.
All three arguments to wave.emit() are arbitrary. In the above example, we use:
graphto indicate the source of the event.node_clickedto indicate the type of event.- the third argument can be a string, number, boolean or any complex structure, like
{ foo: 'bar', qux: 42 }.
Here's a complete, minimal example that prints mouse coordinates each time you click on a page.
from h2o_wave import main, app, Q, ui
@app('/demo')
async def serve(q: Q):
if not q.client.initialized:
q.page['meta'] = ui.meta_card(box='', script=ui.inline_script(
# Handle and transmit event
'window.onclick = (e) => wave.emit("window", "clicked", { x: e.clientX, y: e.clientY });'
))
q.client.initialized = True
else:
# Capture event
if q.events.window:
click = q.events.window.clicked
if click:
print(click.x, click.y)
await q.page.save()Examples
You can play with more examples in the Wave Tour as well.
Layout (`website/docs/layout.md`)
title: Layout
This section illustrates how to lay out cards on a page.
Wave supports two layout systems out of the box: a grid layout and a flex (flexible) layout.
By default, pages use the grid-based system. The grid-based system is simpler to use and ensures that your page layout looks exactly the same on every display, without surprises.
To switch to a flex layout, simply define a page layout using the ui.layout() API, as shown below.
Flex Layout
The flex layout system allows you to build user interfaces and dashboards that adapt to various device and screen sizes ("responsive layout").
:::info
To view examples of flex and responsive layouts in actions, see https://github.com/h2oai/wave/tree/main/py/demo
:::
To define a layout for your page, attach a layout to your page's meta card using ui.layout().
The following example defines a simple layout consisting of a header, a body and a footer. The body, in turn, is split horizontally into a content area and a sidebar.
q.page['meta'] = ui.meta_card(box='', layouts=[
ui.layout(
breakpoint='xl',
width='1200px',
zones=[
ui.zone('header'),
ui.zone('body', direction=ui.ZoneDirection.ROW, zones=[
ui.zone('content', size='75%'),
ui.zone('sidebar', size='25%'),
]),
ui.zone('footer'),
]
)
])A layout, at a minimum, consists of a breakpoint and a set of zones.
Breakpoints
The breakpoint is the minimum viewport (browser) width at which the layout will be applied to the page. Values must be pixel widths (e.g. 0px, 576px, 768px) or a named preset. The named presets are:
xs:0pxfor extra small devices (portrait phones)s:576pxfor small devices (landscape phones)m:768pxfor medium devices (tablets)l:992pxfor large devices (desktops)xl:1200pxfor extra large devices (large desktops).
A breakpoint value of xs matches all viewport widths, unless other breakpoints are set.
Zones
A zone is a named area inside the layout where cards can be placed. A zone is defined using ui.zone().
Zones have a direction, either ui.ZoneDirection.COLUMN (with children laid out top to bottom) or ui.ZoneDirection.ROW (with children laid out left to right).
Zone can define a size, which translates to the zone's width in case the zone is laid out horizontally, or the zone's height in case the zone is laid out vertically.
Zones can have other zones nested inside them.
In the above example, the content and sidebar zones are laid out horizontally inside the body, with the content zone taking up 75% of the available width, and the sidebar taking up 25%.
Cards will take up an equal amount of space inside the zone, not shrinking to fit their content. To prevent a card from expanding provide a ui.box with size='0'.
q.page['meta'] = ui.meta_card(box='', layouts=[
ui.layout(
breakpoint='xs',
zones=[ui.zone(name='zone1', direction=ui.ZoneDirection.COLUMN, size='100%')]
)
])
q.page['tab_card'] = ui.tab_card(
box=ui.box(zone='zone1'),
items=[ui.tab(name='tab1', label='tab')]
)
q.page['form_card'] = ui.form_card(box='zone1', items=[
ui.text(content='my form card')
])Default behavior:
Using size='0':
q.page['tab_card'] = ui.tab_card(
box=ui.box(zone='zone1', size='0'),
items=[ui.tab(name='tab1', label='tab')]
)Width and Height
By default, we encourage static units like px or rem for setting widths and heights (see MDN for more info). In flex layouts, percent values such as 100% only work if every ancestor has an explicit size, otherwise, the computed height can be zero and the content may not render as expected.
Responsive Layout
To make your page adapt to multiple viewport sizes, define multiple layouts using ui.layout().
q.page['meta'] = ui.meta_card(box='', layouts=[
ui.layout(
breakpoint='xs',
zones=[
# Add zones here.
],
),
ui.layout(
breakpoint='m',
zones=[
# Add zones here.
],
),
ui.layout(
breakpoint='xl',
width='1200px',
zones=[
# Add zones here.
]
)
])Placing cards
To place a card inside a zone in your layout, use the card's box attribute.
The simplest way to assign a card to a zone is to set the box to the name of zone:
q.page['shopping_cart'] = ui.markdown_card(box='sidebar', ...)For more control over how the card is placed, use ui.box():
# Same as box='sidebar'
q.page['shopping_cart'] = ui.markdown_card(box=ui.box('sidebar'), ...)Make the card the third item in the sidebar:
q.page['shopping_cart'] = ui.markdown_card(box=ui.box('sidebar', order=3), ...)Make the card take up two parts of available space:
q.page['shopping_cart'] = ui.markdown_card(box=ui.box('sidebar', size=2), ...)Make cards take up available space in a 10:5:4 ratio:
q.page['shopping_cart'] = ui.markdown_card(box=ui.box('sidebar', order=1, size=10), ...)
q.page['bill'] = ui.markdown_card(box=ui.box('sidebar', order=2, size=5), ...)
q.page['discounts'] = ui.markdown_card(box=ui.box('sidebar', order=3, size=4), ...)Make the card 200px wide:
q.page['shopping_cart'] = ui.markdown_card(box=ui.box('sidebar', width='200px'), ...)Make the card 300px high:
q.page['shopping_cart'] = ui.markdown_card(box=ui.box('sidebar', height='300px'), ...)Make the card 200px wide and 300px high:
q.page['shopping_cart'] = ui.markdown_card(box=ui.box('sidebar', width='200px', height='300px'), ...)For responsive layouts, specify multiple boxes using ui.boxes(). Each ui.box() in ui.boxes() refers to the corresponding ui.layout() specified at the page level.
q.page['meta'] = ui.meta_card(box='', layouts=[
ui.layout(breakpoint='xs', zones=[...]),
ui.layout(breakpoint='m', zones=[...]),
ui.layout(breakpoint='xl', zones=[...]),
])
q.page['shopping_cart'] = ui.markdown_card(box=ui.boxes(
ui.box('bottom', height='300px'), # for breakpoint xs, place in the bottom zone.
ui.box('middle', height='200px'), # for breakpoint m, place in the middle zone.
ui.box('sidebar', height='2'), # for breakpoint xl, place in the sidebar zone.
), ... )Aligning cards
Wave cards take all available space by default to avoid unnecessary whitespace. If the extra whitespace is desirable though, one may need to use align and justify zone attributes.
q.page['meta'] = ui.meta_card(box='', layouts=[ui.layout(breakpoint='xs', zones=[
ui.zone(
name='content',
direction=ui.ZoneDirection.ROW,
# Specify a zone size, otherwise will be adapted to the biggest card in the zone.
size='500px',
# Align cards on the cross-axis (vertical direction for ROW and horizontal for COLUMN).
align='center',
# Align cards on the main-axis (vertical direction for COLUMN and horizontal for ROW).
justify='around'
)
])])
q.page['card1'] = ui.tall_info_card(box=ui.box('content', width='200px', height='200px'), name='',
title='Card', caption='Lorem ipsum')
q.page['card2'] = ui.tall_info_card(box=ui.box('content', width='200px', height='200px'), name='',
title='Card', caption='Lorem ipsum')
q.page['card3'] = ui.tall_info_card(box=ui.box('content', width='200px', height='200px'), name='',
title='Card', caption='Lorem ipsum')To see the full alignment effect it's important to note that card sizes have to either be explicitly set (like in the example code) or the size attribute has to be set to '0' (since it defaults to '1').
Grid Layout
By default, every page has a grid system in place, designed to fit HD displays (1920 x 1080 pixels). The grid has 12 columns and 10 rows. A column is 134 pixels wide. A row is 76 pixels high. The gap between rows and columns is set to 15 pixels.
Placing cards
Every card has a box attribute that specifies how to position the card on the page, a string of the form 'column row width height', for example '1 1 2 4' or '8 7 3 6'.
page['quote'] = ui.markdown_card(
box='1 1 2 2',
title='Hello World',
content='"The Internet? Is that thing still around?" - *Homer Simpson*',
)The column and row indicate which column and row to position the top-left corner of the card. The width and height indicate the width and height of the cards, respectively. The column and row are 1-based. The width and height are in spans (units) of columns or rows.
| Attribute | Value | Interpreted as |
|---|---|---|
| column | N | Nth column |
| row | N | Nth row |
| width | N | N columns wide |
| height | N | N rows high |
For example, a box of 1 2 3 4 is interpreted as:
| Attribute | Value | Interpreted as |
|---|---|---|
| column | 1 | 1st column |
| row | 2 | 2nd row |
| width | 3 | 3 columns wide |
| height | 4 | 4 rows high |
:::tip
The default grid is designed to fit HD displays (1920 x 1080 pixels), so if you want your page to fit smaller displays, don't use up the entire grid. Instead, use up less columns and rows, like 8 columns x 6 rows.
:::
See also
layout.py and layout-size.py in Tour app.
Logging (`website/docs/logging.md`)
title: Logging
Wave apps are plain Python programs. Use Python's built-in logging module to configure logging.
Here's a basic configuration that logs a ISO8601 timestamp, log level, and message:
from h2o_wave import Q, main, app
import logging
logging.basicConfig(format='%(asctime)s %(levelname)s %(message)s')
@app('/demo')
async def serve(q: Q):
logging.warning('All your base are belong to us')The above snippet makes the app log a warning every time it is accessed. This is what the logged message look like:
2010-12-12 11:41:42,612 WARNING All your base are belong to us:::tip
To better see your log messages, you might want to turn off Wave server logging by setting the H2O_WAVE_NO_LOG environment variable to 1.
:::
:::info
See Python's logging module for more information.
:::
Pages (`website/docs/pages.md`)
title: Pages
The Wave server stores and manages content. Content is stored in a page cache, called a site. A Wave server contains exactly one site. A site holds a collection of pages. A page is composed of cards. Cards hold content and data buffers.
Reference
To reference a site from within a Wave script, import site.
from h2o_wave import sitesite is a dictionary-like object.
To reference the current site from within a Wave app, use q.site.
async def serve(q: Q):
site = q.siteTo reference a page hosted at /foo, use site['/foo'].
page = site['/foo']To reference the current page in a Wave app, use q.page.
async def serve(q: Q):
page = q.pagepage is also a dictionary-like object. To reference a card named foo, use page['foo'].
card = page['foo']Add
There are two ways to add a card to a page.
The first way is to assign a new card to page['foo'].
page['foo'] = cardThe second way is to use page.add(). This is useful when you want to add a card to a page and obtain a reference to the new card.
card = page.add('foo', card)The following two forms are equivalent. The second form is more concise.
page['foo'] = ui.form_card(...)
card = page['foo']card = page.add('foo', ui.form_card(...)Delete
To delete a card named foo from a page, use del page['foo']:
del page['foo']To delete the page hosted at /foo, use page.drop() or del site['/foo']. The following three forms are equivalent.
page = site['/foo']
page.drop()site['/foo'].drop()del site['/foo']Deleting a page automatically drops all cards associated with that page. Conversely, to delete all cards from a page, simply delete the page.
To clear all cards in the active page from within a Wave app, use q.page.drop():
async def serve(q: Q):
q.page.drop()Replace
Assigning a card to page['foo'] replaces any previously assigned card named foo. Therefore, the following two forms are equivalent. The second form is more concise, hence preferred.
page['foo'] = card1
del page['foo']
page['foo'] = card2page['foo'] = card1
page['foo'] = card2Update
To avoid recreating the card from scratch, but to only update a specific attribute, use name attribute to get the reference to the component:
q.page['example'] = ui.form_card(box='1 1 4 4', items=[
ui.button(name='my_btn', label='Old label'),
])
q.page['example'].my_btn.label = 'New label'Save
To save a page from within a Wave script, use page.save().
page.save()To save the active page from within a Wave app, use q.page.save().
async def serve(q: Q):
await q.page.save():::cautionq.page.save() is an async function, so you must await while calling it.
:::
You don't need to explicitly create a new page. A page is automatically created on save if it doesn't exist.
Control non-app pages
To update other (non-app) pages from within an app, use AsyncSite:
from h2o_wave import Q, AsyncSite
site = AsyncSite()
async def serve(q: Q):
page = site['/foo']
page['bar'] = card
await page.save()Animations (experimental)
Cards can be animated during their creation by setting animate=True on the ui.meta_card.
page['meta'] = ui.meta_card(box='', animate=True):::warning
An animation is considered fluid when it is able to reach 60fps (frames per second). To achieve such fps, one needs to make sure the browser has enough resources to paint the animation at such rate. Loading too many data or doing too much browser work may result in janky animations so use at your own risk.
:::
Default animation is a simple fade in animation (opacity 0 => 1) lasting for 0.5s. if you are not happy with these defaults, it's possible to override these values via custom CSS.
:root {
/* Custom CSS properties to configure duration and animation. */
--wave-animation-duration: 0.5s;
--wave-card-animation: wave-fade-in
}
/* Override this class to get full control over animations. */
.wave-animate-card {
animation: var(--wave-card-animation);
animation-duration: var(--wave-animation-duration);
}
@keyframes wave-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}Plotting (`website/docs/plotting.md`)
title: Plots
Wave provides a versatile plotting API based on Leland Wilkinson's Grammar of Graphics.
A plot is a layered graphic, created using ui.plot(). Each layer displays marks, described by ui.mark(). The layers are rendered on top of each other to produce the final plot.
ui.mark() describes a collection of marks, not one mark. Since each ui.mark() describes one layer in the plot, it follows that all the marks on a layer are of the same type (its geometry). A mark's type can be one of point, interval, line, path, area, polygon, schema, edge.
There are two ways to add plots to a page:
- Use a plot card (
ui.plot_card()) and set itsplotattribute usingui.plot(). - Use a form card (
ui.form_card()), insert a visualization (ui.visualization()) and set itsplotattribute usingui.plot(). See form visualization for more info.
Here's a short example that renders a scatterplot of values between [0, 1].
from h2o_wave import data
q.page['example'] = ui.plot_card(
box='1 1 4 5',
title='Point',
data=data('height weight', 10, rows=[
(170, 59),
(159.1, 47.6),
(166, 69.8),
(176.2, 66.8),
(160.2, 75.2),
(180.3, 76.4),
(164.5, 63.2),
(173, 60.9),
(183.5, 74.8),
(175.5, 70),
]),
plot=ui.plot([ui.mark(type='point', x='=weight', y='=height')])
)Annotation layers
Wave plots are rendered in layers, one upon each other. This mechanism allows mixing multiple plots
and annotations into a single canvas.
ui.plot() accepts a list of marks. The following example renders annotations on top of a scatterplot.
from h2o_wave import data
q.page['example'] = ui.plot_card(
box='1 1 4 5',
title='Numeric-Numeric',
data=data('height weight', 10, rows=[
(170, 59),
(159.1, 47.6),
(166, 69.8),
(176.2, 66.8),
(160.2, 75.2),
(180.3, 76.4),
(164.5, 63.2),
(173, 60.9),
(183.5, 74.8),
(175.5, 70),
]),
plot=ui.plot([
ui.mark(type='point', x='=weight', y='=height', x_min=0, x_max=100, y_min=0, y_max=100), # the plot
ui.mark(x=50, y=50, label='point'), # A single reference point
ui.mark(x=40, label='vertical line'),
ui.mark(y=40, label='horizontal line'),
ui.mark(x=70, x0=60, label='vertical region'),
ui.mark(y=70, y0=60, label='horizontal region'),
ui.mark(x=30, x0=20, y=30, y0=20, label='rectangular region')
])
)Events
In order to be able to handle wave events, simply register them via events attribute of the
plot. Depending on the event type registered, when the event fires, it fillsq.events.<card-name>.<event-name> within python serve function.
Wave currently supports one plot selection event called select_marks. Registering and firing
this event will thus result in q.events.example.select_marks filled.
You can also visit handling events section to learn more about
advanced techniques.
from h2o_wave import data
q.page['example'] = ui.plot_card(
box='1 1 4 5',
title='Interval, range',
data=data('profession salary', 5, rows=[
('medicine', 33000),
('fire fighting', 18000),
('pedagogy', 24000),
('psychology', 22500),
('computer science', 36000),
]),
events=['select_marks'],
plot=ui.plot([ui.mark(type='interval', x='=salary', y='=profession', y_min=0)])
)Disable events
If you do not wish to raise wave events on interaction with a specific mark, simply specify the interactive=False attribute.
from h2o_wave import data
q.page['example'] = ui.plot_card(
box='1 1 4 5',
title='Interval, range',
data=data('year value', 8, rows=[
('1991', 3),
('1992', 4),
('1993', 3.5),
('1994', 5),
('1995', 4.9),
('1996', 6),
('1997', 7),
('1998', 9),
('1999', 13),
]),
events=['select_marks'],
plot=ui.plot([
ui.mark(type='line', x_scale='time', x='=year', y='=value', y_min=0, interactive=False),
ui.mark(type='point', x='=year', y='=value', size=8, fill_color='red')
]),
)Custom axis title
If you are not happy with the defaults provided, simply use either x_title or y_title attribute.
from h2o_wave import data
q.page['example'] = ui.plot_card(
box='1 1 4 5',
title='Line title',
data=data('month price', 12, rows=[
('Jan', 51),
('Feb', 91),
('Mar', 34),
('Apr', 47),
('May', 63),
('June', 58),
('July', 56),
('Aug', 77),
('Sep', 99),
('Oct', 106),
('Nov', 88),
('Dec', 56),
]),
plot=ui.plot([
ui.mark(type='line', x='=month', y='=price', y_min=0, x_title='Month', y_title='Price')
])
)Custom scales
You can format your scales any way you want basically since x and y can be expressions. The following example displays a way to show date and time from timestamp data.
from h2o_wave import data
q.page['example'] = ui.plot_card(
box='1 1 4 5',
title='Line',
data=data('year value', 9, rows=[
('2020-05-04T10:22:21', 3),
('2020-05-04T11:22:21', 4),
('2020-05-04T12:22:21', 3.5),
('2020-05-04T13:22:21', 5),
('2020-05-04T14:22:21', 4.9),
('2020-05-04T15:22:21', 6),
('2020-05-04T16:22:21', 7),
('2020-05-04T17:22:21', 9),
('2020-05-04T18:22:21', 13),
]),
plot=ui.plot([
ui.mark(
type='line',
x_scale='time',
x='={{intl year type="time" month="numeric" day="numeric" hour="numeric" minute="numeric" hourCycle="h24" }}',
y='=value',
y_min=0
)
])
)See formatting options for more info.
Horizontal
By default, all the plot cards are rendered vertically. However, there might be cases when you also want to provide a plot caption and conserve the space.
from h2o_wave import data
q.page['example'] = ui.wide_plot_card(
box='1 1 5 4',
title='Wide Plot Card',
caption='''
Lorem ipsum dolor sit amet, consectetur adipisicing elit. Quia aliquam maxime quos facere
necessitatibus tempore eum odio, qui illum. Repellat modi dolor facilis odio ex possimus
''',
data=data('profession salary', 5, rows=[
('medicine', 23000),
('fire fighting', 18000),
('pedagogy', 24000),
('psychology', 22500),
('computer science', 36000),
]),
plot=ui.plot([ui.mark(type='interval', x='=profession', y='=salary', y_min=0)])
)Animations (experimental)
Plots support basic animations when animate=True.
:::warning
An animation is considered fluid when it is able to reach 60fps (frames per second). To achieve such fps, one needs to make sure the browser has enough resources to paint the animation at such rate. Loading too many data or doing too much browser work may result in janky animations so use at your own risk.
:::
from h2o_wave import data
q.page['example'] = ui.plot_card(
box='1 1 4 5',
title='Line',
animate=True,
data=data('year value', 8, rows=[
('1991', 3),
('1992', 4),
('1993', 3.5),
('1994', 5),
('1995', 4.9),
('1996', 6),
('1997', 7),
('1998', 9),
('1999', 13),
]),
plot=ui.plot([ui.mark(type='line', x_scale='time', x='=year', y='=value', y_min=0)])
)Point
- Basic: Make a scatterplot.
- Shapes: Make a scatterplot with categories encoded as mark shapes.
- Sizes: Make a scatterplot with mark sizes mapped to a continuous variable (a "bubble plot").
- Map: Make a plot to compare quantities across categories. Similar to a heatmap, but using size-encoding instead of color-encoding.
- Groups: Make a scatterplot with categories encoded as colors.
- Annotation: Add annotations (points, lines and regions) to a plot.
- Custom: Customize a plot's fill/stroke color, size and opacity.
Interval
Columns
- Basic: Make a column plot.
- Groups: Make a grouped column plot.
- Range: Make a column plot with each bar representing high/low (or start/end) values. Transposing this produces a gantt plot.
- Labels: Make a column plot with labels on each bar.
- Stacked: Make a stacked column plot.
- Stacked, Grouped: Make a column plot with both stacked and grouped bars.
- Annotation: Add annotations to a column plot.
- Theta: Make a "racetrack" plot (a column plot in polar coordinates).
- Theta, Stacked: Make a stacked "racetrack" plot.
Bars
- Basic: Make a bar plot.
- Groups: Make a grouped bar plot.
- Range: Make a bar plot with each bar representing high/low (or start/end) values. Transposing this produces a gantt plot.
- Stacked: Make a stacked bar plot.
- Stacked, Grouped: Make a bar plot with both stacked and grouped bars.
- Annotation: Add annotations to a bar plot.
- Polar: Make a rose plot (a bar plot in polar coordinates).
- Polar, Stacked: Make a stacked rose plot (a stacked bar plot in polar coordinates).
- Helix: Make a bar plot in helical coordinates.
Line
- Basic: Make a line plot.
- Groups: Make a multi-series line plot.
- Smooth: Make a line plot using a smooth curve.
- Step: Make a line plot with a step curve.
- Step, After: Make a line plot with a step-after curve.
- Step, Before: Make a line plot with a step-before curve.
- Labels: Add labels to a line plot.
- Labels, Custom: Add customized labels to a line plot.
- Labels, Stroked: Customize label rendering: add a subtle outline to labels to improve readability.
- Labels, Occlusion: Make a line plot with non-overlapping labels.
- Annotation: Add annotations to a line plot.
Path
- Basic: Make a path plot.
- Point: Make a path plot with an additional layer of points.
- Smooth: Make a path plot with a smooth curve.
Area
- Basic: Make an area plot.
- Groups: Make an area plot showing multiple categories.
- Negative: Make an area plot showing positive and negative values.
- Range: Make an area plot representing a range (band) of values.
- Smooth: Make an area plot with a smooth curve.
- Stacked: Make a stacked area plot.
Area + Line
- Area + Line: Make an area plot with an additional line layer on top.
- Area + Smooth: Make a combined area + line plot using a smooth curve.
- Area + Groups: Make an combined area + line plot showing multiple categories.
Polygon
- Basic: Make a heatmap.
Schema
- Histogram: Make a histogram.
Other
- Axis Titles: Display custom axis titles on a plot.
- Form: Display a plot inside a form.
Project Templates (`website/docs/project-templates.md`)
title: "Project templates"
Wave comes with useful app templates to boost the app development startup time. Use wave init CLI command and pick the one you like or use w_app_<template-name> code snippet coming with the IDE plugins.
Hello World app
App with header
App with header and navigation
App with sidebar and navigation
App with header, sidebar and navigation
Realtime (`website/docs/realtime.md`)
title: Realtime Sync
Wave's realtime sync feature enables all connected users to see up to date content.
Pages created by Wave scripts are automatically synced across all users.
Pages created by Wave apps need to explicitly enable realtime sync. This is because apps support multiple users and multiple clients (browser tabs) by default, and, depending on the problem you're trying to solve, it's up to you to decide whether your app's UI should be synced across all users, synced across one user, or not synced at all.
To enable realtime sync in a Wave app, pass the mode argument to @app():
mode='broadcast'syncs across all users.mode='multicast'syncs across one user (in other words, all the clients for that user).mode='unicast'disables sync. This is the default.
from h2o_wave import Q, main, app, ui
@app('/foo', mode='broadcast')
async def serve(q: Q):
pass:::tip
You can also set the mode using the H2O_WAVE_APP_MODE environment variable.
:::
If you change the mode, make sure you store run-time state appropriate to the mode. Generally:
mode |
Store state in |
|---|---|
broadcast |
q.app |
multicast |
q.user |
unicast |
q.client |
Routing (`website/docs/routing.md`)
title: Routing
App routing
Your Wave app gets hosted at the route you passed to @app().
from h2o_wave import Q, main, app
@app('/foo')
async def serve(q: Q):
passTo host your app at localhost:10101/foo or www.example.com/foo, pass /foo to @app().
To host your app at localhost:10101 or www.example.com, pass / to @app(). Do this if you plan to host exactly one app and nothing else.
You can host multiple apps behind a single Wave server.
:::caution/foo and /foo/bar are two distinct paths. /foo/bar is not interpreted as a sub-path of /foo.
:::
Hash routing
Wave apps support hash routing, a popular client-side mechanism where the location hash (the baz/qux in /foo/bar#baz/qux) can be used to decide which part of the UI to display.
Setting the location hash
To set the location hash, prefix # to the name attribute of command-like components. When the command is invoked, the location hash is set to the name of the command.
For example, if a button is named foo is clicked, q.args.foo is set to True. Instead, if a button named #foo is clicked, the location hash is set to foo (q.args.foo is not set).
from h2o_wave import Q, main, app, ui
@app('/toss')
async def serve(q: Q):
q.page['sides'] = ui.form_card(
box='1 1 4 4',
items=[
ui.button(name='#heads', label='Heads'),
ui.button(name='#tails', label='Tails'),
],
)
await q.page.save()Names don't have to be alphanumeric, so you can use names with nested sub-paths like #foo/bar, #foo/bar/baz, #foo/bar/baz/qux to make route-handling more manageable.
The components that support setting a location hash are:
ui.button()ui.command()ui.nav_item()ui.tab()ui.breadcrumb()
Setting the location hash from python
To set the hash route explicitly without command invocation as described above, ui.meta_card is the way to go.
# If meta_card does not yet exist, create one. If it does, skip this line.
q.page['meta'] = ui.meta_card(box='')
# Redirect to a hash.
q.page['meta'].redirect = '#widgets'
# Apply the change in browser.
await q.page.save()Getting the location hash
To get the location hash, read q.args['#'] (a string). If the route in the browser's address bar is /foo/bar#baz/qux, q.args['#'] is set to baz/qux.
from h2o_wave import Q, main, app, ui
@app('/toss')
async def serve(q: Q):
hash = q.args['#']
if hash == 'heads':
print('Heads!')
elif hash == 'tails':
print('Tails!')
q.page.save()Hash route switching
Combining the two examples above gives us a basic pattern for handling routes and updating the user interface:
from h2o_wave import Q, main, app, ui
@app('/toss')
async def serve(q: Q):
hash = q.args['#']
if hash == 'heads':
q.page['sides'].items = [ui.message_bar(text='Heads!')]
elif hash == 'tails':
q.page['sides'].items = [ui.message_bar(text='Tails!')]
else:
q.page['sides'] = ui.form_card(
box='1 1 4 4',
items=[
ui.button(name='#heads', label='Heads'),
ui.button(name='#tails', label='Tails'),
],
)
await q.page.save()Organizing code
In most sizeable applications, the logic in the above if/elif/else conditionals can call into sub-functions, possibly spread across other modules:
from h2o_wave import Q, main, app, ui
async def on_heads(q: Q):
q.page['sides'].items = [ui.message_bar(text='Heads!')]
async def on_tails(q: Q):
q.page['sides'].items = [ui.message_bar(text='Tails!')]
async def setup_page(q: Q):
q.page['sides'] = ui.form_card(
box='1 1 4 4',
items=[
ui.button(name='#heads', label='Heads'),
ui.button(name='#tails', label='Tails'),
],
)
@app('/toss')
async def serve(q: Q):
hash = q.args['#']
if hash == 'heads':
await on_heads(q)
elif hash == 'tails':
await on_tails(q)
else:
await setup_page(q)
await q.page.save()Reducing boilerplate
As your application gets larger, using the above if/elif/else conditionals can seem tedious or repetitive. If so, you can use on and run_on to reduce the boilerplate.
:::warning
In versions <1.0 this function used to be called handle_on, but is now deprecated due to its drawbacks that the new run_on mechanism addressed.
:::
from h2o_wave import Q, main, app, ui, on, run_on
@on('#heads')
async def on_heads(q: Q):
q.page['sides'].items = [ui.message_bar(text='Heads!')]
@on('#tails')
async def on_tails(q: Q):
q.page['sides'].items = [ui.message_bar(text='Tails!')]
async def setup_page(q: Q):
q.page['sides'] = ui.form_card(
box='1 1 4 4',
items=[
ui.button(name='#heads', label='Heads'),
ui.button(name='#tails', label='Tails'),
],
)
@app('/toss')
async def serve(q: Q):
if not await run_on(q):
await setup_page(q)
await q.page.save()In the above example, the @on('#heads') is read as "if q.args['#'] is 'heads', then invoke the function the @on() is applied to" - in this case, on_heads().
Pattern matching
The @on() annotation supports pattern matching.
This function is called when q.args['#'] == 'menu':
@on('#menu')
async def show_menu(q: Q):
passThis function is called when q.args['#'] == 'menu/donuts':
@on('#menu/donuts')
async def show_donuts(q: Q):
passThis function is called when q.args['#'] matches, say, 'menu/donuts/chocolate', with the parameter donut_name set to 'chocolate':
@on('#menu/donuts/{donut_name}')
async def show_donut(q: Q, donut_name: str):
passSame as above, but donut_name is explicitly set to a string:
@on('#menu/donuts/{donut_name:str}')
async def show_donut(q: Q, donut_name: str):
passThis function is called when q.args['#'] matches, say, 'menu/donuts/42', with the parameter donut_id set to 42:
@on('#menu/donuts/{donut_id:int}')
async def show_donut(q: Q, donut_id: int):
passThis function is called when q.args['#'] matches, say, 'menu/donuts/7e21c93f-3a8f-4994-b63e-4275bc975e60', with the parameter donut_id set to the UUID:
@on('#menu/donuts/{donut_id:uuid}')
async def show_donut(q: Q, donut_id: uuid.UUID):
passThis function is called when q.args['#'] matches, say, 'menu/donuts/below/2.99', with the parameter donut_price set to 2.99:
@on('#menu/donuts/below/{donut_price:float}')
async def show_donuts_below(q: Q, donut_price: float):
passHandling query arguments
The @on() annotation can test the contents of q.args and invoke the corresponding handler.
This function is called when q.args.buy_now is found and the value is truthy:
@on('buy_now')
async def buy_donuts(q: Q):
print(q.args.buy_now)The handler can accept the value of the argument as well. Compare:
@on('buy_now')
async def buy_donuts(q: Q, buy_now: bool):
print(buy_now)This function is called when q.args.jam_filled is False:
@on('jam_filled', lambda x: x is False)
async def buy_plain_donuts(q: Q):
passThis function is called when q.args.jam_filled is True or False:
@on('jam_filled', lambda x: isinstance(x, bool)
async def buy_donuts(q: Q, jam_filled: bool):
passThis function is called when q.args.quantity between 42 and 420:
@on('quantity', lambda x: 42 <= x <= 420)
async def buy_donuts(q: Q, quantity: bool):
passHandling events
The @on() annotation can also test the contents of q.events and invoke the corresponding handler.
This function is called when q.events.donut_plot.select_marks is found and the value is truthy:
@on('donut_plot.select_marks')
async def on_marks_selected(q: Q):
passThis function is called when q.events.donut_plot.select_marks is 0:
@on('donut_plot.select_marks', lambda x: x == 0)
async def on_marks_selected(q: Q):
passThis function is called when q.events.donut_plot.select_marks is an integer:
@on('donut_plot.select_marks', lambda x: isinstance(x, int))
async def on_marks_selected(q: Q, count: int):
passThis function is called when q.events.donut_plot.select_marks between 42 and 420:
@on('donut_plot.select_marks', lambda x: 42 <= x <= 420)
async def on_marks_selected(q: Q, count: int):
passHandling user logout
To get notified when a user logs out of your apps, use the system-wide @system.logout event.
@on('@system.logout')
async def on_user_logout(q: Q):
print(f'User {q.auth.username} logged out.')Note that when a user logs out of the Wave daemon, all the apps linked to the daemon get notified with a @system.logout event.
Handling client (browser tab) disconnect
To get notified when a user closes the tab, use the system-wide @system.client_disconnect event. The time if takes for this function to be called depends on the value of H2O_WAVE_RECONNECT_TIMEOUT (which defaults to 5s).
@on('@system.client_disconnect')
async def on_client_disconnect(q: Q):
print('Client disconnected')Scripts (`website/docs/scripts.md`)
title: Wave Scripts
A Wave script is the simplest way to publish content with Wave, especially live web content:
- Dashboards and visualizations for live monitoring.
- Live information displays: news, tickers, health, or performance data.
A Wave script is one kind of program you can author to interact with Wave. The other kind is a Wave App. The primary difference between an app and a script is that apps are interactive (able to handle user interactions) and scripts are not. If you are not interested in handling user interactions, and only want to publish content, use a Wave script.
Here is the skeleton of a Wave script (example):
from h2o_wave import site, ui
# Grab a reference to a page
page = site['/foo']
# Modify the page
page['qux'] = ui.some_card()
# Save the page
page.save()You can run it by using python my_script.py assuming your Wave server is running.
Here is the skeleton of a Wave script that continuously updates a page (example):
import time
from h2o_wave import site, ui
# Grab a reference to a page
page = site['/foo']
# Grab a reference to a card on the page
card = page['qux']
# Update the page with stats every second
while True:
# Read data from somewhere
cpu_percent, mem_usage, disk_usage = read_system_stats()
# Update card's data
card.data[-1] = [cpu_percent, mem_usage, disk_usage]
# Save the page
page.save()
# Wait a second
time.sleep(1)To update pages at a much lower frequency (say, minutes, hours, days), use a scheduler like cron along with a simpler Wave script:
import time
from h2o_wave import site, ui
# Grab a reference to a page
page = site['/foo']
# Grab a reference to a card on the page
card = page['qux']
# Read data from somewhere
cpu_percent, mem_usage, disk_usage = read_system_stats()
# Update card's data
card.data[-1] = [cpu_percent, mem_usage, disk_usage]
# Save the page
page.save()Multiple Wave scripts running on multiple devices can update the same Wave page. You can use this capability to publish a single page that displays content originating from multiple sources. For example, a single page that displays stats for all the systems in your network, or a single page that displays tickers from different stock exchanges.
Security (`website/docs/security.md`)
title: Security
Wave Server API Access Keys
Wave apps and scripts access the Wave server using access keys via HTTP Basic Authentication.
An application access key is a pair of strings: ID and Secret.
Development
The default ID and secret are the strings access_key_id/access_key_secret, which are set for development convenience, but are obviously not secure.
During development, you can change the default ID and secret when you start the Wave server like this:
./waved -access-key-id <id> -access-key-secret <secret>If you change the ID and secret, you'll need to ensure that your app or script uses the new credentials by setting the H2O_WAVE_ACCESS_KEY_ID and H2O_WAVE_ACCESS_KEY_SECRET environment variables accordingly.
Production
For production deployments, you should generate cryptographically secure random ID/secret pairs like this:
$ ./waved -create-access-key
SUCCESS!
Make sure to copy your new access key ID and secret now.
You won't be able to see it again!
H2O_WAVE_ACCESS_KEY_ID=ENHL90KR2HZD6X2ZIYLZ
H2O_WAVE_ACCESS_KEY_SECRET=dxQPcenUJJgLes8rxkp7rUj02t2y3hCqBteyvY2I
Your key was also added to the keychain located at
.wave-keychainThe above command also stores the credentials in a file named .wave-keychain in the current working directory. The file format is similar to a .htpasswd file, but always uses bcrypt hashes. Note that the access key secret displayed on the console is not stored anywhere, and cannot be recovered. If you lose the secret, simply generate a new one and reconfigure your app to use the new secret.
You can also make the -create-access-key command use a keychain file located elsewhere, like this:
./waved -create-access-key -access-keychain /path/to/file.extensionThe Wave server uses the keychain file to authenticate requests from apps and scripts. By default, it automatically loads the .wave-keychain file if present in the current working directory.
To make the Wave server use a specific keychain file, launch it like this:
./waved -access-keychain /path/to/file.extensionTo view a sorted list of all the keys in a keychain file, use -list-access-keys, like this:
$ ./waved -list-access-keys
ENHL90KR2HZD6X2ZIYLZ
IDID44ZK0L7NG8NDD7IC
N8CK63JT6OZIOX2SWALR
PCBTV5TNQKOSAU2EBD6D
VAKQY6QJN3RRQDU5LD0ETo remove a key from a keychain file, use -remove-access-key, like this:
./waved -remove-access-key ENHL90KR2HZD6X2ZIYLZTo remove a key from a keychain file located elsewhere, do this:
./waved -remove-access-key ENHL90KR2HZD6X2ZIYLZ -access-keychain /path/to/file.extensionHTTPS
To enable HTTP over TLS to secure your Wave server, pass the following flags when starting the Wave server:
-tls-cert-file: path to certificate file or usingH2O_WAVE_TLS_CERT_FILEenv variable.-tls-key-file: path to private key file or usingH2O_WAVE_TLS_KEY_FILEenv variable.
File paths need to be either absolute or relative to the Wave server (waved) location.
Once set, the Wave app needs to know it should talk to the Wave server via https and not http as it does by default. This can be set using H2O_WAVE_ADDRESS="https://127.0.0.1:10101" env variable when starting the Wave app.
Self Signed Certificate
To enable TLS during development, use a self-signed certificate.
To create a private key and a self-signed certificate from scratch, use openssl:
openssl req \
-newkey rsa:2048 -nodes -keyout domain.key \
-x509 -days 365 -out domain.crtThe above command creates a 2048-bit private key (domain.key) and a self-signed x509 certificate (domain.crt) valid for 365 days.
Single Sign On
Wave has built-in support for OpenID Connect.
To enable OpenID Connect, pass the following flags when starting the Wave server:
-oidc-provider-url: The URL for authentication (the identity provider's URL).-oidc-redirect-url: The URL to redirect back to after authentication. This is typically/_auth/callbackappended to the Wave server's address. For example, if the Wave server is running athttps://192.168.42.42:80, set this tohttps://192.168.42.42:80/_auth/callback. If you're testing your app's authorization workflow during development and the Wave server is running athttp://localhost:10101, you can set this argument tohttp://localhost:10101/_auth/callback. If you also specified the-base-urlargument for Wave server, then make sure the redirect URL includes the base URL. For example, if the base URL is set to/my/app/, set the redirect URL tohttps://192.168.42.42:80/my/app/_auth/callback.-oidc-client-id: Client ID (refer to your identity provider's documentation).-oidc-client-secret: Client secret (refer to your identity provider's documentation).-oidc-end-session-url: (Optional) URL to log out (refer to your identity provider's documentation). This flag is optional and might not be supported by your identity provider.-oidc-scopes: (Optional) Comma-separated scopes that will override defaults (openid,profile).-oidc-skip-login: (Optional) Don't show the built-in login form during OIDC authorization. Instead, navigate directly to the identity provider's login form.-oidc-auth-url-params: (Optional) Additional URL parameters to pass during OIDC authorization.
Once authenticated, you can access user's authentication and authorization information from your app using q.auth (see the Auth class for details):
from h2o_wave import Q, main, app
@app('/example')
async def serve(q: Q):
print(q.auth.username)
print(q.auth.access_token)Azure
By default, Azure provides you with URL like https://login.microsoftonline.com/$UUID/oauth2/v2.0/authorize, resulting in an error:
panic: failed connecting to OIDC provider: 404 Not Found:Change the URL to https://sts.windows.net/$UUID/ instead to make OpenID work. You can also try with https://login.microsoftonline.com/$UUID/v2.0
Note: When configuring redirect URIs in Microsoft Entra ID, select the Web platform. For local development, the URI should be set to: http://localhost:10101/_auth/callback
Explicit token refresh
Note that access token is not refreshed automatically and it's not suited for long running jobs. The lifespan of a token depends on a provider settings but usually it's short. Access token is refreshed each time user performs an action i.e. the query handler serve() is called. However, if your UI is blocked (no user interacitons that could automatically refresh the token) and you are performing a long-running job, and still need fresh access token, you can call ensure_fresh_token function that refreshes and sets the token explicitly. Additionally, it also returns the access token if needed for async token providers.
from h2o_wave import Q, main, app
@app('/example')
async def serve(q: Q):
# Refreshes the token and makes it available in q.auth.access_token.
new_access_token = await q.auth.ensure_fresh_token()Synchronous version ensure_fresh_token_sync is also supported if your token provider is synchronous. However, using it is heavily discouraged due to its blocking nature - will make the Wave app super slow for all users, thus only recommended for throwaway, single user PoCs. Async version is the preferred choice to mitigate this.
FAQ
- I'm not sure what my oidc provider url is: The openid connect configuration for any provider is made accessible through the
.well-known/openid-configurationendpoint. The value of-oidc-provider-urlmust be the base url of your provider. For example, if the configuraton address is athttp://localhost:8080/realms/master/.well-known/openid-configuration, then the provider url that you have to pass to wave ishttp://localhost:8080/realms/master. Do not use a trailing slash at the end of the provider url! - Do I have to implement the authenticaton callback myself? No, the callback is handled by the wave server. As mentioned in the description for
-oidc-redirect-urlin the list above, the host part or the base-url suffix is what usually changes between deployment environments, so that's what you need to check for correctness. - The callback is working in my development environment but not in production, or vice versa: Providers usually allow to register multiple callback URI's. Ensure that the correct and necessary callback URI's for all your deployments are registered in your provider's configuration (ergo, the value you use for
-oidc-redirect-urlis in the list of registered URI's). Otherwise, the redirect will fail with an errorThe redirect URI included is not valid. - My identity provider uses
httpbut the authentication link points tohttpswhich makes the login fail: This can happen for using a private deployment of an authentication provider where the custom setup might not match the expected setup of the authentication service (check the endpoints in your.well-known/openid-configuration). In general, when transferring private data, it should be encrypted by using methods like ssl or tsl. To solve this issue, you will need to check if the openid configuration of your provider can be customized, or change the protocol (HTTP/HTTPS) of your Wave server to match the one used by your provider.
App Server API Access Keys
Access to a Wave app is controlled via HTTP Basic Authentication. The basic authentication username/password pair is automatically generated on app launch, and is visible only to the Wave server. You can manually override this behavior by setting the $WAVE_APP_ACCESS_KEY_ID / $WAVE_APP_ACCESS_KEY_SECRET environment variables (for development/testing only - not recommended in production).
Additional HTTP Response Headers
You can make the Wave daemon include additional HTTP response headers by using the -http-headers-file command line argument to waved, pointing to a MIME-formatted file.
A sample file (make sure there are 2 empty lines at the end):
X-Frame-Options: SAMEORIGIN
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=blockAccessing forwarded headers
It's possible to access the HTTP headers that were set on websocket HTTP connection request via q.headers. This might be useful in case built-in OIDC auth is not an option and you already have an existing, company-wide auth based on intercepting all the traffic and verifying if the requests are authenticated.
:::important
Since Wave is websocket-based, the headers retrieved do not come from the initial GET index.html request, but from the websocket /_s/ one.
:::
For a more fine-grained control over which HTTP headers are forwarded or not, check the H2O_WAVE_FORWARDED_HTTP_HEADERS configuration option.
Content Security Policy
For XSS protection, it's possible to set Content Security Policy through additional HTTP Response headers.
Keep in mind that while turning off inline scripts and styles is a good idea security-wise, it may negatively affect some of the components that rely on inlining. These are usually 3rd party visualization libs that render their output in an iframe, notably Altair, Vega, Bokeh etc. In fact, even Wave's native visualization lib G2 uses inlining for some of its features.
State (`website/docs/state.md`)
title: State
How you manage your Wave app's state depends on your app's requirements. In most cases, an app's data is best stored in a database or data store of some kind. But sometimes it's convenient to store run-time information in your app's memory, especially during prototyping when you're trying to iterate quickly and refine ideas.
In a Wave app, you can store run-time information at three levels:
- App-level: Information shared across all users.
- User-level: Information private to a user, but shared across all browser tabs.
- Client-level: Information private to a browser tab.
In real world apps, the decision on whether to store information at the client, user, or app level depends on the problem you're trying to solve. For example, if you were building an online store, you'd probably want to store product inventory at the app level and shopping carts at the user level. Most other kinds of information - search results, past orders, or product details - are best stored at the client-level (searching for products in one tab and having search results appear in another tab would drive even your most loyal customers up the wall).
In other words, your Wave app is multi-user by default, but how your app manages data at the app-level, at the user-level, and at the client-level is up to you.
The Wave query context q (of type Q) carries q.app, q.user, and q.client, three dictionary-like objects for storing information at the app-level, user-level, and client-level, respectively.
:::tipq.app, q.user, and q.client are all Expando instances, which means they behave both like dictionaries and objects: q.client['foo'] is the same as q.client.foo. q.client.foo is easier to read.
:::
In most non-trivial apps, you'll find yourself frequently copying values from q.args into q.client (or q.user, depending on the problem you're solving). If this gets too repetitive for your taste, use copy_expando() to copy everything in q.args to q.client at the beginning of your @app() handler:
from h2o_wave import Q, main, app, copy_expando
@app('/foo')
async def serve(q: Q):
copy_expando(q.args, q.client)
# Do something else...Tutorial Beer (`website/docs/tutorial-beer.md`)
title: "Tutorial: Beer Wall"
In this tutorial, we'll learn how to update a web page periodically and observe our changes live in the browser. Completing this tutorial should improve your understanding of how easy it is to use Wave to publish information in realtime.
The program we'll be writing is a verse generator for the runaway mid-20th century smash hit, 99 Bottles of Beer, which looks something like this:
99 bottles of beer on the wall, 99 bottles of beer.
Take one down, pass it around, 98 bottles of beer on the wall...98 bottles of beer on the wall, 98 bottles of beer.
Take one down, pass it around, 97 bottles of beer on the wall......
Our program will be analogous to our "Hello, World!" program, with the addition of a loop. We'll generate a verse every second, and observe the verse change in the browser in realtime. After that, we'll take a stab at making our program a bit more efficient, introducing how expressions work.
(Incidentally, Donald Knuth proved that this song has a complexity of O(log N) in "The Complexity of Songs", but we won't let that little detail deter us for now.)
Prerequisites
This tutorial assumes your Wave server is up and running, and you have a working directory for authoring programs. If not, head over to the Hello World tutorial and complete steps 1 and 2.
Step 1: Write your program
Our program looks like this. It's mostly similar to the one in the Hello World tutorial, with one exception: we're setting the markdown card's content inside a for loop.
import time
from h2o_wave import site, ui
page = site['/beer']
beer_card = page.add('wall', ui.markdown_card(
box='1 1 4 2',
title='99 Bottles of Beer',
content='',
))
for i in range(99, 0, -1):
beer_card.content = f"""
{i} bottles of beer on the wall, {i} bottles of beer.
Take one down, pass it around, {i - 1} bottles of beer on the wall...
"""
page.save()
time.sleep(1)Step 2: Run your program
python beer_wall.pyStep 3: Watch updates live
Point your browser to http://localhost:10101/beer, and watch the verses fly by.
Step 4: Make it more efficient
Our program is accurate, but not necessarily efficient. Every second, it sends the entire verse in its entirety to the Wave server, with only minuscule changes (i and i-1). You can observe this in the Wave server's log (switch to the terminal window running the Wave server to view the log):
2020/10/02 12:13:43 * /beer {"d":[{"k":"wall content","v":"\n98 bottles of beer on the wall, 98 bottles of beer.\n\nTake one down, pass it around, 97 bottles of beer on the wall...\n"}]}
2020/10/02 12:13:44 * /beer {"d":[{"k":"wall content","v":"\n97 bottles of beer on the wall, 97 bottles of beer.\n\nTake one down, pass it around, 96 bottles of beer on the wall...\n"}]}
2020/10/02 12:13:46 * /beer {"d":[{"k":"wall content","v":"\n96 bottles of beer on the wall, 96 bottles of beer.\n\nTake one down, pass it around, 95 bottles of beer on the wall...\n"}]}
2020/10/02 12:13:47 * /beer {"d":[{"k":"wall content","v":"\n95 bottles of beer on the wall, 95 bottles of beer.\n\nTake one down, pass it around, 94 bottles of beer on the wall...\n"}]}
2020/10/02 12:13:48 * /beer {"d":[{"k":"wall content","v":"\n94 bottles of beer on the wall, 94 bottles of beer.\n\nTake one down, pass it around, 93 bottles of beer on the wall...\n"}]}We can do better. Instead of sending the verse over and over again, we can send the verse once with placeholders for i and i-1, and then send only i and i-1 during subsequent updates. That will keep network traffic down to a minimum and allow the Wave server to perform less work while sending your updates to your website's bajillion visitors.
Here's how we approach this. First, when we create our markdown card, we set the content to an expression (or formula). An expression is a string that starts with = (similar to formulae in spreadsheet software). Our expression looks like this:
beer_verse = '''={{before}} bottles of beer on the wall, {{before}} bottles of beer.
Take one down, pass it around, {{after}} bottles of beer on the wall...
'''We've used a triple-quoted multi-line string here, but you can use single- or double-quoted strings as well. The tokens inside the {{ and }} are placeholders. Note how the verse string starts with a =, indicating that this is an expression.
Next, we create the markdown card as usual using our verse as the content. But more importantly, we set the card's data attribute to a Python dictionary containing values for the placeholders before and after:
beer_card = page.add('wall', ui.markdown_card(
box='1 1 4 2',
title='99 Bottles of Beer',
content=beer_verse,
data=dict(before='99', after='98'),
))Finally, instead of updating the entire verse every second, we update only the .data.before and .data.after attributes of the markdown card:
for i in range(99, 0, -1):
beer_card.data.before = str(i)
beer_card.data.after = str(i - 1)
page.save()
time.sleep(1)Our final program looks like the listing below. You'll notice that the size of our program is mostly unchanged from before.
import time
from h2o_wave import site, ui
page = site['/beer']
beer_verse = '''={{before}} bottles of beer on the wall, {{before}} bottles of beer.
Take one down, pass it around, {{after}} bottles of beer on the wall...
'''
beer_card = page.add('wall', ui.markdown_card(
box='1 1 4 2',
title='99 Bottles of Beer',
content=beer_verse,
data=dict(before='99', after='98'),
))
for i in range(99, 0, -1):
beer_card.data.before = str(i)
beer_card.data.after = str(i - 1)
page.save()
time.sleep(1)Run your program again. You should see the same results in your browser as before, but you'll notice that the information flowing through the Wave server is significantly less than before:
2020/10/02 13:53:11 * /beer {"d":[{"k":"wall data before","v":"98"},{"k":"wall data after","v":"97"}]}
2020/10/02 13:53:12 * /beer {"d":[{"k":"wall data before","v":"97"},{"k":"wall data after","v":"96"}]}
2020/10/02 13:53:13 * /beer {"d":[{"k":"wall data before","v":"96"},{"k":"wall data after","v":"95"}]}
2020/10/02 13:53:14 * /beer {"d":[{"k":"wall data before","v":"95"},{"k":"wall data after","v":"94"}]}
2020/10/02 13:53:15 * /beer {"d":[{"k":"wall data before","v":"94"},{"k":"wall data after","v":"93"}]}
2020/10/02 13:53:16 * /beer {"d":[{"k":"wall data before","v":"93"},{"k":"wall data after","v":"92"}]}Summary
In this tutorial, we learned how to send periodic updates to the Wave server and observe changes in realtime.
In the next tutorial, we'll put these principles to real-world use, popping up charts for a song instead of chart topping pop songs.
Tutorial Counter (`website/docs/tutorial-counter.md`)
title: "Tutorial: Bean Counter"
In the previous tutorials, we've been authoring Wave scripts. A Wave script is a perfectly fine and dandy way to modify pages in Wave and build dashboards. However, to build interactive applications in Wave - user interfaces that can dynamically respond to events (the user's actions) - Wave apps are the way to go.
In the fine tradition of keeping tutorials succinct and useful, we'll author a little app that counts beans of the virtual kind, or, a bean counter, not to be confused with dubiously intentioned financial officers.
This tutorial outlines the basics of how to handle events, update the UI, manage state and easily add realtime sync capabilities to your app. It's probably the most important tutorial to wrap your head around if you're interested in authoring interactive applications.
Prerequisites
This tutorial assumes you have a working directory for authoring programs. If not, head over to the Hello World tutorial and complete step 2.
Step 1: Start listening
The first step towards listening to events from the UI is to define an @app function:
from h2o_wave import Q, main, app
@app('/counter')
async def serve(q: Q):
pass@app is a decorator that takes one required argument - the route to listen to, in this case /counter (which translates to localhost:10101/counter or www.example.com/counter).
The @app decorator is applied to a function called serve(), which is called every single time the user interacts with the UI (access the page, reload it, click a button, access a menu, enter text, and so on).
We named the function serve(), but you can call it anything you please, like shoe_strings().
Lastly, note that we've imported the symbol main into our .py file. You don't have to do anything with main except import it.
To run your app, use wave run within your activated virtual environment:
wave run counterThe wave run command runs your app in development mode, and the app is automatically reloaded when edited. Additionally, it starts Wave server under the hood for you if not already running or explicitly told not to.
At this point, your app will be up and running, but it doesn't do anything yet. Let's change that in a second.
Step 2: Display a button
Let's add a button to our app. Our goal is to increment and display the bean count each time the button is clicked.
To do this, we declare a variable called bean_count, and use form_card() to add a form to our page. A form card is a special type of card that displays a vertical stack of components (also called widgets). In this case, our form contains a solitary button named increment, with a caption showing the current bean_count. The button is marked as primary, which serves no other purpose than to make it look tall, dark, and handsome.
from h2o_wave import Q, main, app, ui
@app('/counter')
async def serve(q: Q):
bean_count = 0
q.page['beans'] = ui.form_card(
box='1 1 1 2',
items=[
ui.button(
name='increment',
label='Click me!',
caption=f'{bean_count} beans',
primary=True,
),
],
)
await q.page.save()Notice how the technique to modify and save pages in a Wave app is different from that of a Wave script.
| Task | Wave Script | Wave App |
|---|---|---|
Access page at route /foo |
page = site['/foo'] |
page = q.page |
Access card named foo |
card = page['foo'] |
card = q.page['foo'] |
| Save page | page.save() |
await q.page.save() |
In a Wave app, we always access the page using the query context q. The query context carries useful information about the active UI event, including who issued the event, what information was entered, which buttons were clicked, and so on; and q.page always refers to the page located at the route you passed to @app() (in this case).
Point your browser to http://localhost:10101/counter. You should see a nice big button, waiting to be clicked on:
If you click on the button, you'll notice that it doesn't do anything. This is because we haven't handled button clicks yet. Instead, when you click the button, the events are sent to your serve() function, but the serve() function blindly adds the form (with the button) to the page all over again and saves it, causing the page to display the same form all over again. Let's fix this next.
Step 3: Handle button clicks
Add a condition to check if the button is clicked, and if so, increment the bean count.
from h2o_wave import Q, main, app, ui
@app('/counter')
async def serve(q: Q):
bean_count = q.client.bean_count or 0
if q.args.increment:
q.client.bean_count = bean_count = bean_count + 1
q.page['beans'] = ui.form_card(
box='1 1 1 2',
items=[
ui.button(
name='increment',
label='Click me!',
caption=f'{bean_count} beans',
primary=True,
),
],
)
await q.page.save()The above edit introduces two important concepts: q.client and q.args.
q.clientis a dictionary-like object for storing and retrieving arbitrary information related to the active client (a browser tab or browser session). Anything you put intoq.clientwhile handling an event can be read back while handling a subsequent event, provided the subsequent event originates from the same client. In our case, we readq.client.bean_countto get the bean count. If it's unavailable (which means this is a new client), we default the bean count to0.q.args, also a dictionary-like object, carries event arguments, which contains information about what the user did. In our case, if the user clicked on our button (namedincrement),q.args.incrementwill beTrue. If not,q.args.incrementwill beNone. Therefore, ifq.args.incrementisTrue, we increment the client'sbean_count. If not, we do nothing.
:::note
From now on, any time you see the term 'client', recall that it's a technical term for 'browser tab' or 'browser session'. In other words, any time you open a new browser tab to access your app, you're accessing your app from a new client.
:::
:::info
Each component in the Wave component library populates q.args with its own value. For more information, see Components.
:::
Your button should now count beans when clicked:
Step 4: Make it more efficient
Our program is accurate, but not necessarily efficient. On every button-click, it is recreating the form card and the button over and over again instead of updating the existing button's caption with the current bean count. You can observe how chatty the app is by gaping in abject horror at the Wave server's log.
:::info
To view the Wave server's log, switch to the terminal window running the Wave server.
:::
Let's wrinkle our noses in disgust and fix this gross inefficiency right away to make our little bean counter web-scale for great good.
from h2o_wave import Q, main, app, ui
@app('/counter')
async def serve(q: Q):
bean_count = q.client.bean_count or 0
if q.args.increment:
q.client.bean_count = bean_count = bean_count + 1
if not q.client.initialized:
q.client.initialized = True
q.page['beans'] = ui.form_card(
box='1 1 1 2',
items=[
ui.button(
name='increment',
label='Click me!',
caption=f'{bean_count} beans',
primary=True,
),
],
)
else:
q.page['beans'].increment.caption = f'{bean_count} beans'
await q.page.save()In the above edit, we check for an arbitrary flag in q.client called initialized.
- If the flag is not set, we assume that the request is originating from a new, empty client. If so, we set
initializedtoTrue(to make a note to ourselves that the client is not empty anymore), then add our form card to the page. - If the flag is already set, we assume that the form card (and the button inside it) are already on the page, and simply update the button's caption with the new bean count. We can refereence the button by simply using its
nameattribute -increment.
Intermission: Understanding state
In the above steps, we learned about q.args (request arguments submitted from the browser) and q.client (arbitrary information associated with the client). Let's expand on these and introduce two new concepts: q.user and q.app.
Before we do that, try accessing http://localhost:10101/counter from two different browser tabs and playing with them. You'll notice that each browser tab (or, each client) maintains separate bean counts.
Maintaining separate bean counts per client is one way to count beans. You can also maintain bean counts at the user level and the app level.
In real world apps, the decision on whether to store information at the client, user, or app level depends on the problem you're trying to solve. For example, if you were building an online store, you'd probably want to store product inventory at the app level and shopping carts at the user level. Most other kinds of information - search results, past orders, or product details - are best stored at the client-level (searching for products in one tab and having search results appear in another tab would drive even your most loyal customers up the wall).
Similar to how q.client stores arbitrary information associated with the client, q.user and q.app store arbitrary information associated with the user and the app, respectively.
In most apps, you'll end up using a mix of q.client, q.user and q.app to correctly handle requests originating from:
- Different users.
- Different browser tabs belonging to the same user (possibly from different devices).
- The same browser tab.
In other words, your Wave app is multi-user by default, but how the app manages data at the app-level, at the user-level and at the client-level is up to you.
Step 5: User-level realtime sync
To maintain bean counts at the user level, all we have to do is store bean_count in q.user instead of q.client.
from h2o_wave import Q, main, app, ui
@app('/counter')
async def serve(q: Q):
bean_count = q.user.bean_count or 0
if q.args.increment:
q.user.bean_count = bean_count = bean_count + 1
if not q.client.initialized:
q.client.initialized = True
q.page['beans'] = ui.form_card(
box='1 1 1 2',
items=[
ui.button(
name='increment',
label='Click me!',
caption=f'{bean_count} beans',
primary=True,
),
],
)
else:
q.page['beans'].increment.caption = f'{bean_count} beans'
await q.page.save()Now when you play with your app, you'll see that the counts are being maintained across browser tabs, but the updated counts don't show up immediately across all tabs. Instead, they show up only when you interact with the other tabs, or reload them.
This would be considered normal behavior for a typical web application, and most users familiar with the interwebs are also familiar with the reload button, but we can do better, because having to hit the reload button to get updates is about as exciting as waiting for paint to dry.
This is easier done than said - simply change the app mode to multicast to enable realtime sync across clients:
from h2o_wave import Q, main, app, ui
@app('/counter', mode='multicast')
async def serve(q: Q):
bean_count = q.user.bean_count or 0
if q.args.increment:
q.user.bean_count = bean_count = bean_count + 1
if not q.client.initialized:
q.client.initialized = True
q.page['beans'] = ui.form_card(
box='1 1 1 2',
items=[
ui.button(
name='increment',
label='Click me!',
caption=f'{bean_count} beans',
primary=True,
),
],
)
else:
q.page['beans'].increment.caption = f'{bean_count} beans'
await q.page.save():::info
The default app mode is unicast, which means "don't sync across clients". On the other hand, multicast means "sync across clients". There's also a third mode, broadcast, which means "sync across users", which we'll see in the next step.
:::
If you play with your app now, you'll see that the user-level bean count indeed syncs across tabs:
Step 6: App-level realtime sync
Going from user-level bean counting to app-level bean counting is easy: simply store bean_count on q.app instead of q.user, and switch the app mode to broadcast:
from h2o_wave import Q, main, app, ui
@app('/counter', mode='broadcast')
async def serve(q: Q):
bean_count = q.app.bean_count or 0
if q.args.increment:
q.app.bean_count = bean_count = bean_count + 1
if not q.client.initialized:
q.client.initialized = True
q.page['beans'] = ui.form_card(
box='1 1 1 2',
items=[
ui.button(
name='increment',
label='Click me!',
caption=f'{bean_count} beans',
primary=True,
),
],
)
else:
q.page['beans'].increment.caption = f'{bean_count} beans'
await q.page.save():::tip
The broadcast mode can be used to build collaborative apps that need to synchronize state across all users, like group chat or multiplayer games.
:::
Summary
In this tutorial, we learned how to author interactive applications, or apps, and easily add realtime sync capabilities to our apps. More importantly, we learned how to deal with events and manage state using four dictionary-like objects:
| Attribute | Type | Use |
|---|---|---|
q.args |
Read-only | Stores command arguments |
q.client |
Read/Write | Stores client-level state |
q.user |
Read/Write | Stores user-level state |
q.app |
Read/Write | Stores app-level state |
Also, we built ourselves a little app that counts beans, and you can now put that knowledge to good use, like build an online voting app for the upcoming elections in the banana republic you're running on your private island (a smidgen of democracy can't hurt).
In the next section, we'll build something a bit more substantial and useful: a to-do list with realtime sync.
Tutorial Hello (`website/docs/tutorial-hello.mdx`)
title: "Tutorial: Hello World"
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
In this section, we'll learn how to author our first Wave program from scratch, and understand the basics of how to display content in a web browser.
:::note
These tutorials assume that you have some familiarity with the Python programming language. You don't have to be an expert, but it might be harder to learn both Wave and Python at the same time.
:::
Step 1: Start the Wave server
Download the most recent version .tar.gz file for your operating system, and extract (unzip) it to a directory of your choice.
Once done, open a terminal, cd into the extracted dir and start the Wave server.
./wavedwaved.exe2020/10/27 16:16:34 #
2020/10/27 16:16:34 # ┌─────────────────────────┐
2020/10/27 16:16:34 # │ ┌ ┌ ┌──┐ ┌ ┌ ┌──┐ │ H2O Wave
2020/10/27 16:16:34 # │ │ ┌──┘ │──│ │ │ └┐ │ (version) (build)
2020/10/27 16:16:34 # │ └─┘ ┘ ┘ └──┘ └─┘ │ © 2020 H2O.ai, Inc.
2020/10/27 16:16:34 # └─────────────────────────┘
2020/10/27 16:16:34 #
2020/10/27 16:16:34 # {"address":":10101","t":"listen","webroot":"/home/elp/wave/www"}The Wave server should now be running at http://localhost:10101.
:::caution Don't close this terminal window!
To run any Wave app or script, you need the Wave server up and running at all times. Your web browser communicates with the Wave server, and the Wave server in turn communicates with the Wave app.
Apps can start Wave server automatically via wave run command. So this step is only necessary for Wave scripts.
:::
Step 2: Set up a working directory
Next, let's set up a working directory to author our program. Create a new directory and open it in a terminal.
Set up a virtual environment
A virtual environment helps us manage our program's dependencies without interfering with system-wide packages.
python3 -m venv venv
source venv/bin/activatepython -m venv venv
.\venv\Scripts\activateconda create -n venv
conda activate venvInstall the Wave Python driver
pip install h2o-waveconda config --append channels conda-forge
conda install -c h2oai h2o-waveStep 3: Write your program
Next, open your preferred text editor, create a Python script called hello_world.py, and copy-paste the following.
For now, don't worry too much about what this program is doing. We'll get to that shortly.
from h2o_wave import site, ui
# Grab a reference to the page at route '/hello'
page = site['/hello']
# Add a markdown card to the page.
page['quote'] = ui.markdown_card(
box='1 1 2 2',
title='Hello World',
content='"The Internet? Is that thing still around?" - *Homer Simpson*',
)
# Finally, save the page.
page.save()Step 4: Run your program
python hello_world.pyStep 5: Admire your creation
Point your browser to http://localhost:10101/hello, and pause to reflect on a particularly pithy quote from the venerable Homer Simpson.
Step 6: Understand your program
Let's walk through this program step by step.
This program (technically a script), illustrates the core of Wave's programming model, or, "How to think in Wave."
- Your Wave server instance holds a collection of pages.
- To change a page, simply grab a reference to a page, change it, and save it.
That's it. Your changes are now visible to everyone.
Let's understand this principle in practice using the little program we just created.
Grab a reference to a page
A site represents a dictionary of all the pages on the Wave server. To get a reference to a page hosted at the route /hello (which translates to http://localhost:10101/hello), simply grab the value at key /hello.
page = site['/hello']Change the page
Similar to how a site represents a collection of pages, a page represents a collection of cards. A card represents a block of content: text, graphics, widgets, or some combination of those.
page['quote'] = ui.markdown_card(
box='1 1 2 2',
title='Hello World',
content='"The Internet? Is that thing still around?" - *Homer Simpson*',
)Pages support different kinds of cards. In this case, we add a card named quote that displays markdown content (markdown_card()). The position and size of the card on the page is specified by the box attribute. In this case, the card is placed at column 1, row 1, sized 2 x 2 units. The content attribute supports Github Flavored Markdown.
Save the page
Finally, we call save() on the page, which broadcasts our changes to all connected web browsers.
page.save()So far, so good.
Step 7: Edit your page from a REPL
Finally, just for kicks, let's make some changes to our hello world page using a Python REPL and watch our page reflect those changes in realtime.
Start a Python REPL
cd $HOME/wave-apps
./venv/bin/pythonGrab a reference to our page
>>"">from h2o_wave import site
page = site['/hello']Grab a reference to our card
>>"">quote = page['quote']Change the title
>>"">quote.title = 'Hello Again!'
page.save()Change the content
>>"">quote.content = "D'oh! - *Homer Simpson*"
page.save()Quit your REPL
>>"">quit()Summary
What we just did - add content from one program and make edits to it from another - illustrates another important aspect of Wave's programming model: The Wave server retains content. Your hello_world.py program did its thing and exited. So did your REPL. But your content was retained for the viewing pleasure of future visitors to /hello.
Next, we'll take the principles we learned from this tutorial and apply it towards a supposedly spirited folk song involving arithmetic progressions.
Tutorial Monitor (`website/docs/tutorial-monitor.md`)
title: "Tutorial: System Monitor"
In this tutorial, we'll put our learnings from the first and second tutorials to some real-world use: a simple system monitoring tool that displays CPU, memory and network stats on a web page.
For example, if you have a spare 256-node Raspberry Pi cluster lying somewhere, you can run this program to each node and monitor your entire cluster's system utilization from one place. How cool is that?
We'll also introduce a new concept, called data buffers, which allows you to use the Wave server to store rows (also called tuples or records) of information - much like how you would use tables in a database, or dataframes in Python or R - to deal with structured data.
Prerequisites
This tutorial assumes your Wave server is up and running, and you have a working directory for authoring programs. If not, head over to the Hello World tutorial and complete steps 1 and 2.
Step 1: Install dependencies
We'll be using the excellent psutil package to read system stats. Let's go ahead and install that in our virtual environment:
pip install psutilStep 2: Monitor CPU usage
Here's what our program looks like:
import time
import psutil
from h2o_wave import site, ui, data
page = site['/monitor']
cpu_card = page.add('cpu_stats', ui.small_series_stat_card(
box='1 1 1 1',
title='CPU',
value='={{usage}}%',
data=dict(usage=0.0),
plot_data=data('tick usage', -15),
plot_category='tick',
plot_value='usage',
plot_zero_value=0,
plot_color='$red',
))
tick = 0
while True:
tick += 1
cpu_usage = psutil.cpu_percent(interval=1)
cpu_card.data.usage = cpu_usage
cpu_card.plot_data[-1] = [tick, cpu_usage]
page.save()
time.sleep(1)Step 3: Run your program
python system_monitor.pyPoint your browser to http://localhost:10101/monitor.
Step 4: Understand your program
You'll notice that the above program is quite similar to the program we wrote during the Beer Wall tutorial, with three important differences (see highlighted lines above):
- We use a
ui.small_series_stat_card()instead of aui.markdown_card(). - The card is capable of dealing with multiple rows of data.
- To display information on the card, you only need to send it new values (and not all the data rows all over again).
Let's explore these topics one by one.
Using a stats card
The Wave SDK ships with a variety of stats cards, which are cards that display values or graphics, or a combination of both (see Gallery for more).
In this case, we use small_series_stats_card(), which displays a value and a time series visualization.
cpu_card = page.add('cpu_stats', ui.small_series_stat_card(...)Declaring a data buffer
The stats card is capable of rendering its visualization using a data buffer. A data buffer is similar to a database table in that it has a predefined structure (columns and rows), but is write-only (you cannot query information; only insert, update or delete them).
:::info
The data buffer topic covers different types of buffers in more detail.
:::
In this case, we declare a cyclic buffer, a FIFO data structure that holds a fixed number of rows, and can only be appended to. Our buffer holds at most 15 rows, and has exactly two columns: tick (a one-up integer) and usage (the CPU usage).
plot_data=data('tick usage', -15),Internally, the card's data buffer might look like this in memory when first created:
| # | tick | usage |
|---|---|---|
| 0 | ||
| 1 | ||
| ... | ... | ... |
| 13 | ||
| 14 |
Inserting into the data buffer
Lastly, we measure CPU usage every second and append a new row to the end of card's data buffer, like this:
cpu_card.plot_data[-1] = [tick, cpu_usage]Internally, the card's data buffer might look like this in memory while in use:
| # | tick | usage |
|---|---|---|
| 0 | 1015 | 4.2 |
| 1 | 1016 | 4.9 |
| ... | ... | ... |
| 13 | 1028 | 5.8 |
| 14 | 1039 | 7.5 |
Step 5: Monitor memory usage
As a final step, we can duplicate parts of our program to create another card that displays memory stats. The two cards behave identically, except that one displays CPU usage and the other, memory.
import time
import psutil
from h2o_wave import site, ui, data
page = site['/monitor']
cpu_card = page.add('cpu_stats', ui.small_series_stat_card(
box='1 1 1 1',
title='CPU',
value='={{usage}}%',
data=dict(usage=0.0),
plot_data=data('tick usage', -15),
plot_category='tick',
plot_value='usage',
plot_zero_value=0,
plot_color='$red',
))
mem_card = page.add('mem_stats', ui.small_series_stat_card(
box='1 2 1 1',
title='Memory',
value='={{usage}}%',
data=dict(usage=0.0),
plot_data=data('tick usage', -15),
plot_category='tick',
plot_value='usage',
plot_zero_value=0,
plot_color='$blue',
))
tick = 0
while True:
tick += 1
cpu_usage = psutil.cpu_percent(interval=1)
cpu_card.data.usage = cpu_usage
cpu_card.plot_data[-1] = [tick, cpu_usage]
mem_usage = psutil.virtual_memory().percent
mem_card.data.usage = mem_usage
mem_card.plot_data[-1] = [tick, mem_usage]
page.save()
time.sleep(1)Step 6: Run your program again
Terminate your program (CTRL + C) and restart it:
python system_monitor.pyPoint your browser to http://localhost:10101/monitor. You should now see both CPU and memory stats live:
Exercise
Explore other kinds of cards in the Widgets and display additional stats gleaned from psutil (network, disk, processes, etc.).
Summary
In this tutorial, we learned how to use stats cards to display live information. The knowledge you've gained from these first few tutorials should be enough to design and deploy live dashboards using Wave. You will also have noticed that you don't need to keep your Python program running all the time to continue displaying your pages. You can terminate your Python program any time, and the Wave server will happily display the last known state of all your pages.
The programs you've been authoring till now are one kind of programs, called Wave scripts. Wave scripts are not interactive. They can modify pages on the Wave server, but cannot respond to user actions, like handling button clicks, menu commands, dropdown changes, and so on. To handle user interactions, you need to author Wave Apps, which are long-running programs (servers or services) that are capable of modifying pages in response to user actions. Let's see how to do that in the next tutorial.
Tutorial Todo (`website/docs/tutorial-todo.md`)
title: "Tutorial: Todo List"
In this tutorial, we'll build something a bit more substantial and useful: a to-do list with realtime sync, in under 75 lines of (well-formatted, well-commented) code.
More importantly, this tutorial will not introduce any new concepts. Everything you need to know about authoring interactive apps using Wave is already covered in the previous tutorial. From this point on, it's mostly a matter of abstraction, which is a fancy term for how you solve the problem at hand using short, simple, clear, elegant, modular functions that do one thing and do it well.
Above all, prefer brevity and clarity. The best code is no code at all.
Step 1: Listen
We'll start with a basic skeleton and then work our way up from there.
The first step is to define an @app function. Also, we want the landing page to show a list of to-dos, so we'll throw in an empty show_todos() function for now, and call it from serve().
from h2o_wave import Q, main, app, ui
@app('/todo')
async def serve(q: Q):
show_todos(q)
await q.page.save()
def show_todos(q: Q):
passStep 2: What's in a to-do?
A to-do item has some basic attributes: an ID, some text content, and whether it's completed or not. Let's define a class for that, with a global one-up id.
from h2o_wave import Q, main, app, ui
_id = 0
# A simple class that represents a to-do item.
class TodoItem:
def __init__(self, text):
global _id
_id += 1
self.id = f'todo_{_id}'
self.text = text
self.done = False
@app('/todo')
async def serve(q: Q):
show_todos(q)
await q.page.save()
def show_todos(q: Q):
passStep 3: Make a to-do list
Since we want each user to have a separate to-do list, it's appropriate to keep the to-do list in q.user.
Here, we attempt to fetch the list from q.user and create one if it doesn't exist. We also throw in some sample to-do items for good measure.
from typing import List
from h2o_wave import Q, main, app, ui
_id = 0
# A simple class that represents a to-do item.
class TodoItem:
def __init__(self, text):
global _id
_id += 1
self.id = f'todo_{_id}'
self.text = text
self.done = False
@app('/todo')
async def serve(q: Q):
show_todos(q)
await q.page.save()
def show_todos(q: Q):
# Get items for this user.
todos: List[TodoItem] = q.user.todos
# Create a sample list if we don't have any.
if todos is None:
q.user.todos = todos = [TodoItem('Do this'), TodoItem('Do that'), TodoItem('Do something else')]Step 4: Show the to-do list
Next, we turn each incomplete to-do item into a checkbox (using ui.checkbox()), and display it in a form card (using ui.form_card()).
Also, we want each checkbox to raise an event immediately when checked, so we set its trigger attribute to True.
:::tip
Several components have a trigger attribute. Normally, an event is triggered only when a command-like component (a button, menu, or tab) is clicked. If you want a component to immediately trigger an event when changed, set trigger to True.
:::
from typing import List
from h2o_wave import Q, main, app, ui
_id = 0
# A simple class that represents a to-do item.
class TodoItem:
def __init__(self, text):
global _id
_id += 1
self.id = f'todo_{_id}'
self.text = text
self.done = False
@app('/todo')
async def serve(q: Q):
show_todos(q)
await q.page.save()
def show_todos(q: Q):
# Get items for this user.
todos: List[TodoItem] = q.user.todos
# Create a sample list if we don't have any.
if todos is None:
q.user.todos = todos = [TodoItem('Do this'), TodoItem('Do that'), TodoItem('Do something else')]
# Create checkboxes.
not_done = [ui.checkbox(name=todo.id, label=todo.text, trigger=True) for todo in todos if not todo.done]
# Display list
q.page['form'] = ui.form_card(box='1 1 3 10', items=[
ui.text_l('To Do'),
*not_done,
])Step 5: Show the 'done' list
We also turn each completed to-do item into another list of checkboxes, checked by default (using its value attribute). We append this to the form card and put a separator in between (using ui.separator()) to distinguish the completed items from the incomplete ones.
from typing import List
from h2o_wave import Q, main, app, ui
_id = 0
# A simple class that represents a to-do item.
class TodoItem:
def __init__(self, text):
global _id
_id += 1
self.id = f'todo_{_id}'
self.text = text
self.done = False
@app('/todo')
async def serve(q: Q):
show_todos(q)
await q.page.save()
def show_todos(q: Q):
# Get items for this user.
todos: List[TodoItem] = q.user.todos
# Create a sample list if we don't have any.
if todos is None:
q.user.todos = todos = [TodoItem('Do this'), TodoItem('Do that'), TodoItem('Do something else')]
# Create done/not-done checkboxes.
done = [ui.checkbox(name=todo.id, label=todo.text, value=True, trigger=True) for todo in todos if todo.done]
not_done = [ui.checkbox(name=todo.id, label=todo.text, trigger=True) for todo in todos if not todo.done]
# Display list
q.page['form'] = ui.form_card(box='1 1 3 10', items=[
ui.text_l('To Do'),
*not_done,
*([ui.separator('Done')] if len(done) else []),
*done,
])At this point, try running your app.
cd $HOME/wave-apps
source venv/bin/activate
wave run todoPoint your browser to http://localhost:10101/todo.
You should be able to see your todo list in all its glory. Unfortunately, checking any of the items seems to have no effect. Let's fix that next.
Step 6: Handle checkboxes
Each time a checkbox is checked or unchecked, our serve() function is called, which in turn calls show_todos().
- If a checkbox is checked,
q.argswill contain aTruefor that checkbox. - If a checkbox is unchecked,
q.argswill contain aFalsefor that checkbox.
So, we iterate through all the to-do items and set their done attribute based on the value of their corresponding checkbox.
from typing import List
from h2o_wave import Q, main, app, ui
_id = 0
# A simple class that represents a to-do item.
class TodoItem:
def __init__(self, text):
global _id
_id += 1
self.id = f'todo_{_id}'
self.text = text
self.done = False
@app('/todo')
async def serve(q: Q):
show_todos(q)
await q.page.save()
def show_todos(q: Q):
# Get items for this user.
todos: List[TodoItem] = q.user.todos
# Create a sample list if we don't have any.
if todos is None:
q.user.todos = todos = [TodoItem('Do this'), TodoItem('Do that'), TodoItem('Do something else')]
# If the user checked/unchecked an item, update our list.
for todo in todos:
if todo.id in q.args:
todo.done = q.args[todo.id]
# Create done/not-done checkboxes.
done = [ui.checkbox(name=todo.id, label=todo.text, value=True, trigger=True) for todo in todos if todo.done]
not_done = [ui.checkbox(name=todo.id, label=todo.text, trigger=True) for todo in todos if not todo.done]
# Display list
q.page['form'] = ui.form_card(box='1 1 3 10', items=[
ui.text_l('To Do'),
*not_done,
*([ui.separator('Done')] if len(done) else []),
*done,
])You should now be able to check/uncheck the items in your todo list.
Next, let's see how to add new items to the list.
Step 7: The 'new todo' form
Next, let's display a form to add new items to our list. For that, we'll add a new button to our existing form, named new_todo, and direct the serve() function to the new_todo() function if the button is clicked. Recall that when buttons are clicked, q.args.button_name will be True, so we check if q.args.new_todo is True.
In the new_todo() function, we display a new form containing a textbox (using ui.textbox()) and a set of buttons to add the item or return to to-do list (a ui.buttons() helps us display buttons side-by-side).
/* Detailed source-code truncated for AI context efficiency. */You should now be able to bring up the new to-do form.
Step 8: Add to-do and return
Finally, we handle the add_todo button-click, redirecting serve() to a new add_todo() function, which simply inserts a the new to-do item into our user-level todo list and calls show_todos() to redraw the to-do list.
:::note
In this example, for clarity, we named both the buttons and their corresponding functions new_todo and add_todo, but this is not necessary.
:::
/* Detailed source-code truncated for AI context efficiency. */You should now be able to add new to-do items to your list. Congratulations!
Step 9: Make it realtime
To make your app realtime, simply pass mode='multicast' to @app().
@app('/todo', mode='multicast')Now try opening http://localhost:10101/todo from multiple browser tabs:
Groovy!
Exercise
A little housekeeping goes a long way: add a "Clear" button on the main page to clear all completed to-dos.
Next steps
Congratulations! You've completed all the tutorials (hopefully). There are three paths you can take from here:
- Widgets. Widgets that cover best UX practices and a lot of widgets variations.
- Gallery. 150+ examples that cover everything that Wave has to offer.
- Guide. An in-depth look at each of Wave's features.
- API. Reference-level documentation for the Python API.
Happy hacking!
Wavedb (`website/docs/wavedb.md`)
title: WaveDB
WaveDB is SQLite with a HTTP interface.
It is a ~6MB (~2MB UPX-compressed) self-contained, zero-dependency executable that bundles SQLite 3.35.5 (2021-04-19) with JSON1, RTREE, FTS5, GEOPOLY, STAT4, and SOUNDEX.
If you are already a fan of SQLite, WaveDB acts as a thin HTTP-server wrapper that lets you access your SQLite databases over a network.
WaveDB can be used as a lightweight, cross-platform, installation-free companion SQL database for Wave apps. The h2o-wave package includes non-blocking async functions to access WaveDB.
Database files managed by WaveDB are 100% interoperable with SQLite, which means you can manage them with the sqlite3 CLI, backup/restore/transfer them as usual, or use Litestream for replication.
Install
Linux, OSX, Windows
Download the executable for your platform. No installation required.
Other Platforms
Compiling WaveDB on other platforms is easy if you have the Go and C compilers handy (uses cgo):
git clone --depth 1 https://github.com/h2oai/wave.git
cd wave
go build -o wavedb cmd/wavedb/main.goLaunch
Launch WaveDB (defaults to http://localhost:10100/):
./wavedb2021/05/07 14:00:41
2021/05/07 14:00:41 ┌────────┐┌────┐ H2O WaveDB
2021/05/07 14:00:41 │ ┐┌┐┐┌─┘│─┐ │ DEV 20210507132041
2021/05/07 14:00:41 │ └┘└┘└─┘└─┘ │ © 2021 H2O.ai, Inc.
2021/05/07 14:00:41 └──────────────┘
2021/05/07 14:00:41
2021/05/07 14:00:41 listening :10100Launch WaveDB on a different port:
./wavedb -listen :8080Launch WaveDB with verbose logging (useful during development):
./wavedb -verboseLaunch WaveDB with a custom API access key/secret pair:
./wavedb -access-key-id uzer -access-key-secret pa55wordServe your existing SQLite database files using WaveDB (defaults to current directory):
./wavedb -dir /path/to/my/db/filesExamples
- Using WaveDB from a Wave app - A To-do list
- Using WaveDB from a Wave app - Paginated table
- Using WaveDB from a standalone script
Usage
For this example, we'll use the Chinook Sample Database from sqlitetutorial.net.
$ wget https://www.sqlitetutorial.net/wp-content/uploads/2018/03/chinook.zip
$ unzip chinook.zip
$ ls -l
-rw-r--r-- 1 elp elp 884736 May 7 12:34 chinook.db
-rwxr-xr-x 1 elp elp 9678432 May 7 13:20 wavedb
$ ./wavedb -access-key-id uzer -access-key-secret pa55wordFrom h2o-wave
The h2o-wave Python package provides non-blocking async functions to access WaveDB.
from h2o_wave import connect
# Create a database connection
connection = connect(key_id='uzer', key_secret='pa55word')
# Access the 'chinook' database.
# Automatically creates a new database if it does not exist.
db = connection["chinook"]
# Execute a query
results, err = await db.exec('SELECT name, composer FROM tracks LIMIT 5')
# Print results
if err:
print(err)
else:
print(results)The query API is simple:
db.exec(...): Execute one query.db.exec_many(...): Execute multiple queries.db.exec_atomic(...): Excute multiple queries as a transaction (rollback all on failure)
All three APIs support both DDL (CREATE, ALTER, DROP, etc.) and DML (SELECT, INSERT, UPDATE, etc.) statements:
result, error = db.exec('CREATE TABLE student(name TEXT, age INTEGER)')
result, error = db.exec('INSERT INTO student VALUES ("Alice", 18)')
result, error = db.exec('SELECT name, age FROM student WHERE age > 17')Queries can be parameterized (use on user-supplied input to prevent SQL injection):
result, error = db.exec('INSERT INTO student VALUES (?, ?)', "Bob", 19)
result, error = db.exec('SELECT name, age FROM student WHERE age > ?', 17)You can use any combination of the above in an exec_many() to batch all your queries into one request:
results, error = db.exec_many(
'CREATE TABLE student(name TEXT, age INTEGER)',
'INSERT INTO student VALUES ("Alice", 18)',
('INSERT INTO student VALUES (?, ?)', "Bob", 19),
'SELECT name, age FROM student WHERE age > 17',
('SELECT name, age FROM student WHERE age > ?', 17),
)In the above example, substituting exec_many() with exec_atomic() executes all the queries in the batch as part of a transaction, rolling back all queries on any failures.
From curl
Use curl to send a query:
curl -s -u uzer:pa55word -d '{"e":{"d":"chinook", "s":[{"q": "select name, composer from tracks limit 5"}]}}' http://localhost:10100{"r":[[["For Those About To Rock (We Salute You)","Angus Young, Malcolm Young, Brian Johnson"],["Balls to the Wall",null],["Fast As a Shark","F. Baltes, S. Kaufman, U. Dirkscneider \u0026 W. Hoffman"],["Restless and Wild","F. Baltes, R.A. Smith-Diesel, S. Kaufman, U. Dirkscneider \u0026 W. Hoffman"],["Princess of the Dawn","Deaffy \u0026 R.A. Smith-Diesel"]]]}Use jq to pretty-print the response:
curl -s -u uzer:pa55word -d '{"e":{"d":"chinook", "s":[{"q": "SELECT name, composer FROM tracks LIMIT 5"}]}}' http://localhost:10100 | jq '.'{
"r": [
[
[
"For Those About To Rock (We Salute You)",
"Angus Young, Malcolm Young, Brian Johnson"
],
[
"Balls to the Wall",
null
],
[
"Fast As a Shark",
"F. Baltes, S. Kaufman, U. Dirkscneider & W. Hoffman"
],
[
"Restless and Wild",
"F. Baltes, R.A. Smith-Diesel, S. Kaufman, U. Dirkscneider & W. Hoffman"
],
[
"Princess of the Dawn",
"Deaffy & R.A. Smith-Diesel"
]
]
]
}JSON protocol
Grammar
type request = exec_request | drop_request
type reply = exec_reply | drop_reply
type exec_request = { d: database_name, s: statement[], a: atomicity }
type database_name = string
type statement = { q: query, p: parameter[] }
type query = string
type parameter = primitive
type atomicity = 0 | 1
type exec_reply = { r: result[], e: error }
type result = row[]
type row = primitive[]
type error = string
type drop_request = { d: database_name }
type drop_reply = { e: error }
type primitive = number | string | boolean | nullExamples
Sample exec_request:
{
"e": {
"d": "chinook",
"s": [
{
"q": "select name, composer from tracks limit 5"
}
]
}
}Sample reply:
{
"r": [
[
[
"For Those About To Rock (We Salute You)",
"Angus Young, Malcolm Young, Brian Johnson"
],
[
"Balls to the Wall",
null
],
[
"Fast As a Shark",
"F. Baltes, S. Kaufman, U. Dirkscneider & W. Hoffman"
],
[
"Restless and Wild",
"F. Baltes, R.A. Smith-Diesel, S. Kaufman, U. Dirkscneider & W. Hoffman"
],
[
"Princess of the Dawn",
"Deaffy & R.A. Smith-Diesel"
]
]
]
}Deployment
For production deployments:
- Use the
-access-keychainoption to control API keys. See documentation. - Use the
-tls-cert-fileand-tls-key-fileoptions to enable HTTPS.
$ ./wavedb \
-access-keychain /path/to/keychain \
-tls-cert-file /path/to/cert-file \
-tls-key-file /path/to/key-fileConfiguration
$ ./wavedb -help
Usage of ./wavedb:
-access-key-id string
default API access key ID (default "access_key_id")
-access-key-secret string
default API access key secret (default "access_key_secret")
-access-keychain string
path to file containing API access keys (default ".wave-keychain")
-benchmark int
run benchmarks for the given number of iterations
-dir string
path to directory containing database (.db) files (default ".")
-listen string
listen on this address (default ":10100")
-tls-cert-file string
path to certificate file (TLS only)
-tls-key-file string
path to private key file (TLS only)
-verbose
enable verbose logging
-version
print version and exitAcknowledgements
WaveDB is based on SQLite and bvinc/go-sqlite-lite, built with Go. The Python client uses HTTPX under the hood. Thank you to the authors and contributors.
WaveDB can be used from any language or platform that has libraries for HTTP and JSON. The reference implementation of the client is ~75 lines of code if you're interested in porting it to other languages.
--- METRICS ---
- Files Extracted: 39
- Estimated Token Budget: ~61764 tokens
- Canonical Reference: https://codewiki.google/github.com/h2oai/wave