Metadata-Version: 2.4
Name: conduto
Version: 0.1.42
Summary: CLI to scaffold data migration/ELT projects with YAML schemas and a uv-managed environment.
Author: João Pedro Zanetti Gonçalves
License-Expression: MIT
Project-URL: Homepage, https://github.com/joaopedrozg/conduto
Project-URL: Repository, https://github.com/joaopedrozg/conduto
Project-URL: Issues, https://github.com/joaopedrozg/conduto/issues
Keywords: etl,elt,dagster,database,migration,schemas,cli
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Database
Classifier: Topic :: Software Development :: Code Generators
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: jinja2>=3.1.6
Requires-Dist: pyyaml>=6.0
Requires-Dist: rich>=15.0.0
Requires-Dist: textual>=8.2.0
Requires-Dist: typer>=0.27.1
Provides-Extra: postgresql
Requires-Dist: psycopg[binary]>=3.2; extra == "postgresql"
Provides-Extra: mysql
Requires-Dist: pymysql>=1.1; extra == "mysql"
Provides-Extra: sqlserver
Requires-Dist: pyodbc>=5.1; extra == "sqlserver"
Provides-Extra: clickhouse
Requires-Dist: clickhouse-connect>=1.7; extra == "clickhouse"
Provides-Extra: duckdb
Requires-Dist: duckdb>=1.0; extra == "duckdb"
Provides-Extra: deltalake
Requires-Dist: deltalake>=0.20; extra == "deltalake"
Requires-Dist: pyarrow>=15.0; extra == "deltalake"
Requires-Dist: boto3>=1.34; extra == "deltalake"
Provides-Extra: all
Requires-Dist: psycopg[binary]>=3.2; extra == "all"
Requires-Dist: pymysql>=1.1; extra == "all"
Requires-Dist: pyodbc>=5.1; extra == "all"
Requires-Dist: clickhouse-connect>=1.7; extra == "all"
Requires-Dist: duckdb>=1.0; extra == "all"
Requires-Dist: deltalake>=0.20; extra == "all"
Requires-Dist: pyarrow>=15.0; extra == "all"
Requires-Dist: boto3>=1.34; extra == "all"
Dynamic: license-file

# conduto

> **The pipe that carries your data from source to destination.**

