47  Python

The grammar does not change when the language does. One engine reads one specification, so a sentence means the same thing no matter who wrote it. Everything in this book up to here is as true in Python as it is in R, and what differs is short enough to state in one chapter.

Every Python block with a plot under it was executed, through the same engine every other chapter uses.

47.1 A column is written col.gdp

R writes a column as a bare name, Julia as a symbol, Python through a small accessor:

gapminder_2007: first 5 of 142 rows
country continent year life population gdp
Afghanistan Asia 2007 43.828 31889923 974.5803
Albania Europe 2007 76.423 3600523 5937.0295
Algeria Africa 2007 72.301 33333216 6223.3675
Angola Africa 2007 42.731 12420476 4797.2313
Argentina Americas 2007 75.320 40301927 12779.3796
data(gapminder_2007) + point + x(col.gdp, scale='log') + y(col.life) + color(col.continent)
1K 10K 40 50 60 70 80 Life Gdp Continent Asia Europe Africa Americas Oceania

“Given gapminder 2007: points, x is gdp on a log scale, y is life, color by continent.”

The R form of that sentence is the one from A first plot, with col. in front of each column. Those four characters are not decoration, and the next paragraph says what they do. In R a bare gdp cannot be anything except a name; Python has no bare names, and in this grammar a plain string is how you spell a value: style(color="tomato"), title("…"), palette("okabe"). Without the accessor a column and a value would look identical, and the rule that a channel takes a column, never a value, would be invisible in Python.

So a string where a column belongs is refused, and told what to write:

data(gapminder_2007) + point + x('gdp') + y(col.life)
gog: `x("gdp")` binds a *value*, and a channel takes a *column*. Python has no bare names, so a column is written with the accessor: `x(col.gdp)` maps the column called `gdp`.

The message names the column spelling, x(col.gdp). On a channel you could also set, it names both, because one maps a column and earns a legend while the other sets one value and earns none (Setting vs mapping). The mistake in the other direction is refused the same way:

data(gapminder_2007) + point + x(col.gdp) + y(col.life) + style(color=col.continent)
gog: `style(color=)` fixes one value for the whole layer, and `col.continent` is a column. To *map* it — one value per category, with a legend to decode it — that is a channel: `color(col.continent)`.

A name Python cannot spell after a dot takes the bracket form instead: col["life exp"] for a space, col["gdp.2007"] for a dot. A non-Latin column name needs nothing special either: col.국가 works, and the bracket form covers the rest.

47.2 A sentence over several lines takes parentheses

R continues a line that ends in +. Python ends the statement there, so a sentence spanning several lines is wrapped in parentheses:

(data(gapminder_2007) + point + x(col.gdp, scale='log') + y(col.life)
 + color(col.continent) + size(col.population)
 + title('Wealth and life expectancy, 2007'))
1K 10K 40 50 60 70 80 Wealth and life expectancy, 2007 Life Gdp Continent Asia Europe Africa Americas Oceania Population 199.6K 659.4M 1.3B

“Given gapminder 2007: points, x is gdp on a log scale, y is life, color by continent, size by population.”

That is Python’s rule about lines rather than anything of gog’s. With the accessor, it covers almost everything you will type differently.

47.3 The table

A table is a dict of columns, so a first plot needs nothing installed:

data({'level': ['Low', 'Medium', 'High'], 'count': [30.0, 20.0, 10.0]},
     name='small') + bar + x(col.level) + y(col.count)
Low Medium High 0 10 20 30 Count Level

“Given the small table: bars, x is level, y is count.”

Anything carrying .columns and df[name] is also a table, which covers pandas and polars. Neither is imported, so neither is a dependency of the binding, and you read a file the way you already do. GAPMINDER_CSV here is the path to the book’s gapminder_2007.csv, which The book’s data says where to get:

import pandas as pd

gapminder_2007 = pd.read_csv(GAPMINDER_CSV)
print(gapminder_2007.shape, list(gapminder_2007.columns))
(142, 6) ['country', 'continent', 'year', 'life', 'population', 'gdp']

Every plot on this page reads that table. Types convert the way you would expect. Numbers become positions, text becomes categories, and datetime64, date and datetime all become a calendar axis. NaN and None are missing values: the engine leaves out the whole row and says how many it left out. A pandas Categorical carries its declared order across, which is the counterpart of R’s factor(x, levels = …). A plain dict of lists declares the same order with ordered(values, levels), which Data describes.

47.4 Computing a column first

A channel takes a column name and never an expression. This is true in all four languages, and pandas is where a Python reader meets it. There is no x(log(col.gdp)), and gog has no small language for deriving values, because Python is already one: compute the column, then bind it by name.

europe = gapminder_2007[gapminder_2007.continent == 'Europe'].copy()
europe['gdp_thousands'] = europe.gdp / 1000
europe = europe.sort_values('life', ascending=False)
print(europe[['country', 'gdp_thousands', 'life']].head(3).to_string(index=False))
country  gdp_thousands   life
    Iceland      36.180789 81.757
Switzerland      37.506419 81.701
      Spain      28.821064 80.941

That result is a table like any other, and the sentence over it is the sentence you already know:

