Metadata-Version: 2.4
Name: grist-backup
Version: 1.0.0
Summary: CLI tool for backing up Grist documents as SQLite files
Project-URL: Homepage, https://github.com/lmzr/grist-backup
Author: lmzr
License-Expression: GPL-3.0-or-later
License-File: LICENSE
Keywords: backup,cli,grist,sqlite
Classifier: Development Status :: 5 - Production/Stable
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: System Administrators
Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
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 :: System :: Archiving :: Backup
Requires-Python: >=3.11
Description-Content-Type: text/markdown

# grist-backup

CLI tool to back up [Grist](https://www.getgrist.com/) documents as their
native SQLite (`.grist`) files — equivalent to the **Download → Document
(with history)** action in the Grist web UI.

Written in pure Python standard library: **zero runtime dependencies**.

## Features

- Downloads the original SQLite `.grist` file (full edit history by default)
- Multiple accounts in a single config file (e.g. work + personal)
- Explicit doc list **or** auto-discovery via the API (optionally scoped by
  workspace name)
- Auto-discovers document name, workspace and team via the Grist API
- Configurable subdirectory layout by `account` / `team` / `workspace`
- `xz` (LZMA) compression by default — `.grist` files compress extremely well
- Streaming download + on-the-fly compression (no double disk write)
- Atomic writes via temp file + rename
- Clear exit codes for scripting / cron jobs
- All errors logged to stderr

## Installation

Requires Python 3.11+.

```bash
pip install grist-backup
```

### From source (development)

```bash
git clone https://github.com/lmzr/grist-backup.git
cd grist-backup
uv sync
uv run grist-backup --help
```

## Configuration

Create a TOML config file in one of (searched in order):

1. `./grist-backup.toml` (current directory)
2. `~/.config/grist-backup/config.toml`
3. `~/.grist-backup.toml`

Or override with `--config /path/to/file.toml`.

Generate an example config file with:

```bash
grist-backup --example-config > ~/.config/grist-backup/config.toml
```

**Example:**

```toml
backup_dir = "~/grist_backups"
subdirs = ["team"]     # optional — default: ["team"]

[accounts.perso]
api_key = "your-api-key-here"
server  = "https://docs.getgrist.com"

# Only `id` is required. Name is auto-fetched from the Grist API.
[[accounts.perso.docs]]
id = "abc123DEF456"

# Override the fetched name explicitly:
[[accounts.perso.docs]]
id   = "xyz789UVW012"
name = "budget_override"

[accounts.work]
api_key = "another-api-key"
server  = "https://grist.mycompany.com"

[[accounts.work.docs]]
id = "mno345PQR678"
```

Lock the file down — it contains API keys:

```bash
chmod 600 grist-backup.toml
```

### Self-hosted Grist instances

The tool works with self-hosted Grist (grist-core). All API endpoints used
are part of the core project. A few things to keep in mind:

**`server` must point to the root URL** — not to an org-specific path:

```toml
# Correct:
server = "https://grist.example.com"

# Wrong (will break /api/orgs discovery):
server = "https://grist.example.com/o/myorg"
```

On self-hosted multi-org instances (`GRIST_SINGLE_ORG` unset), Grist uses
URL path prefixes (`/o/orgname/`) to distinguish teams. The `/api/orgs`
endpoint lives at the root level and won't respond correctly if the server
URL already includes an org path.

On single-org instances (`GRIST_SINGLE_ORG=myorg`), the root URL is the
only option — no special handling needed.

### Metadata auto-discovery

For each document, grist-backup calls `GET /api/docs/{docId}` to retrieve its
name, workspace and team — unless the name is already set in the config
**and** `subdirs` doesn't reference `team` or `workspace` (no API call needed
in that case).

If the metadata call fails (no network, bad key, deleted doc…), the tool
falls back silently: doc name defaults to the doc id, and any missing
`team` / `workspace` subdirectory level is dropped.

### Subdirectory layout (`subdirs`)

Controls how backups are organized under `backup_dir`:

| Value | Result |
|-------|--------|
| `[]` | Flat — all files directly in `backup_dir` |
| `["team"]` *(default)* | One subdir per team |
| `["account"]` | One subdir per account config name |
| `["account", "team"]` | Nested: account → team |
| `["team", "workspace"]` | Nested: team → workspace |
| `["account", "team", "workspace"]` | Full nesting |

`team` and `workspace` come from the Grist API; `account` is the name you
defined in the config (`[accounts.<name>]`).

### Auto-discovery of documents

Instead of listing each doc in the config, you can let the tool fetch the
list from the Grist API. The selection is **hierarchical**: everything, or
one or more teams (in full), or specific workspaces within a team.

```toml
# Everything
[accounts.everything]
api_key  = "..."
server   = "https://docs.getgrist.com"
discover = true

# Scoped — array of entries, each qualifies a team
[accounts.scoped]
api_key  = "..."
server   = "https://mycompany.getgrist.com"
discover = [
  { team = "MyCompany" },                                        # whole team
  { team = "PartnerOrg", workspaces = ["Finance", "Product"] },  # scoped
]

# Or equivalent array-of-tables (better for many entries):
[accounts.scoped2]
api_key = "..."
server  = "https://mycompany.getgrist.com"

[[accounts.scoped2.discover]]
team = "MyCompany"

[[accounts.scoped2.discover]]
team = "PartnerOrg"
workspaces = ["Finance", "Product"]
```