🇺🇸 **English** · [Português (BR)](https://github.com/joaopedrozg/conduto/blob/main/README.md)

[![PyPI](https://img.shields.io/pypi/v/conduto?label=pypi)](https://pypi.org/project/conduto/)
![Python](https://img.shields.io/pypi/pyversions/conduto)
![License](https://img.shields.io/pypi/l/conduto)
![CI](https://github.com/joaopedrozg/conduto/actions/workflows/ci.yml/badge.svg)

`conduto` is a CLI that scaffolds a **complete ELT project in a single command**: credentials for both ends in the `.env`, YAML schemas for the tables, DDL in the target database and Dagster code (assets + schedules) ready to run.

**What it's for:** you have data in one database and need to keep it synchronized in another. Without conduto that means hand-writing the driver, credentials, cross-database type mapping, `CREATE TABLE` on the target, incremental reads by watermark, scheduling and orchestration. With conduto you answer a wizard and get a project that loads the data — incremental or full loads, on a schedule, plus an interface to trigger and monitor each table.

- **Source and target:** PostgreSQL, MySQL, SQL Server, ClickHouse, DuckDB and Delta Lake
- **Orchestration:** Dagster (independent assets, per-table schedules, web UI)
- **Install:** `pip install "conduto[postgresql]"` (drivers per database)
- **Code:** [github.com/joaopedrozg/conduto](https://github.com/joaopedrozg/conduto) · **Issues:** [open an issue](https://github.com/joaopedrozg/conduto/issues)

---

## Contents

1. [Installation](#installation)
2. [Quick start](#quick-start)
3. [The commands](#the-commands)
4. [The interactive wizard](#the-interactive-wizard)
5. [What gets generated](#what-gets-generated)
6. [How data is loaded](#how-data-is-loaded)
7. [Configuration reference](#configuration-reference)
8. [Supported databases](#supported-databases)
9. [Development](#development)

---

## Installation

The base package installs only the CLI (Typer, Rich, Textual, Jinja2 and PyYAML). Database drivers come as **extras per database** — install only what you use:

```bash
pip install "conduto[postgresql]"   # psycopg
pip install "conduto[mysql]"        # pymysql
pip install "conduto[sqlserver]"    # pyodbc
pip install "conduto[clickhouse]"   # clickhouse-connect
pip install "conduto[duckdb]"       # duckdb
pip install "conduto[deltalake]"    # deltalake, pyarrow and boto3

pip install "conduto[all]"          # every database
```

Same thing with `uv`:

```bash
uv tool install "conduto[all]"

# or run it without installing it for good
uvx --from "conduto[postgresql]" conduto init my_project
```

Requirements: **Python 3.10+** (the package declares support for 3.10–3.14) and [`uv`](https://docs.astral.sh/uv/) on your PATH — conduto uses it to set up the generated project's environment.

> Without the extra, the CLI still works fine (`conduto --help`, `ddl`, `schedules`, `docs`...). When you pick a database whose driver is missing, conduto asks whether to install it right away (via `uv` or `pip`) and prints the manual command if it fails. Projects generated by `conduto init` get the database driver in their own `uv add` — the extra is only for the CLI's environment.

### Linux: PEP 668 and the "environment required" error

Debian/Ubuntu, Fedora, Arch and Homebrew on macOS mark the system Python as *externally-managed* (PEP 668): `pip install` outside a venv is refused. This does **not** affect `uvx`, `uv tool` or `pipx` users — they already run in their own venv.

When it happens, conduto detects the marker before installing and shows both ways out:

```bash
uvx --from "conduto[postgresql]" conduto   # isolated venv, system untouched

pip install --break-system-packages psycopg[binary]   # only if you allow it
```

The prompt "Install on the system Python anyway?" defaults to **no** — nothing is broken without explicit consent.

---

## Quick start

```bash
# 1. Creates the project: connections in .env, schemas/ and main.yml
conduto init my_project

# 2. Generates the table DDL and applies it to the target database
conduto ddl --apply

# 3. Generates the schedules and the Dagster code
conduto schedules

# 4. Starts the Dagster server (http://localhost:3000)
conduto dagster

# 5. Web documentation of the project (http://localhost:8000)
conduto docs
```

Check the version and the help:

```bash
conduto --version
conduto --help
conduto [COMMAND] --help
```

### What `conduto init` asks

The flow is interactive and guided — you never type database or table names by hand:

1. **Source database** — default port and user change per database
2. **Source credentials** — host, port, user and password
3. **Connection test** — if it fails, retype them or continue anyway
4. **Databases on the source server** — conduto lists them and you pick one
5. **Target database and credentials** — same flow, plus picking the **target schema** (with an option to create a new database/schema)
6. **Schema mode** — generate automatically (you check the source schemas and tables) or configure manually (generates three examples for you to edit)
7. **Schedules** — whether to generate each table's schedule automatically (default: hourly) and the Dagster code
8. **Dagster server** — whether to start it now and generate the `run_dagster.ps1` / `run_dagster.sh` scripts

**Inside an existing uv project?** If the current directory already has a `pyproject.toml` (for example after `uv add conduto`), conduto adapts: it writes `.env`, `main.yml` and `schemas/` straight into the current project and adds only the missing dependencies — no subfolder, no `uv init`. In that case use `uv run conduto init` (the project name becomes optional).

### Generating schemas automatically

After both connections are tested, in **generate automatically** mode conduto:

- lists the source database schemas and opens a selection screen with a **search filter** — check any number with `space`, all visible ones with `a` (**Select all**) and clear with `l`;
- then shows the tables of the checked schemas using the same search/check screen — tables that already have `schemas/<table>.yml` show up in amber as "already exists" (regenerating overwrites);
- reads the columns from the database (types, PK, FK, unique, default, nullable) and writes `schemas/*.yml` and `main.yml`.

Your checks **are what defines the source schema of each table** — no other schema prompt shows up afterwards. In **manual** mode, conduto asks the source schema once and generates the `clientes`, `pedidos` and `produtos` examples.

### Adding a new table

Create the schema with just the name (or add the path to `main.yml`) and let conduto fill in the columns:

```yaml
# schemas/my_table.yml
table: my_table
```

```bash
conduto inferir                      # infers every schema without columns
conduto inferir --tabela my_table
conduto schedules                    # generates schedule and Dagster code for the new one
```

The command reads the source credentials from the `.env`, queries the database and writes the `columns:` while preserving whatever already exists (description, schedule etc.).

---

## The commands

| Command | What it does | Main options |
| --- | --- | --- |
| `conduto init [NAME]` | Creates/updates the project: `.env`, `schemas/`, `main.yml`, uv environment, schedules and Dagster code | — |
| `conduto ddl` | Converts the YAML schemas into `CREATE TABLE` for the target | `--apply` / `--no-apply`, `--output file.sql`, `--dir` |
| `conduto schedules` | (Re)generates the schema schedules and the Dagster code | `--dir` |
| `conduto dagster` | Starts the project's Dagster server (http://localhost:3000) | `--dir` |
| `conduto docs` | Starts the project's web documentation (http://localhost:8000) | `--port`, `--host`, `--no-open`, `--dir` |
| `conduto inferir` | Infers the columns of the tables in the source database | `--tabela`, `--dir` |
| `conduto install-sqlserver-driver` | Downloads and installs the ODBC Driver for SQL Server (Windows, Linux, macOS) | — |
| `conduto --help` | General help; `conduto [COMMAND] --help` shows each command's options | — |

Global flags: `--lang pt|en` (language for the run) and `--version`.

> The command names stay in Portuguese (`inferir`, `ddl`, `schedules`...) — only the messages, prompts and help texts are translated.

### Usage examples

```bash
# only write the DDL to a file, never touching the database
conduto ddl --no-apply --output ddl.sql

# apply straight away, without prompting (handy for scripts/CI)
conduto ddl --apply

# work in another directory
conduto schedules --dir path/to/project
conduto docs --port 9000 --no-open
```

### Web documentation

```bash
conduto docs                  # opens http://localhost:8000
conduto docs --port 9000      # specific port
conduto docs --no-open        # don't open the browser
```

The page shows the project overview, the file tree, the `.env` connections (passwords masked), the per-database particularities, the tables with their schemas, the schedules, the generated DDL, the environment and the list of commands.

---

## The interactive wizard

In the terminal, `conduto init` and `conduto ddl` run inside a **TUI screen** (Textual):

- **Side menu of steps** with the state of each one: current (● blue), done (✓ green), skipped (— gray) or pending (○);
- **Review**: clicking a completed step shows what was answered there, without rerunning the flow;
- **Log panel** with what the command is doing, without flashing old logs on every step change;
- **Logs in SQLite**: all output is kept in `~/.conduto/registros.db` and opens with **`F3`**, with timestamp, step and scrolling — the output is also replayed in the terminal when the shell closes;
- **Footer shortcuts**: `F2` toggles the step menu, `F3` opens the logs, `esc` goes back;
- **Colors are status**: green = success, amber = attention, red = error, blue = information, gray = neutral;
- Loading widgets (spinner and progress bar) on slow operations such as the connection test and applying the DDL.

Without an interactive terminal (CI, pipe) or with the `CONDUTO_SEM_TUI` variable set, everything falls back to the classic numbered prompts in the terminal — same behaviour, no screen.

> Logs live at `~/.conduto/registros.db`; point `CONDUTO_REGISTROS` somewhere else to change that.

---

## What gets generated

```text
my_project/
├── .env                        # source and target credentials
├── main.yml                    # manifest: table list + overall schedule
├── schemas/                    # one YAML per table
│   ├── clientes.yml
│   ├── pedidos.yml
│   └── produtos.yml
├── definitions.py              # entry point for `dagster dev`
├── conduto_dagster/            # generated Dagster code
│   ├── __init__.py
│   ├── etl.py                  # connection, read and load of the tables
│   └── definitions.py          # assets and schedules built from the YAMLs
├── run_dagster.ps1             # Windows
├── run_dagster.sh              # Linux/macOS
└── pyproject.toml              # uv environment (pyyaml, jinja2, dagster, dagster-webserver)
```

> Outside a uv project, this structure is created inside `my_project/`. Inside an existing uv project, the files go to the current directory. The project is created **without an `src/` folder** (`uv init --bare`) — scripts and Dagster code live at the root.

### `.env` — credentials

```bash
DB_ORIGEM_TYPE=postgresql
DB_ORIGEM_HOST=localhost
DB_ORIGEM_PORT=5432
DB_ORIGEM_NAME=postgres
DB_ORIGEM_SCHEMA=public
DB_ORIGEM_USER=postgres
DB_ORIGEM_PASSWORD=postgres

DB_DESTINO_TYPE=postgresql
DB_DESTINO_HOST=localhost
DB_DESTINO_PORT=5432
DB_DESTINO_NAME=postgres
DB_DESTINO_SCHEMA=public
DB_DESTINO_USER=postgres
DB_DESTINO_PASSWORD=postgres

# Optional: rows per load batch (default 20000)
# CONDUTO_LOTE=50000

# Optional (PostgreSQL): load statement_timeout in ms (0 = no limit)
# DB_DESTINO_STATEMENT_TIMEOUT=0
```

Variable names stay in Portuguese: `DB_ORIGEM_*` holds the source and `DB_DESTINO_*` the target.

`DB_ORIGEM_SCHEMA` is the **default** source schema — the ETL only uses it when the table has no `source_schema` of its own in the YAML.

> **Important:** the `.env` holds credentials and must not be committed.

### `main.yml` — manifest

```yaml
version: "1.0"
project: my_project

# Schedule of the whole model (every table, in manifest order)
schedule:
  cron: "0 * * * *"

tables:
  - path: "schemas/clientes.yml"
  - path: "schemas/pedidos.yml"
  - path: "schemas/produtos.yml"
```

### `schemas/*.yml` — tables

```yaml
table: clientes
schema: public              # schema in the TARGET (what conduto ddl uses)
source_schema: public       # schema in the SOURCE (where the ETL reads from)
description: "Customer registry table"
schedule:
  cron: "0 * * * *"
  mode: incremental         # incremental or full
  incremental_column: criado_em
  full_load: false          # true forces a full load on the next run
  truncate: false           # true wipes the target table before loading
columns:
  - name: id
    type: integer
    primary_key: true
    nullable: false
  - name: email
    type: varchar(255)
    unique: true
  - name: criado_em
    type: timestamp
    default: CURRENT_TIMESTAMP
```

Two schema keys with different jobs:

| Key | Which schema it is |
| --- | --- |
| `schema` | the **target** — what `conduto ddl` uses in the `CREATE TABLE` |
| `source_schema` | the **source** — where the ETL reads the table from at load time |

The source one is stored per table because a table can live in one schema and another in a different one (`Person`, `HumanResources`, `dbo`...). Without it the ETL would read everything from the single `DB_ORIGEM_SCHEMA` in the `.env` and fail with `Invalid object name`. In older projects without the key, `DB_ORIGEM_SCHEMA` still applies as a fallback.

The three examples cover common modelling patterns:

| Schema | What it shows |
| --- | --- |
| `clientes.yml` | primary key, `unique` column and `default` |
| `pedidos.yml` | foreign key `foreign_key: clientes(id)` — documentation only |
| `produtos.yml` | `numeric` and `boolean` types, optional columns (`nullable: true`) |

#### No dependencies between tables

`foreign_key` is **model documentation only** — nothing in the project turns it into a constraint:

- `conduto ddl` never emits a `FOREIGN KEY` in the `CREATE TABLE`, on any target;
- the Dagster code never creates `deps` between assets;
- `main.yml` is never reordered by FK topology.

So if `pedidos` references `clientes`, you can still load `pedidos` on its own, without `clientes` existing or having run first. Referential integrity stays with whoever writes to the source.

#### Custom types

Types that don't exist in every database — `ltree`, `citext`, `hstore`, `tsvector`, `inet`, `geometry`, `interval`, `hierarchyid`, `year`, PostgreSQL arrays etc. — follow a rule per target: the type is kept where it exists natively (PostgreSQL, plus `geometry`/`hierarchyid` in SQL Server) and degrades to text the target accepts in the other cases (`text`/`nvarchar(max)`/`String`/`varchar`/`string`). That way the `CREATE TABLE` never fails because of a type.

When the rule isn't the one you want, declare the equivalent on the column with `types:` — it beats the rule table:

```yaml
columns:
  - name: localizacao
    type: geometry            # default rule per target
    types:                    # override: only for the listed targets
      mysql: point
      deltalake: binary
```

If a type has no rule at all it passes through as-is and conduto warns you — a sign that a `types:` declaration is worth it.

---

## How data is loaded

The generated Dagster code (`conduto_dagster/`) reads `main.yml` and the `schemas/*.yml` **at runtime**: changing a schema's `schedule` key changes the asset/schedule on the next reload, with nothing to regenerate.

Each table is an **independent asset**, with the mode defined in its own YAML:

| Mode | Behaviour |
| --- | --- |
| `incremental` | reads only what is greater than the watermark (last value of `incremental_column` on the target); with no watermark, loads everything |
| `full` | wipes the table's target and reloads it entirely |
| `truncate: true` | wipes the target before loading, whatever mode is in effect |
| `full_load: true` | forces a full load on the next run even in incremental mode — set it back to `false` afterwards |

How loading happens per target:

- **PostgreSQL**: `COPY` — direct streaming `COPY (SELECT ...) TO STDOUT` → `COPY ... FROM STDIN` when source and target are both PostgreSQL (Python only forwards bytes), and `COPY FROM STDIN` in pre-serialized blocks from other sources. On an already populated table with a PK, the target gets an **upsert** on the primary key so incremental loads don't raise `UniqueViolation`.
- **ClickHouse**: native batched `INSERT`.
- **DuckDB**: rows via Arrow + `INSERT SELECT` (DuckDB's `executemany` is slow, one row at a time).
- **Delta Lake**: `write_deltalake`.
- **SQL Server / MySQL and the rest**: batched `INSERT`, with `fast_executemany` (bulk operation) on SQL Server and an automatic fallback if the driver refuses.

The source is always read in **batches** (`fetchmany`) with a producer/consumer thread pair — memory stays stable even on huge tables.

### Running it

```bash
# in the browser (http://localhost:3000): select the assets and click "Materialize"
uv run dagster dev

# or through the generated scripts
.\run_dagster.ps1      # Windows
./run_dagster.sh       # Linux/macOS

# or straight from conduto
conduto dagster
conduto dagster --dir path/to/project
```

`dagster dev` requires the `dagster-webserver` package; conduto installs it along with the other dependencies and, if it's missing in an existing project, installs it before starting the server (`uv add dagster-webserver`). While the server boots, conduto shows an animated status and tells you when it's up. If the `conduto_dagster/` code doesn't exist yet it's generated on the spot and the `[tool.dagster]` block is added to `pyproject.toml` (recent Dagster versions require it).

### Load performance

- **`CONDUTO_LOTE`** in the `.env`: rows per copy batch (default `20000`). On heavy loads `50000` usually pays off.
- **`DB_DESTINO_STATEMENT_TIMEOUT`** (PostgreSQL, in ms; `0` = no limit): avoids hitting the server's `statement_timeout` during `COPY`. Use the **session pooler** (port `5432`) or a direct connection — the transaction pooler (`6543`) doesn't allow session `SET`.
- For big loads, drop non-essential indexes/constraints from the target table before a full load and recreate them afterwards — `COPY` gets much faster without them.

---

## Configuration reference

### Environment variables

| Variable | Where | Effect |
| --- | --- | --- |
| `CONDUTO_LANG` | shell | forces the CLI language: `pt` or `en` (e.g. `CONDUTO_LANG=en conduto init`) |
| `CONDUTO_SEM_TUI` | shell | turns the TUI screen off and uses the classic prompts (useful in CI/pipes) |
| `CONDUTO_REGISTROS` | shell | alternative path for the wizard's SQLite log file (default `~/.conduto/registros.db`) |
| `CONDUTO_LOTE` | project `.env` | rows per load batch (default `20000`) |
| `DB_DESTINO_STATEMENT_TIMEOUT` | project `.env` | `SET statement_timeout` on the PostgreSQL target, in ms (`0` = no limit) |

### Language

conduto detects the machine's language (Portuguese by default, with English support) and uses that preference for messages, prompts, help and labels. Detection order:

1. `CONDUTO_LANG`
2. Locale variables (`LANG`, `LC_ALL`, `LC_MESSAGES`) and Python's locale
3. Windows display language

To force it for a single run: `conduto --lang en init my_project`.

> `--lang` applies to the command's flow; `--help` follows automatic detection and can be forced with `CONDUTO_LANG=en conduto --help`.

### The `schedule` key (per YAML schema)

| Key | Values | Default |
| --- | --- | --- |
| `cron` | cron expression for the schedule | `0 * * * *` (hourly) |
| `mode` | `incremental` or `full` | `full` |
| `incremental_column` | column used as the watermark | inferred (`updated_at`, `created_at` or any other temporal) |
| `full_load` | `true` forces a full load on the next run | `false` |
| `truncate` | `true` wipes the target table before loading | `false` |

`conduto schedules` infers the incremental update column (preferring `updated_at`/`atualizado_em`, then `created_at`/`criado_em` and finally any temporal column), writes the key into the schemas and keeps the overall model schedule in `main.yml`. Values you edited by hand are **preserved** on regeneration — only missing keys get the default.

---

## Supported databases

| Database | Extra | Default port | Default user | Default database |
| --- | --- | --- | --- | --- |
| PostgreSQL | `conduto[postgresql]` | 5432 | `postgres` | `postgres` |
| MySQL | `conduto[mysql]` | 3306 | `root` | `mysql` |
| SQL Server | `conduto[sqlserver]` | 1433 | `sa` | `master` |
| ClickHouse | `conduto[clickhouse]` | 8123 | `default` | `default` |
| DuckDB | `conduto[duckdb]` | — | — | `origem.duckdb` (local file) |
| Delta Lake | `conduto[deltalake]` | — | `minioadmin` | `deltalake` (S3/MinIO storage) |

Particularities applied automatically in the CLI flow and in the DDL:

- **ClickHouse**: MergeTree `ENGINE`/`ORDER BY` and no constraints;
- **Delta Lake**: no constraints in the `CREATE TABLE`;
- **MySQL**: database == schema;
- **SQL Server**: automatic download and install of the ODBC driver.

### SQL Server ODBC driver

`pyodbc` needs the native driver on the system. If the connection fails because of it, `conduto init` offers **Install driver automatically** — the credentials you typed stay in memory only and the connection test reruns by itself afterwards. You can also install it directly:

```bash
conduto install-sqlserver-driver
```

Works on Windows (winget or MSI), Linux (apt) and macOS (Homebrew). On Windows, if the terminal isn't elevated, conduto brings up the UAC confirmation in front; with a pending reboot, the install is blocked until you restart. There's also a standalone script for manual/offline installs:

```powershell
.\scripts\install-sqlserver-odbc.ps1 -DownloadOnly -OutFile .\msodbcsql18.msi   # download only
.\scripts\install-sqlserver-odbc.ps1                                            # download and install
```

Supported versions: 18 (default) and 17 (`-Version 17`). Official documentation: [Download ODBC Driver for SQL Server](https://learn.microsoft.com/sql/connect/odbc/download-odbc-driver-for-sql-server).

---

## Development

> This README also exists in Portuguese, in [`README.md`](https://github.com/joaopedrozg/conduto/blob/main/README.md) — an absolute link on purpose, because this is the file PyPI renders (`readme = "README.en.md"` in `pyproject.toml`), and relative links would break there. When you edit one, update the other; CI fails if the two drift apart.

```bash
uv sync --all-extras   # project + drivers for every database + dev (pytest)
uv run pytest -q       # tests
uv run python scripts/check_readme_sync.py   # checks both READMEs
uv build               # package it
```

CI (`.github/workflows/ci.yml`) runs on every push/PR: installs with `uv sync --all-extras`, smoke-tests the CLI, runs the tests and builds the package.

### Publishing a new version

Every merge/push to `main` triggers the **Publish to PyPI** workflow, which computes the version from the last one published on PyPI (`patch` bump; if the PR already bumped `pyproject.toml` beyond the published one, that version is used), publishes and creates the `vX.Y.Z` tag. The bump is applied only in the workflow's working tree — it never lands on `main`.

For a manual release (`patch`, `minor` or `major`): **Actions → Publish to PyPI → Run workflow** and pick the bump type.

---

## License

[MIT](LICENSE)
