|
| 1 | +<!-- livebook:{"default_language":"python"} --> |
| 2 | + |
| 3 | +# Working with Python |
| 4 | + |
| 5 | +```elixir |
| 6 | +Mix.install( |
| 7 | + [ |
| 8 | + {:pythonx, "~> 0.4.9"}, |
| 9 | + {:kino_pythonx, "~> 0.1.0"}, |
| 10 | + {:kino_db, "~> 0.4.0"}, |
| 11 | + {:adbc, ">= 0.0.0"} |
| 12 | + ], |
| 13 | + config: [adbc: [drivers: [:duckdb]]] |
| 14 | +) |
| 15 | +``` |
| 16 | + |
| 17 | +```pyproject.toml |
| 18 | +[project] |
| 19 | +name = "project" |
| 20 | +version = "0.0.0" |
| 21 | +requires-python = "==3.13.*" |
| 22 | +dependencies = [ |
| 23 | + "numpy==2.2.3", |
| 24 | + "matplotlib==3.10.1", |
| 25 | + "pandas==2.2.3", |
| 26 | + "polars==1.24.0", |
| 27 | + "altair==5.5.0", |
| 28 | + "plotly==6.0.0", |
| 29 | + "seaborn==0.13.2", |
| 30 | + "pillow==11.1.0", |
| 31 | + "pyarrow==23.0.0" |
| 32 | +] |
| 33 | +``` |
| 34 | + |
| 35 | +## Introduction |
| 36 | + |
| 37 | +Besides Elixir, Livebook supports running Python code. Not only that, it allows you to mix Elixir and Python code seamlessly. In this notebook we will explore various ways in which you can use Python in your notebooks. |
| 38 | + |
| 39 | +To enable Python in a new notebook, you just need to click <kbd>+ Python</kbd> button right below the setup cell. This does two things: |
| 40 | + |
| 41 | +* adds `:pythonx` as an Elixir dependency, which embeds a fully fledged Python interpreter directly in Elixir |
| 42 | +* inserts a **pyproject.toml** cell, where you can configure all the Python packages you need, as detailed in the **uv** package manager [documentation](https://docs.astral.sh/uv/concepts/projects/dependencies/) |
| 43 | + |
| 44 | +By explicitly listing the required dependencies, Livebook knows exactly what to install, making the environment easily reproducible, no global `pip` installs required! |
| 45 | + |
| 46 | +Having done that, you can now insert Python cells and write typical Python code. |
| 47 | + |
| 48 | +> Note that Python cells also offer intellisense such as module/function/variable completion and on-hover documentation, which you can test throughout the cells below. |
| 49 | +
|
| 50 | +## Visual representation |
| 51 | + |
| 52 | +As you can see in the setup section above, this notebook already lists a number of popular Python packages as dependencies. Livebook provides rich rendering for a variety of object, such as charts and dataframes, which we are going to see in this section. |
| 53 | + |
| 54 | +<!-- livebook:{"break_markdown":true} --> |
| 55 | + |
| 56 | +### Polars |
| 57 | + |
| 58 | +[Polars](https://pola.rs) dataframes. |
| 59 | + |
| 60 | +```python |
| 61 | +import polars as pl |
| 62 | +import datetime as dt |
| 63 | +``` |
| 64 | + |
| 65 | +```python |
| 66 | +df = pl.DataFrame( |
| 67 | + { |
| 68 | + "name": ["Alice Archer", "Ben Brown", "Chloe Cooper", "Daniel Donovan"], |
| 69 | + "birthdate": [ |
| 70 | + dt.date(1997, 1, 10), |
| 71 | + dt.date(1985, 2, 15), |
| 72 | + dt.date(1983, 3, 22), |
| 73 | + dt.date(1981, 4, 30), |
| 74 | + ], |
| 75 | + "weight": [57.9, 72.5, 53.6, 83.1], # (kg) |
| 76 | + "height": [1.56, 1.77, 1.65, 1.75], # (m) |
| 77 | + } |
| 78 | +) |
| 79 | + |
| 80 | +df |
| 81 | +``` |
| 82 | + |
| 83 | +### Pandas |
| 84 | + |
| 85 | +[Pandas](https://pandas.pydata.org) dataframes. |
| 86 | + |
| 87 | +```python |
| 88 | +import pandas as pd |
| 89 | +import datetime as dt |
| 90 | +``` |
| 91 | +> Note: the cells with `import` may take a while the first time you run them. That's an expected Python behaviour when importing a large module tree. Python stores a cached module bytecode in disk, so even if you restart the notebook, subsequent imports of that module should be fast. |
| 92 | +
|
| 93 | +```python |
| 94 | +df = pd.DataFrame( |
| 95 | + { |
| 96 | + "name": ["Alice Archer", "Ben Brown", "Chloe Cooper", "Daniel Donovan"], |
| 97 | + "birthdate": [ |
| 98 | + dt.date(1997, 1, 10), |
| 99 | + dt.date(1985, 2, 15), |
| 100 | + dt.date(1983, 3, 22), |
| 101 | + dt.date(1981, 4, 30), |
| 102 | + ], |
| 103 | + "weight": [57.9, 72.5, 53.6, 83.1], # (kg) |
| 104 | + "height": [1.56, 1.77, 1.65, 1.75], # (m) |
| 105 | + } |
| 106 | +) |
| 107 | + |
| 108 | +df |
| 109 | +``` |
| 110 | + |
| 111 | +### Matplotlib |
| 112 | + |
| 113 | +[Matplotlib](https://matplotlib.org) plots. |
| 114 | + |
| 115 | +```python |
| 116 | +import matplotlib.pyplot as plt |
| 117 | +``` |
| 118 | + |
| 119 | +```python |
| 120 | +plt.plot([1, 2], [1, 2]) |
| 121 | +plt.gcf() |
| 122 | +``` |
| 123 | + |
| 124 | +```python |
| 125 | +import numpy as np |
| 126 | +``` |
| 127 | + |
| 128 | +```python |
| 129 | +x = np.linspace(0, 10, 100) |
| 130 | +y = np.sin(x) |
| 131 | + |
| 132 | +plt.plot(x, y, 'b-', label='sine wave') |
| 133 | +plt.title('Simple Sine Wave') |
| 134 | +plt.xlabel('x') |
| 135 | +plt.ylabel('sin(x)') |
| 136 | +plt.legend() |
| 137 | +plt.grid(True) |
| 138 | + |
| 139 | +plt.gcf() |
| 140 | +``` |
| 141 | + |
| 142 | +### Seaborn |
| 143 | + |
| 144 | +[seaborn](https://seaborn.pydata.org) plots. |
| 145 | + |
| 146 | +```python |
| 147 | +import seaborn as sns |
| 148 | +``` |
| 149 | + |
| 150 | +```python |
| 151 | +df = sns.load_dataset("penguins") |
| 152 | + |
| 153 | +sns.pairplot(df, hue="species") |
| 154 | +``` |
| 155 | + |
| 156 | +### Vega-Altair |
| 157 | + |
| 158 | +[Vega-Altair](https://altair-viz.github.io) charts. |
| 159 | + |
| 160 | +```python |
| 161 | +import altair as alt |
| 162 | +import pandas as pd |
| 163 | +``` |
| 164 | + |
| 165 | +```python |
| 166 | +source = pd.DataFrame({ |
| 167 | + "a": ["A", "B", "C", "D", "E", "F", "G", "H", "I"], |
| 168 | + "b": [28, 55, 43, 91, 81, 53, 19, 87, 52] |
| 169 | +}) |
| 170 | + |
| 171 | +alt.Chart(source).mark_bar().encode(x="a", y="b") |
| 172 | +``` |
| 173 | + |
| 174 | +### Plotly |
| 175 | + |
| 176 | +[Plotly](https://plotly.com/python) graphs. |
| 177 | + |
| 178 | +```python |
| 179 | +import plotly.express as px |
| 180 | +``` |
| 181 | + |
| 182 | +```python |
| 183 | +px.line(x=["a", "b", "c"], y=[1, 3, 2], title="Sample figure") |
| 184 | +``` |
| 185 | + |
| 186 | +```python |
| 187 | +import pandas as pd |
| 188 | +``` |
| 189 | + |
| 190 | +```python |
| 191 | +df = pd.DataFrame({"a": [1, 3, 2], "b": [3, 2, 1]}) |
| 192 | + |
| 193 | +px.bar(df) |
| 194 | +``` |
| 195 | + |
| 196 | +### Pillow |
| 197 | + |
| 198 | +[Pillow](https://python-pillow.github.io) images. |
| 199 | + |
| 200 | +```python |
| 201 | +from PIL import Image |
| 202 | +from urllib.request import urlopen |
| 203 | +``` |
| 204 | + |
| 205 | +```python |
| 206 | +url = "https://github.com/elixir-nx/nx/raw/v0.9/nx/nx.png" |
| 207 | +Image.open(urlopen(url)) |
| 208 | +``` |
| 209 | + |
| 210 | +## Elixir interoperability |
| 211 | + |
| 212 | +In Livebook you can mix Elixir and Python code together. For example, let's define a variable in an Elixir cell: |
| 213 | + |
| 214 | +```elixir |
| 215 | +elixir_variable = 1 |
| 216 | +``` |
| 217 | + |
| 218 | +We can access it under the same name in a Python cell: |
| 219 | + |
| 220 | +```python |
| 221 | +elixir_variable |
| 222 | +``` |
| 223 | + |
| 224 | +> Hint: the language is shown in the bottom-right corner of each code cell. You can change the language by clicking the language icon. When inserting a new cell, you can also change the langauge by clicking on the dropdown icon. |
| 225 | +
|
| 226 | +<!-- livebook:{"break_markdown":true} --> |
| 227 | + |
| 228 | +An important implication of the variable interoperability is that you can use `Kino` inputs to provide data for Python code. |
| 229 | + |
| 230 | +```elixir |
| 231 | +range_input = Kino.Input.range("How cool is that?") |
| 232 | +``` |
| 233 | + |
| 234 | +```elixir |
| 235 | +number = Kino.Input.read(range_input) |
| 236 | +``` |
| 237 | + |
| 238 | +```python |
| 239 | +if number % 3 == 0 and number % 5 == 0: |
| 240 | + print("FizzBuzz") |
| 241 | +elif number % 3 == 0: |
| 242 | + print("Fizz") |
| 243 | +elif number % 5 == 0: |
| 244 | + print("Buzz") |
| 245 | +else: |
| 246 | + print(number) |
| 247 | +``` |
| 248 | + |
| 249 | +Note that as you change the slider value, the cell indicator in the bottom-right changes to *Stale*. The value change tracking propagates from Elixir all the way to the Python cell! |
| 250 | + |
| 251 | +<!-- livebook:{"break_markdown":true} --> |
| 252 | + |
| 253 | +One gotcha here is that sometimes the default encoding may not be what you'd expect. Specifically, this is the case with strings: |
| 254 | + |
| 255 | +```elixir |
| 256 | +hello_from_elixir = "Hello!" |
| 257 | +``` |
| 258 | + |
| 259 | +```python |
| 260 | +hello_from_elixir |
| 261 | +``` |
| 262 | + |
| 263 | +Elixir does not distinguish strings and binaries as separate data types - strings are just binaries, with the expectation that they follow utf-8 encoding. When passed to Python, a string therefore becomes a `bytes` object. However, converting it to a Python string is as easy as decoding the bytes: |
| 264 | + |
| 265 | +```python |
| 266 | +hello_from_elixir.decode("utf-8") |
| 267 | +``` |
| 268 | + |
| 269 | +## ADBC and data processing |
| 270 | + |
| 271 | +Here we are going to have a look at another type of interoperability, by combining Elixir and Python libraries. |
| 272 | + |
| 273 | +Elixir has a [adbc](https://hexdocs.pm/adbc/Adbc.html) package, with bindings for Arrow Database Connectivity (ADBC). ADBC provides a standard interface for querying databases and getting results in the Apache Arrow format. |
| 274 | + |
| 275 | +In fact, the Livebook's "Database connection" smart cell, already uses ADBC for some of the databases. Let's connect to an in-memory DuckDB for the sake of this example: |
| 276 | + |
| 277 | +<!-- livebook:{"attrs":"e30","chunks":null,"kind":"Elixir.KinoDB.ConnectionCell","livebook_object":"smart_cell"} --> |
| 278 | + |
| 279 | +```elixir |
| 280 | +:ok = Adbc.download_driver!(:duckdb) |
| 281 | +{:ok, db} = Kino.start_child({Adbc.Database, driver: :duckdb}) |
| 282 | +{:ok, conn} = Kino.start_child({Adbc.Connection, database: db}) |
| 283 | +``` |
| 284 | + |
| 285 | +Now, we can query the database, but since there is no data, let's use `SELECT` with `VALUES`. |
| 286 | + |
| 287 | +In particular, because we want to further process the query result in Python, let's use the `Adbc.Connection.py_query/3` function. This will give use a Python table object that can be efficiently consumed by Python code. |
| 288 | + |
| 289 | +```elixir |
| 290 | +{:ok, py_table} = |
| 291 | + Adbc.Connection.py_query( |
| 292 | + conn, |
| 293 | + """ |
| 294 | + SELECT * |
| 295 | + FROM (VALUES |
| 296 | + (1, 'Alice', 'Engineering', 92000.00), |
| 297 | + (2, 'Bob', 'Marketing', 78000.00), |
| 298 | + (3, 'Carol', 'Engineering', 105000.00), |
| 299 | + (4, 'Dave', 'HR', 65000.00), |
| 300 | + (5, 'Eve', 'Marketing', 83000.00), |
| 301 | + (6, 'Frank', 'Engineering', 98000.00) |
| 302 | + ) AS employees(id, name, department, salary) |
| 303 | + ORDER BY department, salary DESC |
| 304 | + """, |
| 305 | + [] |
| 306 | + ) |
| 307 | +``` |
| 308 | + |
| 309 | +The object has type `pyarrow.Table` and can be easily converted into a dataframe using the `polars` library: |
| 310 | + |
| 311 | +```python |
| 312 | +df = pl.from_arrow(py_table) |
| 313 | +df |
| 314 | +``` |
| 315 | + |
| 316 | +which we can operate on as usual: |
| 317 | + |
| 318 | +```python |
| 319 | +summary = ( |
| 320 | + df |
| 321 | + .group_by("department") |
| 322 | + .agg([ |
| 323 | + pl.col("salary").cast(pl.Float64).mean().alias("avg_salary"), |
| 324 | + pl.col("salary").max().alias("max_salary"), |
| 325 | + pl.col("name").count().alias("headcount"), |
| 326 | + ]) |
| 327 | + .sort("avg_salary", descending=True) |
| 328 | +) |
| 329 | + |
| 330 | +summary |
| 331 | +``` |
| 332 | + |
| 333 | +Finally, if we want to get the result back as Elixir data structure, we can call `Adbc.Result.from_py/1`. |
| 334 | + |
| 335 | +```elixir |
| 336 | +{:ok, result} = Adbc.Result.from_py(summary) |
| 337 | +Adbc.Result.to_map(result) |
| 338 | +``` |
| 339 | + |
| 340 | +```elixir |
| 341 | +result |> Table.to_rows() |> Enum.to_list() |
| 342 | +``` |
| 343 | + |
| 344 | +## Distributed Python with FLAME |
| 345 | + |
| 346 | +[FLAME](https://hexdocs.pm/flame/FLAME.html) makes it easy to dynamically start additional machines and run code on them. Our Python integration is fully interoperable with FLAME, allowing you to run distributed Python code and keep references to Python objects across machines. |
| 347 | + |
| 348 | +Livebook comes with a **FLAME runner** smart cell for configuring and starting a runner pool, however for that to work, you need to run the given notebook using either Fly or Kubernetes runtime. You can click the runtime icon in the sidebar to explore further. |
| 349 | + |
| 350 | +The rest of this section will assume you have either a Fly or k8s runtime setup. Once that's done, running computation on another machine is as simple as: |
| 351 | + |
| 352 | +<!-- livebook:{"force_markdown":true} --> |
| 353 | + |
| 354 | +```elixir |
| 355 | +result = |
| 356 | + FLAME.call(:runner, fn -> |
| 357 | + 1 + 1 |
| 358 | + end) |
| 359 | +``` |
| 360 | + |
| 361 | +## Wrapping up |
| 362 | + |
| 363 | +As you can see, there are many levels of interoperability between Elixir and Python. In your notebooks, you can mix and match code from both languages. In turn, you get access to the extensive ecosystem of Python packages for enhancing your data workflows, while still being able to orchestrate it with Elixir. |
| 364 | + |
| 365 | +Enjoy! |
0 commit comments