(data(europe) + point + x(col.gdp_thousands) + y(col.life)
 + size(col.population) + style(color='steelblue')
 + x_label('GDP per capita (thousands)')
 + title('Europe, 2007'))
10 20 30 40 50 72 74 76 78 80 82 Europe, 2007 Life GDP per capita (thousands) Population 301.9K 41.4M 82.4M

The division stayed in pandas, where you can see it, and the plot names a column that exists. That split is deliberate rather than a missing feature. A plot is a mapping from a table (Data), so a channel names one of its columns, and anything you would rather compute is computed first.

47.5 Showing the plot

In a Jupyter notebook a plot displays itself, as it does in the R console. A script displays nothing on its own, so say where the plot goes:

plot = data(gapminder_2007) + point + x(col.gdp) + y(col.life)
print(plot)
print(plot.save('/tmp/life.svg'))
<gog plot: point on gapminder_2007>
/tmp/life.svg

Printing a plot gives one line naming its mark and its table, not the picture. save() writes the SVG, show() opens it in a browser, and render() returns it as a string. R’s print() opens the viewer without being asked; Python waits to be told, which is the convention each language already has.

47.6 Six names Python already uses

bin, map, max, min, range and sum are gog’s words here and builtins there, so from gog import * puts gog’s over Python’s for the rest of the file. The R package does the same to base R, with max, min, range and sum among them: a grammar keeps its own vocabulary. Calling five of the six says so, and names the fix. Only bin is silent: bin(5) returns a transform where Python would have returned a string.

try:
    range(10)
except GogError as error:
    print(error)
gog: `range()` takes the band's two ends, each one number between 0 and 1, e.g. `range(0.25, 0.75)`. For a sequence of integers, gog shadows that name: use `builtins.range` for Python's.

The worst of the six is map, because it is the only one whose error does not mention gog. Python reports the wrong number of arguments and names nothing you can act on.

A module that needs both can import gog and write gog.point, or take the builtin back with from builtins import sum.

47.7 What does not change

The four operators, including how tightly they bind. * groups before +, + before |, and / sits beside *. Those are Python’s own precedences and R’s alike, so a derived layer and a crossed facet group exactly as they do in Operators:

(data(gapminder_2007) + bar * mean + x(col.continent) + y(col.life)
 + order(col.life, desc=True)
 + title('Mean life expectancy, highest first'))
Oceania Europe Americas Asia Africa 0 20 40 60 80 Mean life expectancy, highest first Life Continent

“Given gapminder 2007: bars derived by mean, x is continent, y is life, ordered by life, largest first.”

The vocabulary does not change either: every mark, transform, channel, setting and plot-level atom in the kernel card exists in Python under the same name. Neither does an engine refusal, which reaches you word for word. A refusal the binding raises is the one that differs, because it teaches Python’s own spelling:

data(gapminder_2007) + bar + x(col.continent)
gog: `bar` needs `y()` but none is set. Add `y(<column>)` — a bar cannot be drawn without it.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

The distance between the two spellings is measured rather than argued. Every sentence in this book was translated by rule and run through the Python binding: 632 drew a byte-identical plot, 166 earned a word-identical refusal, and 19 differed only in the example inside the message, where each binding teaches its own syntax. Eleven sentences do not carry over at all. Nine are in R, about R-only spellings, and two are the GIF exports in Play. Two rewrites account for nearly all the rest: the accessor, 812 times, and the parentheses, 554.

47.8 Reading from a database

Python has a database standard, and query() speaks it. It also speaks Spark, because that is where a large share of Python’s data is stored.

The standard is DB-API, also called PEP 249. Anything with a .cursor() method works: the built-in sqlite3, DuckDB, psycopg, pyodbc, Snowflake’s connector, and the Databricks SQL connector.

import sqlite3
from gog import query, bar, x, y, col

con = sqlite3.connect("sales.db")
plot = query(con, "SELECT status, revenue FROM orders") + bar + x(col.status) + y(col.revenue)
plot.save("orders.svg")

A Spark session works too. Hand query() the session itself, and the table name is whatever your catalog calls it. This is the route to a Databricks table:

plot = query(spark, "SELECT status, revenue FROM main.sales.orders") + bar + x(col.status) + y(col.revenue)

In a Databricks notebook the session is already there, under the name spark. gog installs from PyPI with its engine included, so %pip install gog is the only setup step.

Neither route adds a dependency to gog. You bring the connection; gog reads rows from it.

47.9 Getting it

A plot is drawn by the engine, which is a compiled Rust binary, so the Python package has to be able to find one. The released wheel carries it: one wheel per platform, each holding the engine built for that platform, so installing needs no Rust toolchain.

pip install gog

That command works today. gog is on PyPI. Five wheels cover macOS and Linux on both common processor families, and Windows on Intel. The other three bindings install from their own registries, and the preface lists all four commands.

To work from a checkout instead, build the engine once and import the package from where it sits:

cargo build --release -p gog-cli
import sys
sys.path.insert(0, "py-pkg/gog")

from gog import *

The engine is looked for in four places, in this order: the GOG_CLI_PATH environment variable, the copy inside the installed package, gog-cli on your PATH, and a local target/release build. The first three are how an installed copy works. This page was rendered through the first: the book points GOG_CLI_PATH at a local build before any Python runs.