If the same team is listed twice, once as "whole team" and once with
workspaces, the whole-team entry wins and the scoped ones are dropped
(logged at info level).

CLI flag `--discover` forces discovery at runtime (scope = everything),
taking precedence over any `discover` / `docs` setting:

```bash
grist-backup -a perso --discover
grist-backup --all --discover       # back up everything for every account
```

Precedence per account:

| config `discover` | config `docs` | CLI `--discover` | Used |
|-------------------|---------------|------------------|------|
| absent / `false` | non-empty | — | explicit docs |
| absent / `false` | empty | absent | **error** (exit 2) |
| `true` / list | — | — | discovery (with warning if docs also present) |
| — | — | present | discovery (scope = everything) |

Discovery enumerates teams via `GET /api/orgs`, then workspaces+docs via
`GET /api/orgs/{orgId}/workspaces`. Because those responses already contain
team and workspace names, per-doc metadata fetches are skipped during the
download phase.

If the discovery API call fails (network, auth…), the account is treated
as failed: exit code 2 with `-a`, or skipped with a warning under `--all`.

### Getting an API key

In Grist: click your profile avatar (top right) → **Profile Settings** →
**API** section → **Create**.

API keys inherit the user's permissions.

## Usage

```text
grist-backup (-a ACCOUNT | --all) [-d DOC_ID] [-o OUTPUT] [-c CONFIG]
             [--no-history] [--no-compress] [--discover]
```

Exactly one of `-a` or `--all` is required.

| Flag | Description |
|------|-------------|
| `-a, --account` | Account name in config |
| `--all` | Back up every document of every account in config |
| `-d, --doc` | Document ID or config doc name (requires `-a`, overrides config `docs` list) |
| `-o, --output` | Output file or directory (overrides `backup_dir`) |
| `-c, --config` | Config file path (overrides default search) |
| `--no-history` | Exclude edit history from the backup |
| `--no-compress` | Disable xz/lzma compression |
| `--discover` | Fetch the doc list from the API (overrides config) |
| `--example-config` | Print an example config file to stdout and exit |
| `-V, --version` | Show version and exit |

**Default filename:** `{doc_name}_{YYYY-MM-DD}_{HHMMSS}.grist.xz`
(without compression: `.grist`).

The timestamp format can be customized in the config file with `timestamp_format`
([strftime syntax](https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes)):

```toml
timestamp_format = "%Y%m%d"   # → budget_20260420.grist.xz
```

Example tree with default `subdirs = ["team"]` and `--all` across two
accounts on different teams:
```
~/grist_backups/
├── MyCompany/
│   ├── budget_2026-04-16_081948.grist.xz
│   └── roadmap_2026-04-16_081948.grist.xz
└── Personal/
    └── notes_2026-04-16_081948.grist.xz
```

With `subdirs = ["account", "team"]`:
```
~/grist_backups/
├── perso/Personal/notes_2026-04-16_081948.grist.xz
└── work/MyCompany/
    ├── budget_2026-04-16_081948.grist.xz
    └── roadmap_2026-04-16_081948.grist.xz
```

### Examples

Back up all documents for one account:

```bash
grist-backup -a perso
```

Back up every document of every account (ideal for cron):

```bash
grist-backup --all
```

Back up one specific document by ID, saved to a custom directory:

```bash
grist-backup -a work -d mno345PQR678 -o /mnt/nas/grist_backups/
```

Back up one document by its config name (the `name` field in `[[accounts.<acct>.docs]]`):

```bash
grist-backup -a perso -d budget_override
```

Back up uncompressed with a custom filename:

```bash
grist-backup -a perso -d abc123 --no-compress -o today_budget.grist
```

Without history (smaller file, no undo):

```bash
grist-backup -a perso --no-history
```

### Exit codes

| Code | Meaning |
|------|---------|
| 0 | All downloads succeeded |
| 1 | Partial failure (some succeeded, some failed) |
| 2 | Total failure or config/argument error |

## Decompressing `.grist.xz` files

**macOS** (native Archive Utility, or CLI):
```bash
unxz backup.grist.xz        # replaces file in place
# or
xz -d -k backup.grist.xz    # keeps the .xz file
```

Installing `xz` if missing: `brew install xz`.

**Linux**: `unxz` / `xz -d` (usually pre-installed).

**Windows**: [7-Zip](https://www.7-zip.org/) or PeaZip.

**Python**:
```python
import lzma, shutil
with lzma.open("backup.grist.xz") as src, open("backup.grist","wb") as dst:
    shutil.copyfileobj(src, dst)
```

The resulting `.grist` is a standard SQLite database — inspectable with
`sqlite3 backup.grist ".tables"` or any SQLite GUI.

## Automation (cron)

Example daily backup of every account at 3 AM:

```cron
0 3 * * * cd $HOME && /usr/local/bin/grist-backup --all >> /var/log/grist_backup.log 2>&1
```

The tool always logs to stderr — the redirection above captures both channels.

## Development

```bash
uv run ruff check src/
uv run ruff format src/
```

## Building & Publishing

```bash
# Build the package (creates dist/*.tar.gz and dist/*.whl)
uvx --from build pyproject-build

# Check that the README renders correctly for PyPI
uvx twine check dist/*

# Upload to TestPyPI
uvx twine upload --repository testpypi dist/*

# Upload to PyPI (production)
uvx twine upload dist/*
```
