Slide of the tiger
Slide the power of Python (and Jinja2) into Google Slides
sliger is a small CLI and Python library that talks to the Google Slides and Drive APIs so you can:
- duplicate a presentation or an individual slide
- delete a slide by index
- render Jinja2 templates that live in text boxes (
jinjify) - replace
placeholders with real images (imagify)
The CLI, as a uv tool:
uv tool install sligerAs a library in another project:
uv add sligerFrom a checkout:
uv syncRequires Python 3.10+ and uv. To publish a tagged release, see Releasing.
Enable the Google Slides API and Google Drive API on a GCP project, then authenticate in one of two ways:
Service account — a JSON key. Share the presentation with the client_email in that file.
sliger --creds-file service-account.json --presentation-id PRESENTATION_ID jinjifyYour Google user (OAuth) — a Desktop app OAuth client (GCP → APIs & Services → Credentials → OAuth client ID → Desktop app). A Web application client will not work: the CLI listens on a random http://localhost port, which only Desktop clients allow. Put the OAuth consent screen in Testing and add your Google account as a test user. The first run opens a browser; the token is cached at ~/.config/sliger/token.json.
sliger --client-secrets client_secret.json --presentation-id PRESENTATION_ID jinjifyUser OAuth requests the Slides scope plus drive.file (files this app creates). Copying an existing presentation needs broader Drive access:
sliger --client-secrets client_secret.json --full-drive \
--presentation-id PRESENTATION_ID duplicate-presentation --copy-title "Copy"--creds-file also accepts client secrets or a saved token. Environment variables: SLIGER_CREDS_FILE, SLIGER_CLIENT_SECRETS, SLIGER_TOKEN_FILE, SLIGER_PRESENTATION_ID, SLIGER_CONFIG_PATH, SLIGER_FULL_DRIVE.
Every command needs credentials and a presentation ID. If a presentation lives at
https://docs.google.com/presentation/d/1ijjVtlf9Jq1Rr0xTOMZWcSAUbfl6oA1aaBickwpUdGQ/edit
the presentation ID is 1ijjVtlf9Jq1Rr0xTOMZWcSAUbfl6oA1aaBickwpUdGQ.
Turn off Tools → Preferences → Use smart quotes in Google Slides so apostrophes in templates stay as plain ' / " characters Jinja can parse. See the community thread.
sliger --creds-file creds.json --presentation-id PRESENTATION_ID \
duplicate-presentation --copy-title "A new presentation"Prints the new presentation ID on stdout. Sharing is off by default (the service account already owns the copy). Opt in only if you need a public link:
sliger --creds-file creds.json --presentation-id PRESENTATION_ID \
duplicate-presentation --copy-title "A new presentation" --anyone-can-view--anyone-can-edit still exists and is intentionally opt-in: it makes the copy writable by anyone with the link.
Slide numbers are 1-based.
sliger --creds-file creds.json --presentation-id PRESENTATION_ID delete-slide --id 3
sliger --creds-file creds.json --presentation-id PRESENTATION_ID duplicate-slide --id 3--slide-number is an alias of --id.
Renders Jinja in every text box. Built-in context:
| Name | Meaning |
|---|---|
now |
time.localtime() at render time |
strftime |
time.strftime plus %O for an ordinal day (1st, 2nd, …) |
Hi! Today is {{ strftime("%A, %O %B", now) }}
renders to Hi! Today is Friday, 2nd September.
Pass extra variables as JSON (or a Python dict literal):
sliger --creds-file creds.json --presentation-id PRESENTATION_ID \
jinjify --data '{"company_name": "Slido"}'--dry-run prints each original → rendered change and does not call the Slides API.
sliger --creds-file creds.json --presentation-id PRESENTATION_ID \
jinjify --data '{"company_name": "Slido"}' --dry-runOr --data-file vars.json. Custom Python functions come from a TOML config:
[function_map]
greet_pycon = "custom_functions.greet_pycon"sliger --creds-file creds.json --presentation-id PRESENTATION_ID \
--config-path config.toml jinjifyEach value must be a module.function dotted path. The directory that contains the TOML file is added to sys.path, so a sibling custom_functions.py works. Those functions run with your process privileges — only load config you trust.
{{ greet_pycon() }}
Jinja on a shape is stored in that shape's alt-text (sliger:…), so you can re-jinjify the same deck when the warehouse moves. Functions may return a string, a table (list[dict] / TableResult), an image (ImageResult / path / bytes), or sliger_repeat("items") to duplicate the slide per row.
Built-in sql() and named connections. sqlite ships in core; warehouses are extras (pip install 'sliger[bigquery]', 'sliger[snowflake]', 'sliger[databricks]', or 'sliger[warehouses]' for all three). Every connector uses the same :named parameters and the same TableResult path:
[connections.db]
type = "sqlite"
url = "file:./metrics.db"
[connections.bq]
type = "bigquery"
project = "env:GCP_PROJECT"
dataset = "metrics"
[connections.snow]
type = "snowflake"
account = "env:SNOWFLAKE_ACCOUNT"
user = "env:SNOWFLAKE_USER"
password = "env:SNOWFLAKE_PASSWORD"
warehouse = "COMPUTE_WH"
database = "ANALYTICS"
schema = "PUBLIC"
[connections.dbx]
type = "databricks"
host = "env:DATABRICKS_HOST"
http_path = "env:DATABRICKS_HTTP_PATH"
token = "env:DATABRICKS_TOKEN"
[function_map]
events_count = "custom_functions.events_count"{{ sql("select sum(n) as events from metrics where account = :account_uuid") }}
{{ sliger_repeat("deals") }}
{{ item.name }}
URLs work too: bigquery://PROJECT/DATASET, snowflake://USER:PASSWORD@ACCOUNT/DB/SCHEMA?warehouse=WH, databricks://token:TOKEN@HOST/sql/1.0/warehouses/ID. sql() and mapped functions pull missing arguments from --data. One bad box becomes [sliger error: …] and does not abort the rest of the deck. A missing extra fails with pip install 'sliger[snowflake]' (or the matching extra).
To add a warehouse: implement sliger.connectors.base.Connector, list it in sliger.connectors.registry.BUILTIN, and (if the SDK is heavy) add a matching [project.optional-dependencies] extra. Third-party packages can expose a sliger.connectors entry point or call sliger.connectors.register().
sliger --creds-file creds.json --presentation-id TEMPLATE_ID --config-path config.toml \
render --copy-title "Acme Q3" --data-file acme.json
sliger --creds-file creds.json --presentation-id TEMPLATE_ID inspect --data-file acme.json
sliger --creds-file creds.json --config-path config.toml \
repl "{{ sql('select 1 as n') }}"render copies the template, expands repeaters, jinjifies, imagifies, and writes a provenance line into speaker notes.
Looks for a text box whose entire content is:

