MEP | Title | Discussion | Implementation |
---|---|---|---|
1 |
Representation as a Python File |
XXX |
This MEP proposes a representation of Marimo apps as structured Python files. The representation is legible and easily versionable through source control. It is designed to allow importing Marimo apps as Python modules, to access cells, names, or the DAG itself, while also functioning as an executable script. We strive for a minimal representation with these constraints in mind.
Marimo apps are dataflow programs, implemented in Python and executed by the
marimo
Python library. It makes sense for these apps to be stored on disk as
pure Python files; if an app were a pure Python file, then:
- marimo could load it using an
import
statement instead of parsing a new file format - users wouldn't need to learn a new file format
- users could format these files however they like, and marimo would still be able to read them
- they could be executed as scripts using
python
- they could be imported into other marimo apps or Python modules using
the familiar
import
statement - users could easily write or edit them using their text editor of choice
We seek a representation that has the following properties, ordered from most important to least:
- git diff friendly
- easy for humans to read
- Pythonic
- cell ordering (in column) or arrangement (in grid)
- cell names (given by user, or automatically generated)
- cell refs and defs
- dataflow structure
- usable as a Python module
- for users to use ("remix") cells, names, or the entire graph in other Python programs
- executable as a Python script
- editable with a text editor
Three cells, in a program called "numerics". Each cells can be understood as a map between references and definitions ("refs" and "defs").
cell | refs | defs |
---|---|---|
import marimo as mo
import numpy as np |
{} | {mo, np} |
def matmul(X, Y):
return np.matmul(X, y) |
{np} | {matmul} |
Z = matmul(np.random.randn(4, 4), np.random.randn(4, 4))
mo.md(f"You calculated {Z}") |
{matmul, mo, np} | {Z} |
Saved to numerics.py
:
import marimo
__generated_with = "0.0.1"
app = marimo.App()
@app.cell
def __():
import marimo as mo
import numpy as np
return mo, np
@app.cell
def __(np):
def matmul(X, Y):
return np.matmul(X, Y)
return matmul,
@app.cell
def calculate(matmul, mo, np):
from other_app import _data
Z = matmul(np.random.randn(4, 4), _data())
mo.md(f"You calculated {Z}")
return Z,
if __name__ == '__main__':
app.run()
A marimo file contains two names that will be used either internally by the marimo library, or by users:
app
: The only public facing attribute. Theapp
object will be extended with methods and attributes that provide access to the app's defs.- (optional)
__generated_with
: the version of marimo used to generate the app
Because the module is a pure Python file, it may be formatted in any way the
user likes. Users should not rely on the ability to include arbitrary comments
and logic to marimo, because these will be overwritten if/when using the
frontend to save edits (on the other hand, they may safely rely on custom
contents if they only use marimo run
).
The module's code is organized in the following order:
- Version.
__generated_with
, the version of marimo used to generate the module - App. A creation of an empty app.
- Cells. Definitions of cells as functions, decorated with
@app.cell
. - Syntax errors. A list of cells with syntax errors, included only when at least one cell has a syntax error, and interleaved with cell definitions.
- Main: An
if __name__ == '__main__'
guarded section for running the app.
Cells are included in the module as functions, decorated with app.cell
. A cell
function is a mapping from the cell's refs, which it takes as arguments, to its
defs, which it returns.
Cells are defined in the order that they are arranged in the frontend.
The body of the cell (excluding the return statement) is dedented and pasted verbatim into the frontend, meaning that all formatting is preserved.
Users may choose to name their cell functions, either in the frontend or
by directly editing the generated module. Cells which are left unnamed by the
user are given the placeholder name _
. Named cells can be imported
by other scripts/libraries from the module top-level.
Cells have the following restrictions on their names:
- No cell can be named
app
. - No cell can be named
marimo
. - No cell can be named a reserved Python keyword.
- Names cannot begin with two underscores: these are reserved for unnamed cells and for future use by marimo's codegen and loader.
The app
object has three members that must remain in future versions to
ensure backward compatibility:
_functions
, a list of the functions used to make the app, used by the library to instantiate the module._add_unparsable_cell
, used in the module to register cells that have syntax errors.run
, which executes the apps and returns its outputs (a dict mapping cell name to output) and its defs (a dict mapping def name to value).
In the future the app
object may be extended with additional public members,
such as a functions
object that provides access to function defs that were
defined in refless cells (or at least cells whose only refs are imports), or a
classes
object providing access to class defs, and code transformation
utilities (eg, to push refs such as imports down into a cell).
A generated module must always be importable, even if one or more cells have syntax errors, so that the server can extract cell codes and load them into the editor.
Cells with syntax errors cannot be defined as functions (if they were, importing
the module would raise a SyntaxError exception, making it impossible to load
the app in the marimo frontend). Instead, they are included
in the file via the function _add_unparsable_cell
. This function takes
the cell's code as a string, and inserts it into an internal list that combines
valid cells and unparsable ones (so that the order of cells is preserved).
Example:
import marimo
__generated_with = "0.0.1"
app = marimo.App()
@app.cell
def _():
import numpy as np
return np,
app._add_unparsable_cell(
"""
_ error
"""
)
@app.cell
def _():
'all good'
return
app._add_unparsable_cell(
"""
_ another_error
_ and \"\"\"another\"\"\"
""",
)
if __name__ == '__main__':
app.run()
A marimo-generated module can be executed as a regular Python script, e.g.,
python numerics.py
This will execute app.run()
, which will in turn execute the DAG. This may be
useful if the dag has side-effects such as writing disk or printing to stdout.
(It could be useful if the generated HTML was printed to standard out, but it's not clear that the generated HTML is useful outside the marimo frontend.)
In the future we may lift top-level names ("refless defs", ie defs that do not depend on refs) and make them optional parameters of the main program. This would be helpful when stiching together marimo apps with pipelining tools (which we may ourselves build).
The names of cells are added to the cell's top-level namespace. This makes
it possible for users to import a cell directly from a module
(from numerics import calculate
), at the cost of polluting the top-level
namespace.
The specification takes care to avoid name clashes with cell names. The specification reserves the the following names for itself:
marimo
app
- any name starting with two underscores (used for internal variables and unnamed cells)
Cells are forbidden from having the same name as a reserved name.
Because users can have arbitrary names, this means that should we wish
to add public names in the future, these names will have to be nested
under the app
object.
The file must always export an app
object that has the three
attributes mentioned in the app
section.
Additional functionality can be added by adding members to the App
class, or
by adding members to the module whose names start with __
. No other global
names may be added.
The file format is guaranteed to be backwards compatible: marimo will always be
able to open modules generated by older versions of marimo, because all it
needs are the cell functions (app._functions
) and the cells with syntax
errors (app._unparsable_cells
).
Conversely, for as long as we only depend on these two names, marimo should be forward compatible with modules generated by newer versions of marimo (i.e., an old version of marimo should be able to load a module generated by a newer version).
The version of marimo used to generate the module is included in the file in case a backward incompatible change is made. Any version of marimo that introduces the backward incompatible change should be bundled with a loader that can read modules generated by older versions.
Evaluation against the criteria:
- git diff friendly
Let's consider different kinds of modifications made to an app, and the modifications that would be made to the generated module as a result.
modification to app | modification to module | clean diff? |
---|---|---|
modify an existing cell | modification to a single function (body, signature, returns) | Y |
move a cell (up or down) | function moved to a different location in the file | Y |
rename a cell | a function's name is changed | Y |
introduce a syntax error | function replaced with a call to `_add_unparsable_cell` | Sort of |
change marimo version | version number in `__generated_with` changes | Y |
Verdict: A. Very clean diffs!
- easy for humans to read
- Pythonic
Yes, fairly Pythonic. Unnamed cells are a little cryptic, but it's easy to get used to them.
2. cell ordering (in column) or arrangement (in grid)
Easy to read off, since cells are defined in presentation order. However, it is not obvious how to extend this format to accommodate grid layouts in a way that remains readable.
3. cell names (given by user, or automatically generated)
Easy to read off, and to distinguish between named and unnamed cells.
4. cell refs and defs
Easy to read off from signature and returns.
5. dataflow structure
Not at all evident. Would need to rely on an external program to visualize the DAG, and even still it would be awkward to visualize DAGs with unnamed cells.
Verdict: B+. Very readable on all accounts except for dataflow structure, on which we totally fail.
- usable as a Python module
- for users to use ("remix") cells, names, or the entire graph in other Python programs
Verdict: B+. Cells are accessible at top-level, which is nice. Remixing
APIs can be included in the future under the app
object, which is okay.
- executable as a Python script
Verdict: A+. Can execute with python
directly.
- editable with a text editor.
Verdict: A. The specification is simple, flexible, and totally free of magical tokens.
criterion | marimo's grade | jupyter's grade | streamlit's grade |
---|---|---|---|
clean diffs | A | F | A+ |
readability | B+ | F | A+ |
usable as a Python module | B+ | F | C |
executable as a Python script | A | F | F |
editable with a text editor | A | F- | A+ |
- Considered including cells in a function namespace, so that there would be no name conflicts. But this feels too complicated.
def _make_app():
app = marimo._App()
@app.cell
def __():
import marimo as mo
import numpy as np
return mo, np
@app.cell
def __(np):
def matmul(X, Y):
return np.matmul(X, Y)
return matmul,
@app.cell
def calculate(matmul, mo, np):
from other_app import _data
Z = matmul(np.random.randn(4, 4), _data())
mo.md(f"You calculated {Z}")
return Z,
return app
- No decorators, a function that returns cell in presentation order, and app made after. But too complicated, plus where to put unparsable cells?
"""a marimo app"""
import marimo
_marimo_version = 0.0.1
def _cells():
def a():
import marimo as mo
import numpy as np
return mo, np
def b(np):
def matmul(X, Y):
return np.matmul(X, Y)
return matmul,
def c(matmul, mo, np):
Z = matmul(np.random.randn(4, 4), np.random.randn(4, 4))
mo.md(f"You calculated {Z}")
return Z,
return b, c, a
app = marimo._make_app([b, c, a])
if __name__ == '__main__':
app.run()
-
Cells defined in a topological ordering of the DAG. While this conveys the program order, it makes the file difficult for the author of the app to read, and can lead to messy diffs as small changes (such as renaming cells or swapping contents of cells, depending on the implementation) can lead to large and confusing diffs.
-
Unnamed cells given a default name, such as
__a
,__b
. This would make it easier to visualize the program as a DAG, since we would have names to refer to, but it would pollute the reader's symbol table. -
Explicitly defining the DAG, as sequence of function calls, under the
__main__
section. This was supposed to help readability, since it documents the graph; however, it ended up hurting readability because DAGs can become very verbose (due to the need for namespacing) and obtuse (when users don't name their cells, which will be the default). Moreover it was misleading because it suggested that sibling cells ran in a deterministic order, which is false. -
A flat format in which the program was stored as a script, using comments to separate cells instead of encapsulating them in functions. This is a smaller representation than what we've proposed in this MEP, but it makes it unwieldy to use the script as a module. It would also require us to add magic comments or symbols to parse the code into cells, making the format brittle.
-
Leave comments documenting which cells are involved in cycles, which names are multiply defined and by which cells, and which cells try to delete their refs. We can add these things later, without affecting compatibility, since they are just comments.
https://github.com/marimo-team/prototype/tree/mep-0001-revision
- Invoking a marimo app as a script with optional parameters, one for each
mo.Name
object:
python app.py -n x 1.0 -n y "hello"
where the syntax is -n [name] [value]
. This lets one override the default
values of UI elements.
- Designate a special member or string whose value becomes help documentation for the app (docstring, or for the script ...)
- How can we extend this format to handle grid layouts? The
cell
decorator could be extended to optionally take coordinates/width/height of a cell, and cells could be defined in some flattened order (e.g, row-wise) - Communicating the DAG structure in a comment
- A tool (or a marimo app) for visualizing the DAG
- A tool that tells you if your app has any problems, like cyclic references
or multiply defined cells ... (
marimo check
)