The inside is Jinja-rendered first. A local path is uploaded to Drive and inserted at the text box's size/position; an http(s):// URL is used directly.
 }})
sliger --creds-file creds.json --presentation-id PRESENTATION_ID \
--config-path config.toml imagify --data '{"account_uuid": "abc"}'--dry-run lists placeholders and resolved paths without uploading.
Local files are uploaded only long enough for Slides to fetch them, then deleted from Drive. During that window they are link-readable — do not put secrets in those files. HTTP(S) URLs skip Drive entirely.
from sliger import Sliger
client = Sliger("creds.json", "TEMPLATE_ID", config_path="config.toml")
report = client.inspect({"company_name": "Example"})
result = client.render("Example Q3", {"company_name": "Example"})
print(result.url)demo/ is a Streamlit UI that used this library at PyCon Italy 2023. It is not installed with the package.
uv sync --group demo
uv run streamlit run demo/automater_frontend.pyuv sync
uv run ruff check sliger tests
uv run ruff format sliger tests
uv run pytestuv.lock is the source of truth for dependency versions; CI installs with uv sync --locked. Optional: uv run pre-commit install to run Ruff on each commit.
gcloud auth login authenticates the gcloud CLI only. The Python client uses
Application Default Credentials
from gcloud auth application-default login, and that token must include Slides
and Drive scopes (the default ADC login does not).
gcloud auth application-default login \
--scopes=https://www.googleapis.com/auth/presentations,https://www.googleapis.com/auth/drive
gcloud auth application-default set-quota-project YOUR_PROJECT_ID
gcloud services enable slides.googleapis.com drive.googleapis.com --project YOUR_PROJECT_IDThen, against a throwaway deck (the live test creates and deletes one). From the repo root, if .secrets/user-token.json exists you only need:
export GOOGLE_CLOUD_QUOTA_PROJECT=nnewtonians-questionario1-prd
SLIGER_LIVE=1 uv run pytest -m live -sOr pass an explicit token path (must be a real file, not a ... placeholder):
export SLIGER_CREDS_FILE="$PWD/.secrets/user-token.json"
export GOOGLE_CLOUD_QUOTA_PROJECT=nnewtonians-questionario1-prd
SLIGER_LIVE=1 uv run pytest -m live -sSLIGER_LIVE=1 also runs live BigQuery and Snowflake SQL tests when those credentials are in the environment. Warehouse-only (no Slides):
uv sync --extra bigquery --extra snowflake
export GOOGLE_CLOUD_PROJECT=infra-foundations
export GOOGLE_CLOUD_QUOTA_PROJECT=infra-foundations
export SNOWFLAKE_ACCOUNT=...
export SNOWFLAKE_USER=...
export SNOWFLAKE_TOKEN=... # or SNOWFLAKE_PASSWORD
SLIGER_LIVE=1 uv run pytest tests/test_bigquery.py tests/test_snowflake.py -m live -sGitHub Actions workflow live.yml does the same on main when the repository variable LIVE_WAREHOUSES=true and these secrets exist: GCP_SA_JSON, GOOGLE_CLOUD_PROJECT, SNOWFLAKE_ACCOUNT, SNOWFLAKE_USER, SNOWFLAKE_TOKEN.
- Register this GitHub repository as a PyPI Trusted Publisher for project
sliger, workflowrelease.yml, environmentpypi. - Tag a version that matches
pyproject.tomlandCHANGELOG.md:
git tag v0.2.0
git push origin v0.2.0The release workflow builds the wheel, runs tests/smoke_test.py against it, and publishes with uv publish.
- Keep service-account JSON keys and OAuth client secrets out of git. The
.gitignorealready ignores*service_account*.json. - Duplicated presentations are not world-writable. That used to be the default and is now
--anyone-can-edit. imagifystill has to make a local upload briefly link-readable so Slides can fetch it; the Drive file is deleted immediately after insert.
